Skip to content
18 changes: 11 additions & 7 deletions crates/bindings-typescript/src/lib/table.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -194,6 +194,11 @@ export type TableOpts<Row extends RowObj> = {
public?: boolean;
indexes?: IndexOpts<keyof Row & string>[]; // declarative multi‑column indexes
constraints?: ConstraintOpts<keyof Row & string>[];
/**
* @deprecated Prefer `spacetime.reducer({ onSchedule: table }, ...)` or
* `spacetime.procedure({ onSchedule: table }, ...)` so table definitions can
* live in a separate module from reducer/procedure definitions.
*/
scheduled?: () =>
| ReducerExport<any, { [k: string]: RowBuilder<RowObj> }>
| ProcedureExport<
Expand Down Expand Up @@ -422,11 +427,9 @@ export function table<Row extends RowObj, const Opts extends TableOpts<Row>>(
}

// If this column is shaped like ScheduleAtAlgebraicType, mark it as the schedule‑at column
if (scheduled) {
const algebraicType = builder.typeBuilder.algebraicType;
if (ScheduleAt.isScheduleAt(algebraicType)) {
scheduleAtCol = colIds.get(name)!;
}
const algebraicType = builder.typeBuilder.algebraicType;
if (ScheduleAt.isScheduleAt(algebraicType)) {
scheduleAtCol = colIds.get(name)!;
}
}

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
};
}
44 changes: 38 additions & 6 deletions crates/bindings-typescript/src/lib/table_schema.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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<any, any> | ProcedureExport<any, any, any>;
};
readonly schedule?: TableSchedule;
};

export type UntypedTableSchema = TableSchema<
Expand Down
25 changes: 25 additions & 0 deletions crates/bindings-typescript/src/lib/type_util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`
Expand Down
25 changes: 22 additions & 3 deletions crates/bindings-typescript/src/server/procedures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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>
Expand All @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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 = {
Expand Down
17 changes: 14 additions & 3 deletions crates/bindings-typescript/src/server/reducers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand All @@ -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;
Expand All @@ -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);
Expand Down
102 changes: 101 additions & 1 deletion crates/bindings-typescript/src/server/schema.test-d.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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;
}
Loading
Loading