Cache: synthesize update events for primary-key tables #21

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

A field change on a primary-key table is delivered as a delete followed by an insert. Every official SDK surfaces it as a single update carrying both the old and new row.

Where it stands

cache.apply_database_update already handles the state correctly — deletes are applied before inserts precisely so a pk change lands as an upsert (stdb/cache.lua:95-108):

-- Deletes first: a pk update arrives as delete+insert of the same key,
-- and applying the insert last is what makes it an upsert.

The cache ends up right. The event stream does not: the same loop pushes { type = "delete" } then { type = "insert" } into counts.events, so a consumer reading evt.counts.events sees the row leave and a different row arrive.

Why this matters more in a game than elsewhere

The natural Defold mapping is event → sprite. delete despawns a game object, insert spawns one. So a player moving one tile — a single column update — destroys and recreates their game object: the sprite flickers, any tweens or animation state on it are lost, and a go.delete / factory.create pair runs every tick of movement instead of a cheap go.set_position.

Working around it in user code means diffing the event list for delete/insert pairs sharing a pk, which is exactly the bookkeeping an SDK exists to absorb — and it has to be redone in every project.

Proposed change

In the per-table loop, when the table has a pk and one batch contains both a delete and an insert for the same key, emit one event instead of two:

{ type = "update", table = name, row = new_row, old = old_row }

Notes for whoever picks this up:

  • Scope is one upd batch, matching the server's own framing. Do not try to correlate across tables or across separate TransactionUpdate messages.
  • Tables without a pk keep the current behaviour. With no key there is no defensible way to pair a delete with an insert, and guessing by field equality would be wrong for genuine same-shape churn. Official SDKs draw the line in the same place — on_update exists only on TableWithPrimaryKey.
  • counts.inserts / counts.deletes should keep counting the underlying row operations; changing those numbers to count synthesized updates would break the conformance assertions that use them.
  • Both orderings need handling: the server sends deletes first today, but pairing should be by key within the batch rather than by position.

Acceptance

A unit test in dev/test/test_cache.lua feeding a delete+insert of one pk in a single batch and asserting exactly one update event with correct old and row. Plus a case proving a pk-less table still emits the two separate events, and a case proving an unpaired delete stays a delete.

The README event table gains the update row, and the docs note that pk-less tables never produce one.

Priority

This is the highest-value feature gap for 1.0. It is small, self-contained in one already-tested pure module, and it removes a footgun that every consumer of this SDK would otherwise hit and have to solve themselves.

A field change on a primary-key table is delivered as a `delete` followed by an `insert`. Every official SDK surfaces it as a single `update` carrying both the old and new row. ## Where it stands `cache.apply_database_update` already handles the *state* correctly — deletes are applied before inserts precisely so a pk change lands as an upsert (`stdb/cache.lua:95-108`): ```lua -- Deletes first: a pk update arrives as delete+insert of the same key, -- and applying the insert last is what makes it an upsert. ``` The cache ends up right. The **event stream** does not: the same loop pushes `{ type = "delete" }` then `{ type = "insert" }` into `counts.events`, so a consumer reading `evt.counts.events` sees the row leave and a different row arrive. ## Why this matters more in a game than elsewhere The natural Defold mapping is event → sprite. `delete` despawns a game object, `insert` spawns one. So a player moving one tile — a single column update — destroys and recreates their game object: the sprite flickers, any tweens or animation state on it are lost, and a `go.delete` / `factory.create` pair runs every tick of movement instead of a cheap `go.set_position`. Working around it in user code means diffing the event list for delete/insert pairs sharing a pk, which is exactly the bookkeeping an SDK exists to absorb — and it has to be redone in every project. ## Proposed change In the per-table loop, when the table has a `pk` and one batch contains both a delete and an insert for the same key, emit one event instead of two: ```lua { type = "update", table = name, row = new_row, old = old_row } ``` Notes for whoever picks this up: - **Scope is one `upd` batch**, matching the server's own framing. Do not try to correlate across tables or across separate `TransactionUpdate` messages. - **Tables without a `pk` keep the current behaviour.** With no key there is no defensible way to pair a delete with an insert, and guessing by field equality would be wrong for genuine same-shape churn. Official SDKs draw the line in the same place — `on_update` exists only on `TableWithPrimaryKey`. - **`counts.inserts` / `counts.deletes`** should keep counting the underlying row operations; changing those numbers to count synthesized updates would break the conformance assertions that use them. - Both orderings need handling: the server sends deletes first today, but pairing should be by key within the batch rather than by position. ## Acceptance A unit test in `dev/test/test_cache.lua` feeding a delete+insert of one pk in a single batch and asserting exactly one `update` event with correct `old` and `row`. Plus a case proving a pk-less table still emits the two separate events, and a case proving an unpaired delete stays a `delete`. The README event table gains the `update` row, and the docs note that pk-less tables never produce one. ## Priority This is the highest-value feature gap for 1.0. It is small, self-contained in one already-tested pure module, and it removes a footgun that every consumer of this SDK would otherwise hit and have to solve themselves.
yuris closed this issue 2026-08-19 17:42:14 +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#21
No description provided.