Summary
electricCollectionOptions forwards an Electric update to the collection even when that primary key was never received as an insert. Electric update payloads contain only changed columns plus the PK. In a persisted/resumed collection, the collection then materializes that partial payload as a row. Subsequent updates merge into it, so immutable columns that were absent from the first update never recover.
This can make rows permanently invisible to normal application filters while their primary keys still exist locally.
Why this matters
A persisted Electric resume offset assumes the local materialization is complete. If the initial snapshot was interrupted/partially persisted but the resume offset advanced, a later update for a missed row is the first message the client sees for that key. The current behavior turns that partial update into a durable ghost row instead of recognizing the broken resume invariant and starting a fresh snapshot.
We observed this in production with persisted SQLite collections: external calendar rows were missing immutable fields (createdAt, type, etc.), failed the UI filters, and survived reloads because later updates merged into the ghost. A count-only integrity check also misses the condition because the correct ID is present.
Minimal reproduction
- Compose
createCollection(persistedCollectionOptions(electricCollectionOptions(...))) with an Electric shape whose rows include an immutable createdAt column.
- Persist a complete
electric:resume marker while omitting row B from the local SQLite materialization (for example, filter B from the initial snapshot while preserving the response headers/offset).
- Resume the shape and update a mutable column on B server-side.
- Electric sends
operation: update for B with { id, changedColumn }.
- The collection now contains B, but it lacks
createdAt and any other immutable columns omitted by the update. Later updates do not repair those fields.
Expected: an update for a key absent from the current materialization should be treated as a consistency failure: clear/reset the resume state and request a fresh snapshot (or otherwise refuse to materialize a partial row).
Actual: the partial update is written as a new row and becomes durable.
Versions
@tanstack/electric-db-collection 0.3.4
@tanstack/db-sqlite-persistence-core 0.1.10
@electric-sql/client 1.5.18
- Electric server 1.6.10
The relevant path appears to be processChangeMessage: it marks every non-delete key as synced and passes operation through to write without checking whether the key is already materialized.
Summary
electricCollectionOptionsforwards an Electricupdateto the collection even when that primary key was never received as aninsert. Electric update payloads contain only changed columns plus the PK. In a persisted/resumed collection, the collection then materializes that partial payload as a row. Subsequent updates merge into it, so immutable columns that were absent from the first update never recover.This can make rows permanently invisible to normal application filters while their primary keys still exist locally.
Why this matters
A persisted Electric resume offset assumes the local materialization is complete. If the initial snapshot was interrupted/partially persisted but the resume offset advanced, a later update for a missed row is the first message the client sees for that key. The current behavior turns that partial update into a durable ghost row instead of recognizing the broken resume invariant and starting a fresh snapshot.
We observed this in production with persisted SQLite collections: external calendar rows were missing immutable fields (
createdAt,type, etc.), failed the UI filters, and survived reloads because later updates merged into the ghost. A count-only integrity check also misses the condition because the correct ID is present.Minimal reproduction
createCollection(persistedCollectionOptions(electricCollectionOptions(...)))with an Electric shape whose rows include an immutablecreatedAtcolumn.electric:resumemarker while omitting rowBfrom the local SQLite materialization (for example, filter B from the initial snapshot while preserving the response headers/offset).operation: updatefor B with{ id, changedColumn }.createdAtand any other immutable columns omitted by the update. Later updates do not repair those fields.Expected: an update for a key absent from the current materialization should be treated as a consistency failure: clear/reset the resume state and request a fresh snapshot (or otherwise refuse to materialize a partial row).
Actual: the partial update is written as a new row and becomes durable.
Versions
@tanstack/electric-db-collection0.3.4@tanstack/db-sqlite-persistence-core0.1.10@electric-sql/client1.5.18The relevant path appears to be
processChangeMessage: it marks every non-delete key as synced and passesoperationthrough towritewithout checking whether the key is already materialized.