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:
Jason Larabie
2026-07-16 13:03:10 -07:00
committed by GitHub
parent 00045f3d20
commit 7acce2ce4b
10 changed files with 778 additions and 72 deletions
+11 -7
View File
@@ -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 multicolumn 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 scheduleat 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;
}
+112 -31
View File
@@ -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) {
+1 -12
View File
@@ -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([]);
});
});
+11 -9
View File
@@ -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