live queries: subscription-driven re-render from the browser peer, and @fluree/react - #1733
live queries: subscription-driven re-render from the browser peer, and @fluree/react#1733aaj3f wants to merge 94 commits into
Conversation
…nding in limbo Three shell-side holes from the #1733 review. 1. `LiveRegistry`/`LiveSubscription` carried no `channel.generation` stamp, unlike `Ledger` and `Snapshot`, which stamp it precisely because a fresh engine re-mints the same small handle numbers. `LiveQuerySet::next_id` restarts at 1, so: subscribe -> subId 1; worker crashes and recycles; the consumer re-subscribes as the docs instruct and the new engine also returns 1, overwriting `subs[1]`; the consumer then tidies its stale handle with `oldSub.unsubscribe()` — which deletes the NEW callback and posts `{op:"unsubscribe", subId:1}`, killing a live subscription silently. The registry now stamps its generation, drops the map when it moves (checked on subscribe, after the round trip, and on every cycle event), and an unsubscribe from a previous generation is a no-op. 2. The post-recycle re-init did `.then(res => { if (res.ok) … })` with no else. A NON-fatally failed re-init — exactly what a `getToken` rejection produces, since that mints `unauthorized`/401 and only RuntimeError/RangeError are fatalized — replied `ok: false`, so nothing fired, `recycle()` was not re-entered, and no further respawn was scheduled. Listeners saw the crash and then silence, forever: not ready, not terminal, the one state a consumer cannot act on. It now re-enters the backoff ladder, so it either recovers or spends the budget and becomes a terminal error the consumer is told about. 3. `smoke-peer-server.mjs` asserted only `sse.length !== 0`, so a reconnect bug opening five streams stayed green — and a stream-per-commit is the shape peer mode exists to avoid. Now `check("SSE streams", sse.length, 1)`. And the README's peer-credentials paragraph said "There is no mid-session re-auth hook in `fluree-db-browser` yet". This stack ADDS one: `BrowserPeer::set_token` with a shared `TokenCell` through every I/O surface and a passing native test. It is real, it is documented as absent, and it is unreachable — `grep -rn "set_token\|setToken" fluree-db-wasm/src fluree-db-wasm/js/src` finds nothing. I did not wire the protocol op, and the README now says why rather than saying the hook does not exist. It is not a size question: `getToken` is currently the single source of truth (asked at connect, re-asked on every recycle, never embedded in a replayable init), so a pushed `setToken` would be silently superseded by the next recycle's `getToken` unless the two are reconciled first. Which side owns the token is a protocol decision, not a fix. No automated test for (1) or (2): this package has no unit-test harness (scripts are `tsc` and the two browser smokes), and `Channel` — whose injectable `spawn` would make both testable in ~40 lines of `node:test` — is not exported, with the worker constructed from a literal expression on purpose (review H-6, bundler static analysis). Both changes type-check; standing up a harness here is a separate call.
…nding in limbo Three shell-side holes from the #1733 review. 1. `LiveRegistry`/`LiveSubscription` carried no `channel.generation` stamp, unlike `Ledger` and `Snapshot`, which stamp it precisely because a fresh engine re-mints the same small handle numbers. `LiveQuerySet::next_id` restarts at 1, so: subscribe -> subId 1; worker crashes and recycles; the consumer re-subscribes as the docs instruct and the new engine also returns 1, overwriting `subs[1]`; the consumer then tidies its stale handle with `oldSub.unsubscribe()` — which deletes the NEW callback and posts `{op:"unsubscribe", subId:1}`, killing a live subscription silently. The registry now stamps its generation, drops the map when it moves (checked on subscribe, after the round trip, and on every cycle event), and an unsubscribe from a previous generation is a no-op. 2. The post-recycle re-init did `.then(res => { if (res.ok) … })` with no else. A NON-fatally failed re-init — exactly what a `getToken` rejection produces, since that mints `unauthorized`/401 and only RuntimeError/RangeError are fatalized — replied `ok: false`, so nothing fired, `recycle()` was not re-entered, and no further respawn was scheduled. Listeners saw the crash and then silence, forever: not ready, not terminal, the one state a consumer cannot act on. It now re-enters the backoff ladder, so it either recovers or spends the budget and becomes a terminal error the consumer is told about. 3. `smoke-peer-server.mjs` asserted only `sse.length !== 0`, so a reconnect bug opening five streams stayed green — and a stream-per-commit is the shape peer mode exists to avoid. Now `check("SSE streams", sse.length, 1)`. And the README's peer-credentials paragraph said "There is no mid-session re-auth hook in `fluree-db-browser` yet". This stack ADDS one: `BrowserPeer::set_token` with a shared `TokenCell` through every I/O surface and a passing native test. It is real, it is documented as absent, and it is unreachable — `grep -rn "set_token\|setToken" fluree-db-wasm/src fluree-db-wasm/js/src` finds nothing. I did not wire the protocol op, and the README now says why rather than saying the hook does not exist. It is not a size question: `getToken` is currently the single source of truth (asked at connect, re-asked on every recycle, never embedded in a replayable init), so a pushed `setToken` would be silently superseded by the next recycle's `getToken` unless the two are reconciled first. Which side owns the token is a protocol decision, not a fix. No automated test for (1) or (2): this package has no unit-test harness (scripts are `tsc` and the two browser smokes), and `Channel` — whose injectable `spawn` would make both testable in ~40 lines of `node:test` — is not exported, with the worker constructed from a literal expression on purpose (review H-6, bundler static analysis). Both changes type-check; standing up a harness here is a separate call.
…nding in limbo Three shell-side holes from the #1733 review. 1. `LiveRegistry`/`LiveSubscription` carried no `channel.generation` stamp, unlike `Ledger` and `Snapshot`, which stamp it precisely because a fresh engine re-mints the same small handle numbers. `LiveQuerySet::next_id` restarts at 1, so: subscribe -> subId 1; worker crashes and recycles; the consumer re-subscribes as the docs instruct and the new engine also returns 1, overwriting `subs[1]`; the consumer then tidies its stale handle with `oldSub.unsubscribe()` — which deletes the NEW callback and posts `{op:"unsubscribe", subId:1}`, killing a live subscription silently. The registry now stamps its generation, drops the map when it moves (checked on subscribe, after the round trip, and on every cycle event), and an unsubscribe from a previous generation is a no-op. 2. The post-recycle re-init did `.then(res => { if (res.ok) … })` with no else. A NON-fatally failed re-init — exactly what a `getToken` rejection produces, since that mints `unauthorized`/401 and only RuntimeError/RangeError are fatalized — replied `ok: false`, so nothing fired, `recycle()` was not re-entered, and no further respawn was scheduled. Listeners saw the crash and then silence, forever: not ready, not terminal, the one state a consumer cannot act on. It now re-enters the backoff ladder, so it either recovers or spends the budget and becomes a terminal error the consumer is told about. 3. `smoke-peer-server.mjs` asserted only `sse.length !== 0`, so a reconnect bug opening five streams stayed green — and a stream-per-commit is the shape peer mode exists to avoid. Now `check("SSE streams", sse.length, 1)`. And the README's peer-credentials paragraph said "There is no mid-session re-auth hook in `fluree-db-browser` yet". This stack ADDS one: `BrowserPeer::set_token` with a shared `TokenCell` through every I/O surface and a passing native test. It is real, it is documented as absent, and it is unreachable — `grep -rn "set_token\|setToken" fluree-db-wasm/src fluree-db-wasm/js/src` finds nothing. I did not wire the protocol op, and the README now says why rather than saying the hook does not exist. It is not a size question: `getToken` is currently the single source of truth (asked at connect, re-asked on every recycle, never embedded in a replayable init), so a pushed `setToken` would be silently superseded by the next recycle's `getToken` unless the two are reconciled first. Which side owns the token is a protocol decision, not a fix. No automated test for (1) or (2): this package has no unit-test harness (scripts are `tsc` and the two browser smokes), and `Channel` — whose injectable `spawn` would make both testable in ~40 lines of `node:test` — is not exported, with the worker constructed from a literal expression on purpose (review H-6, bundler static analysis). Both changes type-check; standing up a harness here is a separate call.
…nding in limbo Three shell-side holes from the #1733 review. 1. `LiveRegistry`/`LiveSubscription` carried no `channel.generation` stamp, unlike `Ledger` and `Snapshot`, which stamp it precisely because a fresh engine re-mints the same small handle numbers. `LiveQuerySet::next_id` restarts at 1, so: subscribe -> subId 1; worker crashes and recycles; the consumer re-subscribes as the docs instruct and the new engine also returns 1, overwriting `subs[1]`; the consumer then tidies its stale handle with `oldSub.unsubscribe()` — which deletes the NEW callback and posts `{op:"unsubscribe", subId:1}`, killing a live subscription silently. The registry now stamps its generation, drops the map when it moves (checked on subscribe, after the round trip, and on every cycle event), and an unsubscribe from a previous generation is a no-op. 2. The post-recycle re-init did `.then(res => { if (res.ok) … })` with no else. A NON-fatally failed re-init — exactly what a `getToken` rejection produces, since that mints `unauthorized`/401 and only RuntimeError/RangeError are fatalized — replied `ok: false`, so nothing fired, `recycle()` was not re-entered, and no further respawn was scheduled. Listeners saw the crash and then silence, forever: not ready, not terminal, the one state a consumer cannot act on. It now re-enters the backoff ladder, so it either recovers or spends the budget and becomes a terminal error the consumer is told about. 3. `smoke-peer-server.mjs` asserted only `sse.length !== 0`, so a reconnect bug opening five streams stayed green — and a stream-per-commit is the shape peer mode exists to avoid. Now `check("SSE streams", sse.length, 1)`. And the README's peer-credentials paragraph said "There is no mid-session re-auth hook in `fluree-db-browser` yet". This stack ADDS one: `BrowserPeer::set_token` with a shared `TokenCell` through every I/O surface and a passing native test. It is real, it is documented as absent, and it is unreachable — `grep -rn "set_token\|setToken" fluree-db-wasm/src fluree-db-wasm/js/src` finds nothing. I did not wire the protocol op, and the README now says why rather than saying the hook does not exist. It is not a size question: `getToken` is currently the single source of truth (asked at connect, re-asked on every recycle, never embedded in a replayable init), so a pushed `setToken` would be silently superseded by the next recycle's `getToken` unless the two are reconciled first. Which side owns the token is a protocol decision, not a fix. No automated test for (1) or (2): this package has no unit-test harness (scripts are `tsc` and the two browser smokes), and `Channel` — whose injectable `spawn` would make both testable in ~40 lines of `node:test` — is not exported, with the worker constructed from a literal expression on purpose (review H-6, bundler static analysis). Both changes type-check; standing up a harness here is a separate call.
…nding in limbo Three shell-side holes from the #1733 review. 1. `LiveRegistry`/`LiveSubscription` carried no `channel.generation` stamp, unlike `Ledger` and `Snapshot`, which stamp it precisely because a fresh engine re-mints the same small handle numbers. `LiveQuerySet::next_id` restarts at 1, so: subscribe -> subId 1; worker crashes and recycles; the consumer re-subscribes as the docs instruct and the new engine also returns 1, overwriting `subs[1]`; the consumer then tidies its stale handle with `oldSub.unsubscribe()` — which deletes the NEW callback and posts `{op:"unsubscribe", subId:1}`, killing a live subscription silently. The registry now stamps its generation, drops the map when it moves (checked on subscribe, after the round trip, and on every cycle event), and an unsubscribe from a previous generation is a no-op. 2. The post-recycle re-init did `.then(res => { if (res.ok) … })` with no else. A NON-fatally failed re-init — exactly what a `getToken` rejection produces, since that mints `unauthorized`/401 and only RuntimeError/RangeError are fatalized — replied `ok: false`, so nothing fired, `recycle()` was not re-entered, and no further respawn was scheduled. Listeners saw the crash and then silence, forever: not ready, not terminal, the one state a consumer cannot act on. It now re-enters the backoff ladder, so it either recovers or spends the budget and becomes a terminal error the consumer is told about. 3. `smoke-peer-server.mjs` asserted only `sse.length !== 0`, so a reconnect bug opening five streams stayed green — and a stream-per-commit is the shape peer mode exists to avoid. Now `check("SSE streams", sse.length, 1)`. And the README's peer-credentials paragraph said "There is no mid-session re-auth hook in `fluree-db-browser` yet". This stack ADDS one: `BrowserPeer::set_token` with a shared `TokenCell` through every I/O surface and a passing native test. It is real, it is documented as absent, and it is unreachable — `grep -rn "set_token\|setToken" fluree-db-wasm/src fluree-db-wasm/js/src` finds nothing. I did not wire the protocol op, and the README now says why rather than saying the hook does not exist. It is not a size question: `getToken` is currently the single source of truth (asked at connect, re-asked on every recycle, never embedded in a replayable init), so a pushed `setToken` would be silently superseded by the next recycle's `getToken` unless the two are reconciled first. Which side owns the token is a protocol decision, not a fix. No automated test for (1) or (2): this package has no unit-test harness (scripts are `tsc` and the two browser smokes), and `Channel` — whose injectable `spawn` would make both testable in ~40 lines of `node:test` — is not exported, with the worker constructed from a literal expression on purpose (review H-6, bundler static analysis). Both changes type-check; standing up a harness here is a separate call.
bplatz
left a comment
There was a problem hiding this comment.
Approving — the design holds up. One snapshot + one nested cycle guard + a hash gate on formatted bytes is the right shape, the RAII lease means cancellation can't wedge a ledger, the IndexedDB CID re-verification (with cache_rejections and its poison-and-heal test) closes a real hole, and structuralShare's __proto__ handling on both the read and the write side is more careful than most implementations. The timer_millis clamp is a good catch, pinned by mechanism rather than symptom.
Please look at the inline points before merging. The one I'd actually act on is set_token: it ships with a test and has no caller anywhere in the stack, and two doc comments in this PR contradict each other about whether it exists.
Verified locally: fluree-db-browser 66 tests pass, @fluree/react 205 pass under tsc + vitest + build, cargo fmt --all --check clean. The wasm32 clippy gate I could not cross-build here.
Two housekeeping items: the PR has no labels, so .github/release.yml files it under "Other Changes"; and none of the four named deferrals (CORS batch endpoint, --storage-all, shell vitest, the ~3.3s cold-open floor) carry a Follow-up: #N. fluree-db-wasm/js/src/index.ts is ~1100 lines with no test runner — the PR shows it's testable with zero production changes, so I'd rather see that suite here than deferred.
| //! - **No raw token in `init`** (recycle replays init): the token arrives | ||
| //! from the main thread over the event channel just before this | ||
| //! constructor runs; it is then held inside the transports for the life of | ||
| //! the peer. There is no mid-session re-auth hook in `fluree-db-browser` |
There was a problem hiding this comment.
BrowserPeer::set_token does exist — it's added in this PR with a passing test (connect.rs:279). But nothing calls it: no set_token/setToken in fluree-db-wasm/src, js/src, or fluree-react/src, and getToken is only pulled at connect/reconnect.
So a peer tab whose token expires 401s on every block fetch with no recovery — nothing in PeerTransport triggers a close+connect on that. bridge.rs:22 also claims "the shell's auth flow refreshes the bearer mid-session", which no shell does.
Either wire it through (one Peer method + a setToken op) or drop the capability. Either way both comments need fixing.
There was a problem hiding this comment.
Wired through, the shape you suggested: Peer::setToken (wasm export) → a setToken protocol op → a requirePeer dispatch in the worker (playground answers with the same typed unsupported the transact ops use in peer mode) → Peer.setToken(token) on the JS surface. (77c469b)
You were right that both comments were wrong — peer.rs claimed no re-auth hook exists while bridge.rs claimed the shell already refreshes. Both now say the same true thing: proactive refresh needs no teardown, and getToken stays the pull at connect/crash-recycle — the two compose (refresh before expiry via setToken; a 401 that lands anyway still surfaces typed and recovers through recycle/reconnect with a fresh getToken pull).
The Rust half was already pinned by set_token_refreshes_the_bearer_without_teardown; a shell-side protocol test rides the new vitest suite in this PR.
| "Hydration only supports JSON-LD and TypedJson output formats".to_string(), | ||
| )); | ||
| } | ||
| // SEAM: this branch calls hydration directly and is NOT wrapped in the |
There was a problem hiding this comment.
A comment is the only thing between a multi-ledger peer and an unrecoverable formatting miss here. Worth hoisting the loop into a helper both paths call, or returning a typed refusal on a residency target — and a tracked follow-up rather than prose.
There was a problem hiding this comment.
Took the typed-refusal option and filed the tracked follow-up you asked for: #1775. The prose guard is now a live assertion — if ANY graph in the dataset is residency-backed, the hydration branch returns a typed error naming the issue instead of dying on an unrecoverable mid-hydration miss. Fires never today (the browser peer is single-ledger), which is exactly the invariant the old comment narrated. (098ad55)
I went refusal-over-hoist deliberately: wrapping this branch in the existing loop would give FALSE recovery for a multi-ledger dataset — misses can come from a secondary graph's store and the loop only fetches against one store, so it would spin to a non-progress failure rather than recover. The real fix (hoist into a shared helper with per-graph store routing) is what #1775 tracks.
| #[cfg(any(target_arch = "wasm32", feature = "residency"))] | ||
| if let Some(cs) = crate::residency::content_store(db.snapshot) { | ||
| let _guard = cs.query_guard(); | ||
| let mut budget = fluree_db_binary_index::read::need_fetch::RetryBudget::default(); |
There was a problem hiding this comment.
Each round re-runs the body against the same tracker. Formatting is pure w.r.t. resident state but not w.r.t. the fuel counter, so a query near budget can fail FuelExceeded purely for having taken N rounds. Same shape as the execution loop, so not a regression — but on a peer, misses are the normal path, so it's likelier to bite. Snapshot/restore per round, or state the trade.
There was a problem hiding this comment.
Fixed — snapshot/restore, your first option. The loop snapshots the counter once (Tracker::current_micro_fuel) and rolls back before each retry via a new Tracker::restore_micro_fuel, so N rounds charge like one and the successful round's charge stands. (098ad55)
It's sound in this frame specifically because execution has already finished — formatting is the tracker's sole fuel writer, which is now a documented caller invariant on the API (the rollback would erase a concurrent consume_fuel, which is also why I left the execution loop exactly as you read it: its rounds run concurrent consumers, and you're right that it's not a regression there).
Two unit tests pin the API (rollback semantics; inert when fuel tracking is off).
| /// drops any signal folded into the abandoned cycle: nothing is left to | ||
| /// run it, and the next signal opens a fresh cycle at the latest head | ||
| /// anyway. | ||
| fn abandon(&self, ledger: &str) { |
There was a problem hiding this comment.
Clearing running is the point; clearing pending isn't. A head change that folded into a cycle which then got cancelled is dropped entirely, and nothing re-runs it until the next commit. "The next signal opens a fresh cycle anyway" only holds if there is a next signal.
The lease exists so safety doesn't rest on caller behavior — leaving pending set costs nothing and makes the code match the doc.
There was a problem hiding this comment.
Fixed — abandon() now clears only running. Tracing the consumer made your point sharper than "costs nothing": pending is only ever consumed by the NEXT cycle's finish(), which then forces one more FULL cycle — and that upgrade is load-bearing when the next cycle is a prime() (deliberately solo, one subscription). Dropping pending on abandon meant a prime racing a cancelled cycle served only itself and stranded every sibling subscription on stale data until some unrelated commit arrived. (4d5ddeb)
Two new tests pin it (Coalescer-level: a dropped lease still owes a follow-up; public-API level: an abandoned cycle's folded commit reaches an earlier subscription via the follow-up a later prime forces), both mutation-verified; monotone emission order is untouched — the abandoned cycle never emitted.
| // broken server returning a multi-GiB object can exhaust the linear-memory | ||
| // ceiling and trap the instance. A declared length past the residency | ||
| // budget cannot be a legitimate block. | ||
| if let Some(len) = crate::config::declared_length_over_cap( |
There was a problem hiding this comment.
Only guards a declared Content-Length. A chunked response with no header skips the pre-check and is fully materialized into linear memory — the exact thing this prevents. Fine against our own proxy; not a defense against the "hostile or broken server" the comment invokes. Narrow the claim or cap incrementally.
There was a problem hiding this comment.
Implemented the incremental cap rather than narrowing the claim. Body reads now stream through a ReadableStreamDefaultReader with a running total gated per chunk (config::body_cap_step — one saturating add + one compare, same shape and cfg-gating as declared_length_over_cap, natively unit-tested including the two-chunks-under-declare-nothing case); on overflow it aborts the controller + cancels the reader and returns typed. The declared-length fast-reject stays. Two details worth noting: the whole drain races ONE timeout (a trickle of small chunks can't reset the deadline per read), and each chunk is copied out of JS memory exactly once — which also retires the 2x materialization array_buffer() had on this path. (4d5ddeb)
The streaming loop itself is wasm-only; it runs green in the headless-Chrome suite (fetch round-trip tests), and the threshold logic is pinned natively.
| return this.liveHandleFor(key, spec, gcTime).state; | ||
| } | ||
|
|
||
| private liveHandleFor( |
There was a problem hiding this comment.
storeFor/observe/snapshotFor all short-circuit on closed, but public handleFor doesn't — a call after close() inserts into byKey/bySubId and skips armJanitor(), so it's never collected. One-line guard.
There was a problem hiding this comment.
Fixed, with one refinement to the framing: the branch was already half-defended — liveHandleFor guarded armJanitor() on closed but still unconditionally inserted the fresh handle into byKey/bySubId, which is the actual leak (no janitor AND no possible observer, since observe short-circuits on closed, so collect() can never fire). It now returns a standalone untracked handle when closed instead of touching the maps. (846a265)
Kept handleFor public rather than privatizing: the whitebox cache tests drive it directly, and the guard is the smaller surface change. New regression test asserts a post-close handleFor call caches nothing (two calls for a fresh key return distinct handles); mutation-verified (reverting the guard fails it).
| return; | ||
| } | ||
| reg.sub = sub; | ||
| this.byEngineId.set(sub.subId, subId); |
There was a problem hiding this comment.
byEngineId is only populated after this await, while the engine auto-primes the moment it registers (wasm/src/live.rs:55). It works because the subscribe reply and the cycle event are separate postMessages and the reply's microtasks drain first.
If that ever inverts, applyCycle drops the payload while Rust has already committed the change-gate hash — and the next cycle reports unchanged on a handle with hasResult === false, i.e. the transport-contract error path. The Rust side guards the analogous race explicitly (deliverable in run_serialized); this one has no guard, comment, or test.
There was a problem hiding this comment.
Agreed the microtask-ordering assumption deserved a real guard, not prose — it now mirrors the Rust side's deliverable gate. A pendingRegistrations counter brackets register()'s await (try/finally), and applyCycle stashes an entry it cannot place — keyed by raw engine id — only while a registration is in flight; when the subscribe reply lands, the stash is replayed as that subscription's own CycleUpdate (watermark included). The stash clears whenever the pending count returns to zero and on crash-recycle (a fresh worker can reissue a dead worker's ids — same hazard the existing byEngineId.clear() there guards). Normal delivered path pays one counter check. (846a265)
New test holds the subscribe reply open, emits the auto-prime cycle first, and asserts it is delivered (not dropped) once the reply resolves; mutation-verified (no-oping the stash fails it).
…JS surface Review #1733 (peer.rs thread): BrowserPeer::set_token existed — tested, end-to-end sound at the Rust level (shared TokenCell; fetch transports stamp per request, SSE resolves per connect) — but nothing above it called it, so a peer tab whose token expired 401'd on every block fetch with no recovery short of close()+connect(), and two doc comments contradicted each other about whether the capability existed at all (bridge.rs claimed the shell refreshes mid-session; peer.rs claimed no re-auth hook exists). Wire it through, exactly the shape the review suggested: Peer::setToken (wasm export) -> a `setToken` protocol op -> worker `requirePeer` dispatch -> `Peer.setToken(token)` on the JS surface. Peer-only: the playground answers with the same typed `unsupported` pattern the transact ops use in peer mode (memory ledgers carry no bearer). Both contradictory docs are now true and say the same thing: proactive refresh needs no teardown; getToken is still the pull at connect/crash-recycle — the two compose. Rust side already pinned by connect.rs's set_token_refreshes_the_bearer_without_teardown; a shell-side protocol test lands with the vitest harness (in flight on this PR).
…dataset seam Two #1733 review findings on the formatting frame's residency behavior (format/mod.rs threads): 1. Fuel re-charge per retry round. The drain/fetch/re-run loop re-ran formatting against the same Tracker, so N rounds charged N x one round's fuel and a near-budget query could fail FuelExceeded merely for missing residency N times — and on a peer, misses are the NORMAL first-query path. Formatting is pure and a failed round's work is discarded, so: snapshot the fuel counter once before the loop (Tracker::current_micro_fuel) and roll back before each retry via the new Tracker::restore_micro_fuel; the successful round's charge stands. Sound here because execution has finished — this frame is the tracker's sole fuel writer (the caller invariant is documented on the API). The execution loop shares the shape but runs concurrent consumers; left as the reviewer read it (explicitly not a regression there). 2. Dataset-hydration seam (#1775). The multi-ledger hydration branch is not wrapped by the drain/fetch/re-run loop, so on a residency-backed target a formatting miss would be unrecoverable — previously guarded only by a prose comment. The invariant is now enforced: a typed refusal if ANY graph in the dataset is residency-backed (live assertion; fires never today — the browser peer is single-ledger). The real fix (hoist the loop into a shared helper with per-graph store routing) is tracked in #1775. New: DataSetDb::graphs() iterator; two fluree-db-core unit tests pinning restore_micro_fuel (rollback semantics + inert when fuel tracking is off). Both changes live entirely inside the residency-gated cfg block — the default native path is untouched.
Two live-query enablers in the head-change path (SDK program A2/A5): Incremental catch-up now gives its new novelty entries the same F8 treatment the load and index-apply paths already get: after the caught-up commits are applied (outside the write guard), the reverse-dict leaves their overlay translation will touch are prewarmed through `prefetch_novelty_translation`, so a head-change → re-query on a residency-mode peer is a pure hit instead of spending a retry round per cold translation leaf. When an index update follows in the same plan, the apply path's own prefetch covers the swapped store and this pass is a resident no-op. Same cfg discipline as the rest of the residency arms — default native builds compile the pre-existing path unchanged. `MAX_INCREMENTAL_COMMITS` becomes target-split: native keeps 5; wasm32 gets 64. A browser tab's SSE reconnect after sleeping routinely wakes tens of commits behind, catch-up preserves warm per-store state (the overlay-translation cache is store-id-keyed) that a re-open drops, and the parent-chain walk is one sequential fetch per commit — 64 is where ~gap × RTT stops beating a re-open's concurrent artifact loads. Past the cap the reload fallback still ends in the same "queryable at t" signal: the browser head sink fires its callbacks unconditionally after notify, with the record's watermarks.
Two additions to the real-path recovery suite, driving the SSE head-sink
path (`LedgerManager::notify` with the record in hand) against a
residency-mode instance whose ledger is cached in the manager:
- beyond the incremental cap (8 commits > native 5): notify must take the
reload fallback (`NotifyResult::Reloaded`), land the manager exactly at
the record's commit watermark, and the re-opened state must answer a
production-loop query with every pre- and post-gap row — the engine
half of the "queryable at t" contract the browser head sink's
unconditional callback relies on;
- within the cap (2 commits): notify must catch up incrementally
(`CommitsApplied { count: 2 }`) at the record watermark and serve the
caught-up novelty rows through the production loop with the miss
register drained. The translation-prefetch call-site wiring runs here;
the prefetcher itself is unit-pinned in fluree-db-binary-index (this
fixture's dictionaries are too small to make the api-level prefetch
observable, as the suite's novelty test already documents).
LiveQuerySet: per-ledger subscriptions (SPARQL or JSON-LD) re-run on head
change through ONE advance-cycle: one frozen GraphDb snapshot, one
cycle-level query guard (nests with the retry loop's per-query guard and
keeps early fetches resident for later subscriptions), every subscription
executed via the production formatted query entry, xxh3 over the formatted
bytes as the change gate, and ONE batch CycleOutcome
{t, changed, unchanged, errored}. Per-subscription errors never hold the
barrier. Mid-cycle head signals coalesce into exactly one follow-up cycle
at the latest head. prime() runs a newly-mounted subscription solo at the
current head before it joins the barrier.
Invalidation is v1 (re-run all, diff before notify) with the v2 seam in
place: FootprintFilter::affected(&commit_flakes) per subscription,
consulted only for subscriptions that have produced a result and only
when the host supplies the cycle's novelty flakes; the default filter
reports everything affected.
Native tests cover the batch split (changed/unchanged/errored + monotone
t), coalescing (state machine + a mid-cycle fold observed through the
public API), solo priming, the footprint seam's never-skip-unprimed rule,
unsubscribe mid-flight, and the cycle guard genuinely wrapping the cycle
(a filter probe observes queries_in_flight == 1 inside, 0 after).
SelectedSub replaces the four-way tuple the registry snapshot produced (clippy type_complexity), and the lockfile picks up the crate's new serde_json/xxhash-rust dependencies.
… surface Long-lived subscribed sessions outlive their tokens; baking the bearer into the transports at build_peer time forced a full reconnect on expiry, dropping warm residency/cache/ledger state. Now one shared TokenCell (Arc<RwLock<String>>, Debug-redacted) feeds every surface: - WasmFetchTransport::with_token stamps the cell's CURRENT bearer on every request, replacing the authorization header the proxy clients baked in (per-request cost: one read-lock + the header allocation the request needed anyway). - ChannelSseSource resolves the bearer from the cell PER CONNECT — the same semantics as the native ReqwestSseSource's per-connect token provider — so reconnects after a refresh carry the fresh token. - BrowserPeer::set_token / token_cell expose the refresh to the shell; rustdoc spells out the expiry story (401 -> storage error, 403 -> not-found parity, SSE 401/403 -> fatal pump stop; refresh, retry queries, restart head tracking — proactive refresh needs no restart). Tests: transport stamps-and-replaces without duplication + Debug redaction; end-to-end set_token through the engine's nameservice path on one peer (old bearer before, new bearer after, no teardown); SSE source resolves per connect (Bearer a then Bearer b across two connects).
…(A1)
The shell's second engine mode: connect(url, { getToken, subscribe }) runs
fluree-db-browser's BrowserPeer in the same worker protocol as the
playground. Rust side: the snapshot slab + query surface extracted into a
mode-agnostic EngineCore (src/engine.rs); Playground delegates; the new
wasm-only src/peer.rs wires connectPeer (BrowserIoConfig::from_max_memory -
the ONE ceiling derives residency/write-behind/fetch-width, and the
per-query budget is a quarter of it, inside the governor's engine-headroom
split), head-change fan-out (the Send+Sync engine callback forwards through
a channel; a spawn_local drain task is what touches JS), SSE head tracking,
and shutdown. Peer is read-only: no transact exports exist; the worker
answers transact/createLedger/debugCrash in peer mode with typed
unsupported (501).
Protocol: a versioned EVENT message kind (unsolicited worker-to-main,
disjoint from responses by shape; consumers ignore unknown kinds/versions)
carrying headChange fan-out and tokenRequest. Credentials never ride init -
init is replayed verbatim on crash recycle, and a replayed message must not
be a credential replay - so the worker asks over the event channel and the
main-thread getToken callback answers (fire-and-forget tokenResponse op),
at connect AND at every recycle re-connect (reason: "reconnect"). No
mid-session re-auth hook exists in fluree-db-browser yet; documented: token
expiry means reconnect with a fresh token.
Verified without a server (smoke phase 3, CI): the token event round-trip,
peer init/close, and typed non-fatal failure against an unreachable remote;
plus 6/6 wasm-bindgen tests and the existing crash/recycle phases still
green. The full against-a-real-server flow (real blocks, real SSE) is
covered natively by fluree-db-browser's mock-driver suites and stated as
not-yet-automated in the README's verification-status section.
Size: 9.32 MB raw / 2.37 MB brotli (-Os) - +0.33/+0.09 over PR-2 for the
whole browser-io stack now linked in.
…t-violation tests Counts 194 -> 200, and the two rows that earned new coverage say what it is.
The job's path filter listed only fluree-react/ and ci.yml, but three of its steps reach into fluree-db-wasm/js: protocolCompat.test.ts imports ../../fluree-db-wasm/js/src/index.js and .../protocol.js by relative path and tsconfig's include covers test/**, so Type-check compiles that tree, Test loads it, and Demo type-checks it again through its own tsconfig. The failure mode is the one that erodes trust in a path-gated job: a PR changing connect()'s signature or the protocol types merges green because the job never ran, and then the next unrelated fluree-react PR goes red for a change its author did not make. Filter verified against sample paths the way bench-paths is — it newly matches fluree-db-wasm/js/** and still skips fluree-db-wasm/src/, js-extra/, some-fluree-react/, and other workflows.
CI's workspace clippy job runs --all --all-features --all-targets -D warnings on the HOST target; these three were red there and invisible only because this PR's base is not main (the job never fired on it yet): - config.rs timer_millis: dead on a host LIB build — its production callers are the wasm driver modules, and only the clamp test (host-test) uses it otherwise. cfg(any(wasm32, test)) so the host lib no longer sees it as dead. - the clamp test name carried a capital T (setTimeout) → non_snake_case. - live.rs a_prime_racing test: Arc<Mutex<Vec<(i64, Vec<SubId>)>>> tripped type_complexity; factored the inner Vec into a SeenOutcomes alias. Verified clean on host (--all-targets) and wasm32.
… trusting it IndexedDB is the one admission path into the engine the proxy client does not verify: the network path checks bytes against the CID inside ProxyStorage, but a cache hit was handed straight to make_resident. Because CAS blocks are immutable and never revalidated, a same-origin writer (a second app on the origin, an XSS, a compromised third-party bundle) could plant arbitrary bytes under a well-formed CID key and have them trusted as that block for the life of the database, producing silently wrong query results with no observable network request. Re-verify on the cache-hit path (fluree_db_nameservice_sync::verify_object_integrity, already shared and wasm-safe). A row that fails is discarded, not served: the read falls through to the origin fetch, which verifies and — via make_resident's write-behind CachePut — overwrites the poison under the same key, healing the cache. Rejections increment a CasStats.cache_rejections counter (a nonzero value is a security signal) and log a warning. Test seeds the cache with attacker bytes under a valid CID and the origin with the real bytes: the read returns the verified bytes, a fetch fires, the counter increments, and write-behind overwrites the poisoned row. Mutation-checked (bypassing the verify serves the poison and fails the test).
…cycle is delivered cycle_over wrote each changed subscription's new xxh3 hash into the registry before run_serialized emitted the outcome. If that emit reached no callback — the shell's auto-prime (fluree-db-wasm/src/live.rs:61 `let _ = set.prime()`) runs detached and delivery is only via the callback installed later when JS calls onCycleOutcome — the hash was committed for a payload the SDK never received. The next cycle computes the identical result, sees the committed hash, and reports the subscription `unchanged` with no payload: it renders nothing, forever, with no error. Gate the hash commit on deliverability. cycle_over takes a `commit_hashes` flag; the emit path passes whether a callback is currently registered (checked per cycle, so a listener attaching between cycles is picked up), and the pure run_cycle family passes true because it hands the outcome straight back. An undelivered cycle leaves last_hash untouched, so the first delivered cycle re-sends the payload. (The throw-mid-cycle variant self-heals: a closing port recycles the worker, and re-subscribe builds a fresh LiveQuerySet with cleared hashes.) New test: a prime with no listener, then a listener attaches and advances — the sub is delivered its payload, not wedged unchanged. Mutation-checked (forcing deliverable=true reproduces the wedge, [([],[1])]). The existing prime-then-unchanged test now registers a listener, matching production where one always exists.
IoJob derives Debug, and its SseOpen variant carried headers as a bare
Vec<(&str, String)> holding ("authorization", "Bearer {token}"). One
tracing::debug!(?job) or panic!("unexpected job {other:?}") in the driver
would write the user's bearer token to the browser console, readable by any
extension or error-reporting SDK on the page. The sibling TransportRequest
already hand-redacts for exactly this reason.
Wrap the headers in an SseHeaders newtype whose Debug redacts the
authorization value (non-sensitive headers stay visible). IoJob keeps its
derived Debug and stays correct for any future variant that carries the type.
Test asserts the token never reaches the debug string.
…lds bursts The head-change drain awaited each advance to completion before pulling the next event. The driver's coalescer only folds when a second advance for a ledger arrives WHILE the first is running (begin returns None), so a sequential drain never folded anything: a five-event burst ran five full advance cycles — five re-queries and five IndexedDB round trips on the peer — and a slow cycle on one ledger blocked every other ledger behind it. The comment even claimed the opposite was happening. Spawn each advance instead of awaiting it. Overlapping advances are what the coalescer was built for: a same-ledger burst folds into one follow-up cycle at the latest head, and different ledgers advance in parallel. The coalescer's folding is covered by live.rs (mid_cycle_head_signals_coalesce_into_one_followup_cycle); this change is what lets the drain reach it. The has_ledger gate still runs before the spawn, so unsubscribed ledgers cost nothing.
… the stream sse::run awaited the fetch promise for response headers with nothing racing it. The module correctly keeps no timeout on the long-lived STREAM, but a server that accepts the connection and never sends headers is hung, not long-lived: the head-tracking future then parks forever inside until_stopped, the reconnect backoff never runs, no Disconnected event is emitted, and the peer looks connected while silently never seeing another commit. Race the headers await against a connect deadline (config.nameservice_timeout, clamped through timer_millis). On expiry, abort the fetch and surface SseConnectError::Retryable, which drops onto the existing backoff/reconnect path. The stream itself remains unbounded in time once headers arrive.
fetch::execute read the whole body via array_buffer() with no size check, then copied it into wasm linear memory. With max_concurrent_fetches of these in flight, a hostile or broken server answering a block request with a multi-GiB body can exhaust the linear-memory ceiling and trap the wasm instance (recovered only by discarding all engine state). The residency tier's ObjectExceedsBudget check runs AFTER the bytes are already in memory, so it cannot prevent the trap. Pre-check the declared Content-Length against the residency budget (plumbed in as max_body_bytes) and abort+reject before the body read. The size decision is a pure helper in config.rs, unit-tested off-wasm (the driver is wasm-only). Bounds the honest-oversized case; a lying or absent Content-Length is not caught here — the residency budget still rejects such a block after the fact, and a fully streaming byte-cap would be the complete defense.
…essures The driver forwarded SSE body chunks over an UNBOUNDED channel to a consumer that awaits LedgerManager::notify per event. A server streaming faster than the consumer drains would accumulate chunks without limit. Switch to a bounded channel (depth 256): a full channel makes the driver's stream-read send await, which pauses the ReadableStream read and so the TCP read — backpressure to the server instead of unbounded buffering. Head-change events are small and infrequent, so the bound is never reached in normal operation; consumer-drop is still detected (send returns Err) and cancels the stream. Also gates config::declared_length_over_cap (the #6a body-cap helper) on cfg(any(wasm32, test)): its only production caller is the wasm driver, so a host lib build would otherwise flag it dead under -D warnings.
- onLedgerHead detach was not idempotent: it captured the listener set at
subscribe time and deleted the ledger entry whenever that set emptied, so a
defensive double-cleanup (or a late unmount after another component
re-subscribed the same ledger) evicted the new component's head listener,
which then silently stopped updating. Guard with a "removed" flag and only
delete when the registered set is still the captured one. (mutation-checked)
- A query first observed AFTER close() rendered "loading" forever: snapshotFor
minted INITIAL_STATE for an unknown key on a closed cache, and a closed
cache never observes or fetches, so nothing could ever move it. Return a
typed "client-closed" error (a frozen singleton, so getSnapshot stays
referentially stable) instead.
- RemoteTransport left the connection indicator stale when nothing was live to
watch: refresh() aborted the stream and returned without a state change, so
a page that dropped its last subscription kept showing "live", and a
time-travel-only page showed "connecting" forever. Add an "idle" state
(non-breaking: the package is unreleased) and emit it when the URL resolves
to null.
- A peer cycle spanning two spellings of one ledger (useQuery("demo/board")
and useQuery("demo/board:main") on one page) was filed under whichever
spelling claimed first, so client.ledgerHead for the other never fired.
Group by each subscribing spec's ledger and emit one CycleUpdate per
spelling, as failAll already does. (regression test added)
…ener The channel dispatches the same cycleOutcome event object to every listener, and PeerTransport registers two that both call toLiveCycle on it — one for per-subscription fan-out (LiveRegistry), one for delivery (Peer.onCycle). So every changed payload was UTF-8-decoded and JSON.parsed twice per commit, on the main thread, in a package whose whole argument is that you pay only when the answer changes. Memoize the decoded LiveCycle in a WeakMap keyed on the event; the event is unreferenced after dispatch, so entries collect with it.
The WALKTHROUGH and README told the reader they needed a server built from PR #1730, which reads as an external dependency. This branch already carries the per-ledger ?ledger= SSE fix (the same two commits are up standalone as #1730 for landing on main), so `cargo build -p fluree-db-server` from this checkout is all the demo needs. Corrected the setup note, the requirements table, and the known-blockers entry to say so.
Formatting only (assert_eq! wrapping, a long send() call, a stray blank line) across the cache-verify, SSE-timeout and buffer-bound changes. No logic change; native 4382, binary-index 394, wasm clippy, and both tsc suites are green at this head.
… paths Found by an independent second adversarial pass over the shell: - F1 (High): a FATAL re-init reply double-recycled. onmessage recycles a fatal reply synchronously, and the reinit .then recycled ALL non-ok replies — fatal included — as a microtask, arming two respawn timers. The later one fired an orphan worker (live wasm + SSE stream) after the first recovered, splitting subscriptions across two engines and leaking past close(). This is a flaw inside the first-round #7 remediation ("treat any non-ok re-init as a recycle trigger"): the correct predicate is non-fatal-non-ok. Gate the .then on !fatal, and make recycle() re-entrancy-safe (clear any armed respawn timer before re-arming). - F2 (Medium): playground()/connect() leaked the spawned worker (and, in peer mode, its wasm instance) on a NON-fatal init failure — bad url, a rejecting getToken (401), unsupported mode — because onmessage doesn't recycle those and the caller never received the Channel to close it. Wrap init in try/catch → channel.close() + rethrow. - F4 (Medium): a getToken that never settles wedged init forever (the worker's `initing` never resolves, every later op awaits it). Bound the init round-trip with a 30s timeout in Channel.call that rejects typed; the F2 cleanup then closes the channel.
…y fire; de-vacuum the head-detach test An independent second adversarial pass found two first-round fixes that were correct in isolation but dead through the real code paths, and one test that could not pin the fix it guarded: - Finding 1 (Medium): the query-after-close CLOSED_STATE never fired through useQuery. watch()→handleFor→liveHandleFor minted a real handle into byKey even when closed, so snapshotFor's byKey hit returned loading before the closed-check — a permanent spinner. watch() now returns a shared frozen CLOSED_STORE (stable subscribe/getSnapshot, getSnapshot→client-closed error) via cache.storeFor, minting nothing. New liveClient test; mutation-verified. - Finding 2 (Medium): the idle connection state was unreachable for the two cases its docstring named. refresh() ran only when the live-ledger set changed, so a query-less or all-time-anchored page sat at `connecting` forever. start() now calls the (debounced) refresh() once, which coalesces with initial subscribes and settles the final state — idle when nothing live subscribes. Documented that idle is remote-only (peer has no "nothing to connect"). Two new RemoteTransport tests; mutation-verified. - Finding 3 (Low, test-quality): the head-detach regression test passed with either guard removed (the `removed` flag and the `=== set` identity check are mutually redundant for the only reachable threat, a double-detach), so it pinned neither. The `=== set` check alone is sufficient and principled (this path has no counter to keep idempotent, unlike addObserver); dropped the redundant `removed` flag, so the existing test now pins the one guard that matters (mutation-verified: removing `=== set` fails it).
…emo 4/5) Non-behavioral fixes from the second adversarial pass — all comment/CSS only: - browser S1 (Should-fix): document that residency_budget_bytes is a HARD requirement (must exceed the combined per-ledger concurrent working set), not just a high-water mark — exceeding it makes the tipping fetch wait the full budget_wait for a release that cannot come, then fail typed, and repeat every cycle. A fast-fail that skips the futile wait is deferred to the browser reproduction (human-validation H2): the production retry loop takes a nested per-query guard, so "only the cycle's own guard is live" cannot be read from the in-flight count without confirming the guard nesting in a real browser first — and speculatively changing the live hot path is the perf risk we most want to avoid. - shell F5 (Low): document LiveCycle as READ-ONLY. One decoded cycle is memoized and handed — same instance, arrays and errors included — to every onCycle listener so a commit decodes once; a consumer that mutates it corrupts what the other listeners and the cache see. Copy before mutating. - react demo finding 4 (Low): the "only the changed row re-renders" headline is true only for non-reordering commits. Note in the demo docstring that a vote crossing a neighbor legitimately re-renders the band it jumped (positional structural sharing — correct, not a regression), and how to see the clean single-row case. - react demo finding 5 (Low): give `idle` and `connecting` distinct dot colors so a settled page is visually distinct from a connecting one (they shared the default grey).
The second review's one remaining gap (F3, Medium): the only bound on a query
was the retained-memory budget, so a query that is slow but not memory-hungry
ran unbounded on the worker's event loop with no recourse short of close()
(which discards the whole engine). Add an optional per-call timeout.
`QueryOptions.timeoutMs` (and the wasm `query_sparql`/`query_jsonld` gain an
`Option<f64>`) arms a `TimeoutFuture` on the worker event loop, raced against
the query. If it elapses first, the query's shared QueryCancellation is tripped
with `Timeout`; the engine's cooperative checkpoints unwind it into
`QueryError::Cancelled { reason: Timeout }`, which maps to a NEW typed JS code
`timeout` (HTTP 408, already the api-crate status for a cancelled query — only
the code is new). This reuses the existing external-signaller cancellation
design (the handle was built for exactly this); a timeout is a normal typed
reply, so it does NOT poison/recycle the worker.
Scope and honesty:
- Zero cost on the default path. With no `timeoutMs` (and no memory budget) the
query runs with default options and no cancellation handle — byte-for-byte
the pre-F3 path, so no non-timeout query pays for this. The timer is dropped
(clearing its setTimeout) the moment the query wins, so no lingering timer.
- The worker's query methods are async and yield to the event loop at every
await (peer residency fetches from IndexedDB / network), so the timer fires
and an I/O-bound query aborts promptly. A purely compute-bound stretch with
no awaits is bounded only by reaching the next cooperative checkpoint;
hard-preempting pure compute needs a cross-thread SharedArrayBuffer signal —
a separate, larger change, documented in run_query_with_timeout, not silently
implied to work.
- The ms value is clamped to i32::MAX so a huge input cannot wrap negative and
be treated by setTimeout as "fire immediately" (the browser timer-overflow
trap).
Tests: the reason->code mapping (Timeout -> `timeout`, Cancelled/disconnect ->
`cancelled`) is pinned by a native unit test (`cargo test -p fluree-db-wasm`).
The end-to-end timer race needs a real worker and is a human-validation item.
Verified: native test green; wasm32 clippy -D warnings clean; wasm-bindgen glue
regenerated and `build:ts` clean against the new 3-arg signature.
- rustfmt reflowed the exec_options_with_cancel return tuple and the F3 error-mapping test's assert (post-hoc, per the fmt-after-last-edit rule). - S1's `ResidencyError::EvictionDeferred` link needed a full path (crate::residency::ResidencyError::EvictionDeferred) — the type is not in scope in live.rs. (The crate has pre-existing broken intra-doc links, so CI does not gate `cargo doc -D warnings`; this just makes the new link render.)
…ry tests F3 added a third `timeout_ms` arg to query_sparql/query_jsonld; the wasm-bindgen playground tests still called them with two. These compile only for wasm32 under `--tests` (wasm-pack test), which the F3 verification pass missed — so the break was invisible to the lib-only wasm clippy run. Pass None (no timeout) at all nine call sites. Verified: wasm32 clippy --tests -D warnings clean.
…JS surface Review #1733 (peer.rs thread): BrowserPeer::set_token existed — tested, end-to-end sound at the Rust level (shared TokenCell; fetch transports stamp per request, SSE resolves per connect) — but nothing above it called it, so a peer tab whose token expired 401'd on every block fetch with no recovery short of close()+connect(), and two doc comments contradicted each other about whether the capability existed at all (bridge.rs claimed the shell refreshes mid-session; peer.rs claimed no re-auth hook exists). Wire it through, exactly the shape the review suggested: Peer::setToken (wasm export) -> a `setToken` protocol op -> worker `requirePeer` dispatch -> `Peer.setToken(token)` on the JS surface. Peer-only: the playground answers with the same typed `unsupported` pattern the transact ops use in peer mode (memory ledgers carry no bearer). Both contradictory docs are now true and say the same thing: proactive refresh needs no teardown; getToken is still the pull at connect/crash-recycle — the two compose. Rust side already pinned by connect.rs's set_token_refreshes_the_bearer_without_teardown; a shell-side protocol test lands with the vitest harness (in flight on this PR).
…dataset seam Two #1733 review findings on the formatting frame's residency behavior (format/mod.rs threads): 1. Fuel re-charge per retry round. The drain/fetch/re-run loop re-ran formatting against the same Tracker, so N rounds charged N x one round's fuel and a near-budget query could fail FuelExceeded merely for missing residency N times — and on a peer, misses are the NORMAL first-query path. Formatting is pure and a failed round's work is discarded, so: snapshot the fuel counter once before the loop (Tracker::current_micro_fuel) and roll back before each retry via the new Tracker::restore_micro_fuel; the successful round's charge stands. Sound here because execution has finished — this frame is the tracker's sole fuel writer (the caller invariant is documented on the API). The execution loop shares the shape but runs concurrent consumers; left as the reviewer read it (explicitly not a regression there). 2. Dataset-hydration seam (#1775). The multi-ledger hydration branch is not wrapped by the drain/fetch/re-run loop, so on a residency-backed target a formatting miss would be unrecoverable — previously guarded only by a prose comment. The invariant is now enforced: a typed refusal if ANY graph in the dataset is residency-backed (live assertion; fires never today — the browser peer is single-ledger). The real fix (hoist the loop into a shared helper with per-graph store routing) is tracked in #1775. New: DataSetDb::graphs() iterator; two fluree-db-core unit tests pinning restore_micro_fuel (rollback semantics + inert when fuel tracking is off). Both changes live entirely inside the residency-gated cfg block — the default native path is untouched.
…r transport handleFor is public and bypasses the closed-cache guard that storeFor/observe/snapshotFor share: a call straight to it after close() was minting a QueryHandle and inserting it into byKey/bySubId with no janitor ever armed to collect it, since observe() (the only path that can attach an observer and un-stick GC) also no-ops when closed. liveHandleFor now hands back a standalone, untracked handle on that path instead. PeerTransport.applyCycle could silently drop a cycle entry for an engine id whose subscribe() reply hadn't resolved yet (byEngineId not populated), since the engine primes a subscription the moment it registers and the reply/cycle are separate postMessages with no ordering guarantee between them. A dropped entry left the cache's hasResult false, so the next "unchanged" cycle would misreport as a transport-contract violation. Unresolved entries are now stashed by raw engine id while any registration is in flight and replayed once that id's mapping lands; the stash is cleared whenever no registration is pending (so it can't accumulate ids this transport will never own) and again on a crash recycle (a fresh worker can reissue an id a dead one used).
…ked bodies Coalescer::abandon() cleared both `running` and `pending` when a cycle lease was dropped mid-await, silently discarding a head change some other task had already folded in. Only `running` needs releasing; leaving `pending` set lets the next begin()/finish() pair still force the one owed follow-up cycle instead of stranding sibling subscriptions on stale data until an unrelated signal happens to arrive. fetch::execute only rejected an oversized body via a declared Content-Length; a chunked response with no such header skipped the check and was fully materialized into linear memory regardless of size. Body reads now stream through ReadableStreamDefaultReader and enforce the same cap incrementally per chunk (config::body_cap_step), aborting via the existing AbortController once the running total crosses it.
Adds the first test runner for fluree-db-wasm/js: a Worker-stub harness (test/helpers.ts) that lets connect()/playground() run against a scripted double instead of a real worker, no wasm build required. Pins six already-shipped behaviors: the toLiveCycle decode-once memo (ported from the round-2 adversarial-review prototype), the crash-recycle ladder's guard against double-firing on a fatal re-init reply, connect()'s cleanup on a non-fatal init failure, the 30s init timeout, and per-query timeoutMs passthrough for both Ledger.query and Snapshot.query. Also wires a step into the react-sdk CI job (npm ci + npm test in fluree-db-wasm/js) — that job already reruns on any fluree-db-wasm/js change per the react-paths filter, since fluree-react's own suite compiles against this package's src directly.
Completes the harness item the suite's author correctly declined: their
branch base predated the setToken wiring, so writing it there would have
meant fabricating the feature. At this tip the op exists — pin that
Peer.setToken puts {op:"setToken", token} on the wire verbatim and resolves
on the ok reply. (The worker-side peer-only gate is behind the stub; the
wire contract is the pinnable layer here.) Mutation-verified: blanking the
token at the call site fails the test.
|
All seven inline points are addressed in code (each has a thread reply with the commit), plus the two housekeeping items: both PRs now carry the |
This is the third and final PR of the wasm program, and the one with a product behind it. #1714 made the engine run in a browser; #1715 made it a peer that reads a real server. This makes it live: a commit on the server re-renders exactly the affected React component, in a browser tab, with no polling code anywhere in the application.
The demo is the argument. Two tabs on one ledger, a vote cast in tab B, and in the untouched tab A only the changed row re-renders — its counter goes
1×→2×while both sibling rows sit at1×, and a second panel querying the same ledger re-runs and does not re-render at all, because its answer did not change.fluree-react/demo/WALKTHROUGH.mdis a five-step script for running exactly that, written so someone can present it cold.What's in it, bottom to top
Engine (
fluree-db-api). Head-change catch-up now prefetches the novelty translation leaves it is about to need, so a subscription's re-query after a commit is a resident hit rather than a fetch round. The incremental catch-up ceiling is raised on wasm (5 → 64 commits) with a measured rationale — the parent-chain walk is one sequential fetch per commit, so at browser RTTs ~64 is roughly where catch-up stops beating a full reload — and the beyond-ceiling reload path was verified to still emit the "queryable at t" signal, so a sleeping tab that wakes far behind still fires exactly one cycle.Live-query driver (
fluree-db-browser). A subscription registry and an advance-cycle: on a head change, take ONE frozen snapshot, hold ONE cycle-level query guard, re-run every subscription against that single view, hash each formatted result (xxh3) as a change gate, and emit ONE batch outcome —{ledger, t, changed, unchanged, errored}. Unchanged subscriptions ship zero payload. Mid-cycle head events fold into exactly one follow-up cycle at the latest head, never a queue. Sibling components therefore cannot disagree about which commit they are showing, which is the invariant everything above depends on. The v2 invalidation filter has a seam here (affected(&flakes) -> bool, default true, never consulted for a subscription that has not yet produced a result) but v1 deliberately re-runs everything and diffs — correct by construction, and the diff gate is what keeps it cheap.Shell (
fluree-db-wasm+@fluree/db-wasm). Peer mode:connect(url, {getToken, …}), an unsolicited worker→maineventchannel (versioned), and subscribe/unsubscribe verbs that surface each advance-cycle as one batchedcycleOutcomeevent with payloads transferred rather than cloned. Credentials never ride the init message — the worker asks for a token over the event channel and the main thread answers — which matters because a crash recycle replays init. Live subscriptions work in playground mode too, driven after local commits, so the no-server demo is live as well.@fluree/react— a new top-level npm package, not a cargo workspace member, with its own path-gated CI job. A framework-agnostic core (client → query cache → handles → observers) with a ~100-line React adapter onuseSyncExternalStore; React context injects the client only, never data. Two transports behind one hooks API: remote (HTTP queries, SSE-triggered cycles — no wasm anywhere, server-renderable) and peer (the in-browser engine).connectis injected rather than imported, so the package has zero dependency on@fluree/db-wasmand remote mode bundles without wasm. Unchanged rows keep object identity across an advance, which is what makesReact.memowork down the tree.Decisions worth scrutiny
unchanged, which the cache needs for error→ready recovery. HenceonCycle/onEngineStateas shell API additions.opts.at(historical views) and non-language-matchedopts.formatsurface as typedunsupportederrors, because the engine has no such path and quietly serving something else would be worse.postMessageclones. The worker's xxh3 gate decides whether anything changed; identity is preserved against the previous result after the boundary.Two wasm cache guards that look redundant and are not
Worth stating because the natural review instinct is to collapse them.
ledger_manager::temp_cache_dirreturns a synthetic path on wasm instead of callingenv::temp_dir(), which prevents a panic —temp_dir()aborts with "no filesystem on this platform".fluree_db_core::disk_cache::ensure_cache_dirmakescreate_dir_allon that path a no-op on wasm, which prevents an error that would otherwise abort the index load before any CAS fetch is attempted. Different failure, different layer; removing either one reintroduces a distinct bug. Verified that no othercreate_dir_allsits on the wasm read path — the remainder are import and server-defaults paths, native-only.Performance, stated honestly
Peer mode is not a speed-up on first paint, and the README says so in a section with that title. Measured: remote mode reaches first rendered data in 141 ms on loopback; a peer cold open is ~3.4 s. Profiling the peer cold open found something worth the team's attention: ~3.3 s of it is fixed overhead, invariant to data size — a 2-row ledger costs 3,318 ms and a 200-row ledger 3,547 ms — and the worker is 90% on-CPU throughout (3,538 ms busy vs 327 ms idle, with 23 ms of network). That number is bug-shaped rather than tuning-shaped, it is not addressable by any transport work, and finding its root cause is now the top perf item for the browser peer. It is localized to
load_and_attach_binary_store,Fluree::db, andexecute_formatted, but not root-caused; nobody should read this PR as claiming otherwise.Choose peer mode for update latency and offline-capable local re-query, not time-to-first-paint. A warm re-query is 49–182 ms. One useful lower bound fell out of the hang investigation: with IndexedDB entirely out of the picture (a wedged database, so the peer runs fully cache-less) it serves in 252 ms, and two engines in two tabs serve in 252/262 ms — so the cache is not what the cold open is waiting on.
Native impact is unchanged from #1715's posture: the engine changes are cfg-gated or behind default-
Nonetrait methods, and@fluree/reactis not in the cargo workspace, so no Rust job builds because of it.What live runs found that the test suites could not
178 vitest tests, all mocked, all green — and running the thing in a real browser against a real server found six defects they were structurally incapable of seeing. Every one was silent:
fetchheld as an instance property and invoked as a method — every browser rejects that as an illegal invocation; Node does not check, and every test injected a fetch impl.name:main, so head events were discarded as unwatched: stream open, nothing erroring, nothing updating.ledgerHead()read as unknown forever..map()in userland silently defeats the entire point of the package, which is why it now leads both READMEs.engine_crashedwith an empty page console.GET /events?ledger=returning 400 for every value — shipped separately as fix(server): accept per-ledger SSE subscriptions on GET /events #1730, since it breaks any peer subscribed to explicit ledgers and has nothing to do with wasm.The end-to-end gate that catches this class now runs in CI: it launches a real
fluree-server, mints a scoped storage-proxy token, drives the shipped npm package in headless Chrome, and asserts rows — plus a cold, head-tracking-off phase in its own fresh profile. Its traffic assertions are the interesting part, and they are shape assertions rather than magic numbers: every recordedGET /storage/objects/{cid}succeeded (a partial failure fails the gate rather than degrading quietly), exactly one SSE stream, and zero requests to/query— that last one is what proves the engine computed locally instead of shipping the query to the server. A run records around three dozen CAS objects; that count is reported, not asserted, since it moves with the data.Known blockers and open items
open()fires no event whatsoever, not evenblocked, which is durable per browser profile and is why fresh-profile testing never reproduced it. The driver no longer depends on the cache opening; a wedged or unavailable IndexedDB now degrades to cache-less operation, exactly as the module's doc comment always claimed. Verified before/after on the same wedged profile with the failing precondition re-probed during the passing run.!Sendbecause it holds JS handles, so it runs in its ownspawn_localtask and is observed over a channel — this module's founding trick, and deliberately not routed through the driver, since a deadline that depends on dispatch cannot detect dispatch failing. Native is byte-unchanged; the error names the URL and says "did not dispatch", so "never started" is distinguishable from "timed out". Two tests guard it: one holds the receiver without draining (a live-but-not-dispatching driver) and asserts the typed error, the other asserts a reply inside the deadline still wins so the guard cannot mask real behavior.fluree-events-tokenissues onlyfluree.events.*, while the storage proxy requiresfluree.storage.*. A--storage-allflag would fix that and is not in this PR. Follow-up: events-token CLI: option to mint fluree.storage.* scopes for the browser peer #1773.Pre-review remediation (before this leaves draft)
This PR went through a blind internal pre-review first — a reviewer with no context on the work, applying the repo's own rubric, checking the PR out like any colleague's. Verdict was request changes over 32 findings, and all 32 are now closed: fixed, or rebutted with mechanism. Highlights, because several are more interesting than the fixes:
The critical one falsified this PR's own headline claim.
primebypassed the coalescer whileadvancewent through it, and nothing downstream ordered by watermark — so a slow prime landing after a fast advance pinned a component on pre-commit data and left its stored hash at the stale result, meaning a later commit restoring that result reported "unchanged" with no payload and the component never recovered. "Sibling components cannot disagree about which commit they're showing" was true within a cycle and false across two. Fixed structurally rather than patched:advanceandprimeare now one serialized path, so there is one concurrency regime per ledger, andCoalescer::beginreturns an RAII lease that releases the slot on drop.Two findings turned out worse than the review stated. A failed block fetch during result formatting was answering
400 err:system/FormatError— telling a caller its data was malformed on a condition a retry fixes; it is now a 503err:storage/ReadFailure, with the same shape one frame over flagged rather than quietly left. And the events parser'sallflag accepted only the literal stringstrue/1, so?all=TRUE,?all=yesand every typo silently meant false — a 200 SSE stream matching nothing, forever. That second fix ships in #1730, which owns that parser (see the note below).A "never" timer that fired immediately. Five sites clamped a duration with
u32::try_from(..).unwrap_or(u32::MAX); asetTimeoutdelay abovei32::MAXoverflows and fires at once, so the value meaning "effectively never" meant "now". Found while mutation-checking something else, when a deliberately-wedged open resolved instantly.Two documentation claims were checked and could not be stood behind, and that matters here because this README's whole framing is separating claims by evidence: a coverage row claiming the remote suite proved per-subscription delivery ordering (the reviewer neutered the entire ticket mechanism and all tests still passed — the missing test has been added rather than the belt removed), and "thirty subscriptions do not fire thirty simultaneous requests", which was false at mount because the limiter only applied inside cycles. The demo also contradicted its own headline —
App.tsxsaid "search this file for a timer, an interval; there are none" and contained asetInterval. Fixed the code, not the claim.One finding was declined, with reasoning: a
wasm-pathsCI gate analogous tobench-paths.bench-comparemeasures a known crate set, butwasm-smokeruns the engine and its failures are runtime ones any engine crate can cause — so an honest gate would match nearly everything, and a narrow one is the same silent-skip trap as thereact-sdkpath-gate bug fixed in this PR. Reducing that job's feature set was the saving that doesn't trade correctness for it.Test counts after remediation:
fluree-db-browser62,it_residency_retry8,fluree-db-serverlib 151,@fluree/react200, headless-browser suites 11, plus the real-server peer gate passing end to end.Overlap with #1730, deliberate
The two commits here touching
fluree-db-server/src/routes/events.rsare byte-identical to #1730's first commit. That fix was extracted so it could land early and independently — it breaks any peer subscribed to explicit ledgers, which has nothing to do with wasm — and this stack needs it because the peer gate subscribes with?ledger=. When #1730 merges, these two become patch-identical to main and drop on rebase; the?all=fix lives only in #1730.Named gaps, not absorbed
The shell
index.tssurface is now covered by a vitest suite IN this PR (fluree-db-wasm/js/test/, wired into the react-sdk CI job):globalThis.Workeris stubbed with a message-recording double andconnect({ workerUrl })/playground({ workerUrl })drive the realChannel,toLiveCycle, recycle ladder andLiveRegistrywith zero production changes. Seven tests pin the cycle-decode memo (one parse per payload, shared object identity), the fatal-re-init single-recycle guard, init-failure worker cleanup, the 30s init timeout, per-querytimeoutMspassthrough on both query surfaces, and thesetTokenwire contract — each proven non-vacuous by mutating the committed guard it pins and watching it fail. Thepeer.rsdrain is covered by the peer gate plus the coalescer tests influree-db-browser::live. On tokens: review found thatBrowserPeer::set_tokenexisted (tested) but nothing above it called it, and two docs contradicted each other about it. It is now wired through end to end —Peer.setToken(token)→ asetTokenop → a peer-only worker dispatch → the sharedTokenCell— so a long-lived tab can refresh its bearer proactively with no teardown;getTokenremains the pull at connect/crash-recycle, and both docs now agree.External adversarial-review remediation (2026-09) + caught up to
mainSince the pre-review, this stack was rebased onto current
main(49 commits; semantically verified at each level, not just textually) and put through a second, independent adversarial review of the surface the internal pre-review and the human reviewer had NOT covered — the wasm read-path, the two new browser crates, and this PR's Rust + TypeScript. Every agreed finding is fixed in-tree, each verified by execution (mutation-checked where a test pins behavior), not by description:onLedgerHead's detach is idempotent (a stale cleanup no longer evicts a re-subscribed component's head listener); a peer cycle spanning two spellings of one ledger is delivered under each spelling; a query first observed afterclose()returns a typed error instead ofloadingforever.LIMIT-shaped scan no longer fetches the whole predicate run (with an airtight retry-termination gate, in wasm32 groundwork: the engine stack compiles and runs on wasm32-unknown-unknown, native untouched #1714).IoJob'sDebug; the SSE connect is bounded (a hung server no longer silently kills head-tracking); and an oversized/flooding response is rejected/backpressured before it can trap the wasm instance.Deferred, deliberately and tracked (not silent backlog): the per-block CORS preflight is its own follow-up PR — #1772, a batch object endpoint (a maintainer decision, since it is a cold-profile-only perf item, not correctness); a
fluree-events-token --storage-allscope flag — #1773 (a separate server-CLI change the peer needs to self-serve a storage token); the ~3.3 s cold-open floor root-cause — #1774; and the dataset-residency formatting seam — #1775 (the hydration branch now refuses typed on a residency-backed target instead of dying on an unrecoverable miss; the real fix is hoisting the retry loop with per-graph store routing).