From 4c8857a99c82cdda697a57157c6cca820e9e5837 Mon Sep 17 00:00:00 2001 From: Noa Date: Mon, 2 Feb 2026 15:58:03 -0600 Subject: [PATCH 1/2] Export reducers, etc from a module --- Cargo.lock | 1 + Cargo.toml | 1 + crates/bindings-typescript/package.json | 3 +- .../bindings-typescript/src/server/index.ts | 1 - .../src/server/procedures.ts | 53 +++- .../src/server/reducers.ts | 166 +++--------- .../src/server/register_hooks.ts | 4 - .../bindings-typescript/src/server/runtime.ts | 163 ++++++------ .../bindings-typescript/src/server/schema.ts | 240 ++++++++++++------ .../bindings-typescript/src/server/sys.d.ts | 10 +- .../bindings-typescript/src/server/views.ts | 63 ++++- crates/bindings-typescript/tsup.config.ts | 5 +- crates/cli/Cargo.toml | 1 + crates/cli/src/tasks/javascript.rs | 35 +++ 14 files changed, 436 insertions(+), 310 deletions(-) delete mode 100644 crates/bindings-typescript/src/server/register_hooks.ts diff --git a/Cargo.lock b/Cargo.lock index f2dadefeaf0..ee46ecc5132 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7492,6 +7492,7 @@ dependencies = [ "regex", "reqwest 0.12.24", "rolldown", + "rolldown_common", "rolldown_utils", "rustyline", "serde", diff --git a/Cargo.toml b/Cargo.toml index cbb16a759de..166c30f70eb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -255,6 +255,7 @@ rayon-core = "1.11.0" regex = "1" reqwest = { version = "0.12", features = ["stream", "json"] } rolldown = { git = "https://github.com/rolldown/rolldown.git", tag = "v1.0.0-beta.42" } +rolldown_common = { git = "https://github.com/rolldown/rolldown.git", tag = "v1.0.0-beta.42" } rolldown_utils = { git = "https://github.com/rolldown/rolldown.git", tag = "v1.0.0-beta.42" } ron = "0.8" rusqlite = { version = "0.29.0", features = ["bundled", "column_decltype"] } diff --git a/crates/bindings-typescript/package.json b/crates/bindings-typescript/package.json index 0e4987ecd29..74b69b7762c 100644 --- a/crates/bindings-typescript/package.json +++ b/crates/bindings-typescript/package.json @@ -20,8 +20,7 @@ "author": "Clockwork Labs", "type": "module", "sideEffects": [ - "./src/server/polyfills.ts", - "./src/server/register_hooks.ts" + "./src/server/polyfills.ts" ], "scripts": { "build:js": "tsup", diff --git a/crates/bindings-typescript/src/server/index.ts b/crates/bindings-typescript/src/server/index.ts index bc6edff25f0..6fe733a0bc2 100644 --- a/crates/bindings-typescript/src/server/index.ts +++ b/crates/bindings-typescript/src/server/index.ts @@ -11,4 +11,3 @@ export { type Uuid } from '../lib/uuid'; export { type Random } from './rng'; import './polyfills'; // Ensure polyfills are loaded -import './register_hooks'; // Ensure module hooks are registered diff --git a/crates/bindings-typescript/src/server/procedures.ts b/crates/bindings-typescript/src/server/procedures.ts index f0a2f7d8cab..9609bdca86c 100644 --- a/crates/bindings-typescript/src/server/procedures.ts +++ b/crates/bindings-typescript/src/server/procedures.ts @@ -19,9 +19,41 @@ import { import { bsatnBaseSize } from '../lib/util'; import { Uuid } from '../lib/uuid'; import { httpClient, type HttpClient } from './http_internal'; +import type { DbView } from './db_view'; import { makeRandom, type Random } from './rng'; import { callUserFunction, ReducerCtxImpl, sys } from './runtime'; -import type { SchemaInner } from './schema'; +import { + exportContext, + registerExport, + type ModuleExport, + type SchemaInner, +} from './schema'; + +export type ProcedureExport< + S extends UntypedSchemaDef, + Params extends ParamsObj, + Ret extends TypeBuilder, +> = ProcedureFn & ModuleExport; + +export function makeProcedureExport< + S extends UntypedSchemaDef, + Params extends ParamsObj, + Ret extends TypeBuilder, +>( + ctx: SchemaInner, + name: string | undefined, + params: Params, + ret: Ret, + fn: ProcedureFn +): ProcedureExport { + const procedureExport: ProcedureExport = (...args) => + fn(...args); + procedureExport[exportContext] = ctx; + procedureExport[registerExport] = (ctx, exportName) => { + registerProcedure(ctx, name ?? exportName, params, ret, fn); + }; + return procedureExport; +} export type ProcedureFn< S extends UntypedSchemaDef, @@ -45,7 +77,7 @@ export interface ProcedureCtx { export interface TransactionCtx extends ReducerCtx {} -export function procedure< +function registerProcedure< S extends UntypedSchemaDef, Params extends ParamsObj, Ret extends TypeBuilder, @@ -99,7 +131,8 @@ export function callProcedure( sender: Identity, connectionId: ConnectionId | null, timestamp: Timestamp, - argsBuf: Uint8Array + argsBuf: Uint8Array, + dbView: DbView ): Uint8Array { const { fn, deserializeArgs, serializeReturn, returnTypeBaseSize } = moduleCtx.procedures[id]; @@ -108,7 +141,8 @@ export function callProcedure( const ctx: ProcedureCtx = new ProcedureCtxImpl( sender, timestamp, - connectionId + connectionId, + dbView ); const ret = callUserFunction(fn, ctx, args); @@ -124,12 +158,16 @@ const ProcedureCtxImpl = class ProcedureCtx #identity: Identity | undefined; #uuidCounter: { value: 0 } | undefined; #random: Random | undefined; + #dbView: DbView; constructor( readonly sender: Identity, readonly timestamp: Timestamp, - readonly connectionId: ConnectionId | null - ) {} + readonly connectionId: ConnectionId | null, + dbView: DbView + ) { + this.#dbView = dbView; + } get identity() { return (this.#identity ??= new Identity(sys.identity())); @@ -151,7 +189,8 @@ const ProcedureCtxImpl = class ProcedureCtx const ctx: TransactionCtx = new ReducerCtxImpl( this.sender, new Timestamp(timestamp), - this.connectionId + this.connectionId, + this.#dbView ); return body(ctx); } catch (e) { diff --git a/crates/bindings-typescript/src/server/reducers.ts b/crates/bindings-typescript/src/server/reducers.ts index f149e3e002d..3beee517b81 100644 --- a/crates/bindings-typescript/src/server/reducers.ts +++ b/crates/bindings-typescript/src/server/reducers.ts @@ -1,21 +1,38 @@ -import Lifecycle from '../lib/autogen/lifecycle_type'; import type RawReducerDefV9 from '../lib/autogen/raw_reducer_def_v_9_type'; -import type { - ParamsAsObject, - ParamsObj, - Reducer, - ReducerCtx, -} from '../lib/reducers'; +import type { ParamsObj, Reducer } from '../lib/reducers'; import { type UntypedSchemaDef } from '../lib/schema'; -import { - ColumnBuilder, - RowBuilder, - type Infer, - type RowObj, - type TypeBuilder, -} from '../lib/type_builders'; +import { RowBuilder, type Infer, type RowObj } from '../lib/type_builders'; import { toPascalCase } from '../lib/util'; -import type { SchemaInner } from './schema'; +import { + exportContext, + registerExport, + type ModuleExport, + type SchemaInner, +} from './schema'; + +export interface ReducerExport< + S extends UntypedSchemaDef, + Params extends ParamsObj, +> extends Reducer, + ModuleExport {} + +export function makeReducerExport< + S extends UntypedSchemaDef, + Params extends ParamsObj, +>( + ctx: SchemaInner, + name: string | undefined, + params: RowObj | RowBuilder, + fn: Reducer, + lifecycle?: Infer['lifecycle'] +): ReducerExport { + const reducerExport: ReducerExport = (...args) => fn(...args); + reducerExport[exportContext] = ctx; + reducerExport[registerExport] = (ctx, exportName) => { + registerReducer(ctx, name ?? exportName, params, fn, lifecycle); + }; + return reducerExport; +} /** * internal: pushReducer() helper used by reducer() and lifecycle wrappers @@ -25,7 +42,7 @@ import type { SchemaInner } from './schema'; * @param fn - The reducer function. * @param lifecycle - Optional lifecycle hooks for the reducer. */ -export function pushReducer( +export function registerReducer( ctx: SchemaInner, name: string, params: RowObj | RowBuilder, @@ -61,120 +78,3 @@ export function pushReducer( } export type Reducers = Reducer[]; - -/** - * Defines a SpacetimeDB reducer function. - * - * Reducers are the primary way to modify the state of your SpacetimeDB application. - * They are atomic, meaning that either all operations within a reducer succeed, - * or none of them do. - * - * @template S - The inferred schema type of the SpacetimeDB module. - * @template Params - The type of the parameters object expected by the reducer. - * - * @param {string} name - The name of the reducer. This name will be used to call the reducer from clients. - * @param {Params} params - An object defining the parameters that the reducer accepts. - * Each key-value pair represents a parameter name and its corresponding - * {@link TypeBuilder} or {@link ColumnBuilder}. - * @param {(ctx: ReducerCtx, payload: ParamsAsObject) => void} fn - The reducer function itself. - * - `ctx`: The reducer context, providing access to `sender`, `timestamp`, `connection_id`, and `db`. - * - `payload`: An object containing the arguments passed to the reducer, typed according to `params`. - * - * @example - * ```typescript - * // Define a reducer named 'create_user' that takes 'username' (string) and 'email' (string) - * reducer( - * 'create_user', - * { - * username: t.string(), - * email: t.string(), - * }, - * (ctx, { username, email }) => { - * // Access the 'user' table from the database view in the context - * ctx.db.user.insert({ username, email, created_at: ctx.timestamp }); - * console.log(`User ${username} created by ${ctx.sender.identityId}`); - * } - * ); - * ``` - */ -export function reducer( - ctx: SchemaInner, - name: string, - params: Params, - fn: Reducer -): void { - pushReducer(ctx, name, params, fn); -} - -/** - * Registers an initialization reducer that runs when the SpacetimeDB module is published - * for the first time. - * This function is useful to set up any initial state of your database that is guaranteed - * to run only once, and before any other reducers or client connections. - * @template S - The inferred schema type of the SpacetimeDB module. - * @template Params - The type of the parameters object expected by the initialization reducer. - * - * @param params - The parameters object defining the expected input for the initialization reducer. - * @param fn - The initialization reducer function. - * - `ctx`: The reducer context, providing access to `sender`, `timestamp`, `connection_id`, and `db`. - */ -export function init( - ctx: SchemaInner, - name: string, - params: Params, - fn: Reducer -): void { - pushReducer(ctx, name, params, fn, Lifecycle.Init); -} - -/** - * Registers a reducer to be called when a client connects to the SpacetimeDB module. - * This function allows you to define custom logic that should execute - * whenever a new client establishes a connection. - * @template S - The inferred schema type of the SpacetimeDB module. - * @template Params - The type of the parameters object expected by the connection reducer. - * @param params - The parameters object defining the expected input for the connection reducer. - * @param fn - The connection reducer function itself. - */ -export function clientConnected< - S extends UntypedSchemaDef, - Params extends ParamsObj, ->( - ctx: SchemaInner, - name: string, - params: Params, - fn: Reducer -): void { - pushReducer(ctx, name, params, fn, Lifecycle.OnConnect); -} - -/** - * Registers a reducer to be called when a client disconnects from the SpacetimeDB module. - * This function allows you to define custom logic that should execute - * whenever a client disconnects. - * - * @template S - The inferred schema type of the SpacetimeDB module. - * @template Params - The type of the parameters object expected by the disconnection reducer. - * @param params - The parameters object defining the expected input for the disconnection reducer. - * @param fn - The disconnection reducer function itself. - * @example - * ```typescript - * spacetime.clientDisconnected( - * { reason: t.string() }, - * (ctx, { reason }) => { - * console.log(`Client ${ctx.connection_id} disconnected: ${reason}`); - * } - * ); - * ``` - */ -export function clientDisconnected< - S extends UntypedSchemaDef, - Params extends ParamsObj, ->( - ctx: SchemaInner, - name: string, - params: Params, - fn: Reducer -): void { - pushReducer(ctx, name, params, fn, Lifecycle.OnDisconnect); -} diff --git a/crates/bindings-typescript/src/server/register_hooks.ts b/crates/bindings-typescript/src/server/register_hooks.ts deleted file mode 100644 index 7a6a64edd8b..00000000000 --- a/crates/bindings-typescript/src/server/register_hooks.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { register_hooks } from 'spacetime:sys@2.0'; -import { hooks } from './runtime'; - -register_hooks(hooks); diff --git a/crates/bindings-typescript/src/server/runtime.ts b/crates/bindings-typescript/src/server/runtime.ts index 4b1ae375cf5..a38d75b00ba 100644 --- a/crates/bindings-typescript/src/server/runtime.ts +++ b/crates/bindings-typescript/src/server/runtime.ts @@ -1,13 +1,12 @@ import * as _syscalls2_0 from 'spacetime:sys@2.0'; -import type { ModuleHooks, u16, u32 } from 'spacetime:sys@2.0'; +import type { ModuleHooks, u128, u16, u256, u32 } from 'spacetime:sys@2.0'; import { AlgebraicType, ProductType, type Deserializer, } from '../lib/algebraic_type'; import RawModuleDef from '../lib/autogen/raw_module_def_type'; -import type RawModuleDefV9 from '../lib/autogen/raw_module_def_v_9_type'; import type RawTableDefV9 from '../lib/autogen/raw_table_def_v_9_type'; import type Typespace from '../lib/autogen/typespace_type'; import { ConnectionId } from '../lib/connection_id'; @@ -40,7 +39,7 @@ import { SenderError, SpacetimeHostError } from './errors'; import { Range, type Bound } from './range'; import ViewResultHeader from '../lib/autogen/view_result_header_type'; import { makeRandom, type Random } from './rng'; -import { getRegisteredSchema } from './schema'; +import type { SchemaInner } from './schema'; const { freeze } = Object; @@ -177,9 +176,6 @@ class AuthCtxImpl implements AuthCtx { } } -/** Cache the `ReducerCtx` object to avoid allocating anew for ever reducer call. */ -let REDUCER_CTX: InstanceType | undefined; - // Using a class expression rather than declaration keeps the class out of the // type namespace, so that `ReducerCtx` still refers to the interface. export const ReducerCtxImpl = class ReducerCtx< @@ -198,13 +194,14 @@ export const ReducerCtxImpl = class ReducerCtx< constructor( sender: Identity, timestamp: Timestamp, - connectionId: ConnectionId | null + connectionId: ConnectionId | null, + dbView: DbView ) { Object.seal(this); this.sender = sender; this.timestamp = timestamp; this.connectionId = connectionId; - this.db = getDbView(); + this.db = dbView; } /** Reset the `ReducerCtx` to be used for a new transaction */ @@ -268,45 +265,68 @@ export const callUserFunction = function __spacetimedb_end_short_backtrace< return fn(...args); }; -let reducerArgsDeserializers: Deserializer[]; +export const makeHooks = (schema: SchemaInner): ModuleHooks => + new ModuleHooksImpl(schema); + +class ModuleHooksImpl implements ModuleHooks { + #schema: SchemaInner; + #dbView_: DbView | undefined; + #reducerArgsDeserializers; + /** Cache the `ReducerCtx` object to avoid allocating anew for ever reducer call. */ + #reducerCtx_: InstanceType | undefined; + + constructor(schema: SchemaInner) { + this.#schema = schema; + this.#reducerArgsDeserializers = schema.moduleDef.reducers.map( + ({ params }) => ProductType.makeDeserializer(params, schema.typespace) + ); + } + + get #dbView() { + return (this.#dbView_ ??= freeze( + Object.fromEntries( + this.#schema.moduleDef.tables.map(table => [ + toCamelCase(table.name), + makeTableView(this.#schema.typespace, table), + ]) + ) + )); + } + + get #reducerCtx() { + return (this.#reducerCtx_ ??= new ReducerCtxImpl( + Identity.zero(), + Timestamp.UNIX_EPOCH, + null, + this.#dbView + )); + } -export const hooks: ModuleHooks = { __describe_module__() { const writer = new BinaryWriter(128); - RawModuleDef.serialize( - writer, - RawModuleDef.V9(getRegisteredSchema().moduleDef) - ); + RawModuleDef.serialize(writer, RawModuleDef.V9(this.#schema.moduleDef)); return writer.getBuffer(); - }, - __call_reducer__(reducerId, sender, connId, timestamp, argsBuf) { - const moduleCtx = getRegisteredSchema(); - if (reducerArgsDeserializers == null) { - reducerArgsDeserializers = moduleCtx.moduleDef.reducers.map( - ({ params }) => - ProductType.makeDeserializer(params, moduleCtx.typespace) - ); - } - const deserializeArgs = reducerArgsDeserializers[reducerId]; + } + + __call_reducer__( + reducerId: u32, + sender: u256, + connId: u128, + timestamp: bigint, + argsBuf: DataView + ): undefined | { tag: 'err'; value: string } { + const moduleCtx = this.#schema; + const deserializeArgs = this.#reducerArgsDeserializers[reducerId]; BINARY_READER.reset(argsBuf); const args = deserializeArgs(BINARY_READER); const senderIdentity = new Identity(sender); - let ctx; - if (REDUCER_CTX == null) { - ctx = REDUCER_CTX = new ReducerCtxImpl( - senderIdentity, - new Timestamp(timestamp), - ConnectionId.nullIfZero(new ConnectionId(connId)) - ); - } else { - ctx = REDUCER_CTX; - ReducerCtxImpl.reset( - REDUCER_CTX, - senderIdentity, - new Timestamp(timestamp), - ConnectionId.nullIfZero(new ConnectionId(connId)) - ); - } + const ctx = this.#reducerCtx; + ReducerCtxImpl.reset( + ctx, + senderIdentity, + new Timestamp(timestamp), + ConnectionId.nullIfZero(new ConnectionId(connId)) + ); try { callUserFunction(moduleCtx.reducers[reducerId], ctx, args); } catch (e) { @@ -315,9 +335,14 @@ export const hooks: ModuleHooks = { } throw e; } - }, - __call_view__(id, sender, argsBuf) { - const moduleCtx = getRegisteredSchema(); + } + + __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({ @@ -325,7 +350,7 @@ export const hooks: ModuleHooks = { // this is the non-readonly DbView, but the typing for the user will be // the readonly one, and if they do call mutating functions it will fail // at runtime - db: getDbView(), + db: this.#dbView, from: makeQueryBuilder(moduleCtx.schemaType), }); const args = deserializeParams(new BinaryReader(argsBuf)); @@ -339,16 +364,17 @@ export const hooks: ModuleHooks = { serializeReturn(retBuf, ret); } return { data: retBuf.getBuffer() }; - }, - __call_view_anon__(id, argsBuf) { - const moduleCtx = getRegisteredSchema(); + } + + __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 non-readonly DbView, but the typing for the user will be // the readonly one, and if they do call mutating functions it will fail // at runtime - db: getDbView(), + db: this.#dbView, from: makeQueryBuilder(moduleCtx.schemaType), }); const args = deserializeParams(new BinaryReader(argsBuf)); @@ -362,34 +388,25 @@ export const hooks: ModuleHooks = { serializeReturn(retBuf, ret); } return { data: retBuf.getBuffer() }; - }, - __call_procedure__(id, sender, connection_id, timestamp, args) { + } + + __call_procedure__( + id: u32, + sender: u256, + connection_id: u128, + timestamp: bigint, + args: Uint8Array + ): Uint8Array { return callProcedure( - getRegisteredSchema(), + this.#schema, id, new Identity(sender), ConnectionId.nullIfZero(new ConnectionId(connection_id)), new Timestamp(timestamp), - args + args, + this.#dbView ); - }, -}; - -let DB_VIEW: DbView | null = null; -function getDbView() { - DB_VIEW ??= makeDbView(getRegisteredSchema().moduleDef); - return DB_VIEW; -} - -function makeDbView(moduleDef: Infer): DbView { - return freeze( - Object.fromEntries( - moduleDef.tables.map(table => [ - toCamelCase(table.name), - makeTableView(moduleDef.typespace, table), - ]) - ) - ); + } } const BINARY_WRITER = new BinaryWriter(0); @@ -862,10 +879,12 @@ type Intersections = Ts extends [ : unknown; function wrapSyscalls< - Modules extends Record any>[], + Modules extends Record any)>[], >(...modules: Modules): Intersections { return Object.fromEntries( - modules.flatMap(Object.entries).map(([k, v]) => [k, wrapSyscall(v)]) + modules + .flatMap(Object.entries) + .map(([k, v]) => [k, typeof v === 'function' ? wrapSyscall(v) : v]) ) as Intersections; } diff --git a/crates/bindings-typescript/src/server/schema.ts b/crates/bindings-typescript/src/server/schema.ts index 8a69b055dcb..9c80b40ac3a 100644 --- a/crates/bindings-typescript/src/server/schema.ts +++ b/crates/bindings-typescript/src/server/schema.ts @@ -1,3 +1,5 @@ +import { moduleHooks, type ModuleDefaultExport } from 'spacetime:sys@2.0'; +import Lifecycle from '../lib/autogen/lifecycle_type'; import { type ParamsAsObject, type ParamsObj, @@ -12,34 +14,31 @@ import { } from '../lib/schema'; import type { UntypedTableSchema } from '../lib/table_schema'; import { ColumnBuilder, TypeBuilder } from '../lib/type_builders'; -import { procedure, type ProcedureFn, type Procedures } from './procedures'; import { - clientConnected, - clientDisconnected, - init, - reducer, + makeProcedureExport, + type ProcedureExport, + type ProcedureFn, + type Procedures, +} from './procedures'; +import { + makeReducerExport, + type ReducerExport, type Reducers, } from './reducers'; +import { makeHooks } from './runtime'; import { - defineView, + makeAnonViewExport, + makeViewExport, type AnonViews, type AnonymousViewFn, + type ViewExport, type ViewFn, type ViewOpts, type ViewReturnTypeBuilder, type Views, } from './views'; -let REGISTERED_SCHEMA: SchemaInner | null = null; - -export function getRegisteredSchema(): SchemaInner { - if (REGISTERED_SCHEMA == null) { - throw new Error('Schema has not been registered yet. Call schema() first.'); - } - return REGISTERED_SCHEMA; -} - export class SchemaInner< S extends UntypedSchemaDef = UntypedSchemaDef, > extends ModuleContext { @@ -99,7 +98,7 @@ export class SchemaInner< // TODO(cloutiertyler): It might be nice to have a way to access the types // for the tables from the schema object, e.g. `spacetimedb.user.type` would // be the type of the user table. -class Schema { +export class Schema implements ModuleDefaultExport { #ctx: SchemaInner; constructor(ctx: SchemaInner) { @@ -107,6 +106,29 @@ class Schema { this.#ctx = ctx; } + [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' + ); + } + if ( + moduleExport[exportContext] != null && + moduleExport[exportContext] !== registeredSchema + ) { + throw new TypeError('multiple schemas are not supported'); + } + moduleExport[registerExport](registeredSchema, name); + } + return makeHooks(registeredSchema); + } + get schemaType(): S { return this.#ctx.schemaType; } @@ -129,7 +151,6 @@ class Schema { * @template S - The inferred schema type of the SpacetimeDB module. * @template Params - The type of the parameters object expected by the reducer. * - * @param {string} name - The name of the reducer. This name will be used to call the reducer from clients. * @param {Params} params - An object defining the parameters that the reducer accepts. * Each key-value pair represents a parameter name and its corresponding * {@link TypeBuilder} or {@link ColumnBuilder}. @@ -140,8 +161,7 @@ class Schema { * @example * ```typescript * // Define a reducer named 'create_user' that takes 'username' (string) and 'email' (string) - * spacetime.reducer( - * 'create_user', + * export const create_user = spacetime.reducer( * { * username: t.string(), * email: t.string(), @@ -155,29 +175,42 @@ class Schema { * ``` */ reducer( - name: string, params: Params, fn: Reducer - ): Reducer; - reducer(name: string, fn: Reducer): Reducer; + ): ReducerExport; + reducer(fn: Reducer): ReducerExport; reducer( name: string, - paramsOrFn: Params | Reducer, - fn?: Reducer - ): Reducer { - if (typeof paramsOrFn === 'function') { - // This is the case where params are omitted. - // The second argument is the reducer function. - // We pass an empty object for the params. - reducer(this.#ctx, name, {}, paramsOrFn); - return paramsOrFn; - } else { - // This is the case where params are provided. - // The second argument is the params object, and the third is the function. - // The `fn` parameter is guaranteed to be defined here. - reducer(this.#ctx, name, paramsOrFn, fn!); - return fn!; + params: Params, + fn: Reducer + ): ReducerExport; + reducer(name: string, fn: Reducer): ReducerExport; + reducer( + ...args: + | [Params, Reducer] + | [Reducer] + | [string, Params, Reducer] + | [string, Reducer] + ): ReducerExport { + let name: string | undefined, + params: Params = {} as Params, + fn: Reducer; + switch (args.length) { + case 1: + [fn] = args; + break; + case 2: { + let arg1; + [arg1, fn] = args; + if (typeof arg1 === 'string') name = arg1; + else params = arg1; + break; + } + case 3: + [name, params, fn] = args; + break; } + return makeReducerExport(this.#ctx, name, params, fn); } /** @@ -192,17 +225,19 @@ class Schema { * - `ctx`: The reducer context, providing access to `sender`, `timestamp`, `connection_id`, and `db`. * @example * ```typescript - * spacetime.init((ctx) => { + * export const init = spacetime.init((ctx) => { * ctx.db.user.insert({ username: 'admin', email: 'admin@example.com' }); * }); * ``` */ - init(fn: Reducer): void; - init(name: string, fn: Reducer): void; - init(nameOrFn: any, maybeFn?: Reducer): void { + init(fn: Reducer): ReducerExport; + init(name: string, fn: Reducer): ReducerExport; + init(nameOrFn: any, maybeFn?: Reducer): ReducerExport { const [name, fn] = - typeof nameOrFn === 'string' ? [nameOrFn, maybeFn] : ['init', nameOrFn]; - init(this.#ctx, name, {}, fn); + typeof nameOrFn === 'string' + ? [nameOrFn, maybeFn] + : [undefined, nameOrFn]; + return makeReducerExport(this.#ctx, name, {}, fn, Lifecycle.Init); } /** @@ -215,20 +250,23 @@ class Schema { * * @example * ```typescript - * spacetime.clientConnected( + * export const onConnect = spacetime.clientConnected( * (ctx) => { * console.log(`Client ${ctx.connectionId} connected`); * } * ); */ - clientConnected(fn: Reducer): void; - clientConnected(name: string, fn: Reducer): void; - clientConnected(nameOrFn: any, maybeFn?: Reducer): void { + clientConnected(fn: Reducer): ReducerExport; + clientConnected(name: string, fn: Reducer): ReducerExport; + clientConnected( + nameOrFn: any, + maybeFn?: Reducer + ): ReducerExport { const [name, fn] = typeof nameOrFn === 'string' ? [nameOrFn, maybeFn] - : ['on_connect', nameOrFn]; - clientConnected(this.#ctx, name, {}, fn); + : [undefined, nameOrFn]; + return makeReducerExport(this.#ctx, name, {}, fn, Lifecycle.OnConnect); } /** @@ -241,29 +279,32 @@ class Schema { * * @example * ```typescript - * spacetime.clientDisconnected( + * export const onDisconnect = spacetime.clientDisconnected( * (ctx) => { * console.log(`Client ${ctx.connectionId} disconnected`); * } * ); * ``` */ - clientDisconnected(fn: Reducer): void; - clientDisconnected(name: string, fn: Reducer): void; - clientDisconnected(nameOrFn: any, maybeFn?: Reducer): void { + clientDisconnected(fn: Reducer): ReducerExport; + clientDisconnected(name: string, fn: Reducer): ReducerExport; + clientDisconnected( + nameOrFn: any, + maybeFn?: Reducer + ): ReducerExport { const [name, fn] = typeof nameOrFn === 'string' ? [nameOrFn, maybeFn] : ['on_disconnect', nameOrFn]; - clientDisconnected(this.#ctx, name, {}, fn); + return makeReducerExport(this.#ctx, name, {}, fn, Lifecycle.OnDisconnect); } - view( + view>( opts: ViewOpts, ret: Ret, - fn: ViewFn - ): void { - defineView(this.#ctx, opts, false, {}, ret, fn); + fn: F + ): ViewExport { + return makeViewExport(this.#ctx, opts, {}, ret, fn); } // TODO: re-enable once parameterized views are supported in SQL @@ -291,12 +332,11 @@ class Schema { // } // } - anonymousView( - opts: ViewOpts, - ret: Ret, - fn: AnonymousViewFn - ): void { - defineView(this.#ctx, opts, true, {}, ret, fn); + anonymousView< + Ret extends ViewReturnTypeBuilder, + F extends AnonymousViewFn, + >(opts: ViewOpts, ret: Ret, fn: F): ViewExport { + return makeAnonViewExport(this.#ctx, opts, {}, ret, fn); } // TODO: re-enable once parameterized views are supported in SQL @@ -324,6 +364,15 @@ class Schema { // } // } + procedure>( + params: Params, + ret: Ret, + fn: ProcedureFn + ): ProcedureFn; + procedure>( + ret: Ret, + fn: ProcedureFn + ): ProcedureFn; procedure>( name: string, params: Params, @@ -336,27 +385,60 @@ class Schema { fn: ProcedureFn ): ProcedureFn; procedure>( - name: string, - paramsOrRet: Ret | Params, - retOrFn: ProcedureFn | Ret, - maybeFn?: ProcedureFn - ): ProcedureFn { - if (typeof retOrFn === 'function') { - procedure(this.#ctx, name, {}, paramsOrRet as Ret, retOrFn); - return retOrFn; - } else { - procedure(this.#ctx, name, paramsOrRet as Params, retOrFn, maybeFn!); - return maybeFn!; + ...args: + | [Params, Ret, ProcedureFn] + | [Ret, ProcedureFn] + | [string, Params, Ret, ProcedureFn] + | [string, Ret, ProcedureFn] + ): ProcedureExport { + let name: string | undefined, + params: Params = {} as Params, + ret: Ret, + fn: ProcedureFn; + switch (args.length) { + case 2: + [ret, fn] = args; + break; + case 3: { + let arg1; + [arg1, ret, fn] = args; + if (typeof arg1 === 'string') name = arg1; + else params = arg1; + break; + } + case 4: + [name, params, ret, fn] = args; + break; } + return makeProcedureExport(this.#ctx, name, params, ret, fn); } clientVisibilityFilter = { - sql: (filter: string) => { - this.#ctx.moduleDef.rowLevelSecurity.push({ sql: filter }); - }, + sql: (filter: string): ModuleExport => ({ + [exportContext]: this.#ctx, + [registerExport](ctx, _exportName) { + ctx.moduleDef.rowLevelSecurity.push({ sql: filter }); + }, + }), }; } +export const registerExport = Symbol('SpacetimeDB.registerExport'); +export const exportContext = Symbol('SpacetimeDB.exportContext'); + +export interface ModuleExport { + [registerExport](ctx: SchemaInner, exportName: string): void; + [exportContext]?: SchemaInner; +} + +function isModuleExport(x: unknown): x is ModuleExport { + return ( + (typeof x === 'function' || typeof x === 'object') && + x !== null && + registerExport in x + ); +} + /** * Extracts the inferred schema type from a Schema instance */ @@ -414,7 +496,5 @@ export function schema( return tablesToSchema(ctx, handles); }); - REGISTERED_SCHEMA = ctx; - return new Schema(ctx); } diff --git a/crates/bindings-typescript/src/server/sys.d.ts b/crates/bindings-typescript/src/server/sys.d.ts index 1d6c6628ff0..1bf078e400d 100644 --- a/crates/bindings-typescript/src/server/sys.d.ts +++ b/crates/bindings-typescript/src/server/sys.d.ts @@ -6,7 +6,13 @@ declare module 'spacetime:sys@2.0' { export type u128 = bigint; export type u256 = bigint; - export type ModuleHooks = { + export const moduleHooks: unique symbol; + + interface ModuleDefaultExport { + [moduleHooks](exports: object): ModuleHooks; + } + + export interface ModuleHooks { __describe_module__(): Uint8Array; __call_reducer__( @@ -28,7 +34,7 @@ declare module 'spacetime:sys@2.0' { timestamp: bigint, args: Uint8Array ): Uint8Array; - }; + } export function register_hooks(hooks: ModuleHooks); diff --git a/crates/bindings-typescript/src/server/views.ts b/crates/bindings-typescript/src/server/views.ts index dcd56d4e628..b1321d415cd 100644 --- a/crates/bindings-typescript/src/server/views.ts +++ b/crates/bindings-typescript/src/server/views.ts @@ -19,7 +19,58 @@ import { } from '../lib/type_builders'; import { bsatnBaseSize, toPascalCase } from '../lib/util'; import { type QueryBuilder, type RowTypedQuery } from './query'; -import type { SchemaInner } from './schema'; +import { + exportContext, + registerExport, + type ModuleExport, + type SchemaInner, +} from './schema'; + +export type ViewExport = ViewFn & ModuleExport; + +export function makeViewExport< + S extends UntypedSchemaDef, + Params extends ParamsObj, + Ret extends ViewReturnTypeBuilder, + F extends ViewFn, +>( + ctx: SchemaInner, + opts: ViewOpts, + params: Params, + ret: Ret, + fn: F +): ViewExport { + const viewExport = + // @ts-expect-error typescript incorrectly says Function#bind requires an argument. + fn.bind() as ViewExport; + viewExport[exportContext] = ctx; + viewExport[registerExport] = (ctx, exportName) => { + registerView(ctx, opts, exportName, false, params, ret, fn); + }; + return viewExport; +} + +export function makeAnonViewExport< + S extends UntypedSchemaDef, + Params extends ParamsObj, + Ret extends ViewReturnTypeBuilder, + F extends AnonymousViewFn, +>( + ctx: SchemaInner, + opts: ViewOpts, + params: Params, + ret: Ret, + fn: F +): ViewExport { + const viewExport = + // @ts-expect-error typescript incorrectly says Function#bind requires an argument. + fn.bind() as ViewExport; + viewExport[exportContext] = ctx; + viewExport[registerExport] = (ctx, exportName) => { + registerView(ctx, opts, exportName, true, params, ret, fn); + }; + return viewExport; +} export type ViewCtx = Readonly<{ sender: Identity; @@ -37,7 +88,7 @@ export type ReadonlyDbView = { }; export type ViewOpts = { - name: string; + name?: string; public: true; }; @@ -80,7 +131,7 @@ export type ViewReturnTypeBuilder = OptionAlgebraicType >; -export function defineView< +export function registerView< S extends UntypedSchemaDef, const Anonymous extends boolean, Params extends ParamsObj, @@ -88,6 +139,7 @@ export function defineView< >( ctx: SchemaInner, opts: ViewOpts, + exportName: string, anon: Anonymous, params: Params, ret: Ret, @@ -95,7 +147,8 @@ export function defineView< ? AnonymousViewFn : ViewFn ) { - const paramsBuilder = new RowBuilder(params, toPascalCase(opts.name)); + const name = opts.name ?? exportName; + const paramsBuilder = new RowBuilder(params, toPascalCase(name)); // Register return types if they are product types let returnType = ctx.registerTypesRecursively(ret).algebraicType; @@ -109,7 +162,7 @@ export function defineView< ctx.moduleDef.miscExports.push({ tag: 'View', value: { - name: opts.name, + name, index: (anon ? ctx.anonViews : ctx.views).length, isPublic: opts.public, isAnonymous: anon, diff --git a/crates/bindings-typescript/tsup.config.ts b/crates/bindings-typescript/tsup.config.ts index 962453c7f0b..491a90ae3d8 100644 --- a/crates/bindings-typescript/tsup.config.ts +++ b/crates/bindings-typescript/tsup.config.ts @@ -189,10 +189,7 @@ export default defineConfig([ '(globalThis.window=globalThis.window||globalThis));', }, treeshake: { - moduleSideEffects: [ - 'src/server/polyfills.ts', - 'src/server/register_hooks.ts', - ], + moduleSideEffects: ['src/server/polyfills.ts'], }, external: ['undici', /^spacetime:sys.*$/], noExternal: ['base64-js', 'fast-text-encoding', 'statuses', 'pure-rand'], diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index c680262a8cc..c06537d5042 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -78,6 +78,7 @@ clap-markdown.workspace = true git2.workspace = true dialoguer = { workspace = true, features = ["fuzzy-select"] } rolldown.workspace = true +rolldown_common.workspace = true rolldown_utils.workspace = true xmltree.workspace = true quick-xml.workspace = true diff --git a/crates/cli/src/tasks/javascript.rs b/crates/cli/src/tasks/javascript.rs index 8e2b8c604db..4ec52526fdd 100644 --- a/crates/cli/src/tasks/javascript.rs +++ b/crates/cli/src/tasks/javascript.rs @@ -1,3 +1,4 @@ +use anyhow::Context; use regex::Regex; use rolldown::{Bundler, BundlerOptions, Either, SourceMapType}; use rolldown_utils::indexmap::FxIndexMap; @@ -180,5 +181,39 @@ pub(crate) fn build_javascript(project_path: &Path, build_debug: bool) -> anyhow eprintln!("Rolldown warning: {w}"); }); + let output_chunk = bundle_output + .assets + .into_iter() + .find_map(|chunk| match chunk { + rolldown_common::Output::Chunk(chunk) if chunk.is_entry && chunk.filename == "bundle.js" => Some(chunk), + _ => None, + }) + .expect("there should be an output chunk"); + + let sys_imports = output_chunk.imports.iter().filter_map(|spec| { + let (maj, min) = spec.strip_prefix("spacetime:sys@")?.split_once('.')?; + Option::zip(maj.parse::().ok(), min.parse::().ok()) + }); + + let mut maj_sys_ver = None; + for (maj, _min) in sys_imports { + anyhow::ensure!( + *maj_sys_ver.get_or_insert(maj) == maj, + "The module pulls in 2 different versions of the `spacetimedb/server` package" + ); + } + + let maj_sys_ver = maj_sys_ver.context( + "Your module doesn't import the `spacetimedb/server` package at all - \ + this is likely a mistake, as your module will not be able to do anything", + )?; + + if maj_sys_ver == 2 { + anyhow::ensure!( + output_chunk.exports.contains(&"default".into()), + "It seems like you haven't exported your schema. You must `export default spacetime;`" + ); + } + Ok(project_path.join("dist").join("bundle.js")) } From ac98ef9be07ebfb14efff38376747a0453ca9a97 Mon Sep 17 00:00:00 2001 From: Noa Date: Wed, 4 Feb 2026 13:20:02 -0600 Subject: [PATCH 2/2] Host side --- crates/core/src/host/v8/error.rs | 11 ++- crates/core/src/host/v8/mod.rs | 100 +++++++++---------- crates/core/src/host/v8/syscall/common.rs | 8 +- crates/core/src/host/v8/syscall/hooks.rs | 6 +- crates/core/src/host/v8/syscall/mod.rs | 23 ++++- crates/core/src/host/v8/syscall/v2.rs | 114 +++++++++++++--------- 6 files changed, 157 insertions(+), 105 deletions(-) diff --git a/crates/core/src/host/v8/error.rs b/crates/core/src/host/v8/error.rs index 22ffc292ece..c8084e2f691 100644 --- a/crates/core/src/host/v8/error.rs +++ b/crates/core/src/host/v8/error.rs @@ -615,7 +615,7 @@ pub(super) fn log_traceback(replica_ctx: &ReplicaContext, func_type: &str, func: } /// Run `body` within a try-catch context and capture any JS exception thrown as a [`JsError`]. -pub(super) fn catch_exception<'scope, T>( +pub(super) fn catch_exception_continue<'scope, T>( scope: &mut PinScope<'scope, '_>, body: impl FnOnce(&mut PinScope<'scope, '_>) -> Result>, ) -> Result, CanContinue)> { @@ -641,6 +641,15 @@ pub(super) fn catch_exception<'scope, T>( }) } +/// Run `body` within a try-catch context and capture any JS exception thrown as a [`JsError`]. +pub(super) fn catch_exception<'scope, T>( + scope: &mut PinScope<'scope, '_>, + body: impl FnOnce(&mut PinScope<'scope, '_>) -> Result>, +) -> Result> { + tc_scope!(scope, scope); + catch_exception_continue(scope, body).map_err(|(e, _)| e) +} + /// Encodes whether it is safe to continue using the [`Isolate`] /// for further execution after [`catch_exception`] has happened. #[derive(Debug)] diff --git a/crates/core/src/host/v8/mod.rs b/crates/core/src/host/v8/mod.rs index e21a08e5747..1f67ae688f2 100644 --- a/crates/core/src/host/v8/mod.rs +++ b/crates/core/src/host/v8/mod.rs @@ -1,7 +1,7 @@ use self::budget::energy_from_elapsed; use self::error::{ - catch_exception, exception_already_thrown, log_traceback, CanContinue, ErrorOrException, ExcResult, - ExceptionThrown, Throwable, + catch_exception, catch_exception_continue, exception_already_thrown, log_traceback, CanContinue, ErrorOrException, + ExcResult, ExceptionThrown, Throwable, }; use self::ser::serialize_to_js; use self::string::{str_from_ident, IntoJsString}; @@ -31,7 +31,6 @@ use crate::module_host_context::ModuleCreationContext; use crate::replica_context::ReplicaContext; use crate::subscription::module_subscription_manager::TransactionOffset; use crate::util::jobs::{AllocatedJobCore, CorePinner, LoadBalanceOnDropGuard}; -use anyhow::Context as _; use core::any::type_name; use core::str; use enum_as_inner::EnumAsInner; @@ -508,21 +507,25 @@ fn startup_instance_worker<'scope>( scope: &mut PinScope<'scope, '_>, program: Arc, module_or_mcc: Either, -) -> anyhow::Result<(HookFunctions<'scope>, Either)> { - // Start-up the user's module. - eval_user_module_catch(scope, &program).map_err(DescribeError::Setup)?; +) -> anyhow::Result<(HookFunctions<'scope>, ModuleCommon)> { + let hook_functions = catch_exception(scope, |scope| { + // Start-up the user's module. + let exports_obj = eval_user_module(scope, &program)?; - // Find the `__call_reducer__` function. - let hook_functions = get_hooks(scope).context("The `spacetimedb/server` module was never imported")?; + // Find the `__call_reducer__` function. + let hooks = + get_hooks(scope, exports_obj)?.ok_or_else(|| anyhow::anyhow!("must export schema as default export"))?; + Ok(hooks) + })?; // If we don't have a module, make one. let module_common = match module_or_mcc { - Either::Left(module_common) => Either::Left(module_common), + Either::Left(module_common) => module_common, Either::Right(mcc) => { let def = extract_description(scope, &hook_functions, &mcc.replica_ctx)?; // Validate and create a common module from the raw definition. - Either::Right(build_common_module_from_raw(mcc, def)?) + build_common_module_from_raw(mcc, def)? } }; @@ -578,6 +581,13 @@ async fn spawn_instance_worker( let mut isolate = new_isolate(); scope_with_context!(let scope, &mut isolate, Context::new(scope, Default::default())); + let (replica_ctx, scheduler) = match &module_or_mcc { + Either::Left(module) => (module.replica_ctx(), module.scheduler()), + Either::Right(mcc) => (&mcc.replica_ctx, &mcc.scheduler), + }; + let instance_env = InstanceEnv::new(replica_ctx.clone(), scheduler.clone()); + scope.set_slot(JsInstanceEnv::new(instance_env)); + catch_exception(scope, |scope| Ok(builtins::evalute_builtins(scope)?)) .expect("our builtin code shouldn't error"); @@ -596,7 +606,6 @@ async fn spawn_instance_worker( } Ok((crf, module_common)) => { // Success! Send `module_common` to the spawner. - let module_common = module_common.into_inner(); send_result(Ok(module_common.clone())); (crf, module_common) } @@ -606,9 +615,6 @@ async fn spawn_instance_worker( let info = &module_common.info(); let mut instance_common = InstanceCommon::new(&module_common); let replica_ctx: &Arc = module_common.replica_ctx(); - let scheduler = module_common.scheduler().clone(); - let instance_env = InstanceEnv::new(replica_ctx.clone(), scheduler); - scope.set_slot(JsInstanceEnv::new(instance_env)); // Create a zero-initialized buffer for holding reducer args. // Arguments needing more space will not use this. @@ -737,7 +743,7 @@ fn eval_module<'scope>( resource_name: Local<'scope, Value>, code: Local<'_, v8::String>, resolve_deps: impl MapFnTo>, -) -> ExcResult<(Local<'scope, v8::Module>, Local<'scope, v8::Promise>)> { +) -> ExcResult> { // Assemble the source. v8 figures out things like the `script_id` and // `source_map_url` itself, so we don't actually have to provide them. let origin = ScriptOrigin::new(scope, resource_name, 0, 0, false, 0, None, false, false, true, None); @@ -763,36 +769,26 @@ fn eval_module<'scope>( } let value = value.cast::(); - if value.state() == v8::PromiseState::Pending { - // If the user were to put top-level `await new Promise((resolve) => { /* do nothing */ })` - // the module value would never actually resolve. For now, reject this entirely. - return Err(error::TypeError("module has top-level await and is pending").throw(scope)); + match value.state() { + v8::PromiseState::Pending => { + // If the user were to put top-level `await new Promise((resolve) => { /* do nothing */ })` + // the module value would never actually resolve. For now, reject this entirely. + Err(error::TypeError("module has top-level await and is pending").throw(scope)) + } + v8::PromiseState::Rejected => Err(error::ExceptionValue(value.result(scope)).throw(scope)), + v8::PromiseState::Fulfilled => Ok(module), } - - Ok((module, value)) } /// Compiles, instantiate, and evaluate the user module with `code`. -fn eval_user_module<'scope>( - scope: &mut PinScope<'scope, '_>, - code: &str, -) -> ExcResult<(Local<'scope, v8::Module>, Local<'scope, v8::Promise>)> { +/// Returns the exports of the module. +fn eval_user_module<'scope>(scope: &mut PinScope<'scope, '_>, code: &str) -> ExcResult> { // Convert the code to a string. let code = code.into_string(scope).map_err(|e| e.into_range_error().throw(scope))?; let name = str_from_ident!(spacetimedb_module).string(scope).into(); - eval_module(scope, name, code, resolve_sys_module) -} - -/// Compiles, instantiate, and evaluate the user module with `code` -/// and catch any exceptions. -fn eval_user_module_catch<'scope>(scope: &mut PinScope<'scope, '_>, program: &str) -> anyhow::Result<()> { - catch_exception(scope, |scope| { - eval_user_module(scope, program)?; - Ok(()) - }) - .map_err(|(e, _)| e) - .map_err(Into::into) + let module = eval_module(scope, name, code, resolve_sys_module)?; + Ok(module.get_module_namespace().cast()) } /// Calls free function `fun` with `args`. @@ -886,7 +882,7 @@ where // We'd like this tightly around `call`. env.start_funcall(op.name().clone(), op.timestamp(), op.call_type()); - let call_result = catch_exception(scope, |scope| call(scope, op)).map_err(|(e, can_continue)| { + let call_result = catch_exception_continue(scope, |scope| call(scope, op)).map_err(|(e, can_continue)| { // Convert `can_continue` to whether the isolate has "trapped". // Also cancel execution termination if needed, // that can occur due to terminating long running reducers. @@ -929,11 +925,10 @@ fn extract_description<'scope>( DESCRIBE_MODULE_DUNDER, |a, b, c| log_traceback(replica_ctx, a, b, c), || { - catch_exception(scope, |scope| { + Ok(catch_exception(scope, |scope| { let def = call_describe_module(scope, hooks)?; Ok(def) - }) - .map_err(|(e, _)| e.into()) + })?) }, ) } @@ -951,24 +946,25 @@ mod test { fn with_module_catch( code: &str, - logic: impl for<'scope> FnOnce(&mut PinScope<'scope, '_>) -> Result>, + logic: impl for<'scope> FnOnce( + &mut PinScope<'scope, '_>, + Local, + ) -> Result>, ) -> anyhow::Result { with_scope(|scope| { - eval_user_module_catch(scope, code).unwrap(); - catch_exception(scope, |scope| { - let ret = logic(scope)?; + Ok(catch_exception(scope, |scope| { + let exports = eval_user_module(scope, code)?; + let ret = logic(scope, exports)?; Ok(ret) - }) - .map_err(|(e, _)| e) - .map_err(anyhow::Error::from) + })?) }) } #[test] fn call_call_reducer_works() { let call = |code| { - with_module_catch(code, |scope| { - let hooks = get_hooks(scope).unwrap(); + with_module_catch(code, |scope, exports| { + let hooks = get_hooks(scope, exports)?.unwrap(); let op = ReducerOp { id: ReducerId(42), name: &ReducerName::for_test("foobar"), @@ -1047,8 +1043,8 @@ js error Uncaught Error: foobar }, }) "#; - let raw_mod = with_module_catch(code, |scope| { - let hooks = get_hooks(scope).unwrap(); + let raw_mod = with_module_catch(code, |scope, exports| { + let hooks = get_hooks(scope, exports)?.unwrap(); call_describe_module(scope, &hooks) }) .map_err(|e| e.to_string()); diff --git a/crates/core/src/host/v8/syscall/common.rs b/crates/core/src/host/v8/syscall/common.rs index b3de098f645..edea62f3b01 100644 --- a/crates/core/src/host/v8/syscall/common.rs +++ b/crates/core/src/host/v8/syscall/common.rs @@ -1,5 +1,4 @@ use super::super::{ - call_free_fun, de::deserialize_js, de::scratch_buf, env_on_isolate, @@ -51,7 +50,7 @@ pub fn call_call_procedure( let args = &[procedure_id, sender, connection_id, timestamp, procedure_args]; // Call the function. - let ret = call_free_fun(scope, fun, args)?; + let ret = fun.call(scope, hooks.recv, args).ok_or_else(exception_already_thrown)?; // Deserialize the user result. let ret = @@ -67,7 +66,10 @@ pub fn call_describe_module( hooks: &HookFunctions<'_>, ) -> Result> { // Call the function. - let raw_mod_js = call_free_fun(scope, hooks.describe_module, &[])?; + let raw_mod_js = hooks + .describe_module + .call(scope, hooks.recv, &[]) + .ok_or_else(exception_already_thrown)?; // Deserialize the raw module. let raw_mod = cast!( diff --git a/crates/core/src/host/v8/syscall/hooks.rs b/crates/core/src/host/v8/syscall/hooks.rs index 7a9207ee091..71f30aecbdb 100644 --- a/crates/core/src/host/v8/syscall/hooks.rs +++ b/crates/core/src/host/v8/syscall/hooks.rs @@ -97,6 +97,7 @@ impl HooksInfo { /// The actual callable module hook functions and their abi version. pub(in super::super) struct HookFunctions<'scope> { pub abi: AbiVersion, + pub recv: Local<'scope, v8::Value>, /// describe_module and call_reducer existed in v1.0, but everything else is `Option`al pub describe_module: Local<'scope, Function>, pub call_reducer: Local<'scope, Function>, @@ -106,7 +107,9 @@ pub(in super::super) struct HookFunctions<'scope> { } /// Returns the hook function previously registered in [`register_hooks`]. -pub(in super::super) fn get_hooks<'scope>(scope: &mut PinScope<'scope, '_>) -> Option> { +pub(in super::super) fn get_registered_hooks<'scope>( + scope: &mut PinScope<'scope, '_>, +) -> Option> { let ctx = scope.get_current_context(); let hooks = ctx.get_slot::()?; @@ -120,6 +123,7 @@ pub(in super::super) fn get_hooks<'scope>(scope: &mut PinScope<'scope, '_>) -> O Some(HookFunctions { abi: hooks.abi, + recv: v8::undefined(scope).into(), describe_module: get(ModuleHookKey::DescribeModule)?, call_reducer: get(ModuleHookKey::CallReducer)?, call_view: get(ModuleHookKey::CallView), diff --git a/crates/core/src/host/v8/syscall/mod.rs b/crates/core/src/host/v8/syscall/mod.rs index 8175397f829..f1b7e49cc80 100644 --- a/crates/core/src/host/v8/syscall/mod.rs +++ b/crates/core/src/host/v8/syscall/mod.rs @@ -1,5 +1,6 @@ use crate::host::v8::de::scratch_buf; use crate::host::v8::error::{ErrorOrException, ExcResult, ExceptionThrown, Throwable, TypeError}; +use crate::host::v8::exception_already_thrown; use crate::host::wasm_common::abi::parse_abi_version; use crate::host::wasm_common::module_host_actor::{AnonymousViewOp, ReducerOp, ReducerResult, ViewOp, ViewReturnData}; use spacetimedb_lib::VersionTuple; @@ -10,7 +11,7 @@ mod hooks; mod v1; mod v2; -pub(super) use self::hooks::{get_hooks, HookFunctions, ModuleHookKey}; +pub(super) use self::hooks::{get_registered_hooks, HookFunctions, ModuleHookKey}; /// The return type of a module -> host syscall. pub(super) type FnRet<'scope> = ExcResult>; @@ -112,3 +113,23 @@ pub(super) fn call_call_view_anon( } pub use self::common::{call_call_procedure, call_describe_module}; + +pub(super) fn get_hooks<'scope>( + scope: &mut PinScope<'scope, '_>, + exports_obj: Local<'_, v8::Object>, +) -> Result>, ErrorOrException> { + if let Some(hooks) = get_registered_hooks(scope) { + return Ok(Some(hooks)); + } + + let default = super::str_from_ident!(default).string(scope); + let default_export = exports_obj + .get(scope, default.into()) + .ok_or_else(exception_already_thrown)?; + if default_export.is_null_or_undefined() { + return Ok(None); + } + let hooks = v2::get_hooks_from_default_export(scope, default_export, exports_obj)? + .ok_or_else(|| anyhow::anyhow!("default export is not a Schema object"))?; + Ok(Some(hooks)) +} diff --git a/crates/core/src/host/v8/syscall/v2.rs b/crates/core/src/host/v8/syscall/v2.rs index d160dcb2c50..44e6742d3a3 100644 --- a/crates/core/src/host/v8/syscall/v2.rs +++ b/crates/core/src/host/v8/syscall/v2.rs @@ -8,17 +8,18 @@ use super::super::ser::serialize_to_js; use super::super::string::{str_from_ident, StringConst}; use super::super::to_value::ToValue; use super::super::util::{make_dataview, make_uint8array}; -use super::super::{call_free_fun, env_on_isolate, Throwable}; +use super::super::{env_on_isolate, Throwable}; use super::common::{ console_log, console_timer_end, console_timer_start, datastore_index_scan_range_bsatn_inner, datastore_table_row_count, datastore_table_scan_bsatn, deserialize_row_iter_idx, get_env, identity, index_id_from_name, procedure_abort_mut_tx, procedure_commit_mut_tx, procedure_http_request, procedure_start_mut_tx, row_iter_bsatn_close, table_id_from_name, volatile_nonatomic_schedule_immediate, }; +use super::hooks::get_hook_function; use super::hooks::HookFunctions; -use super::hooks::{get_hook_function, set_hook_slots}; -use super::{AbiVersion, ModuleHookKey}; +use super::AbiVersion; use crate::host::instance_env::InstanceEnv; +use crate::host::v8::exception_already_thrown; use crate::host::wasm_common::instrumentation::span; use crate::host::wasm_common::module_host_actor::{AnonymousViewOp, ReducerOp, ReducerResult, ViewOp, ViewReturnData}; use crate::host::wasm_common::RowIterIdx; @@ -31,19 +32,15 @@ use spacetimedb_primitives::{ColId, IndexId, ReducerId, TableId, ViewFnPtr}; use spacetimedb_sats::u256; use v8::{ callback_scope, ArrayBuffer, ConstructorBehavior, DataView, Function, FunctionCallbackArguments, Local, Module, - Object, PinCallbackScope, PinScope, Value, + Object, PinScope, Value, }; macro_rules! create_synthetic_module { - ($scope:expr, $module_name:expr $(, ($wrapper:ident, $abi_call:expr, $fun:ident))* $(,)?) => {{ - let export_names = &[$(str_from_ident!($fun).string($scope)),*]; - let eval_steps = |context, module| { + ($scope:expr, $module_name:expr $(, ($($fun:tt)*))* $(,)?) => {{ + let export_names = &[$(create_synthetic_module!(@export_name ($($fun)*)).string($scope)),*]; + let eval_steps = |context: v8::Local, module: v8::Local| { callback_scope!(unsafe scope, context); - $( - register_module_fun(scope, &module, str_from_ident!($fun), |s, a, rv| { - $wrapper($abi_call, s, a, rv, $fun) - })?; - )* + $(create_synthetic_module!(@register scope, &module, ($($fun)*));)* Some(v8::undefined(scope).into()) }; @@ -54,16 +51,31 @@ macro_rules! create_synthetic_module { export_names, eval_steps, ) - }} + }}; + (@export_name ($wrapper:ident, $abi_call:expr, $fun:ident)) => { + str_from_ident!($fun) + }; + (@register $scope:expr, $module:expr, ($wrapper:ident, $abi_call:expr, $fun:ident)) => { + register_module_fun($scope, $module, str_from_ident!($fun), |s, a, rv| { + $wrapper($abi_call, s, a, rv, $fun) + })?; + }; + (@export_name ($name:ident = $value:expr)) => { + str_from_ident!($name) + }; + (@register $scope:expr, $module:expr, ($name:ident = $value:expr)) => { + let name = str_from_ident!($name).string($scope); + let value = $value($scope); + $module.set_synthetic_module_export($scope, name, value.into())?; + }; } /// Registers all module -> host syscalls in the JS module `spacetimedb_sys`. pub(super) fn sys_v2_0<'scope>(scope: &mut PinScope<'scope, '_>) -> Local<'scope, Module> { - use register_hooks_v2_0 as register_hooks; create_synthetic_module!( scope, "spacetime:sys@2.0", - (with_nothing, (), register_hooks), + (moduleHooks = hooks_symbol), (with_sys_result, AbiCall::TableIdFromName, table_id_from_name), (with_sys_result, AbiCall::IndexIdFromName, index_id_from_name), ( @@ -137,7 +149,7 @@ pub(super) fn sys_v2_0<'scope>(scope: &mut PinScope<'scope, '_>) -> Local<'scope /// Registers a function in `module` /// where the function has `name` and does `body`. fn register_module_fun( - scope: &mut PinCallbackScope<'_, '_>, + scope: &mut PinScope<'_, '_>, module: &Local<'_, Module>, name: &'static StringConst, body: impl Copy + for<'scope> Fn(&mut PinScope<'scope, '_>, FunctionCallbackArguments<'scope>, v8::ReturnValue<'_>), @@ -234,19 +246,6 @@ fn with_sys_result<'scope, O: JsReturnValue>( } } -/// A higher order function conforming to the interface of [`with_sys_result`]. -fn with_nothing<'scope, O: JsReturnValue>( - (): (), - scope: &mut PinScope<'scope, '_>, - args: FunctionCallbackArguments<'scope>, - rv: v8::ReturnValue<'_>, - run: impl FnOnce(&mut PinScope<'scope, '_>, FunctionCallbackArguments<'scope>) -> ExcResult, -) { - if let Ok(ret) = run(scope, args) { - ret.set_return(scope, rv) - } -} - /// Module ABI that registers the functions called by the host. /// /// # Signature @@ -280,9 +279,26 @@ fn with_nothing<'scope, O: JsReturnValue>( /// /// Throws a `TypeError` if: /// - `hooks` is not an object that has functions `__describe_module__` and `__call_reducer__`. -fn register_hooks_v2_0<'scope>(scope: &mut PinScope<'scope, '_>, args: FunctionCallbackArguments<'_>) -> ExcResult<()> { +pub fn get_hooks_from_default_export<'scope>( + scope: &mut PinScope<'scope, '_>, + default_export: Local<'_, v8::Value>, + exports_obj: Local<'_, v8::Object>, +) -> ExcResult>> { // Convert `hooks` to an object. - let hooks = cast!(scope, args.get(0), Object, "hooks object").map_err(|e| e.throw(scope))?; + let hooks_fn = default_export + .try_cast::() + .ok() + .map(|obj| { + let symbol = hooks_symbol(scope); + obj.get(scope, symbol.into()).ok_or_else(exception_already_thrown) + }) + .transpose()?; + let Some(hooks_fn) = hooks_fn else { return Ok(None) }; + let hooks_fn = cast!(scope, hooks_fn, v8::Function, "hooks function").map_err(|e| e.throw(scope))?; + let hooks = hooks_fn + .call(scope, default_export, &[exports_obj.into()]) + .ok_or_else(exception_already_thrown)?; + let hooks = cast!(scope, hooks, Object, "hooks object").map_err(|e| e.throw(scope))?; let describe_module = get_hook_function(scope, hooks, str_from_ident!(__describe_module__))?; let call_reducer = get_hook_function(scope, hooks, str_from_ident!(__call_reducer__))?; @@ -291,19 +307,20 @@ fn register_hooks_v2_0<'scope>(scope: &mut PinScope<'scope, '_>, args: FunctionC let call_procedure = get_hook_function(scope, hooks, str_from_ident!(__call_procedure__))?; // Set the hooks. - set_hook_slots( - scope, - AbiVersion::V2, - &[ - (ModuleHookKey::DescribeModule, describe_module), - (ModuleHookKey::CallReducer, call_reducer), - (ModuleHookKey::CallView, call_view), - (ModuleHookKey::CallAnonymousView, call_view_anon), - (ModuleHookKey::CallProcedure, call_procedure), - ], - )?; - - Ok(()) + Ok(Some(HookFunctions { + abi: AbiVersion::V2, + recv: hooks.into(), + describe_module, + call_reducer, + call_view: Some(call_view), + call_view_anon: Some(call_view_anon), + call_procedure: Some(call_procedure), + })) +} + +fn hooks_symbol<'scope>(scope: &mut PinScope<'scope, '_>) -> Local<'scope, v8::Symbol> { + let symbol = const { StringConst::new("SpacetimeDB.moduleHooks.v2") }.string(scope); + v8::Symbol::for_api(scope, symbol) } /// Calls the `__call_reducer__` function `fun`. @@ -331,7 +348,10 @@ pub(super) fn call_call_reducer<'scope>( let args = &[reducer_id, sender, conn_id, timestamp, reducer_args]; // Call the function. - let ret = call_free_fun(scope, hooks.call_reducer, args)?; + let ret = hooks + .call_reducer + .call(scope, hooks.recv, args) + .ok_or_else(exception_already_thrown)?; // Deserialize the user result. let user_res = if ret.is_undefined() { @@ -397,7 +417,7 @@ pub(super) fn call_call_view( let args = &[view_id, sender, view_args]; // Call the function. - let ret = call_free_fun(scope, fun, args)?; + let ret = fun.call(scope, hooks.recv, args).ok_or_else(exception_already_thrown)?; // Returns an object with a `data` field containing the bytes. let ret = cast!(scope, ret, v8::Object, "object return from `__call_view_anon__`").map_err(|e| e.throw(scope))?; @@ -445,7 +465,7 @@ pub(super) fn call_call_view_anon( let args = &[view_id, view_args]; // Call the function. - let ret = call_free_fun(scope, fun, args)?; + let ret = fun.call(scope, hooks.recv, args).ok_or_else(exception_already_thrown)?; let ret = cast!(scope, ret, v8::Object, "object return from `__call_view_anon__`").map_err(|e| e.throw(scope))?;