Skip to content

browser peer + playground: fluree-db-browser, @fluree/db-wasm, SSE head tracking, and the residency retry loop - #1715

Merged
aaj3f merged 31 commits into
mainfrom
wasm/pr2
Sep 3, 2026
Merged

browser peer + playground: fluree-db-browser, @fluree/db-wasm, SSE head tracking, and the residency retry loop#1715
aaj3f merged 31 commits into
mainfrom
wasm/pr2

Conversation

@aaj3f

@aaj3f aaj3f commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

This is the second and final PR of the wasm program: everything that turns PR-1's "the engine runs on wasm32" into product. It's deliberately over-scoped as one reviewable stack (per our fewer-bigger-PRs preference) — 13 commits in four layers, each layer gated independently before assembly. Stacked on wasm/groundwork; nothing here touches the native engine paths beyond what's called out below.

What you can actually do at this HEAD:

  • Run Fluree in a browser tab, no server, no special headers. cd fluree-db-wasm/js && npm install && node scripts/build.mjs && node scripts/serve.mjs → an interactive playground at 127.0.0.1:8787/demo/ (create ledger → JSON-LD insert/upsert/update → SPARQL + JSON-LD queries, plus a transfer-vs-clone transport timer). Headless proof: node scripts/smoke-browser.mjs — the CI-shaped smoke drives the shipped npm package over CDP and passes with init ~900 ms, insert ~740 ms, query ~675 ms on the dev box.
  • Assemble a browser cache peer against a real server. fluree-db-browser::connect(api_base, token, config) yields a BrowserPeer over the peer-proxy wire contract: CID-verified fetches through a channel-bridged transport, an IndexedDB CAS cache with verify-once-then-trust and LRU byte budget, a synchronous residency tier feeding the engine's read path, SSE head tracking, and the production retry loop that makes cold queries converge. (The JS shell's peer mode — wiring BrowserPeer into the worker protocol — is the one deliberately-deferred integration; the constraints are enumerated in fluree-db-wasm/js's protocol notes and both sides were built to slot together.)

The four layers

1. Server browser-readiness + the transport seam (fluree-db-server, fluree-db-nameservice-sync). CORS now uses AllowHeaders::mirror_request() — the first cut hand-listed allowed headers and our internal review caught that it would have regressed every browser client that sends fluree-identity/fluree-track-*/Idempotency-Key (the wildcard it replaced doesn't cover Authorization, which is the whole reason to touch this; mirroring covers both). Object GETs get ETag: "{cid}" + immutable caching (CID-addressed bytes are the textbook case — this also slots the browser's HTTP cache underneath IndexedDB for free); the NS record gets an ETag over the served bytes (a digest, not the {commit_t}:{index_t} composite we first tried — retracted/branches/serving all change without a commit, which review also caught) with 304 handling ordered after every authz/serving gate so a 304 can never become an existence oracle (test-pinned, including out-of-scope-token → 404 with a matching validator). The proxy clients now sit on an HttpTransport seam (plain-data request/response, Bytes bodies, no reqwest types) so a wasm fetch impl can slot in; native behavior is preserved to the error-message level (all four call sites diffed line-by-line in review), and ReqwestTransport + the native machinery are target-gated so fluree-db-nameservice-sync genuinely builds for wasm32 — which CI now checks.

