Subscribing to a view silently stores empty rows #42

Closed
opened 2026-08-19 17:29:48 +00:00 by yuris · 0 comments
Owner

Found while reviewing PR #40 (#32's doctrine correction), reproduced against a live 2.8.2 server. Subscribing to a view silently stores empty rows and reports them as inserts.

The failure

A view arrives over the wire under its own table_name / table_id — captured with a probe view flip: table_name: "flip", table_id: 4097 — and routes through the same cache.apply_database_update path as a table.

The generator does not emit views. It walks .tables[], while a view lives in misc_exports[].View with return_type: {Array: {Ref: 3}} — a shape that walk never visits. So the cache has no definition for it, and then:

  1. table_def returns {} for the unknown relation
  2. def.columns is nil
  3. decode_row(encoded, nil) sets columns = {}
  4. the mapping loop runs zero times and returns {}

Net result: an empty row is stored and counted as an insert, with no error anywhere. Every field of every view row is silently discarded.

Why a client cannot work around it

A view's rows are positional in every message type — not just TransactionUpdate, but InitialSubscription, SubscribeApplied, SubscribeMultiApplied, UnsubscribeApplied, UnsubscribeMultiApplied and OneOffQueryResponse too. Verified across all seven.

And the order is the view's own declared order, not the underlying table's. The probe's table item is (id, alpha, beta); its view flip returns (beta, alpha, id), and rows arrive as ["b1","a1",1]. So a consumer cannot hand cache.new the table's column list and get correct results — they would get every field mapped to the wrong name, which is worse than empty.

The view's row type is also dropped from the emitted typespace, since that is seeded only from tables, reducers and procedures.

Why this is worse than "views are unsupported"

Views are the case where column order is most load-bearing — they are positional everywhere, with an order derivable from nothing the SDK currently emits. PR #40 rewrote the repo's doctrine to say exactly that, and the honest conclusion is that the one relation kind whose decoding depends entirely on declared order is the one the generator does not cover.

An unsupported feature that raises is fine. One that silently stores empty rows is the failure class this project has repeatedly gone out of its way to eliminate — #8's dropped header, #17's silent new identity, #21's phantom delete+insert, #26's opaque enums.

Work

  • Fail loudly first. cache.apply_database_update (or table_def) should raise, or emit a distinct event, when a table_name arrives with no definition — rather than storing {}. That is the small change and it converts silent corruption into a clear error, independent of whether views are ever supported.
  • Emit views from dev/tools/gen-schema.sh, reading misc_exports[].View and resolving return_type's Array: {Ref: n} to that typespace entry for the column names, order and types
  • Mark them in the generated module so a consumer (and the cache) knows a view is positional in every message, unlike a table
  • Add a view to dev/server/index.js and capture a fixture, so this is proven on the wire rather than argued
  • Decide whether the cache should store view rows at all — a view is derived, has no primary key, and cannot be updated

Acceptance

A fixture-backed test where subscribing to a view populates the cache with correctly named fields in the view's own order. And, separately, a test that an unknown relation raises rather than storing an empty row — that one matters even if view support is deferred.

Priority

The loud-failure half is a 1.0 blocker: it is silent data loss reachable by an ordinary SELECT * FROM my_view. Full view support can be deferred with a documented limitation, but not the silent part.

Found while reviewing PR #40 (#32's doctrine correction), reproduced against a live 2.8.2 server. Subscribing to a **view** silently stores empty rows and reports them as inserts. ## The failure A view arrives over the wire under its **own** `table_name` / `table_id` — captured with a probe view `flip`: `table_name: "flip"`, `table_id: 4097` — and routes through the same `cache.apply_database_update` path as a table. The generator does not emit views. It walks `.tables[]`, while a view lives in `misc_exports[].View` with `return_type: {Array: {Ref: 3}}` — a shape that walk never visits. So the cache has no definition for it, and then: 1. `table_def` returns `{}` for the unknown relation 2. `def.columns` is `nil` 3. `decode_row(encoded, nil)` sets `columns = {}` 4. the mapping loop runs **zero** times and returns `{}` Net result: **an empty row is stored and counted as an insert, with no error anywhere.** Every field of every view row is silently discarded. ## Why a client cannot work around it A view's rows are **positional in every message type** — not just `TransactionUpdate`, but `InitialSubscription`, `SubscribeApplied`, `SubscribeMultiApplied`, `UnsubscribeApplied`, `UnsubscribeMultiApplied` and `OneOffQueryResponse` too. Verified across all seven. And the order is the **view's own declared order**, not the underlying table's. The probe's table `item` is `(id, alpha, beta)`; its view `flip` returns `(beta, alpha, id)`, and rows arrive as `["b1","a1",1]`. So a consumer cannot hand `cache.new` the table's column list and get correct results — they would get every field mapped to the wrong name, which is worse than empty. The view's row type is also dropped from the emitted `typespace`, since that is seeded only from tables, reducers and procedures. ## Why this is worse than "views are unsupported" Views are the case where column order is **most** load-bearing — they are positional everywhere, with an order derivable from nothing the SDK currently emits. PR #40 rewrote the repo's doctrine to say exactly that, and the honest conclusion is that the one relation kind whose decoding depends entirely on declared order is the one the generator does not cover. An unsupported feature that raises is fine. One that silently stores empty rows is the failure class this project has repeatedly gone out of its way to eliminate — #8's dropped header, #17's silent new identity, #21's phantom delete+insert, #26's opaque enums. ## Work - [ ] **Fail loudly first.** `cache.apply_database_update` (or `table_def`) should raise, or emit a distinct event, when a `table_name` arrives with no definition — rather than storing `{}`. That is the small change and it converts silent corruption into a clear error, independent of whether views are ever supported. - [ ] Emit views from `dev/tools/gen-schema.sh`, reading `misc_exports[].View` and resolving `return_type`'s `Array: {Ref: n}` to that typespace entry for the column names, order and types - [ ] Mark them in the generated module so a consumer (and the cache) knows a view is positional in **every** message, unlike a table - [ ] Add a view to `dev/server/index.js` and capture a fixture, so this is proven on the wire rather than argued - [ ] Decide whether the cache should store view rows at all — a view is derived, has no primary key, and cannot be updated ## Acceptance A fixture-backed test where subscribing to a view populates the cache with correctly named fields in the view's own order. And, separately, a test that an unknown relation raises rather than storing an empty row — that one matters even if view support is deferred. ## Priority The loud-failure half is a **1.0 blocker**: it is silent data loss reachable by an ordinary `SELECT * FROM my_view`. Full view support can be deferred with a documented limitation, but not the silent part.
yuris closed this issue 2026-08-20 04:47:24 +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#42
No description provided.