Typed per-table API: db.<table> with on_insert / on_update / on_delete #27

Closed
opened 2026-08-19 15:42:13 +00:00 by yuris · 0 comments
Owner

Row changes arrive as one firehose: on_event(evt) where evt.counts.events is a flat list of { type, table, row }. Every consumer writes the same dispatch loop — filter by table name, branch on event type, call their own handler.

Both peer SDKs offer per-table registration instead. The Godot SDK generates a facade per table (client.db.<table>.on_insert(cb) / on_update / on_delete plus remove_*, backed by LocalDatabase.subscribe_to_inserts/updates/deletes); the official SDKs do the same through ctx.db.<table>.

Reversing an earlier call

I previously argued against this: on_event already carries every row change, filtering is five lines of user code, and a registration layer is API surface to freeze at 1.0 for no new capability.

That was wrong on one point. Once the generator emits column types and unique constraints, a generated table facade is where that schema information becomes usable — typed rows, find by unique column, and the pk that update synthesis needs all hang off the same object. The per-table API stops being sugar over the event stream and becomes the place the schema lives. Both peer SDKs converged there for that reason.

Shape

Generated per table, from the schema module:

local db = stdb.connect{...}

db.message:on_insert(function(row) end)
db.message:on_update(function(old, new) end)      -- pk tables only, see #21
db.message:on_delete(function(row) end)
db.message:count()
db.message:iter()
db.message:find("id", 7)                          -- unique columns only

Design constraints worth settling before writing code:

  • on_event stays. It is the only way to see connection lifecycle, subscription and reducer events, and removing it breaks consumers for no gain. The per-table API is purely additive.
  • Deregistration is required, not optional. A Defold game object that registers a callback in init() and is then deleted must be able to unregister in final(), or the closure captures a dead self and the next row crashes it. This is the most Defold-specific requirement here — Godot gets it half-free from node lifecycle, and Lua closures have none. Return a handle from on_insert, or take a token.
  • Callbacks fire per row, after the whole batch is applied. A callback that observes a half-applied transaction sees an inconsistent cache.
  • Reentrancy. A callback that subscribes or calls a reducer while the batch is dispatching must not mutate the list being iterated. The Rust SDK queues mutations made inside callbacks and applies them between messages; the cheap version here is to snapshot the callback list before dispatch.

Acceptance

Unit tests covering: registration and dispatch per table, deregistration actually stopping delivery, a pk table producing one on_update rather than delete+insert, a pk-less table producing insert/delete only, and a callback registering another callback mid-dispatch without corrupting the run.

dev/example/ should use the per-table API for at least one table, so conformance exercises it against a live server.

Row changes arrive as one firehose: `on_event(evt)` where `evt.counts.events` is a flat list of `{ type, table, row }`. Every consumer writes the same dispatch loop — filter by table name, branch on event type, call their own handler. Both peer SDKs offer per-table registration instead. The Godot SDK generates a facade per table (`client.db.<table>.on_insert(cb)` / `on_update` / `on_delete` plus `remove_*`, backed by `LocalDatabase.subscribe_to_inserts/updates/deletes`); the official SDKs do the same through `ctx.db.<table>`. ## Reversing an earlier call I previously argued against this: `on_event` already carries every row change, filtering is five lines of user code, and a registration layer is API surface to freeze at 1.0 for no new capability. That was wrong on one point. Once the generator emits column types and unique constraints, a **generated table facade is where that schema information becomes usable** — typed rows, `find` by unique column, and the pk that update synthesis needs all hang off the same object. The per-table API stops being sugar over the event stream and becomes the place the schema lives. Both peer SDKs converged there for that reason. ## Shape Generated per table, from the schema module: ```lua local db = stdb.connect{...} db.message:on_insert(function(row) end) db.message:on_update(function(old, new) end) -- pk tables only, see #21 db.message:on_delete(function(row) end) db.message:count() db.message:iter() db.message:find("id", 7) -- unique columns only ``` Design constraints worth settling before writing code: - **`on_event` stays.** It is the only way to see connection lifecycle, subscription and reducer events, and removing it breaks consumers for no gain. The per-table API is purely additive. - **Deregistration is required, not optional.** A Defold game object that registers a callback in `init()` and is then deleted must be able to unregister in `final()`, or the closure captures a dead `self` and the next row crashes it. This is the most Defold-specific requirement here — Godot gets it half-free from node lifecycle, and Lua closures have none. Return a handle from `on_insert`, or take a token. - **Callbacks fire per row, after the whole batch is applied.** A callback that observes a half-applied transaction sees an inconsistent cache. - **Reentrancy.** A callback that subscribes or calls a reducer while the batch is dispatching must not mutate the list being iterated. The Rust SDK queues mutations made inside callbacks and applies them between messages; the cheap version here is to snapshot the callback list before dispatch. ## Acceptance Unit tests covering: registration and dispatch per table, deregistration actually stopping delivery, a pk table producing one `on_update` rather than delete+insert, a pk-less table producing insert/delete only, and a callback registering another callback mid-dispatch without corrupting the run. `dev/example/` should use the per-table API for at least one table, so conformance exercises it against a live server.
yuris closed this issue 2026-08-20 01:29:17 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
yuris/spacetimedb-defold#27
No description provided.