From 2151d9f1fe7301196b2a60d096c2f23bb8fa5dad Mon Sep 17 00:00:00 2001 From: Test Date: Thu, 22 Oct 2026 11:11:35 -0600 Subject: [PATCH 01/20] fix(db): resume independent subscription demand after abandoned renders --- docs/contributing/oracle-coverage.md | 29 +- .../2026-10-22-dbclient-migration-feedback.md | 456 ++++++++++++++++++ docs/guides/ssr.md | 55 +++ packages/db/src/collection/changes.ts | 2 + ...n-subscription-lifecycle-grammar-oracle.ts | 45 ++ ...tion-subscription-lifecycle-oracle.test.ts | 114 +++++ .../db/tests/collection-subscription.test.ts | 4 + packages/db/tests/db-client.test.ts | 5 +- .../descriptor-query-binding-oracle.test.tsx | 210 ++++++-- 9 files changed, 871 insertions(+), 49 deletions(-) create mode 100644 docs/contributing/oracle-reviews/2026-10-22-dbclient-migration-feedback.md diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index 5afacbd874..523bdd2de8 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -484,9 +484,32 @@ appears in `DbClient.dehydrate()` after the first enter event. A separate same-hash transition from a concrete query to an unbound descriptor query checks that the hook reports the missing client before reusing prior rows. The same React owner checks that a descriptor render suspended before commit -starts no source work, while a later direct `Collection.preload()` starts that -source once and reaches ready. This is one abandoned-render/direct-reader cut, -not a general concurrent-render schedule. +starts no source work. Its finite 24-cell grammar crosses later reader intent, +startSync true/false and eager/on-demand sources. Ordinary subscription admission +also crosses no cleanup, loading callback cleanup, ready callback cleanup, and +held ready cleanup. Preload and deferring readers retain no-cleanup controls. +The shared independent judgment in +`collection-subscription-lifecycle-grammar-oracle.ts` predicts adapter entries +and the complete public status trace at admission return and cleanup settlement. +A second ordinary subscriber reuses a live run or restarts a retired source. +Public rows without cleanup distinguish on-demand source preload, which does +not acquire rows, from ordinary subscriptions. The core lifecycle owner adds +24 cases crossing the four cleanup histories, no/unrequested/requested start +gates and eager/on-demand modes. The erroneous release-before-request repair +fails four core and four React cells at admission return. The final expanded +React grammar also rejects the original no-release implementation. Historical +calibration of a previously-requested-start mutant predates the final call +reorder and is not evidence against that mutant on the current path. +This does not establish arbitrary concurrent-render schedules, failed-provider +histories, or Electric txid delivery. The [migration feedback review](oracle-reviews/2026-10-22-dbclient-migration-feedback.md) +records calibration and the still-open SSR hydration law: a warm server source +lets a second unregistered plain hook render rows absent from result-only +transport. A task-local real React hydrateRoot witness recovers for that query +and passes when it is explicitly preloaded. The descriptor owner is the next +receiving owner once the product chooses automatic result capture or an initial +empty/loading policy; partial nonready rows, status flags, normalized-source +transport, mixed Suspense/plain schedules, and the real Start/Electric path +remain needed witnesses. This diagnostic is not claimed as repaired coverage. Its no-client diagnostic also covers explicit `queryKey` and legacy dependency identity for both `useLiveQuery` and `useLiveSuspenseQuery` at first consumption. A local hook-first SSR stream over a concrete diff --git a/docs/contributing/oracle-reviews/2026-10-22-dbclient-migration-feedback.md b/docs/contributing/oracle-reviews/2026-10-22-dbclient-migration-feedback.md new file mode 100644 index 0000000000..99ac5e5b1f --- /dev/null +++ b/docs/contributing/oracle-reviews/2026-10-22-dbclient-migration-feedback.md @@ -0,0 +1,456 @@ +# DbClient migration feedback evaluation + +Evaluation date: 2026-10-22. Source: `/Users/kyle.mathews/Downloads/Feedback.txt`. +The report describes db 0.15.1, react-db 0.5.11, electric-db-collection 0.6.2, +react-router-with-db 0.1.0, and Start 1.169, without a reviewed commit or an +application fixture. This evaluation uses fresh `origin/main` +`205ab7158d81680c3cc4f06cd9ebe62b30911a5a`, in the isolated +`codex/evaluate-dbclient-feedback` worktree. Current package versions are db +0.15.2, react-db 0.5.12, and electric-db-collection 0.6.3. + +The review found two reproducible correctness failures. A newly admitted ordinary +subscriber must be able to start a source left deferred by an abandoned render; +that defect is repaired here. A plain SSR hook can render a warm source's rows +that are absent from result-only transport; actual React hydration then recovers. +That defect remains unfixed, with a product decision needed about the initial +snapshot policy. A green scoped test run does not close it. + +Production changes add one executable call and an ordering comment. The React +oracle, shared lifecycle judgment, three core test drivers, +SSR guidance, and coverage map also change. At the initial evaluation closeout, +the changes were uncommitted and unpushed; only the helper's initial branch was +published. The user subsequently approved PR preparation after both review +findings were repaired. No external review response was sent. + +Source weight: production +2/-0, including the comment; oracle and ordinary +test files +332/-46. Documentation is separate. The production call repairs +the existing admission path without adding state or replacing its control flow. + +PR preparation also measured shipped bytes with +`pnpm size:compare --packages db,react-db --files` against the stated merge-base. +The final db production bundle grows by 8 gzip bytes and 116 brotli bytes. Its changed +module grows by 24 minified bytes before compression. The react-db bundle is +unchanged. A simplifier review found no safe consolidation: moving release into +the shared start helper or resumed-acquisition path broadens its authority. +The earlier experiment releasing from resumeSubscriber broke synchronous-error +and peer-release laws. The measured growth pays for the separate independent +demand boundary without a new state or branch. + +## Laws, experiments, and remaining enforcement + +### Hydration must begin from the server's observable snapshot — F02 + +Authority: the [SSR guide](../../guides/ssr.md) promises query-result transport +and hydration before hooks render. React requires the initial hydration output +to agree with server markup. The review exposes a missing transport boundary, +not an obligation to choose one particular capture implementation. + +The existing [React descriptor owner](../../../packages/react-db/tests/descriptor-query-binding-oracle.test.tsx) +checks request isolation, descriptor binding, transported results, source claims, +and handoff. Its green histories either register the observed query or supply +the corresponding normalized source data. They do not establish the behavior +of a second, unregistered plain query over a source loaded by another query. +This is an absent history dimension, not a wrong per-client reference map. + +The diagnostic history uses one descriptor and two projections. Preload the +first projection into the server client; it synchronously publishes +`{ id: 'one', value: 'server' }`. Render a plain hook for the second projection, +dehydrate the server client, hydrate a fresh browser client, and compare the +initial server snapshots. Then run real `hydrateRoot` in jsdom with an +`onRecoverableError` recorder. Explicitly preloading the second projection is +the distinguishing control. The expected relation is markup equality and zero +hydration recoveries, independent of the query evaluator. + +| History | Server markup | Initial browser snapshot | React recoveries | +| --- | --- | --- | --- | +| Second query unregistered | `server` | `` | 1; assertion RED | +| Second query explicitly preloaded | `server` | `server` | 0; GREEN | + +The source facts exist on the server at render time. The transport lacks the +second query's result and the source rows; waiting until browser commit cannot +repair that earlier observation. The real React receiving witness confirms the +reported host failure with a controlled source. It does not establish the +reporter's Start/router/Electric deployment. + +The unresolved choice is whether plain SSR hooks automatically capture their +observable synchronous result for transport, or initially render empty/loading +unless a result or normalized source snapshot was explicitly supplied. The +former needs capture and transport semantics; the latter changes observable +server behavior. Merely refusing new source starts does not solve reads from an +already warm source. Capturing only ready eager rows fixes one trace while +missing partial nonready rows and status flags. Suppressing all unregistered +rows would conflict with documented reuse of explicitly normalized sources. + +The failing diagnostic and passing control are preserved in task scratch as +`dbclient-hydration-feedback.probe.test.tsx` and `react-hydration-red.log`. +They are not installed as a passing oracle or hidden behind an expected-failure +gate. The permanent owner remains the React descriptor owner, as recorded in +the [coverage map](../oracle-coverage.md). After the product decision, extend its +model and histories for ready/partial/nonready sources, full rendered status, +normalized-source transport, and mixed plain/Suspense/preload schedules; reject +the original behavior and a ready-only capture mutant at the hydration cut. +Actual Start/Electric delivery is a separate receiving witness. F02 is +`design-decision`, with the correctness failure still confirmed and open. + +### Independent subscription demand outlives an abandoned render — F03 + +Authority: public ordinary `Collection.subscribeChanges()` starts sync when +needed; `deferAcquisition` explicitly withholds that demand. React render-time +source deferral prevents work before commit, but does not grant an abandoned +render authority to veto a later independent reader. This composes the +Collection subscription contract with the descriptor owner's zero-work-before- +commit law. Source `preload()` is independent demand too, although on-demand +source preload alone does not acquire rows. + +The old React owner tested only the preload reader after suspension. Its +expected start rule was correct within that scope; its history grammar omitted +ordinary and deferring subscriptions. The first extension crossed all three +readers with startSync true/false and eager/on-demand sources (12 cells). PR +review then exposed cleanup reentry during ordinary admission, R01 below. + +The final React grammar has 24 cells: ordinary subscriptions cross those source +axes with no cleanup, cleanup from loading, synchronous cleanup from ready, and +held cleanup from ready (16 cells); preload and deferring readers retain their +eight no-cleanup controls. The core lifecycle owner adds 24 cases crossing the +four cleanup histories with no start gate, a held unrequested gate, or a held +requested gate, in both source modes. The shared finite judgment is defined in +`collection-subscription-lifecycle-grammar-oracle.ts`. It derives expectations +from demand and cleanup authority, without reusing production classifiers. + +Without cleanup, ordinary/preload demand starts once and reaches ready; +deferring demand remains idle with zero starts. An eager source publishes at +start; on-demand rows are owed to an ordinary subscriber, while source preload +alone does not acquire them. Cleanup during loading prevents adapter entry. +Cleanup during ready permits one entry and retires it. Held resource cleanup +retains the ready public status until settlement, as defined by the glossary. +Admission cannot retry a retired start. A fresh later subscriber may restart +it; otherwise the peer reuses the existing run. + +The real React driver renders an ordinary hook and throws a never-settling +promise inside Suspense, preventing commit. Direct public Collection operations +then supply demand. The driver checks adapter entries, cleanup calls, status +and the complete status trace at admission return and cleanup settlement. It +checks rows only in no-cleanup histories, and checks fresh demand afterward. +The core driver reaches the same judgment independently of React, including +an explicit no-gate control. These are finite histories, not a claim about all +concurrent schedules. + +| Subject | Result at intended checkpoint | Classification | +| --- | --- | --- | +| Original implementation, first 12-cell extension | All 4 ordinary cells see 0 starts instead of 1 | Historical assertion RED | +| First release-before-request repair, first 12 cells | All pass | Historical GREEN; omitted cleanup histories | +| Previously-requested-start mutant on that first repair | Both startSync=false ordinary cells fail | Historical assertion RED; not current calibration after reorder | +| Release-before-request repair, expanded cleanup grammar | 4/223 core owner cases fail and 4/24 selected React cases fail at admission return | Assertion RED | +| Original/no-release mutant, final 24-cell React grammar | All 16 ordinary cells fail on adapter entries or status; 8 controls pass | Assertion RED | +| Final request-before-release repair | All 741 surrounding core and 441 React tests pass | GREEN | + +The repair records ordinary demand, requests/starts sync, then calls +`sync.resumeStart()` to release any render gate. There is no trailing start +request that could retry after callback cleanup. It adds no state, retry, +recovery path, or lifecycle. Releasing the gate in `resumeSubscriber()` was +tried and rejected: that query-owned acquisition transition may occur while +the renderer still owns a separate start gate, and it broke existing +synchronous-error/peer-release tests. That distinction remains intact. + +Three core test files previously used an ordinary new subscriber to simulate +the renderer-held gate. Their drivers now create a deferring subscriber and +resume its acquisition while retaining the separate renderer gate. Their +queued request, cancellation, lease, and release assertions remain. Core tests +that directly exercise `_sync.loadSubset` still cover the actual deferred sync +queue. No expected result was changed to match a wrong production output. + +F03 is `fixed-now` for abandoned-render deferral followed by independent public +subscription demand in the finite eager/on-demand, startSync true/false grammar. +The model, histories and drivers check admission, cleanup settlement, and later +fresh demand; both the original design and release-before-request repair are +rejected. No remaining +counterexample is known within those cells. Arbitrary React interruption +schedules and Electric `awaitTxId` delivery are not established. In particular, +the reporter's downstream txid timeout was not reproduced against Electric. + +### Indexes must exist before compilation if diagnostics depend on them — F01/F08 + +This is an initialization requirement, not evidence of incorrect rows. Four +executed cells compile the same ordered-limit query before source sync: + +| Index setup | Sync starts | Missing-index warnings | Installed indexes | +| --- | --- | --- | --- | +| None | 0 | 1 | 0 | +| Inside sync callback | 0 | 1 | 0 | +| `client.collection(descriptor).createIndex(...)` before query | 0 | 0 | 1 | +| Eager auto-indexing | 0 | 0 | 1 | + +The report is right about timing and the missing descriptor config API. The +literal claim that there is no place to initialize indexes is too broad: an +application client initializer is available. The guide now shows it. Whether to +add declarative `indexes` or `onCreate` is a product/API decision. F08 retains its +unique hydration-fallback context but is a duplicate of F01's timing issue; +that exact React fallback/index-warning combination was not independently run. +The existing index-suggestion owner remains authoritative for diagnostic laws; +this evaluation does not invent a new runtime failure law. + +### Readiness, cancellation, identity, and ownership — F04/F05/F06 + +Preload waits for readiness. A controlled never-ready source starts once and +keeps the promise pending. Whole-client cleanup rejects it and stops the source +once. The API has no timeout or `AbortSignal` option. Thus F04 identifies an +operational need and API choice; it does not falsify an existing deadline +promise. Real unreachable-Electric retry behavior remains reporter evidence. +A timer race alone does not cancel work; the guide now calls out cleanup. + +The same prepared query produces different function-form `query` and +object-form `config` hashes. F05's diagnosis is correct. Config identity also +includes result settings such as schema, getKey, singleResult, and collation; +blindly merging hashes could alias different result contracts. The requested +guide note and shared-config example are implemented. This documentation action +is closed without changing identity semantics or adding a warning. + +Three distinct browser preloads produce three public result records and three +retained preloaded Collections. Once one mounted observer takes authoritative +live ownership, public records fall from three to two, while retained preloaded +Collections remain three. Whole-client cleanup exists, and private error/retry +paths can discard entries; therefore “nothing discards them” is too broad. +There is no public per-query release. F06 needs a policy for cache lifetime, +active observers, repeated preload, eviction, and release. Cardinality proves +retained ownership here, not endless active network work or a violated memory +budget. The guide documents current ownership and independently owned query +Collections as an alternative. This does not close the API request. + +## Lossless finding ledger + +Severity below is this evaluation's estimate; the report supplied no formal +severity ranking. `design-decision` does not mean fixed or permission to defer a +known defect. No item is marked deferred. + +| ID | Original claim and remedy | Evidence and technical verdict | Action | Durable destination | +| --- | --- | --- | --- | --- | +| F01 | 55 manual indexes have no descriptor creation hook; sync is too late; propose indexes/onCreate, use eager indexing | Timing confirmed by four cells; existing client initializer disproves literal lack of an initialization point. Low correctness severity, meaningful API friction | `design-decision`: declarative indexes versus initializer/eager policy | SSR index guidance; law analysis above | +| F02 | Plain warm-source SSR query renders rows but hydrates empty; use separate preload client or refuse unregistered source starts | Confirmed markup mismatch and actual React recovery, high SSR correctness impact; proposed refusal alone misses already warm sources | `design-decision`: capture versus initial empty/loading semantics; unfixed | React descriptor owner and coverage-map gap; preserved failing probe | +| F03 | Discarded render strands deferred start; ordinary subscriber should resume; preload works; awaitTxId may time out | Start defect confirmed; four original cells fail. Downstream Electric consequence unverified. High lifecycle impact | `fixed-now`: one executable call, 24-cell receiving grammar, 24 core cleanup histories and preserved acquisition boundaries | React descriptor owner, core acquisition owner, review calibration | +| F04 | Unreachable Electric can hang preload; request signal/deadline; race timer and cleanup | Missing option and controlled pending lifetime confirmed; real SDK retries unrun. Operational API need, no pre-existing deadline law | `design-decision`: signal/cancellation ownership and deadline policy | SSR lifetime guidance and API decision here | +| F05 | Function and config hashes differ; function hook misses preload silently; request guide note or warning | Distinct hashes executed; config result semantics prevent naive canonicalization. Medium migration friction | `fixed-now`: requested documentation alternative implemented; hashes unchanged | Shared-config SSR example and identity warning in guide | +| F06 | Browser preload map retains results on navigation; request public release; server-only workaround | Retained map confirmed; public records are consumed separately. Cleanup/private discard qualify literal claim. Potential unbounded workload-dependent retention | `design-decision`: release/eviction/cache semantics | SSR lifetime guidance and measured ownership history | +| F07 | Default ElectricCollectionUtils rejects Date descriptors; use generic any; old concrete Collection worked | Not reproduced by reliable direct strict compiler check on current source, including Date schema/non-schema, wrapper, and handler. Earlier isolated Vitest failure is contradictory and withdrawn as confirmation | `confirmed-open`, evidence gap; smallest failing assignment and resolved dependencies requested | Type probe in scratch and fixture request; no unsafe variance change | +| F08 | Hydration fallback warns about missing index before sync; eager indexing fixes | Compile-before-sync timing confirmed; exact fallback path not run. Low severity | `duplicate` of F01; unique fallback premise retained | F01 initialization guidance and this ledger | + +F07 needs particular care: `tsc --noEmit` with strictFunctionTypes enabled and +explicit absolute paths to current db/electric sources accepts assignments to +`CollectionOptions & NonSingleResult`. +It also accepts the report's generic `TConfig extends CollectionConfig` wrapper with a Date schema and typed mutation handler. Positive +non-never assertions avoid a vacuous `never` witness. Reversing the assertions +causes errors and leaves an unused `@ts-expect-error`. The tested fixtures do +not refute an unprovided application assignment. The report's Date/type-variance +explanation remains unconfirmed. There is no basis to loosen typed public +utilities or introduce production `any` for it. + +The surrounding setup and closing prose also contain useful claims. They are +kept separately rather than silently folded into the numbered issues: + +| ID | Contextual claim | Evidence, qualification, and action | Destination | +| --- | --- | --- | --- | +| N01 | 26 collections and about 50 hooks migrated; every page SSR/hydration and handoff succeeds with workarounds | `confirmed-open` for independent verification: reporter's application observation; no application fixture. Not an additional alleged defect | Retain integration testimony without claiming an end-to-end run | +| N02 | Descriptor factory receives request DbClient; handlers resolve another descriptor through it | `accepted-design`: client-aware factory signature in client.ts and SSR factory guidance. Source-verified; application handler not run | Existing scoped-client API and this review | +| N03 | Keep all browser sources syncing for txid waits; await only written tables, chosen from mutation result; unwritten collection “always times out” | `accepted-design` for evidence-driven waiting and scoped browser ownership, qualified: awaitTxId also accepts snapshot visibility evidence, so “always” is not a universal rule. Existing snapshot-end test exercises that exception | Electric awaitTxId/snapshot tests; do not turn the ERP's observation into a Postgres law | +| N04 | Separate finite-lived server preload client; query-results-only transport; timer, finally cleanup, graceful browser recovery | `design-decision` linked to F02/F04/F06. Cleanup is material; timer does not cancel; graceful application recovery not independently run | SSR lifetime guidance and unresolved transport policy | +| N05 | Shared builder/config lets loader and hook hashes match across clients | `fixed-now` documentation action linked to F05. A callback alone is insufficient across function/config forms; use the same config or config objects with the same explicit queryKey at both consumers, preserving compatible sources and result configuration | SSR shared-config example | +| N06 | Nested materialize descriptors resolve; descriptor ids stabilize identity across clients | `accepted-design`: existing descriptor owner checks includes/nested FROM, two-client identity and rows, with explicit finite limits | Existing owner, exercised in full React run | +| N07 | routerWithDbClient, Electric sync metadata hooks, and hydrated-to-live handoff work unchanged | `confirmed-open` for independent application verification; preserved positive report. Relevant component tests are not that ERP/Start integration | Reporter testimony; real-provider receiving limit above | +| N08 | Pass DbClient into module-level optimistic actions; wait for transaction settled before success toast | `accepted-design`: scoped-client mutation ownership and Transaction.when('settled') contract support this usage; reporter's toast integration not run | Existing transaction/client APIs; no unrelated optimistic machinery changed | + +## Reviewer assessment + +Recommendation: **hire**, with moderate confidence from this one integration +report. The author found two concrete lifecycle/SSR counterexamples, connected +them to practical application behavior, supplied useful workarounds, and +identified hash and retained-ownership details accurately. Signal is high and +the report preserves successful integration evidence as well as friction. + +Prioritization and fix design need strengthening. Hydration and independent +demand are correctness defects; index hooks, cancellation, cache release, and +type erasure need separate contract/API treatment. “No place,” “nothing +discards,” and unwritten collections “always” timing out overstate the evidence. +The typing claim lacks a minimal fixture and did not reproduce in the reliable +compiler check. Refusing source starts is an incomplete hydration remedy. +Missing tests, law boundaries, partial-ready states, and adjacent schedules were +not analyzed by the reporter. These reduce confidence in a ready-to-implement +fix specification, without diminishing the value of the original diagnosis. + +## Verification and replay + +Scratch root: `/tmp/evaluate-dbclient-feedback/`. It holds the unchanged raw +report, append-only initial ledger and corrections, diagnostic sources, and +logs. Scratch files are not intended repository changes. Logs can include +cleanup diagnostics; the decisive REDs below are assertion failures reached +before cleanup, not setup failures or timeouts. + +| Check | Executed result | Evidence file | +| --- | --- | --- | +| Expanded React subscriber grammar on original implementation | 4 subscriber assertion failures | original-subscriber-red.log | +| Historical previously-requested-start mutant before reorder | 2 false-config subscriber assertion failures | narrow-start-requested-mutant.log | +| Historical first-repair 12-cell subscriber grammar | 12 pass | react-subscriber-expanded.log | +| Full React suite before R01 | 22 files, 429 tests pass; type errors none | react-full-final.log | +| Full React suite after R01 | 22 files, 441 tests pass; type errors none | r01-react-green.log | +| Core surrounding suites before R01 | 7 files, 717 tests pass; type errors none | subscription-surrounding-final.log | +| Core surrounding suites after R01 | 7 files, 741 tests pass; type errors none | r01-core-green.log | +| R01 expanded core owner before reorder | 4 assertion failures, 219 pass; no suite/audit errors | r01-core-full-red.log | +| R01 expanded React matrix before reorder | 4 assertion failures, 20 pass | r01-react-red.log | +| Original/no-release mutant on final React grammar | 16 ordinary assertion failures, 8 controls pass; restored | r01-original-mutant.log | +| Final shipped-size comparison | db +8 gzip / +116 brotli bytes, changed module +24 minified bytes; react-db unchanged | r01-size.log | +| Diagnostic runtime probes | 7 pass: index timing, pending cleanup, identity, ownership counts | runtime-probes.log | +| Actual hydration diagnostic | 1 RED, 1 control GREEN | react-hydration-red.log | +| Reliable direct strict TypeScript check | Exit 0 on positive Date descriptor assignments | electric-tsc.log | +| Electric snapshot visibility exception | 1 existing test passes; 124 unrelated tests filtered; typecheck disabled for this targeted runtime check | txid-snapshot.log | +| Scoped ESLint | Exit 0, no errors; 3 pre-existing no-shadow warnings in lifecycle owner | Direct command output | +| Whitespace validation | git diff --check passes | Direct command output | + +Run the permanent React owner from `packages/react-db`: + +```sh +pnpm exec vitest run --coverage.enabled=false \ + --pool-options.threads.maxThreads=2 --pool-options.threads.minThreads=1 \ + tests/descriptor-query-binding-oracle.test.tsx +``` + +Run its demand matrix alone with `-t 'after an abandoned render'`. The original +calibration removes only the added `this.sync.resumeStart()` in addSubscriber. +The narrow calibration keeps that repair but changes sync.resumeStart's gate +to require both deferred start and a previously requested start; filter to +`"'subscriber' after an abandoned render"`. Those historical mutations were temporary and restored. The previously-requested +start mutant is no longer discriminating on the reordered path, which requests +before release; its old kill does not calibrate the current implementation. +The expanded final matrix is also run with the added resume call removed. +sync.ts has no final diff. + +Core surrounding command, from `packages/db`: + +```sh +pnpm exec vitest run --coverage.enabled=false \ + --pool-options.threads.maxThreads=2 --pool-options.threads.minThreads=1 \ + tests/collection-subscription-lifecycle-oracle.test.ts \ + tests/collection-subscription.test.ts tests/collection.test.ts \ + tests/db-client.test.ts tests/query/immutable-demand-boundary.test.ts \ + tests/live-query-deferred-acquisition-oracle.test.ts +``` + +Vitest's path matching also selects `tests/query/live-query-collection.test.ts`. +For scratch probes, copy each archived file back to its original package's +tests directory, run the same package-local Vitest filename, then move it back. +The electric probe uses direct `pnpm exec tsc -p +/tmp/evaluate-dbclient-feedback/tsconfig.electric.json --noEmit`, with that config +including the package-local file and absolute source paths. The hydration probe +is intentionally RED on this branch. It is not part of the green suite count. + +## Oracle review evidence + +This is a self-review of the modified oracle portions, not an independent +review of the whole owner or repository. Base HEAD is the exact commit above; +the content identifiers below bind the uncommitted executable changes to this +record. Recheck this evidence if those files change. + +| Requirement | Outcome for the changed F03 boundary | +| --- | --- | +| ORC-001 | Law and authority distinguish independent demand, explicit deferral, cleanup retirement, pending-cleanup status and on-demand preload limits | +| ORC-002 | Shared finite judgment derives entries and status traces from demand and cleanup authority; row predictions follow fixture facts. Neither reuses production classifiers | +| ORC-003 | Opening and adjacent prose distinguish the judgment, finite cleanup grammar, core and React drivers, and observation checkpoints | +| ORC-004 | Bounded enumeration: 24 React cells (16 ordinary cleanup cells plus 8 controls) and 24 core cells (4 cleanup histories × 3 gate states × 2 modes). Gate, loading/ready, held cleanup and mode axes distinguish concrete next observations. Arbitrary schedules and failed providers remain outside scope | +| ORC-005 | Real Suspense render establishes the abandoned-render cut. Drivers compare callback entries, cleanup calls and full status traces at admission return and cleanup settlement; later demand checks restart/reuse. Rows are checked without cleanup | +| ORC-006 | Original/no-release mutant fails 16 final React cases. Release-before-request repair fails four core and four receiving cases at admission return. These are assertion failures, without timeout/setup failure | +| ORC-007 | Not triggered: bounded enumeration, no new random property or replay grammar | +| ORC-008 | No stateful reference machine added; finite demand/cleanup judgment distinguishes callback entry, public status, trace and fresh demand. Pending cleanup is explicit, without copying production state | +| ORC-009 | Uses source, sync run, acquisition, subscription, and render/commit cuts as in glossary; fixture starts are sync-entry instrumentation, not a new production state | +| ORC-010 | finally resolves held cleanup, removes listener, releases peer/subscriber/tree/client and handles pending preload rejection. Original-mutant source restoration uses finally; no shrinking layer changed | +| ORC-011 | Expected entry counts and cleanup retirement derive from public contracts; preload/deferring controls and fresh-demand restart challenge overbroad suppression. No named shared semantic computation fault requiring another formulation was found | +| ORC-012 | This exact-base/content record, loss audit, and coverage-map update state the finite closure and open receiving cells | +| ORC-013 | Ordinary versus deferring demand distinguishes veto from ownership. Callback cleanup rejects trailing restart; held cleanup distinguishes delayed retirement. A new peer rejects suppressing all restarts. Historical previously-requested-start mutant evidence does not calibrate the reordered path | +| ORC-014 | Real React supplies the abandoned-render premise. Sync readiness/publication remains controlled; Electric txid delivery and arbitrary host schedules are expressly outside the closure claim, with owner/gap in coverage map | + +## Content identifiers + +SHA-256 of the executable files reviewed above, on the stated base plus working +changes: + +| File | SHA-256 | +| --- | --- | +| `packages/db/src/collection/changes.ts` | `9b7d6bf3f24bd23cfc383fd416630527134e8082dc04b563b1269247351651e7` | +| `packages/react-db/tests/descriptor-query-binding-oracle.test.tsx` | `ac6686ebc5caa4aa4aceca43fb1b89e495ab1e01e923c866e35ed4835f07940d` | +| `packages/db/tests/collection-subscription-lifecycle-oracle.test.ts` | `ed48ff71b10b5878a182527ce3ce29bf1f3866b848b91b4cb164a881d88f75f4` | +| `packages/db/tests/collection-subscription.test.ts` | `eaea5e97d9af83383af3c9595f05db4f1dbdbafbd13ea8c30e3a209347af3239` | +| `packages/db/tests/db-client.test.ts` | `8ff61c5f994db41062834c4857502f96a9118678b43a6da41571bb1749d50bb7` | +| `packages/db/tests/collection-subscription-lifecycle-grammar-oracle.ts` | `4b915ee56770f18334ab477cf7710ae2108e0dbaada2cd5654a1f6be3c25499b` | + +The source report SHA-256 is `846313a43c3deae3b72abcd7ae2102c4b589460465ee838c9fb42fa6dd379922`. + +## Loss audit and implementation state + +Returned to the raw report after verification: all eight numbered issues, +setup/helper suggestions, and closing positive claims are represented above. +F08 overlaps F01 without losing fallback timing; N04/N05 preserve workaround +details without erasing their corresponding defects. No sibling production +defect was confirmed. The erroneous intermediate typing conclusion is corrected +in the append-only scratch ledger and here. + +Numbered finding totals: 2 `fixed-now`, 4 `design-decision`, 1 `confirmed-open`, +1 `duplicate` = 8. Contextual claim totals: 1 `fixed-now`, 1 `design-decision`, +2 `confirmed-open` reporter observations, 4 `accepted-design` qualified API +usages = 8. Combined: 3 fixed + 5 decisions + 3 open + 4 accepted + 1 duplicate += 16 raw items. These are disposition counts, not 16 production defects. +There are zero deferred, stale, already-fixed, or fully refuted items. + +Item accounting is complete; implementation is not fully complete. F02 remains +a demonstrated defect pending its exact initial-snapshot contract. F07 needs +the failing application fixture. Index initialization, cancellation and preload +release need API decisions. Reporter deployment success, actual unreachable +Electric retries, the txid consequence, and Start/Electric hydration are not +independently established. No postponement of confirmed repair work has been +approved or inferred. The permanent suite's green excludes the explicitly +preserved failing hydration diagnostic. + +Initial evaluation readiness: **not assessed**. The original request was to +evaluate feedback, before the later PR preparation request. The known +hydration counterexample prevents claiming this work closes all migration bugs. + +## Subsequent PR preparation review + +The user subsequently requested PR preparation. Parallel simplification and +independent production/lifecycle and oracle/documentation review found two +introduced issues. No simplification was warranted. The user accepted R01; the +admission path is now repaired and the expanded oracle covers cleanup reentry. +The user also accepted OD01; the guide and N05 now require config objects at +both consumers when using an explicit queryKey. + +| ID | Finding | Evidence and gap | Current action | +| --- | --- | --- | --- | +| R01 / PL01, P2 | Ordinary subscription can restart a source after its startup callback cleans it up | A deferred, previously requested start reaches ready, whose listener calls cleanup once. At subscribeChanges return, starts=2 and status=ready; the trace is loading, ready, cleaned-up, loading, ready. Nondeferred and deferred-without-queued-request controls each start once and remain cleaned-up. The copied original stays stranded at zero starts/idle in both deferred cases. Existing cleanup law requires retirement; new reader histories omitted callback cleanup, while core cancellation histories use explicit gate release without this admission path | Fixed after user acceptance. Mark demand, request/start sync, then release the gate. The shared cleanup judgment reaches 24 core and 24 React cells; original wrong order fails four cases in each path, and the repair passes the surrounding suites | +| OD01, P3 | Added queryKey guidance promises unsupported cross-form reuse | getExplicitQueryKey reads object inputs only; the bare callback hook accepts a dependency array as its second argument. Therefore an explicit loader key cannot connect useLiveQuery(openTodos.query) | Fixed after user acceptance. The guide and N05 require config objects with the same explicit queryKey at both consumers, preserving compatible sources and result configuration. Verified against the hook overloads, getExplicitQueryKey and the preload key lookup | + +The runtime probe ran successfully against the current source and an isolated +copy with only the added resume call removed. An earlier TypeScript-script +setup failure is excluded from the evidence. The parent repeated the successful +three-case current-source probe. Raw independent reviews, exact scripts, and +stdout are preserved under the same scratch root as `prep-code-review.md`, +`prep-production-review.md`, `prep-production-current.log`, +`prep-production-original.log`, and `prep-oracle-docs-review.md`. + +The hydrated-row core test now resumes detached demand rather than itself +proving the exact sync-layer queue path. Adjacent explicit _sync.loadSubset +and lifecycle request histories preserve that path; this is a qualification, +not another confirmed defect. Internal resumed deferring acquisition remains +distinct from new ordinary demand. Executable content identifiers above bind +the accepted repair and expanded judgment. + +F03 is fixed-now within the expanded finite scope. Numbered totals return to +2 fixed, 4 decisions, 1 open, and 1 duplicate. R01 and OD01 are fixed. +The eight contextual items retain their earlier dispositions, with N05 qualified +by OD01. R01 and OD01 are additional PR-review items, not additional claims +attributed to the migration reporter. The user approved committing the reviewed +changes and generating PR documentation. The open hydration +policy and missing typing fixture remain outside this repair's closure claim. + +A focused independent follow-up reviewed the accepted R01 repair and changed +judgment/drivers after calibration. It found no residual actionable defect: +request-before-release removes the trailing restart; the shared judgment is +independent; both drivers check entries, cleanup calls, full status traces at +admission return/settlement, and fresh-demand restart. It verified the 24 core +and 24 React case counts and the loading/held-cleanup distinctions. This was a +read-only source review, not a new broad test run or whole-PR readiness verdict. diff --git a/docs/guides/ssr.md b/docs/guides/ssr.md index 8fa0c17011..4395728fc9 100644 --- a/docs/guides/ssr.md +++ b/docs/guides/ssr.md @@ -292,6 +292,61 @@ const state = dbClient.dehydrate() This payload contains the projected query result and no source collection rows unless that collection was also materialized explicitly. +Use the config object form in both the loader and the hook when they share a +preloaded result: + +```tsx +const openTodos = { + query: (q: InitialQueryBuilder) => + q.from({ todo: todoCollection }).where(({ todo }) => eq(todo.status, 'open')), +} + +await dbClient.preloadLiveQuery(openTodos) +// In the component: +const { data } = useLiveQuery(openTodos) +``` + +The function form `useLiveQuery(openTodos.query)` currently derives a different +identity from `useLiveQuery(openTodos)`. It will not reuse the loader's result. +Sharing the query callback alone is insufficient. To use an explicit `queryKey`, +pass config objects with the same `queryKey` to both `preloadLiveQuery` and +`useLiveQuery`. Their sources and result configuration must still describe the +same query. + +### Indexes before query compilation + +Create manual indexes when initializing each request or browser client, before +preloading or rendering queries that use them: + +```tsx +function createDbClient() { + const client = new DbClient() + client.collection(todoCollection).createIndex((todo) => todo.id, { + indexType: BasicIndex, + }) + return client +} +``` + +Collection materialization does not start sync unless its config requests it. +An index installed inside the sync callback can arrive after the ordered query +has compiled. `autoIndex: 'eager'` with `defaultIndexType: BasicIndex` is an +alternative when indexes should be created as queries compile. Descriptors +currently have no `indexes` or `onCreate` option. + +### Preload lifetime + +`preloadLiveQuery()` has no built-in deadline or `AbortSignal` option. A source +that keeps retrying without becoming ready can keep its preload pending. Apply +an application deadline to request work and clean up its request-scoped client +when abandoning it; a timer race alone does not stop the underlying sync. + +Preloaded results belong to the client. There is no public method to release +one preloaded query. `cleanup()` releases the entire client and its Collections. +Avoid accumulating route-specific preloads in a browser client without an +application lifetime policy. A separately owned `createLiveQueryCollection()` +can be preloaded and cleaned up explicitly when per-query ownership is needed. + ### Browser Hydrate the browser client before rendering components that read from DB. diff --git a/packages/db/src/collection/changes.ts b/packages/db/src/collection/changes.ts index bed65f1ae5..64dcf6d268 100644 --- a/packages/db/src/collection/changes.ts +++ b/packages/db/src/collection/changes.ts @@ -447,7 +447,9 @@ export class CollectionChangesManager< if (!defersAcquisition) { // Mark first, so a sync run that starts now builds non-deferred demand. this.markSubscriberOrPreload() + // Request before release so cleanup during startup cannot trigger a retry. this.startSyncIfStopped() + this.sync.resumeStart() } else if (getBuilderFromConfig(this.collection.config)) { this.startSyncIfStopped() } diff --git a/packages/db/tests/collection-subscription-lifecycle-grammar-oracle.ts b/packages/db/tests/collection-subscription-lifecycle-grammar-oracle.ts index 6c51de566c..3b403ccc07 100644 --- a/packages/db/tests/collection-subscription-lifecycle-grammar-oracle.ts +++ b/packages/db/tests/collection-subscription-lifecycle-grammar-oracle.ts @@ -40,6 +40,51 @@ import { fc } from '@fast-check/vitest' export type DemandName = `a` | `b` +/** + * Ordinary subscription admission may start one sync run. Cleanup from its + * loading or ready callback retires that start; admission must not retry it. + * A loading callback runs before the adapter enters, so it can prevent even + * that first adapter invocation. A pending adapter cleanup retains the ready + * public status until cleanup settles, as specified in the glossary. + * + * These finite histories and their independent judgment are shared by the core + * admission driver and the real React abandoned-render receiving driver. A + * pending cleanup is legal only after ready, when an adapter resource exists. + */ +export const subscriberStartCleanupHistories = [ + { cleanupAt: `none`, cleanupPending: false }, + { cleanupAt: `loading`, cleanupPending: false }, + { cleanupAt: `ready`, cleanupPending: false }, + { cleanupAt: `ready`, cleanupPending: true }, +] as const + +export function modelSubscriberStartCleanup({ + cleanupAt, + cleanupPending, +}: (typeof subscriberStartCleanupHistories)[number]) { + const starts = cleanupAt === `loading` ? 0 : 1 + const cleanups = cleanupAt === `ready` ? 1 : 0 + const statusOnReturn = + cleanupAt === `none` || cleanupPending ? `ready` : `cleaned-up` + const statusAfterCleanup = cleanupAt === `none` ? `ready` : `cleaned-up` + const statusesOnReturn: Array<`loading` | `ready` | `cleaned-up`> = [ + `loading`, + ] + if (starts) statusesOnReturn.push(`ready`) + if (statusOnReturn === `cleaned-up`) statusesOnReturn.push(`cleaned-up`) + const statusesAfterCleanup = cleanupPending + ? [...statusesOnReturn, `cleaned-up`] + : statusesOnReturn + return { + starts, + cleanups, + statusOnReturn, + statusAfterCleanup, + statusesOnReturn, + statusesAfterCleanup, + } +} + /** * A bounded callback-reentry grammar for direct snapshots. The callback phase * is an observation cut, not a second subscription state machine. A released diff --git a/packages/db/tests/collection-subscription-lifecycle-oracle.test.ts b/packages/db/tests/collection-subscription-lifecycle-oracle.test.ts index 0a7ae23cca..67f459e1ae 100644 --- a/packages/db/tests/collection-subscription-lifecycle-oracle.test.ts +++ b/packages/db/tests/collection-subscription-lifecycle-oracle.test.ts @@ -14,6 +14,10 @@ import { oracleRandomParameters, readOracleRunConfig, } from './oracle-config.js' +import { + modelSubscriberStartCleanup, + subscriberStartCleanupHistories, +} from './collection-subscription-lifecycle-grammar-oracle.js' import type { CollectionSubscription } from '../src/collection/subscription.js' import type { LoadSubsetOptions, SyncConfig } from '../src/types.js' @@ -2588,6 +2592,9 @@ describe(`CollectionSubscription demand lifecycle oracle`, () => { }, ) + // Resuming a query-owned acquisition does not release the renderer's + // separate sync-start gate. An ordinary new subscriber does release it; + // descriptor-query-binding-oracle checks that independent-reader boundary. acquisitionCase( [`deferred:request`, `deferred:resume`], `owns deferred-start acquisition only when it reaches the adapter`, @@ -2615,7 +2622,9 @@ describe(`CollectionSubscription demand lifecycle oracle`, () => { expect(collection._deferSyncStart()).toBe(true) const subscription = collection.subscribeChanges(() => {}, { includeInitialState: false, + deferAcquisition: true, }) + subscription.resumeDeferredAcquisition() subscription.requestSnapshot({ where }) reach(`deferred:request`) expect(loads).toEqual([]) @@ -2660,7 +2669,9 @@ describe(`CollectionSubscription demand lifecycle oracle`, () => { expect(collection._deferSyncStart()).toBe(true) const subscription = collection.subscribeChanges(() => {}, { includeInitialState: false, + deferAcquisition: true, }) + subscription.resumeDeferredAcquisition() subscription.requestSnapshot({ where, onLoadSubsetResult: (result) => observed.push(result), @@ -3408,7 +3419,9 @@ describe(`CollectionSubscription demand lifecycle oracle`, () => { expect(collection._deferSyncStart()).toBe(true) const subscription = collection.subscribeChanges(() => {}, { includeInitialState: false, + deferAcquisition: true, }) + subscription.resumeDeferredAcquisition() const removeReadyListener = collection.on(`status:${phase}`, () => { if (!cancelOnEntry) return cancelOnEntry = false @@ -3441,6 +3454,107 @@ describe(`CollectionSubscription demand lifecycle oracle`, () => { }, ) + // The grammar's independent judgment treats cleanup as retiring this start, + // not permission to retry it. This driver crosses no render gate, a held + // gate with no request, and a held requested start with both source modes. + // It records the complete public status trace at subscribeChanges return + // and cleanup settlement. A later ordinary reader is fresh restart demand. + it.each( + subscriberStartCleanupHistories.flatMap((history) => + ([`none`, `unrequested`, `requested`] as const).flatMap((gate) => + ([`eager`, `on-demand`] as const).map((syncMode) => ({ + ...history, + gate, + syncMode, + })), + ), + ), + )( + `ordinary admission preserves $cleanupAt cleanup (pending=$cleanupPending, $gate start, $syncMode)`, + async (history) => { + const expected = modelSubscriberStartCleanup(history) + const pendingCleanup = createDeferred() + let starts = 0 + let cleanups = 0 + let cleanupRequested = false + let cleanup: Promise | undefined + const statuses: Array = [] + const collection = createCollection({ + id: `ordinary-admission-cleanup`, + startSync: false, + syncMode: history.syncMode, + getKey: (row: { id: string }) => row.id, + sync: { + sync: ({ markReady }) => { + starts++ + markReady() + return { + loadSubset: () => true, + cleanup: () => { + cleanups++ + return history.cleanupPending + ? pendingCleanup.promise + : undefined + }, + } + }, + }, + }) + if (history.gate !== `none`) { + expect(collection._deferSyncStart()).toBe(true) + if (history.gate === `requested`) collection.startSyncImmediate() + } + const removeStatusListener = collection.on( + `status:change`, + ({ status }) => { + statuses.push(status) + if (!cleanupRequested && status === history.cleanupAt) { + cleanupRequested = true + cleanup = collection.cleanup() + } + }, + ) + let subscription: + ReturnType | undefined + let peer: ReturnType | undefined + try { + subscription = collection.subscribeChanges(() => {}, { + includeInitialState: false, + }) + expect(starts, `adapter entries at admission return`).toBe( + expected.starts, + ) + expect(cleanups).toBe(expected.cleanups) + expect(collection.status).toBe(expected.statusOnReturn) + expect(statuses).toEqual(expected.statusesOnReturn) + + pendingCleanup.resolve() + await cleanup + expect(starts, `cleanup settlement must not restart`).toBe( + expected.starts, + ) + expect(collection.status).toBe(expected.statusAfterCleanup) + expect(statuses).toEqual(expected.statusesAfterCleanup) + + removeStatusListener() + peer = collection.subscribeChanges(() => {}, { + includeInitialState: false, + }) + expect(starts, `later demand may restart a retired run`).toBe( + expected.starts + (history.cleanupAt === `none` ? 0 : 1), + ) + expect(collection.status).toBe(`ready`) + } finally { + removeStatusListener() + pendingCleanup.resolve() + await cleanup + peer?.unsubscribe() + subscription?.unsubscribe() + await collection.cleanup() + } + }, + ) + it(`keeps an eager subscription ready after collection restart`, async () => { const collection = createCollection<{ id: string }>({ id: `eager-subscription-restart`, diff --git a/packages/db/tests/collection-subscription.test.ts b/packages/db/tests/collection-subscription.test.ts index 2a0958c025..ef42668b2a 100644 --- a/packages/db/tests/collection-subscription.test.ts +++ b/packages/db/tests/collection-subscription.test.ts @@ -782,7 +782,9 @@ describe(`CollectionSubscription status tracking`, () => { expect(collection._deferSyncStart()).toBe(true) const subscription = collection.subscribeChanges(() => {}, { includeInitialState: false, + deferAcquisition: true, }) + subscription.resumeDeferredAcquisition() const where = new Func(`eq`, [ new PropRef([`id`]), new Value(`requested`), @@ -1490,7 +1492,9 @@ describe(`CollectionSubscription status tracking`, () => { if (start === `deferred`) expect(collection._deferSyncStart()).toBe(true) const subscription = collection.subscribeChanges(() => {}, { includeInitialState: false, + deferAcquisition: start === `deferred`, }) + if (start === `deferred`) subscription.resumeDeferredAcquisition() unsubscribeDuringLoad = () => subscription.unsubscribe() try { diff --git a/packages/db/tests/db-client.test.ts b/packages/db/tests/db-client.test.ts index c3a103af6f..7a371f17e6 100644 --- a/packages/db/tests/db-client.test.ts +++ b/packages/db/tests/db-client.test.ts @@ -376,7 +376,7 @@ describe(`DbClient`, () => { expect(collection.status).toBe(`ready`) }) - it(`defers adapter sync and replays subset loads after hydrated rows render`, () => { + it(`defers adapter sync and replays subset loads after hydrated rows render`, async () => { const lifecycleOrder: Array = [] const descriptor = collectionOptions(`people`, () => ({ id: `people`, @@ -415,12 +415,15 @@ describe(`DbClient`, () => { const collection = client._materializeCollectionForRender(descriptor) const subscription = collection.subscribeChanges(() => {}, { includeInitialState: true, + deferAcquisition: true, }) + subscription.resumeDeferredAcquisition() expect(collection.get(`1`)).toMatchObject({ id: `1`, name: `stale` }) expect(lifecycleOrder).toEqual([]) collection._resumeSyncStart() + await Promise.resolve() expect(lifecycleOrder).toEqual([`sync`, `load`]) expect(collection.get(`1`)).toMatchObject({ id: `1`, name: `fresh` }) diff --git a/packages/react-db/tests/descriptor-query-binding-oracle.test.tsx b/packages/react-db/tests/descriptor-query-binding-oracle.test.tsx index 5042bef2d4..847da9eecc 100644 --- a/packages/react-db/tests/descriptor-query-binding-oracle.test.tsx +++ b/packages/react-db/tests/descriptor-query-binding-oracle.test.tsx @@ -78,9 +78,17 @@ * neighboring Suspense history extends a query during a render that is never * committed. The sync callback count and public rows are checked at those * cuts. Another suspended render leaves a source shared with a later direct - * reader. That reader's preload is an independent demand for data: it may - * start the source even though the earlier render never committed. These - * histories do not cover arbitrary concurrent render schedules. + * reader. An ordinary subscription or preload is independent demand for data: + * it starts the source even though the earlier render never committed. A + * deferring subscription retains the zero-start cut. The finite grammar crosses + * all three readers with startSync true/false and eager/on-demand sources. It + * extends ordinary admission with loading/ready callback cleanup, including + * held resource cleanup. The shared lifecycle judgment predicts adapter entries + * and the complete status trace at admission return and cleanup settlement. + * Later demand restarts a retired source or reuses its existing run. Rows are + * checked only without callback cleanup; on-demand source preload alone does + * not request rows. These histories do not cover arbitrary concurrent render + * schedules or Electric transaction evidence. */ import { act, render, renderHook, waitFor } from '@testing-library/react' import { @@ -101,6 +109,11 @@ import { useLiveQuery } from '../src/useLiveQuery' import { useLiveQueryEffect } from '../src/useLiveQueryEffect' import { useLiveSuspenseQuery } from '../src/useLiveSuspenseQuery' import { mockSyncCollectionOptions } from '../../db/tests/utils' +import { createDeferred } from '../../db/src/deferred' +import { + modelSubscriberStartCleanup, + subscriberStartCleanupHistories, +} from '../../db/tests/collection-subscription-lifecycle-grammar-oracle' import type { Context, DeferredLiveQueryCollections, @@ -253,52 +266,159 @@ describe(`standalone descriptor query binding`, () => { mounted.unmount() }) - it(`lets a direct reader start a source after a descriptor render suspends`, async () => { - const client = new DbClient() - let starts = 0 - const descriptor = collectionOptions(`suspended-direct-reader`, () => ({ - id: `suspended-direct-reader`, - getKey: (row: Row) => row.id, - startSync: true, - sync: { - sync: ({ markReady }) => { - starts++ - markReady() + // A suspended render owns no provider demand. A later independent reader + // does: ordinary subscriptions and preloads start exactly one sync run, + // while a deferring subscription preserves the zero-start cut. Ordinary + // admission also crosses startup cleanup: cleanup retires this start, and + // only fresh later demand can restart it. Both startSync settings must work. + it.each( + ( + [ + { reader: `preload`, startSync: true }, + { reader: `preload`, startSync: false }, + { reader: `subscriber`, startSync: true }, + { reader: `subscriber`, startSync: false }, + { reader: `deferring subscriber`, startSync: true }, + { reader: `deferring subscriber`, startSync: false }, + ] as const + ).flatMap((history) => + ([`eager`, `on-demand`] as const).flatMap((syncMode) => + (history.reader === `subscriber` + ? subscriberStartCleanupHistories + : [subscriberStartCleanupHistories[0]] + ).map((cleanupHistory) => ({ + ...history, + syncMode, + ...cleanupHistory, + })), + ), + ), + )( + `$reader after an abandoned render (startSync=$startSync, $syncMode, cleanup=$cleanupAt, pending=$cleanupPending)`, + async (history) => { + const { reader, startSync, syncMode, cleanupAt, cleanupPending } = history + const expected = modelSubscriberStartCleanup(history) + const pendingCleanup = createDeferred() + const client = new DbClient() + let starts = 0 + let cleanups = 0 + const descriptor = collectionOptions(`suspended-direct-reader`, () => ({ + id: `suspended-direct-reader`, + getKey: (row: Row) => row.id, + startSync, + syncMode, + sync: { + sync: ({ begin, write, commit, markReady }) => { + starts++ + const publish = () => { + begin() + write({ type: `insert`, value: { id: `one`, value: `live` } }) + commit() + return true as const + } + if (syncMode === `eager`) publish() + markReady() + return { + loadSubset: publish, + cleanup: () => { + cleanups++ + return cleanupPending ? pendingCleanup.promise : undefined + }, + } + }, }, - }, - })) - const query = new Query().from({ item: descriptor }) - const never = new Promise(() => {}) + })) + const query = new Query().from({ item: descriptor }) + const never = new Promise(() => {}) - function Suspended(): ReactNode { - useLiveQuery({ query }) - throw never - } + function Suspended(): ReactNode { + useLiveQuery({ query }) + throw never + } - const mounted = render( - - Waiting}> - - - , - ) - const source = client.collection(descriptor) - let directRead: Promise | undefined - try { - expect(mounted.getByText(`Waiting`)).toBeDefined() - expect(starts, `before commit or direct demand`).toBe(0) + const mounted = render( + + Waiting}> + + + , + ) + const source = client.collection(descriptor) + const statuses: Array = [] + let cleanup: Promise | undefined + let cleanupRequested = false + const removeStatusListener = source.on(`status:change`, ({ status }) => { + statuses.push(status) + if (!cleanupRequested && status === cleanupAt) { + cleanupRequested = true + cleanup = source.cleanup() + } + }) + let directRead: Promise | undefined + let subscription: ReturnType | undefined + let peer: ReturnType | undefined + try { + expect(mounted.getByText(`Waiting`)).toBeDefined() + expect(starts, `before commit or direct demand`).toBe(0) - directRead = source.preload() - void directRead.catch(() => {}) - expect(starts, `direct demand starts the source`).toBe(1) - await directRead - expect(source.status).toBe(`ready`) - } finally { - mounted.unmount() - await client.cleanup() - await directRead?.catch(() => {}) - } - }) + if (reader === `preload`) { + directRead = source.preload() + void directRead.catch(() => {}) + } else { + subscription = source.subscribeChanges(() => {}, { + deferAcquisition: reader === `deferring subscriber`, + includeInitialState: true, + }) + } + expect(starts, `direct demand starts the source`).toBe( + reader === `deferring subscriber` ? 0 : expected.starts, + ) + expect(cleanups).toBe(expected.cleanups) + expect(source.status).toBe( + reader === `deferring subscriber` ? `idle` : expected.statusOnReturn, + ) + expect(statuses).toEqual( + reader === `deferring subscriber` ? [] : expected.statusesOnReturn, + ) + pendingCleanup.resolve() + await cleanup + await directRead + expect(source.status).toBe( + reader === `deferring subscriber` + ? `idle` + : expected.statusAfterCleanup, + ) + expect(statuses).toEqual( + reader === `deferring subscriber` + ? [] + : expected.statusesAfterCleanup, + ) + if (cleanupAt === `none`) { + const expectsRow = + reader !== `deferring subscriber` && + (syncMode === `eager` || reader === `subscriber`) + expect(source.get(`one`)?.value).toBe(expectsRow ? `live` : undefined) + } + removeStatusListener() + if (reader !== `deferring subscriber`) { + peer = source.subscribeChanges(() => {}) + expect(starts, `later demand reuses or restarts the source`).toBe( + expected.starts + (cleanupAt === `none` ? 0 : 1), + ) + expect(source.status).toBe(`ready`) + } + } finally { + removeStatusListener() + pendingCleanup.resolve() + await cleanup + peer?.unsubscribe() + subscription?.unsubscribe() + mounted.unmount() + await client.cleanup() + await directRead?.catch(() => {}) + } + }, + ) it(`rejects an unbound descriptor after a same-hash concrete query`, async () => { const id = `standalone-descriptor-unbound-transition` From 2975790568cab6c4834db575fe22904693f669e3 Mon Sep 17 00:00:00 2001 From: Test Date: Thu, 22 Oct 2026 11:15:36 -0600 Subject: [PATCH 02/20] chore(db): add subscription startup fix changeset --- .changeset/resume-independent-subscription-demand.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/resume-independent-subscription-demand.md diff --git a/.changeset/resume-independent-subscription-demand.md b/.changeset/resume-independent-subscription-demand.md new file mode 100644 index 0000000000..1e71865883 --- /dev/null +++ b/.changeset/resume-independent-subscription-demand.md @@ -0,0 +1,5 @@ +--- +'@tanstack/db': patch +--- + +Resume collection sync when a new subscriber follows a suspended render. Preserve cleanup during startup so a retired sync run does not restart. From 75a7d043fee10f50f34ecd4b1f13df918b2bf373 Mon Sep 17 00:00:00 2001 From: Test Date: Thu, 22 Oct 2026 14:05:37 -0600 Subject: [PATCH 03/20] docs: require source reading across oracle skill handoffs --- .agents/skills/oracle-authoring/SKILL.md | 15 ++++++++++++--- .agents/skills/oracle-review/SKILL.md | 15 ++++++++++++--- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/.agents/skills/oracle-authoring/SKILL.md b/.agents/skills/oracle-authoring/SKILL.md index bbbce6fc95..fa4ab0c2d2 100644 --- a/.agents/skills/oracle-authoring/SKILL.md +++ b/.agents/skills/oracle-authoring/SKILL.md @@ -13,9 +13,18 @@ Use this skill whenever an oracle changes, or the production driver, history grammar, or comparison that it uses. This includes a change made while you fix a bug, fix a review finding, or evaluate review findings. -Read [the oracle guide](../../../docs/contributing/oracle-rubric.md), -[the glossary](../../../docs/contributing/glossary.md), and the relevant owner -and limits in [the coverage map](../../../docs/contributing/oracle-coverage.md). +Read [the oracle guide](../../../docs/contributing/oracle-rubric.md) and +[the glossary](../../../docs/contributing/glossary.md) in full, then read the +relevant owner and limits in +[the coverage map](../../../docs/contributing/oracle-coverage.md). + +After a handoff or compaction, reopen required sources whose relevant contents +are absent from the current context. A prior agent's statement that it read +them does not supply their definitions. Apply the glossary's model alignment +rule before naming or renaming a state, action, boundary, or observation. Use +canonical terms and declare abstractions that combine or split production +concepts. Delegated agents must do the reading relevant to their own scope. + Follow the subsystem reading requirements in AGENTS.md. Use the [instrument library](../../../docs/contributing/instruments/index.md) diff --git a/.agents/skills/oracle-review/SKILL.md b/.agents/skills/oracle-review/SKILL.md index 1c3fd118ff..1a47e8c33a 100644 --- a/.agents/skills/oracle-review/SKILL.md +++ b/.agents/skills/oracle-review/SKILL.md @@ -9,9 +9,18 @@ Ask both whether the oracle enforces its stated law and whether that is the right law to promise. A passing test can coexist with a wrong theory; a failing test can coexist with correct production. -Read [the oracle guide](../../../docs/contributing/oracle-rubric.md), -[the glossary](../../../docs/contributing/glossary.md), and the relevant owner -and limits in [the coverage map](../../../docs/contributing/oracle-coverage.md). +Read [the oracle guide](../../../docs/contributing/oracle-rubric.md) and +[the glossary](../../../docs/contributing/glossary.md) in full, then read the +relevant owner and limits in +[the coverage map](../../../docs/contributing/oracle-coverage.md). + +After a handoff or compaction, reopen required sources whose relevant contents +are absent from the current context. A prior agent's statement that it read +them does not supply their definitions. Apply the glossary's model alignment +rule before naming or renaming a state, action, boundary, or observation. Use +canonical terms and declare abstractions that combine or split production +concepts. Delegated agents must do the reading relevant to their own scope. + Follow the subsystem reading requirements in AGENTS.md. Guide conformance is judged by its ORC requirements, not by use of these instruments or headings. From 7f2ad8e12f44c1811d1b557b19b9116435259763 Mon Sep 17 00:00:00 2001 From: Test Date: Thu, 22 Oct 2026 14:09:24 -0600 Subject: [PATCH 04/20] docs: clarify migration review references and terminology --- .../2026-10-22-dbclient-migration-feedback.md | 67 +++++++++++++++++-- 1 file changed, 60 insertions(+), 7 deletions(-) diff --git a/docs/contributing/oracle-reviews/2026-10-22-dbclient-migration-feedback.md b/docs/contributing/oracle-reviews/2026-10-22-dbclient-migration-feedback.md index 99ac5e5b1f..4a8e2d4f14 100644 --- a/docs/contributing/oracle-reviews/2026-10-22-dbclient-migration-feedback.md +++ b/docs/contributing/oracle-reviews/2026-10-22-dbclient-migration-feedback.md @@ -1,6 +1,7 @@ # DbClient migration feedback evaluation -Evaluation date: 2026-10-22. Source: `/Users/kyle.mathews/Downloads/Feedback.txt`. +Evaluation date: 2026-10-22. Source artifact: `Feedback.txt`, identified by its +SHA-256 under [Content identifiers](#content-identifiers). The report describes db 0.15.1, react-db 0.5.11, electric-db-collection 0.6.2, react-router-with-db 0.1.0, and Start 1.169, without a reviewed commit or an application fixture. This evaluation uses fresh `origin/main` @@ -77,9 +78,10 @@ observable synchronous result for transport, or initially render empty/loading unless a result or normalized source snapshot was explicitly supplied. The former needs capture and transport semantics; the latter changes observable server behavior. Merely refusing new source starts does not solve reads from an -already warm source. Capturing only ready eager rows fixes one trace while -missing partial nonready rows and status flags. Suppressing all unregistered -rows would conflict with documented reuse of explicitly normalized sources. +already warm source. Capturing only rows from eager source Collections with +status `ready` fixes one trace. It misses partial nonready rows and status flags. +Suppressing all unregistered rows would conflict with documented reuse of +explicitly normalized sources. The failing diagnostic and passing control are preserved in task scratch as `dbclient-hydration-feedback.probe.test.tsx` and `react-hydration-red.log`. @@ -451,6 +453,57 @@ A focused independent follow-up reviewed the accepted R01 repair and changed judgment/drivers after calibration. It found no residual actionable defect: request-before-release removes the trailing restart; the shared judgment is independent; both drivers check entries, cleanup calls, full status traces at -admission return/settlement, and fresh-demand restart. It verified the 24 core -and 24 React case counts and the loading/held-cleanup distinctions. This was a -read-only source review, not a new broad test run or whole-PR readiness verdict. +admission return/settlement, and fresh-demand restart. It verified the counts +for 24 core cases and 24 React cases, including loading cleanup and held cleanup. +This was a read-only source review, not a new broad test run or whole-PR +readiness verdict. + +## Subsequent CodeRabbit evaluation + +[Review 5618234197](https://github.com/TanStack/db/pull/2229#pullrequestreview-5618234197) +covers `2975790568cab6c4834db575fe22904693f669e3`. The next head +`75a7d043fee10f50f34ecd4b1f13df918b2bf373` changes only skill instructions; +all six reviewed executable hashes still match. The inline comment's newer +commit metadata does not change the review body's stated coverage. + +| ID | Claim or contextual assessment | Disposition | +| --- | --- | --- | +| C01 | LGTM shared cleanup judgment | accepted-design | +| C02 | LGTM ordinary subscriber production order | accepted-design | +| C03 | LGTM db-only patch changeset | accepted-design | +| C04 | LGTM core lifecycle changes and cleanup histories | accepted-design | +| C05 | LGTM subscription driver deferral changes | accepted-design | +| C06 | LGTM client test driver changes | accepted-design | +| C07 | LGTM React receiving grammar | accepted-design | +| C08 | LGTM coverage-map limits | accepted-design | +| C09 | LGTM SSR guide identity, indexes, lifetime | accepted-design | +| C10 | Rewrite 24 core and 24 React case counts | fixed-now | +| C11 | Clarify ready eager rows | fixed-now | +| C12 | Replace machine-local Feedback.txt source path with stable identifier | fixed-now | +| C13 | Mutable summary warns 0% docstring coverage against 80% threshold | accepted-design | +| C14 | Mutable summary says mergeable with documentation follow-up | accepted-design | +| C15 | Mutable summary title check passes | accepted-design | +| C16 | Mutable summary description check passes | accepted-design | +| C17 | Mutable summary linked-issue check skipped | accepted-design | +| C18 | Mutable summary out-of-scope check skipped | accepted-design | + +The three corrections change prose only. The source reference now uses the +existing immutable artifact identifier, whose SHA-256 matches the archived raw +feedback. Counts and Collection status wording retain the original meaning. +Direct source and link checks, plus whitespace validation, are sufficient for +these findings. No production or oracle behavior changed, so no new runtime +RED/GREEN claim is made. + +The docstring warning names no functions and supplies no repository authority +for its 80% threshold. The admission method has JSDoc, and the shared judgment +has adjacent literate contract prose. Preserve that approved documentation +form rather than add redundant comments for an unverified external metric. + +Loss audit: 18 items = 3 fixed-now + 15 accepted-design contextual assessments. +No CodeRabbit finding is deferred or confirmed open. Raw review, inline data, +mutable summary and the detailed ledger are retained in task scratch. Related +PR #2060 is a useful navigation pointer, not new proof of current behavior. +Reviewer recommendation: hire, with low confidence from this review alone. +It supplies three useful documentation corrections and no new runtime +counterexample. The original open hydration decision and typing fixture gap +remain separate; pending CI and a mergeable flag do not establish PR readiness. From 16824f6778bc3feb21c67d4f865c4f444e11f2e4 Mon Sep 17 00:00:00 2001 From: Test Date: Thu, 22 Oct 2026 14:37:18 -0600 Subject: [PATCH 05/20] fix(db): reject subscription admission during cleanup --- .../resume-independent-subscription-demand.md | 4 +- docs/contributing/glossary.md | 2 + docs/contributing/oracle-coverage.md | 17 ++ .../2026-10-22-dbclient-migration-feedback.md | 141 +++++++++ packages/db/src/collection/changes.ts | 2 + packages/db/src/collection/index.ts | 2 + packages/db/src/query/effect.ts | 43 +-- .../collection-cleanup-restart-oracle.test.ts | 282 +++++++++++++++--- 8 files changed, 428 insertions(+), 65 deletions(-) diff --git a/.changeset/resume-independent-subscription-demand.md b/.changeset/resume-independent-subscription-demand.md index 1e71865883..eb0c9926b7 100644 --- a/.changeset/resume-independent-subscription-demand.md +++ b/.changeset/resume-independent-subscription-demand.md @@ -2,4 +2,6 @@ '@tanstack/db': patch --- -Resume collection sync when a new subscriber follows a suspended render. Preserve cleanup during startup so a retired sync run does not restart. +Resume Collection sync when a new subscriber follows a suspended render, while preserving cleanup during startup. + +Reject ordinary subscriptions and deferred acquisition resumption during active cleanup. Effects created during that interval still return disposed handles. diff --git a/docs/contributing/glossary.md b/docs/contributing/glossary.md index e45217b638..89959fc76e 100644 --- a/docs/contributing/glossary.md +++ b/docs/contributing/glossary.md @@ -206,6 +206,8 @@ Use nouns for state and verbs for transitions: - A sync transaction **applies** when its writes and events become visible. - A replay or repair **publishes** one coherent public snapshot. - Cleanup start **invalidates** a sync run and **closes** restart admission. + Ordinary subscription admission and deferred acquisition resumption **throw** + during active cleanup without changing subscriber ownership. Cleanup **settles** after adapter cleanup settles and the Collection publishes `cleaned-up`. Restart then **starts** a new sync run. - Truncate replay **replaces** source state inside the current sync run and diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index 523bdd2de8..0a82fc4351 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -90,6 +90,23 @@ that test identifiers must copy production's private data structures. ### Guide-audit boundary handoffs +The cleanup/restart owner checks ordinary subscription admission and deferred +acquisition resumption during held adapter cleanup. Its eighteen cases cross +loading/ready/error status, eager/on-demand sources, and direct, snapshot, or resumed +admission. The three-cut model requires rejection before subscriber ownership +or acquisition, followed by successful explicit admission after cleanup settles. +Twelve Effect receiving cases cross both source modes, all three prior statuses, and +cleanup on the first or second joined source. A late Effect returns disposed, +reports the cleanup once, and retains no subscription or cleanup observer. A +new Effect after settlement is admitted. The original implementation fails all +eighteen admission cases; a guard limited to fresh subscribers misses six +resumption cases. A guard without earlier Effect cleanup observation fails the +twelve receiving cases during construction. The owner covers these controlled +adapter cuts, with cleanup rejection and terminal-listener restart covered by +its existing histories. It does not establish arbitrary concurrent framework +schedules or actual provider shutdown. The [migration feedback review](oracle-reviews/2026-10-22-dbclient-migration-feedback.md) +records the rejection policy and exact calibration. + The Collection lifecycle history owner includes a 26-cell cleanup-restart matrix. It crosses explicit and default eager sources with absent or present loaders, empty or nonempty replacements, and no interruption, truncate during diff --git a/docs/contributing/oracle-reviews/2026-10-22-dbclient-migration-feedback.md b/docs/contributing/oracle-reviews/2026-10-22-dbclient-migration-feedback.md index 4a8e2d4f14..e5e1fe41e5 100644 --- a/docs/contributing/oracle-reviews/2026-10-22-dbclient-migration-feedback.md +++ b/docs/contributing/oracle-reviews/2026-10-22-dbclient-migration-feedback.md @@ -507,3 +507,144 @@ Reviewer recommendation: hire, with low confidence from this review alone. It supplies three useful documentation corrections and no new runtime counterexample. The original open hydration decision and typing fixture gap remain separate; pending CI and a mergeable flag do not establish PR readiness. + + +## Cleanup admission follow-up — CodeRabbit review 5620262151 + +[Review 5620262151](https://github.com/TanStack/db/pull/2229#pullrequestreview-5620262151) +covers `75a7d043fee10f50f34ecd4b1f13df918b2bf373`. Its runtime code is +identical to the starting head `7f2ad8e12f44c1811d1b557b19b9116435259763`. +This appendix audits the follow-up on that head. The executable content hashes +below identify the tested tree independently of this report's commit. + +### Law, experiment, and repair + +A same-path diagnostic reproduced the admitted-but-idle subscription in eight +cells: loading/ready status, eager/on-demand mode, and both initial-snapshot +options. The adapter cleanup promise remained held when `subscribeChanges` +returned an active subscription. After cleanup settled, the Collection stayed +`cleaned-up` with one subscriber until an explicit restart. The same observations +held with `changes.ts` from merge-base `205ab7158`; this predates the PR. + +The review proposed automatic retry. The public cleanup contract instead closes +restart admission while cleanup is active. On 2026-10-22, the user selected +rejection: “yes reject during cleanup.” This decision applies to ordinary +subscription admission and deferred acquisition resumption. A subscription that +still defers acquisition can retain logical demand without starting source work. +Existing subscriptions can survive cleanup for an explicit restart. An Effect +constructed during active cleanup must still return a disposed handle. + +| Law and authority | Oracle extension and observations | Decisive evidence | +| --- | --- | --- | +| Ordinary admission and deferred resumption reject during active cleanup. Authority: the user decision, `subscribeChanges` contract, and glossary cleanup boundary. | The primary cleanup/restart owner's three-cut model now drives 18 cells: loading/ready/error, eager/on-demand, and plain/snapshot/resumed admission. At the held cut it checks the error, unchanged subscriber ownership and callbacks, zero new sync runs or acquisitions, and retained deferral. After settlement it checks no automatic restart, successful explicit admission, and the replacement row. | Original implementation: 18 admission assertion failures. Omitting only the resume guard: six resumption assertion failures. Final owner: all 70 tests pass. | +| Cleanup start makes an Effect terminal without retaining partial source ownership. Authority: the existing Effect cleanup contract and glossary. | Twelve receiving histories use real `createEffect`: all three prior statuses, both source modes, and cleanup on either joined source. They check a disposed handle, one cleanup report, no batches, no source subscriptions or retained cleanup observers, and successful new construction after settlement. | Guards with the old Effect observer order fail all 12 receiving histories during construction with `CollectionStateError`. The final observer order passes them. | + +The production repair adds the existing lifecycle admission assertion before +ordinary ownership or deferred resumption changes. The Effect cleanup observer +moves before subscription admission. It can retire a late Effect before that +Effect requests forbidden source work. There is no retry queue or new state. +These are two new executable guard statements; the Effect machinery is moved. +Tests and contract prose account for the remaining source growth. + +The old cleanup owner checked start/preload admission and the late Effect +handle, but did not exercise fresh ordinary admission or deferred resumption +while adapter cleanup remained pending. The general subscription grammar models +completed cleanup and explicit restart. R01 checks cleanup during an admission +already in progress. Neither history is this new admission boundary. + +The new oracle retains the independent three-cut judgment. It adds production +paths and finite histories, without changing that model to match production. +A deferred subscription may report its local snapshot before a rejected resume. +The callback recorder preserves that prefix and requires rejection to add no +callbacks. The later row projection deliberately compares only `id` and `rank`; +virtual-field semantics are outside this law. All recorder entries remain +countable, so an extra callback cannot disappear in a final row map. + +### Executed evidence and limits + +Scratch root: `/tmp/tanstack-skill-reading-update/`. It retains the complete raw +review, append-only item ledger, diagnostic source, and logs. Calibration restores +the final source bytes in `finally` and verifies their identity. The original +and missing-resume outcomes are assertion failures at the held admission cut. +The guard-only outcome is a runtime failure after setup at Effect construction; +it is not a timeout or a refinement-assertion kill. + +| Check | Result | Evidence file | +| --- | --- | --- | +| Same-path current and merge-base diagnostics | Eight observed histories pass on each version | cleanup-probe-current.log, cleanup-probe-base.log | +| Original implementation, final 30-cell selection | 18 admission failures, 12 Effect controls pass | cleanup-calibration-original.log | +| Fresh-subscriber-only guard | Six resumption failures, 24 controls pass | cleanup-calibration-no-resume-guard.log | +| Guards without Effect observer reorder | 12 construction failures, 18 admission controls pass | cleanup-calibration-guard-only.log | +| Core and Effect surrounding suites | Ten files, 951 tests pass | cleanup-surrounding.log | +| Collection, DbClient, immutable-demand and receiving query suites | Four files, 224 tests pass | cleanup-additional.log | +| Full React suite | 22 files, 441 tests pass | cleanup-react.log | +| Scoped ESLint and whitespace | No lint errors; diff check passes | Direct command output | +| Production bundle against merge-base 205ab7158 | db +12 gzip / +64 brotli bytes; React unchanged | cleanup-size.log | + +All Vitest runs report no type errors. The complete repository `pnpm test` +command was not run. The ordinary receiving checks use controlled adapter +cleanup and real Collection entry points; the Effect checks use that same +premise through real Effect construction. They establish the stated finite +admission and ownership cuts, not actual provider shutdown or every framework +schedule. The owner already has cleanup rejection and terminal-status restart +histories. Ordinary admission specifically at a terminal-status callback, +multiple overlapping cleanup failures, and arbitrary framework scheduling are +not new coverage here. The primary cleanup/restart owner owns additional core +boundary histories; framework conformance drivers own their host schedules. + +Replay the new finite selection from `packages/db`: + +```sh +pnpm exec vitest run --coverage.enabled=false \ + tests/collection-cleanup-restart-oracle.test.ts \ + -t 'closes .* admission during|releases a late Effect' +``` + +The ten-file command adds the lifecycle history, acquisition cells, subscription +reentrancy, deferred acquisition, sync reentrancy, idle cleanup, ordinary +subscription, Effect, and Effect disposal suites named in the evidence table. +The four-file command selects `collection.test.ts`, `db-client.test.ts`, and +`query/immutable-demand-boundary.test.ts`; Vitest also selects the receiving +`query/live-query-collection.test.ts` by path matching. + +| ORC requirement | Follow-up outcome | +| --- | --- | +| 001 authority and limits | The explicit rejection decision, public cleanup contract, and finite bounds are recorded above and beside the executable law. | +| 002 independent judgment | The reused three-cut model derives admission and terminality from the contract, without importing production classifiers. | +| 003 literate responsibilities | Opening prose, model, case arrays, real drivers, and checks remain distinguishable in the primary owner. | +| 004 generated grammar controls | No new generated property. The fixed products reconstruct the report and add error status, resumption, snapshot, and join-position neighbors. | +| 005 production path and observations | Public admission, callbacks, subscriber counts, statuses, replacement row, Effect handle, and cleanup observer ownership are compared at held and settled cuts. | +| 006 calibration | Original, missing-resume, and guard-only outcomes are classified above. Each reaches its named admission or construction boundary. | +| 007 campaign parity and replay | No new important generated property. The finite selection runs directly with the command above. Existing lifecycle campaigns still run fixed and random seeds. | +| 008 state minimality | No reference-model state was introduced or removed. | +| 009 vocabulary mapping | Existing cleanup-start, settlement, subscription, acquisition, and sync-run concepts are reused. The prior model's dependent abstraction is unchanged. | +| 010 failure fidelity | New drivers use the owner's cleanup aggregator to preserve primary and teardown errors and release every resource. Its existing calibration remains green. | +| 011 second formulation | No concrete shared semantic fault remains after the explicit policy choice; no additional independent formulation is claimed. | +| 012 review evidence | This appendix records applicable outcomes and exact source identities. This is a self-review, not an independent agent review. | +| 013 boundary witness | Held cleanup rejects while explicit admission after settlement succeeds. Ready, loading, and error statuses prevent a ready-only repair from defining the law. | +| 014 receiving handoff | Real Effect construction receives controlled adapter cleanup through the same owner and judgment. No real provider-shutdown or all-framework claim is made. | + +### Loss audit and reviewer assessment + +CR2-01 is the single unique behavioral finding, repeated in generated prompts. +Its admitted-but-idle counterexample is confirmed. Its proposed retry is +superseded by the user's rejection decision. The boundary repair and its finite +oracle coverage are `fixed-now`; no cleanup finding is deferred or left open. +The review body, both repeated prompts, selected-file metadata, and outside-diff +placement were reconciled. There is no associated inline finding. Loss total: +**one raw item = one fixed-now**. The original F02 hydration policy and F07 +application typing fixture remain separate open work. + +Reviewer assessment: hire for counterexample discovery, with low confidence +from one finding. The report supplies a useful public lifecycle cut and a +correct symptom. Its proposed cure lacks contract authority and misses the +Effect guarantee, so its fix guidance needs independent verification. + +Executed source identities (SHA-256): + +| File | SHA-256 | +| --- | --- | +| `packages/db/src/collection/changes.ts` | `f6b2e2fff7b782e1306838ae860e467f21fcaf9aaff5271486bec9e9a5aa4c5d` | +| `packages/db/src/collection/index.ts` | `29bf79f5b702a1bfea91040c86d08b439e99688c5bff10a5b9983f6bd652e11c` | +| `packages/db/src/query/effect.ts` | `0e7cd82b5213edf3666e8793f6bd71ffd1dd9166f7574cbf5c75920c74610f7b` | +| `packages/db/tests/collection-cleanup-restart-oracle.test.ts` | `a87b7b3fad0637a1d3cb7de831d3943304f3c8c4e84c11b558367e35be1f52c6` | diff --git a/packages/db/src/collection/changes.ts b/packages/db/src/collection/changes.ts index 64dcf6d268..00ccbf01b6 100644 --- a/packages/db/src/collection/changes.ts +++ b/packages/db/src/collection/changes.ts @@ -411,6 +411,7 @@ export class CollectionChangesManager< /** A deferring subscription resumed: it may now start this sync run. */ private resumeSubscriber(): void { + this.lifecycle.assertCanStartSync() // Mark first, so a sync run that starts now builds non-deferred demand. this.markSubscriberOrPreload() this.startSyncIfStopped() @@ -439,6 +440,7 @@ export class CollectionChangesManager< * defers its own acquisition, but no other Collection's sync run. */ private addSubscriber(defersAcquisition: boolean): void { + if (!defersAcquisition) this.lifecycle.assertCanStartSync() const previousSubscriberCount = this.activeSubscribersCount this.activeSubscribersCount++ this.lifecycle.cancelGCTimer() diff --git a/packages/db/src/collection/index.ts b/packages/db/src/collection/index.ts index ba6853ff00..e1552fcd0f 100644 --- a/packages/db/src/collection/index.ts +++ b/packages/db/src/collection/index.ts @@ -1159,6 +1159,8 @@ export class CollectionImpl< /** * Subscribe to changes in the collection + * Ordinary subscriptions throw during active cleanup. Wait for cleanup() + * to settle before subscribing or resuming deferred acquisition. * Changes to the same key retain their causal order within a callback. * Changes to different keys have no promised order within a callback. * @param callback - Function called when items change diff --git a/packages/db/src/query/effect.ts b/packages/db/src/query/effect.ts index ac6b8cda63..b5939b6aec 100644 --- a/packages/db/src/query/effect.ts +++ b/packages/db/src/query/effect.ts @@ -569,6 +569,28 @@ class EffectPipelineRunner { const { sourceId, alias, collection } = source const collectionId = collection.id + const handleSourceCleanup = () => { + if (this.disposed) return + this.onSourceError( + new Error( + devBuild() && process.env.NODE_ENV !== `production` + ? `Source collection '${collectionId}' was cleaned up while effect depends on it` + : codedMessage(134, { collectionId }), + ), + ) + } + // Observe cleanup before subscription admission, which rejects while + // cleanup is active. A late Effect still returns its disposed handle. + const cleanupStartUnsubscribe = + collection._onCleanupStart(handleSourceCleanup) + // Registration can dispose the runner before its handle is available. + if (this.isDisposed()) { + cleanupStartUnsubscribe() + this.starting = false + return + } + this.unsubscribeCallbacks.add(cleanupStartUnsubscribe) + this.sentToD2RowsBySource.set(sourceId, new Map()) // Discover dependencies: if source collection is itself a live query @@ -705,17 +727,6 @@ class EffectPipelineRunner { ) } - const handleSourceCleanup = () => { - if (this.disposed) return - this.onSourceError( - new Error( - devBuild() && process.env.NODE_ENV !== `production` - ? `Source collection '${collectionId}' was cleaned up while effect depends on it` - : codedMessage(134, { collectionId }), - ), - ) - } - // Listen for status changes on source collections const statusUnsubscribe = collection.on(`status:change`, (event) => { if (this.disposed) return @@ -750,16 +761,6 @@ class EffectPipelineRunner { } }) this.unsubscribeCallbacks.add(statusUnsubscribe) - const cleanupStartUnsubscribe = - collection._onCleanupStart(handleSourceCleanup) - // Registration reports an already-active cleanup synchronously. That - // callback can dispose this runner before the unsubscribe handle exists. - if (this.isDisposed()) { - cleanupStartUnsubscribe() - this.starting = false - return - } - this.unsubscribeCallbacks.add(cleanupStartUnsubscribe) } // Mark as subscribed so the graph can start running diff --git a/packages/db/tests/collection-cleanup-restart-oracle.test.ts b/packages/db/tests/collection-cleanup-restart-oracle.test.ts index 356f91cbf0..7fcf6d198c 100644 --- a/packages/db/tests/collection-cleanup-restart-oracle.test.ts +++ b/packages/db/tests/collection-cleanup-restart-oracle.test.ts @@ -57,6 +57,11 @@ import type { SyncConfig } from '../src/types' * pending. The Promise settles afterward. The model does not reproduce manager * callbacks or adapter machinery. A status listener may restart at terminal * publication; adjacent event-restart histories exercise that reentry. + * Ordinary subscriptions and resumption of deferred acquisition obey the same + * restart admission boundary. Eighteen histories cross loading/ready/error cleanup, + * eager/on-demand sources, and admission with or without a snapshot or through + * deferred resumption. Rejection retains no new subscriber or acquisition; + * explicit admission after cleanup settlement starts the replacement run. * * The production driver calls the real cleanup, live-query, and Effect entry * points. Refinement checks run before releasing the controlled adapter gate @@ -215,6 +220,129 @@ describe(`Collection cleanup oracle calibration`, () => { }) describe(`Collection cleanup admission oracle`, () => { + // These paths share the three-cut model above. The held adapter promise + // reaches cleanup start without confusing the retained public status with + // permission to start another sync run. No automatic retry is requested. + const subscriberAdmissionCases = ( + [`loading`, `ready`, `error`] as const + ).flatMap((priorStatus) => + ([`eager`, `on-demand`] as const).flatMap((syncMode) => + ([`subscribe`, `snapshot`, `resume`] as const).map((admission) => ({ + priorStatus, + syncMode, + admission, + })), + ), + ) + + it.each(subscriberAdmissionCases)( + `closes $admission admission during $priorStatus $syncMode cleanup`, + async ({ priorStatus, syncMode, admission }) => { + const cleanupGate = createDeferred() + let starts = 0 + let acquisitions = 0 + let callbackCount = 0 + const subscriberCounts: Array = [] + const rows = new Map() + let publish = () => {} + const collection = createCollection({ + getKey: (row) => row.id, + syncMode, + sync: { + sync: ({ begin, write, commit, markReady, markError }) => { + starts++ + publish = () => { + begin() + write({ type: `insert`, value: { id: 1, rank: starts } }) + commit() + } + if (priorStatus === `ready` || starts > 1) markReady() + if (priorStatus === `error` && starts === 1) { + markError(new Error(`initial source error`)) + } + return { + cleanup: () => cleanupGate.promise, + loadSubset: () => { + acquisitions++ + return true + }, + } + }, + }, + }) + let subscription: + ReturnType | undefined + const subscribe = () => { + subscription = collection.subscribeChanges( + (changes) => { + callbackCount++ + for (const change of changes) { + if (change.type === `delete`) rows.delete(change.key) + else { + const { id, rank } = change.value + rows.set(change.key, { id, rank }) + } + } + }, + { + includeInitialState: admission !== `subscribe`, + deferAcquisition: admission === `resume`, + }, + ) + } + const admit = () => { + if (admission === `resume`) subscription!.resumeDeferredAcquisition() + else subscribe() + } + const off = collection.on(`subscribers:change`, ({ subscriberCount }) => { + subscriberCounts.push(subscriberCount) + }) + + await withOracleCleanup(async () => { + collection.startSyncImmediate() + const cleanup = collection.cleanup() + // A deferring subscription starts no source work and may keep its + // logical demand. Only its attempted resumption must be rejected. + if (admission === `resume`) subscribe() + // Deferred admission may report its local snapshot. The rejected + // resumption must add no callback to that already-observed prefix. + const callbacksBeforeAdmission = callbackCount + const atStart = expectedCleanupBoundary(priorStatus, `cleanup-start`) + expect(collection.status).toBe(atStart.collectionStatus) + expect(observeRestartAdmission(admit)).toBe(atStart.restartAdmission) + expect(collection.subscriberCount).toBe(0) + expect(subscriberCounts).toEqual([]) + expect(callbackCount).toBe(callbacksBeforeAdmission) + expect(acquisitions).toBe(0) + expect(starts).toBe(1) + if (admission === `resume`) { + expect(subscription!.isDeferringAcquisition()).toBe(true) + } + + cleanupGate.resolve() + await cleanup + const settled = expectedCleanupBoundary( + priorStatus, + `cleanup-settlement`, + ) + expect(collection.status).toBe(settled.collectionStatus) + expect(starts).toBe(1) + expect(collection.subscriberCount).toBe(0) + expect(callbackCount).toBe(callbacksBeforeAdmission) + expect(observeRestartAdmission(admit)).toBe(settled.restartAdmission) + expect(collection.subscriberCount).toBe(1) + expect(starts).toBe(2) + publish() + expect([...rows.values()]).toEqual([{ id: 1, rank: 2 }]) + }, [ + () => cleanupGate.resolve(), + () => subscription?.unsubscribe(), + off, + () => collection.cleanup(), + ]) + }, + ) + it(`calibrates every declared bounded-product cell exactly once`, () => { expectExactCaseIds(reentryScenarios, [ `abort:plain:1`, @@ -799,54 +927,122 @@ describe(`Collection cleanup admission oracle`, () => { } }) - it(`releases an Effect cleanup-start observer registered during active cleanup`, async () => { - const cleanupGate = createDeferred() - const source = createCollection({ - getKey: (row) => row.id, - sync: { - sync: ({ markReady }) => { - markReady() - return { cleanup: () => cleanupGate.promise } + const lateEffectCases = ([`loading`, `ready`, `error`] as const).flatMap( + (priorStatus) => + ([`eager`, `on-demand`] as const).flatMap((syncMode) => + ([`first`, `second`] as const).map((cleaningPosition) => ({ + priorStatus, + syncMode, + cleaningPosition, + })), + ), + ) + + // Effects must retire at cleanup start, including when construction reaches + // a source whose adapter cleanup is already pending. The same boundary model + // requires a terminal dependent without partial subscription ownership. + it.each(lateEffectCases)( + `releases a late Effect during $priorStatus $syncMode cleanup of its $cleaningPosition source`, + async ({ priorStatus, syncMode, cleaningPosition }) => { + const cleanupGate = createDeferred() + let starts = 0 + const source = createCollection({ + getKey: (row) => row.id, + syncMode, + sync: { + sync: ({ markReady, markError }) => { + starts++ + if (priorStatus === `ready` || starts > 1) markReady() + if (priorStatus === `error` && starts === 1) { + markError(new Error(`initial source error`)) + } + return { + cleanup: () => cleanupGate.promise, + loadSubset: () => true, + } + }, }, - }, - }) - const registerCleanupStart = source._onCleanupStart.bind(source) - let activeCleanupStartObservers = 0 - source._onCleanupStart = (callback) => { - activeCleanupStartObservers++ - const unsubscribe = registerCleanupStart(callback) - let active = true - return () => { - if (!active) return - active = false - activeCleanupStartObservers-- - unsubscribe() + }) + const healthySource = createCollection({ + getKey: (row) => row.id, + sync: { sync: ({ markReady }) => markReady() }, + }) + source.createIndex((row) => row.id, { indexType: BasicIndex }) + healthySource.createIndex((row) => row.id, { indexType: BasicIndex }) + const registerCleanupStart = source._onCleanupStart.bind(source) + let activeCleanupStartObservers = 0 + source._onCleanupStart = (callback) => { + activeCleanupStartObservers++ + const unsubscribe = registerCleanupStart(callback) + let active = true + return () => { + if (!active) return + active = false + activeCleanupStartObservers-- + unsubscribe() + } } - } - let effect: ReturnType | undefined - try { - await source.preload() - const cleanup = source.cleanup() - - effect = createEffect({ - query: (q) => q.from({ row: source }), - onBatch: () => {}, - onSourceError: () => {}, - }) + const firstSource = cleaningPosition === `first` ? source : healthySource + const secondSource = + cleaningPosition === `second` ? source : healthySource + const errors: Array = [] + let batches = 0 + const create = () => + createEffect({ + query: (q) => + q + .from({ first: firstSource }) + .join( + { second: secondSource }, + ({ first, second }) => eq(first.id, second.id), + `inner`, + ), + onBatch: () => { + batches++ + }, + onSourceError: (error) => { + errors.push(error.message) + }, + }) + let effect: ReturnType | undefined + await withOracleCleanup(async () => { + source.startSyncImmediate() + const cleanup = source.cleanup() + effect = create() + const atStart = expectedCleanupBoundary(priorStatus, `cleanup-start`) + expect(observeDependent(!effect.disposed)).toBe(atStart.dependent) + expect(source.status).toBe(atStart.collectionStatus) + expect(source.subscriberCount).toBe(0) + expect(healthySource.subscriberCount).toBe(0) + expect(activeCleanupStartObservers).toBe(0) + expect(batches).toBe(0) + expect(errors).toEqual([ + `Source collection '${source.id}' was cleaned up while effect depends on it`, + ]) - expect(effect.disposed).toBe(true) - expect(source.subscriberCount).toBe(0) + cleanupGate.resolve() + await cleanup + expect(effect.disposed).toBe(true) + expect(source.subscriberCount).toBe(0) + expect(healthySource.subscriberCount).toBe(0) + // A later, explicit Effect construction is allowed. The retired Effect + // stays disposed; neither cleanup settlement nor restart revives it. + effect = create() + expect(effect.disposed).toBe(false) + expect(source.subscriberCount).toBe(1) + expect(healthySource.subscriberCount).toBe(1) + expect(activeCleanupStartObservers).toBeGreaterThan(0) + expect(errors).toHaveLength(1) + }, [ + () => cleanupGate.resolve(), + () => effect?.dispose(), + () => source.cleanup(), + () => healthySource.cleanup(), + ]) expect(activeCleanupStartObservers).toBe(0) - - cleanupGate.resolve() - await cleanup - } finally { - cleanupGate.resolve() - await effect?.dispose() - await source.cleanup() - } - }) + }, + ) it.each([`first`, `second`] as const)( `does not acquire live-query source subscriptions during active cleanup: %s source`, From da37bc9bbafbed4b2cc901adcbad9181b0aeb93d Mon Sep 17 00:00:00 2001 From: Test Date: Thu, 22 Oct 2026 16:23:37 -0600 Subject: [PATCH 06/20] fix(db): preserve subscription ownership across cleanup and resumption --- .../resume-independent-subscription-demand.md | 2 + docs/contributing/oracle-coverage.md | 29 ++- .../2026-10-22-dbclient-migration-feedback.md | 183 +++++++++++++++ packages/db/mangle-cache.json | 3 +- packages/db/src/collection/changes.ts | 21 +- packages/db/src/collection/lifecycle.ts | 10 +- packages/db/src/collection/subscription.ts | 12 +- packages/db/src/live-query-observer.ts | 30 +-- packages/db/src/live-query-options.ts | 5 + packages/db/src/query/pooled-live-query.ts | 32 ++- .../collection-cleanup-restart-oracle.test.ts | 147 +++++++++++- ...n-subscription-lifecycle-grammar-oracle.ts | 29 +++ ...tion-subscription-lifecycle-oracle.test.ts | 106 ++++++++- packages/db/tests/db-client.test.ts | 6 +- ...-query-deferred-acquisition-oracle.test.ts | 73 +++++- ...y-observer-history-oracle.property.test.ts | 218 +++++++++++++++++- .../query/pooled-live-query-gc-oracle.test.ts | 158 ++++++++++++- .../descriptor-query-binding-oracle.test.tsx | 22 +- .../tests/useLiveSuspenseQuery.test.tsx | 4 +- 19 files changed, 1021 insertions(+), 69 deletions(-) diff --git a/.changeset/resume-independent-subscription-demand.md b/.changeset/resume-independent-subscription-demand.md index eb0c9926b7..863b6abbc5 100644 --- a/.changeset/resume-independent-subscription-demand.md +++ b/.changeset/resume-independent-subscription-demand.md @@ -5,3 +5,5 @@ Resume Collection sync when a new subscriber follows a suspended render, while preserving cleanup during startup. Reject ordinary subscriptions and deferred acquisition resumption during active cleanup. Effects created during that interval still return disposed handles. + +Release partial observer and pooled-query subscriptions when setup fails or cleanup interrupts startup. Resumed subscriptions release abandoned render deferrals without restoring retired ownership. Admission checks retain an existing cleanup promise until a replacement sync run starts. diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index 0a82fc4351..2251f2f49a 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -107,6 +107,33 @@ its existing histories. It does not establish arbitrary concurrent framework schedules or actual provider shutdown. The [migration feedback review](oracle-reviews/2026-10-22-dbclient-migration-feedback.md) records the rejection policy and exact calibration. +The eight-item follow-up at reviewed head `16824f677` extends the same owners. +The cleanup/restart owner adds six plain/live-query logical-demand cases at +idle, ready, and cleaned-up cleanup start, plus six admission outcomes that +distinguish a pending cleanup promise from an actual replacement sync run. +The deferred-acquisition owner adds render-deferred eager/on-demand sources and +explicit deferred subscription/resumption histories across both query shapes +and one/two live-query levels. The React descriptor driver receives resumption +after a real abandoned suspended render through the existing startup judgment. +The observer-history owner adds eight failed-setup histories, with both modes, +one/two failures, cleanup rejection or real sync-start failure. Client, status, +and layout leases are checked immediately, then retry, publication and release. +Two late-listener seed failures retain a healthy peer and exclude the failed +listener from later publications. Eight stream-preload histories cross pending +or successful streams, both source modes and a render deferral. The real React +streaming test receives the on-demand premise while retaining server rows until +browser acquisition completes. +The pooled release owner adds twelve new/retained/released partition histories +through held loading/ready cleanup and two startup-cleanup reentry histories. +The subscription boundary owner adds 28 resumption/retirement histories crossing +loading, ready and acquiring notifications, cleanup, source mode and render gate. +The shared token-count model forbids restoring a retired owner. Deferred-demand +cancellation fixtures now create logical demand before explicit resumption; +render-owned queued loads remain covered by the DbClient fixture. These finite +extensions do not establish arbitrary async/provider schedules or new framework +error policy. The [follow-up audit](oracle-reviews/2026-10-22-dbclient-migration-feedback.md) +records original RED, hostile controls, and the unchanged rejection decision. + The Collection lifecycle history owner includes a 26-cell cleanup-restart matrix. It crosses explicit and default eager sources with absent or present loaders, empty or nonempty replacements, and no interruption, truncate during @@ -691,7 +718,7 @@ generic visitor behavior outside the named collectors. | Joined result keys | [Joined result key oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/query/join-result-key-oracle.property.test.ts) | A nested-loop model recomputes the pair set of an inner, left, or full join over source keys drawn from plain, comma-bearing, bracket-bearing, and quoted strings, numbers beside the strings that print the same, both infinities, and `NaN`. After preload and each synced group change, the published rows must equal the model's pairs and the result key count must equal the row count. The comma-joined key encoding fails its two pinned histories and both campaigns; plain `JSON.stringify`, which prints infinities as the missing-side `null`, fails the pinned infinity history and both campaigns. A pinned history in which a `NaN`-keyed pair leaves and re-forms fails when the join index compares source-key prefixes with `===`. Joins over subqueries, more than two sources, custom `getKey`, and optimistic mutations are outside this owner. It runs in `@tanstack/db`'s `test:oracles` campaign. The [2026-09-30 review](https://github.com/TanStack/db/blob/main/docs/contributing/oracle-reviews/2026-09-30-where-predicate-and-join-keys.md) records each ORC outcome. | | D2 Index storage | [Index refinement oracle](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/index-refinement-oracle.property.test.ts) | A plain-`Map` model sums multiplicities under a type-and-value identity in which `NaN` equals `NaN` and `-0` equals `0`. Histories of up to twelve additions to two keys mix prefixed arrays with `NaN`, `0`, `-0`, `1`, `'1'`, `1n`, and `'a'` prefixes and unprefixed values, with cancellation and reappearance. After each addition, `get` and `has` must equal the model. Each step also checks that `get` returns what `getIterator` yields, in its order, since the two walk the storage separately. Comparing prefixes with `===`, which disagrees with the `Map` that holds them, fails three pinned histories and both campaigns; the `-0` control passes under both. Every history also runs with `prefixIdentity`, where values under one key with the same prefix are one value; mutants that merge unprefixed values or ignore the option in a prefix map fail. The [structural identity oracle](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/structural-identity-oracle.property.test.ts) checks that `structuralIdentity` agrees with D2 value identity for equal representations (array subclasses, class instances, null prototypes, key order, non-enumerable symbols) and independent values, including separator-bearing strings and keys, enumerable symbol keys, Dates, RegExps with numeric `lastIndex`, Maps and Sets with zero to two entries (the second Set member is a fresh tagged array, so primitive deduplication and unwrapped reordering are outside this lane), arrays with an optional enumerable `note` field, binary values, holes, deep nesting, and a cyclic value; an independent normal-form model decides equality and is checked against `equalHashValues`. The model expands `deep` shorthand into nested arrays. A pinned equal `deep`/array pair and adjacent-depth control guard the model boundary exposed by seed 1158727847/path 255. The equal-pair grammar crosses same-spec values with explicit-array/`deep` equivalent specs; the independent-pair grammar uses two specs. Both run fixed and fresh campaigns with guarded one-case seed/path replay. Pinned same-description symbols and equal-body Map/Set/object pairs expose collisions that random independent pairs rarely reach. Mutants that stringify symbols, collapse those three object-kind tags, drop a string's or key's length or an array's length, or omit Date and RegExp headers fail it. A first-entry-only Map/Set mutant and an array-identity mutant that ignores enumerable non-index fields fail the pinned comparisons; lowering the depth cap is equivalent, because the cap only bounds work. Open: reference-valued RegExp `lastIndex` can give D2-unequal values one defined identity and merge `groupBy` contributions ([#2192](https://github.com/TanStack/db/issues/2192)); the structural-identity and incrementalization owners need a production repair and receiving witness. Temporal values, other opaque reference values, large binary values, getters/proxies, and mutation between observations are outside this generated grammar. groupBy's use of it is owned by the incrementalization law: its string-extrema lane fails a string identity without a length prefix, and a focused groupBy witness fails when the group key is left out of the identity. Compaction, presence tracking, and structural payloads are outside this owner; a focused test checks that an index built without presence (as reduce builds its indexes) throws when presence is read; the [reduce fast-path oracle](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/operators/reduce-fast-path-oracle.property.test.ts) compares the fast path's emitted entries one by one with the general path's, over keys that move between the paths, hash-merged outputs, -0, and zero and negative multiplicities, while an unchanged `NaN` output is owned by a focused reduce test; the join operator tests and incrementalization law own operator behavior. | | Pooled live queries | [pooled live query oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/query/pooled-live-query-oracle.property.test.ts) | An independent `eq` model (nullish UNKNOWN, Date as timestamp, `NaN` equal to `NaN`, `-0` equal to `0`) gives membership; a live-query Collection compiled for the same query gives order, values, and status. Histories mix sync transactions, optimistic writes confirmed or rolled back, peer mounts and unmounts, and source cleanup and restart, including cleanup while a write is pending and a mount between cleanup and restart. Wholesale observers must match after every step, granular changes must match a reference observer by type, key, value, and previous value and replay to the model's rows, the layout revision must advance when keys reorder, and observing must never build the live-query Collection. Peers may add a residual `not(eq(...))` conjunct, which the model evaluates itself; a view that ignores it, reports an unfiltered `entries()`, or delivers a row entering or leaving it as an update fails it. Other residual operators use the same compiler evaluator but are not generated. Peers may also order their rows by `id` or by a `g` that can be null, with explicit `nulls`; order comes from the reference. A comparator that ignores direction or `nulls`, an order left out of the partition key, and key order instead of the query's order each fail the pinned reorder history and both campaigns. A partition that ignores a row's previous group, orders by arrival, skips normalization, skips the initial snapshot, follows the source's status after cleanup, or never terminates fails it. Two shared conformance scenarios (`eq-filter-rows`, `eq-filter-peers`) run the pooled path under React, Vue, Solid, Svelte, and Angular; a partition that ignores a row's previous group fails both under every adapter, and `eq-filter-peers` checks through `subscriberCount` that each adapter shares one source subscription per partition while mounted and releases every source subscription after the final framework unmount. A focused [release-timing test](https://github.com/TanStack/db/blob/main/packages/db/tests/query/pooled-live-query-gc-oracle.test.ts) compares when a pooled query and a live-query Collection release their sources across `gcTime` 1, 100, 0, and `Infinity`, with and without a subscriber, and with no `gcTime`, where both use the live-query Collection default, checks that a partition waits for its views' longest `gcTime`, checks that a view resubscribed after its partition released follows later source writes as its live-query Collection does, including a view with a residual conjunct, and checks that a partition that subscribes again still serves new mounts from one source subscription. Further focused witnesses check that a partition keeps groups only for values that have rows or watchers, that a detached reader never sees a dropped group's revision reused, keep a pooled query's public Collection live while the view is observed, accept it as a query source, and check that a pooled query turns terminal when source cleanup starts while the adapter's cleanup is still pending, as a live-query Collection does. A pinned oracle witness checks that an `eq` path whose getter throws excludes the row on both paths; a pooled path that rethrows fails it. The pooled oracle's grammar never releases a partition, so release and resubscribe histories rely on these fixed witnesses; a fixed delay, a missing unsubscribed floor, a last-`gcTime`-wins rule, and releasing at `gcTime` 0 each fail it. Removing the partition's final-listener release survives the mounted row checks but fails the shared framework unmount checkpoint and core release-timing owner. The [2026-10-01 review](oracle-reviews/2026-10-01-pooled-live-queries.md) records each ORC outcome. | -| Live-query deferred acquisition | [deferred acquisition oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/live-query-deferred-acquisition-oracle.test.ts), [React Suspense warm-cache witness](https://github.com/TanStack/db/blob/main/packages/react-db/tests/suspense-warm-cache.test.tsx) | An independent model of the law that a live-query Collection starts no provider work before a subscriber or a preload in its current sync run crosses five source states (eager or on-demand, idle or already started, and an on-demand source that already holds the rows), an unordered query and an ordered window, one or two live-query levels, and seven histories: start and read, subscribe or preload after a start, subscribe or preload without one, cleanup before a subscriber, and restart after a run that had one. After every command it checks source sync-run starts, acquisition attempts, readiness when the call returns (the synchronous observation cut), settled readiness, and rows. A pooled block checks that building a pooled view starts no idle eager source until a view subscribes or preloads, and a DbClient block checks that a preload answered by a pending or settled stream still resumes acquisition. Mutants that never defer, keep the subscriber-or-preload fact across cleanup, ignore preload, never resume a builder's subscriptions, let a deferring subscriber start a base source, build a pooled partition without deferral, never resume it, mark a preload one microtask late, or drop the stream's preload mark each fail it. The React witness checks that `useLiveSuspenseQuery` over a source that already holds the rows renders without the fallback, for eager and on-demand sources and both shapes; creating the Suspense collection started, or not preloading it in render, fails it. A later review added `peer` and `truncate` commands: a peer that starts an idle on-demand source, or a truncate on a running one, made the live query acquire before any subscriber, and both guards are killed separately. Pinned blocks check that a subscriber surviving cleanup is served after a restart while a surviving deferring subscriber is not, and that `toArrayWhenReady()` and `stateWhenReady()` count as a preload. A pinned block checks that a deferring live query is not counted in its source's `subscriberCount` or `subscribers:change` until its first subscriber, while still keeping the source from garbage collection; a Query Collection test checks that it causes no refetch. Another pinned block checks that a preload or subscriber requested by an inner live query's status handler, while the outer query creates its subscription to it, still resumes acquisition. Solid's Suspense receiving witnesses check that a resource whose preload settles first retains its live-query Collection while its sibling remains pending past the unsubscribed floor, then releases that temporary owner on both commit and abandonment, and that a preload settling after its owner unmounts cannot publish an error through the abandoned result. Open: source failure and asynchronous loads before resumption, which the subscription lifecycle oracle owns once acquisition resumes; a resume that throws, which leaves later sources unstarted only for a live query that is already in error; asynchronous sources; and Suspense with a `DbClient`, where render already defers source starts. | +| Live-query deferred acquisition | [deferred acquisition oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/live-query-deferred-acquisition-oracle.test.ts), [React Suspense warm-cache witness](https://github.com/TanStack/db/blob/main/packages/react-db/tests/suspense-warm-cache.test.tsx) | An independent model of the law that a live-query Collection starts no provider work before a subscriber or a preload in its current sync run crosses seven source states (eager or on-demand, idle, already started or render-deferred, and an on-demand source that already holds the rows), an unordered query and an ordered window, one or two live-query levels, and eleven histories: start and read, subscribe or preload after a start, subscribe or preload without one, deferred subscription and resumption with or without a start, cleanup before a subscriber, peer start, source truncate, and restart after a run that had one. After every command it checks source sync-run starts, acquisition attempts, readiness when the call returns (the synchronous observation cut), settled readiness, and rows. A pooled block checks that building a pooled view starts no idle eager source until a view subscribes or preloads, and eight DbClient histories check that a preload answered by a pending or settled stream still resumes acquisition across eager/on-demand idle and render-deferred sources. The React streaming witness receives the on-demand render-deferral premise, starts local acquisition before the stream settles, and retains server rows until browser acquisition completes. Mutants that never defer, keep the subscriber-or-preload fact across cleanup, ignore preload, never resume a builder's subscriptions, let a deferring subscriber start a base source, build a pooled partition without deferral, never resume it, mark a preload one microtask late, or drop the stream's preload mark each fail it. The React witness checks that `useLiveSuspenseQuery` over a source that already holds the rows renders without the fallback, for eager and on-demand sources and both shapes; creating the Suspense collection started, or not preloading it in render, fails it. A later review added `peer` and `truncate` commands: a peer that starts an idle on-demand source, or a truncate on a running one, made the live query acquire before any subscriber, and both guards are killed separately. Pinned blocks check that a subscriber surviving cleanup is served after a restart while a surviving deferring subscriber is not, and that `toArrayWhenReady()` and `stateWhenReady()` count as a preload. A pinned block checks that a deferring live query is not counted in its source's `subscriberCount` or `subscribers:change` until its first subscriber, while still keeping the source from garbage collection; a Query Collection test checks that it causes no refetch. Another pinned block checks that a preload or subscriber requested by an inner live query's status handler, while the outer query creates its subscription to it, still resumes acquisition. Solid's Suspense receiving witnesses check that a resource whose preload settles first retains its live-query Collection while its sibling remains pending past the unsubscribed floor, then releases that temporary owner on both commit and abandonment, and that a preload settling after its owner unmounts cannot publish an error through the abandoned result. Open: source failure and asynchronous loads before resumption, which the subscription lifecycle oracle owns once acquisition resumes; a resume that throws, which leaves later sources unstarted only for a live query that is already in error; asynchronous sources; and arbitrary Suspense schedules with a `DbClient` beyond the bounded abandoned-render and streaming handoffs recorded above. | | Live-query notification algebra | [real-query and checker calibration](https://github.com/TanStack/db/blob/main/packages/db/tests/notification-laws-oracle.test.ts), [independent Map checker](https://github.com/TanStack/db/blob/main/packages/db-collection-e2e/src/utils/notification-laws-oracle.ts) | One-key synchronous source histories check exact live-query callback reconstruction and unsubscribe silence. Synthetic multi-key histories calibrate the checker; direct source Collection events remain with the change-event history owner. | | Flat-row change tracking | [flat change tracking oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/flat-change-tracking-oracle.property.test.ts) | Generated callbacks over flat rows, frozen or not and with or without a non-enumerable field (assignments of `0`, `-0`, `NaN`, `undefined`, functions, and fresh objects; reverts; deletions; added fields; data and accessor `defineProperty`; stored drafts; throws), run through the flat tracker, the draft proxy, and an independent model, which must report the same change sets with `0` and `-0` equal, or throw the same error and leave the rows unchanged. Defining a field acts as assigning it, and a non-enumerable field is row data only once written; the proxy broke three of those laws on `main` and the flat tracker one. Diffs using `!==`, `Object.is` alone, or no deletions fail it. It found that the proxy dropped a field added as `undefined` when another field reverted; the pinned history and both campaigns fail without that fix. Non-flat rows must fall back. The [2026-10-01 review](oracle-reviews/2026-10-01-pooled-live-queries.md) records each ORC outcome. | | Virtual props cache | [cache laws](https://github.com/TanStack/db/blob/main/packages/db/tests/virtual-props-cache.test.ts) | Focused laws for the per-key cache of rows read with virtual props: a change's published value is the row later `get` and `toArray` reads return, for every subscriber, after sync and local writes, and a key that is deleted or rolled back leaves the cache. Enriching the value before the previous value fails the first two; skipping the drop on delete events fails the third. Rows read with virtual props through other paths, such as joins, are outside these laws. | diff --git a/docs/contributing/oracle-reviews/2026-10-22-dbclient-migration-feedback.md b/docs/contributing/oracle-reviews/2026-10-22-dbclient-migration-feedback.md index e5e1fe41e5..97e15e0da1 100644 --- a/docs/contributing/oracle-reviews/2026-10-22-dbclient-migration-feedback.md +++ b/docs/contributing/oracle-reviews/2026-10-22-dbclient-migration-feedback.md @@ -648,3 +648,186 @@ Executed source identities (SHA-256): | `packages/db/src/collection/index.ts` | `29bf79f5b702a1bfea91040c86d08b439e99688c5bff10a5b9983f6bd652e11c` | | `packages/db/src/query/effect.ts` | `0e7cd82b5213edf3666e8793f6bd71ffd1dd9166f7574cbf5c75920c74610f7b` | | `packages/db/tests/collection-cleanup-restart-oracle.test.ts` | `a87b7b3fad0637a1d3cb7de831d3943304f3c8c4e84c11b558367e35be1f52c6` | + +## Eight-item review of 16824f677 — follow-up evaluation + +The user supplied eight deduplicated, unverified findings against PR #2229 at +`16824f6778bc3feb21c67d4f865c4f444e11f2e4`. The reviewer used medium effort and +reported no clear AGENTS.md violations. Those statements describe provenance, +not correctness evidence. This appendix records a self-review and executed +experiments. The earlier appendices remain historical records. + +### Laws, experiments, and inference + +The review exposed incomplete ownership handling around a deliberate rejection +boundary. It also exposed a separate resumption defect. The user's decision, +“yes reject during cleanup,” remains authoritative. Automatic admission and +restart would revise that contract. It would not establish failure atomicity +for other setup errors. + +| Law and authority | Existing coverage and precise gap | Experiment and repair | +| --- | --- | --- | +| Explicit demand can start a source independently of an abandoned render. Authority: subscriber/preload contract and the deferred-acquisition owner's opening law. | Ordinary admission released the render gate. The deferred-acquisition grammar lacked render-deferred source states and explicit subscription resumption. Stream preloads covered only an eager source without that gate. | R03 fails with zero source starts. The grammar now crosses seven source states, two query shapes, one/two levels and eleven histories. Both ordinary admission and resumption use the existing request-then-release ordering. Eight stream histories cross pending/success, eager/on-demand and idle/render-deferred. Omitting release only from resumption fails all four render-deferred stream cases; the four idle controls pass. | +| A failed subscription call transfers no listener ownership because it returns no release handle. Authority: observer subscribe/dispose contract. | The eligibility ledger covered publication and reentry after successful registration. It did not fail source admission during attachment or inspect leases before cleanup. | R07 leaves failed records and client/status/layout leases. Eight histories cross mode, cleanup/startup failure and one/two attempts. They inspect leases immediately, then explicit retry, publication and release. Two granular late-seed histories keep a healthy peer. Shared release removes only the failed record and detaches channels when the last owner leaves. A detach-only mutant fails all ten ownership histories. Synchronous setup errors still propagate. | +| Source cleanup makes a dependent pooled live query terminal and releases partial ownership. Authority: pooled/live-query lifecycle equivalence and glossary cleanup start. | The row grammar never releases a partition. The release owner covered reattachment and pending cleanup separately, but not their intersection or cleanup during startup before assignment. | R02 fails on a released partition reattaching during held cleanup. Twelve histories cross new/retained/released ownership, both observer modes and loading/ready cleanup. Two startup reentry histories check total logical ownership as well as public acquiring count. Observe cleanup before subscribing, release on failure, and unsubscribe a handle returned after synchronous retirement. | +| Cleanup callers share one operation until a replacement sync run actually starts. Authority: public cleanup promise and restart contract. | Admission validation also cleared cleanup ownership. A successful replacement was indistinguishable from a deferred request or a failed first-demand notification. | R05's successful subscriber legitimately starts a replacement. Its narrower defect appears when no replacement starts. Six outcomes compare promise identity, adapter entries and cleanup counts before settlement. Validation is now pure; entering loading transfers cleanup ownership to the actual replacement run. | +| Deferred logical demand starts no source work during cleanup. Authority: deferred-acquisition contract and the approved ordinary-admission rejection policy. | R06 was absent from the cleanup grammar: idle/cleaned-up cleanup-start callbacks could subscribe to a plain Collection, but a live-query Collection attempted local startup and threw. | Six histories cross plain/live-query and idle/ready/cleaned-up cleanup. Ordinary admission and resumption reject at the callback cut; logical demand stays deferred, then resumes explicitly after settlement. Local live-query startup now waits while cleanup is active. A universal rejection of deferred demand would violate this neighboring law. | +| Resumption cannot restore a retired subscription. Authority: subscription ownership and unsubscribe contract. | Releasing the render gate makes startup callbacks reachable during resumption. The old ownership increment followed those callbacks even when they unsubscribed. | S02 adds 28 histories from an independent token-count judgment: seven retirement/cleanup forms, both modes and both render-gate states. Start, acquisition count and the complete subscriber-count trace are checked at return and after settlement. Resumption commits its flag before notification and checks retirement on both sides. Removing the post-start guard fails 16 lifecycle and two pooled reentry assertions. | +| A deferred-source handoff preserves startup error identity and releases healthy peers. Authority: existing DbClient failure/peer contract. | After R03, independent demand may consume the render gate before the later handoff. The existing source-failure tests then exposed a lost error. | S01 preserves the current Collection sync error after the render release is already consumed. Existing exact-error, failed-stream replacement and healthy-peer witnesses pass. No automatic retry or new lifecycle state is introduced. | + +These repairs preserve cleanup rejection, terminal dependents, render-only +zero-work behavior, independent preload demand, publication eligibility and +explicit restart. The laws are encoded and reached at the listed observation +cuts within their finite domains. No known reachable counterexample remains +within these declared repair boundaries. This is not a proof over arbitrary +provider, callback or framework schedules. + +The receiving React driver adds deferred subscription resumption to the real +abandoned Suspense grammar. It reuses the core startup/cleanup judgment and +checks adapter entries, complete status traces, cleanup settlement and later +demand. Its 64 tests pass. A full-suite failure also exposed an older conflicting +expectation in `useLiveSuspenseQuery.test.tsx`: it expected no browser acquisition +while a server stream was pending. The established stream-preload law predicts +one local start, independently of stream availability. The corrected assertion +requires that start. The unchanged later assertions require the server row +until browser acquisition completes, then the browser row. This changes the +work observation to the accepted law without weakening snapshot authority. + +### Source-order item ledger + +Severity below is this evaluation's assessment. The supplied review gave no +severity except marking R06 as a design issue. Complete raw claims and the +append-only working ledger remain in the scratch root named below. + +| ID | Original claim and proposal | Technical judgment and evidence | PR action and durable value | +| --- | --- | --- | --- | +| R01 | Ordinary admission throws during GC cleanup while status remains ready; framework subscription can fail; no public promise/status exists to wait on. Proposed automatic admission/restart. | Rejection and retained status are approved. The no-promise subclaim is false: the same-path fake-timer GC probe calls cleanup(), obtains the exact pending operation, awaits it and subscribes successfully. A framework that does not handle the throw can still surface it. | `accepted-design`. Preserve the API-suitability concern and the GC timeline evidence. The public retry instruction remains valid. No automatic restart added. | +| R02 | registry.add precedes a throwing source subscription, retaining a nonterminal partition without a cleanup observer. | Confirmed, medium. Released-partition reattachment during pending cleanup exposes nonterminal status and partial ownership. Original expanded owner has four assertion failures on that boundary. | `fixed-now`. Earlier cleanup observation and complete partial release; release-owner grammar and startup reentry checks above. | +| R03 | resumeSubscriber checks admission but does not release an abandoned render gate, leaving a live query loading; changed fixtures hide it. | Confirmed, high. Both source modes have zero starts at the required demand cut. The correct React invocation fails all 16 added resumption cells before repair. | `fixed-now`. Shared subscriber start path and expanded acquisition/React grammars. Cancellation fixtures now create logical demand before explicit resumption. The DbClient fixture queues actual render-owned work directly, preserving its original cancellation assertion. | +| R04 | A startup throw consumes deferral, rejects other queued loads, and retains the first-demand flag while rolling back only counts. | These observations are real but belong to a failed sync run. The same-path probe reaches error, preserves the exact startup error for queued work, proves the old render release does not retry, and proves cleanup/new demand restores first-demand notification and readiness. Rolling back to an implicitly retryable idle run is not promised. | `accepted-design`. Preserve failure identity and explicit-restart witnesses. S01 enforces the neighboring handoff error law. This does not endorse partial ownership on failure, which R02/R07 repair. | +| R05 | Admission validation clears cleanupPromise, so concurrent cleanup can lose coalescing. Proposed a pure check. | Partly correct, medium. Successful ordinary admission starts a replacement and should receive a new operation. Deferred requests and failure before replacement startup must retain the old promise; those cases fail originally. | `fixed-now`. Pure assertion, transfer at actual loading, six distinguishing promise-identity histories. | +| R06 | Split admission differs between deferred plain/live-query Collections and requires cleanup-listener ordering. Proposed one shared method. | Narrower defect confirmed, medium: idle/cleaned-up cleanup-start reentry differs; ready cleanup does not show a blanket difference. Internal terminal dependents still need cleanup observation before source work. A common method cannot remove that ownership obligation. | `fixed-now`. Shared ordinary/resumed start logic and no local startup for deferred demand during cleanup. Six histories retain valid logical admission. No general policy/state-machine replacement. | +| R07 | Observer attach leaks client/status/layout listeners when source admission throws; should expose observer error status. | Leak confirmed, medium, including the logical listener record. A retry on the same observer originally attaches no source. Snapshot-error conversion is a separate unapproved policy. | `fixed-now`. Release failed registration and channels, preserve healthy peers and synchronous error reporting. Ten ownership histories plus existing publication campaigns. | +| R08 | An Effect created during cleanup returns disposed, never runs after restart, and gives no public reason. | Terminality is approved. The no-diagnostic subclaim is false: the same-path probe observes disposed and exactly one onSourceError with the cleanup cause, zero source ownership and no revival. The existing twelve joined-source receiving histories agree. | `accepted-design`. Keep explicit new Effect construction after settlement. No automatic revival. | +| R09 | Closing proposal: admitting then restarting would likely remove R01/R02/R07/R08; R03–R06 are separate. | This is a proposed contract revision, not a demonstrated cure. It conflicts with the user's rejection choice and does not prove setup-failure atomicity. | `accepted-design` for retaining rejection. Preserve automatic admission as an evaluated alternative, without implementation. | +| R10 | Metadata: eight deduplicated issues, medium effort, verified head 16824f67, no verification and no clear AGENTS violations. | Provenance retained. Matching HEAD was verified locally; effort and absence claims have no independent behavioral authority. | `duplicate` of the provenance recorded at this appendix's opening, with no product action or independent correctness credit. | +| S01 | Evaluation-discovered lost startup error after independent demand consumes the render gate. | Existing exact-error/healthy-peer tests fail on the first R03 repair. | `fixed-now`; deferred-source handoff now preserves that failure and peer release. | +| S02 | Evaluation-discovered resumption restores an owner retired during startup or misbalances notification reentry. | Original post-start ownership order fails pooled reentry; the calibrated guard mutant fails 18 assertions. | `fixed-now`; token-count grammar and two-sided retirement guards. | + +Loss accounting: **eight raw findings = five fixed-now + three accepted-design**. +The two additional raw prose/metadata items remain separately accounted for. +Including them gives ten raw items = five fixed-now + four accepted-design + +one duplicate provenance entry. +Two discovered siblings are fixed separately. False subclaims remain explicit +inside their source items. No item is deferred or silently merged. + +Reviewer recommendation: **hire for bug discovery, with reservations about fix +judgment**. Five of eight findings expose real defects, sometimes on a narrower +trace. R02/R03/R07 identify useful lifecycle cuts and R05 correctly questions +side effects in validation. The report does not execute them, overstates the +absence of public diagnostics in R01/R08, and treats an approved terminal policy +as a defect. Its automatic-restart proposal lacks contract authority and misses +failure atomicity. The ranking of R03 below the policy concern understates the +most direct progress failure. The reviewer is useful with independent contract +and experimental verification. + +### Executed evidence and limits + +Scratch root: `/tmp/tanstack-skill-reading-update/`. Logs retain assertion +messages and exact selected tests. No mutant remains in the working source. +Every mutation restores original bytes in `finally` and checks SHA-256 identity. + +| Experiment | Executed outcome | Log basename | +| --- | --- | --- | +| Original same-path probes | Seven assertion failures / seven passing controls; R01/R04/R08 reach the reported premises | review-eight-probes-red-2.log | +| Corrected original cleanup/observer/pool owner selection | 16 assertion failures / 125 passes: four cleanup, eight observer and four released-partition failures | review-eight-boundaries-red.log | +| Original real React receiving driver | 16 added resumption assertion failures | review-eight-react-red-proper.log | +| First repair, source handoff | Existing exact-error/peer tests expose S01 | review-eight-oracles-green-first.log | +| Startup cleanup during resumption | Two acquiring-count assertion failures expose S02 | review-eight-pool-reentry.log | +| Resumption retains render gate mutant | Four stream-preload assertion failures / four idle controls pass | review-eight-mutant-resume-keeps-render-gate.log | +| Observer detach-only mutant | All ten setup-ownership histories fail assertions, including healthy-peer preservation | review-eight-mutant-observer-detach-only-final.log | +| Retired-owner guard removed | 18 count assertion failures / 268 passes across complete lifecycle and pool files; no type or cell-audit error | review-eight-mutant-resume-retired-owner-final.log | +| Final five modified oracle owners | 741 tests pass; no type errors | review-eight-owners-final.log | +| Broad core/Effect/query surrounding run | 53 files, 2,584 tests pass; no type errors or unhandled errors | review-eight-core-final-3.log | +| Full React suite | 22 files, 457 tests pass; no type errors | review-eight-react-final-2.log | +| Scoped ESLint | Zero errors; pre-existing warnings remain | review-eight-lint-final-2.log, final command output | +| Production bundle vs merge-base 205ab7158 | db +102 gzip / +171 brotli bytes; react-db unchanged | review-eight-size.log | + +The early `review-eight-oracles-red.log` is not a clean calibration total. It +contains eight invalid virtual-field comparisons and eight progress timeouts +among 80 failures. The invalid comparisons were corrected to the declared row +projection. The clean boundary and React assertions above carry the RED claim. +`review-eight-react-red.log` selected no tests and supplies no evidence. +The first retired-owner mutant used a test-name selection that also triggered +the existing cell audit because other tests were skipped. Only its count +assertions were useful; the complete-file rerun supersedes that audit artifact. +Intermediate broad GREEN attempts passed runtime assertions but had test-fixture +type errors. They are not cited as clean GREEN runs. + +Production source weight for this follow-up is +71/-39 lines, net +32 across six +files. Shared admission startup, shared observer release and centralized pooled +status release remove duplicate control flow. The remaining added checks defend +observable partial-construction and reentry boundaries. Tests add dimensions +and assertions separately from production. Shipped growth above is +90 gzip and ++107 brotli bytes beyond reviewed head's earlier measured delta. No new stored +state, retry queue or recovery lifecycle was added. + +Replay the broad core run from `packages/db`: + +```sh +pnpm exec vitest run tests/collection tests/db-client.test.ts tests/effect \ + tests/live-query tests/query/pooled-live-query --coverage.enabled=false \ + --pool-options.threads.maxThreads=2 --pool-options.threads.minThreads=1 +``` + +From `packages/react-db`, run the same command without path selections. The +complete repository `pnpm test` was not run. These results do not establish +actual provider shutdown, every framework commit schedule, arbitrary recursive +callbacks or unbounded failure histories. Source/error and cleanup facts are +available at the asserted cut; no concrete missing-input hypothesis remains for +these repairs. The coverage map retains each owner's wider limits. + +| ORC requirement | Follow-up result | +| --- | --- | +| 001 authority and limits | Accepted rejection, demand, ownership, error and cleanup-promise laws are stated above and beside executable checks. | +| 002 independent judgment | Demand model, three-cut cleanup timeline, handle-count ownership and token-count retirement judgment do not import production semantic helpers. Private counts supplement public ownership observations; they do not compute expectations. | +| 003 literate responsibilities | Changed owners explain contract, independent judgment, finite grammar, real driver and immediate/settled comparisons. The shared grammar is named by core and React drivers. | +| 004 grammar controls | New cases are bounded enumerations, not important generated properties. Existing random campaigns are unchanged. Render deferral, explicit resume, held cleanup, released partition and failed attachment reconstruct the reports. Idle/no-retirement/successful replacement/healthy-peer neighbors distinguish their boundaries. No pending cleanup before adapter entry is claimed; callback cleanup at loading is synchronous. | +| 005 path and observations | Real Collection, observer, pooled view, Effect and React entry points run. Start counts, exact errors, handles, raw callback/count traces, rows, promise identity and resource leases reach immediate and settled assertions. Row projection omits unrelated virtual fields but retains full promised id/value fields. | +| 006 calibration | Original and mutant outcomes are classified above. Clean kills are assertion failures. Timeouts, invalid comparisons, missing selection and type errors supply no sensitivity credit. | +| 007 campaigns/replay | No important generated property added or altered. Existing observer and lifecycle fixed/random campaigns remain green with unchanged generators, recorder, budget and replay interface. Finite cases run directly through the listed test paths. | +| 008 minimal state | No new reference state. New source states project idle render deferral; explicit demand must erase its work distinction. The token model needs only retirement point and cleanup to predict counts and adapter entry. Distinguishing loading from ready is necessary because cleanup can prevent adapter entry only at loading. | +| 009 vocabulary | Canonical sync run, cleanup start, settlement, deferred acquisition, subscription and acquiring ownership remain distinct. The token-count judgment abstracts one logical owner's transfer and release, not production acquisition states. | +| 010 failure fidelity | New observer and cleanup checks use the existing primary/secondary cleanup aggregator. Pool and retirement fixtures resolve controlled gates and remove reentry callbacks before deterministic cleanup. No failure erasure or shrinking claim is made. | +| 011 second formulation | No concrete shared semantic fault requiring another formulation was identified. Terminal dependent policy is independently authorized by the user's decision and glossary, not inferred from the model. | +| 012 evidence | This versioned appendix records every applicable outcome and nonapplicable trigger, exact source identities, calibration and finite limits. This is self-review. | +| 013 boundary neighbors | Held cleanup versus post-settlement admission, logical versus acquiring demand, idle versus render-deferred source, failed validation versus actual replacement, retirement versus live owner, and failed late seed versus healthy peer all change asserted observations. | +| 014 receiving handoff | Real React abandoned Suspense resumption reuses the shared core judgment. The streaming React witness receives on-demand render deferral, starts acquisition before stream settlement and checks server/browser snapshot handoff. No actual remote-provider shutdown or other-framework schedule claim follows. | + +The eight-item implementation and bounded oracle repair are complete. Whole-PR +readiness remains separate: F02 still needs an initial-snapshot policy and F07 +still needs the reported failing application fixture. Earlier browser E2E runs +failed in dependency setup rather than demonstrating an assertion failure. +A new CI run does not erase those independent limits. + +Final evaluated source identities (SHA-256), based on reviewed HEAD +`16824f6778bc3feb21c67d4f865c4f444e11f2e4` plus this follow-up: + +| File | SHA-256 | +| --- | --- | +| `packages/db/mangle-cache.json` | `d16ffa42f01a034ee4786bc6b12e26b8b00d0e474c5ae94b8e2fa2e97883e27e` | +| `packages/db/src/collection/changes.ts` | `64e0b1d507dc9390a87c1db907bb178998f971910957ebe18a4d9565398f4433` | +| `packages/db/src/collection/lifecycle.ts` | `19025f597061032ad0dfd1fe394e6fa873af473d3b67698cfdb2e6ecb0d1ffef` | +| `packages/db/src/collection/subscription.ts` | `40d0d093671dcab5464614820de7799883577bf22a754922e29ba54f4fa3730d` | +| `packages/db/src/live-query-observer.ts` | `e212d8b1d10ed334c8268e7125a74f3de32ce7ca81c14627a3003e5c31a4b023` | +| `packages/db/src/live-query-options.ts` | `e6117272c8dfbc5d4c0636a2c3ff760a6db6b99e42e0edf8b10e36529e7a2dbf` | +| `packages/db/src/query/pooled-live-query.ts` | `2d247b2b14e9b8cc9a96e054d1b5dd4bb632b5a392086594c023263c8f98fc3e` | +| `packages/db/tests/collection-cleanup-restart-oracle.test.ts` | `65b3b32f54bc1ecaa61e62a2754142c7f5cf25ef1917df7ac14177342d094a20` | +| `packages/db/tests/collection-subscription-lifecycle-grammar-oracle.ts` | `e8144f0517fb21d55b66b3c0e63b2e628df6cc4ebda4edc8825adb384e8bf46b` | +| `packages/db/tests/collection-subscription-lifecycle-oracle.test.ts` | `f43e492ede9e000bf5c8a82687001d8b9a4c0b74c960a4022ef4708216b0dc9d` | +| `packages/db/tests/db-client.test.ts` | `e9aecb15f4bf1b29d07fb010ad6f79cc8eb379ba70b232d517f56c39b0836908` | +| `packages/db/tests/live-query-deferred-acquisition-oracle.test.ts` | `7df15face42f44bff73f7eab3e05a62c802650de9d671eedacfb1421bb9551b6` | +| `packages/db/tests/live-query-observer-history-oracle.property.test.ts` | `66a4803dd813967874f02db368595cca57cc4887208699247225ca25a27e938b` | +| `packages/db/tests/query/pooled-live-query-gc-oracle.test.ts` | `5eba86cb508775f885a9c13ec17253d04256f857002664bdeedfbce685426f89` | +| `packages/react-db/tests/descriptor-query-binding-oracle.test.tsx` | `101b8030b4d5e2ee4a090dca940c9708344fa2bdbd482ca94065969ef41478ab` | +| `packages/react-db/tests/useLiveSuspenseQuery.test.tsx` | `7d0ace536f2a4a8b6a5ccf124463ddf68cdb53ff390058a556fce3e6363bb136` | diff --git a/packages/db/mangle-cache.json b/packages/db/mangle-cache.json index acc9210bbf..3b4db20497 100644 --- a/packages/db/mangle-cache.json +++ b/packages/db/mangle-cache.json @@ -415,5 +415,6 @@ "cancelRepairRetry": "cG", "coverSource": "fN", "windowRequestGeneration": "av", - "latestWindowRequest": "gE" + "latestWindowRequest": "gE", + "startSyncForSubscriber": "gT" } diff --git a/packages/db/src/collection/changes.ts b/packages/db/src/collection/changes.ts index 00ccbf01b6..eefdd50794 100644 --- a/packages/db/src/collection/changes.ts +++ b/packages/db/src/collection/changes.ts @@ -410,12 +410,18 @@ export class CollectionChangesManager< } /** A deferring subscription resumed: it may now start this sync run. */ - private resumeSubscriber(): void { + private resumeSubscriber(): () => void { this.lifecycle.assertCanStartSync() + this.startSyncForSubscriber() + return () => this.changeAcquiringSubscribers(1) + } + + private startSyncForSubscriber(): void { // Mark first, so a sync run that starts now builds non-deferred demand. this.markSubscriberOrPreload() this.startSyncIfStopped() - this.changeAcquiringSubscribers(1) + // Committed demand releases even an abandoned render's sync-start gate. + this.sync.resumeStart() } private changeAcquiringSubscribers(delta: 1 | -1): void { @@ -447,12 +453,11 @@ export class CollectionChangesManager< try { if (!defersAcquisition) { - // Mark first, so a sync run that starts now builds non-deferred demand. - this.markSubscriberOrPreload() - // Request before release so cleanup during startup cannot trigger a retry. - this.startSyncIfStopped() - this.sync.resumeStart() - } else if (getBuilderFromConfig(this.collection.config)) { + this.startSyncForSubscriber() + } else if ( + !this.lifecycle.isCleaningUp() && + getBuilderFromConfig(this.collection.config) + ) { this.startSyncIfStopped() } } catch (error) { diff --git a/packages/db/src/collection/lifecycle.ts b/packages/db/src/collection/lifecycle.ts index 3c3a6730bc..61799a26d1 100644 --- a/packages/db/src/collection/lifecycle.ts +++ b/packages/db/src/collection/lifecycle.ts @@ -132,6 +132,9 @@ export class CollectionLifecycleManager< ) } this.validateStatusTransition(this.status, newStatus) + // Only an actual replacement run transfers later cleanup to a new run. + // Validation, deferred requests and failed admission retain the old promise. + if (newStatus === `loading`) this.cleanupPromise = null const revision = ++this.statusRevision const previousStatus = this.status this.status = newStatus @@ -255,9 +258,10 @@ export class CollectionLifecycleManager< : codedMessage(209, { id: this.id }), ) } - // A synchronously finished retirement retains its public promise until - // settlement. Starting a new sync run assigns later cleanup to that run. - this.cleanupPromise = null + } + + public isCleaningUp(): boolean { + return this.cleaningUp } /** diff --git a/packages/db/src/collection/subscription.ts b/packages/db/src/collection/subscription.ts index 9027af7a4f..07f9fa3a2d 100644 --- a/packages/db/src/collection/subscription.ts +++ b/packages/db/src/collection/subscription.ts @@ -85,8 +85,8 @@ type CollectionSubscriptionOptions = { * subscriber or a preload in its current sync run. */ deferAcquisition?: boolean - /** Called once when deferred acquisition resumes. */ - onResumeAcquisition?: () => void + /** Start sync, then return the ownership notification for a live resumption. */ + onResumeAcquisition?: () => () => void } type TruncateReplayPublicationControl = Readonly<{ @@ -295,9 +295,13 @@ export class CollectionSubscription if (!this.defersAcquisition || this.unsubscribed) return // Start the source first: if its start throws, this subscription still // defers rather than claiming acquisition it never began. - this.options.onResumeAcquisition?.() + const notifyResumed = this.options.onResumeAcquisition?.() + // Startup can synchronously retire this subscription. It then owns no + // acquisition, including when a status callback unsubscribes during startup. + if (this.unsubscribed as boolean) return this.defersAcquisition = false - if (this.collection.status !== `idle`) { + notifyResumed?.() + if (!(this.unsubscribed as boolean) && this.collection.status !== `idle`) { this.restartDetachedDemands(this.collection._sync.getSyncRunGeneration()) } } diff --git a/packages/db/src/live-query-observer.ts b/packages/db/src/live-query-observer.ts index 0dab756aa0..136ab3a93e 100644 --- a/packages/db/src/live-query-observer.ts +++ b/packages/db/src/live-query-observer.ts @@ -626,24 +626,28 @@ class LiveQueryObserverImpl< } const record: SubscriptionRecord = { listener, active: true } - this.subscriptions.add(record) - if (this.subscriptions.size === 1) { - this.attach() - } else { - // The initial-state replay only happens on attach, so a granular - // subscriber that arrives while already attached is seeded with the - // current rows — delivered to this subscription alone, without advancing - // the observer's revision (the collection state did not change). - // Wholesale consumers read getSnapshot() instead and need no seed. - if (!this.wholesale) this.seed(record) - } - - return () => { + const release = () => { if (!record.active) return record.active = false this.subscriptions.delete(record) if (this.subscriptions.size === 0) this.detach() } + this.subscriptions.add(record) + try { + if (this.subscriptions.size === 1) { + this.attach() + } else { + // Seed only this late granular listener; a failed seed releases its + // record without detaching peers that already own subscriptions. + if (!this.wholesale) this.seed(record) + } + } catch (error) { + // Setup returned no ownership handle. Release every acquired channel + // and the logical record, so a later explicit subscription can attach. + release() + throw error + } + return release } /** Deliver the collection's current rows to one late subscription as inserts. */ diff --git a/packages/db/src/live-query-options.ts b/packages/db/src/live-query-options.ts index 25abc0d25c..acc6ef4faa 100644 --- a/packages/db/src/live-query-options.ts +++ b/packages/db/src/live-query-options.ts @@ -42,6 +42,11 @@ export function resumeDeferredLiveQueryCollections( for (const collection of pending) { try { collection._resumeSyncStart() + // Independent demand may have released this render's gate already. + // Its startup failure still belongs to this deferred-source handoff. + if (collection.status === `error`) { + throw collection._lifecycle.getSyncError() + } } catch (error) { firstFailure ??= { error } } diff --git a/packages/db/src/query/pooled-live-query.ts b/packages/db/src/query/pooled-live-query.ts index 9f87605ff3..8c986fa43f 100644 --- a/packages/db/src/query/pooled-live-query.ts +++ b/packages/db/src/query/pooled-live-query.ts @@ -154,11 +154,6 @@ class Partition { if (!this.terminated && !this.subscription) { // A released partition that subscribes again serves new mounts too. this.registry.add() - this.subscription = this.source.subscribeChanges( - (changes) => - this.apply(changes as Array>), - { includeInitialState: true, deferAcquisition }, - ) const stopStatus = this.source.on(`status:change`, (event) => { this.deliverStatus(event) }) @@ -175,10 +170,29 @@ class Partition { status: `error`, }) }) + // Late registration can terminate before either release handle exists. + if (this.terminated as boolean) { + stopStatus() + stopCleanupStart() + return + } this.stopStatusEvents = () => { stopStatus() stopCleanupStart() } + try { + const subscription = this.source.subscribeChanges( + (changes) => + this.apply(changes as Array>), + { includeInitialState: true, deferAcquisition }, + ) + // Cleanup can re-enter while source sync starts, before assignment. + if (this.terminated as boolean) subscription.unsubscribe() + else this.subscription = subscription + } catch (error) { + this.terminate() + throw error + } } } @@ -187,10 +201,6 @@ class Partition { ? { ...event, status: `error` as const } : event for (const listener of [...this.statusListeners]) listener(delivered) - if (this.terminated) { - this.stopStatusEvents?.() - this.stopStatusEvents = undefined - } } addListener(group: PartitionGroup, listener: Listener): void { @@ -213,6 +223,8 @@ class Partition { } private release(): void { + this.stopStatusEvents?.() + this.stopStatusEvents = undefined this.subscription?.unsubscribe() this.subscription = undefined this.registry.remove() @@ -228,8 +240,6 @@ class Partition { private readonly releaseIfUnused = (): void => { if (this.listenerCount > 0) return - this.stopStatusEvents?.() - this.stopStatusEvents = undefined // Views read groups by key, so a later subscription refills new ones. this.groups.clear() this.release() diff --git a/packages/db/tests/collection-cleanup-restart-oracle.test.ts b/packages/db/tests/collection-cleanup-restart-oracle.test.ts index 7fcf6d198c..0f53bf04d4 100644 --- a/packages/db/tests/collection-cleanup-restart-oracle.test.ts +++ b/packages/db/tests/collection-cleanup-restart-oracle.test.ts @@ -42,7 +42,7 @@ import type { SyncConfig } from '../src/types' * If adapter cleanup and local teardown both fail, the aggregate keeps the * adapter error primary and every local error as an ordered secondary * diagnostic. A lone error keeps its identity. - * No replacement owner is admitted until terminal publication. Concurrent + * No replacement sync run is admitted until terminal publication. Concurrent * callers share the cleanup promise. Rejection still finalizes the old run and * rejects every waiter. A later ordinary preload starts a new sync run. * A subset load still pending at cleanup ends with one `loadingSubset:change` @@ -220,6 +220,151 @@ describe(`Collection cleanup oracle calibration`, () => { }) describe(`Collection cleanup admission oracle`, () => { + // Logical demand is distinct from a request to start a sync run. Cleanup + // rejects the latter, including resumption, while a deferred subscription + // may retain demand without starting even a live-query Collection's run. + // Idle and cleaned-up sources distinguish this law from checking only ready + // Collections. The callback is the synchronous cleanup-start observation cut. + it.each( + ([`plain`, `live-query`] as const).flatMap((kind) => + ([`idle`, `ready`, `cleaned-up`] as const).map((priorStatus) => ({ + kind, + priorStatus, + })), + ), + )( + `retains deferred demand during $priorStatus $kind cleanup`, + async ({ kind, priorStatus }) => { + let starts = 0 + const source = createCollection({ + getKey: (row) => row.id, + gcTime: 0, + sync: { + sync: ({ markReady }) => { + starts++ + markReady() + }, + }, + }) + const live = createLiveQueryCollection({ + query: (q) => q.from({ row: source }), + gcTime: 0, + }) + const collection = kind === `plain` ? source : live + let subscription: + ReturnType | undefined + let reached = false + if (priorStatus === `ready`) { + source.startSyncImmediate() + collection.startSyncImmediate() + } else if (priorStatus === `cleaned-up`) { + await collection.cleanup() + } + const before = starts + const stop = collection._onCleanupStart(() => { + reached = true + expect(() => collection.subscribeChanges(() => {})).toThrow( + expect.objectContaining(cleanupError), + ) + subscription = collection.subscribeChanges(() => {}, { + deferAcquisition: true, + includeInitialState: true, + }) + expect(subscription.isDeferringAcquisition()).toBe(true) + expect(() => subscription!.resumeDeferredAcquisition()).toThrow( + expect.objectContaining(cleanupError), + ) + expect(collection.subscriberCount).toBe(0) + expect(starts).toBe(before) + }) + await withOracleCleanup(async () => { + await collection.cleanup() + expect(reached).toBe(true) + expect(subscription).toBeDefined() + expect(starts).toBe(before) + stop() + subscription!.resumeDeferredAcquisition() + for (let turn = 0; turn < 10; turn++) await Promise.resolve() + expect(collection.status).toBe(`ready`) + expect(collection.subscriberCount).toBe(1) + }, [ + stop, + () => subscription?.unsubscribe(), + () => live.cleanup(), + () => source.cleanup(), + ]) + }, + ) + + // Promise ownership changes only when a replacement run actually starts. + // A validation failure or a still-deferred start cannot create a second + // retirement. Successful admission is the neighboring case: its new run + // must have a distinct cleanup promise even before the old one settles. + it.each([ + `none`, + `deferred-start`, + `listener-failure`, + `subscribe`, + `resume`, + `preload`, + ] as const)( + `preserves retirement identity through %s admission`, + async (admission) => { + let starts = 0 + let cleanups = 0 + const collection = createCollection({ + getKey: (row) => row.id, + gcTime: 0, + sync: { + sync: ({ markReady }) => { + starts++ + markReady() + return () => { + cleanups++ + } + }, + }, + }) + collection.startSyncImmediate() + const first = collection.cleanup() + const failure = new Error(`demand failed before replacement startup`) + let stop = () => {} + let subscription: + ReturnType | undefined + let preload: Promise | undefined + await withOracleCleanup(async () => { + if (admission === `listener-failure`) { + stop = collection[`_changes`].onFirstSubscriberOrPreload(() => { + throw failure + }) + expect(() => collection.subscribeChanges(() => {})).toThrow(failure) + } else if (admission === `deferred-start`) { + collection._deferSyncStart() + collection.startSyncImmediate() + } else if (admission === `subscribe`) { + subscription = collection.subscribeChanges(() => {}) + } else if (admission === `resume`) { + subscription = collection.subscribeChanges(() => {}, { + deferAcquisition: true, + }) + subscription.resumeDeferredAcquisition() + } else if (admission === `preload`) preload = collection.preload() + const replacement = [`subscribe`, `resume`, `preload`].includes( + admission, + ) + const second = collection.cleanup() + expect(second === first).toBe(!replacement) + expect(starts).toBe(replacement ? 2 : 1) + expect(cleanups).toBe(starts) + await Promise.all([first, second, preload]) + }, [ + () => stop(), + () => subscription?.unsubscribe(), + () => collection.cleanup(), + ]) + }, + ) + // These paths share the three-cut model above. The held adapter promise // reaches cleanup start without confusing the retained public status with // permission to start another sync run. No automatic retry is requested. diff --git a/packages/db/tests/collection-subscription-lifecycle-grammar-oracle.ts b/packages/db/tests/collection-subscription-lifecycle-grammar-oracle.ts index 3b403ccc07..4d05153a48 100644 --- a/packages/db/tests/collection-subscription-lifecycle-grammar-oracle.ts +++ b/packages/db/tests/collection-subscription-lifecycle-grammar-oracle.ts @@ -85,6 +85,35 @@ export function modelSubscriberStartCleanup({ } } +/** + * Resumption transfers one logical subscription from deferred to acquiring. + * Unsubscription at any startup callback removes that owner permanently. + * Cleanup can additionally invalidate the sync run, before or after adapter + * entry. Notification happens only for a transfer that actually committed; + * an unsubscribe from that notification contributes its matching decrement. + * This token-count judgment uses no production acquisition state machine. + */ +export const subscriberResumeRetirementHistories = ( + [`none`, `loading`, `ready`, `subscribers`] as const +).flatMap((retireAt) => + (retireAt === `none` ? [false] : [false, true]).map((cleanup) => ({ + retireAt, + cleanup, + })), +) + +export function modelSubscriberResumeRetirement({ + retireAt, + cleanup, +}: (typeof subscriberResumeRetirementHistories)[number]) { + return { + starts: cleanup && retireAt === `loading` ? 0 : 1, + acquiring: retireAt === `none` ? 1 : 0, + counts: + retireAt === `none` ? [1] : retireAt === `subscribers` ? [1, 0] : [], + } +} + /** * A bounded callback-reentry grammar for direct snapshots. The callback phase * is an observation cut, not a second subscription state machine. A released diff --git a/packages/db/tests/collection-subscription-lifecycle-oracle.test.ts b/packages/db/tests/collection-subscription-lifecycle-oracle.test.ts index 67f459e1ae..782f58b93f 100644 --- a/packages/db/tests/collection-subscription-lifecycle-oracle.test.ts +++ b/packages/db/tests/collection-subscription-lifecycle-oracle.test.ts @@ -15,7 +15,9 @@ import { readOracleRunConfig, } from './oracle-config.js' import { + modelSubscriberResumeRetirement, modelSubscriberStartCleanup, + subscriberResumeRetirementHistories, subscriberStartCleanupHistories, } from './collection-subscription-lifecycle-grammar-oracle.js' import type { CollectionSubscription } from '../src/collection/subscription.js' @@ -2592,9 +2594,11 @@ describe(`CollectionSubscription demand lifecycle oracle`, () => { }, ) - // Resuming a query-owned acquisition does not release the renderer's - // separate sync-start gate. An ordinary new subscriber does release it; - // descriptor-query-binding-oracle checks that independent-reader boundary. + // Demand created while acquisition is deferred stays logical until an + // explicit resumption. That resumption also releases any render start gate; + // retirement before it returns no acquisition lease. The phase named + // `deferred` here is deferred acquisition, not an already resumed owner + // whose provider work is still blocked by an unrelated render. acquisitionCase( [`deferred:request`, `deferred:resume`], `owns deferred-start acquisition only when it reaches the adapter`, @@ -2624,7 +2628,6 @@ describe(`CollectionSubscription demand lifecycle oracle`, () => { includeInitialState: false, deferAcquisition: true, }) - subscription.resumeDeferredAcquisition() subscription.requestSnapshot({ where }) reach(`deferred:request`) expect(loads).toEqual([]) @@ -2632,7 +2635,7 @@ describe(`CollectionSubscription demand lifecycle oracle`, () => { if (action === `release-before-resume`) { subscription.releaseSnapshot(where) } - collection._resumeSyncStart() + subscription.resumeDeferredAcquisition() await flushPromises() if (action === `resume`) reach(`deferred:resume`) @@ -2671,7 +2674,6 @@ describe(`CollectionSubscription demand lifecycle oracle`, () => { includeInitialState: false, deferAcquisition: true, }) - subscription.resumeDeferredAcquisition() subscription.requestSnapshot({ where, onLoadSubsetResult: (result) => observed.push(result), @@ -2680,7 +2682,7 @@ describe(`CollectionSubscription demand lifecycle oracle`, () => { if (action === `cleanup`) await collection.cleanup() else if (action === `release`) subscription.releaseSnapshot(where) else if (action === `unsubscribe`) subscription.unsubscribe() - else collection._resumeSyncStart() + else subscription.resumeDeferredAcquisition() await flushPromises() if (action === `cleanup`) { observeAcquisitionInteraction(`none:cleanup`, `no-acquisition`) @@ -3421,7 +3423,6 @@ describe(`CollectionSubscription demand lifecycle oracle`, () => { includeInitialState: false, deferAcquisition: true, }) - subscription.resumeDeferredAcquisition() const removeReadyListener = collection.on(`status:${phase}`, () => { if (!cancelOnEntry) return cancelOnEntry = false @@ -3436,7 +3437,7 @@ describe(`CollectionSubscription demand lifecycle oracle`, () => { try { cancelOnEntry = true - collection._resumeSyncStart() + subscription.resumeDeferredAcquisition() await flushPromises() expect(collection.status).toBe( @@ -3555,6 +3556,93 @@ describe(`CollectionSubscription demand lifecycle oracle`, () => { }, ) + // The callback can release this owner before resumption commits, or from + // its acquiring notification. Both cuts must leave balanced public counts + // and no later acquisition. The no-retirement neighbor must acquire normally. + it.each( + subscriberResumeRetirementHistories.flatMap((history) => + ([`eager`, `on-demand`] as const).flatMap((syncMode) => + [false, true].map((renderGate) => ({ + ...history, + syncMode, + renderGate, + })), + ), + ), + )( + `balances resume at $retireAt (cleanup=$cleanup, $syncMode, gate=$renderGate)`, + async (history) => { + const expected = modelSubscriberResumeRetirement(history) + let starts = 0 + let loads = 0 + const counts: Array = [] + const collection = createCollection<{ id: string }>({ + getKey: (row) => row.id, + gcTime: 0, + syncMode: history.syncMode, + sync: { + sync: ({ markReady }) => { + starts++ + markReady() + return { + loadSubset: () => { + loads++ + return true + }, + } + }, + }, + }) + if (history.renderGate) collection._deferSyncStart() + const subscription = collection.subscribeChanges(() => {}, { + deferAcquisition: true, + includeInitialState: true, + }) + let cleanup: Promise | undefined + let retired = false + const retire = () => { + if (retired) return + retired = true + subscription.unsubscribe() + if (history.cleanup) cleanup = collection.cleanup() + } + const offStatus = collection.on(`status:change`, ({ status }) => { + if (status === history.retireAt) retire() + }) + const offCounts = collection.on( + `subscribers:change`, + ({ subscriberCount }) => { + counts.push(subscriberCount) + if (history.retireAt === `subscribers` && subscriberCount === 1) + retire() + }, + ) + try { + subscription.resumeDeferredAcquisition() + expect(starts).toBe(expected.starts) + expect(collection.subscriberCount).toBe(expected.acquiring) + expect(counts).toEqual(expected.counts) + await cleanup + await flushPromises() + expect(collection.subscriberCount).toBe(expected.acquiring) + expect(loads).toBe( + history.syncMode === `on-demand` && history.retireAt === `none` + ? 1 + : 0, + ) + offStatus() + offCounts() + subscription.unsubscribe() + expect(collection.subscriberCount).toBe(0) + } finally { + offStatus() + offCounts() + subscription.unsubscribe() + await collection.cleanup() + } + }, + ) + it(`keeps an eager subscription ready after collection restart`, async () => { const collection = createCollection<{ id: string }>({ id: `eager-subscription-restart`, diff --git a/packages/db/tests/db-client.test.ts b/packages/db/tests/db-client.test.ts index 7a371f17e6..9145680fa3 100644 --- a/packages/db/tests/db-client.test.ts +++ b/packages/db/tests/db-client.test.ts @@ -417,13 +417,15 @@ describe(`DbClient`, () => { includeInitialState: true, deferAcquisition: true, }) - subscription.resumeDeferredAcquisition() + // Exercise a render-owned queued load. Resuming this subscription would + // itself be independent demand and must release the render gate immediately. + const queuedLoad = collection._sync.loadSubset({}) expect(collection.get(`1`)).toMatchObject({ id: `1`, name: `stale` }) expect(lifecycleOrder).toEqual([]) collection._resumeSyncStart() - await Promise.resolve() + await queuedLoad expect(lifecycleOrder).toEqual([`sync`, `load`]) expect(collection.get(`1`)).toMatchObject({ id: `1`, name: `fresh` }) diff --git a/packages/db/tests/live-query-deferred-acquisition-oracle.test.ts b/packages/db/tests/live-query-deferred-acquisition-oracle.test.ts index 6ba058ed65..3d92cac14e 100644 --- a/packages/db/tests/live-query-deferred-acquisition-oracle.test.ts +++ b/packages/db/tests/live-query-deferred-acquisition-oracle.test.ts @@ -36,8 +36,13 @@ * asynchronously. Over eager source Collections that resumption is * synchronous. * + * A committed subscriber, including one that resumes deferred acquisition, + * owns its demand independently of an abandoned render's sync-start deferral. + * Its admission releases that deferral. A failed sync start remains an error + * in that run; releasing the old render handle does not implicitly retry it. + * * Limits: source Collections load synchronously, so every checkpoint is - * settled. The grammar crosses five source states, two query shapes, and one or + * settled. The grammar crosses seven source states, two query shapes, and one or * two live-query levels, with one peer consumer and one truncate before the * first subscriber. It does not generate source failure, asynchronous loads, * or several live-query consumers; the subscription lifecycle oracle owns @@ -78,20 +83,26 @@ const ROWS: Array = [ * `on-demand-holding` already holds every row, as it would after another * consumer's acquisition. Each fixture counts sync-run starts and acquisition * attempts: those are its provider work. + * `-render-deferred` sources are idle behind an abandoned render's start gate. + * They expose the same demand law through a different production start path. */ type SourceState = | `eager-idle` | `eager-running` + | `eager-render-deferred` | `on-demand-idle` | `on-demand-running` | `on-demand-holding` + | `on-demand-render-deferred` const SOURCE_STATES: Array = [ `eager-idle`, `eager-running`, + `eager-render-deferred`, `on-demand-idle`, `on-demand-running`, `on-demand-holding`, + `on-demand-render-deferred`, ] /** @@ -113,6 +124,8 @@ const EXPECTED_IDS: Record> = { * Commands act on the outermost live-query Collection, which is the only * public Collection in the history. `start` starts its sync run, as a * framework render or `startSync: true` does. `subscribe` adds a subscriber. + * `defer-subscribe` adds logical demand without acquisition; `resume` makes + * that subscription ask for data independently of an earlier render. * `preload` asks for its data. `cleanup` ends its sync run. Two commands act * on the source Collection instead, and neither is a subscriber or a preload * of the live-query Collection: `peer` adds another consumer that subscribes @@ -124,6 +137,8 @@ type Command = | `start` | `read` | `subscribe` + | `defer-subscribe` + | `resume` | `unsubscribe` | `preload` | `cleanup` @@ -135,6 +150,19 @@ const HISTORIES: Record> = { 'subscribe after start': [`start`, `read`, `subscribe`], 'preload after start': [`start`, `preload`], 'subscribe without a prior start': [`subscribe`], + 'resume deferred acquisition after start': [ + `start`, + `defer-subscribe`, + `read`, + `resume`, + `read`, + ], + 'resume deferred acquisition without a prior start': [ + `defer-subscribe`, + `read`, + `resume`, + `read`, + ], 'preload without a prior start': [`preload`], 'cleanup before a subscriber, then subscribe': [ `start`, @@ -227,6 +255,7 @@ function applyModel( const next = { ...model } switch (command) { case `start`: + case `defer-subscribe`: next.syncRunning = true break case `read`: @@ -235,6 +264,7 @@ function applyModel( // subscription, so unsubscribing does not defer acquisition again. break case `subscribe`: + case `resume`: case `preload`: next.syncRunning = true next.subscriberOrPreload = true @@ -320,7 +350,7 @@ function makeSource(state: SourceState) { id: `deferred-acquisition-${sequence++}`, getKey: (row) => row.id, syncMode: onDemand ? `on-demand` : `eager`, - startSync: state !== `eager-idle` && state !== `on-demand-idle`, + startSync: state.endsWith(`running`) || state.endsWith(`holding`), autoIndex: `eager`, defaultIndexType: BTreeIndex, gcTime: 0, @@ -356,6 +386,7 @@ function makeSource(state: SourceState) { }, }, }) + if (state.endsWith(`render-deferred`)) collection._deferSyncStart() return { collection, counts, truncate: () => truncateRows?.() } } @@ -409,7 +440,8 @@ describe(`live-query deferred acquisition`, () => { const { outer, collections } = makeLiveQuery(source, shape, depth) let peer: { unsubscribe: () => void } | undefined let model = initialModel(state) - let subscription: { unsubscribe: () => void } | undefined + let subscription: + ReturnType | undefined try { for (const [step, command] of commands.entries()) { const before = { ...counts } @@ -425,6 +457,15 @@ describe(`live-query deferred acquisition`, () => { includeInitialState: true, }) break + case `defer-subscribe`: + subscription = outer.subscribeChanges(() => {}, { + includeInitialState: true, + deferAcquisition: true, + }) + break + case `resume`: + subscription!.resumeDeferredAcquisition() + break case `unsubscribe`: subscription?.unsubscribe() subscription = undefined @@ -647,12 +688,25 @@ describe(`pooled live-query deferred acquisition`, () => { * stream for a query. The observer's preload then returns that stream instead * of preloading the live-query Collection. It is still a request for the * Collection's data, so it counts as a preload: deferred acquisition resumes - * and the idle source Collection starts its sync run. + * and the idle source Collection starts its sync run. This includes a source + * left deferred by a render: that render does not own the preload's demand. */ describe(`preload answered by a DbClient stream`, () => { - for (const stream of [`pending`, `success`] as const) { - it(`a ${stream} stream still resumes deferred acquisition`, async () => { - const { collection: source, counts } = makeSource(`eager-idle`) + it.each( + ([`pending`, `success`] as const).flatMap((stream) => + ( + [ + `eager-idle`, + `eager-render-deferred`, + `on-demand-idle`, + `on-demand-render-deferred`, + ] as const + ).map((state) => ({ stream, state })), + ), + )( + `a $stream stream still resumes $state acquisition`, + async ({ stream, state }) => { + const { collection: source, counts } = makeSource(state) const { outer } = makeLiveQuery(source, `all`, 1) const client = new DbClient() const queryHash = `deferred-acquisition-stream-${stream}` @@ -671,14 +725,15 @@ describe(`preload answered by a DbClient stream`, () => { void observer.preload() await settle() expect(counts.starts, `after the preload`).toBe(1) + expect(counts.acquisitions > 0).toBe(state.startsWith(`on-demand`)) expect(outer.toArray.map((row) => row.id)).toEqual(EXPECTED_IDS.all) } finally { observer.dispose() await outer.cleanup() await source.cleanup() } - }) - } + }, + ) }) /** diff --git a/packages/db/tests/live-query-observer-history-oracle.property.test.ts b/packages/db/tests/live-query-observer-history-oracle.property.test.ts index 09d2290ca1..0eb99954e1 100644 --- a/packages/db/tests/live-query-observer-history-oracle.property.test.ts +++ b/packages/db/tests/live-query-observer-history-oracle.property.test.ts @@ -1,11 +1,227 @@ import { fc } from '@fast-check/vitest' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' +import { DbClient } from '../src/client.js' +import { createDeferred } from '../src/deferred.js' import { createCollection } from '../src/collection/index.js' import { createLiveQueryObserver } from '../src/live-query-observer.js' import { oraclePropertyOptions, oracleRuns } from './oracle-config.js' +import { withOracleCleanup } from './utils.js' import type { ChangeMessage } from '../src/types.js' type Row = { id: string; version: number } + +/** + * Failed subscription setup transfers no listener ownership to the observer: + * the caller received no release handle. This is the resource counterpart of + * the eligibility ledger below, and follows the observer's subscribe/dispose + * contract. A later explicit subscription must work on the same observer. + * + * The bounded grammar crosses both modes, cleanup rejection and a real sync + * startup throw, and one or two failed attempts before an explicit retry. + * The independent ownership judgment counts only calls that returned handles. + * We observe client/status/layout leases immediately after each failure, then source + * ownership and listener delivery after retry, publication and final release. + * A neighboring granular history rejects one or two late listener seeds while + * a healthy peer remains subscribed. Only the peer owns later publications. + * These are controlled core boundaries; framework commit errors are not modeled. + */ +describe(`observer subscription setup ownership`, () => { + it.each([1, 2])( + `preserves a healthy peer across %s failed late listener seeds`, + async (failures) => { + let publish = () => {} + const source = createCollection({ + getKey: (row) => row.id, + gcTime: 0, + sync: { + sync: ({ begin, write, commit, markReady }) => { + let version = 0 + publish = () => { + begin() + write({ + type: version === 0 ? `insert` : `update`, + value: { id: `a`, version: ++version }, + }) + commit() + } + publish() + markReady() + }, + }, + }) + const observer = createLiveQueryObserver(source, { mode: `granular` }) + const healthy = vi.fn() + const failure = new Error(`late listener seed failed`) + const failed = vi.fn(() => { + throw failure + }) + let stop: (() => void) | undefined + await withOracleCleanup(() => { + stop = observer.subscribe(healthy) + for (let attempt = 1; attempt <= failures; attempt++) { + expect(() => observer.subscribe(failed)).toThrow(failure) + expect(failed).toHaveBeenCalledTimes(attempt) + expect(source.subscriberCount).toBe(1) + } + const prefix = healthy.mock.calls.length + expect(publish).not.toThrow() + expect(healthy).toHaveBeenCalledTimes(prefix + 1) + expect(failed).toHaveBeenCalledTimes(failures) + expect(observer.getSnapshot().state?.get(`a`)?.version).toBe(2) + stop() + expect(source.subscriberCount).toBe(0) + }, [() => stop?.(), () => observer.dispose(), () => source.cleanup()]) + }, + ) + + it.each( + ([`granular`, `wholesale`] as const).flatMap((mode) => + ([`cleanup`, `sync-throw`] as const).flatMap((failureAt) => + ([1, 2] as const).map((failures) => ({ mode, failureAt, failures })), + ), + ), + )( + `releases failed $mode setup at $failureAt before $failures-attempt retry`, + async ({ mode, failureAt, failures }) => { + const gate = createDeferred() + const failure = new Error(`source startup failed`) + let failStart = failureAt === `sync-throw` + let publish = () => {} + const source = createCollection({ + getKey: (row) => row.id, + gcTime: 0, + sync: { + sync: ({ begin, write, commit, markReady }) => { + if (failStart) throw failure + publish = () => { + begin() + write({ type: `insert`, value: { id: `a`, version: 1 } }) + commit() + } + markReady() + return { cleanup: () => gate.promise } + }, + }, + }) + const client = new DbClient() + let statusLeases = 0 + const on = source.on.bind(source) + source.on = (event, listener) => { + const stop = on(event, listener) + if (event !== `status:change`) return stop + statusLeases++ + let active = true + return () => { + if (active) { + active = false + statusLeases-- + stop() + } + } + } + let clientLeases = 0 + const subscribeClient = client.subscribe.bind(client) + vi.spyOn(client, `subscribe`).mockImplementation((listener) => { + clientLeases++ + const stop = subscribeClient(listener) + let active = true + return () => { + if (active) { + active = false + clientLeases-- + stop() + } + } + }) + let layoutLeases = 0 + const subscribeLayout = source._subscribeLayoutChanges.bind(source) + vi.spyOn(source, `_subscribeLayoutChanges`).mockImplementation( + (listener) => { + layoutLeases++ + const stop = subscribeLayout(listener) + let active = true + return () => { + if (active) { + active = false + layoutLeases-- + stop() + } + } + }, + ) + const observer = createLiveQueryObserver(source, { + mode, + client, + queryHash: `setup-${mode}-${failureAt}-${failures}`, + }) + const failedListener = vi.fn() + const listener = vi.fn() + let pending: Promise | undefined + let release: (() => void) | undefined + await withOracleCleanup(async () => { + if (failureAt === `cleanup`) { + source.startSyncImmediate() + pending = source.cleanup() + } + for (let attempt = 0; attempt < failures; attempt++) { + expect(() => observer.subscribe(failedListener)).toThrow( + failureAt === `cleanup` ? /during cleanup/ : failure, + ) + expect({ + clientLeases, + layoutLeases, + statusLeases, + source: source.subscriberCount, + }).toEqual({ + clientLeases: 0, + layoutLeases: 0, + statusLeases: 0, + source: 0, + }) + if (failureAt === `sync-throw`) await source.cleanup() + } + const failedPrefix = failedListener.mock.calls.length + gate.resolve() + await pending + failStart = false + release = observer.subscribe(listener) + expect(source.subscriberCount).toBe(1) + expect({ clientLeases, layoutLeases }).toEqual({ + clientLeases: 1, + layoutLeases: 1, + }) + publish() + expect( + Array.from( + observer.getSnapshot().state!.values(), + ({ id, version }) => ({ id, version }), + ), + ).toEqual([{ id: `a`, version: 1 }]) + expect(listener).toHaveBeenCalled() + expect(failedListener).toHaveBeenCalledTimes(failedPrefix) + release() + expect({ + clientLeases, + layoutLeases, + statusLeases, + source: source.subscriberCount, + }).toEqual({ + clientLeases: 0, + layoutLeases: 0, + statusLeases: 0, + source: 0, + }) + }, [ + () => gate.resolve(), + () => release?.(), + () => observer.dispose(), + () => source.cleanup(), + () => client.cleanup(), + ]) + }, + ) +}) + type Mode = `granular` | `wholesale` type Reaction = | `none` diff --git a/packages/db/tests/query/pooled-live-query-gc-oracle.test.ts b/packages/db/tests/query/pooled-live-query-gc-oracle.test.ts index 583c5ef24f..decd9bfab8 100644 --- a/packages/db/tests/query/pooled-live-query-gc-oracle.test.ts +++ b/packages/db/tests/query/pooled-live-query-gc-oracle.test.ts @@ -1,9 +1,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { CollectionImpl, createCollection } from '../../src/collection/index.js' import { createLiveQueryObserver } from '../../src/live-query-observer.js' -import { Query } from '../../src/query/builder/index.js' +import { BaseQueryBuilder, Query } from '../../src/query/builder/index.js' import { createLiveQueryCollection, eq, not } from '../../src/query/index.js' import { createPooledLiveQuery } from '../../src/query/pooled-live-query.js' +import { createDeferred } from '../../src/deferred.js' import { resolveLiveQueryValue } from '../../src/live-query-options.js' import { mockSyncCollectionOptions } from '../utils.js' import type { Collection } from '../../src/collection/index.js' @@ -18,6 +19,9 @@ import type { Collection } from '../../src/collection/index.js' * Collection lifecycle's 50 ms floor. Views of one partition with different `gcTime`s release with the longest. * A view that subscribes again after its partition released, as a hidden * React Activity does, follows the source like its restarted Collection. + * Source cleanup instead makes that partition terminal, including a released + * partition that reattaches during cleanup. Failed or interrupted startup must + * retain no partial source ownership. * * Each case runs the same timeline against a pooled query and a live-query * Collection on separate sources and compares when each source loses its @@ -79,6 +83,158 @@ describe(`pooled live query gcTime`, () => { beforeEach(() => vi.useFakeTimers()) afterEach(() => vi.useRealTimers()) + it.each([`loading`, `ready`] as const)( + `releases a partition when source startup re-enters cleanup at %s`, + async (cleanupAt) => { + const gate = createDeferred() + let starts = 0 + const source = createCollection({ + getKey: (row) => row.id, + gcTime: 0, + sync: { + sync: ({ markReady }) => { + starts++ + markReady() + return { cleanup: () => gate.promise } + }, + }, + }) + const builder = new Query() + .from({ r: source }) + .where(({ r }) => eq(r.g, `x`)) + if (!(builder instanceof BaseQueryBuilder)) + throw new Error(`not a builder`) + const view = createPooledLiveQuery(builder, { gcTime: 1 })! + let pending: Promise | undefined + const off = source.on(`status:change`, ({ status }) => { + if (status === cleanupAt) pending = source.cleanup() + }) + let stop: { unsubscribe: () => void } | undefined + try { + stop = view.subscribeChanges(() => {}) + expect(pending).toBeDefined() + expect(starts).toBe(cleanupAt === `loading` ? 0 : 1) + expect(view.status).toBe(`error`) + expect(source.subscriberCount).toBe(0) + expect(source[`_changes`].activeSubscribersCount).toBe(0) + gate.resolve() + await pending + expect(source.subscriberCount).toBe(0) + } finally { + off() + gate.resolve() + stop?.unsubscribe() + await source.cleanup() + } + }, + ) + + // Cleanup start makes a dependent terminal even if it registers late. A + // released partition has no source subscription, so its next attachment + // must observe cleanup before acquiring one. The bounded grammar varies + // initial ownership (new, retained, released), callback mode and held source + // status. It checks terminality before settlement, after restart, and after a + // fresh partition mounts. Source counts detect retained or duplicate ownership. + it.each( + ([`new`, `retained`, `released`] as const).flatMap((ownership) => + ([`granular`, `wholesale`] as const).flatMap((mode) => + ([`loading`, `ready`] as const).map((priorStatus) => ({ + ownership, + mode, + priorStatus, + })), + ), + ), + )( + `retires $ownership $mode partition during $priorStatus cleanup`, + async ({ ownership, mode, priorStatus }) => { + const gate = createDeferred() + let starts = 0 + const source = createCollection({ + getKey: (row) => row.id, + gcTime: 0, + sync: { + sync: ({ begin, write, commit, markReady }) => { + starts++ + begin() + write({ type: `insert`, value: { id: `a`, g: `x` } }) + commit() + if (priorStatus === `ready` || starts > 1) markReady() + return { cleanup: () => gate.promise } + }, + }, + }) + const build = () => { + const builder = new Query() + .from({ r: source }) + .where(({ r }) => eq(r.g, `x`)) + if (!(builder instanceof BaseQueryBuilder)) + throw new Error(`not a builder`) + return createPooledLiveQuery(builder, { gcTime: 1 })! + } + let view: ReturnType | undefined + let observer: ReturnType | undefined + let stop: (() => void) | undefined + let fresh: + | ReturnType> + | undefined + let stopFresh: (() => void) | undefined + try { + source.startSyncImmediate() + if (ownership !== `new`) { + view = build() + observer = createLiveQueryObserver(view, { mode }) + stop = observer.subscribe(() => {}) + if (ownership === `released`) { + stop() + stop = undefined + await vi.advanceTimersByTimeAsync(100) + expect(source.subscriberCount).toBe(0) + } + } + const pending = source.cleanup() + expect(source.status).toBe(priorStatus) + view ??= build() + observer ??= createLiveQueryObserver(view, { mode }) + let attachmentError: unknown + try { + if (ownership !== `retained`) stop = observer.subscribe(() => {}) + } catch (error) { + attachmentError = error + } + expect( + attachmentError, + `late dependent becomes terminal without acquiring a source`, + ).toBeUndefined() + expect(view.status).toBe(`error`) + expect(observer.getSnapshot().status).toBe(`error`) + expect(source.subscriberCount).toBe(0) + expect(source[`_changes`].activeSubscribersCount).toBe(0) + gate.resolve() + await pending + source.startSyncImmediate() + expect(view.status).toBe(`error`) + fresh = createLiveQueryObserver(build(), { mode }) + stopFresh = fresh.subscribe(() => {}) + expect(fresh.getSnapshot().status).toBe(`ready`) + expect( + Array.from(fresh.getSnapshot().state!.values(), ({ id, g }) => ({ + id, + g, + })), + ).toEqual([{ id: `a`, g: `x` }]) + expect(source.subscriberCount).toBe(1) + } finally { + gate.resolve() + stop?.() + stopFresh?.() + observer?.dispose() + fresh?.dispose() + await source.cleanup() + } + }, + ) + for (const gcTime of [1, 100, 0, Number.POSITIVE_INFINITY]) { for (const subscribe of [true, false]) { it(`matches a live-query Collection for gcTime ${gcTime}${subscribe ? `` : ` without a subscriber`}`, async () => { diff --git a/packages/react-db/tests/descriptor-query-binding-oracle.test.tsx b/packages/react-db/tests/descriptor-query-binding-oracle.test.tsx index 847da9eecc..8c1ef0935b 100644 --- a/packages/react-db/tests/descriptor-query-binding-oracle.test.tsx +++ b/packages/react-db/tests/descriptor-query-binding-oracle.test.tsx @@ -81,7 +81,7 @@ * reader. An ordinary subscription or preload is independent demand for data: * it starts the source even though the earlier render never committed. A * deferring subscription retains the zero-start cut. The finite grammar crosses - * all three readers with startSync true/false and eager/on-demand sources. It + * all four readers with startSync true/false and eager/on-demand sources. It * extends ordinary admission with loading/ready callback cleanup, including * held resource cleanup. The shared lifecycle judgment predicts adapter entries * and the complete status trace at admission return and cleanup settlement. @@ -278,12 +278,15 @@ describe(`standalone descriptor query binding`, () => { { reader: `preload`, startSync: false }, { reader: `subscriber`, startSync: true }, { reader: `subscriber`, startSync: false }, + { reader: `resumed subscriber`, startSync: true }, + { reader: `resumed subscriber`, startSync: false }, { reader: `deferring subscriber`, startSync: true }, { reader: `deferring subscriber`, startSync: false }, ] as const ).flatMap((history) => ([`eager`, `on-demand`] as const).flatMap((syncMode) => - (history.reader === `subscriber` + (history.reader === `subscriber` || + history.reader === `resumed subscriber` ? subscriberStartCleanupHistories : [subscriberStartCleanupHistories[0]] ).map((cleanupHistory) => ({ @@ -366,9 +369,18 @@ describe(`standalone descriptor query binding`, () => { void directRead.catch(() => {}) } else { subscription = source.subscribeChanges(() => {}, { - deferAcquisition: reader === `deferring subscriber`, + deferAcquisition: + reader === `deferring subscriber` || + reader === `resumed subscriber`, includeInitialState: true, }) + if (reader === `resumed subscriber`) { + expect( + starts, + `deferred demand preserves the abandoned-render cut`, + ).toBe(0) + subscription.resumeDeferredAcquisition() + } } expect(starts, `direct demand starts the source`).toBe( reader === `deferring subscriber` ? 0 : expected.starts, @@ -396,7 +408,9 @@ describe(`standalone descriptor query binding`, () => { if (cleanupAt === `none`) { const expectsRow = reader !== `deferring subscriber` && - (syncMode === `eager` || reader === `subscriber`) + (syncMode === `eager` || + reader === `subscriber` || + reader === `resumed subscriber`) expect(source.get(`one`)?.value).toBe(expectsRow ? `live` : undefined) } removeStatusListener() diff --git a/packages/react-db/tests/useLiveSuspenseQuery.test.tsx b/packages/react-db/tests/useLiveSuspenseQuery.test.tsx index 2b4d56aa55..dcaf67ea8e 100644 --- a/packages/react-db/tests/useLiveSuspenseQuery.test.tsx +++ b/packages/react-db/tests/useLiveSuspenseQuery.test.tsx @@ -563,7 +563,9 @@ describe(`useLiveSuspenseQuery`, () => { { wrapper: browserWrapper }, ) - expect(browserLoad).not.toHaveBeenCalled() + // Suspense preload is local demand even while the server stream is pending. + // Its rows remain authoritative until the browser acquisition completes. + expect(browserLoad).toHaveBeenCalledTimes(1) await act(async () => { resolveServerLoad() From e852b0accf9971f61348066277290b010a7b016e Mon Sep 17 00:00:00 2001 From: Test Date: Thu, 22 Oct 2026 16:42:14 -0600 Subject: [PATCH 07/20] fix(db): retain surviving demand across interrupted admission --- .../resume-independent-subscription-demand.md | 2 + docs/contributing/oracle-coverage.md | 10 ++ .../2026-10-22-dbclient-migration-feedback.md | 111 +++++++++++++++++ packages/db/src/collection/changes.ts | 29 ++++- ...-query-deferred-acquisition-oracle.test.ts | 112 ++++++++++++++++++ 5 files changed, 258 insertions(+), 6 deletions(-) diff --git a/.changeset/resume-independent-subscription-demand.md b/.changeset/resume-independent-subscription-demand.md index 863b6abbc5..dfa1046a8c 100644 --- a/.changeset/resume-independent-subscription-demand.md +++ b/.changeset/resume-independent-subscription-demand.md @@ -7,3 +7,5 @@ Resume Collection sync when a new subscriber follows a suspended render, while p Reject ordinary subscriptions and deferred acquisition resumption during active cleanup. Effects created during that interval still return disposed handles. Release partial observer and pooled-query subscriptions when setup fails or cleanup interrupts startup. Resumed subscriptions release abandoned render deferrals without restoring retired ownership. Admission checks retain an existing cleanup promise until a replacement sync run starts. + +Subscribers that survive cleanup during admission retain demand for an explicit restart. Cleanup in a first-demand callback does not start a replacement implicitly. diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index 2251f2f49a..c81d4b0d91 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -134,6 +134,16 @@ extensions do not establish arbitrary async/provider schedules or new framework error policy. The [follow-up audit](oracle-reviews/2026-10-22-dbclient-migration-feedback.md) records original RED, hostile controls, and the unchanged rejection decision. +A subsequent current-head summary exposed cleanup during ownership transfer. +The deferred-acquisition owner adds 192 surviving-owner histories crossing both +source modes, shapes, depths, ordinary/resumed/deferred readers, render gates, +source loading/ready cleanup, and explicit restart later or from terminal +publication. They check the complete post-cleanup status trace, ownership, +replacement acquisition, readiness and rows. The previous implementation fails +44 assertions; retaining ownership without fencing first-demand cleanup fails +16. The controlled grammar does not claim arbitrary recursive cleanup or async +provider work before acquisition. This owner retains those wider witnesses. + The Collection lifecycle history owner includes a 26-cell cleanup-restart matrix. It crosses explicit and default eager sources with absent or present loaders, empty or nonempty replacements, and no interruption, truncate during diff --git a/docs/contributing/oracle-reviews/2026-10-22-dbclient-migration-feedback.md b/docs/contributing/oracle-reviews/2026-10-22-dbclient-migration-feedback.md index 97e15e0da1..57aeef23c2 100644 --- a/docs/contributing/oracle-reviews/2026-10-22-dbclient-migration-feedback.md +++ b/docs/contributing/oracle-reviews/2026-10-22-dbclient-migration-feedback.md @@ -831,3 +831,114 @@ Final evaluated source identities (SHA-256), based on reviewed HEAD | `packages/db/tests/query/pooled-live-query-gc-oracle.test.ts` | `5eba86cb508775f885a9c13ec17253d04256f857002664bdeedfbce685426f89` | | `packages/react-db/tests/descriptor-query-binding-oracle.test.tsx` | `101b8030b4d5e2ee4a090dca940c9708344fa2bdbd482ca94065969ef41478ab` | | `packages/react-db/tests/useLiveSuspenseQuery.test.tsx` | `7d0ace536f2a4a8b6a5ccf124463ddf68cdb53ff390058a556fce3e6363bb136` | + +## Current-head summary: cleanup during ownership transfer + +CodeRabbit's summary at `da37bc9bbafbed4b2cc901adcbad9181b0aeb93d` reported no +actionable comments, but said: “The deferred-resumption cleanup edge case is +unresolved but has no established user impact.” It then paused automatic +reviews. This concern required an experiment without an inline finding. +The previous appendix's closeout is historical and does not cover this new +counterexample. The earlier eight-item ledger remains unchanged. + +### Law, experiment, and repair + +A returned acquiring subscription remains demand after cleanup. Explicit restart +must serve it. A still-deferred subscription contributes no acquisition. +Cleanup during admission retires that start. Admission must not start another +run unless a caller explicitly requested one. Authority comes from the primary +owner's surviving-subscriber contract, cleanup start, and shared startup judgment. + +Eight probes varied plain/live-query Collection, eager/on-demand source and +cleanup from loading/ready callbacks during resumption. Two on-demand live +queries remained loading after explicit restart with one acquiring subscriber. +Removing the target render gate exposed a neighbor: first-demand notification +could clean up the query, after which admission started it again. These are +concrete consequences absent from the summary. + +The old surviving-subscriber grammar cleaned up after admission settled. The +retirement grammar unsubscribed at the interruption. Neither represented a +surviving owner whose transfer was still in progress. The primary deferred- +acquisition owner now adds 192 finite histories: two modes, two shapes, one/two +levels, ordinary/resumed/deferred readers, render gate present/absent, source +loading/ready cleanup, and explicit restart later/from terminal publication. + +The independent judgment counts returned acquiring handles as next-run demand. +A deferred handle contributes none. Eager local rows can establish readiness +without acquisition. The driver reaches cleanup through real status callbacks. +It compares the complete target status trace after cleanup starts, ownership +and absence of implicit restart, then acquisition, readiness and rows after +explicit restart. Two-level histories permit an inner query's earlier accepted +acquisition to remain useful. They require acquisition to exist, without forcing +an unnecessary new physical attempt for each outer restart. + +The first draft incorrectly required that additional attempt. Its assertion +was corrected before production changed. The corrected original implementation +fails 44 histories at unintended-restart or replacement-progress assertions. +Keeping ownership retention while removing the sync-run guard fails 16 +unintended-restart assertions. Both runs have no type errors. Evidence: +`review-da37-surviving-red-original.log` and +`review-da37-surviving-red-no-generation-guard.log` in the existing scratch root. +The earlier draft total is not calibration evidence. Mutants restore source +bytes in finally and verify identity. + +The repair uses the existing sync-run generation to fence admission after a +first-demand callback cleans up. Current acquiring ownership also contributes +to the demand judgment. Ownership commitment restores a demand mark cleared +during startup cleanup, including an explicit cleanup-listener restart. +Retired owners do not commit that notification. Ordinary admission includes +its fallible ownership notification in rollback, preserving the setup error if +a release listener throws too. Rollback removes the pending owner's count, +without resetting a total that could erase a reentrant peer. No stored lifecycle +state, retry queue or recovery lifecycle was added. + +The same 53-file core selection passes **2,776 tests**. The full 22-file React +selection passes **457 tests**. Both report no type errors or unhandled errors. +Scoped ESLint has zero errors. The production bundle is **+138 gzip / +191 +brotli bytes** against merge-base `205ab7158`; React is unchanged. This adds +36 gzip / 20 brotli bytes beyond `da37bc9bb`. Additional source weight is +production +23/-6 and tests +112/-0, with documentation separate. Evidence logs: +`review-da37-core-green.log`, `review-da37-react-green.log`, +`review-da37-lint.log`, and `review-da37-size.log`. + +| Item | Technical judgment | Action and durable value | +| --- | --- | --- | +| CR3-01: unresolved resumption/cleanup edge, without established impact | Plausible concern. Same-path probes establish stalled replacement and unintended restart. Concrete diagnoses are S03/S04 below. | `fixed-now`; primary surviving-owner grammar and ownership-transfer repair. | +| CR3-02: docstring score 16.67%, below the bot's 80% threshold; generate docstrings | Reported default metric, with no named function or missing contract. Neither AGENTS.md nor ESLint imposes that threshold. The summary says no repository configuration applied. The score establishes no repository defect. | `refuted` as a required PR repair. Preserve the metric and documentation idea here. Existing contract prose remains required and was updated. No comments were generated only for the score. | +| CR3-03: no actionable comments, covered head da37, minimal risk, automatic pause | Exact covered commit verified. Risk classification is opinion and does not override F02 or the executed cleanup counterexample. | `duplicate` provenance retained here, without extra correctness authority. | +| S03: startup cleanup loses a surviving acquiring owner's demand | Confirmed on-demand progress failure after explicit restart. | `fixed-now`; current ownership and demand commitment survive the transfer. | +| S04: first-demand cleanup is followed by implicit startup | Confirmed ordering failure with an already-running local query. | `fixed-now`; existing sync-run generation fences admission. | + +New-summary accounting: three raw items = one fixed-now + one refuted repository- +requirement claim + one duplicate provenance entry. S03/S04 remain separate +violations. No finding is deferred. Reviewer recommendation for this summary +alone: insufficient evidence for a hire decision. It preserved a useful concern, +but gave no trace and missed the demonstrated consequence. + +ORC-001–014: authority, limits, independent handle judgment, literate layers, +real entry points and immediate/settled comparisons are stated above and beside +the grammar. New cases are bounded enumeration, so generated controls and new +campaign/replay interfaces are not triggered (004/007). Existing fixed/random +campaigns remain unchanged and green. No reference state was added (008). +Glossary terms and the existing projection remain unchanged (009). Controlled +teardown removes callbacks before cleanup, with the preceding failure-fidelity +limits (010). No concrete shared semantic fault calls for a second formulation +(011). Original and narrow-mutant assertion failures calibrate both ownership +retention and startup fencing (006/013). This versioned appendix and the source +identities below supply 012. These cases claim controlled core callbacks only, +so no additional real-provider/framework handoff is claimed (014). The React +run is compatibility evidence, not a receiving witness for this new premise. + +The law is encoded and asserted across the stated 192 histories, including +no-acquisition and explicit-listener-restart neighbors. No counterexample remains +within this declared boundary. Arbitrary recursive cleanup, asynchronous +provider work before acquisition and other framework schedules remain outside +these finite histories. The primary owner's coverage-map entry retains those +wider witnesses. No universal history or provider-shutdown proof is claimed. + +Source identities for this follow-up on `da37bc9bb` (SHA-256): + +| File | SHA-256 | +| --- | --- | +| `packages/db/src/collection/changes.ts` | `4d0849637a03e8012ee965191d9ed9332957e0bdf07b19d25d4deb0e400e9d34` | +| `packages/db/tests/live-query-deferred-acquisition-oracle.test.ts` | `0198add12e3c185aaa70a378bdfe115632686f864a178a42cbaa934809d387bd` | diff --git a/packages/db/src/collection/changes.ts b/packages/db/src/collection/changes.ts index eefdd50794..606f0665b9 100644 --- a/packages/db/src/collection/changes.ts +++ b/packages/db/src/collection/changes.ts @@ -380,7 +380,7 @@ export class CollectionChangesManager< /** Whether this Collection had a subscriber or a preload in this sync run. */ public hasSubscriberOrPreload(): boolean { - return this.subscriberOrPreload + return this.subscriberOrPreload || this.acquiringSubscribersCount > 0 } /** Listen for the first subscriber or preload in the current sync run. */ @@ -417,8 +417,11 @@ export class CollectionChangesManager< } private startSyncForSubscriber(): void { + const syncRunGeneration = this.sync.getSyncRunGeneration() // Mark first, so a sync run that starts now builds non-deferred demand. this.markSubscriberOrPreload() + // A first-demand callback may itself retire this start. + if (syncRunGeneration !== this.sync.getSyncRunGeneration()) return this.startSyncIfStopped() // Committed demand releases even an abandoned render's sync-start gate. this.sync.resumeStart() @@ -428,6 +431,11 @@ export class CollectionChangesManager< const previous = this.acquiringSubscribersCount this.acquiringSubscribersCount += delta this.events.emitSubscribersChange(this.acquiringSubscribersCount, previous) + // Cleanup during startup can clear the earlier demand mark. Commit it for + // surviving owners, including a replacement started by a cleanup listener. + if (delta === 1 && this.acquiringSubscribersCount > 0) { + this.markSubscriberOrPreload() + } } private startSyncIfStopped(): void { @@ -447,9 +455,9 @@ export class CollectionChangesManager< */ private addSubscriber(defersAcquisition: boolean): void { if (!defersAcquisition) this.lifecycle.assertCanStartSync() - const previousSubscriberCount = this.activeSubscribersCount this.activeSubscribersCount++ this.lifecycle.cancelGCTimer() + let acquiring = false try { if (!defersAcquisition) { @@ -460,16 +468,25 @@ export class CollectionChangesManager< ) { this.startSyncIfStopped() } + // A deferring subscriber asks for no data, so adapters do not see it. + if (!defersAcquisition) { + acquiring = true + this.changeAcquiringSubscribers(1) + } } catch (error) { - this.activeSubscribersCount = previousSubscriberCount + this.activeSubscribersCount-- + if (acquiring) { + try { + this.changeAcquiringSubscribers(-1) + } catch { + // Keep the setup failure primary if a release listener also throws. + } + } if (this.activeSubscribersCount === 0) { this.lifecycle.startGCTimer() } throw error } - - // A deferring subscriber asks for no data, so adapters do not see it. - if (!defersAcquisition) this.changeAcquiringSubscribers(1) } /** diff --git a/packages/db/tests/live-query-deferred-acquisition-oracle.test.ts b/packages/db/tests/live-query-deferred-acquisition-oracle.test.ts index 3d92cac14e..0043af772d 100644 --- a/packages/db/tests/live-query-deferred-acquisition-oracle.test.ts +++ b/packages/db/tests/live-query-deferred-acquisition-oracle.test.ts @@ -1103,6 +1103,118 @@ describe(`DbClient preload releases independent source starts`, () => { * clean up while subscribed, restart, then settle. */ describe(`a subscriber that outlives cleanup`, () => { + // Admission may still be transferring ownership when a source status callback + // cleans up the live-query Collection. Cleanup retires that start, not the + // surviving logical subscription. It must not restart before an explicit restart request. + // The model counts a returned acquiring handle as demand for the next run; + // a still-deferred handle contributes none. This extends the settled-cleanup + // histories below with the construction boundary, both shapes and depths. + it.each( + ([`eager-idle`, `on-demand-idle`] as const).flatMap((state) => + QUERY_SHAPES.flatMap((shape) => + ([1, 2] as const).flatMap((depth) => + ([`ordinary`, `resumed`, `deferred`] as const).flatMap((reader) => + [false, true].flatMap((renderGate) => + ([`loading`, `ready`] as const).flatMap((cleanupAt) => + [false, true].map((restartFromCleanup) => ({ + state, + shape, + depth, + reader, + renderGate, + cleanupAt, + restartFromCleanup, + })), + ), + ), + ), + ), + ), + ), + )( + `$state $shape depth=$depth $reader gate=$renderGate survives $cleanupAt cleanup during admission (restart=$restartFromCleanup)`, + async ({ + state, + shape, + depth, + reader, + renderGate, + cleanupAt, + restartFromCleanup, + }) => { + const expected = { + acquiring: reader === `deferred` ? 0 : 1, + ready: reader !== `deferred` || state === `eager-idle`, + acquires: reader !== `deferred` && state === `on-demand-idle`, + } + const { collection: source, counts } = makeSource(state) + const { outer, collections } = makeLiveQuery(source, shape, depth) + if (renderGate) expect(outer._deferSyncStart()).toBe(true) + let subscription: ReturnType | undefined + if (reader !== `ordinary`) { + subscription = outer.subscribeChanges(() => {}, { + deferAcquisition: true, + }) + } + let pending: Promise | undefined + let reached = false + const retiredStatuses: Array = [] + const stopTarget = outer.on(`status:change`, ({ status }) => { + if (reached) { + retiredStatuses.push(status) + if (restartFromCleanup && status === `cleaned-up`) + outer.startSyncImmediate() + } + }) + const stopSource = source.on(`status:change`, ({ status }) => { + if (status === cleanupAt && !reached) { + reached = true + pending = outer.cleanup() + } + }) + try { + if (reader === `ordinary`) + subscription = outer.subscribeChanges(() => {}) + else if (reader === `resumed`) subscription!.resumeDeferredAcquisition() + else source.startSyncImmediate() + expect(reached, `source startup reached cleanup`).toBe(true) + await pending + if (!restartFromCleanup) { + expect(outer.status, `cleanup must not retry admission`).toBe( + `cleaned-up`, + ) + expect(retiredStatuses).toEqual([`cleaned-up`]) + } else { + expect(retiredStatuses[0]).toBe(`cleaned-up`) + expect( + retiredStatuses.filter((status) => status === `loading`), + ).toHaveLength(1) + } + expect(outer.subscriberCount).toBe(expected.acquiring) + stopSource() + stopTarget() + outer.startSyncImmediate() + await settle() + expect(counts.acquisitions > 0, `replacement has acquisition`).toBe( + expected.acquires, + ) + expect(outer.status === `ready`, `replacement readiness`).toBe( + expected.ready, + ) + expect(outer.toArray.map((row) => row.id)).toEqual( + expected.ready ? EXPECTED_IDS[shape] : [], + ) + expect(outer.subscriberCount).toBe(expected.acquiring) + } finally { + stopSource() + stopTarget() + subscription?.unsubscribe() + for (const collection of collections) await collection.cleanup() + await source.cleanup() + } + }, + ) + for (const state of [ `eager-idle`, `on-demand-idle`, From 38eb2831f6005faa51f4021f0c29dd532c3a6cf4 Mon Sep 17 00:00:00 2001 From: Test Date: Thu, 22 Oct 2026 18:17:37 -0600 Subject: [PATCH 08/20] test(db): classify replay demand by subscription ownership --- docs/contributing/oracle-coverage.md | 11 ++ .../2026-10-22-dbclient-migration-feedback.md | 88 ++++++++++++++ ...ad-subset-replay-refinement-oracle.test.ts | 109 ++++++++++++++++-- 3 files changed, 196 insertions(+), 12 deletions(-) diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index c81d4b0d91..bb98026395 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -324,6 +324,17 @@ retired-route publication; and checks before, during, and after-commit abort boundaries with applied receipts. These fixtures do not establish real-provider cancellation or framework timing. +The ordered replay owner distinguishes replay renewal from an ordinary lazy +demand created during another subscription's replay. Its adapter records the +leases held at truncate, then matches subscription identity and independently +evaluated key predicates once per renewed lease. New owners and different keys +remain ordinary demand. Terminal-error assertions run after each history action +and at convergence. Named neighbors require a new include demand's failure to +be terminal and a settled joined demand's failed replay to remain nonterminal. +The CI seed `-1751969525`, path `67:11:10:12:1:8:7:7`, reaches the former. +This projection covers the grammar's five numeric keys and one-level includes; +it does not establish arbitrary predicates or provider-driven replay schedules. + The ordered-work owner has a 96-cell optimistic-departure matrix over source, joined, and anti-joined filters; update and delete; Collection and Effect; acknowledgement and rollback; one- and two-row windows; and indexed and diff --git a/docs/contributing/oracle-reviews/2026-10-22-dbclient-migration-feedback.md b/docs/contributing/oracle-reviews/2026-10-22-dbclient-migration-feedback.md index 57aeef23c2..b789b00e69 100644 --- a/docs/contributing/oracle-reviews/2026-10-22-dbclient-migration-feedback.md +++ b/docs/contributing/oracle-reviews/2026-10-22-dbclient-migration-feedback.md @@ -942,3 +942,91 @@ Source identities for this follow-up on `da37bc9bb` (SHA-256): | --- | --- | | `packages/db/src/collection/changes.ts` | `4d0849637a03e8012ee965191d9ed9332957e0bdf07b19d25d4deb0e400e9d34` | | `packages/db/tests/live-query-deferred-acquisition-oracle.test.ts` | `0198add12e3c185aaa70a378bdfe115632686f864a178a42cbaa934809d387bd` | + + +## Final-head CI: subscription-specific replay classification + +Core shard 1 at `e852b0accf9971f61348066277290b010a7b016e` found CI-01 in +`replay.ordered-window-faults`: seed `-1751969525`, shrink path +`67:11:10:12:1:8:7:7`. The five-action reduction holds work, moves row 1 to +rank 1, truncates markers, then settles one request successfully and one with +failure. The query is ascending, offset zero, limit one, with an anti-join, +on-demand markers and an inline include. No peer participates. + +### Law and competing accounts + +A failed ordinary lazy demand is terminal. A failed truncate replay retains the +last complete publication until a later authoritative replay succeeds. Authority +is the architecture's demand, replay and publication contracts and this owner's +stated terminal-error law. Those contracts apply to each subscription's demand; +a shared source's replay does not classify every other subscription's load. + +The existing grammar already generated the case. Its source-wide +`markerReplaying` flag mistook a new include subscription's first request for +renewal of an older demand. The real adapter diagnostic reaches a new lexical +subscription without a replay hold. Its ordinary request fails and the query +correctly enters terminal error. The oracle rejected that allowed result. +The same exact seed fails with all six changed production files restored to +`16824f67`, so this classifier defect predates the eight-item repairs. + +An initial hypothesis blamed production's lazy-demand failure path. A guard +experiment did not repair the failure and was removed. It supplies no GREEN +credit. A trial that inspected a nonpublic Collection property could not +classify provider ownership and was also discarded. A test-name selection that +matched zero tests supplies no evidence. Final evidence below uses only adapter +inputs, recorded lease ownership, semantic predicates and public query status. + +### Model, observations and calibration + +Before truncate, the adapter records its held leases. A new acquisition renews +one captured lease only when its subscription token and independently evaluated +key predicate match. Each lease is consumed once. A new owner or different keys +remain ordinary demand even during another owner's replay. Separate source and +marker snapshots retain the provider boundary; a WeakSet records which adapter +received each options object. The five-key grammar makes predicate membership a +complete finite demand projection here. No production replay classifier or +private state computes the expected result. + +Terminal-error assertions now run after each history action, before final +recovery, as well as at convergence. The named CI reduction requires terminal +error. A settled joined-demand replay neighbor requires nonterminal recovery. +Existing fixed and random campaigns use the unchanged generators and 300-run +budget. The direct seed/path replay still runs without a normal campaign. + +| Experiment | Result | Log in existing scratch root | +| --- | --- | --- | +| Original final-head seed replay | One convergence assertion fails; 23 controls pass | final-db1-replay-current.log | +| Reviewed-head production replay | Same convergence assertion fails; 23 controls pass; all six files byte-restored | final-db1-replay-16824.log | +| Added intermediate cut and pinned reduction, old classifier | Two terminal-error assertions fail; 23 controls pass | final-db1-expanded-red.log | +| Subscription-specific classifier, direct replay | 26 tests pass, no type errors | final-db1-classifier-second.log | +| Owner-blind classifier mutant | New-include assertion fails, replay neighbor passes; no type or setup error | final-db1-mutant-owner-blind.log | +| Production mutant makes every subset error terminal | Replay-neighbor assertion fails, ordinary-failure neighbor passes; no type or setup error | final-db1-mutant-fatal-replay.log | +| Final surrounding selection | Three files, 962 tests pass; includes both normal campaigns; no type errors | final-db1-classifier-surrounding-final.log | + +All mutant source bytes were restored and verified. This correction changes no +production file. The original eight-item dispositions and S01–S04 remain intact. +CI-01 is `fixed-now` as an oracle-classification defect, with the generated +witness, distinguishing neighbors and calibration retained in the primary owner. +No product contract or approved cleanup policy changed. + +ORC-001–014: the law and finite projection are stated above and in the owner; +expected classification uses adapter-observed ownership and an independent +predicate evaluator (001–003/009). Existing generated domains, exclusions, +fixed/random parity and replay interface are unchanged; the CI reduction and +settled-demand neighbor supply reconstruction and ablation controls (004/007). +The driver invokes real Collections and checks status at each action and +convergence, with no missing-input hypothesis for this controlled boundary +(005/013). The two assertion failures calibrate owner identity and replay +terminality separately (006). Captured ownership and provider identity are +necessary to distinguish a new demand from renewal of an existing one (008). +Existing cleanup fidelity is unchanged; experimental mutations restore bytes +(010). No additional shared-fault hypothesis calls for a second formulation +(011). This versioned record and source identity supply 012. No provider or +framework handoff claim is made (014). + +Limits remain five numeric keys, one-level includes and controlled adapter +schedules. This is self-review. The coverage map names the expanded observation +cuts. F02's initial-snapshot decision and F07's application fixture remain +separate readiness obligations. + +Oracle source SHA-256 at this follow-up: `86f652fadc3ddeddfd5188d18b049969293209efdda51641e28ec38122884ea2`. diff --git a/packages/db/tests/query/load-subset-replay-refinement-oracle.test.ts b/packages/db/tests/query/load-subset-replay-refinement-oracle.test.ts index 32ec955ad6..d9d2d4158a 100644 --- a/packages/db/tests/query/load-subset-replay-refinement-oracle.test.ts +++ b/packages/db/tests/query/load-subset-replay-refinement-oracle.test.ts @@ -1100,7 +1100,9 @@ describe(`loadSubset replay refinement`, () => { * first becomes ready is fatal too (an initial load failure), except on * a lazy join source, which owns its demand. So a query may end in * `error` only after a demand load issued outside a replay failed, or a - * load failed while the query was still loading. + * load failed while the query was still loading. Replay renews demand held + * by that subscription before the truncate. A new subscription's first + * demand remains ordinary even if another subscription is replaying. * - **Status:** with no failed load, a quiet live query and its peer are * `ready`; a release that throws does not change status. * - **Reported faults:** an injected fault may surface as a rejected load, a @@ -1335,6 +1337,7 @@ async function checkOrderedReplay( type Load = { epoch: number; outcome: `pending` | `ok` | `failed` } const loads: Array = [] const acquired = new Map() + const markerRequests = new WeakSet() const held: Array<(ok: boolean) => void> = [] let holding = false let truncates = 0 @@ -1345,8 +1348,37 @@ async function checkOrderedReplay( let truncating = false let anyLoadFailed = false let earlyThrowSinceTruncate = false - // A marker truncate's commit starts its replay loads synchronously. - let markerReplaying = false + // Replay renews the leases held at its boundary. The adapter's subscription + // token identifies an owner; evaluating its immutable predicate on this + // finite key domain identifies the demand independently of production replay + // state. Consume each captured lease once, so a new owner or different keys + // cannot inherit another demand's replay classification. + const demandKey = (options: LoadSubsetOptions, marker: boolean) => + JSON.stringify( + [1, 2, 3, 4, 5].filter((id) => + options.where + ? evaluateReferenceExpression( + options.where, + marker ? { id, rowId: id } : truth.get(id)!, + ) === true + : true, + ), + ) + const replayDemands = { + source: [] as Array, + marker: [] as Array, + } + const isReplayDemand = (options: LoadSubsetOptions, marker: boolean) => { + const previous = marker ? replayDemands.marker : replayDemands.source + const index = previous.findIndex( + (previousOptions) => + previousOptions.subscription === options.subscription && + demandKey(previousOptions, marker) === demandKey(options, marker), + ) + if (index < 0) return false + previous.splice(index, 1) + return true + } let demandFailed = false // A failed initial load is fatal too (error 229). The queries are created // below; a load can only fail after they start. @@ -1426,11 +1458,13 @@ async function checkOrderedReplay( // Marker loads share the history's held and failing loads, so // joined work can stay pending or fail. loadSubset: async (options) => { + const replay = isReplayDemand(options, true) + markerRequests.add(options) acquired.set(options, (acquired.get(options) ?? 0) + 1) const load: Load = { epoch: truncates, outcome: `pending` } loads.push(load) reach.markerLoads++ - const demand = !markerReplaying && isDemand(options) + const demand = !replay && isDemand(options) if (holding) { const ok = await new Promise((resolve) => held.push(resolve), @@ -1491,10 +1525,11 @@ async function checkOrderedReplay( sync.markReady() return { loadSubset: async (options) => { + const replay = isReplayDemand(options, false) acquired.set(options, (acquired.get(options) ?? 0) + 1) const load: Load = { epoch: truncates, outcome: `pending` } loads.push(load) - const demand = !truncating && isDemand(options) + const demand = !replay && isDemand(options) if (holding) { const ok = await new Promise((resolve) => held.push(resolve), @@ -1661,6 +1696,11 @@ async function checkOrderedReplay( throw new Error(`held loads did not drain`) } const truncate = async () => { + replayDemands.source = [...acquired].flatMap(([options, count]) => + count > 0 && !markerRequests.has(options) + ? Array.from({ length: count }, () => options) + : [], + ) truncates++ earlyThrowSinceTruncate = false if (held.length > 0) reach.heldAcrossTruncate++ @@ -1679,6 +1719,11 @@ async function checkOrderedReplay( // An eager marker source rewrites its rows; an on-demand one replays the // demand it retains. const truncateMarkers = async () => { + replayDemands.marker = [...acquired].flatMap(([options, count]) => + count > 0 && markerRequests.has(options) + ? Array.from({ length: count }, () => options) + : [], + ) installedMarkers.clear() markerSync.begin() markerSync.truncate() @@ -1688,13 +1733,7 @@ async function checkOrderedReplay( installedMarkers.add(id) } } - markerReplaying = true - let receipt: true | Promise - try { - receipt = markerSync.commit() - } finally { - markerReplaying = false - } + const receipt = markerSync.commit() if (receipt !== true) await receipt } try { @@ -1819,6 +1858,15 @@ async function checkOrderedReplay( swallow(error) } await flushPromises() + replayDemands.source = [] + replayDemands.marker = [] + // Classify terminal errors at the failing history cut, before final + // recovery can hide an incorrect transition. + for (const shown of queries) { + if (shown.status === `error`) { + expect(fatalLoadFailed(), `history: terminal error`).toBe(true) + } + } } holding = false throwNextUnload = false @@ -2027,6 +2075,39 @@ describe(`Ordered windows across truncate replays`, () => { }, reaches: `terminalErrors`, }, + { + name: `a new include demand during another subscription's replay`, + history: [ + { type: `hold` }, + { type: `move`, id: 1, rank: 1 }, + { type: `truncateMarkers` }, + { type: `settle`, ok: true }, + { type: `settle`, ok: false }, + ], + scenario: { + ...ordinaryOrderedScenario, + limit: 1, + joined: `none`, + markerMode: `on-demand`, + includes: true, + }, + terminal: true, + }, + { + name: `a failed replay of settled joined demand`, + history: [ + { type: `hold` }, + { type: `truncateMarkers` }, + { type: `settle`, ok: false }, + ], + scenario: { + ...ordinaryOrderedScenario, + limit: 1, + joined: `some`, + markerMode: `on-demand`, + }, + terminal: false, + }, { name: `two window moves while a load is held`, history: [ @@ -2086,10 +2167,14 @@ describe(`Ordered windows across truncate replays`, () => { history: Array scenario?: OrderedScenario reaches?: keyof OrderedReach + terminal?: boolean }>)( `keeps the ordered window for $name (generated replay)`, async ({ history, scenario, ...replay }) => { const reach = await checkOrderedReplay(history, scenario) + if (`terminal` in replay) { + expect(reach.terminalErrors > 0).toBe(replay.terminal) + } const reaches: keyof OrderedReach | undefined = `reaches` in replay ? replay.reaches : undefined if (reaches) expect(reach[reaches], reaches).toBeGreaterThan(0) From a39a07d216b0d241be4a4144769e6b9ffd307498 Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 23 Oct 2026 09:18:59 -0600 Subject: [PATCH 09/20] test(db): enforce generated replay demand reach --- docs/contributing/oracle-coverage.md | 16 ++ .../2026-10-22-dbclient-migration-feedback.md | 152 ++++++++++++++++ ...-query-deferred-acquisition-oracle.test.ts | 4 +- packages/db/tests/oracle-config.ts | 1 + packages/db/tests/oracle-reach.test.ts | 32 ++++ packages/db/tests/oracle-reach.ts | 62 +++++++ ...ad-subset-replay-refinement-oracle.test.ts | 170 +++++++++++++++++- 7 files changed, 426 insertions(+), 11 deletions(-) create mode 100644 packages/db/tests/oracle-reach.test.ts create mode 100644 packages/db/tests/oracle-reach.ts diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index bb98026395..cc132c496a 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -332,6 +332,17 @@ remain ordinary demand. Terminal-error assertions run after each history action and at convergence. Named neighbors require a new include demand's failure to be terminal and a settled joined demand's failed replay to remain nonterminal. The CI seed `-1751969525`, path `67:11:10:12:1:8:7:7`, reaches the former. +The normal broad campaigns did not each reach both critical failure origins +in the declared-reach audit. A second property in the same owner, +`replay.subscription-demand-origin`, generates zero to three prior replays, +marked-row rank moves and ordinary/renewal failure schedules. Its 75-history +fixed and random campaigns must each reach new demand failing during marker +replay and a settled demand's failed renewal. The adapter witnesses non-aborted +failure independently of expected terminal status. A positive terminality check +rejects ignored ordinary failures. Source-wide, subscription-blind and +always-terminal controls also fail. Broad reach counts each of its existing +seventeen required dimensions once per history. The [declared-reach audit](oracle-reviews/2026-10-22-dbclient-migration-feedback.md#audit-against-the-declared-reach-rules-from-pr-2233) +records counts, exact replay coordinates, calibration and chronology limits. This projection covers the grammar's five numeric keys and one-level includes; it does not establish arbitrary predicates or provider-driven replay schedules. @@ -575,6 +586,11 @@ receiving owner once the product chooses automatic result capture or an initial empty/loading policy; partial nonready rows, status flags, normalized-source transport, mixed Suspense/plain schedules, and the real Start/Electric path remain needed witnesses. This diagnostic is not claimed as repaired coverage. +The Date descriptor typing report remains an evidence gap. The reliable strict +compiler probe accepts the reported Date forms. The receiving owners are +`packages/db/tests/db-client.test-d.ts` and +`packages/electric-db-collection/tests/electric.test-d.ts`; extending them needs +the failing application assignment and its resolved dependency versions. Its no-client diagnostic also covers explicit `queryKey` and legacy dependency identity for both `useLiveQuery` and `useLiveSuspenseQuery` at first consumption. A local hook-first SSR stream over a concrete diff --git a/docs/contributing/oracle-reviews/2026-10-22-dbclient-migration-feedback.md b/docs/contributing/oracle-reviews/2026-10-22-dbclient-migration-feedback.md index b789b00e69..024c89752c 100644 --- a/docs/contributing/oracle-reviews/2026-10-22-dbclient-migration-feedback.md +++ b/docs/contributing/oracle-reviews/2026-10-22-dbclient-migration-feedback.md @@ -1030,3 +1030,155 @@ cuts. F02's initial-snapshot decision and F07's application fixture remain separate readiness obligations. Oracle source SHA-256 at this follow-up: `86f652fadc3ddeddfd5188d18b049969293209efdda51641e28ec38122884ea2`. + + +## Audit against the declared-reach rules from PR #2233 + +This self-review starts at `38eb2831f6005faa51f4021f0c29dd532c3a6cf4`. +It uses rule proposal `91ee6db4f1e37fffb44ca4432d01e27c63bed45c`, now merged +as `65d9ada508114f199464f753d50eb3447eb0eaf9`. The audit changes tests and +coverage evidence only. It preserves the maintainer's cleanup-rejection decision. +The scratch ledger and raw logs are in `/tmp/tanstack-rule-2233/`. + +### Law, experiment and inference + +**Law.** An ordinary lazy demand failure is terminal. A settled demand's failed +truncate renewal preserves the last complete publication and permits a later +authoritative replay. Authority is the live-query architecture's demand and +replay-participant rules, publication law 9, and this owner's terminal-error +contract. Subscription identity and immutable predicate membership distinguish +renewal from new demand; sharing a Collection does not establish renewal. + +**Reach.** The existing 300-history fixed campaign reached zero failures of new +demand during marker replay and one failed settled renewal. A calibrated random +campaign reached zero of either. Both fail when both states are required. +The old grammar can express the CI counterexample, but its normal budget does +not reliably enforce that interaction. More named examples would not fix this +missing sampling dimension. + +The primary replay owner now runs `replay.subscription-demand-origin`: 75 fixed +histories with seed `2233`, then 75 random histories with the same grammar and +judgment. It varies ordinary versus renewal origin, zero to three prior +successful replays, a marked row from 1/3/4/5, and its rank from 1 to 9. +The ordinary branch holds work, moves the row, truncates markers, succeeds one +held request, then fails the next. The renewal branch holds, truncates, and fails +the held renewal. Both use the existing production driver and independent +five-key predicate projection. Actual adapter entry, prior demand, public +readiness and a non-aborted failure establish reach, independently of the +expected terminal status. Named neighbors calibrate the recorder. + +The original broad grammar, seed, 300-run budget and replay coordinates remain. +Its seventeen existing required dimensions now count histories, once per state, +instead of events. Both new states require at least one history per campaign. +Explicit seed/path replay skips campaign minima. Public terminal status is +checked after actions and at convergence. A new positive assertion also requires +an observed ordinary failure to become terminal at convergence. + +**RED.** `replay-broad-reach-calibrated-red.log` records both broad campaigns +failing their added critical reach requirements on unchanged production. +This is a reach failure, not a product defect. The initial +`replay-reach-original.log` used an uncalibrated new-subscription witness that +missed the named regression. It supplies no reliable zero-reach claim and is +superseded. A new logical demand can use an already-seen subscription token. +The calibrated recorder checks demand renewal rather than token novelty alone. + +**GREEN.** `final-owners-green.log` passes 1,006 tests in eight files, with no +type errors. It includes the replay owner, reach helper, subscription lifecycle, +cleanup, deferred acquisition, observer construction/publication, partition +release and DbClient tests. Scoped ESLint passes. Production source is unchanged +in this audit. Earlier full core and React counts remain evidence at their +recorded heads, not new runs against an integrated tree. + +Each count below is the number of histories in that normal GREEN run. + +| Property / declared state | Fixed | Random | +| --- | ---: | ---: | +| `replay.ordered-window-faults` / `settlementThrows` | 12 | 23 | +| `replay.ordered-window-faults` / `throwsAfterReplay` | 50 | 53 | +| `replay.ordered-window-faults` / `replayStartThrows` | 56 | 64 | +| `replay.ordered-window-faults` / `heldAcrossTruncate` | 26 | 19 | +| `replay.ordered-window-faults` / `failedLoads` | 9 | 6 | +| `replay.ordered-window-faults` / `settledWindowChecks` | 246 | 238 | +| `replay.ordered-window-faults` / `tiedWindows` | 15 | 13 | +| `replay.ordered-window-faults` / `peerChecks` | 127 | 107 | +| `replay.ordered-window-faults` / `windowMoves` | 92 | 86 | +| `replay.ordered-window-faults` / `descendingRuns` | 139 | 149 | +| `replay.ordered-window-faults` / `joinedRuns` | 225 | 238 | +| `replay.ordered-window-faults` / `includeChecks` | 152 | 149 | +| `replay.ordered-window-faults` / `markChanges` | 87 | 79 | +| `replay.ordered-window-faults` / `antiJoinedRuns` | 76 | 87 | +| `replay.ordered-window-faults` / `innerJoinedRuns` | 81 | 74 | +| `replay.ordered-window-faults` / `markerLoads` | 132 | 120 | +| `replay.ordered-window-faults` / `markerTruncates` | 80 | 71 | +| `replay.subscription-demand-origin` / `new-demand-failed-during-marker-replay` | 9 | 10 | +| `replay.subscription-demand-origin` / `settled-marker-demand-replay-failed` | 32 | 38 | + +**Mutant.** The generated focused campaigns reject all four semantic controls +at assertions, with no setup, timeout or type failure: + +| Exact semantic edit | Distinguishing assertion | Evidence log | +| --- | --- | --- | +| Classify marker loads from the source-wide synchronous truncate flag | Illegal terminal error after ordinary demand is mislabeled renewal | `replay-mutant-source-wide.log` | +| Remove subscription identity from the captured-demand match, retaining predicates | Same illegal terminal classification | `replay-mutant-no-subscription.log` | +| Make `recordSubsetError` terminal regardless of `fatalBeforeReady` | Settled renewal must remain nonterminal | `replay-mutant-fatal-renewal.log` | +| Remove `failDemand`'s terminal transition | Ordinary failure must be terminal: zero observed versus one required | `replay-mutant-ignore-ordinary-failure.log` | + +The first control shrinks at seed `2233`, path `1:0`, to ordinary origin, zero +previous replays, row 1 and rank 1. Direct replay rejects that mutant and passes +after byte restoration (`replay-direct-mutant-red.log`, +`replay-direct-green.log`). The all-terminal control shrinks at the same seed, +path `2:1:1:1`, to renewal with zero previous replays. Removing the rank move +from the grammar leaves ordinary-demand reach at zero and fails both campaign +minima (`replay-mutant-no-rank-move.log`). All mutated files were restored in +`finally`, with byte identity verified. The new positive assertion was added +before its ignored-failure production mutant ran. + +**Limits.** Five numeric keys, one-level inline includes, one live query in the +focused grammar, ascending offset-zero limit-one windows and controlled adapter +settlement. The unchanged broad grammar covers other window/order/peer forms. +Neither property proves arbitrary predicates, actual provider cancellation, +unbounded replay histories or framework scheduling. The coverage map names the +same primary owner and these remaining receiving boundaries. + +### Audit of the seven shortcuts + +| Obligation | Result and remaining qualification | +| --- | --- | +| A01 production-first | No new production repair in this pass. Earlier entries retain their recorded finite-oracle REDs and original/narrow-mutant evidence. The new generated reach was added after those historical product commits. It cannot establish historical generated-oracle-first chronology. Under a literal requirement for generated RED before every historical repair, that chronology remains a deviation. A retrospective run cannot erase it. | +| A02 model authority | No reference semantics changed in this pass. Earlier rejection, explicit-restart and terminal-Effect rules follow the approved cleanup decision. The stream-preload expectation follows demand law 15, while server rows remain protected until local acquisition settles. Replay classification follows captured logical demand, not observed terminal output. F02's competing snapshot policies remain undecided. | +| A03 unmeasured reach | Confirmed gap in the normal replay campaigns, repaired with a focused process grammar, mandatory minima and reported history counts. Broad publication campaigns in other owners remain compatibility evidence only for the new construction cuts. | +| A04 quiet exclusions | No comparison was relaxed. Existing broad aborted-move and terminal-error reach exceptions remain explicit, with named witnesses. The new property requires the two terminality premises. The deferred owner's unchecked status/rows apply only while no live-query sync run exists, not after an owed ready result. Changed cancellation fixtures retain queued-load and release assertions. No new product exclusion or retired promise was introduced. | +| A05 pinned-only coverage | Replay terminality needed a focused generated interaction and now has one. The other review repairs use bounded enumerations: admission/status/mode, six promise outcomes, 28 retirement forms, 192 interrupted ownership transfers, eight observer construction forms plus two healthy-peer seed cases, twelve partition forms plus two startup cases, and twelve late-Effect receiving cases. Those are finite boundary matrices with original or hostile controls. They do not claim arbitrary generated construction histories. The deferred owner now calls its eleven command histories finite. | +| A06 older counterexamples | F02 remains a demonstrated warm-source SSR mismatch with a real hydrateRoot trace and a pending initial-snapshot decision. Its descriptor receiving owner and missing witnesses remain in the coverage map. F07 remains an unconfirmed Date typing report whose reliable strict compiler probe passes. It needs the failing application assignment and resolved dependency versions. Neither is dismissed because it predates this audit. | +| A07 no failing mutant | Earlier finite owners retain the exact assertion controls documented above. The new generated property rejects source-wide, identity-blind, always-terminal and ignored-ordinary-failure designs, plus grammar ablation. No setup-only failure receives calibration credit. | + +These results preserve the eight-item ledger: five fixed-now and three +accepted-design, with S01–S04 separately fixed in their declared finite bounds. +The audit adds a reach-enforcement repair, not another product-policy change. +Whole-PR readiness remains **not ready** because F02 needs a product decision. +F07 remains an evidence gap. Green finite evidence does not resolve either. + +### ORC-001–015 result for this repair + +Authority, limits and the unchanged independent projection satisfy 001/002. +Prose, grammar, driver and comparison remain beside executable code (003/009). +The shrunk ordinary and renewal histories reconstruct the known witnesses. +Removing rank movement loses the ordinary premise. Rank bounds avoid existing +starting ranks, and the constructive grammar places failure after holding work +rather than requesting failure without a pending request (004). Real Collection +entry points, action cuts and convergence supply 005/013. The four assertion +controls and reach ablation supply 006. Equal fixed/random budgets and the +checked direct replay supply 007. No model state or equivalence class changed +(008). Existing primary/secondary cleanup fidelity remains, and experimental +mutations restore exact bytes (010). No new shared semantic hypothesis calls +for a second formulation (011). This versioned record, starting revision and +source identities supply 012. Controlled-boundary claims require no new +provider handoff (014). Mandatory campaign minima and the table supply 015. + +SHA-256 identities for the repaired executable sources: + +| File | SHA-256 | +| --- | --- | +| `packages/db/tests/query/load-subset-replay-refinement-oracle.test.ts` | `34aec1038062a15957642c35de2fafe2576b2941a326f449c75b6a3b28cb9da1` | +| `packages/db/tests/oracle-config.ts` | `a3464344b04bfdc59bc4ab42489eeea540aed1461f9fb440e2da0f5b0a0b0c46` | +| `packages/db/tests/oracle-reach.ts` | `d38c86d0a2d197d566108cfb59cdd6cb624619df8ab570afa2c7e09b7f00ba63` | diff --git a/packages/db/tests/live-query-deferred-acquisition-oracle.test.ts b/packages/db/tests/live-query-deferred-acquisition-oracle.test.ts index 0043af772d..c749b29d82 100644 --- a/packages/db/tests/live-query-deferred-acquisition-oracle.test.ts +++ b/packages/db/tests/live-query-deferred-acquisition-oracle.test.ts @@ -1098,8 +1098,8 @@ describe(`DbClient preload releases independent source starts`, () => { * Cleanup ends a live-query Collection's sync run but keeps its subscriptions. * A subscriber that survives is still a subscriber in the next sync run, so a * restarted live-query Collection acquires for it at once instead of - * deferring until another subscriber or preload arrives. The generated grammar - * unsubscribes before cleanup, so this block pins the history: subscribe, + * deferring until another subscriber or preload arrives. The finite command + * histories above unsubscribe before cleanup. This block pins the history: subscribe, * clean up while subscribed, restart, then settle. */ describe(`a subscriber that outlives cleanup`, () => { diff --git a/packages/db/tests/oracle-config.ts b/packages/db/tests/oracle-config.ts index 8fe37b9d2e..997d131746 100644 --- a/packages/db/tests/oracle-config.ts +++ b/packages/db/tests/oracle-config.ts @@ -21,6 +21,7 @@ const staticOracleProperties = [ `query-db.refetch-ordered-prefix`, `ordered-loader.request-authority`, `replay.ordered-window-faults`, + `replay.subscription-demand-origin`, `oracle-replay.calibration`, `structural-identity.equal-representations`, `structural-identity.independent-values`, diff --git a/packages/db/tests/oracle-reach.test.ts b/packages/db/tests/oracle-reach.test.ts new file mode 100644 index 0000000000..51f6f08a35 --- /dev/null +++ b/packages/db/tests/oracle-reach.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest' +import { createOracleReach } from './oracle-reach.js' + +describe(`createOracleReach`, () => { + it(`fails for a declared state that no history entered`, () => { + const reach = createOracleReach(`example`, [`seen`, `unseen`] as const) + reach.beginHistory() + reach.reach(`seen`) + expect(() => reach.assertReached()).toThrow(/unseen \(0\)/) + }) + + it(`counts a history once per state`, () => { + const reach = createOracleReach(`example`, [`state`] as const, { + minimum: 2, + }) + reach.beginHistory() + reach.reach(`state`) + reach.reach(`state`) + expect(reach.counts().get(`state`)).toBe(1) + expect(() => reach.assertReached()).toThrow(/state \(1\)/) + reach.beginHistory() + reach.reach(`state`) + expect(() => reach.assertReached()).not.toThrow() + }) + + it(`rejects an undeclared state`, () => { + const reach = createOracleReach(`example`, [`state`] as const) + expect(() => reach.reach(`other` as unknown as `state`)).toThrow( + /undeclared reach state other/, + ) + }) +}) diff --git a/packages/db/tests/oracle-reach.ts b/packages/db/tests/oracle-reach.ts new file mode 100644 index 0000000000..9fa10c0cf3 --- /dev/null +++ b/packages/db/tests/oracle-reach.ts @@ -0,0 +1,62 @@ +import { readOracleRunConfig } from './oracle-config.js' + +/** + * Declared reach for a generated-history property. + * + * A law is enforced only in the states that generated histories reach. A + * property can pass any number of runs and still check nothing in the state + * its law is about, because the grammar, the driver, or the run budget never + * produces that state. Counting passing runs does not show reach. + * + * A property declares the states its laws depend on. Its driver calls + * `reach(state)` when a history enters one. After the property ran, + * `assertReached()` fails for every declared state that fewer than `minimum` + * histories entered. A history counts once per state, however often it + * enters it. + * + * A replay of one seed or path checks one history, not reach, so the check + * does nothing then. + */ +export function createOracleReach( + property: string, + states: ReadonlyArray, + { minimum = 1 }: { minimum?: number } = {}, +) { + const counts = new Map(states.map((state) => [state, 0])) + let current = new Set() + const { replaySeed, replayPath } = readOracleRunConfig() + const replaying = replaySeed !== undefined || replayPath !== undefined + return { + /** Starts one generated history. */ + beginHistory(): void { + current = new Set() + }, + /** The current history entered a declared state. */ + reach(state: TState): void { + if (!counts.has(state)) { + throw new Error(`${property}: undeclared reach state ${state}`) + } + if (current.has(state)) return + current.add(state) + counts.set(state, counts.get(state)! + 1) + }, + /** How many histories entered each declared state. */ + counts(): ReadonlyMap { + return counts + }, + /** Fails for each declared state that too few histories entered. */ + assertReached(): void { + if (replaying) return + const missed = [...counts] + .filter(([, count]) => count < minimum) + .map(([state, count]) => `${state} (${count})`) + if (missed.length > 0) { + throw new Error( + `${property}: generated histories reached these states fewer than ` + + `${minimum} times, so their laws were not enforced there: ` + + missed.join(`, `), + ) + } + }, + } +} diff --git a/packages/db/tests/query/load-subset-replay-refinement-oracle.test.ts b/packages/db/tests/query/load-subset-replay-refinement-oracle.test.ts index d9d2d4158a..99519a4894 100644 --- a/packages/db/tests/query/load-subset-replay-refinement-oracle.test.ts +++ b/packages/db/tests/query/load-subset-replay-refinement-oracle.test.ts @@ -13,6 +13,7 @@ import { BasicIndex } from '../../src/indexes/basic-index.js' import { BTreeIndex } from '../../src/indexes/btree-index.js' import { evaluateReferenceExpression } from '../reference-expression-oracle.js' import { flushPromises } from '../utils.js' +import { createOracleReach } from '../oracle-reach.js' import { oraclePropertyOptions, oracleRuns, @@ -1248,6 +1249,12 @@ type OrderedReach = { terminalErrors: number } +const replayDemandStates = [ + `new-demand-failed-during-marker-replay`, + `settled-marker-demand-replay-failed`, +] as const +type ReplayDemandState = (typeof replayDemandStates)[number] + const ordinaryOrderedScenario: OrderedScenario = { direction: `asc`, offset: 0, @@ -1262,6 +1269,7 @@ const ordinaryOrderedScenario: OrderedScenario = { async function checkOrderedReplay( history: Array, scenario: OrderedScenario = ordinaryOrderedScenario, + recordReach: (state: ReplayDemandState) => void = () => {}, ): Promise { const sign = scenario.direction === `asc` ? 1 : -1 let window = { offset: scenario.offset, limit: scenario.limit } @@ -1368,6 +1376,8 @@ async function checkOrderedReplay( source: [] as Array, marker: [] as Array, } + let markerTruncating = false + let hadMarkerDemand = false const isReplayDemand = (options: LoadSubsetOptions, marker: boolean) => { const previous = marker ? replayDemands.marker : replayDemands.source const index = previous.findIndex( @@ -1459,6 +1469,18 @@ async function checkOrderedReplay( // joined work can stay pending or fail. loadSubset: async (options) => { const replay = isReplayDemand(options, true) + // These are adapter-entry witnesses, independent of the expected + // terminal status. A new logical demand cannot renew a captured + // demand merely because both use the same source or subscription. + const newDemandDuringReplay = + !replay && + isDemand(options) && + markerTruncating && + hadMarkerDemand + const settledReplay = + replay && + queries.length > 0 && + queries.every(({ status }) => status === `ready`) markerRequests.add(options) acquired.set(options, (acquired.get(options) ?? 0) + 1) const load: Load = { epoch: truncates, outcome: `pending` } @@ -1470,6 +1492,12 @@ async function checkOrderedReplay( held.push(resolve), ) if (!ok) { + if (!options.signal?.aborted) { + if (newDemandDuringReplay) + recordReach(`new-demand-failed-during-marker-replay`) + if (settledReplay) + recordReach(`settled-marker-demand-replay-failed`) + } load.outcome = `failed` anyLoadFailed = true if (demand) demandFailed = true @@ -1724,6 +1752,7 @@ async function checkOrderedReplay( ? Array.from({ length: count }, () => options) : [], ) + hadMarkerDemand = replayDemands.marker.length > 0 installedMarkers.clear() markerSync.begin() markerSync.truncate() @@ -1733,7 +1762,13 @@ async function checkOrderedReplay( installedMarkers.add(id) } } - const receipt = markerSync.commit() + markerTruncating = true + let receipt: true | Promise + try { + receipt = markerSync.commit() + } finally { + markerTruncating = false + } if (receipt !== true) await receipt } try { @@ -2171,9 +2206,15 @@ describe(`Ordered windows across truncate replays`, () => { }>)( `keeps the ordered window for $name (generated replay)`, async ({ history, scenario, ...replay }) => { - const reach = await checkOrderedReplay(history, scenario) + const entered = new Set() + const reach = await checkOrderedReplay(history, scenario, (state) => + entered.add(state), + ) if (`terminal` in replay) { expect(reach.terminalErrors > 0).toBe(replay.terminal) + expect(entered).toContain( + replay.terminal ? replayDemandStates[0] : replayDemandStates[1], + ) } const reaches: keyof OrderedReach | undefined = `reaches` in replay ? replay.reaches : undefined @@ -2205,14 +2246,26 @@ describe(`Ordered windows across truncate replays`, () => { abortedMoves: 0, terminalErrors: 0, } + // The broad property claims these dimensions, not the rare terminal + // interaction owned by the subscription-specific property below. + const dimensions = ( + Object.keys(total) as Array + ).filter( + (dimension) => + dimension !== `abortedMoves` && dimension !== `terminalErrors`, + ) + const declared = createOracleReach(orderedReplayProperty, dimensions) await fc.assert( fc.asyncProperty( fc.array(orderedCommand, { minLength: 1, maxLength: 12 }), orderedScenario, async (history, scenario) => { + declared.beginHistory() const reach = await checkOrderedReplay(history, scenario) for (const key of Object.keys(total) as Array) { total[key] += reach[key] + if (dimensions.includes(key) && reach[key] > 0) + declared.reach(key) } }, ), @@ -2223,19 +2276,118 @@ describe(`Ordered windows across truncate replays`, () => { ...(campaign === `replay` ? { numRuns: 1 } : {}), }, ) + console.info( + orderedReplayProperty, + campaign, + Object.fromEntries(declared.counts()), + ) + declared.assertReached() // Reach witnesses: releases throw without a truncate, while a replay // starts, and while it settles; loads fail and stay held across a // truncate; and the settled-window law is checked. // An aborted move needs a truncate to land inside a held move, and a // terminal error needs a held marker demand load to fail; the named // replays above reach both. - if (campaign !== `replay`) { - for (const [dimension, count] of Object.entries(total)) { - if (dimension === `abortedMoves` || dimension === `terminalErrors`) - continue - expect(count, dimension).toBeGreaterThan(0) - } - } + // Counts above are histories, rather than repeated events in one trace. + }, + 120_000, + ) +}) + +/** + * The broad grammar can legally reach these states, but its normal campaigns + * did not each reach both states in the audit. This process grammar keeps marker replay + * and held failure adjacent while varying prior replay count, the moved row, + * its rank and the failure origin. It uses the same driver, + * independent classification and observation cuts as the broad property. + * + * An include may start new ordinary demand during another owner's + * replacement; a settled joined owner renews its own demand. The adapter must + * witness each failed acquisition in normal fixed and random campaigns. + * Aborted work does not count. Named neighbors above calibrate both witnesses. + */ +const replayDemandProperty = `replay.subscription-demand-origin` +const replayDemandCampaigns = + orderedReplayConfig.replayPath === undefined + ? ([`fixed`, `random`] as const) + : orderedReplayConfig.replayProperty === replayDemandProperty + ? ([`replay`] as const) + : ([] as const) +const replayDemandHistory = fc.record({ + origin: fc.constantFrom(`new-demand` as const, `renewal` as const), + previousReplays: fc.integer({ min: 0, max: 3 }), + move: fc.record({ + id: fc.constantFrom(1, 3, 4, 5), + rank: fc.integer({ min: 1, max: 9 }), + }), +}) + +describe(`subscription-specific replay demand`, () => { + it.each(replayDemandCampaigns)( + `distinguishes failed demand origins (%s campaign)`, + async (campaign) => { + const declared = createOracleReach( + replayDemandProperty, + replayDemandStates, + ) + await fc.assert( + fc.asyncProperty(replayDemandHistory, async (generated) => { + declared.beginHistory() + const history: Array = [ + ...Array.from({ length: generated.previousReplays }, () => ({ + type: `truncateMarkers` as const, + })), + { type: `hold` }, + ...(generated.origin === `new-demand` + ? [{ type: `move` as const, ...generated.move }] + : []), + { type: `truncateMarkers` }, + ...Array.from( + { length: generated.origin === `new-demand` ? 1 : 0 }, + () => ({ + type: `settle` as const, + ok: true, + }), + ), + { type: `settle`, ok: false }, + ] + const entered = new Set() + const reach = await checkOrderedReplay( + history, + { + ...ordinaryOrderedScenario, + limit: 1, + joined: generated.origin === `new-demand` ? `none` : `some`, + markerMode: `on-demand`, + includes: generated.origin === `new-demand`, + }, + (state) => { + entered.add(state) + declared.reach(state) + }, + ) + // Ordinary failure must become terminal, not merely be permitted to. + // A settled renewal must keep the query available for later replay. + if (entered.has(replayDemandStates[0])) + expect(reach.terminalErrors, `ordinary failure is terminal`).toBe(1) + if (entered.has(replayDemandStates[1])) + expect(reach.terminalErrors, `replay failure is nonterminal`).toBe( + 0, + ) + }), + campaign === `fixed` + ? { seed: 2233, numRuns: oracleRuns(75) } + : { + ...oraclePropertyOptions(75, replayDemandProperty), + ...(campaign === `replay` ? { numRuns: 1 } : {}), + }, + ) + console.info( + replayDemandProperty, + campaign, + Object.fromEntries(declared.counts()), + ) + declared.assertReached() }, 120_000, ) From 929c1d418a84c2d9ed57fb68aa829ba8c85c5ab7 Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 23 Oct 2026 09:25:09 -0600 Subject: [PATCH 10/20] test(db): verify oracle reach against merged rules --- .../2026-10-22-dbclient-migration-feedback.md | 73 +++++++++++++++++++ ...ad-subset-replay-refinement-oracle.test.ts | 6 +- 2 files changed, 75 insertions(+), 4 deletions(-) diff --git a/docs/contributing/oracle-reviews/2026-10-22-dbclient-migration-feedback.md b/docs/contributing/oracle-reviews/2026-10-22-dbclient-migration-feedback.md index 024c89752c..4de31429f4 100644 --- a/docs/contributing/oracle-reviews/2026-10-22-dbclient-migration-feedback.md +++ b/docs/contributing/oracle-reviews/2026-10-22-dbclient-migration-feedback.md @@ -1182,3 +1182,76 @@ SHA-256 identities for the repaired executable sources: | `packages/db/tests/query/load-subset-replay-refinement-oracle.test.ts` | `34aec1038062a15957642c35de2fafe2576b2941a326f449c75b6a3b28cb9da1` | | `packages/db/tests/oracle-config.ts` | `a3464344b04bfdc59bc4ab42489eeea540aed1461f9fb440e2da0f5b0a0b0c46` | | `packages/db/tests/oracle-reach.ts` | `d38c86d0a2d197d566108cfb59cdd6cb624619df8ab570afa2c7e09b7f00ba63` | + + +### Integration with merged PR #2233 + +Oracle repair commit `a39a07d21` was followed by merge commit `e32ac735c`, which +includes `origin/main` at `65d9ada50`. The authoring skill conflict retains both +the oracle-first sequence and the branch's full-source reading requirement. +The review skill retains both sets of instructions as well. Upstream's persisted +readiness extraction merges with the observer ownership repair without a manual +production edit. No branch history was rewritten. + +The first combined core command passed 2,808 runtime tests but failed with two +test-source type errors. The filtered dimensions have a narrower union than +`keyof OrderedReach`; the loop had continued to iterate all keys. Iterating the +declared dimensions fixes those errors and removes the unused event aggregation. +This does not alter generated histories, public judgments or reach minima. +`integrated-core.log` is not a clean GREEN result. The final command in +`integrated-core-fixed.log` passes all 2,808 tests in 55 files, including type +checks, with no unhandled errors. The smaller earlier selection did not expose +these test-source diagnostics. + +The merged React suite passes 458 tests in 23 files. The persistence readiness +owner passes 64 runtime and type tests in two runner entries. Evidence: +`integrated-react.log` and `integrated-persistence.log`. Scoped ESLint, +Prettier, `git diff --check`, and the 418-name mangle-cache check pass. +The complete repository command remains unrun. + +The source-wide, identity-blind and always-terminal mutants again fail both +focused campaigns at the terminal-status assertion on the combined tree. +Removing rank movement again fails reach. Ignoring ordinary failure again fails +the positive terminality assertion. The `integrated-replay-*.log` files preserve +these runs. Every production and test mutation was restored before the final +checks. The direct seed/path replay passes all 26 selected tests +(`integrated-direct-green.log`). + +Final normal core-run history counts follow. Fixed seeds and budgets are +unchanged, and the minimum is one history for each declared state. + +| Property / declared state | Fixed | Random | +| --- | ---: | ---: | +| `replay.ordered-window-faults` / `settlementThrows` | 12 | 22 | +| `replay.ordered-window-faults` / `throwsAfterReplay` | 50 | 46 | +| `replay.ordered-window-faults` / `replayStartThrows` | 56 | 55 | +| `replay.ordered-window-faults` / `heldAcrossTruncate` | 26 | 14 | +| `replay.ordered-window-faults` / `failedLoads` | 9 | 12 | +| `replay.ordered-window-faults` / `settledWindowChecks` | 246 | 245 | +| `replay.ordered-window-faults` / `tiedWindows` | 15 | 13 | +| `replay.ordered-window-faults` / `peerChecks` | 127 | 120 | +| `replay.ordered-window-faults` / `windowMoves` | 92 | 88 | +| `replay.ordered-window-faults` / `descendingRuns` | 139 | 141 | +| `replay.ordered-window-faults` / `joinedRuns` | 225 | 230 | +| `replay.ordered-window-faults` / `includeChecks` | 152 | 131 | +| `replay.ordered-window-faults` / `markChanges` | 87 | 83 | +| `replay.ordered-window-faults` / `antiJoinedRuns` | 76 | 70 | +| `replay.ordered-window-faults` / `innerJoinedRuns` | 81 | 77 | +| `replay.ordered-window-faults` / `markerLoads` | 132 | 138 | +| `replay.ordered-window-faults` / `markerTruncates` | 80 | 80 | +| `replay.subscription-demand-origin` / `new-demand-failed-during-marker-replay` | 9 | 9 | +| `replay.subscription-demand-origin` / `settled-marker-demand-replay-failed` | 32 | 40 | + +Against merged main `65d9ada50`, `pnpm size:compare --packages db,react-db --files` +reports db +139 gzip / +238 brotli bytes and React unchanged. Tree-shaken app +scenarios range from +136 to +148 gzip bytes. This is the existing production +repair's cost on the integrated baseline. The declared-reach audit adds zero +production source lines or shipped bytes. `integrated-size-final.log` records +the final matching-source cached comparison. Earlier bundle figures remain tied +to their original bases. + +Final repaired replay-owner SHA-256: +`7b47e515ce94ec26f60fd78b400ddb2ea104abd9eb3bd8978a098c5efd7662e8`. +The six-part evidence block and the stated finite limits above still apply. +The historical generated-RED chronology deviation, F02 decision and F07 evidence +gap remain visible. Integration GREEN does not turn this into a ready verdict. diff --git a/packages/db/tests/query/load-subset-replay-refinement-oracle.test.ts b/packages/db/tests/query/load-subset-replay-refinement-oracle.test.ts index 99519a4894..950eb486fa 100644 --- a/packages/db/tests/query/load-subset-replay-refinement-oracle.test.ts +++ b/packages/db/tests/query/load-subset-replay-refinement-oracle.test.ts @@ -2262,10 +2262,8 @@ describe(`Ordered windows across truncate replays`, () => { async (history, scenario) => { declared.beginHistory() const reach = await checkOrderedReplay(history, scenario) - for (const key of Object.keys(total) as Array) { - total[key] += reach[key] - if (dimensions.includes(key) && reach[key] > 0) - declared.reach(key) + for (const key of dimensions) { + if (reach[key] > 0) declared.reach(key) } }, ), From 83bd08ded8b74254515536233d7d82a32a85d3c3 Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 23 Oct 2026 11:38:18 -0600 Subject: [PATCH 11/20] fix(db): preserve error provenance and partial subscription ownership Law: ordinary hook errors remain readable; failed setup transfers no resource ownership. See the shared conformance and lifecycle contracts. Reach: generated origin/timing, late cleanup, partial attach and real React ordinary/infinite histories enforce mandatory reach minima in the audit. RED: extended owners reject parent 929c1d418 at handoff throw, failed-setup lease, and post-unmount ownership assertions before the repairs. GREEN: 2816 core tests, 462 React tests, six guarded replays, scoped lint and the 418-name private-member check pass. Mutant: broad recorded-error throws, late status ownership, and narrow peer detach all fail their intended assertions. Limits: finite histories; H04/H08 need legal failing traces. Earlier SSR snapshot policy remains open. Preserve rejection during active cleanup. Record measured bundle growth and refactors tried in the versioned audit. --- .../resume-independent-subscription-demand.md | 2 + docs/contributing/oracle-coverage.md | 30 ++ .../2026-10-22-dbclient-migration-feedback.md | 343 ++++++++++++++++++ packages/db/src/client.ts | 2 +- packages/db/src/collection/changes.ts | 2 +- packages/db/src/collection/subscription.ts | 2 +- packages/db/src/live-query-observer.ts | 83 +++-- packages/db/src/live-query-options.ts | 7 +- packages/db/src/query/pooled-live-query.ts | 1 + ...n-subscription-lifecycle-grammar-oracle.ts | 26 ++ ...tion-subscription-lifecycle-oracle.test.ts | 161 ++++++++ ...-query-deferred-acquisition-oracle.test.ts | 107 ++++++ ...y-observer-history-oracle.property.test.ts | 172 +++++++++ packages/db/tests/oracle-config.ts | 6 + packages/db/tests/oracle-replay-manifest.ts | 37 +- .../query/pooled-live-query-gc-oracle.test.ts | 180 +++++++++ .../descriptor-query-binding-oracle.test.tsx | 166 ++++++++- 17 files changed, 1278 insertions(+), 49 deletions(-) diff --git a/.changeset/resume-independent-subscription-demand.md b/.changeset/resume-independent-subscription-demand.md index dfa1046a8c..b0adbe3561 100644 --- a/.changeset/resume-independent-subscription-demand.md +++ b/.changeset/resume-independent-subscription-demand.md @@ -9,3 +9,5 @@ Reject ordinary subscriptions and deferred acquisition resumption during active Release partial observer and pooled-query subscriptions when setup fails or cleanup interrupts startup. Resumed subscriptions release abandoned render deferrals without restoring retired ownership. Admission checks retain an existing cleanup promise until a replacement sync run starts. Subscribers that survive cleanup during admission retain demand for an explicit restart. Cleanup in a first-demand callback does not start a replacement implicitly. + +Keep ordinary hook errors readable when deferred sync startup records an error. Explicit preloads still report source startup failures. Release channels acquired before synchronous cleanup registration or observer attachment fails. diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index cc132c496a..b057bed95c 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -144,6 +144,36 @@ replacement acquisition, readiness and rows. The previous implementation fails 16. The controlled grammar does not claim arbitrary recursive cleanup or async provider work before acquisition. This owner retains those wider witnesses. +The ten-finding high review adds generated error-provenance and partial-setup +histories to the existing owners. The deferred-acquisition owner crosses ready, +returned-error and thrown starts, before/during handoff, with defined/absent +causes. Its independent start-handoff judgment also feeds real React ordinary +and infinite hook drivers, which compare readable error/boundary behavior and +ownership immediately after unmount. Explicit DbClient preload continues to +report source startup failures. Pooled lifecycle histories add released +partitions whose synchronous late cleanup callback returns or throws, including +1–3 healthy status peers. Observer histories add early capability or sync +failure, one/two failed attempts, both modes and reentrant surviving peers, +then explicit retry/publication/release. First-party persisted readiness does +not throw at registration; that premise is a controlled core-capability claim. +The lifecycle owner adds held/immediate cleanup and actual post-transfer +notification throws across 1–3 demands, checked after replacement readiness. +Original and narrow-mutant assertions calibrate these cuts. All six new +properties have declared reach, paired fixed/random campaigns and guarded +seed/path replay. The [high-review audit](oracle-reviews/2026-10-22-dbclient-migration-feedback.md#high-review-of-ten-findings--2026-10-23) +records counts and finite limits. + +Two high-review claims remain evidence gaps, not accepted exclusions. The +pooled lifecycle owner needs a legal eager-source `subscribeChanges` throw +whose contract promises same-run partition recovery; current eager +`markError`/`markReady` histories recover both shared views. On-demand sources +are ineligible for pooling. The lifecycle/deferred-acquisition owners need a +notification-throw trace that keeps retained demand detached past supported +replacement readiness; generated real throws currently acquire afterward. +Arbitrary provider outages, callback programs and other frameworks' error +policies remain outside these new witnesses. The approved cleanup admission +rejection policy remains unchanged. + The Collection lifecycle history owner includes a 26-cell cleanup-restart matrix. It crosses explicit and default eager sources with absent or present loaders, empty or nonempty replacements, and no interruption, truncate during diff --git a/docs/contributing/oracle-reviews/2026-10-22-dbclient-migration-feedback.md b/docs/contributing/oracle-reviews/2026-10-22-dbclient-migration-feedback.md index 4de31429f4..cc8ca0f111 100644 --- a/docs/contributing/oracle-reviews/2026-10-22-dbclient-migration-feedback.md +++ b/docs/contributing/oracle-reviews/2026-10-22-dbclient-migration-feedback.md @@ -1255,3 +1255,346 @@ Final repaired replay-owner SHA-256: The six-part evidence block and the stated finite limits above still apply. The historical generated-RED chronology deviation, F02 decision and F07 evidence gap remain visible. Integration GREEN does not turn this into a ready verdict. +## High review of ten findings — 2026-10-23 + +The review checks `929c1d418a84c2d9ed57fb68aa829ba8c85c5ab7`, the head supplied +by GitHub when this evaluation began. The reviewer did not supply its own +reviewed SHA. Production and test repairs below follow that head. Earlier +entries, decisions, counterexamples, and evidence remain unchanged. + +The review exposed two distinct laws. A recorded Collection error does not +prove that a particular handoff threw. A failed subscription setup transfers no +resource ownership to a caller that received no release handle. Those laws +need separate error-provenance and partial-construction observations. + +### Laws, experiments, and inference + +| Law and authority | Previous coverage and missing distinction | Decisive experiment | Repair and enforcement boundary | +| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Ordinary React hooks expose a returned source error through `status` and `isError`. A handoff propagates an exception from the start it performs. The shared conformance contract and subscription ownership contract supply authority. H01/H06. | Healthy deferred starts and explicit preload rejection did not distinguish recorded error from this call's throw. React did not observe owners after failed commit. | Original helper fails generated error-origin/timing histories. Both real hooks enter the boundary on returned `markError`, then retain one Collection subscriber after unmount. | Default handoff reports only its actual throws. Explicit DbClient preload still requests source-error reporting. Core compares exact error identity, status, one start, and drained handoff at return. React compares status, `isError`, boundary capture, and zero owners immediately after unmount. | +| Failed construction releases all channels acquired before failure. Real cleanup callbacks may run during registration. H05/H07. | Pooled cleanup cases omitted a throwing late status callback. Observer setup cases omitted earlier capability failure and reentrant peer ownership. | Original pooled code retains one source status lease. Original observer retains status/layout leases. A narrow observer repair still leaks when a reentrant peer survives. | Own pooled status release before cleanup registration. Observer installs one release list before registration and detaches the failed physical attachment even with surviving logical records. Check immediately after every failed attempt, then explicit retry, publication, and final release. | +| Ordinary demand is independent of an abandoned render. Deferred acquisition alone starts no provider work. Law 15 and the shared startup judgment supply authority. H02. | Existing real React abandoned-render histories already distinguish ordinary, resumed, preload, and deferring readers, with both source modes and `startSync` values. | All real abandoned-render cases retain zero adapter starts before commit/direct demand. Ordinary demand subsequently starts once. Deferred demand retains zero. | Retain behavior. Change the comment from “Committed demand” to “Independent demand.” This rule does not promise that an arbitrary ordinary subscription is a render-only read. | +| First-demand history, current acquiring ownership, and cleanup-surviving logical demand are different facts. H03. | The retirement matrix omitted the retained first-demand mark. Ordinary admission lacked an observation during its acquiring-count callback, before publication registration. | Removing the current-count OR fails one ownership assertion. Removing the positive-count guard fails four reentrant retirement assertions. | Preserve the representations. Extend the existing token judgment and callback observation. An acquiring notification can unsubscribe synchronously and make the count zero even when `delta === 1`. | +| Retained logical demand can acquire after explicit replacement readiness. Cleanup invalidates old acquisition authority. The glossary supplies authority. H08. | A synchronous second first-demand notification was not generated at the ownership-transfer cut. | Real `notifyResumed` throws after transfer, including while held cleanup leaves public status ready. Retained demand still acquires after explicit replacement readiness. A mutant suppressing readiness-driven restart fails the acquisition-count assertion. | Add generated coverage. Do not add recovery machinery for an unproved permanent failure. The notification-error consequence remains an evidence gap beyond these histories. | + +H01's reference judgment is shared by the core and real React driver. It retains +callback outcome, handoff timing, and defined/undefined cause. It combines no +production queues or classifiers. The React driver supplies only host scheduling +and observations. Explicit preload has a distinct error-reporting obligation: +it must reject if its source startup failed even when graph demand consumed the +gate first. Existing exact-error, healthy-peer, stream and replacement cases +continue to check that obligation. + +H07 uses a controlled persisted-readiness capability that can fail registration +before returning a handle. First-party readiness registration does not currently +throw. The driver also uses real sync startup throws. This proves cleanup at the +core capability boundary, not a first-party persistence outage or every possible +provider failure. A failed registration itself transfers no unreturned handle. +A returned reentrant peer remains a logical record. A later explicit subscription +reattaches that record and receives subsequent publication. + +### Lossless source-order ledger + +| ID and original location | Claim and technical verdict | PR action / disposition | Durable value and destination | +| ------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| H01 `live-query-options.ts:47`, correctness | Confirmed. The helper rethrows unrelated recorded errors, including `undefined`. The summary's error-boundary consequence is reproduced with real React. | `fixed-now`: distinguish readable status from an actual thrown start. Explicit preload retains source-error reporting. | Shared start-handoff judgment, deferred-acquisition owner, and both React receiving drivers. | +| H02 `collection/changes.ts:427`, correctness | Default admission releases the gate by design. The alleged ordinary-hook render work is not observed: real abandoned renders start no adapter work. A default subscriber is independent demand. | `accepted-design`: retain law 15 and clarify the comment. | Existing real React/core startup matrices remain. The separate test-integrity allegation is checked below. | +| H03 `collection/changes.ts:436`, simplification | Refuted. Sticky first demand, current ownership, and surviving logical demand differ. The count can become zero during a positive-count event. | `refuted`: do not remove either count check. | Added mark comparison and admission-reentry witness in the lifecycle owners. Both proposed removals fail assertions. | +| H04 `query/pooled-live-query.ts:194`, correctness | The catch terminates the partition. A legal recoverable source error that reaches that catch remains unproved. Eager `markError`/`markReady` before or after view construction recovers both shared views without throwing. | `confirmed-open`, evidence gap. No speculative partition recovery added. | Pooled lifecycle owner retains two finite same-run recovery cases. Needed: smallest legal eager-source sequence where `subscribeChanges` throws yet the same partition must recover. | +| H05 `query/pooled-live-query.ts:173`, correctness | Confirmed. A released partition can register during held cleanup; a retained view status callback throws before registration returns and leaves a status lease. | `fixed-now`: assign the status release before cleanup registration. | Generated late-cleanup property in pooled lifecycle owner, with returned/throwing and defined/undefined neighbors. | +| H06 `react-db/useLiveQuery.ts:1044`, `useLiveInfiniteQuery.ts:438`, correctness | Confirmed on the original helper for both hooks. A post-acquisition throw prevents the unsubscribe handle from reaching React. The footnote is correct: the hook pattern predates this PR, but the broad helper throw makes it reachable. | `fixed-now` through H01. Separate hook rollback guards were tried and removed after their removal survived repaired-helper histories. Both hook source files remain unchanged in this pass. | Keep separate error-surface and post-unmount ownership properties through the actual hooks. Do not dismiss the finding as outside the diff. | +| H07 `live-query-observer.ts:643`, correctness | Confirmed at the controlled capability and real sync boundaries. Partial registration and a surviving reentrant peer defeat the older release placement/record-count rule. | `fixed-now`: one early-owned release list and explicit failed-attachment detach. | Observer history owner generates failure boundary, mode, peer presence, and repeated failed attempts, then checks successful next use. | +| H08 `collection/subscription.ts:303`, correctness | A second first-demand callback can throw after ownership transfer. `subscribers:change` callback errors are reported asynchronously and cannot skip the next statement. Generated synchronous-throw histories retain demand and acquire after replacement readiness. Permanent stranding is not reproduced. | `confirmed-open`, evidence gap. No retry/finally state added without a legal failing trace. | Lifecycle owner now generates actual notification throws across held/immediate cleanup and 1–3 distinct demands. Needed: smallest source-ready sequence that skips restart and remains stuck after the supported readiness checkpoint. | +| H09 `collection/subscription.ts:304`, simplification | Confirmed. The outer unsubscribed guard duplicates `restartDetachedDemands`' first authority check. | `fixed-now`: remove the guard and cast. | Whole lifecycle, retirement and deferred-acquisition owners protect the inner check. | +| H10 `db/mangle-cache.json:419`, code weight | Confirmed reporting gap. The reported +139 gzip/+238 brotli package figures match the previous head against merged main. | `fixed-now`: perform and record actual refactor experiments and final shipped-byte measurement below. Growth remains explicit. | This versioned record and PR body retain accepted/rejected alternatives and their evidence. | + +The H02 provenance claim is separate from its product claim. Commit +`2151d9f1f` did change cancellation fixtures to create deferred logical demand +and then resume it. That change does not establish permission for ordinary React +render work. At this evaluation's parent, the DbClient queued-load fixture still +checks no adapter work before release, using an explicit queued load. The real +React startup matrix separately checks zero work for abandoned renders and +later acquisition for direct demand. The high review supplied no failing render +fixture. These assertions remain intact, and all actual render-cut suites pass. + +The ten panel entries and both summary bullets are accounted for above. H01's +`undefined` cause, H02's test-integrity subclaim, both H06 hooks, and H06's +outside-diff provenance remain distinct. No additional product sibling was +confirmed. Fixture setup and replay-registration mistakes found during this +pass are recorded below rather than counted as product defects. + +### Generated grammar and calibration + +All six properties run fixed seed `2229` and an unseeded campaign with equal +budgets. Each declares reach with a minimum of one history per named state. +The state counts below come from the final normal runs. Observer reach uses an +actual returned peer handle, checked against the generated premise. + +| Property / state | Fixed | Random | +| ------------------------------------------------------------------- | ----: | -----: | +| `live-query.start-handoff` / prior-error | 33 | 27 | +| same / marked-error | 29 | 29 | +| same / own-throw | 18 | 15 | +| same / undefined-cause | 43 | 57 | +| same / healthy | 35 | 40 | +| `pooled-live-query.late-cleanup` / return | 22 | 22 | +| same / throw | 18 | 18 | +| same / undefined-cause | 18 | 21 | +| `live-query-observer.partial-attach` / early-failure | 29 | 27 | +| same / sync-failure | 31 | 33 | +| same / surviving-peer | 26 | 32 | +| same / no-peer | 34 | 28 | +| `subscription-lifecycle.notification-error` / throwing-notification | 31 | 32 | +| same / retained-owner | 60 | 60 | +| same / held-cleanup | 30 | 36 | +| same / replacement-ready | 60 | 60 | +| `react.descriptor-error-surface` / ordinary | 16 | 16 | +| same / infinite | 14 | 14 | +| same / mark-error | 16 | 16 | +| same / throw | 14 | 14 | +| same / undefined-cause | 15 | 13 | +| `react.descriptor-ownership` / ordinary | 16 | 11 | +| same / infinite | 14 | 19 | +| same / mark-error | 16 | 21 | +| same / throw | 14 | 9 | +| same / undefined-cause | 15 | 12 | + +The core handoff budget is 100, pooled cleanup 40, observer attachment and +notification error 60, and each React observation 30. These are sampled finite +histories, not universal proofs. The domains retain the distinctions needed to +reconstruct the reports and the following neighboring cases: + +- Handoff: ready versus returned error versus throw, before versus during + release, and defined versus absent cause. Timing distinguishes another + reader's failure. Outcome distinguishes readable error from exception. + Calling an old render handle again must not start a second run. +- Pooled registration: returned versus thrown late callback, defined versus + absent cause, and 1–3 healthy peer status listeners. Removing release-before- + cleanup prevents the late-registration premise. On-demand sources are + excluded by production's pool eligibility, not a test waiver. +- Observer: granular/wholesale, early capability/real sync failure, one/two + failed attempts, and absent/present reentrant peer. Removing early failure + loses partial-registration reach. Removing peer presence accepts the narrow + record-count repair. Successful retry/publication/release follows every case. +- Notification: held/immediate cleanup, returned/thrown second notification, + and 1–3 distinct predicates. Cleanup is required to clear the first demand + mark and reach the second notification. Distinct predicates retain separate + demands. Replacement readiness is explicit because cleanup promises no + automatic restart. +- React: both hooks, returned error versus actual throw, and defined/absent + cause. A healthy source is covered by the surrounding host suite. Direct + QueryBuilder input to the infinite hook is outside its accepted input form. + +Original RED and hostile results are assertion failures at the claimed cuts: + +| Experiment | Captured failure | +| ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Original H01 helper | Seed `2229`, path `0:1`: `mark-error/before-release/error` unexpectedly throws. A random original replay is `1735232938`, path `1`, with an earlier thrown start. | +| Original real React, ordinary hook | Error-surface seed `-1964136193`, path `2:0`: boundary capture replaces readable error. Ownership seed `-1619664311`, path `0:0`: one owner remains after unmount. Fixed seed `2229`, path `0:0` also fails both observations. | +| Original real React, infinite hook | Restricting the same grammar to the infinite hook reproduces both boundary and retained-owner assertions. The receiving input is `() => query`. | +| Original H05 placement | Seed `2229`, path `2:1:1`, `throws=true/cause=error/peers=1`: one status lease remains. | +| Original H07 attachment | Seed `2229`, path `0:0:0`, and random `-1896275468`, path `0:0:0:0`: early capability failure retains status/layout leases. | +| Narrow H07 repair without failed-attachment detach for a surviving peer | Seed `2229`, path `0:1`, and random `754580778`, path `0:0:0`: peer=true retains failed physical ownership. Both campaign assertions fail. | +| Restore H01's broad recorded-error throw | Both final core campaigns fail the handoff throw assertion. Random seed `1085601716`, path `0:1`. | +| Delete H05's early status ownership | Both final pooled campaigns fail the zero-status-lease assertion. Random seed `-395346131`, path `0:1`. | +| Suppress readiness-driven restart after transfer | Both notification campaigns fail the acquisition-count assertion. Random seed `-843337973`, path `0:0:1`, reaches a real thrown notification and observes zero loads instead of one. This calibrates retained-demand coverage, not an H08 production repair. | +| Delete H03 current-count OR | One assertion fails during acquiring-count cleanup before the new subscription joins publication registration. | +| Delete H03 positive-count conjunct | Four assertions fail in reentrant retirement histories. | + +Every production mutation was restored byte for byte before the final GREEN. +Removing the hook rollback guards survives both direct-source and exploratory +lazy-child histories with the repaired helper. That surviving mutant supplies +no evidence that rollback guards are necessary. The unproved lazy-child +extension is not part of the final claimed grammar. Original helper plus guards +passes both hooks' ownership checks, but the upstream provenance repair also +passes without adding those guards. + +Early H04 on-demand throw probes did not reach pool subscription: the pool +explicitly rejects on-demand sources. They are setup failures, not RED evidence. +An early infinite-hook fixture also supplied an unsupported direct QueryBuilder. +Its setup failures are withdrawn. The corrected real-hook original assertions +above supply the evidence. The first combined core run passed runtime tests but +reported two test-source builder type errors. The final run fixes the narrowing +without changing the grammar or expected results. A replay manifest test also +caught dynamically constructed React property IDs. Literal IDs repair the +registry check without changing behavior. + +### GREEN commands and direct replay + +From `packages/db`: + +```sh +pnpm exec vitest run tests/collection tests/db-client.test.ts tests/effect \ + tests/live-query tests/query/pooled-live-query \ + tests/query/load-subset-replay-refinement-oracle.test.ts \ + --coverage.enabled=false \ + --pool-options.threads.maxThreads=2 --pool-options.threads.minThreads=1 +``` + +This final run passes 2,816 runtime/type tests in 54 runner entries, with no type +or unhandled errors. The final observer reach check separately passes all 30 +owner tests. The complete replay-wrapper run passes 34 protocol tests, while +its three manifest tests initially fail on the dynamic IDs. After correction, +all three manifest tests pass. From `packages/react-db`, the full test command +passes 462 tests in 23 files. A final four-campaign run adds nonvacuous Collection +ownership and `isError` comparisons and passes. No full repository `pnpm test` +claim follows from these scoped runs. + +All six guarded replays run only the manifest's random lane and report one +completed exact-property witness. Use these seed/path pairs: + +| Property | Seed | Path | +| ------------------------------------------- | ----------: | --------- | +| `live-query.start-handoff` | 2229 | `0:1` | +| `pooled-live-query.late-cleanup` | 2229 | `2:1:1` | +| `live-query-observer.partial-attach` | -1896275468 | `0:0:0:0` | +| `subscription-lifecycle.notification-error` | -843337973 | `0:0:1` | +| `react.descriptor-error-surface` | -1964136193 | `2:0` | +| `react.descriptor-ownership` | -1619664311 | `0:0` | + +For example, from `packages/db`: + +```sh +TANSTACK_DB_ORACLE_SEED=2229 \ +TANSTACK_DB_ORACLE_PATH=0:1 \ +TANSTACK_DB_ORACLE_PROPERTY=live-query.start-handoff \ +node --import tsx tests/oracle-replay.ts \ + tests/live-query-deferred-acquisition-oracle.test.ts --coverage.enabled=false +``` + +For React, run from `packages/react-db` and use +`../db/tests/oracle-replay.ts` with the descriptor test file. None of the new +properties uses `fc.commands`, so no commands replay path applies. + +### Shipped weight and refactor experiments — H10 + +The target remains merged main `65d9ada508114f199464f753d50eb3447eb0eaf9`, verified +by a fresh fetch. `pnpm size:compare --packages db,react-db --files` reports: + +| Scenario | gzip delta | brotli delta | +| --------------------- | ---------: | -----------: | +| db package | +175 | +233 | +| React package | 0 | 0 | +| app | +175 | +294 | +| app-effect | +164 | +138 | +| app-group-by | +174 | +100 | +| app-includes | +170 | +66 | +| app-join | +173 | +170 | +| app-local-storage | +166 | +219 | +| app-optimistic-action | +171 | +248 | +| app-paced-mutations | +160 | +65 | + +Actual alternatives tried during this pass: + +1. Replace separate observer channel locals and release closures with one + early-owned list. Keep the existing callback runner so every acquired + channel gets a release attempt. This repair removes parallel ownership + mechanics and supports early registration failures. +2. Remove the duplicate outer unsubscribed guard. Existing inner authority + checks preserve behavior through the full lifecycle suite. +3. Remove current-demand count from `hasSubscriberOrPreload`. Reject this + candidate: it fails one admission-reentry ownership assertion. +4. Remove the positive-count conjunct from positive ownership notification. + Reject this candidate: four reentrant retirement assertions fail. +5. Add then remove rollback guards in both React hooks. Keep their removal: + repaired-helper ownership histories pass without them. Measured React cost + falls from +22 gzip/+26 brotli to zero. The typical app falls from +186 gzip + to +175. Brotli changes are nonlinear and not a sum of source-file deltas. + +The db package remains 36 gzip bytes larger and 5 brotli bytes smaller than +this review's parent comparison. Its remaining growth pays for error-provenance +separation, early resource ownership, and the prior demand/admission repairs. +The rejected count simplifications violate concrete ownership laws. No extra +React rollback or replacement lifecycle remains. The new production diff is +net +7 source lines, reported only as context. Shipped bytes above are the +weight measure. The test/model/replay changes add roughly 840 lines separately +from production and contract documentation. + +### ORC-001–015 assessment and limits + +001/002: The conformance error surface, handle ownership, glossary cleanup +rules and architecture law 15 supply authority. Small error-origin and +resource-token judgments stay independent of production classifiers. +003/009: Each changed owner states its law and limits before mechanics. +The shared handoff judgment names its projection. Vocabulary follows the +glossary. 004: The grammar controls above name reconstruction, axis ablation, +finite ranges and nearby exclusions. 005/013: Real Collection/observer/pool +entry points and real React commit/unmount paths reach the stated cuts. +Returned versus thrown starts and cleanup callbacks are distinguishing +neighbors. No concrete missing source fact is identified for these cuts. +006: Original and narrow-mutant assertions above reject the wrong designs. +H04/H08 gaps receive no closure credit. 007: Equal fixed/random budgets and six +guarded direct replays pass. 008: The handoff projection retains timing and +outcome because each changes allowed throw behavior. The retirement model's +new mark observation distinguishes retired ownership after cleanup. No new +stateful reference machine was introduced. 010: Driver cleanup is deterministic +within these fault domains. Experimental mutations restore exact bytes. +Cleanup-throwing capabilities are outside this grammar and are not claimed. +011: No new shared semantic-fault hypothesis needs a second formulation. +012: This versioned record preserves verdict-critical evidence and source +identities. 014: React receives the shared handoff judgment through actual host +scheduling. Controlled capability failure remains a core-only claim. +015: Mandatory minima and every declared state count appear above. + +The encoded laws are enforced at their stated return, failed-construction, +retry-publication and host-unmount cuts for these finite grammars. They do not +establish arbitrary provider outages, every callback program, every React +schedule, or other frameworks' error policies. Ordinary hook/error ownership +and explicit preload rejection are separate observations. The pooled and +observer owners retain their broader existing lifecycle histories. + +H04 remains open until a legal eager-source subscription throw with promised +same-run recovery is supplied. H08 remains open until a retained demand is +shown stuck past supported replacement readiness. Both questions were sent +with the tested path and observed results. Neither is silently deferred or +refuted by a different-path green test. The coverage map names those owners +and required witnesses. + +### Reviewer assessment, loss audit, and readiness + +**Hire recommendation: hire for code review.** The review found four reproduced +behavioral findings, including two subtle partial-construction leaks, plus a +valid simplification and the missing size rationale. Its strongest contribution +was tracing a new throw into unchanged receiving code. Its recovery and render +claims need better legal-path evidence. One simplification contradicts reentrant +ownership, and the review did not supply executable fixtures for its two +remaining failure consequences. Those limits reduce confidence in autonomous +design recommendations, without erasing the useful discoveries. + +Ten raw items = six `fixed-now` (H01/H05/H06/H07/H09/H10), one +`accepted-design` (H02), one `refuted` (H03), and two `confirmed-open` evidence +gaps (H04/H08). No items are deferred or merged away as duplicates. Accounting +is complete. Confirmed authorized repairs and their declared oracle work are +complete in the bounded domains above. The two unproved consequences remain +open investigations, rather than confirmed unfixed defects. + +Whole-PR verdict remains **not ready** because the earlier confirmed F02 warm- +source hydration mismatch still needs its initial-snapshot policy. H04/H08, +the earlier F07 typing report, and final-head CI also limit readiness evidence. +The earlier 24 passing checks at `929c1d418` do not validate this new tree. +Browser dependency installation timed out at that head and on merged main; +that infrastructure failure did not execute application assertions. CodeRabbit +remains paused and last reviewed `da37bc9bb`. No bot command or review comment +was sent during this evaluation. + +### Repaired source identities + +The implementation/test revision is identified by its parent above and these +SHA-256 values. The subsequent closeout entry pins the resulting commit. + +| File | SHA-256 | +| --- | --- | +| `packages/db/src/client.ts` | `72fb0652c06885430bdb0554277de1c2e2c25b6cfa52105fb4e120feefd730b1` | +| `packages/db/src/collection/changes.ts` | `0c07c750b94a97773bd707975a7ec7a41959ca7271112c4c0efeb79763a52b70` | +| `packages/db/src/collection/subscription.ts` | `e20dee08fe0a9c952f4b0b0e20cc1d95890559e5e980665926afd3f23148a900` | +| `packages/db/src/live-query-observer.ts` | `ddc6f6c2a13df4b919222ba7618626c36209079b7c3e1252f9fd0275211f576a` | +| `packages/db/src/live-query-options.ts` | `49d617f85b1a5cc09c4815130db703baa45fb7b5915b5a0973b77c662dbcf26b` | +| `packages/db/src/query/pooled-live-query.ts` | `01c253b1b781d78f570d3dbb7048cf90f5bc05a4292ee3ee623a9661463204d3` | +| `packages/db/tests/collection-subscription-lifecycle-grammar-oracle.ts` | `b6e5752c5f78312971667dd9c0e6ea7af7fd5d9a9db18b29a20be1b68c75325a` | +| `packages/db/tests/collection-subscription-lifecycle-oracle.test.ts` | `a344eecab6e4b1220f97249ae7de0feb905bf23af07cb6ca5926c1e387373a63` | +| `packages/db/tests/live-query-deferred-acquisition-oracle.test.ts` | `818998ac2d4049475d95d08d75b2a8c3321ec10ee3c073c5778017308d8147cd` | +| `packages/db/tests/live-query-observer-history-oracle.property.test.ts` | `6e1caf13a0eec60b530a1d31708b1e9c0ec5e636bd97d211d004f1f24c7678eb` | +| `packages/db/tests/oracle-config.ts` | `c1e6b14b96491d266571d65913f0bfb3acc98cdc3b9544a95317ae1b0b18970b` | +| `packages/db/tests/oracle-replay-manifest.ts` | `eef645f84c09044ac06bbd369b237b427a3cee1ab5bcb198020abc4d3724ba9b` | +| `packages/db/tests/query/pooled-live-query-gc-oracle.test.ts` | `029a28a9210c7c8981994f83044a454b6934c3cd61e29ae3e5c079c167ef6507` | +| `packages/react-db/tests/descriptor-query-binding-oracle.test.tsx` | `979091a554ac15c6f24f8b1d81112473c1e1b9db048e25a3031095e3a36cb4b5` | diff --git a/packages/db/src/client.ts b/packages/db/src/client.ts index 87c62f5c3e..6dadb0046a 100644 --- a/packages/db/src/client.ts +++ b/packages/db/src/client.ts @@ -398,7 +398,7 @@ export class DbClient { let resumeFailure: { error: unknown } | undefined try { - resumeDeferredLiveQueryCollections(deferredCollections) + resumeDeferredLiveQueryCollections(deferredCollections, true) } catch (error) { resumeFailure = { error } } diff --git a/packages/db/src/collection/changes.ts b/packages/db/src/collection/changes.ts index 606f0665b9..baab04f9b8 100644 --- a/packages/db/src/collection/changes.ts +++ b/packages/db/src/collection/changes.ts @@ -423,7 +423,7 @@ export class CollectionChangesManager< // A first-demand callback may itself retire this start. if (syncRunGeneration !== this.sync.getSyncRunGeneration()) return this.startSyncIfStopped() - // Committed demand releases even an abandoned render's sync-start gate. + // Independent demand releases even an abandoned render's sync-start gate. this.sync.resumeStart() } diff --git a/packages/db/src/collection/subscription.ts b/packages/db/src/collection/subscription.ts index 07f9fa3a2d..5b38308c23 100644 --- a/packages/db/src/collection/subscription.ts +++ b/packages/db/src/collection/subscription.ts @@ -301,7 +301,7 @@ export class CollectionSubscription if (this.unsubscribed as boolean) return this.defersAcquisition = false notifyResumed?.() - if (!(this.unsubscribed as boolean) && this.collection.status !== `idle`) { + if (this.collection.status !== `idle`) { this.restartDetachedDemands(this.collection._sync.getSyncRunGeneration()) } } diff --git a/packages/db/src/live-query-observer.ts b/packages/db/src/live-query-observer.ts index b0a5a4f2b6..5e089b709f 100644 --- a/packages/db/src/live-query-observer.ts +++ b/packages/db/src/live-query-observer.ts @@ -1,4 +1,5 @@ import { LiveQueryObserverDisposedError } from './errors.js' +import { runAllCallbacks } from './utils/callbacks.js' import { getLiveQueryStatusFlags, getPublicCollection, @@ -633,8 +634,9 @@ class LiveQueryObserverImpl< if (this.subscriptions.size === 0) this.detach() } this.subscriptions.add(record) + const attaching = !this.attached try { - if (this.subscriptions.size === 1) { + if (attaching) { this.attach() } else { // Seed only this late granular listener; a failed seed releases its @@ -644,6 +646,7 @@ class LiveQueryObserverImpl< } catch (error) { // Setup returned no ownership handle. Release every acquired channel // and the logical record, so a later explicit subscription can attach. + if (attaching) this.detach() release() throw error } @@ -667,6 +670,14 @@ class LiveQueryObserverImpl< private attach(): void { const collection = this.collection if (!collection || this.disposed) return + // Install ownership before any registration can call back or throw. + const releases: Array<() => void> = [] + const release = () => runAllCallbacks(releases.splice(0)) + this.collectionUnsub = release + const ownRelease = (unsubscribe: () => void) => { + if (this.collectionUnsub === release) releases.push(unsubscribe) + else unsubscribe() + } this.registerClientResource() this.syncHydrationState() this.refreshDetachedState(collection) @@ -755,42 +766,45 @@ class LiveQueryObserverImpl< // any status change publishes a synthetic notify so consumers re-read the // snapshot. Unlike onFirstReady, `on` returns a real unsubscribe, so a // detached attachment leaves nothing behind. - const statusUnsub = collection.on(`status:change`, ({ status }) => - notify(undefined, status), + ownRelease( + collection.on(`status:change`, ({ status }) => notify(undefined, status)), ) const subscribeLayoutChanges = ( collection as Collection & { _subscribeLayoutChanges?: (listener: () => void) => () => void } )._subscribeLayoutChanges - const layoutUnsub = - typeof subscribeLayoutChanges === `function` - ? subscribeLayoutChanges.call(collection, () => - notify([], collection.status, true), - ) - : noop - const persistedUnsubs = this.persistedSources?.map((source) => - source.readiness.subscribe(() => { - if (this.disposed || this.subscriptions.size === 0) return - this.snapshotDirty = true - if ( - this.hasHydrationSeed() && - this.readPersistedReadiness().status === `ready` - ) { - this.scheduleHydrationHandoff() - } else { - this.emit(undefined) - } - }), - ) + if (typeof subscribeLayoutChanges === `function`) { + ownRelease( + subscribeLayoutChanges.call(collection, () => + notify([], collection.status, true), + ), + ) + } + for (const source of this.persistedSources ?? []) { + ownRelease( + source.readiness.subscribe(() => { + if (this.disposed || this.subscriptions.size === 0) return + this.snapshotDirty = true + if ( + this.hasHydrationSeed() && + this.readPersistedReadiness().status === `ready` + ) { + this.scheduleHydrationHandoff() + } else { + this.emit(undefined) + } + }), + ) + if (this.collectionUnsub !== release) return + } // `subscribeChanges` delivers the initial state synchronously, so a // listener can dispose the observer while the collection subscription is // still being created. Register the release hook up front; if detach() // ran during that replay (collectionUnsub no longer points at our hook), // undo the subscription as soon as the call returns. - let subscription: { unsubscribe: () => void } | null = null - const clientUnsub = + ownRelease( this.client && this.queryHash ? this.client.subscribe((event) => { if ( @@ -809,24 +823,15 @@ class LiveQueryObserverImpl< : this.diffEntries(previousEntries, nextEntries), ) }) - : noop - const release = () => { - clientUnsub() - statusUnsub() - layoutUnsub() - persistedUnsubs?.forEach((unsubscribe) => unsubscribe()) - subscription?.unsubscribe() - } - this.collectionUnsub = release - subscription = collection.subscribeChanges( + : noop, + ) + const subscription = collection.subscribeChanges( (changes) => notify(changes as Array>), { includeInitialState: !this.wholesale && !attachedWithHydrationSeed }, ) + ownRelease(() => subscription.unsubscribe()) this.blockDelivery = false - if (this.collectionUnsub !== release) { - subscription.unsubscribe() - return - } + if (this.collectionUnsub !== release) return if (this.wholesale || attachedWithHydrationSeed) { // Publications raised while subscribeChanges starts sync are part of the // subscribe handshake. Apply their final snapshot state now, but suppress diff --git a/packages/db/src/live-query-options.ts b/packages/db/src/live-query-options.ts index acc6ef4faa..e340d0a29b 100644 --- a/packages/db/src/live-query-options.ts +++ b/packages/db/src/live-query-options.ts @@ -35,6 +35,7 @@ export type DeferredLiveQueryCollections = Set< /** Release every deferred source, then report the first startup failure. */ export function resumeDeferredLiveQueryCollections( collections: DeferredLiveQueryCollections, + reportSourceErrors = false, ): void { const pending = Array.from(collections) collections.clear() @@ -42,9 +43,9 @@ export function resumeDeferredLiveQueryCollections( for (const collection of pending) { try { collection._resumeSyncStart() - // Independent demand may have released this render's gate already. - // Its startup failure still belongs to this deferred-source handoff. - if (collection.status === `error`) { + // An explicit preload owns source failure reporting even when graph + // demand already released the gate. Ordinary hooks read error status. + if (reportSourceErrors && collection.status === `error`) { throw collection._lifecycle.getSyncError() } } catch (error) { diff --git a/packages/db/src/query/pooled-live-query.ts b/packages/db/src/query/pooled-live-query.ts index 8c986fa43f..46fc4d584d 100644 --- a/packages/db/src/query/pooled-live-query.ts +++ b/packages/db/src/query/pooled-live-query.ts @@ -157,6 +157,7 @@ class Partition { const stopStatus = this.source.on(`status:change`, (event) => { this.deliverStatus(event) }) + this.stopStatusEvents = stopStatus // Like a live query, a pooled view fails for good when its source // starts cleanup, before the adapter's cleanup settles; queries // mounted later get a new partition. diff --git a/packages/db/tests/collection-subscription-lifecycle-grammar-oracle.ts b/packages/db/tests/collection-subscription-lifecycle-grammar-oracle.ts index 4d05153a48..82eeaf9bf0 100644 --- a/packages/db/tests/collection-subscription-lifecycle-grammar-oracle.ts +++ b/packages/db/tests/collection-subscription-lifecycle-grammar-oracle.ts @@ -109,6 +109,8 @@ export function modelSubscriberResumeRetirement({ return { starts: cleanup && retireAt === `loading` ? 0 : 1, acquiring: retireAt === `none` ? 1 : 0, + // Cleanup forgets first demand when no acquiring owner survives. + subscriberOrPreload: !cleanup || retireAt === `none`, counts: retireAt === `none` ? [1] : retireAt === `subscribers` ? [1, 0] : [], } @@ -1040,3 +1042,27 @@ export function expectedDeferralBatches( ] : [] } + +/** + * A render sync-start handoff reports exceptions from the starts it performs. + * A Collection's recorded error is a readable status, not evidence that this + * handoff threw. The ordinary React contract exposes that status as isError; + * an explicit preload separately owns reporting its source startup failure. + * + * This projection retains error origin and timing because either changes the + * permitted synchronous result. It does not model source rows or recovery. + * Before-release histories use independent demand to release the same gate. + */ +export type StartHandoffHistory = { + outcome: `ready` | `mark-error` | `throw` + timing: `before-release` | `during-release` + cause: `error` | `undefined` +} + +export function expectedStartHandoff(history: StartHandoffHistory) { + return { + throws: history.outcome === `throw` && history.timing === `during-release`, + status: history.outcome === `ready` ? `ready` : `error`, + starts: 1, + } as const +} diff --git a/packages/db/tests/collection-subscription-lifecycle-oracle.test.ts b/packages/db/tests/collection-subscription-lifecycle-oracle.test.ts index 782f58b93f..6c9db3475b 100644 --- a/packages/db/tests/collection-subscription-lifecycle-oracle.test.ts +++ b/packages/db/tests/collection-subscription-lifecycle-oracle.test.ts @@ -4,6 +4,7 @@ import { createCollection } from '../src/collection/index.js' import { createDeferred } from '../src/deferred.js' import { BTreeIndex } from '../src/indexes/btree-index.js' import { Func, PropRef, Value } from '../src/query/ir.js' +import { createOracleReach } from './oracle-reach.js' import { captureMicrotaskErrors, createOnDemandCollection, @@ -3622,6 +3623,9 @@ describe(`CollectionSubscription demand lifecycle oracle`, () => { expect(starts).toBe(expected.starts) expect(collection.subscriberCount).toBe(expected.acquiring) expect(counts).toEqual(expected.counts) + expect(collection._hasSubscriberOrPreload()).toBe( + expected.subscriberOrPreload, + ) await cleanup await flushPromises() expect(collection.subscriberCount).toBe(expected.acquiring) @@ -4639,3 +4643,160 @@ describe(`CollectionSubscription demand lifecycle oracle`, () => { 120_000, ) }) + +/** + * A resumption that transfers ownership before a first-demand notification + * fails still retains its logical demand. Cleanup ends acquisition authority; + * an explicit replacement sync run supplies the next opportunity to acquire. + * This follows cleanup/restart and logical-demand ownership in the glossary. + * The notification can run twice only when cleanup cleared its earlier mark. + * Without cleanup the first mark occurs before the transfer, so its throw + * leaves the subscription deferred. Collection event throws are asynchronous + * and therefore cannot skip resumption's next statement. + * + * These generated histories reach a real synchronous notifyResumed throw, + * including while public status stays ready during held cleanup. They check + * the transfer at return and acquisition after replacement readiness. They do + * not promise recovery from arbitrary programming errors or implicit restart. + */ +describe(`resumption notification error and surviving demand`, () => { + for (const campaign of [`fixed`, `random`] as const) { + it(`acquires retained demand after replacement readiness (${campaign})`, async () => { + const reach = createOracleReach(`resume-notification-error`, [ + `throwing-notification`, + `retained-owner`, + `held-cleanup`, + `replacement-ready`, + ]) + await fc.assert( + fc.asyncProperty( + fc.record({ + pending: fc.boolean(), + throws: fc.boolean(), + demands: fc.integer({ min: 1, max: 3 }), + }), + async (history) => { + reach.beginHistory() + const gate = createDeferred() + const failure = new Error(`first-demand notification`) + let loads = 0 + const source = createCollection<{ id: string }>({ + getKey: (row) => row.id, + gcTime: 0, + syncMode: `on-demand`, + sync: { + sync: ({ markReady }) => { + markReady() + return { + cleanup: () => (history.pending ? gate.promise : undefined), + loadSubset: () => { + loads++ + return true + }, + } + }, + }, + }) + source.startSyncImmediate() + const subscription = source.subscribeChanges(() => {}, { + deferAcquisition: true, + }) + for (let i = 0; i < history.demands; i++) + subscription.requestSnapshot({ + where: new Func(`eq`, [ + new PropRef([`id`]), + new Value(String(i)), + ]), + }) + // Drain the initial run's notifications while demand still defers. + await flushPromises() + expect(loads).toBe(0) + let cleanup: Promise | undefined + let notifications = 0 + const off = source._onFirstSubscriberOrPreload(() => { + notifications++ + if (notifications === 1) { + cleanup = source.cleanup() + if (!history.pending) source.startSyncImmediate() + } else if (history.throws) throw failure + }) + try { + let result: { threw: boolean; error?: unknown } = { threw: false } + try { + subscription.resumeDeferredAcquisition() + } catch (error) { + result = { threw: true, error } + } + expect(notifications).toBe(2) + expect(result.threw).toBe(history.throws) + if (history.throws) { + expect(result.error).toBe(failure) + reach.reach(`throwing-notification`) + } + expect(subscription.isDeferringAcquisition()).toBe(false) + expect(source.subscriberCount).toBe(1) + reach.reach(`retained-owner`) + if (history.pending) { + expect(source.status).toBe(`ready`) + reach.reach(`held-cleanup`) + } + gate.resolve() + await cleanup + if (history.pending) source.startSyncImmediate() + await flushPromises() + reach.reach(`replacement-ready`) + expect(source.status).toBe(`ready`) + expect(loads).toBe(history.demands) + expect(subscription.status).toBe(`ready`) + } finally { + off() + subscription.unsubscribe() + gate.resolve() + await source.cleanup() + } + }, + ), + campaign === `fixed` + ? { ...oraclePropertyOptions(60), seed: 2229 } + : oraclePropertyOptions( + 60, + `subscription-lifecycle.notification-error`, + ), + ) + reach.assertReached() + console.info( + `resume-notification-error ${campaign}`, + Object.fromEntries(reach.counts()), + ) + }) + } +}) + +// During ordinary admission, the new owner exists before subscribeChanges +// returns its handle. Cleanup in the acquiring-count callback must retain that +// demand even though the subscription has not joined the publication channel. +// The token judgment is one surviving owner, independent of production's list. +it(`retains admitted demand during subscriber-change cleanup`, async () => { + const source = createCollection<{ id: string }>({ + getKey: (row) => row.id, + gcTime: 0, + sync: { sync: ({ markReady }) => markReady() }, + }) + let cleanup: Promise | undefined + let observed: boolean | undefined + const off = source.on(`subscribers:change`, ({ subscriberCount }) => { + if (subscriberCount !== 1) return + cleanup = source.cleanup() + observed = source._hasSubscriberOrPreload() + }) + const subscription = source.subscribeChanges(() => {}) + try { + expect(observed).toBe(true) + expect(source.subscriberCount).toBe(1) + await cleanup + } finally { + off() + subscription.unsubscribe() + await source.cleanup() + } +}) diff --git a/packages/db/tests/live-query-deferred-acquisition-oracle.test.ts b/packages/db/tests/live-query-deferred-acquisition-oracle.test.ts index c749b29d82..3684a1c16d 100644 --- a/packages/db/tests/live-query-deferred-acquisition-oracle.test.ts +++ b/packages/db/tests/live-query-deferred-acquisition-oracle.test.ts @@ -51,6 +51,8 @@ * that wait for readiness. */ import { describe, expect, it, vi } from 'vitest' +import { fc } from '@fast-check/vitest' +import { resumeDeferredLiveQueryCollections } from '../src/live-query-options.js' import { BTreeIndex, DbClient, @@ -64,6 +66,9 @@ import { } from '../src' import { Query } from '../src/query/builder/index.js' import { createPooledLiveQuery } from '../src/query/pooled-live-query.js' +import { expectedStartHandoff } from './collection-subscription-lifecycle-grammar-oracle.js' +import { createOracleReach } from './oracle-reach.js' +import { oraclePropertyOptions, oracleRuns } from './oracle-config.js' import type { Collection, LiveQueryObserver } from '../src' type Row = { id: string; rank: number; group: string } @@ -1503,3 +1508,105 @@ describe(`a request that arrives while a source subscription is created`, () => }) } }) + +/** + * Source errors remain readable through a handoff that did not throw. This + * extends the primary demand oracle at its previously omitted failure cut. + * The grammar crosses callback return/throw, independent/owned start, and an + * optional error cause. Removing timing confuses another reader's failure + * with ours; removing outcome confuses markError with throw. No second start + * without cleanup is legal. The shared judgment also feeds the React driver. + */ +describe(`render sync-start handoff error provenance`, () => { + for (const campaign of [`fixed`, `random`] as const) { + it(`reports only its own thrown starts (${campaign})`, async () => { + const reach = createOracleReach(`start-handoff`, [ + `prior-error`, + `marked-error`, + `own-throw`, + `undefined-cause`, + `healthy`, + ]) + await fc.assert( + fc.asyncProperty( + fc.record({ + outcome: fc.constantFrom( + `ready` as const, + `mark-error` as const, + `throw` as const, + ), + timing: fc.constantFrom( + `before-release` as const, + `during-release` as const, + ), + cause: fc.constantFrom(`error` as const, `undefined` as const), + }), + async (history) => { + reach.beginHistory() + const expected = expectedStartHandoff(history) + const failure = + history.cause === `error` + ? new Error(`source failure`) + : undefined + let starts = 0 + const source = createCollection<{ id: string }>({ + getKey: (row) => row.id, + gcTime: 0, + sync: { + sync: ({ markReady, markError }) => { + starts++ + if (history.outcome === `throw`) throw failure + if (history.outcome === `mark-error`) markError(failure) + else markReady() + }, + }, + }) + source._deferSyncStart() + source.startSyncImmediate() + try { + if (history.timing === `before-release`) { + try { + source._resumeSyncStart() + } catch { + /* Independent reader owns this error. */ + } + } + if (source.status === `error`) reach.reach(`prior-error`) + if (history.outcome === `mark-error`) reach.reach(`marked-error`) + if (expected.throws) reach.reach(`own-throw`) + if (history.cause === `undefined`) reach.reach(`undefined-cause`) + if (history.outcome === `ready`) reach.reach(`healthy`) + const pending = new Set([source]) + let result: { threw: boolean; error?: unknown } = { threw: false } + try { + resumeDeferredLiveQueryCollections(pending) + } catch (error) { + result = { threw: true, error } + } + expect(result.threw, JSON.stringify(history)).toBe( + expected.throws, + ) + if (expected.throws) expect(result.error).toBe(failure) + expect(source.status).toBe(expected.status) + expect(starts).toBe(expected.starts) + expect(pending.size).toBe(0) + resumeDeferredLiveQueryCollections(pending) + source._resumeSyncStart() + expect(starts, `old render handles never retry`).toBe(1) + } finally { + await source.cleanup() + } + }, + ), + campaign === `fixed` + ? { numRuns: oracleRuns(100), seed: 2229 } + : oraclePropertyOptions(100, `live-query.start-handoff`), + ) + reach.assertReached() + console.info( + `start-handoff ${campaign}`, + Object.fromEntries(reach.counts()), + ) + }) + } +}) diff --git a/packages/db/tests/live-query-observer-history-oracle.property.test.ts b/packages/db/tests/live-query-observer-history-oracle.property.test.ts index 0eb99954e1..d5ce449b8c 100644 --- a/packages/db/tests/live-query-observer-history-oracle.property.test.ts +++ b/packages/db/tests/live-query-observer-history-oracle.property.test.ts @@ -4,8 +4,13 @@ import { DbClient } from '../src/client.js' import { createDeferred } from '../src/deferred.js' import { createCollection } from '../src/collection/index.js' import { createLiveQueryObserver } from '../src/live-query-observer.js' +import { + PERSISTED_READINESS, + waitForPersistedInitialRender, +} from '../src/persisted-readiness.js' import { oraclePropertyOptions, oracleRuns } from './oracle-config.js' import { withOracleCleanup } from './utils.js' +import { createOracleReach } from './oracle-reach.js' import type { ChangeMessage } from '../src/types.js' type Row = { id: string; version: number } @@ -222,6 +227,173 @@ describe(`observer subscription setup ownership`, () => { ) }) +/** + * The setup law also covers failure before subscribeChanges, and a peer that + * subscribes reentrantly during setup. A failed attempt transfers no physical + * channel ownership. An already-returned peer record remains a logical owner; + * the next explicit subscribe reattaches it. This follows subscribe/dispose's + * handle ownership contract, not persistence's implementation. The capability + * supplies controlled registration failure; no failing registration transfers + * an unreturned handle. First-party persisted readiness currently never throws. + */ +describe(`observer partial attachment`, () => { + for (const campaign of [`fixed`, `random`] as const) { + it(`releases every acquired channel before retry (${campaign})`, async () => { + const reach = createOracleReach(`observer-partial-attach`, [ + `early-failure`, + `sync-failure`, + `surviving-peer`, + `no-peer`, + ]) + await fc.assert( + fc.asyncProperty( + fc.record({ + mode: fc.constantFrom(`granular` as const, `wholesale` as const), + failureAt: fc.constantFrom(`persisted` as const, `sync` as const), + reenter: fc.boolean(), + failures: fc.integer({ min: 1, max: 2 }), + }), + async (history) => { + reach.beginHistory() + const failure = new Error(`setup failure`) + let failing = true + let reentered = false + let peerStop: (() => void) | undefined + let publish = () => {} + const peer = vi.fn() + let persistedLeases = 0 + const source = createCollection({ + getKey: (row) => row.id, + gcTime: 0, + ...{ + [PERSISTED_READINESS]: { + networkTimeoutMs: 0, + getOrStartNetworkDeadline: () => 0, + getSnapshot: () => ({ status: `ready` as const }), + waitForInitialRender: waitForPersistedInitialRender, + subscribe: () => { + if (history.reenter && !reentered) { + reentered = true + peerStop = observer.subscribe(peer) + } + if (failing && history.failureAt === `persisted`) + throw failure + persistedLeases++ + return () => { + persistedLeases-- + } + }, + }, + }, + sync: { + sync: ({ begin, write, commit, markReady }) => { + if (failing && history.failureAt === `sync`) throw failure + markReady() + publish = () => { + begin() + write({ type: `insert`, value: { id: `a`, version: 1 } }) + commit() + } + }, + }, + }) + let statusLeases = 0 + const on = source.on.bind(source) + source.on = (event, listener) => { + const stop = on(event, listener) + if (event !== `status:change`) return stop + statusLeases++ + let active = true + return () => { + if (active) { + active = false + statusLeases-- + stop() + } + } + } + let layoutLeases = 0 + const subscribeLayout = source._subscribeLayoutChanges.bind(source) + vi.spyOn(source, `_subscribeLayoutChanges`).mockImplementation( + (listener) => { + const stop = subscribeLayout(listener) + layoutLeases++ + return () => { + layoutLeases-- + stop() + } + }, + ) + const observer: ReturnType< + typeof createLiveQueryObserver + > = createLiveQueryObserver(source, { mode: history.mode }) + let stop: (() => void) | undefined + await withOracleCleanup(async () => { + for (let i = 0; i < history.failures; i++) { + expect(() => observer.subscribe(() => {})).toThrow(failure) + reach.reach( + history.failureAt === `persisted` + ? `early-failure` + : `sync-failure`, + ) + expect(Boolean(peerStop)).toBe(history.reenter) + reach.reach(peerStop ? `surviving-peer` : `no-peer`) + expect( + { + statusLeases, + layoutLeases, + persistedLeases, + source: source.subscriberCount, + }, + `failure returns no physical ownership`, + ).toEqual({ + statusLeases: 0, + layoutLeases: 0, + persistedLeases: 0, + source: 0, + }) + await source.cleanup() + } + failing = false + stop = observer.subscribe(() => {}) + expect(source.subscriberCount).toBe(1) + publish() + expect(observer.getSnapshot().state?.get(`a`)?.version).toBe(1) + if (history.reenter) expect(peer).toHaveBeenCalled() + stop() + peerStop?.() + expect({ + statusLeases, + layoutLeases, + persistedLeases, + source: source.subscriberCount, + }).toEqual({ + statusLeases: 0, + layoutLeases: 0, + persistedLeases: 0, + source: 0, + }) + }, [ + () => stop?.(), + () => peerStop?.(), + () => observer.dispose(), + () => source.cleanup(), + ]) + }, + ), + campaign === `fixed` + ? { numRuns: oracleRuns(60), seed: 2229 } + : oraclePropertyOptions(60, `live-query-observer.partial-attach`), + ) + reach.assertReached() + console.info( + `observer-partial-attach ${campaign}`, + Object.fromEntries(reach.counts()), + ) + }) + } +}) + type Mode = `granular` | `wholesale` type Reaction = | `none` diff --git a/packages/db/tests/oracle-config.ts b/packages/db/tests/oracle-config.ts index 997d131746..c88c8c7da1 100644 --- a/packages/db/tests/oracle-config.ts +++ b/packages/db/tests/oracle-config.ts @@ -3,6 +3,12 @@ import { oracleReplayReporter } from './oracle-replay-witness.js' type OracleEnvironment = Record const staticOracleProperties = [ + `pooled-live-query.late-cleanup`, + `live-query-observer.partial-attach`, + `subscription-lifecycle.notification-error`, + `live-query.start-handoff`, + `react.descriptor-error-surface`, + `react.descriptor-ownership`, `cursor-pagination.no-peek`, `cursor-pagination.history`, `cursor-pagination.cache`, diff --git a/packages/db/tests/oracle-replay-manifest.ts b/packages/db/tests/oracle-replay-manifest.ts index 7bcf5f9337..fab922922a 100644 --- a/packages/db/tests/oracle-replay-manifest.ts +++ b/packages/db/tests/oracle-replay-manifest.ts @@ -8,6 +8,21 @@ import { registeredOracleProperties } from './oracle-config.js' * promoted to assertion evidence merely because a process exited cleanly. */ const ownerGroups: ReadonlyArray = [ + [ + `db/tests/live-query-deferred-acquisition-oracle.test.ts`, + `live-query`, + `start-handoff`, + ], + [ + `db/tests/query/pooled-live-query-gc-oracle.test.ts`, + `pooled-live-query`, + `late-cleanup`, + ], + [ + `react-db/tests/descriptor-query-binding-oracle.test.tsx`, + `react`, + `descriptor-error-surface descriptor-ownership`, + ], [ `db/tests/cleanup-queue-oracle.property.test.ts`, `cleanup-queue`, @@ -112,7 +127,7 @@ const ownerGroups: ReadonlyArray = [ [ `db/tests/collection-subscription-lifecycle-oracle.test.ts`, `subscription-lifecycle`, - `async-statistics async-restart`, + `async-statistics async-restart notification-error`, ], [ `db/tests/collection-subscription-replay-oracle.property.test.ts`, @@ -132,7 +147,7 @@ const ownerGroups: ReadonlyArray = [ [ `db/tests/live-query-observer-history-oracle.property.test.ts`, `live-query-observer`, - `granular-history wholesale-history`, + `granular-history wholesale-history partial-attach`, ], [ `db-sqlite-persistence-core/tests/sqlite-resume-snapshot-oracle.test.ts`, @@ -267,6 +282,24 @@ export type OracleReplayManifestEntry = { // Pagination cells carry their property name in the test title so the guard // can select one cell without running the rest of the matrix. const replayTestNames = new Map([ + [`live-query.start-handoff`, `reports only its own thrown starts.*random`], + [ + `pooled-live-query.late-cleanup`, + `releases late cleanup registration channels.*random`, + ], + [ + `live-query-observer.partial-attach`, + `releases every acquired channel before retry.*random`, + ], + [ + `subscription-lifecycle.notification-error`, + `acquires retained demand after replacement readiness.*random`, + ], + [ + `react.descriptor-error-surface`, + `error-surface after descriptor failure.*random`, + ], + [`react.descriptor-ownership`, `ownership after descriptor failure.*random`], [ `structural-identity.equal-representations`, `agrees with D2 value identity for equal representations`, diff --git a/packages/db/tests/query/pooled-live-query-gc-oracle.test.ts b/packages/db/tests/query/pooled-live-query-gc-oracle.test.ts index decd9bfab8..66a150d1ec 100644 --- a/packages/db/tests/query/pooled-live-query-gc-oracle.test.ts +++ b/packages/db/tests/query/pooled-live-query-gc-oracle.test.ts @@ -1,4 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { fc } from '@fast-check/vitest' +import { oraclePropertyOptions, oracleRuns } from '../oracle-config.js' +import { createOracleReach } from '../oracle-reach.js' import { CollectionImpl, createCollection } from '../../src/collection/index.js' import { createLiveQueryObserver } from '../../src/live-query-observer.js' import { BaseQueryBuilder, Query } from '../../src/query/builder/index.js' @@ -235,6 +238,183 @@ describe(`pooled live query gcTime`, () => { }, ) + /** + * Late cleanup notification can invoke application status listeners before + * registration returns. A throw is still fail-fast, but it cannot retain a + * source status lease. This extends failed-construction ownership to the + * real synchronous callback boundary, with returned and thrown neighbors. + * Releasing the partition before cleanup is essential: a retained partition + * receives cleanup at its original registration, not during registration. + */ + for (const campaign of [`fixed`, `random`] as const) { + it(`releases late cleanup registration channels (${campaign})`, async () => { + const reach = createOracleReach(`pooled-late-cleanup`, [ + `return`, + `throw`, + `undefined-cause`, + ]) + await fc.assert( + fc.asyncProperty( + fc.record({ + throws: fc.boolean(), + cause: fc.constantFrom(`error` as const, `undefined` as const), + peers: fc.integer({ min: 1, max: 3 }), + }), + async (history) => { + reach.beginHistory() + const gate = createDeferred() + const source = createCollection({ + getKey: (row) => row.id, + gcTime: 0, + sync: { + sync: ({ markReady }) => { + markReady() + return { cleanup: () => gate.promise } + }, + }, + }) + source.startSyncImmediate() + let statusLeases = 0 + const on = source.on.bind(source) + source.on = (event, listener) => { + const off = on(event, listener) + if (event !== `status:change`) return off + statusLeases++ + let active = true + return () => { + if (active) { + active = false + statusLeases-- + off() + } + } + } + const builder = new Query() + .from({ r: source }) + .where(({ r }) => eq(r.g, `x`)) + if (!(builder instanceof BaseQueryBuilder)) + throw new Error(`not a builder`) + const view = createPooledLiveQuery(builder, { gcTime: 1 })! + const failure = + history.cause === `error` + ? new Error(`status listener`) + : undefined + const stops = Array.from({ length: history.peers }, () => + view.on(`status:change`, () => {}), + ) + stops.push( + view.on(`status:change`, () => { + if (history.throws) throw failure + }), + ) + let subscription: + ReturnType | undefined + try { + await vi.advanceTimersByTimeAsync(100) + expect(source.subscriberCount).toBe(0) + expect(statusLeases, `partition released before cleanup`).toBe(0) + const pending = source.cleanup() + let result: { threw: boolean; error?: unknown } = { threw: false } + try { + subscription = view.subscribeChanges(() => {}) + } catch (error) { + result = { threw: true, error } + } + reach.reach(history.throws ? `throw` : `return`) + if (history.cause === `undefined`) reach.reach(`undefined-cause`) + expect(result.threw).toBe(history.throws) + if (history.throws) expect(result.error).toBe(failure) + expect( + statusLeases, + `failed/terminal registration owns no channel`, + ).toBe(0) + expect(source.subscriberCount).toBe(0) + expect(view.status).toBe(`error`) + gate.resolve() + await pending + } finally { + stops.forEach((stop) => stop()) + subscription?.unsubscribe() + gate.resolve() + await source.cleanup() + } + }, + ), + campaign === `fixed` + ? { numRuns: oracleRuns(40), seed: 2229 } + : oraclePropertyOptions(40, `pooled-live-query.late-cleanup`), + ) + reach.assertReached() + console.info( + `pooled-late-cleanup ${campaign}`, + Object.fromEntries(reach.counts()), + ) + }) + } + + /** A recoverable eager-source error changes status, not partition lifetime. + * Only source cleanup is terminal. The return-to-ready checkpoint checks + * both shared views and fresh subscription ownership in the same sync run. + * On-demand sources cannot enter this path: pooling explicitly excludes them. + */ + it.each([`before-views`, `after-views`] as const)( + `retains a partition through recoverable error $0`, + async (errorAt) => { + let fail = () => {} + let recover = () => {} + const source = createCollection({ + getKey: (row) => row.id, + gcTime: 0, + sync: { + sync: ({ begin, write, commit, markReady, markError }) => { + begin() + write({ type: `insert`, value: { id: `a`, g: `x` } }) + commit() + markReady() + fail = () => markError(new Error(`temporary source error`)) + recover = markReady + }, + }, + }) + source.startSyncImmediate() + if (errorAt === `before-views`) fail() + const build = () => { + const builder = new Query() + .from({ r: source }) + .where(({ r }) => eq(r.g, `x`)) + if (!(builder instanceof BaseQueryBuilder)) + throw new Error(`not a builder`) + return createPooledLiveQuery(builder, { gcTime: 1 })! + } + const views = [build(), build()] + const stops: Array> = [] + try { + if (errorAt === `after-views`) fail() + for (const view of views) { + expect(view).toBeDefined() + stops.push(view.subscribeChanges(() => {})) + expect(view.status).toBe(`error`) + } + recover() + for (const view of views) { + expect(view.status).toBe(`ready`) + expect( + Array.from(view.entries(), ([key, row]) => ({ + key, + id: row.id, + g: row.g, + })), + ).toEqual([{ key: `a`, id: `a`, g: `x` }]) + } + expect(source.subscriberCount).toBe(1) + } finally { + stops.forEach((stop) => stop.unsubscribe()) + await vi.advanceTimersByTimeAsync(100) + await source.cleanup() + } + }, + ) + for (const gcTime of [1, 100, 0, Number.POSITIVE_INFINITY]) { for (const subscribe of [true, false]) { it(`matches a live-query Collection for gcTime ${gcTime}${subscribe ? `` : ` without a subscriber`}`, async () => { diff --git a/packages/react-db/tests/descriptor-query-binding-oracle.test.tsx b/packages/react-db/tests/descriptor-query-binding-oracle.test.tsx index 8c1ef0935b..fe8bbd5621 100644 --- a/packages/react-db/tests/descriptor-query-binding-oracle.test.tsx +++ b/packages/react-db/tests/descriptor-query-binding-oracle.test.tsx @@ -92,6 +92,7 @@ */ import { act, render, renderHook, waitFor } from '@testing-library/react' import { + BTreeIndex, DbClient, Query, collectionOptions, @@ -102,8 +103,12 @@ import { prepareLiveQueryValue, toArray, } from '@tanstack/db' -import { describe, expect, it } from 'vitest' -import { Suspense } from 'react' +import { describe, expect, it, vi } from 'vitest' +import { fc } from '@fast-check/vitest' +import { Component, Suspense } from 'react' +import { oraclePropertyOptions, oracleRuns } from '../../db/tests/oracle-config' +import { createOracleReach } from '../../db/tests/oracle-reach' +import { useLiveInfiniteQuery } from '../src/useLiveInfiniteQuery' import { DbProvider } from '../src/DbProvider' import { useLiveQuery } from '../src/useLiveQuery' import { useLiveQueryEffect } from '../src/useLiveQueryEffect' @@ -111,6 +116,7 @@ import { useLiveSuspenseQuery } from '../src/useLiveSuspenseQuery' import { mockSyncCollectionOptions } from '../../db/tests/utils' import { createDeferred } from '../../db/src/deferred' import { + expectedStartHandoff, modelSubscriberStartCleanup, subscriberStartCleanupHistories, } from '../../db/tests/collection-subscription-lifecycle-grammar-oracle' @@ -1675,3 +1681,159 @@ describe(`standalone descriptor query binding`, () => { } } }) + +/** + * React receives the source-error premise through actual descriptor startup. + * The shared conformance contract exposes ordinary hook errors as a readable + * status. The start-handoff judgment distinguishes a returned markError from + * a synchronous throw; neither permits a failed commit to retain ownership. + * The grammar varies both hooks, defined/absent causes, and callback return/ + * throw. We observe the boundary and result after commit, and Collection + * ownership immediately after unmount, before Collection GC can hide a leak. + */ +describe(`descriptor error handoff at React commit`, () => { + class Boundary extends Component< + { children: ReactNode; caught: Array }, + { failed: boolean } + > { + state = { failed: false } + static getDerivedStateFromError() { + return { failed: true } + } + componentDidCatch(error: unknown) { + this.props.caught.push(error) + } + render() { + return this.state.failed ? null : this.props.children + } + } + const properties = { + 'error-surface': `react.descriptor-error-surface`, + ownership: `react.descriptor-ownership`, + } as const + for (const observation of [`error-surface`, `ownership`] as const) { + for (const campaign of [`fixed`, `random`] as const) { + it(`${observation} after descriptor failure (${campaign})`, async () => { + const reach = createOracleReach(`react-error-handoff`, [ + `ordinary`, + `infinite`, + `mark-error`, + `throw`, + `undefined-cause`, + ]) + await fc.assert( + fc.asyncProperty( + fc.record({ + hook: fc.constantFrom(`ordinary` as const, `infinite` as const), + outcome: fc.constantFrom(`mark-error` as const, `throw` as const), + cause: fc.constantFrom(`error` as const, `undefined` as const), + }), + async (history) => { + reach.beginHistory() + const failure = + history.cause === `error` + ? new Error(`source startup`) + : undefined + const client = new DbClient() + const descriptor = collectionOptions( + `react-handoff-error`, + () => ({ + id: `react-handoff-error`, + syncMode: `eager`, + autoIndex: `eager`, + defaultIndexType: BTreeIndex, + getKey: (row: Row) => row.id, + startSync: true, + gcTime: 0, + sync: { + sync: ({ markError }) => { + if (history.outcome === `throw`) throw failure + markError(failure) + }, + }, + }), + ) + const query = new Query() + .from({ item: descriptor }) + .orderBy(({ item }) => item.id) + const caught: Array = [] + let current: + | { + isError?: boolean + status: string + collection?: { + subscriberCount: number + cleanup: () => Promise + } + } + | undefined + function Consumer() { + current = + history.hook === `ordinary` + ? useLiveQuery({ query }) + : useLiveInfiniteQuery(() => query, { pageSize: 1 }) + return null + } + const consoleError = vi + .spyOn(console, `error`) + .mockImplementation(() => {}) + let mounted: ReturnType | undefined + try { + mounted = render( + + + + + , + ) + await act(async () => {}) + reach.reach(history.hook) + reach.reach(history.outcome) + if (history.cause === `undefined`) + reach.reach(`undefined-cause`) + if (observation === `error-surface`) { + const expected = expectedStartHandoff({ + ...history, + timing: `during-release`, + }) + if (!expected.throws) { + expect(caught, `ordinary errors remain readable`).toEqual( + [], + ) + expect(current?.status).toBe(`error`) + expect(current?.isError).toBe(true) + } else { + expect( + caught, + `a thrown start remains fail-fast`, + ).toHaveLength(1) + } + } + mounted.unmount() + expect(current?.collection).toBeDefined() + if (current?.collection) + expect( + current.collection.subscriberCount, + `commit/unmount releases every hook owner`, + ).toBe(0) + } finally { + mounted?.unmount() + consoleError.mockRestore() + await current?.collection?.cleanup() + await client.cleanup() + } + }, + ), + campaign === `fixed` + ? { numRuns: oracleRuns(30), seed: 2229 } + : oraclePropertyOptions(30, properties[observation]), + ) + reach.assertReached() + console.info( + `react-error-handoff ${observation} ${campaign}`, + Object.fromEntries(reach.counts()), + ) + }) + } + } +}) From 1d1ac0170d5520ff21b54aee90c006382841150d Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 23 Oct 2026 11:40:23 -0600 Subject: [PATCH 12/20] docs: pin high review evidence to verified implementation --- .../2026-10-22-dbclient-migration-feedback.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/docs/contributing/oracle-reviews/2026-10-22-dbclient-migration-feedback.md b/docs/contributing/oracle-reviews/2026-10-22-dbclient-migration-feedback.md index cc8ca0f111..a2f5ea859d 100644 --- a/docs/contributing/oracle-reviews/2026-10-22-dbclient-migration-feedback.md +++ b/docs/contributing/oracle-reviews/2026-10-22-dbclient-migration-feedback.md @@ -1598,3 +1598,18 @@ SHA-256 values. The subsequent closeout entry pins the resulting commit. | `packages/db/tests/oracle-replay-manifest.ts` | `eef645f84c09044ac06bbd369b237b427a3cee1ab5bcb198020abc4d3724ba9b` | | `packages/db/tests/query/pooled-live-query-gc-oracle.test.ts` | `029a28a9210c7c8981994f83044a454b6934c3cd61e29ae3e5c079c167ef6507` | | `packages/react-db/tests/descriptor-query-binding-oracle.test.tsx` | `979091a554ac15c6f24f8b1d81112473c1e1b9db048e25a3031095e3a36cb4b5` | + +### Ten-finding implementation closeout + +Implementation and oracle commit: `83bd08ded8b74254515536233d7d82a32a85d3c3`. The source +identities above match this commit after the commit hook. The following +closeout commit changes documentation only. The earlier REDs target parent +`929c1d418a84c2d9ed57fb68aa829ba8c85c5ab7`; the final GREEN commands and six +guarded replays target the implementation identified here. + +Scoped ESLint passes with zero errors and four warnings on unchanged lines. +Prettier, `git diff --check`, and the 418-name mangle-cache check pass. +The historical audit prefix remains byte-identical. The changeset now records +readable source errors and partial-attachment release. No published commit +was amended or rebased. The production changes preserve the approved cleanup +rejection policy. The loss totals and non-ready verdict above still apply. From 90a30827fd32c5444243206f136a89e738ee6099 Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 23 Oct 2026 12:20:20 -0600 Subject: [PATCH 13/20] test: enforce independent React receiving witnesses --- .agents/skills/oracle-authoring/SKILL.md | 21 +- .agents/skills/oracle-review/SKILL.md | 4 + docs/contributing/oracle-coverage.md | 9 +- .../2026-10-22-dbclient-migration-feedback.md | 105 +++++++ .../descriptor-query-binding-oracle.test.tsx | 268 ++++++++++-------- .../tests/useLiveInfiniteQuery.test.tsx | 18 +- 6 files changed, 289 insertions(+), 136 deletions(-) diff --git a/.agents/skills/oracle-authoring/SKILL.md b/.agents/skills/oracle-authoring/SKILL.md index f8637d59a3..3a6f78b1d3 100644 --- a/.agents/skills/oracle-authoring/SKILL.md +++ b/.agents/skills/oracle-authoring/SKILL.md @@ -19,15 +19,20 @@ first. Auditors check the order of your work and penalize shortcuts. Work in this order when a production change is part of the task: 1. State the law and its authority. -2. Extend the history grammar, driver, or observation until generated +2. Inventory the materially affected public receiving paths and their promised + observation cuts. Include required real-host receiving witnesses in this + pre-edit gate; a controlled core witness does not supply their evidence. +3. Extend the history grammar, driver, or observation until generated histories reach the state where the law matters. Declare that state with - `createOracleReach` (ORC-015). -3. Run the extended oracle on the unchanged production code and record the RED - failure. -4. Change production, with the oracle running after each attempt. -5. Record the GREEN result and a hostile mutant that fails. - -Do not change production before step 3. If a model rule disagrees with + `createOracleReach` (ORC-015) for each required receiving path. +4. Run those comparisons on unchanged production. Record the defect's RED + result and the neighboring guarantees' results. A setup failure or missing + required witness blocks production edits; repair the test path first. +5. Change production, with the oracle running after each attempt. +6. Record the GREEN result and a hostile mutant that fails. + +Do not change production before step 4. Retrospective runs or a documented +process gap do not satisfy this gate. If a model rule disagrees with production, find its authority before you edit either one. A rule that you change only so that it matches production output is model hacking. diff --git a/.agents/skills/oracle-review/SKILL.md b/.agents/skills/oracle-review/SKILL.md index 7476438525..da83d6f213 100644 --- a/.agents/skills/oracle-review/SKILL.md +++ b/.agents/skills/oracle-review/SKILL.md @@ -68,6 +68,10 @@ Report each one as a finding: - A production change without a RED result from the extended oracle on the parent commit. +- Required public receiving paths tested only after the production repair, + or aggregate reach that hides an unexercised receiving path. Check the + pre-edit inventory, actual comparison results, and their order. Retrospective + evidence or documentation of a gap does not satisfy the pre-edit gate. - A model rule that changed to match production output without an authority. - A claim about a state that no declared reach shows generated histories enter. - A skipped, narrowed, or relaxed comparison without a coverage-map entry and a diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index b057bed95c..3c7b7be771 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -148,8 +148,13 @@ The ten-finding high review adds generated error-provenance and partial-setup histories to the existing owners. The deferred-acquisition owner crosses ready, returned-error and thrown starts, before/during handoff, with defined/absent causes. Its independent start-handoff judgment also feeds real React ordinary -and infinite hook drivers, which compare readable error/boundary behavior and -ownership immediately after unmount. Explicit DbClient preload continues to +and infinite hook drivers. Each hook now has independent fixed/random campaigns +and reach minima for returned/thrown source failures before/during commit, +including absent causes. They compare readable error/boundary behavior after +commit, one source start, and ownership immediately after unmount, before GC. +Independent startup owns a pre-commit throw; the later hook reads error status. +The restored broad-rethrow defect fails both observations for each hook. +Explicit DbClient preload continues to report source startup failures. Pooled lifecycle histories add released partitions whose synchronous late cleanup callback returns or throws, including 1–3 healthy status peers. Observer histories add early capability or sync diff --git a/docs/contributing/oracle-reviews/2026-10-22-dbclient-migration-feedback.md b/docs/contributing/oracle-reviews/2026-10-22-dbclient-migration-feedback.md index a2f5ea859d..b8f2b05090 100644 --- a/docs/contributing/oracle-reviews/2026-10-22-dbclient-migration-feedback.md +++ b/docs/contributing/oracle-reviews/2026-10-22-dbclient-migration-feedback.md @@ -1613,3 +1613,108 @@ The historical audit prefix remains byte-identical. The changeset now records readable source errors and partial-attachment release. No published commit was amended or rebased. The production changes preserve the approved cleanup rejection policy. The loss totals and non-ready verdict above still apply. + +### Current receiving-gate enforcement — 2026-10-23 + +This pass starts at `1d1ac0170d5520ff21b54aee90c006382841150d` and changes no +production source. It strengthens the current receiving comparisons before +considering any further repair. It does not repair historical work order. + +**Law and authority.** The shared conformance contract exposes recorded source +errors as readable React status. The shared `expectedStartHandoff` judgment +permits an exception only from startup performed by that handoff. Both hooks +must release every subscriber after unmount, before GC can conceal ownership. +Explicit preload failure reporting and abandoned-render zero-start cuts remain +neighboring guarantees in their existing owners. + +The pre-edit inventory names both public hooks, failure before/during commit, +returned `markError`/thrown startup, defined/absent causes, error/boundary after +commit, and ownership immediately after unmount. Each hook now has independent +fixed/random campaigns and reach minima. Independent DbClient startup supplies +the earlier-failure premise: its throw is observed before render, the source +already reports error, and commit must not start it again. No model rule or +production expectation changes. + +**Reach.** Each row contains 30 histories. Columns count returned errors before +and during commit, thrown starts before and during commit, and absent causes. +Every minimum is one; counts are per history, not callback count. + +| Hook / observation / campaign | Returned before | Returned during | Thrown before | Thrown during | Absent cause | +| --- | ---: | ---: | ---: | ---: | ---: | +| ordinary / error-surface / fixed | 7 | 9 | 8 | 6 | 14 | +| ordinary / error-surface / random | 10 | 10 | 6 | 4 | 15 | +| ordinary / ownership / fixed | 7 | 9 | 8 | 6 | 14 | +| ordinary / ownership / random | 5 | 6 | 8 | 11 | 15 | +| infinite / error-surface / fixed | 7 | 9 | 8 | 6 | 14 | +| infinite / error-surface / random | 5 | 4 | 9 | 12 | 19 | +| infinite / ownership / fixed | 7 | 9 | 8 | 6 | 14 | +| infinite / ownership / random | 7 | 6 | 8 | 9 | 14 | + +**RED and mutant.** Temporarily removing `reportSourceErrors &&` from the helper +restores the reviewed defect. After rebuilding db, all eight receiving campaigns +fail at the intended comparisons. Error-surface campaigns observe one boundary +error instead of a readable result. Ownership campaigns observe one retained +subscriber after unmount instead of zero. These are assertion failures, not +setup errors. The mutation is restored byte for byte and db rebuilt. +This calibration tests current enforcement; it is not a new pre-fix RED claim. + +Four new guarded replays preserve the changed grammar. The property IDs remain +`react.descriptor-error-surface` and `react.descriptor-ownership`; the additional +`-t 'ordinary '` or `-t 'infinite '` selects the receiving hook independently. +The runner's manifest still selects the random lane. All four exact witnesses +execute and pass after restoration: + +| Hook / observation | Seed | Path | +| --- | ---: | --- | +| ordinary / error-surface | -2062994310 | `0` | +| infinite / error-surface | -1755367813 | `4` | +| ordinary / ownership | -44638460 | `5:0` | +| infinite / ownership | -938612395 | `3` | + +**GREEN.** The full React package passes 466 tests in 23 files, including type +checks. The deferred-acquisition owner and DbClient pass 580 tests in two files. +The four guarded replays pass. Scoped ESLint and Prettier pass. All three edited +skills validate with `quick_validate.py`, using cached PyYAML. A broader replay- +protocol run was interrupted; its partial output is not a clean GREEN claim. +The protocol itself is unchanged, and its previous results remain historical. + +The first surrounding React run exposed a real-clock test gap: a nominal +10-millisecond wait overshot the abandoned-render GC cut under load. The two +infinite-query GC tests now advance a controlled clock by exactly 10 milliseconds, +check the same live-state expectation, then run pending timers and check cleanup. +All 12 tests in that file pass separately and in the full package. No status +comparison was relaxed and no product behavior changed. + +**Grammar and limits.** Both hooks independently sample the same bounded +2 × 2 × 2 failure grammar. Removing timing loses the unrelated-error case; +removing either hook loses its host path; absent causes distinguish a missing +error value from absence of failure. The grammar reconstructs the original +returned-error/during-commit witness. The driver excludes a claimed pre-commit +failure without prior startup by checking source error and one start before +render. Healthy startup retains its core-owner coverage. Source startup is +synchronous and eager. This does not prove +arbitrary React schedules, asynchronous outages, error recovery, Suspense error +policy, or another framework's receiving boundary. + +For ORC-001–015, authority/model independence and canonical terms remain +unchanged (001/002/009). Prose, generated timing, real receiving drivers and +commit/unmount assertions are colocated (003–005). The eight intended assertion +failures calibrate the checker (006); paired campaigns and exact guarded replays +satisfy 007. No reference-model state changes (008). Cleanup exceptions remain +outside this grammar (010), and no new shared semantic-fault hypothesis requires +a second formulation (011). This versioned entry records the evidence (012). +Before/during timing distinguishes the exception boundary (013), both real hooks +receive the startup premise (014), and each path has mandatory reach (015). +The coverage map identifies the same owner and finite limits. + +The oracle-authoring, oracle-review and personal evaluate-review instructions +now require the receiving-path inventory and comparisons before production edits. +Missing required witnesses and setup failures block that gate. Aggregate reach +cannot substitute for each receiving path. Retrospective verification and a +recorded deviation cannot satisfy the gate. + +The React owner SHA-256 is +`e9bff514ae6bf023ec5d310a7848fb6b817132dad6fb9cc2dc1848d5d3ecd550`. +Production remains pinned to the starting head. All 26 CI checks passed on that +head; later-head CI must be assessed separately. F02's initial-snapshot decision +still prevents a ready verdict. H04/H08 and F07 retain their evidence gaps. diff --git a/packages/react-db/tests/descriptor-query-binding-oracle.test.tsx b/packages/react-db/tests/descriptor-query-binding-oracle.test.tsx index fe8bbd5621..2a44ee0875 100644 --- a/packages/react-db/tests/descriptor-query-binding-oracle.test.tsx +++ b/packages/react-db/tests/descriptor-query-binding-oracle.test.tsx @@ -1687,9 +1687,11 @@ describe(`standalone descriptor query binding`, () => { * The shared conformance contract exposes ordinary hook errors as a readable * status. The start-handoff judgment distinguishes a returned markError from * a synchronous throw; neither permits a failed commit to retain ownership. - * The grammar varies both hooks, defined/absent causes, and callback return/ - * throw. We observe the boundary and result after commit, and Collection - * ownership immediately after unmount, before Collection GC can hide a leak. + * Each hook has independent campaigns and reach checks. The grammar varies + * defined/absent causes, callback return/throw, and failure before/during commit. + * Before-commit failures come from independent DbClient startup, whose throw + * belongs to that caller. We observe the boundary and result after commit, + * and Collection ownership immediately after unmount, before GC can hide a leak. */ describe(`descriptor error handoff at React commit`, () => { class Boundary extends Component< @@ -1711,129 +1713,153 @@ describe(`descriptor error handoff at React commit`, () => { 'error-surface': `react.descriptor-error-surface`, ownership: `react.descriptor-ownership`, } as const - for (const observation of [`error-surface`, `ownership`] as const) { - for (const campaign of [`fixed`, `random`] as const) { - it(`${observation} after descriptor failure (${campaign})`, async () => { - const reach = createOracleReach(`react-error-handoff`, [ - `ordinary`, - `infinite`, - `mark-error`, - `throw`, - `undefined-cause`, - ]) - await fc.assert( - fc.asyncProperty( - fc.record({ - hook: fc.constantFrom(`ordinary` as const, `infinite` as const), - outcome: fc.constantFrom(`mark-error` as const, `throw` as const), - cause: fc.constantFrom(`error` as const, `undefined` as const), - }), - async (history) => { - reach.beginHistory() - const failure = - history.cause === `error` - ? new Error(`source startup`) - : undefined - const client = new DbClient() - const descriptor = collectionOptions( - `react-handoff-error`, - () => ({ - id: `react-handoff-error`, - syncMode: `eager`, - autoIndex: `eager`, - defaultIndexType: BTreeIndex, - getKey: (row: Row) => row.id, - startSync: true, - gcTime: 0, - sync: { - sync: ({ markError }) => { - if (history.outcome === `throw`) throw failure - markError(failure) + for (const hook of [`ordinary`, `infinite`] as const) { + for (const observation of [`error-surface`, `ownership`] as const) { + for (const campaign of [`fixed`, `random`] as const) { + it(`${hook} ${observation} after descriptor failure (${campaign})`, async () => { + const reach = createOracleReach(`react-error-handoff-${hook}`, [ + `mark-error-before-release`, + `mark-error-during-release`, + `throw-before-release`, + `throw-during-release`, + `undefined-cause`, + ]) + await fc.assert( + fc.asyncProperty( + fc.record({ + outcome: fc.constantFrom( + `mark-error` as const, + `throw` as const, + ), + cause: fc.constantFrom(`error` as const, `undefined` as const), + timing: fc.constantFrom( + `before-release` as const, + `during-release` as const, + ), + }), + async (history) => { + reach.beginHistory() + const failure = + history.cause === `error` + ? new Error(`source startup`) + : undefined + const client = new DbClient() + let starts = 0 + const descriptor = collectionOptions( + `react-handoff-error`, + () => ({ + id: `react-handoff-error`, + syncMode: `eager`, + autoIndex: `eager`, + defaultIndexType: BTreeIndex, + getKey: (row: Row) => row.id, + startSync: true, + gcTime: 0, + sync: { + sync: ({ markError }) => { + starts++ + if (history.outcome === `throw`) throw failure + markError(failure) + }, }, - }, - }), - ) - const query = new Query() - .from({ item: descriptor }) - .orderBy(({ item }) => item.id) - const caught: Array = [] - let current: - | { - isError?: boolean - status: string - collection?: { - subscriberCount: number - cleanup: () => Promise - } - } - | undefined - function Consumer() { - current = - history.hook === `ordinary` - ? useLiveQuery({ query }) - : useLiveInfiniteQuery(() => query, { pageSize: 1 }) - return null - } - const consoleError = vi - .spyOn(console, `error`) - .mockImplementation(() => {}) - let mounted: ReturnType | undefined - try { - mounted = render( - - - - - , + }), ) - await act(async () => {}) - reach.reach(history.hook) - reach.reach(history.outcome) - if (history.cause === `undefined`) - reach.reach(`undefined-cause`) - if (observation === `error-surface`) { - const expected = expectedStartHandoff({ - ...history, - timing: `during-release`, - }) - if (!expected.throws) { - expect(caught, `ordinary errors remain readable`).toEqual( - [], - ) - expect(current?.status).toBe(`error`) - expect(current?.isError).toBe(true) - } else { + const query = new Query() + .from({ item: descriptor }) + .orderBy(({ item }) => item.id) + const caught: Array = [] + let current: + | { + isError?: boolean + status: string + collection?: { + subscriberCount: number + cleanup: () => Promise + } + } + | undefined + function Consumer() { + current = + hook === `ordinary` + ? useLiveQuery({ query }) + : useLiveInfiniteQuery(() => query, { pageSize: 1 }) + return null + } + const consoleError = vi + .spyOn(console, `error`) + .mockImplementation(() => {}) + let mounted: ReturnType | undefined + try { + if (history.timing === `before-release`) { + let threw = false + try { + client.collection(descriptor) + } catch (error) { + threw = true + expect(error).toBe(failure) + } expect( - caught, - `a thrown start remains fail-fast`, - ).toHaveLength(1) + threw, + `independent startup owns its exception`, + ).toBe(history.outcome === `throw`) + expect(client.collection(descriptor).status).toBe(`error`) + expect(starts).toBe(1) + } + mounted = render( + + + + + , + ) + await act(async () => {}) + expect(starts, `commit does not repeat source startup`).toBe( + expectedStartHandoff(history).starts, + ) + reach.reach(`${history.outcome}-${history.timing}`) + if (history.cause === `undefined`) + reach.reach(`undefined-cause`) + if (observation === `error-surface`) { + const expected = expectedStartHandoff(history) + if (!expected.throws) { + expect(caught, `ordinary errors remain readable`).toEqual( + [], + ) + expect(current?.status).toBe(expected.status) + expect(current?.isError).toBe(true) + } else { + expect( + caught, + `a thrown start remains fail-fast`, + ).toHaveLength(1) + } } + mounted.unmount() + expect(current?.collection).toBeDefined() + if (current?.collection) + expect( + current.collection.subscriberCount, + `commit/unmount releases every hook owner`, + ).toBe(0) + } finally { + mounted?.unmount() + consoleError.mockRestore() + await current?.collection?.cleanup() + await client.cleanup() } - mounted.unmount() - expect(current?.collection).toBeDefined() - if (current?.collection) - expect( - current.collection.subscriberCount, - `commit/unmount releases every hook owner`, - ).toBe(0) - } finally { - mounted?.unmount() - consoleError.mockRestore() - await current?.collection?.cleanup() - await client.cleanup() - } - }, - ), - campaign === `fixed` - ? { numRuns: oracleRuns(30), seed: 2229 } - : oraclePropertyOptions(30, properties[observation]), - ) - reach.assertReached() - console.info( - `react-error-handoff ${observation} ${campaign}`, - Object.fromEntries(reach.counts()), - ) - }) + }, + ), + campaign === `fixed` + ? { numRuns: oracleRuns(30), seed: 2229 } + : oraclePropertyOptions(30, properties[observation]), + ) + reach.assertReached() + console.info( + `react-error-handoff ${hook} ${observation} ${campaign}`, + Object.fromEntries(reach.counts()), + ) + }) + } } } }) diff --git a/packages/react-db/tests/useLiveInfiniteQuery.test.tsx b/packages/react-db/tests/useLiveInfiniteQuery.test.tsx index 1e67758e2f..58f0c3b798 100644 --- a/packages/react-db/tests/useLiveInfiniteQuery.test.tsx +++ b/packages/react-db/tests/useLiveInfiniteQuery.test.tsx @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { act, render, renderHook, waitFor } from '@testing-library/react' import { Suspense } from 'react' import { @@ -34,7 +34,10 @@ function createMockPosts(count: number): Array { } describe(`useLiveInfiniteQuery`, () => { + afterEach(() => vi.useRealTimers()) + it(`reclaims a query-function collection abandoned before it commits`, async () => { + vi.useFakeTimers() const source = createCollection( mockSyncCollectionOptions({ id: `abandoned-infinite-query`, @@ -66,13 +69,17 @@ describe(`useLiveInfiniteQuery`, () => { // Like useLiveQuery, the hook starts sync during render. The render asks // for no data, but its collection holds the source until GC reclaims it. expect(source.subscriberCount).toBe(0) - await new Promise((resolve) => setTimeout(resolve, 10)) + // Advance the clock explicitly: an overloaded worker can overshoot a real + // ten-millisecond wait and run collection GC before this observation. + await act(() => vi.advanceTimersByTimeAsync(10)) expect(source.status).not.toBe(`cleaned-up`) - await waitFor(() => expect(source.status).toBe(`cleaned-up`)) + await act(() => vi.runAllTimersAsync()) + expect(source.status).toBe(`cleaned-up`) rendered.unmount() }) it(`reclaims a supplied collection that an abandoned render started`, async () => { + vi.useFakeTimers() // Like useLiveQuery, the hook starts a supplied collection with eager // sources during render, so its first paint is ready. The supplied // collection's own gcTime reclaims a render that never commits. #1675 kept @@ -109,9 +116,10 @@ describe(`useLiveInfiniteQuery`, () => { ) expect(source.subscriberCount).toBe(0) - await new Promise((resolve) => setTimeout(resolve, 10)) + await act(() => vi.advanceTimersByTimeAsync(10)) expect(source.status).not.toBe(`cleaned-up`) - await waitFor(() => expect(source.status).toBe(`cleaned-up`)) + await act(() => vi.runAllTimersAsync()) + expect(source.status).toBe(`cleaned-up`) rendered.unmount() }) From f374bdbb12c626a393c920a111ab892e905646f9 Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 23 Oct 2026 13:18:37 -0600 Subject: [PATCH 14/20] fix(db): reject unprepared SSR snapshots and preserve valid hydration --- .../resume-independent-subscription-demand.md | 4 + docs/contributing/oracle-coverage.md | 36 +- .../2026-10-22-dbclient-migration-feedback.md | 274 ++++++++++++++ docs/errors.md | 10 + docs/guides/ssr.md | 36 ++ packages/db/mangle-cache.json | 4 +- packages/db/src/client.ts | 17 + packages/db/src/live-query-observer.ts | 47 ++- .../db/src/live-query-window-controller.ts | 17 +- .../tests/fixtures/error-site-messages.json | 7 + ...-observer-snapshot-oracle.property.test.ts | 122 +++++-- packages/db/tests/oracle-config.ts | 3 + packages/db/tests/oracle-replay-manifest.ts | 19 +- .../db/tests/ssr-snapshot-admission-oracle.ts | 97 +++++ packages/react-db/src/useLiveInfiniteQuery.ts | 35 +- packages/react-db/src/useLiveQuery.ts | 9 +- .../descriptor-query-binding-oracle.test.tsx | 338 ++++++++++++++++++ packages/svelte-db/src/useLiveQuery.svelte.ts | 5 +- .../svelte-db/tests/hydration.svelte.test.ts | 91 ++++- ...r-snapshot-admission-oracle.svelte.test.ts | 157 ++++++++ 20 files changed, 1285 insertions(+), 43 deletions(-) create mode 100644 packages/db/tests/ssr-snapshot-admission-oracle.ts create mode 100644 packages/svelte-db/tests/ssr-snapshot-admission-oracle.svelte.test.ts diff --git a/.changeset/resume-independent-subscription-demand.md b/.changeset/resume-independent-subscription-demand.md index b0adbe3561..92abccb9f8 100644 --- a/.changeset/resume-independent-subscription-demand.md +++ b/.changeset/resume-independent-subscription-demand.md @@ -1,5 +1,7 @@ --- '@tanstack/db': patch +'@tanstack/react-db': patch +'@tanstack/svelte-db': patch --- Resume Collection sync when a new subscriber follows a suspended render, while preserving cleanup during startup. @@ -11,3 +13,5 @@ Release partial observer and pooled-query subscriptions when setup fails or clea Subscribers that survive cleanup during admission retain demand for an explicit restart. Cleanup in a first-demand callback does not start a replacement implicitly. Keep ordinary hook errors readable when deferred sync startup records an error. Explicit preloads still report source startup failures. Release channels acquired before synchronous cleanup registration or observer attachment fails. + +Reject SSR reads of incidental server-only rows or readiness with an actionable preparation error. Preserve cold loading and normalized source hydration, consume prepared infinite-query snapshots, and register warm Suspense results for streaming. diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index 3c7b7be771..f7d3d5e4b1 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -613,14 +613,34 @@ calibration of a previously-requested-start mutant predates the final call reorder and is not evidence against that mutant on the current path. This does not establish arbitrary concurrent-render schedules, failed-provider histories, or Electric txid delivery. The [migration feedback review](oracle-reviews/2026-10-22-dbclient-migration-feedback.md) -records calibration and the still-open SSR hydration law: a warm server source -lets a second unregistered plain hook render rows absent from result-only -transport. A task-local real React hydrateRoot witness recovers for that query -and passes when it is explicitly preloaded. The descriptor owner is the next -receiving owner once the product chooses automatic result capture or an initial -empty/loading policy; partial nonready rows, status flags, normalized-source -transport, mixed Suspense/plain schedules, and the real Start/Electric path -remain needed witnesses. This diagnostic is not claimed as repaired coverage. +records the SSR admission repair. The maintainer chose a programming-error +rejection when an ordinary server hook reads incidental rows or readiness that +its first browser render cannot reproduce. Cold/loading SSR remains legal. +`packages/db/tests/ssr-snapshot-admission-oracle.ts` owns the independent +preparation judgment and history grammar. The descriptor owner drives React's +ordinary and infinite hooks independently. The Svelte server driver is +`packages/svelte-db/tests/ssr-snapshot-admission-oracle.svelte.test.ts`. +Each compares rejection, projected rows and status before browser commit across +no preparation, completed or pending query preload, and normalized source +transport, including ready-empty and partial-loading sources. Normalized rows +remain provisional. React also runs real `hydrateRoot`, observes recoverable +errors, and checks the committed browser result. Svelte's +`hydration.svelte.test.ts` receives normalized transport and normal warm-browser +initialization through its native effect scheduler. Real React streaming checks +cold and warm Suspense discovery. The audit records RED on unchanged production, +GREEN, narrow mutants, and exact receiving replays. +The separate `live-query-observer-snapshot-oracle.property.test.ts` owner checks +unchanged getter identity with and without a completed DbClient query. It runs +ordered/single-result and attached/detached histories through ordinary and server +getters. It does not model provisional hydration replacement or host scheduling. +Limits: the generated sources are synchronous and eager with zero to three rows. +Infinite receiving coverage uses a one-row initial page and its peek-ahead row. +Arbitrary asynchronous providers, later page-shape changes during a pending +hydration stream, persistence readiness, mixed Suspense/plain streaming schedules, +custom dehydration filters, and the real Start/Electric host remain open paths +for these same receiving owners. There is no demonstrated counterexample in +these unexercised paths. Transport deliberately discarding required state and +rendering local Collection object identity are outside the portable snapshot law. The Date descriptor typing report remains an evidence gap. The reliable strict compiler probe accepts the reported Date forms. The receiving owners are `packages/db/tests/db-client.test-d.ts` and diff --git a/docs/contributing/oracle-reviews/2026-10-22-dbclient-migration-feedback.md b/docs/contributing/oracle-reviews/2026-10-22-dbclient-migration-feedback.md index b8f2b05090..87ff5a3e3f 100644 --- a/docs/contributing/oracle-reviews/2026-10-22-dbclient-migration-feedback.md +++ b/docs/contributing/oracle-reviews/2026-10-22-dbclient-migration-feedback.md @@ -1718,3 +1718,277 @@ The React owner SHA-256 is Production remains pinned to the starting head. All 26 CI checks passed on that head; later-head CI must be assessed separately. F02's initial-snapshot decision still prevents a ready verdict. H04/H08 and F07 retain their evidence gaps. + +## SSR preparation admission — 2026-10-23 + +This entry implements the maintainer's decision: fail on programming errors that +expose incidental server-only rows or readiness. An ordinary cold/loading render +remains legal. The SSR guide already permits normalized source rows and prepared +query results. Normalized rows carry no query readiness. A completed query result +can carry readiness. Runtime provider failures retain their existing error surface. +This is a product decision, rather than authority inferred from production output. + +**Pre-edit gate.** Production was unchanged at `90a30827fd32c5444243206f136a89e738ee6099` +while the shared SSR judgment and receiving drivers were added. The inventory named +React ordinary and infinite reads, Svelte server reads, normal browser warm reads, +normalized hydration, and cold/warm Suspense discovery. Named replays ran all seven +preparation boundaries before either generated campaign could stop at its first +mismatch. The React drivers use real `renderToString`, `hydrateRoot`, commit, and +unmount. Svelte uses real server compilation and its native browser effect root. +A new real `renderToPipeableStream` witness checks Suspense discovery. + +**RED.** In `packages/react-db`: + +```sh +pnpm exec vitest run tests/descriptor-query-binding-oracle.test.tsx \ + -t 'SSR snapshot admission|SSR Suspense discovery' --coverage.enabled=false \ + --pool-options.threads.maxThreads=2 --pool-options.threads.minThreads=1 +``` + +In `packages/svelte-db`: + +```sh +pnpm exec vitest run tests/ssr-snapshot-admission-oracle.svelte.test.ts \ + tests/hydration.svelte.test.ts --coverage.enabled=false \ + --pool-options.threads.maxThreads=2 --pool-options.threads.minThreads=1 +``` + +All three receiving paths accepted unsupported ready-empty snapshots and incidental +rows. Pending query registration did not transport partial rows. Normalized ready +sources differed at the first browser status observation. React infinite reads also +ignored a completed query result. Cold/loading, ordinary completed query transport, +normalized partial-loading rows, and Svelte browser initialization supplied passing +neighbor controls. The first streaming control had a missing test import. Correcting +that setup before production edits produced a passing cold discovery control and an +assertion RED for the warm query's absent transport entry. The setup error receives +no defect or calibration credit. + +**Repair.** The shared observer checks server reads against completed query results +or explicit/hydrated normalized sources. Unsupported server reads throw the coded +preparation error. Normalized source snapshots report loading on both hosts until +browser acquisition proves readiness. A Collection record remembers hydration, +including an empty chunk. It preserves the existing dehydration selection policy. +The infinite controller consumes a matching result through the same observer and +uses its own server snapshot. Streaming Suspense preloads before its server snapshot, +including when a peer warmed the source. Svelte distinguishes server reads from +ordinary browser initialization. + +**Model and grammar.** The pure judgment reads caller preparation, authored source +state, and zero to three authored rows. It imports no production classifier. The +source-state abstraction combines synchronous eager startup and readiness. Query +preparation distinguishes completed and pending preload. Removing that distinction +admits partial untransported data. Removing rows misses partial-loading data. +Removing readiness misses ready-empty markup. Removing source preparation rejects +valid normalized hydration. Idle plus nonempty rows, a completed preload with an +unready source, and a pending preload over a synchronously ready source are outside +this grammar. Named examples reconstruct F02 and every admission boundary. Campaign +examples preserve boundary reach even when random sampling omits one. Fixed and +random campaigns execute the same property and budget. + +**Self-review.** ORC-001/002: maintainer decision and SSR guide authorize the judgment, +which stays independent. ORC-003/005/009: literate model and directly named receiving +drivers keep the grammar, paths, observations, and canonical terms visible. ORC-004: +the reconstruction, ablation, bounded range and invalid neighbors are stated above. +ORC-008 does not apply to this stateless judgment. ORC-010: cleanup does not throw in +the controlled grammar, including the calibrated production mutants. ORC-011: +server/browser markup equality independently challenges the preparation judgment's +claim that accepted state is reproducible. ORC-013: partial-loading rows and +ready-empty status distinguish row-only and readiness-only checks. ORC-014: both +React hooks and Svelte each receive their premise through their real host paths. +ORC-006/007/012/015 results, counts, replays and artifact identities follow below. + +**Limits.** This closes F02 for the bounded preparation histories and named receiving +cuts. It is not a proof of arbitrary providers or schedules. The coverage map keeps +asynchronous providers, persistence readiness, pending-stream page-shape changes, +mixed plain/Suspense streaming, and real Start/Electric execution with their owners. +Custom filters that deliberately discard needed state and rendering local Collection +identity are outside portable hydration. H04/H08 still need legal failing traces. +F07 still needs the failing application type assignment and dependency versions. + +**GREEN and reach.** React passes 486 tests in 23 files. After the final reach +classifier correction, its SSR owner passes all 20 selected tests again. Svelte +passes all 146 tests in 10 files, including type checks, with no unhandled errors. +Core observer, snapshot, controller, DbClient, hydration-authority, preload-identity, +and production-error owners pass 274 tests in eight files. The unchanged replay +protocol and manifest suite also passed in the preceding 311-test core run. +The final scoped ESLint and Prettier checks pass. Generated error documentation +matches its fixture. The private-member cache validates all 420 names. The consumer +minified check passes 107 error names, index metadata, query rows and live updates. + +Every campaign executes 120 property samples at fixed seed 2229 or without a seed. +The seven boundary examples are samples in each campaign. Counts below include +those examples. `normalized-ready` now requires an actually ready source, rather +than counting idle normalized sources. No observation expectation changed. + +| Receiving path / campaign | Cold/loading | Unbacked ready-empty | Unbacked rows | Query result | Normalized ready | Normalized partial | Pending query | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| ordinary / fixed | 23 | 4 | 38 | 23 | 18 | 14 | 16 | +| ordinary / random | 26 | 2 | 38 | 19 | 18 | 17 | 19 | +| infinite / fixed | 23 | 4 | 38 | 23 | 18 | 14 | 16 | +| infinite / random | 17 | 5 | 45 | 21 | 19 | 13 | 22 | +| svelte / fixed | 23 | 4 | 38 | 23 | 18 | 14 | 16 | +| svelte / random | 18 | 12 | 44 | 14 | 16 | 16 | 18 | + +**Calibration.** These are assertion failures at the stated public comparisons: + +| Hostile change | Distinguishing comparison | +| --- | --- | +| Check only nonempty rows | React ordinary, React infinite and Svelte all accept an unsupported ready-empty snapshot, failing the rejection assertion. | +| Bypass admission | React accepts incidental rows/readiness and fails 10 admission assertions. | +| Preserve live readiness for normalized SSR | Both React paths fail server/first-browser markup equality, six assertions. | +| Omit the infinite controller's client binding | Infinite admission and prepared-result handoff fail seven assertions. | +| Skip streaming Suspense preload | The warm real-stream result is absent from transport. The cold discovery control still passes. | +| Apply the server getter to Svelte browser initialization | Both warm-browser controls fail the explicit no-initialization-error assertion. Normalized controls still pass. | + +An initial constant-false bypass failed TypeScript compilation. It receives no +calibration credit. The corrected bypass retained type-valid control flow and +failed the intended assertions. The first browser-guard mutant threw directly +at the intended public call. A later run captures that call and fails the explicit +no-error comparison instead. All temporary source mutations were restored, db was +rebuilt, and the same receiving checks passed. + +Guarded replays each execute one selected receiving campaign and pass on restored +production. React ordinary uses seed `451253809`, path `1`. React infinite uses +seed `-348951204`, path `1`. Svelte uses seed `2229`, path `1`, captured by its own +ready-empty hostile run. The old Svelte path `0:0` no longer selects a sample after +adding campaign examples. The replay guard correctly rejected its zero-execution +run. Only the replacement replay receives evidence credit. + +From the relevant framework package, the guarded command is: + +```sh +TANSTACK_DB_ORACLE_PROPERTY= TANSTACK_DB_ORACLE_SEED= \ +TANSTACK_DB_ORACLE_PATH= node --import tsx ../db/tests/oracle-replay.ts \ + tests/ --coverage.enabled=false \ + --pool-options.threads.maxThreads=2 --pool-options.threads.minThreads=1 +``` + +Property IDs are `react.ssr-ordinary`, `react.ssr-infinite`, and +`svelte.ssr-snapshot`. The React owner is `descriptor-query-binding-oracle.test.tsx`. +The Svelte owner is `ssr-snapshot-admission-oracle.svelte.test.ts`. + +**Production weight.** `pnpm size:compare --packages db,react-db --files` compares +the full branch with merged main `65d9ada50`. db grows 372 gzip / 345 brotli bytes. +React grows 103 gzip / 106 brotli bytes. The typical app grows 294 gzip / 346 brotli +bytes. Other app scenarios grow 272–297 gzip bytes. These totals include the earlier +lifecycle repair, rather than measuring only this follow-up. + +The repair reuses one observer snapshot builder and one window pagination builder. +It combines the persisted-readiness condition instead of adding another publication +path. Private-member minification includes both new private names. Narrower +alternatives were tried as the row-only, source-readiness, and omitted-window-binding +mutants above. Their intended assertion failures rule out those byte reductions. +A separate hydration fact preserves existing dehydration selection and represents +empty hydration chunks. Source rows and query readiness require distinct evidence. +The remaining shipped growth pays for admission, that provenance, the matching +infinite observer, and host selection. It adds no retry or recovery lifecycle. +Tests, oracle metadata and contract documentation grow separately from production. + +This entry is tied to the following artifact hashes and the commit that contains it. +The starting production head is recorded above. Hashes cover final oracle artifacts: + +| Artifact | SHA-256 | +| --- | --- | +| `packages/db/tests/ssr-snapshot-admission-oracle.ts` | `6da03f17b4b8244f0c7b60825f33b42188faa457582eecfadc5a71d3c83d85d3` | +| `packages/react-db/tests/descriptor-query-binding-oracle.test.tsx` | `ac2b11d9894fbf4df35432f9f4a62ffaeb5081021e55e5f873846477b9411ca3` | +| `packages/svelte-db/tests/ssr-snapshot-admission-oracle.svelte.test.ts` | `9a8fc2ad26688559887d6b37cd17bf43bfb8dcbfb887abe6e13981900f120789` | +| `packages/svelte-db/tests/hydration.svelte.test.ts` | `6ef9c10936885165d9ace242e9caf1846b83fa1c439fd124288ba53b6f634379` | + + +### Final SSR cache check and publication evidence + +The preceding SSR results and size figures describe the first candidate. This +entry records the final cache design and supersedes those size figures. The +maintainer's programming-error policy and the bounded SSR judgment are unchanged. + +**Adjacent cache law.** With no write between reads, an ordinary observer getter +retains its snapshot identity. Without a DbClient, its server getter exposes that +same snapshot. A completed client query also shares its ready snapshot. Reading +the server view cannot invalidate the ordinary view's cache. This follows from the +existing retained-snapshot contract, independently of SSR admission. + +The markup comparisons did not observe cross-getter object identity. Before the +cache fix, the retained-snapshot owner added those observations to its existing +sorted-map histories. Its four ordered/single-result × attached/detached paths +all failed on the global server-mode cache. The focused replay also failed. A +narrower client-only mode passed the no-client checks, but all four prepared-client +paths and the focused replay still failed their identity comparisons. Each RED +run had five assertion failures at the promised getter checkpoint. A malformed +preload fixture failed setup before that corrected RED and receives no credit. + +The final observer leaves `getSnapshot()` unchanged. It caches only provisional +server copies in a separate WeakMap keyed by the ordinary snapshot. Completed +query results and no-client reads use the original object. Weak keys allow obsolete +revisions to retire. This adds no retry, rollback, or recovery lifecycle. These +failed candidates supply hostile controls for cache invalidation. The previously +recorded admission mutants still challenge the unchanged admission judgment. + +**Cache oracle conformance.** ORC-001/002: the retained-snapshot promise authorizes +identity at an unchanged revision, independently of production cache fields. +ORC-003/005/009: the literate owner explains its sorted-map model, history, real +observer/client preload path and getter checkpoint. ORC-004: the existing one-to-24 +command grammar keeps keys a..d, versions 0..9, and delayed read orders. Both query +shapes and both attachment modes reconstruct the reported failures. Removing +client preparation misses the narrower failed candidate. No-write capture is the +marginal identity case. Deletes of absent keys do not mutate the model or source. +ORC-008: the reference model retains its existing state. ORC-010: the recorded +controlled runs had no cleanup failure that replaced an assertion. ORC-011: no +separate shared semantic-fault hypothesis applies to object identity. ORC-013: +no-client and prepared-client observations distinguish the two failed cache modes. +ORC-014: these are core getter claims, with no host handoff inferred. ORC-006/007/015: +each of eight normal campaigns reaches both `server-getter` and `client-getter` +40 times. Fixed seed 2043 and an unsupplied seed use the same property and budget. +The existing wrong-lazy-snapshot calibration still fails its row comparison. + +The guarded cache replay uses property `live-query-observer.retained-snapshot`, +seed `2043`, and captured shrink path `0:0:0`. It executes all four random/replay +variants and passes. Separating the campaign test cases lets the manifest select +those variants without running fixed campaigns. A preliminary guarded command +rejected the missing owner mapping, which was then registered. That rejected +command is not successful replay evidence. The final run reports four nonzero +executions, four passing tests, and six skipped unrelated/fixed tests. + +**Final receiving reach.** Each SSR path again runs 120 samples per campaign, +including the seven boundary examples. The fixed campaigns retain their counts +above. Final random counts, in the same column order, are: + +| Receiving path | Cold/loading | Unbacked ready-empty | Unbacked rows | Query result | Normalized ready | Normalized partial | Pending query | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| React ordinary | 18 | 4 | 42 | 21 | 17 | 18 | 16 | +| React infinite | 18 | 8 | 34 | 24 | 21 | 15 | 15 | +| Svelte | 13 | 4 | 48 | 13 | 20 | 22 | 16 | + +React passes 486 tests in 23 files and Svelte passes 146 in 10 files on the final +db build. Both include type checks. The core run passes 311 tests in nine files, +including 37 guarded-replay protocol/manifest tests. After splitting the cache +campaign cases, the eight core behavioral owners pass 278 tests. This is four +more test cases, with the same eight campaigns and history budget. Svelte builds +successfully. The minified consumer passes 107 error names, index metadata, rows +and live updates. The production-error bundle check verifies 248 literals, with +20 shared literals, in ESM and CommonJS development and production builds. + +**Final production weight.** Against merged main `65d9ada50`, db grows 378 gzip / +460 brotli bytes. React grows 103 gzip / 106 brotli bytes. The typical app grows +322 gzip / 326 brotli bytes. Other scenarios grow 298–322 gzip bytes. These are +full-branch figures. The row-only, readiness-preserving and omitted-window-binding +alternatives failed the earlier calibrated comparisons. The global and client-only +cache alternatives above failed snapshot identity. The separate weak cache retains +one ordinary builder while preserving both public getter contracts. Remaining +bytes pay for preparation provenance, provisional snapshots and host selection. +Tests, oracle metadata and contract documentation are separate from shipped code. +This SSR follow-up adds 124 production-source lines and removes six. Its test +source and fixtures add 806 lines and remove 28. Those counts exclude contract +documentation and build metadata. + +The bounded limits and H04/H08/F07 evidence gaps above still apply. The coverage +map names the cache owner and its limits alongside the SSR receiving owners. +The following artifact hashes and the containing commit identify the final evidence: + +| Artifact | SHA-256 | +| --- | --- | +| `packages/db/tests/ssr-snapshot-admission-oracle.ts` | `6da03f17b4b8244f0c7b60825f33b42188faa457582eecfadc5a71d3c83d85d3` | +| `packages/db/tests/live-query-observer-snapshot-oracle.property.test.ts` | `a8674794624052ba2b76aa10b47af19a0e773cc029cf263c9e4c504fe673484a` | +| `packages/react-db/tests/descriptor-query-binding-oracle.test.tsx` | `ac2b11d9894fbf4df35432f9f4a62ffaeb5081021e55e5f873846477b9411ca3` | +| `packages/svelte-db/tests/ssr-snapshot-admission-oracle.svelte.test.ts` | `9a8fc2ad26688559887d6b37cd17bf43bfb8dcbfb887abe6e13981900f120789` | +| `packages/svelte-db/tests/hydration.svelte.test.ts` | `6ef9c10936885165d9ace242e9caf1846b83fa1c439fd124288ba53b6f634379` | +| `packages/db/tests/oracle-replay-manifest.ts` | `3fd499676ba595cba4220e1d728760df8482ef671bd6e889592b0ed209087e82` | diff --git a/docs/errors.md b/docs/errors.md index fcfdcce0c4..be3833c30f 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -2469,3 +2469,13 @@ Development builds show this message, with `${...}` replaced by values: ```text A windowed or findOne result row has no include route ``` + + + +## Error 245: `live-query-observer.ts` + +Development builds show this message, with `${...}` replaced by values: + +```text +SSR cannot render server-only live-query rows or readiness. Await DbClient.preloadLiveQuery() for this query, or explicitly preload its source Collections for hydration. +``` diff --git a/docs/guides/ssr.md b/docs/guides/ssr.md index 4395728fc9..36a5d82108 100644 --- a/docs/guides/ssr.md +++ b/docs/guides/ssr.md @@ -313,6 +313,42 @@ pass config objects with the same `queryKey` to both `preloadLiveQuery` and `useLiveQuery`. Their sources and result configuration must still describe the same query. +### Prepare every server snapshot + +An ordinary server hook can render empty/loading state without a preload. To +render data, await a matching `preloadLiveQuery()` or explicitly preload the +source Collections. A different query warming the same source does not prepare +this query's result for transport. Reading those incidental rows or their +readiness during SSR throws an error with the required preparation steps. +Normal browser reads and runtime query errors keep their usual behavior. + +Normalized source transport carries rows without query readiness. Queries that +use only those rows report `loading` for both server markup and the first +browser snapshot. A matching completed query result reports `ready`. Browser +commit starts acquisition and publishes the live status normally. + +Streaming `useLiveSuspenseQuery` discovers and prepares its result during render, +including when another query already warmed the source. Ordinary hooks require +explicit preparation to render that data. + +For `useLiveInfiniteQuery`, preload the initial window plus its one-row peek: + +```tsx +const orderedTodos = new Query() + .from({ todo: todoCollection }) + .orderBy(({ todo }) => todo.id) +const pageSize = 20 + +await dbClient.preloadLiveQuery({ + query: orderedTodos.limit(pageSize + 1).offset(0), +}) +// In the component: +const result = useLiveInfiniteQuery(() => orderedTodos, { pageSize }) +``` + +Custom dehydration filters must retain the Collection rows or query result that +the markup requires. + ### Indexes before query compilation Create manual indexes when initializing each request or browser client, before diff --git a/packages/db/mangle-cache.json b/packages/db/mangle-cache.json index 3b4db20497..ddc6545071 100644 --- a/packages/db/mangle-cache.json +++ b/packages/db/mangle-cache.json @@ -416,5 +416,7 @@ "coverSource": "fN", "windowRequestGeneration": "av", "latestWindowRequest": "gE", - "startSyncForSubscriber": "gT" + "startSyncForSubscriber": "gT", + "snapshotFrom": "gV", + "serverSnapshots": "gU" } diff --git a/packages/db/src/client.ts b/packages/db/src/client.ts index 6dadb0046a..7769f3828c 100644 --- a/packages/db/src/client.ts +++ b/packages/db/src/client.ts @@ -183,6 +183,7 @@ export type DehydrateDbClientOptions = { type CollectionRecord = { collection: AnyCollection shouldDehydrate: boolean + hydrated: boolean } type LiveQueryRecord = { @@ -564,6 +565,7 @@ export class DbClient { this.collectionsById.set(collection.id, { collection, shouldDehydrate: !deferSyncStart, + hydrated: this.pendingHydration.has(collection.id), }) if (materializeOptions?.initialData?.length) { @@ -669,6 +671,7 @@ export class DbClient { for (const chunk of state.collections) { const record = this.collectionsById.get(chunk.collectionId) if (record) { + record.hydrated = true this.applyRows(record.collection, chunk, `hydration`) continue } @@ -692,6 +695,20 @@ export class DbClient { return () => this.listeners.delete(listener) } + /** @internal Normalized source transport carries rows, not readiness. */ + _hasSsrCollectionSnapshot(collection: AnyCollection): boolean { + const sources = getBuilderFromConfig( + collection.config, + )?.getSourceCollections() + if (sources) + return sources.every((source) => this._hasSsrCollectionSnapshot(source)) + const record = this.collectionsById.get(collection.id) + return ( + record?.collection === collection && + (record.shouldDehydrate || record.hydrated) + ) + } + /** @internal */ _setSsrStreamingEnabled(enabled: boolean): void { this.ssrStreamingEnabled = enabled diff --git a/packages/db/src/live-query-observer.ts b/packages/db/src/live-query-observer.ts index 5e089b709f..8fbbc62e0f 100644 --- a/packages/db/src/live-query-observer.ts +++ b/packages/db/src/live-query-observer.ts @@ -1,3 +1,4 @@ +import { codedMessage, devBuild } from './error-message.js' import { LiveQueryObserverDisposedError } from './errors.js' import { runAllCallbacks } from './utils/callbacks.js' import { @@ -203,6 +204,8 @@ class LiveQueryObserverImpl< private cachedEntries: Array<[TKey, T]> | undefined private cachedCollectionRevision: number | undefined private cachedCollectionLayoutRevision: number | undefined + private serverSnapshots: + WeakMap, LiveQuerySnapshot> | undefined private snapshotDirty = true private cachedSnapshot: LiveQuerySnapshot = DISABLED_SNAPSHOT private layoutRevision = 0 @@ -332,7 +335,49 @@ class LiveQueryObserverImpl< } getServerSnapshot(): LiveQuerySnapshot { - return this.getSnapshot() + const snapshot = this.getSnapshot() + const collection = this.collection + if ( + !this.client || + !collection || + this.hasHydrationSeed() || + snapshot.isError + ) + return snapshot + + // Only completed query results transfer readiness. Source rows remain + // provisional, and incidental server state needs explicit preparation. + if (snapshot.state?.size || snapshot.isReady) { + const { sources } = this.getSourceInfo() + if ( + !(sources ?? [collection]).every((source) => + this.client!._hasSsrCollectionSnapshot(source), + ) + ) { + throw new Error( + devBuild() && process.env.NODE_ENV !== `production` + ? `SSR cannot render server-only live-query rows or readiness. Await DbClient.preloadLiveQuery() for this query, or explicitly preload its source Collections for hydration.` + : codedMessage(245), + ) + } + } + + // Keep the ordinary cache intact. Weak keys let obsolete revisions retire + // even when a browser observer survives its hydration render. + let serverSnapshot = this.serverSnapshots?.get(snapshot) + if (!serverSnapshot) { + serverSnapshot = { + ...snapshot, + status: `loading`, + ...getLiveQueryStatusFlags(`loading`), + persistedStatus: snapshot.isPersistedReady + ? `loading` + : snapshot.persistedStatus, + isPersistedReady: false, + } + ;(this.serverSnapshots ??= new WeakMap()).set(snapshot, serverSnapshot) + } + return serverSnapshot } getError(): unknown { diff --git a/packages/db/src/live-query-window-controller.ts b/packages/db/src/live-query-window-controller.ts index 98b5a191cc..3469826dfc 100644 --- a/packages/db/src/live-query-window-controller.ts +++ b/packages/db/src/live-query-window-controller.ts @@ -15,6 +15,7 @@ import type { LiveQueryObserver, LiveQuerySnapshot, } from './live-query-observer.js' +import type { DbClient } from './client.js' import type { Collection } from './collection/index.js' import type { CollectionStatus } from './types.js' import type { @@ -541,6 +542,8 @@ export interface LiveQueryWindowSnapshot< /** @internal This contract is unstable while RFC #1623 is being implemented. */ export interface CreateLiveQueryWindowControllerOptions { + client?: DbClient + queryHash?: string /** Rows per page (default 20). Invalid values use the default. */ pageSize?: number /** Value of the first page's `pageParam` (default 0). */ @@ -555,6 +558,7 @@ export interface LiveQueryWindowController< TKey extends string | number, > { getSnapshot: () => LiveQueryWindowSnapshot + getServerSnapshot: () => LiveQueryWindowSnapshot subscribe: (listener: () => void) => () => void /** Load one more page, resolving only after that page is committed. */ fetchNextPage: () => Promise @@ -651,11 +655,22 @@ class LiveQueryWindowControllerImpl< // only coherent observer contract and guarantees non-reentrant subscribe. this.observer = createLiveQueryObserver(collection, { mode: `wholesale`, + client: options.client, + queryHash: options.queryHash, }) } getSnapshot(): LiveQueryWindowSnapshot { - const observerSnapshot = this.observer.getSnapshot() + return this.snapshotFrom(this.observer.getSnapshot()) + } + + getServerSnapshot(): LiveQueryWindowSnapshot { + return this.snapshotFrom(this.observer.getServerSnapshot()) + } + + private snapshotFrom( + observerSnapshot: LiveQuerySnapshot, + ): LiveQueryWindowSnapshot { if (this.hasPaginationError && observerSnapshot.status === `ready`) { this.failedHasNextPage = this.getComputedHasNextPage(observerSnapshot) } diff --git a/packages/db/tests/fixtures/error-site-messages.json b/packages/db/tests/fixtures/error-site-messages.json index 4dd1df77c5..d91e741194 100644 --- a/packages/db/tests/fixtures/error-site-messages.json +++ b/packages/db/tests/fixtures/error-site-messages.json @@ -928,5 +928,12 @@ "file": "query/compiler/index.ts", "template": "A windowed or findOne result row has no include route", "literals": ["A windowed or findOne result row has no include route"] + }, + "245": { + "file": "live-query-observer.ts", + "template": "SSR cannot render server-only live-query rows or readiness. Await DbClient.preloadLiveQuery() for this query, or explicitly preload its source Collections for hydration.", + "literals": [ + "SSR cannot render server-only live-query rows or readiness. Await DbClient.preloadLiveQuery() for this query, or explicitly preload its source Collections for hydration." + ] } } diff --git a/packages/db/tests/live-query-observer-snapshot-oracle.property.test.ts b/packages/db/tests/live-query-observer-snapshot-oracle.property.test.ts index 9e52a2051d..e6c475766b 100644 --- a/packages/db/tests/live-query-observer-snapshot-oracle.property.test.ts +++ b/packages/db/tests/live-query-observer-snapshot-oracle.property.test.ts @@ -1,9 +1,12 @@ import { fc } from '@fast-check/vitest' import { describe, expect, it } from 'vitest' import { createCollection } from '../src/collection/index.js' +import { DbClient } from '../src/client.js' +import { getLiveQueryHash } from '../src/live-query-options.js' import { localOnlyCollectionOptions } from '../src/local-only.js' import { createLiveQueryObserver } from '../src/live-query-observer.js' -import { createLiveQueryCollection } from '../src/query/index.js' +import { Query, createLiveQueryCollection } from '../src/query/index.js' +import { createOracleReach } from './oracle-reach.js' import { oraclePropertyOptions, oracleRuns } from './oracle-config.js' /** @@ -19,6 +22,12 @@ import { oraclePropertyOptions, oracleRuns } from './oracle-config.js' * read returned. A single-result query keeps only the first such row: `state` * holds that row, and `data` is that row or `undefined`. * + * Without a DbClient, server and ordinary getters expose the same revision. + * Reading either getter must retain that revision's cached object identity. + * A completed client query also shares readiness on both views. Reading its + * server view must not invalidate the ordinary getter's cached object. The + * SSR-specific transport policy has its own descriptor receiving owner. + * * The model is a sorted map from key to version. Each history step changes the * model and the source Collection the same way, and a `capture` step records * the model's rows next to the unread production snapshot. At the end, the @@ -36,7 +45,8 @@ import { oraclePropertyOptions, oracleRuns } from './oracle-config.js' | attached and detached reads | a subscriber is present or absent | publication cache vs refreshDetachedState | the same observations | this generated oracle; listener delivery belongs to `live-query-observer-history-oracle.property.test.ts` | * The source is a local-only Collection behind an `orderBy` live query, so - * visible order is key order. Hydration seeds, persisted status, and framework + * visible order is key order. Prepared client seeds are checked only for getter + * identity. Hydration replacement, persisted status, and framework * wiring have other owners. The calibration test feeds the checker a snapshot * whose `data` and `state` read the live Collection on first access, the design * #1542 proposed, and requires the checker to reject it. @@ -86,15 +96,11 @@ function setup(shape: Shape) { getKey: (row) => row.id, }), ) - const query = createLiveQueryCollection({ - startSync: true, - query: (q) => { - const ordered = q.from({ row: source }).orderBy(({ row }) => row.id) - return shape === `single` ? ordered.findOne() : ordered - }, - }) + const ordered = new Query().from({ row: source }).orderBy(({ row }) => row.id) + const builder = shape === `single` ? ordered.findOne() : ordered + const query = createLiveQueryCollection({ startSync: true, query: builder }) const observer = createLiveQueryObserver(query) - return { source, query, observer } + return { source, query, observer, builder } } /** Apply a history, retaining every captured snapshot unread. */ @@ -104,10 +110,13 @@ function run( commands: ReadonlyArray, ): { captured: Array + observer: ReturnType[`observer`] + query: ReturnType[`query`] + builder: ReturnType[`builder`] cleanup: () => Promise source: ReturnType[`source`] } { - const { source, query, observer } = setup(shape) + const { source, query, observer, builder } = setup(shape) const unsubscribe = attached ? observer.subscribe(() => {}) : undefined const model = new Map() const captured: Array = [ @@ -136,6 +145,9 @@ function run( captured.push({ snapshot: observer.getSnapshot(), rows: modelRows(model) }) return { captured, + observer, + query, + builder, source, cleanup: async () => { unsubscribe?.() @@ -192,9 +204,45 @@ async function checkHistory( attached: boolean, commands: ReadonlyArray, reads: ReadonlyArray, + reach?: ReturnType< + typeof createOracleReach<`server-getter` | `client-getter`> + >, ): Promise { - const { captured, cleanup } = run(shape, attached, commands) + const { captured, observer, query, builder, cleanup } = run( + shape, + attached, + commands, + ) + const client = new DbClient() + let clientObserver: ReturnType | undefined try { + // No write occurs between the final capture and either getter. The model's + // revision is unchanged, so both public getter paths retain its identity. + const current = captured.at(-1)!.snapshot + reach?.reach(`server-getter`) + expect( + observer.getServerSnapshot(), + `server getter preserves an unchanged revision`, + ).toBe(current) + expect( + observer.getSnapshot(), + `ordinary getter preserves the same revision`, + ).toBe(current) + await client.preloadLiveQuery({ query: builder }) + clientObserver = createLiveQueryObserver(query, { + client, + queryHash: getLiveQueryHash({ query: builder }), + }) + const clientSnapshot = clientObserver.getSnapshot() + reach?.reach(`client-getter`) + expect( + clientObserver.getServerSnapshot(), + `a prepared client server read keeps the shared revision`, + ).toBe(clientSnapshot) + expect( + clientObserver.getSnapshot(), + `a server read cannot invalidate the ordinary snapshot cache`, + ).toBe(clientSnapshot) const order = reads.length > 0 ? reads @@ -233,6 +281,8 @@ async function checkHistory( } } } finally { + clientObserver?.dispose() + await client.cleanup() await cleanup() } } @@ -244,21 +294,41 @@ const reads = fc.array( ) describe(`retained live-query snapshots are point-in-time`, () => { - it.each([ - [`ordered`, true], - [`ordered`, false], - [`single`, true], - [`single`, false], - ] as const)( - `first reads match the capture-time rows (%s, attached=%s)`, - async (shape, attached) => { - const property = fc.asyncProperty(history, reads, (commands, order) => - checkHistory(shape, attached, commands, order), + it.each( + ( + [ + [`ordered`, true], + [`ordered`, false], + [`single`, true], + [`single`, false], + ] as const + ).flatMap(([shape, attached]) => + ([`fixed`, `random`] as const).map( + (campaign) => [shape, attached, campaign] as const, + ), + ), + )( + `first reads match the capture-time rows (%s, attached=%s, %s)`, + async (shape, attached, campaign) => { + const reach = createOracleReach( + `snapshot-${shape}-${attached}-${campaign}`, + [`server-getter`, `client-getter`], + ) + const property = fc.asyncProperty(history, reads, (commands, order) => { + reach.beginHistory() + return checkHistory(shape, attached, commands, order, reach) + }) + await fc.assert( + property, + campaign === `fixed` + ? { numRuns: oracleRuns(40), seed: 2043 } + : oraclePropertyOptions(40, PROPERTY), + ) + reach.assertReached() + console.info( + `snapshot-getter ${shape} ${attached} ${campaign}`, + Object.fromEntries(reach.counts()), ) - // Fixed campaign: a repeatable baseline. Seed 2043 is arbitrary. - await fc.assert(property, { numRuns: oracleRuns(40), seed: 2043 }) - // Random campaign, or a replay of a reported seed and path. - await fc.assert(property, oraclePropertyOptions(40, PROPERTY)) }, ) diff --git a/packages/db/tests/oracle-config.ts b/packages/db/tests/oracle-config.ts index c88c8c7da1..ec8480037f 100644 --- a/packages/db/tests/oracle-config.ts +++ b/packages/db/tests/oracle-config.ts @@ -3,6 +3,9 @@ import { oracleReplayReporter } from './oracle-replay-witness.js' type OracleEnvironment = Record const staticOracleProperties = [ + `react.ssr-ordinary`, + `react.ssr-infinite`, + `svelte.ssr-snapshot`, `pooled-live-query.late-cleanup`, `live-query-observer.partial-attach`, `subscription-lifecycle.notification-error`, diff --git a/packages/db/tests/oracle-replay-manifest.ts b/packages/db/tests/oracle-replay-manifest.ts index fab922922a..cda334ba01 100644 --- a/packages/db/tests/oracle-replay-manifest.ts +++ b/packages/db/tests/oracle-replay-manifest.ts @@ -21,7 +21,17 @@ const ownerGroups: ReadonlyArray = [ [ `react-db/tests/descriptor-query-binding-oracle.test.tsx`, `react`, - `descriptor-error-surface descriptor-ownership`, + `descriptor-error-surface descriptor-ownership ssr-ordinary ssr-infinite`, + ], + [ + `svelte-db/tests/ssr-snapshot-admission-oracle.svelte.test.ts`, + `svelte`, + `ssr-snapshot`, + ], + [ + `db/tests/live-query-observer-snapshot-oracle.property.test.ts`, + `live-query-observer`, + `retained-snapshot`, ], [ `db/tests/cleanup-queue-oracle.property.test.ts`, @@ -282,6 +292,10 @@ export type OracleReplayManifestEntry = { // Pagination cells carry their property name in the test title so the guard // can select one cell without running the rest of the matrix. const replayTestNames = new Map([ + [ + `live-query-observer.retained-snapshot`, + `first reads match the capture-time rows.*random`, + ], [`live-query.start-handoff`, `reports only its own thrown starts.*random`], [ `pooled-live-query.late-cleanup`, @@ -300,6 +314,9 @@ const replayTestNames = new Map([ `error-surface after descriptor failure.*random`, ], [`react.descriptor-ownership`, `ownership after descriptor failure.*random`], + [`react.ssr-ordinary`, `ordinary admits reproducible SSR snapshots.*random`], + [`react.ssr-infinite`, `infinite admits reproducible SSR snapshots.*random`], + [`svelte.ssr-snapshot`, `admits reproducible SSR snapshots.*random`], [ `structural-identity.equal-representations`, `agrees with D2 value identity for equal representations`, diff --git a/packages/db/tests/ssr-snapshot-admission-oracle.ts b/packages/db/tests/ssr-snapshot-admission-oracle.ts new file mode 100644 index 0000000000..e940cd9fc3 --- /dev/null +++ b/packages/db/tests/ssr-snapshot-admission-oracle.ts @@ -0,0 +1,97 @@ +import { fc } from '@fast-check/vitest' + +/** + * SSR may expose only a snapshot the first browser render can reproduce. + * The maintainer chose a programming-error rejection for incidental server-only + * rows or readiness. An ordinary cold/loading render remains legal. The SSR + * guide's normalized rows are provisional; they do not transfer query readiness. + * A completed query preload separately transfers a ready result. + * + * This judgment uses declared preparation and authored rows, never production + * registration or readiness classifiers. `sourceState` combines adapter startup + * and readiness only for this eager synchronous grammar. `preparation` describes + * caller actions, not inferred implementation state. The host drivers below + * supply the real hook and compare return/throw, rendered rows and status before + * browser commit. Runtime failures, persistence, source identity metadata, custom + * filters discarding required transport, and asynchronous providers are outside + * this grammar. Source loading and an unawaited query preload are valid histories. + */ +export type SsrSnapshotHistory = { + preparation: `none` | `source` | `query` | `pending-query` + sourceState: `idle` | `loading` | `ready` + rows: Array<{ id: string; name: string }> +} + +export const ssrSnapshotHistoryArbitrary = fc + .record({ + preparation: fc.constantFrom( + `none`, + `source`, + `query`, + `pending-query`, + ), + sourceState: fc.constantFrom( + `idle`, + `loading`, + `ready`, + ), + count: fc.integer({ min: 0, max: 3 }), + }) + .filter(({ preparation, sourceState, count }) => + sourceState === `idle` + ? count === 0 && (preparation === `none` || preparation === `source`) + : preparation === `query` + ? sourceState === `ready` + : preparation !== `pending-query` || sourceState === `loading`, + ) + .map(({ preparation, sourceState, count }): SsrSnapshotHistory => ({ + preparation, + sourceState, + rows: Array.from({ length: count }, (_, index) => ({ + id: String(index + 1), + name: `server ${index + 1}`, + })), + })) + +export function expectedSsrSnapshot(history: SsrSnapshotHistory) { + const prepared = + history.preparation === `query` || history.preparation === `source` + return { + rejects: + !prepared && (history.sourceState === `ready` || history.rows.length > 0), + rows: history.rows, + status: history.preparation === `query` ? `ready` : `loading`, + } as const +} + +// Named replays reconstruct the admission boundary before random shrinking can +// stop a campaign at its first mismatch. They use the same driver and judgment. +export const ssrSnapshotExamples: Array = [ + { preparation: `none`, sourceState: `idle`, rows: [] }, + { preparation: `none`, sourceState: `ready`, rows: [] }, + { + preparation: `none`, + sourceState: `ready`, + rows: [{ id: `1`, name: `server 1` }], + }, + { + preparation: `query`, + sourceState: `ready`, + rows: [{ id: `1`, name: `server 1` }], + }, + { + preparation: `source`, + sourceState: `ready`, + rows: [{ id: `1`, name: `server 1` }], + }, + { + preparation: `source`, + sourceState: `loading`, + rows: [{ id: `1`, name: `server 1` }], + }, + { + preparation: `pending-query`, + sourceState: `loading`, + rows: [{ id: `1`, name: `server 1` }], + }, +] diff --git a/packages/react-db/src/useLiveInfiniteQuery.ts b/packages/react-db/src/useLiveInfiniteQuery.ts index ca4e0b8da5..d51ba04eec 100644 --- a/packages/react-db/src/useLiveInfiniteQuery.ts +++ b/packages/react-db/src/useLiveInfiniteQuery.ts @@ -2,11 +2,13 @@ import { useCallback, useRef, useSyncExternalStore } from 'react' import { + UnhashableQueryIRError, assertLiveQueryWindowManyResult, compareLiveQueryWindowDependencies, createLiveQueryCollection, createLiveQueryWindowController, fetchNextLiveQueryWindowPage, + getLiveQueryHash, getLiveQueryWindowCollectionWarning, getLiveQueryWindowInputKind, liveQueryWindowMatches, @@ -113,6 +115,7 @@ type InfiniteQueryRenderState = { inputCollection: Collection | null inputQuery: unknown client: DbClient | undefined + queryHash: string | undefined identityMode: `collection` | `queryKey` | `legacyDeps` | `derived` dependencies: Array | null pageSize: number @@ -128,6 +131,7 @@ type InfiniteQueryRenderState = { type RenderedWindowCollection = { client: DbClient | undefined + queryHash: string | undefined dependencies: Array collection: Collection deferredCollections: Set< @@ -293,6 +297,7 @@ export function useLiveInfiniteQuery( let renderState = committed if (needsNewController) { let collection = committed?.collection + let queryHash = committed?.queryHash let warning: string | null = null let startInRender = false let suppliedCollection = false @@ -349,6 +354,7 @@ export function useLiveInfiniteQuery( // collection in terminal error, such as after its source restarted, // cannot serve the query again, so a new one replaces it. collection = rendered.collection + queryHash = rendered.queryHash for (const deferred of rendered.deferredCollections) { deferredCollections.add(deferred) } @@ -364,8 +370,24 @@ export function useLiveInfiniteQuery( // so a collection that starts syncing now never publishes fewer rows // than the controller's pages. The controller grows the limit via // setWindow. + const windowQuery = input.query.limit(requiredLimit).offset(0) + queryHash = undefined + if (dbClient) { + try { + queryHash = getLiveQueryHash( + { query: windowQuery }, + config.queryKey, + ) + } catch (error) { + if ( + !(error instanceof UnhashableQueryIRError) || + config.queryKey !== undefined + ) + throw error + } + } collection = createLiveQueryCollection({ - query: input.query.limit(requiredLimit).offset(0), + query: windowQuery, gcTime: DEFAULT_GC_TIME_MS, }) startInRender = true @@ -379,6 +401,9 @@ export function useLiveInfiniteQuery( } if (inputIsCollection) { + queryHash = dbClient + ? getLiveQueryHash(collection, config.queryKey) + : undefined warning = getLiveQueryWindowCollectionWarning(collection, pageSize + 1) ?? null } else { @@ -398,6 +423,7 @@ export function useLiveInfiniteQuery( if (createdCollection) { renderedCollectionRef.current = { client: dbClient, + queryHash, dependencies: [...identityDeps], collection, deferredCollections, @@ -409,6 +435,7 @@ export function useLiveInfiniteQuery( inputCollection: inputIsCollection ? collection : null, inputQuery: inputIsCollection ? null : queryFnOrCollection, client: dbClient, + queryHash, identityMode, dependencies: inputIsCollection ? null : [...identityDeps], pageSize, @@ -418,6 +445,8 @@ export function useLiveInfiniteQuery( pageSize, initialPageParam, initialPageCount, + client: dbClient, + queryHash, }), warning, warned: false, @@ -441,7 +470,9 @@ export function useLiveInfiniteQuery( [controller, currentRenderState], ) const getSnapshot = useCallback(() => controller.getSnapshot(), [controller]) - const snapshot = useSyncExternalStore(subscribe, getSnapshot, getSnapshot) + const snapshot = useSyncExternalStore(subscribe, getSnapshot, () => + controller.getServerSnapshot(), + ) const fetchNextPage = useCallback( () => fetchNextLiveQueryWindowPage(controller), diff --git a/packages/react-db/src/useLiveQuery.ts b/packages/react-db/src/useLiveQuery.ts index 853d4ea68b..e391d9bba6 100644 --- a/packages/react-db/src/useLiveQuery.ts +++ b/packages/react-db/src/useLiveQuery.ts @@ -1049,7 +1049,14 @@ function useLiveQueryImpl( const returned = useSyncExternalStore( instance.subscribe, () => observer.getSnapshot(), - () => observer.getServerSnapshot(), + () => { + // Streaming Suspense discovers a query even when a peer warmed its source. + if (forSuspense && dbClient?._isSsrStreamingEnabled()) { + observer.preloadForInitialRender().catch(() => {}) + return observer.getSnapshot() + } + return observer.getServerSnapshot() + }, ) // Only useLiveSuspenseQuery reads this, and it costs a define per render. if (forSuspense) { diff --git a/packages/react-db/tests/descriptor-query-binding-oracle.test.tsx b/packages/react-db/tests/descriptor-query-binding-oracle.test.tsx index 2a44ee0875..629ecf8525 100644 --- a/packages/react-db/tests/descriptor-query-binding-oracle.test.tsx +++ b/packages/react-db/tests/descriptor-query-binding-oracle.test.tsx @@ -90,6 +90,7 @@ * not request rows. These histories do not cover arbitrary concurrent render * schedules or Electric transaction evidence. */ +import { Writable } from 'node:stream' import { act, render, renderHook, waitFor } from '@testing-library/react' import { BTreeIndex, @@ -98,6 +99,7 @@ import { collectionOptions, createCollection, eq, + getLiveQueryHash, getStableQueryBuilderHash, isUndefined, prepareLiveQueryValue, @@ -106,6 +108,13 @@ import { import { describe, expect, it, vi } from 'vitest' import { fc } from '@fast-check/vitest' import { Component, Suspense } from 'react' +import { renderToPipeableStream, renderToString } from 'react-dom/server' +import { hydrateRoot } from 'react-dom/client' +import { + expectedSsrSnapshot, + ssrSnapshotExamples, + ssrSnapshotHistoryArbitrary, +} from '../../db/tests/ssr-snapshot-admission-oracle' import { oraclePropertyOptions, oracleRuns } from '../../db/tests/oracle-config' import { createOracleReach } from '../../db/tests/oracle-reach' import { useLiveInfiniteQuery } from '../src/useLiveInfiniteQuery' @@ -120,12 +129,14 @@ import { modelSubscriberStartCleanup, subscriberStartCleanupHistories, } from '../../db/tests/collection-subscription-lifecycle-grammar-oracle' +import type { SsrSnapshotHistory } from '../../db/tests/ssr-snapshot-admission-oracle' import type { Context, DeferredLiveQueryCollections, QueryBuilder, } from '@tanstack/db' import type { ReactNode } from 'react' +import type { Root } from 'react-dom/client' type Row = { id: string; value: string } @@ -1863,3 +1874,330 @@ describe(`descriptor error handoff at React commit`, () => { } } }) + +/** + * The shared SSR admission judgment rejects server-only rows/readiness, while + * cold loading, completed query results and normalized sources remain legal. + * Each hook independently generates the same preparation histories. Both real + * React server renders compare before browser commit; hydrateRoot must then + * report no recovery, and committed sync must replace the seed normally. + * Query-only infinite preparation includes its first-page peek-ahead window. + * This driver supplies host scheduling, not another expected-result model. + */ +describe(`SSR snapshot admission`, () => { + const properties = { + ordinary: `react.ssr-ordinary`, + infinite: `react.ssr-infinite`, + } as const + for (const hook of [`ordinary`, `infinite`] as const) { + const makeReach = () => + createOracleReach(`ssr-${hook}`, [ + `cold-loading`, + `unbacked-ready-empty`, + `unbacked-rows`, + `query-result`, + `normalized-ready`, + `normalized-partial`, + `pending-query`, + ]) + async function checkHistory( + history: SsrSnapshotHistory, + reach: ReturnType, + ) { + reach.beginHistory() + const expected = expectedSsrSnapshot(history) + const starts = { server: 0, browser: 0 } + const descriptor = collectionOptions(`ssr-admission`, (client) => ({ + id: `ssr-admission`, + getKey: (row: { id: string; name: string }) => row.id, + autoIndex: `eager`, + defaultIndexType: BTreeIndex, + sync: { + sync: ({ begin, write, commit, markReady }) => { + const runtime = client.requireDependency<`server` | `browser`>( + `runtime`, + ) + starts[runtime]++ + begin() + for (const row of history.rows) + write({ + type: `insert`, + value: { + ...row, + name: runtime === `server` ? row.name : `browser ${row.id}`, + }, + }) + commit() + if (runtime === `browser` || history.sourceState === `ready`) + markReady() + }, + }, + })) + const query = new Query() + .from({ person: descriptor }) + .orderBy(({ person }) => person.id) + .select(({ person }) => ({ id: person.id, name: person.name })) + const server = new DbClient({ runtime: `server` }) + const browser = new DbClient({ runtime: `browser` }) + server._setSsrServerCleanupEnabled(true) + browser._setSsrServerCleanupEnabled(true) + let root: Root | undefined + const host = document.createElement(`div`) + const recoveries: Array = [] + let unmount: (() => void) | undefined + const useResult = () => + hook === `ordinary` + ? useLiveQuery({ query }) + : useLiveInfiniteQuery(() => query, { pageSize: 1 }) + function Consumer() { + const result = useResult() + return ( + + {result.data.map((row) => ( + {row.name} + ))} + + ) + } + const app = (client: DbClient) => ( + + + + ) + try { + if (history.sourceState !== `idle`) { + const warming = server.preloadLiveQuery({ + query: new Query() + .from({ person: descriptor }) + .select(({ person }) => ({ id: person.id })), + }) + if (history.sourceState === `ready`) await warming + else void warming.catch(() => {}) + } + if (history.preparation === `source`) server.collection(descriptor) + if ( + history.preparation === `query` || + history.preparation === `pending-query` + ) { + const preload = server.preloadLiveQuery({ + query: hook === `ordinary` ? query : query.limit(2).offset(0), + }) + if (history.preparation === `query`) await preload + else void preload.catch(() => {}) + } + let html: string | undefined + let failure: unknown + try { + html = renderToString(app(server)) + } catch (error) { + failure = error + } + if (expected.rejects) { + expect( + failure, + `SSR must reject untransported rows or readiness`, + ).toBeInstanceOf(Error) + expect(String(failure)).toMatch(/SSR.*(?:preload|hydrat)/i) + // The same warm source is ordinary valid browser usage. + const mounted = renderHook(useResult, { + wrapper: ({ children }: { children: ReactNode }) => ( + {children} + ), + }) + unmount = mounted.unmount + expect( + mounted.result.current.data.map((row) => ({ + id: row.id, + name: row.name, + })), + ).toEqual( + hook === `ordinary` ? history.rows : history.rows.slice(0, 1), + ) + reach.reach( + history.rows.length ? `unbacked-rows` : `unbacked-ready-empty`, + ) + } else { + expect(failure, `prepared and cold SSR remain legal`).toBeUndefined() + browser.hydrate(server.dehydrate()) + const initialBrowserHtml = renderToString(app(browser)) + expect( + initialBrowserHtml, + `initial rows and every rendered status flag match`, + ).toBe(html) + host.innerHTML = html! + expect( + host.querySelector(`output`)?.getAttribute(`data-status`), + ).toBe(expected.status) + expect( + [...host.querySelectorAll(`span`)].map((span) => span.textContent), + ).toEqual( + (hook === `ordinary` + ? expected.rows + : expected.rows.slice(0, 1) + ).map((row) => row.name), + ) + expect(starts.browser, `hydration read starts no source`).toBe(0) + document.body.append(host) + await act(async () => { + root = hydrateRoot(host, app(browser), { + onRecoverableError: (error) => recoveries.push(error), + }) + await Promise.resolve() + }) + expect( + recoveries, + `React receives the same snapshot without recovery`, + ).toEqual([]) + expect( + starts.browser, + `committed browser demand starts the source`, + ).toBe(1) + expect( + host.querySelector(`output`)?.getAttribute(`data-status`), + ).toBe(`ready`) + expect( + [...host.querySelectorAll(`span`)].map((span) => span.textContent), + ).toEqual( + (hook === `ordinary` ? history.rows : history.rows.slice(0, 1)).map( + (row) => `browser ${row.id}`, + ), + ) + if (history.preparation === `query`) reach.reach(`query-result`) + else if ( + history.preparation === `source` && + history.sourceState !== `idle` + ) + reach.reach( + history.sourceState === `loading` + ? `normalized-partial` + : `normalized-ready`, + ) + else reach.reach(`cold-loading`) + } + if (history.preparation === `pending-query`) + reach.reach(`pending-query`) + } finally { + unmount?.() + act(() => root?.unmount()) + host.remove() + await browser.cleanup() + await server.cleanup() + } + } + for (const history of ssrSnapshotExamples) { + it(`replays ${hook} SSR ${history.preparation}/${history.sourceState}/${history.rows.length}`, async () => { + await checkHistory(history, makeReach()) + }) + } + for (const campaign of [`fixed`, `random`] as const) { + it(`${hook} admits reproducible SSR snapshots (${campaign})`, async () => { + const reach = makeReach() + await fc.assert( + fc.asyncProperty(ssrSnapshotHistoryArbitrary, (history) => + checkHistory(history, reach), + ), + { + ...(campaign === `fixed` + ? { numRuns: oracleRuns(120), seed: 2229 } + : oraclePropertyOptions(120, properties[hook])), + examples: ssrSnapshotExamples.map((history) => [history]), + }, + ) + reach.assertReached() + console.info( + `ssr-admission ${hook} ${campaign}`, + Object.fromEntries(reach.counts()), + ) + }) + } + } +}) + +// Suspense discovery is explicit data demand in streaming mode. A warm source +// must still register this query, rather than bypassing transport because its +// live-query Collection happens to be ready before the wrapper reads it. +describe(`SSR Suspense discovery`, () => { + for (const warm of [false, true]) { + it(`streams a render-discovered result with warm=${warm}`, async () => { + const descriptor = collectionOptions(`ssr-suspense-discovery`, () => ({ + id: `ssr-suspense-discovery`, + getKey: (row: { id: string; name: string }) => row.id, + sync: { + sync: ({ begin, write, commit, markReady }) => { + begin() + write({ type: `insert`, value: { id: `1`, name: `server 1` } }) + commit() + markReady() + }, + }, + })) + const client = new DbClient() + client._setSsrStreamingEnabled(true) + const query = new Query() + .from({ person: descriptor }) + .select(({ person }) => ({ name: person.name })) + function Consumer() { + const result = useLiveSuspenseQuery({ query }) + return {result.data[0]?.name} + } + try { + if (warm) + await client.preloadLiveQuery({ + query: new Query() + .from({ person: descriptor }) + .select(({ person }) => ({ id: person.id })), + }) + const errors: Array = [] + const html = await new Promise((resolve, reject) => { + let text = `` + const sink = new Writable({ + write(chunk, _encoding, done) { + text += chunk.toString() + done() + }, + }) + sink.on(`finish`, () => resolve(text)) + sink.on(`error`, reject) + const stream = renderToPipeableStream( + + loading}> + + + , + { + onAllReady() { + stream.pipe(sink) + }, + onError(error) { + errors.push(error) + }, + }, + ) + }) + expect(errors).toEqual([]) + expect(html).toContain(`server 1`) + const hash = getLiveQueryHash({ query }) + const result = client + .dehydrate({ shouldDehydrateLiveQuery: () => true }) + .liveQueries?.find((entry) => entry.queryHash === hash) + expect( + result, + `render-discovered warm and cold results enter transport`, + ).toBeDefined() + const snapshot = result?.snapshot ?? (await result?.promise) + expect(snapshot?.rows.map((row) => row.value)).toEqual([ + expect.objectContaining({ name: `server 1` }), + ]) + } finally { + await client.cleanup() + } + }) + } +}) diff --git a/packages/svelte-db/src/useLiveQuery.svelte.ts b/packages/svelte-db/src/useLiveQuery.svelte.ts index 10c8967112..227d25b149 100644 --- a/packages/svelte-db/src/useLiveQuery.svelte.ts +++ b/packages/svelte-db/src/useLiveQuery.svelte.ts @@ -452,7 +452,10 @@ export function useLiveQuery( queryHash: currentResolved.queryHash, onPreload: currentResolved.resumeDeferredCollections, }) - const initialSnapshot = currentObserver.getServerSnapshot() + const initialSnapshot = + typeof window === `undefined` + ? currentObserver.getServerSnapshot() + : currentObserver.getSnapshot() // Reactive state that gets updated granularly through change events const state = new SvelteMap(initialSnapshot.state ?? []) diff --git a/packages/svelte-db/tests/hydration.svelte.test.ts b/packages/svelte-db/tests/hydration.svelte.test.ts index 37c37a7534..11174050c5 100644 --- a/packages/svelte-db/tests/hydration.svelte.test.ts +++ b/packages/svelte-db/tests/hydration.svelte.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it, vi } from 'vitest' import { flushSync } from 'svelte' -import { DbClient } from '@tanstack/db' +import { DbClient, Query, collectionOptions } from '@tanstack/db' import { useLiveQuery } from '../src/useLiveQuery.svelte.js' +import { expectedSsrSnapshot } from '../../db/tests/ssr-snapshot-admission-oracle' import { createPeopleDescriptor, peopleQuery } from './ssr-test-utils.js' describe(`Svelte hydration`, () => { @@ -41,3 +42,91 @@ describe(`Svelte hydration`, () => { dispose() }) }) + +// Normal browser initialization must not use the stricter SSR admission rule. +// Source-only hydration is provisional until the browser's own sync is ready. +describe(`Svelte SSR receiving controls`, () => { + for (const preparation of [`warm-browser`, `normalized`] as const) { + for (const count of [0, 1]) { + it(`${preparation} with ${count} rows remains legal`, async () => { + const rows = Array.from({ length: count }, (_, index) => ({ + id: String(index), + name: `seed ${index}`, + })) + const expected = expectedSsrSnapshot({ + preparation: `source`, + sourceState: `ready`, + rows, + }) + const client = new DbClient() + const descriptor = collectionOptions(`svelte-ssr-receiving`, () => ({ + id: `svelte-ssr-receiving`, + getKey: (row: { id: string; name: string }) => row.id, + sync: { + sync: ({ begin, write, commit, markReady }) => { + if (preparation === `warm-browser`) { + begin() + for (const row of rows) write({ type: `insert`, value: row }) + commit() + } + markReady() + }, + }, + })) + if (preparation === `warm-browser`) + await client.preloadLiveQuery({ + query: new Query() + .from({ row: descriptor }) + .select(({ row }) => ({ id: row.id })), + }) + else + client.hydrate({ + collections: [ + { + collectionId: descriptor.id, + rows: rows.map((value) => ({ key: value.id, value })), + }, + ], + }) + const createQuery = () => + useLiveQuery({ + query: (q) => + q + .from({ row: descriptor }) + .select(({ row }) => ({ id: row.id, name: row.name })), + client, + }) + let query!: ReturnType + let dispose: (() => void) | undefined + let initializationError: unknown + try { + try { + dispose = $effect.root(() => { + query = createQuery() + }) + } catch (error) { + initializationError = error + } + expect( + initializationError, + `valid browser initialization does not throw`, + ).toBeUndefined() + expect(query.status).toBe( + preparation === `warm-browser` ? `ready` : expected.status, + ) + expect(query.data).toEqual( + expected.rows.map((row) => expect.objectContaining(row)), + ) + flushSync() + await vi.waitFor(() => { + flushSync() + expect(query.status).toBe(`ready`) + }) + } finally { + dispose?.() + await client.cleanup() + } + }) + } + } +}) diff --git a/packages/svelte-db/tests/ssr-snapshot-admission-oracle.svelte.test.ts b/packages/svelte-db/tests/ssr-snapshot-admission-oracle.svelte.test.ts new file mode 100644 index 0000000000..f6def7c24b --- /dev/null +++ b/packages/svelte-db/tests/ssr-snapshot-admission-oracle.svelte.test.ts @@ -0,0 +1,157 @@ +// @vitest-environment node +import { describe, expect, it } from 'vitest' +import { fc } from '@fast-check/vitest' +import { render } from 'svelte/server' +import { BTreeIndex, DbClient, Query, collectionOptions } from '@tanstack/db' +import { oraclePropertyOptions, oracleRuns } from '../../db/tests/oracle-config' +import { createOracleReach } from '../../db/tests/oracle-reach' +import { + expectedSsrSnapshot, + ssrSnapshotExamples, + ssrSnapshotHistoryArbitrary, +} from '../../db/tests/ssr-snapshot-admission-oracle' +import SsrDbApp from './SsrDbApp.svelte' +import { peopleQuery } from './ssr-test-utils' +import type { SsrSnapshotHistory } from '../../db/tests/ssr-snapshot-admission-oracle' + +/** + * Svelte supplies its own server host for the shared SSR admission judgment. + * Source actions and legal histories are the same as React's receiving driver; + * expected rejection, rows and status come only from the shared model. Compare + * the two server renders before a browser subscription can hide missing state. + * Actual browser subscription/handoff remains in hydration.svelte.test.ts. + */ +describe(`Svelte SSR snapshot admission`, () => { + const makeReach = () => + createOracleReach(`ssr-svelte`, [ + `cold-loading`, + `unbacked-ready-empty`, + `unbacked-rows`, + `query-result`, + `normalized-ready`, + `normalized-partial`, + `pending-query`, + ]) + async function checkHistory( + history: SsrSnapshotHistory, + reach: ReturnType, + ) { + reach.beginHistory() + const expected = expectedSsrSnapshot(history) + const descriptor = collectionOptions(`svelte-ssr-people`, () => ({ + id: `svelte-ssr-people`, + autoIndex: `eager`, + defaultIndexType: BTreeIndex, + getKey: (row: { id: string; name: string; sourcePayload: string }) => + row.id, + sync: { + sync: ({ begin, write, commit, markReady }) => { + begin() + for (const row of history.rows) + write({ + type: `insert`, + value: { ...row, sourcePayload: `source-only` }, + }) + commit() + if (history.sourceState === `ready`) markReady() + }, + }, + })) + const server = new DbClient(), + browser = new DbClient() + server._setSsrServerCleanupEnabled(true) + browser._setSsrServerCleanupEnabled(true) + try { + if (history.sourceState !== `idle`) { + const warming = server.preloadLiveQuery({ + query: new Query() + .from({ person: descriptor }) + .select(({ person }) => ({ id: person.id })), + }) + if (history.sourceState === `ready`) await warming + else void warming.catch(() => {}) + } + if (history.preparation === `source`) server.collection(descriptor) + if ( + history.preparation === `query` || + history.preparation === `pending-query` + ) { + const preload = server.preloadLiveQuery(peopleQuery(descriptor)) + if (history.preparation === `query`) await preload + else void preload.catch(() => {}) + } + let html: string | undefined, failure: unknown + try { + html = render(SsrDbApp, { + props: { client: server, descriptor }, + }).body + } catch (error) { + failure = error + } + if (expected.rejects) { + expect( + failure, + `SSR must reject untransported rows or readiness`, + ).toBeInstanceOf(Error) + expect(String(failure)).toMatch(/SSR.*(?:preload|hydrat)/i) + reach.reach( + history.rows.length ? `unbacked-rows` : `unbacked-ready-empty`, + ) + } else { + expect(failure, `prepared and cold SSR remain legal`).toBeUndefined() + browser.hydrate(server.dehydrate()) + const initialBrowserHtml = render(SsrDbApp, { + props: { client: browser, descriptor }, + }).body + expect( + initialBrowserHtml, + `initial rows and status match before commit`, + ).toBe(html) + expect(html).toContain(`data-status="${expected.status}"`) + for (const row of expected.rows) + expect(html).toContain(`${row.name}`) + if (history.preparation === `query`) reach.reach(`query-result`) + else if ( + history.preparation === `source` && + history.sourceState !== `idle` + ) + reach.reach( + history.sourceState === `loading` + ? `normalized-partial` + : `normalized-ready`, + ) + else reach.reach(`cold-loading`) + } + if (history.preparation === `pending-query`) reach.reach(`pending-query`) + } finally { + await browser.cleanup() + await server.cleanup() + } + } + for (const history of ssrSnapshotExamples) { + it(`replays SSR ${history.preparation}/${history.sourceState}/${history.rows.length}`, async () => { + await checkHistory(history, makeReach()) + }) + } + for (const campaign of [`fixed`, `random`] as const) { + it(`admits reproducible SSR snapshots (${campaign})`, async () => { + const reach = makeReach() + await fc.assert( + fc.asyncProperty(ssrSnapshotHistoryArbitrary, (history) => + checkHistory(history, reach), + ), + { + ...(campaign === `fixed` + ? { numRuns: oracleRuns(120), seed: 2229 } + : oraclePropertyOptions(120, `svelte.ssr-snapshot`)), + examples: ssrSnapshotExamples.map((history) => [history]), + }, + ) + reach.assertReached() + console.info( + `ssr-admission svelte ${campaign}`, + Object.fromEntries(reach.counts()), + ) + }) + } +}) From 2d1dacbe6754c6dd03b06ecf9af2c084ed426a86 Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 23 Oct 2026 15:48:49 -0600 Subject: [PATCH 15/20] fix(db): capture rendered SSR views through hydration --- .../resume-independent-subscription-demand.md | 2 + docs/contributing/oracle-coverage.md | 35 +- docs/errors.md | 2 +- docs/guides/ssr.md | 20 +- packages/db/mangle-cache.json | 3 +- packages/db/src/client.ts | 68 ++- packages/db/src/live-query-observer.ts | 120 ++-- packages/db/src/live-query-options.ts | 34 +- packages/db/src/query/pooled-live-query.ts | 1 - .../db-client-preload-identity-oracle.test.ts | 45 ++ .../tests/fixtures/error-site-messages.json | 4 +- ...y-observer-history-oracle.property.test.ts | 132 ++++ ...-observer-snapshot-oracle.property.test.ts | 183 +++++- packages/db/tests/oracle-config.ts | 3 + packages/db/tests/oracle-replay-manifest.ts | 15 +- .../query/pooled-live-query-gc-oracle.test.ts | 7 + .../db/tests/ssr-snapshot-admission-oracle.ts | 74 ++- packages/react-db/src/useLiveInfiniteQuery.ts | 2 +- packages/react-db/src/useLiveQuery.ts | 2 +- packages/react-db/src/useLiveSuspenseQuery.ts | 16 +- .../descriptor-query-binding-oracle.test.tsx | 578 +++++++++++++++++- packages/svelte-db/tests/SsrDbApp.svelte | 7 +- packages/svelte-db/tests/SsrDbQuery.svelte | 12 + .../svelte-db/tests/hydration.svelte.test.ts | 2 +- ...r-snapshot-admission-oracle.svelte.test.ts | 5 +- .../svelte-db/tests/ssr-snapshot-fixture.ts | 81 +++ ...ssr-snapshot-handoff-oracle.svelte.test.ts | 213 +++++++ 27 files changed, 1533 insertions(+), 133 deletions(-) create mode 100644 packages/svelte-db/tests/ssr-snapshot-fixture.ts create mode 100644 packages/svelte-db/tests/ssr-snapshot-handoff-oracle.svelte.test.ts diff --git a/.changeset/resume-independent-subscription-demand.md b/.changeset/resume-independent-subscription-demand.md index 92abccb9f8..cbe6144fbe 100644 --- a/.changeset/resume-independent-subscription-demand.md +++ b/.changeset/resume-independent-subscription-demand.md @@ -15,3 +15,5 @@ Subscribers that survive cleanup during admission retain demand for an explicit Keep ordinary hook errors readable when deferred sync startup records an error. Explicit preloads still report source startup failures. Release channels acquired before synchronous cleanup registration or observer attachment fails. Reject SSR reads of incidental server-only rows or readiness with an actionable preparation error. Preserve cold loading and normalized source hydration, consume prepared infinite-query snapshots, and register warm Suspense results for streaming. + +Capture prepared SSR query rows and readiness at render time. Keep loading captures separate from completed preloads, preserve pending streams, and allow browser writes before hydration. Support prepared non-streaming Suspense, require portable opaque query identity, and include limit and offset in explicit-key result identity. diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index f7d3d5e4b1..ab0dea568d 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -622,17 +622,38 @@ ordinary and infinite hooks independently. The Svelte server driver is `packages/svelte-db/tests/ssr-snapshot-admission-oracle.svelte.test.ts`. Each compares rejection, projected rows and status before browser commit across no preparation, completed or pending query preload, and normalized source -transport, including ready-empty and partial-loading sources. Normalized rows -remain provisional. React also runs real `hydrateRoot`, observes recoverable -errors, and checks the committed browser result. Svelte's -`hydration.svelte.test.ts` receives normalized transport and normal warm-browser -initialization through its native effect scheduler. Real React streaming checks -cold and warm Suspense discovery. The audit records RED on unchanged production, -GREEN, narrow mutants, and exact receiving replays. +transport, including ready-empty and partial-loading sources. Render-time query +captures carry their own readiness; normalized source rows alone remain +provisional. React also runs real `hydrateRoot`, observes recoverable errors, and +checks the committed browser result. Svelte's actual server-to-browser driver is +`packages/svelte-db/tests/ssr-snapshot-handoff-oracle.svelte.test.ts`: it loads a +server-compiled fixture and calls browser-compiled `hydrate`, then compares the +first public hook rows/status before effects with the shared judgment. Both +hosts cross writes after server render and before browser hydration, including a +same-query browser preload that completes before hydration. The older +server-only owner proves server reconstruction only; its second render is not +browser evidence. `hydration.svelte.test.ts` separately receives normalized +transport and normal warm-browser initialization through its native scheduler. Real React streaming checks +cold and warm Suspense discovery, opaque keyed/unkeyed logic, and prepared +non-streaming Suspense. Infinite identity checks callback-returned Collections +and explicit keys with matching and unbounded windows across builder, direct +Collection and callback-returned Collection inputs. The latter callback path is +JavaScript runtime coverage; it has no TypeScript overload. A separate React +streaming property crosses zero to three rows and zero to three microtask source +publications before preload settlement. It compares transported rows with the +view actually rendered, including empty and unchanged controls. The +[SSR review audit](oracle-reviews/2026-10-23-ssr-review.md) records RED on unchanged +production, GREEN, narrow mutants, and exact receiving replays. The separate `live-query-observer-snapshot-oracle.property.test.ts` owner checks unchanged getter identity with and without a completed DbClient query. It runs ordered/single-result and attached/detached histories through ordinary and server getters. It does not model provisional hydration replacement or host scheduling. +Focused snapshot checks protect lazy state, one admission walk for an unchanged +revision, explicit preload after a loading capture, JSON-compatible non-streaming +transport, and pending-stream ownership. The observer history +owner generates throwing releases followed by explicit retries, in both modes +and with thrown undefined. This last premise is a controlled synchronous channel +release; first-party event listeners report their failures asynchronously. Limits: the generated sources are synchronous and eager with zero to three rows. Infinite receiving coverage uses a one-row initial page and its peek-ahead row. Arbitrary asynchronous providers, later page-shape changes during a pending diff --git a/docs/errors.md b/docs/errors.md index be3833c30f..0bfbd3897c 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -2477,5 +2477,5 @@ A windowed or findOne result row has no include route Development builds show this message, with `${...}` replaced by values: ```text -SSR cannot render server-only live-query rows or readiness. Await DbClient.preloadLiveQuery() for this query, or explicitly preload its source Collections for hydration. +SSR cannot render server-only live-query rows or readiness. Await DbClient.preloadLiveQuery() for this query, or explicitly preload its source Collections for hydration. Opaque queries require an explicit serializable queryKey. ``` diff --git a/docs/guides/ssr.md b/docs/guides/ssr.md index 36a5d82108..f8269b5d75 100644 --- a/docs/guides/ssr.md +++ b/docs/guides/ssr.md @@ -322,14 +322,18 @@ this query's result for transport. Reading those incidental rows or their readiness during SSR throws an error with the required preparation steps. Normal browser reads and runtime query errors keep their usual behavior. -Normalized source transport carries rows without query readiness. Queries that -use only those rows report `loading` for both server markup and the first -browser snapshot. A matching completed query result reports `ready`. Browser -commit starts acquisition and publishes the live status normally. +Normalized source transport carries rows without query readiness. When a server +hook reads explicitly prepared sources, it captures the query's render-time +rows and status as a query result. Ready prepared queries can therefore finish +non-streaming Suspense; loading queries remain loading. Later server writes and +browser writes before hydration do not change that initial view. Browser commit +starts acquisition and publishes the live status normally. A loading capture +does not complete an explicit data preload. Streaming `useLiveSuspenseQuery` discovers and prepares its result during render, including when another query already warmed the source. Ordinary hooks require -explicit preparation to render that data. +explicit preparation to render that data. Opaque SSR queries require a serializable +`queryKey`, including when the source is already warm. For `useLiveInfiniteQuery`, preload the initial window plus its one-row peek: @@ -346,8 +350,10 @@ await dbClient.preloadLiveQuery({ const result = useLiveInfiniteQuery(() => orderedTodos, { pageSize }) ``` -Custom dehydration filters must retain the Collection rows or query result that -the markup requires. +An explicit `queryKey` also includes the query's limit and offset in its transport +identity. Use the same initial window in the preload and the infinite hook. + +Custom dehydration filters must retain the query result that the markup requires. ### Indexes before query compilation diff --git a/packages/db/mangle-cache.json b/packages/db/mangle-cache.json index ddc6545071..1c5acd85af 100644 --- a/packages/db/mangle-cache.json +++ b/packages/db/mangle-cache.json @@ -417,6 +417,5 @@ "windowRequestGeneration": "av", "latestWindowRequest": "gE", "startSyncForSubscriber": "gT", - "snapshotFrom": "gV", - "serverSnapshots": "gU" + "snapshotFrom": "gV" } diff --git a/packages/db/src/client.ts b/packages/db/src/client.ts index 7769f3828c..cdd5a312db 100644 --- a/packages/db/src/client.ts +++ b/packages/db/src/client.ts @@ -147,6 +147,8 @@ export type DehydratedLiveQueryResult< TKey extends string | number = string | number, > = { rows: Array> + /** An initial SSR view may precede query readiness. Omitted means ready. */ + status?: `loading` } export type DehydratedDbState = { @@ -161,6 +163,8 @@ export type DbClientLiveQuery = { readonly dehydratedAt: number readonly status: DbClientLiveQueryState readonly promise: Promise + /** Initial hydration view retained while a local preload completes. */ + readonly initialSnapshot?: DehydratedLiveQueryResult readonly snapshot?: DehydratedLiveQueryResult readonly error?: unknown } @@ -195,6 +199,7 @@ type LiveQueryRecord = { resultPromise: Promise succeed: (snapshot: DehydratedLiveQueryResult) => void fail: (error: unknown) => void + initialSnapshot?: DehydratedLiveQueryResult snapshot?: DehydratedLiveQueryResult error?: unknown } @@ -438,8 +443,13 @@ export class DbClient { const queryHash = getLiveQueryHash(prepared, options.queryKey) const sources = getPreparedLiveQuerySources(prepared) const existing = this.liveQueries.get(queryHash) - if (existing && existing.status !== `error`) { - this._assertLiveQuerySources(queryHash, sources) + this._assertLiveQuerySources(queryHash, sources) + if ( + existing && + (existing.status === `pending` || + (existing.status === `success` && + existing.snapshot?.status !== `loading`)) + ) { return { result: existing.promise } } @@ -642,9 +652,10 @@ export class DbClient { const liveQueries = Array.from(this.liveQueries.values()).flatMap( (query): Array => { + const decision = options.shouldDehydrateLiveQuery?.(query) const shouldDehydrate = - options.shouldDehydrateLiveQuery?.(query) ?? - query.status === `success` + decision ?? + (query.status === `success` || query.snapshot !== undefined) if (!shouldDehydrate || query.status === `error`) { return [] } @@ -653,9 +664,11 @@ export class DbClient { { queryHash: query.queryHash, dehydratedAt: query.dehydratedAt, - ...(query.snapshot - ? { snapshot: query.snapshot } - : { promise: query.resultPromise }), + ...(query.snapshot ? { snapshot: query.snapshot } : {}), + ...(query.status === `pending` && + (!query.snapshot || this.ssrStreamingEnabled || decision === true) + ? { promise: query.resultPromise } + : {}), }, ] }, @@ -764,16 +777,44 @@ export class DbClient { } } + /** @internal Capture the first server view without starting acquisition. */ + _captureServerQuery( + queryHash: string, + snapshot: DehydratedLiveQueryResult, + sources?: ReadonlyArray, + ): void { + const existing = this.liveQueries.get(queryHash) + if (existing) { + if (sources) this._assertLiveQuerySources(queryHash, sources) + existing.snapshot ??= snapshot + return + } + const record = this.createLiveQueryRecord( + queryHash, + this.nextLiveQueryTimestamp(), + ) + record.sources = sources + this.liveQueries.set(queryHash, record) + this.emit({ type: `liveQueryAdded`, query: record }) + record.succeed(snapshot) + } + /** @internal */ _registerLiveQuery( queryHash: string, promise: Promise, sources?: ReadonlyArray, ): Promise { + // Observe even a rejected incoming promise when a source conflict discards it. + void Promise.resolve(promise).catch(() => {}) + if (sources) this._assertLiveQuerySources(queryHash, sources) const existing = this.liveQueries.get(queryHash) - if (existing && existing.status !== `error`) { - void Promise.resolve(promise).catch(() => {}) - if (sources) this._assertLiveQuerySources(queryHash, sources) + if ( + existing && + (existing.status === `pending` || + (existing.status === `success` && + existing.snapshot?.status !== `loading`)) + ) { return existing.promise } @@ -782,6 +823,7 @@ export class DbClient { this.nextLiveQueryTimestamp(), ) record.sources = sources + record.initialSnapshot = existing?.initialSnapshot this.liveQueries.set(queryHash, record) this.emit({ type: `liveQueryAdded`, query: record }) @@ -879,9 +921,13 @@ export class DbClient { if (existing?.status === `pending`) { void record.resultPromise.then(existing.succeed, existing.fail) } + record.initialSnapshot = dehydratedQuery.promise + ? undefined + : dehydratedQuery.snapshot + if (dehydratedQuery.promise) record.snapshot = dehydratedQuery.snapshot this.emit({ type: `liveQueryAdded`, query: record }) - if (dehydratedQuery.snapshot) { + if (dehydratedQuery.snapshot && !dehydratedQuery.promise) { record.succeed(dehydratedQuery.snapshot) } else if (dehydratedQuery.promise) { Promise.resolve(dehydratedQuery.promise).then(record.succeed, record.fail) diff --git a/packages/db/src/live-query-observer.ts b/packages/db/src/live-query-observer.ts index 8fbbc62e0f..27b754762c 100644 --- a/packages/db/src/live-query-observer.ts +++ b/packages/db/src/live-query-observer.ts @@ -129,8 +129,8 @@ export interface LiveQueryObserver< > { /** Stable per-revision snapshot for wholesale materialization. */ getSnapshot: () => LiveQuerySnapshot - /** Stable server snapshot used by useSyncExternalStore-style adapters. */ - getServerSnapshot: () => LiveQuerySnapshot + /** Server snapshot. preloadRequested marks an explicit streaming Suspense load. */ + getServerSnapshot: (preloadRequested?: boolean) => LiveQuerySnapshot /** * Subscribe to changes. The listener receives the change set (or `undefined` * for the synthetic notify a ready collection emits on attach). Granular @@ -204,8 +204,6 @@ class LiveQueryObserverImpl< private cachedEntries: Array<[TKey, T]> | undefined private cachedCollectionRevision: number | undefined private cachedCollectionLayoutRevision: number | undefined - private serverSnapshots: - WeakMap, LiveQuerySnapshot> | undefined private snapshotDirty = true private cachedSnapshot: LiveQuerySnapshot = DISABLED_SNAPSHOT private layoutRevision = 0 @@ -225,6 +223,7 @@ class LiveQueryObserverImpl< | { dehydratedAt: number entries: Array<[TKey, T]> + snapshot: DehydratedLiveQueryResult } | undefined private hydrationError: unknown @@ -287,7 +286,7 @@ class LiveQueryObserverImpl< this.hasHydrationError || liveStatus === `error` ? (`error` as const) : this.hasHydrationSeed() - ? (`ready` as const) + ? (this.hydrationSeed!.snapshot.status ?? `ready`) : liveStatus // Bump the layout revision when the ordered key sequence changes @@ -334,7 +333,7 @@ class LiveQueryObserverImpl< return this.cachedSnapshot } - getServerSnapshot(): LiveQuerySnapshot { + getServerSnapshot(preloadRequested = false): LiveQuerySnapshot { const snapshot = this.getSnapshot() const collection = this.collection if ( @@ -345,39 +344,44 @@ class LiveQueryObserverImpl< ) return snapshot - // Only completed query results transfer readiness. Source rows remain - // provisional, and incidental server state needs explicit preparation. - if (snapshot.state?.size || snapshot.isReady) { - const { sources } = this.getSourceInfo() - if ( + // React also calls this getter in the browser during hydration. Only + // server reads perform admission; transported seeds win over local activity. + if (typeof window !== `undefined`) return snapshot + + // A suspended component has not rendered this loading view. Its settled + // stream will supply the first rendered result. + if (preloadRequested && !snapshot.isReady) return snapshot + + const { sources } = this.getSourceInfo() + if ( + !this.queryHash || + (!preloadRequested && + (this.getVisibleEntries(collection).length > 0 || snapshot.isReady) && !(sources ?? [collection]).every((source) => this.client!._hasSsrCollectionSnapshot(source), - ) - ) { - throw new Error( - devBuild() && process.env.NODE_ENV !== `production` - ? `SSR cannot render server-only live-query rows or readiness. Await DbClient.preloadLiveQuery() for this query, or explicitly preload its source Collections for hydration.` - : codedMessage(245), - ) - } + )) + ) { + throw new Error( + devBuild() && process.env.NODE_ENV !== `production` + ? `SSR cannot render server-only live-query rows or readiness. Await DbClient.preloadLiveQuery() for this query, or explicitly preload its source Collections for hydration. Opaque queries require an explicit serializable queryKey.` + : codedMessage(245), + ) } - // Keep the ordinary cache intact. Weak keys let obsolete revisions retire - // even when a browser observer survives its hydration render. - let serverSnapshot = this.serverSnapshots?.get(snapshot) - if (!serverSnapshot) { - serverSnapshot = { - ...snapshot, - status: `loading`, - ...getLiveQueryStatusFlags(`loading`), - persistedStatus: snapshot.isPersistedReady - ? `loading` - : snapshot.persistedStatus, - isPersistedReady: false, - } - ;(this.serverSnapshots ??= new WeakMap()).set(snapshot, serverSnapshot) - } - return serverSnapshot + // Capture at render, rather than sampling mutable source rows at dehydrate. + // Loading views transfer rows without answering a later data preload. + this.client._captureServerQuery( + this.queryHash, + { + rows: this.getVisibleEntries(collection).map(([key, value]) => ({ + key, + value, + })), + ...(snapshot.isReady ? {} : { status: `loading` as const }), + }, + sources, + ) + return this.getSnapshot() } getError(): unknown { @@ -389,9 +393,11 @@ class LiveQueryObserverImpl< const collection = this.collection if (!collection) return { rows: [] } - const entries = this.hasHydrationSeed() - ? this.hydrationSeed!.entries - : this.readEntries(collection).entries + const entries = + this.hasHydrationSeed() && + this.hydrationSeed!.snapshot.status !== `loading` + ? this.hydrationSeed!.entries + : this.readEntries(collection).entries return { rows: entries.map(([key, value]) => ({ @@ -461,21 +467,18 @@ class LiveQueryObserverImpl< return changed } + const snapshot = query.initialSnapshot ?? query.snapshot if ( - query.status !== `success` || - !query.snapshot || - (this.hydrationSeed && - this.hydrationSeed.dehydratedAt >= query.dehydratedAt) + !snapshot || + (this.hydrationSeed && this.hydrationSeed.snapshot === snapshot) ) { return false } this.hydrationSeed = { dehydratedAt: query.dehydratedAt, - entries: query.snapshot.rows.map((row) => [ - row.key as TKey, - row.value as T, - ]), + snapshot, + entries: snapshot.rows.map((row) => [row.key as TKey, row.value as T]), } this.hydrationError = undefined this.hasHydrationError = false @@ -909,13 +912,14 @@ class LiveQueryObserverImpl< } private detach(): void { - this.collectionUnsub?.() + const release = this.collectionUnsub + const unregister = this.unregisterClientResource this.collectionUnsub = null this.attached = false this.blockDelivery = false this.publicationQueue.length = 0 - this.unregisterClientResource?.() this.unregisterClientResource = undefined + runAllCallbacks([() => release?.(), () => unregister?.()]) } private registerClientResource(): void { @@ -1029,22 +1033,24 @@ class LiveQueryObserverImpl< const { sources, pooledSource } = this.getSourceInfo() if (this.client && this.queryHash) { const query = this.client._getLiveQuery(this.queryHash) - if (query?.status === `pending` || query?.status === `success`) { - try { - if (sources) - this.client._assertLiveQuerySources(this.queryHash, sources) + try { + if (sources) + this.client._assertLiveQuerySources(this.queryHash, sources) + if ( + query?.status === `pending` || + (query?.status === `success` && query.snapshot?.status !== `loading`) + ) { if (pooledSource && this.collection) { return Promise.all([query.promise, this.collection.preload()]).then( () => {}, ) } - // The client stream answers this preload, but it is still a request - // for this Collection's data, so its deferred acquisition may resume. + // Stream reuse remains explicit demand for the local Collection. this.collection?._markPreload() - } catch (error) { - return Promise.reject(error) + return query.status === `pending` ? query.promise : Promise.resolve() } - return query.status === `pending` ? query.promise : Promise.resolve() + } catch (error) { + return Promise.reject(error) } } diff --git a/packages/db/src/live-query-options.ts b/packages/db/src/live-query-options.ts index e340d0a29b..c5c1161fbc 100644 --- a/packages/db/src/live-query-options.ts +++ b/packages/db/src/live-query-options.ts @@ -200,11 +200,35 @@ export function getLiveQueryHash( preparedValue: unknown, queryKey?: LiveQueryKey, ): string { - const identity = queryKey?.length - ? [`queryKey`, queryKey] - : isCollection(preparedValue) - ? [`collection`, preparedValue.id] - : [`derived`, getPreparedLiveQueryIdentity(preparedValue)] + if (queryKey?.length) { + const query = + preparedValue instanceof BaseQueryBuilder + ? preparedValue + : preparedValue && + typeof preparedValue === `object` && + `query` in preparedValue + ? preparedValue.query + : undefined + const ir = isCollection(preparedValue) + ? ( + preparedValue as { + utils: { + getWindow?: () => { limit: number; offset: number } | undefined + } + } + ).utils.getWindow?.() + : query instanceof BaseQueryBuilder + ? query._getQuery() + : undefined + const window = + ir && (ir.limit !== undefined || (ir.offset ?? 0) !== 0) + ? [{ limit: ir.limit, offset: ir.offset ?? 0 }] + : [] + return getStableValueHash([`queryKey`, queryKey, ...window], `queryKey`) + } + const identity = isCollection(preparedValue) + ? [`collection`, preparedValue.id] + : [`derived`, getPreparedLiveQueryIdentity(preparedValue)] return getStableValueHash(identity, `queryKey`) } diff --git a/packages/db/src/query/pooled-live-query.ts b/packages/db/src/query/pooled-live-query.ts index 46fc4d584d..463e149e89 100644 --- a/packages/db/src/query/pooled-live-query.ts +++ b/packages/db/src/query/pooled-live-query.ts @@ -173,7 +173,6 @@ class Partition { }) // Late registration can terminate before either release handle exists. if (this.terminated as boolean) { - stopStatus() stopCleanupStart() return } diff --git a/packages/db/tests/db-client-preload-identity-oracle.test.ts b/packages/db/tests/db-client-preload-identity-oracle.test.ts index baa25a4eae..9ee5ca463a 100644 --- a/packages/db/tests/db-client-preload-identity-oracle.test.ts +++ b/packages/db/tests/db-client-preload-identity-oracle.test.ts @@ -691,3 +691,48 @@ describe(`DbClient preload source identity`, () => { } }) }) + +// The same source-identity judgment applies to a captured loading view. That +// view is not a completed preload, but its declared source claim still belongs +// to the result. A conflicting preload must reject before starting its adapter. +it(`keeps a loading server view's source claim before preload startup`, async () => { + const client = new DbClient() + const first = createCollection({ + id: `provisional-identity`, + getKey: (row) => row.id, + sync: { sync: () => {} }, + }) + let starts = 0 + const other = createCollection({ + id: first.id, + getKey: (row) => row.id, + sync: { + sync: ({ markReady }) => { + starts++ + markReady() + }, + }, + }) + const query = new Query().from({ row: first }) + const collection = resolveLiveQueryValue({ query })! + const observer = createLiveQueryObserver(collection, { + client, + queryHash: getLiveQueryHash({ query }), + }) + const browserWindow = globalThis.window + vi.stubGlobal(`window`, undefined) + try { + expect(observer.getServerSnapshot().status).toBe(`loading`) + expect(() => + client.preloadLiveQuery({ query: new Query().from({ row: other }) }), + ).toThrow(/different source Collections/) + expect(starts, `source contradiction fails before acquisition`).toBe(0) + } finally { + vi.stubGlobal(`window`, browserWindow) + observer.dispose() + await collection.cleanup() + await client.cleanup() + await first.cleanup() + await other.cleanup() + } +}) diff --git a/packages/db/tests/fixtures/error-site-messages.json b/packages/db/tests/fixtures/error-site-messages.json index d91e741194..68cfb8d20c 100644 --- a/packages/db/tests/fixtures/error-site-messages.json +++ b/packages/db/tests/fixtures/error-site-messages.json @@ -931,9 +931,9 @@ }, "245": { "file": "live-query-observer.ts", - "template": "SSR cannot render server-only live-query rows or readiness. Await DbClient.preloadLiveQuery() for this query, or explicitly preload its source Collections for hydration.", + "template": "SSR cannot render server-only live-query rows or readiness. Await DbClient.preloadLiveQuery() for this query, or explicitly preload its source Collections for hydration. Opaque queries require an explicit serializable queryKey.", "literals": [ - "SSR cannot render server-only live-query rows or readiness. Await DbClient.preloadLiveQuery() for this query, or explicitly preload its source Collections for hydration." + "SSR cannot render server-only live-query rows or readiness. Await DbClient.preloadLiveQuery() for this query, or explicitly preload its source Collections for hydration. Opaque queries require an explicit serializable queryKey." ] } } diff --git a/packages/db/tests/live-query-observer-history-oracle.property.test.ts b/packages/db/tests/live-query-observer-history-oracle.property.test.ts index d5ce449b8c..069b252631 100644 --- a/packages/db/tests/live-query-observer-history-oracle.property.test.ts +++ b/packages/db/tests/live-query-observer-history-oracle.property.test.ts @@ -227,6 +227,138 @@ describe(`observer subscription setup ownership`, () => { ) }) +/** + * A release retires its logical owner even when application notification throws. + * Reporting that failure cannot leave the observer attached to zero channels. + * This is the existing ownership law at teardown rather than construction. + * The independent judgment is zero channels after release and one after a new + * returned subscription. A controlled channel release supplies the synchronous + * collaborator failure after retiring its real Collection handle. First-party + * event listeners report errors asynchronously and cannot supply this premise. + * The grammar varies both modes, thrown undefined, and repeated release/retry. + */ +describe(`observer release failure ownership`, () => { + for (const campaign of [`fixed`, `random`] as const) { + it(`reattaches after reporting a release failure (${campaign})`, async () => { + const reach = createOracleReach(`observer-release`, [ + `granular`, + `wholesale`, + `release-error`, + `retry`, + `undefined-cause`, + ]) + await fc.assert( + fc.asyncProperty( + fc.record({ + mode: fc.constantFrom(`granular` as const, `wholesale` as const), + undefinedCause: fc.boolean(), + repeats: fc.integer({ min: 1, max: 3 }), + }), + async (history) => { + reach.beginHistory() + reach.reach(history.mode) + const failure = history.undefinedCause + ? undefined + : new Error(`release notification`) + let failRelease = false, + publish = () => {} + const source = createCollection({ + getKey: (row) => row.id, + gcTime: 0, + sync: { + sync: ({ begin, write, commit, markReady }) => { + markReady() + let version = 0 + publish = () => { + begin() + write({ + type: version ? `update` : `insert`, + value: { id: `a`, version: ++version }, + }) + commit() + } + }, + }, + }) + // A controlled channel release retires its real Collection handle, + // then reports a synchronous collaborator failure. Event listeners + // use reportUncaught instead and cannot supply this premise. + const subscribe = source.subscribeChanges.bind(source) + const stopFailure = vi + .spyOn(source, `subscribeChanges`) + .mockImplementation((...args) => { + const subscription = subscribe(...args) + const unsubscribe = subscription.unsubscribe.bind(subscription) + vi.spyOn(subscription, `unsubscribe`).mockImplementation(() => { + unsubscribe() + if (failRelease) { + failRelease = false + throw failure + } + }) + return subscription + }) + const observer = createLiveQueryObserver(source, { + mode: history.mode, + }) + let stop: (() => void) | undefined + await withOracleCleanup(() => { + for (let i = 0; i < history.repeats; i++) { + stop = observer.subscribe(() => {}) + expect( + source.subscriberCount, + `new ownership has a physical channel`, + ).toBe(1) + failRelease = true + let outcome = { threw: false, error: undefined as unknown } + try { + stop() + } catch (error) { + outcome = { threw: true, error } + } + stop = undefined + expect(outcome).toEqual({ threw: true, error: failure }) + reach.reach(`release-error`) + if (history.undefinedCause) reach.reach(`undefined-cause`) + expect( + source.subscriberCount, + `throwing release still retires its channel`, + ).toBe(0) + } + const listener = vi.fn() + stop = observer.subscribe(listener) + reach.reach(`retry`) + expect( + source.subscriberCount, + `explicit retry acquires a new channel`, + ).toBe(1) + publish() + expect(observer.getSnapshot().state?.get(`a`)?.version).toBe(1) + expect(listener).toHaveBeenCalled() + }, [ + () => { + failRelease = false + stop?.() + }, + () => stopFailure.mockRestore(), + () => observer.dispose(), + () => source.cleanup(), + ]) + }, + ), + campaign === `fixed` + ? { numRuns: oracleRuns(40), seed: 2229 } + : oraclePropertyOptions(40, `live-query-observer.release-failure`), + ) + reach.assertReached() + console.info( + `observer-release ${campaign}`, + Object.fromEntries(reach.counts()), + ) + }) + } +}) + /** * The setup law also covers failure before subscribeChanges, and a peer that * subscribes reentrantly during setup. A failed attempt transfers no physical diff --git a/packages/db/tests/live-query-observer-snapshot-oracle.property.test.ts b/packages/db/tests/live-query-observer-snapshot-oracle.property.test.ts index e6c475766b..97b4c7a8f7 100644 --- a/packages/db/tests/live-query-observer-snapshot-oracle.property.test.ts +++ b/packages/db/tests/live-query-observer-snapshot-oracle.property.test.ts @@ -1,7 +1,7 @@ import { fc } from '@fast-check/vitest' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { createCollection } from '../src/collection/index.js' -import { DbClient } from '../src/client.js' +import { DbClient, collectionOptions } from '../src/client.js' import { getLiveQueryHash } from '../src/live-query-options.js' import { localOnlyCollectionOptions } from '../src/local-only.js' import { createLiveQueryObserver } from '../src/live-query-observer.js' @@ -383,3 +383,182 @@ describe(`retained live-query snapshots are point-in-time`, () => { } }) }) + +// Work probe: an unchanged prepared server revision needs one admission walk, +// and checking row presence must not materialize its lazy keyed view. +it(`reuses server admission without building the lazy state Map`, async () => { + const client = new DbClient() + const source = client.collection( + collectionOptions(`server-work`, () => + localOnlyCollectionOptions({ + id: `server-work`, + getKey: (row) => row.id, + initialData: [{ id: `a`, version: 1 }], + }), + ), + ) + await source.preload() + const query = new Query().from({ row: source }).orderBy(({ row }) => row.id) + const collection = createLiveQueryCollection({ query }) + await collection.preload() + const observer = createLiveQueryObserver(collection, { + client, + queryHash: getLiveQueryHash({ query }), + }) + const walk = vi.spyOn(client, `_hasSsrCollectionSnapshot`) + const snapshot = observer.getSnapshot() + const state = vi.spyOn(snapshot, `state`, `get`) + const browserWindow = globalThis.window + vi.stubGlobal(`window`, undefined) + try { + const first = observer.getServerSnapshot() + for (let i = 0; i < 10; i++) + expect(observer.getServerSnapshot()).toBe(first) + expect( + walk.mock.calls.length, + `admit an unchanged revision once`, + ).toBeLessThanOrEqual(1) + expect( + state, + `row-presence check keeps keyed materialization lazy`, + ).not.toHaveBeenCalled() + } finally { + vi.stubGlobal(`window`, browserWindow) + observer.dispose() + await collection.cleanup() + await client.cleanup() + } +}) + +// Capturing a loading SSR view must not answer a later explicit data preload. +// The real source controls readiness; server capture owns only the initial view. +it(`keeps explicit preload pending after a loading server capture`, async () => { + let ready = () => {} + const client = new DbClient() + const descriptor = collectionOptions(`capture-preload`, () => ({ + id: `capture-preload`, + getKey: (row: Row) => row.id, + sync: { + sync: ({ markReady }) => { + ready = markReady + }, + }, + })) + const source = client.collection(descriptor) + const query = new Query().from({ row: source }) + const collection = createLiveQueryCollection({ query }) + const observer = createLiveQueryObserver(collection, { + client, + queryHash: getLiveQueryHash({ query }), + }) + const browserWindow = globalThis.window + vi.stubGlobal(`window`, undefined) + try { + expect(observer.getServerSnapshot().isReady).toBe(false) + let settled = false + const preload = client.preloadLiveQuery({ query }).then(() => { + settled = true + }) + await Promise.resolve() + await Promise.resolve() + expect(settled, `an initial view is not completed data demand`).toBe(false) + ready() + await preload + expect(settled).toBe(true) + } finally { + vi.stubGlobal(`window`, browserWindow) + observer.dispose() + await collection.cleanup() + await client.cleanup() + } +}) + +// A pending stream owns one result promise. Capturing its initial loading view +// must not replace that promise when another preload requests the same data. +it(`retains a pending stream after capturing its loading view`, async () => { + let ready = () => {} + const client = new DbClient() + const descriptor = collectionOptions(`pending-capture`, () => ({ + id: `pending-capture`, + getKey: (row: Row) => row.id, + sync: { + sync: ({ markReady }) => { + ready = markReady + }, + }, + })) + const query = new Query().from({ row: descriptor }) + const hash = getLiveQueryHash({ query }) + const preload = client.preloadLiveQuery({ query }) + const original = client.dehydrate({ shouldDehydrateLiveQuery: () => true }) + .liveQueries![0]!.promise + const source = client.collection(descriptor) + const collection = createLiveQueryCollection({ + query: new Query().from({ row: source }), + }) + const observer = createLiveQueryObserver(collection, { + client, + queryHash: hash, + }) + const browserWindow = globalThis.window + vi.stubGlobal(`window`, undefined) + try { + expect(observer.getServerSnapshot().isReady).toBe(false) + const peer = client.preloadLiveQuery({ query }) + expect( + client.dehydrate({ shouldDehydrateLiveQuery: () => true }) + .liveQueries![0]!.promise, + `initial capture cannot replace the established stream`, + ).toBe(original) + ready() + await Promise.all([preload, peer]) + } finally { + ready() + vi.stubGlobal(`window`, browserWindow) + observer.dispose() + await collection.cleanup() + await client.cleanup() + } +}) + +// Non-streaming transport remains JSON-compatible. Capturing an initial view +// does not opt its route into promise streaming; opted-in streaming still keeps +// the pending result promise beside the initial snapshot. +it.each([false, true])( + `captures a pending initial view with streaming=%s`, + async (streaming) => { + const client = new DbClient() + client._setSsrStreamingEnabled(streaming) + const descriptor = collectionOptions(`pending-json`, () => ({ + id: `pending-json`, + getKey: (row: Row) => row.id, + sync: { sync: () => {} }, + })) + const query = new Query().from({ row: descriptor }) + void client.preloadLiveQuery({ query }).catch(() => {}) + const source = client.collection(descriptor) + const collection = createLiveQueryCollection({ + query: new Query().from({ row: source }), + }) + const observer = createLiveQueryObserver(collection, { + client, + queryHash: getLiveQueryHash({ query }), + }) + const browserWindow = globalThis.window + vi.stubGlobal(`window`, undefined) + try { + observer.getServerSnapshot() + const transported = client.dehydrate().liveQueries![0]! + expect(transported.snapshot?.status).toBe(`loading`) + expect( + transported.promise instanceof Promise, + `only requested streaming carries a promise`, + ).toBe(streaming) + } finally { + vi.stubGlobal(`window`, browserWindow) + observer.dispose() + await collection.cleanup() + await client.cleanup() + } + }, +) diff --git a/packages/db/tests/oracle-config.ts b/packages/db/tests/oracle-config.ts index ec8480037f..2051bfdcb4 100644 --- a/packages/db/tests/oracle-config.ts +++ b/packages/db/tests/oracle-config.ts @@ -5,9 +5,12 @@ type OracleEnvironment = Record const staticOracleProperties = [ `react.ssr-ordinary`, `react.ssr-infinite`, + `react.ssr-stream-cut`, `svelte.ssr-snapshot`, + `svelte.ssr-handoff`, `pooled-live-query.late-cleanup`, `live-query-observer.partial-attach`, + `live-query-observer.release-failure`, `subscription-lifecycle.notification-error`, `live-query.start-handoff`, `react.descriptor-error-surface`, diff --git a/packages/db/tests/oracle-replay-manifest.ts b/packages/db/tests/oracle-replay-manifest.ts index cda334ba01..6cfc724957 100644 --- a/packages/db/tests/oracle-replay-manifest.ts +++ b/packages/db/tests/oracle-replay-manifest.ts @@ -21,13 +21,18 @@ const ownerGroups: ReadonlyArray = [ [ `react-db/tests/descriptor-query-binding-oracle.test.tsx`, `react`, - `descriptor-error-surface descriptor-ownership ssr-ordinary ssr-infinite`, + `descriptor-error-surface descriptor-ownership ssr-ordinary ssr-infinite ssr-stream-cut`, ], [ `svelte-db/tests/ssr-snapshot-admission-oracle.svelte.test.ts`, `svelte`, `ssr-snapshot`, ], + [ + `svelte-db/tests/ssr-snapshot-handoff-oracle.svelte.test.ts`, + `svelte`, + `ssr-handoff`, + ], [ `db/tests/live-query-observer-snapshot-oracle.property.test.ts`, `live-query-observer`, @@ -157,7 +162,7 @@ const ownerGroups: ReadonlyArray = [ [ `db/tests/live-query-observer-history-oracle.property.test.ts`, `live-query-observer`, - `granular-history wholesale-history partial-attach`, + `granular-history wholesale-history partial-attach release-failure`, ], [ `db-sqlite-persistence-core/tests/sqlite-resume-snapshot-oracle.test.ts`, @@ -316,7 +321,13 @@ const replayTestNames = new Map([ [`react.descriptor-ownership`, `ownership after descriptor failure.*random`], [`react.ssr-ordinary`, `ordinary admits reproducible SSR snapshots.*random`], [`react.ssr-infinite`, `infinite admits reproducible SSR snapshots.*random`], + [`react.ssr-stream-cut`, `transports the rendered view.*random`], [`svelte.ssr-snapshot`, `admits reproducible SSR snapshots.*random`], + [`svelte.ssr-handoff`, `matches the actual browser initialization.*random`], + [ + `live-query-observer.release-failure`, + `reattaches after reporting a release failure.*random`, + ], [ `structural-identity.equal-representations`, `agrees with D2 value identity for equal representations`, diff --git a/packages/db/tests/query/pooled-live-query-gc-oracle.test.ts b/packages/db/tests/query/pooled-live-query-gc-oracle.test.ts index 66a150d1ec..44e90526d2 100644 --- a/packages/db/tests/query/pooled-live-query-gc-oracle.test.ts +++ b/packages/db/tests/query/pooled-live-query-gc-oracle.test.ts @@ -275,6 +275,7 @@ describe(`pooled live query gcTime`, () => { }) source.startSyncImmediate() let statusLeases = 0 + let statusReleases = 0 const on = source.on.bind(source) source.on = (event, listener) => { const off = on(event, listener) @@ -282,6 +283,7 @@ describe(`pooled live query gcTime`, () => { statusLeases++ let active = true return () => { + statusReleases++ if (active) { active = false statusLeases-- @@ -314,6 +316,7 @@ describe(`pooled live query gcTime`, () => { expect(source.subscriberCount).toBe(0) expect(statusLeases, `partition released before cleanup`).toBe(0) const pending = source.cleanup() + const releasesBeforeRegistration = statusReleases let result: { threw: boolean; error?: unknown } = { threw: false } try { subscription = view.subscribeChanges(() => {}) @@ -328,6 +331,10 @@ describe(`pooled live query gcTime`, () => { statusLeases, `failed/terminal registration owns no channel`, ).toBe(0) + expect( + statusReleases - releasesBeforeRegistration, + `one late channel needs one release invocation`, + ).toBe(1) expect(source.subscriberCount).toBe(0) expect(view.status).toBe(`error`) gate.resolve() diff --git a/packages/db/tests/ssr-snapshot-admission-oracle.ts b/packages/db/tests/ssr-snapshot-admission-oracle.ts index e940cd9fc3..b9f0a76ece 100644 --- a/packages/db/tests/ssr-snapshot-admission-oracle.ts +++ b/packages/db/tests/ssr-snapshot-admission-oracle.ts @@ -4,8 +4,11 @@ import { fc } from '@fast-check/vitest' * SSR may expose only a snapshot the first browser render can reproduce. * The maintainer chose a programming-error rejection for incidental server-only * rows or readiness. An ordinary cold/loading render remains legal. The SSR - * guide's normalized rows are provisional; they do not transfer query readiness. - * A completed query preload separately transfers a ready result. + * guide's normalized rows alone are provisional. A prepared source that has + * completed this query may render ready only if transport captures that exact + * query view, including readiness; otherwise non-streaming Suspense cannot finish. + * This is a consequence of prepared SSR and hydration equality, not an inference + * that normalized source rows prove browser Collection readiness. * * This judgment uses declared preparation and authored rows, never production * registration or readiness classifiers. `sourceState` combines adapter startup @@ -20,6 +23,9 @@ export type SsrSnapshotHistory = { preparation: `none` | `source` | `query` | `pending-query` sourceState: `idle` | `loading` | `ready` rows: Array<{ id: string; name: string }> + serverWrite?: boolean + browserWrite?: boolean + browserPreload?: boolean } export const ssrSnapshotHistoryArbitrary = fc @@ -36,6 +42,9 @@ export const ssrSnapshotHistoryArbitrary = fc `ready`, ), count: fc.integer({ min: 0, max: 3 }), + serverWrite: fc.boolean(), + browserWrite: fc.boolean(), + browserPreload: fc.boolean(), }) .filter(({ preparation, sourceState, count }) => sourceState === `idle` @@ -44,14 +53,26 @@ export const ssrSnapshotHistoryArbitrary = fc ? sourceState === `ready` : preparation !== `pending-query` || sourceState === `loading`, ) - .map(({ preparation, sourceState, count }): SsrSnapshotHistory => ({ - preparation, - sourceState, - rows: Array.from({ length: count }, (_, index) => ({ - id: String(index + 1), - name: `server ${index + 1}`, - })), - })) + .map( + ({ + preparation, + sourceState, + count, + serverWrite, + browserWrite, + browserPreload, + }): SsrSnapshotHistory => ({ + preparation, + sourceState, + serverWrite, + browserWrite, + browserPreload, + rows: Array.from({ length: count }, (_, index) => ({ + id: String(index + 1), + name: `server ${index + 1}`, + })), + }), + ) export function expectedSsrSnapshot(history: SsrSnapshotHistory) { const prepared = @@ -60,7 +81,11 @@ export function expectedSsrSnapshot(history: SsrSnapshotHistory) { rejects: !prepared && (history.sourceState === `ready` || history.rows.length > 0), rows: history.rows, - status: history.preparation === `query` ? `ready` : `loading`, + status: + history.preparation === `query` || + (history.preparation === `source` && history.sourceState === `ready`) + ? `ready` + : `loading`, } as const } @@ -95,3 +120,30 @@ export const ssrSnapshotExamples: Array = [ rows: [{ id: `1`, name: `server 1` }], }, ] + +// Adjacent timing witnesses keep preparation fixed while moving publication +// across the render/transport and transport/browser observation cuts. +ssrSnapshotExamples.push( + { + preparation: `source`, + sourceState: `loading`, + rows: [{ id: `1`, name: `server 1` }], + serverWrite: true, + }, + { preparation: `none`, sourceState: `idle`, rows: [], browserWrite: true }, + { + preparation: `source`, + sourceState: `ready`, + rows: [], + serverWrite: true, + browserWrite: true, + }, +) + +ssrSnapshotExamples.push({ + preparation: `source`, + sourceState: `loading`, + rows: [{ id: `1`, name: `server 1` }], + browserWrite: true, + browserPreload: true, +}) diff --git a/packages/react-db/src/useLiveInfiniteQuery.ts b/packages/react-db/src/useLiveInfiniteQuery.ts index d51ba04eec..6105efad24 100644 --- a/packages/react-db/src/useLiveInfiniteQuery.ts +++ b/packages/react-db/src/useLiveInfiniteQuery.ts @@ -400,7 +400,7 @@ export function useLiveInfiniteQuery( throw new Error(`useLiveInfiniteQuery: Failed to create a collection.`) } - if (inputIsCollection) { + if (inputIsCollection || suppliedCollection) { queryHash = dbClient ? getLiveQueryHash(collection, config.queryKey) : undefined diff --git a/packages/react-db/src/useLiveQuery.ts b/packages/react-db/src/useLiveQuery.ts index e391d9bba6..c48a966744 100644 --- a/packages/react-db/src/useLiveQuery.ts +++ b/packages/react-db/src/useLiveQuery.ts @@ -1053,7 +1053,7 @@ function useLiveQueryImpl( // Streaming Suspense discovers a query even when a peer warmed its source. if (forSuspense && dbClient?._isSsrStreamingEnabled()) { observer.preloadForInitialRender().catch(() => {}) - return observer.getSnapshot() + return observer.getServerSnapshot(true) } return observer.getServerSnapshot() }, diff --git a/packages/react-db/src/useLiveSuspenseQuery.ts b/packages/react-db/src/useLiveSuspenseQuery.ts index 28c39bab8e..63ae1e89cd 100644 --- a/packages/react-db/src/useLiveSuspenseQuery.ts +++ b/packages/react-db/src/useLiveSuspenseQuery.ts @@ -267,6 +267,14 @@ export function useLiveSuspenseQuery( throw observerError === undefined ? clientQuery?.error : observerError } + if (queryInfo.client?._isSsrStreamingEnabled() && !queryInfo.queryHash) { + const reason = queryInfo.identityError + ? `${queryInfo.identityError.reason} at ${queryInfo.identityError.path}` + : `the query has no stable identity` + throw new Error( + `Cannot stream this live query during SSR because ${reason}. Provide an explicit serializable queryKey.`, + ) + } const errorEntry = initialRenderErrors.get(result.collection) const initialRenderError = queryInfo.client ? errorEntry?.byClient.get(queryInfo.client) @@ -304,14 +312,6 @@ export function useLiveSuspenseQuery( (collectionStatus === `error` && result.persistedStatus !== `unavailable`)) ) { - if (queryInfo.client?._isSsrStreamingEnabled() && !queryInfo.queryHash) { - const reason = queryInfo.identityError - ? `${queryInfo.identityError.reason} at ${queryInfo.identityError.path}` - : `the query has no stable identity` - throw new Error( - `Cannot stream this live query during SSR because ${reason}. Provide an explicit serializable queryKey.`, - ) - } // Create or reuse promise for current collection if (!promiseRef.current) { const collection = result.collection diff --git a/packages/react-db/tests/descriptor-query-binding-oracle.test.tsx b/packages/react-db/tests/descriptor-query-binding-oracle.test.tsx index 629ecf8525..244505ec1d 100644 --- a/packages/react-db/tests/descriptor-query-binding-oracle.test.tsx +++ b/packages/react-db/tests/descriptor-query-binding-oracle.test.tsx @@ -98,6 +98,7 @@ import { Query, collectionOptions, createCollection, + createLiveQueryCollection, eq, getLiveQueryHash, getStableQueryBuilderHash, @@ -1884,6 +1885,18 @@ describe(`descriptor error handoff at React commit`, () => { * Query-only infinite preparation includes its first-page peek-ahead window. * This driver supplies host scheduling, not another expected-result model. */ +// React's server renderer runs in this jsdom test process. Remove the browser +// global only for that host call; hydrateRoot retains the real browser globals. +function renderSsr(node: ReactNode): string { + const browserWindow = globalThis.window + vi.stubGlobal(`window`, undefined) + try { + return renderToString(node) + } finally { + vi.stubGlobal(`window`, browserWindow) + } +} + describe(`SSR snapshot admission`, () => { const properties = { ordinary: `react.ssr-ordinary`, @@ -1899,6 +1912,9 @@ describe(`SSR snapshot admission`, () => { `normalized-ready`, `normalized-partial`, `pending-query`, + `server-write`, + `browser-write`, + `same-query-preload`, ]) async function checkHistory( history: SsrSnapshotHistory, @@ -1907,6 +1923,7 @@ describe(`SSR snapshot admission`, () => { reach.beginHistory() const expected = expectedSsrSnapshot(history) const starts = { server: 0, browser: 0 } + const writers = new Map<`server` | `browser`, (name: string) => void>() const descriptor = collectionOptions(`ssr-admission`, (client) => ({ id: `ssr-admission`, getKey: (row: { id: string; name: string }) => row.id, @@ -1918,6 +1935,11 @@ describe(`SSR snapshot admission`, () => { `runtime`, ) starts[runtime]++ + writers.set(runtime, (name) => { + begin() + write({ type: `insert`, value: { id: `late`, name } }) + commit() + }) begin() for (const row of history.rows) write({ @@ -1995,7 +2017,7 @@ describe(`SSR snapshot admission`, () => { let html: string | undefined let failure: unknown try { - html = renderToString(app(server)) + html = renderSsr(app(server)) } catch (error) { failure = error } @@ -2025,12 +2047,28 @@ describe(`SSR snapshot admission`, () => { ) } else { expect(failure, `prepared and cold SSR remain legal`).toBeUndefined() + if (history.serverWrite && history.sourceState !== `idle`) { + writers.get(`server`)!(`late server`) + reach.reach(`server-write`) + } browser.hydrate(server.dehydrate()) - const initialBrowserHtml = renderToString(app(browser)) - expect( - initialBrowserHtml, - `initial rows and every rendered status flag match`, - ).toBe(html) + if (history.browserWrite) { + if (history.browserPreload) { + await browser.preloadLiveQuery({ + query: hook === `infinite` ? query.limit(2).offset(0) : query, + }) + reach.reach(`same-query-preload`) + } + await browser.collection(descriptor).preload() + writers.get(`browser`)!(`late browser`) + reach.reach(`browser-write`) + } + let initialBrowserHtml: string | undefined, hydrationError: unknown + try { + initialBrowserHtml = renderToString(app(browser)) + } catch (error) { + hydrationError = error + } host.innerHTML = html! expect( host.querySelector(`output`)?.getAttribute(`data-status`), @@ -2043,7 +2081,10 @@ describe(`SSR snapshot admission`, () => { : expected.rows.slice(0, 1) ).map((row) => row.name), ) - expect(starts.browser, `hydration read starts no source`).toBe(0) + expect( + starts.browser, + `hydration read starts no additional source`, + ).toBe(history.browserWrite ? 1 : 0) document.body.append(host) await act(async () => { root = hydrateRoot(host, app(browser), { @@ -2051,6 +2092,14 @@ describe(`SSR snapshot admission`, () => { }) await Promise.resolve() }) + expect( + hydrationError, + `browser activity cannot become an SSR programming error`, + ).toBeUndefined() + expect( + initialBrowserHtml, + `initial rows and every rendered status flag match`, + ).toBe(html) expect( recoveries, `React receives the same snapshot without recovery`, @@ -2065,9 +2114,27 @@ describe(`SSR snapshot admission`, () => { expect( [...host.querySelectorAll(`span`)].map((span) => span.textContent), ).toEqual( - (hook === `ordinary` ? history.rows : history.rows.slice(0, 1)).map( - (row) => `browser ${row.id}`, - ), + hook === `ordinary` + ? [ + ...history.rows.map((row) => `browser ${row.id}`), + ...(history.browserWrite + ? [`late browser`] + : history.preparation === `source` && + history.serverWrite && + history.sourceState !== `idle` + ? [`late server`] + : []), + ] + : [ + ...history.rows.map((row) => `browser ${row.id}`), + ...(history.browserWrite + ? [`late browser`] + : history.preparation === `source` && + history.serverWrite && + history.sourceState !== `idle` + ? [`late server`] + : []), + ].slice(0, 1), ) if (history.preparation === `query`) reach.reach(`query-result`) else if ( @@ -2145,7 +2212,7 @@ describe(`SSR Suspense discovery`, () => { .select(({ person }) => ({ name: person.name })) function Consumer() { const result = useLiveSuspenseQuery({ query }) - return {result.data[0]?.name} + return {result.data.map((row) => row.name).join(`,`)} } try { if (warm) @@ -2201,3 +2268,492 @@ describe(`SSR Suspense discovery`, () => { }) } }) + +/** Prepared Suspense results must render on either React server host. Streaming + * is explicit demand; opaque logic still requires a portable identity even + * when warm. This bounded grammar crosses source/query preparation, empty/nonempty + * results, streaming on/off, and keyed/unkeyed opaque discovery. The observation + * is final server markup and server errors, before any browser can repair it. + */ +describe(`SSR Suspense receiving matrix`, () => { + for (const streaming of [false, true]) { + for (const preparation of [`source`, `query`] as const) { + for (const count of [0, 2]) { + it(`renders prepared ${preparation} count=${count} streaming=${streaming}`, async () => { + const descriptor = collectionOptions(`suspense-prepared`, () => ({ + id: `suspense-prepared`, + getKey: (row: { id: string; name: string }) => row.id, + sync: { + sync: ({ begin, write, commit, markReady }) => { + begin() + for (let i = 0; i < count; i++) + write({ + type: `insert`, + value: { id: String(i), name: `server ${i}` }, + }) + commit() + markReady() + }, + }, + })) + const client = new DbClient() + client._setSsrStreamingEnabled(streaming) + const query = new Query() + .from({ person: descriptor }) + .select(({ person }) => ({ name: person.name })) + function Consumer() { + const { data } = useLiveSuspenseQuery({ query }) + return ( + + {data.map((row) => row.name).join(`,`) || `empty ready`} + + ) + } + const app = ( + + loading}> + + + + ) + const browserWindow = globalThis.window + try { + if (preparation === `source`) + await client.collection(descriptor).preload() + else await client.preloadLiveQuery({ query }) + vi.stubGlobal(`window`, undefined) + let html: string + const errors: Array = [] + if (!streaming) html = renderToString(app) + else + html = await new Promise((resolve, reject) => { + let result = `` + const sink = new Writable({ + write(chunk, _encoding, done) { + result += chunk.toString() + done() + }, + }) + sink.on(`finish`, () => resolve(result)) + sink.on(`error`, reject) + const stream = renderToPipeableStream(app, { + onAllReady() { + stream.pipe(sink) + }, + onError(error) { + errors.push(error) + }, + }) + }) + expect(errors).toEqual([]) + expect( + html, + `prepared results finish rather than suspend forever`, + ).toContain( + `${count ? `server 0,server 1` : `empty ready`}`, + ) + } finally { + vi.stubGlobal(`window`, browserWindow) + await client.cleanup() + } + }) + } + } + } + for (const warm of [false, true]) + for (const keyed of [false, true]) { + it(`checks opaque streaming identity warm=${warm} keyed=${keyed}`, async () => { + const descriptor = collectionOptions(`opaque-ssr`, () => ({ + id: `opaque-ssr`, + getKey: (row: { id: string }) => row.id, + sync: { + sync: ({ begin, write, commit, markReady }) => { + begin() + write({ type: `insert`, value: { id: `server` } }) + commit() + markReady() + }, + }, + })) + const client = new DbClient() + client._setSsrStreamingEnabled(true) + const query = new Query() + .from({ row: descriptor }) + .fn.where(({ row }) => row.id.length > 0) + function Consumer() { + const { data } = useLiveSuspenseQuery({ + query, + ...(keyed ? { queryKey: [`opaque`] } : {}), + }) + return {data.map((row) => row.id).join(`,`)} + } + const browserWindow = globalThis.window + try { + if (warm) + await client.preloadLiveQuery({ + query: new Query() + .from({ row: descriptor }) + .select(({ row }) => ({ id: row.id })), + }) + vi.stubGlobal(`window`, undefined) + const errors: Array = [] + const html = await new Promise((resolve, reject) => { + let result = `` + const sink = new Writable({ + write(chunk, _encoding, done) { + result += chunk.toString() + done() + }, + }) + sink.on(`finish`, () => resolve(result)) + sink.on(`error`, reject) + const stream = renderToPipeableStream( + + loading}> + + + , + { + onAllReady() { + stream.pipe(sink) + }, + onError(error) { + errors.push(error) + }, + }, + ) + }) + if (keyed) { + expect(errors).toEqual([]) + expect(html).toContain(`server`) + expect( + client + .dehydrate() + .liveQueries?.some( + (q) => + q.queryHash === getLiveQueryHash({ query }, [`opaque`]), + ), + ).toBe(true) + } else { + expect( + errors, + `warm rows do not waive transport identity`, + ).toHaveLength(1) + expect(String(errors[0])).toMatch(/explicit serializable queryKey/) + } + } finally { + vi.stubGlobal(`window`, browserWindow) + await client.cleanup() + } + }) + } +}) + +/** Result identity includes the caller's current input and its initial window. + * The reference is authored rows sliced by page size, never transport records. + * Browser replacement crosses builder -> callback Collection, with a distinct + * explicit key or derived identity. Server preload checks both matching and + * unbounded windows under one key; their portable result identities must differ. + */ +function useRuntimeWindow( + input: unknown, + config: { pageSize: number; queryKey?: Array }, +) { + // Exercise the callback-returned Collection runtime path without extending + // TypeScript's current QueryBuilder-only callback overload. + return Reflect.apply(useLiveInfiniteQuery, undefined, [input, config]) as { + data: Array<{ id: string; name: string }> + } +} +describe(`infinite hydration identity`, () => { + for (const keyed of [false, true]) + it(`switches to callback Collection keyed=${keyed}`, async () => { + const source = createCollection( + mockSyncCollectionOptions<{ id: string; name: string }>({ + id: `infinite-identity-source`, + getKey: (row) => row.id, + initialData: [ + { id: `a`, name: `old` }, + { id: `b`, name: `new` }, + ], + }), + ) + const client = new DbClient() + const oldQuery = new Query() + .from({ row: source }) + .where(({ row }) => eq(row.id, `a`)) + .orderBy(({ row }) => row.id) + const supplied = createLiveQueryCollection({ + query: new Query() + .from({ row: source }) + .where(({ row }) => eq(row.id, `b`)) + .orderBy(({ row }) => row.id) + .limit(2), + }) + let unmount: (() => void) | undefined + try { + await client.preloadLiveQuery({ + query: oldQuery.limit(2), + ...(keyed ? { queryKey: [`old`] } : {}), + }) + await supplied.preload() + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ) + const hook = renderHook( + ({ replacement }: { replacement: boolean }) => + useRuntimeWindow(() => (replacement ? supplied : oldQuery), { + pageSize: 1, + ...(keyed ? { queryKey: [replacement ? `new` : `old`] } : {}), + }), + { wrapper, initialProps: { replacement: false } }, + ) + unmount = hook.unmount + expect(hook.result.current.data.map((row) => row.name)).toEqual([`old`]) + hook.rerender({ replacement: true }) + expect( + hook.result.current.data.map((row) => row.name), + `replacement owns its hydration identity`, + ).toEqual([`new`]) + } finally { + unmount?.() + await client.cleanup() + await supplied.cleanup() + await source.cleanup() + } + }) + for (const matching of [false, true]) + it(`keeps keyed initial window distinct matching=${matching}`, async () => { + const descriptor = collectionOptions(`window-identity`, () => ({ + id: `window-identity`, + getKey: (row: { id: string }) => row.id, + autoIndex: `eager`, + defaultIndexType: BTreeIndex, + sync: { + sync: ({ begin, write, commit, markReady }) => { + begin() + for (const id of [`a`, `b`, `c`, `d`]) + write({ type: `insert`, value: { id } }) + commit() + markReady() + }, + }, + })) + const query = new Query() + .from({ row: descriptor }) + .orderBy(({ row }) => row.id) + const client = new DbClient() + try { + const unbounded = getLiveQueryHash({ query }, [`window`]) + const windowed = getLiveQueryHash({ query: query.limit(2).offset(0) }, [ + `window`, + ]) + await client.preloadLiveQuery({ + query: matching ? query.limit(2).offset(0) : query, + queryKey: [`window`], + }) + function Consumer() { + const result = useLiveInfiniteQuery(() => query, { + pageSize: 1, + queryKey: [`window`], + }) + return {result.data.map((row) => row.id).join(`,`)} + } + const renderWindow = () => + renderSsr( + + + , + ) + if (matching) expect(renderWindow()).toContain(`a`) + else expect(renderWindow).toThrow(/SSR.*preload/) + expect( + unbounded, + `unbounded and windowed results cannot alias`, + ).not.toBe(windowed) + if (matching) + expect( + client + .dehydrate() + .liveQueries?.find((q) => q.queryHash === windowed)?.snapshot + ?.rows, + ).toHaveLength(2) + } finally { + await client.cleanup() + } + }) +}) + +// Streaming must capture the view it actually renders, even when the adapter +// publishes again in the microtask before its preload promise captures a result. +// This metamorphic observation compares the final host markup with transport. +async function checkStreamCut( + history: { count: number; writes: number }, + reach: ReturnType, +) { + reach.beginHistory() + const authoredRows = Array.from({ length: history.count }, (_, i) => ({ + id: String(i), + name: `first ${i}`, + })) + const descriptor = collectionOptions(`stream-render-cut`, () => ({ + id: `stream-render-cut`, + getKey: (row: { id: string; name: string }) => row.id, + sync: { + sync: ({ begin, write, commit, markReady }) => { + begin() + for (const row of authoredRows) write({ type: `insert`, value: row }) + commit() + markReady() + queueMicrotask(() => { + for (let i = 0; i < history.writes; i++) { + begin() + write({ + type: history.count || i ? `update` : `insert`, + value: { id: `0`, name: `later ${i}` }, + }) + commit() + } + }) + }, + }, + })) + const client = new DbClient() + client._setSsrStreamingEnabled(true) + const query = new Query() + .from({ row: descriptor }) + .select(({ row }) => ({ name: row.name })) + function Consumer() { + const result = useLiveSuspenseQuery({ query }) + return {result.data.map((row) => row.name).join(`,`)} + } + const browserWindow = globalThis.window + vi.stubGlobal(`window`, undefined) + try { + const errors: Array = [] + const html = await new Promise((resolve, reject) => { + let text = `` + const sink = new Writable({ + write(chunk, _encoding, done) { + text += chunk.toString() + done() + }, + }) + sink.on(`finish`, () => resolve(text)) + sink.on(`error`, reject) + const stream = renderToPipeableStream( + + loading}> + + + , + { + onAllReady() { + stream.pipe(sink) + }, + onError(error) { + errors.push(error) + }, + }, + ) + }) + expect(errors).toEqual([]) + const rendered = html.match(/([^<]*)<\/span>/)?.[1] + expect(rendered).toBe(authoredRows.map((row) => row.name).join(`,`)) + const transport = client + .dehydrate({ shouldDehydrateLiveQuery: () => true }) + .liveQueries!.find( + (result) => result.queryHash === getLiveQueryHash({ query }), + )! + const snapshot = transport.snapshot ?? (await transport.promise) + expect( + snapshot?.rows.map((row) => (row.value as { name: string }).name), + `stream transport reproduces its rendered view`, + ).toEqual(authoredRows.map((row) => row.name)) + reach.reach(history.count ? `nonempty` : `empty`) + reach.reach(history.writes ? `later-publication` : `unchanged`) + } finally { + vi.stubGlobal(`window`, browserWindow) + await client.cleanup() + } +} +const createStreamReach = () => + createOracleReach(`react-stream-cut`, [ + `empty`, + `nonempty`, + `later-publication`, + `unchanged`, + ]) +describe(`stream render snapshot cut`, () => { + for (const history of [ + { count: 0, writes: 1 }, + { count: 1, writes: 1 }, + { count: 1, writes: 0 }, + ]) + it(`replays count=${history.count} later-writes=${history.writes}`, () => + checkStreamCut(history, createStreamReach())) + for (const campaign of [`fixed`, `random`] as const) + it(`transports the rendered view (${campaign})`, async () => { + const reach = createStreamReach() + await fc.assert( + fc.asyncProperty( + fc.record({ + count: fc.integer({ min: 0, max: 3 }), + writes: fc.integer({ min: 0, max: 3 }), + }), + (history) => checkStreamCut(history, reach), + ), + campaign === `fixed` + ? { numRuns: oracleRuns(40), seed: 2229 } + : oraclePropertyOptions(40, `react.ssr-stream-cut`), + ) + reach.assertReached() + console.info( + `react-stream-cut ${campaign}`, + Object.fromEntries(reach.counts()), + ) + }) +}) + +// Explicit keys identify the same window across builder preload and supplied +// Collection input, including a callback that returns that Collection. +for (const callback of [false, true]) + it(`hydrates a supplied window with callback=${callback}`, async () => { + const source = createCollection( + mockSyncCollectionOptions<{ id: string }>({ + id: `supplied-window-key`, + getKey: (row) => row.id, + autoIndex: `eager`, + defaultIndexType: BTreeIndex, + initialData: [{ id: `a` }, { id: `b` }, { id: `c` }], + }), + ) + const query = new Query() + .from({ row: source }) + .orderBy(({ row }) => row.id) + .limit(2) + .offset(0) + const supplied = createLiveQueryCollection({ query }) + const client = new DbClient() + function Consumer() { + const result = useRuntimeWindow(callback ? () => supplied : supplied, { + pageSize: 1, + queryKey: [`supplied`], + }) + return {result.data.map((row) => row.id).join(`,`)} + } + try { + await client.preloadLiveQuery({ query, queryKey: [`supplied`] }) + expect( + renderSsr( + + + , + ), + ).toContain(`a`) + } finally { + await client.cleanup() + await supplied.cleanup() + await source.cleanup() + } + }) diff --git a/packages/svelte-db/tests/SsrDbApp.svelte b/packages/svelte-db/tests/SsrDbApp.svelte index adc6823847..fb17c358f9 100644 --- a/packages/svelte-db/tests/SsrDbApp.svelte +++ b/packages/svelte-db/tests/SsrDbApp.svelte @@ -12,12 +12,17 @@ let { client, descriptor, + onInitial, }: { client: DbClient + onInitial?: ( + rows: Array<{ id: string; name: string }>, + status: string, + ) => void descriptor: CollectionOptions } = $props() - + diff --git a/packages/svelte-db/tests/SsrDbQuery.svelte b/packages/svelte-db/tests/SsrDbQuery.svelte index 6e0c9a58da..fc6cc8d88d 100644 --- a/packages/svelte-db/tests/SsrDbQuery.svelte +++ b/packages/svelte-db/tests/SsrDbQuery.svelte @@ -10,7 +10,12 @@ let { descriptor, + onInitial, }: { + onInitial?: ( + rows: Array<{ id: string; name: string }>, + status: string, + ) => void descriptor: CollectionOptions } = $props() @@ -21,6 +26,13 @@ name: people.name, })), }) + function reportInitial() { + onInitial?.( + people.data.map(({ id, name }) => ({ id, name })), + people.status, + ) + } + reportInitial() diff --git a/packages/svelte-db/tests/hydration.svelte.test.ts b/packages/svelte-db/tests/hydration.svelte.test.ts index 11174050c5..c9c2854834 100644 --- a/packages/svelte-db/tests/hydration.svelte.test.ts +++ b/packages/svelte-db/tests/hydration.svelte.test.ts @@ -112,7 +112,7 @@ describe(`Svelte SSR receiving controls`, () => { `valid browser initialization does not throw`, ).toBeUndefined() expect(query.status).toBe( - preparation === `warm-browser` ? `ready` : expected.status, + preparation === `warm-browser` ? `ready` : `loading`, ) expect(query.data).toEqual( expected.rows.map((row) => expect.objectContaining(row)), diff --git a/packages/svelte-db/tests/ssr-snapshot-admission-oracle.svelte.test.ts b/packages/svelte-db/tests/ssr-snapshot-admission-oracle.svelte.test.ts index f6def7c24b..5894fb64c9 100644 --- a/packages/svelte-db/tests/ssr-snapshot-admission-oracle.svelte.test.ts +++ b/packages/svelte-db/tests/ssr-snapshot-admission-oracle.svelte.test.ts @@ -18,8 +18,9 @@ import type { SsrSnapshotHistory } from '../../db/tests/ssr-snapshot-admission-o * Svelte supplies its own server host for the shared SSR admission judgment. * Source actions and legal histories are the same as React's receiving driver; * expected rejection, rows and status come only from the shared model. Compare - * the two server renders before a browser subscription can hide missing state. - * Actual browser subscription/handoff remains in hydration.svelte.test.ts. + * the two server renders to check server-side transport reconstruction only. + * The actual browser branch is checked by ssr-snapshot-handoff-oracle.svelte.test.ts; + * hydration.svelte.test.ts separately owns normal browser/effect handoff. */ describe(`Svelte SSR snapshot admission`, () => { const makeReach = () => diff --git a/packages/svelte-db/tests/ssr-snapshot-fixture.ts b/packages/svelte-db/tests/ssr-snapshot-fixture.ts new file mode 100644 index 0000000000..0d07149697 --- /dev/null +++ b/packages/svelte-db/tests/ssr-snapshot-fixture.ts @@ -0,0 +1,81 @@ +import { render } from 'svelte/server' +import { BTreeIndex, DbClient, Query, collectionOptions } from '@tanstack/db' +import SsrDbApp from './SsrDbApp.svelte' +import { peopleQuery } from './ssr-test-utils' +import type { SsrSnapshotHistory } from '../../db/tests/ssr-snapshot-admission-oracle' + +// Server-only production driver. The browser owner loads this through Vite's +// SSR compilation, then transfers only serialized HTML and dehydration data. +// It supplies no expected rows, readiness rule or comparison. +export async function renderSsrSnapshotHistory(history: SsrSnapshotHistory) { + let publish = () => {} + const descriptor = collectionOptions(`svelte-ssr-people`, () => ({ + id: `svelte-ssr-people`, + autoIndex: `eager`, + defaultIndexType: BTreeIndex, + getKey: (row: { id: string; name: string; sourcePayload: string }) => + row.id, + sync: { + sync: ({ begin, write, commit, markReady }) => { + begin() + for (const row of history.rows) + write({ + type: `insert`, + value: { ...row, sourcePayload: `source-only` }, + }) + commit() + publish = () => { + begin() + write({ + type: `insert`, + value: { + id: `late`, + name: `late server`, + sourcePayload: `source-only`, + }, + }) + commit() + } + if (history.sourceState === `ready`) markReady() + }, + }, + })) + const client = new DbClient() + client._setSsrServerCleanupEnabled(true) + try { + if (history.sourceState !== `idle`) { + const warming = client.preloadLiveQuery({ + query: new Query() + .from({ person: descriptor }) + .select(({ person }) => ({ id: person.id })), + }) + if (history.sourceState === `ready`) await warming + else void warming.catch(() => {}) + } + if (history.preparation === `source`) client.collection(descriptor) + if ( + history.preparation === `query` || + history.preparation === `pending-query` + ) { + const preload = client.preloadLiveQuery(peopleQuery(descriptor)) + if (history.preparation === `query`) await preload + else void preload.catch(() => {}) + } + let html: string | undefined, error: string | undefined + try { + html = render(SsrDbApp, { props: { client, descriptor } }).body + } catch (failure) { + error = String(failure) + } + if (history.serverWrite && history.sourceState !== `idle`) publish() + return JSON.parse( + JSON.stringify({ html, error, state: client.dehydrate() }), + ) as { + html?: string + error?: string + state: ReturnType + } + } finally { + await client.cleanup() + } +} diff --git a/packages/svelte-db/tests/ssr-snapshot-handoff-oracle.svelte.test.ts b/packages/svelte-db/tests/ssr-snapshot-handoff-oracle.svelte.test.ts new file mode 100644 index 0000000000..945923f2a8 --- /dev/null +++ b/packages/svelte-db/tests/ssr-snapshot-handoff-oracle.svelte.test.ts @@ -0,0 +1,213 @@ +import { TextEncoder } from 'node:util' +import { fc } from '@fast-check/vitest' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { flushSync, hydrate, unmount } from 'svelte' +import { BTreeIndex, DbClient, collectionOptions } from '@tanstack/db' +import { + expectedSsrSnapshot, + ssrSnapshotExamples, + ssrSnapshotHistoryArbitrary, +} from '../../db/tests/ssr-snapshot-admission-oracle' +import { oraclePropertyOptions, oracleRuns } from '../../db/tests/oracle-config' +import { createOracleReach } from '../../db/tests/oracle-reach' +import { peopleQuery } from './ssr-test-utils' +import SsrDbApp from './SsrDbApp.svelte' +import type { createServer } from 'vite' +import type { renderSsrSnapshotHistory } from './ssr-snapshot-fixture' +import type { SsrSnapshotHistory } from '../../db/tests/ssr-snapshot-admission-oracle' + +/** + * The shared snapshot judgment predicts the view at server render and the first + * browser initialization, before browser effects subscribe. Vite supplies a real + * server compilation for the first host and this test's Svelte plugin supplies + * a real browser compilation for hydrate(). Only serialized transport crosses. + * The component records its first public hook rows/status before effects, so + * a later successful commit cannot hide an initial mismatch. Source writes may + * occur between render/dehydrate and between hydrate(state)/browser mount. + */ +let server: Awaited> +let renderServer: typeof renderSsrSnapshotHistory +beforeAll(async () => { + // jsdom's typed arrays have a different realm from Node's encoder. Vite's + // esbuild boundary requires an encoder returning this host's Uint8Array. + const encoder = globalThis.TextEncoder + vi.stubGlobal( + `TextEncoder`, + class extends TextEncoder { + override encode(input?: string) { + return new Uint8Array(super.encode(input)) + } + }, + ) + const { createServer } = await import('vite') + vi.stubGlobal(`TextEncoder`, encoder) + server = await createServer({ + root: import.meta.dirname.replace(/\/tests$/, ``), + configFile: `${import.meta.dirname}/../vite.config.ts`, + server: { middlewareMode: true, hmr: false, ws: false }, + optimizeDeps: { noDiscovery: true }, + }) + renderServer = (await server.ssrLoadModule(`/tests/ssr-snapshot-fixture.ts`)) + .renderSsrSnapshotHistory +}, 30_000) +afterAll(async () => { + await server.close() +}) + +const makeReach = () => + createOracleReach(`svelte-actual-handoff`, [ + `cold`, + `query`, + `source-ready`, + `source-loading`, + `rejected`, + `server-write`, + `browser-write`, + `same-query-preload`, + ]) +async function check( + history: SsrSnapshotHistory, + reach: ReturnType, +) { + reach.beginHistory() + const expected = expectedSsrSnapshot(history) + const browserWindow = globalThis.window + let rendered: Awaited> + vi.stubGlobal(`window`, undefined) + try { + rendered = await renderServer(history) + } finally { + vi.stubGlobal(`window`, browserWindow) + } + if (expected.rejects) { + expect(rendered.error, `server rejects unsupported markup`).toMatch( + /SSR.*(?:preload|hydrat)/i, + ) + reach.reach(`rejected`) + return + } + expect(rendered.error).toBeUndefined() + expect(typeof window, `receiving host uses the browser branch`).toBe(`object`) + const client = new DbClient() + let starts = 0, + publish = () => {} + const descriptor = collectionOptions(`svelte-ssr-people`, () => ({ + id: `svelte-ssr-people`, + autoIndex: `eager`, + defaultIndexType: BTreeIndex, + getKey: (row: { id: string; name: string; sourcePayload: string }) => + row.id, + sync: { + sync: ({ begin, write, commit, markReady }) => { + starts++ + begin() + for (const row of history.rows) + write({ + type: `insert`, + value: { + ...row, + name: `browser ${row.id}`, + sourcePayload: `browser`, + }, + }) + commit() + publish = () => { + begin() + write({ + type: `insert`, + value: { + id: `late`, + name: `late browser`, + sourcePayload: `browser`, + }, + }) + commit() + } + markReady() + }, + }, + })) + const initial: Array<{ + rows: Array<{ id: string; name: string }> + status: string + }> = [] + const target = document.createElement(`div`) + target.innerHTML = rendered.html! + document.body.append(target) + let app: ReturnType | undefined + try { + client.hydrate(rendered.state) + if (history.browserWrite) { + if (history.browserPreload) { + await client.preloadLiveQuery(peopleQuery(descriptor)) + reach.reach(`same-query-preload`) + } + await client.collection(descriptor).preload() + publish() + reach.reach(`browser-write`) + } + if (history.serverWrite && history.sourceState !== `idle`) + reach.reach(`server-write`) + let failure: unknown + try { + app = hydrate(SsrDbApp, { + target, + props: { + client, + descriptor, + onInitial: ( + rows: Array<{ id: string; name: string }>, + status: string, + ) => initial.push({ rows, status }), + }, + }) + } catch (error) { + failure = error + } + expect(failure, `real browser initialization remains legal`).toBeUndefined() + expect(initial, `real browser hook matches the render-time model`).toEqual([ + { rows: expected.rows, status: expected.status }, + ]) + reach.reach( + history.preparation === `query` + ? `query` + : history.preparation === `source` && history.sourceState !== `idle` + ? history.sourceState === `ready` + ? `source-ready` + : `source-loading` + : `cold`, + ) + flushSync() + expect(starts, `browser commit starts or reuses one source`).toBe(1) + } finally { + if (app) await unmount(app) + target.remove() + await client.cleanup() + } +} + +describe(`Svelte real SSR handoff`, () => { + for (const history of ssrSnapshotExamples) + it(`replays ${history.preparation}/${history.sourceState}/${history.rows.length} server-write=${!!history.serverWrite} browser-write=${!!history.browserWrite}`, () => + check(history, makeReach())) + for (const campaign of [`fixed`, `random`] as const) + it(`matches the actual browser initialization (${campaign})`, async () => { + const reach = makeReach() + await fc.assert( + fc.asyncProperty(ssrSnapshotHistoryArbitrary, (history) => + check(history, reach), + ), + { + ...(campaign === `fixed` + ? { numRuns: oracleRuns(120), seed: 2229 } + : oraclePropertyOptions(120, `svelte.ssr-handoff`)), + examples: ssrSnapshotExamples.map((history) => [history]), + }, + ) + reach.assertReached() + console.info( + `svelte-actual-handoff ${campaign}`, + Object.fromEntries(reach.counts()), + ) + }, 30_000) +}) From 44ee3f002f4db8b4c6b3987d8c821047762c4a1a Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 23 Oct 2026 15:55:55 -0600 Subject: [PATCH 16/20] fix(db): keep window identity within live-query metadata --- packages/db/src/live-query-options.ts | 9 ++---- ...-observer-snapshot-oracle.property.test.ts | 2 +- packages/db/tests/live-query-options.test.ts | 29 +++++++++++++++++++ 3 files changed, 32 insertions(+), 8 deletions(-) diff --git a/packages/db/src/live-query-options.ts b/packages/db/src/live-query-options.ts index c5c1161fbc..aacf1e6f42 100644 --- a/packages/db/src/live-query-options.ts +++ b/packages/db/src/live-query-options.ts @@ -2,6 +2,7 @@ import { BaseQueryBuilder } from './query/builder/index.js' import { collectSourceRefs, requireCollectionSource } from './query/ir.js' import { isCollection } from './live-query-adapter.js' import { createLiveQueryCollection } from './query/live-query-collection.js' +import { getBuilderFromConfig } from './query/live/collection-registry.js' import { createPooledLiveQuery, getPooledQueryIdentity, @@ -210,13 +211,7 @@ export function getLiveQueryHash( ? preparedValue.query : undefined const ir = isCollection(preparedValue) - ? ( - preparedValue as { - utils: { - getWindow?: () => { limit: number; offset: number } | undefined - } - } - ).utils.getWindow?.() + ? getBuilderFromConfig(preparedValue.config)?.getWindow() : query instanceof BaseQueryBuilder ? query._getQuery() : undefined diff --git a/packages/db/tests/live-query-observer-snapshot-oracle.property.test.ts b/packages/db/tests/live-query-observer-snapshot-oracle.property.test.ts index 97b4c7a8f7..7acb15724d 100644 --- a/packages/db/tests/live-query-observer-snapshot-oracle.property.test.ts +++ b/packages/db/tests/live-query-observer-snapshot-oracle.property.test.ts @@ -214,7 +214,7 @@ async function checkHistory( commands, ) const client = new DbClient() - let clientObserver: ReturnType | undefined + let clientObserver: typeof observer | undefined try { // No write occurs between the final capture and either getter. The model's // revision is unchanged, so both public getter paths retain its identity. diff --git a/packages/db/tests/live-query-options.test.ts b/packages/db/tests/live-query-options.test.ts index b138edf23e..f3c9a75488 100644 --- a/packages/db/tests/live-query-options.test.ts +++ b/packages/db/tests/live-query-options.test.ts @@ -436,3 +436,32 @@ describe(`live query identity`, () => { ) }) }) + +// A source Collection can expose arbitrary adapter utilities. A coincidentally +// named utility is not live-query window metadata and identity must not call it. +it.each([false, true])( + `derives a keyed source identity without invoking utilities (throws=%s)`, + async (throws) => { + let calls = 0 + const source = createCollection({ + id: `keyed-source-utils`, + getKey: (row: { id: string }) => row.id, + sync: { sync: () => {} }, + utils: { + getWindow: () => { + calls++ + if (throws) throw new Error(`adapter utility requires a caller`) + return { limit: 7, offset: 3 } + }, + }, + }) + try { + expect(getLiveQueryHash(source, [`source-key`])).toBe( + getLiveQueryHash(undefined, [`source-key`]), + ) + expect(calls, `identity reads no arbitrary adapter utility`).toBe(0) + } finally { + await source.cleanup() + } + }, +) From a0991825ea326fcdbfadbeb61c2029ff130b1a88 Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 23 Oct 2026 16:01:33 -0600 Subject: [PATCH 17/20] docs: record SSR review laws and verification --- .../oracle-reviews/2026-10-23-ssr-review.md | 401 ++++++++++++++++++ 1 file changed, 401 insertions(+) create mode 100644 docs/contributing/oracle-reviews/2026-10-23-ssr-review.md diff --git a/docs/contributing/oracle-reviews/2026-10-23-ssr-review.md b/docs/contributing/oracle-reviews/2026-10-23-ssr-review.md new file mode 100644 index 0000000000..5caa1839c4 --- /dev/null +++ b/docs/contributing/oracle-reviews/2026-10-23-ssr-review.md @@ -0,0 +1,401 @@ +# Second high review of SSR preparation — 2026-10-23 + +Reviewed production: `f374bdbb12c626a393c920a111ab892e905646f9`, PR #2229. +Repair production and tests: `44ee3f002f4db8b4c6b3987d8c821047762c4a1a`. +This record reviews that repair without changing its production or test blobs. +The raw review contains ten findings: seven correctness, one test-integrity, +one efficiency, and one simplification. The reporting user did not verify them. +No proposed fixes accompanied the findings. This evaluation verified each one. + +## Laws, experiments, and inference + +The server render and the first browser view must agree in projected rows and +observable readiness. The SSR guide supplies that promise. The maintainer chose +programming-error rejection for server reads without reproducible transport. +Browser writes, source-loading rows, and a preload before browser hydration are +valid operations. They cannot become SSR programming errors. Runtime source +errors keep their ordinary error surface. Active-cleanup rejection retains the +separate approved contract. + +Prepared ready data must also let non-streaming Suspense finish. The old model +forced source-prepared queries to report loading. That protected one transport +mechanism but contradicted prepared Suspense completion. Before production edits, +the independent model changed its prediction for a prepared ready source: + +| Case | Old prediction | Joint contract prediction | +| --- | --- | --- | +| Prepared ready source, no query capture | Server and browser loading | Render captures ready query rows/status. Server and browser ready. | +| Prepared loading source | Loading rows | Loading rows, captured at render. A later explicit preload still waits. | +| Normalized rows in a browser without query capture | Loading | Loading. Source rows alone do not prove query readiness. | + +The repaired model does not infer browser Collection readiness from normalized +rows. It requires a query-result capture of the actual server view. React and +Svelte both rejected the previous implementation under this model before any +production repair. This is a repair of the joint contract, not an assertion +rewrite to accept new production output. + +| Law and findings | Previous evidence gap | Decisive experiment | Repair and neighboring guarantee | +| --- | --- | --- | --- | +| Initial-view equality, SR03/SR08/SR09 | Missing server-write/browser-write dimensions and a false Svelte receiving path. | Real React hydration rejected browser activity. Real Svelte hydration returned later source rows. Browser-guard-only mutants still failed both hosts. | Capture query rows/status at render in existing DbClient query records. Preserve first hydration view across a same-query local preload. Commit can publish later live rows. | +| Prepared Suspense and portable identity, SR01/SR02 | Forced-loading model and a warm-result identity bypass. | Source-prepared non-streaming Suspense stayed suspended. Warm opaque unkeyed streaming rendered without transport identity. | Preserve captured readiness. Streaming admission runs even for warm rows. Empty/nonempty, query/source preparation, cold/warm and keyed/unkeyed controls remain. | +| Selected query/window identity, SR06/SR07 | Callback Collection selection and explicit-key windows were absent. | Replacement displayed old rows. Bounded and unbounded hashes aliased. | Recompute selected Collection hash and include limit/offset with explicit keys. Matching builder and supplied Collection windows share identity. | +| Ownership retirement, SR04/SR10 | No retry after a throwing release. Active-resource accounting hid duplicate calls. | Observer retry acquired zero channels. Pooled status release ran twice. | Retire observer attachment before invoking release handles. Remove redundant pooled release. All acquired channels still release and errors propagate. | +| Unchanged getter work, SR05 | Identity tests did not observe traversal or lazy Map work. | Eleven admission walks for eleven unchanged reads. | First capture supplies later reads without another admission walk or keyed Map build. This is a deterministic measured improvement. | + +### Primary owners and checkpoints + +`packages/db/tests/ssr-snapshot-admission-oracle.ts` owns the preparation judgment +and history grammar. It uses authored rows and caller preparation actions. It +imports no production admission or identity classifier to compute expectations. +React's descriptor owner drives ordinary and infinite hooks separately. It uses +actual `renderToString` and `hydrateRoot`, and compares server markup, initial +browser rows/status flags, recoverable errors, and committed browser output. + +The new Svelte handoff driver loads the real fixture with Vite SSR compilation. +Only JSON-serialized HTML and dehydration data cross into browser-compiled +`hydrate()`. The component records its first public hook rows/status before +effects. The driver derives expectations from the same shared judgment. The old +Svelte owner now states its narrower server-reconstruction scope honestly. +A normal warm-browser control remains separate from server admission. + +The shared grammar covers no/source/query/pending-query preparation, idle/loading/ +ready sources, zero to three rows, and writes on both sides of transport. A +same-query browser preload may finish before hydration. Idle sources contain no +rows. Completed query preparation requires ready sources. Pending preparation +requires loading sources. Those exclusions describe legal starting histories. +Ablating either write axis loses its distinct timing violation. Removing the +same-query preload axis loses replacement of the initial loading view. Removing +ready-empty cases loses readiness without row evidence. Named witnesses rebuild +the reported traces. Empty and three-row bounds remain in the random grammar. + +The React stream-cut owner separately varies zero to three rows and zero to three +microtask publications between startup and preload settlement. It compares the +transported result with the view actually rendered, including empty and unchanged +controls. It does not infer this ordering from a delayed return promise. + +The observer release owner varies granular/wholesale mode, Error/undefined +failures and one to three release/retry cycles. Its controlled wrapper retires a +real Collection subscription and then throws synchronously. It compares thrown +identity, zero channels after release, a fresh channel on explicit retry, and a +later public update. Real Collection event listeners report failures +asynchronously and do not supply this premise. No first-party callback-failure +claim receives credit from this controlled witness. + +The pooled owner counts every release invocation, preserving duplicate evidence. +The work probe counts admission calls and lazy keyed-state accesses. These +observations do not establish an elapsed-time budget or universal complexity law. + +## Pre-edit RED and calibration + +All required receiving paths ran on unchanged `f374bdbb1` before initial +production edits. Logs remained under `/tmp/tanstack-ssr-review2` during the task. +The durable results below preserve their useful evidence without depending on +those temporary files remaining available. + +| Experiment | Observed result | +| --- | --- | +| React receiving gate, revised ready law | 11 assertion failures, 13 passes. Source readiness was loading, late source rows changed markup, and browser activity threw the SSR-only error. | +| Actual Svelte handoff gate | Six assertion failures, six passes. First browser hook rows/status disagreed with the render-time model. | +| React Suspense/identity gate | Eight assertion failures, eight passes. Prepared Suspense did not finish, warm opaque identity bypassed admission, callback replacement retained old rows, and bounded/unbounded hashes matched. | +| Corrected observer release gate | Two generated-property failures. Explicit retry acquired zero channels instead of one. | +| Pooled release gate | Two generated-property failures. One status handle received two release invocations. | +| Work and explicit-preload neighbor | One failure, one pass. Eleven unchanged server reads repeated admission eleven times. A separate unchanged-production work probe also recorded twelve lazy state-getter accesses. Explicit preload control passed. | +| Narrow mutant: browser guard without render capture | React: one intended mismatch and one neighbor pass. Svelte: first-browser rows contained a late server row and failed. | +| Browser-only Svelte corruption | Old server-only test passed. Actual browser comparison failed at first hook rows/status. This directly demonstrates SR09's false green. | + +Earlier Svelte setup failures receive no RED credit. An initial observer premise +used a `subscribers:change` callback that reports errors asynchronously. Its +failure to throw was a fixture error, not production evidence. The corrected +controlled-release gate above supplies SR04 evidence. The first narrow-mutant +build also failed setup. Only the corrected build and value-assertion failures +receive calibration credit. All temporary mutants were restored. + +### Sibling defects found during the repair + +These are additional witnesses, not replacements for any original finding. +Each received a failing test before its subsequent production repair. + +| ID | Repair-attempt counterexample | RED and final enforcement | +| --- | --- | --- | +| N01 | A ready streaming component rendered before queued source updates, but its preload later transported the updated rows. | Four failures, one unchanged pass. Stream-cut property captures the rendered view. | +| N02 | A loading capture caused a second preload to replace an established pending stream. | Promise identity assertion failed. Pending records now remain reusable while carrying an initial snapshot. | +| N03 | A provisional result admitted a conflicting same-hash source before acquisition. | Rejection assertion failed. Source claims now check before replacement/startup. | +| N04 | A non-streaming loading capture carried a Promise into JSON transport. | Non-streaming promise-presence assertion failed, streaming neighbor passed. Default non-streaming transport retains its snapshot only. | +| N05 | A browser preload completed before hydration and replaced the original loading view. | React: six failures, 25 passes. Svelte: three failures, ten passes. Initial snapshot survives local preload replacement. | +| N06 | Matching explicit-key builder preload and supplied window disagreed. | Both direct and callback supplied Collection inputs failed. Window metadata now participates across these input forms. | +| N07 | Window inspection invoked an unrelated source utility named getWindow. | Two focused cases failed: an invented window changed identity and a utility threw. Hashing now reads registered live-query builder metadata. The finite invocation boundary has no independent generated-history owner. | +| N08 | The retained-snapshot helper erased its row type through generic ReturnType. | Parent CI and a direct full-package compiler run reported the same four errors. The helper retains its inferred observer type. Both the direct owner check and the final full-package compiler then passed. | + +The callback-returned Collection path in SR06/N06 is supported by current runtime +resolution, but has no TypeScript overload. Tests invoke this JavaScript path with +`Reflect.apply`. This repair adds no new typed public input form. + +## Lossless source-order findings + +Each quoted claim below is the original review text. File/line references name +the reviewed commit. All ten dispositions are `fixed-now`; SR04's confirmed +premise has the controlled-boundary limit stated above. + +### SR01 — correctness, `packages/react-db/src/useLiveQuery.ts:1058` + +> With SSR streaming off, useLiveSuspenseQuery now reads getServerSnapshot. That method forces status `loading` for every result that has no hydration seed, so a query whose sources were explicitly preloaded can never reach ready on the server. + +Confirmed. Forced-loading model confused normalized source readiness with captured query readiness. + +Repair evidence: react-remaining-red.log: prepared non-streaming output suspended; react-final-red.log: source-ready status loading rather than ready. Original implementation rejects the intended readiness comparison. + +Owner and durable value: shared SSR judgment and React descriptor receiving owner. Histories: source/query preparation; 0 or 2 rows; streaming on/off. +Checkpoint: completed server markup before browser work. + +### SR02 — correctness, `packages/react-db/src/useLiveQuery.ts:1054` + +> The streaming-Suspense branch returns observer.getSnapshot() and skips SSR admission even when the query has no queryHash, so warm rows render on the server with nothing that transfers them to the browser. + +Confirmed. Warm Suspense bypassed discovery and the missing-identity branch ran only while loading. + +Repair evidence: react-remaining-red.log: warm unkeyed opaque result rendered without an identity error. Original warm bypass fails; cold and explicit-key neighbors remain legal controls. + +Owner and durable value: React descriptor receiving owner. Histories: warm/cold opaque logic; explicit key present/absent. +Checkpoint: server markup and error callbacks; dehydrated query presence. + +### SR03 — correctness, `packages/db/src/live-query-observer.ts:352` + +> The admission check also runs in the browser, because React calls getServerSnapshot during hydrateRoot. Rows produced by browser-side activity therefore throw the 'SSR cannot render server-only rows' error during hydration. + +Confirmed. Hydration driver cleared window during its browser pass and never received browser-local writes. + +Repair evidence: react-final-red.log: browser activity threw SSR programming error. narrow-mutant-react.log: browser-only guard still fails render-time equality. + +Owner and durable value: shared SSR judgment, React ordinary/infinite and actual Svelte handoff drivers. Histories: browser writes and same-query preload before hydration. +Checkpoint: first public browser rows/status, React recoverable errors, committed browser output. + +### SR04 — correctness, `packages/db/src/live-query-observer.ts:682` + +> Re-attachment is now decided by `!this.attached` instead of `subscriptions.size === 1`. detach() resets `attached` only after collectionUnsub() returns, and that call now rethrows via runAllCallbacks. A throwing release therefore leaves the observer marked attached but with no channels. + +Confirmed. Final channel counts missed a failed release followed by fresh explicit ownership. + +Repair evidence: observer-release-red.log: retry subscriber count 0 instead of 1. Original detach ordering fails retry assertion. Controlled synchronous release only; first-party event callbacks report asynchronously. + +Owner and durable value: observer history owner. Histories: granular/wholesale; Error/undefined; 1–3 release/retry cycles. +Checkpoint: release error identity, zero channels after release, fresh channel and later publication. + +### SR05 — efficiency, `packages/db/src/live-query-observer.ts:350` + +> getServerSnapshot repeats the full source walk (getSourceInfo plus recursive _hasSsrCollectionSnapshot with builder lookups) on every call, and calls `snapshot.state?.size`, which forces the lazy Map build just to test whether there are any rows. + +Confirmed. Snapshot identity checks did not count admission walks or lazy Map getter accesses. + +Repair evidence: work-and-preload-red.log: 11 admission walks where one suffices. Original implementation fails work counter; capture removes repeated admission and lazy Map materialization. + +Owner and durable value: observer snapshot owner. Histories: prepared unchanged revision; repeated server getters; explicit preload neighbor. +Checkpoint: deterministic admission and lazy state-access counters. + +### SR06 — correctness, `packages/react-db/src/useLiveInfiniteQuery.ts:357` + +> When the query function returns a Collection (input.kind === 'collection'), queryHash keeps the stale committed?.queryHash from the previous query and is never recomputed, because inputIsCollection is false on that path. + +Confirmed. Callback-returned Collection input was absent from replacement coverage. + +Repair evidence: react-remaining-red.log: replacement showed old rows instead of new. Original stale-hash branch fails replacement assertion. Callback-returned Collections are JavaScript runtime coverage, not a new TypeScript overload. + +Owner and durable value: React descriptor receiving owner. Histories: builder to callback-returned Collection; derived/explicit identity. +Checkpoint: replacement rendered rows and current query hash. + +### SR07 — correctness, `packages/react-db/src/useLiveInfiniteQuery.ts:377` + +> With a queryKey, the windowed query's transport hash is just hash(queryKey) and ignores the limit/offset window. An infinite query therefore shares a hydration record with any non-windowed preload that uses the same key. + +Confirmed. Explicit keys erased limit/offset; supplied Collection windows were absent from input-form coverage. + +Repair evidence: react-remaining-red.log: bounded/unbounded hashes equal; supplied-window-red.log: matching supplied forms rejected. Unbounded alias and matching-window rejection provide opposite boundary controls. + +Owner and durable value: React descriptor receiving owner. Histories: matching window versus unbounded preload; builder/direct Collection/callback input. +Checkpoint: distinct or matching transport hash and server window rows. + +### SR08 — correctness, `packages/db/src/client.ts:707` + +> _hasSsrCollectionSnapshot admits a normalized source that is still loading. Rows it receives between server render and dehydrate() change the transported rows, so the first browser snapshot no longer matches the markup. + +Confirmed. Transport sampled mutable source rows at dehydrate instead of retaining rendered query view. + +Repair evidence: react-final-red.log and svelte-final-red.log: late source rows changed initial browser output. narrow-mutant-react.log and narrow-mutant-svelte.log: guard-only repair admits late rows and fails. + +Owner and durable value: shared SSR judgment; React ordinary/infinite and actual Svelte handoff. Histories: loading/ready source; server writes after render; browser writes before hydration. +Checkpoint: rendered rows/status versus first browser hook result. + +### SR09 — test-coverage, `packages/svelte-db/tests/ssr-snapshot-admission-oracle.svelte.test.ts:106` + +> False green: the 'initial browser' render uses svelte/server render() under @vitest-environment node. typeof window is undefined there, so it goes through getServerSnapshot, and the real browser branch (getSnapshot, useLiveQuery.svelte.ts:455) is never compared with the server markup. + +Confirmed. Node server render was incorrectly credited as an initial browser witness. + +Repair evidence: svelte-browser-mutant.log: old server-only test green while real browser comparison red. Browser-only corruption survives old test and fails new receiving assertion. + +Owner and durable value: shared SSR judgment and actual Svelte handoff driver. Histories: real SSR compilation followed by browser compilation/hydrate; same timing grammar. +Checkpoint: first public hook values before effects. + +### SR10 — simplification, `packages/db/src/query/pooled-live-query.ts:160` + +> The new early `this.stopStatusEvents = stopStatus` overlaps the terminated branch below it. In the re-entrant cleanup case, terminate() already calls stopStatus via release(), and then `if (this.terminated) { stopStatus(); ... }` calls it again. + +Confirmed. Resource balance tracked active handles but normalized duplicate release calls away. + +Repair evidence: ownership-red.log: two pooled status releases for one handle. Original redundant call fails invocation counter. The observer failures in this initial log used an invalid event-listener premise and receive no credit. + +Owner and durable value: pooled live-query GC owner. Histories: synchronous cleanup registration; callback return/throw and undefined cause. +Checkpoint: every status release invocation, not only remaining resources. + +## Final GREEN and declared reach + +The final React package run passed 517 tests in 23 files. The Svelte package run +passed 163 tests in 11 files. Both reported no type errors. The final scoped core run +passed 346 tests in ten files with no type errors. It selected DbClient source +identity/hydration, observer history/snapshots/API, pooled GC, and window control. +This includes the error-message owner. Manifest/reporting checks have separate +results below. Earlier assertion-only framework runs reported type-check errors in test +drivers. Those runs receive no clean-GREEN credit. The corrected runs do. + +The full DB test compiler also passed on `44ee3f002`: +`pnpm exec tsc -p packages/db/tsconfig.test.json --noEmit --incremental false`. +It exited zero with no diagnostics. + +Normal campaigns use seed 2229 and an unsupplied seed with identical budgets. +Counts below are histories reaching each declared state, not total events. + +| Path, fixed/random budget | State counts, fixed / random | +| --- | --- | +| React ordinary, 120/120 | cold-loading 19/25; unbacked-ready-empty 5/6; unbacked-rows 43/34; query-result 18/15; normalized-ready 12/18; normalized-partial 23/22; pending-query 19/17; server-write 29/30; browser-write 27/34; same-query-preload 14/17 | +| React infinite, 120/120 | cold-loading 19/26; unbacked-ready-empty 5/3; unbacked-rows 43/36; query-result 18/18; normalized-ready 12/19; normalized-partial 23/18; pending-query 19/20; server-write 29/30; browser-write 27/41; same-query-preload 14/19 | +| Actual Svelte handoff, 120/120 | cold 19/15; query 18/16; source-ready 12/26; source-loading 23/16; rejected 48/47; server-write 29/34; browser-write 27/36; same-query-preload 14/13 | +| React stream cut, 40/40 | empty 11/14; nonempty 29/26; later-publication 31/29; unchanged 9/11 | +| Observer release, 40/40 | granular 22/22; wholesale 18/18; release-error 40/40; retry 40/40; undefined-cause 18/21 | +| Pooled late cleanup, 40/40 | return 22/18; throw 18/22; undefined-cause 18/15 | + +Each owner enforces declared minima. Fixed examples add replayable witnesses but +do not replace these campaigns. Six guarded final replays use seed 2229 and captured failure paths. Each +reported one completed execution with nonzero runs and `failed:false`: + +| Property | Package | Path | Runs | +| --- | --- | --- | --- | +| react.ssr-ordinary | react-db | 4 | 120 | +| react.ssr-infinite | react-db | 4 | 120 | +| svelte.ssr-handoff | svelte-db | 4 | 120 | +| react.ssr-stream-cut | react-db | 0:0:1 | 1 | +| live-query-observer.release-failure | db | 0:0 | 1 | +| pooled-live-query.late-cleanup | db | 0:0 | 1 | + +For example, from `packages/react-db`: + +```sh +TANSTACK_DB_ORACLE_SEED=2229 \ +TANSTACK_DB_ORACLE_PATH=4 \ +TANSTACK_DB_ORACLE_PROPERTY=react.ssr-ordinary \ +pnpm exec node --import tsx ../db/tests/oracle-replay.ts \ + tests/descriptor-query-binding-oracle.test.tsx \ + --coverage.enabled=false --maxWorkers=2 +``` + +The manifest selects the named random lane and excludes fixed campaigns. +Direct `tsx` launch failed at a sandbox IPC socket before test execution. +`node --import tsx` ran the intended properties successfully. A broad core plus +replay-protocol run stopped reporting progress and was terminated. It receives +no GREEN credit. The separate scoped core run, six direct guarded replays and +six manifest/reporting tests passed. The error-message owner passed 19 tests. No replay-protocol implementation changed. + +The production build, 419-name mangle check, scoped ESLint, formatting, +error-doc generation and whitespace checks passed. ESLint retained one existing +require-await warning in the pooled GC file, with zero errors. The minified public consumer +check passed 107 error names, index metadata, query rows and live updates. +Production error checks erased 248 literals, including 20 shared literals, in +ESM and CommonJS while retaining development text. + +## Shipped weight and alternatives + +Compared with merged main `65d9ada508114f199464f753d50eb3447eb0eaf9`, +`pnpm size:compare --packages db,react-db --files` measured: + +| Output | Gzip change | Brotli change | +| --- | ---: | ---: | +| db package | +660 bytes | +646 bytes | +| react-db package | +107 bytes | +119 bytes | +| Typical app | +396 bytes | +410 bytes | +| Other app scenarios | +363 to +392 bytes | +250 to +476 bytes | + +These are whole-branch changes, not only this follow-up. Production source in +this follow-up grows by 70 net lines. Tests and fixtures grow by 1,326 net lines before this review record. +Documentation and metadata grow separately and do not enter shipped-byte figures. + +The repair removed the separate observer WeakMap and forced-loading snapshot +copies. It reused DbClient query-result records instead of adding a second +snapshot cache or lifecycle. A browser-only admission guard was tried as a narrow +mutant. It fixed browser rejection but still failed late-server-write equality +in both hosts. Counting a loading capture as a completed preload failed the +explicit-demand law. Replacing a pending record failed stream-promise identity. +Omitting the retained initial snapshot failed same-query preloads in both hosts. +Omitting window metadata from supplied Collection hashing failed matching +preload/hook identity. Reading arbitrary utils was also rejected: registered +builder metadata is both narrower and five production lines smaller. These experiments justify the remaining transport fields +and branches. No retries, generations, or separate recovery state machine were +added for a programming error. + +## Oracle conformance self-review + +This is a self-review. No independent agent or new external review ran. + +| Requirement | Outcome | +| --- | --- | +| ORC-001 authority/limits | SSR guide, prepared Suspense API, ownership contracts and maintainer policy supply authority. Finite limits appear beside owners and below. | +| ORC-002 independence | Shared judgment uses authored rows and preparation actions. Ownership uses token/channel counts. Production classifiers do not compute expected values. | +| ORC-003 literate responsibilities | Shared owner states the law, grammar and judgment. Directly named host owners explain drivers, recordings and checkpoints. | +| ORC-004 grammar controls | Named reconstructions, axis ablations, zero/three row bounds and idle/pending exclusions appear above. Window identity has matching/unbounded and selected-input controls. | +| ORC-005 path/observations | Actual React hydrateRoot and browser-compiled Svelte hydrate receive serialized state. First hook values, status flags and errors are observed before commit repair. Render captures provide information the browser otherwise lacks. | +| ORC-006 calibration | Original assertion failures, browser-guard-only mutants and browser-only Svelte corruption reach their intended comparisons. Setup failures receive no credit. | +| ORC-007 campaigns/replay | Equal fixed/random budgets and six completed guarded replays, including exact seed/path witnesses. No command-model replayPath is applicable. | +| ORC-008 model minimality | Preparation and source state remain distinct: ready-empty and source-loading predict different status. Later server/browser writes cannot change the modeled initial view. No new reference lifecycle state machine was added. | +| ORC-009 vocabulary | Model sourceState combines startup/readiness for the stated synchronous eager grammar. Preparation describes caller actions. Canonical Collection, cleanup, readiness and publication terms retain their glossary meaning. | +| ORC-010 failure fidelity | Observer driver retains thrown undefined, primary assertion and secondary cleanup diagnostics through withOracleCleanup. Host drivers release clients, mounts and compiler resources. The intended assertion failures survive shrinking. | +| ORC-011 second formulation | The named shared-fault concern was an incorrect readiness policy and a fake browser premise. Contract reconciliation and actual receiving drivers distinguish them. A second structurally similar model would not add independent evidence. No other concrete shared semantic fault was identified. | +| ORC-012 evidence/closure | This versioned record identifies reviewed and repaired commits, lossless findings, calibration, reach and finite limits. It does not claim universal proof. | +| ORC-013 nearby boundaries | Ready-empty versus cold-empty; prepared versus incidental rows; keyed versus opaque-unkeyed; matching versus unbounded windows; empty versus nonempty stream updates; and successful retry after throwing release distinguish plausible wrong rules. | +| ORC-014 handoff | React and Svelte real host APIs receive the shared law at the named cuts. jsdom supplies browser globals and DOM. This is no claim about a deployed Start/Electric host. SR04 remains explicitly controlled. | +| ORC-015 reach | Each generated path has minimum reach assertions. The table above reports actual normal-run counts separately for both hosts and both React hooks. | + +## Reviewer assessment, loss audit and readiness + +**Hire.** All ten claims supplied useful, reproducible evidence. The reviewer +connected hydration timing, Suspense readiness, identity and lifecycle ownership +instead of relying only on local code style. Prioritization was sound. The work +finding distinguishes unnecessary traversal from latency. The simplification +exposed duplicate release evidence that resource-balance checks had erased. +SSR row/readiness mismatches have high user impact. Callback replacement and +window identity have medium impact. Work and duplicate idempotent release are +low severity. SR04 needed a narrower synchronous-release premise than a real event-listener +failure. The review supplied counterexamples, not a complete transport design. +The repair still needed neighboring-law experiments to avoid new stream and +preload defects. + +Loss accounting: **10 raw = 10 fixed-now**. No original item is merged away, +refuted, deferred or left as an accepted-design exception. The additional +repair-attempt witnesses also pass. Accounting and the authorized repair for +this review are complete within the boundaries above. + +Known limits remain in the [coverage map](../oracle-coverage.md): synchronous eager +sources with zero to three authored rows, initial infinite windows, and the +named microtask schedules. Arbitrary asynchronous providers, persistence +readiness, later page-shape changes during hydration streams, mixed ordinary/ +Suspense streaming schedules, custom filters, and deployed Start/Electric hosts +remain unexercised paths. No known reachable counterexample remains in this +review's repaired scope. Finite green histories are not a proof for all histories. + +Parent CI had two failing unit shards from N08 and a canceled browser E2E job. +The aggregate test jobs reflected those results. Local assertion counts did not +erase those failures. N08 now has direct owner and full-package compiler evidence. + +Whole-PR readiness remains **unproven** until final pushed-head CI and the earlier +evidence gaps receive their own decisive evidence. The prior migration audit +retains H04 (legal recoverable pooled subscription throw), H08 (permanently +stranded demand after notification failure), and F07 (Date assignment and exact +dependency versions). This review neither closes those reports nor invents a +recovery state machine for them. CodeRabbit remains paused. No external review +request was sent. From 10e0e6fed7f127d515144e1a14c4e69805425cc2 Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 23 Oct 2026 16:55:25 -0600 Subject: [PATCH 18/20] fix(db): preserve mixed streaming hydration views --- .../resume-independent-subscription-demand.md | 5 + docs/contributing/oracle-coverage.md | 15 +- docs/guides/ssr.md | 8 + packages/db/mangle-cache.json | 4 +- packages/db/src/client.ts | 28 ++- packages/db/src/live-query-observer.ts | 39 +++- ...ad-subset-replay-refinement-oracle.test.ts | 5 +- packages/react-db/src/useLiveQuery.ts | 1 + .../descriptor-query-binding-oracle.test.tsx | 207 +++++++++++++++++- packages/react-router-with-db/src/index.tsx | 7 +- .../react-router-with-db/tests/index.test.ts | 43 ++++ 11 files changed, 331 insertions(+), 31 deletions(-) diff --git a/.changeset/resume-independent-subscription-demand.md b/.changeset/resume-independent-subscription-demand.md index cbe6144fbe..8497dc9416 100644 --- a/.changeset/resume-independent-subscription-demand.md +++ b/.changeset/resume-independent-subscription-demand.md @@ -2,6 +2,7 @@ '@tanstack/db': patch '@tanstack/react-db': patch '@tanstack/svelte-db': patch +'@tanstack/react-router-with-db': patch --- Resume Collection sync when a new subscriber follows a suspended render, while preserving cleanup during startup. @@ -17,3 +18,7 @@ Keep ordinary hook errors readable when deferred sync startup records an error. Reject SSR reads of incidental server-only rows or readiness with an actionable preparation error. Preserve cold loading and normalized source hydration, consume prepared infinite-query snapshots, and register warm Suspense results for streaming. Capture prepared SSR query rows and readiness at render time. Keep loading captures separate from completed preloads, preserve pending streams, and allow browser writes before hydration. Support prepared non-streaming Suspense, require portable opaque query identity, and include limit and offset in explicit-key result identity. + +Keep an ordinary reader's initial loading view when a Suspense sibling completes the same streaming query. Forward replacement query records through the Router stream without discarding the earlier hydration view. + +Preserve server snapshots for later-hydrating boundaries after a sibling commits. Wait for missing streamed results instead of rendering local rows during hydration. diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index ab0dea568d..443445c250 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -641,7 +641,18 @@ Collection and callback-returned Collection inputs. The latter callback path is JavaScript runtime coverage; it has no TypeScript overload. A separate React streaming property crosses zero to three rows and zero to three microtask source publications before preload settlement. It compares transported rows with the -view actually rendered, including empty and unchanged controls. The +view actually rendered, including empty and unchanged controls. Its mixed-reader +histories place an ordinary hook before or after a Suspense sibling, or omit it. +Real React hydration receives JSON-copied snapshots and streamed promises from +the real Router adapter under its controlled SSR callback. It compares each +hook's first browser view with its own server view, even when local browser rows +differ. Mandatory reach distinguishes both reader orders, Suspense alone, and +same/different browser rows. It crosses async hydration with a synchronous +React act cut that permits ordinary commit before Suspense hydration, while +transported promises can still be pending. Both schedules must preserve each +reader's first projected rows and readiness. The Router's focused receiving test separately +checks that a replacement record reaches the stream after an initial loading +record. The [SSR review audit](oracle-reviews/2026-10-23-ssr-review.md) records RED on unchanged production, GREEN, narrow mutants, and exact receiving replays. The separate `live-query-observer-snapshot-oracle.property.test.ts` owner checks @@ -657,7 +668,7 @@ release; first-party event listeners report their failures asynchronously. Limits: the generated sources are synchronous and eager with zero to three rows. Infinite receiving coverage uses a one-row initial page and its peek-ahead row. Arbitrary asynchronous providers, later page-shape changes during a pending -hydration stream, persistence readiness, mixed Suspense/plain streaming schedules, +hydration stream, persistence readiness, arbitrary concurrent-root hydration schedules, custom dehydration filters, and the real Start/Electric host remain open paths for these same receiving owners. There is no demonstrated counterexample in these unexercised paths. Transport deliberately discarding required state and diff --git a/docs/guides/ssr.md b/docs/guides/ssr.md index f8269b5d75..4555b1fcb2 100644 --- a/docs/guides/ssr.md +++ b/docs/guides/ssr.md @@ -330,6 +330,14 @@ browser writes before hydration do not change that initial view. Browser commit starts acquisition and publishes the live status normally. A loading capture does not complete an explicit data preload. +An ordinary hook can render loading before a Suspense sibling completes the +same query. Each hook retains its own server-rendered view during hydration. +The Router stream forwards a newer result for that query without replacing the +ordinary hook's earlier view. A later boundary waits for its missing streamed +result instead of substituting browser rows. DbClient retains transported server +snapshots until cleanup, so one reader's commit cannot consume another +boundary's initial view. New ordinary browser reads still use live results. + Streaming `useLiveSuspenseQuery` discovers and prepares its result during render, including when another query already warmed the source. Ordinary hooks require explicit preparation to render that data. Opaque SSR queries require a serializable diff --git a/packages/db/mangle-cache.json b/packages/db/mangle-cache.json index 1c5acd85af..682c17e561 100644 --- a/packages/db/mangle-cache.json +++ b/packages/db/mangle-cache.json @@ -417,5 +417,7 @@ "windowRequestGeneration": "av", "latestWindowRequest": "gE", "startSyncForSubscriber": "gT", - "snapshotFrom": "gV" + "snapshotFrom": "gV", + "usesSettledHydration": "gU", + "hydratedLiveQueries": "gW" } diff --git a/packages/db/src/client.ts b/packages/db/src/client.ts index cdd5a312db..2bdf579564 100644 --- a/packages/db/src/client.ts +++ b/packages/db/src/client.ts @@ -138,6 +138,8 @@ export type DehydratedCollectionChunk< export type DehydratedLiveQuery = { queryHash: string dehydratedAt: number + /** Earlier ordinary markup may precede a shared Suspense result. */ + initialSnapshot?: DehydratedLiveQueryResult snapshot?: DehydratedLiveQueryResult promise?: Promise } @@ -344,6 +346,9 @@ export class DbClient { private collectionsById = new Map() private pendingHydration = new Map>() private liveQueries = new Map() + // A committed reader cannot consume a later boundary's server snapshot. + // Retain transported records for server getters until this client cleans up. + private hydratedLiveQueries = new Map() private preloadedLiveQueries = new Map< string, { @@ -664,6 +669,10 @@ export class DbClient { { queryHash: query.queryHash, dehydratedAt: query.dehydratedAt, + ...(query.initialSnapshot && + query.initialSnapshot !== query.snapshot + ? { initialSnapshot: query.initialSnapshot } + : {}), ...(query.snapshot ? { snapshot: query.snapshot } : {}), ...(query.status === `pending` && (!query.snapshot || this.ssrStreamingEnabled || decision === true) @@ -743,8 +752,14 @@ export class DbClient { } /** @internal */ - _getLiveQuery(queryHash: string): DbClientLiveQuery | undefined { - return this.liveQueries.get(queryHash) + _getLiveQuery( + queryHash: string, + forServerSnapshot = false, + ): DbClientLiveQuery | undefined { + return ( + (forServerSnapshot && this.hydratedLiveQueries.get(queryHash)) || + this.liveQueries.get(queryHash) + ) } /** @internal Reject a local stream whose hash already names different sources. */ @@ -786,6 +801,7 @@ export class DbClient { const existing = this.liveQueries.get(queryHash) if (existing) { if (sources) this._assertLiveQuerySources(queryHash, sources) + if (snapshot.status === `loading`) existing.initialSnapshot ??= snapshot existing.snapshot ??= snapshot return } @@ -794,6 +810,7 @@ export class DbClient { this.nextLiveQueryTimestamp(), ) record.sources = sources + if (snapshot.status === `loading`) record.initialSnapshot = snapshot this.liveQueries.set(queryHash, record) this.emit({ type: `liveQueryAdded`, query: record }) record.succeed(snapshot) @@ -893,6 +910,7 @@ export class DbClient { this.collectionsById.clear() this.pendingHydration.clear() this.liveQueries.clear() + this.hydratedLiveQueries.clear() this.preloadedLiveQueries.clear() this.liveQueryResources.clear() this.listeners.clear() @@ -921,9 +939,9 @@ export class DbClient { if (existing?.status === `pending`) { void record.resultPromise.then(existing.succeed, existing.fail) } - record.initialSnapshot = dehydratedQuery.promise - ? undefined - : dehydratedQuery.snapshot + record.initialSnapshot = + dehydratedQuery.initialSnapshot ?? dehydratedQuery.snapshot + this.hydratedLiveQueries.set(record.queryHash, record) if (dehydratedQuery.promise) record.snapshot = dehydratedQuery.snapshot this.emit({ type: `liveQueryAdded`, query: record }) diff --git a/packages/db/src/live-query-observer.ts b/packages/db/src/live-query-observer.ts index 27b754762c..7287886938 100644 --- a/packages/db/src/live-query-observer.ts +++ b/packages/db/src/live-query-observer.ts @@ -200,6 +200,7 @@ class LiveQueryObserverImpl< private readonly persistedSources: ReadonlyArray | undefined private readonly onPreload: (() => void) | undefined + private readonly usesSettledHydration: boolean private visibleStatus: CollectionStatus | undefined private cachedEntries: Array<[TKey, T]> | undefined private cachedCollectionRevision: number | undefined @@ -246,6 +247,7 @@ class LiveQueryObserverImpl< client: DbClient | undefined, queryHash: string | undefined, onPreload: (() => void) | undefined, + suspendUntilReady: boolean, ) { this.collection = collection this.wholesale = wholesale @@ -255,14 +257,15 @@ class LiveQueryObserverImpl< ? collectPersistedReadinessSources(collection) : undefined this.onPreload = onPreload + this.usesSettledHydration = suspendUntilReady this.registerClientResource() } - getSnapshot(): LiveQuerySnapshot { + getSnapshot(forServerSnapshot = false): LiveQuerySnapshot { const collection = this.collection if (!collection) return DISABLED_SNAPSHOT - this.syncHydrationState() + this.syncHydrationState(forServerSnapshot) if (!this.attached) this.refreshDetachedState(collection) const local = this.readPersistedReadiness() @@ -334,7 +337,25 @@ class LiveQueryObserverImpl< } getServerSnapshot(preloadRequested = false): LiveQuerySnapshot { - const snapshot = this.getSnapshot() + if ( + typeof window !== `undefined` && + !this.liveResultIsAuthoritative && + this.client && + this.queryHash + ) { + const query = this.client._getLiveQuery(this.queryHash, true) + const snapshot = this.usesSettledHydration + ? query?.snapshot + : (query?.initialSnapshot ?? query?.snapshot) + // A later boundary may hydrate before its result promise arrives. + // Local rows cannot substitute for the missing server-rendered view. + if (query?.status === `pending` && !snapshot) { + if (this.usesSettledHydration) + this.preloadForInitialRender().catch(() => {}) + throw query.promise + } + } + const snapshot = this.getSnapshot(true) const collection = this.collection if ( !this.client || @@ -381,7 +402,7 @@ class LiveQueryObserverImpl< }, sources, ) - return this.getSnapshot() + return this.getSnapshot(true) } getError(): unknown { @@ -441,12 +462,12 @@ class LiveQueryObserverImpl< return this.cachedEntries ?? this.captureEntries(collection).entries } - private syncHydrationState(): boolean { + private syncHydrationState(forServerSnapshot = false): boolean { if (!this.client || !this.queryHash || this.liveResultIsAuthoritative) { return false } - const query = this.client._getLiveQuery(this.queryHash) + const query = this.client._getLiveQuery(this.queryHash, forServerSnapshot) if (!query) return false if ( @@ -467,7 +488,8 @@ class LiveQueryObserverImpl< return changed } - const snapshot = query.initialSnapshot ?? query.snapshot + const snapshot = + (!this.usesSettledHydration && query.initialSnapshot) || query.snapshot if ( !snapshot || (this.hydrationSeed && this.hydrationSeed.snapshot === snapshot) @@ -1177,6 +1199,8 @@ export interface CreateLiveQueryObserverOptions { queryHash?: string /** Resume framework-deferred query sources before a server preload. */ onPreload?: () => void + /** Read the completed stream rather than an earlier ordinary loading view. */ + suspendUntilReady?: boolean } /** @@ -1201,5 +1225,6 @@ export function createLiveQueryObserver< options.client, options.queryHash, options.onPreload, + options.suspendUntilReady === true, ) } diff --git a/packages/db/tests/query/load-subset-replay-refinement-oracle.test.ts b/packages/db/tests/query/load-subset-replay-refinement-oracle.test.ts index 950eb486fa..1265fb674a 100644 --- a/packages/db/tests/query/load-subset-replay-refinement-oracle.test.ts +++ b/packages/db/tests/query/load-subset-replay-refinement-oracle.test.ts @@ -2375,10 +2375,7 @@ describe(`subscription-specific replay demand`, () => { }), campaign === `fixed` ? { seed: 2233, numRuns: oracleRuns(75) } - : { - ...oraclePropertyOptions(75, replayDemandProperty), - ...(campaign === `replay` ? { numRuns: 1 } : {}), - }, + : oraclePropertyOptions(75, replayDemandProperty), ) console.info( replayDemandProperty, diff --git a/packages/react-db/src/useLiveQuery.ts b/packages/react-db/src/useLiveQuery.ts index c48a966744..55646d5a5f 100644 --- a/packages/react-db/src/useLiveQuery.ts +++ b/packages/react-db/src/useLiveQuery.ts @@ -1020,6 +1020,7 @@ function useLiveQueryImpl( client: dbClient, queryHash: instance.queryHash, onPreload: resumeDeferredCollections, + suspendUntilReady: forSuspense, }) } const observer = instance.observer! diff --git a/packages/react-db/tests/descriptor-query-binding-oracle.test.tsx b/packages/react-db/tests/descriptor-query-binding-oracle.test.tsx index 244505ec1d..60926037d8 100644 --- a/packages/react-db/tests/descriptor-query-binding-oracle.test.tsx +++ b/packages/react-db/tests/descriptor-query-binding-oracle.test.tsx @@ -111,6 +111,7 @@ import { fc } from '@fast-check/vitest' import { Component, Suspense } from 'react' import { renderToPipeableStream, renderToString } from 'react-dom/server' import { hydrateRoot } from 'react-dom/client' +import { routerWithDbClient } from '../../react-router-with-db/src' import { expectedSsrSnapshot, ssrSnapshotExamples, @@ -130,6 +131,7 @@ import { modelSubscriberStartCleanup, subscriberStartCleanupHistories, } from '../../db/tests/collection-subscription-lifecycle-grammar-oracle' +import type { DehydratedRouterDbState } from '../../react-router-with-db/src' import type { SsrSnapshotHistory } from '../../db/tests/ssr-snapshot-admission-oracle' import type { Context, @@ -2587,8 +2589,20 @@ describe(`infinite hydration identity`, () => { // Streaming must capture the view it actually renders, even when the adapter // publishes again in the microtask before its preload promise captures a result. // This metamorphic observation compares the final host markup with transport. +// An ordinary sibling may already have rendered loading before Suspense starts +// the same query. Each reader owes its own rendered view during hydration; +// preserving the earlier view cannot prevent the explicit preload completing. +// Synchronous act permits the ordinary sibling's effects before React hydrates +// its Suspense boundary. That sibling must not consume another reader's seed. async function checkStreamCut( - history: { count: number; writes: number }, + history: { + count: number + writes: number + ordinaryFirst?: boolean + ordinaryLast?: boolean + browserWrite?: boolean + splitHydration?: boolean + }, reach: ReturnType, ) { reach.beginHistory() @@ -2596,20 +2610,25 @@ async function checkStreamCut( id: String(i), name: `first ${i}`, })) - const descriptor = collectionOptions(`stream-render-cut`, () => ({ + const client = new DbClient({ runtime: `server` }) + const descriptor = collectionOptions(`stream-render-cut`, (owner) => ({ id: `stream-render-cut`, getKey: (row: { id: string; name: string }) => row.id, sync: { sync: ({ begin, write, commit, markReady }) => { begin() - for (const row of authoredRows) write({ type: `insert`, value: row }) + const rows = + owner !== client && history.browserWrite + ? [{ id: `0`, name: `browser only` }] + : authoredRows + for (const row of rows) write({ type: `insert`, value: row }) commit() markReady() queueMicrotask(() => { for (let i = 0; i < history.writes; i++) { begin() write({ - type: history.count || i ? `update` : `insert`, + type: rows.length || i ? `update` : `insert`, value: { id: `0`, name: `later ${i}` }, }) commit() @@ -2618,15 +2637,54 @@ async function checkStreamCut( }, }, })) - const client = new DbClient() client._setSsrStreamingEnabled(true) + let finishRouterRender = () => {} + const router: { + isServer: boolean + options: { + context: { dbClient: DbClient } + dehydrate?: () => Promise + } + serverSsr: { + isDehydrated: () => boolean + onRenderFinished: (callback: () => void) => void + } + } = { + isServer: true, + options: { context: { dbClient: client } }, + serverSsr: { + isDehydrated: () => true, + onRenderFinished: (callback) => { + finishRouterRender = callback + }, + }, + } + // The real Router adapter supplies the transport for the same generated law. + // Its SSR callback is controlled; this is not a deployed Start host witness. + Reflect.apply(routerWithDbClient, undefined, [router, client]) + const initialRouterState = await router.options.dehydrate!() const query = new Query() .from({ row: descriptor }) .select(({ row }) => ({ name: row.name })) + const ordinaryReads: Array<{ status: string; names: Array }> = [] + const suspenseReads: Array> = [] function Consumer() { const result = useLiveSuspenseQuery({ query }) + suspenseReads.push(result.data.map((row) => row.name)) return {result.data.map((row) => row.name).join(`,`)} } + function OrdinaryConsumer() { + const result = useLiveQuery({ query }) + ordinaryReads.push({ + status: result.status, + names: result.data.map((row) => row.name), + }) + return ( + + {result.data.map((row) => row.name).join(`,`)} + + ) + } const browserWindow = globalThis.window vi.stubGlobal(`window`, undefined) try { @@ -2643,9 +2701,11 @@ async function checkStreamCut( sink.on(`error`, reject) const stream = renderToPipeableStream( + {history.ordinaryFirst && } loading}> + {history.ordinaryLast && } , { onAllReady() { @@ -2670,10 +2730,102 @@ async function checkStreamCut( snapshot?.rows.map((row) => (row.value as { name: string }).name), `stream transport reproduces its rendered view`, ).toEqual(authoredRows.map((row) => row.name)) + if (history.ordinaryFirst || history.ordinaryLast) { + const firstServerRead = ordinaryReads[0] + ordinaryReads.length = 0 + const firstSuspenseRead = suspenseReads[0] + suspenseReads.length = 0 + vi.stubGlobal(`window`, browserWindow) + const browser = new DbClient({ runtime: `browser` }) + browser.hydrate(initialRouterState.dehydratedDbClient) + const reader = initialRouterState.dbStream.getReader() + finishRouterRender() + try { + for (;;) { + const chunk = await reader.read() + if (chunk.done) break + browser.hydrate({ + ...chunk.value, + liveQueries: chunk.value.liveQueries?.map( + ({ promise, ...data }) => ({ + ...JSON.parse(JSON.stringify(data)), + ...(promise + ? { + promise: promise.then((value) => + JSON.parse(JSON.stringify(value)), + ), + } + : {}), + }), + ), + }) + } + } finally { + reader.releaseLock() + } + const host = document.createElement(`div`) + host.innerHTML = html + let root: Root | undefined + const hydrationErrors: Array = [] + try { + const hydrate = () => { + root = hydrateRoot( + host, + + {history.ordinaryFirst && } + loading}> + + + {history.ordinaryLast && } + , + { onRecoverableError: (error) => hydrationErrors.push(error) }, + ) + } + await act( + history.splitHydration + ? hydrate + : async () => { + hydrate() + await Promise.resolve() + }, + ) + expect(ordinaryReads[0], `ordinary first hydration view`).toEqual( + firstServerRead, + ) + expect(suspenseReads[0], `Suspense first hydration view`).toEqual( + firstSuspenseRead, + ) + expect(hydrationErrors).toEqual([]) + } finally { + await act(async () => { + root?.unmount() + await Promise.resolve() + }) + await browser.cleanup() + } + } reach.reach(history.count ? `nonempty` : `empty`) reach.reach(history.writes ? `later-publication` : `unchanged`) + reach.reach( + history.ordinaryFirst + ? `ordinary-first` + : history.ordinaryLast + ? `suspense-first` + : `suspense-only`, + ) + reach.reach( + (history.ordinaryFirst || history.ordinaryLast) && history.splitHydration + ? `split-hydration` + : `together-hydration`, + ) + reach.reach( + (history.ordinaryFirst || history.ordinaryLast) && history.browserWrite + ? `browser-different` + : `browser-same`, + ) } finally { vi.stubGlobal(`window`, browserWindow) + finishRouterRender() await client.cleanup() } } @@ -2683,8 +2835,36 @@ const createStreamReach = () => `nonempty`, `later-publication`, `unchanged`, + `ordinary-first`, + `suspense-first`, + `suspense-only`, + `browser-different`, + `browser-same`, + `split-hydration`, + `together-hydration`, ]) describe(`stream render snapshot cut`, () => { + it(`keeps an ordinary reader from blocking a shared Suspense preload`, () => + checkStreamCut( + { count: 1, writes: 0, ordinaryFirst: true, browserWrite: true }, + createStreamReach(), + )) + it(`hydrates an ordinary reader rendered after its Suspense sibling`, () => + checkStreamCut( + { count: 1, writes: 0, ordinaryLast: true, browserWrite: true }, + createStreamReach(), + )) + it(`retains the Suspense view after its ordinary sibling commits`, () => + checkStreamCut( + { + count: 1, + writes: 0, + ordinaryFirst: true, + browserWrite: true, + splitHydration: true, + }, + createStreamReach(), + )) for (const history of [ { count: 0, writes: 1 }, { count: 1, writes: 1 }, @@ -2697,10 +2877,19 @@ describe(`stream render snapshot cut`, () => { const reach = createStreamReach() await fc.assert( fc.asyncProperty( - fc.record({ - count: fc.integer({ min: 0, max: 3 }), - writes: fc.integer({ min: 0, max: 3 }), - }), + fc + .record({ + count: fc.integer({ min: 0, max: 3 }), + writes: fc.integer({ min: 0, max: 3 }), + ordinaryFirst: fc.boolean(), + ordinaryLast: fc.boolean(), + browserWrite: fc.boolean(), + splitHydration: fc.boolean(), + }) + .map((history) => ({ + ...history, + ordinaryLast: !history.ordinaryFirst && history.ordinaryLast, + })), (history) => checkStreamCut(history, reach), ), campaign === `fixed` diff --git a/packages/react-router-with-db/src/index.tsx b/packages/react-router-with-db/src/index.tsx index 3124966fed..cdc025ac29 100644 --- a/packages/react-router-with-db/src/index.tsx +++ b/packages/react-router-with-db/src/index.tsx @@ -51,13 +51,14 @@ export function routerWithDbClient( dbClient._setSsrServerCleanupEnabled(true) const dbStream = createPushableStream() const bufferedQueryHashes = new Set() - const streamedQueryHashes = new Set() + const streamedQueryVersions = new Map() let criticalStateCaptured = false let renderFinishRegistered = false const streamLiveQuery = (queryHash: string) => { - if (streamedQueryHashes.has(queryHash)) return - streamedQueryHashes.add(queryHash) + const version = dbClient._getLiveQuery(queryHash)!.dehydratedAt + if (streamedQueryVersions.get(queryHash) === version) return + streamedQueryVersions.set(queryHash, version) const enqueued = dbStream.enqueue( dbClient.dehydrate({ diff --git a/packages/react-router-with-db/tests/index.test.ts b/packages/react-router-with-db/tests/index.test.ts index 93248748d5..b177ebf94d 100644 --- a/packages/react-router-with-db/tests/index.test.ts +++ b/packages/react-router-with-db/tests/index.test.ts @@ -142,6 +142,49 @@ describe(`routerWithDbClient`, () => { }) }) + it(`streams a completed preload after an earlier loading render`, async () => { + const dbClient = new DbClient() + let isDehydrated = false + let finishRender = () => {} + const router = { + options: { context: { dbClient } }, + isServer: true, + serverSsr: { + isDehydrated: () => isDehydrated, + onRenderFinished: (callback: () => void) => { + finishRender = callback + }, + }, + } as unknown as AnyRouter + adaptRouter(router, dbClient) + const state = + (await router.options.dehydrate?.()) as DehydratedRouterDbState + const reader = state.dbStream.getReader() + isDehydrated = true + try { + dbClient._captureServerQuery(`shared`, { rows: [], status: `loading` }) + const first = await reader.read() + const initial = first.value!.liveQueries![0]! + expect(initial.snapshot ?? (await initial.promise)).toEqual({ + rows: [], + status: `loading`, + }) + const rows = [{ key: `1`, value: { id: `1`, text: `Ready` } }] + await dbClient._registerLiveQuery(`shared`, Promise.resolve({ rows })) + finishRender() + const next = await reader.read() + expect(next.done, `a later preload must reach the browser`).toBe(false) + const completed = next.value!.liveQueries![0]! + expect(completed.dehydratedAt).toBeGreaterThan(initial.dehydratedAt) + expect(completed.snapshot ?? (await completed.promise)).toEqual({ rows }) + await expect(reader.read()).resolves.toMatchObject({ done: true }) + } finally { + finishRender() + reader.releaseLock() + await dbClient.cleanup() + } + }) + it(`includes queries registered while critical dehydration is pending`, async () => { const dbClient = new DbClient() let releaseOriginalDehydrate!: () => void From ce8f99d350556cf461cca059db41e4f490eea74d Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 23 Oct 2026 16:58:12 -0600 Subject: [PATCH 19/20] test(react-db): count actual hydration receiving paths --- .../descriptor-query-binding-oracle.test.tsx | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/packages/react-db/tests/descriptor-query-binding-oracle.test.tsx b/packages/react-db/tests/descriptor-query-binding-oracle.test.tsx index 60926037d8..0954c71394 100644 --- a/packages/react-db/tests/descriptor-query-binding-oracle.test.tsx +++ b/packages/react-db/tests/descriptor-query-binding-oracle.test.tsx @@ -2813,16 +2813,12 @@ async function checkStreamCut( ? `suspense-first` : `suspense-only`, ) - reach.reach( - (history.ordinaryFirst || history.ordinaryLast) && history.splitHydration - ? `split-hydration` - : `together-hydration`, - ) - reach.reach( - (history.ordinaryFirst || history.ordinaryLast) && history.browserWrite - ? `browser-different` - : `browser-same`, - ) + if (history.ordinaryFirst || history.ordinaryLast) { + reach.reach( + history.splitHydration ? `split-hydration` : `together-hydration`, + ) + reach.reach(history.browserWrite ? `browser-different` : `browser-same`) + } } finally { vi.stubGlobal(`window`, browserWindow) finishRouterRender() From b4955406a57e0032caffd7d494ca66799c45dd00 Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 23 Oct 2026 17:00:55 -0600 Subject: [PATCH 20/20] docs: record mixed streaming hydration evidence --- .../oracle-reviews/2026-10-23-ssr-review.md | 184 ++++++++++++++++++ 1 file changed, 184 insertions(+) diff --git a/docs/contributing/oracle-reviews/2026-10-23-ssr-review.md b/docs/contributing/oracle-reviews/2026-10-23-ssr-review.md index 5caa1839c4..b6bb1b49ab 100644 --- a/docs/contributing/oracle-reviews/2026-10-23-ssr-review.md +++ b/docs/contributing/oracle-reviews/2026-10-23-ssr-review.md @@ -399,3 +399,187 @@ stranded demand after notification failure), and F07 (Date assignment and exact dependency versions). This review neither closes those reports nor invents a recovery state machine for them. CodeRabbit remains paused. No external review request was sent. + + +## Follow-up: mixed streaming readers — 2026-10-23 + +Starting head: `a0991825ea326fcdbfadbeb61c2029ff130b1a88`. +Production repair: `10e0e6fed7f127d515144e1a14c4e69805425cc2`. +Final oracle instrumentation: `ce8f99d350556cf461cca059db41e4f490eea74d`. +This is a self-review. No independent agent or external review ran. +The earlier record remains historical. This section supersedes its exclusion +of the mixed-reader schedules exercised below. + +### Joint law and the missing experiment + +Each reader's first browser rows and readiness must reproduce that reader's +server-rendered view. Keeping an ordinary loading view cannot prevent explicit +Suspense preparation from completing. One committed reader cannot consume a +later boundary's server view. The SSR guide supplies initial-view equality and +explicit preload completion. React's hydration API supplies the receiving cut. +The maintainer's programming-error policy does not permit a crash for these +legal schedules. Active Collection cleanup retains its separately approved rule. + +The old stream-cut property compared one Suspense render with its transported +rows. It omitted an ordinary sibling and never hydrated this mixed tree. Its +narrower green result did not establish the joint promise. The expanded +metamorphic judgment retains each server reader's projected primitive values +and status, then compares that same reader's first browser result. It imports +no production classification to predict equality. + +The grammar varies zero to three authored rows and zero to three microtask +publications. An ordinary reader appears before Suspense, after Suspense, or +not at all. Browser startup may publish different rows under the same key. +An async React act cut and a synchronous act cut distinguish hydration together +from ordinary commit before Suspense hydration. Snapshots and resolved Promise +values cross through JSON. The real Router adapter supplies the stream under +controlled SSR callbacks. Actual renderToPipeableStream and hydrateRoot supply +the React receiving path. Suspense-only histories compare server markup and +transport. Only mixed histories receive browser-hydration credit. + +Removing placement loses the competing loading/ready views. Removing browser +variation permits local recomputation to conceal missing transport. Removing +the split cut permits an early commit to consume another boundary's view. +Empty rows distinguish readiness without row evidence. Microtask writes retain +the original rendered-view versus later-publication distinction. The grammar +reconstructs each known witness below. It permits one ordinary sibling, so the +mapping excludes both placement flags being true. Generated writes insert a +missing key and update an existing key, including the browser-only row. + +### Additional findings and pre-edit evidence + +These findings supplement N01–N08 and do not replace any raw SR item. + +| ID | Finding and test gap | Decisive evidence | Disposition | +| --- | --- | --- | --- | +| N09 | Completing a shared Suspense query replaced an ordinary reader's initial loading view. The previous owner lacked the second reader and its first-browser observation. | On unchanged a0991825e, three failures and three passes. Fixed seed 2229, path 0:0:0, and random seed -1274793130, path 0:0:0, reduce to zero rows and ready instead of loading. | fixed-now, within the mixed grammar above. | +| N10 | Router suppressed every later record with an already-streamed hash. Core snapshot repair alone could not deliver the replacement. | The actual Router receiving test ran before production edits. Its second read reported done:true instead of forwarding the completed preload. | fixed-now. The same Router adapter also supplies the generated receiving path. | +| N11 | A split hydration cut used local rows before the stream delivered its result, or lost a result after a sibling consumed it. Async-only hydration hid this schedule. | The expanded owner ran before the retention/wait repair: three failures, five passes. Seed 2229, path 3:0:0:1, reduces to empty rows with incorrect readiness. Random seed -45443823, path 0:0, reaches the same mismatch. A retained-record-only attempt still failed all three comparisons. | fixed-now, including retained transport and waiting for missing rows. | +| N12 | CI rejected a dynamically spread one-run replay override as oracle/no-disabled. Local ESLint does not include that installed CI rule. | Lint at a0991825e failed while all unit shards passed. Shared replay options replace the override. Normal fixed/random campaigns still run 75 histories each, and direct seed 2233/path 1:0 passes. | Local repair complete. Final CI must supply rule-specific confirmation. | + +N09 and N10 reached their receiving assertions on unchanged production before +its first edit. N11 reached its assertions on the first mixed-reader repair +before adding retained server records or the pending-result wait. These are +assertion failures at the promised first-view cuts, not timeout kills. The +retention-only attempt demonstrates why a narrower repair is insufficient. + +Fixture mistakes receive separate treatment. One browser-row fixture attempted +a duplicate insert and produced four uncaught exceptions. The corrected fixture +passes but those exceptions supply no calibration credit. Another run passed +519 assertions but reported a private-option TypeCheckError. It was not clean +GREEN. The fixture now uses its known client identity. Initial bundle checks +stopped at their stale-dist guard before execution. Later checks used a fresh +build. An incorrectly addressed diagnostic command changed no test source and +supplies no scheduling evidence. Temporary diagnostics are absent from the final +oracle. Development multiple-renderer warnings remain distinguishable from +uncaught errors and recoverable hydration mismatches. + +### Repair, alternatives, and cost + +The transport reuses the record's existing initial snapshot field. It sends a +second view only when that view differs from the completed result. Ordinary +readers retain their earlier view. Suspense reads the completed result. Router +replaces its hash Set with a version Map so replacement records can arrive. + +DbClient retains a reference to each transported query record until cleanup. +Only the server getter can recover that record after ordinary consumption. +Normal new browser observers continue to read live results. If hydration lacks +the server-rendered rows, its getter suspends on the stream Promise. Explicit +Suspense demand can still start local work. No retry timer, generation, queue, +or replacement lifecycle was added. + +Reusing only the active record was smaller but failed the split schedule. +Retention alone also failed while the Promise still lacked its snapshot. Reader +counts cannot include a boundary React has not instantiated. A separate root +hydration-completion lifecycle would require an architectural signal this API +does not expose. The chosen map keeps references rather than copying rows. +Its memory cost lasts for the DbClient lifetime and grows with distinct hydrated +query hashes. cleanup clears it. Those costs earn their place through the +receiving law and the rejected narrower designs. + +Compared with a0991825e, production grows by 45 net source lines. Tests grow by +225 net lines after correcting reach counters. Guide, coverage-map and changeset +text grow by 24 net lines, excluding this audit. The mangle cache adds two names +and drops none, for 421 names. Tests and documentation are separate from shipped +bytes. Against merged main 65d9ada508114f199464f753d50eb3447eb0eaf9, +size:compare over db, react-db and react-router-with-db reports: + +| Output | Gzip change | Brotli change | +| --- | ---: | ---: | +| db | +799 bytes | +891 bytes | +| react-db | +117 bytes | +129 bytes | +| react-router-with-db | +25 bytes | +27 bytes | +| Package total | +941 bytes | +1,047 bytes | +| Typical app | +479 bytes | +527 bytes | +| Other app scenarios | +437 to +488 bytes | +329 to +443 bytes | + +### Final evidence and self-review + +The full React package passes 520 tests in 23 files with coverage and type +checks. Svelte passes 163 tests in 11 files. Core checks cover 379 distinct tests +in 13 files, including observer lifecycle, DbClient transport and identity, +pooled queries, windows, and errors. Router passes ten tests in two files. +The full DB test compiler passes. The final reach-only adjustment runs the +same eight stream-cut tests again without broadening unrelated testing. + +Both normal stream campaigns retain a 40-history budget. Fixed seed 2229 and +the final unseeded run report these actual counts. Browser states count only +histories that reached hydrateRoot, not Suspense-only transport histories. + +| Declared state | Fixed | Random | +| --- | ---: | ---: | +| empty | 11 | 9 | +| nonempty | 29 | 31 | +| later-publication | 31 | 29 | +| unchanged | 9 | 11 | +| ordinary-first | 21 | 18 | +| suspense-first | 11 | 9 | +| suspense-only | 8 | 13 | +| browser-different | 12 | 18 | +| browser-same | 20 | 9 | +| split-hydration | 16 | 12 | +| together-hydration | 16 | 15 | + +Every normal campaign asserts minimum reach. The guarded stream replay at seed +2229/path 3:0:0:1 completes one execution with two runs and failed:false. The +older replay-demand property's wrapper stops at no-named-owner before execution. +Its direct replay passes but receives no guarded-replay credit. No manifest +entry was invented for this lint repair. + +Scoped ESLint has zero errors or warnings. Prettier, whitespace and the 421-name +mangle check pass. The minified consumer passes 107 error names, index metadata, +query rows and live updates. Production-error checks erase 248 literals, +including 20 shared literals, in production ESM and CommonJS while preserving +development text. + +| Requirement | Follow-up outcome | +| --- | --- | +| ORC-001 | SSR guide and hydration equality authorize the joint law. The finite source and host limits remain explicit. | +| ORC-002 | The relation compares each rendered primitive projection with that reader's first browser projection. Expected equality does not use the transport classifier. | +| ORC-003 | Local prose explains the law, placements, scheduling cuts, public observations and controlled Router premise beside the existing owner. | +| ORC-004 | The reconstruction, ablation, zero/three bounds and mutually exclusive placement mapping appear above. Duplicate inserts are invalid and no longer enter the fixture. | +| ORC-005 | Real React APIs receive actual Router adapter chunks. First hook rows/status and recoverable errors are compared. Suspense-only cases claim transport evidence only. Missing result data causes a wait rather than local substitution. | +| ORC-006 | Original mixed-reader and Router comparisons fail. The split comparison also rejects retention without waiting for the missing result. Setup/type failures receive no credit. | +| ORC-007 | The same grammar and comparison run 40 fixed and 40 unseeded histories. Named seed/path replay completes directly. No command-model replayPath applies. | +| ORC-008 | The relation needs a separate first view per reader: loading versus completed readiness predicts different hydration. It introduces no reference lifecycle state machine. | +| ORC-009 | Rows, Collection readiness, cleanup and publication retain glossary meanings. split-hydration names a host scheduling cut, not a new Collection state. | +| ORC-010 | Shrinking preserves the first-view mismatch. Diagnostics distinguish primary mismatches, fixture exceptions, compiler failures and cleanup. Roots, readers and clients are released. | +| ORC-011 | Browser-local variation challenges the concrete shared-fault hypothesis that both sides recompute from equal local rows. The relation needs no second copy of production semantics. | +| ORC-012 | This append-only section identifies production and final oracle commits, exact failures, reach, alternatives and finite limits. | +| ORC-013 | Empty readiness, both reader orders, async versus split hydration, and same/different browser rows distinguish nearby wrong consequences. | +| ORC-014 | Actual React supplies the split cut. Router callbacks remain controlled. This is not a deployed Start or real-provider claim. | +| ORC-015 | Each declared receiving state requires actual browser execution. The table records the final normal campaigns. | + +The ten raw SR findings remain accounted for as ten fixed-now. This follow-up +adds N09–N11 repairs and the local N12 CI repair without losing earlier evidence. +It makes no independent-reviewer quality claim. The earlier reviewer assessment +remains historical. + +Whole-PR readiness remains unproven. At a0991825e every unit shard passed, Lint +failed and browser E2E was canceled. Final pushed-head CI still needs inspection. +Earlier H04/H08/F07 evidence gaps remain in their migration audit. The coverage +map retains arbitrary asynchronous providers, persistence readiness, later +infinite-page changes, custom filters, concurrent-root schedules and deployed +Start/Electric receiving paths. No known counterexample remains in the exercised +mixed-reader scope. Passing these finite histories is not universal proof. +CodeRabbit remains paused. No new external review request was sent.