diff --git a/crates/bindings-typescript/src/lib/query.ts b/crates/bindings-typescript/src/lib/query.ts index 37743ccd0be..f0813725a50 100644 --- a/crates/bindings-typescript/src/lib/query.ts +++ b/crates/bindings-typescript/src/lib/query.ts @@ -2,6 +2,7 @@ import { ConnectionId } from './connection_id'; import { Identity } from './identity'; import type { ColumnIndex, IndexColumns, IndexOpts } from './indexes'; import type { UntypedSchemaDef } from './schema'; +import type { UntypedTableDef } from './table'; import type { UntypedTableSchema } from './table_schema'; import { Timestamp } from './timestamp'; import type { @@ -222,6 +223,25 @@ export type QueryBuilder = { > as Tbl['accessorName']]: TableRef & From; } & {}; +/** + * The type of `q.from` in an `addQuery` callback. + * + * Root-level tables appear as direct properties (same as `QueryBuilder`). + * Declared namespaces appear as sub-objects — each is itself a `QueryBuilder` for that + * namespace's schema, so `q.from..` is fully typed. + * + * When `SchemaDef['namespaces']` is absent or `{}`, no namespace properties appear — + * accessing an undeclared namespace is a compile error. + */ +export type SubscriptionFromBuilder = + QueryBuilder & { + readonly [NS in keyof NonNullable]: NonNullable< + SchemaDef['namespaces'] + >[NS] extends UntypedSchemaDef + ? QueryBuilder[NS]> + : never; + }; + /** * A runtime reference to a table. This materializes the RowExpr for us. * TODO: Maybe add the full SchemaDef to the type signature depending on how joins will work. @@ -334,6 +354,38 @@ export function makeQueryBuilder( return Object.freeze(qb) as QueryBuilder; } +/** + * Builds the `q.from` object for use in `addQuery` callbacks. + * + * Tables whose `sourceName` contains no `.` are placed at the root. + * Tables with a dotted `sourceName` (e.g. `"namespace.table"`) are grouped under a + * sub-object keyed by the namespace alias, with the part after the dot as the + * property key within that namespace. + */ +export function makeFromBuilder( + tables: SchemaDef['tables'] +): SubscriptionFromBuilder { + const result: Record = Object.create(null); + const namespaces: Record> = Object.create(null); + + for (const table of Object.values(tables) as UntypedTableDef[]) { + const dotIdx = table.sourceName.indexOf('.'); + if (dotIdx === -1) { + result[table.accessorName] = createTableRefFromDef(table as any); + } else { + const ns = table.sourceName.slice(0, dotIdx); + const key = table.sourceName.slice(dotIdx + 1); + (namespaces[ns] ??= Object.create(null))[key] = createTableRefFromDef(table as any); + } + } + + for (const [ns, nsTables] of Object.entries(namespaces)) { + result[ns] = Object.freeze(nsTables); + } + + return Object.freeze(result) as unknown as SubscriptionFromBuilder; +} + function createRowExpr( tableDef: TableDef ): RowExpr { @@ -873,7 +925,10 @@ function literalValueToSql(value: unknown): string { } function quoteIdentifier(name: string): string { - return `"${name.replace(/"/g, '""')}"`; + return name + .split('.') + .map(part => `"${part.replace(/"/g, '""')}"`) + .join('.'); } function isLiteralExpr( diff --git a/crates/bindings-typescript/src/lib/reducers.ts b/crates/bindings-typescript/src/lib/reducers.ts index 0eae2adc2a9..7b938aaf781 100644 --- a/crates/bindings-typescript/src/lib/reducers.ts +++ b/crates/bindings-typescript/src/lib/reducers.ts @@ -98,6 +98,11 @@ export interface JwtClaims { readonly fullPayload: JsonObject; } +export type AliasViews = + SchemaDef extends { namespaces: infer NS extends Record } + ? { readonly [K in keyof NS]: ReducerCtx } + : {}; + /** * Reducer context parametrized by the inferred Schema */ @@ -113,4 +118,5 @@ export type ReducerCtx = Readonly<{ newUuidV4(): Uuid; newUuidV7(): Uuid; random: Random; + as: AliasViews; }>; diff --git a/crates/bindings-typescript/src/lib/util.ts b/crates/bindings-typescript/src/lib/util.ts index e4b989b811b..b79de68c622 100644 --- a/crates/bindings-typescript/src/lib/util.ts +++ b/crates/bindings-typescript/src/lib/util.ts @@ -113,7 +113,7 @@ export function toPascalCase(s: string): string { */ export function toCamelCase(s: T): CamelCase { const str = s - .replace(/[-_]+/g, '_') // collapse runs to a single separator (no backtracking issue) + .replace(/[-_]+/g, '_') .replace(/_([a-zA-Z0-9])/g, (_, c) => c.toUpperCase()); return (str.charAt(0).toLowerCase() + str.slice(1)) as CamelCase; } diff --git a/crates/bindings-typescript/src/server/db_view.ts b/crates/bindings-typescript/src/server/db_view.ts index 9e0350ff863..70d225fa246 100644 --- a/crates/bindings-typescript/src/server/db_view.ts +++ b/crates/bindings-typescript/src/server/db_view.ts @@ -9,7 +9,9 @@ export type ReadonlyDbView = { readonly [Tbl in Values< SchemaDef['tables'] > as Tbl['accessorName']]: ReadonlyTable; -}; +} & (SchemaDef extends { namespaces: infer NS extends Record } + ? { readonly [K in keyof NS]: ReadonlyDbView } + : {}); /** * A type representing the database view, mapping table names to their corresponding Table handles. @@ -18,4 +20,6 @@ export type DbView = { readonly [Tbl in Values< SchemaDef['tables'] > as Tbl['accessorName']]: Table; -}; +} & (SchemaDef extends { namespaces: infer NS extends Record } + ? { readonly [K in keyof NS]: DbView } + : {}); diff --git a/crates/bindings-typescript/src/server/index.ts b/crates/bindings-typescript/src/server/index.ts index a840be4a59d..ae084f672d4 100644 --- a/crates/bindings-typescript/src/server/index.ts +++ b/crates/bindings-typescript/src/server/index.ts @@ -33,5 +33,6 @@ export { type ResponseInit, } from './http'; export type { HandlerContext, HttpHandlerExport } from './http'; +export { ScheduleAt } from '../lib/schedule_at'; import './polyfills'; // Ensure polyfills are loaded diff --git a/crates/bindings-typescript/src/server/procedures.ts b/crates/bindings-typescript/src/server/procedures.ts index d07b71f5185..eacd7897d4d 100644 --- a/crates/bindings-typescript/src/server/procedures.ts +++ b/crates/bindings-typescript/src/server/procedures.ts @@ -154,7 +154,7 @@ export type Procedures = Array<{ }>; export function callProcedure( - moduleCtx: SchemaInner, + procedures: Procedures, id: number, sender: Identity, connectionId: ConnectionId | null, @@ -163,7 +163,7 @@ export function callProcedure( dbView: () => DbView ): Uint8Array { const { fn, deserializeArgs, serializeReturn, returnTypeBaseSize } = - moduleCtx.procedures[id]; + procedures[id]; const args = deserializeArgs(new BinaryReader(argsBuf)); const ctx: ProcedureCtx = new ProcedureCtxImpl( diff --git a/crates/bindings-typescript/src/server/runtime.ts b/crates/bindings-typescript/src/server/runtime.ts index b3121dc2085..77d66273f3f 100644 --- a/crates/bindings-typescript/src/server/runtime.ts +++ b/crates/bindings-typescript/src/server/runtime.ts @@ -10,6 +10,8 @@ import { import { RawModuleDef, ViewResultHeader, + type RawProcedureDefV10, + type RawReducerDefV10, type RawTableDefV10, type Typespace, } from '../lib/autogen/types'; @@ -27,6 +29,8 @@ import { type UniqueIndex, } from '../lib/indexes'; import { callProcedure } from './procedures'; +import type { Procedures } from './procedures'; +import type { Reducers } from './reducers'; import { type HandlerContext, Request, @@ -40,6 +44,7 @@ import { serializeHeaders, } from './http_shared'; import { + type AliasViews, type AuthCtx, type JsonObject, type JwtClaims, @@ -48,13 +53,13 @@ import { import { type UntypedSchemaDef } from '../lib/schema'; import { type RowType, type Table, type TableMethods } from '../lib/table'; import { bsatnBaseSize, hasOwn } from '../lib/util'; -import { type AnonymousViewCtx, type ViewCtx } from './views'; +import { type AnonymousViewCtx, type AnonViews, type ViewCtx, type Views } from './views'; import { isRowTypedQuery, makeQueryBuilder, toSql } from './query'; -import type { DbView } from './db_view'; +import type { DbView, ReadonlyDbView } from './db_view'; import { getErrorConstructor, SenderError } from './errors'; import { Range, type Bound } from './range'; import { makeRandom, type Random } from './rng'; -import type { SchemaInner } from './schema'; +import type { MountedDispatchInfo, SchemaInner } from './schema'; import { HttpRequest, HttpResponse } from '../lib/autogen/types'; const { freeze } = Object; @@ -226,18 +231,21 @@ export const ReducerCtxImpl = class ReducerCtx< timestamp: Timestamp; connectionId: ConnectionId | null; db: DbView; + as: AliasViews; constructor( sender: Identity, timestamp: Timestamp, connectionId: ConnectionId | null, - dbView: DbView + dbView: DbView, + asViews: object = {} ) { Object.seal(this); this.sender = sender; this.timestamp = timestamp; this.connectionId = connectionId; - this.db = dbView; + this.db = dbView as unknown as DbView; + this.as = asViews as AliasViews; } /** Reset the `ReducerCtx` to be used for a new transaction */ @@ -245,13 +253,21 @@ export const ReducerCtxImpl = class ReducerCtx< me: InstanceType, sender: Identity, timestamp: Timestamp, - connectionId: ConnectionId | null + connectionId: ConnectionId | null, + dbView?: DbView, + asViews?: object ) { me.sender = sender; me.timestamp = timestamp; me.connectionId = connectionId; me.#uuidCounter = undefined; me.#senderAuth = undefined; + if (dbView !== undefined) { + me.db = dbView; + } + if (asViews !== undefined) { + me.as = asViews as AliasViews; + } } get databaseIdentity() { @@ -337,31 +353,102 @@ export function runWithTx( } } +type FlatMountDispatch = { + reducerFns: Reducers; + reducerDefs: RawReducerDefV10[]; + procedureFns: Procedures; + procedureDefs: RawProcedureDefV10[]; + anonViewFns: AnonViews; + viewFns: Views; + tables: Array<{ accessorName: string; tableDef: RawTableDefV10 }>; + typespace: Typespace; + dbView_: DbView | undefined; + /** e.g. "alias." for a mount with namespace alias "alias" */ + namePrefix: string; +}; + +function flattenMountDispatches( + dispatches: MountedDispatchInfo[], + parentPrefix = '' +): FlatMountDispatch[] { + const result: FlatMountDispatch[] = []; + for (const d of dispatches) { + const namePrefix = parentPrefix + d.namespace + '.'; + result.push({ + reducerFns: d.reducerFns, + reducerDefs: d.reducerDefs, + procedureFns: d.procedureFns, + procedureDefs: d.procedureDefs, + anonViewFns: d.anonViewFns, + viewFns: d.viewFns, + tables: d.tables, + typespace: d.typespace, + dbView_: undefined, + namePrefix, + }); + result.push(...flattenMountDispatches(d.subDispatches, namePrefix)); + } + return result; +} + export const makeHooks = (schema: SchemaInner): ModuleHooks => new ModuleHooksImpl(schema); class ModuleHooksImpl implements ModuleHooks { #schema: SchemaInner; #dbView_: DbView | undefined; + #consumerAs_: object | undefined; #reducerArgsDeserializers; - /** Cache the `ReducerCtx` object to avoid allocating anew for ever reducer call. */ + #consumerReducerCount: number; + #consumerProcedureCount: number; + #flatMounts: FlatMountDispatch[]; + #consumerAnonViewCount: number; + #consumerViewCount: number; + /** Cache the `ReducerCtx` object to avoid allocating anew for every reducer call. */ #reducerCtx_: InstanceType | undefined; constructor(schema: SchemaInner) { this.#schema = schema; - this.#reducerArgsDeserializers = schema.moduleDef.reducers.map( + this.#consumerReducerCount = schema.reducers.length; + this.#consumerProcedureCount = schema.procedures.length; + this.#consumerAnonViewCount = schema.anonViews.length; + this.#consumerViewCount = schema.views.length; + this.#flatMounts = flattenMountDispatches(schema.mountedDispatchInfos); + + const consumerDeserializers = schema.moduleDef.reducers.map( ({ params }) => ProductType.makeDeserializer(params, schema.typespace) ); + const mountedDeserializers = this.#flatMounts.flatMap(({ reducerDefs, typespace }) => + reducerDefs.map(({ params }) => ProductType.makeDeserializer(params, typespace)) + ); + this.#reducerArgsDeserializers = [...consumerDeserializers, ...mountedDeserializers]; } get #dbView() { - return (this.#dbView_ ??= freeze( + if (this.#dbView_ !== undefined) return this.#dbView_; + const rootTables = Object.values(this.#schema.schemaType.tables).map( + table => [ + table.accessorName, + makeTableView(this.#schema.typespace, table.tableDef), + ] + ); + const mountNs = this.#schema.mountedDispatchInfos.map(dispatch => [ + dispatch.namespace, + buildDbViewForDispatch(dispatch, dispatch.namespace + '.'), + ]); + this.#dbView_ = freeze(Object.fromEntries([...rootTables, ...mountNs])) as DbView; + return this.#dbView_; + } + + #getMountDbView(mountIdx: number): DbView { + const m = this.#flatMounts[mountIdx]; + return (m.dbView_ ??= freeze( Object.fromEntries( - Object.values(this.#schema.schemaType.tables).map(table => [ - table.accessorName, - makeTableView(this.#schema.typespace, table.tableDef), + m.tables.map(({ accessorName, tableDef }) => [ + accessorName, + makeTableView(m.typespace, tableDef, m.namePrefix), ]) - ) + ) as DbView )); } @@ -374,6 +461,14 @@ class ModuleHooksImpl implements ModuleHooks { )); } + get #consumerAs() { + return (this.#consumerAs_ ??= buildAliasCtxMap( + this.#reducerCtx, + this.#schema.mountedDispatchInfos, + '' + )); + } + __describe_module__() { const writer = new BinaryWriter(128); RawModuleDef.serialize( @@ -398,19 +493,46 @@ class ModuleHooksImpl implements ModuleHooks { timestamp: bigint, argsBuf: DataView ): void { - const moduleCtx = this.#schema; const deserializeArgs = this.#reducerArgsDeserializers[reducerId]; BINARY_READER.reset(argsBuf); const args = deserializeArgs(BINARY_READER); const senderIdentity = new Identity(sender); + + let fn: ((...args: any[]) => any) | undefined; + let dbView: DbView; + let asViews: object; + + if (reducerId < this.#consumerReducerCount) { + fn = this.#schema.reducers[reducerId]; + dbView = this.#dbView; + asViews = this.#consumerAs; + } else { + let offset = this.#consumerReducerCount; + for (let i = 0; i < this.#flatMounts.length; i++) { + const m = this.#flatMounts[i]; + if (reducerId < offset + m.reducerFns.length) { + fn = m.reducerFns[reducerId - offset]; + dbView = this.#getMountDbView(i); + break; + } + offset += m.reducerFns.length; + } + if (fn === undefined) { + throw new RangeError(`unknown reducerId ${reducerId}`); + } + asViews = {}; + } + const ctx = this.#reducerCtx; ReducerCtxImpl.reset( ctx, senderIdentity, new Timestamp(timestamp), - ConnectionId.nullIfZero(new ConnectionId(connId)) + ConnectionId.nullIfZero(new ConnectionId(connId)), + dbView!, + asViews! ); - callUserFunction(moduleCtx.reducers[reducerId], ctx, args); + callUserFunction(fn, ctx, args); } __call_view__( @@ -419,14 +541,35 @@ class ModuleHooksImpl implements ModuleHooks { argsBuf: Uint8Array ): { data: Uint8Array } { const moduleCtx = this.#schema; - const { fn, deserializeParams, serializeReturn, returnTypeBaseSize } = - moduleCtx.views[id]; + let viewFns: Views; + let localId: number; + let dbView: ReadonlyDbView; + + if (id < this.#consumerViewCount) { + viewFns = moduleCtx.views; + localId = id; + dbView = this.#dbView as ReadonlyDbView; + } else { + let offset = this.#consumerViewCount; + let found = false; + for (let i = 0; i < this.#flatMounts.length; i++) { + const m = this.#flatMounts[i]; + if (id < offset + m.viewFns.length) { + viewFns = m.viewFns; + localId = id - offset; + dbView = this.#getMountDbView(i) as ReadonlyDbView; + found = true; + break; + } + offset += m.viewFns.length; + } + if (!found) throw new RangeError(`unknown viewId ${id}`); + } + + const { fn, deserializeParams, serializeReturn, returnTypeBaseSize } = viewFns![localId!]; const ctx: ViewCtx = freeze({ sender: new Identity(sender), - // this is the non-readonly DbView, but the typing for the user will be - // the readonly one, and if they do call mutating functions it will fail - // at runtime - db: this.#dbView, + db: dbView!, from: makeQueryBuilder(moduleCtx.schemaType), }); const args = deserializeParams(new BinaryReader(argsBuf)); @@ -444,13 +587,34 @@ class ModuleHooksImpl implements ModuleHooks { __call_view_anon__(id: u32, argsBuf: Uint8Array): { data: Uint8Array } { const moduleCtx = this.#schema; - const { fn, deserializeParams, serializeReturn, returnTypeBaseSize } = - moduleCtx.anonViews[id]; + let anonViewFns: AnonViews; + let localId: number; + let dbView: ReadonlyDbView; + + if (id < this.#consumerAnonViewCount) { + anonViewFns = moduleCtx.anonViews; + localId = id; + dbView = this.#dbView as ReadonlyDbView; + } else { + let offset = this.#consumerAnonViewCount; + let found = false; + for (let i = 0; i < this.#flatMounts.length; i++) { + const m = this.#flatMounts[i]; + if (id < offset + m.anonViewFns.length) { + anonViewFns = m.anonViewFns; + localId = id - offset; + dbView = this.#getMountDbView(i) as ReadonlyDbView; + found = true; + break; + } + offset += m.anonViewFns.length; + } + if (!found) throw new RangeError(`unknown anonViewId ${id}`); + } + + const { fn, deserializeParams, serializeReturn, returnTypeBaseSize } = anonViewFns![localId!]; const ctx: AnonymousViewCtx = freeze({ - // this is the non-readonly DbView, but the typing for the user will be - // the readonly one, and if they do call mutating functions it will fail - // at runtime - db: this.#dbView, + db: dbView!, from: makeQueryBuilder(moduleCtx.schemaType), }); const args = deserializeParams(new BinaryReader(argsBuf)); @@ -473,15 +637,40 @@ class ModuleHooksImpl implements ModuleHooks { timestamp: bigint, args: Uint8Array ): Uint8Array { - return callProcedure( - this.#schema, - id, - new Identity(sender), - ConnectionId.nullIfZero(new ConnectionId(connection_id)), - new Timestamp(timestamp), - args, - () => this.#dbView - ); + const senderIdentity = new Identity(sender); + const connId = ConnectionId.nullIfZero(new ConnectionId(connection_id)); + const ts = new Timestamp(timestamp); + + if (id < this.#consumerProcedureCount) { + return callProcedure( + this.#schema.procedures, + id, + senderIdentity, + connId, + ts, + args, + () => this.#dbView as DbView + ); + } + + let offset = this.#consumerProcedureCount; + for (let i = 0; i < this.#flatMounts.length; i++) { + const m = this.#flatMounts[i]; + if (id < offset + m.procedureFns.length) { + return callProcedure( + m.procedureFns, + id - offset, + senderIdentity, + connId, + ts, + args, + () => this.#getMountDbView(i) + ); + } + offset += m.procedureFns.length; + } + + throw new RangeError(`unknown procedureId ${id}`); } __call_http_handler__( @@ -559,11 +748,59 @@ class HandlerContextImpl } } +function buildDbViewForDispatch(dispatch: MountedDispatchInfo, namePrefix: string): object { + const tableEntries = dispatch.tables.map(({ accessorName, tableDef }) => [ + accessorName, + makeTableView(dispatch.typespace, tableDef, namePrefix), + ]); + const subNsEntries = dispatch.subDispatches.map(sub => [ + sub.namespace, + buildDbViewForDispatch(sub, namePrefix + sub.namespace + '.'), + ]); + return freeze(Object.fromEntries([...tableEntries, ...subNsEntries])); +} + +function buildAliasCtx( + parent: InstanceType, + dispatch: MountedDispatchInfo, + namePrefix: string +): object { + const nsDb = buildDbViewForDispatch(dispatch, namePrefix); + const subAs = buildAliasCtxMap(parent, dispatch.subDispatches, namePrefix); + return { + get sender() { return parent.sender; }, + get databaseIdentity() { return parent.databaseIdentity; }, + get identity() { return parent.identity; }, + get timestamp() { return parent.timestamp; }, + get connectionId() { return parent.connectionId; }, + get senderAuth() { return parent.senderAuth; }, + get random() { return parent.random; }, + newUuidV4() { return parent.newUuidV4(); }, + newUuidV7() { return parent.newUuidV7(); }, + db: nsDb, + as: subAs, + }; +} + +function buildAliasCtxMap( + parent: InstanceType, + dispatches: MountedDispatchInfo[], + parentPrefix: string +): object { + return freeze( + Object.fromEntries(dispatches.map(d => [ + d.namespace, + buildAliasCtx(parent, d, parentPrefix + d.namespace + '.'), + ])) + ); +} + function makeTableView( typespace: Typespace, - table: RawTableDefV10 + table: RawTableDefV10, + namePrefix = '' ): Table { - const table_id = sys.table_id_from_name(table.sourceName); + const table_id = sys.table_id_from_name(namePrefix + table.sourceName); const rowType = typespace.types[table.productTypeRef]; if (rowType.tag !== 'Product') { throw 'impossible'; @@ -659,7 +896,7 @@ function makeTableView( for (const indexDef of table.indexes) { const accessorName = indexDef.accessorName!; - const index_id = sys.index_id_from_name(indexDef.sourceName!); + const index_id = sys.index_id_from_name(namePrefix + indexDef.sourceName!); let column_ids: number[]; let isHashIndex = false; diff --git a/crates/bindings-typescript/src/server/schema.ts b/crates/bindings-typescript/src/server/schema.ts index d9f20be3025..0f1562e0533 100644 --- a/crates/bindings-typescript/src/server/schema.ts +++ b/crates/bindings-typescript/src/server/schema.ts @@ -3,6 +3,11 @@ import { CaseConversionPolicy, Lifecycle, type MethodOrAny, + type RawModuleDefV10, + type RawProcedureDefV10, + type RawReducerDefV10, + type RawTableDefV10, + type Typespace, } from '../lib/autogen/types'; import { type ParamsAsObject, @@ -18,6 +23,7 @@ import { } from '../lib/schema'; import type { UntypedTableSchema } from '../lib/table_schema'; import { ColumnBuilder, TypeBuilder } from '../lib/type_builders'; +import { hasOwn } from '../lib/util'; import { Router, type HandlerFn, @@ -54,10 +60,25 @@ import { } from './views'; import type { UntypedTableDef } from '../lib/table'; +export type MountedDispatchInfo = { + namespace: string; + reducerFns: Reducers; + reducerDefs: RawReducerDefV10[]; + procedureFns: Procedures; + procedureDefs: RawProcedureDefV10[]; + anonViewFns: AnonViews; + viewFns: Views; + typespace: Typespace; + tables: Array<{ accessorName: string; tableDef: RawTableDefV10 }>; + subDispatches: MountedDispatchInfo[]; +}; + export class SchemaInner< S extends UntypedSchemaDef = UntypedSchemaDef, > extends ModuleContext { schemaType: S; + exportsRegistered = false; + schedulesResolved = false; existingFunctions = new Set(); existingHttpHandlers = new Set(); reducers: Reducers = []; @@ -78,6 +99,7 @@ export class SchemaInner< new Map(); pendingSchedules: PendingSchedule[] = []; pendingHttpRoutes: PendingHttpRoute[] = []; + mountedDispatchInfos: MountedDispatchInfo[] = []; constructor(getSchemaType: (ctx: SchemaInner) => S) { super(); @@ -103,6 +125,10 @@ export class SchemaInner< } resolveSchedules() { + if (this.schedulesResolved) { + return; + } + this.schedulesResolved = true; for (const { reducer, scheduleAtCol, tableName } of this.pendingSchedules) { const functionName = this.functionExports.get(reducer()); if (functionName === undefined) { @@ -185,23 +211,9 @@ export class Schema implements ModuleDefaultExport { } [moduleHooks](exports: object) { - // if (!(hasOwn(exports, 'default') && exports.default instanceof Schema)) { - // throw new TypeError('must export schema as default export'); - // } - const registeredSchema = this.#ctx; - for (const [name, moduleExport] of Object.entries(exports)) { - if (name === 'default') continue; - if (!isModuleExport(moduleExport)) { - throw new TypeError( - 'exporting something that is not a spacetime export' - ); - } - checkExportContext(moduleExport, registeredSchema); - moduleExport[registerExport](registeredSchema, name); - } - registeredSchema.resolveSchedules(); - registeredSchema.resolveHttpRoutes(); - return makeHooks(registeredSchema); + this.buildRawModuleDefV10(exports); + this.#ctx.resolveHttpRoutes(); + return makeHooks(this.#ctx); } get schemaType(): S { @@ -216,6 +228,53 @@ export class Schema implements ModuleDefaultExport { return this.#ctx.typespace; } + get mountedDispatchInfos(): MountedDispatchInfo[] { + return this.#ctx.mountedDispatchInfos; + } + + /** Internal: register exports and materialize the RawModuleDefV10 for upload. */ + buildRawModuleDefV10( + exports: object, + opts?: { ignoreNonModuleExports?: boolean } + ): RawModuleDefV10 { + registerModuleExports(this.#ctx, exports, { + ignoreNonModuleExports: opts?.ignoreNonModuleExports ?? false, + }); + this.#ctx.resolveSchedules(); + return this.#ctx.rawModuleDefV10(); + } + + /** + * @internal – called by schema() when processing a mounted namespace entry. + * Registers the library's exports and returns both the serialized module def + * and the runtime dispatch info needed by ModuleHooksImpl for __call_reducer__. + */ + buildMountForDispatch( + exports: object + ): { rawDef: RawModuleDefV10; dispatch: MountedDispatchInfo } { + const rawDef = this.buildRawModuleDefV10(exports, { + ignoreNonModuleExports: true, + }); + return { + rawDef, + dispatch: { + namespace: '', + reducerFns: [...this.#ctx.reducers], + reducerDefs: [...this.#ctx.moduleDef.reducers], + procedureFns: [...this.#ctx.procedures], + procedureDefs: [...this.#ctx.moduleDef.procedures], + anonViewFns: [...this.#ctx.anonViews], + viewFns: [...this.#ctx.views], + typespace: this.#ctx.moduleDef.typespace, + tables: Object.values(this.#ctx.schemaType.tables).map(t => ({ + accessorName: t.accessorName, + tableDef: t.tableDef, + })), + subDispatches: [...this.#ctx.mountedDispatchInfos], + }, + }; + } + /** * Defines a SpacetimeDB reducer function. * @@ -612,18 +671,100 @@ export interface ModuleSettings { CASE_CONVERSION_POLICY?: CaseConversionPolicy; } -export function schema>( - tables: H, +type MountedModuleNamespace = { + default: Schema; + [key: string]: unknown; +}; + +type SchemaEntry = UntypedTableSchema | MountedModuleNamespace; + +type ExtractTableEntries> = { + [K in keyof H as H[K] extends UntypedTableSchema ? K : never]: Extract< + H[K], + UntypedTableSchema + >; +}; + +type ExtractMountSchemas> = { + [K in keyof H as H[K] extends { default: Schema } + ? K + : never]: H[K] extends { default: Schema } + ? S + : never; +}; + +type SchemaDefForEntries> = + TablesToSchema> & { + namespaces: ExtractMountSchemas; + }; + +function isUntypedTableSchema(x: unknown): x is UntypedTableSchema { + return typeof x === 'object' && x !== null && hasOwn(x, 'tableDef'); +} + +function isMountedModuleNamespace(x: unknown): x is MountedModuleNamespace { + return ( + typeof x === 'object' && + x !== null && + hasOwn(x, 'default') && + x.default instanceof Schema + ); +} + +function registerModuleExports( + schema: SchemaInner, + exports: object, + opts?: { ignoreNonModuleExports?: boolean } +) { + if (schema.exportsRegistered) { + return; + } + schema.exportsRegistered = true; + + for (const [name, moduleExport] of Object.entries(exports)) { + if (name === 'default') continue; + if (!isModuleExport(moduleExport)) { + if (opts?.ignoreNonModuleExports) { + continue; + } + throw new TypeError('exporting something that is not a spacetime export'); + } + checkExportContext(moduleExport, schema); + moduleExport[registerExport](schema, name); + } +} + +export function schema>( + entries: H, moduleSettings?: ModuleSettings -): Schema> { - const ctx = new SchemaInner>(ctx => { +): Schema> { + const ctx = new SchemaInner>(ctx => { // Apply module settings. if (moduleSettings?.CASE_CONVERSION_POLICY != null) { ctx.setCaseConversionPolicy(moduleSettings.CASE_CONVERSION_POLICY); } const tableSchemas: Record = {}; - for (const [accName, table] of Object.entries(tables)) { + for (const [accName, entry] of Object.entries(entries)) { + if (entry instanceof Schema) { + throw new TypeError( + `schema entry '${accName}' looks like a default import; use \`import * as ${accName} from '...'\` so the mount can see the library's named reducer exports.` + ); + } + if (isMountedModuleNamespace(entry)) { + const { rawDef, dispatch } = entry.default.buildMountForDispatch(entry); + dispatch.namespace = accName; + ctx.addMount({ namespace: accName, module: rawDef }); + ctx.mountedDispatchInfos.push(dispatch); + continue; + } + if (!isUntypedTableSchema(entry)) { + throw new TypeError( + `schema entry '${accName}' must be a table or a mounted module namespace object` + ); + } + + const table = entry; const tableDef = table.tableDef(ctx, accName); tableSchemas[accName] = tableToSchema(accName, table, tableDef); ctx.moduleDef.tables.push(tableDef); @@ -643,7 +784,7 @@ export function schema>( }); } } - return { tables: tableSchemas } as TablesToSchema; + return { tables: tableSchemas } as SchemaDefForEntries; }); return new Schema(ctx); diff --git a/crates/bindings-typescript/src/server/views.ts b/crates/bindings-typescript/src/server/views.ts index 6d2b475b177..2c87c5b7438 100644 --- a/crates/bindings-typescript/src/server/views.ts +++ b/crates/bindings-typescript/src/server/views.ts @@ -187,7 +187,7 @@ export function registerView< } (anon ? ctx.anonViews : ctx.views).push({ - fn, + fn: fn as unknown as ViewFn, deserializeParams: ProductType.makeDeserializer(paramType, typespace), serializeReturn: AlgebraicType.makeSerializer(returnType, typespace), returnTypeBaseSize: bsatnBaseSize(typespace, returnType), diff --git a/crates/bindings-typescript/tests/ctx_as.test.ts b/crates/bindings-typescript/tests/ctx_as.test.ts new file mode 100644 index 00000000000..ed3e4fa7c58 --- /dev/null +++ b/crates/bindings-typescript/tests/ctx_as.test.ts @@ -0,0 +1,117 @@ +import { beforeAll, describe, expect, it, vi } from 'vitest'; + +vi.mock( + 'spacetime:sys@2.0', + () => ({ + moduleHooks: Symbol('moduleHooks'), + table_id_from_name: () => 1, + index_id_from_name: () => 1, + row_iter_bsatn_close: () => {}, + }), + { virtual: true } +); + +vi.mock('spacetime:sys@2.1', () => ({}), { virtual: true }); + +describe('ctx.as alias proxy', () => { + let schema: typeof import('../src/server/schema').schema; + let table: typeof import('../src/lib/table').table; + let t: typeof import('../src/lib/type_builders').t; + let moduleHooks: symbol; + + beforeAll(async () => { + ({ schema } = await import('../src/server/schema')); + ({ table } = await import('../src/lib/table')); + ({ t } = await import('../src/lib/type_builders')); + ({ moduleHooks } = (await import('spacetime:sys@2.0')) as any); + }); + + it('ctx.as. provides a narrowed ctx with library db and delegating sender', async () => { + const sessions = table( + { name: 'sessions' }, + { id: t.u64().primaryKey().autoInc() } + ); + const authSchema = schema({ sessions }); + const authLib = { default: authSchema }; + + const players = table({ name: 'players' }, { id: t.u32().primaryKey() }); + const consumer = schema({ players, myauth: authLib }); + + let capturedCtx: any; + const myReducer = consumer.reducer((ctx: any) => { + capturedCtx = ctx; + }); + + const hooks = (consumer as any)[moduleHooks]({ myReducer }); + hooks.__call_reducer__( + 0, + 0n, + 0n, + 0n, + new DataView(new ArrayBuffer(0)) + ); + + expect(capturedCtx.as).toBeDefined(); + expect(capturedCtx.as.myauth).toBeDefined(); + expect(capturedCtx.as.myauth.db).toBeDefined(); + expect(capturedCtx.as.myauth.db.sessions).toBeDefined(); + expect(capturedCtx.as.myauth.sender).toBe(capturedCtx.sender); + expect(capturedCtx.as.myauth.timestamp).toBe(capturedCtx.timestamp); + }); + + it('ctx.as is empty object when there are no mounts', async () => { + const players = table({ name: 'players' }, { id: t.u32().primaryKey() }); + const consumer = schema({ players }); + + let capturedCtx: any; + const myReducer = consumer.reducer((ctx: any) => { + capturedCtx = ctx; + }); + + const hooks = (consumer as any)[moduleHooks]({ myReducer }); + hooks.__call_reducer__( + 0, + 0n, + 0n, + 0n, + new DataView(new ArrayBuffer(0)) + ); + + expect(capturedCtx.as).toBeDefined(); + expect(Object.keys(capturedCtx.as)).toHaveLength(0); + }); + + it('ctx.as..as carries nested sub-mount aliases', async () => { + const bazTable = table({ name: 'baz_items' }, { id: t.u32().primaryKey() }); + const bazSchema = schema({ bazTable }); + const bazLib = { default: bazSchema }; + + const sessions = table( + { name: 'sessions' }, + { id: t.u64().primaryKey().autoInc() } + ); + const authSchema = schema({ sessions, baz: bazLib }); + const authLib = { default: authSchema }; + + const players = table({ name: 'players' }, { id: t.u32().primaryKey() }); + const consumer = schema({ players, myauth: authLib }); + + let capturedCtx: any; + const myReducer = consumer.reducer((ctx: any) => { + capturedCtx = ctx; + }); + + const hooks = (consumer as any)[moduleHooks]({ myReducer }); + hooks.__call_reducer__( + 0, + 0n, + 0n, + 0n, + new DataView(new ArrayBuffer(0)) + ); + + expect(capturedCtx.as.myauth.as.baz).toBeDefined(); + expect(capturedCtx.as.myauth.as.baz.db.bazTable).toBeDefined(); + expect(capturedCtx.as.myauth.as.baz.sender).toBe(capturedCtx.sender); + }); +}); diff --git a/crates/bindings-typescript/tests/schema_mounts.test.ts b/crates/bindings-typescript/tests/schema_mounts.test.ts new file mode 100644 index 00000000000..69b5a81bd27 --- /dev/null +++ b/crates/bindings-typescript/tests/schema_mounts.test.ts @@ -0,0 +1,222 @@ +import { beforeAll, describe, expect, it, vi } from 'vitest'; + +vi.mock( + 'spacetime:sys@2.0', + () => ({ + moduleHooks: Symbol('moduleHooks'), + }), + { virtual: true } +); + +vi.mock('spacetime:sys@2.1', () => ({}), { virtual: true }); + +vi.mock('../src/server/runtime', () => ({ + makeHooks: () => ({}), + callProcedure: () => new Uint8Array(), + callUserFunction: (fn: (...args: any[]) => any, ...args: any[]) => + fn(...args), + ReducerCtxImpl: class {}, + sys: { + row_iter_bsatn_close: () => {}, + }, +})); + +describe('schema mounts', () => { + let schema: typeof import('../src/server/schema').schema; + let table: typeof import('../src/lib/table').table; + let t: typeof import('../src/lib/type_builders').t; + + beforeAll(async () => { + ({ schema } = await import('../src/server/schema')); + ({ table } = await import('../src/lib/table')); + ({ t } = await import('../src/lib/type_builders')); + }); + + it('emits mounted submodule module defs and resolves mounted schedules', () => { + const players = table({ name: 'players' }, { id: t.u32().primaryKey() }); + + const sessionCleanupTick = table( + { + name: 'session_cleanup_tick', + scheduled: (): any => cleanExpiredSessions, + }, + { + scheduledId: t.u64().primaryKey().autoInc(), + scheduledAt: t.scheduleAt(), + } + ); + + const sessions = table( + { name: 'sessions' }, + { + id: t.u64().primaryKey().autoInc(), + } + ); + + const authSchema = schema({ + sessions, + sessionCleanupTick, + }); + + const cleanExpiredSessions = authSchema.reducer(() => {}); + const authLib = { + default: authSchema, + cleanExpiredSessions, + }; + + const consumer = schema({ + players, + myauth: authLib, + }); + + const raw = consumer.buildRawModuleDefV10({}); + const mounts = raw.sections.find( + section => section.tag === 'Mounts' + )?.value; + + expect(mounts).toHaveLength(1); + expect(mounts?.[0]?.namespace).toBe('myauth'); + + const mountedSections = mounts?.[0]?.module.sections ?? []; + const mountedReducers = mountedSections.find( + section => section.tag === 'Reducers' + )?.value; + const mountedSchedules = mountedSections.find( + section => section.tag === 'Schedules' + )?.value; + + expect(mountedReducers).toEqual( + expect.arrayContaining([ + expect.objectContaining({ sourceName: 'cleanExpiredSessions' }), + ]) + ); + expect(mountedSchedules).toEqual([ + expect.objectContaining({ + tableName: 'sessionCleanupTick', + functionName: 'cleanExpiredSessions', + }), + ]); + }); + + it('rejects default-import style mounts with a clear error', () => { + const sessions = table( + { name: 'sessions' }, + { + id: t.u64().primaryKey().autoInc(), + } + ); + + const authSchema = schema({ sessions }); + + expect(() => + schema({ + myauth: authSchema as any, + }) + ).toThrow(/looks like a default import/); + }); + + it('populates mountedDispatchInfos with reducer fns and table metadata', () => { + const sessions = table( + { name: 'sessions' }, + { id: t.u64().primaryKey().autoInc() } + ); + + const authSchema = schema({ sessions }); + const cleanExpiredSessions = authSchema.reducer(() => {}); + const authLib = { default: authSchema, cleanExpiredSessions }; + + const players = table({ name: 'players' }, { id: t.u32().primaryKey() }); + const consumer = schema({ players, myauth: authLib }); + + const infos = consumer.mountedDispatchInfos; + expect(infos).toHaveLength(1); + + const info = infos[0]; + expect(info.reducerFns).toHaveLength(1); + expect(info.reducerDefs).toHaveLength(1); + expect(info.reducerDefs[0].sourceName).toBe('cleanExpiredSessions'); + expect(info.tables).toHaveLength(1); + expect(info.tables[0].accessorName).toBe('sessions'); + expect(info.subDispatches).toHaveLength(0); + }); + + it('flattens nested mount dispatches depth-first', () => { + // baz library: 1 reducer + const bazTable = table({ name: 'baz_items' }, { id: t.u32().primaryKey() }); + const bazSchema = schema({ bazTable }); + const bazReducer = bazSchema.reducer(() => {}); + const bazLib = { default: bazSchema, bazReducer }; + + // auth library: 1 own reducer, mounts baz + const sessions = table( + { name: 'sessions' }, + { id: t.u64().primaryKey().autoInc() } + ); + const authSchema = schema({ sessions, baz: bazLib }); + const authReducer = authSchema.reducer(() => {}); + const authLib = { default: authSchema, authReducer }; + + // consumer: 1 own reducer, mounts auth + const players = table({ name: 'players' }, { id: t.u32().primaryKey() }); + const consumer = schema({ players, myauth: authLib }); + const consumerReducer = consumer.reducer(() => {}); + + // Verify depth-first structure: + // consumer.mountedDispatchInfos[0] = myauth (authReducer) + // consumer.mountedDispatchInfos[0].subDispatches[0] = myauth.baz (bazReducer) + const infos = consumer.mountedDispatchInfos; + expect(infos).toHaveLength(1); + + const authInfo = infos[0]; + expect(authInfo.reducerFns).toHaveLength(1); + expect(authInfo.reducerDefs[0].sourceName).toBe('authReducer'); + expect(authInfo.subDispatches).toHaveLength(1); + + const bazInfo = authInfo.subDispatches[0]; + expect(bazInfo.reducerFns).toHaveLength(1); + expect(bazInfo.reducerDefs[0].sourceName).toBe('bazReducer'); + expect(bazInfo.subDispatches).toHaveLength(0); + + // Unused variable check + void consumerReducer; + }); + + it('mountedDispatchInfos carry namespace and nested namespace dispatches propagate', () => { + const sessions = table( + { name: 'sessions' }, + { id: t.u64().primaryKey().autoInc() } + ); + const authSchema = schema({ sessions }); + const authLib = { default: authSchema }; + + const players = table({ name: 'players' }, { id: t.u32().primaryKey() }); + const consumer = schema({ players, myauth: authLib }); + + const infos = consumer.mountedDispatchInfos; + expect(infos).toHaveLength(1); + expect(infos[0].namespace).toBe('myauth'); + expect(infos[0].tables[0].accessorName).toBe('sessions'); + }); + + it('nested mounts carry their own namespace on subDispatches', () => { + const bazTable = table({ name: 'baz_items' }, { id: t.u32().primaryKey() }); + const bazSchema = schema({ bazTable }); + const bazLib = { default: bazSchema }; + + const sessions = table( + { name: 'sessions' }, + { id: t.u64().primaryKey().autoInc() } + ); + const authSchema = schema({ sessions, baz: bazLib }); + const authLib = { default: authSchema }; + + const players = table({ name: 'players' }, { id: t.u32().primaryKey() }); + const consumer = schema({ players, myauth: authLib }); + + const authInfo = consumer.mountedDispatchInfos[0]; + expect(authInfo.namespace).toBe('myauth'); + expect(authInfo.subDispatches).toHaveLength(1); + expect(authInfo.subDispatches[0].namespace).toBe('baz'); + expect(authInfo.subDispatches[0].tables[0].accessorName).toBe('bazTable'); + }); +});