2. The residency retry loop + freshness (fluree-db-api, fluree-db-binary-index, fluree-db-core). The production form of PR-1's machinery: query_with_options on residency builds (wasm32, or the residency dev feature natively) wraps execution in drain-wants → concurrent fetch-and-pin → re-run, holding the storage's query guard across rounds (a new default trait method mirroring miss_register; the guard is what makes progress monotone — eviction is frozen while any query is in flight) with a fresh fuel tracker per round so retries don't bill earlier rounds against the final budget. Scans rarely reach this loop at all: the operator-frame retry inside binary_scan absorbs leaf misses in place (mutation-tested — disabling the outer loop only fails the one-shot fast-path and policy tests, exactly the intended division of labor). Freshness keeps novelty replay (no new consistency mode) and closes its miss source by prefetching the overlay's reverse-dict leaves at ledger load, pinned by a mutation-checked multi-leaf unit test. Streaming query entry is excluded on wasm by design (rows emit before completion; can't re-run). Not yet wrapped: the cypher/tracked/dataset entries — named follow-ups, same pattern.

3. fluree-db-browser — the engine-side browser I/O crate. A Send + Sync channel bridge to a spawn_local driver that owns fetch/AbortController/IndexedDB (the storage traits box Send futures, so a JsFuture-holding transport can't exist — this is the shape that satisfies them); WasmFetchTransport implementing the seam to its documented contract (Ok for any HTTP status — status semantics stay client logic; credentials: 'omit'; exactly one JS→wasm copy per block, documented); the residency tier (Arc-clone hits, pin sets, batch-first coalesced fetches, no-eviction-while-in-flight with a bounded deferred-insert wait); one memory governor deriving every budget from a single ceiling; write-behind IndexedDB persistence with fetch-admission backpressure (a lagging IDB stalls the pipeline instead of queueing clones). The integration test to start review at runs the engine's landed RetryBudget::after_error through the real content_store_for bridge over this storage — miss → register → drain → fetch → pin → re-run with one network fetch, and empty-register → "real error" discrimination — every step positively asserted. 4 real-browser tests (headless Chrome) cover fetch round-trips, error classification, and IndexedDB persistence across reopen.

4. fluree-db-wasm + @fluree/db-wasm (name pending the npm-org decision) — the JS shell. The engine in a dedicated worker behind a typed protocol; a main-thread proxy with hand-written TS types; per-call frozen snapshot handles (an update mid-session provably doesn't leak into an open snapshot — test-pinned) and no streaming surface; a crash model where a wasm trap marks the worker fatal, in-flight calls reject with typed engine_crashed/out_of_memory, and the proxy recycles the worker so the Playground object survives; maxMemoryBytes (default ¼ of deviceMemory, clamped) applied per query. SSE head tracking rides a shared pump lifted from the server's peer subscription loop (parameterized over connection/timer/sink; the server keeps its public shape and drives the same pump), consumed browser-side as a dedicated streaming driver job — with a real ReadableStream test in Chrome. Ships with a wasm-smoke CI job that executes the browser build (bindgen tests in a dedicated worker + the shipped package over CDP), not just compiles it.

Performance and build characteristics, transparently

Native first, because it's the thing we rank highest. The shared-crate surface of this PR is deliberately tiny: the server header/CORS commit, the HttpTransport seam under the proxy clients (reviewed line-by-line for behavior preservation down to error-message text; per HTTP round trip it adds one async_trait box and a small header Vec — invisible next to the network — and the response body stays Bytes uncopied), the SSE-pump lift (server keeps its public shape and drives the same pump), and two default trait methods on the storage traits (query_guard/miss_register, default None/None — native implementors never override them and the retry loop that consults them doesn't compile into default native builds). Everything else is two brand-new crates. The full native battery is green at this HEAD — workspace check, nameservice-sync 96, proxy_integration 34 (serial), browser crate 41, residency suites 5/5 + 380, query lib 1416, fmt/clippy per crate — and since this touches perf-relevant paths the bench-compare gate will annotate TIME and PEAK_MEM on the PR; treat that as part of the review.

Wasm runtime characteristics, measured not guessed (dev box, headless Chrome, shipped package over CDP, no isolation headers): engine init ~901 ms (includes wasm streaming-compile + worker spawn + first instantiation — a one-time cost per page), JSON-LD insert ~739 ms, SPARQL query ~675 ms on a fresh micro-ledger. Result transfer between worker and main thread uses transferred ArrayBuffers rather than structured clone where size warrants it — the measured gap that motivated this is ~300 ms vs ~6.6 ms at 32 MB. On the peer read path: a synchronous residency hit is an Arc clone (zero copy); a fetched block is copied exactly twice (JS→Bytes, BytesArc<[u8]> — the seam contract's price, documented in cas.rs), and the Vec-returning legacy Storage methods pay a third at their boundary, which the residency-first entry points avoid. IndexedDB writes are write-behind and can never delay a read; a lagging IDB applies backpressure to fetch admission instead of queueing clones.

What a cold open actually costs, measured (not in this PR to fix, but reviewers should have the number): opening a 200-row ledger in a browser peer issues 18 object GETs — and on a 100 ms link they arrive as 16 sequential waves, 15 of them a single object, because the peer discovers what it needs a round at a time. Roughly 37 serialized round trips to move 20 KB. Two consequences worth knowing while reading the CORS commit: Authorization makes every object GET non-simple, so each one costs an OPTIONS first (~1.9 s of a ~5.8 s cold open at 100 ms RTT), and the Access-Control-Max-Age we set genuinely helps the repeated-URL routes but buys nothing on the object route, because preflight caching is keyed per URL and every CID has its own URL. Also measured: Range is CORS-safelisted (so the ranged leaf reads planned later add no preflight of their own), and a stable batch URL preflights exactly once ever. None of this is urgent — objects are immutable-cached, so it is paid once per cold profile and never again — but it says where the next work goes: a batch object endpoint (one preflight, ~4× fewer requests, drops into the driver's already-batch-shaped fetch_cids) and widening what each discovery round learns. The peer is discovery-limited, not width-limited: max_concurrent_fetches defaults to 8 and currently sits unused because the engine never knows about 8 objects at once.

Build/size: shipped wasm (fat LTO, -Os, wasm-opt -Os): 8.99 MB raw / 3.31 gzip / 2.28 brotli, 26 KB JS glue. A pure-size build (-Oz, one env flip documented in build.mjs) lands at 7.48 / 2.83 / 2.01. We shipped -Os on the speed-first mandate — -Oz trades speed-relevant codegen for ~12% wire size and I'd rather hold the line on speed until someone shows the download matters; flip is one env var if the team disagrees. For calibration: this binary still links r2rml, the reasoner, BM25, and Cypher — feature-slicing a slim profile is available headroom we deliberately haven't spent (DuckDB-wasm ships ~11 MB compressed; we're at 2.3 with everything in).

Decisions that warrant scrutiny (and the alternatives they beat)

  • CORS via AllowHeaders::mirror_request(), not an allow-list. Our first cut hand-listed headers and internal review proved it would regress every documented fluree-*/Idempotency-Key browser caller (the wildcard it replaced doesn't cover Authorization — the reason to touch this at all — but covered everything else). With nothing to keep in sync. It is not, however, "exactly the wildcard's trust posture" — an earlier draft said that and review correctly pushed back, because the difference is the point of the change: * deliberately excludes Authorization, so cross-origin authenticated requests were impossible before and are possible now. This is strictly more permissive, on purpose. The consequence is real for internal deployments — a page the user visits can reach an internal Fluree server with an Authorization header and read the response. That is not CSRF (no ambient credentials; the page must already hold a token), but it widens the browser-as-pivot surface. The code comment now says this rather than the softer version. Follow-up, deliberately not smuggled in here: cors_enabled is a bool with allow_origin(Any) hardcoded, and now that browser clients are a first-class use case an origin allow-list is worth having.
  • NS ETag = digest of the served bytes, not the {commit_t}:{index_t} composite we tried first — review found four ways the representation changes without a commit (retracted, branch count, serving, drop-recreate), each of which would have pinned a stale 304 in browsers indefinitely. Bytes-digest is strictly stronger than any field list.
  • Eviction policy: frozen-while-any-query-in-flight, newcomer waits on overflow. Alternative: per-query pin attribution. Rejected because pins can't be attributed through a resolve_cached_bytes(&cid) hit — a query that hits bytes another query fetched would hold no pin, and its working set could vanish between retry rounds (livelock). Tier-global freeze is coarser but is what makes retry progress provably monotone. The cost: a memory-hungry query can hold the tier open; the bounded budget_wait + typed failure is the escape.
  • The transport buffers whole bodies; SSE is a separate driver job. Alternative: a streaming HttpTransport. Rejected — every CAS consumer wants whole verified blocks (CID verification needs the full payload anyway), and the one genuine streaming consumer (SSE) gets a purpose-built job type instead of complicating the seam every block read rides.
  • Retry loop lives at the api query entry (cfg-gated), not in the JS shell. Alternative: shell-side loop. Rejected per the test-guard lesson: api-level means a native CI test with a miss-injecting store exercises the production loop — shell-side would leave it untestable until a browser harness, and unusable by non-JS embedders.
  • Worker-per-engine on the stable toolchain; no wasm threads. The +atomics build (wasm-bindgen-rayon) needs a pinned nightly, a second artifact, and pushes COOP/COEP isolation headers onto every embedding page (breaks OAuth popups; no Safari credentialless). Deferred behind a profiling trigger, not ideology — the ladder (OPFS sync reads → multi-worker → threads) is documented and each rung is additive.
  • -Os over -Oz — argued in the build section above.

Two deliberate server behavior deltas (flagged for review, not buried)

The SSE lift carries two argued changes: head-change notifications now populate source_branch/branches from the wire (previously hardcoded None/0 — branch lineage the peer client provably needs), and an unparseable ledger_id in one event degrades to using it verbatim as the name instead of erroring the whole stream. Both are in the lift commit's body with rationale; neither has a behavioral test upstream because — honest note — no pre-existing integration test exercises PeerSubscriptionTask at all. The pump got six unit tests (parse/backoff-growth-and-reset/failure/malformed-frame/stop) in the lift, but the peer-subscription coverage gap predates this PR and is worth its own issue.

What this PR does not do

No peer mode in the JS shell yet (the BrowserPeer ↔ worker-protocol wiring is enumerated and deferred — the protocol needs an unsolicited-event channel first, and a recycled worker's replayed init must never carry a raw token, so the token flow needs its callback bridge). No cypher/tracked/dataset retry wrapping. No OPFS cache tier, no ranged/pack-paged fetches, no wasm threads — all deliberately Phase-3, gated on measurements. The public/anonymous server tier stays its own future PR before any hosted playground.

Review map: start with fluree-db-browser/src/cas.rs (the storage + the after_error integration test), then fluree-db-api/src/view/query.rs (the loop), then the SSE lift (fluree-db-nameservice-sync/src/head_stream.rs against the old peer/subscription.rs), then the shell's protocol.ts/worker.ts. The server header commit is small and its tests tell the story. The Cargo diffs are wide but mechanical.

Pre-review remediation (before undrafting)

Before asking anyone to spend review time here, we ran this PR through an internal blind pre-review (a reviewer with no context on the work, applying the repo's own review rubric, checking out the PR like any colleague's). Verdict was request-changes (one CI-gate red, six highs) — all resolved. Every finding was then either fixed or rebutted with evidence — the remediation commits are on the branch, findings referenced in their messages. Highlights: a clippy lint only reachable under --all-features (invisible while stacked — the base branch gets no CI) fixed; the residency tier's eviction now uses epoch-tick semantics (entries older than the oldest live query guard are evictable), closing a warm-full-tier scenario where every cold fetch would stall then fail — with the monotone-progress property re-proven by tests at tier, storage, and api-loop levels; write-behind backpressure actually applies now (permit acquired inside the fetch slot, buffer_unordered batches) with a stalled-persist throttling test; fluree-db-browser's five real-browser suites now run in CI (they previously never executed anywhere); JS snapshot handles are generation-stamped so pre-crash objects reject typed instead of silently reading a recycled engine's state, respawns are capped with backoff, and the worker-spawn pattern is the bundler-statically-detectable shape (Vite verified by a repro script; other bundlers honestly marked unverified). One engine fact surfaced while making the memory-budget browser test deterministic, worth its own follow-up: the per-query memory budget charges grouped-aggregation state (GROUP_EST_BYTES) and hash-join lanes, but plain cross-join row retention and GROUP_CONCAT payload bytes are not charged today — the test pins the typed-rejection contract on a charged lane and says so.

Review remediation

Four items from review, all addressed; the two flagged as wanted-before-merge are the first and last.

Vary: Authorization on CAS object responses — a real cross-principal cache leak, now fixed. These bodies are authorized by bearer token and served private, max-age=31536000, immutable. private keeps them out of shared caches, but the browser's own cache is per profile, not per principal, and immutable means it never revalidates — so after principal A read a ledger, principal B on the same browser profile was served those CIDs directly, with the server never seeing B's token and never getting to refuse it. Vary puts the token in the cache key. Pinned at all three object responses (200, 206, 304) in test_object_endpoint_immutable_caching_and_conditional_get, and mutation-checked: reverting the source with the assertions kept fails the test. The NS routes deliberately don't need it — no-cache revalidates, so authorization re-runs server-side before anything is served.

The two subscription behavior deltas are now pinned. Review's point stood: peer/subscription.rs is −377/+152, a restructure carrying two deliberate behavior changes, and the six new tests covered the lifted SSE pump rather than those changes — so a regression would have had no failing test and no clean bisect boundary between "the refactor broke it" and "the intended change broke it". Two tests added at the conversion site (server_sse.rs): one pins source_branch/branches being carried from the wire (they were hardcoded None/0, so a branched ledger arrived looking unbranched) and that their absence still defaults cleanly for an older server; the other pins that an unparseable ledger_id degrades to the verbatim id plus the wire's branch rather than tearing down the stream, with a precondition assertion that the fixture really is an id the canonical parser rejects, so the test cannot go vacuous. The first is mutation-checked against the hardcoded values.

The duplicated query entry path is factored into one plan_and_execute_round. Both cfg arms now call it and differ only in the retry wrapper. This one is worth stating precisely because it interacts with the dev-dependency feature unification raised on #1714: cargo test -p fluree-db-api and every api bench compile the residency arm, so the production cfg(not(...)) arm is not built there at all — two copies of the main query path meant nothing in the api suite exercised what ships, and drift between them would have been invisible. The call is once per query, not per row, so it is not on any hot path; verified building in all three configurations (production native, residency, wasm32).

Caught up to main, and where the browser-driver hardening lives

Rebased onto current main (verified semantically, not just textually). A second independent adversarial review then covered the two new crates (which the human review explicitly excluded). Its findings are fixed in the stacked child #1733, not here, because they are entangled with lq-smoke's token-cell evolution (pre-this-PR there is no TokenCell, so the driver-open/deadline/timer commits cannot be cleanly cherry-picked down without pulling a dependency chain). This is a strict stack that merges in order, so #1715#1733 lands the browser peer as a unit: the driver-open hang, the u32::MAX-is-immediate timer, the SSE connect timeout, the unverified-cache and token-Debug issues, and the buffer bounds are all closed by the time #1733 merges. #1715's own head is the transport/CORS/header surface the human review approved; its browser-crate hardening completes in #1733.

@bplatz bplatz left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving. Reviewed the native/shared surface and the server-side security posture — not the two new crates (fluree-db-browser ~4k lines, fluree-db-wasm, the JS shell), so don't read this as a full sign-off on those. Four inline items; the first and last are the ones I'd want settled before merge.

Verified clean:

  • The storage trait additions are inert: query_guard/miss_register are default-None plus mechanical forwarding through Arc<dyn …>, StorageContentStore, BranchedContentStore. No native implementor overrides them; every consumer is cfg-gated.
  • The query.rs non-residency arm is the original code verbatim — production planning/execution untouched.
  • The NS-record conditional GET is correct: no-cache forces revalidation so authz is re-checked every time, and the 304 is ordered after every authz/serving gate. The bytes-digest ETag over the composite is the right call.
  • "No pre-existing test exercises PeerSubscriptionTask" — grepped, accurate.

/// and immutable, so a cached copy is valid forever. `private` because the
/// response was authorized by a bearer token; the public-visibility tier
/// will relax this to `public` for ledgers that opt in.
const OBJECT_CACHE_CONTROL: &str = "private, max-age=31536000, immutable";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggest adding Vary: Authorization to the object responses.

These are authorized by bearer token and cached private, max-age=31536000, immutable. private is the browser's own cache, shared across sessions on one profile; immutable means it never revalidates. So after principal A reads a ledger, principal B on the same browser profile gets those CIDs served from cache with the server never seeing B's token.

The comment here already reasons about the token (private "because the response was authorized by a bearer token") — this is the next step of that thought. Vary puts the header in the cache key so a different token is a different entry.

The NS route doesn't need it (no-cache revalidates, authz re-runs). Cost is low in practice since the peer's real cache is IndexedDB with its own CID verification — the HTTP tier is a bonus, not the mechanism.

ns_record: NsRecord,
) {
async fn notify_mgr(&self, mgr: &Arc<LedgerManager>, ns_record: NsRecord) {
let ledger_id = ns_record.ledger_id.clone();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This file is −377/+152 — a restructure plus two deliberate behavior changes (source_branch/branches from the wire, unparseable ledger_id degrading to verbatim rather than erroring the stream) — in live replication code with no test coverage. The six new tests cover the lifted pump, not this task.

You flag the gap and note it predates the PR; agreed on both. What I'd still suggest: either an integration test pinning the two deltas, or split the behavior changes into their own commit separate from the lift. As it stands a regression here has no failing test and no clean bisect boundary between "the refactor broke it" and "the intended change broke it."

// execution completes, so it cannot be re-run transparently and is
// excluded on residency-mode peers.
#[cfg(any(target_arch = "wasm32", feature = "residency"))]
let (batches, plan_ms, exec_ms) = {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggest factoring the plan+execute body into one async fn that both arms call, so they differ only in the retry wrapper.

As written this is two copies of the main query entry path, and per the dev-dep feature unification (raised on #1714), cargo test -p fluree-db-api and every api bench compile the residency arm — the cfg(not(…)) production arm isn't compiled in those binaries at all. So nothing in the api suite exercises what ships, and drift between the arms would be invisible.

In #1714 the duplicated code was error-path only, which made this tolerable. Here it's the main path, so the exposure is larger.

Comment thread fluree-db-server/src/routes/mod.rs Outdated
/// (the `fluree-*` policy/tracking headers, `idempotency-key`, trace ids,
/// `range`, `if-none-match`, `last-event-id`, …) without a list to keep in
/// sync; with `allow_origin(Any)` and no credentials it has exactly the
/// trust posture `*` had. The expose list makes response metadata — the

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wording: "exactly the trust posture * had" is not quite right, and the difference is the point of the change. * deliberately excludes Authorization, so cross-origin authenticated requests were impossible before and are possible now — strictly more permissive, deliberately.

Worth stating that way, because the consequence is real for internal deployments: a page the user visits can now reach an internal Fluree server with an Authorization header and read the response. Not CSRF — no ambient credentials — but it widens the browser-as-pivot surface.

Follow-up rather than this PR: cors_enabled is a bool with allow_origin(Any) hardcoded. Now that browser clients are a first-class use case, an origin allow-list is worth having.

@aaj3f
aaj3f force-pushed the wasm/pr2 branch 2 times, most recently from 1defc7e to 4d81372 Compare August 28, 2026 20:45
@aaj3f

aaj3f commented Aug 31, 2026

Copy link
Copy Markdown
Contributor Author

Thanks — all four addressed, including the two you wanted settled before merge.

Vary: Authorization. Real bug, fixed. Your reasoning is exactly right and I'd only sharpen one part of it: private is doing less work here than it looks, because the browser's own cache is per profile rather than per principal, and immutable means it never revalidates — so the server never got a chance to refuse principal B at all. Applied to all three object responses (200, 206, and the 304, which refreshes the entry and so must carry the same key). Pinned in test_object_endpoint_immutable_caching_and_conditional_get and mutation-checked: reverting the source with the assertions kept fails the test. Left the NS routes alone for the reason you gave — no-cache revalidates and authorization re-runs server-side.

peer/subscription.rs coverage. Agreed on the substance. I took the test option rather than the commit split: post-rebase the split would be disruptive, and a test is the durable guard where a bisect boundary is a one-time convenience. Two tests at the conversion site that actually owns the deltas (server_sse.rs) — one pins source_branch/branches coming from the wire and that their absence still defaults cleanly so an older server stays readable; the other pins the unparseable-ledger_id degradation, with a precondition assertion that the fixture really is an id the canonical parser rejects, so the test cannot quietly stop exercising the fallback. The first is mutation-checked against the old hardcoded None/0.

The duplicated query entry path. Factored into one plan_and_execute_round; both arms now differ only in the retry wrapper. Your framing is what made this worth doing rather than merely tidy — given the dev-dep unification from #1714, the api suite compiles only the residency arm, so two copies meant nothing in that suite exercised what ships and drift would have been invisible. It is once per query rather than per row, and an async fn is a state machine rather than a boxed allocation, so there is no hot-path cost. Ordering inside the retry loop is unchanged (fresh tracker per round, floor charged, then plan+execute, guard and budget still outside the loop). All three configurations build; api suite 3319 green, residency retry 5/5.

CORS wording. Corrected, and in the code comment as well as the description — the claim was in both. It now says this is strictly more permissive than *, deliberately, because * excludes Authorization and so cross-origin authenticated requests were impossible before and are possible now, with the internal-deployment consequence spelled out and the not-CSRF distinction kept. The origin allow-list is noted as a follow-up rather than smuggled in here.

On the scope of your review: understood, and noted — the two new crates not being covered is worth someone's eyes before this lands, and I'd rather that be explicit than assumed.

aaj3f added 11 commits September 3, 2026 10:50
CORS: mirror the preflight's Access-Control-Request-Headers instead of
the blanket Any. The Fetch spec excludes Authorization from the literal
* wildcard that Any emits, so bearer-token requests from spec-conforming
browsers failed preflight; mirroring covers authorization and every
other header the API reads (fluree-* policy/tracking headers,
idempotency-key, trace ids, range, if-none-match) with no allow-list to
keep in sync, and with allow_origin(Any) and no credentials it has the
trust posture * had. Also expose the Range/CAS/x-fdb-* response metadata
to page JavaScript and cache preflight results.

GET /storage/objects/{cid}: ETag (the CID) + immutable Cache-Control on
200 and 206 responses; If-None-Match -> 304 checked only after the
authorization and serving gates so revalidation can't leak existence
and costs no storage I/O. The * validator form is not honored (the
route answers before consulting storage).

GET /storage/ns/{alias}: ETag derived from the serialized record (a
truncated SHA-256 of the exact bytes served, computed after the serving
tiers are resolved) so head CIDs, retracted, branches, and serving all
invalidate it; Cache-Control: private, no-cache; If-None-Match -> 304.
Tests pin 200/206/304 header behavior, weak/list validators, the
out-of-scope 404, ETag change on head advance and on child-branch
creation without a commit, preflight mirroring of documented client
headers, and CORS headers on no-leak 404s.
ProxyStorage and ProxyNameService now perform all network I/O through a
new transport::HttpTransport trait (execute(TransportRequest) ->
TransportResponse with http::StatusCode/HeaderMap and zero-copy Bytes
bodies). The wire protocol — URL construction, address<->CID parsing,
status mapping, client-side CID verification, FLKB content negotiation —
stays in the shared clients and is now transport-agnostic; a browser
(wasm32) fetch transport can be injected via the new
from_api_base_with_transport constructors.

Native behavior is preserved: the default ReqwestTransport keeps the
per-client timeouts (60s blocks / 30s NS lookups) and the historical
error-message taxonomy (timeout/connect/request/body per call site), and
public constructor signatures are untouched, so server and CLI consumers
compile with zero diffs. One deliberate delta: the transport reads the
response body for every status where the old code left non-200 bodies
unread — observable only if that read fails, and the TransportResponse
docs state the full-buffering contract.

TransportRequest implements Debug by hand with the authorization value
redacted, so a transport impl logging the request can never leak the
bearer token (pinned by a test).

The trait keeps Send futures on every target deliberately: the engine's
storage traits box Send futures unconditionally, so a ?Send transport
could never carry a ProxyStorage impl — a wasm fetch implementation
bridges over channels instead (documented on the module).

New tests inject a canned transport end-to-end: URL/bearer-header
formation, CID verification (accept + reject), and 403->NotFound
no-leak mapping, proving the seam carries the full client logic.
…t impl

Nothing takes a generic T: HttpTransport bound - the proxy clients hold
Arc<dyn HttpTransport> and auto-deref to the vtable. The blanket impl
shadowed that deref, boxing every dispatch twice on the CAS read path
for no consumer. A comment now records why it is deliberately absent.
…parser

Two additive proxy-client APIs for cache layers — the consumer is the
browser peer's CAS cache (fluree-db-browser), which keeps verified
blocks resident and must avoid re-copying them:

- ProxyStorage::read_object_bytes(address) -> Bytes: the raw-endpoint
  fetch (CID-verified) returning the transport's buffer uncopied.
  read_bytes/read_bytes_hint now call it and convert at their own
  boundary, so native behavior and copy counts are unchanged.
- cid_and_ledger_from_address is pub: cache layers key by CID without
  re-implementing the address layout; the mapping stays pinned by this
  module's round-trip tests (including the exhaustiveness guard).

Byte-neutral natively: no caller changes, no wire changes.
…ds for wasm32

The proxy clients, HttpTransport seam, and CID integrity verification
are runtime-agnostic; everything else — sync/pull/clone, SSE watchers,
config store, origin fetchers, ReqwestTransport — is native-only and now
sits behind cfg(not(target_arch = "wasm32")) with reqwest/tokio/rand/
parking_lot/async-stream moved to native-only target dependencies.
verify_object_integrity moves to a runtime-agnostic integrity module
(origin:: and crate-root re-export paths unchanged); the default
constructors that build ReqwestTransport are native-only, wasm callers
inject a transport via from_api_base_with_transport. fluree-db-nameservice
is consumed with default-features=false behind a new default 'native'
feature, so native dependency resolution is byte-identical.

CI: the wasm32 job now checks -p fluree-db-nameservice-sync alongside
fluree-db-api, so the crate's wasm buildability can no longer drift
(it was previously unverified — the E-doc's claim was vacuous).

Consumer: fluree-db-browser implements HttpTransport over fetch and
builds ProxyStorage/ProxyNameService on wasm32 via the injection
constructors.
Two small core surfaces the residency retry loop and the load-time
novelty prefetch build on:

`ContentStore::query_guard` / `StorageRead::query_guard` (default `None`,
forwarded through `Arc<dyn Storage>`, `Arc<dyn ContentStore>`,
`StorageContentStore`, and `BranchedContentStore`, which composes its
ancestry chain into one handle) hands out an opaque RAII
`InFlightGuard`. A store with a residency tier freezes eviction while any
guard is alive — the no-eviction-while-in-flight policy that keeps the
fetch-pins contract true across a retry loop's rounds, so every byte a
round observed stays resident for the re-run and progress is monotone.
The guard rides the storage traits rather than a concrete browser type so
the api-level loop needs no dependency on the browser crate: whichever
store is behind the ledger's content store supplies its own payload.

`DictNovelty` gains read-only iteration (`subjects.iter_entries()`,
`strings.iter_values()`, via a new `NsVecBiDict::iter_entries`): the
entries query-time overlay translation reverse-looks-up against the
persisted dictionaries, which a residency-mode load prefetches.
Overlay translation resolves every novelty subject and string against the
persisted reverse trees through sync lookups at query time
(`find_subject_id_by_parts` / `find_string_id`), so on a residency-mode
peer each cold reverse-tree leaf would cost a retry round — the F8
novelty miss source. `prefetch_novelty_reverse_wants` computes the routed
leaf set for a novelty entry iterator (the exact key encodings the
lookups use, over both reverse trees), skips resident leaves, and pins
the rest in one concurrent round through the fetch-pins contract
(`fetch_wants`) — no filesystem involvement, so it is wasm-safe. No-op
outside residency mode.

Unit test builds a multi-leaf CAS-backed reverse tree over a
miss-injecting store: cold lookups miss with a typed, registered
NeedFetch; one prefetch round pins the whole routed set (asserted >= 2
leaves — a set, not one object); every subsequent lookup is a pure hit
recording no wants. Mutation-checked: no-opping the prefetcher fails the
test on "wanted 0".
Promotes the drain/fetch/re-run loop the residency tests ran by hand into
the real query path. Under `cfg(any(wasm32, feature = "residency"))`,
`query_with_options` — the frozen-`GraphDb` entry, so a round never sees
another snapshot's index — wraps plan+execute in the loop: on any
execution error it calls `RetryBudget::after_error` against the store
backing the snapshot's binary index (resolved from the range provider, so
the loop drains exactly the store the sync reads went through); progress
re-runs the round, no wants surfaces the real error. Each round gets a
FRESH fuel tracker — the Tracker is Arc-shared, so reusing one would bill
earlier rounds' work against the final round's budget — and the store's
in-flight guard is held across all rounds so pinned bytes cannot evict
between them. The streaming entry stays unwrapped (rows are emitted
before execution completes, so a mid-stream miss cannot be re-run
transparently) and is documented as excluded on residency-mode peers.
Default native builds compile the pre-residency path unchanged; the
`residency` feature exists so native tests can drive the loop.

F8(b) wiring: both ledger-load provider sites prefetch the novelty
reverse-dict want set (`prefetch_novelty_translation`) right after the
range provider is installed, so overlay translation lookups on a cold
peer are pure hits instead of a retry round per reverse-tree leaf.

The recovery suite now drives the PRODUCTION loop: one direct
`Fluree::query` / `query_connection` call must complete each path (scan,
one-shot fast path, dir-only COUNT(DISTINCT) walk, policy f:query) with
results identical to a plain native instance, misses positively observed,
and the register drained. Mutation-checked: disabling the loop's retry
arm fails the fast-path and policy tests (the scan and dir-walk paths are
absorbed by the operator-frame retry, which is the point of having both).
A new novelty test commits data past the index, loads on a residency
instance, proves a translation lookup is a pure post-prefetch hit, and
completes the combined persisted+novelty query through the loop.
… storage, IndexedDB cache

The engine side of the browser peer (the JS shell lives elsewhere):

- protocol/bridge: IoJob channel protocol + IoHandle; WasmFetchTransport
  implements the HttpTransport seam by enqueueing jobs and awaiting a
  oneshot, so engine-visible futures stay Send while a spawn_local
  driver task owns every JS handle (fetch/AbortController/IndexedDB).
- residency: CID-keyed in-memory tier serving resolve_cached_bytes —
  O(1) Arc-clone hits, LRU byte budget, pin support with a
  query-duration PinSet, typed working-set-exceeds-budget errors.
- cas: BrowserCasStorage wraps a Raw-mode ProxyStorage (which owns URL
  formation + CID verification) with the residency tier, IndexedDB
  write-behind via the driver, request coalescing, and a bounded fetch
  width. Implements the Storage traits + the residency hook; exposes
  ensure_resident/prefetch as residency-first entry points (fetched
  blocks cost exactly two copies: JS->Bytes, Bytes->Arc<[u8]>).
- budget: pure LRU byte-budget index for the persistent cache, rebuilt
  from the meta store at open, natively tested.
- driver (wasm32-only): fetch execution per the transport contract
  (Ok for any status, credentials omit, CORS mode, AbortController
  timeouts, Timeout/Connect/Request/Body classification, one copy out
  of JS memory) and the IndexedDB blocks+meta stores with planned
  eviction and batched access-time flushes.
- connect: build_peer assembles FlureeBuilder::memory().build_with(cas,
  NameServiceMode::ReadOnly(ProxyNameService)) over any IoHandle
  (native tests use a mock driver); connect() (wasm32) starts the real
  driver.

Native unit tests cover the bridge round-trip, coalescing (8 readers ->
1 fetch), residency LRU/pins/budget errors, cache-hit/write-behind/
integrity-rejection/403-NotFound paths, bounded prefetch width, and
head resolution through the proxy nameservice via the injected
transport. Browser-only paths (fetch, IndexedDB) have wasm-bindgen-test
coverage in tests/browser.rs.
…governor, bounded write-behind, batch-first fetch

Four design-review findings folded in:

- F5 (pin attribution): eviction from the residency tier now runs only
  while no query is in flight. BrowserCasStorage::query_guard() marks a
  query (the retry loop holds one across its rounds); a mid-flight
  insert that would need eviction gets a typed EvictionDeferred, and
  the async caller waits one bounded release interval (budget_wait,
  served natively by a tokio timer and on wasm by a driver Sleep job so
  the engine-side future stays Send) before failing typed. This is what
  upholds the engine's fetch-pins contract without per-query pin
  attribution through the sync hook.
- F4 (memory governor): BrowserIoConfig::from_max_memory derives the
  residency budget, write-behind bound, and fetch width from one
  ceiling; the rustdoc names what the crate cannot govern (engine
  operator memory, and the forward-pack readers pinning pack bytes for
  the store's lifetime — an engine-side gap owned by the read-path
  work).
- F9 (write-behind backpressure): a byte-bounded WriteBehindGauge.
  Every CachePut carries a permit released only when the driver
  finishes the IndexedDB write; fetch completion acquires the permit,
  so a lagging IndexedDB stalls the fetch pipeline instead of queueing
  unbounded block clones. Peak/outstanding surface in CasStats.
- F1/F2 (batch-first wants): fetch_cids / fetch_cids_pinned make a
  drained want-set resident concurrently (bounded width, coalesced) and
  pin successes into a PinSet — the fetch half of the miss-register
  drain loop; address_for maps CID+ledger to the canonical address.

New tests: gauge admission/blocking/oversize, deferred-insert wait and
typed failure, backpressure peak bound, want-batch fetch+pin, tier
deferral semantics, release wakeups, governor derivation (37 native
tests total).
… fetching

Rebased onto the restacked transport (the duplicate core hook dropped in
favor of read-path's canonical StorageRead::resolve_cached_bytes /
miss_register pair on groundwork). BrowserCasStorage now participates in
the sync residency tier fully: it holds a MissRegister and returns it
from StorageRead::miss_register (forwarded to the engine through
StorageContentStore), so every sync miss the binary-index read path
takes against this storage is recorded for a retry frame to drain.
fetch_wants(ledger, wants, pins) adapts a drained Want set onto the
batch fetch path for shell-side callers that hold the ledger context.

The new integration test is the load-bearing proof: the engine's landed
RetryBudget::after_error, running against the exact StorageContentStore
bridge the engine assembles, records a sync miss into this storage's
register, drains it, fetches through ContentStore::get (our read_bytes,
which makes the bytes resident — the fetch-pins contract, upheld under
a held query guard), verifies residency, and the re-run hits with one
fetch total. An empty register correctly reports 'real error' to the
loop. 38 native tests; browser suite unchanged (4 headless-Chrome
tests).
aaj3f added 20 commits September 3, 2026 10:50
…p into a shared head-stream pump

fluree-db-nameservice-sync grows head_stream::run_head_stream — the peer
subscription loop parameterized over everything runtime-specific: the
connection (SseChunkSource; ReqwestSseSource natively, a channel fed by
the browser driver's fetch-stream on wasm), the timer (Sleeper), the
consumer (HeadSink), and a watch-channel stop signal. It reuses the
crate's existing server_sse payload parser and lifts the server's
ExponentialBackoff verbatim (configurable multiplier, +/-25% jitter,
reset on clean stream end, 401/403 and token-load failures fatal). The
watch/server_sse modules are un-gated for wasm32 (pure code); rand moves
to both targets for the jitter; wasm gets tokio/sync for the stop
signal. Six native pump tests cover chunk-split parsing, backoff growth
and reset, malformed-frame skipping, and both stop paths.

fluree-db-server's PeerSubscriptionTask now drives the shared pump: the
task keeps its public shape (new/spawn, PeerSubscriptionError with its
is_fatal taxonomy retained for compat) and a PeerEventSink owns what
events mean for a peer — state watermarks, ledger-cache notify, preload
on connect, retraction eviction — with the previous log lines kept.

Two deliberate behavior deltas, both strict improvements: (1) ledger
records now carry source_branch/branches from the wire into
LedgerManager::notify (the old local conversion hardcoded None/0 —
lineage the peer needs for BranchedContentStore, cf. the proxy client's
finding #11 regression test); (2) an SSE record with an unparseable
ledger_id degrades to name=ledger_id instead of erroring the event.
NOTE: no existing integration test exercises the subscription task; the
gate here is the full server suite plus the pump's own unit tests.
The browser half of the lifted head-stream loop:

- protocol/driver: a dedicated SseOpen job — streaming deliberately does
  not ride the HttpTransport seam (whose contract is full-body
  buffering). The driver fetch-streams the events URL (credentials
  omit, CORS), forwards raw ReadableStream chunks over a channel, and
  the chunk receiver dropping cancels the reader and aborts the fetch.
  Reconnect delays ride the existing Sleep job (DriverSleeper).
- heads: ChannelSseSource (the pump's connect = an SseOpen job;
  driver-gone is fatal), PeerHeadSink (ledger updates go through
  LedgerManager::notify — the native peer's incremental refresh, so an
  open peer re-opens at the new head between queries while in-flight
  queries keep their frozen views — then fan out to callbacks;
  retractions evict via disconnect_ledger; graph-source events ignored
  in the v1 peer), HeadChange + HeadTracker (stop() or drop stops the
  pump).
- BrowserPeer: on_head_change(f) registry for the shell,
  head_stream(ledgers) -> (HeadTracker, future) for any-executor
  spawning, start_head_tracking (wasm, spawn_local); reconnect knobs on
  BrowserIoConfig.

Tests: native end-to-end through the mock driver (events URL +
accept/authorization headers asserted, callback receives the HeadChange,
tracker stops the pump) and a REAL-browser wasm-bindgen test where a
data:text/event-stream URL flows through the driver's actual
ReadableStream path into the shared pump (stop-on-first-event; noted:
a data URL cannot exercise reconnect or non-2xx classification — the
pump's native unit tests carry those).
…nd engine)

New workspace crate wrapping fluree-db-api (default-features = false, the
shape the wasm32 CI check enforces) behind #[wasm_bindgen] exports for the
browser package in fluree-db-wasm/js. Named fluree-db-wasm to match the
crate family: it IS the wasm binding of fluree-db, nothing more.

Runtime model: single-threaded on the worker event loop; no tokio runtime is
ever constructed (async exports become Promises; the engine's detached spawns
already route through the spawn_detached seam to spawn_local). Queries run
against frozen GraphDb snapshots held in a handle slab, never a live alias,
so a head advance - or the future peer mode's fetch-and-re-run loop - cannot
move the view mid-query (design review F6); the streaming query entry is
deliberately not bound, buffered results only. One memory setting: the
constructor's max_memory_bytes becomes each query's memory budget via
QueryCancellation::set_memory_limit, so an oversized query dies as a typed
out_of_memory (507) instead of trapping the worker (review F4); the JS shell
owns trap recycling for whatever still gets through.

Errors cross the boundary as JS Errors with stable {code, status} props
(not_found / conflict / invalid_input / cancelled / out_of_memory /
unsupported / internal) mapped from ApiError.

Root Cargo.toml adds the member plus [profile.wasm-release] (inherits
release, opt-level=s) - inert for every native build; it exists because the
shipped .wasm is download-size-bound and wasm-pack cannot select custom
profiles, so the package build drives cargo + wasm-bindgen-cli directly.

wasm-bindgen-tests run in a dedicated worker (the package's real hosting
context) in headless Chrome and assert on returned rows, receipt t, snapshot
isolation across an update, and error codes - positive ran-markers, never
didn't-crash.
…, smoke

The JS half of the browser shell (name placeholder pending the npm org
decision). Main-thread module (src/index.ts) contains no wasm: it spawns the
dedicated engine worker (src/worker.ts) and speaks the typed protocol in
src/protocol.ts; the .wasm streams+compiles lazily inside the worker on
first use. Public types are our own TS declarations, not the wasm-bindgen
internals.

Crash model (review F4): a wasm trap poisons the instance; the worker marks
the error fatal, and the proxy rejects in-flight calls with the typed error
(engine_crashed, or out_of_memory for allocation traps), terminates, then
respawns + re-inits a fresh worker so the Playground object stays alive.
The single maxMemoryBytes option (default: quarter of navigator.deviceMemory
clamped [256 MiB, 2 GiB]; 512 MiB where unavailable) rides the init message
into the engine's per-query budget.

Frozen views (review F6): Ledger.query freezes the head per call worker-side;
Ledger.snapshot() exposes the frozen view for multi-query consistency.
Buffered results only - no streaming surface exists on the protocol.

Result transport: engine emits UTF-8 JSON bytes; default posts the buffer in
the transfer list (zero-copy, size-independent), clone mode structured-clones
a string instead; both selectable per call, side-by-side timer in the demo.

scripts/build.mjs: cargo --profile wasm-release -> wasm-bindgen --target web
-> wasm-opt (explicit post-MVP feature flags) -> tsc -> size report via
node's zlib (gzip -9 + brotli -q 11). Drives cargo directly because
wasm-pack cannot select custom cargo profiles and the root release profile
is native-tuned; the CLI version is checked against Cargo.lock (glue/module
ABI is version-locked). scripts/smoke-browser.mjs: dependency-free CDP
client driving headless Chrome against demo/smoke.html over plain HTTP with
deliberately NO isolation headers (the hosting contract); exits 0 only on
the page's positive pass marker with the expected bound rows.
scripts/serve.mjs: static server with correct wasm/module MIME types.
Runs on the pinned toolchain next to the wasm32 check job: (1) wasm-pack
test --headless --chrome runs the crate's wasm-bindgen-tests inside a
dedicated worker in a real browser engine, asserting on returned rows -
positive ran-markers; (2) the npm package is built (wasm-release profile +
wasm-opt + tsc) and the SHIPPED artifact is driven end-to-end (main proxy ->
module worker -> wasm) over CDP with no COOP/COEP headers, then the size
report is printed. RUSTFLAGS restates the getrandom cfg because a job-level
env RUSTFLAGS masks .cargo/config.toml (same note as the wasm32 job).
wasm-bindgen-cli is installed at the exact Cargo.lock version; ubuntu-latest
supplies a matching Chrome + chromedriver pair.
The api retry loop reaches storage only through dyn StorageRead, so the
crate's eviction-freeze guard is now exposed via the trait's query_guard
(wrapped in the opaque core InFlightGuard), mirroring miss_register. The
new test exercises the dyn path specifically: freeze while held, release
on drop.
…nned vars order, smoke robustness

Everything below was found by actually executing the browser build (the
compile gates were green throughout):

- build.rs: raise the wasm shadow stack to 8 MiB via -zstack-size (covers
  the cdylib and the wasm-bindgen-test executables). The 1 MiB default
  overflowed in the dev-profile browser tests as a 'memory access out of
  bounds' trap; the workspace already documents this codebase's unoptimized
  async futures need 8 MiB native (RUST_MIN_STACK in .cargo/config.toml) -
  same futures, same fix. rustc places the stack first in linear memory, so
  an overflow traps instead of corrupting, and untouched stack pages cost
  address space, not resident memory.
- index.ts: Omit over the Request union collapsed it to common keys and
  rejected every op-specific field; use a distributive Omit.
- tests/playground.rs: head.vars arrives in the engine's var-registry order,
  not SELECT-clause order (same on the HTTP surface; the W3C JSON results
  format mandates no order) - assert the set, not the sequence.
- smoke-browser.mjs: resolve bare Chrome candidates through `which` instead
  of returning the first name unchecked (macOS has no google-chrome on
  PATH - launching the app binary to probe would hand off to a running
  instance and hang); cleanup retries and never decides the exit code (the
  SIGKILLed Chrome can still be flushing its temp profile).
…eview H-1, H-2)

H-1: a budget-full residency tier no longer bricks in-query cold
fetches. Eviction previously froze whenever ANY query guard was live —
including the querying context's own — so once the LRU filled to budget
(the steady state of a cache), every cold fetch stalled budget_wait and
failed typed, forever. Eviction is now governed by an epoch-tick rule:
each QueryGuard records the tier-clock tick at begin_query, every
observation (resolve hit, insert incl. its already-resident return)
bumps last_use under the same lock, so entries whose last_use predates
the OLDEST live guard's begin tick provably belong to no in-flight
query's working set and stay evictable. Monotone progress (the engine's
fetch-pins contract) is preserved structurally: anything a live query
touched carries a tick at or after its begin. Eviction plans before it
applies (a deferred insert never wastes cached bytes), EvictionDeferred
now fires only when even unobservable entries cannot make room, guard
drops always wake deferred waiters (any drop can advance the epoch),
and remove/clear_unpinned obey the same rule so a shell-side free-memory
call cannot break a running query. Tests pin both required properties:
the bricking scenario (tier full, guard live, cold fetch succeeds by
shedding pre-guard leftovers — at tier and storage level, the latter
proving no budget_wait stall) and monotonicity (observed bytes survive
another context's fetch; a fully-observed tier still defers).

H-2: write-behind backpressure is now real. The gauge permit was
acquired AFTER the fetch slot was released, so a lagging IndexedDB let
completed fetches park in acquire() while their freed slots admitted
more fetches — unbounded un-persisted clones, the exact OOM the gauge
exists to prevent. The permit is now acquired INSIDE the fetch-slot
scope (at most max_concurrent_fetches blocks between fetch-complete and
gauge admission; no deadlock — permits are released by IndexedDB writes,
which never take fetch slots), and prefetch/fetch_cids swap unbounded
join_all for buffer_unordered at the fetch width. New test wedges the
persist path (permits never released) and proves fetch admission stalls
at 1 admitted + width parked, un-persisted bytes bounded at the budget,
and the batch drains once persists resume.

Also folded from the review's mediums in these files: the native
missed-wakeup race on the deferred-insert wait (release interest is now
registered via Notified::enable BEFORE the re-attempt — the gauge's own
pattern); a cancelled coalescing leader no longer fails its followers
(bounded re-election with a residency re-check, replacing the spurious
'was cancelled' error); and fetch_cids_pinned reports a CID evicted
before its pin landed as a failure instead of silently claiming it
pinned.
- SSE idle zombie: each ReadableStream read now races the chunk
  receiver's closed() signal, so a stopped pump releases an IDLE
  stream's connection immediately (cancel + abort) instead of on the
  next chunk — and a connect whose ready send fails no longer streams
  into the void. Matters because SSE shares the ~6 per-host connection
  slots with block fetches.
- IndexedDB index consistency: put() applies planned evictions to the
  in-memory index (and drops the victims from the dirty set) BEFORE
  awaiting the transaction, and inserts the new key only after commit —
  the access-time flusher can no longer resurrect meta for a
  just-deleted victim (orphan meta inflating the rebuilt index) or
  write meta for a block that never lands. get() on a store miss drops
  the stale index entry so put()'s already-persisted skip cannot become
  permanent (failed tx, another tab's eviction).
- Residency floor: from_max_memory clamps the residency budget to
  32 MiB, so a tiny memory ceiling degrades (smaller cache) instead of
  bricking every ordinary leaflet read with ObjectExceedsBudget; the
  divide-before-multiply ordering is now commented as the 32-bit
  overflow guard it is.
- Head-stream observability: the pump logs reconnect_in_ms before each
  backoff sleep, restoring the delay field the subscription-loop lift
  dropped.
- Test hygiene: the browser-suite cache test polls for the write-behind
  landing instead of trusting a fixed 200 ms sleep (CI-box flake
  guard); stale pre-epoch doc comments on query_guard()/the release
  signal updated.

NOTE: the review's low nit about test_ns_record_etag_and_conditional_get
(stale composite-ETag doc, unpinned * validator) was already fixed on
this branch at assembly — the doc describes the digest ETag and line
3413 pins * as a 200.
Pre-review C-1: the if-then-panic shape trips the workspace-denied
manual_assert lint the moment --all-features CI compiles the residency
test target (invisible while the PR is stacked — the base branch gets no
CI). Verified: clippy clean and the suite still 5/5.
H-4: every Ledger/Snapshot object is generation-stamped and validated
worker-side, so pre-crash objects reject typed (not_found / closed)
instead of silently reading a recycled engine's state — the fresh worker
reuses small handle numbers, which is exactly how the aliasing happened.
A debugCrash test hook (deliberate trap) lets the JS smoke drive the
whole path: crash -> typed engine_crashed -> stale snapshot not_found ->
respawned engine answers.
H-5: respawns are capped (3 consecutive) with exponential backoff; past
the cap, or when the worker never boots at all, the channel goes
terminal and everything rejects engine_unavailable. New codes
engine_restarting / engine_unavailable documented.
H-6: the default spawn is the literal new Worker(new URL(...)) shape
bundlers' static detection requires; verified unbundled and via a
zero-config vite build (scripts/vite-repro.mjs); the README now says
plainly which bundlers are NOT covered by checks.
Mediums: not_initialized re-init window closed; transact pre-gate
(inputs over a quarter of the memory budget refused typed before the
allocator can trap) with tests; smoke hardened.
Also: the memory-budget browser test now pins the typed-rejection
contract via grouped aggregation (GROUP_EST_BYTES-charged, 4,096 groups
against a 64 KiB budget) — cross joins and GROUP_CONCAT payloads are not
charged today, and the first version of this test could only time out
the harness or blow chromedriver's 10 MB response cap trying.
…ed wasm-pack, packed-tarball smoke, ns-ETag doc

- the wasm32 job now checks fluree-db-browser and wasm-smoke runs its
  real-browser suites (review H-3: they previously never executed in CI)
- wasm-pack installed via the pinned installer action, not curl|sh
- the npm smoke drives the packed tarball, so a files-allowlist
  regression fails in CI instead of consumers' installs
- the ns-ETag test's doc-comment describes the digest ETag actually
  served and pins '*' as deliberately unhonored (stale text from the
  abandoned composite design)
- probe lockfile picks up the rebased workspace's dep graph
…s the native deferred-insert wait; the wasm arm waits via a driver Sleep job. The wasm32 CI job now gates warnings, so this would fail CI.
std::env::temp_dir() PANICS on wasm32 ("no filesystem on this
platform"). ledger_manager's cache-dir default was gated for that, but
view/fluree_ext.rs called it raw at two sites in load_graph_db_inner —
an ungated impl, so both are reachable on wasm and would trap the
engine if that path runs there.

Scope honestly: these are LATENT sites, not a diagnosed crash. A trap
was observed opening an indexed ledger through the JS shell and first
attributed here by inspection, but that attribution came from a grep
rather than a symbolized frame and did not hold — the trap cleared on a
base that still contains these lines, fixed instead by degrading the
disk-cache directory create where there is no filesystem. These calls
remain a real hazard on their own terms; that is why they are fixed.

Rather than add a third copy of the cfg, ledger_manager now owns a
temp_cache_dir(leaf) helper that is the ONE place knowing the wasm arm
exists, and both fluree_ext sites route through it. Each site keeps its
own leaf name, so native paths are unchanged.
These bodies are authorized by a bearer token and served `private,
max-age=31536000, immutable`. `private` keeps them out of shared caches, but
the browser's own cache is per PROFILE, not per principal, and `immutable`
means it never revalidates — so once principal A read a ledger, principal B on
the same browser profile was served those CIDs straight from cache, with the
server never seeing B's token and never getting the chance to refuse it.

`Vary: Authorization` puts the token in the cache key, so a different token is
a different entry. Applied to all three object responses — 200, 206 and the
304, which refreshes the entry and so must carry the same key.

The nameservice routes deliberately do not need this: they are `no-cache`, so
every use revalidates and authorization re-runs on the server first.

Pinned in test_object_endpoint_immutable_caching_and_conditional_get and
mutation-checked: reverting this file with the assertions kept fails it.
… `*`

The comment claimed that with `allow_origin(Any)` and no credentials,
`AllowHeaders::mirror_request()` had "exactly the trust posture \`*\` had".
It does not, and the difference is the point of the change rather than a side
effect of it: `*` deliberately excludes `Authorization`, so cross-origin
AUTHENTICATED requests were impossible before and are possible now.

The consequence is real for an internal deployment — a page the user visits can
reach an internal Fluree server with an `Authorization` header and read the
response. Not CSRF, since there are no ambient credentials and the page must
already hold a token, but it does widen the browser-as-pivot surface, and it is
why an origin allow-list is now worth having (`cors_enabled` is a bool with
`allow_origin(Any)` hardcoded).

Comment only; no behavior change.
…re carried

`peer/subscription.rs` is -377/+152: a restructure plus two deliberate
behavior changes, in live replication code. The six tests that came with it
cover the lifted SSE pump, not these — so a regression here would have had no
failing test and no clean bisect boundary between "the refactor broke it" and
"the intended change broke it". Pinned at the conversion site that owns them.

`source_branch`/`branches` are carried from the wire; they were hardcoded to
`None`/`0` regardless of what the server sent, so a branched ledger arrived at
the peer looking unbranched. The test also pins that their ABSENCE still
defaults cleanly, so an older server stays readable.

An unparseable `ledger_id` degrades to the verbatim id plus the wire's
`branch` rather than erroring the event and tearing down the stream: one
unreadable record should not stop a peer receiving every other ledger's
updates. The test asserts the fixture really is an id the canonical parser
rejects, so it cannot quietly stop exercising the fallback.

The first is mutation-checked against the hardcoded values.
The residency and production arms were two copies of the main query entry
path, differing only in which fuel tracker they passed. They now call one
`plan_and_execute_round` and differ only in the retry wrapper around it.

This matters more than ordinary de-duplication because of the dev-dependency
feature unification: `fluree-db-api`'s dev-deps enable `residency`, and under
`resolver = "2"` that unifies into every target needing dev-deps — so
`cargo test -p fluree-db-api` and every api bench compile the RESIDENCY arm,
and the production `cfg(not(...))` arm is not built there at all. Two copies
meant nothing in the api suite exercised what ships, and drift between them
would have been invisible.

Once per query, not per row, so it is not on any hot path, and an `async fn`
is a state machine rather than a boxed allocation. Ordering inside the retry
loop is unchanged: fresh tracker per round, floor charged, then plan+execute,
with the store guard and retry budget still outside the loop.

Verified building in all three configurations (production native, residency,
wasm32); api suite 3319 green, residency retry 5/5.
The wasm-smoke job sets a job-level RUSTFLAGS carrying the wasm
`getrandom_backend="wasm_js"` cfg. The "Install wasm-bindgen CLI" step is a
NATIVE host `cargo install`, and getrandom 0.3.4+ now hard-errors
("the wasm_js backend requires the wasm_js feature") when that cfg is set for
a non-wasm target — so the step started failing on ecosystem drift, with no
change to our code. Clear RUSTFLAGS for this one step, exactly as the
fluree-server native build step in the same job already does.
…awn seam

The commit windows in tx_builder detach onto their own task via a bare
tokio::spawn (the cancellation shield from 9e68d18: a caller dropped
mid-window must abandon the WAIT, never the commit — or the emptied cache
slot hands the genesis placeholder to every parked reader). On
wasm32-unknown-unknown there is no tokio runtime, so tokio::spawn panics
(TryCurrentError) and every playground transact trapped the instance —
5 of 6 wasm-bindgen playground tests failed the moment the wasm-smoke job
could actually run them (the failure was previously masked by the
getrandom install break).

Fix: `wasm_compat::spawn_shielded` — the same seam shape as the existing
`spawn_detached`, but with the result awaited. Native: `tokio::spawn`,
exactly the call the two shield sites made directly (JoinError detail
preserved in the mapped message). wasm32: the work runs to completion on
its own `spawn_local` task — the browser event loop is the executor — and
the result returns over a oneshot, so the shield property is identical: a
caller dropping the returned future cannot cancel the commit window. The
spawn is eager (at call, not first poll) on both targets.

Verified: cargo check -p fluree-db-api --all-targets clean; wasm32 clippy
--tests -D warnings clean; wasm-pack test --headless --chrome
fluree-db-wasm 6/6 green in real Chrome (was 1/6 at this head), and
fluree-db-browser 5/5.
@aaj3f
aaj3f merged commit 53e007d into main Sep 3, 2026
17 checks passed
@aaj3f
aaj3f deleted the wasm/pr2 branch September 3, 2026 20:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants