diff --git a/crates/bindings-typescript/src/lib/reducers.ts b/crates/bindings-typescript/src/lib/reducers.ts index 0eae2adc2a9..6e7f99df1bd 100644 --- a/crates/bindings-typescript/src/lib/reducers.ts +++ b/crates/bindings-typescript/src/lib/reducers.ts @@ -2,7 +2,7 @@ import type { DbView } from '../server/db_view'; import type { Random } from '../server/rng'; import type { ConnectionId } from './connection_id'; import type { Identity } from './identity'; -import { type UntypedSchemaDef } from './schema'; +import { type MountsOf, type UntypedSchemaDef } from './schema'; import { type Timestamp } from './timestamp'; import { ColumnBuilder, @@ -80,6 +80,12 @@ export interface JsonObject { [key: string]: JsonValue; } +export type ReducerCtxMounts = { + readonly [Alias in keyof MountsOf & string]: ReducerCtx< + MountsOf[Alias] + >; +}; + /** * Auth Claims extracted from the payload of a JWT token */ @@ -109,6 +115,7 @@ export type ReducerCtx = Readonly<{ timestamp: Timestamp; connectionId: ConnectionId | null; db: DbView; + as: ReducerCtxMounts; senderAuth: AuthCtx; newUuidV4(): Uuid; newUuidV7(): Uuid; diff --git a/crates/bindings-typescript/src/lib/schema.ts b/crates/bindings-typescript/src/lib/schema.ts index be9edc9e113..44986c436ee 100644 --- a/crates/bindings-typescript/src/lib/schema.ts +++ b/crates/bindings-typescript/src/lib/schema.ts @@ -37,11 +37,17 @@ export type TableNamesOf = Values< S['tables'] >['accessorName']; +export type MountsOf = Extract< + NonNullable, + Record +>; + /** * An untyped representation of the database schema. */ export type UntypedSchemaDef = { tables: Record; + mounts?: Record; }; /** @@ -52,6 +58,7 @@ export interface TablesToSchema> tables: { readonly [AccName in keyof T & string]: TableToSchema; }; + mounts?: {}; } export interface TableToSchema< @@ -90,6 +97,7 @@ export function tablesToSchema< return { tables: tableDefs as TablesToSchema['tables'], + mounts: {}, }; } @@ -149,7 +157,7 @@ export function tableToSchema< // For client,`schama.tableName` will always be there as canonical name. // For module, if explicit name is not provided via `name`, accessor name will // be used, it is stored as alias in database, hence works in query builder. - sourceName: schema.tableName || accName, + sourceName: tableDef.sourceName, accessorName: accName, columns: schema.rowType.row, // typed as T[i]['rowType']['row'] under TablesToSchema rowType: schema.rowSpacetimeType, diff --git a/crates/bindings-typescript/src/server/db_view.ts b/crates/bindings-typescript/src/server/db_view.ts index 9e0350ff863..3096bb97951 100644 --- a/crates/bindings-typescript/src/server/db_view.ts +++ b/crates/bindings-typescript/src/server/db_view.ts @@ -1,4 +1,4 @@ -import type { UntypedSchemaDef } from '../lib/schema'; +import type { MountsOf, UntypedSchemaDef } from '../lib/schema'; import type { ReadonlyTable, Table } from '../lib/table'; import type { Values } from '../lib/type_util'; @@ -9,6 +9,10 @@ export type ReadonlyDbView = { readonly [Tbl in Values< SchemaDef['tables'] > as Tbl['accessorName']]: ReadonlyTable; +} & { + readonly [Alias in keyof MountsOf & string]: ReadonlyDbView< + MountsOf[Alias] + >; }; /** @@ -18,4 +22,8 @@ export type DbView = { readonly [Tbl in Values< SchemaDef['tables'] > as Tbl['accessorName']]: Table; +} & { + readonly [Alias in keyof MountsOf & string]: DbView< + MountsOf[Alias] + >; }; diff --git a/crates/bindings-typescript/src/server/procedures.ts b/crates/bindings-typescript/src/server/procedures.ts index 39e5f58542f..d99e128652c 100644 --- a/crates/bindings-typescript/src/server/procedures.ts +++ b/crates/bindings-typescript/src/server/procedures.ts @@ -24,7 +24,9 @@ import type { DbView } from './db_view'; import { makeRandom, type Random } from './rng'; import { callUserFunction, ReducerCtxImpl, sys } from './runtime'; import { + assertNotReservedMountedName, exportContext, + moduleExportKind, registerExport, type ModuleExport, type SchemaInner, @@ -52,6 +54,7 @@ export function makeProcedureExport< const procedureExport: ProcedureExport = (...args) => fn(...args); procedureExport[exportContext] = ctx; + procedureExport[moduleExportKind] = 'procedure'; procedureExport[registerExport] = (ctx, exportName) => { registerProcedure(ctx, name ?? exportName, params, ret, fn); ctx.functionExports.set( @@ -109,6 +112,10 @@ function registerProcedure< fn: ProcedureFn, opts?: ProcedureOpts ) { + assertNotReservedMountedName(exportName, 'Procedure export name'); + if (opts?.name != null) { + assertNotReservedMountedName(opts.name, 'Procedure name'); + } ctx.defineFunction(exportName); const paramsType: ProductType = { elements: Object.entries(params).map(([n, c]) => ({ @@ -160,7 +167,8 @@ export function callProcedure( connectionId: ConnectionId | null, timestamp: Timestamp, argsBuf: Uint8Array, - dbView: () => DbView + dbView: () => DbView, + asView: () => ReducerCtx['as'] ): Uint8Array { const { fn, deserializeArgs, serializeReturn, returnTypeBaseSize } = moduleCtx.procedures[id]; @@ -170,7 +178,8 @@ export function callProcedure( sender, timestamp, connectionId, - dbView + dbView, + asView ); const ret = callUserFunction(fn, ctx, args); @@ -187,14 +196,17 @@ const ProcedureCtxImpl = class ProcedureCtx #uuidCounter: { value: 0 } | undefined; #random: Random | undefined; #dbView: () => DbView; + #asView: () => ReducerCtx['as']; constructor( readonly sender: Identity, readonly timestamp: Timestamp, readonly connectionId: ConnectionId | null, - dbView: () => DbView + dbView: () => DbView, + asView: () => ReducerCtx['as'] ) { this.#dbView = dbView; + this.#asView = asView; } get databaseIdentity() { @@ -222,8 +234,9 @@ const ProcedureCtxImpl = class ProcedureCtx this.sender, new Timestamp(timestamp), this.connectionId, - this.#dbView() - ); + this.#dbView(), + this.#asView() + ) as unknown as TransactionCtx; return body(ctx); } catch (e) { sys.procedure_abort_mut_tx(); diff --git a/crates/bindings-typescript/src/server/reducers.ts b/crates/bindings-typescript/src/server/reducers.ts index f8aa1c390bf..470dfad81dc 100644 --- a/crates/bindings-typescript/src/server/reducers.ts +++ b/crates/bindings-typescript/src/server/reducers.ts @@ -5,7 +5,9 @@ import { type UntypedSchemaDef } from '../lib/schema'; import { RowBuilder, type RowObj } from '../lib/type_builders'; import { toPascalCase } from '../lib/util'; import { + assertNotReservedMountedName, exportContext, + moduleExportKind, registerExport, type ModuleExport, type SchemaInner, @@ -21,6 +23,15 @@ export interface ReducerOpts { name: string; } +export const reducerExportInfo = Symbol('SpacetimeDB.reducerExportInfo'); + +export type ReducerExportInfo = { + params: RowObj | RowBuilder; + fn: Reducer; + opts?: ReducerOpts; + lifecycle?: Lifecycle; +}; + export function makeReducerExport< S extends UntypedSchemaDef, Params extends ParamsObj, @@ -31,8 +42,20 @@ export function makeReducerExport< fn: Reducer, lifecycle?: Lifecycle ): ReducerExport { - const reducerExport: ReducerExport = (...args) => fn(...args); + const reducerExport: ReducerExport = ((...args) => + (fn as any)(...args)) as ReducerExport; reducerExport[exportContext] = ctx; + reducerExport[moduleExportKind] = 'reducer'; + ( + reducerExport as ReducerExport & { + [reducerExportInfo]: ReducerExportInfo; + } + )[reducerExportInfo] = { + params, + fn, + opts, + lifecycle, + }; reducerExport[registerExport] = (ctx, exportName) => { registerReducer(ctx, exportName, params, fn, opts, lifecycle); ctx.functionExports.set( @@ -58,8 +81,15 @@ export function registerReducer( params: RowObj | RowBuilder, fn: Reducer, opts?: ReducerOpts, - lifecycle?: Lifecycle + lifecycle?: Lifecycle, + allowReservedMountedName: boolean = false ): void { + if (!allowReservedMountedName) { + assertNotReservedMountedName(exportName, 'Reducer export name'); + } + if (!allowReservedMountedName && opts?.name != null) { + assertNotReservedMountedName(opts.name, 'Reducer name'); + } ctx.defineFunction(exportName); if (!(params instanceof RowBuilder)) { diff --git a/crates/bindings-typescript/src/server/runtime.ts b/crates/bindings-typescript/src/server/runtime.ts index 5031b1d850c..c74ba20aab6 100644 --- a/crates/bindings-typescript/src/server/runtime.ts +++ b/crates/bindings-typescript/src/server/runtime.ts @@ -193,18 +193,21 @@ export const ReducerCtxImpl = class ReducerCtx< timestamp: Timestamp; connectionId: ConnectionId | null; db: DbView; + as: IReducerCtx['as']; constructor( sender: Identity, timestamp: Timestamp, connectionId: ConnectionId | null, - dbView: DbView + dbView: DbView, + asView: IReducerCtx['as'] ) { Object.seal(this); this.sender = sender; this.timestamp = timestamp; this.connectionId = connectionId; - this.db = dbView; + this.db = dbView as DbView; + this.as = asView; } /** Reset the `ReducerCtx` to be used for a new transaction */ @@ -221,6 +224,20 @@ export const ReducerCtxImpl = class ReducerCtx< me.#senderAuth = undefined; } + static resetTree( + me: InstanceType, + sender: Identity, + timestamp: Timestamp, + connectionId: ConnectionId | null + ) { + this.reset(me, sender, timestamp, connectionId); + for (const childCtx of Object.values(me.as) as InstanceType< + typeof this + >[]) { + this.resetTree(childCtx, sender, timestamp, connectionId); + } + } + get databaseIdentity() { return (this.#identity ??= new Identity(sys.identity())); } @@ -290,22 +307,16 @@ class ModuleHooksImpl implements ModuleHooks { } get #dbView() { - return (this.#dbView_ ??= freeze( - Object.fromEntries( - Object.values(this.#schema.schemaType.tables).map(table => [ - table.accessorName, - makeTableView(this.#schema.typespace, table.tableDef), - ]) - ) + return (this.#dbView_ ??= makeDbView( + this.#schema.typespace, + this.#schema.schemaType )); } get #reducerCtx() { - return (this.#reducerCtx_ ??= new ReducerCtxImpl( - Identity.zero(), - Timestamp.UNIX_EPOCH, - null, - this.#dbView + return (this.#reducerCtx_ ??= makeReducerCtx( + this.#dbView, + this.#schema.schemaType )); } @@ -339,13 +350,13 @@ class ModuleHooksImpl implements ModuleHooks { const args = deserializeArgs(BINARY_READER); const senderIdentity = new Identity(sender); const ctx = this.#reducerCtx; - ReducerCtxImpl.reset( + ReducerCtxImpl.resetTree( ctx, senderIdentity, new Timestamp(timestamp), ConnectionId.nullIfZero(new ConnectionId(connId)) ); - callUserFunction(moduleCtx.reducers[reducerId], ctx, args); + callUserFunction(moduleCtx.reducers[reducerId] as any, ctx as any, args); } __call_view__( @@ -415,7 +426,8 @@ class ModuleHooksImpl implements ModuleHooks { ConnectionId.nullIfZero(new ConnectionId(connection_id)), new Timestamp(timestamp), args, - () => this.#dbView + () => this.#dbView, + () => this.#reducerCtx.as ); } } @@ -423,6 +435,50 @@ class ModuleHooksImpl implements ModuleHooks { const BINARY_WRITER = new BinaryWriter(0); const BINARY_READER = new BinaryReader(new Uint8Array()); +function makeDbView( + typespace: Typespace, + schemaDef: UntypedSchemaDef +): DbView { + return freeze( + Object.assign( + Object.create(null), + Object.fromEntries( + Object.values(schemaDef.tables).map(table => [ + table.accessorName, + makeTableView(typespace, table.tableDef), + ]) + ), + Object.fromEntries( + Object.entries(schemaDef.mounts ?? {}).map(([alias, mountedSchema]) => [ + alias, + makeDbView(typespace, mountedSchema), + ]) + ) + ) + ) as DbView; +} + +function makeReducerCtx( + dbView: DbView, + schemaDef: UntypedSchemaDef +): InstanceType { + const asView = freeze( + Object.fromEntries( + Object.keys(schemaDef.mounts ?? {}).map(alias => [ + alias, + makeReducerCtx(dbView[alias], (schemaDef.mounts ?? {})[alias]), + ]) + ) + ); + return new ReducerCtxImpl( + Identity.zero(), + Timestamp.UNIX_EPOCH, + null, + dbView, + asView + ); +} + function makeTableView( typespace: Typespace, table: RawTableDefV10 diff --git a/crates/bindings-typescript/src/server/schema.test-d.ts b/crates/bindings-typescript/src/server/schema.test-d.ts index 26f9018f240..5716f59a12d 100644 --- a/crates/bindings-typescript/src/server/schema.test-d.ts +++ b/crates/bindings-typescript/src/server/schema.test-d.ts @@ -97,3 +97,39 @@ spacetimedbIndexSplit.init(ctx => { // @ts-expect-error `nickname` is not indexed, so no index accessor should exist. const _nickname = ctx.db.account.nickname; }); + +const authLib = schema({ + user: table({}, { id: t.u32() }), +}); + +function authHelper( + ctx: import('../lib/reducers').ReducerCtx +) { + ctx.db.user.count(); +} + +const authCreate = authLib.reducer(ctx => { + ctx.db.user.count(); +}); + +const appWithMount = schema({ + account, + auth: { + default: authLib, + authCreate, + authHelper, + }, +}); + +appWithMount.init(ctx => { + ctx.db.account.byEmailAndOrg.filter(['a@example.com', 1]); + ctx.db.auth.user.count(); + ctx.as.auth.db.user.count(); + authHelper(ctx.as.auth); + + // @ts-expect-error mounted aliases are only exposed through declared namespaces. + ctx.as.public; + + // @ts-expect-error mounted tables are not hoisted onto the root db view. + ctx.db.user; +}); diff --git a/crates/bindings-typescript/src/server/schema.ts b/crates/bindings-typescript/src/server/schema.ts index b9eb258762b..c418c06d80a 100644 --- a/crates/bindings-typescript/src/server/schema.ts +++ b/crates/bindings-typescript/src/server/schema.ts @@ -14,6 +14,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, @@ -23,7 +24,10 @@ import { } from './procedures'; import { makeReducerExport, + reducerExportInfo, + registerReducer, type ReducerExport, + type ReducerExportInfo, type ReducerOpts, type Reducers, } from './reducers'; @@ -42,6 +46,46 @@ import { } from './views'; import type { UntypedTableDef } from '../lib/table'; +type TableEntriesOf> = { + [Name in keyof Entries & string as Entries[Name] extends UntypedTableSchema + ? Name + : never]: Extract; +}; + +type MountEntriesOf> = { + [Name in keyof Entries & + string as Entries[Name] extends MountedSchemaNamespace + ? Name + : never]: Entries[Name] extends MountedSchemaNamespace ? S : never; +}; + +type SchemaEntry = UntypedTableSchema | MountedSchemaNamespace; + +type SchemaEntriesToSchema> = { + tables: TablesToSchema>['tables']; + mounts: MountEntriesOf; +}; + +export interface MountedSchemaNamespace< + S extends UntypedSchemaDef = UntypedSchemaDef, +> { + readonly default: Schema; + readonly [name: string]: unknown; +} + +type MountedSchemaRegistration = { + alias: string; + namespace: MountedSchemaNamespace; + schema: SchemaInner; + scopedSchema: UntypedSchemaDef; +}; + +export const moduleExportKind = Symbol('SpacetimeDB.moduleExportKind'); +export const schemaContext = Symbol('SpacetimeDB.schemaContext'); +export const reservedMountedPrefix = '__ns__'; + +export type ModuleExportKind = 'reducer' | 'procedure' | 'view'; + export class SchemaInner< S extends UntypedSchemaDef = UntypedSchemaDef, > extends ModuleContext { @@ -61,6 +105,8 @@ export class SchemaInner< string > = new Map(); pendingSchedules: PendingSchedule[] = []; + mountedSchemas: Record = + Object.create(null); constructor(getSchemaType: (ctx: SchemaInner) => S) { super(); @@ -137,6 +183,10 @@ export class Schema implements ModuleDefaultExport { this.#ctx = ctx; } + get [schemaContext](): SchemaInner { + return this.#ctx; + } + [moduleHooks](exports: object) { // if (!(hasOwn(exports, 'default') && exports.default instanceof Schema)) { // throw new TypeError('must export schema as default export'); @@ -152,6 +202,11 @@ export class Schema implements ModuleDefaultExport { checkExportContext(moduleExport, registeredSchema); moduleExport[registerExport](registeredSchema, name); } + registerMountedReducers( + registeredSchema, + registeredSchema.mountedSchemas, + [] + ); registeredSchema.resolveSchedules(); return makeHooks(registeredSchema); } @@ -490,6 +545,15 @@ export const exportContext = Symbol('SpacetimeDB.exportContext'); export interface ModuleExport { [registerExport](ctx: SchemaInner, exportName: string): void; [exportContext]?: SchemaInner; + [moduleExportKind]?: ModuleExportKind; +} + +export function assertNotReservedMountedName(name: string, kind: string): void { + if (name.startsWith(reservedMountedPrefix)) { + throw new TypeError( + `${kind} '${name}' cannot start with '${reservedMountedPrefix}'; this prefix is reserved for internal mounted-library names` + ); + } } function isModuleExport(x: unknown): x is ModuleExport { @@ -543,39 +607,258 @@ export interface ModuleSettings { CASE_CONVERSION_POLICY?: CaseConversionPolicy; } -export function schema>( - tables: H, +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)) { - const tableDef = table.tableDef(ctx, accName); - tableSchemas[accName] = tableToSchema(accName, table, tableDef); + const tableSchemas: Record = Object.create(null); + const mounts: Record = Object.create(null); + for (const [accName, entry] of Object.entries(entries)) { + assertNotReservedMountedName(accName, 'Schema entry name'); + + if (entry instanceof Schema) { + throw new TypeError( + `Schema entry '${accName}' must use a module namespace import (for example: import * as lib from './lib') rather than a default schema import` + ); + } + + if (isMountedSchemaNamespace(entry)) { + const mounted = mountSchema(ctx, accName, entry); + mounts[accName] = mounted.scopedSchema; + continue; + } + + if (!isUntypedTableSchema(entry)) { + throw new TypeError( + `Schema entry '${accName}' must be a table() definition or a mounted library namespace` + ); + } + + if (entry.tableName) { + assertNotReservedMountedName(entry.tableName, 'Table name'); + } + const tableDef = entry.tableDef(ctx, accName); + tableSchemas[accName] = tableToSchema(accName, entry, tableDef); ctx.moduleDef.tables.push(tableDef); - if (table.schedule) { + if (entry.schedule) { ctx.pendingSchedules.push({ - ...table.schedule, + ...entry.schedule, tableName: tableDef.sourceName, }); } - if (table.tableName) { + if (entry.tableName) { ctx.moduleDef.explicitNames.entries.push({ tag: 'Table', value: { sourceName: accName, - canonicalName: table.tableName, + canonicalName: entry.tableName, }, }); } } - return { tables: tableSchemas } as TablesToSchema; + return { + tables: tableSchemas as SchemaEntriesToSchema['tables'], + mounts: mounts as SchemaEntriesToSchema['mounts'], + }; }); return new Schema(ctx); } + +function isUntypedTableSchema(x: unknown): x is UntypedTableSchema { + return ( + typeof x === 'object' && + x !== null && + hasOwn(x, 'tableDef') && + typeof x.tableDef === 'function' + ); +} + +function isMountedSchemaNamespace(x: unknown): x is MountedSchemaNamespace { + return ( + typeof x === 'object' && + x !== null && + hasOwn(x, 'default') && + x.default instanceof Schema + ); +} + +function internalMountName(path: readonly string[], name: string): string { + const normalizedName = name.startsWith(reservedMountedPrefix) + ? name.slice(reservedMountedPrefix.length) + : name; + return `${reservedMountedPrefix}${[...path, normalizedName].join('__')}`; +} + +function mountSchema( + hostCtx: SchemaInner, + alias: string, + namespace: MountedSchemaNamespace +): MountedSchemaRegistration { + const mountedDefault = namespace.default; + const mountedCtx = mountedDefault[schemaContext]; + const scopedSchema = prefixSchemaType([alias], mountedCtx.schemaType); + + for (const table of mountedCtx.moduleDef.tables) { + hostCtx.moduleDef.tables.push(prefixRawTableDef([alias], table)); + } + + for (const explicitName of mountedCtx.moduleDef.explicitNames.entries) { + if (explicitName.tag === 'Function') continue; + hostCtx.moduleDef.explicitNames.entries.push( + prefixExplicitNameEntry([alias], explicitName) + ); + } + + for (const pendingSchedule of mountedCtx.pendingSchedules) { + hostCtx.pendingSchedules.push({ + ...pendingSchedule, + tableName: internalMountName([alias], pendingSchedule.tableName), + }); + } + + const mounted = { + alias, + namespace, + schema: mountedCtx, + scopedSchema, + } satisfies MountedSchemaRegistration; + hostCtx.mountedSchemas[alias] = mounted; + return mounted; +} + +function prefixSchemaType( + path: readonly string[], + schemaDef: UntypedSchemaDef +): UntypedSchemaDef { + const tables = Object.fromEntries( + Object.entries(schemaDef.tables).map(([accName, table]) => { + const tableDef = prefixRawTableDef(path, table.tableDef); + return [ + accName, + { + ...table, + sourceName: tableDef.sourceName, + tableDef, + } satisfies UntypedTableDef, + ]; + }) + ); + + const mounts = Object.fromEntries( + Object.entries(schemaDef.mounts ?? {}).map(([alias, mounted]) => [ + alias, + prefixSchemaType([...path, alias], mounted), + ]) + ); + + return { + tables, + mounts, + }; +} + +function prefixRawTableDef(path: readonly string[], table: any) { + return { + ...table, + sourceName: internalMountName(path, table.sourceName), + indexes: table.indexes.map((index: any) => ({ + ...index, + sourceName: + index.sourceName == null + ? index.sourceName + : internalMountName(path, index.sourceName), + })), + constraints: table.constraints.map((constraint: any) => ({ + ...constraint, + sourceName: + constraint.sourceName == null + ? constraint.sourceName + : internalMountName(path, constraint.sourceName), + })), + sequences: table.sequences.map((sequence: any) => ({ + ...sequence, + sourceName: + sequence.sourceName == null + ? sequence.sourceName + : internalMountName(path, sequence.sourceName), + })), + }; +} + +function prefixExplicitNameEntry(path: readonly string[], explicitName: any) { + return { + ...explicitName, + value: { + sourceName: internalMountName(path, explicitName.value.sourceName), + canonicalName: internalMountName(path, explicitName.value.canonicalName), + }, + }; +} + +function registerMountedReducers( + hostCtx: SchemaInner, + mounts: Record, + parentPath: string[] +) { + for (const mounted of Object.values(mounts)) { + const path = [...parentPath, mounted.alias]; + registerMountedReducerExports(hostCtx, mounted, path); + registerMountedReducers(hostCtx, mounted.schema.mountedSchemas, path); + } +} + +function registerMountedReducerExports( + hostCtx: SchemaInner, + mounted: MountedSchemaRegistration, + path: string[] +) { + for (const [exportName, moduleExport] of Object.entries(mounted.namespace)) { + if (exportName === 'default' || !isModuleExport(moduleExport)) continue; + if (moduleExport[moduleExportKind] !== 'reducer') continue; + + const reducerInfo = ( + moduleExport as ReducerExport & { + [reducerExportInfo]: ReducerExportInfo; + } + )[reducerExportInfo]; + if (reducerInfo == null) continue; + + const internalExportName = internalMountName(path, exportName); + const mountedOpts = + reducerInfo.opts == null + ? undefined + : { + ...reducerInfo.opts, + name: internalMountName(path, reducerInfo.opts.name), + }; + + const wrapperFn: Reducer = (ctx, payload) => { + let mountedCtx: ReducerCtx = ctx; + for (const alias of path) { + mountedCtx = mountedCtx.as[alias]; + } + reducerInfo.fn(mountedCtx, payload); + }; + + registerReducer( + hostCtx, + internalExportName, + reducerInfo.params, + wrapperFn, + mountedOpts, + reducerInfo.lifecycle, + true + ); + hostCtx.functionExports.set( + moduleExport as ReducerExport, + internalExportName + ); + } +} diff --git a/crates/bindings-typescript/src/server/sys.d.ts b/crates/bindings-typescript/src/server/sys.d.ts index 71599c8f92a..f79d89b00a9 100644 --- a/crates/bindings-typescript/src/server/sys.d.ts +++ b/crates/bindings-typescript/src/server/sys.d.ts @@ -40,6 +40,7 @@ declare module 'spacetime:sys@2.0' { } export function register_hooks(hooks: ModuleHooks); + export function __resetMockSys(): void; export function table_id_from_name(name: string): u32; export function index_id_from_name(name: string): u32; diff --git a/crates/bindings-typescript/src/server/views.ts b/crates/bindings-typescript/src/server/views.ts index accd0c92563..4f68118e48a 100644 --- a/crates/bindings-typescript/src/server/views.ts +++ b/crates/bindings-typescript/src/server/views.ts @@ -20,7 +20,9 @@ import { bsatnBaseSize, toPascalCase } from '../lib/util'; import type { ReadonlyDbView } from './db_view'; import { type QueryBuilder, type RowTypedQuery } from './query'; import { + assertNotReservedMountedName, exportContext, + moduleExportKind, registerExport, type ModuleExport, type SchemaInner, @@ -44,6 +46,7 @@ export function makeViewExport< // @ts-expect-error typescript incorrectly says Function#bind requires an argument. fn.bind() as ViewExport; viewExport[exportContext] = ctx; + viewExport[moduleExportKind] = 'view'; viewExport[registerExport] = (ctx, exportName) => { registerView(ctx, opts, exportName, false, params, ret, fn); }; @@ -66,6 +69,7 @@ export function makeAnonViewExport< // @ts-expect-error typescript incorrectly says Function#bind requires an argument. fn.bind() as ViewExport; viewExport[exportContext] = ctx; + viewExport[moduleExportKind] = 'view'; viewExport[registerExport] = (ctx, exportName) => { registerView(ctx, opts, exportName, true, params, ret, fn); }; @@ -143,6 +147,10 @@ export function registerView< ? AnonymousViewFn : ViewFn ) { + assertNotReservedMountedName(exportName, 'View export name'); + if (opts.name != null) { + assertNotReservedMountedName(opts.name, 'View name'); + } const paramsBuilder = new RowBuilder(params, toPascalCase(exportName)); // Register return types if they are product types @@ -186,7 +194,7 @@ export function registerView< } (anon ? ctx.anonViews : ctx.views).push({ - fn, + fn: fn as ViewFn, deserializeParams: ProductType.makeDeserializer(paramType, typespace), serializeReturn: AlgebraicType.makeSerializer(returnType, typespace), returnTypeBaseSize: bsatnBaseSize(typespace, returnType), diff --git a/crates/bindings-typescript/tests/mocks/spacetime_sys_v2_0.ts b/crates/bindings-typescript/tests/mocks/spacetime_sys_v2_0.ts new file mode 100644 index 00000000000..f8a3f0fe30c --- /dev/null +++ b/crates/bindings-typescript/tests/mocks/spacetime_sys_v2_0.ts @@ -0,0 +1,76 @@ +export const moduleHooks = Symbol('spacetimedb.test.moduleHooks'); + +const tableIds = new Map(); +const indexIds = new Map(); + +function nextId(map: Map, name: string) { + const existing = map.get(name); + if (existing != null) return existing; + const next = map.size + 1; + map.set(name, next); + return next; +} + +export function __resetMockSys() { + tableIds.clear(); + indexIds.clear(); +} + +export function register_hooks(_hooks: unknown) {} +export function table_id_from_name(name: string) { + return nextId(tableIds, name); +} +export function index_id_from_name(name: string) { + return nextId(indexIds, name); +} +export function datastore_table_row_count(_tableId: number) { + return 0n; +} +export function datastore_table_scan_bsatn(_tableId: number) { + return 0; +} +export function datastore_index_scan_range_bsatn() { + return 0; +} +export function row_iter_bsatn_advance() { + return 0; +} +export function row_iter_bsatn_close() {} +export function datastore_insert_bsatn() { + return 0; +} +export function datastore_update_bsatn() { + return 0; +} +export function datastore_delete_by_index_scan_range_bsatn() { + return 0; +} +export function datastore_delete_all_by_eq_bsatn() { + return 0; +} +export function volatile_nonatomic_schedule_immediate() {} +export function console_log() {} +export function console_timer_start() { + return 0; +} +export function console_timer_end() {} +export function identity() { + return 0n; +} +export function get_jwt_payload() { + return new Uint8Array(); +} +export function procedure_http_request() { + return [new Uint8Array(), new Uint8Array()] as const; +} +export function procedure_start_mut_tx() { + return 0n; +} +export function procedure_commit_mut_tx() {} +export function procedure_abort_mut_tx() {} +export function datastore_index_scan_point_bsatn() { + return 0; +} +export function datastore_delete_by_index_scan_point_bsatn() { + return 0; +} diff --git a/crates/bindings-typescript/tests/mocks/spacetime_sys_v2_1.ts b/crates/bindings-typescript/tests/mocks/spacetime_sys_v2_1.ts new file mode 100644 index 00000000000..c73482b328a --- /dev/null +++ b/crates/bindings-typescript/tests/mocks/spacetime_sys_v2_1.ts @@ -0,0 +1,3 @@ +export function datastore_clear() { + return 0n; +} diff --git a/crates/bindings-typescript/tests/server_namespacing.test.ts b/crates/bindings-typescript/tests/server_namespacing.test.ts new file mode 100644 index 00000000000..1403e1d9ea4 --- /dev/null +++ b/crates/bindings-typescript/tests/server_namespacing.test.ts @@ -0,0 +1,212 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { moduleHooks, __resetMockSys } from 'spacetime:sys@2.0'; +import { schema, table } from '../src/server/index'; +import t from '../src/lib/type_builders'; + +describe('server schema namespacing', () => { + beforeEach(() => { + __resetMockSys(); + }); + + it('mounts library tables and reducers under an alias', () => { + const seen = { + rootMountedDb: false, + rootMountedAs: false, + mountedScopedDb: false, + }; + + const auth = schema({ + users: table({}, { id: t.u32() }), + }); + + const signIn = auth.reducer({ name: 'sign_in' }, ctx => { + seen.mountedScopedDb = + 'users' in ctx.db && !('auth' in (ctx.db as object)); + ctx.db.users.count(); + }); + + const authNs = { + default: auth, + signIn, + helper( + ctx: typeof auth.schemaType extends infer _ + ? Parameters[0] + : never + ) { + ctx.db.users.count(); + }, + ignored: 'not-a-spacetime-export', + }; + + const app = schema({ + sessions: table({}, { id: t.u32() }), + auth: authNs, + }); + + const useAuth = app.reducer(ctx => { + seen.rootMountedDb = 'users' in ctx.db.auth; + seen.rootMountedAs = 'users' in ctx.as.auth.db; + ctx.db.auth.users.count(); + ctx.as.auth.db.users.count(); + authNs.helper(ctx.as.auth); + }); + + const hooks = app[moduleHooks]({ default: app, useAuth }); + + expect(app.moduleDef.tables.map(table => table.sourceName)).toEqual( + expect.arrayContaining(['sessions', '__ns__auth__users']) + ); + expect(app.moduleDef.reducers.map(reducer => reducer.sourceName)).toEqual( + expect.arrayContaining(['useAuth', '__ns__auth__signIn']) + ); + expect(app.moduleDef.explicitNames.entries).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + tag: 'Function', + value: expect.objectContaining({ + sourceName: '__ns__auth__signIn', + canonicalName: '__ns__auth__sign_in', + }), + }), + ]) + ); + + const rootReducerId = app.moduleDef.reducers.findIndex( + reducer => reducer.sourceName === 'useAuth' + ); + const mountedReducerId = app.moduleDef.reducers.findIndex( + reducer => reducer.sourceName === '__ns__auth__signIn' + ); + + hooks.__call_reducer__( + rootReducerId, + 0n, + 0n, + 0n, + new DataView(new ArrayBuffer(0)) + ); + hooks.__call_reducer__( + mountedReducerId, + 0n, + 0n, + 0n, + new DataView(new ArrayBuffer(0)) + ); + + expect(seen.rootMountedDb).toBe(true); + expect(seen.rootMountedAs).toBe(true); + expect(seen.mountedScopedDb).toBe(true); + }); + + it('rejects default-import style mounts', () => { + const auth = schema({ + users: table({}, { id: t.u32() }), + }); + + expect(() => + schema({ + auth: auth as never, + }) + ).toThrow(/module namespace import/i); + }); + + it('uses the reserved internal prefix for nested mounted names', () => { + const profile = schema({ + settings: table({}, { id: t.u32() }), + }); + + const auth = schema({ + users: table({}, { id: t.u32() }), + profile: { + default: profile, + }, + }); + + const app = schema({ + auth: { + default: auth, + }, + }); + + expect(app.moduleDef.tables.map(table => table.sourceName)).toEqual( + expect.arrayContaining([ + '__ns__auth__users', + '__ns__auth__profile__settings', + ]) + ); + }); + + it('rejects reserved internal prefix in user-defined names', () => { + const users = table({}, { id: t.u32() }); + const app = schema({ + users, + }); + + const reducer = app.reducer(ctx => { + ctx.db.users.count(); + }); + const namedReducer = app.reducer({ name: '__ns__create_user' }, ctx => { + ctx.db.users.count(); + }); + const procedure = app.procedure(t.string(), () => ''); + const namedProcedure = app.procedure( + { name: '__ns__get_message' }, + t.string(), + () => '' + ); + const view = app.anonymousView( + { name: 'listUsers', public: true }, + t.array(users.rowType), + () => [] + ); + const namedView = app.anonymousView( + { name: '__ns__list_users', public: true }, + t.array(users.rowType), + () => [] + ); + + expect(() => + schema({ + __ns__users: table({}, { id: t.u32() }), + }) + ).toThrow(/reserved for internal mounted-library names/i); + + expect(() => + schema({ + users: table({ name: '__ns__users' }, { id: t.u32() }), + }) + ).toThrow(/reserved for internal mounted-library names/i); + + expect(() => + schema({ + __ns__auth: { + default: app, + }, + }) + ).toThrow(/reserved for internal mounted-library names/i); + + expect(() => + app[moduleHooks]({ default: app, __ns__createUser: reducer }) + ).toThrow(/reserved for internal mounted-library names/i); + + expect(() => + app[moduleHooks]({ default: app, createUser: namedReducer }) + ).toThrow(/reserved for internal mounted-library names/i); + + expect(() => + app[moduleHooks]({ default: app, __ns__getMessage: procedure }) + ).toThrow(/reserved for internal mounted-library names/i); + + expect(() => + app[moduleHooks]({ default: app, getMessage: namedProcedure }) + ).toThrow(/reserved for internal mounted-library names/i); + + expect(() => + app[moduleHooks]({ default: app, __ns__listUsers: view }) + ).toThrow(/reserved for internal mounted-library names/i); + + expect(() => + app[moduleHooks]({ default: app, listUsers: namedView }) + ).toThrow(/reserved for internal mounted-library names/i); + }); +}); diff --git a/crates/bindings-typescript/vitest.config.ts b/crates/bindings-typescript/vitest.config.ts index 56dd6752bb9..78ce6d998db 100644 --- a/crates/bindings-typescript/vitest.config.ts +++ b/crates/bindings-typescript/vitest.config.ts @@ -1,7 +1,18 @@ import type { UserConfig } from 'vite'; import { defineConfig } from 'vitest/config'; +import { fileURLToPath } from 'node:url'; export default defineConfig({ + resolve: { + alias: { + 'spacetime:sys@2.0': fileURLToPath( + new URL('./tests/mocks/spacetime_sys_v2_0.ts', import.meta.url) + ), + 'spacetime:sys@2.1': fileURLToPath( + new URL('./tests/mocks/spacetime_sys_v2_1.ts', import.meta.url) + ), + }, + }, test: { include: ['tests/**/*.test.ts'], globals: true, diff --git a/crates/core/src/client/message_handlers_v1.rs b/crates/core/src/client/message_handlers_v1.rs index d6cf8fc6257..063147b3c42 100644 --- a/crates/core/src/client/message_handlers_v1.rs +++ b/crates/core/src/client/message_handlers_v1.rs @@ -1,7 +1,7 @@ use super::messages::{SubscriptionUpdateMessage, SwitchedServerMessage, ToProtocol, TransactionUpdateMessage}; use super::{ClientConnection, DataMessage, MessageHandleError, Protocol}; use crate::energy::EnergyQuanta; -use crate::host::module_host::{EventStatus, ModuleEvent, ModuleFunctionCall}; +use crate::host::module_host::{EventStatus, ModuleDefReducerLookupExt, ModuleEvent, ModuleFunctionCall}; use crate::host::{FunctionArgs, ReducerId}; use crate::identity::Identity; use crate::worker_metrics::WORKER_METRICS; @@ -67,7 +67,10 @@ pub async fn handle(client: &ClientConnection, message: DataMessage, timer: Inst res.map(drop).map_err(|e| { ( Some(reducer), - mod_info.module_def.reducer_full(&**reducer).map(|(id, _)| id), + mod_info + .module_def + .lookup_reducer_by_external_name(reducer) + .map(|(id, _)| id), e.into(), ) }) diff --git a/crates/core/src/host/module_host.rs b/crates/core/src/host/module_host.rs index 88c45a3f867..9dde8f6fda4 100644 --- a/crates/core/src/host/module_host.rs +++ b/crates/core/src/host/module_host.rs @@ -67,6 +67,7 @@ use spacetimedb_schema::identifier::Identifier; use spacetimedb_schema::reducer_name::ReducerName; use spacetimedb_schema::schema::{Schema, TableSchema}; use spacetimedb_schema::table_name::TableName; +use std::borrow::Cow; use std::collections::VecDeque; use std::fmt; use std::sync::atomic::AtomicBool; @@ -1632,7 +1633,7 @@ impl ModuleHost { let (reducer_id, reducer_def) = self .info .module_def - .reducer_full(reducer_name) + .lookup_reducer_by_external_name(reducer_name) .ok_or(ReducerCallError::NoSuchReducer)?; if let Some(lifecycle) = reducer_def.lifecycle { return Err(ReducerCallError::LifecycleReducer(lifecycle)); @@ -2548,9 +2549,30 @@ fn args_error_log_message(function_kind: &str, function_name: &str) -> String { ) } +fn normalize_external_reducer_name(name: &str) -> Option> { + match name.split_once('/') { + Some((alias, reducer)) if !alias.is_empty() && !reducer.is_empty() && !reducer.contains('/') => { + Some(Cow::Owned(format!("__ns__{alias}__{reducer}"))) + } + Some(_) => None, + None => Some(Cow::Borrowed(name)), + } +} + +pub(crate) trait ModuleDefReducerLookupExt { + fn lookup_reducer_by_external_name(&self, name: &str) -> Option<(ReducerId, &ReducerDef)>; +} + +impl ModuleDefReducerLookupExt for ModuleDef { + fn lookup_reducer_by_external_name(&self, name: &str) -> Option<(ReducerId, &ReducerDef)> { + let normalized = normalize_external_reducer_name(name)?; + self.reducer_full(normalized.as_ref()) + } +} + #[cfg(test)] mod tests { - use super::ModuleHost; + use super::{normalize_external_reducer_name, ModuleDefReducerLookupExt, ModuleHost}; use crate::client::{ ClientActorId, ClientConfig, ClientConnectionReceiver, ClientConnectionSender, OutboundMessage, Protocol, WsVersion, @@ -2558,9 +2580,12 @@ mod tests { use crate::db::relational_db::tests_utils::{insert, with_auto_commit, TestDB}; use crate::subscription::module_subscription_actor::ModuleSubscriptions; use spacetimedb_client_api_messages::websocket::{common::RowListLen as _, v1 as ws_v1, v2 as ws_v2}; + use spacetimedb_lib::db::raw_def::v10::RawModuleDefV10Builder; use spacetimedb_lib::identity::AuthCtx; + use spacetimedb_lib::RawModuleDef; use spacetimedb_lib::{AlgebraicType, Identity}; use spacetimedb_sats::product; + use spacetimedb_schema::def::ModuleDef; use std::sync::Arc; fn v2_client_config() -> ClientConfig { @@ -2656,4 +2681,30 @@ mod tests { Ok(()) } + + #[test] + fn namespaced_reducer_names_are_normalized() { + assert_eq!( + normalize_external_reducer_name("auth/login").as_deref(), + Some("__ns__auth__login") + ); + assert_eq!( + normalize_external_reducer_name("login").as_deref(), + Some("login") + ); + assert_eq!(normalize_external_reducer_name("auth/"), None); + assert_eq!(normalize_external_reducer_name("/login"), None); + assert_eq!(normalize_external_reducer_name("auth/login/extra"), None); + } + + #[test] + fn reducer_lookup_uses_namespaced_aliases() { + let mut raw = RawModuleDefV10Builder::new(); + raw.add_reducer("__ns__auth__login", product![]); + let module = ModuleDef::try_from(RawModuleDef::V10(raw.finish())).expect("valid module"); + + assert!(module.lookup_reducer_by_external_name("auth/login").is_some()); + assert!(module.lookup_reducer_by_external_name("login").is_none()); + assert!(module.lookup_reducer_by_external_name("auth/unknown").is_none()); + } }