Handles for subscriptions and reducer calls #29

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

Two async operations return a bare query_id or request_id and take a single (err, ...) callback. Both peer SDKs return an object instead, and the difference shows up in ordinary code.

Subscriptions

subscribe_query returns a query_id you hold and later pass to unsubscribe. There is no way to ask whether it is still live, and the applied and error paths both arrive through the one callback.

The Godot SDK returns a handle with query_id, applied/end signals, active/ended state, wait_for_applied(timeout) and unsubscribe(). The official Rust SDK has the same state machine (Pending → Sent → Applied → Ended/Error) with is_active() / is_ended().

The concretely awkward case today: after a reconnect, query sets are replayed under their original query_id, so a handle stays valid — but a subscription that errored is dropped, and the holder has no way to discover that except by having caught the callback at the time. State on the handle makes it observable.

Reducer calls

call_reducer(name, args, cb) gives one callback with (err, update). The wire distinguishes more: Committed, Failed(message) and OutOfEnergy. stdb/connection.lua:301-321 already separates all three internally, then flattens them to a single error string before the consumer sees it.

OutOfEnergy is not the same class of problem as a reducer that threw — one is the account's fault, the other the module's — and a game may well want to surface them differently. Collapsing both into a string loses that distinction.

Shape

local sub = db:subscribe_query({ "SELECT * FROM message" })
sub:on_applied(function() end)
sub:on_error(function(err) end)
sub:is_active()
sub:unsubscribe()

local call = db:call_reducer("send_message", { "yuris", "hello" })
call:on_ok(function(update) end)
call:on_error(function(err) end)          -- reducer failed
call:on_out_of_energy(function() end)

Constraints:

  • The existing callback forms keep working, byte for byte. This is additive, and 1.0 freezes both.
  • A timeout would be new behaviour, not a handle feature. The Godot SDK declares reducer_call_timeout and leaves it unimplemented, which is a fair warning: pending calls currently live until the connection drops. A timeout needs a real owner in the update loop and a defined interaction with reconnect replay. Prefer shipping handles without timeouts over shipping half a timeout.
  • No coroutine/await layer here. Godot gets await free from engine signals; the Lua equivalent is a coroutine wrapper, which is a separate opinion and a separate issue.

Acceptance

Unit tests: handle state transitions including the error path, unsubscribe on an already-ended subscription being a no-op rather than an error, each of the three reducer outcomes reaching its own callback, and the legacy callback form behaving exactly as it does today.

Two async operations return a bare `query_id` or `request_id` and take a single `(err, ...)` callback. Both peer SDKs return an object instead, and the difference shows up in ordinary code. ## Subscriptions `subscribe_query` returns a `query_id` you hold and later pass to `unsubscribe`. There is no way to ask whether it is still live, and the applied and error paths both arrive through the one callback. The Godot SDK returns a handle with `query_id`, `applied`/`end` signals, `active`/`ended` state, `wait_for_applied(timeout)` and `unsubscribe()`. The official Rust SDK has the same state machine (Pending → Sent → Applied → Ended/Error) with `is_active()` / `is_ended()`. The concretely awkward case today: after a reconnect, query sets are replayed under their original `query_id`, so a handle stays valid — but a subscription that *errored* is dropped, and the holder has no way to discover that except by having caught the callback at the time. State on the handle makes it observable. ## Reducer calls `call_reducer(name, args, cb)` gives one callback with `(err, update)`. The wire distinguishes more: `Committed`, `Failed(message)` and `OutOfEnergy`. `stdb/connection.lua:301-321` already separates all three internally, then flattens them to a single error string before the consumer sees it. `OutOfEnergy` is not the same class of problem as a reducer that threw — one is the account's fault, the other the module's — and a game may well want to surface them differently. Collapsing both into a string loses that distinction. ## Shape ```lua local sub = db:subscribe_query({ "SELECT * FROM message" }) sub:on_applied(function() end) sub:on_error(function(err) end) sub:is_active() sub:unsubscribe() local call = db:call_reducer("send_message", { "yuris", "hello" }) call:on_ok(function(update) end) call:on_error(function(err) end) -- reducer failed call:on_out_of_energy(function() end) ``` Constraints: - **The existing callback forms keep working**, byte for byte. This is additive, and 1.0 freezes both. - **A timeout would be new behaviour, not a handle feature.** The Godot SDK declares `reducer_call_timeout` and leaves it unimplemented, which is a fair warning: pending calls currently live until the connection drops. A timeout needs a real owner in the update loop and a defined interaction with reconnect replay. Prefer shipping handles without timeouts over shipping half a timeout. - **No coroutine/await layer here.** Godot gets `await` free from engine signals; the Lua equivalent is a coroutine wrapper, which is a separate opinion and a separate issue. ## Acceptance Unit tests: handle state transitions including the error path, `unsubscribe` on an already-ended subscription being a no-op rather than an error, each of the three reducer outcomes reaching its own callback, and the legacy callback form behaving exactly as it does today.
yuris closed this issue 2026-08-19 17:26:55 +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#29
No description provided.