Skip to content

Add a live data layer: merge changed rows by id instead of reloading lists - #18

Merged
HomemadeToast57 merged 1 commit into
mainfrom
jack/live-entities
Jul 27, 2026
Merged

HomemadeToast57 merged 1 commit into
mainfrom
jack/live-entities

Conversation

@HomemadeToast57

Copy link
Copy Markdown
Collaborator

Does what 0.2.0-next.20 concluded was the real fix for rows popping out and back in:

Both are the same underlying thing — any design that replaces the whole list from a server snapshot has a window in which that snapshot is stale. The fix is to stop replacing the list (merge the changed row by id), not to keep shrinking the window.

next.19 shrank the window (reload-hold) and was reverted. This stops replacing the list.

What's new

LiveEntityStore (src/live.ts) is the sync state machine; useEntity (bool-sdk/react) is its React surface:

const todos = useEntity("todos", { sort: "-created_at" });
todos.data; todos.loading; todos.error;
await todos.create({ title });           // optimistic, rolls back on failure
await todos.update(id, { done: true });
await todos.remove(id);

One call replaces the useEffect + useState + load() + subscription pattern the system prompt taught every app to hand-wire.

The four defects it fixes

Before After
Stale snapshot overwrites screen full list replaced on every ping deltas merged by id — no snapshot to be stale
Bulk write cost trigger is FOR EACH ROW → 50 rows = 50 full reloads pings batch into ONE keyed fetch per 50ms
Out-of-order responses last-to-land wins, view rewinds monotonic sequence, stale responses dropped
Optimistic rows wiped by a concurrent reload overlay on committed state; rollback drops overlay

Reconcile cost

  • DELETE → applied locally, zero fetches
  • ding carries row (future private-channel doorbell) → zero fetches
  • otherwise → fetch only the changed rows by id, through the gateway so auth + telemetry still apply
  • ding with no id (older platform trigger) → one coalesced full reload

Filtered views self-maintain: matchesFilter evaluates the same DSL entities.ts translates to PostgREST, including SQL three-valued null semantics, so a delta-applied row matches what the next full load would return.

Testing

bun test — 134 pass, 0 fail. bun run typecheck clean. 24 new tests in src/live.test.ts cover coalescing, delete-without-fetch, row-on-ding, RLS-hidden rows leaving the view, filter transitions, stale-response dropping (via a gated deferred), optimistic survival across a concurrent refetch, rollback on every mutation, and self-echo dedupe.

Paired with

Platform PR restoring id to the doorbell payload and adding the capability-gated prompt variant.

🤖 Generated with Claude Code

…lists

0.2.0-next.19 tried to fix rows popping out and back in by withholding the
doorbell reload while a client's own writes were in flight. next.20 reverted it
and named the real problem: any design that replaces the whole list from a
server snapshot has a window in which that snapshot is stale, so the fix is to
stop replacing the list. This does that.

LiveEntityStore (live.ts) is the sync state machine, and useEntity
(bool-sdk/react) is its React surface. Four defects in the hand-wired
load() + subscribe(() => load()) pattern, which the system prompt taught every
app to rebuild:

- Replacing the list let a stale reload overwrite rows already on screen. Now
  changes apply as deltas merged by id, so there is no snapshot to be stale.
- No coalescing: the platform trigger fires per row, so a 50-row write rang the
  doorbell 50 times and cost 50 full-list reloads. Pings now batch into one
  keyed fetch per 50ms window.
- No response ordering: two reloads could land out of order and rewind the view.
  Full loads now carry a monotonic sequence and stale responses are dropped.
- A full reload wiped optimistic rows whose writes hadn't committed. Pending
  writes are now an overlay on top of committed state; rollback drops the
  overlay.

Reconciling costs as little as the ding allows: DELETE applies with no fetch, a
ding carrying the row applies with no fetch, and anything else fetches only the
changed rows by id — through the gateway, so auth and telemetry still apply. A
ding with no id (older platform trigger) degrades to one coalesced full reload.

Filtered views self-maintain via matchesFilter, a client-side evaluation of the
same DSL entities.ts translates to PostgREST, including SQL's three-valued null
semantics so a delta-applied row matches what the next full load would return.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@HomemadeToast57
HomemadeToast57 merged commit a117611 into main Jul 27, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant