Skip to content

Commit 996a4e9

Browse files
committed
Add submodule 'merge' api in typescript
1 parent 7cdbe66 commit 996a4e9

3 files changed

Lines changed: 156 additions & 7 deletions

File tree

crates/bindings-typescript/src/server/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
export * from '../lib/type_builders';
22
export {
33
schema,
4+
merge,
45
type InferSchema,
56
type ModuleExport,
67
type ModuleSettings,

crates/bindings-typescript/src/server/schema.ts

Lines changed: 72 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,8 @@ export class SchemaInner<
7979
> = new Map();
8080
pendingSchedules: PendingSchedule[] = [];
8181
mountedDispatchInfos: MountedDispatchInfo[] = [];
82+
pendingMergedExports: Array<[string, ModuleExport]> = [];
83+
mergedSchemas: Set<SchemaInner> = new Set();
8284

8385
constructor(getSchemaType: (ctx: SchemaInner<S>) => S) {
8486
super();
@@ -185,6 +187,14 @@ export class Schema<S extends UntypedSchemaDef> implements ModuleDefaultExport {
185187
exports: object,
186188
opts?: { ignoreNonModuleExports?: boolean }
187189
): RawModuleDefV10 {
190+
// Register merged exports first so they are in ctx.reducers before
191+
// registerModuleExports runs. Skip any already registered (e.g. if the
192+
// consumer re-exports the same reducer by name).
193+
for (const [name, exp] of this.#ctx.pendingMergedExports) {
194+
if (!this.#ctx.functionExports.has(exp as ReducerExport<any, any>)) {
195+
exp[registerExport](this.#ctx, name);
196+
}
197+
}
188198
registerModuleExports(this.#ctx, exports, {
189199
ignoreNonModuleExports: opts?.ignoreNonModuleExports ?? false,
190200
});
@@ -550,9 +560,14 @@ function isModuleExport(x: unknown): x is ModuleExport {
550560
);
551561
}
552562

553-
/** Verify that the ModuleContext that `exp` comes from is the same as `schema` */
563+
/** Verify that the ModuleContext that `exp` comes from is the same as `schema`,
564+
* or is a library that was merged into `schema` via merge(). */
554565
function checkExportContext(exp: ModuleExport, schema: SchemaInner) {
555-
if (exp[exportContext] != null && exp[exportContext] !== schema) {
566+
if (
567+
exp[exportContext] != null &&
568+
exp[exportContext] !== schema &&
569+
!schema.mergedSchemas.has(exp[exportContext])
570+
) {
556571
throw new TypeError('multiple schemas are not supported');
557572
}
558573
}
@@ -598,7 +613,7 @@ type MountedModuleNamespace = {
598613
[key: string]: unknown;
599614
};
600615

601-
type SchemaEntry = UntypedTableSchema | MountedModuleNamespace;
616+
type SchemaEntry = UntypedTableSchema | MountedModuleNamespace | ModuleExport;
602617

603618
type ExtractTableEntries<H extends Record<string, SchemaEntry>> = {
604619
[K in keyof H as H[K] extends UntypedTableSchema ? K : never]: Extract<
@@ -639,6 +654,11 @@ function registerModuleExports(
639654
throw new TypeError('exporting something that is not a spacetime export');
640655
}
641656
checkExportContext(moduleExport, schema);
657+
// Skip exports already registered via pendingMergedExports to prevent
658+
// double-registration when the consumer re-exports a merged reducer by name.
659+
if (schema.functionExports.has(moduleExport as ReducerExport<any, any>)) {
660+
continue;
661+
}
642662
moduleExport[registerExport](schema, name);
643663
}
644664
}
@@ -666,15 +686,31 @@ export function schema<const H extends Record<string, SchemaEntry>>(
666686
ctx.mountedDispatchInfos.push(dispatch);
667687
continue;
668688
}
689+
if (isModuleExport(entry)) {
690+
// Entry came from a merge() spread — defer registration to buildRawModuleDefV10
691+
// and track the source schema so checkExportContext allows re-exports.
692+
ctx.pendingMergedExports.push([accName, entry]);
693+
if (entry[exportContext] != null) {
694+
ctx.mergedSchemas.add(entry[exportContext]);
695+
}
696+
continue;
697+
}
669698
if (!isUntypedTableSchema(entry)) {
670699
throw new TypeError(
671-
`schema entry '${accName}' must be a table or a mounted module namespace object`
700+
`schema entry '${accName}' must be a table, a mounted module namespace object, or a merge() result`
672701
);
673702
}
674703

675704
const table = entry;
676-
const tableDef = table.tableDef(ctx, accName);
677-
tableSchemas[accName] = tableToSchema(accName, table, tableDef);
705+
// UntypedTableSchema.tableDef is a factory fn; UntypedTableDef.tableDef is a
706+
// pre-materialized RawTableDefV10 (produced by merge()). Handle both.
707+
const isMaterialized = typeof (table as any).tableDef !== 'function';
708+
const tableDef: RawTableDefV10 = isMaterialized
709+
? (table as unknown as UntypedTableDef).tableDef
710+
: table.tableDef(ctx, accName);
711+
tableSchemas[accName] = isMaterialized
712+
? (table as unknown as UntypedTableDef)
713+
: tableToSchema(accName, table, tableDef);
678714
ctx.moduleDef.tables.push(tableDef);
679715
if (table.schedule) {
680716
ctx.pendingSchedules.push({
@@ -697,3 +733,33 @@ export function schema<const H extends Record<string, SchemaEntry>>(
697733

698734
return new Schema(ctx);
699735
}
736+
737+
/**
738+
* Flattens a library's tables and named exports into a plain object suitable
739+
* for spreading into `schema({...})`. The library's tables and reducers land
740+
* directly in the consumer's public namespace with no namespace prefix.
741+
*
742+
* Multiple `merge()` calls compose without `default`-key collisions:
743+
* ```ts
744+
* const spacetimedb = schema({
745+
* players,
746+
* ...merge(authLib),
747+
* ...merge(utilLib),
748+
* });
749+
* ```
750+
*
751+
* Libraries with scheduled reducers or user-defined compound column types
752+
* should use the namespaced mount form instead: `schema({ alias: lib })`.
753+
*/
754+
export function merge(
755+
lib: MountedModuleNamespace
756+
): Record<string, UntypedTableDef | ModuleExport> {
757+
const { default: libSchema, ...namedExports } = lib as Record<string, unknown> & {
758+
default: Schema<any>;
759+
};
760+
const tables: Record<string, UntypedTableDef> = {};
761+
for (const td of Object.values(libSchema.schemaType.tables)) {
762+
tables[td.accessorName] = td;
763+
}
764+
return { ...tables, ...(namedExports as Record<string, ModuleExport>) };
765+
}

crates/bindings-typescript/tests/schema_mounts.test.ts

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,12 @@ vi.mock('../src/server/runtime', () => ({
2323

2424
describe('schema mounts', () => {
2525
let schema: typeof import('../src/server/schema').schema;
26+
let merge: typeof import('../src/server/schema').merge;
2627
let table: typeof import('../src/lib/table').table;
2728
let t: typeof import('../src/lib/type_builders').t;
2829

2930
beforeAll(async () => {
30-
({ schema } = await import('../src/server/schema'));
31+
({ schema, merge } = await import('../src/server/schema'));
3132
({ table } = await import('../src/lib/table'));
3233
({ t } = await import('../src/lib/type_builders'));
3334
});
@@ -180,4 +181,85 @@ describe('schema mounts', () => {
180181
// Unused variable check
181182
void consumerReducer;
182183
});
184+
185+
it('merge() flattens library tables and reducers into the consumer namespace', () => {
186+
const sessions = table(
187+
{ name: 'sessions' },
188+
{ id: t.u64().primaryKey().autoInc() }
189+
);
190+
const authSchema = schema({ sessions });
191+
const cleanExpiredSessions = authSchema.reducer(() => {});
192+
const authLib = { default: authSchema, cleanExpiredSessions };
193+
194+
const players = table({ name: 'players' }, { id: t.u32().primaryKey() });
195+
const consumer = schema({ players, ...merge(authLib) });
196+
197+
const raw = consumer.buildRawModuleDefV10({});
198+
199+
// No namespaced mounts — library content lives in the root module
200+
const mounts = raw.sections.find(section => section.tag === 'Mounts');
201+
expect(mounts?.value ?? []).toHaveLength(0);
202+
203+
// Both tables are in the root Tables section
204+
const tables = raw.sections.find(section => section.tag === 'Tables')?.value ?? [];
205+
const tableNames = tables.map((t: any) => t.sourceName);
206+
expect(tableNames).toContain('players');
207+
expect(tableNames).toContain('sessions');
208+
209+
// The merged reducer is in the root Reducers section
210+
const reducers = raw.sections.find(section => section.tag === 'Reducers')?.value ?? [];
211+
expect(reducers).toEqual(
212+
expect.arrayContaining([
213+
expect.objectContaining({ sourceName: 'cleanExpiredSessions' }),
214+
])
215+
);
216+
});
217+
218+
it('merge() allows re-exporting a merged reducer without checkExportContext error', () => {
219+
const sessions = table(
220+
{ name: 'sessions' },
221+
{ id: t.u64().primaryKey().autoInc() }
222+
);
223+
const authSchema = schema({ sessions });
224+
const cleanExpiredSessions = authSchema.reducer(() => {});
225+
const authLib = { default: authSchema, cleanExpiredSessions };
226+
227+
const players = table({ name: 'players' }, { id: t.u32().primaryKey() });
228+
const consumer = schema({ players, ...merge(authLib) });
229+
230+
// Simulate consumer re-exporting the merged reducer by name — should not throw
231+
expect(() =>
232+
consumer.buildRawModuleDefV10({ cleanExpiredSessions })
233+
).not.toThrow();
234+
});
235+
236+
it('merge() from two libraries composes without default-key collision', () => {
237+
const sessionsTable = table(
238+
{ name: 'sessions' },
239+
{ id: t.u64().primaryKey().autoInc() }
240+
);
241+
const authSchema = schema({ sessionsTable });
242+
const authReducer = authSchema.reducer(() => {});
243+
const authLib = { default: authSchema, authReducer };
244+
245+
const itemsTable = table({ name: 'items' }, { id: t.u32().primaryKey() });
246+
const utilSchema = schema({ itemsTable });
247+
const utilReducer = utilSchema.reducer(() => {});
248+
const utilLib = { default: utilSchema, utilReducer };
249+
250+
const players = table({ name: 'players' }, { id: t.u32().primaryKey() });
251+
252+
// Both merges should compose cleanly
253+
expect(() =>
254+
schema({ players, ...merge(authLib), ...merge(utilLib) })
255+
).not.toThrow();
256+
257+
const consumer = schema({ players, ...merge(authLib), ...merge(utilLib) });
258+
const raw = consumer.buildRawModuleDefV10({});
259+
260+
const reducers = raw.sections.find(section => section.tag === 'Reducers')?.value ?? [];
261+
const reducerNames = reducers.map((r: any) => r.sourceName);
262+
expect(reducerNames).toContain('authReducer');
263+
expect(reducerNames).toContain('utilReducer');
264+
});
183265
});

0 commit comments

Comments
 (0)