@@ -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(). */
554565function 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
603618type 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+ }
0 commit comments