diff --git a/crates/bindings-typescript/src/lib/table.ts b/crates/bindings-typescript/src/lib/table.ts index bfbbf77c461..3179feb141b 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,10 @@ export type TableOpts = { public?: boolean; indexes?: IndexOpts[]; // declarative multi‑column indexes constraints?: ConstraintOpts[]; + /** + * @deprecated Prefer `spacetime.schedule(table, reducerOrProcedure)` so table + * definitions can live in a separate module from reducer/procedure definitions. + */ scheduled?: () => | ReducerExport }> | ProcedureExport< @@ -422,11 +426,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,9 +494,9 @@ export function table>( CoerceRow >['algebraicType']['value']; - const schedule = + const schedule: TableSchedule | undefined = scheduled && scheduleAtCol !== undefined - ? { scheduleAtCol, reducer: scheduled } + ? { reducer: scheduled } : undefined; return { @@ -543,6 +545,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..e24bd7183ba 100644 --- a/crates/bindings-typescript/src/lib/table_schema.ts +++ b/crates/bindings-typescript/src/lib/table_schema.ts @@ -1,9 +1,24 @@ -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 { 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 = { + reducer: () => UntypedScheduledFunctionExport; +}; /** * Represents a handle to a database table, including its name, row type, and row spacetime type. @@ -50,12 +65,17 @@ 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.schedule(table, reducerOrProcedure)` 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..c0e5f4e7f85 100644 --- a/crates/bindings-typescript/src/server/procedures.ts +++ b/crates/bindings-typescript/src/server/procedures.ts @@ -30,11 +30,17 @@ import { type SchemaInner, } from './schema'; +declare const procedureReturnTypeBrand: unique symbol; + export type ProcedureExport< S extends UntypedSchemaDef, Params extends ParamsObj, Ret extends TypeBuilder, -> = ProcedureFn & ModuleExport; +> = ProcedureFn & + ModuleExport & { + /** Type-only brand used to preserve the procedure return builder in assignability checks. */ + readonly [procedureReturnTypeBrand]: Ret; + }; export function makeProcedureExport< S extends UntypedSchemaDef, @@ -49,8 +55,13 @@ export function makeProcedureExport< ): ProcedureExport { const name = opts?.name; - const procedureExport: ProcedureExport = (...args) => - fn(...args); + // ProcedureExport carries a type-only return-builder brand, so the plain + // function literal needs a cast when we attach module export metadata below. + const procedureExport = ((...args) => fn(...args)) as ProcedureExport< + S, + Params, + Ret + >; procedureExport[exportContext] = ctx; procedureExport[registerExport] = (ctx, exportName) => { registerProcedure(ctx, name ?? exportName, params, ret, fn); diff --git a/crates/bindings-typescript/src/server/schema.test-d.ts b/crates/bindings-typescript/src/server/schema.test-d.ts index 26f9018f240..c7e0168d9ef 100644 --- a/crates/bindings-typescript/src/server/schema.test-d.ts +++ b/crates/bindings-typescript/src/server/schema.test-d.ts @@ -97,3 +97,75 @@ spacetimedbIndexSplit.init(ctx => { // @ts-expect-error `nickname` is not indexed, so no index accessor should exist. const _nickname = ctx.db.account.nickname; }); + +const scheduledMessages = table( + {}, + { + scheduledId: t.u64().primaryKey().autoInc(), + scheduledAt: t.scheduleAt(), + text: t.string(), + } +); + +const spacetimedbSchedules = schema({ scheduledMessages }); + +const processScheduledMessage = spacetimedbSchedules.reducer( + { + scheduledMessage: scheduledMessages.rowType, + }, + (_ctx, { scheduledMessage }) => { + void scheduledMessage.text; + } +); + +spacetimedbSchedules.schedule(scheduledMessages, processScheduledMessage); + +const processWrongPayload = spacetimedbSchedules.reducer( + { + text: t.string(), + }, + () => {} +); + +// @ts-expect-error scheduled reducers must take the scheduled table row type. +spacetimedbSchedules.schedule(scheduledMessages, processWrongPayload); + +const processNoPayload = spacetimedbSchedules.reducer({}, () => {}); + +// @ts-expect-error scheduled reducers must take exactly one payload field. +spacetimedbSchedules.schedule(scheduledMessages, processNoPayload); + +const processMultiplePayloadFields = spacetimedbSchedules.reducer( + { + first: scheduledMessages.rowType, + second: scheduledMessages.rowType, + }, + () => {} +); + +// @ts-expect-error scheduled reducers must take exactly one payload field. +spacetimedbSchedules.schedule(scheduledMessages, processMultiplePayloadFields); + +const processScheduledProcedure = spacetimedbSchedules.procedure( + { + scheduledMessage: scheduledMessages.rowType, + }, + t.unit(), + (_ctx, { scheduledMessage }) => { + void scheduledMessage.text; + return {}; + } +); + +spacetimedbSchedules.schedule(scheduledMessages, processScheduledProcedure); + +const processWrongReturnProcedure = spacetimedbSchedules.procedure( + { + scheduledMessage: scheduledMessages.rowType, + }, + t.string(), + () => '' +); + +// @ts-expect-error scheduled procedures must return unit. +spacetimedbSchedules.schedule(scheduledMessages, processWrongReturnProcedure); diff --git a/crates/bindings-typescript/src/server/schema.ts b/crates/bindings-typescript/src/server/schema.ts index 384e7ebcd0c..c9399f8ec0e 100644 --- a/crates/bindings-typescript/src/server/schema.ts +++ b/crates/bindings-typescript/src/server/schema.ts @@ -16,8 +16,11 @@ import { type TablesToSchema, type UntypedSchemaDef, } from '../lib/schema'; -import type { UntypedTableSchema } from '../lib/table_schema'; -import { ColumnBuilder, TypeBuilder } from '../lib/type_builders'; +import type { IndexOpts } from '../lib/indexes'; +import type { TableSchema, UntypedTableSchema } from '../lib/table_schema'; +import { ColumnBuilder, RowBuilder, TypeBuilder } from '../lib/type_builders'; +import type { t } from '../lib/type_builders'; +import type { HasExactlyOneKnownKey } from '../lib/type_util'; import { Router, type HandlerFn, @@ -55,6 +58,31 @@ import { } from './views'; import type { UntypedTableDef } from '../lib/table'; +type ScheduledReducerOrProcedure< + Params extends Record>, +> = + | ReducerExport + | ProcedureExport>; + +export type ScheduledFunctionExport< + Row extends Record>, + Params extends Record>, +> = + HasExactlyOneKnownKey extends true + ? ScheduledReducerOrProcedure + : never; + +/** + * Internal erased form of a scheduled reducer/procedure export. + * + * The public `Schema.schedule(...)` API preserves row/return-type checks before + * values enter `pendingSchedules`. From this point on, schedule resolution only + * needs the export object identity to look up its registered function name. + */ +type UntypedScheduledFunctionExport = + | ReducerExport + | ProcedureExport; + export class SchemaInner< S extends UntypedSchemaDef = UntypedSchemaDef, > extends ModuleContext { @@ -67,14 +95,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,7 +129,21 @@ export class SchemaInner< } resolveSchedules() { - for (const { reducer, scheduleAtCol, tableName } of this.pendingSchedules) { + for (const { reducer, table } of this.pendingSchedules) { + const tableName = this.tableSourceNames.get(table); + if (tableName === undefined) { + throw new TypeError( + 'Schedule target table is not part of this schema.' + ); + } + + const { scheduleAtCol } = table; + if (scheduleAtCol === undefined) { + throw new TypeError( + `Table ${tableName} defines a schedule, but it does not have a ScheduleAt column.` + ); + } + const functionName = this.functionExports.get(reducer()); if (functionName === undefined) { const msg = `Table ${tableName} defines a schedule, but it seems like the associated function was not exported.`; @@ -136,7 +175,10 @@ export class SchemaInner< } } -type PendingSchedule = UntypedTableSchema['schedule'] & { tableName: string }; +type PendingSchedule = { + table: UntypedTableSchema; + reducer: () => UntypedScheduledFunctionExport; +}; type PendingHttpRoute = { handler: HttpHandlerExport; method: MethodOrAny; @@ -474,22 +516,22 @@ export class Schema implements ModuleDefaultExport { params: Params, ret: Ret, fn: ProcedureFn - ): ProcedureFn; + ): ProcedureExport; procedure>( ret: Ret, fn: ProcedureFn - ): ProcedureFn; + ): ProcedureExport; procedure>( opts: ProcedureOpts, params: Params, ret: Ret, fn: ProcedureFn - ): ProcedureFn; + ): ProcedureExport; procedure>( opts: ProcedureOpts, ret: Ret, fn: ProcedureFn - ): ProcedureFn; + ): ProcedureExport; procedure>( ...args: | [Params, Ret, ProcedureFn] @@ -519,6 +561,31 @@ export class Schema implements ModuleDefaultExport { return makeProcedureExport(this.#ctx, opts, params, ret, fn); } + /** + * Registers a table as the schedule table for a reducer or procedure. + * + * Prefer this over `table({ scheduled })` when table definitions and + * reducer/procedure definitions live in separate modules. + */ + schedule< + Row extends Record>, + Idx extends readonly IndexOpts[], + Params extends Record>, + >( + table: TableSchema, + reducerOrProcedure: ScheduledFunctionExport + ): ModuleExport { + return { + [exportContext]: this.#ctx, + [registerExport](ctx, _exportName) { + ctx.pendingSchedules.push({ + table, + reducer: () => reducerOrProcedure, + }); + }, + }; + } + httpHandler(fn: HandlerFn): HttpHandlerExport; httpHandler(opts: HttpHandlerOpts, fn: HandlerFn): HttpHandlerExport; httpHandler( @@ -639,11 +706,12 @@ export function schema>( for (const [accName, table] of Object.entries(tables)) { const tableDef = table.tableDef(ctx, accName); tableSchemas[accName] = tableToSchema(accName, table, tableDef); + ctx.tableSourceNames.set(table, tableDef.sourceName); ctx.moduleDef.tables.push(tableDef); if (table.schedule) { ctx.pendingSchedules.push({ - ...table.schedule, - tableName: tableDef.sourceName, + table, + 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..763e80b4571 --- /dev/null +++ b/crates/bindings-typescript/tests/schema_schedule.test.ts @@ -0,0 +1,254 @@ +import { describe, expect, it, vi } from 'vitest'; + +const { moduleHooks } = vi.hoisted(() => ({ + moduleHooks: Symbol('moduleHooks'), +})); + +vi.mock('spacetime:sys@2.0', () => ({ + moduleHooks, +})); + +vi.mock('spacetime:sys@2.1', () => ({ + 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 schedules registered with the separate schedule API', () => { + const scheduledMessages = table( + { name: 'scheduled_messages' }, + { + scheduledId: t.u64().primaryKey().autoInc(), + scheduledAt: t.scheduleAt(), + text: t.string(), + } + ); + + const spacetime = schema({ scheduledMessages }); + const processScheduledMessage = spacetime.reducer( + { scheduledMessage: scheduledMessages.rowType }, + () => {} + ); + const schedules = spacetime.schedule( + scheduledMessages, + processScheduledMessage + ); + + spacetime[moduleHooks]({ processScheduledMessage, schedules }); + + expect(spacetime.moduleDef.schedules).toEqual([ + { + sourceName: undefined, + tableName: 'scheduledMessages', + scheduleAtCol: 1, + functionName: 'processScheduledMessage', + }, + ]); + }); + + it('emits procedure schedules registered with the separate schedule API', () => { + const scheduledMessages = table( + {}, + { + scheduledId: t.u64().primaryKey().autoInc(), + scheduledAt: t.scheduleAt(), + text: t.string(), + } + ); + + const spacetime = schema({ scheduledMessages }); + const processScheduledMessage = spacetime.procedure( + { scheduledMessage: scheduledMessages.rowType }, + t.unit(), + () => ({}) + ); + const schedules = spacetime.schedule( + scheduledMessages, + processScheduledMessage + ); + + spacetime[moduleHooks]({ processScheduledMessage, schedules }); + + expect(spacetime.moduleDef.schedules).toEqual([ + { + sourceName: undefined, + tableName: 'scheduledMessages', + scheduleAtCol: 1, + functionName: 'processScheduledMessage', + }, + ]); + }); + + it('keeps legacy table scheduled option working', () => { + const scheduledMessages = table( + { + scheduled: () => processScheduledMessage, + }, + { + scheduledId: t.u64().primaryKey().autoInc(), + scheduledAt: t.scheduleAt(), + text: t.string(), + } + ); + const spacetime = schema({ scheduledMessages }); + const processScheduledMessage = spacetime.reducer( + { scheduledMessage: scheduledMessages.rowType }, + () => {} + ); + + spacetime[moduleHooks]({ processScheduledMessage }); + + expect(spacetime.moduleDef.schedules).toEqual([ + { + sourceName: undefined, + tableName: 'scheduledMessages', + scheduleAtCol: 1, + functionName: 'processScheduledMessage', + }, + ]); + }); + + it('keeps legacy table scheduled option working for procedures', () => { + const scheduledMessages = table( + { + scheduled: () => processScheduledMessage, + }, + { + scheduledId: t.u64().primaryKey().autoInc(), + scheduledAt: t.scheduleAt(), + text: t.string(), + } + ); + const spacetime = schema({ scheduledMessages }); + const processScheduledMessage = spacetime.procedure( + { scheduledMessage: scheduledMessages.rowType }, + t.unit(), + () => ({}) + ); + + spacetime[moduleHooks]({ processScheduledMessage }); + + expect(spacetime.moduleDef.schedules).toEqual([ + { + sourceName: undefined, + tableName: 'scheduledMessages', + scheduleAtCol: 1, + functionName: 'processScheduledMessage', + }, + ]); + }); + + it('keeps legacy table scheduled option as a no-op without ScheduleAt', () => { + const scheduledMessages = table( + { + scheduled: () => processScheduledMessage, + }, + { + scheduledId: t.u64().primaryKey().autoInc(), + text: t.string(), + } + ); + const spacetime = schema({ scheduledMessages }); + const processScheduledMessage = spacetime.reducer( + { scheduledMessage: scheduledMessages.rowType }, + () => {} + ); + + spacetime[moduleHooks]({ processScheduledMessage }); + + expect(spacetime.moduleDef.schedules).toEqual([]); + }); + + it('rejects a schedule whose reducer is not exported', () => { + const scheduledMessages = table( + {}, + { + scheduledId: t.u64().primaryKey().autoInc(), + scheduledAt: t.scheduleAt(), + } + ); + + const spacetime = schema({ scheduledMessages }); + const processScheduledMessage = spacetime.reducer( + { scheduledMessage: scheduledMessages.rowType }, + () => {} + ); + const schedules = spacetime.schedule( + scheduledMessages, + processScheduledMessage + ); + + expect(() => spacetime[moduleHooks]({ schedules })).toThrow( + 'Table scheduledMessages defines a schedule, but it seems like the associated function was not exported.' + ); + }); + + it('rejects a schedule whose table 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 spacetime = schema({ scheduledMessages }); + const processScheduledMessage = spacetime.reducer( + { scheduledMessage: scheduledMessages.rowType }, + () => {} + ); + const schedules = spacetime.schedule( + otherScheduledMessages, + processScheduledMessage + ); + + expect(() => + spacetime[moduleHooks]({ processScheduledMessage, schedules }) + ).toThrow('Schedule target table is not part of this schema.'); + }); + + it('rejects a schedule whose table has no ScheduleAt column', () => { + const scheduledMessages = table( + {}, + { + scheduledId: t.u64().primaryKey().autoInc(), + text: t.string(), + } + ); + + const spacetime = schema({ scheduledMessages }); + const processScheduledMessage = spacetime.reducer( + { scheduledMessage: scheduledMessages.rowType }, + () => {} + ); + const schedules = spacetime.schedule( + scheduledMessages, + processScheduledMessage + ); + + expect(() => + spacetime[moduleHooks]({ processScheduledMessage, schedules }) + ).toThrow( + 'Table scheduledMessages defines a schedule, but it does not have a ScheduleAt column.' + ); + }); +});