diff --git a/crates/bindings-typescript/src/lib/table.ts b/crates/bindings-typescript/src/lib/table.ts index bfbbf77c461..cb15705989f 100644 --- a/crates/bindings-typescript/src/lib/table.ts +++ b/crates/bindings-typescript/src/lib/table.ts @@ -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 = { public?: boolean; indexes?: IndexOpts[]; // declarative multi‑column indexes constraints?: ConstraintOpts[]; + /** + * @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 }> | ProcedureExport< @@ -422,11 +427,9 @@ export function table>( } // 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>( CoerceRow >['algebraicType']['value']; - const schedule = + const schedule: TableSchedule | undefined = scheduled && scheduleAtCol !== undefined ? { scheduleAtCol, reducer: scheduled } : undefined; @@ -543,6 +546,7 @@ export function table>( // can expose them without type-smuggling. idxs: userIndexes as OptsIndices, constraints: constraints as OptsConstraints, + scheduleAtCol, schedule, }; } diff --git a/crates/bindings-typescript/src/lib/table_schema.ts b/crates/bindings-typescript/src/lib/table_schema.ts index e9ce375adb9..f66a3eba226 100644 --- a/crates/bindings-typescript/src/lib/table_schema.ts +++ b/crates/bindings-typescript/src/lib/table_schema.ts @@ -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 + | ProcedureExport; + +export type TableSchedule = { + scheduleAtCol: number; + reducer: () => UntypedScheduledFunctionExport; +}; + +export type ScheduleTableForParams> = + HasExactlyOneKnownKey extends true + ? Params[keyof Params] extends RowBuilder< + infer Row extends Record> + > + ? TableSchema[]> + : 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 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?: { - scheduleAtCol: number; - reducer: () => ReducerExport | ProcedureExport; - }; + readonly schedule?: TableSchedule; }; export type UntypedTableSchema = TableSchema< diff --git a/crates/bindings-typescript/src/lib/type_util.ts b/crates/bindings-typescript/src/lib/type_util.ts index e943b649203..942b76a1424 100644 --- a/crates/bindings-typescript/src/lib/type_util.ts +++ b/crates/bindings-typescript/src/lib/type_util.ts @@ -48,6 +48,31 @@ export type Values = T[keyof T]; */ export type CollapseTuple = A extends [infer T] ? T : A; +/** + * Conditional-type helper for distinguishing a single type from a union. + */ +export type IsUnion = [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 = [keyof T] extends [never] + ? false + : string extends keyof T + ? true + : IsUnion extends true + ? false + : true; + type CamelCaseImpl = S extends `${infer Head}_${infer Tail}` ? `${Head}${Capitalize>}` : S extends `${infer Head}-${infer Tail}` diff --git a/crates/bindings-typescript/src/server/procedures.ts b/crates/bindings-typescript/src/server/procedures.ts index d07b71f5185..5f1867d986c 100644 --- a/crates/bindings-typescript/src/server/procedures.ts +++ b/crates/bindings-typescript/src/server/procedures.ts @@ -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, >( ctx: SchemaInner, - opts: ProcedureOpts | undefined, + opts: ProcedureOptsWithOptionalName | undefined, params: Params, ret: Ret, fn: ProcedureFn @@ -58,6 +60,12 @@ export function makeProcedureExport< procedureExport as ProcedureExport, 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, > = (ctx: ProcedureCtx, args: InferTypeOfRow) => Infer; -export interface ProcedureOpts { +export interface ProcedureOpts< + Params extends ParamsObj = ParamsObj, + Ret extends TypeBuilder = TypeBuilder, +> { name: string; + onSchedule?: Ret extends ReturnType + ? ScheduleTableForParams + : never; } +export type ProcedureOptsWithOptionalName< + Params extends ParamsObj = ParamsObj, + Ret extends TypeBuilder = TypeBuilder, +> = Omit, 'name'> & { name?: string }; + export interface ProcedureCtx { readonly sender: Identity; readonly databaseIdentity: Identity; @@ -107,7 +126,7 @@ function registerProcedure< params: Params, ret: Ret, fn: ProcedureFn, - opts?: ProcedureOpts + opts?: ProcedureOptsWithOptionalName ) { ctx.defineFunction(exportName); const paramsType: ProductType = { diff --git a/crates/bindings-typescript/src/server/reducers.ts b/crates/bindings-typescript/src/server/reducers.ts index f8aa1c390bf..ea5f770faf8 100644 --- a/crates/bindings-typescript/src/server/reducers.ts +++ b/crates/bindings-typescript/src/server/reducers.ts @@ -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, ModuleExport {} -export interface ReducerOpts { +export interface ReducerOpts { name: string; + onSchedule?: ScheduleTableForParams; } +export type ReducerOptsWithOptionalName = + Omit, 'name'> & { name?: string }; + export function makeReducerExport< S extends UntypedSchemaDef, Params extends ParamsObj, >( ctx: SchemaInner, - opts: ReducerOpts | undefined, + opts: ReducerOptsWithOptionalName | undefined, params: RowObj | RowBuilder, fn: Reducer, lifecycle?: Lifecycle @@ -39,6 +44,12 @@ export function makeReducerExport< reducerExport as ReducerExport, 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, fn: Reducer, - opts?: ReducerOpts, + opts?: ReducerOptsWithOptionalName, lifecycle?: Lifecycle ): void { ctx.defineFunction(exportName); diff --git a/crates/bindings-typescript/src/server/schema.test-d.ts b/crates/bindings-typescript/src/server/schema.test-d.ts index 26f9018f240..6d142fe2741 100644 --- a/crates/bindings-typescript/src/server/schema.test-d.ts +++ b/crates/bindings-typescript/src/server/schema.test-d.ts @@ -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 +> = 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; +} diff --git a/crates/bindings-typescript/src/server/schema.ts b/crates/bindings-typescript/src/server/schema.ts index 384e7ebcd0c..cd99697826c 100644 --- a/crates/bindings-typescript/src/server/schema.ts +++ b/crates/bindings-typescript/src/server/schema.ts @@ -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 + | ProcedureExport; + 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 - | ProcedureExport, - string - > = new Map(); + functionExports: Map = new Map(); + tableSourceNames: Map = new Map(); httpHandlerExports: Map, 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(); + 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; method: MethodOrAny; @@ -256,19 +319,19 @@ export class Schema implements ModuleDefaultExport { ): ReducerExport; reducer(fn: Reducer): ReducerExport; reducer( - opts: ReducerOpts, + opts: ReducerOptsWithOptionalName, params: Params, fn: Reducer ): ReducerExport; - reducer(opts: ReducerOpts, fn: Reducer): ReducerExport; + reducer(opts: ReducerOpts<{}>, fn: Reducer): ReducerExport; reducer( ...args: | [Params, Reducer] | [Reducer] - | [ReducerOpts, Params, Reducer] - | [ReducerOpts, Reducer] + | [ReducerOptsWithOptionalName, Params, Reducer] + | [ReducerOpts<{}>, Reducer] ): ReducerExport { - let opts: ReducerOpts | undefined, + let opts: ReducerOptsWithOptionalName | undefined, params: Params = {} as Params, fn: Reducer; switch (args.length) { @@ -278,7 +341,8 @@ export class Schema 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; else params = arg1 as Params; break; } @@ -307,7 +371,7 @@ export class Schema implements ModuleDefaultExport { * ``` */ init(fn: Reducer): ReducerExport; - init(opts: ReducerOpts, fn: Reducer): ReducerExport; + init(opts: ReducerOpts<{}>, fn: Reducer): ReducerExport; init( ...args: [Reducer] | [ReducerOpts, Reducer] ): ReducerExport { @@ -340,7 +404,10 @@ export class Schema implements ModuleDefaultExport { * ); */ clientConnected(fn: Reducer): ReducerExport; - clientConnected(opts: ReducerOpts, fn: Reducer): ReducerExport; + clientConnected( + opts: ReducerOpts<{}>, + fn: Reducer + ): ReducerExport; clientConnected( ...args: [Reducer] | [ReducerOpts, Reducer] ): ReducerExport { @@ -375,7 +442,7 @@ export class Schema implements ModuleDefaultExport { */ clientDisconnected(fn: Reducer): ReducerExport; clientDisconnected( - opts: ReducerOpts, + opts: ReducerOpts<{}>, fn: Reducer ): ReducerExport; clientDisconnected( @@ -474,30 +541,35 @@ export class Schema implements ModuleDefaultExport { params: Params, ret: Ret, fn: ProcedureFn - ): ProcedureFn; + ): ProcedureExport; procedure>( ret: Ret, fn: ProcedureFn - ): ProcedureFn; + ): ProcedureExport; procedure>( - opts: ProcedureOpts, + opts: ProcedureOptsWithOptionalName, params: Params, ret: Ret, fn: ProcedureFn - ): ProcedureFn; + ): ProcedureExport; procedure>( - opts: ProcedureOpts, + opts: ProcedureOpts<{}, Ret>, ret: Ret, fn: ProcedureFn - ): ProcedureFn; + ): ProcedureExport; procedure>( ...args: | [Params, Ret, ProcedureFn] | [Ret, ProcedureFn] - | [ProcedureOpts, Params, Ret, ProcedureFn] - | [ProcedureOpts, Ret, ProcedureFn] + | [ + ProcedureOptsWithOptionalName, + Params, + Ret, + ProcedureFn, + ] + | [ProcedureOpts<{}, Ret>, Ret, ProcedureFn] ): ProcedureExport { - let opts: ProcedureOpts | undefined, + let opts: ProcedureOptsWithOptionalName | undefined, params: Params = {} as Params, ret: Ret, fn: ProcedureFn; @@ -508,7 +580,8 @@ export class Schema 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; else params = arg1 as Params; break; } @@ -639,11 +712,19 @@ export function schema>( 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) { diff --git a/crates/bindings-typescript/src/server/views.ts b/crates/bindings-typescript/src/server/views.ts index 58e77062db0..06b3b94dedc 100644 --- a/crates/bindings-typescript/src/server/views.ts +++ b/crates/bindings-typescript/src/server/views.ts @@ -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 = { : 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] 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. diff --git a/crates/bindings-typescript/tests/schema_schedule.test.ts b/crates/bindings-typescript/tests/schema_schedule.test.ts new file mode 100644 index 00000000000..fd773dfb169 --- /dev/null +++ b/crates/bindings-typescript/tests/schema_schedule.test.ts @@ -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([]); + }); +}); diff --git a/modules/module-test-ts/src/index.ts b/modules/module-test-ts/src/index.ts index 164f7b7c1f1..1412ddad7bf 100644 --- a/modules/module-test-ts/src/index.ts +++ b/modules/module-test-ts/src/index.ts @@ -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