Boost what matters

Feature Requests

Submit feature requests and boost what you want to see next.

#5577

Ergonomic Partial Updates via AsChangeset Macro

RequestedJul 22, 2026

I’d like to request a quality-of-life feature for partial row update requests, similar to Diesel’s [#[derive(AsChangeset)]](https://docs.diesel.rs/master/diesel/query_builder/derive.AsChangeset.html) macro. **To be clear up front**: I am not asking for column-level mutations at the storage layer. I know SpacetimeDB currently requires whole-row operations under the hood. Rather, this is a request for a DX wrapper to hide the boilerplate of requesting an update to specific fields while preserving the rest of the row. Currently, modifying a single field requires a manual "read-modify-write" pattern in user-land, meaning we have to execute a `find()` query first just to preserve the unmodified data. A macro could generate a changeset struct where non-primary-key fields are wrapped in `Option<T>`. This would allow us to call an `update_changeset` method, and the host (or a generated wrapper) would handle fetching the old row, applying the `Some` values, and writing the full row back. This eliminates the boilerplate of the initial read query, allows for clean `..Default::default()` syntax when updating large tables, and potentially offers a minor performance perk by removing user-land boundary crossing. ```rust // CURRENT: Requires a read to avoid overwriting `zone` let prev = ctx.db.locality_tbl().actor_id().find(actor_id).unwrap(); ctx.db.locality_tbl().actor_id().update(Locality { actor_id, chunk_x: new_x, chunk_z: new_z, zone: prev.zone, }); // PROPOSED: Using a generated changeset struct ctx.db.locality_tbl().actor_id().update_partial(LocalityChangeset { actor_id, chunk_x: Some(new_x), chunk_z: Some(new_z), ..Default::default() // `zone` defaults to None and is untouched }); ``` **Table definition with macro** ```rust #[table(accessor = locality_tbl)] #[derive(AsChangeset)] pub struct Locality { #[primary_key] pub actor_id: ActorId, pub zone: ZoneName, pub chunk_x: i8, pub chunk_z: i8, } // ...which would automatically yield: pub struct LocalityChangeset { pub actor_id: ActorId, pub zone: Option<ZoneName>, pub chunk_x: Option<i8>, pub chunk_z: Option<i8>, } ```

1TeV
From 1 booster
#4697

V8 procedures: async/await support for blocking host calls

RequestedMay 27, 2026

## Summary V8 procedure syscalls like `fetch()` block the V8 thread for their full duration, forcing the runtime to spawn additional instances for concurrent requests — each with its own OS thread and V8 isolate. This proposal adds async/await support for procedures so they can yield at host calls, multiplexing multiple in-flight procedures on a single V8 worker. Synchronous procedures continue to work unchanged; module authors opt in to async at their own pace. ## Motivation Blocking procedure syscalls (`fetch()`) in the V8 runtime park the V8 thread for their full duration — up to 30s per request. When another operation arrives while an instance is blocked, `ModuleInstanceManager` creates a new V8 instance — spawning an OS thread, allocating a V8 isolate, and recompiling the module. The pool never shrinks, so instances accumulate at peak load. For example, an LLM chat app where each message triggers a procedure calling an LLM API (30-60s response time). With 10 concurrent users, that's 10 blocked instances each holding an OS thread and V8 heap, plus additional instances for interleaved work. This grows with every concurrent long-running request and never reclaims. PR #4663 addresses this for reducers, views, and lifecycle callbacks by moving them to a single-worker FIFO lane (`JsInstanceLane`). Procedures are deliberately left on the old pool because they block on `rt.block_on()`. V8 has native async/await and there's exactly one guest language to support, so async procedures are a natural fit. A file-by-file analysis of what the implementation would involve accompanies this issue (see comments below), along with a verification checklist, to help expedite this work. ## How the WASM runtime already solves this The WASM runtime doesn't have this problem. Both runtimes call the same `instance_env.http_request()`, but the paths diverge at the syscall layer: ```rust // WASM path (wasmtime/wasm_instance_env.rs) — yields via async host function let result = async { env.instance_env.http_request(request, body)?.await }.await; // V8 path (v8/syscall/common.rs) — blocks the thread let (response, body) = rt.block_on(env.instance_env.http_request(request, body)?)?; ``` The WASM runtime uses `SingleCoreExecutor` backed by a `tokio::task::LocalSet` — a single-threaded async executor where multiple tasks are multiplexed cooperatively. When a procedure yields at an async host function, the executor polls other tasks. Wasmtime bridges synchronous WASM guest code to async host functions via stack switching. V8 doesn't have stack switching, but it doesn't need it — native async/await serves the same purpose. #4663 brings the reducer path closer to this model (single worker, FIFO queue) but doesn't add the async yielding that would let procedures share the worker. ## What it looks like to module authors ```typescript // Synchronous procedure — works as today, blocks V8 thread (ctx) => { const resp = ctx.http.fetch(url); return resp.text(); } // Async procedure — V8 thread is free during await async (ctx) => { const resp = await ctx.http.fetch(url); return resp.text(); } ``` Both forms coexist. Synchronous procedures use the existing `rt.block_on()` path unchanged. Async procedures yield at `await` points. Module authors adopt `async` at their own pace — no migration required. Reducers remain synchronous and never yield, same as the WASM runtime. The runtime detects async functions at registration time via `fn.constructor.name === 'AsyncFunction'` and automatically selects the async execution path, providing `AsyncProcedureCtx` (where `fetch()` returns `Promise<Response>` instead of `SyncResponse`). No explicit flag is needed: ```typescript export const myProc = spacetimedb.procedure( { url: t.string() }, t.string(), async (ctx, { url }) => { const resp = await ctx.http.fetch(url); return resp.text(); } ); ``` ## `withTx` vulnerability (ships independently) The current `withTx<T>(body: (ctx) => T): T` signature silently accepts async callbacks — TypeScript infers `T = Promise<X>`, the call type-checks, and the transaction commits before the awaited body runs. This is a latent data corruption path that exists today. Fix: a conditional type `T extends Promise<any> ? never : T` rejects async callbacks at compile time, plus a runtime thenable check as defense-in-depth. This can ship independently of async procedures. ## Key implementation areas The changes needed have been traced through the codebase. The full file-by-file analysis and verification checklist are in the comments below. Here's a summary of the key areas: **Event loop.** The synchronous `for request in request_rx.iter()` loop in `spawn_instance_worker()` becomes an async event loop with `tokio::task::LocalSet`, `FuturesUnordered` for in-flight async futures, and `v8::MicrotasksPolicy::kExplicit` to control when Promise resolutions propagate. The scheduling priority (request channel biased, with non-blocking `try_next` t

1TeV
From 1 booster
#5588

B-tree indexes on non-unit (payload-carrying) enums

RequestedJul 26, 2026

**Summary** Allow #[index(btree)] (and multi-column indexes) on enums that carry data, not just unit/C-style enums. [Discord conversation](https://discord.com/channels/1037340874172014652/1138987509834059867/1530939373913247854) **Motivation / Use case** A very common pattern is an “intent” or “state” enum that sometimes references another entity: ```rust #[derive(SpacetimeType)] pub enum MoveIntent { Path(WorldPos), Position(WorldPos), Direction(u8), ChaseAttack(ActorId), // ← want to find all rows where this == some ActorId } #[table(accessor = move_state_tbl)] pub struct MoveState { pub intent: MoveIntent, #[primary_key] pub actor_id: ActorId, pub zone: ZoneName, } ``` When an actor leaves a zone I need to efficiently find every other actor that is currently chasing them (MoveIntent::ChaseAttack(their_id)) and clear those move states. **Current work-arounds** 1. Full table scan (.iter() + filter) — simple but O(n). 2. Manually pack the enum into two columns (kind: u8 + payload: u32) and put a multi-column B-tree on them. 3. Maintain a separate “chasers” table with its own index on target_id. All three work, but they either cost performance or force awkward denormalisation. **Suggested behaviour** Ideally the index would support both: - Equality on a whole variant: intent.filter(MoveIntent::ChaseAttack(id)) - Or at least equality / range on the payload of a specific variant (the most useful part for this pattern). Even a restricted form that only lets you index “the payload of variant X” would already solve the majority of real-world cases.

2TeV
From 1 booster