From 4e3d36de3a41d1c7a0bb439cd2de00a49b345587 Mon Sep 17 00:00:00 2001 From: Noa Date: Wed, 12 Nov 2025 12:01:19 -0600 Subject: [PATCH 1/3] Basic procedure calling --- .../bindings-typescript/src/lib/procedures.ts | 60 +++++++++ crates/bindings-typescript/src/lib/schema.ts | 27 ++++ .../src/server/procedures.ts | 40 ++++++ .../src/server/register_hooks.ts | 4 +- .../bindings-typescript/src/server/runtime.ts | 77 +++++++---- .../bindings-typescript/src/server/sys.d.ts | 14 ++ crates/core/src/error.rs | 2 + crates/core/src/host/host_controller.rs | 1 + crates/core/src/host/instance_env.rs | 95 +++++++++++++- crates/core/src/host/module_host.rs | 33 ++--- crates/core/src/host/v8/mod.rs | 53 ++++++-- crates/core/src/host/v8/syscall/hooks.rs | 4 + crates/core/src/host/v8/syscall/mod.rs | 16 ++- crates/core/src/host/v8/syscall/v1.rs | 82 +++++++++++- crates/core/src/host/wasm_common.rs | 1 + .../src/host/wasm_common/module_host_actor.rs | 24 ++-- .../src/host/wasmtime/wasm_instance_env.rs | 124 +++--------------- 17 files changed, 477 insertions(+), 180 deletions(-) create mode 100644 crates/bindings-typescript/src/lib/procedures.ts create mode 100644 crates/bindings-typescript/src/server/procedures.ts diff --git a/crates/bindings-typescript/src/lib/procedures.ts b/crates/bindings-typescript/src/lib/procedures.ts new file mode 100644 index 00000000000..37176c6441a --- /dev/null +++ b/crates/bindings-typescript/src/lib/procedures.ts @@ -0,0 +1,60 @@ +import { AlgebraicType, ProductType } from '../lib/algebraic_type'; +import type { ConnectionId } from '../lib/connection_id'; +import type { Identity } from '../lib/identity'; +import type { Timestamp } from '../lib/timestamp'; +import type { ParamsObj } from './reducers'; +import { MODULE_DEF, type UntypedSchemaDef } from './schema'; +import type { Infer, InferTypeOfRow, TypeBuilder } from './type_builders'; +import { bsatnBaseSize } from './util'; + +export type ProcedureFn< + S extends UntypedSchemaDef, + Params extends ParamsObj, + Ret extends TypeBuilder, +> = (ctx: ProcedureCtx, args: InferTypeOfRow) => Infer; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export interface ProcedureCtx { + readonly sender: Identity; + readonly identity: Identity; + readonly timestamp: Timestamp; + readonly connectionId: ConnectionId | null; +} + +export function procedure< + S extends UntypedSchemaDef, + Params extends ParamsObj, + Ret extends TypeBuilder, +>(name: string, params: Params, ret: Ret, fn: ProcedureFn) { + const paramsType: ProductType = { + elements: Object.entries(params).map(([n, c]) => ({ + name: n, + algebraicType: + 'typeBuilder' in c ? c.typeBuilder.algebraicType : c.algebraicType, + })), + }; + const returnType = ret.algebraicType; + + MODULE_DEF.miscExports.push({ + tag: 'Procedure', + value: { + name, + params: paramsType, + returnType, + }, + }); + + PROCEDURES.push({ + fn, + paramsType, + returnType, + returnTypeBaseSize: bsatnBaseSize(MODULE_DEF.typespace, returnType), + }); +} + +export const PROCEDURES: Array<{ + fn: ProcedureFn; + paramsType: ProductType; + returnType: AlgebraicType; + returnTypeBaseSize: number; +}> = []; diff --git a/crates/bindings-typescript/src/lib/schema.ts b/crates/bindings-typescript/src/lib/schema.ts index 4f48ebabdfc..51a0edae5e7 100644 --- a/crates/bindings-typescript/src/lib/schema.ts +++ b/crates/bindings-typescript/src/lib/schema.ts @@ -47,6 +47,7 @@ import { } from './views'; import RawIndexDefV9 from './autogen/raw_index_def_v_9_type'; import type { IndexOpts } from './indexes'; +import { procedure, type ProcedureFn } from './procedures'; export type TableNamesOf = S['tables'][number]['name']; @@ -547,6 +548,32 @@ class Schema { // } // } + procedure>( + name: string, + params: Params, + ret: Ret, + fn: ProcedureFn + ): ProcedureFn; + procedure>( + name: string, + ret: Ret, + fn: ProcedureFn + ): ProcedureFn; + procedure>( + name: string, + paramsOrRet: Ret | Params, + retOrFn: ProcedureFn | Ret, + maybeFn?: ProcedureFn + ): ProcedureFn { + if (typeof retOrFn === 'function') { + procedure(name, {}, paramsOrRet as Ret, retOrFn); + return retOrFn; + } else { + procedure(name, paramsOrRet as Params, retOrFn, maybeFn!); + return maybeFn!; + } + } + clientVisibilityFilter = { sql(filter: string): void { MODULE_DEF.rowLevelSecurity.push({ sql: filter }); diff --git a/crates/bindings-typescript/src/server/procedures.ts b/crates/bindings-typescript/src/server/procedures.ts new file mode 100644 index 00000000000..4cca8e3f0e0 --- /dev/null +++ b/crates/bindings-typescript/src/server/procedures.ts @@ -0,0 +1,40 @@ +import { AlgebraicType, ProductType } from '../lib/algebraic_type'; +import BinaryReader from '../lib/binary_reader'; +import BinaryWriter from '../lib/binary_writer'; +import type { ConnectionId } from '../lib/connection_id'; +import { Identity } from '../lib/identity'; +import { PROCEDURES, type ProcedureCtx } from '../lib/procedures'; +import { MODULE_DEF, type UntypedSchemaDef } from '../lib/schema'; +import type { Timestamp } from '../lib/timestamp'; +import { sys } from './runtime'; + +const { freeze } = Object; + +export function callProcedure( + id: number, + sender: Identity, + connectionId: ConnectionId | null, + timestamp: Timestamp, + argsBuf: Uint8Array +): Uint8Array { + const { fn, paramsType, returnType, returnTypeBaseSize } = PROCEDURES[id]; + const args = ProductType.deserializeValue( + new BinaryReader(argsBuf), + paramsType, + MODULE_DEF.typespace + ); + + const ctx: ProcedureCtx = freeze({ + sender, + timestamp, + connectionId, + get identity() { + return new Identity(sys.identity().__identity__); + }, + }); + + const ret = fn(ctx, args); + const retBuf = new BinaryWriter(returnTypeBaseSize); + AlgebraicType.serializeValue(retBuf, returnType, ret, MODULE_DEF.typespace); + return retBuf.getBuffer(); +} diff --git a/crates/bindings-typescript/src/server/register_hooks.ts b/crates/bindings-typescript/src/server/register_hooks.ts index c6d31521c4e..5861f347e78 100644 --- a/crates/bindings-typescript/src/server/register_hooks.ts +++ b/crates/bindings-typescript/src/server/register_hooks.ts @@ -1,6 +1,8 @@ import { register_hooks } from 'spacetime:sys@1.0'; import { register_hooks as register_hooks_v1_1 } from 'spacetime:sys@1.1'; -import { hooks, hooks_v1_1 } from './runtime'; +import { register_hooks as register_hooks_v1_2 } from 'spacetime:sys@1.2'; +import { hooks, hooks_v1_1, hooks_v1_2 } from './runtime'; register_hooks(hooks); register_hooks_v1_1(hooks_v1_1); +register_hooks_v1_2(hooks_v1_2); diff --git a/crates/bindings-typescript/src/server/runtime.ts b/crates/bindings-typescript/src/server/runtime.ts index 581f35ddd7f..55260520a78 100644 --- a/crates/bindings-typescript/src/server/runtime.ts +++ b/crates/bindings-typescript/src/server/runtime.ts @@ -1,54 +1,48 @@ -import { AlgebraicType } from '../lib/algebraic_type'; +import * as _syscalls1_0 from 'spacetime:sys@1.0'; +import * as _syscalls1_2 from 'spacetime:sys@1.2'; + +import type { ModuleHooks, u16, u32 } from 'spacetime:sys@1.0'; +import { AlgebraicType, ProductType } 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'; -import { Identity } from '../lib/identity'; -import { Timestamp } from '../lib/timestamp'; import BinaryReader from '../lib/binary_reader'; import BinaryWriter from '../lib/binary_writer'; -import { SenderError, SpacetimeHostError } from './errors'; -import { Range, type Bound } from './range'; +import { ConnectionId } from '../lib/connection_id'; +import { Identity } from '../lib/identity'; import { type Index, type IndexVal, - type UniqueIndex, type RangedIndex, + type UniqueIndex, } from '../lib/indexes'; -import { type RowType, type Table, type TableMethods } from '../lib/table'; +import { callProcedure } from './procedures'; import { - type ReducerCtx, REDUCERS, - type JwtClaims, type AuthCtx, type JsonObject, + type JwtClaims, + type ReducerCtx, } from '../lib/reducers'; import { MODULE_DEF } from '../lib/schema'; - -import * as _syscalls from 'spacetime:sys@1.0'; -import type { u16, u32, ModuleHooks } from 'spacetime:sys@1.0'; -import type { DbView } from './db_view'; -import { toCamelCase } from '../lib/util'; +import { type RowType, type Table, type TableMethods } from '../lib/table'; +import { Timestamp } from '../lib/timestamp'; import type { Infer } from '../lib/type_builders'; -import { bsatnBaseSize } from '../lib/util'; +import { bsatnBaseSize, toCamelCase } from '../lib/util'; import { ANON_VIEWS, VIEWS, type AnonymousViewCtx, type ViewCtx, } from '../lib/views'; +import type { DbView } from './db_view'; +import { SenderError, SpacetimeHostError } from './errors'; +import { Range, type Bound } from './range'; const { freeze } = Object; -const sys: typeof _syscalls = freeze( - Object.fromEntries( - Object.entries(_syscalls).map(([name, syscall]) => [ - name, - wrapSyscall(syscall), - ]) - ) as typeof _syscalls -); +export const sys = freeze(wrapSyscalls(_syscalls1_0, _syscalls1_2)); export function parseJsonObject(json: string): JsonObject { let value: unknown; @@ -235,9 +229,9 @@ export const hooks_v1_1: import('spacetime:sys@1.1').ModuleHooks = { // at runtime db: getDbView(), }); - const args = AlgebraicType.deserializeValue( + const args = ProductType.deserializeValue( new BinaryReader(argsBuf), - AlgebraicType.Product(params), + params, MODULE_DEF.typespace ); const ret = fn(ctx, args); @@ -253,9 +247,9 @@ export const hooks_v1_1: import('spacetime:sys@1.1').ModuleHooks = { // at runtime db: getDbView(), }); - const args = AlgebraicType.deserializeValue( + const args = ProductType.deserializeValue( new BinaryReader(argsBuf), - AlgebraicType.Product(params), + params, MODULE_DEF.typespace ); const ret = fn(ctx, args); @@ -265,6 +259,18 @@ export const hooks_v1_1: import('spacetime:sys@1.1').ModuleHooks = { }, }; +export const hooks_v1_2: import('spacetime:sys@1.2').ModuleHooks = { + __call_procedure__(id, sender, connection_id, timestamp, args) { + return callProcedure( + id, + new Identity(sender), + ConnectionId.nullIfZero(new ConnectionId(connection_id)), + new Timestamp(timestamp), + args + ); + }, +}; + let DB_VIEW: DbView | null = null; function getDbView() { DB_VIEW ??= makeDbView(MODULE_DEF); @@ -587,6 +593,21 @@ class TableIterator implements IterableIterator { } } +type Intersections = Ts extends [ + infer T, + ...infer Rest, +] + ? T & Intersections + : unknown; + +function wrapSyscalls< + Modules extends Record any>[], +>(...modules: Modules): Intersections { + return Object.fromEntries( + modules.flatMap(Object.entries).map(([k, v]) => [k, wrapSyscall(v)]) + ) as Intersections; +} + function wrapSyscall any>( func: F ): (...args: Parameters) => ReturnType { diff --git a/crates/bindings-typescript/src/server/sys.d.ts b/crates/bindings-typescript/src/server/sys.d.ts index 8a102453d03..c482eb6ff9d 100644 --- a/crates/bindings-typescript/src/server/sys.d.ts +++ b/crates/bindings-typescript/src/server/sys.d.ts @@ -75,3 +75,17 @@ declare module 'spacetime:sys@1.1' { export function register_hooks(hooks: ModuleHooks); } + +declare module 'spacetime:sys@1.2' { + export type ModuleHooks = { + __call_procedure__( + id: u32, + sender: u256, + connection_id: u128, + timestamp: bigint, + args: Uint8Array + ): Uint8Array; + }; + + export function register_hooks(hooks: ModuleHooks); +} diff --git a/crates/core/src/error.rs b/crates/core/src/error.rs index 31e3c79ceeb..d0a7e2cd4d4 100644 --- a/crates/core/src/error.rs +++ b/crates/core/src/error.rs @@ -267,6 +267,8 @@ pub enum NodesError { BadColumn, #[error("can't perform operation; not inside transaction")] NotInTransaction, + #[error("can't perform operation; not inside anonymous transaction")] + NotInAnonTransaction, #[error("ABI call not allowed while holding open a transaction: {0}")] WouldBlockTransaction(AbiCall), #[error("table with name {0:?} already exists")] diff --git a/crates/core/src/host/host_controller.rs b/crates/core/src/host/host_controller.rs index cb40eb48240..0d8853570df 100644 --- a/crates/core/src/host/host_controller.rs +++ b/crates/core/src/host/host_controller.rs @@ -179,6 +179,7 @@ pub struct ProcedureCallResult { pub start_timestamp: Timestamp, } +#[derive(Debug)] pub struct CallProcedureReturn { pub result: Result, pub tx_offset: Option, diff --git a/crates/core/src/host/instance_env.rs b/crates/core/src/host/instance_env.rs index 4bb73c65913..b8fef43dbd3 100644 --- a/crates/core/src/host/instance_env.rs +++ b/crates/core/src/host/instance_env.rs @@ -2,13 +2,17 @@ use super::scheduler::{get_schedule_from_row, ScheduleError, Scheduler}; use crate::database_logger::{BacktraceFrame, BacktraceProvider, LogLevel, ModuleBacktrace, Record}; use crate::db::relational_db::{MutTx, RelationalDB}; use crate::error::{DBError, DatastoreError, IndexError, NodesError}; +use crate::host::module_host::{DatabaseUpdate, EventStatus, ModuleEvent, ModuleFunctionCall}; use crate::host::wasm_common::TimingSpan; use crate::replica_context::ReplicaContext; +use crate::subscription::module_subscription_actor::{commit_and_broadcast_event, ModuleSubscriptions}; +use crate::subscription::module_subscription_manager::{from_tx_offset, TransactionOffset}; use crate::util::asyncify; use chrono::{DateTime, Utc}; use core::mem; use parking_lot::{Mutex, MutexGuard}; use smallvec::SmallVec; +use spacetimedb_client_api_messages::energy::EnergyQuanta; use spacetimedb_datastore::execution_context::Workload; use spacetimedb_datastore::locking_tx_datastore::state_view::StateView; use spacetimedb_datastore::locking_tx_datastore::{FuncCallType, MutTxId}; @@ -28,7 +32,6 @@ use std::sync::Arc; use std::time::{Duration, Instant}; use std::vec::IntoIter; -#[derive(Clone)] pub struct InstanceEnv { pub replica_ctx: Arc, pub scheduler: Scheduler, @@ -41,6 +44,10 @@ pub struct InstanceEnv { pub func_type: FuncCallType, /// The name of the last, including current, function to be executed by this environment. pub func_name: String, + /// Are we in an anonymous tx context? + in_anon_tx: bool, + /// A procedure's last known transaction offset. + procedure_last_tx_offset: Option, } #[derive(Clone, Default)] @@ -187,6 +194,8 @@ impl InstanceEnv { // run a function func_type: FuncCallType::Reducer, func_name: String::from(""), + in_anon_tx: false, + procedure_last_tx_offset: None, } } @@ -602,10 +611,94 @@ impl InstanceEnv { // TODO(procedure-tx): should we add a new workload, e.g., `AnonTx`? let tx = asyncify(move || stdb.begin_mut_tx(IsolationLevel::Serializable, Workload::Internal)).await; self.tx.set_raw(tx); + self.in_anon_tx = true; Ok(()) } + /// Finishes an anonymous transaction, + /// returning `Some(_)` if there was no ongoing one, + /// in which case the caller should return early. + fn finish_anon_tx(&mut self) -> Result<(), NodesError> { + if self.in_anon_tx { + self.in_anon_tx = false; + Ok(()) + } else { + // Not in an anon tx context. + // This can happen if a reducer calls this ABI + // and tries to commit its own transaction early. + // We refuse to do this, as it would cause a later panic in the host. + Err(NodesError::NotInAnonTransaction) + } + } + + pub async fn commit_mutable_tx(&mut self) -> Result<(), NodesError> { + self.finish_anon_tx()?; + + let stdb = self.relational_db().clone(); + let tx = self.take_tx()?; + let subs = self.replica_ctx.subscriptions.clone(); + + let event = ModuleEvent { + timestamp: Timestamp::now(), + caller_identity: stdb.database_identity(), + caller_connection_id: None, + function_call: ModuleFunctionCall::default(), + status: EventStatus::Committed(DatabaseUpdate::default()), + request_id: None, + timer: None, + // The procedure will pick up the tab for the energy. + energy_quanta_used: EnergyQuanta { quanta: 0 }, + host_execution_duration: Duration::from_millis(0), + }; + // Commit the tx and broadcast it. + // This is somewhat expensive, + // and can block for a while, + // so we need to asyncify it. + let event = asyncify(move || commit_and_broadcast_event(&subs, None, event, tx)).await; + self.procedure_last_tx_offset = Some(event.tx_offset); + + Ok(()) + } + + pub fn abort_mutable_tx(&mut self) -> Result<(), NodesError> { + self.finish_anon_tx()?; + let stdb = self.relational_db().clone(); + let tx = self.take_tx()?; + + // Roll back the tx; this isn't that expensive, so we don't need to `asyncify`. + let offset = ModuleSubscriptions::rollback_mut_tx(&stdb, tx); + self.procedure_last_tx_offset = Some(from_tx_offset(offset)); + Ok(()) + } + + /// In-case there is a anonymous tx at the end of a procedure, + /// it must be terminated. + /// + /// This represents a misuse by the module author of the module ABI. + pub fn terminate_dangling_anon_tx(&mut self) { + // Try to abort the anon tx. + match self.abort_mutable_tx() { + // There was no dangling anon tx. Yay! + Err(NodesError::NotInAnonTransaction) => {} + // There was one, which has been aborted. + // The module is using the ABI wrong! 😭 + Ok(()) => { + let message = format!( + "aborting dangling anonymous transaction in procedure {}", + self.func_name + ); + self.console_log_simple_message(LogLevel::Error, None, &message); + } + res => unreachable!("should've had a tx to close; {res:?}"), + } + } + + /// After a procedure has finished, take its known last tx offset, if any. + pub fn take_procedure_tx_offset(&mut self) -> Option { + self.procedure_last_tx_offset.take() + } + pub async fn http_request( &mut self, request: st_http::Request, diff --git a/crates/core/src/host/module_host.rs b/crates/core/src/host/module_host.rs index 202bf2f10e0..87b1be33a1b 100644 --- a/crates/core/src/host/module_host.rs +++ b/crates/core/src/host/module_host.rs @@ -401,13 +401,6 @@ impl Instance { Instance::Js(inst) => inst.trapped(), } } - - async fn call_procedure(&mut self, params: CallProcedureParams) -> CallProcedureReturn { - match self { - Instance::Wasm(inst) => inst.call_procedure(params).await, - Instance::Js(inst) => inst.call_procedure(params).await, - } - } } /// Creates the table for `table_def` in `stdb`. @@ -1604,18 +1597,20 @@ impl ModuleHost { let caller_connection_id = caller_connection_id.unwrap_or(ConnectionId::ZERO); let args = args.into_tuple(procedure_seed).map_err(InvalidProcedureArguments)?; - self.call_async_with_instance(&procedure_def.name, async move |mut inst| { - let res = inst - .call_procedure(CallProcedureParams { - timestamp: Timestamp::now(), - caller_identity, - caller_connection_id, - timer, - procedure_id, - args, - }) - .await; - (res, inst) + let params = CallProcedureParams { + timestamp: Timestamp::now(), + caller_identity, + caller_connection_id, + timer, + procedure_id, + args, + }; + self.call_async_with_instance(&procedure_def.name, async move |inst| match inst { + Instance::Wasm(mut inst) => (inst.call_procedure(params).await, Instance::Wasm(inst)), + Instance::Js(inst) => { + let (r, s) = inst.call_procedure(params).await; + (r, Instance::Js(s)) + } }) .await .map_err(Into::into) diff --git a/crates/core/src/host/v8/mod.rs b/crates/core/src/host/v8/mod.rs index e96b7b5c549..c47549c5f77 100644 --- a/crates/core/src/host/v8/mod.rs +++ b/crates/core/src/host/v8/mod.rs @@ -6,8 +6,8 @@ use self::error::{ use self::ser::serialize_to_js; use self::string::{str_from_ident, IntoJsString}; use self::syscall::{ - call_call_reducer, call_call_view, call_call_view_anon, call_describe_module, get_hooks, resolve_sys_module, FnRet, - HookFunctions, + call_call_procedure, call_call_reducer, call_call_view, call_call_view_anon, call_describe_module, get_hooks, + resolve_sys_module, FnRet, HookFunctions, }; use super::module_common::{build_common_module_from_raw, run_describer, ModuleCommon}; use super::module_host::{CallProcedureParams, CallReducerParams, Module, ModuleInfo, ModuleRuntime}; @@ -36,6 +36,7 @@ use anyhow::Context as _; use core::any::type_name; use core::str; use enum_as_inner::EnumAsInner; +use futures::FutureExt; use itertools::Either; use spacetimedb_auth::identity::ConnectionAuthCtx; use spacetimedb_client_api_messages::energy::FunctionBudget; @@ -382,8 +383,14 @@ impl JsInstance { .await } - pub async fn call_procedure(&mut self, _params: CallProcedureParams) -> CallProcedureReturn { - todo!("JS/TS module procedure support") + pub async fn call_procedure(self: Box, params: CallProcedureParams) -> (CallProcedureReturn, Box) { + let (r, s) = self + .send_recv( + JsWorkerReply::into_call_procedure, + JsWorkerRequest::CallProcedure { params }, + ) + .await; + (*r, s) } pub async fn call_view(self: Box, tx: MutTxId, params: CallViewParams) -> (ViewCallResult, Box) { @@ -400,6 +407,7 @@ enum JsWorkerReply { UpdateDatabase(anyhow::Result), CallReducer(ReducerCallResult), CallView(Box), + CallProcedure(Box), ClearAllClients(anyhow::Result<()>), CallIdentityConnected(Result<(), ClientConnectedError>), CallIdentityDisconnected(Result<(), ReducerCallError>), @@ -425,6 +433,8 @@ enum JsWorkerRequest { }, /// See [`JsInstance::call_view`]. CallView { tx: MutTxId, params: CallViewParams }, + /// See [`JsInstance::call_procedure`]. + CallProcedure { params: CallProcedureParams }, /// See [`JsInstance::clear_all_clients`]. ClearAllClients, /// See [`JsInstance::call_identity_connected`]. @@ -578,6 +588,14 @@ fn spawn_instance_worker( let (res, trapped) = instance_common.call_view_with_tx(tx, params, &mut inst); reply("call_view", JsWorkerReply::CallView(res.into()), trapped); } + JsWorkerRequest::CallProcedure { params } => { + let (res, trapped) = instance_common + .call_procedure(params, &mut inst) + .now_or_never() + .expect("our call_procedure implementation is not actually async"); + + reply("call_procedure", JsWorkerReply::CallProcedure(res.into()), trapped); + } JsWorkerRequest::ClearAllClients => { let res = instance_common.clear_all_clients(); reply("clear_all_clients", ClearAllClients(res), false); @@ -757,11 +775,10 @@ impl WasmInstance for V8Instance<'_, '_, '_> { } fn call_reducer(&mut self, op: ReducerOp<'_>, budget: FunctionBudget) -> ReducerExecuteResult { - let ExecutionResult { stats, call_result } = common_call(self.scope, budget, op, |scope, op| { + common_call(self.scope, budget, op, |scope, op| { Ok(call_call_reducer(scope, self.hooks, op)?) - }); - let call_result = call_result.and_then(|res| res.map_err(ExecutionError::User)); - ExecutionResult { stats, call_result } + }) + .map_result(|call_result| call_result.and_then(|res| res.map_err(ExecutionError::User))) } fn call_view(&mut self, op: ViewOp<'_>, budget: FunctionBudget) -> ViewExecuteResult { @@ -782,10 +799,22 @@ impl WasmInstance for V8Instance<'_, '_, '_> { async fn call_procedure( &mut self, - _op: ProcedureOp, - _budget: FunctionBudget, + op: ProcedureOp, + budget: FunctionBudget, ) -> (ProcedureExecuteResult, Option) { - todo!("JS/TS module procedure support") + let result = common_call(self.scope, budget, op, |scope, op| { + call_call_procedure(scope, self.hooks, op) + }) + .map_result(|call_result| { + call_result.map_err(|e| match e { + ExecutionError::User(e) => anyhow::Error::msg(e), + ExecutionError::Recoverable(e) | ExecutionError::Trap(e) => e, + }) + }); + let tx_offset = env_on_isolate_unwrap(self.scope) + .instance_env + .take_procedure_tx_offset(); + (result, tx_offset) } } @@ -815,7 +844,7 @@ where CanContinue::Yes => ExecutionError::Recoverable(e.into()), CanContinue::YesCancelTermination => { scope.cancel_terminate_execution(); - ExecutionError::Trap(e.into()) + ExecutionError::Recoverable(e.into()) } } }); diff --git a/crates/core/src/host/v8/syscall/hooks.rs b/crates/core/src/host/v8/syscall/hooks.rs index e5143a3a651..fd6df6cb4b7 100644 --- a/crates/core/src/host/v8/syscall/hooks.rs +++ b/crates/core/src/host/v8/syscall/hooks.rs @@ -50,6 +50,7 @@ pub(in super::super) enum ModuleHookKey { CallReducer, CallView, CallAnonymousView, + CallProcedure, } impl ModuleHookKey { @@ -63,6 +64,7 @@ impl ModuleHookKey { ModuleHookKey::CallReducer => 21, ModuleHookKey::CallView => 22, ModuleHookKey::CallAnonymousView => 23, + ModuleHookKey::CallProcedure => 24, } } } @@ -110,6 +112,7 @@ pub(in super::super) struct HookFunctions<'scope> { pub call_reducer: Local<'scope, Function>, pub call_view: Option>, pub call_view_anon: Option>, + pub call_procedure: Option>, } /// Returns the hook function previously registered in [`register_hooks`]. @@ -131,5 +134,6 @@ pub(in super::super) fn get_hooks<'scope>(scope: &mut PinScope<'scope, '_>) -> O call_reducer: get(ModuleHookKey::CallReducer)?, call_view: get(ModuleHookKey::CallView), call_view_anon: get(ModuleHookKey::CallAnonymousView), + call_procedure: get(ModuleHookKey::CallProcedure), }) } diff --git a/crates/core/src/host/v8/syscall/mod.rs b/crates/core/src/host/v8/syscall/mod.rs index 25afa0f4eac..1b3bdebe4f2 100644 --- a/crates/core/src/host/v8/syscall/mod.rs +++ b/crates/core/src/host/v8/syscall/mod.rs @@ -5,7 +5,7 @@ use v8::{callback_scope, Context, FixedArray, Local, Module, PinScope}; use crate::host::v8::de::scratch_buf; use crate::host::v8::error::{ErrorOrException, ExcResult, ExceptionThrown, Throwable, TypeError}; use crate::host::wasm_common::abi::parse_abi_version; -use crate::host::wasm_common::module_host_actor::{AnonymousViewOp, ReducerOp, ReducerResult, ViewOp}; +use crate::host::wasm_common::module_host_actor::{AnonymousViewOp, ProcedureOp, ReducerOp, ReducerResult, ViewOp}; mod hooks; mod v1; @@ -54,6 +54,7 @@ fn resolve_sys_module_inner<'scope>( "sys" => match (major, minor) { (1, 0) => Ok(v1::sys_v1_0(scope)), (1, 1) => Ok(v1::sys_v1_1(scope)), + (1, 2) => Ok(v1::sys_v1_2(scope)), _ => Err(TypeError(format!( "Could not import {spec:?}, likely because this module was built for a newer version of SpacetimeDB.\n\ It requires sys module v{major}.{minor}, but that version is not supported by the database." @@ -103,6 +104,19 @@ pub(super) fn call_call_view_anon( } } +/// Calls the registered `__call_procedure__` function hook. +/// +/// This handles any (future) ABI version differences. +pub(super) fn call_call_procedure( + scope: &mut PinScope<'_, '_>, + hooks: &HookFunctions<'_>, + op: ProcedureOp, +) -> Result> { + match hooks.abi { + AbiVersion::V1 => v1::call_call_procedure(scope, hooks, op), + } +} + /// Calls the registered `__describe_module__` function hook. /// /// This handles any (future) ABI version differences. diff --git a/crates/core/src/host/v8/syscall/v1.rs b/crates/core/src/host/v8/syscall/v1.rs index aa3abccde20..74b5e7822c3 100644 --- a/crates/core/src/host/v8/syscall/v1.rs +++ b/crates/core/src/host/v8/syscall/v1.rs @@ -14,13 +14,13 @@ use crate::host::v8::{ TerminationError, Throwable, }; use crate::host::wasm_common::instrumentation::span; -use crate::host::wasm_common::module_host_actor::{AnonymousViewOp, ReducerOp, ReducerResult, ViewOp}; +use crate::host::wasm_common::module_host_actor::{AnonymousViewOp, ProcedureOp, ReducerOp, ReducerResult, ViewOp}; use crate::host::wasm_common::{err_to_errno_and_log, RowIterIdx, TimingSpan, TimingSpanIdx}; use crate::host::AbiCall; use anyhow::Context; use bytes::Bytes; use spacetimedb_lib::{bsatn, ConnectionId, Identity, RawModuleDef}; -use spacetimedb_primitives::{errno, ColId, IndexId, ReducerId, TableId, ViewFnPtr}; +use spacetimedb_primitives::{errno, ColId, IndexId, ProcedureId, ReducerId, TableId, ViewFnPtr}; use spacetimedb_sats::Serialize; use v8::{ callback_scope, ConstructorBehavior, Function, FunctionCallbackArguments, Isolate, Local, Module, Object, @@ -118,6 +118,11 @@ pub(super) fn sys_v1_1<'scope>(scope: &mut PinScope<'scope, '_>) -> Local<'scope create_synthetic_module!(scope, "spacetime:sys@1.1", (with_nothing, (), register_hooks)) } +pub(super) fn sys_v1_2<'scope>(scope: &mut PinScope<'scope, '_>) -> Local<'scope, Module> { + use register_hooks_v1_2 as register_hooks; + create_synthetic_module!(scope, "spacetime:sys@1.2", (with_nothing, (), register_hooks)) +} + /// Registers a function in `module` /// where the function has `name` and does `body`. fn register_module_fun( @@ -384,6 +389,42 @@ fn register_hooks_v1_1<'scope>(scope: &mut PinScope<'scope, '_>, args: FunctionC Ok(v8::undefined(scope).into()) } +/// Module ABI that registers the functions called by the host. +/// +/// # Signature +/// +/// ```ignore +/// export function register_hooks(hooks: { +/// __call_procedure__( +/// id: u32, +/// sender: u256, +/// connection_id: u128, +/// timestamp: u64, +/// args: Uint8Array +/// ): Uint8Array; +/// }): void; +/// ``` +/// +/// # Returns +/// +/// Returns nothing. +/// +/// # Throws +/// +/// Throws a `TypeError` if: +/// - `hooks` is not an object that has the correct functions. +fn register_hooks_v1_2<'scope>(scope: &mut PinScope<'scope, '_>, args: FunctionCallbackArguments<'_>) -> FnRet<'scope> { + // Convert `hooks` to an object. + let hooks = cast!(scope, args.get(0), Object, "hooks object").map_err(|e| e.throw(scope))?; + + let call_procedure = get_hook_function(scope, hooks, str_from_ident!(__call_procedure__))?; + + // Set the hooks. + set_hook_slots(scope, AbiVersion::V1, &[(ModuleHookKey::CallProcedure, call_procedure)])?; + + Ok(v8::undefined(scope).into()) +} + /// Calls the `__call_reducer__` function `fun`. pub(super) fn call_call_reducer( scope: &mut PinScope<'_, '_>, @@ -454,7 +495,7 @@ pub(super) fn call_call_view_anon( hooks: &HookFunctions<'_>, op: AnonymousViewOp<'_>, ) -> Result> { - let fun = hooks.call_view_anon.context("`__call_view__` was never defined")?; + let fun = hooks.call_view_anon.context("`__call_view_anon__` was never defined")?; let AnonymousViewOp { fn_ptr: ViewFnPtr(view_id), @@ -480,6 +521,41 @@ pub(super) fn call_call_view_anon( Ok(Bytes::copy_from_slice(bytes)) } +/// Calls the `__call_procedure__` function `fun`. +pub(super) fn call_call_procedure( + scope: &mut PinScope<'_, '_>, + hooks: &HookFunctions<'_>, + op: ProcedureOp, +) -> Result> { + let fun = hooks.call_procedure.context("`__call_procedure__` was never defined")?; + + let ProcedureOp { + id: ProcedureId(procedure_id), + name: _, + caller_identity: sender, + caller_connection_id: connection_id, + timestamp, + arg_bytes: procedure_args, + } = op; + // Serialize the arguments. + let procedure_id = serialize_to_js(scope, &procedure_id)?; + let sender = serialize_to_js(scope, &sender.to_u256())?; + let connection_id = serialize_to_js(scope, &connection_id.to_u128())?; + let timestamp = serialize_to_js(scope, ×tamp.to_micros_since_unix_epoch())?; + let procedure_args = serialize_to_js(scope, &procedure_args)?; + let args = &[procedure_id, sender, connection_id, timestamp, procedure_args]; + + // Call the function. + let ret = call_free_fun(scope, fun, args)?; + + // Deserialize the user result. + let ret = + cast!(scope, ret, v8::Uint8Array, "bytes return from `__call_procedure__`").map_err(|e| e.throw(scope))?; + let bytes = ret.get_contents(&mut []); + + Ok(Bytes::copy_from_slice(bytes)) +} + /// Calls the registered `__describe_module__` function hook. pub(super) fn call_describe_module( scope: &mut PinScope<'_, '_>, diff --git a/crates/core/src/host/wasm_common.rs b/crates/core/src/host/wasm_common.rs index e5155a475f7..520f4faf9e6 100644 --- a/crates/core/src/host/wasm_common.rs +++ b/crates/core/src/host/wasm_common.rs @@ -347,6 +347,7 @@ pub(super) type TimingSpanSet = ResourceSlab; pub fn err_to_errno(err: &NodesError) -> Option { match err { NodesError::NotInTransaction => Some(errno::NOT_IN_TRANSACTION), + NodesError::NotInAnonTransaction => Some(errno::TRANSACTION_NOT_ANONYMOUS), NodesError::WouldBlockTransaction(_) => Some(errno::WOULD_BLOCK_TRANSACTION), NodesError::DecodeRow(_) => Some(errno::BSATN_DECODE_ERROR), NodesError::DecodeValue(_) => Some(errno::BSATN_DECODE_ERROR), diff --git a/crates/core/src/host/wasm_common/module_host_actor.rs b/crates/core/src/host/wasm_common/module_host_actor.rs index eb0cc9b83fe..08c25415eea 100644 --- a/crates/core/src/host/wasm_common/module_host_actor.rs +++ b/crates/core/src/host/wasm_common/module_host_actor.rs @@ -153,6 +153,14 @@ pub struct ExecutionResult { pub call_result: T, } +impl ExecutionResult { + pub fn map_result(self, f: impl FnOnce(T) -> U) -> ExecutionResult { + let Self { stats, call_result } = self; + let call_result = f(call_result); + ExecutionResult { stats, call_result } + } +} + pub type ReducerExecuteResult = ExecutionResult>; pub type ViewExecuteResult = ExecutionResult>; @@ -381,11 +389,9 @@ impl WasmModuleInstance { } pub async fn call_procedure(&mut self, params: CallProcedureParams) -> CallProcedureReturn { - let ret = self.common.call_procedure(params, &mut self.instance).await; - if ret.result.is_err() { - self.trapped = true; - } - ret + let (res, trapped) = self.common.call_procedure(params, &mut self.instance).await; + self.trapped = trapped; + res } } @@ -570,11 +576,11 @@ impl InstanceCommon { Ok(self.execute_view_calls(tx, view_calls, inst)) } - async fn call_procedure( + pub(crate) async fn call_procedure( &mut self, params: CallProcedureParams, inst: &mut I, - ) -> CallProcedureReturn { + ) -> (CallProcedureReturn, bool) { let CallProcedureParams { timestamp, caller_identity, @@ -629,6 +635,8 @@ impl InstanceCommon { self.allocated_memory = memory_allocation; } + let trapped = call_result.is_err(); + let result = match call_result { Err(err) => { inst.log_traceback("procedure", &procedure_def.name, &err); @@ -659,7 +667,7 @@ impl InstanceCommon { } }; - CallProcedureReturn { result, tx_offset } + (CallProcedureReturn { result, tx_offset }, trapped) } /// Execute a reducer. diff --git a/crates/core/src/host/wasmtime/wasm_instance_env.rs b/crates/core/src/host/wasmtime/wasm_instance_env.rs index f41d040a7ff..1188bc12783 100644 --- a/crates/core/src/host/wasmtime/wasm_instance_env.rs +++ b/crates/core/src/host/wasmtime/wasm_instance_env.rs @@ -1,26 +1,22 @@ #![allow(clippy::too_many_arguments)] use super::{Mem, MemView, NullableMemOp, WasmError, WasmPointee, WasmPtr}; -use crate::database_logger::{BacktraceFrame, BacktraceProvider, LogLevel, ModuleBacktrace, Record}; +use crate::database_logger::{BacktraceFrame, BacktraceProvider, ModuleBacktrace, Record}; use crate::error::NodesError; use crate::host::instance_env::{ChunkPool, InstanceEnv}; -use crate::host::module_host::{DatabaseUpdate, EventStatus, ModuleEvent, ModuleFunctionCall}; use crate::host::wasm_common::instrumentation::{span, CallTimes}; use crate::host::wasm_common::module_host_actor::ExecutionTimings; use crate::host::wasm_common::{err_to_errno_and_log, RowIterIdx, RowIters, TimingSpan, TimingSpanIdx, TimingSpanSet}; use crate::host::AbiCall; -use crate::subscription::module_subscription_actor::{commit_and_broadcast_event, ModuleSubscriptions}; -use crate::subscription::module_subscription_manager::{from_tx_offset, TransactionOffset}; -use crate::util::asyncify; +use crate::subscription::module_subscription_manager::TransactionOffset; use anyhow::Context as _; -use spacetimedb_client_api_messages::energy::EnergyQuanta; use spacetimedb_data_structures::map::IntMap; use spacetimedb_datastore::locking_tx_datastore::FuncCallType; use spacetimedb_lib::{bsatn, ConnectionId, Timestamp}; use spacetimedb_primitives::{errno, ColId}; use std::future::Future; use std::num::NonZeroU32; -use std::time::{Duration, Instant}; +use std::time::Instant; use wasmtime::{AsContext, Caller, StoreContextMut}; /// A stream of bytes which the WASM module can read from @@ -119,12 +115,6 @@ pub(super) struct WasmInstanceEnv { /// A pool of unused allocated chunks that can be reused. // TODO(Centril): consider using this pool for `console_timer_start` and `bytes_sink_write`. chunk_pool: ChunkPool, - - /// Are we in an anonymous tx context? - in_anon_tx: bool, - - /// A procedure's last known transaction offset. - procedure_last_tx_offset: Option, } const STANDARD_BYTES_SINK: u32 = 1; @@ -147,8 +137,6 @@ impl WasmInstanceEnv { timing_spans: Default::default(), call_times: CallTimes::new(), chunk_pool: <_>::default(), - in_anon_tx: false, - procedure_last_tx_offset: None, } } @@ -246,8 +234,6 @@ impl WasmInstanceEnv { self.instance_env.start_funcall(name, ts, func_type); - self.in_anon_tx = false; - (args, errors) } @@ -299,7 +285,7 @@ impl WasmInstanceEnv { /// After a procedure has finished, take its known last tx offset, if any. pub fn take_procedure_tx_offset(&mut self) -> Option { - self.procedure_last_tx_offset.take() + self.instance_env.take_procedure_tx_offset() } /// Record a span with `start`. @@ -1484,10 +1470,7 @@ impl WasmInstanceEnv { let res = res.and_then(|()| Ok(timestamp.write_to(mem, out)?)); let result = res - .map(|()| { - env.in_anon_tx = true; - 0u16.into() - }) + .map(|()| 0u16.into()) .or_else(|err| Self::convert_wasm_result(AbiCall::ProcedureStartMutTransaction, err)); (caller, result) @@ -1495,22 +1478,6 @@ impl WasmInstanceEnv { ) } - /// Finishes an anonymous transaction, - /// returning `Some(_)` if there was no ongoing one, - /// in which case the caller should return early. - fn finish_anon_tx(&mut self) -> Option> { - if self.in_anon_tx { - self.in_anon_tx = false; - None - } else { - // Not in an anon tx context. - // This can happen if a reducer calls this ABI - // and tries to commit its own transaction early. - // We refuse to do this, as it would cause a later panic in the host. - Some(Ok(errno::TRANSACTION_NOT_ANONYMOUS.get().into())) - } - } - /// Commits a mutable transaction, /// suspending execution of this WASM instance until /// the transaction has been committed @@ -1541,40 +1508,12 @@ impl WasmInstanceEnv { |mut caller| async move { let (_, env) = Self::mem_env(&mut caller); - if let Some(ret) = env.finish_anon_tx() { - return (caller, ret); - } - - let inst = env.instance_env(); - let stdb = inst.relational_db().clone(); - let tx = inst.take_tx(); - let subs = inst.replica_ctx.subscriptions.clone(); - - let res = async move { - let tx = tx?; - let event = ModuleEvent { - timestamp: Timestamp::now(), - caller_identity: stdb.database_identity(), - caller_connection_id: None, - function_call: ModuleFunctionCall::default(), - status: EventStatus::Committed(DatabaseUpdate::default()), - request_id: None, - timer: None, - // The procedure will pick up the tab for the energy. - energy_quanta_used: EnergyQuanta { quanta: 0 }, - host_execution_duration: Duration::from_millis(0), - }; - // Commit the tx and broadcast it. - // This is somewhat expensive, - // and can block for a while, - // so we need to asyncify it. - let event = asyncify(move || commit_and_broadcast_event(&subs, None, event, tx)).await; - env.procedure_last_tx_offset = Some(event.tx_offset); - - Ok::<_, NodesError>(0u16.into()) - } - .await - .or_else(|err| Self::convert_wasm_result(AbiCall::ProcedureCommitMutTransaction, err.into())); + let res = env + .instance_env + .commit_mutable_tx() + .await + .map(|()| 0u16.into()) + .or_else(|err| Self::convert_wasm_result(AbiCall::ProcedureCommitMutTransaction, err.into())); (caller, res) }, @@ -1613,23 +1552,11 @@ impl WasmInstanceEnv { /// See [`WasmInstanceEnv::procedure_abort_mut_tx`] for details. pub fn procedure_abort_mut_tx_inner(&mut self) -> RtResult { - if let Some(ret) = self.finish_anon_tx() { - return ret; - } - - let inst = self.instance_env(); - let stdb = inst.relational_db().clone(); - let tx = inst.take_tx(); - - tx.map(|tx| { - // Roll back the tx; this isn't that expensive, so we don't need to `asyncify`. - let offset = ModuleSubscriptions::rollback_mut_tx(&stdb, tx); - self.procedure_last_tx_offset = Some(from_tx_offset(offset)); - 0u16.into() - }) - .map_err(NodesError::from) - .map_err(WasmError::from) - .or_else(|err| Self::convert_wasm_result(AbiCall::ProcedureAbortMutTransaction, err)) + self.instance_env + .abort_mutable_tx() + .map(|()| 0u16.into()) + .map_err(WasmError::from) + .or_else(|err| Self::convert_wasm_result(AbiCall::ProcedureAbortMutTransaction, err)) } /// In-case there is a anonymous tx at the end of a procedure, @@ -1637,24 +1564,7 @@ impl WasmInstanceEnv { /// /// This represents a misuse by the module author of the module ABI. pub fn terminate_dangling_anon_tx(&mut self) { - const NOT_ANON: u32 = errno::TRANSACTION_NOT_ANONYMOUS.get() as u32; - - // Try to abort the anon tx. - match self.procedure_abort_mut_tx_inner() { - // There was no dangling anon tx. Yay! - Ok(NOT_ANON) => {} - // There was one, which has been aborted. - // The module is using the ABI wrong! 😭 - Ok(0) => { - let message = format!( - "aborting dangling anonymous transaction in procedure {}", - self.funcall_name() - ); - self.instance_env() - .console_log_simple_message(LogLevel::Error, None, &message); - } - res => unreachable!("should've had a tx to close; {res:?}"), - } + self.instance_env.terminate_dangling_anon_tx(); } /// Perform an HTTP request as specified by the buffer `request_ptr[..request_len]`, From 6e2ece6f585845f695ba79a6f60b034ecc22c083 Mon Sep 17 00:00:00 2001 From: Noa Date: Fri, 21 Nov 2025 14:26:51 -0600 Subject: [PATCH 2/3] Do errors better --- .../bindings-typescript/src/server/errors.ts | 320 ++++++++---------- .../bindings-typescript/src/server/index.ts | 3 +- 2 files changed, 134 insertions(+), 189 deletions(-) diff --git a/crates/bindings-typescript/src/server/errors.ts b/crates/bindings-typescript/src/server/errors.ts index 845acfd1659..ebf6e86ecfa 100644 --- a/crates/bindings-typescript/src/server/errors.ts +++ b/crates/bindings-typescript/src/server/errors.ts @@ -46,200 +46,146 @@ export class SenderError extends Error { } } -/** - * A generic error class for unknown error codes. - */ -export class HostCallFailure extends SpacetimeHostError { - static CODE = 1; - static MESSAGE = 'ABI called by host returned an error'; - constructor() { - super(HostCallFailure.CODE); - } -} - -/** - * Error indicating that an ABI call was made outside of a transaction. - */ -export class NotInTransaction extends SpacetimeHostError { - static CODE = 2; - static MESSAGE = 'ABI call can only be made while in a transaction'; - constructor() { - super(NotInTransaction.CODE); - } -} - -/** - * Error indicating that BSATN decoding failed. - * This typically means that the data could not be decoded to the expected type. - */ -export class BsatnDecodeError extends SpacetimeHostError { - static CODE = 3; - static MESSAGE = "Couldn't decode the BSATN to the expected type"; - constructor() { - super(BsatnDecodeError.CODE); - } -} - -/** - * Error indicating that a specified table does not exist. - */ -export class NoSuchTable extends SpacetimeHostError { - static CODE = 4; - static MESSAGE = 'No such table'; - constructor() { - super(NoSuchTable.CODE); - } -} - -/** - * Error indicating that a specified index does not exist. - */ -export class NoSuchIndex extends SpacetimeHostError { - static CODE = 5; - static MESSAGE = 'No such index'; - constructor() { - super(NoSuchIndex.CODE); - } -} - -/** - * Error indicating that a specified row iterator is not valid. - */ -export class NoSuchIter extends SpacetimeHostError { - static CODE = 6; - static MESSAGE = 'The provided row iterator is not valid'; - constructor() { - super(NoSuchIter.CODE); - } -} - -/** - * Error indicating that a specified console timer does not exist. - */ -export class NoSuchConsoleTimer extends SpacetimeHostError { - static CODE = 7; - static MESSAGE = 'The provided console timer does not exist'; - constructor() { - super(NoSuchConsoleTimer.CODE); - } -} - -/** - * Error indicating that a specified bytes source or sink is not valid. - */ -export class NoSuchBytes extends SpacetimeHostError { - static CODE = 8; - static MESSAGE = 'The provided bytes source or sink is not valid'; - constructor() { - super(NoSuchBytes.CODE); - } -} - -/** - * Error indicating that a provided sink has no more space left. - */ -export class NoSpace extends SpacetimeHostError { - static CODE = 9; - static MESSAGE = 'The provided sink has no more space left'; - constructor() { - super(NoSpace.CODE); - } -} - -/** - * Error indicating that there is no more space in the database. - */ -export class BufferTooSmall extends SpacetimeHostError { - static CODE = 11; - static MESSAGE = 'The provided buffer is not large enough to store the data'; - constructor() { - super(BufferTooSmall.CODE); - } -} - -/** - * Error indicating that a value with a given unique identifier already exists. - */ -export class UniqueAlreadyExists extends SpacetimeHostError { - static CODE = 12; - static MESSAGE = 'Value with given unique identifier already exists'; - constructor() { - super(UniqueAlreadyExists.CODE); - } -} - -/** - * Error indicating that the specified delay in scheduling a row was too long. - */ -export class ScheduleAtDelayTooLong extends SpacetimeHostError { - static CODE = 13; - static MESSAGE = 'Specified delay in scheduling row was too long'; - constructor() { - super(ScheduleAtDelayTooLong.CODE); - } -} - -/** - * Error indicating that an index was not unique when it was expected to be. - */ -export class IndexNotUnique extends SpacetimeHostError { - static CODE = 14; - static MESSAGE = 'The index was not unique'; - constructor() { - super(IndexNotUnique.CODE); - } -} - -/** - * Error indicating that an index was not unique when it was expected to be. - */ -export class NoSuchRow extends SpacetimeHostError { - static CODE = 15; - static MESSAGE = 'The row was not found, e.g., in an update call'; - constructor() { - super(NoSuchRow.CODE); - } -} - -/** - * Error indicating that an auto-increment sequence has overflowed. - */ -export class AutoIncOverflow extends SpacetimeHostError { - static CODE = 16; - static MESSAGE = 'The auto-increment sequence overflowed'; - constructor() { - super(AutoIncOverflow.CODE); - } -} - -/** - * List of all SpacetimeError subclasses. - */ -const errorSubclasses = [ - HostCallFailure, - NotInTransaction, - BsatnDecodeError, - NoSuchTable, - NoSuchIndex, - NoSuchIter, - NoSuchConsoleTimer, - NoSuchBytes, - NoSpace, - BufferTooSmall, - UniqueAlreadyExists, - ScheduleAtDelayTooLong, - IndexNotUnique, - NoSuchRow, -]; +const errorData = { + /** + * A generic error class for unknown error codes. + */ + HostCallFailure: [1, 'ABI called by host returned an error'], + + /** + * Error indicating that an ABI call was made outside of a transaction. + */ + NotInTransaction: [2, 'ABI call can only be made while in a transaction'], + + /** + * Error indicating that BSATN decoding failed. + * This typically means that the data could not be decoded to the expected type. + */ + BsatnDecodeError: [3, "Couldn't decode the BSATN to the expected type"], + + /** + * Error indicating that a specified table does not exist. + */ + NoSuchTable: [4, 'No such table'], + + /** + * Error indicating that a specified index does not exist. + */ + NoSuchIndex: [5, 'No such index'], + + /** + * Error indicating that a specified row iterator is not valid. + */ + NoSuchIter: [6, 'The provided row iterator is not valid'], + + /** + * Error indicating that a specified console timer does not exist. + */ + NoSuchConsoleTimer: [7, 'The provided console timer does not exist'], + + /** + * Error indicating that a specified bytes source or sink is not valid. + */ + NoSuchBytes: [8, 'The provided bytes source or sink is not valid'], + + /** + * Error indicating that a provided sink has no more space left. + */ + NoSpace: [9, 'The provided sink has no more space left'], + + /** + * Error indicating that there is no more space in the database. + */ + BufferTooSmall: [ + 11, + 'The provided buffer is not large enough to store the data', + ], + + /** + * Error indicating that a value with a given unique identifier already exists. + */ + UniqueAlreadyExists: [ + 12, + 'Value with given unique identifier already exists', + ], + + /** + * Error indicating that the specified delay in scheduling a row was too long. + */ + ScheduleAtDelayTooLong: [ + 13, + 'Specified delay in scheduling row was too long', + ], + + /** + * Error indicating that an index was not unique when it was expected to be. + */ + IndexNotUnique: [14, 'The index was not unique'], + + /** + * Error indicating that an index was not unique when it was expected to be. + */ + NoSuchRow: [15, 'The row was not found, e.g., in an update call'], + + /** + * Error indicating that an auto-increment sequence has overflowed. + */ + AutoIncOverflow: [16, 'The auto-increment sequence overflowed'], + + WouldBlockTransaction: [ + 17, + 'Attempted async or blocking op while holding open a transaction', + ], + + TransactionNotAnonymous: [ + 18, + 'Not in an anonymous transaction. Called by a reducer?', + ], + + TransactionIsReadOnly: [ + 19, + 'ABI call can only be made while within a mutable transaction', + ], + + TransactionIsMut: [ + 20, + 'ABI call can only be made while within a read-only transaction', + ], +} as const; + +function mapEntries, U>( + x: T, + f: (key: keyof T, value: T[keyof T]) => U +): { [k in keyof T]: U } { + return Object.fromEntries( + Object.entries(x).map(([k, v]) => [k, f(k, v)]) + ) as any; +} + +export const errors = Object.freeze( + mapEntries(errorData, (name, [code, message]) => + Object.defineProperty( + class extends SpacetimeHostError { + static CODE = code; + static MESSAGE = message; + constructor() { + super(code); + } + }, + 'name', + { value: name, writable: false } + ) + ) +); /** * Set of prototypes of all SpacetimeError subclasses for quick lookup. */ -const errorProtoypes = new Set(errorSubclasses.map(cls => cls.prototype)); +const errorProtoypes = new Set(Object.values(errors).map(cls => cls.prototype)); /** * Map from error codes to their corresponding SpacetimeError subclass. */ const errnoToClass = new Map( - errorSubclasses.map(cls => [cls.CODE as number, cls]) + Object.values(errors).map(cls => [cls.CODE as number, cls]) ); diff --git a/crates/bindings-typescript/src/server/index.ts b/crates/bindings-typescript/src/server/index.ts index 903bb80ec73..a056a00e3b0 100644 --- a/crates/bindings-typescript/src/server/index.ts +++ b/crates/bindings-typescript/src/server/index.ts @@ -2,8 +2,7 @@ export * from '../lib/type_builders'; export { schema, type InferSchema } from '../lib/schema'; export { table } from '../lib/table'; export { reducers } from '../lib/reducers'; -export * as errors from './errors'; -export { SenderError } from './errors'; +export { SenderError, SpacetimeHostError, errors } from './errors'; export { type Reducer, type ReducerCtx } from '../lib/reducers'; export { type DbView } from './db_view'; From 66d079ede8e1e82f57ff88a41970e926f8d8bd4e Mon Sep 17 00:00:00 2001 From: Noa Date: Mon, 24 Nov 2025 12:55:28 -0600 Subject: [PATCH 3/3] Fix procedure return types --- crates/bindings-typescript/src/lib/procedures.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/bindings-typescript/src/lib/procedures.ts b/crates/bindings-typescript/src/lib/procedures.ts index 37176c6441a..259cfb56576 100644 --- a/crates/bindings-typescript/src/lib/procedures.ts +++ b/crates/bindings-typescript/src/lib/procedures.ts @@ -3,7 +3,11 @@ import type { ConnectionId } from '../lib/connection_id'; import type { Identity } from '../lib/identity'; import type { Timestamp } from '../lib/timestamp'; import type { ParamsObj } from './reducers'; -import { MODULE_DEF, type UntypedSchemaDef } from './schema'; +import { + MODULE_DEF, + registerTypesRecursively, + type UntypedSchemaDef, +} from './schema'; import type { Infer, InferTypeOfRow, TypeBuilder } from './type_builders'; import { bsatnBaseSize } from './util'; @@ -33,7 +37,7 @@ export function procedure< 'typeBuilder' in c ? c.typeBuilder.algebraicType : c.algebraicType, })), }; - const returnType = ret.algebraicType; + const returnType = registerTypesRecursively(ret).algebraicType; MODULE_DEF.miscExports.push({ tag: 'Procedure',