mirror of
https://github.com/clockworklabs/SpacetimeDB.git
synced 2026-07-25 11:32:30 -04:00
Additive schedule registration for TypeScript for file-splitting (#5435)
Closes: https://github.com/clockworklabs/SpacetimeDB/issues/4571 - The secondary issue specifically # Description of Changes - Added new `onSchedule` optional parameter for `reducers` and `procedures` as suggested by @cloutiertyler to move towards reactive reducers - Enforced at most one scheduled `reducer`/`procedure` per schedule table - Kept the existing `table({ scheduled })` path working - Moved `ScheduleAt` column tracking onto table metadata - Lets the new schedule API resolve schedule-at columns without relying on legacy table options - Kept `schedule.scheduleAtCol` for compatibility # Other Options Considered - Add new additive API `.schedule()` - Was the first implementation of this branch but was the wrong direction for the future - Rust-inspired schedule token/name handle - Closest conceptual match to Rust: table stores a schedule descriptor/name and the reducer binds the implementation separately - Avoids importing reducers from table definitions and could preserve type checking if the token carries the row type - Adds more API surface and abstraction, and needs careful design to avoid users managing fragile names manually - `scheduled: 'reducerName'` string/name in `table(...)` - Very simple and closest to the raw module-def storage model - Fully breaks the runtime import cycle - Loses compile-time signature checking unless paired with another checker/registration API, and typos become runtime/module validation errors - This basically is what we had before 2.0 # API and ABI breaking changes - Adds new TypeScript options to `.reducer()` and `.procedure()` for `onSchedule` - No intended breaking changes to existing `table({ scheduled })` behavior # Expected complexity level and risk 3 - Preserving legacy scheduled table behavior while changing how schedule metadata is assembled # Testing - [x] Added new tests to cover the legacy schedule and new registration - [x] Updated module-test-ts coverage to show both legacy and new reducer scheduling - [x] Ran a local throwaway test project to test before and after for both legacy + new api
This commit is contained in:
@@ -20,7 +20,7 @@ import type {
|
||||
UntypedIndex,
|
||||
} from './indexes';
|
||||
import ScheduleAt from './schedule_at';
|
||||
import type { TableSchema } from './table_schema';
|
||||
import type { TableSchema, TableSchedule } from './table_schema';
|
||||
import {
|
||||
RowBuilder,
|
||||
type ColumnBuilder,
|
||||
@@ -194,6 +194,11 @@ export type TableOpts<Row extends RowObj> = {
|
||||
public?: boolean;
|
||||
indexes?: IndexOpts<keyof Row & string>[]; // declarative multi‑column indexes
|
||||
constraints?: ConstraintOpts<keyof Row & string>[];
|
||||
/**
|
||||
* @deprecated Prefer `spacetime.reducer({ onSchedule: table }, ...)` or
|
||||
* `spacetime.procedure({ onSchedule: table }, ...)` so table definitions can
|
||||
* live in a separate module from reducer/procedure definitions.
|
||||
*/
|
||||
scheduled?: () =>
|
||||
| ReducerExport<any, { [k: string]: RowBuilder<RowObj> }>
|
||||
| ProcedureExport<
|
||||
@@ -422,11 +427,9 @@ export function table<Row extends RowObj, const Opts extends TableOpts<Row>>(
|
||||
}
|
||||
|
||||
// If this column is shaped like ScheduleAtAlgebraicType, mark it as the schedule‑at column
|
||||
if (scheduled) {
|
||||
const algebraicType = builder.typeBuilder.algebraicType;
|
||||
if (ScheduleAt.isScheduleAt(algebraicType)) {
|
||||
scheduleAtCol = colIds.get(name)!;
|
||||
}
|
||||
const algebraicType = builder.typeBuilder.algebraicType;
|
||||
if (ScheduleAt.isScheduleAt(algebraicType)) {
|
||||
scheduleAtCol = colIds.get(name)!;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -492,7 +495,7 @@ export function table<Row extends RowObj, const Opts extends TableOpts<Row>>(
|
||||
CoerceRow<Row>
|
||||
>['algebraicType']['value'];
|
||||
|
||||
const schedule =
|
||||
const schedule: TableSchedule | undefined =
|
||||
scheduled && scheduleAtCol !== undefined
|
||||
? { scheduleAtCol, reducer: scheduled }
|
||||
: undefined;
|
||||
@@ -543,6 +546,7 @@ export function table<Row extends RowObj, const Opts extends TableOpts<Row>>(
|
||||
// can expose them without type-smuggling.
|
||||
idxs: userIndexes as OptsIndices<Opts>,
|
||||
constraints: constraints as OptsConstraints<Opts>,
|
||||
scheduleAtCol,
|
||||
schedule,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,9 +1,35 @@
|
||||
import type { ProcedureExport, ReducerExport } from '../server';
|
||||
import type { ProductType } from './algebraic_type';
|
||||
import type { RawScheduleDefV10, RawTableDefV10 } from './autogen/types';
|
||||
import type { IndexOpts } from './indexes';
|
||||
import type { ModuleContext } from './schema';
|
||||
import type { ColumnBuilder, RowBuilder } from './type_builders';
|
||||
import type { HasExactlyOneKnownKey } from './type_util';
|
||||
import type { ProcedureExport, ReducerExport } from '../server';
|
||||
|
||||
/**
|
||||
* Internal erased form of a scheduled reducer/procedure export.
|
||||
*
|
||||
* The legacy `TableOpts.scheduled` option checks the scheduled function shape
|
||||
* before it reaches `TableSchema`. From here, schedule resolution only needs
|
||||
* the export object identity to look up its registered function name.
|
||||
*/
|
||||
export type UntypedScheduledFunctionExport =
|
||||
| ReducerExport<any, any>
|
||||
| ProcedureExport<any, any, any>;
|
||||
|
||||
export type TableSchedule = {
|
||||
scheduleAtCol: number;
|
||||
reducer: () => UntypedScheduledFunctionExport;
|
||||
};
|
||||
|
||||
export type ScheduleTableForParams<Params extends Record<string, any>> =
|
||||
HasExactlyOneKnownKey<Params> extends true
|
||||
? Params[keyof Params] extends RowBuilder<
|
||||
infer Row extends Record<string, ColumnBuilder<any, any, any>>
|
||||
>
|
||||
? TableSchema<Row, readonly IndexOpts<keyof Row & string>[]>
|
||||
: never
|
||||
: never;
|
||||
|
||||
/**
|
||||
* Represents a handle to a database table, including its name, row type, and row spacetime type.
|
||||
@@ -50,12 +76,18 @@ export type TableSchema<
|
||||
}[];
|
||||
|
||||
/**
|
||||
* The schedule defined on the table, if any.
|
||||
* The column id of the schedule-at column, if this table has a ScheduleAt column.
|
||||
*/
|
||||
readonly schedule?: {
|
||||
scheduleAtCol: number;
|
||||
reducer: () => ReducerExport<any, any> | ProcedureExport<any, any, any>;
|
||||
};
|
||||
readonly scheduleAtCol?: number;
|
||||
|
||||
/**
|
||||
* The legacy schedule defined on the table, if any.
|
||||
*
|
||||
* @deprecated Prefer `spacetime.reducer({ onSchedule: table }, ...)` or
|
||||
* `spacetime.procedure({ onSchedule: table }, ...)` so table definitions can
|
||||
* live in a separate module from reducer/procedure definitions.
|
||||
*/
|
||||
readonly schedule?: TableSchedule;
|
||||
};
|
||||
|
||||
export type UntypedTableSchema = TableSchema<
|
||||
|
||||
@@ -48,6 +48,31 @@ export type Values<T> = T[keyof T];
|
||||
*/
|
||||
export type CollapseTuple<A extends any[]> = A extends [infer T] ? T : A;
|
||||
|
||||
/**
|
||||
* Conditional-type helper for distinguishing a single type from a union.
|
||||
*/
|
||||
export type IsUnion<T, U = T> = [T] extends [never]
|
||||
? false
|
||||
: T extends any
|
||||
? [U] extends [T]
|
||||
? false
|
||||
: true
|
||||
: false;
|
||||
|
||||
/**
|
||||
* True when an object type has exactly one known key.
|
||||
*
|
||||
* If keys widen to plain `string`, the exact count is no longer knowable, so
|
||||
* treat it as valid and let narrower call sites or runtime checks handle it.
|
||||
*/
|
||||
export type HasExactlyOneKnownKey<T> = [keyof T] extends [never]
|
||||
? false
|
||||
: string extends keyof T
|
||||
? true
|
||||
: IsUnion<keyof T> extends true
|
||||
? false
|
||||
: true;
|
||||
|
||||
type CamelCaseImpl<S extends string> = S extends `${infer Head}_${infer Tail}`
|
||||
? `${Head}${Capitalize<CamelCaseImpl<Tail>>}`
|
||||
: S extends `${infer Head}-${infer Tail}`
|
||||
|
||||
@@ -11,10 +11,12 @@ import type { ConnectionId } from '../lib/connection_id';
|
||||
import { Identity } from '../lib/identity';
|
||||
import type { ParamsObj, ReducerCtx } from '../lib/reducers';
|
||||
import { type UntypedSchemaDef } from '../lib/schema';
|
||||
import type { ScheduleTableForParams } from '../lib/table_schema';
|
||||
import { Timestamp } from '../lib/timestamp';
|
||||
import {
|
||||
type Infer,
|
||||
type InferTypeOfRow,
|
||||
type t,
|
||||
type TypeBuilder,
|
||||
} from '../lib/type_builders';
|
||||
import { bsatnBaseSize } from '../lib/util';
|
||||
@@ -42,7 +44,7 @@ export function makeProcedureExport<
|
||||
Ret extends TypeBuilder<any, any>,
|
||||
>(
|
||||
ctx: SchemaInner,
|
||||
opts: ProcedureOpts | undefined,
|
||||
opts: ProcedureOptsWithOptionalName<Params, Ret> | undefined,
|
||||
params: Params,
|
||||
ret: Ret,
|
||||
fn: ProcedureFn<S, Params, Ret>
|
||||
@@ -58,6 +60,12 @@ export function makeProcedureExport<
|
||||
procedureExport as ProcedureExport<any, any, any>,
|
||||
name ?? exportName
|
||||
);
|
||||
if (opts?.onSchedule !== undefined) {
|
||||
ctx.pendingSchedules.push({
|
||||
table: opts.onSchedule,
|
||||
functionName: name ?? exportName,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return procedureExport;
|
||||
@@ -69,10 +77,21 @@ export type ProcedureFn<
|
||||
Ret extends TypeBuilder<any, any>,
|
||||
> = (ctx: ProcedureCtx<S>, args: InferTypeOfRow<Params>) => Infer<Ret>;
|
||||
|
||||
export interface ProcedureOpts {
|
||||
export interface ProcedureOpts<
|
||||
Params extends ParamsObj = ParamsObj,
|
||||
Ret extends TypeBuilder<any, any> = TypeBuilder<any, any>,
|
||||
> {
|
||||
name: string;
|
||||
onSchedule?: Ret extends ReturnType<typeof t.unit>
|
||||
? ScheduleTableForParams<Params>
|
||||
: never;
|
||||
}
|
||||
|
||||
export type ProcedureOptsWithOptionalName<
|
||||
Params extends ParamsObj = ParamsObj,
|
||||
Ret extends TypeBuilder<any, any> = TypeBuilder<any, any>,
|
||||
> = Omit<ProcedureOpts<Params, Ret>, 'name'> & { name?: string };
|
||||
|
||||
export interface ProcedureCtx<S extends UntypedSchemaDef> {
|
||||
readonly sender: Identity;
|
||||
readonly databaseIdentity: Identity;
|
||||
@@ -107,7 +126,7 @@ function registerProcedure<
|
||||
params: Params,
|
||||
ret: Ret,
|
||||
fn: ProcedureFn<S, Params, Ret>,
|
||||
opts?: ProcedureOpts
|
||||
opts?: ProcedureOptsWithOptionalName<any, any>
|
||||
) {
|
||||
ctx.defineFunction(exportName);
|
||||
const paramsType: ProductType = {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { AlgebraicType } from '../lib/algebraic_type';
|
||||
import { FunctionVisibility, type Lifecycle } from '../lib/autogen/types';
|
||||
import type { ParamsObj, Reducer } from '../lib/reducers';
|
||||
import { type UntypedSchemaDef } from '../lib/schema';
|
||||
import type { ScheduleTableForParams } from '../lib/table_schema';
|
||||
import { RowBuilder, type RowObj } from '../lib/type_builders';
|
||||
import { toPascalCase } from '../lib/util';
|
||||
import {
|
||||
@@ -17,16 +18,20 @@ export interface ReducerExport<
|
||||
> extends Reducer<S, Params>,
|
||||
ModuleExport {}
|
||||
|
||||
export interface ReducerOpts {
|
||||
export interface ReducerOpts<Params extends ParamsObj = ParamsObj> {
|
||||
name: string;
|
||||
onSchedule?: ScheduleTableForParams<Params>;
|
||||
}
|
||||
|
||||
export type ReducerOptsWithOptionalName<Params extends ParamsObj = ParamsObj> =
|
||||
Omit<ReducerOpts<Params>, 'name'> & { name?: string };
|
||||
|
||||
export function makeReducerExport<
|
||||
S extends UntypedSchemaDef,
|
||||
Params extends ParamsObj,
|
||||
>(
|
||||
ctx: SchemaInner,
|
||||
opts: ReducerOpts | undefined,
|
||||
opts: ReducerOptsWithOptionalName<Params> | undefined,
|
||||
params: RowObj | RowBuilder<RowObj>,
|
||||
fn: Reducer<any, any>,
|
||||
lifecycle?: Lifecycle
|
||||
@@ -39,6 +44,12 @@ export function makeReducerExport<
|
||||
reducerExport as ReducerExport<any, any>,
|
||||
exportName
|
||||
);
|
||||
if (opts?.onSchedule !== undefined) {
|
||||
ctx.pendingSchedules.push({
|
||||
table: opts.onSchedule,
|
||||
functionName: exportName,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return reducerExport;
|
||||
@@ -57,7 +68,7 @@ export function registerReducer(
|
||||
exportName: string,
|
||||
params: RowObj | RowBuilder<RowObj>,
|
||||
fn: Reducer<any, any>,
|
||||
opts?: ReducerOpts,
|
||||
opts?: ReducerOptsWithOptionalName<any>,
|
||||
lifecycle?: Lifecycle
|
||||
): void {
|
||||
ctx.defineFunction(exportName);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { schema } from './schema';
|
||||
import { registerExport, schema } from './schema';
|
||||
import type { ProcedureExport } from './procedures';
|
||||
import { table } from '../lib/table';
|
||||
import t from '../lib/type_builders';
|
||||
|
||||
@@ -97,3 +98,102 @@ spacetimedbIndexSplit.init(ctx => {
|
||||
// @ts-expect-error `nickname` is not indexed, so no index accessor should exist.
|
||||
const _nickname = ctx.db.account.nickname;
|
||||
});
|
||||
|
||||
const manuallyTypedProcedureExport: ProcedureExport<
|
||||
any,
|
||||
{},
|
||||
ReturnType<typeof t.unit>
|
||||
> = Object.assign((_ctx: any, _args: {}) => ({}), {
|
||||
[registerExport]: () => {},
|
||||
});
|
||||
void manuallyTypedProcedureExport;
|
||||
|
||||
// Scheduled reducer/procedure type coverage. Each valid scheduled function uses
|
||||
// its own table handle because runtime allows at most one scheduled function per
|
||||
// schedule table.
|
||||
const scheduledMessageRow = {
|
||||
scheduledId: t.u64().primaryKey().autoInc(),
|
||||
scheduledAt: t.scheduleAt(),
|
||||
text: t.string(),
|
||||
};
|
||||
|
||||
const scheduledTable = () => table({}, scheduledMessageRow);
|
||||
|
||||
{
|
||||
// Positive reducer case: onSchedule accepts a table whose row matches the
|
||||
// reducer's single scheduled payload field.
|
||||
const scheduledReducerMessages = scheduledTable();
|
||||
const spacetimedb = schema({ scheduledReducerMessages });
|
||||
const processScheduledMessage = spacetimedb.reducer(
|
||||
{ onSchedule: scheduledReducerMessages },
|
||||
{
|
||||
scheduledMessage: scheduledReducerMessages.rowType,
|
||||
},
|
||||
(_ctx, { scheduledMessage }) => {
|
||||
void scheduledMessage.text;
|
||||
}
|
||||
);
|
||||
void processScheduledMessage;
|
||||
}
|
||||
|
||||
{
|
||||
// Positive procedure case: scheduled procedures follow the same payload rule
|
||||
// as reducers and must return unit.
|
||||
const scheduledProcedureMessages = scheduledTable();
|
||||
const spacetimedb = schema({ scheduledProcedureMessages });
|
||||
const processScheduledProcedure = spacetimedb.procedure(
|
||||
{ onSchedule: scheduledProcedureMessages },
|
||||
{
|
||||
scheduledMessage: scheduledProcedureMessages.rowType,
|
||||
},
|
||||
t.unit(),
|
||||
(_ctx, { scheduledMessage }) => {
|
||||
void scheduledMessage.text;
|
||||
return {};
|
||||
}
|
||||
);
|
||||
void processScheduledProcedure;
|
||||
}
|
||||
|
||||
const legacyScheduledMessages = table(
|
||||
{
|
||||
scheduled: (): any => undefined,
|
||||
},
|
||||
scheduledMessageRow
|
||||
);
|
||||
const legacyScheduleAtCol: number | undefined =
|
||||
legacyScheduledMessages.schedule?.scheduleAtCol;
|
||||
void legacyScheduleAtCol;
|
||||
|
||||
{
|
||||
// Negative reducer case: onSchedule rejects a reducer whose payload does not
|
||||
// use the scheduled table row type.
|
||||
const wrongPayloadMessages = scheduledTable();
|
||||
const spacetimedb = schema({ wrongPayloadMessages });
|
||||
const processWrongPayload = spacetimedb.reducer(
|
||||
// @ts-expect-error scheduled reducers must take the scheduled table row type.
|
||||
{ onSchedule: wrongPayloadMessages },
|
||||
{
|
||||
text: t.string(),
|
||||
},
|
||||
() => {}
|
||||
);
|
||||
void processWrongPayload;
|
||||
}
|
||||
|
||||
{
|
||||
// Negative procedure case: onSchedule rejects procedures that do not return
|
||||
// unit.
|
||||
const wrongReturnProcedureMessages = scheduledTable();
|
||||
const spacetimedb = schema({ wrongReturnProcedureMessages });
|
||||
const processWrongReturnProcedure = spacetimedb.procedure(
|
||||
// @ts-expect-error scheduled procedures must return unit.
|
||||
{ onSchedule: wrongReturnProcedureMessages },
|
||||
{
|
||||
scheduledMessage: wrongReturnProcedureMessages.rowType,
|
||||
},
|
||||
t.string(),
|
||||
() => ''
|
||||
);
|
||||
void processWrongReturnProcedure;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
type UntypedSchemaDef,
|
||||
} from '../lib/schema';
|
||||
import type { UntypedTableSchema } from '../lib/table_schema';
|
||||
import { ColumnBuilder, TypeBuilder } from '../lib/type_builders';
|
||||
import { TypeBuilder, type ColumnBuilder } from '../lib/type_builders';
|
||||
import {
|
||||
Router,
|
||||
type HandlerFn,
|
||||
@@ -31,12 +31,14 @@ import {
|
||||
type ProcedureExport,
|
||||
type ProcedureFn,
|
||||
type ProcedureOpts,
|
||||
type ProcedureOptsWithOptionalName,
|
||||
type Procedures,
|
||||
} from './procedures';
|
||||
import {
|
||||
makeReducerExport,
|
||||
type ReducerExport,
|
||||
type ReducerOpts,
|
||||
type ReducerOptsWithOptionalName,
|
||||
type Reducers,
|
||||
} from './reducers';
|
||||
import { makeHooks } from './runtime';
|
||||
@@ -55,6 +57,17 @@ import {
|
||||
} from './views';
|
||||
import type { UntypedTableDef } from '../lib/table';
|
||||
|
||||
/**
|
||||
* Internal erased form of a scheduled reducer/procedure export.
|
||||
*
|
||||
* Public reducer/procedure schedule options preserve row/return-type checks
|
||||
* before values enter `pendingSchedules`. Legacy table schedules still resolve
|
||||
* through export object identity.
|
||||
*/
|
||||
type UntypedScheduledFunctionExport =
|
||||
| ReducerExport<any, any>
|
||||
| ProcedureExport<any, any, any>;
|
||||
|
||||
export class SchemaInner<
|
||||
S extends UntypedSchemaDef = UntypedSchemaDef,
|
||||
> extends ModuleContext {
|
||||
@@ -67,14 +80,11 @@ export class SchemaInner<
|
||||
anonViews: AnonViews = [];
|
||||
httpHandlers: HandlerFn[] = [];
|
||||
/**
|
||||
* Maps ReducerExport objects to the name of the reducer.
|
||||
* Used for resolving the reducers of scheduled tables.
|
||||
* Maps reducer/procedure export objects to their source names.
|
||||
* Used for resolving scheduled table targets.
|
||||
*/
|
||||
functionExports: Map<
|
||||
| ReducerExport<UntypedSchemaDef, any>
|
||||
| ProcedureExport<UntypedSchemaDef, any, any>,
|
||||
string
|
||||
> = new Map();
|
||||
functionExports: Map<UntypedScheduledFunctionExport, string> = new Map();
|
||||
tableSourceNames: Map<UntypedTableSchema, string[]> = new Map();
|
||||
httpHandlerExports: Map<HttpHandlerExport<UntypedSchemaDef>, string> =
|
||||
new Map();
|
||||
pendingSchedules: PendingSchedule[] = [];
|
||||
@@ -104,12 +114,59 @@ export class SchemaInner<
|
||||
}
|
||||
|
||||
resolveSchedules() {
|
||||
for (const { reducer, scheduleAtCol, tableName } of this.pendingSchedules) {
|
||||
const functionName = this.functionExports.get(reducer());
|
||||
// Pending schedules come from two API paths:
|
||||
// - legacy table({ scheduled }) schedules already know their tableName and
|
||||
// scheduleAtCol because schema() resolves them while iterating table keys
|
||||
// - reducer/procedure({ onSchedule }) schedules only know the table handle,
|
||||
// so resolve them through tableSourceNames and reject duplicate table
|
||||
// handle registrations because the target table name is ambiguous
|
||||
const scheduledTables = new Map<string, string>();
|
||||
for (const {
|
||||
functionName: knownFunctionName,
|
||||
reducer,
|
||||
table,
|
||||
tableName: knownTableName,
|
||||
scheduleAtCol: knownScheduleAtCol,
|
||||
} of this.pendingSchedules) {
|
||||
let tableName = knownTableName;
|
||||
if (tableName === undefined) {
|
||||
const tableNames = this.tableSourceNames.get(table);
|
||||
if (tableNames !== undefined && tableNames.length > 1) {
|
||||
throw new TypeError(
|
||||
'Schedule target table is registered more than once in this schema. Use a distinct table handle for each scheduled table.'
|
||||
);
|
||||
}
|
||||
tableName = tableNames?.[0];
|
||||
}
|
||||
if (tableName === undefined) {
|
||||
throw new TypeError(
|
||||
'Schedule target table is not part of this schema.'
|
||||
);
|
||||
}
|
||||
|
||||
const scheduleAtCol = knownScheduleAtCol ?? table.scheduleAtCol;
|
||||
if (scheduleAtCol === undefined) {
|
||||
throw new TypeError(
|
||||
`Table ${tableName} defines a schedule, but it does not have a ScheduleAt column.`
|
||||
);
|
||||
}
|
||||
|
||||
const functionName =
|
||||
knownFunctionName ??
|
||||
(reducer === undefined
|
||||
? undefined
|
||||
: this.functionExports.get(reducer()));
|
||||
if (functionName === undefined) {
|
||||
const msg = `Table ${tableName} defines a schedule, but it seems like the associated function was not exported.`;
|
||||
throw new TypeError(msg);
|
||||
}
|
||||
const existingFunctionName = scheduledTables.get(tableName);
|
||||
if (existingFunctionName !== undefined) {
|
||||
throw new TypeError(
|
||||
`Table ${tableName} defines multiple schedules: ${existingFunctionName} and ${functionName}. A schedule table can only be used by one reducer or procedure.`
|
||||
);
|
||||
}
|
||||
scheduledTables.set(tableName, functionName);
|
||||
this.moduleDef.schedules.push({
|
||||
sourceName: undefined,
|
||||
tableName,
|
||||
@@ -136,7 +193,13 @@ export class SchemaInner<
|
||||
}
|
||||
}
|
||||
|
||||
type PendingSchedule = UntypedTableSchema['schedule'] & { tableName: string };
|
||||
type PendingSchedule = {
|
||||
table: UntypedTableSchema;
|
||||
tableName?: string;
|
||||
scheduleAtCol?: number;
|
||||
reducer?: () => UntypedScheduledFunctionExport;
|
||||
functionName?: string;
|
||||
};
|
||||
type PendingHttpRoute = {
|
||||
handler: HttpHandlerExport<UntypedSchemaDef>;
|
||||
method: MethodOrAny;
|
||||
@@ -256,19 +319,19 @@ export class Schema<S extends UntypedSchemaDef> implements ModuleDefaultExport {
|
||||
): ReducerExport<S, Params>;
|
||||
reducer(fn: Reducer<S, {}>): ReducerExport<S, {}>;
|
||||
reducer<Params extends ParamsObj>(
|
||||
opts: ReducerOpts,
|
||||
opts: ReducerOptsWithOptionalName<Params>,
|
||||
params: Params,
|
||||
fn: Reducer<S, Params>
|
||||
): ReducerExport<S, Params>;
|
||||
reducer(opts: ReducerOpts, fn: Reducer<S, {}>): ReducerExport<S, {}>;
|
||||
reducer(opts: ReducerOpts<{}>, fn: Reducer<S, {}>): ReducerExport<S, {}>;
|
||||
reducer<Params extends ParamsObj>(
|
||||
...args:
|
||||
| [Params, Reducer<S, Params>]
|
||||
| [Reducer<S, {}>]
|
||||
| [ReducerOpts, Params, Reducer<S, Params>]
|
||||
| [ReducerOpts, Reducer<S, {}>]
|
||||
| [ReducerOptsWithOptionalName<Params>, Params, Reducer<S, Params>]
|
||||
| [ReducerOpts<{}>, Reducer<S, {}>]
|
||||
): ReducerExport<S, Params> {
|
||||
let opts: ReducerOpts | undefined,
|
||||
let opts: ReducerOptsWithOptionalName<Params> | undefined,
|
||||
params: Params = {} as Params,
|
||||
fn: Reducer<S, Params>;
|
||||
switch (args.length) {
|
||||
@@ -278,7 +341,8 @@ export class Schema<S extends UntypedSchemaDef> implements ModuleDefaultExport {
|
||||
case 2: {
|
||||
let arg1;
|
||||
[arg1, fn] = args;
|
||||
if (typeof arg1.name === 'string') opts = arg1 as ReducerOpts;
|
||||
if (typeof arg1.name === 'string')
|
||||
opts = arg1 as ReducerOptsWithOptionalName<Params>;
|
||||
else params = arg1 as Params;
|
||||
break;
|
||||
}
|
||||
@@ -307,7 +371,7 @@ export class Schema<S extends UntypedSchemaDef> implements ModuleDefaultExport {
|
||||
* ```
|
||||
*/
|
||||
init(fn: Reducer<S, {}>): ReducerExport<S, {}>;
|
||||
init(opts: ReducerOpts, fn: Reducer<S, {}>): ReducerExport<S, {}>;
|
||||
init(opts: ReducerOpts<{}>, fn: Reducer<S, {}>): ReducerExport<S, {}>;
|
||||
init(
|
||||
...args: [Reducer<S, {}>] | [ReducerOpts, Reducer<S, {}>]
|
||||
): ReducerExport<S, {}> {
|
||||
@@ -340,7 +404,10 @@ export class Schema<S extends UntypedSchemaDef> implements ModuleDefaultExport {
|
||||
* );
|
||||
*/
|
||||
clientConnected(fn: Reducer<S, {}>): ReducerExport<S, {}>;
|
||||
clientConnected(opts: ReducerOpts, fn: Reducer<S, {}>): ReducerExport<S, {}>;
|
||||
clientConnected(
|
||||
opts: ReducerOpts<{}>,
|
||||
fn: Reducer<S, {}>
|
||||
): ReducerExport<S, {}>;
|
||||
clientConnected(
|
||||
...args: [Reducer<S, {}>] | [ReducerOpts, Reducer<S, {}>]
|
||||
): ReducerExport<S, {}> {
|
||||
@@ -375,7 +442,7 @@ export class Schema<S extends UntypedSchemaDef> implements ModuleDefaultExport {
|
||||
*/
|
||||
clientDisconnected(fn: Reducer<S, {}>): ReducerExport<S, {}>;
|
||||
clientDisconnected(
|
||||
opts: ReducerOpts,
|
||||
opts: ReducerOpts<{}>,
|
||||
fn: Reducer<S, {}>
|
||||
): ReducerExport<S, {}>;
|
||||
clientDisconnected(
|
||||
@@ -474,30 +541,35 @@ export class Schema<S extends UntypedSchemaDef> implements ModuleDefaultExport {
|
||||
params: Params,
|
||||
ret: Ret,
|
||||
fn: ProcedureFn<S, Params, Ret>
|
||||
): ProcedureFn<S, Params, Ret>;
|
||||
): ProcedureExport<S, Params, Ret>;
|
||||
procedure<Ret extends TypeBuilder<any, any>>(
|
||||
ret: Ret,
|
||||
fn: ProcedureFn<S, {}, Ret>
|
||||
): ProcedureFn<S, {}, Ret>;
|
||||
): ProcedureExport<S, {}, Ret>;
|
||||
procedure<Params extends ParamsObj, Ret extends TypeBuilder<any, any>>(
|
||||
opts: ProcedureOpts,
|
||||
opts: ProcedureOptsWithOptionalName<Params, Ret>,
|
||||
params: Params,
|
||||
ret: Ret,
|
||||
fn: ProcedureFn<S, Params, Ret>
|
||||
): ProcedureFn<S, Params, Ret>;
|
||||
): ProcedureExport<S, Params, Ret>;
|
||||
procedure<Ret extends TypeBuilder<any, any>>(
|
||||
opts: ProcedureOpts,
|
||||
opts: ProcedureOpts<{}, Ret>,
|
||||
ret: Ret,
|
||||
fn: ProcedureFn<S, {}, Ret>
|
||||
): ProcedureFn<S, {}, Ret>;
|
||||
): ProcedureExport<S, {}, Ret>;
|
||||
procedure<Params extends ParamsObj, Ret extends TypeBuilder<any, any>>(
|
||||
...args:
|
||||
| [Params, Ret, ProcedureFn<S, Params, Ret>]
|
||||
| [Ret, ProcedureFn<S, Params, Ret>]
|
||||
| [ProcedureOpts, Params, Ret, ProcedureFn<S, Params, Ret>]
|
||||
| [ProcedureOpts, Ret, ProcedureFn<S, Params, Ret>]
|
||||
| [
|
||||
ProcedureOptsWithOptionalName<Params, Ret>,
|
||||
Params,
|
||||
Ret,
|
||||
ProcedureFn<S, Params, Ret>,
|
||||
]
|
||||
| [ProcedureOpts<{}, Ret>, Ret, ProcedureFn<S, Params, Ret>]
|
||||
): ProcedureExport<S, Params, Ret> {
|
||||
let opts: ProcedureOpts | undefined,
|
||||
let opts: ProcedureOptsWithOptionalName<Params, Ret> | undefined,
|
||||
params: Params = {} as Params,
|
||||
ret: Ret,
|
||||
fn: ProcedureFn<S, Params, Ret>;
|
||||
@@ -508,7 +580,8 @@ export class Schema<S extends UntypedSchemaDef> implements ModuleDefaultExport {
|
||||
case 3: {
|
||||
let arg1;
|
||||
[arg1, ret, fn] = args;
|
||||
if (typeof arg1.name === 'string') opts = arg1 as ProcedureOpts;
|
||||
if (typeof arg1.name === 'string')
|
||||
opts = arg1 as ProcedureOptsWithOptionalName<Params, Ret>;
|
||||
else params = arg1 as Params;
|
||||
break;
|
||||
}
|
||||
@@ -639,11 +712,19 @@ export function schema<const H extends Record<string, UntypedTableSchema>>(
|
||||
for (const [accName, table] of Object.entries(tables)) {
|
||||
const tableDef = table.tableDef(ctx, accName);
|
||||
tableSchemas[accName] = tableToSchema(accName, table, tableDef);
|
||||
const tableSourceNames = ctx.tableSourceNames.get(table);
|
||||
if (tableSourceNames === undefined) {
|
||||
ctx.tableSourceNames.set(table, [tableDef.sourceName]);
|
||||
} else {
|
||||
tableSourceNames.push(tableDef.sourceName);
|
||||
}
|
||||
ctx.moduleDef.tables.push(tableDef);
|
||||
if (table.schedule) {
|
||||
ctx.pendingSchedules.push({
|
||||
...table.schedule,
|
||||
table,
|
||||
tableName: tableDef.sourceName,
|
||||
scheduleAtCol: table.schedule.scheduleAtCol,
|
||||
reducer: table.schedule.reducer,
|
||||
});
|
||||
}
|
||||
if (table.tableName) {
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
type RowObj,
|
||||
type TypeBuilder,
|
||||
} from '../lib/type_builders';
|
||||
import type { IsUnion } from '../lib/type_util';
|
||||
import { bsatnBaseSize, toPascalCase } from '../lib/util';
|
||||
import type { ReadonlyDbView } from './db_view';
|
||||
import { type QueryBuilder, type RowTypedQuery } from './query';
|
||||
@@ -122,18 +123,6 @@ type PrimaryKeyColumnNames<Row extends RowObj> = {
|
||||
: never;
|
||||
}[keyof Row & string];
|
||||
|
||||
// Standard conditional-type trick for distinguishing a single type from a
|
||||
// union. We use it because zero or one primary-key column is valid, but a union
|
||||
// of two or more column names means the row builder marked multiple primary
|
||||
// keys.
|
||||
type IsUnion<T, U = T> = [T] extends [never]
|
||||
? false
|
||||
: T extends any
|
||||
? [U] extends [T]
|
||||
? false
|
||||
: true
|
||||
: false;
|
||||
|
||||
// In generic code, row keys may widen from literal names like "id" | "name"
|
||||
// to plain `string`. That means "unknown column name", not "multiple primary
|
||||
// keys", so avoid a false-positive type error and rely on the runtime check.
|
||||
|
||||
@@ -0,0 +1,443 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { moduleHooks } from 'spacetime:sys@2.0';
|
||||
|
||||
const sysMock = vi.hoisted(() => ({
|
||||
moduleHooks: Symbol('moduleHooks'),
|
||||
}));
|
||||
|
||||
vi.mock('spacetime:sys@2.0', () => ({
|
||||
moduleHooks: sysMock.moduleHooks,
|
||||
}));
|
||||
|
||||
vi.mock('spacetime:sys@2.1', () => ({
|
||||
moduleHooks: sysMock.moduleHooks,
|
||||
}));
|
||||
|
||||
vi.mock('../src/server/runtime', () => ({
|
||||
makeHooks: () => ({}),
|
||||
callUserFunction: (fn: (...args: unknown[]) => unknown, ...args: unknown[]) =>
|
||||
fn(...args),
|
||||
ReducerCtxImpl: class {},
|
||||
runWithTx: () => undefined,
|
||||
sys: {},
|
||||
}));
|
||||
|
||||
import { schema } from '../src/server/schema';
|
||||
import { table } from '../src/lib/table';
|
||||
import { t } from '../src/lib/type_builders';
|
||||
|
||||
describe('schema schedules', () => {
|
||||
it('emits reducer schedules registered with onSchedule', () => {
|
||||
const scheduledMessages = table(
|
||||
{ name: 'scheduled_messages' },
|
||||
{
|
||||
scheduledId: t.u64().primaryKey().autoInc(),
|
||||
scheduledAt: t.scheduleAt(),
|
||||
text: t.string(),
|
||||
}
|
||||
);
|
||||
|
||||
const spacetimedb = schema({ scheduledMessages });
|
||||
const processScheduledMessage = spacetimedb.reducer(
|
||||
{ onSchedule: scheduledMessages },
|
||||
{ scheduledMessage: scheduledMessages.rowType },
|
||||
() => {}
|
||||
);
|
||||
|
||||
spacetimedb[moduleHooks]({ processScheduledMessage });
|
||||
|
||||
expect(spacetimedb.moduleDef.schedules).toEqual([
|
||||
{
|
||||
sourceName: undefined,
|
||||
tableName: 'scheduledMessages',
|
||||
scheduleAtCol: 1,
|
||||
functionName: 'processScheduledMessage',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('emits procedure schedules registered with onSchedule', () => {
|
||||
const scheduledMessages = table(
|
||||
{},
|
||||
{
|
||||
scheduledId: t.u64().primaryKey().autoInc(),
|
||||
scheduledAt: t.scheduleAt(),
|
||||
text: t.string(),
|
||||
}
|
||||
);
|
||||
|
||||
const spacetimedb = schema({ scheduledMessages });
|
||||
const processScheduledMessage = spacetimedb.procedure(
|
||||
{ onSchedule: scheduledMessages },
|
||||
{ scheduledMessage: scheduledMessages.rowType },
|
||||
t.unit(),
|
||||
() => ({})
|
||||
);
|
||||
|
||||
spacetimedb[moduleHooks]({ processScheduledMessage });
|
||||
|
||||
expect(spacetimedb.moduleDef.schedules).toEqual([
|
||||
{
|
||||
sourceName: undefined,
|
||||
tableName: 'scheduledMessages',
|
||||
scheduleAtCol: 1,
|
||||
functionName: 'processScheduledMessage',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps legacy table scheduled option working', () => {
|
||||
const processScheduledMessageRef = { current: undefined as any };
|
||||
const scheduledMessages = table(
|
||||
{
|
||||
scheduled: () => processScheduledMessageRef.current,
|
||||
},
|
||||
{
|
||||
scheduledId: t.u64().primaryKey().autoInc(),
|
||||
scheduledAt: t.scheduleAt(),
|
||||
text: t.string(),
|
||||
}
|
||||
);
|
||||
expect(scheduledMessages.schedule?.scheduleAtCol).toBe(1);
|
||||
const spacetimedb = schema({ scheduledMessages });
|
||||
processScheduledMessageRef.current = spacetimedb.reducer(
|
||||
{ scheduledMessage: scheduledMessages.rowType },
|
||||
() => {}
|
||||
);
|
||||
|
||||
spacetimedb[moduleHooks]({
|
||||
processScheduledMessage: processScheduledMessageRef.current,
|
||||
});
|
||||
|
||||
expect(spacetimedb.moduleDef.schedules).toEqual([
|
||||
{
|
||||
sourceName: undefined,
|
||||
tableName: 'scheduledMessages',
|
||||
scheduleAtCol: 1,
|
||||
functionName: 'processScheduledMessage',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps legacy scheduled duplicate table handles working', () => {
|
||||
const processScheduledMessageRef = { current: undefined as any };
|
||||
const scheduledMessages = table(
|
||||
{
|
||||
scheduled: () => processScheduledMessageRef.current,
|
||||
},
|
||||
{
|
||||
scheduledId: t.u64().primaryKey().autoInc(),
|
||||
scheduledAt: t.scheduleAt(),
|
||||
text: t.string(),
|
||||
}
|
||||
);
|
||||
const spacetimedb = schema({
|
||||
first: scheduledMessages,
|
||||
second: scheduledMessages,
|
||||
});
|
||||
processScheduledMessageRef.current = spacetimedb.reducer(
|
||||
{ scheduledMessage: scheduledMessages.rowType },
|
||||
() => {}
|
||||
);
|
||||
|
||||
spacetimedb[moduleHooks]({
|
||||
processScheduledMessage: processScheduledMessageRef.current,
|
||||
});
|
||||
|
||||
expect(spacetimedb.moduleDef.schedules).toEqual([
|
||||
{
|
||||
sourceName: undefined,
|
||||
tableName: 'first',
|
||||
scheduleAtCol: 1,
|
||||
functionName: 'processScheduledMessage',
|
||||
},
|
||||
{
|
||||
sourceName: undefined,
|
||||
tableName: 'second',
|
||||
scheduleAtCol: 1,
|
||||
functionName: 'processScheduledMessage',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps legacy table scheduled option working for procedures', () => {
|
||||
const processScheduledMessageRef = { current: undefined as any };
|
||||
const scheduledMessages = table(
|
||||
{
|
||||
scheduled: () => processScheduledMessageRef.current,
|
||||
},
|
||||
{
|
||||
scheduledId: t.u64().primaryKey().autoInc(),
|
||||
scheduledAt: t.scheduleAt(),
|
||||
text: t.string(),
|
||||
}
|
||||
);
|
||||
const spacetimedb = schema({ scheduledMessages });
|
||||
processScheduledMessageRef.current = spacetimedb.procedure(
|
||||
{ scheduledMessage: scheduledMessages.rowType },
|
||||
t.unit(),
|
||||
() => ({})
|
||||
);
|
||||
|
||||
spacetimedb[moduleHooks]({
|
||||
processScheduledMessage: processScheduledMessageRef.current,
|
||||
});
|
||||
|
||||
expect(spacetimedb.moduleDef.schedules).toEqual([
|
||||
{
|
||||
sourceName: undefined,
|
||||
tableName: 'scheduledMessages',
|
||||
scheduleAtCol: 1,
|
||||
functionName: 'processScheduledMessage',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps legacy table scheduled option as a no-op without ScheduleAt', () => {
|
||||
const processScheduledMessageRef = { current: undefined as any };
|
||||
const scheduledMessages = table(
|
||||
{
|
||||
scheduled: () => processScheduledMessageRef.current,
|
||||
},
|
||||
{
|
||||
scheduledId: t.u64().primaryKey().autoInc(),
|
||||
text: t.string(),
|
||||
}
|
||||
);
|
||||
const spacetimedb = schema({ scheduledMessages });
|
||||
processScheduledMessageRef.current = spacetimedb.reducer(
|
||||
{ scheduledMessage: scheduledMessages.rowType },
|
||||
() => {}
|
||||
);
|
||||
|
||||
spacetimedb[moduleHooks]({
|
||||
processScheduledMessage: processScheduledMessageRef.current,
|
||||
});
|
||||
|
||||
expect(spacetimedb.moduleDef.schedules).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects a legacy scheduled function that was not exported', () => {
|
||||
const processScheduledMessageRef = { current: undefined as any };
|
||||
const scheduledMessages = table(
|
||||
{
|
||||
scheduled: () => processScheduledMessageRef.current,
|
||||
},
|
||||
{
|
||||
scheduledId: t.u64().primaryKey().autoInc(),
|
||||
scheduledAt: t.scheduleAt(),
|
||||
}
|
||||
);
|
||||
const spacetimedb = schema({ scheduledMessages });
|
||||
processScheduledMessageRef.current = spacetimedb.reducer(
|
||||
{ scheduledMessage: scheduledMessages.rowType },
|
||||
() => {}
|
||||
);
|
||||
|
||||
expect(() => spacetimedb[moduleHooks]({})).toThrow(
|
||||
'Table scheduledMessages defines a schedule, but it seems like the associated function was not exported.'
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects an onSchedule table that is not in the schema', () => {
|
||||
const scheduledMessages = table(
|
||||
{},
|
||||
{
|
||||
scheduledId: t.u64().primaryKey().autoInc(),
|
||||
scheduledAt: t.scheduleAt(),
|
||||
}
|
||||
);
|
||||
const otherScheduledMessages = table(
|
||||
{},
|
||||
{
|
||||
scheduledId: t.u64().primaryKey().autoInc(),
|
||||
scheduledAt: t.scheduleAt(),
|
||||
}
|
||||
);
|
||||
|
||||
const spacetimedb = schema({ scheduledMessages });
|
||||
const processScheduledMessage = spacetimedb.reducer(
|
||||
{ onSchedule: otherScheduledMessages },
|
||||
{ scheduledMessage: scheduledMessages.rowType },
|
||||
() => {}
|
||||
);
|
||||
|
||||
expect(() => spacetimedb[moduleHooks]({ processScheduledMessage })).toThrow(
|
||||
'Schedule target table is not part of this schema.'
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects an onSchedule table with no ScheduleAt column', () => {
|
||||
const scheduledMessages = table(
|
||||
{},
|
||||
{
|
||||
scheduledId: t.u64().primaryKey().autoInc(),
|
||||
text: t.string(),
|
||||
}
|
||||
);
|
||||
|
||||
const spacetimedb = schema({ scheduledMessages });
|
||||
const processScheduledMessage = spacetimedb.reducer(
|
||||
{ onSchedule: scheduledMessages },
|
||||
{ scheduledMessage: scheduledMessages.rowType },
|
||||
() => {}
|
||||
);
|
||||
|
||||
expect(() => spacetimedb[moduleHooks]({ processScheduledMessage })).toThrow(
|
||||
'Table scheduledMessages defines a schedule, but it does not have a ScheduleAt column.'
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects multiple scheduled functions for the same table', () => {
|
||||
const scheduledMessages = table(
|
||||
{},
|
||||
{
|
||||
scheduledId: t.u64().primaryKey().autoInc(),
|
||||
scheduledAt: t.scheduleAt(),
|
||||
}
|
||||
);
|
||||
|
||||
const spacetimedb = schema({ scheduledMessages });
|
||||
const firstScheduledMessage = spacetimedb.reducer(
|
||||
{ onSchedule: scheduledMessages },
|
||||
{ scheduledMessage: scheduledMessages.rowType },
|
||||
() => {}
|
||||
);
|
||||
const secondScheduledMessage = spacetimedb.reducer(
|
||||
{ onSchedule: scheduledMessages },
|
||||
{ scheduledMessage: scheduledMessages.rowType },
|
||||
() => {}
|
||||
);
|
||||
|
||||
expect(() =>
|
||||
spacetimedb[moduleHooks]({
|
||||
firstScheduledMessage,
|
||||
secondScheduledMessage,
|
||||
})
|
||||
).toThrow(
|
||||
'Table scheduledMessages defines multiple schedules: firstScheduledMessage and secondScheduledMessage. A schedule table can only be used by one reducer or procedure.'
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects onSchedule targets registered under multiple schema keys', () => {
|
||||
const scheduledMessages = table(
|
||||
{},
|
||||
{
|
||||
scheduledId: t.u64().primaryKey().autoInc(),
|
||||
scheduledAt: t.scheduleAt(),
|
||||
}
|
||||
);
|
||||
|
||||
const spacetimedb = schema({
|
||||
first: scheduledMessages,
|
||||
second: scheduledMessages,
|
||||
});
|
||||
const processScheduledMessage = spacetimedb.reducer(
|
||||
{ onSchedule: scheduledMessages },
|
||||
{ scheduledMessage: scheduledMessages.rowType },
|
||||
() => {}
|
||||
);
|
||||
|
||||
expect(() => spacetimedb[moduleHooks]({ processScheduledMessage })).toThrow(
|
||||
'Schedule target table is registered more than once in this schema. Use a distinct table handle for each scheduled table.'
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects mixed legacy and onSchedule registrations for the same table', () => {
|
||||
const legacyScheduledMessageRef = { current: undefined as any };
|
||||
const scheduledMessages = table(
|
||||
{
|
||||
scheduled: () => legacyScheduledMessageRef.current,
|
||||
},
|
||||
{
|
||||
scheduledId: t.u64().primaryKey().autoInc(),
|
||||
scheduledAt: t.scheduleAt(),
|
||||
}
|
||||
);
|
||||
|
||||
const spacetimedb = schema({ scheduledMessages });
|
||||
legacyScheduledMessageRef.current = spacetimedb.reducer(
|
||||
{ scheduledMessage: scheduledMessages.rowType },
|
||||
() => {}
|
||||
);
|
||||
const newScheduledMessage = spacetimedb.reducer(
|
||||
{ onSchedule: scheduledMessages },
|
||||
{ scheduledMessage: scheduledMessages.rowType },
|
||||
() => {}
|
||||
);
|
||||
|
||||
expect(() =>
|
||||
spacetimedb[moduleHooks]({
|
||||
legacyScheduledMessage: legacyScheduledMessageRef.current,
|
||||
newScheduledMessage,
|
||||
})
|
||||
).toThrow(
|
||||
'Table scheduledMessages defines multiple schedules: legacyScheduledMessage and newScheduledMessage. A schedule table can only be used by one reducer or procedure.'
|
||||
);
|
||||
});
|
||||
|
||||
it('allows reducer params named onSchedule without treating them as options', () => {
|
||||
const messages = table(
|
||||
{},
|
||||
{
|
||||
id: t.u64().primaryKey(),
|
||||
text: t.string(),
|
||||
}
|
||||
);
|
||||
|
||||
const spacetimedb = schema({ messages });
|
||||
const updateMessage = spacetimedb.reducer(
|
||||
{ onSchedule: t.string() },
|
||||
() => {}
|
||||
);
|
||||
|
||||
spacetimedb[moduleHooks]({ updateMessage });
|
||||
|
||||
expect(spacetimedb.moduleDef.reducers).toEqual([
|
||||
expect.objectContaining({
|
||||
sourceName: 'updateMessage',
|
||||
params: {
|
||||
elements: [
|
||||
expect.objectContaining({
|
||||
name: 'onSchedule',
|
||||
}),
|
||||
],
|
||||
},
|
||||
}),
|
||||
]);
|
||||
expect(spacetimedb.moduleDef.schedules).toEqual([]);
|
||||
});
|
||||
|
||||
it('allows procedure params named onSchedule without treating them as options', () => {
|
||||
const messages = table(
|
||||
{},
|
||||
{
|
||||
id: t.u64().primaryKey(),
|
||||
text: t.string(),
|
||||
}
|
||||
);
|
||||
|
||||
const spacetimedb = schema({ messages });
|
||||
const getMessage = spacetimedb.procedure(
|
||||
{ onSchedule: t.string() },
|
||||
t.unit(),
|
||||
() => ({})
|
||||
);
|
||||
|
||||
spacetimedb[moduleHooks]({ getMessage });
|
||||
|
||||
expect(spacetimedb.moduleDef.procedures).toEqual([
|
||||
expect.objectContaining({
|
||||
sourceName: 'getMessage',
|
||||
params: {
|
||||
elements: [
|
||||
expect.objectContaining({
|
||||
name: 'onSchedule',
|
||||
}),
|
||||
],
|
||||
},
|
||||
}),
|
||||
]);
|
||||
expect(spacetimedb.moduleDef.schedules).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -155,6 +155,13 @@ const playerLikeRow = t.row({
|
||||
name: t.string().unique(),
|
||||
});
|
||||
|
||||
const repeatingTestArgTable = table(
|
||||
{
|
||||
name: 'repeating_test_arg',
|
||||
},
|
||||
repeatingTestArg
|
||||
);
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// SCHEMA (tables + indexes + visibility)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -218,16 +225,10 @@ const spacetimedb = schema({
|
||||
// pk_multi_identity with multiple constraints
|
||||
pkMultiIdentity: table({ name: 'pk_multi_identity' }, pkMultiIdentityRow),
|
||||
|
||||
// repeating_test_arg table with scheduled(repeating_test)
|
||||
repeatingTestArg: table(
|
||||
{
|
||||
name: 'repeating_test_arg',
|
||||
scheduled: (): any => repeatingTest,
|
||||
},
|
||||
repeatingTestArg
|
||||
),
|
||||
// repeating_test_arg table scheduled by repeatingTest
|
||||
repeatingTestArg: repeatingTestArgTable,
|
||||
|
||||
// nonrepeating_test_arg table with scheduled(nonrepeating_test)
|
||||
// nonrepeating_test_arg table with legacy scheduled(nonrepeating_test)
|
||||
nonrepeatingTestArg: table(
|
||||
{
|
||||
name: 'nonrepeating_test_arg',
|
||||
@@ -284,6 +285,7 @@ export const init = spacetimedb.init(ctx => {
|
||||
|
||||
// repeating_test
|
||||
export const repeatingTest = spacetimedb.reducer(
|
||||
{ onSchedule: repeatingTestArgTable },
|
||||
{ arg: repeatingTestArg },
|
||||
(ctx, { arg }) => {
|
||||
const delta = ctx.timestamp.since(arg.prev_time); // adjust if API differs
|
||||
|
||||
Reference in New Issue
Block a user