From 862afff3c74907252c6e5f136f8636f7c642d481 Mon Sep 17 00:00:00 2001 From: joshua-spacetime Date: Mon, 10 Aug 2026 14:14:57 -0700 Subject: [PATCH 1/3] Remove submodule overhead if no submodules --- .../bindings-typescript/src/server/runtime.ts | 50 +++++++++++++++++-- 1 file changed, 47 insertions(+), 3 deletions(-) diff --git a/crates/bindings-typescript/src/server/runtime.ts b/crates/bindings-typescript/src/server/runtime.ts index cf579e87631..eef418642d3 100644 --- a/crates/bindings-typescript/src/server/runtime.ts +++ b/crates/bindings-typescript/src/server/runtime.ts @@ -263,7 +263,32 @@ export const ReducerCtxImpl = class ReducerCtx< this.as = asViews as AliasViews; } - /** Reset the `ReducerCtx` to be used for a new transaction */ + /** + * Reset transaction-local state for a module with no mounted submodules. + * + * Such a module always uses the same `db` and `as` views, so rewriting those + * properties and checking optional arguments on every reducer call is wasted + * work. Keep this hot path separate from `reset` below rather than adding + * submodule bookkeeping to all reducer calls. + */ + static resetForRootModule( + me: InstanceType, + sender: Identity, + timestamp: Timestamp, + connectionId: ConnectionId | null + ) { + me.sender = sender; + me.timestamp = timestamp; + me.connectionId = connectionId; + me.#uuidCounter = undefined; + me.#senderAuth = undefined; + } + + /** + * Reset the reusable `ReducerCtx` for the next reducer call. `dbView` and + * `asViews` replace `ctx.db` and `ctx.as` with the table accessors and + * submodule-alias contexts belonging to the reducer being called. + */ static reset( me: InstanceType, sender: Identity, @@ -420,6 +445,7 @@ class ModuleHooksImpl implements ModuleHooks { #dbView_: DbView | undefined; #consumerAs_: object | undefined; #reducerArgsDeserializers; + #hasSubmodules: boolean; #consumerReducerCount: number; #consumerProcedureCount: number; #flatSubmodules: FlatSubmoduleDispatch[]; @@ -432,6 +458,7 @@ class ModuleHooksImpl implements ModuleHooks { constructor(schema: SchemaInner) { this.#schema = schema; + this.#hasSubmodules = schema.submoduleDispatchInfos.length !== 0; this.#consumerReducerCount = schema.reducers.length; this.#consumerProcedureCount = schema.procedures.length; this.#consumerAnonViewCount = schema.anonViews.length; @@ -552,6 +579,23 @@ class ModuleHooksImpl implements ModuleHooks { BINARY_READER.reset(argsBuf); const args = deserializeArgs(BINARY_READER); const senderIdentity = new Identity(sender); + const reducerTimestamp = new Timestamp(timestamp); + const connectionId = ConnectionId.nullIfZero(new ConnectionId(connId)); + + // With no mounted submodules, every reducer uses the same `ctx.db` table + // accessors and an empty `ctx.as`. Skip namespace dispatch and avoid + // rewriting those unchanged context properties on every reducer call. + if (!this.#hasSubmodules) { + const ctx = this.#reducerCtx; + ReducerCtxImpl.resetForRootModule( + ctx, + senderIdentity, + reducerTimestamp, + connectionId + ); + callUserFunction(this.#schema.reducers[reducerId], ctx, args); + return; + } let fn: ((...args: any[]) => any) | undefined; let dbView: DbView; @@ -582,8 +626,8 @@ class ModuleHooksImpl implements ModuleHooks { ReducerCtxImpl.reset( ctx, senderIdentity, - new Timestamp(timestamp), - ConnectionId.nullIfZero(new ConnectionId(connId)), + reducerTimestamp, + connectionId, dbView!, asViews! ); From 0ac4e81b5f4c72a5a6b9932e4593d9a6773c422b Mon Sep 17 00:00:00 2001 From: joshua-spacetime Date: Mon, 10 Aug 2026 16:05:42 -0700 Subject: [PATCH 2/3] Revert to pre-submodule hot path --- .../bindings-typescript/src/server/runtime.ts | 128 ++++++++++++------ 1 file changed, 83 insertions(+), 45 deletions(-) diff --git a/crates/bindings-typescript/src/server/runtime.ts b/crates/bindings-typescript/src/server/runtime.ts index eef418642d3..8628ba8a565 100644 --- a/crates/bindings-typescript/src/server/runtime.ts +++ b/crates/bindings-typescript/src/server/runtime.ts @@ -78,6 +78,7 @@ import type { SubmoduleDispatchInfo, SchemaInner } from './schema'; import { HttpRequest, HttpResponse } from '../lib/autogen/types'; const { freeze } = Object; +const EMPTY_ALIAS_VIEWS = freeze({}); export const sys = { ..._syscalls2_0, ..._syscalls2_1 }; @@ -253,7 +254,7 @@ export const ReducerCtxImpl = class ReducerCtx< timestamp: Timestamp, connectionId: ConnectionId | null, dbView: DbView, - asViews: object = {} + asViews: object = EMPTY_ALIAS_VIEWS ) { Object.seal(this); this.sender = sender; @@ -263,15 +264,8 @@ export const ReducerCtxImpl = class ReducerCtx< this.as = asViews as AliasViews; } - /** - * Reset transaction-local state for a module with no mounted submodules. - * - * Such a module always uses the same `db` and `as` views, so rewriting those - * properties and checking optional arguments on every reducer call is wasted - * work. Keep this hot path separate from `reset` below rather than adding - * submodule bookkeeping to all reducer calls. - */ - static resetForRootModule( + /** Reset the `ReducerCtx` to be used for a new root-module transaction. */ + static reset( me: InstanceType, sender: Identity, timestamp: Timestamp, @@ -285,29 +279,25 @@ export const ReducerCtxImpl = class ReducerCtx< } /** - * Reset the reusable `ReducerCtx` for the next reducer call. `dbView` and - * `asViews` replace `ctx.db` and `ctx.as` with the table accessors and - * submodule-alias contexts belonging to the reducer being called. + * Reset the reusable `ReducerCtx` for a reducer mounted in a namespace. + * `dbView` and `asViews` select the table and alias views belonging to that + * reducer's module. */ - static reset( + static resetForNamespace( me: InstanceType, sender: Identity, timestamp: Timestamp, connectionId: ConnectionId | null, - dbView?: DbView, - asViews?: object + 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; - } + me.db = dbView; + me.as = asViews as AliasViews; } get databaseIdentity() { @@ -438,14 +428,15 @@ function flattenSubmoduleDispatches( } export const makeHooks = (schema: SchemaInner): ModuleHooks => - new ModuleHooksImpl(schema); + schema.submoduleDispatchInfos.length === 0 + ? new RootModuleHooksImpl(schema) + : new ModuleHooksImpl(schema); class ModuleHooksImpl implements ModuleHooks { #schema: SchemaInner; #dbView_: DbView | undefined; #consumerAs_: object | undefined; #reducerArgsDeserializers; - #hasSubmodules: boolean; #consumerReducerCount: number; #consumerProcedureCount: number; #flatSubmodules: FlatSubmoduleDispatch[]; @@ -458,7 +449,6 @@ class ModuleHooksImpl implements ModuleHooks { constructor(schema: SchemaInner) { this.#schema = schema; - this.#hasSubmodules = schema.submoduleDispatchInfos.length !== 0; this.#consumerReducerCount = schema.reducers.length; this.#consumerProcedureCount = schema.procedures.length; this.#consumerAnonViewCount = schema.anonViews.length; @@ -579,23 +569,6 @@ class ModuleHooksImpl implements ModuleHooks { BINARY_READER.reset(argsBuf); const args = deserializeArgs(BINARY_READER); const senderIdentity = new Identity(sender); - const reducerTimestamp = new Timestamp(timestamp); - const connectionId = ConnectionId.nullIfZero(new ConnectionId(connId)); - - // With no mounted submodules, every reducer uses the same `ctx.db` table - // accessors and an empty `ctx.as`. Skip namespace dispatch and avoid - // rewriting those unchanged context properties on every reducer call. - if (!this.#hasSubmodules) { - const ctx = this.#reducerCtx; - ReducerCtxImpl.resetForRootModule( - ctx, - senderIdentity, - reducerTimestamp, - connectionId - ); - callUserFunction(this.#schema.reducers[reducerId], ctx, args); - return; - } let fn: ((...args: any[]) => any) | undefined; let dbView: DbView; @@ -623,11 +596,11 @@ class ModuleHooksImpl implements ModuleHooks { } const ctx = this.#reducerCtx; - ReducerCtxImpl.reset( + ReducerCtxImpl.resetForNamespace( ctx, senderIdentity, - reducerTimestamp, - connectionId, + new Timestamp(timestamp), + ConnectionId.nullIfZero(new ConnectionId(connId)), dbView!, asViews! ); @@ -811,6 +784,71 @@ class ModuleHooksImpl implements ModuleHooks { } } +/** + * Hooks for modules without mounted submodules. + * + * Keep reducer dispatch in a separate, small function so these modules retain + * the pre-submodules hot path. `ctx.as` is initialized once to a shared empty + * object when the cached reducer context is created; it requires no per-call + * namespace dispatch or context updates. + */ +class RootModuleHooksImpl extends ModuleHooksImpl { + #schema: SchemaInner; + #dbView_: DbView | undefined; + #reducerArgsDeserializers; + #reducerCtx_: InstanceType | undefined; + + constructor(schema: SchemaInner) { + super(schema); + this.#schema = schema; + this.#reducerArgsDeserializers = schema.moduleDef.reducers.map( + ({ params }) => ProductType.makeDeserializer(params, schema.typespace) + ); + } + + get #dbView() { + return (this.#dbView_ ??= freeze( + Object.fromEntries( + Object.values(this.#schema.schemaType.tables).map(table => [ + table.accessorName, + makeTableView(this.#schema.typespace, table.tableDef), + ]) + ) + )); + } + + get #reducerCtx() { + return (this.#reducerCtx_ ??= new ReducerCtxImpl( + Identity.zero(), + Timestamp.UNIX_EPOCH, + null, + this.#dbView + )); + } + + override __call_reducer__( + reducerId: u32, + sender: u256, + connId: u128, + 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); + const ctx = this.#reducerCtx; + ReducerCtxImpl.reset( + ctx, + senderIdentity, + new Timestamp(timestamp), + ConnectionId.nullIfZero(new ConnectionId(connId)) + ); + callUserFunction(moduleCtx.reducers[reducerId], ctx, args); + } +} + const BINARY_WRITER = new BinaryWriter(0); const BINARY_READER = new BinaryReader(new Uint8Array()); From 3cad72ca165b155136c8581c8b36e1e2c18d0c3e Mon Sep 17 00:00:00 2001 From: joshua-spacetime Date: Mon, 10 Aug 2026 17:03:32 -0700 Subject: [PATCH 3/3] Restore the old root-only hooks shape --- .../bindings-typescript/src/server/runtime.ts | 126 +++++++++++++++++- 1 file changed, 119 insertions(+), 7 deletions(-) diff --git a/crates/bindings-typescript/src/server/runtime.ts b/crates/bindings-typescript/src/server/runtime.ts index 8628ba8a565..d80e7687e07 100644 --- a/crates/bindings-typescript/src/server/runtime.ts +++ b/crates/bindings-typescript/src/server/runtime.ts @@ -787,19 +787,19 @@ class ModuleHooksImpl implements ModuleHooks { /** * Hooks for modules without mounted submodules. * - * Keep reducer dispatch in a separate, small function so these modules retain - * the pre-submodules hot path. `ctx.as` is initialized once to a shared empty - * object when the cached reducer context is created; it requires no per-call - * namespace dispatch or context updates. + * This intentionally does not extend `ModuleHooksImpl`. Selecting this class + * in `makeHooks` gives root-only modules the pre-submodules hook-object field + * set and root-only dispatch methods. The only intentional shape difference in + * the cached reducer context is the empty `ctx.as` view required by the current + * API. */ -class RootModuleHooksImpl extends ModuleHooksImpl { +class RootModuleHooksImpl implements ModuleHooks { #schema: SchemaInner; #dbView_: DbView | undefined; #reducerArgsDeserializers; #reducerCtx_: InstanceType | undefined; constructor(schema: SchemaInner) { - super(schema); this.#schema = schema; this.#reducerArgsDeserializers = schema.moduleDef.reducers.map( ({ params }) => ProductType.makeDeserializer(params, schema.typespace) @@ -826,7 +826,24 @@ class RootModuleHooksImpl extends ModuleHooksImpl { )); } - override __call_reducer__( + __describe_module__() { + const writer = new BinaryWriter(128); + RawModuleDef.serialize( + writer, + RawModuleDef.V10(this.#schema.rawModuleDefV10()) + ); + return writer.getBuffer(); + } + + __get_error_constructor__(code: number): new (msg: string) => Error { + return getErrorConstructor(code); + } + + get __sender_error_class__() { + return SenderError; + } + + __call_reducer__( reducerId: u32, sender: u256, connId: u128, @@ -847,6 +864,101 @@ class RootModuleHooksImpl extends ModuleHooksImpl { ); callUserFunction(moduleCtx.reducers[reducerId], ctx, args); } + + __call_view__( + id: u32, + sender: u256, + argsBuf: Uint8Array + ): { data: Uint8Array } { + const moduleCtx = this.#schema; + const { fn, deserializeParams, serializeReturn, returnTypeBaseSize } = + moduleCtx.views[id]; + const ctx: ViewCtx = freeze({ + sender: new Identity(sender), + // This is the mutable DbView, but the user-facing type is readonly. Any + // attempted mutation still fails at runtime because this is a view call. + db: this.#dbView, + from: makeQueryBuilder(moduleCtx.schemaType), + }); + const args = deserializeParams(new BinaryReader(argsBuf)); + const ret = callUserFunction(fn, ctx, args); + const retBuf = new BinaryWriter(returnTypeBaseSize); + if (isRowTypedQuery(ret)) { + const query = toSql(ret); + ViewResultHeader.serialize(retBuf, ViewResultHeader.RawSql(query)); + } else { + ViewResultHeader.serialize(retBuf, ViewResultHeader.RowData); + serializeReturn(retBuf, ret); + } + return { data: retBuf.getBuffer() }; + } + + __call_view_anon__(id: u32, argsBuf: Uint8Array): { data: Uint8Array } { + const moduleCtx = this.#schema; + const { fn, deserializeParams, serializeReturn, returnTypeBaseSize } = + moduleCtx.anonViews[id]; + const ctx: AnonymousViewCtx = freeze({ + // This is the mutable DbView, but the user-facing type is readonly. Any + // attempted mutation still fails at runtime because this is a view call. + db: this.#dbView, + from: makeQueryBuilder(moduleCtx.schemaType), + }); + const args = deserializeParams(new BinaryReader(argsBuf)); + const ret = callUserFunction(fn, ctx, args); + const retBuf = new BinaryWriter(returnTypeBaseSize); + if (isRowTypedQuery(ret)) { + const query = toSql(ret); + ViewResultHeader.serialize(retBuf, ViewResultHeader.RawSql(query)); + } else { + ViewResultHeader.serialize(retBuf, ViewResultHeader.RowData); + serializeReturn(retBuf, ret); + } + return { data: retBuf.getBuffer() }; + } + + __call_procedure__( + id: u32, + sender: u256, + connection_id: u128, + timestamp: bigint, + args: Uint8Array + ): Uint8Array { + return callProcedure( + this.#schema.procedures, + id, + new Identity(sender), + ConnectionId.nullIfZero(new ConnectionId(connection_id)), + new Timestamp(timestamp), + args, + () => this.#dbView + ); + } + + __call_http_handler__( + id: u32, + timestamp: bigint, + request: Uint8Array, + body: Uint8Array + ): [response: Uint8Array, body: Uint8Array] { + const moduleCtx = this.#schema; + const handler = moduleCtx.httpHandlers[id]; + const ctx = new HandlerContextImpl( + new Timestamp(timestamp), + () => this.#dbView + ); + const requestMetadata = HttpRequest.deserialize(new BinaryReader(request)); + const response = callUserFunction( + handler, + ctx, + requestFromWire(requestMetadata, body) + ); + const [responseMetadata, responseBody] = responseIntoWire(response); + const responseBuf = new BinaryWriter( + bsatnBaseSize(moduleCtx.typespace, HttpResponse.algebraicType) + ); + HttpResponse.serialize(responseBuf, responseMetadata); + return [responseBuf.getBuffer(), responseBody]; + } } const BINARY_WRITER = new BinaryWriter(0);