diff --git a/crates/bindings-typescript/src/lib/autogen/types.ts b/crates/bindings-typescript/src/lib/autogen/types.ts index 0ce535eca0f..4804b1d01f5 100644 --- a/crates/bindings-typescript/src/lib/autogen/types.ts +++ b/crates/bindings-typescript/src/lib/autogen/types.ts @@ -316,15 +316,50 @@ export const RawModuleDef = __t.enum('RawModuleDef', { }); export type RawModuleDef = __Infer; -export const RawModuleDefV10 = __t.object('RawModuleDefV10', { - get sections() { - return __t.array(RawModuleDefV10Section); - }, -}); -export type RawModuleDefV10 = __Infer; +export type RawModuleDefV10 = { + sections: RawModuleDefV10Section[]; +}; + +export const RawModuleDefV10: any = __t.lazy(() => + __t.object('RawModuleDefV10', { + get sections(): any { + return __t.array(RawModuleDefV10Section); + }, + }) +); + +export type RawModuleDefV10Mount = { + namespace: string; + module: RawModuleDefV10; +}; + +export const RawModuleDefV10Mount: any = __t.lazy(() => + __t.object('RawModuleDefV10Mount', { + get namespace(): any { + return __t.string(); + }, + get module(): any { + return RawModuleDefV10; + }, + }) +); + +export type RawModuleDefV10Section = + | { tag: 'Typespace'; value: Typespace } + | { tag: 'Types'; value: RawTypeDefV10[] } + | { tag: 'Tables'; value: RawTableDefV10[] } + | { tag: 'Reducers'; value: RawReducerDefV10[] } + | { tag: 'Procedures'; value: RawProcedureDefV10[] } + | { tag: 'Views'; value: RawViewDefV10[] } + | { tag: 'Schedules'; value: RawScheduleDefV10[] } + | { tag: 'LifeCycleReducers'; value: RawLifeCycleReducerDefV10[] } + | { tag: 'RowLevelSecurity'; value: RawRowLevelSecurityDefV9[] } + | { tag: 'CaseConversionPolicy'; value: CaseConversionPolicy } + | { tag: 'ExplicitNames'; value: ExplicitNames } + | { tag: 'Mounts'; value: RawModuleDefV10Mount[] }; // The tagged union or sum type for the algebraic type `RawModuleDefV10Section`. -export const RawModuleDefV10Section = __t.enum('RawModuleDefV10Section', { +export const RawModuleDefV10Section: any = __t.enum('RawModuleDefV10Section', { get Typespace() { return Typespace; }, @@ -358,8 +393,10 @@ export const RawModuleDefV10Section = __t.enum('RawModuleDefV10Section', { get ExplicitNames() { return ExplicitNames; }, + get Mounts(): any { + return __t.array(RawModuleDefV10Mount); + }, }); -export type RawModuleDefV10Section = __Infer; export const RawModuleDefV8 = __t.object('RawModuleDefV8', { get typespace() { diff --git a/crates/bindings-typescript/src/lib/schema.ts b/crates/bindings-typescript/src/lib/schema.ts index be9edc9e113..075ea99d132 100644 --- a/crates/bindings-typescript/src/lib/schema.ts +++ b/crates/bindings-typescript/src/lib/schema.ts @@ -7,10 +7,20 @@ import { } from './algebraic_type'; import type { CaseConversionPolicy, + ExplicitNames, + RawLifeCycleReducerDefV10, + RawModuleDefV10Mount, RawModuleDefV10, RawModuleDefV10Section, + RawProcedureDefV10, + RawReducerDefV10, + RawRowLevelSecurityDefV9, + RawScheduleDefV10, RawScopedTypeNameV10, RawTableDefV10, + RawTypeDefV10, + RawViewDefV10, + Typespace, } from './autogen/types'; import type { UntypedIndex } from './indexes'; import type { UntypedTableDef } from './table'; @@ -174,7 +184,18 @@ type CompoundTypeCache = Map< >; export type ModuleDef = { - [S in RawModuleDefV10Section as Uncapitalize]: S['value']; + typespace: Typespace; + types: RawTypeDefV10[]; + tables: RawTableDefV10[]; + reducers: RawReducerDefV10[]; + procedures: RawProcedureDefV10[]; + views: RawViewDefV10[]; + schedules: RawScheduleDefV10[]; + lifeCycleReducers: RawLifeCycleReducerDefV10[]; + rowLevelSecurity: RawRowLevelSecurityDefV9[]; + caseConversionPolicy: CaseConversionPolicy; + explicitNames: ExplicitNames; + mounts: RawModuleDefV10Mount[]; }; type Section = RawModuleDefV10Section; @@ -199,6 +220,7 @@ export class ModuleContext { explicitNames: { entries: [], }, + mounts: [], }; get moduleDef(): ModuleDef { @@ -245,9 +267,19 @@ export class ModuleContext { value: module.caseConversionPolicy, } ); + push( + module.mounts && { + tag: 'Mounts', + value: module.mounts, + } + ); return { sections }; } + addMount(mount: RawModuleDefV10Mount) { + this.#moduleDef.mounts.push(mount); + } + /** * Set the case conversion policy for this module. * Called by the settings mechanism. diff --git a/crates/bindings-typescript/src/server/schema.ts b/crates/bindings-typescript/src/server/schema.ts index b9eb258762b..c9a71a69297 100644 --- a/crates/bindings-typescript/src/server/schema.ts +++ b/crates/bindings-typescript/src/server/schema.ts @@ -1,5 +1,6 @@ import { moduleHooks, type ModuleDefaultExport } from 'spacetime:sys@2.0'; import { CaseConversionPolicy, Lifecycle } from '../lib/autogen/types'; +import type { RawModuleDefV10 } from '../lib/autogen/types'; import { type ParamsAsObject, type ParamsObj, @@ -14,6 +15,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 { makeProcedureExport, type ProcedureExport, @@ -46,6 +48,8 @@ export class SchemaInner< S extends UntypedSchemaDef = UntypedSchemaDef, > extends ModuleContext { schemaType: S; + exportsRegistered = false; + schedulesResolved = false; existingFunctions = new Set(); reducers: Reducers = []; procedures: Procedures = []; @@ -77,6 +81,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) { @@ -138,22 +146,8 @@ 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(); - return makeHooks(registeredSchema); + this.buildRawModuleDefV10(exports); + return makeHooks(this.#ctx); } get schemaType(): S { @@ -168,6 +162,18 @@ export class Schema implements ModuleDefaultExport { return this.#ctx.typespace; } + /** 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(); + } + /** * Defines a SpacetimeDB reducer function. * @@ -543,18 +549,89 @@ 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 + >; +}; + +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)) { + ctx.addMount({ + namespace: accName, + module: entry.default.buildRawModuleDefV10(entry, { + ignoreNonModuleExports: true, + }), + }); + 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); @@ -574,7 +651,7 @@ export function schema>( }); } } - return { tables: tableSchemas } as TablesToSchema; + return { tables: tableSchemas } as TablesToSchema>; }); return new Schema(ctx); 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..4fe3acb41a3 --- /dev/null +++ b/crates/bindings-typescript/tests/schema_mounts.test.ts @@ -0,0 +1,117 @@ +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/); + }); +});