Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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