diff --git a/.changeset/first-rc.md b/.changeset/first-rc.md new file mode 100644 index 0000000000..9d4870748e --- /dev/null +++ b/.changeset/first-rc.md @@ -0,0 +1,39 @@ +--- +'@tanstack/angular-db': major +'@tanstack/browser-db-sqlite-persistence': major +'@tanstack/capacitor-db-sqlite-persistence': major +'@tanstack/cloudflare-durable-objects-db-sqlite-persistence': major +'@tanstack/convex-db-collection': major +'@tanstack/db': major +'@tanstack/db-ivm': major +'@tanstack/db-sqlite-persistence-core': major +'@tanstack/electric-db-collection': major +'@tanstack/electron-db-sqlite-persistence': major +'@tanstack/expo-db-sqlite-persistence': major +'@tanstack/node-db-sqlite-persistence': major +'@tanstack/offline-transactions': minor +'@tanstack/powersync-db-collection': major +'@tanstack/preact-db': major +'@tanstack/query-db-collection': minor +'@tanstack/react-db': major +'@tanstack/react-native-db-sqlite-persistence': major +'@tanstack/react-router-with-db': major +'@tanstack/rxdb-db-collection': major +'@tanstack/solid-db': major +'@tanstack/svelte-db': major +'@tanstack/tauri-db-sqlite-persistence': major +'@tanstack/trailbase-db-collection': major +'@tanstack/vue-db': major +--- + +Release the first 1.0 candidate. Remove deprecated pre-1.0 APIs and give +published row metadata precedence over colliding selected fields. Structured +source spreads and direct source-row returns keep source attribution; new +functional objects and other projections receive output Collection metadata. +Keep the two packages accidentally released as stable 1.x on their stable +release lines. Both stable minor releases require `@tanstack/db@^1.0.0-rc.0` +for the RC health and restoration settlement APIs; upgrade DB with them. + +Remove Query Collection’s automatic refetch after mutation handlers. Handlers +return `Promise` and must await `collection.utils.refetch()` or write +confirmed data with the `write*` utilities; `{ refetch: false }` is removed. diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 24394d90cb..7b3eef4329 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -28,6 +28,8 @@ jobs: uses: tanstack/config/.github/setup@e4b48f16568324f76f467aa4c2aac2f05db632c3 - name: Check lint run: pnpm run lint:check + - name: Check release versioning + run: pnpm run test:release - name: Typecheck doc and skill examples run: pnpm run test:doc-snippets - name: Check oracle lint without inline directives diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 729fca27c7..825a0f5f10 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,6 +28,8 @@ jobs: uses: tanstack/config/.github/setup@e4b48f16568324f76f467aa4c2aac2f05db632c3 - name: Check lint run: pnpm run lint:check + - name: Check release versioning + run: pnpm run test:release test-db: name: Test (${{ matrix.group }}) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7c220c1a0c..e66681c305 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -158,6 +158,14 @@ If you want to run an example without installing dependencies for the whole repo This repo uses [Changesets](https://github.com/changesets/changesets) to automate releases. If your PR should release a new package version (patch, minor, or major), please run `pnpm changeset` and commit the generated file. If your PR affects docs, examples, styles, etc., you probably don't need to generate a changeset. +During the 1.0 release candidate period, use the same Changeset types. For a +package at `1.0.0-rc.1`, a patch produces `1.0.0-rc.1.1`, and another patch +produces `1.0.0-rc.1.2`. A minor or major change advances the candidate number: +`1.0.0-rc.1.2` becomes `1.0.0-rc.2`. Packages already on stable 1.x versions, +including `@tanstack/query-db-collection` and +`@tanstack/offline-transactions`, continue to use normal semantic versioning +and publish under npm's `latest` tag. + ## Pull requests Maintainers merge pull requests by squashing all commits and editing the commit message if necessary using the GitHub user interface. @@ -167,3 +175,12 @@ Use an appropriate commit type. Be especially careful with breaking changes. ## Releases For each new commit added to `main`, a GitHub Workflow is triggered which runs the [Changesets Action](https://github.com/changesets/action). This generates a preview PR showing the impact of all changesets. When this PR is merged, the package will be published to npm. + +The first 1.0 candidate Changeset enters Changesets prerelease mode through +`pnpm run changeset:version`. Leave `.changeset/pre.json` in prerelease mode +for subsequent candidate releases; the normal release workflow handles each +patch or minor Changeset. When the candidates are ready for stable 1.0, run +`pnpm changeset pre exit` and commit the resulting `.changeset/pre.json` change. +The next Version Packages PR promotes the candidate packages to `1.0.0`. +Already stable packages keep their versions unless a pending Changeset +explicitly releases them. Merge that PR to publish 1.0. diff --git a/docs/collections/convex-collection.md b/docs/collections/convex-collection.md index 2c3e4d5423..36aca89604 100644 --- a/docs/collections/convex-collection.md +++ b/docs/collections/convex-collection.md @@ -84,7 +84,7 @@ const tasks = createCollection( const transaction = tasks.update(taskId, (task) => { task.completed = true }) -await transaction.isPersisted.promise +await transaction.when('settled') ``` DB applies optimistic writes immediately. Successful Convex mutation promises wait for the client's queries to catch up. Before a handler settles, the adapter reads the latest cached result and waits for DB to accept its sync writes. It does not wait for visibility held by that same optimistic transaction. If a wrapping persistence layer rejects source acceptance, that sync run stays failed. Call `collection.reload()` before retrying replay. After readiness, a provider query error can recover when a later valid result arrives. A failed first load requires `reload()`. A rejected handler rolls back its optimistic writes. diff --git a/docs/collections/electric-collection.md b/docs/collections/electric-collection.md index 912c806058..a206107d60 100644 --- a/docs/collections/electric-collection.md +++ b/docs/collections/electric-collection.md @@ -468,7 +468,7 @@ const matchFn = (message) => { ### Common Issue: awaitTxId Stalls or Times Out -A frequent issue developers encounter is that `awaitTxId` (or the transaction's `isPersisted.promise`) stalls indefinitely, eventually timing out with no error messages. The data persists correctly to the database, but the optimistic mutation never resolves. +A frequent issue developers encounter is that `awaitTxId` (or the transaction's `when('settled')`) stalls indefinitely, eventually timing out with no error messages. The data persists correctly to the database, but the optimistic mutation never resolves. **Root Cause:** This happens when the transaction ID (txid) returned from your API doesn't match the actual transaction ID of the mutation in Postgres. This mismatch occurs when you query `pg_current_xact_id()` **outside** the same transaction that performs the mutation. diff --git a/docs/collections/indexed-db-collection.md b/docs/collections/indexed-db-collection.md index b57f4f02c0..32106014ef 100644 --- a/docs/collections/indexed-db-collection.md +++ b/docs/collections/indexed-db-collection.md @@ -86,18 +86,18 @@ const insert = todos.insert({ text: 'Write a draft', completed: false, }) -await insert.isPersisted.promise +await insert.when('settled') const update = todos.update(id, (draft) => { draft.completed = true }) -await update.isPersisted.promise +await update.when('settled') const remove = todos.delete(id) -await remove.isPersisted.promise +await remove.when('settled') ``` -Await `isPersisted.promise` to observe persistence success or failure. The returned transaction itself is not a Promise. +Await `when('settled')` to observe persistence success or failure. The returned transaction itself is not a Promise. If you provide `onInsert`, `onUpdate`, or `onDelete`, the handler runs before persistence. A rejected handler leaves durable rows unchanged and rolls back its optimistic changes. Each accepted Collection batch writes rows and metadata in one IndexedDB transaction. A failed write aborts the batch. @@ -212,7 +212,7 @@ For a transaction involving several Collections, call each Collection's acceptan Automatic writes in one Collection persist in mutation order, even when their handlers finish out of order. Later writes wait for earlier handlers and -persistence to settle before reporting `isPersisted`. Rejection contributes no +persistence to settle before reporting `when('settled')`. Rejection contributes no durable write and allows the next mutation to proceed. Do not await a later automatic write from an earlier handler in the same Collection: each would wait for the other. This rule does not order separate Collections, manual acceptance, diff --git a/docs/collections/local-storage-collection.md b/docs/collections/local-storage-collection.md index 327727ceb0..dd22529352 100644 --- a/docs/collections/local-storage-collection.md +++ b/docs/collections/local-storage-collection.md @@ -115,7 +115,7 @@ that contains any of these: - a `Map`, `Set`, `WeakMap`, `WeakSet`, `RegExp`, `Promise`, `ArrayBuffer`, `SharedArrayBuffer`, or typed array -The rejected mutation's `isPersisted` promise rejects with +The rejected mutation's `when('settled')` promise rejects with `SerializationError`. Nothing is written to storage and the optimistic row rolls back. An automatic write's `onInsert` or `onUpdate` handler does not run; a manual `acceptMutations` call rejects with the same error. @@ -138,7 +138,7 @@ localStorage before the mutation method returns. A later handler failure rejects its transaction promptly, even if an earlier handler is still pending. A handler can start another mutation on the same Collection, then return without -awaiting that mutation's `isPersisted` promise. Do not await that promise or a +awaiting that mutation's `when('settled')` promise. Do not await that promise or a later `acceptMutations()` call inside the earlier handler. Those writes wait for the earlier handler to return, so awaiting them would leave both pending. diff --git a/docs/collections/powersync-collection.md b/docs/collections/powersync-collection.md index d2147241ce..472a7e6926 100644 --- a/docs/collections/powersync-collection.md +++ b/docs/collections/powersync-collection.md @@ -452,21 +452,21 @@ await documents.insert( timestamp: Date.now(), }, } -).isPersisted.promise +).when('settled') // Update with metadata await documents.update( - docId, + String(docId), { metadata: { reason: "typo-fix", editor: "user-456" } }, (doc) => { doc.name = "Report Q4 (Updated)" } -).isPersisted.promise +).when('settled') // Delete with metadata await documents.delete(docId, { metadata: { deletedBy: "user-789", reason: "duplicate" }, -}).isPersisted.promise +}).when('settled') ``` #### Accessing Metadata During Upload @@ -568,7 +568,7 @@ batchTx.mutate(() => { await batchTx.commit() // Wait for the changes to be persisted -await batchTx.isPersisted.promise +await batchTx.when('settled') ``` This approach allows you to: diff --git a/docs/collections/query-collection.md b/docs/collections/query-collection.md index 01da65caff..8602448588 100644 --- a/docs/collections/query-collection.md +++ b/docs/collections/query-collection.md @@ -556,71 +556,49 @@ const productsCollection = createCollection( ## Persistence Handlers -You can define handlers that are called when mutations occur. These handlers persist changes to your backend: +Handlers persist changes to your backend and explicitly synchronize the +confirmed state before returning. They return `Promise`: ```typescript const todosCollection = createCollection( - queryCollectionOptions({ + queryCollectionOptions<{ id: string; text: string; completed: boolean }>({ queryKey: ["todos"], queryFn: fetchTodos, queryClient, getKey: (item) => item.id, - - onInsert: async ({ transaction }) => { - const newItems = transaction.mutations.map((m) => m.modified) - await api.createTodos(newItems) - // Auto-refetch happens after handler completes (pre-1.0 behavior) + onInsert: async ({ transaction, collection }) => { + await api.createTodos(transaction.mutations.map((m) => m.modified)) + await collection.utils.refetch() }, - - onUpdate: async ({ transaction }) => { - const updates = transaction.mutations.map((m) => ({ + onUpdate: async ({ transaction, collection }) => { + await api.updateTodos(transaction.mutations.map((m) => ({ id: m.key, changes: m.changes, - })) - await api.updateTodos(updates) + }))) + await collection.utils.refetch() }, - - onDelete: async ({ transaction }) => { - const ids = transaction.mutations.map((m) => m.key) - await api.deleteTodos(ids) + onDelete: async ({ transaction, collection }) => { + await api.deleteTodos(transaction.mutations.map((m) => m.key)) + await collection.utils.refetch() }, }) ) ``` -> **Note**: QueryCollection currently auto-refetches after handlers complete. See [Controlling Refetch Behavior](#controlling-refetch-behavior) for details on this transitional behavior. - ### Controlling Refetch Behavior -> **⚠️ Transitional API**: QueryCollection currently auto-refetches after handlers complete. This behavior is deprecated and will be removed in v1.0. See the migration notes below. - -#### Current Behavior (Pre-1.0) - -By default, QueryCollection automatically refetches after each handler completes. To **skip** auto-refetch, return `{ refetch: false }`: +Mutation handlers do not automatically refetch. Call and await +`collection.utils.refetch()` to read back server state, or await the direct-write +utilities to apply the confirmed server response without a request. If a handler +does neither, its optimistic changes revert to the previous synced rows when the +transaction settles. A successful server write alone does not update those rows. -```typescript -onInsert: async ({ transaction }) => { - await api.createTodos(transaction.mutations.map((m) => m.modified)) - - // Skip auto-refetch - use this when server doesn't modify the data - return { refetch: false } -} -``` +The `{ refetch: false }` return value has been removed. Handlers return +`Promise` and do not control synchronization through their return value. +Query's configured polling, focus, reconnect, and invalidation triggers still +operate independently of mutation handlers. -If you don't return `{ refetch: false }`, auto-refetch happens automatically. - -#### v1.0 Behavior (Future) - -In v1.0, auto-refetch will be **removed**. Handlers will need to explicitly call `collection.utils.refetch()` when refetching is needed: - -```typescript -onInsert: async ({ transaction, collection }) => { - await api.createTodos(transaction.mutations.map((m) => m.modified)) - - // Explicitly trigger refetch when you need server state - await collection.utils.refetch() -} -``` +#### Explicit Refetch Inside a Handler When no user mutation is persisting and no mutation handler is active, the `refetch()` promise resolves after every accepted result from that call has been @@ -636,7 +614,7 @@ the Query fetch boundary. This rule applies whether the mutation or the refetch starts first. It includes calls through the handler parameter, captured or external Collection references, manual transaction mutation functions, and `createOptimisticAction` mutation functions. Code can await the mutation's -`isPersisted.promise` when it needs the complete transaction and its queued +`when('settled')` when it needs the complete transaction and its queued Collection application. Utilities that refetch inherit the same phase boundary. @@ -655,24 +633,18 @@ boundary, `throwOnError` applies only to the Query fetch because application happens after that call settles. A later application failure is recorded by the Collection's error utilities. -To skip refetch in v1.0, simply don't call `refetch()`: +#### Applying a Server Response Without Refetching -```typescript -onInsert: async ({ transaction }) => { - await api.createTodos(transaction.mutations.map((m) => m.modified)) +Use direct-write utilities when the server returns the confirmed rows or another +source delivers them. Await the write before the handler returns: - // No refetch call = no refetch (v1.0 behavior) +```typescript +onInsert: async ({ transaction, collection }) => { + const rows = await api.createTodos(transaction.mutations.map((m) => m.modified)) + await collection.utils.writeUpsert(rows) } ``` -#### When to Skip Refetch - -Skip refetching when: - -- You're confident the server state exactly matches what you sent (no server-side processing) -- You're handling state updates through other mechanisms (like WebSockets or direct writes) -- You want to optimize for fewer network requests - ## Utility Methods The collection provides these utility methods via `collection.utils`: @@ -845,7 +817,7 @@ When the server returns computed fields (like server-generated IDs or timestamps ```typescript const todosCollection = createCollection( - queryCollectionOptions({ + queryCollectionOptions<{ id: string; text: string; completed: boolean }>({ queryKey: ["todos"], queryFn: fetchTodos, queryClient, @@ -867,7 +839,6 @@ const todosCollection = createCollection( // No need to refetch - we've already synced the server response via direct writes // (optimistic state is automatically replaced when handler completes) - return { refetch: false } // Transitional pre-1.0 opt-out }, onUpdate: async ({ transaction, collection }) => { @@ -885,13 +856,12 @@ const todosCollection = createCollection( }) // No refetch needed since we used direct writes - return { refetch: false } // Transitional pre-1.0 opt-out }, }) ) // Usage is just like a regular collection -todosCollection.insert({ text: "Buy milk", completed: false }) +todosCollection.insert({ id: crypto.randomUUID(), text: "Buy milk", completed: false }) ``` ### Server pagination with live queries @@ -1285,7 +1255,7 @@ has a subset owner or not. It refetches that Query when the subset is acquired, then applies the new server result. The earlier direct write still starts no request by itself. -To use direct writes with persistence handlers, return `{ refetch: false }` to avoid the handler's separate automatic refetch during the pre-1.0 transition. An on-demand `queryFn` still needs to return the complete current result for its pushed-down predicate, order, limit, and offset when it does run. For robust real-time synchronization, use a collection backed by a sync engine that owns snapshot and change-stream ordering. +Await direct writes inside persistence handlers to apply confirmed data before settlement. Handlers do not refetch automatically. An on-demand `queryFn` still needs to return the complete current result for its pushed-down predicate, order, limit, and offset when it does run. For robust real-time synchronization, use a collection backed by a sync engine that owns snapshot and change-stream ordering. ## Complete Direct Write API Reference diff --git a/docs/config.json b/docs/config.json index b0576e0edc..1bc477dd48 100644 --- a/docs/config.json +++ b/docs/config.json @@ -26,6 +26,10 @@ { "label": "Guides", "children": [ + { + "label": "Migrate to the 1.0 RC", + "to": "guides/migrate-to-1-0-rc" + }, { "label": "Live Queries", "to": "guides/live-queries" diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index df7b01dbae..89890d7a4a 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -1,5 +1,15 @@ # Oracle coverage and closeout +## RC scope partition, 2026-10-29 + +The [deferred oracle record](oracle-reviews/2026-10-29-pr-2171-deferred-oracles.md) +names preserved P09/P10/P11/P12 failures, the appended chunk-24 static receiver, +their exact recoverable source snapshot and retained evidence. Their detailed +campaign entries below remain a lossless historical record; those named files +and appended slices are deferred registrations, not active release coverage or +closed laws. Original root metadata and the 24 nullable-facade/public-output +type campaigns remain active. Every release gate remains required. + Use [Oracle Testing Rubric](oracle-rubric.md) when adding or reviewing a law. This map identifies existing owners, their judgment, and their limits. It is not a claim that every state or every test has been audited. @@ -514,12 +524,12 @@ say what production facts they abstract. This is a semantic review, not a rule that test identifiers must copy production's private data structures. | Surface | Status | Completed or next owner | -| ---------------------------------------- | -------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Ordered relations and BTree | Complete | The signed top-K relation, BTree/Map refinement model, and DBSP incrementalization laws are literate. | | D2 operator work | `packages/db-ivm/tests/d2-work.test.ts` (fixed tests; no oracle owner (tracked in #2264)) | A finalized 65-branch graph receives one direct input at the first, middle, or last branch, then a second input on a different branch. The test compares exact forwarded messages and operator invocations after each `run()`: branches without queued input perform no operator work. A connected reversed-registration pair must drain in the same run, and an empty unfinalized graph rejects. The old unconditional loop fails with 64 idle calls per turn. This bounded work check does not establish elapsed latency, fused operators, arbitrary graph topologies, or provider-level live-query work. | | Includes and publication | Complete, [facade rollback](https://github.com/TanStack/db/blob/main/packages/db/tests/query/bucket-facade-rollback-oracle.property.test.ts) ([review](oracle-reviews/perf-lazy-bucket-rollback-copy.md)) | The central recomputation model plus cross-formulation, temporal demand, layered publication, Collection facade lifecycle and space bounds, route-context transport, functional projection, query-shape, optimistic, and source-work owners are literate. They keep the architecture document as their contract source. A failed facade flush restores every facade it wrote, disposes the facades it created, publishes no event or layout revision, and reads the stored rows of no other facade; nested facades across two edges have a pinned witness. The grammar adds optimistic facade inserts and optimistic deletes of shown graph rows; a retired facade must show exactly its visible optimistic rows, and every history ends by settling them and comparing all rows again. A facade commit that throws before committing is generated, and the restore must commit the open sync transaction. Every change pending at a failed root commit, child rows included, stays pending, and the next flush publishes it once. Open: the flush's edge and bucket map copy is unmeasured bookkeeping; disposal between a flush and its rollback has pinned witnesses only; held facade commits are generated one hold at a time for up to three flushes, and a release that covered earlier flushes checks rows only; the grammar also cleans up a shown facade from outside, lets optimistic rows share graph ids and later rolls them back, after which those keys are compared by value; a holder's cleanup of a shown facade rejects (code 241) and the facade keeps receiving the graph's writes; retiring a shown bucket without the graph's retractions is generated (`contradict`) and throws code 240; optimistic deletes and updates of shown graph rows are generated, and an update's copy keeps the row's key; a facade that `resolve()` created before activation can stay ready and empty after a failed flush (recorded limit). | | Collection lifecycle | Complete | The shared logical-owner/acquisition-attempt/sync-run grammar plus mutation admission, lifecycle trace, publication, replay, disposal, and transaction-refinement boundaries are literate. The lifecycle history driver checks law 13 at every command: an attempt that settled without an abort keeps no core abort listener on its signal (RED on d0cde4778 in 36 histories; a mutant that keeps the replay participant's listener fails 33). `parkedReloadDemandCases` drives a demand parked behind a failed first load through a reload whose new run marks ready, commits a snapshot inside `sync()` before or after `markReady()`, or commits it after the first load settled: one `loadSubset` per replay attempt that starts work and one `unloadSubset` per replaced acquisition (RED on d0cde4778 for `ready-then-snapshot`: two loads and one unload, because the restart joined the attempt before the attempt's own setup ran; a mutant without the per-attempt check fails it). Limits: these are four fixed histories, not generated; a second reload and asynchronous failures are outside them. | -| Collection status and health tree | `packages/db/tests/source-health-oracle.test.ts` (`source-health.history`); the `problem values`, reload invariant, request error, crash recovery, failed join load, root request, Effect, pooled view, and include node lanes are in `packages/db/tests/source-health.test.ts` (fixed tests; no oracle owner (tracked in #2264)) | An independent per-Collection model of the first-load gate, `failureReason` and stopped `error` slots with serials and each slot's frame of failures (maintainer decision: a problem shows its newest failure and lists its frame in `errors`; a failure reason's frame is its sync run's reports until `reportHealthy()`, once per error value, less each failure `reportHealthy(error)` removed, and a reload's new run that reports starts a new one), crashes, and reloads derives each live query's status and expected health tree for direct, join, self-join, nested, include, and pooled-nested shapes. Generated histories cross `ready`, `fail`, `report`, `healthy`, `crash`, `reload`, `reload-cleanup-fails` (a retired cleanup that rejects crashes the Collection), `reload-cleanup-held` and `retired-cleanup-rejects` (a retired run's cleanup that rejects later still crashes the Collection, also after a cleanup and a fresh start: the adapter broke its contract for that Collection, by maintainer decision), `cancel` (a commit with an aborted signal changes nothing), `reload-query` (a query's `reload()` reaches every Collection below it that shows an `error`, through nested queries; a crashed query rejects without touching its sources), `cleanup-reload` (cleanup, a fresh start, and a reload in one tick: the cleaned-up run's reload decides nothing for the new one), `snapshot` (`begin({ snapshot: true })` replaces every row at its commit, at any time, also when the reload fails or the Collection crashes before that commit; maintainer decision: only a snapshot or the adapter's `truncate()` replaces rows, and every other write of a reload's new run applies on the kept rows), `mark-ready` (a reload without a snapshot completes at the new run's `markReady()` and keeps the rows; a run's `markReady()` after it is ready or after its `fail()` does nothing, by maintainer decision), `reload-ready-throws` (a first-ready callback that throws while a reload's new run marks ready inside `sync()`, also when the reload starts a cleaned-up Collection through its preload, is reported uncaught and the Collection stays ready, by maintainer decision), and restart. A second `fail()` in a sync run does nothing. A `problem values` lane checks that `fail()`, `reportError()`, and a crash behave the same for any value, also one that cannot describe itself. The Effect lane checks that a problem a source already shows when the Effect's gate opens reaches `onHealthChange` once, that a healthy gate announces nothing, and that each later change is one call. The `effect healthy gate` lane crosses where the gate opens: at the Effect's start, inside an eager source's, a nested query's, or a join's second source's `status:change`, or when an on-demand load settles. A healthy gate that announced its own tree for the status change that opened it failed the three `status:change` openers; an Effect that skips its first healthy change fails the on-demand opener. The lazy grammar's `reload-in-load` checks that a reload from inside the adapter's `loadSubset` rejects and leaves the query running. A query-db receiving witness checks that a direct insert, update, or delete during a reload applies on the kept rows, because it is not a snapshot, and ends with the same rows as during a refetch in flight. A second query-db receiving witness, `housekeeping during a reload` (`query-db.reload-housekeeping`), checks that Query Collection's retention housekeeping keeps the rows during a reload of an eager persisted Collection: an expired persisted retention entry is removed by its expiry timer or by the new run's startup maintenance while the new run's Query fetch is held, and the rows stay, the reload stays in flight, and it ends with the same rows as a refetch. A fourth kind, `expiry-timer-lagging-clock`, makes the expiry timer fire before `Date.now()` reaches `expiresAt`; the entry must still expire in that run, and a callback that drops the early timer fails every time (this was a CI flake and a production bug). The reach counts only begins of the run after the retired one, and the window is observed by polling until the entry is gone. A housekeeping `begin()` marked `snapshot: true` fails it: it replaces the kept rows with none. A third kind, `release-at-startup`, reaches the retention entry that a subset release writes: during a reload of an on-demand persisted Collection, the driver requests and releases a fresh subset through a change subscription in every microtask turn until the new run's `markReady()`, and in the turns after its startup retention maintenance settles each release writes its entry while the reload is in flight; the rows stay and the reload stays in flight in every turn, and it ends with the same rows as a refetch. A retention write marked `snapshot: true` fails it. Release of a Query's rows also begins a housekeeping transaction, but no legal history begins it in the new run while the reload is in flight: core runs the run's cleanup only after retiring its callbacks, and a release takes that path only without a persistence capability, where Query Collection writes no retention entry, so the new on-demand run calls `markReady()` inside `sync()`; a snapshot there is an equivalent mutant for the lane. The lazy grammar's `deferredStart` dimension starts the child the way a persisted Collection does, after the query asked for data, so every lazy history can begin with a first load that enters the adapter from the deferred start; with `reload-in-first-load` that load calls `reload()`, which must reject (a deferred call that skips the guard fails the generated property, shrunk to the empty history). The lazy join grammar checks which request error the query's node shows after every step: every failure has its own message, and the model shows the newest open failure by the order failures became request errors (maintainer decision). It also compares the node's `errors` with the model's frame after every step: every open request failure once, oldest first, each the very value the adapter failed with; a shared error keeps the place of its first failure, and a failed replay lists its failures in failure order, with the newest on top (maintainer decision; the subscription's own replay error stays its earliest held failure). The window grammar makes the same comparison. A replay's verdict decides every request error of the demand it replayed (maintainer decision: coarse verdict): loads that settle while it is judged decide none themselves, its success clears them all, and its failure replaces them with its failures, every failed load of its current attempt that the query still holds for a key that no later held load loaded. Closed from 30-times runs, each now a named history: a query's reload passes through a nested query that a cascade ended, which is a Collection below it that shows A's error, so that inner query's own reload reloads A and starts it, also when another source's reload rejects and the outer query does not start (`a query reload starts an ended nested query even when another source rejects`; the model had reloaded A past the ended inner query). A synchronous throw from `loadSubset` is a rejection (maintainer decision): the lazy driver's `throw-next` failure holds its demand, so the next demand change or replay requests its key again, and the churn replacement twins (`a churn replacement that throws|rejects leaves its key to the older load after the next demand change`) must agree; the driver sees the abort that releases a thrown load's demand. A replay load the query released does not count for the replay's verdict, even when it loaded its keys before the release (law 9: the replay succeeds only as a replacement for the demand that remains; `a released replay load's success does not count for the replay's verdict`; model). A failed replay request that the query released holds nothing, so a parked demand change that asks for its key again parks it (law 9; `a parked growth asks again for a key whose released replay request failed`; model). The main grammar projects every problem's `errors` (messages, order, and which report each cause is) and rejects a problem whose `message`, `cause`, or `at` is not its newest failure's; it checks that a legal `report` or `healthy` announces one `health:change` (a self-join announced two, one per alias, before the relay was registered once per Collection). The demand model treats a replay's failed request of a parked acquisition, which never loaded, as that demand's own failed first load: it covers nothing, and the next demand change parks a retry that holds the failed replay open (law 10; a 30-times counterexample on b521d29d9 of the model, which let the request cover the key, pinned as `a demand change retries a parked key whose replay request failed`; a production mutant that retires the failed segment without a retry fails it and 12 other lazy histories). The `request error bookkeeping` property checks the shared tracker's listed causes and its change result, including a request with several failures. Its `fail-again` step fails an issuer again with every cause it still lists and one new cause, as a replay's later verdict does: a cause still open keeps the place of its first failure (law 17; RED on d0cde4778, which released the issuer's causes before it counted the new list). The lazy and window grammars make `Date.now()` strictly increasing and check every request error's `at` after each step: a failure the model did not show anew keeps the `at` the node showed for it (RED on d0cde4778 in the named history `a failed replay's error stays when parked demand retires after the child restarts` and in the generated lazy property; reach `replay verdict lists an open failure again`, entered by a prefix in which a parked demand joins a failed replay; a mutant that clears the verdict's request errors before it reports its failures fails the named history). Both grammars also check that no `health:change` of a ready query repeats the tree before it. Open (R18, maintainer decision pending on code weight): a replay's verdict, and a lazy load or demand change that decides several keys, announce once per request error they change, so a listener sees intermediate trees (a verdict that replaces key errors with its failures first shows them cleared); a check that every announcement shows a request error frame the model held fails on d0cde4778 (for example `a failed replay's verdict replaces an earlier open failure`, step 5) and passes with a lifecycle `changeRequestErrors(change)` batch that costs about +57 B gzip, so it is not committed. Limits: these announcement checks skip trees whose query is not ready, because a source crash makes the query announce the same crashed tree twice in one step (seen on d0cde4778 at the lazy grammar's `crash` step); the main grammar owns crash announcements and does not count them. Not fixed (R3, R5): during a reload, before it completes, the new run's `reportHealthy()` clears the retired run's failure reason, and a query's own reload drops the retired run's request errors at the new run's first request error; the main and window models encode that behavior, which is in tension with law 16 ("a reload keeps the node's problems until it completes"), and needs a maintainer decision. Not reached (R8): a retired run's committed but unapplied truncate, from a listener that reenters during `commitPendingTransactions`, can complete the new run's reload. A deterministic work witness (`request-errors-work.test.ts`) bounds one failed 2,000-key segment's report-and-settle work at 20·K visits; the original scan-and-sort tracker used 14,007,001. A lazy named history pins that a demand change which retires a fragmented-churn replacement and another segment, while the replaced segment's replay load has failed, fails the replay for the still-held key (laws 9 and 16); the subscription lifecycle owner's demand-change grammar generates the class. A failed replay does not list the failure of a load the query released before the verdict (law 16; glossary request error: it clears when its demand retires); the named history `a failed replay does not list a load the query released before its verdict` fails on the model that listed it and on a production mutant that keeps a released demand's failure. A cleanup during a reload (ARCHITECTURE law 14; maintainer decision that a reload ends with its run) is a generated step in two grammars. The main grammar's `cleanup-during-reload` runs `cleanup({ dependents: true })` from the Collection's or the dependent query's `health:change` while `reload()` announces `reloading: true`. The lazy grammar's `ending` runs `cleanup()` or `cleanup({ dependents: true })` from those, from the Collection's `loadingSubset:change`, or from a change subscription's `status:change` while the reload retires its demand. In both, the model is an ordinary cleanup: no sync run stays live at the adapter, a reload in flight rejects, the Collection ends `cleaned-up`, and a later fresh start's commit is its diff baseline. Setting the epoch after the reload's announcement, or the truncate after the run's retirement, fails 12 histories; restoring the old flag order, or dropping the epoch return, fails the subscription-status ending. Cleanup from the retired run's adapter cleanup function or from the new run's `sync()` is outside this lane. A cleanup of a Collection that a cleanup during a reload left `cleaned-up` has no run to wait for and settles at once; the driver holds an adapter cleanup only while a run is live (a 30-times counterexample of the driver). Focused witnesses check that a reload from inside a `loadSubset` call the deferred start runs rejects, that a cleanup run from a subscription's status listener while a reload retires the run ends that reload with its run (no sync run starts and no replacement stays armed), and that a lazy join load that fails after readiness, under an ordered window that filters on it, publishes the output it held. The main grammar also compares the query's row count at the `status:change` that makes it ready with the count the step leaves (law 9): A's truncate commit that marks A ready while the nested shape's outer query waits for its inner query reaches it, in the first load and in a reload of a failed first load; a completion check that ignores the graph's pending work fails it. Limits: row count only, not identity. After every step the check compares status, rows, the projected tree, node identity, `status:change`/`health:change` order, and `findProblems()`. Named histories pin a reload's new-run `failureReason` surviving its completion. Focused lanes cover request errors, overlapping root loads (a request error clears only when a load started after the failure succeeds; 8 histories plus a later clearing load), a query's own failed first load not restarting when an unrelated source becomes ready, reload invariant witnesses (the retired run's open sync transaction is dropped; a canceled reload commit changes nothing and the reload keeps the rows; a diffing adapter sees an empty baseline inside a reload's snapshot), and Effect disposal on a failed first load or crash. The shared framework conformance scenarios `sync-errors-after-readiness` and `crashed-status` judge the hooks; `sync-errors-after-readiness` reads a two-failure `failureReason.errors` and a one-failure `error.errors` through every framework driver. Adapter receivers for reload after a failed first load (the new run's own readiness decides it, not Collection status): query-db, RxDB, and PowerSync focused tests, Electric's integration test, and TrailBase's lifecycle lane. Adapter receivers for reload of a ready Collection (maintainer decision F19: the reload keeps the rows until it completes; only a `begin({ snapshot: true })` transaction or the new run's own `truncate()` replaces them; every other write applies on them): Electric's integration tests `reload of a ready Collection` (a resumed stream with no changes, or with an update, a delete, and an insert, applies them on the kept rows and completes at up-to-date; a stream that cannot resume replaces the rows with its fresh snapshot; a must-refetch keeps the rows through its reset-marker commit and replaces them at its snapshot) and `an on-demand reload keeps its rows and ends with the source's rows` (resumed or not: the replayed subset's rows apply on the kept rows, and the adapter writes an update for a kept key, since core no longer rewrites a kept-key insert and sweeps nothing; the adapter owns removing rows deleted while no stream listened, [#2269](https://github.com/TanStack/db/issues/2269)), and `a progressive reload that cannot resume keeps its rows through a subset load` (no kept row vanishes before the atomic swap, the kept key takes the source's value, the live query has no error; it failed on the parent core with DuplicateKeySyncError); query-db `a reload of a ready eager|on-demand Collection keeps its rows and ends as a refetch does`; PowerSync `eager mode: a reload of a ready Collection keeps its rows and ends with the table` (three batches in one snapshot transaction); RxDB `a reload of a ready Collection keeps its rows and ends with the documents` (a first run that missed changes; two fetch batches); and TrailBase's lifecycle lane `a reload of a ready eager Collection` (10 histories; see its ORACLE.md). On the parent core, Electric's resumed and must-refetch cases, query-db on-demand, PowerSync, and TrailBase's two-page cases fail; RxDB's passes and guards the port. Each of these mutants fails them: a resumed Electric run whose first commit replaces rows; a fresh Electric, PowerSync, or RxDB load without a snapshot; and a query-db housekeeping or direct write marked `snapshot: true`. A reload keeps every row its new run does not rewrite, and core deletes no kept row on its own (maintainer decision; removing a row the backend deleted is the adapter's job, [#2269](https://github.com/TanStack/db/issues/2269)); the adapters' own on-demand reload receivers belong to their packages. Adapter receivers for an on-demand reload (maintainer decision: no core sweep; a held key is written as `update`; issue #2269): Electric `an on-demand reload keeps its rows and ends with the source's rows` (resumed: deleted rows go through the log; cannot resume: a row deleted while no stream listened stays), query-db `a reload of a ready on-demand Collection keeps its rows and ends as a refetch does` (row ownership removes the deleted row), PowerSync `an unchanged row survives an on-demand reload` (the adapter's own tracking removes the deleted row), and the TrailBase lane `an unchanged row survives an on-demand reload` (a row deleted with no event stays). Without Electric's `syncedKeys` seed and its update-for-held-key write, its non-resumed and progressive witnesses fail. Subset snapshot rows go through the same `syncedKeys` check as stream rows. PowerSync `eager mode: a reload whose later batch fails keeps the rows and the next reload ends with the table` checks core state: no open transaction remains and the accepted rows equal the kept rows; a snapshot begun before the batches fails it. Abort receivers (an abort ends the load): Electric, PowerSync, TrailBase, and query-db `an aborted load writes no rows and its late result changes nothing`; each fails under a mutant that ignores the signal at that adapter's stop point. Limits: an Electric abort while a request is in flight still lets the SDK publish its rows; query-db applies a late result after an abort without `unloadSubset` (a replay replacing the load within a run), whose data is current, so it costs a write, not correctness; RxDB has no on-demand path. Electric's `a ready after the stream gave up changes nothing` witnesses that `markReady()` after the run's `fail()` is a no-op (F6): the SDK gives up while a ready run's up-to-date commit waits for a wrapping sync's acceptance, and the acceptance arrives afterwards; there is no unhandled rejection and no status or health change. It fails on the parent core with an unhandled `CollectionStateError`. Limits: the progressive and on-demand Electric witnesses are fixed examples, not generated histories. A joined-filter lane checks that a failed join load lets an ordered window move again. An Effect has no request errors: a failed load after its gate still ends it, and the Effect load-failure lane checks that. An on-demand reload lane crosses the new run's first commit (an empty `markReady()` or a row commit inside `sync()`, or `markReady()` after it returns) with an unfiltered and a filtered query. While the new run's loads are in flight, it checks that the query keeps the old run's rows and that a direct read shows the kept rows with the new run's own commit on top (no snapshot came). After the loads settle, it checks the new run's rows. A late-first-load lane enumerates an on-demand Collection that fails its first load, stops after readiness, or crashes after a direct or joined query is ready, then calls the query's `reload()`. A query that follows its source into `error` after showing data fails it, and so does a query reload that skips a source still in its first load. A focused RxDB test checks that a change-stream error calls `fail()` before and after readiness. The generated grammar also keeps each Collection's rows and the sync run's open transaction: `write` and `commit` steps span other steps, the driver writes like a diffing adapter, a `write` can omit the `tag` field (an update merges into the row, an insert replaces it, also when a failed reload's open transaction applies on the kept rows), and after every step the check compares rows, the adapter's baseline, the query's rows, a direct subscriber with the initial state on each source when the history includes one (its rows equal the Collection's within one Collection lifetime, also while a reload is in flight or after it fails; a source that starts again after cleanup gets a fresh one), and an Effect over A (ended, source errors, listeners left). `cleanup-hold` and `cleanup-settle` steps run every other step while adapter cleanup is pending, including a crash, a new Effect, and the query's reload, which restarts a query a cascade ended. A second generated property, `source-health.on-demand`, drives a root window over an on-demand Collection: window moves, settle-all outcomes, source reloads, and the source's own calls, with requested loads reported from the adapter boundary; it checks the query's status, its request error, the source's node, and the window's rows. A third, `source-health.lazy-demand`, drives an eager parent Collection left-joined to an on-demand child Collection: parents come and go, loads settle ok or failed, the child source reloads, and the source makes its own calls; the driver reports each load's requested keys from the adapter boundary. It checks the query's status, its request error (which clears when a later load of the failed key succeeds or when the query no longer needs that key), and each parent's child. The lazy and window drivers can add a direct subscriber with the initial state on the on-demand source (its rows equal the Collection's at every step; the driver answers its own requests at once), and the lazy driver can add an Effect over the same join (at quiet checkpoints it has the query's rows; a delete that a later insert undoes between checkpoints is not observed). The lazy driver also crosses a child that holds `markReady()` until a `child-ready` step, so the child can be `loading` or fail its first load when it reloads, a reload stays in flight past its new run's first commit, and a second `reload()` returns the first one's promise; and the child adapter's own `truncate()` (a must-refetch in the same sync run, also while a reload is in flight: its replay requests every needed key, also one whose load threw, and a demand change's replacement that applies releases the coverage it replaces), loads that throw, and source reloads during the first load: the first load waits for the whole replay, and a replay fails only for a key the query still needs when it is judged. The child adapter honours aborts; an adapter that ignores them is outside the contract (maintainer decision: an abort ends the load). In the on-demand lane, a window move after a failed replay waits for the next successful replay, and a later reload aborts it. A reload starts its replay when it retires the sync run, so a load the reload aborted is over and its consumers wait for the replay, as after a truncate; a named history checks that the retired run's load decides nothing when its abort rejects it. A named history pins that a truncate after a reload's failed replay replays the demand again. The on-demand window grammar adds `settle-aborted-fail` and `settle-aborted-ok`, which settle only the loads the query aborted, with declared reach for an aborted load that settles beside a pending load after a request error. A source reload that aborts the app's retry after a request error leaves that error to the replay's verdict: the retry's late rejection or success decides nothing, and a failed replay's verdict replaces the retry's failure. On 2bc79fc7c the coarse verdict's window histories fail, and a mutant whose replay failure reports no error fails 37 tests. Window moves never abort a root load outside a replay, so a root load's late success after an abort outside a replay is unreached (a mutant that lets it clear the root error survives); page-distinct root failures are not modelled: the grammar keeps one logical window demand, as law 16's "later load of that Collection" wording allows. The lazy grammar's `remote-delete` step deletes a child at the provider with no stream event; a child Collection rows check after every step pins that a reload keeps that row (maintainer decision: no reload sweep; RED on 2bc79fc7c, which swept it). The main grammar's blind `insert` write pins that an insert of a key the baseline holds throws, during a reload as at any time (maintainer decision; RED on 2bc79fc7c, which turned a reload's insert of a row the retired run left into an update; a mutant that lets a reload's insert of a held key apply fails 12 named histories); the lazy grammar's diffing adapter writes an `update` for a kept child. Limits: the window grammar has no provider deletion. Every truncate replays the demand again, also one the reload's new run sends while the reload's own replay is open or after it settled (F8: a latch that skipped the reload's own truncate let a replacement delete replayed rows with nothing re-requesting them); a reload with no held acquisition keeps the rows and replays nothing. The window grammar's `reload-query` reaches the query's own reload while its node shows a request error: the query keeps its rows, its node's error, and `reloading` until its new run's window load settles, keeps the window it showed (a window move waiting for a replay ends with the retired run), and then shows only the new run's failure, if any (F4). During the query's own reload the window grammar also issues window moves and `restart`. A move of the new run whose load fails keeps the reload, its rows, `reloading`, and its promise until a later move's load succeeds, and a failure the new run reports shows on the node at once (maintainer decision). `restart` (cleanup and preload of the query) discards the window and any own reload in flight, so the new lifetime loads the initial window. Its limits: no source reload, report, or aborted settle during the own reload; the reload starts only when the source shows no problem; and a restart ends the request-error announcement watch for the rest of the history. Named histories also pin that a later load of a failed key clears its request error while an unrelated load is still pending, that a load failing during a replay is decided by the replay's verdict, that a reload during the first load fails it only for a demand still held when the replay is judged, and that a reload keeps a window move that joined the replay while it aborts a later move that waited. Mutants that restore last round's reload, cleanup, Effect, and replay bugs, and a request error that outlives the failed key's demand, each fail named histories. Witnesses pin that cleanup start announces the problems it drops, that a reload rejects during pending adapter cleanup, that a ready callback that throws still settles a reload, that a failed reload keeps its rows as a diffing adapter's baseline, that a query's own reload settles with its new run (a load that fails again, or a graph that throws), that a source's or the query's reload clears a request error, that an Effect over a source already in `error` or `crashed` ends, and that a pooled view keeps node identity when its source alias is `root`. Later witnesses pin that a reload's open transaction applies on the kept rows after the new run fails, that an Effect over a stopped source leaves no load, subscription, or listener on it, that cleanup start keeps an `error` status's cause, that a source reload's replay keeps a request error that failed after it started, and (query-db) that a late recovery counts when another Query fails meanwhile. The query-db lane `failureReason follows the still-failing Queries` (`query-db.failing-queries`) enumerates every history of four fetches over two subsets' Queries, directly and behind the persisted wrapper (a receiving witness: the wrapper must relay `reportHealthy(error)`'s value). Its model keeps each failing Query's latest error value and the listed values, one per value, oldest first (review: one entry per failing Query, and a value stays listed while any failing Query holds it); fetches succeed, fail with a fresh error, or fail with one shared value; after every fetch it compares the listed causes by identity and order, the shown `message` and `cause`, each kept failure's `at`, and health tree identity (ARCHITECTURE law 17): a fetch that leaves the frame as it was keeps the node and its `failureReason` object and announces no `health:change`. Declared reach includes a Query that fails again while listed and one that recovers with two failures listed while another fails. RED on the parent: a recovered Query's failures stayed listed. The main grammar's `report` and `healthy` steps can name a listed failure's error value or an unlisted value with the same message; a report of a listed value and a healthy call for an unlisted one leave the frame as it was and must announce nothing (maintainer decision), and a healthy call for a listed value removes only that failure. Limits: replacing a Query's failure announces two health:change events (add, then remove). The [snapshot oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/live-query-observer-snapshot-oracle.property.test.ts)'s health lane (`live-query-observer.snapshot-health`) generates `report`, `healthy`, `write`, `subscribe`, `unsubscribe`, and `read` over one ready source and checks that every `getSnapshot()` shows the current `failureReason`, `hasErrors`, and `isSyncError`, on detached reads, attached reads, and across the attach cut, and that the tree keeps its identity while unchanged; after a round trip between reads that ends on the previous read's content, either identity is legal (glossary, health tree). Each read also compares the `failureReason`'s `errors` (messages in order, each cause by identity) with the model's frame, and a declared reach state reads a frame of several failures. Limits: one source, `failureReason` only, no hydration seed, no framework. The shared conformance cells `crashed-status` and `crashed-status-during-first-load-{collection,query}` read each driver's status flags without reading `data`, and require Solid's resource path to show `crashed`, not `error`, when preload rejects for a crashed Collection. By maintainer decision, an error boundary receives an Error for a failed first load or a crash: the cause itself when it is an Error, otherwise an Error with the problem's message whose `cause` is the reported value; React's `useLiveSuspenseQuery` and Solid's resource witnesses cover a failed first load and a crash, each with an Error and a non-Error cause: a failure inside sync and after mount, a sync function that throws, and a later crash (after readiness for React, during the first load for Solid). A crash path that throws the raw cause fails the non-Error crash cells in both. The replay refinement oracle's `settle` command can keep holding loads that start during the step, so a held retry keeps a failed join load's request error visible. A crash-recovery witness checks that nothing recovers a crash in place and that a cascade cleanup gives a fresh start with only the new run's rows; a `reload()` that recovers a crash, a cleanup that keeps the crashed run's rows, and a crash that survives cleanup each fail it. The three generated properties share one model: the main grammar's source model drives the on-demand Collection of the window and lazy grammars too (a load that succeeds is one sync transaction, so a reload's first load replaces every row), and one demand model, from ARCHITECTURE laws 9, 10, and 16, judges the window grammar's single window demand and the lazy grammar's per-parent keys. The lazy grammar checks the request error after every step, also while a replay is open or after one failed, and both grammars check a coverage law: once the query is ready, every held key has a load the adapter still serves, its rows, a replay failure awaiting judgment, or a shown request error (an aborted load without a replacement fails it). Named histories pin the cases that closing the earlier open item found: a demand parked behind a child whose first load failed joins the open replay, also an in-flight one when the child restarts (the CI counterexample seed -838690579: production replaced the in-flight replay and lost its own failed load; `a reload after the source stopped replays its demand and fails with every load`); a failed replay stays open while the query holds demand, and its error clears when its failed demand retires after the rest loaded (law 9); a load the truncate aborted does not clear a request error; a held replacement that loaded decides the demands it replaces; and a cascade cancels a query's pending reload. Law-silent, recorded with the maintainer: a truncate's `markReady` option does nothing on a ready Collection, also after `fail()`; a cleanup after a crash that followed cleanup settles at once; a truncate after `fail()` is outside the grammars; a window move with no request is served from loaded rows. A child whose first load failed parks new demand without a request; by maintainer decision the lazy model predicts the parked acquisition from law 10 (growth or churn), and a declared reach state and prefix enter that region. Named histories pin that a replay re-requests parked demand and then publishes, that a released parked demand parks again, that a replay's failed request keeps its acquisition's coverage, and that a later change drops a parked replacement that no longer matches. ARCHITECTURE law 16 states the rule. A query that crashes while it starts, because a Collection it reads is still cleaning up, reads nothing: it announces nothing more and no cascade ends it (maintainer decision). A pooled view that is unsubscribed, outlives its source's `cleanup({ dependents: true })` and restart, and then subscribes again stays `cleaned-up`, while a live-query Collection restarts; the pooled cleanup-admission lane crosses observed and unobserved views but never restarts their source; a follower tab does not see a provider failure on the leader outside a pending request. | +| Collection status and health tree | `packages/db/tests/source-health-oracle.test.ts` (`source-health.history`, `source-health.required-history`); the `problem values`, reload invariant, request error, crash recovery, failed join load, root request, Effect, pooled view, and include node lanes are in `packages/db/tests/source-health.test.ts` (fixed tests; no oracle owner (tracked in #2264)) | An independent per-Collection model of the first-load gate, `failureReason` and stopped `error` slots with serials and each slot's frame of failures (maintainer decision: a problem shows its newest failure and lists its frame in `errors`; a failure reason's frame is its sync run's reports until `reportHealthy()`, once per error value, less each failure `reportHealthy(error)` removed, and a reload's new run that reports starts a new one), crashes, and reloads derives each live query's status and expected health tree for direct, join, self-join, nested, include, and pooled-nested shapes. The additive `source-health.required-history` lanes execute all nine existing reach prefixes with generated suffixes, in join/nested contexts with direct subscribers. Each fixed/random lane independently enforces all 26 reach states; the original 60 random histories retain six shapes and both subscriber choices. Generated histories cross `ready`, `fail`, `report`, `healthy`, `crash`, `reload`, `reload-cleanup-fails` (a retired cleanup that rejects crashes the Collection), `reload-cleanup-held` and `retired-cleanup-rejects` (a retired run's cleanup that rejects later still crashes the Collection, also after a cleanup and a fresh start: the adapter broke its contract for that Collection, by maintainer decision), `cancel` (a commit with an aborted signal changes nothing), `reload-query` (a query's `reload()` reaches every Collection below it that shows an `error`, through nested queries; a crashed query rejects without touching its sources), `cleanup-reload` (cleanup, a fresh start, and a reload in one tick: the cleaned-up run's reload decides nothing for the new one), `snapshot` (`begin({ snapshot: true })` replaces every row at its commit, at any time, also when the reload fails or the Collection crashes before that commit; maintainer decision: only a snapshot or the adapter's `truncate()` replaces rows, and every other write of a reload's new run applies on the kept rows), `mark-ready` (a reload without a snapshot completes at the new run's `markReady()` and keeps the rows; a run's `markReady()` after it is ready or after its `fail()` does nothing, by maintainer decision), `reload-ready-throws` (a first-ready callback that throws while a reload's new run marks ready inside `sync()`, also when the reload starts a cleaned-up Collection through its preload, is reported uncaught and the Collection stays ready, by maintainer decision), and restart. A second `fail()` in a sync run does nothing. A `problem values` lane checks that `fail()`, `reportError()`, and a crash behave the same for any value, also one that cannot describe itself. The Effect lane checks that a problem a source already shows when the Effect's gate opens reaches `onHealthChange` once, that a healthy gate announces nothing, and that each later change is one call. The `effect healthy gate` lane crosses where the gate opens: at the Effect's start, inside an eager source's, a nested query's, or a join's second source's `status:change`, or when an on-demand load settles. A healthy gate that announced its own tree for the status change that opened it failed the three `status:change` openers; an Effect that skips its first healthy change fails the on-demand opener. The lazy grammar's `reload-in-load` checks that a reload from inside the adapter's `loadSubset` rejects and leaves the query running. A query-db receiving witness checks that a direct insert, update, or delete during a reload applies on the kept rows, because it is not a snapshot, and ends with the same rows as during a refetch in flight. A second query-db receiving witness, `housekeeping during a reload` (`query-db.reload-housekeeping`), checks that Query Collection's retention housekeeping keeps the rows during a reload of an eager persisted Collection: an expired persisted retention entry is removed by its expiry timer or by the new run's startup maintenance while the new run's Query fetch is held, and the rows stay, the reload stays in flight, and it ends with the same rows as a refetch. A fourth kind, `expiry-timer-lagging-clock`, makes the expiry timer fire before `Date.now()` reaches `expiresAt`; the entry must still expire in that run, and a callback that drops the early timer fails every time (this was a CI flake and a production bug). The reach counts only begins of the run after the retired one, and the window is observed by polling until the entry is gone. A housekeeping `begin()` marked `snapshot: true` fails it: it replaces the kept rows with none. A third kind, `release-at-startup`, reaches the retention entry that a subset release writes: during a reload of an on-demand persisted Collection, the driver requests and releases a fresh subset through a change subscription in every microtask turn until the new run's `markReady()`, and in the turns after its startup retention maintenance settles each release writes its entry while the reload is in flight; the rows stay and the reload stays in flight in every turn, and it ends with the same rows as a refetch. A retention write marked `snapshot: true` fails it. Release of a Query's rows also begins a housekeeping transaction, but no legal history begins it in the new run while the reload is in flight: core runs the run's cleanup only after retiring its callbacks, and a release takes that path only without a persistence capability, where Query Collection writes no retention entry, so the new on-demand run calls `markReady()` inside `sync()`; a snapshot there is an equivalent mutant for the lane. The lazy grammar's `deferredStart` dimension starts the child the way a persisted Collection does, after the query asked for data, so every lazy history can begin with a first load that enters the adapter from the deferred start; with `reload-in-first-load` that load calls `reload()`, which must reject (a deferred call that skips the guard fails the generated property, shrunk to the empty history). The lazy join grammar checks which request error the query's node shows after every step: every failure has its own message, and the model shows the newest open failure by the order failures became request errors (maintainer decision). It also compares the node's `errors` with the model's frame after every step: every open request failure once, oldest first, each the very value the adapter failed with; a shared error keeps the place of its first failure, and a failed replay lists its failures in failure order, with the newest on top (maintainer decision; the subscription's own replay error stays its earliest held failure). The window grammar makes the same comparison. A replay's verdict decides every request error of the demand it replayed (maintainer decision: coarse verdict): loads that settle while it is judged decide none themselves, its success clears them all, and its failure replaces them with its failures, every failed load of its current attempt that the query still holds for a key that no later held load loaded. Closed from 30-times runs, each now a named history: a query's reload passes through a nested query that a cascade ended, which is a Collection below it that shows A's error, so that inner query's own reload reloads A and starts it, also when another source's reload rejects and the outer query does not start (`a query reload starts an ended nested query even when another source rejects`; the model had reloaded A past the ended inner query). A synchronous throw from `loadSubset` is a rejection (maintainer decision): the lazy driver's `throw-next` failure holds its demand, so the next demand change or replay requests its key again, and the churn replacement twins (`a churn replacement that throws | rejects leaves its key to the older load after the next demand change`) must agree; the driver sees the abort that releases a thrown load's demand. A replay load the query released does not count for the replay's verdict, even when it loaded its keys before the release (law 9: the replay succeeds only as a replacement for the demand that remains; `a released replay load's success does not count for the replay's verdict`; model). A failed replay request that the query released holds nothing, so a parked demand change that asks for its key again parks it (law 9; `a parked growth asks again for a key whose released replay request failed`; model). The main grammar projects every problem's `errors`(messages, order, and which report each cause is) and rejects a problem whose`message`, `cause`, or `at`is not its newest failure's; it checks that a legal`report`or`healthy`announces one`health:change`(a self-join announced two, one per alias, before the relay was registered once per Collection). The demand model treats a replay's failed request of a parked acquisition, which never loaded, as that demand's own failed first load: it covers nothing, and the next demand change parks a retry that holds the failed replay open (law 10; a 30-times counterexample on b521d29d9 of the model, which let the request cover the key, pinned as`a demand change retries a parked key whose replay request failed`; a production mutant that retires the failed segment without a retry fails it and 12 other lazy histories). The `request error bookkeeping`property checks the shared tracker's listed causes and its change result, including a request with several failures. Its`fail-again`step fails an issuer again with every cause it still lists and one new cause, as a replay's later verdict does: a cause still open keeps the place of its first failure (law 17; RED on d0cde4778, which released the issuer's causes before it counted the new list). The lazy and window grammars make`Date.now()`strictly increasing and check every request error's`at`after each step: a failure the model did not show anew keeps the`at`the node showed for it (RED on d0cde4778 in the named history`a failed replay's error stays when parked demand retires after the child restarts`and in the generated lazy property; reach`replay verdict lists an open failure again`, entered by a prefix in which a parked demand joins a failed replay; a mutant that clears the verdict's request errors before it reports its failures fails the named history). Both grammars also check that no `health:change`of a ready query repeats the tree before it. Open (R18, maintainer decision pending on code weight): a replay's verdict, and a lazy load or demand change that decides several keys, announce once per request error they change, so a listener sees intermediate trees (a verdict that replaces key errors with its failures first shows them cleared); a check that every announcement shows a request error frame the model held fails on d0cde4778 (for example`a failed replay's verdict replaces an earlier open failure`, step 5) and passes with a lifecycle `changeRequestErrors(change)`batch that costs about +57 B gzip, so it is not committed. Limits: these announcement checks skip trees whose query is not ready, because a source crash makes the query announce the same crashed tree twice in one step (seen on d0cde4778 at the lazy grammar's`crash`step); the main grammar owns crash announcements and does not count them. Not fixed (R3, R5): during a reload, before it completes, the new run's`reportHealthy()`clears the retired run's failure reason, and a query's own reload drops the retired run's request errors at the new run's first request error; the main and window models encode that behavior, which is in tension with law 16 ("a reload keeps the node's problems until it completes"), and needs a maintainer decision. Not reached (R8): a retired run's committed but unapplied truncate, from a listener that reenters during`commitPendingTransactions`, can complete the new run's reload. A deterministic work witness (`request-errors-work.test.ts`) bounds one failed 2,000-key segment's report-and-settle work at 20·K visits; the original scan-and-sort tracker used 14,007,001. A lazy named history pins that a demand change which retires a fragmented-churn replacement and another segment, while the replaced segment's replay load has failed, fails the replay for the still-held key (laws 9 and 16); the subscription lifecycle owner's demand-change grammar generates the class. A failed replay does not list the failure of a load the query released before the verdict (law 16; glossary request error: it clears when its demand retires); the named history `a failed replay does not list a load the query released before its verdict`fails on the model that listed it and on a production mutant that keeps a released demand's failure. A cleanup during a reload (ARCHITECTURE law 14; maintainer decision that a reload ends with its run) is a generated step in two grammars. The main grammar's`cleanup-during-reload`runs`cleanup({ dependents: true })`from the Collection's or the dependent query's`health:change`while`reload()`announces`reloading: true`. The lazy grammar's `ending`runs`cleanup()`or`cleanup({ dependents: true })`from those, from the Collection's`loadingSubset:change`, or from a change subscription's `status:change`while the reload retires its demand. In both, the model is an ordinary cleanup: no sync run stays live at the adapter, a reload in flight rejects, the Collection ends`cleaned-up`, and a later fresh start's commit is its diff baseline. Setting the epoch after the reload's announcement, or the truncate after the run's retirement, fails 12 histories; restoring the old flag order, or dropping the epoch return, fails the subscription-status ending. Cleanup from the retired run's adapter cleanup function or from the new run's `sync()`is outside this lane. A cleanup of a Collection that a cleanup during a reload left`cleaned-up`has no run to wait for and settles at once; the driver holds an adapter cleanup only while a run is live (a 30-times counterexample of the driver). Focused witnesses check that a reload from inside a`loadSubset`call the deferred start runs rejects, that a cleanup run from a subscription's status listener while a reload retires the run ends that reload with its run (no sync run starts and no replacement stays armed), and that a lazy join load that fails after readiness, under an ordered window that filters on it, publishes the output it held. The main grammar also compares the query's row count at the`status:change`that makes it ready with the count the step leaves (law 9): A's truncate commit that marks A ready while the nested shape's outer query waits for its inner query reaches it, in the first load and in a reload of a failed first load; a completion check that ignores the graph's pending work fails it. Limits: row count only, not identity. After every step the check compares status, rows, the projected tree, node identity,`status:change`/`health:change`order, and`findProblems()`. Named histories pin a reload's new-run `failureReason`surviving its completion. Focused lanes cover request errors, overlapping root loads (a request error clears only when a load started after the failure succeeds; 8 histories plus a later clearing load), a query's own failed first load not restarting when an unrelated source becomes ready, reload invariant witnesses (the retired run's open sync transaction is dropped; a canceled reload commit changes nothing and the reload keeps the rows; a diffing adapter sees an empty baseline inside a reload's snapshot), and Effect disposal on a failed first load or crash. The shared framework conformance scenarios`sync-errors-after-readiness`and`crashed-status`judge the hooks;`sync-errors-after-readiness`reads a two-failure`failureReason.errors`and a one-failure`error.errors`through every framework driver. Adapter receivers for reload after a failed first load (the new run's own readiness decides it, not Collection status): query-db, RxDB, and PowerSync focused tests, Electric's integration test, and TrailBase's lifecycle lane. Adapter receivers for reload of a ready Collection (maintainer decision F19: the reload keeps the rows until it completes; only a`begin({ snapshot: true })`transaction or the new run's own`truncate()`replaces them; every other write applies on them): Electric's integration tests`reload of a ready Collection`(a resumed stream with no changes, or with an update, a delete, and an insert, applies them on the kept rows and completes at up-to-date; a stream that cannot resume replaces the rows with its fresh snapshot; a must-refetch keeps the rows through its reset-marker commit and replaces them at its snapshot) and`an on-demand reload keeps its rows and ends with the source's rows`(resumed or not: the replayed subset's rows apply on the kept rows, and the adapter writes an update for a kept key, since core no longer rewrites a kept-key insert and sweeps nothing; the adapter owns removing rows deleted while no stream listened, [#2269](https://github.com/TanStack/db/issues/2269)), and`a progressive reload that cannot resume keeps its rows through a subset load`(no kept row vanishes before the atomic swap, the kept key takes the source's value, the live query has no error; it failed on the parent core with DuplicateKeySyncError); query-db`a reload of a ready eager | on-demand Collection keeps its rows and ends as a refetch does`; PowerSync `eager mode: a reload of a ready Collection keeps its rows and ends with the table`(three batches in one snapshot transaction); RxDB`a reload of a ready Collection keeps its rows and ends with the documents`(a first run that missed changes; two fetch batches); and TrailBase's lifecycle lane`a reload of a ready eager Collection`(10 histories; see its ORACLE.md). On the parent core, Electric's resumed and must-refetch cases, query-db on-demand, PowerSync, and TrailBase's two-page cases fail; RxDB's passes and guards the port. Each of these mutants fails them: a resumed Electric run whose first commit replaces rows; a fresh Electric, PowerSync, or RxDB load without a snapshot; and a query-db housekeeping or direct write marked`snapshot: true`. A reload keeps every row its new run does not rewrite, and core deletes no kept row on its own (maintainer decision; removing a row the backend deleted is the adapter's job, [#2269](https://github.com/TanStack/db/issues/2269)); the adapters' own on-demand reload receivers belong to their packages. Adapter receivers for an on-demand reload (maintainer decision: no core sweep; a held key is written as `update`; issue #2269): Electric `an on-demand reload keeps its rows and ends with the source's rows`(resumed: deleted rows go through the log; cannot resume: a row deleted while no stream listened stays), query-db`a reload of a ready on-demand Collection keeps its rows and ends as a refetch does`(row ownership removes the deleted row), PowerSync`an unchanged row survives an on-demand reload`(the adapter's own tracking removes the deleted row), and the TrailBase lane`an unchanged row survives an on-demand reload`(a row deleted with no event stays). Without Electric's`syncedKeys`seed and its update-for-held-key write, its non-resumed and progressive witnesses fail. Subset snapshot rows go through the same`syncedKeys`check as stream rows. PowerSync`eager mode: a reload whose later batch fails keeps the rows and the next reload ends with the table`checks core state: no open transaction remains and the accepted rows equal the kept rows; a snapshot begun before the batches fails it. Abort receivers (an abort ends the load): Electric, PowerSync, TrailBase, and query-db`an aborted load writes no rows and its late result changes nothing`; each fails under a mutant that ignores the signal at that adapter's stop point. Limits: an Electric abort while a request is in flight still lets the SDK publish its rows; query-db applies a late result after an abort without `unloadSubset`(a replay replacing the load within a run), whose data is current, so it costs a write, not correctness; RxDB has no on-demand path. Electric's`a ready after the stream gave up changes nothing`witnesses that`markReady()`after the run's`fail()`is a no-op (F6): the SDK gives up while a ready run's up-to-date commit waits for a wrapping sync's acceptance, and the acceptance arrives afterwards; there is no unhandled rejection and no status or health change. It fails on the parent core with an unhandled`CollectionStateError`. Limits: the progressive and on-demand Electric witnesses are fixed examples, not generated histories. A joined-filter lane checks that a failed join load lets an ordered window move again. An Effect has no request errors: a failed load after its gate still ends it, and the Effect load-failure lane checks that. An on-demand reload lane crosses the new run's first commit (an empty `markReady()`or a row commit inside`sync()`, or `markReady()`after it returns) with an unfiltered and a filtered query. While the new run's loads are in flight, it checks that the query keeps the old run's rows and that a direct read shows the kept rows with the new run's own commit on top (no snapshot came). After the loads settle, it checks the new run's rows. A late-first-load lane enumerates an on-demand Collection that fails its first load, stops after readiness, or crashes after a direct or joined query is ready, then calls the query's`reload()`. A query that follows its source into `error`after showing data fails it, and so does a query reload that skips a source still in its first load. A focused RxDB test checks that a change-stream error calls`fail()`before and after readiness. The generated grammar also keeps each Collection's rows and the sync run's open transaction:`write`and`commit`steps span other steps, the driver writes like a diffing adapter, a`write`can omit the`tag`field (an update merges into the row, an insert replaces it, also when a failed reload's open transaction applies on the kept rows), and after every step the check compares rows, the adapter's baseline, the query's rows, a direct subscriber with the initial state on each source when the history includes one (its rows equal the Collection's within one Collection lifetime, also while a reload is in flight or after it fails; a source that starts again after cleanup gets a fresh one), and an Effect over A (ended, source errors, listeners left).`cleanup-hold`and`cleanup-settle`steps run every other step while adapter cleanup is pending, including a crash, a new Effect, and the query's reload, which restarts a query a cascade ended. A second generated property,`source-health.on-demand`, drives a root window over an on-demand Collection: window moves, settle-all outcomes, source reloads, and the source's own calls, with requested loads reported from the adapter boundary; it checks the query's status, its request error, the source's node, and the window's rows. A third, `source-health.lazy-demand`, drives an eager parent Collection left-joined to an on-demand child Collection: parents come and go, loads settle ok or failed, the child source reloads, and the source makes its own calls; the driver reports each load's requested keys from the adapter boundary. It checks the query's status, its request error (which clears when a later load of the failed key succeeds or when the query no longer needs that key), and each parent's child. The lazy and window drivers can add a direct subscriber with the initial state on the on-demand source (its rows equal the Collection's at every step; the driver answers its own requests at once), and the lazy driver can add an Effect over the same join (at quiet checkpoints it has the query's rows; a delete that a later insert undoes between checkpoints is not observed). The lazy driver also crosses a child that holds `markReady()`until a`child-ready`step, so the child can be`loading`or fail its first load when it reloads, a reload stays in flight past its new run's first commit, and a second`reload()`returns the first one's promise; and the child adapter's own`truncate()`(a must-refetch in the same sync run, also while a reload is in flight: its replay requests every needed key, also one whose load threw, and a demand change's replacement that applies releases the coverage it replaces), loads that throw, and source reloads during the first load: the first load waits for the whole replay, and a replay fails only for a key the query still needs when it is judged. The child adapter honours aborts; an adapter that ignores them is outside the contract (maintainer decision: an abort ends the load). In the on-demand lane, a window move after a failed replay waits for the next successful replay, and a later reload aborts it. A reload starts its replay when it retires the sync run, so a load the reload aborted is over and its consumers wait for the replay, as after a truncate; a named history checks that the retired run's load decides nothing when its abort rejects it. A named history pins that a truncate after a reload's failed replay replays the demand again. The on-demand window grammar adds`settle-aborted-fail`and`settle-aborted-ok`, which settle only the loads the query aborted, with declared reach for an aborted load that settles beside a pending load after a request error. A source reload that aborts the app's retry after a request error leaves that error to the replay's verdict: the retry's late rejection or success decides nothing, and a failed replay's verdict replaces the retry's failure. On 2bc79fc7c the coarse verdict's window histories fail, and a mutant whose replay failure reports no error fails 37 tests. Window moves never abort a root load outside a replay, so a root load's late success after an abort outside a replay is unreached (a mutant that lets it clear the root error survives); page-distinct root failures are not modelled: the grammar keeps one logical window demand, as law 16's "later load of that Collection" wording allows. The lazy grammar's `remote-delete`step deletes a child at the provider with no stream event; a child Collection rows check after every step pins that a reload keeps that row (maintainer decision: no reload sweep; RED on 2bc79fc7c, which swept it). The main grammar's blind`insert`write pins that an insert of a key the baseline holds throws, during a reload as at any time (maintainer decision; RED on 2bc79fc7c, which turned a reload's insert of a row the retired run left into an update; a mutant that lets a reload's insert of a held key apply fails 12 named histories); the lazy grammar's diffing adapter writes an`update`for a kept child. Limits: the window grammar has no provider deletion. Every truncate replays the demand again, also one the reload's new run sends while the reload's own replay is open or after it settled (F8: a latch that skipped the reload's own truncate let a replacement delete replayed rows with nothing re-requesting them); a reload with no held acquisition keeps the rows and replays nothing. The window grammar's`reload-query`reaches the query's own reload while its node shows a request error: the query keeps its rows, its node's error, and`reloading`until its new run's window load settles, keeps the window it showed (a window move waiting for a replay ends with the retired run), and then shows only the new run's failure, if any (F4). During the query's own reload the window grammar also issues window moves and`restart`. A move of the new run whose load fails keeps the reload, its rows, `reloading`, and its promise until a later move's load succeeds, and a failure the new run reports shows on the node at once (maintainer decision). `restart`(cleanup and preload of the query) discards the window and any own reload in flight, so the new lifetime loads the initial window. Its limits: no source reload, report, or aborted settle during the own reload; the reload starts only when the source shows no problem; and a restart ends the request-error announcement watch for the rest of the history. Named histories also pin that a later load of a failed key clears its request error while an unrelated load is still pending, that a load failing during a replay is decided by the replay's verdict, that a reload during the first load fails it only for a demand still held when the replay is judged, and that a reload keeps a window move that joined the replay while it aborts a later move that waited. Mutants that restore last round's reload, cleanup, Effect, and replay bugs, and a request error that outlives the failed key's demand, each fail named histories. Witnesses pin that cleanup start announces the problems it drops, that a reload rejects during pending adapter cleanup, that a ready callback that throws still settles a reload, that a failed reload keeps its rows as a diffing adapter's baseline, that a query's own reload settles with its new run (a load that fails again, or a graph that throws), that a source's or the query's reload clears a request error, that an Effect over a source already in`error`or`crashed`ends, and that a pooled view keeps node identity when its source alias is`root`. Later witnesses pin that a reload's open transaction applies on the kept rows after the new run fails, that an Effect over a stopped source leaves no load, subscription, or listener on it, that cleanup start keeps an `error`status's cause, that a source reload's replay keeps a request error that failed after it started, and (query-db) that a late recovery counts when another Query fails meanwhile. The query-db lane`failureReason follows the still-failing Queries` (`query-db.failing-queries`) enumerates every history of four fetches over two subsets' Queries, directly and behind the persisted wrapper (a receiving witness: the wrapper must relay `reportHealthy(error)`'s value). Its model keeps each failing Query's latest error value and the listed values, one per value, oldest first (review: one entry per failing Query, and a value stays listed while any failing Query holds it); fetches succeed, fail with a fresh error, or fail with one shared value; after every fetch it compares the listed causes by identity and order, the shown `message`and`cause`, each kept failure's `at`, and health tree identity (ARCHITECTURE law 17): a fetch that leaves the frame as it was keeps the node and its `failureReason`object and announces no`health:change`. Declared reach includes a Query that fails again while listed and one that recovers with two failures listed while another fails. RED on the parent: a recovered Query's failures stayed listed. The main grammar's `report`and`healthy` steps can name a listed failure's error value or an unlisted value with the same message; a report of a listed value and a healthy call for an unlisted one leave the frame as it was and must announce nothing (maintainer decision), and a healthy call for a listed value removes only that failure. Limits: replacing a Query's failure announces two health:change events (add, then remove). The [snapshot oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/live-query-observer-snapshot-oracle.property.test.ts)'s health lane (`live-query-observer.snapshot-health`) generates `report`, `healthy`, `write`, `subscribe`, `unsubscribe`, and `read`over one ready source and checks that every`getSnapshot()`shows the current`failureReason`, `hasErrors`, and `isSyncError`, on detached reads, attached reads, and across the attach cut, and that the tree keeps its identity while unchanged; after a round trip between reads that ends on the previous read's content, either identity is legal (glossary, health tree). Each read also compares the `failureReason`'s `errors`(messages in order, each cause by identity) with the model's frame, and a declared reach state reads a frame of several failures. Limits: one source,`failureReason`only, no hydration seed, no framework. The shared conformance cells`crashed-status`and`crashed-status-during-first-load-{collection,query}`read each driver's status flags without reading`data`, and require Solid's resource path to show `crashed`, not `error`, when preload rejects for a crashed Collection. By maintainer decision, an error boundary receives an Error for a failed first load or a crash: the cause itself when it is an Error, otherwise an Error with the problem's message whose `cause`is the reported value; React's`useLiveSuspenseQuery`and Solid's resource witnesses cover a failed first load and a crash, each with an Error and a non-Error cause: a failure inside sync and after mount, a sync function that throws, and a later crash (after readiness for React, during the first load for Solid). A crash path that throws the raw cause fails the non-Error crash cells in both. The replay refinement oracle's`settle`command can keep holding loads that start during the step, so a held retry keeps a failed join load's request error visible. A crash-recovery witness checks that nothing recovers a crash in place and that a cascade cleanup gives a fresh start with only the new run's rows; a`reload()`that recovers a crash, a cleanup that keeps the crashed run's rows, and a crash that survives cleanup each fail it. The three generated properties share one model: the main grammar's source model drives the on-demand Collection of the window and lazy grammars too (a load that succeeds is one sync transaction, so a reload's first load replaces every row), and one demand model, from ARCHITECTURE laws 9, 10, and 16, judges the window grammar's single window demand and the lazy grammar's per-parent keys. The lazy grammar checks the request error after every step, also while a replay is open or after one failed, and both grammars check a coverage law: once the query is ready, every held key has a load the adapter still serves, its rows, a replay failure awaiting judgment, or a shown request error (an aborted load without a replacement fails it). Named histories pin the cases that closing the earlier open item found: a demand parked behind a child whose first load failed joins the open replay, also an in-flight one when the child restarts (the CI counterexample seed -838690579: production replaced the in-flight replay and lost its own failed load;`a reload after the source stopped replays its demand and fails with every load`); a failed replay stays open while the query holds demand, and its error clears when its failed demand retires after the rest loaded (law 9); a load the truncate aborted does not clear a request error; a held replacement that loaded decides the demands it replaces; and a cascade cancels a query's pending reload. Law-silent, recorded with the maintainer: a truncate's `markReady`option does nothing on a ready Collection, also after`fail()`; a cleanup after a crash that followed cleanup settles at once; a truncate after `fail()`is outside the grammars; a window move with no request is served from loaded rows. A child whose first load failed parks new demand without a request; by maintainer decision the lazy model predicts the parked acquisition from law 10 (growth or churn), and a declared reach state and prefix enter that region. Named histories pin that a replay re-requests parked demand and then publishes, that a released parked demand parks again, that a replay's failed request keeps its acquisition's coverage, and that a later change drops a parked replacement that no longer matches. ARCHITECTURE law 16 states the rule. A query that crashes while it starts, because a Collection it reads is still cleaning up, reads nothing: it announces nothing more and no cascade ends it (maintainer decision). A pooled view that is unsubscribed, outlives its source's`cleanup({ dependents: true })`and restart, and then subscribes again stays`cleaned-up`, while a live-query Collection restarts; the pooled cleanup-admission lane crosses observed and unobserved views but never restarts their source; a follower tab does not see a provider failure on the leader outside a pending request. | | Paced mutations | Partial | The core virtual-clock owner checks strategy timing, admission, cleanup, and settlement. The React render-cut owner checks committed callback ownership across suspended and committed transitions, descendant layout effects, and StrictMode while preserving mutate identity. Callback affinity to transaction admission, other frameworks, new debounce/throttle admission after cleanup, failed persistence, and broader generated schedules remain open. | | Optimistic state | Complete | The independent base/intent/source-queue graph plus outcome, transaction-payload, and publication drivers are literate. | | Drafts and native values | Complete | Native differential behavior, draft change tracking, detachment and its class-instance exception, hostile keys, aliases, cycles, and Map/Set live iteration are literate. | @@ -544,7 +554,7 @@ that test identifiers must copy production's private data structures. | Electric and TrailBase | Complete | Electric replica and recovery models, installed-SDK HTTP delivery, PostgreSQL serialization, and TrailBase's controlled RecordApi/native-stream lifecycle are literate with their real-provider limits intact. | | PowerSync | Fixed tests only | Patch conservation, effective-update receipts, metadata and falsey changes, declared-view keys, transformed schema output, logging, cleanup, and native SQLite reach are covered by fixed tests in `correctness.test.ts`; no oracle owns them (tracked in #2264). | | SQLite persistence and native hosts | Complete | Persisted hydration/replay and ownership, shared-handle driver transaction laws, OPFS page and diagnostic state machines, and the 113-law native conformance manifest are literate. Native execution remains distinct from registration and shim evidence. | -| Offline execution | Complete | FIFO retry, scheduler eligibility, leadership replay, transaction settlement, and typed wire serialization are literate. The [interruption settlement oracle](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/interruption-settlement-oracle.property.test.ts) moves one leadership loss, offline change, or disposal across individual microtask turns after one to three commits on disjoint keys, then recovers by regaining leadership, going online, or restarting over the same storage. It requires that no provider call or new outbox admission starts while fenced, that each admitted ID reaches the provider once and in commit order, that a fulfilled commit's row is already gone from storage, that commits settle with their provider outcome after a regain or reconnect, and that the outbox ends empty. A reach check requires interruptions before admission, between admission and the provider call, and after it, for each interruption. Mutants that drop the execution loop's fence, resolve before deletion, drop the admission check, or check admission before awaiting initialization fail it. Open there: shared keys, providers that answer later, and overlapping old and new executors. The settlement oracle's generated FIFO histories draw each transaction as one record (outcome; manual commit, auto-commit with the option set or omitted, or an `OfflineAction`; receipt read or not), use `isPersisted` as an auto-commit's receipt, and require that no failure escapes as an unhandled rejection, also when nobody reads the receipt, and that settlement logs no failure again and writes no debug output. An empty transaction on any commit path may precede each one; its receipt and completion waiter must fulfil without reaching the provider or the outbox. A manual transaction that writes a row and rolls back before it commits may also precede each one; its waiter must reject with an Error that names the rollback. A focused witness rolls a transaction back while it persists, for both provider outcomes: its waiter must wait for the executor and report the executor's outcome. After every transaction settles, a new waiter for each one must reject within a turn rather than hang. An unread receipt still settles with its own outcome when read afterwards, and a caller may register its completion waiter before admission on every path but `OfflineAction`. A focused witness fails the admission outbox write on every commit path and requires the receipt to reject with the storage error, without an unhandled rejection or a repeated log; a failed outbox write stops the executor, so generated histories do not continue past one. Open: the generated failure source is the mutation function only; manual commits always follow their own `mutate()` at once, so a pending manual transaction behind a failing shared-key sibling is not generated; a throwing `mutate()` callback is not generated, because core keeps its transaction pending; a rolled-back-before-commit transaction is generated only on the manual path, with its own key and its waiter registered after `mutate()`, so a secondary rollback of a pending transaction by a failing sibling, whose waiter gets the generic rollback Error, is not generated. | +| Offline execution | Complete | FIFO retry, scheduler eligibility, leadership replay, transaction settlement, and typed wire serialization are literate. The [interruption settlement oracle](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/interruption-settlement-oracle.property.test.ts) moves one leadership loss, offline change, or disposal across individual microtask turns after one to three commits on disjoint keys, then recovers by regaining leadership, going online, or restarting over the same storage. It requires that no provider call or new outbox admission starts while fenced, that each admitted ID reaches the provider once and in commit order, that a fulfilled commit's row is already gone from storage, that commits settle with their provider outcome after a regain or reconnect, and that the outbox ends empty. A reach check requires interruptions before admission, between admission and the provider call, and after it, for each interruption. Mutants that drop the execution loop's fence, resolve before deletion, drop the admission check, or check admission before awaiting initialization fail it. Open there: shared keys, providers that answer later, and overlapping old and new executors. The settlement oracle's generated FIFO histories draw each transaction as one record (outcome; manual commit, auto-commit with the option set or omitted, or an `OfflineAction`; receipt read or not), use `when('settled')` as an auto-commit's receipt, and require that no failure escapes as an unhandled rejection, also when nobody reads the receipt, and that settlement logs no failure again and writes no debug output. An empty transaction on any commit path may precede each one; its receipt and completion waiter must fulfil without reaching the provider or the outbox. A manual transaction that writes a row and rolls back before it commits may also precede each one; its waiter must reject with an Error that names the rollback. A focused witness rolls a transaction back while it persists, for both provider outcomes: its waiter must wait for the executor and report the executor's outcome. After every transaction settles, a new waiter for each one must reject within a turn rather than hang. An unread receipt still settles with its own outcome when read afterwards, and a caller may register its completion waiter before admission on every path but `OfflineAction`. A focused witness fails the admission outbox write on every commit path and requires the receipt to reject with the storage error, without an unhandled rejection or a repeated log; a failed outbox write stops the executor, so generated histories do not continue past one. Open: the generated failure source is the mutation function only; manual commits always follow their own `mutate()` at once, so a pending manual transaction behind a failing shared-key sibling is not generated; a throwing `mutate()` callback is not generated, because core keeps its transaction pending; a rolled-back-before-commit transaction is generated only on the manual path, with its own key and its waiter registered after `mutate()`, so a secondary rollback of a pending transaction by a failing sibling, whose waiter gets the generic rollback Error, is not generated. | | Frameworks | Complete | Shared live-query and infinite-query models are literate. Each framework keeps its own realm, ownership, and scheduling driver. | | Structural values and ordered primitives | Complete | Structural hashing, deep equality, comparison, cursor denotation, index refinement, and query-identity output equivalence are literate. | | Mixed-kind ordered prefixes | Bounded | Eager BasicIndex and BTreeIndex paths compare ascending and descending direct and live-query windows with an unindexed scan across mixed value kinds and paired source changes. One retained on-demand BTree history checks settled windows after a number/bigint transition, a finite request for a missing row, and a cursor request after index eligibility changes. Other provider paging histories remain open. | @@ -603,8 +613,7 @@ 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 +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 ten-finding high review adds generated error-provenance and partial-setup @@ -694,10 +703,93 @@ demand retention, and a heap bound need separate witnesses in the includes owners. The virtual-row-fields owner -(`packages/db/tests/query/virtual-row-fields-oracle.test.ts`) checks presence -of all four public virtual fields directly after implicit, expression, and -functional root publication. Its fixed controlled-Collection cases do not -establish every nested projection shape or framework receiving boundary. +(`packages/db/tests/query/virtual-row-fields-oracle.test.ts`) checks all four +public fields on row roots and keeps inline values in their selected shape. +Its finite 1,056-history attribution grammar crosses single/joined sources, +structured projections/spreads and functional projections/spreads/direct +source returns, and none/each/all reserved-field collisions. Each form crosses +direct output, QueryRef depths one/two, joined and functional QueryRef +consumers, either union branch position and a union consumed through QueryRef. +Eight further histories cross nullable later spreads, spread order and which +source has a pending write. Comparisons at +preload, a held optimistic source update, and settlement enforce the maintainer +rule: structured source spreads and direct functional source returns retain +source metadata; new objects and ordinary projections receive output Collection +metadata. Each collision is an independent axis, including `$collectionId` by +itself; selecting a reserved name cannot choose a source. The reach check +requires observations from joined and single pending sources, colliding projections, +direct functional returns, QueryRef and union pending observations, and a null +later spread. Authored ordinary fields can select source pending state without +changing output metadata. The original source-guessing compiler fails the +same comparisons. Supplemental regressions for two spread orders, unmatched +RIGHT joins, facade projection metadata and inline values live in +`virtual-row-fields.test.ts`. This boundary does not establish arbitrary +nested projection histories beyond these two QueryRef levels, every join kind under source changes, framework +receiving paths or provider behavior. + +The attribution driver compares both authored rows, every ordinary field and +all four virtual fields, then deletes the non-first row. Half its histories +configure independent projected keys; default-key differential evidence remains +supplemental. Another 264 histories populate both union branches with numeric +1 and string "1", different source Collection IDs and equal labels. Either +branch holds a write while its peer is deleted/reinserted. Branch order and +QueryRef consumption vary. Each direct/QueryRef × source/output attribution × +pending-side path must reach held, reinserted and settled comparisons. Fourteen +checker controls reject omitted/duplicate peers, missing ordinary IDs, wrong +keys, union-wide source attribution and stale pending/origin fields; reordered +allowed results pass. Evidence and review corrections are in +[implementation chunk 3](oracle-reviews/2026-10-28-pr-2171-oracle-chunks.md). + +A further 112 histories recompute changing left-join topology while a left +write persists, through join-key changes or delete/reinsert. They cross five +projection shapes, useful spread orders, independent success/rejection (including +both with distinct errors), and either settlement order. A right write overlaps +and receives a same-key queued sync confirmation. Full snapshots, every callback's +installed read, change/previous-value payloads and a downstream metadata projection +are compared at initial, held, topology, acceptance, settlement and later remote +cuts. Aggregate callback deltas reject whole-batch repetition; batch partition +remains permitted. Eight calibration cases reject stale reads/events, wrong +previous values, duplicate messages/batches, omissions and lost earlier failures. +Per shape/topology reach requires both receiving layers, both rejection states +and both settlement orders. See [chunk 4](oracle-reviews/2026-10-28-pr-2171-oracle-chunks.md). + +Another 132 histories receive the same metadata grammar through Collection +facades and inline toArray/materialize controls. Two parent routes share a +bucket, move to a populated or empty destination, retire the last route, then +reactivate while a child write persists. Success/rejection and later source +updates are observed. The primary includes owner supplies an independent +active-interval model: retained routes share one ready facade, retired handles +stay ready/empty, and a new interval gets a new handle. This assumes no +facade-local optimistic mutations. Every shape declares active empty readiness +and nonempty child receiving cuts. Six lifetime and four inline-value hostile +controls reject wrong observations. See [chunk 5](oracle-reviews/2026-10-28-pr-2171-oracle-chunks.md). +Functional callback-input observations now have a separate receiving boundary +in `virtual-row-fields-input-oracle.test.ts`, sharing source metadata judgment +with the existing root/Effect owners. Its finite 33 authoring descriptions cross +seven QueryRef/join/union consumers through root and correlated inline-child +compilation (462 histories), plus two direct-source controls. Each compares +immutable callback captures and their ordinary derived output at initial +readiness, held mutation, settlement and remote next use. Complete before/after +source facts permit retraction evaluation; startup LEFT joins may expose an +absent side, while established outputs require both typed-key rows. Reserved +metadata is judged by presence/readable value, with ownership flags diagnostic; +ordinary own undefined remains distinct from absence. Another 66 histories keep +nested inline array/materialized authored values, and 33 reject compiled +Collection inputs before callbacks. Per-history/path/cut reach is mandatory even +under unrelated replay seeds; unentered calibration-only runs stand aside. +Unchanged production exposes 203 correlated-child violations: new-shape fields +leak and source-spread collisions spoof genuine metadata. Root receivers and +nested inline preservation pass. All 2,153 declared states reach; 17 controls +and independent seeded/unentered/partial-selection checks calibrate this owner. +See [chunk 17](oracle-reviews/2026-10-28-pr-2171-oracle-chunks.md) for exact +fingerprints and independent evidence. Production repair is deferred under the +[RC oracle challenge](oracle-reviews/2026-10-28-pr-2171-oracle-challenge.md). +This extension does not establish arbitrary descriptors/prototypes, greater +recursive depth, same-source overlapping transactions, other dynamic +join kinds, truncate, provider scheduling, framework receivers or construction +failure cleanup. The neighboring includes publication owner removes virtual +fields from its own comparison; the new metadata timeline supplies this bounded +core publication judgment without claiming those broader paths. The generated persistence option type/admission owner is `packages/db-sqlite-persistence-core/tests/persisted-options-type-oracle.ts`. @@ -764,9 +856,71 @@ and guards that check only own/truthy properties or omit a reserved name. Other existing fixed type checks cover adjacent compile-time boundaries: `packages/db/tests/mutation-handler-type.test-d.ts` (fixed tests; no oracle owner (tracked in #2264)) checks sibling Collection/key/utilities in mutation handlers; and -`packages/db/tests/query/virtual-row-fields-oracle.test-d.ts` checks all four -published virtual fields against an independently written public shape. Their -passing type assertions do not prove runtime parsing, I/O, or publication. +`packages/db/tests/query/virtual-row-fields-reference-types.test-d.ts` retains +concrete helper examples. The generated public type owner is +`packages/db/tests/query/virtual-row-fields-types-oracle.test.ts`. Its 24 finite +row descriptions vary key domain, scalar type, nested optionality and +discriminated union membership. Shared runtime/type authoring dimensions cross +five projection shapes and eight compositions; seven child shapes cross +the legal facade/toArray/materialize/findOne forms; array/Date facades are +rejected inputs. Each generated body also runs public query/index construction, +with an explicit index type and failure-preserving cleanup. Two admission +controls reject opaque facades and optionless indexes at their actual boundary. +A reusable recursive type signature checks +values, exact keys, required/optional and readonly modifiers per union member, +without rejecting duplicate equivalent union members. Public-source imports +compile through the actual TypeScript compiler; all diagnostics retain their +file, span and code. Seven hostile controls challenge value enrichment, +nullability, one-branch readonly loss, nested any/metadata/modifiers and Date +metadata any. Their rejection requires failed type-law constraints, not syntax +or setup errors. The normal test:oracles command selects this owner. + +Chunk 24 extends this same owner with `virtual-row-fields-receiver-types-oracle.ts` +and the native compiler support `virtual-row-fields-receiver-types-support.ts`. +Its 34 context families cross the same 24 source descriptions: 14 recursive +ordinary-selection callback paths, 16 direct/QueryRef child materializers and +four positive source-row neighbors. All 816 consumers admit without diagnostics. +The independent declared-shape law checks 4,608 actual callback, rest, parent, +query-output, Collection/get, inline and published-facade markers. All 6,240 +syntax/admission/marker reach states execute once. The callback input retains +required ordinary routeGroup until real rest destructuring removes it; nested +ordinary fields named like virtual fields keep their declared types. + +Unchanged production has 684 intended RED receiving histories: 672 ordinary +callback/parent/inline metadata leaks and 12 structured source-spread union +precision losses. The other 132 receiving histories pass. The original 24 +campaigns and the separate 374 strict chunk-21 callback markers remain intact. +The paired source/direct-facade neighbors pass except for the recorded union +spread defect. Forty-three compiler/checker/AST controls reject fabricated input +metadata, lost genuine metadata, routing or nested fields, wrong optionality, +modifiers, containers, absence and displaced or annotated inference markers. +The finite matrix stays mandatory under partial selection and unrelated seeds; +checker-only selection enters no native matrix. +Independent review found that a marker moved into an ordinary map helper +could observe a reconstructed clean record while the real functional input still +leaked metadata. The original syntax checker accepted that source, and all type +judgments passed. The shared-grammar calibration first reproduced that false +green. The checker now requires the marker's arrow to be the fn.select argument, +and requires a rest declaration directly in that same callback block. + +This static slice does not infer allocation identity or promise exact provenance +for opaque source-valued callbacks or top-level reserved-name collisions. The +executed equal-type direct/copy experiment records identical public callback, +query, downstream-input and output types. It does not change the runtime law or +weaken any existing strict ordinary-shape marker. Additional static receiving +functions are compiled, not invoked; runtime callback execution stays with the +existing metadata input owner. No production repair or closure is claimed. + +Unchanged production fails the nullable whole-row facade comparison in each of +the 24 descriptions: its type loses the empty-row alternative and presents +ordinary fields as required. The metadata runtime owner receives that law with +a legal correlated include, initially matched or unmatched, then two match +transitions. Empty facade rows have required output metadata; inline empty +values remain empty. That runtime path passes. Production type repair is +explicitly deferred until all RC oracle chunks finish. Built declaration/npm +resolution and adapter Promise admission remain receiving work under the +[RC challenge](oracle-reviews/2026-10-28-pr-2171-oracle-challenge.md). These source +type checks do not prove runtime parsing, I/O or arbitrary publication paths. The shared-driver fairness owner now distinguishes K=1 from K=2 at the second hydrate and checks full logical persist FIFO order. Its Node and Chromium @@ -1077,7 +1231,7 @@ distinguishes an executable oracle from a specialized real-provider authority an does not award oracle credit for a filename alone. | PR | Classification | Permanent authority and campaign | -| ------------------------------------------------- | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| ------------------------------------------------- | ----------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | [#1831](https://github.com/TanStack/db/pull/1831) | Fixed tests; no oracle owner (tracked in #2264) | PowerSync's `packages/powersync-db-collection/tests/correctness.test.ts` (fixed tests; no oracle owner (tracked in #2264)) crosses real PowerSync receipts and native SQLite behavior. It runs in the package test campaign; portable declarations retain compiler authority. | | [#1832](https://github.com/TanStack/db/pull/1832) | Equivalent specialized authority | `packages/electric-db-collection/e2e/sql-predicate-semantics-oracle.e2e.test.ts` and `packages/electric-db-collection/e2e/subset-sql-acceptance.e2e.test.ts` run through the package's real-provider `test:e2e` campaign. Compiler unit tests are collateral, not substitutes for either service boundary. | | [#1833](https://github.com/TanStack/db/pull/1833) | Explicit oracle | `packages/db/tests/query/pagination-oracle.property.test.ts` owns inherited collection collation, actual `item2`/`item10` order, exact request options, hostile lexical/numeric controls, and both scan and auto-index paths. It runs in `@tanstack/db`'s `test:oracles` campaign. | @@ -1411,6 +1565,107 @@ source cases for an existing stream hash. The former completes its own preload and reads the first source; the latter rejects before borrowing the first stream. Pooled views have no query Collection config, so this receiving path needs a separate source-identity witness. +Historical baseline at `858b174a6` (superseded by the chunk entry below): +The local live-query cache option owner is +`packages/db/tests/live-query-cache-options-oracle.ts`, with core DbClient and +React receiving drivers in the adjacent `.test.ts` and +`packages/react-db/tests/cache-options-oracle.test.tsx`. The maintainer-approved +law rejects different getKey/schema references from independent same-identity +callers while retaining inline options within a mounted binding. The finite +core grammar crosses each/both options, same/different references, distinct-key +controls, derived/keyed identities, pending/settled result reuse and explicit +preload/observer preload/subscription admission (144 cases). React supplies 24 +real-host initial-render cases. The existing source-ID owner continues to judge +rerender retention. This option owner does not claim arbitrary SSR transport +schedules, every framework or semantic equivalence of distinct functions. +Hydration carries no function references; only local consumers claim them. + +The [RC oracle challenge](oracle-reviews/2026-10-28-pr-2171-oracle-challenge.md) +demonstrates two open gaps in this option owner at `858b174a6`: a guard that +rejects only when every configured option differs passes all 144 cases, as +does a guard that overwrites the original claim immediately before rejecting. +Temporary independent-option and repeated-admission comparisons reject those +mutants with 12 and 24 assertion failures respectively; unchanged production +passes both comparisons. The checked-in grammar still couples changed options +when both are present and compares admission without subsequent reuse. Its +pending-result cell does not hold provider delivery, and its first caller is +always explicit preload. The same owner needs independent option presence and +reference histories, post-rejection observations, and option-specific +hydration/consumption/failure histories. The report assigns host receiving, +release, type, and harness improvements separately. None of these proposals is +new checked-in coverage. + +The follow-up [review coverage matrix](oracle-reviews/2026-10-28-pr-2171-review-coverage-matrix.md) +records current failures at the same head. Cached `getServerSnapshot()` accepts +conflicting options before subscription; the shared admission comparison fails +three conflict histories in core and the same three through actual React +`renderToString`, with compatible controls passing. A newer hydration that +replaces a locally preloaded record loses its row-option claim; all three +conflict histories fail after the replacement is positively observed. These +are open product defects, not merely proposed coverage. This cache owner needs +the SSR receiving path (O16) and replacement-claim histories (O04), followed by +production repair and GREEN/calibration evidence. Portable transport must still +carry no function references. Temporary probes are not checked-in coverage. + +The [chunk implementation record](oracle-reviews/2026-10-28-pr-2171-oracle-chunks.md) +now records the reviewed option extension on unchanged production. The shared +owner crosses independent reference/presence pairs with explicit/default keys; +its core driver runs 1,440 histories over five admission paths and actual held +write delivery. It checks two admissions, original reuse, every next callback, +exact ordinary values/keys, and refused-listener silence. React receives 288 +histories through real mounted hooks and server rendering; all 144 client +histories also compare after React's cached-seed handoff. Independent key +functions make lost original behavior observable. Declared reach distinguishes +single-option/both conflicts, added/removed declarations and distinct keys per +path. Core has 192 admission assertion failures (64 settled SSR, 128 newer +hydration replacement), React has 64 SSR failures; neighboring histories and +checker/cleanup calibrations pass. These remain open product defects: the +maintainer explicitly defers production repair until all oracle chunks finish. +This historical chunk left first-registrant variation, transport serialization, +first local claim after hydration and failure/consumption retirement pending; +the following entries supply their later evidence. Other applicable frameworks +and schema transformations remain limited. This extension does not +establish hydrateRoot or arbitrary concurrent rendering schedules. + +The same chunk record now adds `live-query-cache-lifecycle-oracle.test.ts`: +2,160 legal timelines through preload, observer preload and server-first local +claims over actual JSON-round-tripped server data. Pending delivery, replacement +before/after transport, exact rejection on initial failure, consumed results and +fresh declaration reuse have phase/schedule-specific conflict reach. Equivalent +server/local behavior has independent function refs. On unchanged production, +640 admission assertions fail and 1,526 tests pass, including six controls. +Failures remain open until the deferred production repair. The shared immutable +bound-options checker now receives Preact's six native histories as well as +React's 12; six hostile in-place mutations are rejected. Svelte's actual +client-backed effect receives all 144 cached-option histories. All three native +selectors pass. Portable first claims/failure/consumption are no longer merely +proposed coverage. Remaining cache limits at that chunk were first registration +without an existing stream, arbitrary transport schedules, schema transforms +and native partial-construction cleanup. Svelte SSR/hydrateRoot/continuing publications are +not supplied by its browser admission receiver. + +Chunk 12 adds first registration without a transported query result. Core runs +4,320 histories: explicit preload, streaming observer preload and server read +as first claimant, each received through all five admission paths with held or +settled source delivery. Each of the 15 routes reaches 288 first registrations +and 144 held deliveries. The actual React streaming server driver in +`packages/react-db/tests/cache-first-registration-oracle.test.tsx` receives +1,152 histories through ordinary/Suspense first reads and preload/server second +callers. It supplies no hydration seed or Router result. It checks every render +error, repeated admission, compatible reuse, then core observer continuation. +Core has 640 intended admission failures and 3,750 passes; the new React driver +has 192 intended failures and 960 passes. Flat source rows are copied away from +expected facts in all five cache drivers. A corruption mutant now fails the +ordinary-row comparison, rather than changing both sides. Omitting observer +registration options adds 448 core and 128 React assertion failures. Held +conflicting preload admission is observed before source delivery at a host-task +cut; a pending promise fails promptly and cleanup releases the provider. +Independent reviews and exact evidence are in the chunk record. Production is +unchanged. Schema transformations, Router transport, hydrateRoot, arbitrary +concurrent schedules and other framework first-registration receivers remain +outside this bounded evidence. Native partial-construction evidence is recorded +separately in chunk 11. + `packages/db/tests/live-query-deferred-acquisition-oracle.test.ts` also checks a two-source descriptor union when the first sync start fails: the second source still starts and reaches ready on direct preload, and a preparation error stays @@ -1480,15 +1735,15 @@ focused shape test does not establish public rows for those query forms or generic visitor behavior outside the named collectors. | Surface | Primary executable owners | Independent judgment and important limit | -| -------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Ordered relations and BTree | [top-K relation oracle](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/operators/topk-relation-oracle.test.ts), [fractional-index window moves](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/operators/topKWithFractionalIndex-oracle.test.ts), [BTree/Map](https://github.com/TanStack/db/blob/main/packages/db/tests/btree-map-oracle.test.ts), [incrementalization laws](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/incrementalization-law-oracle.property.test.ts) | Independent ordered relations and cumulative signed output. Each generated incrementalization campaign sums the checker's delivery counts and requires one atomic delivery per checkpoint and more split deliveries than an unsplit run makes; a checker that stops splitting fails all six campaigns. Most ordered steps edit the previous world by one removal or insertion, and a reconstruction check counts promotions of retained rows over the executed fixed campaign (25 global and 21 per parity at 100 runs; independent worlds gave 2 and 2). A type test pins `join(other, type)`'s nullable pair sides for every join type. Top-K consolidation compares same-key values without hashing, including cyclic replacements and fresh transient cancellation. The fractional-index test checks an empty output message for a window move after all rows retract; it does not prove downstream lazy acquisition. Other hash-based operators retain hashing's declared domain. Algebra does not specify client readiness. The BTree/Map oracle checks point operations, neighbor pairs, and full, partial, boundary, exclusive-high, and empty range scans for integer keys and node sizes 4–8. It does not check balance or space, and it assumes consistent comparators ([code-weight BTree review](oracle-reviews/code-weight-btree-trim.md)). The generated groupBy law compares JSON values; a pinned contract test in `packages/db-ivm/tests/operators/groupBy.test.ts` records that after a delete, `min` and `max` return a value hash-equal to a remaining member, not necessarily the remaining instance (`-0` for `0`, an equal Date), which is why the query compiler keeps its own exact-value keys. It checks both insertion orders, reads the group's row by integrating output multiplicities, deletes the minimum and the maximum, and shows that an aggregate input that tells the rows apart returns the remaining instance, for -0/0 in both directions and for both `min` and `max`. The contract holds while every member has positive multiplicity. It pins the current behavior separately: the delete's retraction is the last record a shared hash entry gets, so the deleted instance comes back in either insertion order, for -0/0 in either direction and for an equal Date. For -0/0 and equal Dates the contract assertions only show hash equality, which is all the contract promises. Open: the incrementalization law's value policy compares JSON and clones by `structuredClone`, so no generated history sees -0 or Date identity; a value-identity policy with -0/0 and equal-time Dates would generalize this pinned contract. | | Includes and publication | [cross-formulation](https://github.com/TanStack/db/blob/main/packages/db/tests/query/includes-cross-formulation-oracle.property.test.ts), [temporal](https://github.com/TanStack/db/blob/main/packages/db/tests/query/includes-temporal-oracle.test.ts), [Collection includes](https://github.com/TanStack/db/blob/main/packages/db/tests/query/includes-collection-oracle.property.test.ts), [architecture and complete suite map](https://github.com/TanStack/db/blob/main/packages/db/src/query/live/ARCHITECTURE.md#executable-contracts) | Per-parent/flat-join/partition relations, callback-time rows, nested values, and route histories. Fragmented lazy-demand consolidation covers delta growth and successful, rejected/retried, and obsolete replacements at the Collection request boundary; it observes exact key unions and abort checkpoints. A compiled-includes fixture makes adapter unload evict owned child rows and proves a rejected union cannot remove established public child rows. Retry after that terminal live-query failure remains covered at the acquisition boundary; no automatic in-place recovery is claimed. Observe raw promised order; fresh queries do not establish continuous publication safety. The temporal owner's source-readiness lane enumerates every legal order of delivery, settlement, source readiness, and one later change for a source whose Collection readiness lags its settled subsets. It checks live-query readiness, preload settlement, and visible rows for direct, single-result, ordered-window, include-child, lazy-join, and eager-root sources; `queryOnce` results for the direct and eager-root shapes; `toArrayWhenReady()` and `stateWhenReady()` results for a read started after rows are visible but before readiness, over the direct, single-result, and eager-root shapes (and the lazy join for `toArrayWhenReady()`); and Effect `skipInitial` deltas for the direct, lazy-join, and eager-root shapes, and every on-demand cell of every consumer is crossed with a truncate or (join and include) a parent rekey before settlement. Each on-demand cell also compares its consumer with a metamorphic twin over a source that is ready from its start, which does not rely on the model's eager/on-demand rule. A live-query observer test checks that a hydration seed hands off at subset settlement, and an Electric test pins that a ready progressive query's rows update only at the full sync's swap. Effects over an ordered window or an include have no lane. It rejects both a design that waits for every source's Collection readiness and one that ignores source readiness. Electric live-query tests are the receiving witnesses for a real progressive source and an on-demand source that is ready before its subset loads; a React Suspense test checks that Suspense and `isReady` release together. Other framework adapters have no Suspense witness. | | Collection lifecycle | [mutation startup](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-mutation-startup.test.ts) (fixed tests; no oracle owner (tracked in #2264)), [change-event history](https://github.com/TanStack/db/blob/main/packages/db/tests/change-event-history-oracle.test.ts), [sync publication reentrancy](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-sync-reentrancy-oracle.test.ts), [history](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-subscription-lifecycle-history-oracle.property.test.ts), [publication](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-subscription-lifecycle-publication-oracle.property.test.ts), [replay](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-subscription-replay-oracle.property.test.ts), [effect disposal](https://github.com/TanStack/db/blob/main/packages/db/tests/effect-disposal-oracle.test.ts), [GC and persisting transactions](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-gc-transaction-oracle.property.test.ts), [GC retention boundaries](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-gc-retention-oracle.property.test.ts), [shared GC model](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-gc-retention-oracle.ts), [GC mutation projection](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-gc-mutation-oracle.property.test.ts), [native idle receiver](https://github.com/TanStack/db/tree/main/packages/db/e2e/collection-gc), [initial GC review evidence](oracle-reviews/pr-2258-gc-retention.md), [second GC review and decisions](oracle-reviews/pr-2258-gc-retention-round2.md), [interaction and mutation receiving evidence](oracle-reviews/pr-2258-gc-retention-round3.md), [GC checker calibration](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-gc-retention-oracle.test.ts), [Electric confirmation receiver](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/e2e/collection-gc.e2e.test.ts), [PR #1902 review](oracle-reviews/pr-1902-change-event-history.md), [batch-order decision review](oracle-reviews/change-event-batch-order.md), [absent-key insert review](oracle-reviews/2026-10-02-insert-for-absent-key.md) | Core Collection `insert`/`update`/`delete` admission while `startSync:false` is idle; complete public-row/change-message agreement across bounded one- and two-key histories plus replayable four-key campaigns, where every message must be valid for a strict consumer (insert only for an absent key, update or delete only for a present one; [absent-key insert review](oracle-reviews/2026-10-02-insert-for-absent-key.md)); a two-key deferred sync history checks each key's causal insert/update/delete trace while allowing any cross-key interleaving. Reversing all deferred messages fails that check; reversing each sync transaction's distinct-key messages passes. Queued duplicate admission and cancellation, ownership and phase histories, exact caller/error/publication evidence, and late completion and restart have separate witnesses. Generated deferred histories with cancellation or reentrant callbacks need a witness in the change-event history owner before claiming general batch-shape coverage. Every bounded change-event history, and about half of each generated campaign, runs again on a local-only Collection that ends with `reload()`: it must resolve with the Collection `ready`, the change-message mirror of valid messages must equal the public rows, the public rows must equal the history's kept rows (maintainer decision: a local-only reload keeps the user's writes, and `initialData` loads only into a Collection that starts empty), and a later write must reach both. The adapter that re-inserted `initialData` in each run failed 210 histories (a crash when a kept row changed, or a mix of kept rows and `initialData`); a reset to `initialData` fails 344, and loading `initialData` whenever the Collection is empty fails 45 (a history that deleted every row). Query write utilities and effect self-dependent disposal remain separate contracts. During a truncate, a ready callback can write to any key after a prior optimistic request. Each subscriber, with or without initial state or a filter, must receive valid messages after the Collection is ready and end at the Collection's rows. A ready callback that throws while entering sync surfaces when the sync function returns, whether it calls markReady or commits a truncate ([round 3 follow-ups](oracle-reviews/2026-10-05-round-3-followups.md)). Callback rollbacks, non-optimistic callback writes, and several writing callbacks remain open ([ready-callback truncate review](oracle-reviews/2026-10-05-truncate-ready-callback-delete.md)). The GC property (`collection-lifecycle.gc-persisting-transaction`) checks on a fake clock that automatic garbage collection retires a Collection only after `gcTime` with no subscriber and no persisting transaction, that the delay restarts when the last transaction completes or fails, and that explicit `cleanup()` still retires it while a transaction persists. Histories run over two Collections, with inserts through one Collection's `onInsert` or through a manually committed transaction that touches both. After each subscribe, unsubscribe, insert, settlement, and clock step it compares each Collection's `cleaned-up` status and each persisting row's visibility with an independent deadline model. `main` before the fix, a hold that never restarts the delay, a restart only on completion, and a restart only for the first Collection a transaction touched each fail it. The companion `collection-lifecycle.gc-retention-boundaries` property separates elapsed eligibility from host idle delivery. Its shared reference model checks the absolute 50ms startup/preload handoff, real last unsubscribe, pending versus persisting transactions, delayed commit, success/failure/rollback, surviving siblings, disabled GC, and old idle work after renewal. Generated configured delays are 1, 10, 49, 50, 51, and 100ms; observations include immediately before and at the independent deadline. Eager sync and write-started sync both run. A Chromium receiving driver uses the same model/checker with native idle delivery and cancellation and controlled elapsed timers. It records the actual budget core reads and checks public status at core delivery and a usable native observation opportunity. Exact wall-clock cleanup latency is not promised. Paced mutations, offline transaction restoration, live-query Collections, and actual Electric `awaitTxId` plus provider delivery are not generated by these GC owners. The boundary grammar now generates repeated warm preloads over queued idle and persisting holds, successful and failed cold preloads as first or last releases, both sibling settlement orders, and pending drafts held by subscriptions. Held adapter cleanup crosses settlement with surviving pending or persisting siblings, both before and after cleanup settlement. Exact key comparisons reject leftover settled rows and cleanup resurrection; tracked transaction identities still match unsettled transactions. The 250-history fixed and random campaigns require all 33 declared states. Alongside the 15 focused lanes, constructive interaction histories combine up to seven transactions and three subscribers, delayed commit, three persisting siblings, non-FIFO settlement and preload between releases. Automatic GC also starts held or rejecting adapter cleanup, checks synchronous row discard before adapter settlement, and rejects pending-sibling resurrection. The shared model additionally receives direct updates/deletes, shared update/delete and cancelled insert/delete ownership with a surviving peer update through `collection-lifecycle.gc-mutation-shapes`. Its independent source/intent projection checks exact keys and values across completion/failure/rollback, both Collection orientations and subscriber states; each 150-history campaign requires all 11 declared states. The maintainer chose first-usable-idle retirement as a fixture scheduling assumption, not a public liveness guarantee. The maintainer also chose caller-owned pending draft visibility: keep a subscription; restart after GC does not promise draft restoration. The boundary grammar therefore does not establish post-GC draft restoration, paced/offline restoration, or arbitrary cleanup/source-recovery histories. A real Electric/Postgres receiving driver now crosses gcTime 10/100ms with absent/last-unsubscribed owners, holds an assigned uncommitted txid across the unheld deadline, and observes pending caller, persisting phase, optimistic row, genuine confirmation, successful settlement, synced row, renewed GC and idle delivery. Its 12 paths retain four one-write cases and add eight two-write cases with separate real SQL transactions and both local settlement orders. A genuine acknowledgement releases only one hold; the other SQL transaction remains open beyond the first-release deadline. It shares the model/checker and rejects no-hold and first-release runtime mutants. Native host and real provider are witnessed separately; their combined browser/provider schedule, three-or-more provider writers, provider failure/recovery and persistence wrappers remain outside these bounded receiving histories. The Collection lifecycle GC owners still use fresh disjoint optimistic keys. Before claiming the combined same-key rollback-cascade or confirming-sync-queue law with GC, this owner needs a generated receiving history that crosses those semantic owners at retention and cleanup checkpoints. The history owner's demand-change grammar (`demandChangeLifecycleHistoryArbitrary`, a fixed seed and the random campaign `subscription-lifecycle.demand-change`) adds a third demand, a `replace` command (`replaceSnapshots`: a held replacement whose own load did not fail covers the owners it replaces, law 10), and a `change` command that retires several owners through one `releaseSnapshot(...wheres)` call; the model judges an open replay once after the whole change (law 9). Declared reach: a change during a replay, a replacement covering a failed owner, and a change whose verdict between releases would differ. Per-release judging, first-release-alone, and batching only two-owner changes each fail it. A covered owner's failed replay load reports `loadSubset:error` whatever its settlement order (the model's existing reporting rule; suppressing the last settler fails it), and a prompt cancellation of a covered owner settles the replay as any settlement does. An aborted held replacement covers nothing: the abort ended its load before it applied (maintainer decision: an abort ends the load; law 10), so its replaced owner's failure keeps the replay failed (two 30-times counterexamples, now `keeps a replay failed when a replacement aborted after its replaced owner failed` and its peer-loaded twin; RED on the first simplification, which counted the aborted replacement as covering; a mutant that drops the check fails both). The controller always aborts and releases a segment together, so it never makes that case. An abort ends the load (maintainer decision): the history model's `abort`, and a truncate that replaces an attempt, stop that attempt's gating at once in both `manual` and `reject` cancellation; an owner whose attempt the abort ended before it settled owes the replay nothing, and a rejection after the abort decides nothing (a failure settled before the abort stays). The publication model finishes a replacement at an abort, and the replay oracle's newer attempt drops the older attempts' pending loads. A synchronous throw from `loadSubset` is a rejection (maintainer decision): an acquisition that core releases, also in the acquisition-cell and replay witnesses. RED on 2bc79fc7c: 18 lifecycle tests, 1 refinement test. Mutants that fail: a replay that waits for an aborted load (14), an aborted rejection reported as `loadSubset:error` (19), an aborted load that still holds readiness (17). | | Paced mutations | [virtual-clock oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/paced-mutations-oracle.test.ts) | `createPacedMutations` with queue front/back options, bounded waiting capacity at zero and one, cleanup draining in FIFO/LIFO order after a held write, cleanup timing at the configured wait for immediate writes, admitted writes after separate Collection cleanup, debounce and throttle schedules with explicit and omitted options, and first-leading throttle execution at epoch zero. Finite histories compare optimistic rows, returned transaction identity and receipt outcome, execution order and virtual-clock time. Held writes verify queue serialization. Leading-only throttle and debounce witnesses reject skipped calls immediately with their named dropped-call errors, including omitted edges, both edges disabled, and same-row rollback while a prior write is held. Capacity witnesses cover overflow rejection, distinct-key optimistic rollback, same-key admitted-write survival, and in-flight versus waiting admission. Cleanup witnesses check repeated queue cleanup, eventual admitted receipt settlement, direct queue strategy rejection of new callbacks, public post-cleanup rollback with `QueueDisposedError`, pending debounce transactions that wait until the last call's quiet edge after separate Collection cleanup, and a trailing throttle timer that runs at its regular edge after separate Collection cleanup. Deferred custom queue and batch callbacks check compatibility with `void` and `false` execute results, respectively. Frozen options verify factory non-mutation; a mutable option witness checks that debounce uses its construction-time trailing setting. Throwing `onMutate` histories check leading-edge reuse before any row change, failed-row restoration when a pending transaction already has admitted work, eligible leading writes behind a held handler, and throttle pending merges at the throw, start, receipt, and later-timer cuts. New debounce/throttle admission after cleanup, manual commit of a strategy-owned transaction, longer throwing/reentrant bursts, failed persistence, broader capacities and schedules remain outside this owner. | | React paced-mutation render cuts | [render-cut oracle](https://github.com/TanStack/db/blob/main/packages/react-db/tests/usePacedMutations-render-cuts-oracle.test.tsx), [PR #2170 review](oracle-reviews/pr-2170-react-paced-render-cuts.md) | `usePacedMutations` publishes callbacks only from committed renders and does so before descendant layout effects, while callback-only commits preserve mutate identity. An independent committed-document model crosses ordinary and StrictMode schedules with mutation before or after a suspended candidate, mutation after the candidate commits, and delayed or immediate mutation from a descendant layout effect. It compares ordered callback traces and public rows at the mutation cut, the trace after persistence, and mutate identity at the commit cut. Render-time publication, layout-effect-only publication, and split insertion/layout publication mutants fail at the intended comparisons. A commit between transaction admission and persistence, arbitrary React schedules, server delivery, and other frameworks remain outside this receiving owner. | -| Optimistic state | [same-key composition](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-composition-oracle.test.ts), [history model](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-history-oracle.ts), [generated histories](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-transaction-oracle.property.test.ts), [outcomes](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-history-outcomes-oracle.test.ts), [publication](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-history-publication-oracle.test.ts) | Optimistic state drops at settlement and held sync transactions publish with it ([settlement-drop review](oracle-reviews/2026-10-03-settlement-drop.md)); whole-row active overlays, metadata and prior-value events. A raw subscriber without initial state checks that each truncate batch inserts a key once ([2026-10-02 review](oracle-reviews/2026-10-02-truncate-raw-subscriber.md)). Never rebase a pending snapshot merely to simplify the model. Generated histories also write source batches inside mutation handlers, before they return; the Collection must own the request by then ([handler source batch review](oracle-reviews/2026-10-01-handler-source-batch.md)). The same-key transaction lane also authors a delete followed by a reinsert, which must be visible optimistically and net to the truth-table request ([state mutation round 2](oracle-reviews/2026-10-02-state-mutation-round-2.md)). Source rows are written as `insert` for a key the source lacks, and batches may delete keys the source holds ([state-stack mutation gaps](oracle-reviews/2026-10-02-state-stack-mutant-gaps.md)). Batches may also delete a key the source never held. That delete leaves an active request in place ([absent-key source delete review](oracle-reviews/2026-10-02-absent-key-source-delete.md)). A separate partial campaign keeps the default partial row update mode, where source updates may omit a field and merge into the source row, not into an optimistic row ([round 3 review](oracle-reviews/2026-10-05-state-mutation-round-3.md)). An aborted open batch restores the driver's own source rows as well as its keys, so a later partial update still omits the field ([aborted-batch review](oracle-reviews/2026-10-08-partial-update-aborted-batch.md)). A failed synchronous manual `mutate` callback is now checked after same-key update, distinct insert, and same-key delete, with and without earlier intent, plus two two-Collection cases, one with a restoration listener failure. The throw cut compares public rows and pending payload; the later commit checks only surviving intent. A restoration listener error retains the callback error as the aggregate cause and does not stop other Collection restoration. These witnesses allow transient notifications during the callback and do not cover nested throwing callbacks or async work after the synchronous scope. After every step the driver also checks, by transaction identity, that the Collection tracks exactly its unsettled transactions, so a failed or rolled-back transaction leaves the map ([failed transaction retention review](oracle-reviews/failed-transaction-retention.md)). The [transaction ownership oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/transaction-ownership-oracle.property.test.ts) owns release across two Collections: merged-away pairs, conflicting rollbacks with a throwing subscriber, asynchronous success and failure settlements with a throwing subscriber, rollbacks from a `truncate` listener during a sync commit, a reused transaction id, and a `mutate()` callback that throws, checked in the same call stack and after promises flush. It also checks the settlement error shape and that a settled transaction's set of tracking Collections is empty; its mutations still name their Collection, so neither owner claims the Collection is collectable. Focused [restoration ownership](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/restoration-ownership.test.ts) witnesses check that repeated offline `mutate()` calls add to one transaction, that a completed restoration settles `isPersisted`, and that a restoration one Collection cannot track leaves no Collection tracking it. Its grammar also has direct writes (`insert`, `update` and `delete` outside a transaction), optionally with a throwing subscriber that may have been sent the rows first; it captures each direct write's own transaction and requires it to fail when the admission throws and to complete otherwise, counts handler calls (none for a rejected write), and keeps a mirror subscriber on each Collection that must match the rows after every step. A write whose admission throws rolls back only its own transaction and rethrows. The [change-event history oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/change-event-history-oracle.test.ts) generates local-only direct writes that meet a throwing subscriber, registered after the mirror, and requires the call to rethrow with its transaction completed and the rows following the model. Focused witnesses cover a local-only subscriber that writes the key it was told about, and a subscriber that throws a value that is not an Error; whether a throwing subscriber may stop a later subscriber is not decided. Open (approved limits): a direct write made inside another write's change subscriber reports its subscriber failure to the outer write; on a local-only Collection a direct write whose subscriber throws completes on the fast path but rolls back on the transaction path, and the change-event oracle generates only the fast path; a direct write admitted from inside a sync-commit listener, where the rollback's recompute is deferred, is not generated; the generated ownership histories read every receipt themselves, so only a focused witness (rolling back an empty manual transaction) checks that an unread receipt reaches no `unhandledrejection`; two Collection instances with one id, a Collection cleaned up before its transaction settles, and offline restoration have focused witnesses only; no generated history combines a held completed row with a second Collection. | +| Optimistic state | [same-key composition](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-composition-oracle.test.ts), [history model](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-history-oracle.ts), [generated histories](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-transaction-oracle.property.test.ts), [outcomes](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-history-outcomes-oracle.test.ts), [publication](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-history-publication-oracle.test.ts) | Optimistic state drops at settlement and held sync transactions publish with it ([settlement-drop review](oracle-reviews/2026-10-03-settlement-drop.md)); whole-row active overlays, metadata and prior-value events. A raw subscriber without initial state checks that each truncate batch inserts a key once ([2026-10-02 review](oracle-reviews/2026-10-02-truncate-raw-subscriber.md)). Never rebase a pending snapshot merely to simplify the model. Generated histories also write source batches inside mutation handlers, before they return; the Collection must own the request by then ([handler source batch review](oracle-reviews/2026-10-01-handler-source-batch.md)). The same-key transaction lane also authors a delete followed by a reinsert, which must be visible optimistically and net to the truth-table request ([state mutation round 2](oracle-reviews/2026-10-02-state-mutation-round-2.md)). Source rows are written as `insert` for a key the source lacks, and batches may delete keys the source holds ([state-stack mutation gaps](oracle-reviews/2026-10-02-state-stack-mutant-gaps.md)). Batches may also delete a key the source never held. That delete leaves an active request in place ([absent-key source delete review](oracle-reviews/2026-10-02-absent-key-source-delete.md)). A separate partial campaign keeps the default partial row update mode, where source updates may omit a field and merge into the source row, not into an optimistic row ([round 3 review](oracle-reviews/2026-10-05-state-mutation-round-3.md)). An aborted open batch restores the driver's own source rows as well as its keys, so a later partial update still omits the field ([aborted-batch review](oracle-reviews/2026-10-08-partial-update-aborted-batch.md)). A failed synchronous manual `mutate` callback is now checked after same-key update, distinct insert, and same-key delete, with and without earlier intent, plus two two-Collection cases, one with a restoration listener failure. The throw cut compares public rows and pending payload; the later commit checks only surviving intent. A restoration listener error retains the callback error as the aggregate cause and does not stop other Collection restoration. These witnesses allow transient notifications during the callback and do not cover nested throwing callbacks or async work after the synchronous scope. After every step the driver also checks, by transaction identity, that the Collection tracks exactly its unsettled transactions, so a failed or rolled-back transaction leaves the map ([failed transaction retention review](oracle-reviews/failed-transaction-retention.md)). The [transaction ownership oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/transaction-ownership-oracle.property.test.ts) owns release across two Collections: merged-away pairs, conflicting rollbacks with a throwing subscriber, asynchronous success and failure settlements with a throwing subscriber, rollbacks from a `truncate` listener during a sync commit, a reused transaction id, and a `mutate()` callback that throws, checked in the same call stack and after promises flush. It also checks the settlement error shape and that a settled transaction's set of tracking Collections is empty; its mutations still name their Collection, so neither owner claims the Collection is collectable. Focused [restoration ownership](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/restoration-ownership.test.ts) witnesses check that repeated offline `mutate()` calls add to one transaction, that a completed restoration settles `when('settled')`, and that a restoration one Collection cannot track leaves no Collection tracking it. Its grammar also has direct writes (`insert`, `update` and `delete` outside a transaction), optionally with a throwing subscriber that may have been sent the rows first; it captures each direct write's own transaction and requires it to fail when the admission throws and to complete otherwise, counts handler calls (none for a rejected write), and keeps a mirror subscriber on each Collection that must match the rows after every step. A write whose admission throws rolls back only its own transaction and rethrows. The [change-event history oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/change-event-history-oracle.test.ts) generates local-only direct writes that meet a throwing subscriber, registered after the mirror, and requires the call to rethrow with its transaction completed and the rows following the model. Focused witnesses cover a local-only subscriber that writes the key it was told about, and a subscriber that throws a value that is not an Error; whether a throwing subscriber may stop a later subscriber is not decided. Open (approved limits): a direct write made inside another write's change subscriber reports its subscriber failure to the outer write; on a local-only Collection a direct write whose subscriber throws completes on the fast path but rolls back on the transaction path, and the change-event oracle generates only the fast path; a direct write admitted from inside a sync-commit listener, where the rollback's recompute is deferred, is not generated; the generated ownership histories read every receipt themselves, so only a focused witness (rolling back an empty manual transaction) checks that an unread receipt reaches no `unhandledrejection`; two Collection instances with one id, a Collection cleaned up before its transaction settles, and offline restoration have focused witnesses only; no generated history combines a held completed row with a second Collection. | | Drafts and native values | [proxy](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-oracle.test.ts), [detachment](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-detachment-contract-oracle.test.ts), [iteration](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-iteration-contract-oracle.test.ts), [revert](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-revert-oracle.property.test.ts), [native methods](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-native-methods-oracle.property.test.ts) | Native-operation controls, exact patches and actual stored rows; alias/cycle/adversarial-key histories. Set and Map reorder histories check draft patches and stored iteration order after replacement and clear-and-readd; RegExp replacement/reversion histories check `lastIndex`. General native-mutator and symbol-write support is not established by a plain-object oracle. The revert owner generates assignment, revert, delete, nested, symbol-key, and `for...of` histories and checks `getChanges()` and the draft against an independent draft-equality model: ordered Map and Set contents (including Sets inside Map values), RegExp `lastIndex`, array holes, and symbol keys inside nested values. Map and Set mutators and top-level symbol writes are outside its grammar; array mutators appear only in the mixed block below. Its round-trip grammar also replaces a field with a new object and restores the original value through a nested write. The law requires every changed key, and permits a written key whose final value equals its original to be reported with that value: a false positive repeats the stored value, and a false negative loses a write ([review](oracle-reviews/proxy-revert-replaced-object.md)). A further revert block mixes array `push`, `pop`, and `reverse` with assignments, a nested object replacement, and a retained handle, and checks that a mutator through a replaced handle changes nothing; pinned witnesses require a native write through a Map or Set value, a typed-array view, or a frozen draft to be reported, and a nested revert inside a Map or Set to keep the container's other write; Map and Set mutators are not mixed with assignments, and an equal-valued assignment keeps a retained handle attached (declared). The proxy owner's stored-function law checks, against a native row, that a function stored as data reads back as stored by every read path and that a stored method sees the draft as `this`, so its writes are tracked. The detachment owner pins which key classes a draft copies: enumerable string keys and every symbol key. The revert owner also generates typed arrays (including a subclass that does not forward its constructor argument), URLs, and a class with only private state as written values, with a property that writes two of them over one field. The native-methods owner generates rows (holes, `undefined`, duplicates, nested arrays, empty rows, typed `-0` and `NaN`) and calls every method of `Array.prototype` and `TypedArray.prototype`, in fixed and random campaigns with replay; it compares the result, whether the method returned the array itself, the identity of each original object, and the published row with native values, and a new built-in method fails until it has arguments. The proxy owner's `defineProperty`, stored-function, and frozen-draft laws are bounded tables against native rows. Its `defineProperty` law compares result, value, descriptor, and changes, for values and accessors. The iteration owner checks that array iterators see a push, removal, index write, or length cut made while they are open. Open: a draft reads a class instance of the original row as a plain object, so private-field getters read `undefined`, and a built-in method called through `this` on a nested Map or Set draft rejects the Proxy receiver; a mutator that changes nothing publishes an equal value; writing back a keyless class instance of the original row is a change, and `DataView` setters are untracked ([draft proxy review](oracle-reviews/code-weight-draft-proxy.md)). | -| Query DB and observer | [ownership](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/ownership-lifecycle.oracle.test.ts), [persisted direct-write window](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/persisted-direct-write-window.oracle.test.ts) ([review](oracle-reviews/issue-2029-direct-write-lock-window.md)), [load lifecycle](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/load-subset-lifecycle.test.ts) (fixed tests; no oracle owner (tracked in #2264)), [observer histories](https://github.com/TanStack/db/blob/main/packages/db/tests/live-query-observer-history-oracle.property.test.ts), [retained snapshots](https://github.com/TanStack/db/blob/main/packages/db/tests/live-query-observer-snapshot-oracle.property.test.ts) ([review](oracle-reviews/pr-2043-retained-observer-snapshots.md)) | Real QueryClient boundary, applied-settlement barriers for existing and cached demand, mutation-handler Collection identity, terminal fetch-record lifecycle, and a per-listener eligibility ledger, not a duplicate dispatch queue. The bounded handler grammar crosses insert, update, delete, parameter and mutation-alias access, refetch, and clearError. A held clearError retry retains the prior public error through initial, background, and Collection application failures; success clears it, failure advances the count, including a repeated error at clock time zero. An intermediate Query retry attempt does not recount the previous background error; a held final attempt can succeed or fail. A held application after successful Query fetch keeps the error visible until the applied result. A mocked persisted-baseline scan holds an older success while a newer terminal Query failure arrives; the newer public error and count survive application, including when the Error object and clock tick repeat. Native SQLite persistence, concurrent retries across multiple tracked Queries, mutation-handler fetch-boundary settlement, and deferred result application remain outside this witness. The mutation overlap grammar crosses both start orders. Check reentry, peer survival, FIFO and disposal independently of final rows. A retained observer snapshot shows its capture-time rows on a later first read of `data` or `state`. Direct-write witnesses now distinguish an idle pending eager cache seed (success with no new freshness timestamp) from an in-flight pending request, and require update, delete, and upsert to reach both eager Collections through their shared physical Query, in both acquisition orders. They check both public Collections and the Query cache immediately after each write. A held older shared fetch also crosses B's release and reacquisition, then compares both public rows at refetch and load settlement; mixed select wrappers and three or more co-owners remain outside this witness. The load-lifecycle tests also restore an intermediate checkpoint that keeps an explicit subset refetch pending until a post-write request applies, and rejects a no-cancel mutant at the replacement-request assertion. . Two sync-metadata session witnesses fail on `30175be48`: a claim that inserts a row keeps applied non-owner metadata, and a claim during a held retention cleanup publishes the cleaned owner (see Sync metadata session)| +| Query DB and observer | [ownership](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/ownership-lifecycle.oracle.test.ts), [persisted direct-write window](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/persisted-direct-write-window.oracle.test.ts) ([review](oracle-reviews/issue-2029-direct-write-lock-window.md)), [load lifecycle](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/load-subset-lifecycle.test.ts) (fixed tests; no oracle owner (tracked in #2264)), [observer histories](https://github.com/TanStack/db/blob/main/packages/db/tests/live-query-observer-history-oracle.property.test.ts), [retained snapshots](https://github.com/TanStack/db/blob/main/packages/db/tests/live-query-observer-snapshot-oracle.property.test.ts) ([review](oracle-reviews/pr-2043-retained-observer-snapshots.md)) | Real QueryClient boundary, applied-settlement barriers for existing and cached demand, mutation-handler Collection identity, terminal fetch-record lifecycle, and a per-listener eligibility ledger, not a duplicate dispatch queue. The bounded handler grammar crosses insert, update, and delete through handler parameter and mutation-alias access. All calls explicitly refetch; former `refetch`/`clearError` coordinates remain only as historical replay labels, preserving all 12 identity and 12 mutation-overlap executions. The ordinary [health scenarios](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/ownership-lifecycle.test.ts) observe exact public causes and settlement. A held refetch retains the current public error. A failed initial load, including a failed initial Collection application, remains a loading error for that sync run regardless of later fetch results. After readiness, a pending retry retains the prior failure reason; failure reports the later cause, and successful result application clears it. Query retry attempts and an older held success are crossed with newer failures, including reuse of one Error value in one clock tick. These scenarios supplement the oracle; they do not claim removed utility fields or error counts. Native SQLite persistence, concurrent retries across multiple tracked Queries, mutation-handler fetch-boundary settlement, and deferred result application remain outside this witness. The mutation overlap grammar crosses both start orders. Check reentry, peer survival, FIFO and disposal independently of final rows. A retained observer snapshot shows its capture-time rows on a later first read of `data` or `state`. Direct-write witnesses now distinguish an idle pending eager cache seed (success with no new freshness timestamp) from an in-flight pending request, and require update, delete, and upsert to reach both eager Collections through their shared physical Query, in both acquisition orders. They check both public Collections and the Query cache immediately after each write. A held older shared fetch also crosses B's release and reacquisition, then compares both public rows at refetch and load settlement; mixed select wrappers and three or more co-owners remain outside this witness. The load-lifecycle tests also restore an intermediate checkpoint that keeps an explicit subset refetch pending until a post-write request applies, and rejects a no-cancel mutant at the replacement-request assertion. . Two sync-metadata session witnesses fail on `30175be48`: a claim that inserts a row keeps applied non-owner metadata, and a claim during a held retention cleanup publishes the cleaned owner (see Sync metadata session) | | Observer hydration under terminal statuses | `packages/db/tests/live-query-observer-hydration-oracle.property.test.ts` | ARCHITECTURE.md law 16 and the glossary "crash": while a hydration seed is active, a Collection `error` or `crashed` shows in the snapshot, attached or detached, and is announced; an attached observer that sees the failure ends the seed, so cleanup then shows `cleaned-up` and live rows, not the stale failure or the seed. Decision (not a law-16 claim): `cleaned-up` and restart under an active seed keep the seed as `ready`, because cleanup is not a failure and the next sync run's readiness hands off. Generated histories cross both modes, attach order, live writes, `markReady()`, `fail()`, `crash()`, cleanup, and restart, with declared reach; replays pin the crash, fail-then-cleanup, and crash-before-attach traces. Limits: rows under a failure status are not claimed; the source is a plain controlled Collection, not a live-query graph or an on-demand subset; streamed client errors, handoff timing, and framework adapters stay with the focused observer and adapter tests; the property is not in the `oracle-config.ts` path-replay registry. | | Query DB SSR dehydration | `packages/query-db-collection/tests/ssr-dehydration-oracle.test.ts`, [review record](oracle-reviews/issue-1950-ssr-dehydration.md) | A plain Query cache control, a live on-demand compound predicate, a signal-only request, and an ordered cursor with a custom comparator all retain their row through QueryClient dehydration, reconstruction of Seroval's emitted stream payload, and Query Core hydration from that payload. The on-demand query functions still receive the original IR, comparator, and signal; serialized metadata retains enumerable user metadata but excludes request options. The original implementation fails the compound and signal cases at the stream checkpoint. A serializable stream-only request-options leak passes the former chunk-count check but fails the current payload assertion. This owner covers successful Query cache entries at the installed Query Core 5.90.20 and Seroval 1.5.0 versions. A TanStack Start integration owner still needs the reporter's Router 1.171.33 and Seroval 1.6.8 path, browser Collection resume/refetch, and request cancellation across SSR. Query subset and pagination oracles remain owners of predicate/order/cursor meaning and supported value types. The issue's `shouldDehydrateQuery` workaround replaces Query Core's success-only default and admits pending/error queries; any documented workaround must preserve that default filter. | | Query DB ordered refetch | `packages/query-db-collection/tests/refetch-ordered-prefix-oracle.test.ts` | Issue #2136: after preload, and after `utils.refetch()` or a Query invalidation settles, an on-demand Query Collection behind a LEFT-joined, ordered, limited live query must show the window over the current backend rows. The design under test: query-db reports `invalidateLoadedSubsets()` when an ordered page changes keys and either has a later page or ends earlier in the order than before, and each ordered loader runs its bounded prefix repair. Its model is a pure top-k of marked backend rows. A fixed and a random campaign generate rounds of marker flips (which serve continuation pages), backend changes while the provider lags, a second live query (`observe`) whose own demand can load a page's new row first, optional Query invalidation with further changes, and a refetch, across one or two order terms, a numeric or locale-collated string order, limits 1–3, offsets 0–1, and a static or function Query key. With an unchanged backend, a repeated refetch must serve no more rows, start at most one first-page request, and report nothing. A refetch or invalidation must never start a new full-source request. A coverage law runs at the same checkpoints: every backend row from the start of the order through the window's last row (every row, when the window is short) must be in the source Collection with its current values, so a latent gap fails before a marker exposes it; a named replay of the issue trace with the moved row unmarked passes the window law and fails only the coverage law when Query DB never reports. The grammar can hold an optimistic update across a refetch, with the checkpoint moved to its release, and a premise-directed campaign under a hold (other rows observed, a row moved ahead of the page's end, a new row further ahead, a write to the last-sorting observed row) requires a report during a hold in every case and fails a skip that reads rows held back behind the transaction; the general campaigns did not. The general grammar holds optimistic deletes as well as updates. The generated campaigns declare reach (minimum 3) for a lagging refetch after a continuation, one during a hold, and a refetch that reported while the window was full, where the loader decides between ignoring the report and repairing. A named replay of a refetch during a held delete left the window short after release, also with this PR's report and overlap changes reverted, until the fix that resumes a continuation after a synchronous answer; no window is checked during a hold, where #2148 and #2169 remain open. Reports (`loadedSubsets:invalidated`) are bounded: a step reports at most once, and only when some ordered request was answered again with different rows; named replays require no report when a one-page window, or a window's last page, only loses a row, and a sync-boundary replay requires a report when a page ending on a row without an order value has its end move earlier (on the earlier gate, which read that page as empty, it reported nothing), and another requires a report when the provider returns a one-page window's rows in reverse order (the earlier gate took the last array row as the page's end). When a page's end moves earlier, Query DB reports. A loader whose window lies inside its own first page skips the repair only while its page read past the window or it read the tie of the current end; after an insert into a top-N list moves the end, a named replay requires the loader to prove the window again with bounded reads, no full-source read, and an unchanged Query count. A filtered one-page window whose page end moves past an unseen row must still repair (found by the random campaign; a loader that always skips fails it, the f9 tie replay, and the ordered-work oracle's signalling replays). The skip also refuses while the source holds accepted sync rows back: a replay refetches while an optimistic update persists, so the report arrives before the refetched rows are visible, and a skip that reads those older rows fails it. The ordered loader state oracle checks that a prefix read issued before an ordering invalidation, which settles after the bounded repair, does not retire the repair's prefix; the repair-first order failed before that fix. A `loadSubset` call that acquires a released, invalidated page can report, because Query then refetches that page on its own. A failed repair shows only the last window or the right one. Named replays cover the issue trace after a refetch and after an invalidation, with a page-capping provider, with another live query loading the page's new row first, with an empty later page, with a page whose end moves earlier while a tie request holds its old last row, a second refetch after an invalidation, a repair failure, and a provider that honors neither cursor nor offset. Never signalling, the old overlap-to-full-source rule, ignoring empty later pages, reporting on any page change, removing the repair loop guard, and reporting once per page each fail it by assertion. A replay makes a page's application fail and checks the window shows its last result or the right one, then the next refetch restores it; recording a page before its rows are accepted cannot be distinguished, because the failed attempt reports on its own. Open: the report laws bound reports but do not require each one; a page whose end moves earlier reports even with unique order values, where no row can be missing; it does not check whether a repair waits for in-flight work; the generated campaigns use an uncapped cursor provider only (an offset-only provider and a page-capping provider each hit a loader defect that reproduces on main: #2182, #2183); the generated campaigns do not check the window after a marker flip (a named replay checks the trace where the loader stopped refilling after a joined-side removal, which the repair-retirement fix below closed); a failed repair still recovers through the loader's full-source retry; interval, focus, and reconnect refetches share the invalidation path but are not driven; NaN order values have no executable witness; overlapping `refetch: true` acquisitions on one Query hash are counted, so the first to finish does not clear the loop guard for the other, but no witness separates that from a plain set; a Query refetch that joins a running `refetch: true` acquisition on the same hash does not report, and no witness reaches it (#2185); follower tabs of a multi-tab persisted collection never receive the report (#2174). The offset-only and page-capping loader defects have `it.fails` witnesses in the oracle. A finished bounded repair used to discard its boundary when its retirement made Query DB remove a row the loader had sent; the loader then restarted paging with a smaller first page that could not retire the repair's prefix, so every later refetch read both. A named replay found by the random campaign (at four times the run count) and an ordered loader state witness fail on that, and the fix also closed the joined-side refill defect. At four times the run count, the random campaign found a short window that stopped paging before the last row of the order: a report's repair released continuation Queries, and Query DB answered their reacquisition synchronously from its cache, so the graph turn inside each request asked for more while the loader was still requesting and nothing resumed the staged continuation. Only the coverage law failed. A named replay and an ordered loader state witness (a synchronous answer whose writes the graph already processed must continue the chain) fail on that, and pass with the fix. The loader side of the report also has a second owner: the ordered-work oracle's `refetch-signal` provider re-runs each served request alone and reports when any answer changed, across that oracle's generated plans, and a signalling refetch must start no full-source request unless a bounded page in that step ended on a null or NaN rank, which takes the documented full-source fallback (a random seed found that case). Never signalling and the old overlap-to-full-source rule each fail it. It excludes a peer live query, which can keep a short window after a refetch (#2155). The persisted-collection oracle checks that a source's report reaches the Collection through the managed cache-generation wrapper and that a retired source session's report is dropped. Remaining work for this class is tracked in #2186. | @@ -1501,7 +1756,7 @@ generic visitor behavior outside the named collectors. | Join equality and cold acquisition | `packages/db/tests/query/cold-join-reconciliation-oracle.test.ts` | Independent recomputation for cold acquisition plus direct join/predicate equivalence across established equality domains. Binary/string and nullish classes, replacement histories, raw on-demand values, and both scan/auto-index paths are explicit; the compound lane also covers tuple equality, term order, all four join types, controlled cold sources, and keyed histories. Positive acquisition witnesses require a satisfiable tuple. | | Aggregate selects | [aggregate select oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/query/aggregate-select-oracle.property.test.ts) | Generated select shapes of aggregate queries with and without `groupBy` by `c.k`, the pair `c.k`, `gt(c.v, 1)`, the triple that adds `gt(c.v, 2)`, `upper(c.k)`, `inArray(c.k, ['a', 'b'])`, a Date-valued `coalesce` or a `NaN`-valued `add`, rebuilt per use (literals, parent fields, the group key, the raw field `c.k`, source fields, `count`/`sum`/`max`, `add`, `caseWhen` with expression or object branches, nested objects, spreads, a nested include) and a HAVING condition that may compare an aggregate, selected as a field or not, at the top level, directly or through a `unionAll`, and in an include whose source may shadow the parent alias, through parent updates and source inserts, updates (including key changes that move a row to another group and route) and deletes. A shape throws `NonAggregateExpressionNotInGroupByError` exactly when a non-key source field appears outside an aggregate in its select or HAVING, or the shape spreads a row or holds a nested include; otherwise every published group equals the model and keeps the select's field order. The same grammar checks the opt-in operator laws: a grouped query whose select and HAVING hold no aggregate throws `GroupingOperatorRequiredError` when its keys are passed bare, and works through `groupKeys()`; a bare query builder throws `IncludeOperatorRequiredError` as a field, inside a nested object, or in a conditional branch; a wrapped include inside a nested object throws (a conditional branch may be an include; the includes-collection oracle publishes one, and a compiler that rejects one fails both that oracle and this one). When several errors apply, any one of them passes. Includes are wrapped in `toArray()`, `include()`, or `materialize()`. In three generated histories of six, the driver strips one kind of operator code, the grouping compiler, the include runtime, or the grouping compiler of the `orderBy` aggregate only, from the IR it built. One history in three is a forced history that strips the grouping compiler everywhere or from `orderBy` only, with no, one, or two group keys in turn, so every campaign reaches each path and compiles it through `new BaseQueryBuilder(ir)`; IR without its operator code must throw the coded error for the missing operator. A runtime-stripped history gives its include a child query that is valid on its own, so other errors cannot hide the missing runtime. Half the grouping-stripped histories give the top-level query a select of `count()` alone, with an aggregate HAVING when the generated shape has one, so a model error set of exactly `groupingRequired` witnesses the grouped, one-group, and HAVING paths. The top-level and include queries may order their groups with one or two `orderBy` calls, each on an aggregate or on a number or string select field through `$selected`, strings with the documented `en-US` collation: published rows and each parent's `kids` fall into blocks of equal values in that order. An `orderBy` aggregate that no top-level field selects throws `AggregateFunctionNotInSelectError` (now stated in the guide's "Ordering by SELECT Fields"), and a stripped `orderBy` aggregate compiles with the compiler that `groupKeys()` or a select or HAVING aggregate brings. One history in four takes ordered shapes from a grammar whose shapes compile, because generated ordered shapes compile in about one case in forty; the source starts with a key `A`, where locale and code-unit order differ. The property declares twenty-one states with `createOracleReach`, and each campaign of 160 histories reaches each of them; without the forced select, the one-group and HAVING states were never reached, and a mutant that throws the wrong error class only on the implicit path passed three campaigns. Pinned witnesses in `hand-built-ir-operators.test.ts` cover the old `IncludesSubquery` argument order, an aggregate only in `orderBy` without a select aggregate, and the mixed `orderBy` replay. The compiler before the order step took the query's grouping compiler fails the generated `orderBy` dimension (`top compiles`); an order step that ignores direction, keeps only the first `orderBy` call, or sorts strings by code unit, an include query that ignores direction, a group step that keeps only the first one or two keys, and a check that throws the wrong error class only beside HAVING each fail it (the last passed two of three campaigns before the alone states). A mutant without the include-runtime check, which publishes `kids: null`, and one without the grouping guard, which throws a `TypeError`, both fail it. A mutant that skips the grouping-operator check fails it with a `TypeError`, and one that treats a bare builder as an include fails it with the wrong error class. Open: an `orderBy` term on a raw expression other than an aggregate, and on boolean, Date, `NaN`, or object fields, whose order the docs do not state. The operator laws are generated only for aggregate and grouped selects; for plain selects, `includes.test.ts` pins a bare builder as a field, nested, and in a conditional, because this model publishes one row per group and needs a separate row-per-source model for them. Open: the random campaign's declared reach of "a string field orders A against a lowercase key" can miss on an unlucky seed. One full run on 2026-10-28 missed it (0 histories at 160 runs); 15 reruns reached it. The reach check runs after the campaign, so the miss records no replayable seed. Needed: a grammar weight that makes the case common, or a reported seed for a missed reach. | | Query optimizer semantics | `packages/db/tests/query/optimizer-semantics-oracle.test.ts`, [aggregate review](oracle-reviews/2026-09-28-optimizer-aggregate-pushdown.md), [QueryRef repair](oracle-reviews/2026-09-30-queryref-publication-repair.md), [residual review](oracle-reviews/2026-10-01-pr-1977-residual-convergence.md) | Independent sum recomputation and a materialized-Collection formulation check the first public snapshot of a nested aggregate under a left join. Direct, arithmetic-wrapped, and conditional aggregates distinguish safe from unsafe pushdown; a grouped-key control covers the grouped boundary. Matching and nonmatching global aggregates also cross a nested QueryRef inner join with absent, accepting, and rejecting outer predicates. The matching initial cases fail on the pre-repair revision and pass after the computed-projection lookup repair. A separate fixed residual-convergence grammar checks exact predicate and outer WHERE clause counts at `optimizeQuery` return and after re-optimization: LEFT, RIGHT, INNER, and FULL joins; equality, greater-than, nullable-field `isUndefined`, same-source OR, cross-source equality, clause order, separate/AND form, two active predicates, two residual sources, and an existing residual marker. Plain-array LEFT/RIGHT and three-source join models check first public rows, including unmatched rows. Arbitrary expressions, more complex join chains, and incremental optimizer/publication histories remain outside this owner. | -| QueryRef operators and user-value boundaries | `packages/db/tests/query/subquery-user-value-oracle.test.ts`, `packages/db/tests/transactions.test.ts`, [repair record](oracle-reviews/2026-09-30-queryref-publication-repair.md) | A finite array/Set model checks the compiled output bag for a joined DISTINCT subquery with an outer WHERE. Live-query drivers check selected containers, no-select, functional, and predicate-literal user objects with IR-like fields, top-level and aggregate-subquery proxy-shaped fields, arrays of selected references, outer virtual-field filters on joined DISTINCT and nested aggregate QueryRefs, and a renamed no-select source. The transaction suite documents the existing development-browser duplicate-load guard. The bounded cases run in `test:oracles` or the full DB suite. Joined DISTINCT, nested and flat aggregate source-update histories pass. Ordered joined `findOne()` checks initial, singleton, empty, restored, and changed-join-key cuts; an unordered `findOne()` QueryRef on either side of a join checks default-key candidate deletion and reinsertion. A materialized joined `findOne()` supplies a separate receiving formulation. Other `singleResult` forms, join forms, schedules, and cross-copy value handling outside the duplicate-load guard remain unproved. | +| QueryRef operators and user-value boundaries | `packages/db/tests/query/subquery-user-value-oracle.test.ts`, shared `subquery-literal-value-oracle.ts`, supporting `subquery-literal-value-driver.ts`, correlated `subquery-literal-correlated-oracle.test.ts` / `subquery-literal-correlated-oracle.ts` with concrete factory/driver support, supplemental `subquery-user-value.test.ts` and `transactions.test.ts`, [repair record](oracle-reviews/2026-09-30-queryref-publication-repair.md), [chunk 19](oracle-reviews/2026-10-28-pr-2171-oracle-chunks.md) | The independent relational models own joined DISTINCT with outer WHERE, aggregate-source updates and joined findOne histories. Other fixed container/no-select/functional/predicate-literal examples are supplemental ordinary tests, not independent oracle scope. Chunk 19 adds one ordinary-JavaScript literal model with 17 forms × two positions × root/QueryRef receivers: 68 histories and three cuts each. It checks complete ordinary rows, event bags/current/previous values, event-time reads and immutable QueryRef callback inputs, with all 204 receiving and 102 callback states reached once. Strict exact-driver admission passes; unchanged production has eight undefined-to-null REDs (P11), ten empty-container-loss REDs (P12), 50 passing new histories and six passing relational neighbors. Twenty-four hostile and four lawful controls pass; partial selections still fail mandatory reach. Chunk 20 receives the same literal law through toArray, array/singleton materialize and Collection facades: 272 exact histories, all 3,264 path/cut states once, 200 main passes and 72 intended P11/P12 REDs; 33 checker controls and six independent facet controls pass. Legal QueryRef children have their own correlation WHERE and preserve the authored routeGroup input until actual projection removes it. Facade layouts are ordered, startup callbacks permit canonical empty values but owe complete ready inputs, and all 272 titles remain distinct for precise selection. Chunk 21 adds a separate source-public type judgment (`subquery-literal-types-oracle.ts` / `.test.ts`, supporting `subquery-literal-types-support.ts`) for those exact 340 authored receivers: 1,870 actual QueryResult, Collection-row, contextual-input, inline and facade/API markers. All consumers compile without diagnostics under TypeScript 5.9.3, and all 2,550 syntax/admission/marker states execute once. Unchanged production has 280 intended RED histories: 40 raw-nullish precision failures and 272 contextual-input or propagated-inline metadata failures, with 32 overlapping; 60 native histories and 55 checker/source controls pass. Independent review exposed erased call/construct signatures on records; the recorder now retains those declared capabilities and eight existing empty/nonempty × root/nested record controls reject invented signatures, after all eight failed under the original checker. Only the separate four virtual fields at named query-output or published/source Collection row roots are excluded from this ordinary-value judgment; callbacks and inline children remain strict. The full static callback metadata grammar remains open under existing `virtual-row-fields-types-oracle.test.ts`, with the literal sources as receiving witnesses of its accepted P10 law. Source-public inference does not establish declaration portability; an exported inferred factory currently gives TS4058 and is preserved for O22, including exported Collection and built/installed controls. Broader mixed-expression admission, sparse authored arrays, symbols, ordinary-record readonly modifiers, descriptor flags, arbitrary recursion and provider/framework schedules remain open with their sequential receiving owners. Metadata attribution stays with its separate owner; the four reserved fields are excluded only at each observed root row. No production repair or closure is claimed. | | Opaque backend pagination | [window oracle](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/cursor-pagination.oracle.test.ts), [cache histories](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/cursor-pagination.cache-oracle.test.ts), [cache publication](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/cursor-pagination.publication-oracle.test.ts), [browser acquisition boundaries](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/cursor-pagination.boundary-oracle.test.ts), [QueryCollection integration](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/cursor-pagination-oracle.integration.test.ts) | Full filter/sort/slice reference, opaque token transport, actual Query cache expiry/invalidation/GC, forced refresh during growth, protocol failure publication/recovery, bounded slice work, nested cancellation/replacement, reader abort, browser retry defaults, manual-write cache isolation, and production window publications. Stable backend sequences; not snapshot guarantees for changing endpoints. Peek-ahead remains enabled. | | Experimental no-peek continuation | [test-only live-window probe](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/cursor-pagination-oracle.no-peek.integration.test.ts) | The candidate uses an authoritative fact only for the same complete output prefix and otherwise requests the extra row. It remains test-only; the shipped opaque-pagination owner keeps peek-ahead. See [experiment limits](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/cursor-pagination/NO-PEEK-RESULTS.md). | | Electric and TrailBase | [Electric histories](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/tests/electric-oracle.property.test.ts), [recovery histories](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/tests/electric-recovery-oracle.test.ts), [held resume snapshots](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/tests/electric-resume-snapshot-races.test.ts), [PostgreSQL semantics](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/e2e/sql-predicate-semantics-oracle.e2e.test.ts), [TrailBase contract](https://github.com/TanStack/db/blob/main/packages/trailbase-db-collection/tests/ORACLE.md) | Installed SDK delivery/framing, independent predicates, exact subscription arguments, restart/reset lineage, held certification and durability races, source-order publication before durability, and late errors. The queued-presence property runs identical fixed/random generators plus isolated seed-and-path replay across insert, update, delete, and truncate callbacks. The recovery fixtures use a mocked ShapeStream; they do not establish live Electric-service framing or native persistence-host behavior. The TrailBase lifecycle oracle's early-demand lane checks that an on-demand load issued before the change listener runs does not fetch until the listener runs or cannot start, through a real live query and the subset boundary: 16 histories cross listen, subscribe rejection, reader failure, abort, and cleanup with a service change before the subscription; the adapter from before the wait fails all 16. Its page-event lane checks that a page read before a concurrent change event neither reverts nor resurrects that row, and its stream-end lane pins that the adapter does not resubscribe: 17 histories, 6 of which fail on the adapter before the page fence. A generated page lane (`trailbase.page-event`) crosses two-page loads, cursor and offset paging, overlapping on-demand loads, and up to four changes among page releases. A held-commit lane checks that a page never fails a load for an older event that core holds while a user mutation persists; deciding insert or update from visible rows fails it. After a generated history drains, the Collection holds exactly the rows a page returned or an event touched. Both page lanes deliver events for changes made before the list call at once, during the page, or after it applies, and check that the listener survives a late insert for a row a page already holds. The retry-decision property (`electric.retry-decision`) generates histories over full-log and progressive waits, with and without `onError`, crossing synchronous and asynchronous retries and give-ups, a throwing `onError`, a later error after a retry, an Electric client give-up, an external abort before or during a decision, new demands, the first up-to-date, a persisting optimistic transaction that holds the retried snapshot and later settles, and cleanup. The retry-decision grammar also crosses eager and changes-only sources, an external signal already aborted at sync start, and a status listener that cleans up inside the failure; it checks that a readiness wait rejects with the same cause as every waiting demand, and that such a cleanup leaves a rejected demand rejected. It also holds the acceptance of the first up-to-date commit across later errors, retries, give-ups, and aborts, and checks that the Collection becomes ready with the snapshot's rows when the commit is accepted, and fails with the acceptance's cause when it is not. The descriptor-isolation oracle holds that acceptance across a managed-cache rotation and checks that the retired session's acceptance settles nothing, whether it resolves or rejects, and that the replacement session's snapshot decides readiness. A progressive snapshot's truncate applies at once; a demand whose snapshot arrived before an external abort settles when its rows apply, and cleanup after the abort rejects it. An independent model checks the status, every demand's settlement and cause, and row visibility after each step: a source that still tries stays `loading`, and only a give-up fails its waits. `main` before #2127 and mutants that fail before the decision, skip the failure on a throw or give-up, or skip `markError` on a client give-up or an external abort each fail it at an assertion. An installed-client lane in the SDK delivery oracle drives the same model (`electric-retry-decision-model.ts`) through the real Electric client and HTTP: a retried 503, a response without Electric headers, and an exhausted 50-retry budget, in eager, full-log, and progressive modes, with a synchronous or asynchronous retry (18 cells). Skipping `markError` on the client's give-up fails the 12 give-up cells; failing before the decision, or `main` before #2127, fails 15; the 3 survivors give up in the same continuation that decides, so an early failure is not observable there. A mutant that settles a held demand on acceptance instead of visibility fails the held replays. Excluded: stream steps while `onError` decides or after a final failure, which the client cannot produce. The source-release property (`electric.sdk-source-release`) checks that cleanup removes every `visibilitychange` listener that the installed client registered for a sync run's streams: one to three runs in eager, on-demand, and progressive modes, each answering up to three requests with row-only delivery, an up-to-date, an HTTP 409 reset, or a retried 503, then staying open, giving up, aborting the external signal, or holding an asynchronous retry/give-up decision through cleanup and later runs, with restarts through `preload()`. It declares separate cleanup reach for partial delivery, reset before a later up-to-date, and a pending error decision. The minimal partial/reset histories cross all five endings in every sync mode through the same driver. It compares the live listener count with the number of active runs after each step. The recorder tracks ordered callback/capture identities with duplicate-add semantics; all 8,421 add/remove histories of length zero to three over two callbacks and five capture spellings compare its count with native EventTarget dispatch before teardown. Recorder teardown independently removes retained registrations after the comparison; bounded callback-subset enumeration checks actual EventTarget dispatch and preservation of a primary failure. `main` before the fix, and mutants that release only while the stream is unaborted or only without a final initial failure, fail it at cleanup; the generated managed-rotation property (`electric.sdk-rotation-release`) checks the approved Electric deadline before replacement `ShapeStream` construction. It rotates one to three times in one on-demand sync run, so a replacement session is itself retired. Each old HTTP snapshot arrives during held rotation, before replacement snapshots, or after their settlement. It declares reach for all three delivery cuts, initial/replacement session retirement, and demands awaiting replacement source responses. A transparent constructor observer records zero retired listeners immediately before the real SDK constructor; both retained demands stay pending while rotation is held and before any replacement response, then fulfill after their source snapshots apply. Fresh public/current-generation durable rows, late old-response rejection, and final cleanup comparisons remain. Each round also remembers its retired storage target and checks that the late response's absent key reaches neither that target after delivery nor after replacement settlement. The seventh declared state requires this retired-target observation. This constrains the expired session's row; unrelated retained rows remain allowed, and a separate run with a valid claim can still use a retired generation. Assertion inputs copy mutable observations so cleanup cannot change their failure payload. The browser listener is observed in jsdom; React Native's AppState adapter and overlapping rotations are outside this generated lane. | @@ -1509,7 +1764,7 @@ generic visitor behavior outside the named collectors. | SQLite persistence and native hosts | [persisted histories](https://github.com/TanStack/db/blob/main/packages/db-sqlite-persistence-core/tests/persisted-oracle.test.ts), [reset/resume histories](https://github.com/TanStack/db/blob/main/packages/db-sqlite-persistence-core/tests/sqlite-core-adapter-oracle.test.ts), [setup reopen](https://github.com/TanStack/db/blob/main/packages/db-sqlite-persistence-core/tests/collection-setup-reopen-oracle.test.ts), [reload class](https://github.com/TanStack/db/blob/main/packages/db-sqlite-persistence-core/tests/reload-class-oracle.ts), [dual-adapter resume snapshots](https://github.com/TanStack/db/blob/main/packages/db-sqlite-persistence-core/tests/sqlite-resume-snapshot-oracle.test.ts), [Browser composed-owner histories](https://github.com/TanStack/db/blob/main/packages/browser-db-sqlite-persistence/tests/per-collection-coordinator-oracle.test.ts), [Browser coordinator RPC](https://github.com/TanStack/db/blob/main/packages/browser-db-sqlite-persistence/tests/browser-coordinator-oracle.test.ts) (fixed histories and the source-commit scheduler-order test live in `packages/browser-db-sqlite-persistence/tests/browser-coordinator.test.ts`), [shared-driver fairness](https://github.com/TanStack/db/blob/main/packages/browser-db-sqlite-persistence/tests/shared-driver-fairness-oracle.test.ts), [driver contracts](https://github.com/TanStack/db/blob/main/packages/db-sqlite-persistence-core/tests/contracts/sqlite-driver-contract.ts), [Node shared-handle scheduling](https://github.com/TanStack/db/blob/main/packages/node-db-sqlite-persistence/tests/node-driver.test.ts), [OP-SQLite shared-handle scheduling](https://github.com/TanStack/db/blob/main/packages/react-native-db-sqlite-persistence/tests/op-sqlite-driver-oracle.test.ts), [browser OPFS lifecycle](https://github.com/TanStack/db/blob/main/packages/browser-db-sqlite-persistence/tests/opfs-page-lifecycle-oracle.test.ts), [worker diagnostics](https://github.com/TanStack/db/blob/main/packages/browser-db-sqlite-persistence/tests/opfs-worker-diagnostics-oracle.test.ts), [request order](https://github.com/TanStack/db/blob/main/packages/browser-db-sqlite-persistence/tests/opfs-request-order-oracle.ts), [Electron IPC and composed owner](https://github.com/TanStack/db/blob/main/packages/electron-db-sqlite-persistence/tests/electron-ipc.test.ts), [113-law manifest](https://github.com/TanStack/db/blob/main/packages/db-collection-e2e/src/fixtures/persisted-conformance-manifest.ts), [persisted sync problems](https://github.com/TanStack/db/blob/main/packages/db-sqlite-persistence-core/tests/persisted-sync-problem-oracle.test.ts), [persisted reload replacement](https://github.com/TanStack/db/blob/main/packages/db-sqlite-persistence-core/tests/persisted-reload-replacement-oracle.test.ts) | Core cache/remote rejection/peer/reopen histories, atomic reset/resume lineage, key-set evidence, dual-adapter races, queued coordinator reload/gap notifications during startup, unscheduled startup/reset overlap, and exact driver results. The persisted sync-problem oracle (`packages/db-sqlite-persistence-core/tests/persisted-sync-problem-oracle.test.ts`) owns how the wrapper reports failures of its own remote work and to which sync run: a later successful remote request ends the wrapper's remote failures (maintainer decision: a coarse clear, by any remote request that succeeds, the same one repeated or another; 8 histories failed before it, and a wrapper without the clear fails 7); 35 histories over eight sites (coordinator release, the adapter's own `unloadSubset` throw or reject, scoped refresh ensure and release, rotated reacquisition through a routed or single-process coordinator, and a reload's cleanup release), each crossed with ready or loading and with the current run or a run that `reload()` retired before or after the new run's `markReady()`. A remote failure is a ready Collection's failure reason and never a crash; before the first ready it is only logged; an `unloadSubset` breach surfaces uncaught; a retired run's failures change nothing in the new run and never reject its cleanup. HEAD failed 29 histories; mutants that crash, remove the generation fence, or let a cleanup release reject each fail. Limits: controlled adapter and coordinator doubles; no Browser or Electron follower routing; no coordinator retry queue. The persisted reload-replacement oracle (`packages/db-sqlite-persistence-core/tests/persisted-reload-replacement-oracle.test.ts`) checks over 40 histories that the wrapper forwards `begin({ snapshot })` and `truncate({ markReady })`, that its restore and metadata commits replace nothing and do not complete a reload, and that durable rows equal visible rows after a replacement. HEAD's wrapper failed 15; a dropped snapshot, a non-durable snapshot, dropped truncate options, and a restore marked as a snapshot each fail. Limits: eager Collection only, in-memory adapter. The setup-reopen owner checks that a fresh adapter's first read repairs an interrupted setup, every subset of missing indexes, triggers, and bookkeeping rows, and a schema change to the fresh-install schema with rows kept or reset; complete setup costs at most one driver call over a cached read. It excludes cache-generation claims and concurrent adapters; the held-registration resume cases cover a collected generation at the setup DDL and at the registry read. The reload class owner keeps the reload law, model, histories, driver loop, and comparison in one module, with four production drivers: the Node Browser driver (real Browser persistence and coordinator, one connection per tab), the Node Electron driver (real renderers and main handler with an in-process `ipcMain` double), the real Chromium OPFS page, and a real Electron app (renderer over real IPC to a main process running the Node persistence over `node:sqlite`). Histories cover 1 to 4 eager or managed Collections with any subset of missing derived setup facts, a schema change, a database missing a migrated column or index, lock grants 1 to 5 tasks after the request, statement latency where the host controls it, a local write during another startup read (issued before or after its grant), a write held in flight at another Collection's grant, a follower write, a write from a third tab, takeover (also while a follower read is held after an earlier follower or third-tab write, and while a follower that knows the departed leader's route has a source commit queued behind a held subset read, which it flushes after the grant or which the departed leader received and never answered), restart, and a tab closed while its terms wait. It checks skipped and repaired Collection and database setup, that no term runs ahead of work queued on its tab at its grant unless a leader need is live in that election (a need that an earlier election served exempts none), that needs elect and settle, that a takeover's queued source commit settles within one RPC timeout (one timeout and a retry when the departed leader received it), and an exact per-reload budget: one registry read per adapter, one row scan per tab, two startup snapshots per eager Collection (metadata before the source starts, rows at the baseline hydrate, both required by the Electric resume laws), no rescan after a commit the tab already knows, and no database setup statement when it is complete. The implementation on origin/main fails every fixed history: the original 24 on all four drivers, and the 5 that cover per-election needs on the Node Browser driver. Production on this branch passes all 29 on all four drivers. The Node drivers reject 14 production mutants by assertion, including a lasting need latch, an earlier served need from this tab or another, a need still waiting at the grant that is ignored, and a grant that keeps the departed leader's route. The per-election cells in `browser-coordinator.test.ts` (fixed tests; no oracle owner (tracked in #2264)) also reject those latches and a delayed heartbeat from the departed leader that restores its route during an election. Real Chromium rejects the never-waiting barrier across its histories; the real Electron app rejects it in 10 of the 29 fixed histories; in the others, real IPC finishes the startup reads before any grant. Hold seams patch the adapter in the process that runs it. Not owned here: a barrier that skips queued but not yet running regular work, which driver transaction order and the writer lock hide in reload histories; the shared logical scheduling oracle owns it. The real Electron app uses `node:sqlite` because Electron's ABI cannot load better-sqlite3; a native better-sqlite3 build for Electron is not run. The shared core-adapter contract checks custom local comparator dispatch for ascending, descending, and comparator-equal public-key ordering at `loadSubset` completion. Host packages register this contract, but the local sqlite3 CLI run does not prove native host execution. Browser composes public source commits with per-collection elected-owner routing and covers the complete committed-transaction wire partition through deterministic Node transport seams. Remote-subset histories distinguish logical demand, physical acquisitions, exact acquisition leases, and released replay tombstones. Electron composes source commits with a per-collection renderer owner, IPC adapter, real SQLite, and reopen checks. The shared-driver fairness owner checks K=1 complete-logical-hydrate scheduling with identical fixed, random, and seed-plus-path campaigns. It records public rows, raw dequeue reach, and a persist-first FIFO hostile control. Its Chromium OPFS fixture refines the provider boundary but does not establish a browser matrix, elapsed-time latency, unbounded eventuality, or multi-process coordination. Same-handle Node and OP-SQLite tests cover transaction admission. Controlled OPFS page/worker histories cover ownership and diagnostic-cause retention. The reset/resume owners use sqlite3 CLI and in-memory node:sqlite seams; they do not prove multi-process WAL, mobile/Tauri, or other native-device execution. Distinct database handles rely on SQLite lock admission rather than one in-process queue. React Native hosts without async-context propagation must use the transaction driver supplied to the callback for nested work. Fake workers and synthetic page events do not prove native handle release or real bfcache admission. The Browser composed seams are not real multi-context/OPFS-worker execution; the Electron harness is not an actual Electron process unless its explicit runtime-bridge mode runs. An ownerless elected node suppresses core routing, while a follower may route demand to the elected owner. The follower generation-notice and rotated-reacquisition lanes state the rotation law: a rotated follower holds the new generation's commit notices until the owner has reacquired every active demand, then ends scoped recovery and applies them. Production does not yet end that recovery, so their cells that require it are pinned as failing under the known gap [#2139](https://github.com/TanStack/db/issues/2139); the random generation-notice campaign is omitted until the fix, because some of its histories pass today. Cells that still pass cover failed reacquisitions (scoped recovery stays and the failure is reported), a superseding rotation as leader, and a scoped recovery without rotation, with or without an active demand (untrusted durable rows stay hidden). Other cells cover a follower whose claim expires while its ensure is in flight and an owner reset (the follower requests its demand again). The follower remote-demand lane checks that a follower's load, and a live query over its demand, settle only after the owner's broadcast applies here, for a broadcast that applies at once, one queued behind another subset's hydration, and one while the cache claim is expired; the claim-rotation placement is pinned under #2139. A failed apply rejects the load, errors the query, and fails the source. A renewal that fails transiently at the reply rejects the load but keeps and retries the demand, without releasing the owner's acquisition; a rotation during the reply wait is tracked under #2139. The flush-side expiry check and arrival-order queueing have no killing witness yet. Removing the follower's wait on either ensure path, its terminal check, or the queued flush's terminal marking each fails it. The Electron IPC readiness oracle checks a fresh or reopened renderer, a successful or failed IPC hydration, and an upstream load that succeeds, fails, or is released during hydration, against a model of the three readiness checkpoints; a renderer that replies before the main-process read or treats an IPC failure as empty fails it. The owner is a controlled coordinator double, not two real coordinators; host coordinators retry only classified transport or admission failures while demand remains retained. The manifest excludes progressive and move suites; registration and shim runs are not device execution. The per-collection coordinator oracle's real-tab lane runs a follower and an owner tab with real Browser coordinators over Node's BroadcastChannel and Web Locks and one real SQLite file: 16 histories over the owner's source (custom or Query Collection), the broadcast's placement, the follower's apply outcome, and the consumer, plus a rotation cell. Main fails all 16 histories; an owner tab for a rotated follower generation is not reachable there. The Electron IPC readiness oracle adds a follower and an owner renderer with Electron coordinators: 4 histories over the apply outcome and the consumer, in process and through a real Electron main process; main fails all 4. | | SQLite expression-index planning | [Node expression-index oracle](https://github.com/TanStack/db/blob/main/packages/node-db-sqlite-persistence/tests/expression-index-oracle.test.ts) | RFC #1659 invariant 8 owns identical persisted-index and runtime-expression shapes. Independent expected keys are checked against direct captured SQL, adapter results, and named-index plans. Generated BigInts use SQLite's signed range; one fixed case checks legacy oversized-value reads. Other limits: bounded unqualified JSON paths/scalars, Node BetterSQLite, and no null, arbitrary raw SQL, or native-host planning. Run the package's `test:oracles` campaign. | | Offline execution | [scheduler](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/KeyScheduler-oracle.property.test.ts), [leadership](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/leadership-replay-oracle.property.test.ts), [settlement](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/transaction-settlement-oracle.property.test.ts), [serialization](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/transaction-serializer-oracle.property.test.ts), [web connectivity replay](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/connectivity-replay-oracle.test.ts), [review record](oracle-reviews/pr-1879-visible-replay.md) | Declarative FIFO eligibility, per-transaction outcomes, durable state and typed wire trees. The web connectivity owner checks a persisted write under a false browser online hint, then visible replay by event or explicit retry, at provider-call, outbox, and caller-settlement cuts. It uses controlled browser globals and fake storage; it does not prove actual browser network detection, genuine offline retry timing, multi-tab leadership transfer, or React Native behavior. Issued work may finish after ownership loss, but new work must not start. Exactly-once network execution is not promised. | -| Frameworks | [React conformance](https://github.com/TanStack/db/blob/main/packages/react-db/tests/conformance.test.tsx), [React pagination](https://github.com/TanStack/db/blob/main/packages/react-db/tests/infinite-query-conformance.test.tsx), [shared framework contracts](https://github.com/TanStack/db/tree/main/packages/db/tests/conformance), [Vue synchronous publication](https://github.com/TanStack/db/blob/main/packages/vue-db/tests/useLiveQuery-publication-oracle.test.ts), [Preact conformance](https://github.com/TanStack/db/blob/main/packages/preact-db/tests/conformance.test.ts), [Preact binding witnesses](https://github.com/TanStack/db/blob/main/packages/preact-db/tests/useLiveQuery.test.ts) | Exact exposed rows/pages and each framework's own lifecycle cuts. Vue's synchronous watcher checks insert, update, and nonterminal delete through supplied Collection and identity query inputs; it does not cover an empty final result, multiple changes in one transaction, or query recompilation. A React witness does not prove Preact/Vue/Solid/Angular/Svelte scheduling. Preserve their receiving registrations. The Preact driver observes each result at commit through a layout-effect probe under an error boundary, because Preact runs passive effects after paint and `act` drains only those it scheduled; it has no Suspense, server-snapshot, or DbClient witness. Its binding witnesses check that a write between render and subscription publishes and that an equal `queryKey` or structurally equal query keeps its live-query Collection. Subscribing in a passive effect instead of a layout effect survives both, as allowed scheduling freedom. The query-identity laws (1.0, maintainer decisions 2026-10-22 and 2026-10-23) run through every live-query driver and the React, Preact, Vue, and Svelte infinite drivers, branching on each driver's public `comparesQueryIdentity` policy. In a binding that compares identity (React and Preact), a query without a nonempty `queryKey` whose IR has no stable hash throws `UnhashableQueryIRError` at mount and at recompilation, with any dependency list; an empty key counts as none, and a nonempty key runs the query. Such a binding claims each source Collection ID it compiled, so A(x) -> C(y) -> A(x) is legal while a later B(x) throws `SourceCollectionIdReuseError`. A pairwise model judges 32 enumerated histories over A(x), B(x), C(y), and a disabled step, with declared reach for a swap after an intervening query, a swap after a disabled step, and a revisit after an intervening query. Reach labels name the steps between the claim and its test, so a direct swap does not count; removing the C(y) step fails reach. Claims that keep only the latest query, or reset at a disabled step, fail it. React runs every shared history twice, with derived identity and with a dependency list (mount, config mount, and recompile); skipping the dependency-list check at the first compile fails five scenarios. A nonempty `queryKey` skips the claim: React and Preact witnesses swap a same-ID source under a changed key, and a key reader that ignores keys fails both. One query over two same-ID objects uses overlapping but different rows; an engine mutant that resolves each source through the Collection-ID map, so the last object serves both join sides, fails the live and infinite scenarios. A Svelte hydration witness checks that a `DbClient`-backed query with a dependency list hydrates `queryKey: []` exactly as it hydrates an omitted key. The infinite suite runs the same pairwise grammar over A(x), B(x), and C(y), 18 histories with declared reach for a swap and a revisit after an intervening query; it has no disabled or page-shape step, because one infinite handle changes either its query or its page shape. A new binding starts unclaimed. A React witness checks that a dependency-list query with a `DbClient` rejects an unhashable query when it compiles, at mount or after a dependency change, but not on a render whose dependencies did not change. After a rejection, two reads must each throw or hold no rows. No current driver can fail that check, and reactive drivers do not reject. React unmounts a tree whose render throws. Preact keeps such a component mounted, so its driver mounts the hook under an error boundary, which unmounts it; a Preact component with no boundary keeps its last committed rows and is not witnessed here. It guards a future driver whose binding stays mounted; the `DbClient`-backed Svelte binding, which stays mounted, is checked field by field in fixed [descriptor release tests](https://github.com/TanStack/db/blob/main/packages/svelte-db/tests/descriptor-query-release.svelte.test.ts) (no oracle owner; tracked in #2264). A reactive binding (Vue, Solid, Angular, Svelte without a `DbClient`) runs the same histories and must publish the current input's rows: the unhashable query runs and recompiles when a captured value changes, and B(x) shows B's rows, never A's. One query over two same-ID objects is likewise rejected by a comparing binding and read through an inner join by a reactive one. The same mount-and-recompile split is not yet generated for `DbClient`-backed Svelte or React hooks beyond the receiving witnesses below. The [React source ID reuse oracle](https://github.com/TanStack/db/blob/main/packages/react-db/tests/source-id-reuse-oracle.test.tsx) owns the release law for `DbClient` descriptors: a rejected query releases every sync start it deferred. React crosses six causes (source-ID collision, unhashable IR, a descriptor factory that throws, a query the live query rejects when it compiles, a single-result query in an infinite hook, and a Suspense query that cannot stream during SSR) with six hook paths (live, infinite, and Suspense, each derived or with a dependency list) and two direct readers; Svelte crosses the collision, unhashable, factory, and compile causes with eager and lazy sources and three readers, and its collision history reads every public result field separately after the rejection; dropping the guard from one getter, such as `isLoadingError`, fails all six collision cells. The React oracle also checks that a dependency-list Suspense hook claims its source when it takes a cached Suspense collection, on a retry after suspending and on a sibling mount; with a hashable dependency, the cache path skipped the claim before the fix. These are two fixed histories, not a dimension of the claim grammar: that grammar's disabled step has no Suspense form, because `useLiveSuspenseQuery` rejects a disabled query. Each hook releases at one render boundary (React `useLiveQuery`, `useLiveSuspenseQuery`, and `useLiveInfiniteQuery`, and Svelte's compiled query); removing any one boundary fails exactly that hook's cells, and routing Svelte's getters around the compiled query fails its collision cells; `preload()` overrides a deferral, so those cells pass without the release by design. Declared reach: each enumerated release history asserts that the rejected query ran the shared descriptor's factory once before the reader, and a history that binds the failing descriptor first fails that check. A render that never commits (React: a sibling throws after the hook returns, or the render suspends; Svelte: setup throws after the hook returns) releases its deferral when garbage collection reclaims the live query it built (maintainer decision 2026-10-23), or after the unsubscribed GC floor when the hook's config disables that reclaim (`gcTime` 0 or `Infinity`, or `startSync: false`); both release grammars add these causes, React adds the three config paths and Svelte a `gcTime: 0` setup, and waiting only for the reclaim fails those cells; removing the reclaim release fails exactly them, and releasing at the end of render instead fails the descriptor binding owner's no-work-before-commit law. Known limit, owner the maintainer: the reclaim follows the 50 ms unsubscribed GC floor, so a render whose commit React holds past that floor releases its deferral, and its source may start, before it commits; that render then subscribes to a cleaned-up live query, which restarts. No history holds a commit past the floor, which needs a controlled React scheduler or fake timers; this joins the existing held-commit open cell in the infinite-query publication row. A keyed step records no claim and checks none (maintainer decision 2026-10-23): for a comparing binding the claim grammar adds keyed steps AK and BK over the same-ID objects, 108 histories with declared reach for a claim that crosses a keyed step and a keyed step after a claim; a keyed step that claims, or that resets earlier claims, fails it for React on both paths and for Preact. Each controllable driver records a publication log (`publication-log.ts`) at the end of each synchronous turn, plus the value on screen at the first paint after each `setParam`, once the framework has flushed and before any later task (maintainer decision 2026-10-23). After a same-ID swap, a reactive binding's every logged value holds only the new source's rows or none: a binding that keeps the old rows published until a later task fails it in Vue and Svelte. A value replaced within one synchronous turn is not logged, because the browser never paints it; Solid and Svelte publish the previous query's rows once inside the swap's own turn, for any input change, and pass. Accepted design (maintainer decision 2026-10-23): React claims source IDs during render, so a concurrent render that is abandoned before commit keeps its claim, as the React oracle states for a render that suspends, because that render's live query may still own the source until cleanup. Same-ID source objects stay legal; the claim, not `createCollection`, rejects a swap. Trace: committed C(y); a transition render reads B(x) and is interrupted; an urgent render reads A(x) and throws `SourceCollectionIdReuseError`. The abandoned-transition history has no witness. Adapter-specific `queryKey` change semantics stay with each adapter's own tests; React's infinite hook also has a focused witness that a `queryKey` array changed in place recompiles, because only React hands the caller's array to the shared window binding. The shared lifecycle histories distinguish adapter-created projections from caller-owned retention: omitted and explicitly `undefined` `gcTime` use the 1 ms adapter policy; `gcTime: 0`, a positive 100 ms retention, and supplied Collections keep caller authority. They observe retirement after unmount, replacement, disabling, and positive expiry, plus survival before that expiry. The shared suite also checks initial-query readiness over a lagging on-demand source, for a list and a `findOne` query and for the infinite query (React, Preact, Vue, and Svelte): not ready before the subset settles, ready at settlement while the source is still `loading`, and still ready after the source becomes ready. Solid's Suspense over that source has its own witness. A Solid Suspense witness also checks that a source's `reportError()` and `fail()` after readiness keep the boundary's rows shown and the query `ready`; a hook whose status follows the health tree fails it. A resource that suspends again on a tree change is not observable at that cut, because its refetch over a ready query settles before a fallback renders. A separate Solid two-resource Suspense witness holds one ready projection past the 50 ms unsubscribed floor while its sibling remains pending, then checks both commit and boundary abandonment release the temporary preload owner and preserve ordinary 1 ms teardown. | +| Frameworks | [React conformance](https://github.com/TanStack/db/blob/main/packages/react-db/tests/conformance.test.tsx), [React pagination](https://github.com/TanStack/db/blob/main/packages/react-db/tests/infinite-query-conformance.test.tsx), [shared framework contracts](https://github.com/TanStack/db/tree/main/packages/db/tests/conformance), [Vue synchronous publication](https://github.com/TanStack/db/blob/main/packages/vue-db/tests/useLiveQuery-publication-oracle.test.ts), [Preact conformance](https://github.com/TanStack/db/blob/main/packages/preact-db/tests/conformance.test.ts), [Preact binding witnesses](https://github.com/TanStack/db/blob/main/packages/preact-db/tests/useLiveQuery.test.ts) | Exact exposed rows/pages and each framework's own lifecycle cuts. Vue's synchronous watcher checks insert, update, and nonterminal delete through supplied Collection and identity query inputs; it does not cover an empty final result, multiple changes in one transaction, or query recompilation. A React witness does not prove Preact/Vue/Solid/Angular/Svelte scheduling. Preserve their receiving registrations. The Preact driver observes each result at commit through a layout-effect probe under an error boundary, because Preact runs passive effects after paint and `act` drains only those it scheduled; it has no Suspense, server-snapshot, or DbClient witness. Its binding witnesses check that a write between render and subscription publishes and that an equal `queryKey` or structurally equal query keeps its live-query Collection. Subscribing in a passive effect instead of a layout effect survives both, as allowed scheduling freedom. The query-identity laws (1.0, maintainer decisions 2026-10-22 and 2026-10-23) run through every live-query driver and the React, Preact, Vue, and Svelte infinite drivers, branching on each driver's public `comparesQueryIdentity` policy. In a binding that compares identity (React and Preact), a query without a nonempty `queryKey` whose IR has no stable hash throws `UnhashableQueryIRError` at mount and at recompilation; an empty key counts as none, and a nonempty key runs the query. React no longer accepts dependency arrays. Such a binding claims each source Collection ID it compiled, so A(x) -> C(y) -> A(x) is legal while a later B(x) throws `SourceCollectionIdReuseError`. A pairwise model judges 32 enumerated histories over A(x), B(x), C(y), and a disabled step, with declared reach for a swap after an intervening query, a swap after a disabled step, and a revisit after an intervening query. Reach labels name the steps between the claim and its test, so a direct swap does not count; removing the C(y) step fails reach. Claims that keep only the latest query, or reset at a disabled step, fail it. React retains two shared-suite registrations, but both now call the same derived-identity API; the registration still named `react (dependency list)` does not establish a second production path. The earlier five failures from skipping a dependency-list check at first compile were evidence for the removed API. A nonempty `queryKey` skips the claim: React and Preact witnesses swap a same-ID source under a changed key, and a key reader that ignores keys fails both. One query over two same-ID objects uses overlapping but different rows; an engine mutant that resolves each source through the Collection-ID map, so the last object serves both join sides, fails the live and infinite scenarios. A Svelte hydration witness checks that a `DbClient`-backed query with a dependency list hydrates `queryKey: []` exactly as it hydrates an omitted key. The infinite suite runs the same pairwise grammar over A(x), B(x), and C(y), 18 histories with declared reach for a swap and a revisit after an intervening query; it has no disabled or page-shape step, because one infinite handle changes either its query or its page shape. A new binding starts unclaimed. The former React dependency-list/`DbClient` witness is historical; it does not establish current reuse or hydration behavior. The shared conformance rejection scenarios currently check that, after a rejected rerender, two reads each throw or hold no rows. Reactive drivers accept an unhashable query, as described below. React unmounts a tree whose render throws. Preact keeps such a component mounted, so its driver mounts the hook under an error boundary, which unmounts it; a Preact component with no boundary keeps its last committed rows and is not witnessed here. It guards a future driver whose binding stays mounted; the `DbClient`-backed Svelte binding, which stays mounted, is checked field by field in fixed [descriptor release tests](https://github.com/TanStack/db/blob/main/packages/svelte-db/tests/descriptor-query-release.svelte.test.ts) (no oracle owner; tracked in #2264). A reactive binding (Vue, Solid, Angular, Svelte without a `DbClient`) runs the same histories and must publish the current input's rows: the unhashable query runs and recompiles when a captured value changes, and B(x) shows B's rows, never A's. One query over two same-ID objects is likewise rejected by a comparing binding and read through an inner join by a reactive one. The same mount-and-recompile split is not yet generated for `DbClient`-backed Svelte or React hooks beyond the receiving witnesses below. The [React source ID reuse oracle](https://github.com/TanStack/db/blob/main/packages/react-db/tests/source-id-reuse-oracle.test.tsx) owns the release law for `DbClient` descriptors: a rejected query releases every sync start it deferred. React exercises applicable causes (source-ID collision, unhashable IR, a descriptor factory that throws, a query rejected during compilation, a single-result infinite query, and a render abandoned by a sibling throw or suspension) through live, infinite, and Suspense hooks with derived identity or changed explicit `queryKey`, plus ordinary-hook configurations that disable reclamation, at initial mounts or rerenders and through four readers. Explicit keys bypass source-ID and derived-IR rejection; single-result rejection applies only to infinite hooks, and abandoned-after-hook histories exclude Suspense because its preload already starts the sources. Svelte crosses collision, unhashable, factory, compile, and abandoned-setup causes with eager and lazy sources and three readers, and its collision history reads every public result field separately after the rejection; dropping the guard from one getter, such as `isLoadingError`, fails all six collision cells. The earlier dependency-list Suspense cache-claim failures are historical. Current ordinary [source-ID tests](https://github.com/TanStack/db/blob/main/packages/react-db/tests/source-id-reuse.test.tsx) check separate client-scoped Suspense hooks and an initial suspended retry with different same-ID sources; they use the current derived-identity API. These fixed histories are supplemental tests, not a dimension of the claim grammar: that grammar's disabled step has no Suspense form, because `useLiveSuspenseQuery` rejects a disabled query. React ordinary/infinite hooks and Svelte's compiled query release at their render boundaries. The current React Suspense rejection histories fail within its shared ordinary-hook preparation; removing only the outer Suspense wrapper's catch is an unreached mutant, not a demonstrated kill. The former dependency-array SSR history was removed because its API is absent from the RC; it had executed no cells in the preceding grammar. Routing Svelte's getters around the compiled query fails its collision cells; `preload()` overrides a deferral, so those cells pass without the release by design. Declared reach: each enumerated release history asserts that the rejected query ran the shared descriptor's factory once before the reader, and a history that binds the failing descriptor first fails that check. A render that never commits (React: a sibling throws after the hook returns, or the render suspends; Svelte: setup throws after the hook returns) releases its deferral when garbage collection reclaims the live query it built (maintainer decision 2026-10-23), or after the unsubscribed GC floor when the hook's config disables that reclaim (`gcTime` 0 or `Infinity`, or `startSync: false`); both release grammars add these causes, React adds the three config paths and Svelte a `gcTime: 0` setup, and waiting only for the reclaim fails those cells; removing the reclaim release fails exactly them, and releasing at the end of render instead fails the descriptor binding owner's no-work-before-commit law. Known limit, owner the maintainer: the reclaim follows the 50 ms unsubscribed GC floor, so a render whose commit React holds past that floor releases its deferral, and its source may start, before it commits; that render then subscribes to a cleaned-up live query, which restarts. No history holds a commit past the floor, which needs a controlled React scheduler or fake timers; this joins the existing held-commit open cell in the infinite-query publication row. A keyed step records no claim and checks none (maintainer decision 2026-10-23): for a comparing binding the claim grammar adds keyed steps AK and BK over the same-ID objects, 108 histories with declared reach for a claim that crosses a keyed step and a keyed step after a claim; a keyed step that claims, or that resets earlier claims, fails it for the current React derived-identity path and for Preact. Each controllable driver records a publication log (`publication-log.ts`) at the end of each synchronous turn, plus the value on screen at the first paint after each `setParam`, once the framework has flushed and before any later task (maintainer decision 2026-10-23). After a same-ID swap, a reactive binding's every logged value holds only the new source's rows or none: a binding that keeps the old rows published until a later task fails it in Vue and Svelte. A value replaced within one synchronous turn is not logged, because the browser never paints it; Solid and Svelte publish the previous query's rows once inside the swap's own turn, for any input change, and pass. Accepted design (maintainer decision 2026-10-23): React claims source IDs during render, so a concurrent render that is abandoned before commit keeps its claim, as the React oracle states for a render that suspends, because that render's live query may still own the source until cleanup. Same-ID source objects stay legal; the claim, not `createCollection`, rejects a swap. Trace: committed C(y); a transition render reads B(x) and is interrupted; an urgent render reads A(x) and throws `SourceCollectionIdReuseError`. The abandoned-transition history has no witness. Adapter-specific `queryKey` change semantics stay with each adapter's own tests; React's infinite hook also has a focused witness that a `queryKey` array changed in place recompiles, because only React hands the caller's array to the shared window binding. The shared lifecycle histories distinguish adapter-created projections from caller-owned retention: omitted and explicitly `undefined` `gcTime` use the 1 ms adapter policy; `gcTime: 0`, a positive 100 ms retention, and supplied Collections keep caller authority. They observe retirement after unmount, replacement, disabling, and positive expiry, plus survival before that expiry. The shared suite also checks initial-query readiness over a lagging on-demand source, for a list and a `findOne` query and for the infinite query (React, Preact, Vue, and Svelte): not ready before the subset settles, ready at settlement while the source is still `loading`, and still ready after the source becomes ready. Solid's Suspense over that source has its own witness. A Solid Suspense witness also checks that a source's `reportError()` and `fail()` after readiness keep the boundary's rows shown and the query `ready`; a hook whose status follows the health tree fails it. A resource that suspends again on a tree change is not observable at that cut, because its refetch over a ready query settles before a fallback renders. A separate Solid two-resource Suspense witness holds one ready projection past the 50 ms unsubscribed floor while its sibling remains pending, then checks both commit and boundary abandonment release the temporary preload owner and preserve ordinary 1 ms teardown. | | Backend Collection E2E | [shared provider suites](https://github.com/TanStack/db/tree/main/packages/db-collection-e2e/src/suites); [Electric registration](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/e2e/electric.e2e.test.ts); [Query registration](https://github.com/TanStack/db/blob/main/packages/query-db-collection/e2e/query.e2e.test.ts); [TrailBase registration](https://github.com/TanStack/db/blob/main/packages/trailbase-db-collection/e2e/trailbase.e2e.test.ts) | Electric, Query, and TrailBase register reusable predicate, pagination, join, and live-update checks at their provider boundaries; Electric also registers the move suite. These are receiving paths for core query and Collection laws, not framework render cuts. | | Window-controller overlap | [shared infinite-query conformance](https://github.com/TanStack/db/blob/main/packages/db/tests/conformance/infinite-suite-oracle.ts), [React driver](https://github.com/TanStack/db/blob/main/packages/react-db/tests/infinite-query-conformance.test.tsx), [Vue driver](https://github.com/TanStack/db/blob/main/packages/vue-db/tests/infinite-query-conformance.test.ts), [Svelte driver](https://github.com/TanStack/db/blob/main/packages/svelte-db/tests/infinite-query-conformance.svelte.test.ts), [Preact driver](https://github.com/TanStack/db/blob/main/packages/preact-db/tests/infinite-query-conformance.test.ts), [review record](oracle-reviews/dec-03-window-overlap.md) | Four or five ordered source rows, two window-settlement orders, and public snapshots after each settlement, a subscribed observer update, a detached controller read, and explicit recovery. Insertion and removal of the fifth row distinguish both continuation directions while an earlier error remains visible. The detached read follows a source change with no controller subscriber; its preloaded Collection retains the five-row window. The exact overlap uses the exported DB controller in each package realm. Framework hooks expose no controller preload, so this cell does not establish a hook scheduling path; direct hook paging remains in the adjacent shared scenarios. An unsubscribed controller has no notification claim. | | Infinite-query publication | [shared publication laws](https://github.com/TanStack/db/blob/main/packages/db/tests/conformance/infinite-suite-oracle.ts), [React render cuts](https://github.com/TanStack/db/blob/main/packages/react-db/tests/infinite-query-render-cuts-oracle.test.tsx), [Svelte hook tests](https://github.com/TanStack/db/blob/main/packages/svelte-db/tests/useLiveInfiniteQuery.svelte.test.ts), [review record](oracle-reviews/2026-10-05-infinite-first-paint.md) | Each driver records every value its framework published: React layout commits, Vue pre-flushes, and the Svelte construction value plus pre-effect runs. The shared suite checks that a synchronous source shows its ready first page on the first value, for a query callback and for a supplied collection that has not started, that every ready value in a fixed-source, fixed-query scenario equals the source prefix for its own page count, including an equal dependency replacement that keeps page depth, and that a mount requests an on-demand first window once, for a query callback and for a supplied collection whose finite window is wider than or equal to the one the hook needs, judged by the rows the on-demand source holds after mount. Under deferred acquisition, an on-demand first value is either the ready first page or a value with no rows, by framework: React and Svelte publish before the hook subscribes, Vue after. `preloaded-collection-first-paint` checks the documented route-loader pattern: a limited live query preloaded before mount shows its ready first page on the first value in every framework and asks the source for nothing more; a preload that does not count as demand never settles there. Pre-commit work has no shared form, because Vue setup is its commit. The React driver owns renders that never commit, StrictMode double renders (one first-window request on React 19), retries after GC, a mounted replacement whose startup error must reach the error boundary, and a collection kept from an abandoned render that enters terminal error before its query is selected again, each as under `useLiveQuery`. The React source ID reuse oracle drives the infinite hook too. Svelte owns construction and superseded recomputes before the subscribing effect. In both, the hook acquires an on-demand source as often as `useLiveQuery` does under the same history, for either input form, direct or behind a live-query Collection. On-demand requests from renders that never commit are now owned by the live-query deferred acquisition oracle; the React render-cut oracle checks that an abandoned render makes no acquisition. Open cells, owned here: React 18 StrictMode, where neither hook keeps refs across the double render; a React commit held after the 50 ms unsubscribed GC floor, where a controlled-premise controller witness shows no non-ready value but no React scheduling witness exists; ready values in scenarios that write the source or replace the query, which need a per-step source model; a join of eager and on-demand sources, which needs a zero-acquisition check before commit; a supplied collection with no `.limit`, which sends one unbounded request on `main` too, because the controller cannot narrow it before the source request; and Svelte `state_unsafe_mutation` when a start inside `$derived.by` synchronously writes rows a peer is waiting for, shared with Svelte's `useLiveQuery`, which a two-component probe reproduces and the deferred-acquisition follow-up removes. | @@ -1524,7 +1779,7 @@ generic visitor behavior outside the named collectors. | 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. | | LocalStorage Collection | [mutation order](../../packages/db/tests/local-storage-order-oracle.test.ts), [peer snapshots and ownership](../../packages/db/tests/local-storage-peer-history.test.ts) (fixed tests; no oracle owner (tracked in #2264)) | Finite two-write histories compare authored same/disjoint-key updates, both handler completion orders, and all four application-decision pairs against public rows, durable rows, caller settlement, and fresh restore. A nested fire-and-forget admission checks that the outer write reaches storage first; awaiting the nested receipt inside its earlier handler is an excluded dependency cycle. Handler-free direct insert, update, and delete are checked at the method-return boundary for synchronous Storage effects. A later handler failure is checked while its earlier handler remains held: its receipt rejects and its optimistic row rolls back promptly, while the durable predecessor remains pending. Fixed tests in `packages/db/tests/local-storage-order.test.ts` (no oracle owner (tracked in #2264)) cover the next two histories. An automatic insert followed by manual acceptance crosses same-key update versus disjoint insert and both earlier-handler decisions; manual settlement waits behind that handler, and public, durable, and fresh-restore rows match the authored fold. Un-awaited manual acceptance crosses direct and held-predecessor paths with success or Storage failure: both commit and persistence receipts wait for every registered write, report the exact fault on failure, and agree with durable and public rows at settlement. A two-Collection witness checks that one manual receipt waits for both un-awaited acceptances. Peer witnesses check serialized disjoint typed-key writes before delayed event delivery, equal-ID manual Collection ownership, construction-time rejection of direct or shallow-copy reuse before or after first preload with the first Collection still writable, a peer insert during pending local insert, listener release across cleanup/restart, malformed startup snapshots, transient startup read failures and restart, the null-versus-empty storage sentinel, local and peer clear publication with subsequent public/durable agreement and a pending optimistic mutation across clear, and storage/parser read failures during writes and event delivery. Reload witnesses let another tab (a second Storage object over the same bytes) update or delete a kept row and insert a row, or clear storage, while no event reaches the first tab, with and without a prior local edit of the kept row: `reload()` must resolve with the Collection `ready` and its public rows equal to the durable rows and a fresh restore, and the reloaded run must confirm a later write. The adapter that re-inserted stored rows in each run crashed on the update histories and kept the deleted row; a snapshot only for non-empty storage fails the clear histories. The [same-tab audit](oracle-reviews/2026-10-08-localstorage-same-tab.md) records witnesses that withhold events through each persistence receipt: disjoint typed keys, a manually accepted same-key update followed by automatic update and delete, clear, a rejected Storage write, a pending same-key optimistic insert, cleanup/restart, and a write started by a ready-status listener compare public snapshots with the authored durable fold. A default jsdom Storage witness receives a same-tab write. The old event-only adapter fails seven no-event checkpoints. The original adapter also fails the second-handler-first, delayed-peer, equal-ID, listener-release, read-failure, synchronous direct-write, prompt-failure, mixed manual/automatic order, un-awaited manual receipt, reused-options admission, malformed-startup, and clear-publication witnesses. A failed write read rejects without replacing durable rows; a failed event read retains the public snapshot until a later valid event. These checks do not establish simultaneous cross-tab read-modify-write atomicity, atomic acceptance across distinct storage keys, manual acceptance without a mutation Collection owner, arbitrary long histories, native browser scheduling, or synchronization across distinct custom Storage wrapper objects. Existing LocalStorage failure and CRUD tests remain supporting examples. | -| LocalStorage value fidelity | [JSON value model](../../packages/db/tests/local-storage-value-fidelity-oracle.property.test.ts) | With the default JSON parser, an insert, update, manual insert, or manual update is accepted only if a fresh restore can return the row. A recursive value grammar labels each generated value, while it is built, with its expected JSON form or as lost. Lost: functions, symbols, enumerable symbol keys, `NaN` and infinities (also boxed), `undefined` and missing array entries, arrays with extra or symbol properties, a `toJSON` that returns `undefined` in an array, and Map, Set, WeakMap, Promise, RegExp, Uint8Array, and SharedArrayBuffer values. Normalized: `toJSON` values (Date, a plain object, and a function with its own `toJSON`), `undefined` properties and `toJSON` results, `-0`, boxed finite numbers, and non-enumerable symbol keys. Histories cross the four entry paths with and without an automatic handler. At `isPersisted` settlement, an accepted row must keep the authored writer value, store the model's form durably, and restore it in a fresh Collection; a lost row must reject with `SerializationError`, leave the writer's predecessor and storage bytes unchanged, and never call the handler. Pinned witnesses cover every lost kind and normalization on each path, with and without a handler; a fixed-seed and a random campaign of 300 histories run under `local-storage.value-fidelity`. A pinned witness per entry path uses a `toJSON` that changes after its first call: storage must hold the JSON the check accepted, and serializing the row again during the key check or the snapshot write each fails it. The stringify-only check on `main` fails 162 of 230 cells. The first walker in this PR failed 66 cells of the expanded grammar. Mutants that reject `undefined` properties, accept `undefined` array entries, skip boxed-number unwrapping, treat non-enumerable symbol keys as lost, skip the array-property check, or shrink the opaque-type list each fail at least six cells. Open: custom parsers, including a wrapper around `JSON` (accepted by design, documented); class instances (accepted by design; the guide states they restore as plain objects); cross-realm natives; getters; non-enumerable string data; update payloads whose shape the draft copy changes (array extra properties and symbol keys are dropped, and a hidden symbol key becomes enumerable, so an update of an object with a hidden symbol key rejects although its insert succeeds), which the proxy detachment owner must decide; peer or event delivery of a rejected write, which never reaches storage; and peer delivery of normalized values: the peer owner checks only `Date` at that boundary, so a peer receiving witness for the generated value grammar remains with the peer owner. IndexedDB clone rejection is owned by the IndexedDB settlement and values oracles, which require `SerializationError`; local-only rows keep values by reference, pinned in `local-only.test.ts`. | +| LocalStorage value fidelity | [JSON value model](../../packages/db/tests/local-storage-value-fidelity-oracle.property.test.ts) | With the default JSON parser, an insert, update, manual insert, or manual update is accepted only if a fresh restore can return the row. A recursive value grammar labels each generated value, while it is built, with its expected JSON form or as lost. Lost: functions, symbols, enumerable symbol keys, `NaN` and infinities (also boxed), `undefined` and missing array entries, arrays with extra or symbol properties, a `toJSON` that returns `undefined` in an array, and Map, Set, WeakMap, Promise, RegExp, Uint8Array, and SharedArrayBuffer values. Normalized: `toJSON` values (Date, a plain object, and a function with its own `toJSON`), `undefined` properties and `toJSON` results, `-0`, boxed finite numbers, and non-enumerable symbol keys. Histories cross the four entry paths with and without an automatic handler. At `when('settled')` settlement, an accepted row must keep the authored writer value, store the model's form durably, and restore it in a fresh Collection; a lost row must reject with `SerializationError`, leave the writer's predecessor and storage bytes unchanged, and never call the handler. Pinned witnesses cover every lost kind and normalization on each path, with and without a handler; a fixed-seed and a random campaign of 300 histories run under `local-storage.value-fidelity`. A pinned witness per entry path uses a `toJSON` that changes after its first call: storage must hold the JSON the check accepted, and serializing the row again during the key check or the snapshot write each fails it. The stringify-only check on `main` fails 162 of 230 cells. The first walker in this PR failed 66 cells of the expanded grammar. Mutants that reject `undefined` properties, accept `undefined` array entries, skip boxed-number unwrapping, treat non-enumerable symbol keys as lost, skip the array-property check, or shrink the opaque-type list each fail at least six cells. Open: custom parsers, including a wrapper around `JSON` (accepted by design, documented); class instances (accepted by design; the guide states they restore as plain objects); cross-realm natives; getters; non-enumerable string data; update payloads whose shape the draft copy changes (array extra properties and symbol keys are dropped, and a hidden symbol key becomes enumerable, so an update of an object with a hidden symbol key rejects although its insert succeeds), which the proxy detachment owner must decide; peer or event delivery of a rejected write, which never reaches storage; and peer delivery of normalized values: the peer owner checks only `Date` at that boundary, so a peer receiving witness for the generated value grammar remains with the peer owner. IndexedDB clone rejection is owned by the IndexedDB settlement and values oracles, which require `SerializationError`; local-only rows keep values by reference, pinned in `local-only.test.ts`. | | Local-only direct writes | [direct write witnesses](https://github.com/TanStack/db/blob/main/packages/db/tests/local-only-direct-write-oracle.test.ts) | Focused witnesses pin when a local-only direct write skips the optimistic stage: a completed transaction and one publication per write, and the fallbacks for a pending, persisting, or ambient transaction and a user handler, for insert, update, and delete. Each history also runs on a local-only Collection whose handlers resolve, which must match its rows, change batches, errors, and transaction states, across mixed multi-key batches, a batch that fails midway, schema rejection, and handler rollback. Mutants that drop each guard, write only the first mutation, or never complete the transaction fail them. The change-event history oracle drives the direct path through generated histories (about two thousand writes per run); removing the guard, or guarding only persisting transactions, fails these witnesses. | | Reused sync row objects | [reused row witness](https://github.com/TanStack/db/blob/main/packages/db/tests/sync-reused-row.test.ts) | Focused witnesses for a sync source that changes a stored row object in place: in development, writing it again as an update without `previousValue` throws, while the same write with `previousValue`, or with a new object, moves the row out of an `eq` live query. Production skips the check. The check compares a shallow snapshot taken when the object was written, so it misses changes to nested fields; a write that names `previousValue` and a row whose fields throw on read are not checked. | | Index suggestions | [index suggestion oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/index-suggestion-oracle.test.ts) | A public filtered live-query Collection checks manual/default, eager, threshold, matching-index, and unrelated-index cases against the documented collection-size suggestion policy. A bounded ordered-query decision table separately classifies no field index, a null-placement mismatch, matching but order-ineligible indexes, and usable candidates in either registration order. It compares the development warning after preload; the matching-index selection owner below checks the chosen reader and ordered request. Two cyclic locale-options cells require the diagnostic to preserve valid query construction with or without a field index, and a custom-collation cell requires remediation to preserve comparator reference identity. The original manual-silence/eager-noise implementation failed two expected observations. The issue #2190 baseline failed four diagnostic cells, including a first-unusable/second-usable candidate order; the first formatter draft failed all three non-JSON-safe remediation cells. The owner does not judge repeated-query warning volume, slow-query timing, production-build suppression, every comparison-option mismatch, or every query shape. It runs in `@tanstack/db`'s `test:oracles` campaign. | @@ -1654,6 +1909,27 @@ tracks remaining design and coverage questions, including valid Temporal kinds without public order and mixed-kind query order. Earlier PR-branch counterexamples do not by themselves establish an `origin/main` failure. +The Query DB ownership oracle also owns the explicit mutation-handler refresh +contract ([evidence](oracle-reviews/pr-2171-explicit-query-refresh.md)). Its 18-history table crosses insert, update, and delete with no +confirmation, explicit refetch, and direct writes, with and without discarded +legacy return values. Each history requires optimistic and settled reach. At transaction settlement it +checks exact Query fetch counts and public rows: no confirmation exposes the +initial synced rows; either confirmation path exposes the server result. The +old automatic-refetch implementation fails all nine cases at the fetch-count +comparison (including a duplicate request after explicit refetch). This table +uses a real QueryClient and Collection with controlled backend rows; polling, +focus, reconnect, and external invalidation are outside its grammar. + +The [RC review coverage matrix](oracle-reviews/2026-10-28-pr-2171-review-coverage-matrix.md) +records a missing documentation receiving witness for this owner at +`858b174a6`. Executing the actual successful POST handler from +`docs/guides/error-handling.md` leaves one server row and no Collection row at +transaction settlement: the snippet omits explicit confirmation. The adapter +correctly follows the law above; the authored guidance is wrong. O21/O17 in the +challenge report assign extraction of real snippets and their confirmation +histories to this owner. The guide and durable receiving witness remain open; +the temporary probe is not a new oracle extension. + The Query DB ownership oracle owns the direct-write no-automatic-refetch law ([review](oracle-reviews/issue-1935-direct-write-refetch.md)). The Query DB `optimistic-ordered-refetch-oracle.test.ts` receiver runs a real @@ -2174,10 +2450,47 @@ deferral for a different shared client descriptor; a later direct reader reaches observation cut. A second controlled history makes two deferred sync starts throw and checks that a later shared source still starts and publishes its row. This distinguishes releasing every pending source from stopping at the first -startup error. The first thrown error still reaches React's error boundary. -This owner does not constrain explicit `queryKey`, direct Collection input, -or legacy dependency-array identity, and it does not install a process-wide ID -registry. Real persisted SQLite sources and DbClient SSR preload/streaming +startup error. The preparation rejection still reaches React's error boundary. +The expanded release grammar has 344 histories plus four checker controls. It +crosses applicable rejection causes, ordinary/infinite/Suspense hooks with +derived or changed explicit identities, initial mounts or rerenders, and four +readers. Rejection classes or authored error identities and after-hook execution +are observed before declared `prepared` reach. A subscription reader now compares +its callback replica, rather than Collection storage, after readiness and the +next source publication. A mutant dropping update deliveries survived all 86 +subscription cells before this recorder repair, and fails all 86 at the updated-row +comparison afterward. Abandoned-render histories +establish eventual release on the schedules encountered; they do not claim both +orders of reclamation and independent demand. + +The shared [descriptor startup-release model](https://github.com/TanStack/db/blob/main/packages/db/tests/descriptor-start-release-oracle.ts) +also has React and Svelte receiving drivers. Eighty-five finite prefix/mask/policy +histories fail a descriptor factory before, or after one through three acquired +sources, followed by an unvisited tail. Every acquired source's synchronous +start-failure mask crosses every independent eager/lazy policy mask. React's +three hook paths receive 255 histories; Svelte receives 85 at its native root boundary. Each observes the +exact factory error, factory counts, independent starts, complete ordinary rows, +Collection status and acquired healthy-resource cleanup counts at rejection, +independent demand and client cleanup. Failed starts acquire no returned adapter +resource. These controlled synchronous sources do not prove provider resource +cleanup after a provider throws before returning its cleanup handle, arbitrary +scheduling, or the outer Suspense catch path described above. + +The inline-options grammar additionally crosses live and Suspense config +hooks, getKey/schema/both, and query/queryKey replacement (12 host histories). +Repeated renders and an authoritative source update retain the Collection and +configured option references; identity replacement applies the new references. +Each history declares and reaches rerender, source-update and identity-change +cuts. Schema is exercised at the JavaScript configuration boundary: the current +public hook schema declaration is `never`; this test makes no typing claim. +Infinite hooks accept no inline Collection options and remain covered by their +source-identity and window owners. The old reference-based identity fails at +Collection identity during rerender. Other framework receiving paths are not +claimed by this React driver. +This owner's source-ID law does not constrain explicit `queryKey` or direct +Collection input, and it does not install a process-wide ID registry. React +dependency arrays are removed; Svelte's supported dependency API is covered by +its own receiving tests, not this React owner. Real persisted SQLite sources and DbClient SSR preload/streaming remain outside this controlled React driver. A space policy for a mounted hook that visits unbounded distinct source IDs remains open; this owner needs a bounded-navigation counter witness once that policy is chosen. @@ -3105,6 +3418,29 @@ execution. The existing SQLite owners above retain their separate reset/resume, order, driver, and host contracts. Broader class closure needs witnesses at those remaining paths. +The optimistic history owner includes an external completion action. +`optimistic-history-outcomes-oracle.test.ts` runs264 bounded completion histories: +pending/persisting/completed/failed states,1–3 attempts, absent or independently +pending/persisting same/other-key peers and target/unowned/peer/truncate source +work. It preserves state, request outcome, public rows and waiter-time snapshots; +a later source write checks continued use. Full owner287 passes on858b174a6. +Deferred publication and missing admission guards fail at their intended checks. + +The real Offline settlement owner receives this law through60 serialized-outbox +restoration histories:2/3 transactions, every outcome mask, shared/disjoint +width1/2 and partial-overlap width2. A stateless prefix fold checks whole rows, +pending metadata, eager waiter snapshots, core restoration states, per-ID outcome, +provider calls and remaining outbox. It exposes14 unrepaired cleanup exceptions: +a successful durable replay attempts to complete a snapshot a sibling rollback +already failed. Known trace:shared or chain FT, FTF, TFT, FTT; shared also FFT. +The independent outbox result is successful; the core snapshot must remain failed. +214/220 declared cuts reach; six later cuts remain blocked by earlier RED. +Removing publication or deferring it fails public pending/waiter metadata even +on all-success histories. See the RC chunk10 record for exact logs and limits. +This is an open production class; repairs await completion of all oracle chunks. +The receiving scope excludes deletes, retries, terminal outbox phases, multiple +Collections, native durability and multi-owner timing. + ## Acceptance map The post-merge review added three missing domains to existing owners: @@ -3122,7 +3458,7 @@ The post-merge review added three missing domains to existing owners: and [state retention](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-state-retention-oracle.property.test.ts). A transaction's optimistic state drops when its mutation function settles. Sync transactions committed while it persisted are held and publish with - that drop; `isPersisted` settles after that publication. Handler sync writes + that drop; `when('settled')` settles after that publication. Handler sync writes are generated at every cut, with and without awaiting their acceptance. Receipts stay pending until visible. With one persisting local mutation and no truncate, the first queued same-key source transaction consumes local @@ -3373,7 +3709,7 @@ seed another oracle file shares is therefore not reported. A budget that a helper computes at run time is not tracked either: the script reads the counts and keys written at a campaign's call, or in constants that the oracle file declares or imports. The op-sqlite alias campaigns in -`packages/react-native-db-sqlite-persistence/tests/op-sqlite-driver-oracle.test.ts` + `packages/react-native-db-sqlite-persistence/tests/op-sqlite-driver-oracle.test.ts` (`op-sqlite.write-marker-aliases` and `op-sqlite.columnar-sql-aliases`, 50 runs each) register through `registerAliasOracle`, which builds its parameters from `readAliasOracleRuns()` and `TANSTACK_DB_OP_SQLITE_ORACLE_RUNS`, so lowering @@ -3931,7 +4267,15 @@ rejects, ignores, or stops and resolves an aborted load; declared reach covers an obsolete load that settles, an aborted load that settles, a demand settled only by an aborted load, coverage ended by a snapshot, coverage recorded twice, a row moved between demands, and a covered demand short of -rows. Part 2 fails each +rows. Each campaign also runs two generated packs of nine required histories, +with varied row keys, unordered demands, and ranks. Every pack must reach all +15 declared states through the same real driver and shared judgment; an +unrelated random history cannot supply a missing pack witness. This preserves +all 40 unrestricted histories. The separate +`persisted-demand-coverage.required` seed/path coordinate replays a failing +pack directly; `persisted-demand-coverage.history` retains its original +history grammar and coordinates. A seed without a path still checks the full +campaign's reach, and failures report the actual seed. Part 2 fails each adapter call of a covered history once, before or after its effect, in either session: safety holds for every call, a failed coverage read keeps the load ready, and a failed coverage check waits. Part 3 releases the @@ -4018,7 +4362,7 @@ a write over a seed stores the row this tab shows (decision 17), and a reload after a named notice keeps a seed's metadata (decision 18). | Owner | Production paths | Observations and checkpoint | -|---|---|---| +| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | [sync metadata session oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/sync-metadata-session-oracle.ts) (`flat`, `nested`, `seeds`, `nested-seeds`, `seeds-full`, `nested-conflicts` lanes) | `DbClient` Collection, core hold, `DbClient.hydrate` seeds | `metadata.row.get`, `metadata.collection.get`, `list()`, `list(prefix)`, `collection.base`, each commit receipt's state, thrown errors; after every action and after settlement | | [persisted driver](https://github.com/TanStack/db/blob/main/packages/db-sqlite-persistence-core/tests/sync-metadata-session-persisted.test.ts) (`persisted-flat`, `persisted-nested`, `persisted-seeds`, `persisted-cached`, `persisted-conflicts`, `persisted-nested-seeds`, `persisted-reads`, `persisted-reads-seeds`, `persisted-peers`, `persisted-peers-full`, `persisted-peers-on-demand`) | `persistedCollectionOptions` over an in-memory adapter, store hold, core hold, and a storage read held in flight; seeds through a `DbClient`, alone and crossed with nesting and a row cached in storage at startup; a peer's notices, eager, under full row updates, and on demand | the same source reads after every action; `collection.base` and receipts after settlement; in the peer lanes, the stored rows after settlement | | paired properties and boundary witnesses in the core driver file | production against itself: holds removed, row 2's actions removed, or seeds performed first; stale run; write without a transaction; cleanup with a held seed | source reads (and row 1's view) at each shared checkpoint, or every observation after settlement for seed order; thrown errors; unhandled rejections | @@ -4086,6 +4430,7 @@ a failure reports the timer trace these witnesses now print, and the review record's "Open" section holds the evidence. Restart and resume reads belong to the SQLite resume-snapshot and Electric resume owners; this oracle's claim ends with one sync run. + ## Deferred contracts and evidence The maintainer assigned offline policy work to @@ -5367,3 +5712,223 @@ new behavior decision. Replay seed `-931507916`, path `21:3:3:3:4:3:4:4:4:5:4:4:4:4` reaches the permitted fallback after an ineligible upsert into an empty source and a joined-marker change. This was an overly broad oracle assertion, not a production repair. + +### RC Effect explicit source metadata (2026-10-28) + +Owner: `packages/db/tests/query/virtual-row-fields-effect-oracle.test.ts`, sharing +the small `virtual-row-fields-source-oracle.ts` source judgment with the root +metadata owner. It recomputes explicitly selected ordinary metadata fields and +full before/after deltas independently, then receives actual Effect onBatch and +named callbacks across 200 finite single-source, LEFT-join and populated-union +histories. It preserves previous values, multiplicity, field absence/undefined, +typed source keys, and opaque event-key continuity only within active intervals. +Held mutations, independent confirmations/outcomes, both settlement orders, +remote updates, peer exit/reentry and next-use reach 5,512 lane/channel/cut cells +exactly once. No implicit Effect-root metadata or output Collection ID is claimed. + +Open P09: all 64 LEFT-join histories emit a startup update for a never-entered +unmatched occurrence after its load finished. Those initial comparisons remain +RED on unchanged `858b174a6`; histories continue and all later comparisons pass. +The other 136 native histories and 16 controls pass. Independent hostile +overlays reject dispatch, previous-value, duplication, colliding-key and extra +undefined defects; lawful key renaming, fresh reentry and empty partitions retain +the baseline. Per-lane omissions and seeded partial selections now fail reach. +The finite owner explicitly disables the irrelevant shared replay exemption; +O26 retains a broader campaign inventory for that harness class. See chunk 16 +in the RC chunk record for exact fingerprints, evidence and review. + +Limits: eager controlled core sources, scalar rows, one mutation per source, +and these named paths. No real provider/framework, arbitrary scheduling, +ordered repair, same-source overlap, callback-input sanitization or exact +internal graph-run callback-count guarantee follows. P09 requires production +repair after the full oracle campaign; this entry makes no closure claim. + +### RC bound schema mutation receivers (2026-10-28) + +Owner: `packages/db/tests/live-query-bound-options-oracle.ts`; shared driver: +`live-query-bound-options-support.ts`. React `bound-schema-options-oracle.test.tsx` +and Preact `bound-schema-options-oracle.test.ts` receive the same model through +real live hooks, plus ready-source React Suspense. All 216 finite native +histories pass on unchanged `858b174a6`: nine ordered absent/trim/uppercase +pairs, query/queryKey replacement, independent key proposals and two padded +inputs. Held client updates use retained schemas until identity changes; +synced rows remain raw. Immediate reads, committed hook data/state, handler +original/modified/changes, settlement and later next-use are compared across +2,592 distinct reach cells. Extra own undefined fields and omitted followup +mutations now fail; validation-bypass and source-input-corruption overlays fail +the intended comparisons. See the RC chunk record, chunk 15, for frozen hashes +and independent evidence. Limits: valid synchronous schemas, scalar rows, +explicit string keys, settled mutations before replacement and ready-source +Suspense. This JavaScript runtime receiver makes no typed-schema, initial +suspension, SSR, concurrent-render or provider claim. Default-getKey absence +and async/invalid schemas remain outside it. + +### RC mutation confirmation expansion (2026-10-28) + +Shared rule: `packages/db/tests/mutation-confirmation-oracle.ts`; receivers: +Query `ownership-lifecycle.oracle.test.ts`, Electric `electric-oracle.property.test.ts`. +The finite grammar covers CRUD, absent/present peer, fulfilled void/legacy returns, +rejection before/after a server write, no confirmation versus explicit Query +refetch/direct write or Electric txid acknowledgement. Real utility waits are +held before response delivery. Full ordinary rows, exact errors, terminal state, +settlement-waiter snapshots and real fetch/provider-work counts are checked. +78 Query and 42 Electric histories pass on unchanged 858b174a6; 13 calibration +controls reject wrong rows, outcomes, errors, early acknowledgement and extra work. +O17 receiving expansion:24 ordered CRUD/outcome/invalidation histories and six +post-handler durable-receipt histories now pass. The latter rejects an in-memory +leaked-handler-count mutant in all six histories after a calibrated task-turn +cut. Ordered observations cover a two-row numeric window and eventual fixed +point; late application rejection, concurrent handlers, native SQLite and +live provider timing remain outside these expanded witnesses. +Adapter generated return-type admission now has the O14 source receiver below. Persistence wrappers, +multiple concurrent handlers and live Electric providers remain separate paths. + +The [chunk 18 review](oracle-reviews/2026-10-28-pr-2171-oracle-chunks.md#chunk-18-actual-authored-mutation-handlers-reviewed) +adds exact authored-handler receivers. `packages/db/tests/documented-mutations-oracle.ts` +owns the finite guidance grammar and handler-Promise judgment; its support module +extracts actual initializer ASTs. Query and Electric's +`tests/documented-mutations-oracle.test.ts` use the shared mutation judgment with +complete ordinary rows. Eighteen fences supply 30 current and three explicitly +negative handlers. Their 190 histories distinguish transport rejection from a +resolved HTTP 503, and server commit from handler confirmation and settlement. +Query has 132 passing histories and two G01 settlement failures; Electric's 56 +pass. G01's missing confirmation stages remain unreached and fail reach +separately. Forty-three controls pass. The original instrumentation supplied a +missing await through a handler-return gate; independent review demonstrated +two false greens. The repaired receiver observes the actual handler Promise +while genuine source delivery is held. Both identical omitted-await overlays +now fail, while already accepted direct-write neighbors pass. Shared seeds +preserve reach, and partial native selections cannot waive missing cells. +G01 remains open for the later guidance-repair phase. This is exact-handler +reception through controlled backend/SDK collaborators, not full factory, +schema, component, concurrent-handler, persistence-wrapper or live-provider +coverage. Broader authored guidance and diagnostic consistency remain O21 work. + +### RC public mutation-handler type admission (2026-10-28) + +Owner: `packages/db/tests/mutation-handler-types-oracle.test.ts`. An independent +output/transport rule checks generated Query/Electric consumers through Config, +explicit factory and inferred factory entry points, with/without schema and +Query rows/envelope select.36 consumers contain4,212 cells; all702 declared +states reach six histories. Promise accepts void/undefined and rejection, +including a synchronous never result; fulfilled values and synchronous void +are rejected. Six controls calibrate missing/duplicate/misattributed diagnostics. +All42 tests pass on unchanged858b174a6. Independent compiler probes reject +unrelated same-code and body-local Promise errors substituted for handler +errors. Source imports do not establish packed declarations or npm consumers; +O22 owns that receiving boundary. Full evidence is in the RC chunk record. + +## RC release histories and replay observations + +The shared independent release owner is `scripts/release-history-oracle.js`, +received by `release-history-oracle.property.test.js` and +`publish-history-oracle.property.test.js`. The [chunk 13 review record](oracle-reviews/2026-10-28-pr-2171-oracle-chunks.md#chunk-13-release-histories-actual-changesets-commands-and-finite-replay) +records policy authority, grammar, reach, source fingerprints and calibrations. +It checks 72 version timelines, 14 seed failure/retry timelines and 40 actual +Changesets command histories, with six publish checker controls. Numeric 9/10 +boundaries, strongest pending bumps, standalone stable work, dependency profiles, +all three exit forms, exact consumption/changelog attribution, rollback and +publish retries are exercised. Version/seed has 86 passes. Publish has 34 passes +and 12 intended tag failures: with only prior RC registry history, real +Changesets publishes candidates to latest instead of rc (P01). This remains +unrepaired until the full oracle campaign completes. Strict local shims provide +command metadata and retain raw requests; real npm HTTP, tarballs, installed +consumers and Actions remain separate receiving work under O13/O22. + +Canonical release replays use the shared property registry/manifest and a new +finite comparison witness; setup is not recorded as a completed comparison, +and primary/writer/cleanup failures remain distinct. The older standalone +`recordOracleReplay` comment promises never to throw, but a real RunDetails plus +a directory channel throws EISDIR (P03). Its owner is the guarded replay/ +standalone recorder checks in `packages/db/tests/oracle-replay.test.ts`; the +needed witness is an explicit writer-failure policy comparison for that older +API. The finite wrapper's guarantee does not settle that policy conflict. + +The lint observation owner is +`scripts/eslint-plugin-oracle/replay-observation-oracle.test.js`. Its independent +source-intent model and actual ESLint receiver distinguish typed completed-run +output from literal generated-run budgets. All 3,848 source programs and two +checker controls pass; 1,924 states each reach twice. With ordinary rule/harness +controls the final suite has 3,984 passes. Exported/aliased/budget uses, runtime +JSON shadows and custom serialization hooks remain rejected; type-only uses are +allowed. Broad flow and initializer mutants fail 520 and 200 comparisons. +Only the known plain-data writer and unmodified standard JSON serializer are +recognized; this is not dynamic whole-program flow analysis or a general +serializer policy. Ordinary harness controls stay outside oracle definitions. + +The [chunk 14 review record](oracle-reviews/2026-10-28-pr-2171-oracle-chunks.md#chunk-14-actual-npmpnpm-http-publication) +adds the same release judgment through actual npm 11.17.0, pnpm 11.1.0 and +Changesets 2.29.8 over a loopback HTTP registry: +`scripts/publish-http-oracle.property.test.js`. Fourteen histories cross stable, +prior-RC, never-published and already-current metadata with both RC-only tag +forms and legal failure phases. Raw attempts preserve duplicates; actual +uploaded tarballs supply manifest identity and digest. Exact local bytes are +checked before retry/settlement judgments. Eight histories and three origin +controls pass; six histories fail intended assertions. Three confirm P01's +wrong `latest` tag. Three expose P05: RC-only/no-latest metadata causes uploads +of existing RC versions on retry or an already-current release. Both publisher +defects remain unrepaired. Metadata/restoration reach14, failure/partial/retry +reach8, packed-upload10, already-current1 and completed comparison8 distinguish +earlier assertion failures from later unreached states. Eight ordinary fixture +controls and three repeated overflow probes pass after test-tool repairs. +This receiver establishes real HTTP/packed identity reception, not installed +dependency/API capability, clean workspace setup, production authentication +or Actions. O22/O25/O26 retain those boundaries. The fixture supplies local +pack-time links without an install and requires POSIX process groups. + +The merged-main [chunk 22 extension](oracle-reviews/2026-10-28-pr-2171-oracle-chunks.md#chunk-22-publication-eligibility-without-dist-tags) +preserves those 14 histories and adds 15 neighbors. Exact-version membership, +not tag presence, determines upload eligibility. It covers current candidates +with no tags or stale RC tags, the exact wrong-tag aftermath (`rc` points older, +`latest` points current), absent targets without tags, reversed heterogeneous +candidate versions, retained candidate success before a later failure, and +stable packages whose current versions lack `latest`. Stable and candidate +metadata reach are separate; a stable-phase duplicate failure receives no +candidate-path credit. All 29 histories use the same independent ledger and +raw-attempt comparison. Native results are 21 passes / 14 intended assertion +REDs including six calibrations: seven P01 tag failures and seven P05 duplicate +failures. The stable-path witness receives two actual 409 responses before any +candidate GET. No production repair or class closure is claimed. The extension +uses Vitest 4.1.12 with the existing actual npm/pnpm/Changesets tools and retains +the earlier receiver's limits. Clean setup/build evidence and unresolved release +gates are recorded [separately](oracle-reviews/2026-10-29-pr-2171-release-gates.md). + +The [chunk 23 SortedMap reach repair](oracle-reviews/2026-10-28-pr-2171-oracle-chunks.md#chunk-23-constructive-deferred-row-order-reach) +constructively reaches changed-deferred order restoration before executing +each original generated suffix from its original empty starting condition. +The current-value sorting law, independent Map/full-sort model, four examples, +both 100-run budgets and minimum five remain. Native and independent campaigns +pass 48 tests; changed-deferred comparison reach is 96 per comparator campaign. +Positive/negative premise controls reach 16/0. Exact one-history native and +in-memory stale-placement mutant replays respectively pass and fail the shared +ordering comparison. No production change or broader public receiving claim +follows; the owner's other sampling and contract limits remain. +### Solid publication recorder receiving repair + +The [chunk 25 record](oracle-reviews/2026-10-28-pr-2171-oracle-chunks.md#chunk-25-native-solid-publication-recording-and-ci-import-census) +closes the undefined-Accessor observation gap in the shared conformance owner. +The shared `conformance/publication-laws-oracle.ts` requires complete modeled +rows at the existing final cut while preserving permitted loading turns and +earlier ID membership. All 32 source-ID histories per registration and both +pooled a/b cuts run through actual affected framework drivers. Native Solid's +62 tests, whole-package typecheck and the affected React/Preact/Vue/Svelte/Angular +selections pass. Independent reader mutants fail the intended comparison; +failing-path mounts dispose exactly once. This is test-machinery coverage, +with no library change or broader scheduler, status, backend or device claim. + + +### RC generated error catalog receiving boundary (2026-10-29) + +The existing Production error messages owner now compares the actual copied +generator's stdout with its full frozen catalogs, independent of committed +generated-page freshness. Every coded class requires its heading and real HTML +ID; every coded site requires its source and target. Canonical text-message +fences cannot supply HTML IDs. Independent R3 review retains valid alternate +HTML syntax and rejects missing or fenced-only class IDs through the same check. +The unchanged tooling run has 27 passes and 12 intended assertion failures; +stdout reception reaches once and the completed catalog comparison remains zero +because the old generator emits empty stdout. Ordinary CLI and root-command +controls are separate files. The [chunk 26 record](oracle-reviews/2026-10-28-pr-2171-oracle-chunks.md#chunk-26-generated-error-catalog-observation-and-command-ownership) +retains exact source/evidence hashes and all ORC outcomes. Actual production +bundle erasure, Typedoc generation, tooling GREEN and restored-old-tooling +mutants remain required receiving checks. Generated docs remain CI-owned. diff --git a/docs/contributing/oracle-reviews/2026-10-28-pr-2171-finite-reach-inventory.md b/docs/contributing/oracle-reviews/2026-10-28-pr-2171-finite-reach-inventory.md new file mode 100644 index 0000000000..2bb8c39f6c --- /dev/null +++ b/docs/contributing/oracle-reviews/2026-10-28-pr-2171-finite-reach-inventory.md @@ -0,0 +1,73 @@ +# O26 finite reach inventory (read-only, 2026-10-28) + +Scope: all tracked changed and untracked source/test files in the RC oracle working tree relative to HEAD `858b174a65f160d13d01f0324c7481cc8e4eba3a`. Source inspection found 36 createOracleReach call sites in 24 files: 28 default-policy sites in 19 finite-owner files; eight explicit `replaying:false` sites in five files. Pure models and support drivers do not add their own counters. + +## Confirmed mechanism and experiment + +`packages/db/tests/oracle-reach.ts:31-38` interprets any shared seed OR path as replay unless overridden. None of the 28 finite lanes below has a direct seed/path selector: they enumerate ordinary `.each` inputs. Other randomized properties in the same Electric/Offline/optimistic files do have replay support and must retain their existing policy. + +Executed receiving experiment: Preact bound-options file, selector `Preact retains bound options.*getKey`. With seed/path/property unset: two native tests pass but the suite exits 1 for missing schema/both reach. With only `TANSTACK_DB_ORACLE_SEED=1`: the identical two native tests pass, ten tests skip and the suite exits 0. Logs: `o26-preact-partial-seed-none.log` and `o26-preact-partial-seed-1.log`. This independently confirms that file's missing-lane bypass; other rows are source-inspected, not separately executed. + +## Default-policy finite counter sites + +| File | Lines / finite scope | Existing entered-campaign handling | +| --- | --- | --- | +| packages/db/tests/live-query-cache-lifecycle-oracle.test.ts |80; claimant aggregate |Global afterAll unconditionally checks every claimant; checker-only selection also owns no native history. No zero-native guard. | +| packages/db/tests/live-query-cache-options-oracle.test.ts |54; claimant/path aggregates |`executedRoutes` gates each route. Correctly ignores unentered routes, but a partially entered route loses all required-state checks under shared seed. | +| packages/db/tests/mutation-handler-types-oracle.test.ts |319; public handler type aggregate, minimum6 |afterAll is inside native-only `public handler result admission` describe; checker controls are outside, so fully unselected describe does not enter its hook. Partial native describe is still exempt under seed. | +| packages/db/tests/optimistic-history-outcomes-oracle.test.ts |320; external-completion per-history cuts |Counter is constructed/asserted inside each finite history. No unentered counter, but seed disables even its local required cuts. Earlier random optimistic-outcomes campaign is separate. | +| packages/db/tests/query/virtual-row-fields-oracle.test.ts |239 root;840 union;1228 timeline;1690 facade;2035 nullable facade |Five global aggregates, unconditional afterAll hooks. File contains checker-only controls and unrelated finite families; no native-entry guards. Keep all required cells of each entered campaign; do not blanket-exempt partially selected families. | +| packages/db/tests/query/virtual-row-fields-types-oracle.test.ts |74; generated public-type aggregate |Global unconditional afterAll plus checker-only controls. No zero-native guard. | +| packages/electric-db-collection/tests/electric-oracle.property.test.ts |134 mutation aggregate;153 per-history mutation cuts |Aggregate afterAll is in native-only mutation describe; local counter is inside a native history. Existing random/replay properties elsewhere are distinct. | +| packages/offline-transactions/tests/transaction-settlement-oracle.property.test.ts |1017; restored-outbox aggregate |afterAll is inside native-only restored-history describe. Earlier and later random settlement/retry campaigns are distinct. | +| packages/preact-db/tests/bound-options-oracle.test.ts |28; retained-options aggregate |Global unconditional afterAll plus in-place-replacement checker controls. No zero-native guard. Executed seed bypass above. | +| packages/preact-db/tests/bound-schema-options-oracle.test.ts |21; mutation-schema aggregate |Existing `ran` boolean correctly ignores a wholly unentered native campaign. Partial entered campaign still exempt under seed. | +| packages/query-db-collection/tests/optimistic-ordered-refetch-oracle.test.ts |79 ordered-history aggregate;91 per-history cuts |Aggregate afterAll is in native-only describe; checker controls are outside. Local counter exists only within entered history. | +| packages/query-db-collection/tests/ownership-lifecycle.oracle.test.ts |1677 mutation aggregate;1693 local cuts;1941 post-handler aggregate |Mutation describe is native-only. Post-handler describe includes checker controls under the same afterAll and has no native-entry guard, so checker-only selection reaches an unrelated aggregate assertion without seed. | +| packages/react-db/tests/bound-schema-options-oracle.test.tsx |31; per-hook mutation-schema aggregate |Existing `ran` boolean per hook correctly ignores wholly unentered live/Suspense campaigns. Partial entered hook campaign still exempt under seed. | +| packages/react-db/tests/cache-first-registration-oracle.test.tsx |63; first-registration per-history cuts |Counter constructed/asserted inside each finite history; no unentered aggregate. Seed disables local required cuts. | +| packages/react-db/tests/cache-options-oracle.test.tsx |41; per-path cached-options aggregate |Global unconditional afterAll across client/server paths. File currently contains native histories only. A partial selected path is seed-exempt; no separate calibration lane currently requires a zero-native guard. | +| packages/react-db/tests/descriptor-start-release-oracle.test.tsx |30; descriptor-start per-history cuts |Constructed/asserted inside `checkHistory`; checker-only controls do not construct it. | +| packages/react-db/tests/source-id-reuse-oracle.test.tsx |103 inline-option local cuts;599 descriptor-release local cuts |Both counters are per-history; no unentered aggregate. Existing unrelated fixed native cases/controls do not construct these counters. | +| packages/svelte-db/tests/cache-options-oracle.svelte.test.ts |26; cached-options aggregate |Global unconditional afterAll. File currently has native histories only. Partial history selection is seed-exempt; no current checker-only lane needs an entry guard. | +| packages/svelte-db/tests/descriptor-start-release-oracle.svelte.test.ts |24; descriptor-start per-history cuts |Constructed/asserted inside `checkHistory`; no unentered counter. | + +## Explicit-policy sites inspected; do not sweep-change + +- `packages/db/tests/query/virtual-row-fields-effect-oracle.test.ts:313`: now explicit false, plus `enteredEffectHistories >0`; preserves every required finite lane once any native Effect history enters. +- `scripts/eslint-plugin-oracle/replay-observation-oracle.test.js:116`: explicit false and `campaignEntered`; retained checker-only subprocess control passes. A partially entered source campaign still owes all source states. +- `scripts/publish-history-oracle.property.test.js:40`: explicit false per finite command history. +- `scripts/publish-http-oracle.property.test.js:62`: explicit false per finite HTTP history. +- `scripts/release-history-oracle.property.test.js:114,120,147,251`: explicit false. This finite owner genuinely implements canonical direct replay: property selects a family, path chooses the authored case, local required cuts always remain enforced. Its aggregate afterAll is intentionally skipped only when `oracleReplayRequested()` is true. Seed-only runs still require aggregate reach. Preserve this policy. + +## O26 receiving checks to add later + +For each distinct aggregate policy: (1) no native campaign entered, checker-only controls run; (2) partially entered finite campaign with missing required lanes; (3) full finite campaign; run each with shared seed unset and irrelevant seed 1. Require the same reach verdict with either seed. A missing local-cut mutant must also fail with either seed for per-history counters. Keep existing direct replay receiving tests for the release owner and fixed/random shrink replay owners unchanged. An entered flag must be set before native setup, not only after a successful comparison. Do not let a per-route/per-hook guard borrow another receiving path's reach or silently shrink the declared domain. + +No repository files or production code were edited. This inventory is not a claim that every listed file has been independently executed under both environments. + +## Source fingerprints at inventory capture + +This is a pre-repair source-inspection snapshot. Later oracle edits invalidate affected rows until rechecked. + +| File | SHA256 | +| --- | --- | +| `packages/db/tests/live-query-cache-lifecycle-oracle.test.ts` | `c9da8b274c8402d2eb374d1a2cd4865f53095a9dce7423d6b1f616c4c45ff8fa` | +| `packages/db/tests/live-query-cache-options-oracle.test.ts` | `7ebf875a183111904b46d7905de3572287ccee5f28af9c7267f9969c9b7d60aa` | +| `packages/db/tests/mutation-handler-types-oracle.test.ts` | `59afc1991c1ffd2362922ca3e8a1ce7ee307ca3b7bc40cb05e9ffd3bad699ca3` | +| `packages/db/tests/optimistic-history-outcomes-oracle.test.ts` | `ee937b8c3191d3c827be4420e42a157d28a068a9fc2a6a6e82df940f9d33b523` | +| `packages/db/tests/query/virtual-row-fields-oracle.test.ts` | `c925791e375f2f2a0941e0ab35b965cabe89570fb13a981b14826b874eaca98f` | +| `packages/db/tests/query/virtual-row-fields-types-oracle.test.ts` | `65508306911b9c1082181cad2b7467a858a86823bc33228665f2affe5f1edfe4` | +| `packages/electric-db-collection/tests/electric-oracle.property.test.ts` | `adce4010e6406d428404a3c4e1745e00bd89baf7a0612b09e1748ea3f1294e2c` | +| `packages/offline-transactions/tests/transaction-settlement-oracle.property.test.ts` | `83eb5551300ebb702e119174a2352c985ad6086cefa18042cc5c947c3c89e91a` | +| `packages/preact-db/tests/bound-options-oracle.test.ts` | `208d617b276b5fd0f1975884d2c337dc26313b94b71b208f9bcd890a974b374a` | +| `packages/preact-db/tests/bound-schema-options-oracle.test.ts` | `97c32b831caf5e7d4c6349197e356594fffee0c0b4cfc113a479717fe3a76f0c` | +| `packages/query-db-collection/tests/optimistic-ordered-refetch-oracle.test.ts` | `462f432f7b20797b8326dc50ae9feb9db177354c802c40d3aad53a46e65a493d` | +| `packages/query-db-collection/tests/ownership-lifecycle.oracle.test.ts` | `d9699cf0554bcc58bc9966be51d0b5f8c622b9f6f14e61344fce05420535029b` | +| `packages/react-db/tests/bound-schema-options-oracle.test.tsx` | `8d4707534b5962b8f249885b10104045869a86d9e86faf8c8e0ac289866b41c6` | +| `packages/react-db/tests/cache-first-registration-oracle.test.tsx` | `2247d32fc1e47c95136dafcf796f6c6a8dd8b93124d05b7981043156dafdd9e5` | +| `packages/react-db/tests/cache-options-oracle.test.tsx` | `7a4a85db8fa822fd96b28e2de6da53a5feeb057ef26d79956c878215faed1f6d` | +| `packages/react-db/tests/descriptor-start-release-oracle.test.tsx` | `46c4264fa4dd1e8b7d46d4983b064317cc790f4ee05f2d8c9167561d99c44974` | +| `packages/react-db/tests/source-id-reuse-oracle.test.tsx` | `af236fff80f7d21be7d458b0be70416ec48d08f8fb26c3f762f2f3e91a9d28a0` | +| `packages/svelte-db/tests/cache-options-oracle.svelte.test.ts` | `2ecf6deca78f7c347719f81cb640a7b5e78f236e9f7df51a90bd12e862180627` | +| `packages/svelte-db/tests/descriptor-start-release-oracle.svelte.test.ts` | `98268189dbb4a4591ebdc983e3aa994509daa48dd24a73cf1ba38b9ce656ea8b` | diff --git a/docs/contributing/oracle-reviews/2026-10-28-pr-2171-oracle-challenge.md b/docs/contributing/oracle-reviews/2026-10-28-pr-2171-oracle-challenge.md new file mode 100644 index 0000000000..16f4916b82 --- /dev/null +++ b/docs/contributing/oracle-reviews/2026-10-28-pr-2171-oracle-challenge.md @@ -0,0 +1,779 @@ +# PR #2171: oracle challenge + +Reviewed source: `858b174a65f160d13d01f0324c7481cc8e4eba3a`. +This is a self-review. The author of the repairs designed these challenges +after inspecting the tests; they are not independent or held-out evidence. + +The initial task was to identify concrete oracle improvements. The initial pass +below proposes 15. Two have executed distinguishing experiments. The remaining proposals +identify source-inspected gaps and experiments still needed. They are not +confirmed production bugs or completed oracle repairs. No production change or +permanent oracle extension was made during this audit. + +The follow-up audit expands this plan to **26 checks** and maps **53 review +items plus four evidence caveats** in the +[review coverage matrix](2026-10-28-pr-2171-review-coverage-matrix.md). +That includes overlapping reports, non-runtime findings, and accepted product +choices. Three current failures now have executable witnesses: cached SSR +admission, hydration replacement of local option claims, and an unconfirmed +successful write in a documentation example. The follow-up evidence below +supersedes the initial pass's statement that it established no production defect. + +## Why the previous green results were insufficient + +The cache oracle compared admission alone. Its grammar coupled the two options +when both were present. The metadata oracle checked a broad set of query shapes +over a narrow history: one row, one held update, and successful settlement. +Its union fixtures always had an empty branch. The neighboring publication +oracle intentionally removed virtual fields before comparison. + +Those instruments provide useful evidence, but their combination does not +establish the interactions they omit. More runs of the same grammar would not +repair these omissions. Earlier counts such as 144 cache cases and 409 metadata +cases describe executions, not comprehensive protection of the contracts. + +Authority remains the maintainer decisions recorded in the existing owners, +the virtual-property contract in `docs/guides/live-queries.md`, and the live-query +architecture's Collection boundary and publication laws. These proposals do +not revise the accepted release policy, metadata attribution, or option +retention decisions. + +## Executed challenges + +All runs used the unchanged source revision above plus the stated temporary +edit. Source and test files were restored after each experiment. Raw logs are +task-local under `/private/tmp/rc2171-oracle-challenge/`. + +Command, from `packages/db`: + +```sh +../../node_modules/.bin/vitest run --config vite.config.ts \ + --coverage.enabled=false --typecheck.enabled=false \ + tests/live-query-cache-options-oracle.test.ts +``` + +### M1: reject only when every supplied option differs + +Location: `packages/db/src/client.ts`, `_assertLiveQuerySources`. +Keep the original `rowOptions.some(...)` check, but additionally require: + +```ts +rowOptions.every( + (option, index) => + (option === undefined && record.rowOptions![index] === undefined) || + option !== record.rowOptions![index], +) +``` + +This wrong rule accepts two callers with different `getKey` references when +their configured `schema` reference is the same. The approved rule rejects +either differing reference under the same reusable identity. + +The distinguishing driver edit keeps the schema reference unchanged in the +existing `option === 'both'` histories, while still changing `getKey`: + +```ts +schema: different && option !== `both` ? { ...schema } : schema +``` + +| Execution | Result | +| -------------------------------------------- | --------------------------------- | +| Unchanged production, existing grammar | 144 passed | +| M1, existing grammar | 144 passed: survivor | +| M1, distinguishing grammar | 12 assertion failures, 132 passed | +| Unchanged production, distinguishing grammar | 144 passed | + +The failures were missing synchronous conflict errors or fulfilled promises +where observer preload owed a rejection. They were not setup or timeout errors. +This demonstrates improvement 1; it does not calibrate every proposed option +presence or framework path. + +### M2: overwrite the claim before rejecting a conflicting caller + +Location: the conflict branch of the same method. Insert this immediately +before the conflict error: + +```ts +record.rowOptions = rowOptions +``` + +The first rejection still looks correct. A second attempt with the conflicting +options can now borrow the corrupted claim. + +The distinguishing checker invokes the rejected synchronous action again and +requires the same conflict rejection. It retains the original model and first +comparison. This experiment extends preload and subscribe retries; the async +observer-preload retry remains future work. + +| Execution | Result | +| ------------------------------------------------ | --------------------------------- | +| M2, existing grammar | 144 passed: survivor | +| M2, repeated-admission checker | 24 assertion failures, 120 passed | +| Unchanged production, repeated-admission checker | 144 passed | + +Failures occurred at the repeated-admission assertion. This demonstrates the +claim-preservation part of improvement 2. It does not establish row, listener, +or resource non-interference after rejection. + +## Concrete improvements + +### 1. Vary cache options independently, including absence + +**Owner:** `packages/db/tests/live-query-cache-options-oracle.ts`, its core +driver, and `packages/react-db/tests/cache-options-oracle.test.tsx`. + +**Gap:** the `both` cell changes both references together. First and second +callers always have the same option presence. + +**Change:** generate first/second declarations independently for each option: +absent, same reference, or different reference. Derive conflict from the pair +of declarations and query identity, rather than a single `different` flag. +Keep the default/default success case and both directions of absent/custom. +Use transforming schema fixtures where that boundary permits schemas. + +**Witness and cut:** cache `(keyA, schemaA)`; request `(keyB, schemaA)` under +the same identity. Reject at admission; a distinct query key must succeed. +Declare reach for each single-axis conflict and each presence transition. +M1 is the executed calibration for the first missing distinction. + +### 2. Compare cache state after rejection + +**Owner:** the same cache model and receiving drivers. + +**Gap:** the check ends at throw/rejection. It never asks whether the first +caller's result or resources remain usable. + +**Change:** extend each rejection history with a repeated conflict, a compatible +third caller, and a source update. Compare original keys, schema output, rows, +and surviving subscriber delivery. Record public subscriber ownership before +and after the rejected attach, without requiring incidental private layouts. + +**Witness and cut:** A establishes a result; B conflicts twice; A's compatible +peer still reads A's keys and receives the next update. No rejected listener +remains. Observe admission, the next source publication, and final cleanup. +M2 demonstrates one missing distinction. Also challenge a listener retained +before validation and a rejection that evicts the first result. + +### 3. Control pending delivery and vary which caller establishes the result + +**Owner:** the core cache driver and React receiving driver. + +**Gap:** the first caller is always explicit preload. The source is a ready +local-only Collection. Omitting `await initial` exercises immediate result +reuse, but does not hold source delivery or prove a transport-pending premise. + +**Change:** add an explicitly held source load. Assert that it started and +remains unresolved before the second caller. Cross preload-first and supported +observer-first registration with preload, subscribe, and render consumers. +Record reach separately for each path and pending/settled cut. + +**Witness and cut:** attach a conflicting observer while A's source delivery is +held; it rejects immediately. Release A and verify its exact result. A wrong +implementation that validates options only after readiness must fail before +the release. A healthy compatible peer must continue waiting and then succeed. + +### 4. Model option claims through hydration, failure, and consumption + +**Owner:** extend the cache option model using the public lifecycle paths in +`packages/db/tests/db-client-preload-identity.test.ts`. + +**Gap:** those paths already have source-object witnesses, but the row-option +oracle does not intersect them. A one-shot admission predicate cannot judge +when a claim begins or stops applying. + +**Change:** retain the first local declarations while a result is reusable; +model retirement when a failed or consumed result is no longer reusable. +Generate pending/settled hydration, first local claim, a later hydration chunk, +conflicting reuse, result consumption/failure, and replacement reuse. Server +function references must never become local identity claims. + +**Witness and cut:** hydrate, claim with A, hydrate a newer chunk, reject B; +consume or fail that stream, then allow a fresh B result. Compare public +dehydration, local row keys, and caller outcomes. Challenge both overwritten +local claims and claims retained forever. These experiments remain unrun. + +### 5. Receive option laws in the other actual framework bindings + +**Owner:** `packages/db/tests/conformance/contract.ts`, the shared conformance +suite, and each materially affected framework driver. + +**Gap:** the new option-specific receiving evidence is React. Source-ID and +general conformance evidence does not establish this option-specific handoff. + +**Change:** reuse the option judgment for independent DbClient callers through +each supported config entry point. Reuse inline-retention histories for +identity-comparing bindings, including Preact. Reactive bindings without a +DbClient follow their own documented recompilation law; do not impose React's +identity policy on them. Run real native scheduler cuts and render budgets. + +**Witness and cut:** a normal render retains a comparing binding's Collection; +identity replacement applies new options; a separate conflicting cached caller +rejects before returning rows. A compare-options-by-reference-on-every-render +mutant must fail in each applicable host, not just a controlled core driver. + +### 6. Compare complete metadata results and independently judged keys + +**Owner:** `packages/db/tests/query/virtual-row-fields-oracle.test.ts`. + +**Gap:** the 400 attribution histories read `live.toArray[0]`, check its label, +and compare metadata. Projected keys come from a clean sibling compiled by the +same machinery. Extra rows and shared key mistakes can escape that comparison. + +**Change:** have the model predict every selected ordinary field, every row, +and multiplicity. Compare complete arrays or multisets as the query contract +requires. For ordinary projected roots, add explicit `getKey` functions with +independent expected keys. Keep default-key differential checks as supplemental +evidence; do not invent an undocumented composite-key encoding. + +**Witness and cut:** two rows with distinct labels and model-derived projected +keys, followed by deletion of one. Observe preload and every publication. +Challenge an extra row, a missing non-first row, a lost `id`, and an incorrect +projected key shared by both compiler paths. None is calibrated here. + +### 7. Populate multiple union branches simultaneously + +**Owner:** the same metadata owner. + +**Gap:** every union attribution history has one populated branch and one +empty branch. Either branch position is covered, simultaneous contributors are +not. + +**Change:** generate nonempty branches with different source Collection IDs, +typed source keys, overlapping ordinary values, and independent pending writes. +Vary branch order, branch removal/reinsertion, and QueryRef consumption. Compare +all occurrences without collapsing them into a value-keyed Map. + +**Witness and cut:** A and B both contribute; hold a mutation only on B, then +remove A's last contribution. Each remaining row must retain its own branch's +attribution at pending and settled cuts. Challenge a union-wide first-source +metadata owner and deduplication of distinct contributions. Duplicate public +key handling must follow the existing key contract, rather than inventing a +winner. These experiments remain unrun. + +### 8. Change outer-join match topology during the history + +**Owner:** the nullable-spread grammar in the metadata owner. + +**Gap:** `matched`, spread order, and pending side vary, but match presence +remains static throughout each history. + +**Change:** generate unmatched → matched → unmatched → matched transitions +through join-key updates and legal delete/reinsert actions. Cross both spread +orders and retain an untouched second parent as an isolation check. Compute the +last present source from the authored query and independent source rows. + +**Witness and cut:** `{ ...a, ...b }` initially takes A's metadata, switches to +B when B matches, and returns to A after B leaves. Compare after each action +and inside its public callbacks. Challenge sticky ownership, first-spread +ownership, and clearing metadata when a later spread becomes null. + +### 9. Exercise attribution through rejection and overlapping transactions + +**Owner:** the metadata owner, receiving the settlement contract protected by +the optimistic-history owners. + +**Gap:** its attribution histories use one held update followed by fulfillment. +They do not combine attribution with rejection or two independently pending +source transactions. + +**Change:** generate fulfill/reject decisions, both settlement orders, and a +source update while an optimistic transaction remains held. Track authored +source snapshots and surviving overlays independently. Predict attribution +from the accepted source-versus-output rule at each cut. + +**Witness and cut:** mutate A and B in a joined query; reject one while the other +persists. The selected source's pending state and origin must follow the +remaining transaction. New projections still receive output metadata. +Challenge premature clearing of pending state and retention of the rejected +source's metadata. Do not change optimistic snapshot policy to fit output. + +### 10. Keep virtual fields in publication and downstream observations + +**Owner:** the metadata owner plus the observation machinery of +`packages/db/tests/query/includes-publication-oracle.test.ts`. + +**Gap:** the metadata owner checks snapshots at three cuts. The neighboring +publication oracle's `stripVirtualProperties` removes all `$` fields, so its +callback comparisons cannot supply metadata publication evidence. + +**Change:** add a separate metadata observation alongside the existing +ordinary-value projection. Capture callback payloads, synchronous Collection +reads, and downstream selections of the four virtual fields. Require callbacks +for histories that owe them. Preserve prior observations before later writes. + +**Witness and cut:** an optimistic source update changes label and pending +state in one publication. A callback must never see the new label with stale +attribution. Compare each layer at its own callback; do not require a downstream +layer to advance during its upstream callback. Challenge event-only stale +metadata and correct-after-a-microtask publication. + +### 11. Run the attribution grammar through Collection facades and inline controls + +**Owner:** the metadata owner, with a receiving driver using the facade +lifecycle histories owned by the includes Collection oracle. + +**Gap:** facade projection and inline collision witnesses are supplemental fixed +tests in `virtual-row-fields.test.ts`. They do not receive the broad attribution +grammar or exercise attribution through bucket departure and reactivation. + +**Change:** feed the same projection/collision model through root Collections, +Collection-valued includes, and inline-value controls. Vary active/empty/shared +buckets, route moves, child updates, and cleanup. Model facade lifetime +separately from row attribution; reuse the existing owner rather than creating +a competing lifecycle model. + +**Witness and cut:** two parents share one child facade; one leaves while the +child has a pending write. The remaining parent's facade retains the right +source/output metadata. Inline projected `$key` values retain authored shape. +Challenge using the parent Collection ID for projected facade rows and enriching +inline values as Collection rows. Calibration remains unrun. + +For A12's Effect claim, first inspect its public value contract separately. +An Effect does not publish through an output Collection. Do not infer an +output Collection ID law from the facade contract or treat every reserved +authored scalar as illegal. Its receiving driver must compare the contract +that actually applies; unresolved authority remains a limit of this plan. + +### 12. Observe metadata inside functional callbacks + +**Owner:** the metadata owner and +`packages/db/tests/query/includes-functional-projection-oracle.test.ts`. + +**Gap:** the metadata grammar's functional QueryRef consumer returns the row +directly. Its final output check does not prove a callback could not use spoofed +input metadata to compute an ordinary scalar. The functional-input owner checks +materialized values, but does not supply this reserved-field collision grammar. + +**Change:** generate a colliding inner projection through direct, QueryRef, +join, and union forms; capture public callback inputs and return an ordinary +`observedCollectionId` or pending badge derived from them. Keep the existing +rejection of compiled Collection-valued functional inputs. + +**Witness and cut:** an inner new projection selects a fake `$collectionId`; +an outer callback copies its observed value into an ordinary field. Assert the +legitimate metadata at callback invocation and on the resulting scalar. +An outer-publication-only sanitizer must fail this check. Sanitizing the final +reserved fields is insufficient once user JavaScript consumed the fake value. + +Maintainer decision, 2026-10-28: new projected subquery objects have their +reserved fields removed before a downstream `.fn.select()` callback. Direct +source rows retain source metadata. Inline include values retain authored +shape. This resolves O12's authority question; its receiving oracle remains +to be authored and independently reviewed before production repair. + +Subsequent clarification, 2026-10-28: structured whole-row source spreads also +retain genuine source metadata at callback entry. The stripping rule applies +to projections that create their own row shape; it does not override the +existing source-owned spread rule. + +### 13. Model release histories and receive them through packed artifacts + +**Owner:** a new release-law model adjacent to +`scripts/version-packages.test.mjs` and `scripts/publish-packages.test.mjs`. +Keep their useful fixed regressions as replay inputs or supplemental tests. + +**Gap:** version and publish tests use small separate workspaces and written +answers. The publishing callback records public names and prerelease state; +it does not observe the real Changesets command's registry operations or packed +dependency capabilities. Manifest peer strings alone cannot prove API support. + +**Change:** generate legal sequences of changesets, version runs, repeated +no-op runs, publish failures/retries, and RC exit. Independently model patch +suffixes, minor candidate increments, precedence, consumed changesets, stable +1.x lines, and dist-tags. Include candidate and patch counters around 9/10. +Run the command boundary against a local registry fixture, never npm production. +Pack the real package inventory and receive representative application paths +with supported DB versions. Check that excluded 0.15 combinations are rejected +and RC combinations can execute newly required APIs. + +**Witness and cut:** patch → patch → minor → repeated version → failed publish +→ retry → exit. Every packed manifest, selected tag, and consumed changeset +must match the model. Exercise Query failure reporting and offline outbox +restoration in installed fixtures. Challenge double-consumption, lexical RC +ordering, incorrect stable-package tags, and peers that claim absent methods. +The stable Query minor decision remains accepted policy, not an oracle defect. + +### 14. Generate type inputs from the same boundary descriptions + +**Owner:** `packages/db/tests/query/virtual-row-fields-oracle.test-d.ts`, with +ordinary mutation-handler type tests as supporting evidence. + +**Gap:** the metadata type file has reusable expected type aliases but applies +them to hand-authored concrete queries. That does not meet ORC-016's generated +legal-input requirement or cover the runtime grammar's composition axes. + +**Change:** generate legal TypeScript query inputs from row/value, projection, +nullability, union, and composition descriptions. A reusable independent law +predicts exposed metadata and nullability. Compile the generated inputs through +the public package exports, preserving diagnostic identity and source locations. +Add adapter handler-result forms with an independent Promise admission +rule; runtime legacy-value tolerance is a separate JavaScript promise. + +**Witness and cut:** vary a projected inline object, a whole-row child, a nullable +joined row, and discriminated union members without adding expected answers. +Reject enriched projected-value types, lost optionality, and accepted legacy +handler-result types. Do not use `any`, casts, or an unreachable callback to +manufacture a successful compile. These experiments remain unrun. + +### 15. Preserve oracle failures when teardown also fails + +**Owner:** both cache-option drivers and their shared test lifetime support. + +**Gap:** their `finally` blocks run cleanup sequentially. An early cleanup +exception can replace the comparison failure and stop later cleanup. The +metadata owner already uses `checkWithCleanup`, including a calibration test; +that protection does not extend to the cache drivers. + +**Change:** use cleanup support that attempts every owned release, preserves +the original law/checkpoint as the primary cause, and retains secondary errors. +Register partially acquired resources before subsequent steps can fail. + +**Witness and cut:** cause a cache comparison mismatch, make observer disposal +fail, and verify that view, client, and source cleanup still run. The reported +cause must remain the comparison mismatch. Also check a failed mount before an +unmount handle becomes available. This is harness evidence, not a new product +recovery promise. Its fault-injection calibration remains unrun. + +## Initial pass: implementation order and evidence limits + +Start with 1–2: both have demonstrated survivors. Then add 6–10, since those +histories exercise the metadata boundaries that repeatedly escaped review. +Follow with cache lifecycle/host receiving work, facade/callback receiving +work, and release/type boundaries. Apply 15 to each driver as it changes. + +For every implemented improvement, keep the old regressions, add the missing +grammar or observation, declare per-path reach, and run the unchanged baseline +before considering production changes. Demonstrate a relevant mutant failure +at the named comparison. Do not mark an item complete because this report +describes its experiment. No claim of universal correctness or zero future +reviewer findings follows from this audit. + +The two demonstrated gaps remain open in the checked-in oracle. The remaining +13 proposals still need calibration. No production defect was established by +the experiments here. Only core cache comparisons were mutation-tested; the +React and other native-host counterparts were not run with these mutants. + +After restoring every temporary edit, the core metadata and cache owners pass +together: 553 tests across two files. The existing release tests also pass: +11 tests. These are baseline checks; they do not implement the proposals or +establish full PR CI success. +`pnpm lint:changed`, the report's Prettier check, and `git diff --check` pass. +pnpm reports the existing node_modules/lockfile override mismatch; this audit +did not install dependencies or change the manifest or lockfile. + +## Follow-up: make the plan account for every review item + +The original 15 were insufficient. They concentrated on cache and metadata +interactions. They did not explicitly preserve several already repaired laws, +exercise documentation behavior, or require clean build and exact-head evidence. +Runtime oracles also cannot decide a maintainer's version policy or establish +that unused code has been removed. + +The matrix preserves A01–A15 from the first supplied review, B01–B10 from the +next, C01–C15 from the latest supplied review, G01–G11 from GitHub review +comments, and W01–W02 from the bot's pre-merge warnings. B10 and C01 retain +both of their subclaims. Overlaps retain separate IDs. Four source caveats +remain explicit. The bot's reach-wording suggestion is separate from its +remaining `isPersisted` comment; the first inventory accidentally conflated them. + +No new product decision is needed for these checks. The accepted policies +remain the authority. A request to identify sufficient improvements does not +make proposed checks into implemented coverage. Confirmed failures remain open. + +### 16. Check cached server reads before any subscription + +**Owner:** the shared cache option model and core driver, with a React +`renderToString` receiving driver alongside `cache-options-oracle.test.tsx`. + +**Law and gap:** independent callers cannot borrow conflicting local row +behavior. The current owner reaches preload, observer preload, subscription, +and client render; it does not reach a cached `getServerSnapshot()` before +subscription. An SSR hook never subscribes, so that later guard cannot help. + +**Change:** generate cached/uncached server reads, source-only/query preloads, +same/different/absent options, and keyed/derived identities. Reuse the admission +model. Require a cached row to be present before the conflicting read. Add a +browser hydration control under the same law, without inventing transportable +function identity. + +**Witness and cut:** preload A, then server-render B with the same identity and +a different `getKey` or schema. Reject before returning A's snapshot. A same-option +caller succeeds. A guard placed only in subscription must fail. The core and +actual React server paths both fail this comparison on the current head. + +### 17. Preserve mutation outcomes and explicit confirmation across adapters + +**Owner:** Query's mutation histories in `ownership-lifecycle.oracle.test.ts` +and Electric's handler histories in `electric-oracle.property.test.ts`. + +**Law and gap:** the handler's fulfilled/rejected outcome remains its outcome; +legacy return values are ignored at runtime. Query performs no automatic +refetch. A confirmed write survives settlement; an unconfirmed optimistic row +reverts. Types reject non-void handler results. These are approved decisions, +not an invitation to reinstate old automatic behavior. + +**Change:** keep the existing 18 Query and 12 Electric histories in the required +validation set. Extend their grammar across CRUD, fulfillment/rejection, +legacy/void results, and no confirmation versus explicit refetch/direct write +or Electric acknowledgement. Cross delayed confirmation and ordered consumers +where those existing owners apply. Compare exact handler error identity, fetch +counts, public rows, and transaction outcome while held and at settlement. + +**Witness and cut:** a successful server write followed by a legacy result +must not become a caller failure. A Query handler with no confirmation starts +no fetch. A documented successful-write example must use a confirmed history. +Earlier records calibrate the Electric rejection wrapper and Query automatic +refetch removal; this audit does not newly implement those owners. + +### 18. Enforce external completion through transaction state and Offline restore + +**Owner:** `optimistic-history-oracle.ts`, its outcomes driver, and the real +Offline restoration/settlement receiving paths. + +**Law and gap:** external completion is admitted only for pending work. It +cannot turn failure into success or override persisting/completed work. The +first 15 proposals omitted this law even though the previous review added its +missing action to the primary model. + +**Change:** retain pending/persisting/completed/failed starting states and +repeated completion attempts. Intersect them with restored outbox work, peer +ownership, and mutation outcomes that the Offline owners already express. +Compare public rows, terminal outcome, and transaction state after each action; +observe outbox deletion and caller settlement at the receiving boundary. + +**Witness and cut:** fail a transaction, attempt external completion, and +require rejection without changed rows/outcome. Restore permitted pending +work as a compatible control. Removing the admission guard previously failed +four state histories. Packaged API availability is a separate O13/O22 check. + +### 19. Release descriptor deferrals after failed or uncommitted renders + +**Owner:** React's source-ID reuse oracle and descriptor-query binding driver; +use shared conformance receiving paths where another framework owns this law. + +**Law and gap:** a rejected render cannot leave a descriptor Collection unable +to start for a later consumer. Mounted identity retention does not prove +partial-construction cleanup. The existing React owner already covers rejected +source-ID reuse, Suspense, infinite hooks, and multiple throwing starts; retain +that evidence rather than claiming a new model is needed. + +**Change:** inventory render preparation steps that acquire deferrals. Generate +failure before/after each acquisition, suspended and abandoned renders, and +subsequent direct readers. Reuse the existing lifecycle judgment. Register +cleanup for partial construction and require per-hook reach. + +**Witness and cut:** reject preparation after deferring a shared descriptor; +a later direct preload must reach ready and expose the authored row. A second +source throwing during release must not prevent a healthy third source from +starting. Mutants that omit release or stop after one failure must be rejected +at those public cuts, rather than by an unrelated cleanup error. + +### 20. Observe proxy expressions through diagnostics and actual UI consumers + +**Owner:** the virtual-field query law, index-suggestion tests, and a receiving +witness for the React Native shopping-list projection and badge. + +**Law and gap:** the selected ordinary pending indicator must follow the source +row. JavaScript `!proxy` is a constant, not a query expression. An outer reserved +field receives output metadata. Correct generic metadata cannot prove that the +example actually consumes the ordinary field. + +**Change:** generate pending/settled and true/false source states through +supported predicate, order, and projection expressions. Check both actual +result variation and the intended diagnostic path. Compile the example and +receive those states through its actual component, not a copied projection. + +**Witness and cut:** hold a list write, observe the Saving badge, settle it, +and observe the badge clear. Ordinary projected `hasPendingWrites` must vary +while output `$hasPendingWrites` remains governed by the projection law. +Calibrate a JavaScript-negation replacement and a badge reading the reserved +property. Static wiring plus a core query test is useful but does not establish +native component execution. + +### 21. Execute authored documentation and check migration/API consistency + +**Owner:** `scripts/check-doc-snippets.mjs`, package type checks, and behavior +receivers reusing the relevant mutation/query models. Handwritten guide and +skill references are in scope; generated reference output remains CI-owned. + +**Law and gap:** runnable guidance must resolve public exports and demonstrate +the behavior it promises. Existing removed-symbol searches and snippet +compilation cannot detect a successful POST whose optimistic row disappears. +An existing snippet baseline can also preserve a newly introduced diagnostic. + +**Change:** extract the actual fenced snippet, supply only its documented +external collaborators, and run its successful and failing histories through +the matching model. Compile examples and skills against exports. Reject new +removed-symbol references outside explicitly marked historical/before examples; +compare diagnostic identities against the baseline without expanding it to +waive new regressions. Check prose for source/output attribution, explicit +confirmation, internal helpers, stable-minor upgrade costs, and removed APIs. + +**Witness and cut:** the documented POST succeeds and the server owns the row; +at transaction settlement the Collection must show the confirmed row. An +unexported migration helper fails compilation. A remaining `isPersisted` +reference fails the current-API check. The successful POST witness is RED at +this head; broader generated docs receiving coverage remains proposed. + +### 22. Validate the actual workspace before versioning and packed consumers after it + +**Owner:** workspace install/build/typecheck CI, actual package inventory, and +the release model's package-consumer receiving fixture. + +**Law and gap:** a truthful published peer range does not guarantee a local +workspace can build before the RC versions are written. Existing node_modules +can retain a symlink that a clean install will not create. The first plan's +packed-consumer checks omitted that pre-versioning boundary. + +**Change:** use a disposable, clean, frozen install and the repo's package +build graph. Resolve every imported workspace API, including declaration/test +inputs and examples. Check the actual non-private inventory against the RC +changeset after main integration, including Convex and Preact. Verify local +workspace dev dependencies independently from published peer ranges. Then +version, pack, install, and execute supported consumers through O13. + +**Witness and cut:** Offline has an RC-only peer while the local DB is still +0.15.2; workspace builds must resolve the local source via a workspace build +dependency. A persisted Query handler returning `{}` must fail the new void +contract's typecheck. Neither failure is established by an already linked +runtime test. Both source findings remain open; no fresh install was run here. + +### 23. Check dead exports, branches, and duplicated wrappers with source evidence + +**Owner:** production caller/export inventory, TypeScript control-flow checks, +and the shipped-byte comparison. This is a static review check, not a runtime +state model. + +**Gap and change:** behavior tests cannot reject an unused warning helper, +an impossible SSR branch, an unused recompile mode, or equivalent duplicated +wrappers. Enumerate callers and exported uses before deletion; check narrowed +types and every framework's policy modes. Compare consolidated wrappers/types +and run their unchanged behavioral owners. Do not delete a shared policy merely +because React alone supplies a constant argument. + +**Witness and cut:** leave an unreferenced warning export or dead mode in the +production inventory; require the source check to report it. Preserve a real +reactive-framework caller as a negative control for overbroad deletion. Compare +size after consolidation, without declaring a harmless internal branch a +product failure. + +### 24. Measure repeated work independently from result correctness + +**Owner:** a narrow compiler work probe and `pnpm size:compare`, supplementing +the metadata/functional-projection owners. + +**Gap and change:** correct rows do not expose an alias-array or copy-closure +allocation per row. Instrument the relevant compile/evaluation site or use a +source/AST placement check. Compile once and process increasing row counts; +require compile-invariant source discovery to happen once per compilation. +Count copies only under the conditions that require a copy. Keep output +comparisons alongside these counters. + +**Witness and cut:** hoistable alias discovery or closure construction inside +the row loop fails its deterministic count. An allocation required by a changed +row remains permitted. This is a proposed engineering work check; no new +wall-clock performance promise or measured slowdown is claimed. + +### 25. Receive coded diagnostics and public APIs through published build artifacts + +**Owner:** production error census, `scripts/test-production-errors.mjs`, and +`scripts/test-minified-db.mjs`, with affected adapter artifact checks. + +**Law and gap:** adding a plain Error can bypass coded diagnostics and ship its +full text. Source-only testing does not establish production message erasure +or public export/name stability. The original plan omitted these existing +independent observations. + +**Change:** include newly reachable identity/mutation errors and all affected +packages in the development census. Build actual ESM/CommonJS and minified +consumer entries; compare error codes/names and test message erasure. Keep +intentionally removed errors distinct from missing public exports in docs. + +**Witness and cut:** an uncoded preparation error is reported by the census; +a newly introduced literal survives production bundling and fails erasure. +A documented helper missing from the public entry fails O21/O22 compilation. +Use existing calibrated owners; no new artifact run was performed here. + +### 26. Tie all validation to the exact head and the path actually exercised + +**Owner:** a validation manifest joining package/test inventory, oracle reach, +build fingerprints, and review-item reconstruction. This is execution gating, +not a universal correctness model. + +**Gap and change:** three reported failures were in tests already on the PR. +They needed an ordinary full affected-test run. Other review runs lacked +Changesets dependencies, loaded stale DB builds, or reviewed a moving branch. +Record source head, build inputs/dist age, resolved DB entry/copy, test selection, +exit status, and per-host comparison reach. Run every affected changed test; +include newly merged packages. Rebuild before adapter receiving tests. A new +head invalidates affected evidence. Preserve every raw review ID, outside-diff +comment, additional suggestion, and setup caveat in the validation inventory. + +**Witness and cut:** restore each stale assertion and require its own test to +fail. Remove a required host from the test selection and require the missing +reach to fail validation. A second/stale DB or absent Changesets dependency +must produce invalid setup, never a GREEN product result. Unrun checks remain +unrun even when another suite has thousands of passing cases. + +## Follow-up experiments at unchanged `858b174a6` + +These were temporary production drivers; no source repair or permanent test +extension was retained. The core/React drivers reuse +`checkCacheOptionAdmission` from the existing independent cache model. They +vary `getKey`, schema, and both, each with same/different references. Fixture +schema validation is an identity operation; these probes judge declaration +admission, not schema transformation. + +| Path and named checkpoint | Expected under the approved law | Observed comparison | +| --------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | ---------------------------------------------------------------- | +| Core `getServerSnapshot()` after a one-row query preload, before any subscription | Same options succeed; conflicting options throw before returning cached rows | Three assertion failures, three compatible controls pass | +| Actual React `renderToString` with `DbProvider` and `useLiveQuery`, same cached premise | Same admission rule, through the real server host | Three assertion failures, three compatible controls pass | +| `hydrate()` with a newer stream timestamp after local preload, then another preload | Preserve the original local claim and reject conflicts | Three assertion failures, three compatible controls pass | +| Actual error-handling guide's successful POST handler, then `when('settled')` | Server row and Collection row agree after documented success | One row on the server, zero in the Collection: assertion failure | + +The server runs stub `window` as undefined and do not subscribe. The hydration +run dehydrates the established query, checks its row exists, hydrates a stream +with `dehydratedAt + 1`, and checks that the newer timestamp replaced it before +admission. This distinguishes the real replacement path from ignored hydration. +The documentation run extracts the fenced setup before `// Usage` in +`## Transaction Error Handling`, transpiles it, and evaluates it with the real +Query Collection/QueryClient and a successful in-memory fetch collaborator. +It preloads an empty server, inserts the documented todo, awaits settlement, +and compares all authored ordinary fields with the server's independent row. + +Commands used the root Vitest binary from each package, `--config vite.config.ts`, +`--coverage.enabled=false`, and `--typecheck.enabled=false`. Temporary test +paths were `tests/cache-review-coverage-probe.test.ts` in DB, +`tests/cache-server-review-probe.test.tsx` in React, and +`tests/doc-success-review-probe.test.ts` in Query. Drivers and logs are retained +in `/private/tmp/rc2171-oracle-challenge/` for this task. These paths are scratch +artifacts, not checked-in replay interfaces. The witness construction above +states the inputs and observations needed to reconstruct them. + +The first documentation driver used a non-file `import.meta.url` under Vite +and failed setup. That run supplied no product evidence. Reading the actual doc +from the package working directory reached the row comparison and failed there. +All other RED results above were intended assertion failures, not timeouts or +teardown errors. No repair GREEN or hostile-mutant result is claimed for these +three current defects. + +The existing external-completion, core option-identity, and index-warning files +also pass at this head: 58 tests across three files. Earlier review records +supply their historical RED/GREEN evidence. This bounded baseline does not +execute the new SSR/hydration histories in the checked-in test suite. + +## What the expanded plan does and does not establish + +Every identified review item has a concrete check and a distinguishing witness +in the matrix. Behavioral findings map to independent laws and receiving paths; +source/build/documentation findings map to appropriate checks. Accepted release +policies remain explicit costs, rather than defects a test should reject. + +This is **plan accounting**, not demonstrated interception of all 53 items. +The expanded checks would need implementation and replay against each original +failure or a suitable mutant before making that stronger claim. Known gaps +remain assigned to their existing owners in the coverage map. The current SSR, +hydration, and documentation counterexamples prevent a readiness or bug-class +closure claim. No production fix, permanent oracle extension, commit, push, +generated-document edit, dependency installation, or lockfile change was made +in this audit. diff --git a/docs/contributing/oracle-reviews/2026-10-28-pr-2171-oracle-chunks.md b/docs/contributing/oracle-reviews/2026-10-28-pr-2171-oracle-chunks.md new file mode 100644 index 0000000000..4188a7d895 --- /dev/null +++ b/docs/contributing/oracle-reviews/2026-10-28-pr-2171-oracle-chunks.md @@ -0,0 +1,2841 @@ +# PR #2171 oracle implementation chunks + +Production baseline: `858b174a65f160d13d01f0324c7481cc8e4eba3a`. +All entries below describe test/document changes on that unchanged production. +The maintainer requires every oracle chunk to finish before production repair. +RED results are therefore retained; no product-readiness or bug-class closure +claim is made. The 26-check proposal and 53-item matrix remain the work inventory. +Generated API documentation is excluded. + +## Chunk 1: independent cached options and public observations + +**Law and authority.** The maintainer's independent-caller decision requires +same-identity getKey/schema declarations to agree by reference, including +absence. Distinct query keys permit independent behavior. SSR initial views +remain frozen at their capture cut (`docs/guides/ssr.md`, “Prepare every server +snapshot”). Mounted inline retention is a separate owner. + +**Inventory and grammar.** Six declaration pairs per option cross independently +with derived/keyed identity and same/distinct key: 144 bounded histories. Core +adds held/settled writes and five paths: explicit preload, observer preload, +subscribe, cached server read and preload after positively observed newer +hydration replacement. React supplies mounted hooks and actual renderToString; +these are separate receiving paths, never inferred from a core result. +Schemas test runtime declaration identity only; no transform/type support is +claimed. The domains are exhaustive at these bounds, so ORC-007 random campaigns +do not add a missing finite case. A key cannot change under the same model +identity without a conflict; a distinct key is its legal neighboring control. + +**Observations and reach.** Each core path reaches 288 histories, including 144 +held deliveries. Each path reaches 32 getKey-only, 32 schema-only and 64 both +conflict premises; 144 distinct-key controls; getKey/schema addition 48 each and +removal 24 each. These premise counts are recorded before the actual admission +comparison; a failed comparison does not claim later checkpoints ran. Preload, +observer preload and subscribe each complete admission/retry/original reuse/ +source-update comparisons in all 288. Server-read completes those in 224; +hydrate-preload in 160. Each React receiver reaches 16/16/32 independent/both +conflict premises, 72 distinct-key controls, additions 24 each, removals 12 each. +Client admission/retry/original reuse and settled keyed-row comparisons run in +all 144 histories. SSR completes admission/retry/original reuse in 80. + +Original custom keys use `first-id`, second custom keys use `other-id`, and the +default uses `id`; losing any declaration is observable. Both successful callers +and the original after rejected retries retain exact keys and ordinary fields. +Core checks every callback after the next atomic source publication and no +callback for a refused listener. React checks accepted rows immediately and +after its async act flush. The server held-row check compares the initial empty +view, rather than incorrectly requiring later writes to rewrite the capture. + +**Baseline result.** Run the root Vitest binary from each package with +`run --config vite.config.ts --coverage.enabled=false --typecheck.enabled=false`. +Core selector: `tests/live-query-cache-options-oracle.test.ts`: 192 assertion +failures and 1,314 passes (1,440 histories plus 66 checker controls). The failures +are missing conflicts at 64 settled SSR and 128 held/settled hydration admissions. +React selector: `tests/cache-options-oracle.test.tsx`: 64 SSR assertion failures, +224 passes; zero scheduling warnings after the native handoff correction. +`tests/utils.test.ts`: 132 passes, including three cleanup-failure positions. +DB/React TypeScript checks pass. These are intended assertion failures, not setup, +runtime or timeout evidence. Scratch logs: `chunk1-core-final.log`, +`chunk1-react-settled.log`, and `chunk1-core-rereview.log` under +`/private/tmp/rc2171-oracle-challenge/`. Set +`TANSTACK_DB_ORACLE_STATISTICS=1` to print the reach counts. + +**Calibration.** The shared comparison rejects accepted conflicts on both retry +attempts, wrong second/original keys, and an earlier wrong callback followed by +a correct callback. The prior challenge record retains executed production +mutants for all-options-must-differ and overwrite-before-throw guards. Those +historical experiments are not fresh mutation evidence for the expanded paths. +The cleanup fault injection varies the failing release position and checks the +original mismatch remains the cause, the secondary failure stays distinct, and +all later releases run. No production edit, even a temporary repair, was made. + +**Review.** Independent subagents `cache_oracle_review` and +`cache_receivers_review` applied oracle-review. Their first rounds found +last-only callbacks, weak reach cells, indistinguishable original keys and a +missing React settled observation. These are corrected. Final rounds found no +further supported defect in this bounded scope. The receiver reviewer separately +ran the three cleanup controls: three passed. Challenges that shaped this chunk +are retained evidence, not held-out evidence. + +**ORC accounting.** 001–005: authority, independent declaration model, literate +layers, bounded reference/presence grammar and public receiving cuts described +above. 006: wrong-observation controls described above. 007: finite exhaustive +grammar, random/shrink campaigns inapplicable. 008: stateless comparison, no +stateful transition revision. 009: declaration labels denote references or +absence, not production cache state. 010: fault injection and all-resource +cleanup. 011: no concrete shared semantic hypothesis requiring a second model; +the accepted reference rule is independent of hash/guard internals. 012: this +versioned record, without a closure claim. 013: single-axis conflicts and added/ +removed neighbors distinguish wrong coupled rules. 014: actual React client and +server receiving paths; broader hosts remain pending. 015: per-path counts +above. 016: oracle tests use shared judgments; cleanup examples stay in ordinary +utils tests. + +**Remaining work.** O01 and O02 are implemented within the declared boundary; +O03 held-delivery and O16 cached-SSR witnesses are implemented, while O03 +first-registrant variation and O04 failure/consumption/transport remain pending. +O15 has generic cleanup fidelity evidence but is not closed for every partially +constructed native host. Other frameworks, hydrateRoot, semantic equivalence of +functions and schema transformations remain outside this chunk. Product GREEN +and fresh production-mutant campaigns are deferred until all oracle chunks +finish. The reachable SSR/hydration counterexamples keep the product class open. + +## Chunk 2: hydrated claim lifetime and framework receiving paths + +**Law and inventory.** A portable result contains no server getKey/schema +references. Its first local caller claims them; a newer payload retains those +claims, while failure or consumption retires them. Authority: the maintainer's +option decision, the source-claim lifetime in `db-client-preload-identity.test.ts`, +and the SSR guide's matching-result configuration requirement. React and Preact +compare inline query identity; Preact has no DbClient hook input. Svelte is the +other public client-backed hook. Vue, Solid and Angular expose no DbClient input; +their client-free reactive compilation policy is not changed into React retention. + +**Model, grammar and observations.** The lifecycle owner uses one independent +active/retired judgment over named declarations. It runs 2,160 bounded histories: +72 same-identity declaration pairs, three first local claimants (preload, +observer preload and server read), active/replaced/failed/consumed results, +pending/settled transport, plus replacement and failure before original pending +transport release. Server configuration has equivalent key/schema semantics +with independent references. Actual server dehydration is scanned for callables +before a JSON round trip, so serialization cannot silently hide leaked claims. +Pending conflicts/retries are checked before delivery; compatible promises stay +pending. Newer payloads have different ordinary labels. A pending replacement +is checked immediately and again after obsolete transport settles. Initial +transport failure checks exact error identity on the claimant and compatible +pending peers, then fresh local admission. Consumed/failed results accept the +new declaration and expose its independent local keys after live handoff. + +Per first-claimant path: 720 portable premises, 432 pending premises; +384 pending-conflict and 48 pending-compatible histories reach comparison. +Pending replacement reaches 64 conflicts and eight compatible controls; +pending failure reaches the same split. All 72 pending-failure histories reach +failed-stream absence and retired reuse. On the unchanged RED baseline only +eight compatible pending replacements reach the post-obsolete comparison; +64 stop earlier at the intended missing-conflict assertion. The later check is +present, but no evidence claims those blocked conflict histories completed it. +Phase × conflict/compatible reach prevents borrowing a retirement witness from +an active-result case. Full counts print with TANSTACK_DB_ORACLE_STATISTICS=1. + +**Result and calibration.** The DB lifecycle selector +`tests/live-query-cache-lifecycle-oracle.test.ts` gives 640 admission assertion +failures and 1,526 passes, including six checker controls. There are 576 missing +replacement conflicts and 64 settled server-first missing claims. No setup, +timeout or unhandled-error failure is counted. The shared judgments reject +leaked callables, a no-claim policy and a forever-claim policy. The same pending +and replacement comparisons observe the actual open product defects; production +GREEN and fresh repaired-production mutants remain deferred. + +The shared immutable capture/checker in `live-query-bound-options-oracle.ts` +is received by the existing 12 React retention histories and six new Preact +histories. Each Preact history reaches rerender, source update and changed +identity; each option has two histories and each identity mode three. A render +budget rejects rebuild loops. Six hostile in-place config replacements prove +that mutating the original Collection cannot mutate the expected references. +React cleanup now attempts every returned mount's release while retaining the +primary mismatch. Svelte receives the 144 independent cached-option histories +through actual effect roots and flushSync; all admission/retry/original reads +pass, including 16 key-only, 16 schema-only, 32 both conflicts and 72 distinct +key controls. It compares accepted keyed rows after native effect delivery. + +Commands use each package's root Vitest binary/config with coverage/typecheck +disabled for runtime. Preact selector `tests/bound-options-oracle.test.ts`: +12 passed (six host histories plus six controls). Svelte selector +`tests/cache-options-oracle.svelte.test.ts`: 144 passed. React selector +`tests/source-id-reuse-oracle.test.tsx -t 'retains inline options'`: 12 passed, +80 other histories outside that selector. DB/Preact/React TypeScript checks and +Svelte's test:types pass. Lint:changed passes with only the pre-existing installed +lockfile/override mismatch warning. Scratch logs use `chunk2-*` under the task's +existing scratch directory; lifecycle final is `chunk2-lifecycle-r4.log`. + +**Review and ORC accounting.** Fresh independent reviewers +`cache_lifecycle_review` and `framework_options_review` applied oracle-review. +Their findings added before-release pending comparisons, pending replacement, +phase/schedule-specific conflict reach, equivalent server keys, immutable option +capture, and failure-preserving React cleanup. The lifecycle reviewer reran the +final full suite and confirmed the same 640/1,526 split. The framework reviewer +independently ran all three host selectors. Final reviews found no additional +supported defect within these boundaries. + +ORC-001–005: authority, independent active/retired rule, literate responsibilities, +finite legal timelines and positively observed native/transport cuts above. +006: callable, opposite-policy and in-place-mutation controls. 007: bounded +exhaustiveness rather than random sampling. 008: retaining a claim distinguishes +changed-declaration reuse; failure/consumption removes it, with active neighbors +proving the distinction is needed. 009: declaration labels denote local refs; +portable results and local claims remain distinct. 010: error identity and all +returned-resource cleanup; generic throwing-cleanup calibration is in chunk 1. +011: no remaining named shared semantic hypothesis requiring a second model. +012: this record preserves open product results. 013: active/retired and +before/after transport neighbors. 014: actual Preact/Svelte schedulers receive +the shared judgment. 015: per-path phase/schedule reach above. 016: all oracle +cases replay common judgments; controls supply hostile observations to them. + +**Limits and next work.** This completes the declared O04 transport, first-local +claim and retirement timelines, and O05's applicable native receiving paths. +It does not prove arbitrary stream interleavings, hydrateRoot, Svelte SSR, +Svelte continuing source publications, schema transformations, or cleanup before +a native mount returns. O15 still needs partial-construction fault injection; +O03 first result registration without an existing transported result remains +separate. These limits remain owned by the cache/binding owners. No production +file, dependency installation, generated doc, commit or push changed. + +## Chunk 3: complete metadata results and populated unions + +**Law and authority.** Public virtual-row properties and ARCHITECTURE's facade +boundary require structured whole-row/spread and functional direct-source +results to retain source metadata. New projected objects receive output +Collection metadata (maintainer decision). Authored reserved names cannot choose +attribution. This chunk extends O06/O07's snapshot boundary; it does not claim +callback, facade-lifecycle or functional-input coverage. + +**Model and legal grammar.** Authored source facts and pending-key sets predict +every ordinary selected field and all four virtual fields. No source row or +compiler classifier supplies expected values. The 1,056 attribution histories +retain all prior compositions, add a second source row and its deletion, and +cross independently configured keys with default-key differential checks. +Only the latter consult a sibling query for undocumented compiler key encoding. +Both boolean collision values and legal local origin distinguish authored +values from output metadata. Direct-source callbacks cannot add collisions; +that nearby invalid combination is excluded by grammar. Collisions cross +single/joined sources and direct, recursive, joined, functional and union +consumers, so removing any such axis loses its receiving path. + +Another 264 histories populate two union branches with numeric 1 and string +"1", different source IDs and equal labels. They cross branch order, QueryRef +consumption, either pending branch, five projection shapes and reserved-name +collisions. A peer is deleted and reinserted while the other branch persists; +settlement without confirmation restores its independent authored base. The +custom public key preserves typed identity, avoiding an invalid duplicate-key +winner policy. Model objects never cross into production without copies. + +**Observation and calibration.** Public toArray snapshots are compared at +preload, held mutation, peer deletion/reinsertion and settlement. The comparison +normalizes only unspecified order and retains duplicates, every ordinary field +and all metadata. Fourteen wrong-result controls cover both source/output +policy and reject omitted/duplicated second rows, missing ordinary IDs, wrong +keys, union-wide ownership and stale pending/origin. Allowed reverse order +passes. These are checker calibrations, not fresh production mutants. Full +production repair and its RED/GREEN/mutant record remain deferred by instruction. + +**Reach and unchanged baseline.** 1,343 tests pass: 1,320 attribution histories, +eight prior nullable-spread histories, one prior classification case and14 +checker controls. Multi-row/deletion reach1,056 each, independent-output-key +reach384, simultaneous populated/typed/reinserted union reach264 each, and +true-authored output-pending discrimination reach192. Each of24 union +path × source/output policy × pending-side × held/reinserted/settled cells +reaches18 source-owned or48 output-owned histories. The oracle asserts all +these states; a missing receiving path cannot borrow another path's count. +Command: package-root Vitest with config, coverage and runtime typecheck flags +listed above, selector tests/query/virtual-row-fields-oracle.test.ts. Final +log: chunk3-metadata-collisions.log. DB tsc --noEmit was clean before the final +collision-value grammar addition; the final type/lint check is recorded below. + +**Independent review.** metadata_complete_review and metadata_union_review +applied oracle-review and ran the tests independently. Findings corrected: +position-dependent pending wait, source/model object sharing, aggregate reach, +missing row2 metadata controls, and false-only authored pending collisions. +Final union review independently passed1,343 tests with all required reach +(chunk3-union-review-final.log). No further supported executable defect was +found in this bounded scope. + +**ORC accounting.** 001–003: authority, independent authored-fact judgment and +literate boundaries above. 004: reconstruction of prior forms, meaningful +composition/key/branch/collision axes, typed-key margins and direct-return +collision exclusion. 005: full public snapshots at positively reached cuts. +006:14 calibrated controls, including legal reordered positives. 007: finite +exhaustive grammar, random/shrink campaigns inapplicable. 008: direct +recomputation; pending facts distinguish held/settled rows. 009: authored facts +and pending-key sets denote source state and active optimistic overlays. +010: held gates settle before all-resource failure-preserving cleanup. +011: independent configured keys address the shared-default-key hypothesis; +no further named shared fault requires another formulation. 012: this record +and updated coverage map preserve limits. 013: true/false collisions and +source/output, either branch and peer-return neighbors distinguish wrong laws. +014: controlled core only, no real-provider/framework handoff claim. 015: +per-path counts above. 016: new histories and controls use the shared model/check. + +**Limits.** Dynamic join matches, rejection/overlap, callback/downstream +metadata, facade activation/retirement, functional callback inputs and generated +public types remain separate work. Default compiler-key encoding remains a +supplemental differential observation. No production file or generated doc +changed. No commit or push was made. + +## Chunk 4: join transitions, independent write outcomes and publication + +**Law and authority.** The accepted source/output attribution rule applies at +each publication. ARCHITECTURE's coherent-publication law requires installed +reads and change payloads to agree; downstream callbacks are judged at their +own layer, without requiring downstream advancement inside an upstream callback. +The optimistic-history owner supplies settlement and first-queued-confirmation +origin rules. This is O08–O10's bounded core receiving witness. + +**Model, grammar and observations.** A full left join is recomputed from +independent authored rows and separate optimistic snapshots. The driver has +112 finite histories: five projection shapes, spread order where meaningful, +join-key versus membership transitions, independent success/rejection with +separate errors, and either settlement order. A held left write spans unmatched, +matched, unmatched and rematched cuts. A right write then overlaps and receives +a queued same-key source update. The unconfirmed left row restores its base; +the right source confirmation becomes local only after successful work. A later +remote write distinguishes consumed attribution. An untouched parent remains. + +Every action compares full root/downstream snapshots and every recorded +callback's installed read, change value and previous value. Raw metadata is +captured at callback time. Deltas across the entire callback trace must equal +the independent before/after delta; message multiplicity survives capture. +Batch partition is permitted. Initial-state subscriptions establish subscriber +replicas before update classification is judged. The first attempted driver +omitted that premise and got valid insert messages for first delivery; that was +a fixture error, not product RED. Another run failed only on undeclared reach +names; that was harness setup, also not product evidence. + +**Reach and unchanged baseline.** Full owner: 1,463 passed, including prior +1,343 cases,112 histories and eight new checker controls. For each projection +shape/topology, all rematched-held/two-pending/first-settled/both-settled/remote +cuts and root/downstream held callbacks run. Spread shapes reach16 histories +per such cell; other shapes reach8. Either rejection reaches8/4, both rejection +reaches4/2, and either settlement order reaches8/4 respectively. All120 required +states are asserted by createOracleReach. Final runtime log: +chunk4-metadata-r2.log. Commands use the same package Vitest invocation as above. +No unchanged-production defect was found in these histories. Controls reject +stale installed metadata, stale event origin, wrong previous values, duplicated +messages/whole batches, missing messages/callbacks and an earlier bad callback +followed by a correct final read. No production-mutant or repaired-production +GREEN is claimed. + +**Review.** Fresh metadata_timeline_review and metadata_publication_review +applied oracle-review. They identified duplicated whole batches passing the +checker, grouped unsubscribe cleanup, and missing both-reject histories with +separate caller errors. All are repaired. Both independently passed1,463 tests +(chunk4-metadata-independent-r2.log and chunk4-publication-review.log). The final +bounded verdict found no further supported executable defect. + +**ORC accounting.** 001–003: authorities and literate, independent recomputation +above. 004: initial absence/presence, both topology transitions, meaningful +spread order, caller outcomes and settlement order distinguish the grammar; +missing-key updates and same-source overlap are excluded from this domain. +005: public snapshots, callback reads/payloads and downstream scalar projection +at named cuts. 006: eight wrong-observation controls, including duplicate traces. +007: exhaustive finite grammar, random/shrink campaigns inapplicable. +008: separate base/overlay/pending/origin facts distinguish acceptance, +settlement and later writes. 009: model facts map to applied source rows, +optimistic snapshots and pending writes, not D2 graph state. 010: both gates +settle and independent subscription/Collection cleanup entries attempt all +resources; capture preserves earlier metadata. 011: no identified shared +semantic fault needs a second formulation. 012: this record and coverage map. +013: matched/unmatched, held/settled, local/remote and separate errors distinguish +nearby wrong rules. 014: controlled core scope; no provider/framework handoff. +015:120 path states above. 016: histories and calibrations use shared judgments. + +**Limits.** Same-source overlapping writes, truncate, dynamic joins beyond LEFT, +provider/framework scheduling, facades and partial-construction cleanup remain +open under their recorded owners. Production files, generated docs, package +manifests and lockfiles remain unchanged; no commit or push was made. + +## Chunk 5: facade metadata, active intervals and inline values + +**Law and authority.** ARCHITECTURE's Nested materialization section applies +the Collection metadata boundary to facade rows and preserves authored inline +values. Parents sharing an active bucket share its ready facade. Last-route +departure retires that interval; reactivation gets a new facade. These histories +have no facade-local optimistic mutations, which could legally keep retired +handles nonempty. This is bounded O11 receiving coverage. + +**Model and grammar.** The primary includes owner now recomputes active bucket +intervals from the complete current route groups. Model-only interval tokens +name lifetimes, not production IDs. Complete inputs avoid redundant route-count +state. The metadata receiver crosses 33 projection/collision descriptions, +populated/empty destinations and child success/rejection: 132 histories. +Sharing, one-route departure, last-route departure, held reactivation, +settlement, later source update and resharing compare every child row and all +four metadata fields. Facade output uses its public ID; source-owned rows use +the independently supplied source ID. Inline toArray/materialize comparisons +preserve authored shape. Reads of all four source virtual fields make inline +spread values explicit rather than depending on property enumerability. + +**Results and calibration.** Unchanged production passes 1,632 tests across +both owners (1,599 metadata, 33 includes). Six lifetime controls reject split +sharing, retargeting, stale retired rows/status, missing groups and a cleaned-up +active empty facade; four inline controls reject facade enrichment. The first +checker accepted that active-empty survivor. The review's actual checker probe +now fails at the intended readiness assertion. No production repair GREEN or +production mutant is claimed. Logs: chunk5-ready-final.log, +chunk5-facade-lifecycle-rereview.log and chunk5-active-empty-probe-rejected.log. +DB types and lint:changed pass (chunk5-db-types-final.log and +chunk5-lint-final.log). + +**Reach and review.** Every declared lifecycle cut reaches 32 histories per +ordinary shape and four for direct functional returns. Each facade/inline form +reaches nonempty held/reactivated observations. Active-empty-ready has the same +32/four counts. facade_metadata_review and facade_lifecycle_review used +oracle-review; the latter found the readiness gap, verified its rejection and +independently passed the full suite. No further supported bounded defect found. + +**ORC accounting.** 001–003: authority, limits and literate independent models. +004: shared/sole/absent routes, populated/empty destinations, return intervals +and caller outcomes distinguish each axis; inactive groups have no route. +005: actual public facades and inline values at every named cut. 006: ten +hostile controls plus the actual former checker survivor. 007: exhaustive finite +grammar, so random/replay campaign requirements do not apply. 008–009: active +interval and next token are minimal lifetime facts mapped to public facade +sharing; complete route inputs supply current activity. 010: pending mutation +settles before all-resource failure-preserving cleanup. 011: no new shared-fault +hypothesis requires another formulation. 012: this versioned record at unchanged +858b174a65f160d13d01f0324c7481cc8e4eba3a. 013: last-route and reactivation +neighbors distinguish reuse/retargeting. 014: controlled core receiving scope. +015: per-shape/form cuts above. 016: histories and controls use shared judgments. + +**Limits.** Single-source string child keys only; composite key encoding, +facade-local mutations, construction failures, Effects and provider/framework +scheduling remain separate work. No production or generated docs changed. + +## Chunk 6: generated public metadata types and nullable facade receiving path + +**Law and authority.** VirtualRowProps and ARCHITECTURE's Nested materialization +section supply readonly metadata at Collection roots, source-row metadata on +whole rows and authored shapes for inline values. A nullable selection is either +a complete source row or an empty object. Publishing that object through a +facade supplies required metadata in both cases, while ordinary fields remain +absent in the empty case. The ordinary optional-fields type tests also establish +required selected properties whose value can be undefined. This is bounded O14 +core-source coverage; adapter handler-result generation remains pending. + +**Model and grammar.** Twenty-four finite row descriptions cross three key +domains, two ordinary scalar types, nested optionality and discriminated unions. +Each receives the five projection/eight composition descriptions shared with the +runtime owner. Seven child shapes cross the legal facade/toArray/materialize/findOne forms: +66 query witnesses per description, plus query/index nested refs and +virtual-field removal. Array/Date facade projections are illegal and excluded; +their inline forms remain. Inputs import the public source entry. Every generated body also executes +public query/index construction before compilation, with reverse-order +failure-preserving cleanup. The generated index explicitly chooses BTreeIndex. +Two admission controls preserve the illegal opaque-facade and missing-index-type +fixtures as rejected grammar neighbors. Type erasure prints import-free source +before rebinding; an earlier emit-only removal produced db_1 setup errors and +supplies no product evidence. A recursive type +signature preserves per-member keys, values, optionality and readonly modifiers; +Date built-ins stay opaque while their extra properties are checked. Arrays +retain element shape, mutability and extra fields. Bidirectional signature +comparison permits duplicate equivalent union members, which strict generic +Equal incorrectly rejected in the first draft. Missing whole rows use a +complete-or-absent alternative rather than Partial, which would admit partial +source rows. Expected types come from declarations and authoring shape. + +Concrete helper examples moved from virtual-row-fields-oracle.test-d.ts to the +ordinary virtual-row-fields-reference-types.test-d.ts. They remain supplemental. +The normal package oracle script selects the generated owner. Every compiler +diagnostic, including dependencies, is retained with its file/span/code. + +**Baseline and receiving evidence.** On unchanged production, all 24 generated +descriptions fail only Observation_nullable_whole_facade: inferred ordinary +fields are falsely required because the empty alternative disappears. This is a +new production type defect, deliberately unrepaired. Every one of 69 declared +type-comparison states and construction-admitted reaches in all 24 histories; this counts comparison +reach, not successful histories. The runtime owner adds two legal correlated +histories, initially matched/unmatched, each changing match presence twice. +Complete source rows retain source metadata; empty facade rows receive output +metadata, while inline values stay empty. The runtime owner passes 1,604 tests, including three nullable-boundary checker +controls. +Default empty-row key encoding is only checked against the facade's own public +keys, so no independent encoding claim is made. Generated imports judge public +source types, not published declaration resolution. + +The first generator used invalid root/union API calls. The first runtime draft +omitted required include correlation, and another draft omitted explicit index +type. Those runs failed setup and supply no product RED. After fixture repair, +the source compiler reaches only the intended law mismatch, and the runtime +receiving comparisons pass. Logs: chunk6-independent-types-review-v3.log, +chunk6-independent-runtime-review.log and chunk6-runtime-final.log. Final DB TypeScript and lint results appear with the construction evidence +below. Final-delta runtime review passes 1,604 tests; the second reviewer +independently passed all 24 public construction bodies before the permanent +construction driver was added. + +**Calibration and review.** Seven controls reject enriched inline values, lost +nullability, one mutable metadata union branch, nested any, Date metadata any, +optional nested metadata and nested readonly changes. Each starts with an +accepted generated neighbor. Its hostile compilation must contain only TS2344 +Assert failures at generated Observation/Removal aliases; syntax/setup errors +cannot count. metadata_types_review found the per-union-member readonly, +Date-any and nested-shape survivors; the revised checker rejects each at the +intended type comparison. Its independent complete run has 24 intended RED and +seven calibration passes, with every type state reached 24 times. A second receiver review found the illegal opaque facade and optionless index +inputs. Both are corrected and retained as actual admission calibrations. Its +independent two-owner run has 24 intended RED and 1,611 passes. The permanent construction driver then passed independent re-review: 24 intended +type failures and nine controls pass; all 70 states reach 24 histories, including +construction admission. Log: chunk6-construction-independent-final.log. Main +agrees (chunk6-construction-r3.log). Final package types and lint pass: +chunk6-construction-types-r2.log and chunk6-construction-lint.log. + +**ORC accounting.** 001–003: public/architecture authority and visible textual +models, source grammar, compiler and comparison. 004: key domains, optional +nested values, discriminated unions and every meaningful projection/composition +cell; invalid boolean keys and nullable source Collections are excluded. +005: real compiler public-source imports and actual runtime facade transitions. +006: seven intended diagnostic controls, three nullable-runtime controls, +two construction admission controls and rejected actual checker survivors. +007: exhaustive finite domains, no random campaign obligation. 008: stateless +type judgment, so state minimization does not apply. 009: authoring descriptions +map directly to user syntax; signatures are model-only type observations. +010: compiler source is memory-only, all diagnostics retained; runtime resources +use failure-preserving cleanup. 011: runtime nullable receiving witness supplies +the distinguishing non-type observation for the lost-empty-row hypothesis. +012: this record at unchanged 858b174a65f160d13d01f0324c7481cc8e4eba3a. +013: matched/unmatched, projected/whole, nullable/required and per-member modifier +neighbors reject nearby wrong laws. 014: core-source type and controlled runtime +scope; built/npm/provider receiving claims remain open. 015: 69 type cells plus construction-admitted at +24 histories plus two runtime match states reached by both initial conditions. +016: generated public consumers use one independent law; retained concrete +assertions are ordinary supplemental files. + +**Limits.** Built declarations/npm, adapter handler admission, opaque facade +forms (rejected by admission), nullable QueryRef-facade runtime paths and provider/framework +scheduling remain separate work. Known production type RED prevents a closure +claim. No production, generated documentation, manifest dependency or lockfile +repair was made; the package change only selects the test owner. + +Chunk 6 reviewed test-source SHA-256 fingerprints (before later chunks): + +- `packages/db/tests/query/virtual-row-fields-types-oracle.test.ts`: `65508306911b9c1082181cad2b7467a858a86823bc33228665f2affe5f1edfe4` +- `packages/db/tests/query/virtual-row-fields-grammar-oracle.ts`: `e9587864267256821b898d671c982dc81b3403e8fdb0ef7f20b6d96f0fb1c23b` +- `packages/db/tests/query/virtual-row-fields-oracle.test.ts`: `a2e1e963a9e365cc0ff6cfaa1223c1aacd3e1fc62aa9f5b7aea9c20dc3eaa2c8` + +## Chunk 7 — mutation outcome versus explicit confirmation + +**Law and scope.** Handler Promise outcomes and source confirmation are independent. +Discarded values affect neither. Query handler completion starts no fetch. +Authority: accepted first-RC maintainer decisions and the optimistic-history +acceptance/settlement contract. The shared pure model rebuilds one edited key +and an optional peer from authored inputs. It contains no Query/SDK classifier. +This chunk does not close O17's ordered/later-application paths. + +**Grammar, path and cuts.** 78 Query +42 Electric bounded histories cross CRUD, +absent/present peer, fulfilled void/legacy results, reject-before-write and +reject-after-write. Only fulfilled handlers have a return-value axis; only +written histories request confirmation. Query uses none/refetch/direct write; +Electric uses none/explicit txid. An actual QueryClient and Electric adapter +receive held provider responses. Cuts separately observe handler entry, server +write, requested acknowledgement, accepted acknowledgement, the settlement +waiter, final settlement, and later source confirmation for unconfirmed writes. +Complete ordinary rows include an unchanged scalar and peer when present. +Exact error identity, transaction state, utility outcome and real work counters +are retained. Frozen settlement rows are rechecked after later confirmation. + +**Baseline.** Unchanged production 858b174a65f160d13d01f0324c7481cc8e4eba3a. +Full Query owner 253 passes; full Electric owner105 passes; shared13 controls +pass. Logs chunk7-query-r2.log, chunk7-electric-r2.log, chunk7-model-r2.log. +No new production RED was established by this chunk. Query/Electric package +types pass (chunk7-querytypes-r2.log, chunk7-electrictypes-r2.log); lint exits0 +after import-order correction (two existing warnings in the large Query owner). +No production change, repair GREEN or source-mutant execution is claimed. + +**Reach.** Every declared description cell reaches once. Query cuts: entry78, +server-written72, requested-refetch24, accepted-confirmation48, settlement78, +waiter78 and late-confirmation24. Electric: entry42, server-written36, +requested-txid18, accepted-txid18, settlement42, waiter42 and late-confirmation18. +Each local history asserts its applicable cut reach. + +**Calibration/review.** Thirteen shared controls reject extra Query or SDK work, +omitted peers, partial server replacement, changed success/failure verdicts, +replaced error identity, lost accepted data, leaked optimistic rows, premature +waiter state, duplicated writes and early acknowledgement. Controls first pass +a legal neighbor through the same comparison. Query reviewer caught lost empty +Collection margins and unconditional afterAll reach that broke unrelated replay. +Peer absence restores the margins, and nested suites isolate reach. Electric +reviewer caught a constant work recorder and immediate stream delivery hiding +early acknowledgement. Actual SDK counters and the requested-wait cut fix both. +Independent final review: Query78 +controls13; Electric42 +controls13. +Logs chunk7-query-r3-independent-review.log, chunk7-electric-independent-r2.log, +chunk7-shared-controls-independent-review.log, chunk7-calibration-independent-r2.log. +Unrelated filtered tests pass in both packages. Reviewers named two next witnesses: +post-handler external refetch application (leaked active-handler count) and +rejecting ordered handlers. Those are required next-subchunk work, not waived. + +**ORC accounting.**001–003: accepted contract, independent row recomputation, +visible grammar/driver/check.004: every meaningful finite cell, empty/peer margins, +CRUD and outcome ablations; returned values after throws and prewrite confirmation +are excluded.005: real public Collection mutations, utility calls and waiter cuts; +server facts are supplied before each claimed observation.006:13 intended +wrong-observation controls.007: exhaustive finite grammar, no random obligation. +008: model retains only edited-key membership/version and outcome distinctions +that change these public cuts.009: server-written combines backend write/response; +source acceptance and transaction settlement remain separate.010: all gates +release; verdict/Collection/client cleanup each receive an attempt and primary +errors survive.011: no new shared-fault hypothesis needing a second reference; +independent reviewers reconstructed post-write rejection from the acceptance law. +012:this exact-head record.013:no-confirmation versus explicit confirmation, +rejection before/after write and requested/accepted neighbors.014:controlled +ShapeStream boundary only, no live-provider claim.015:per-description and local +cut reach above.016:shared model/check and generated receivers; calibration-only +owner contains no standalone product examples. + +## Chunk 8 — ordered mutation outcomes and refetch after handler exit + +**Law.** Handler failure and accepted source confirmation remain independent +through the ordered Query receiver. After every handler exits, a later public +refetch again waits for its application obligation. Authority: optimistic +settlement, Query explicit-refetch documentation and the wrapping-sync receipt +contract. A wrapping sync may publish rows before its durable receipt settles; +pending receipt and old public rows are not synonyms. + +**Owners/model.** `optimistic-ordered-refetch-oracle.test.ts` recomputes the +window by filtering/sorting/slicing the independent backend ledger. The existing +Query ownership call-obligation ledger governs post-handler settlement, composed +with `checkPostHandlerRefetch`'s whole-snapshot and waiter checks. Neither model +imports Query's handler count, result classifiers or persistence queue. + +**Grammar/path.** Ordered: CRUD ×fulfilled void/legacy or rejection before a +write/after explicit confirmation ×optional invalidation while held =24 histories. +They observe immediate optimistic windows and callbacks, bounded refill, held +invalidation/report, exact handler failure/state and the final ordered fixed +point. The original two update/fulfilled-void traces retain invalidation head5. +Insert uses head6 to avoid colliding with its local key5. Source/index/live-query +acquisitions register cleanup before subsequent setup can fail. Three controls +reject lost committed data, legacy-value failure and reversed order. + +Post-handler: six CRUD ×fulfilled/rejected histories use a real persisted Query +Collection. After its handler settles, a later refetch's actual durable commit +is held, with no optimistic transaction remaining. A task boundary lets the +utility process completed Query fetch work while the receipt remains held. +The utility must remain pending; either whole old/new snapshot is allowed then. +After release its own waiter must read the complete new rows. Three controls +reject premature receipt fulfillment, partial rows and stale waiter rows. + +**Calibration failures repaired in the oracle.** The first draft required old +rows during the durable hold; source rows already publish there, so those six +failures were a wrong observation/model cut, not a production defect. The next +draft read pending at Query's early flag change. A reviewer removed only the +handler-count decrement with a Vite in-memory transform; production files were +never edited. It ran the faulty wrapper but all9 tests survived, exposing the +timing gap. Keeping the receipt held through a task boundary then rejected all +six histories at the intended pending-versus-resolved comparison; three controls +still pass. Logs chunk8-leaked-count-mutant-independent-review.log, +chunk8-leaked-count-instrumented-independent-review.log and +chunk8-leaked-count-task-turn-independent-review.log. Permanent-source rerun and +final reviewer verdicts follow below. + +**Final chunk8 evidence.** Query full ownership262 +ordered27 =289 passes +(chunk8-runtime-final.log). Package types and lint exit0; lint retains two +existing warnings in the large ownership file. Reviewer mutation_query_review +passes the permanent nine-test post-handler scope and confirms the in-memory +counter mutant fails all six histories at the intended comparison, with all +three controls passing (chunk8-post-handler-r3-independent-review.log, +chunk8-leaked-count-r3-independent-review.log). All12 declared cut/description +states reach once. Reviewed owner fingerprint: +`d9699cf0554bcc58bc9966be51d0b5f8c622b9f6f14e61344fce05420535029b`. +Reviewer mutation_ordered_review passes27 tests and the control-only selector +passes3 with24 skipped. Every ordered description state reaches once; local +cuts:optimistic24, fixed-point24, bounded-refill16, held-report12 and +handler-confirmation18. Logs chunk8-ordered-resumed-final-review.log and +chunk8-ordered-selector-resumed-review.log. Ordered fingerprint: +`462f432f7b20797b8326dc50ae9feb9db177354c802c40d3aad53a46e65a493d`. +No further supported oracle defect remains within either bounded scope. + +**ORC accounting.**001–003: contract authority and independent ledger/window +models beside the driver/comparison.004: CRUD, before/after rejection and +invalidation axes; old update witnesses retained; never returning after a +throw and duplicate insert keys are excluded.005: actual Query/Collection/ +persisted adapter paths, controlled backend/receipt premises supplied before +the cuts.006: six wrong-observation controls plus intended in-memory counter +mutant rejection; the initial survivor and false row-hiding draft are classified +above.007: finite exhaustive descriptions, no random campaign requirement. +008: promise outcome/obligation state retained separately from publication; +ordered expected results are stateless recomputation.009: the call ledger's +application obligation includes the wrapping sync's durable receipt; an +ordered final cut is its eventual fixed point, not the mutation waiter.010: +release gates, attempt each cleanup, preserve original failure.011:no unresolved +shared semantic-fault hypothesis requiring a second reference.012:this record +at unchanged858b174a65f160d13d01f0324c7481cc8e4eba3a.013:prewrite rejection +versus accepted postwrite rejection, and pending receipt versus settled receipt +distinguish neighboring wrong rules.014:controlled provider/storage boundaries; +no live/native provider claim.015:declared reach above.016:all regressions use +the shared models; controls challenge the same checks. + +**Remaining bounds.** Fixed two-row numeric ordered window, one handler at a +time, no ordered persistence wrapper, no entire settlement callback trace, +no late application rejection, and no real SQLite/provider receiving claim. +The Query delay cut and ordered receiver are distinct paths; their combined +arbitrary network scheduling is not established. Types remain next chunk. +Production repairs remain deferred. + +## Chunk 9: generated public mutation-handler types + +**Law and authority.** The approved Query/Electric migration requires +Promise handlers. Fulfilled void/undefined, rejected async work and a +synchronously throwing never-returning handler are admitted. Fulfilled values +and synchronous void are rejected. This law is independent of adapter types. + +**Grammar and receiving paths.** The new DB owner +`mutation-handler-types-oracle.test.ts` generates36 consumers: both adapters, +three key domains, two scalar domains, schema absent/present and, for Query, +rows/envelope select. Each consumer crosses Config annotation, explicit and +inferred factories, CRUD and13 result descriptions:4,212 cells. All702 named +adapter/schema/data/surface/CRUD/result states reach six histories. Positive +neighbors prove each actual public source overload accepts the surrounding +consumer. Invalid imports or dependency diagnostics cannot supply a rejection. +Packed declarations and npm resolution remain O22. + +**Review repairs and calibration.** Two independent oracle reviewers found +missing schema/select receivers and an overly permissive diagnostic classifier. +An in-memory schema-only widening survived the original1,404 cells. The expanded +grammar supplies those paths. Diagnostics now owe a Promise incompatibility +anchored on the CRUD property. Distinct auxiliary overload errors are allowed; +exact duplicates and every diagnostic on an admitted candidate are rejected. +The six permanent controls remove a rejection, duplicate one, substitute a setup +code, append a positive-handler rejection, substitute unrelated same-code text, +or move the actual incompatibility into the callback body. + +The compiler reviewer independently passes42 tests and scans all36 descriptions: +2,592 primary diagnostics, all anchored to the intended CRUD identifiers. Its +actual-compiler substitution probe replaces63 forbidden-result diagnostics per +adapter with body-local Promise assignment errors while preserving the +nine synchronous-void primary diagnostics. Every local error satisfies the old +file/line/code/declaration/cause guards; the new handler-anchor membership check +rejects them. Both the prior same-code substitution and global Promise +widening are rejected. Broad Promise attempts were setup failures and +are not kill evidence. Warm compiler A→B→A equals cold B, including restored +handler rejections. No source production mutation was made; probes use overlays. + +**Results.** Main42 passes in chunk9-handler-types-final.log; compiler reviewer42 +passes in chunk9-handler-compiler-final-baseline.log. Additional logs: +chunk9-handler-anchor-calibration.log, chunk9-all-diagnostic-anchors.log and +chunk9-compiler-probes-substitution-r2.log. DB types and lint pass (r4 logs). +The package oracle selector includes the owner. This chunk reports no new +production defect. Product repairs remain deferred. + +**ORC accounting.**001–003: authority, independent output/transport rule and +literate visible responsibilities.004: schema/select/surface axes reconstruct +public overloads; key/scalar domains keep input shapes legal; synchronous void +and fulfilled value exclusions are explicit.005: actual source entry imports +and retained compiler diagnostics.006: controls and executed mutants above. +007: bounded exhaustive grammar, random/replay campaigns inapplicable.008: +stateless admission law.009: result descriptions are generated input facts. +010: no temporary files or acquired runtime resources; dependency diagnostics +remain visible.011:no unresolved shared semantic-fault hypothesis requiring a +second model.012:this versioned record at unchanged858b174a6.013:fulfilled +undefined versus object, synchronous throw versus synchronous void, and schema +versus absent-schema distinguish adjacent rules.014:actual TypeScript compiler; +no packed-artifact handoff claim.015:702 states each6.016:every consumer and +control uses the same model/check, with no isolated concrete type assertions. + +**Limits.** Finite generated row/result descriptions and the installed compiler +version. Schema transformations, arbitrary provider utility types, packed +ESM/CJS declarations, npm peer resolution and application migration remain +separate receiving evidence. Reviewer final receiver-specific mutant evidence +is appended below when complete. + +**Final receiving review.** handler_types_review independently passes42 tests; +all702 states reach6. A Query schema+select overload-local widening passes the +plain rows, plain envelope and schema rows consumers, then fails the schema +envelope candidate-membership comparison. Electric's overload-local widening +instead causes dependency TS2352: setup failure, no kill evidence. The global +Electric widening and permanent controls retain their narrower evidence. +An add-only diagnostic on an admitted handler fails independently with every +negative diagnostic preserved. Logs:chunk9-handler-types-independent-review-final.log, +handler-types-query-schema-select-local-mutant-r2.log, +handler-types-electric-schema-local-mutant-r2.log and +handler-types-final-positive-auxiliary.log. Final owner fingerprint: +`59afc1991c1ffd2362922ca3e8a1ce7ee307ca3b7bc40cb05e9ffd3bad699ca3`. +Both reviewers find no remaining supported defect in the bounded source scope. + +## Chunk 10: external completion and Offline restoration + +**Law and authority.** Core's transaction admission permits external completion +only while pending. A failed snapshot stays failed even when independently +admitted Offline outbox work later succeeds. The README's per-ID settlement +contract requires that outbox work to keep its own provider outcome and deletion +obligation. Core rollback cancels directly overlapping pending snapshots; it +does not cancel their durable provider work. Completion releases the snapshot's +pending-write contribution before its settlement observer runs. + +**Core grammar and evidence.** The outcomes owner now runs264 finite completion +histories: four admission states, five absent/pending/persisting same/other-key +peer kinds, four source shapes (none, target, unowned key, truncate), plus a +peer-key source for distinct-key peers, each with1–3 completion attempts. The +former12 histories remain prefixes. The primary model compares rows, metadata, +publication cuts, immutable requests, outcomes and states after every action; +tails settle independent peers and apply later source rows. Each history reaches +both comparison-completed cuts once. Full owner287 passes. + +Review found that external completion omitted the primary driver's waiter-time +row comparison. A deferred-touch in-memory mutant survived all263 tests in the +first draft. Adding the same waiter snapshot comparison used for normal +settlement leaves unchanged production287 GREEN and rejects the mutant with39 +intended assertion failures, including six new peer-key witnesses. Removing the +pending-only admission guard fails242 tests. Failures retain the primary +mismatch and distinct cleanup diagnostics through the established helper. +Reviewer handler_compiler_review finds no remaining supported core defect. +Logs:chunk10-core-final-independent-baseline.log, +chunk10-core-final-deferred-touch.log, chunk10-completion-guard-removal.log. +Fingerprints:primary model/driver +`e3df9fb84cbe0ca4212d8f314ddc34f9d7e9d20879db2651c056c56765028892`; +outcomes owner +`ee937b8c3191d3c827be4420e42a157d28a068a9fc2a6a6e82df940f9d33b523`. + +**Real Offline receiver.** The settlement owner expands the old four restores +to60 histories:2/3 transactions, every success/failure mask, shared/disjoint +width1/2 and partial-overlap chains of width2. Alternating authored field edits +preserve the initial/value-edit/flag-edit snapshot law: a later flag-only +snapshot carries the earlier authored value. A stateless prefix fold predicts +provider-applied rows, surviving optimistic snapshots, pending keys, core +restoration states, per-ID outcomes, complete calls and remaining outbox IDs. +The actual executor receives serialized mutations from the real outbox encoder. +Raw restore registration IDs are checked before building a lookup, preserving +duplicate evidence. Every provider tail is held. Actual waiter callbacks eagerly +copy ordinary rows and pending flags; later reads cannot move that cut. + +Intentional provider failures may warn with their exact authored Error. Another +exception cannot substitute for that allowed reason. The first draft captured +console.error only and falsely passed while console.warn reported cleanup +exceptions. The corrected recorder/check catches14 real failures: a successful +replay calls completeWithoutCommit on a snapshot a sibling already rolled back. +The per-ID waiter has resolved, and all preceding ordinary rows, pending flags, +server rows, states, calls and outbox assertions agree; cleanup then throws +TransactionNotPendingCommitError. Shared widths1/2 and chain width2 fail for FT, +FTF,TFT,FTT; shared widths1/2 also fail for FFT. Chain FFT passes, distinguishing +direct overlap cancellation from an unsupported transitive rule. All production +repairs are deferred by the maintainer's instruction. + +**Results and reach.** Final whole settlement owner:14 intended failures, +73 passes (87 total, including seven new checker controls). Scoped receiver: +14 failures,53passes,20 skipped. Of220 declared registration/settlement cuts, +214 reach once; six final cuts for shared/chain FTF/FTT are blocked by earlier +RED comparisons. Those assertions stay present; no later-cut coverage is claimed. +Core and Offline package types pass after correcting one test-only index-signature +mismatch; lint passes with the existing dependency override mismatch warning. +Logs:chunk10-offline-full.log,chunk10-restored-r4.log, +chunk10-db-types-r2.log,chunk10-offline-types-r2.log,chunk10-lint-final.log. + +**Calibration and review.** Seven controls reject extra errors/warnings, leaked +pending flags, stale waiter flags, resurrected failed state, early peer success +and a missing row. Reviewer handler_types_review independently reproduces the +same14 RED failures and53 scoped passes. Removing only restoration touch fails +pending metadata on otherwise unchanged rows, including an all-success shared +history. Deferring that touch lets later reads recover but fails recorded +waiter metadata, including an all-success three-transaction history. Each mutant +campaign gives44failures/23passes; the all-success witnesses distinguish those +mutant failures from the14 baseline product failures. An initial overlay config +attempt failed setup and supplies no kill evidence. Logs: +chunk10-restored-no-touch-mutant-r2.log, +chunk10-restored-deferred-touch-mutant.log. Offline owner fingerprint: +`83eb5551300ebb702e119174a2352c985ad6086cefa18042cc5c947c3c89e91a`. +No further supported oracle defect remains in this bounded receiving scope. + +**ORC accounting.**001–003: transaction/README authority, independent prefix +and primary row models, literate layers.004: old prefixes retained, repeated +admission/peer/source/mask/width/overlap axes distinguish adjacent legal cases; +duplicate outbox IDs, nested rollback cascades and completed source ownership +are excluded by the stated grammar.005: real core calls and real Offline +restore/provider/outbox paths, eager waiter/public metadata observations. +006: controls, intended comparison kills and earlier survivors classified above. +007: finite exhaustive new grammars; existing fixed/random settlement campaigns +remain unchanged.008: failed core snapshot versus successful outbox work and +pending versus persisting peers remain distinct.009: snapshot/provider/outbox +terms stay separate.010: all gates/releases attempted, seed primary preserved, +console spies restored before resource cleanup so secondary failures remain +visible.011: chain histories challenge transitive cancellation; no remaining +shared-fault hypothesis needs another model.012:this record on858b174a6. +013: failed versus pending admission, direct versus chain overlap and waiter +versus later read distinguish wrong rules.014: actual executor receives the +controlled storage/provider premises; native/provider durability remains open. +015:core264×two completed cuts; Offline214/220 as bounded above.016:all new +histories and controls share models/checkers. + +**Limits.** Core completion+touch path, one Collection,2/3 restored transactions, +width1/2, unmarked whole-row insert/update records, controlled in-memory storage +and provider, Node/jsdom observations. Deletes, retries, terminal phases, +multicollection work, real native storage and multi-owner schedules remain +separate owners. The14 reachable product counterexamples keep this class open. + +## Chunk 11: rejected renders and independent descriptor startup + +Source remains `858b174a65f160d13d01f0324c7481cc8e4eba3a`; no production +repair, generated-document edit, commit, dependency install or lockfile change. +This chunk strengthens O19 and O15 at native React/Svelte boundaries. + +**Law and authority.** A rejected preparation or uncommitted render cannot keep +an independently read descriptor Collection from starting and serving its row. +Releasing one descriptor's deferral cannot stop at a sibling's startup failure. +Each source owns its eager/lazy start policy; the preparation rejection remains +the caller's rejection. Healthy returned adapter resources clean up once. +Authority is the existing source-ID/descriptor cleanup contract, the SSR guide's +client-owned descriptor lifetimes, and the glossary's crash/cleanup laws. + +**Grammar and reach.** The React source-ID owner now runs 344 release histories: +applicable causes × derived/explicit-key ordinary/infinite/Suspense paths × +initial mounts/rerenders × hook/preload/start/direct-subscription readers. +Source-ID collision needs a prior claim, and explicit keys intentionally bypass +source-ID/derived-IR rejection. Each history reaches `prepared`, `reader-ready` +and `source-update` (344 each, 1,032 cuts). Preparation reach checks descriptor +materialization, intended rejection class or exact authored error identity, and +execution after the hook for authored throws/suspensions. Four checker controls +reject missing/extra start, wrong value and duplicate rows. + +The shared `descriptor-start-release-oracle.ts` judgment has 85 finite histories: +zero through three acquired sources, every failed-start mask, and independently +every eager-start mask, followed by a throwing factory and an unvisited tail. +React's three hooks receive 255 histories; Svelte's native effect root receives 85. Each reaches rejection, independent demand and cleanup (255/85 of each, +1,020 cuts). Checks compare successful factory counts before any test demand, +exact preparation failure identity, starts, complete ordinary rows/status and +healthy-resource cleanup counts. References are retained across client cleanup; +reacquiring a Collection would exercise restart instead of the cleanup cut. + +Reconstruction includes the old two-throwing-starts-before-healthy witness. +Deleting prefix length loses before/after acquisition cases. Deleting the failure +mask loses the first-exception blocking distinction. Deleting independent policy +masks loses mixed eager/lazy neighbors. Zero acquisition is the boundary control; +a mask naming a source outside its acquired prefix is invalid. The bounded +enumeration is direct replay through its parameterized test name, not a random +campaign. A removed dependency-array SSR cause required `usesDeps=true`; every +current hook supplied false. The removed API therefore contributed no executed +history, and no current public behavior was retired. + +**Review repairs and calibration.** Independent reviewers +`handler_compiler_review` and `handler_types_review` found: + +1. A direct-subscription reader used an empty callback and read Collection + storage. A memory mutant dropping update deliveries reached 122 drops and + passed all 86 subscription histories. Recording a callback-derived replica + makes the same mutant fail all 86 at the updated-row comparison. +2. Boundary text alone accepted unrelated render errors. Cause-specific class or + authored identity checks and after-hook execution markers now validate the + intended path before reach. Replacing the preparation rejection with a later + source error fails 255 React histories at the exact-error comparison; four + controls pass. +3. Uniform source policies could not distinguish applying one source's policy to + all siblings. Independent per-source eager masks expanded 30 to 85 histories. + +A memory overlay that stops resuming after the first startup exception fails +12 React histories and 32 Svelte histories at rejection: later starts stay zero, +status stays idle, and healthy rows are missing. Other cells can legitimately +recover during React retries; those cells are not credited as kills. No mutant +modified production files. Removing only the outer Suspense wrapper's catch +survives and its injected marker is unreached: current failures occur in the +shared ordinary-hook preparation first. This is an explicit limit, and the +coverage map's stale blanket claim that every hook's own boundary was killed is +corrected. + +**Results.** Independent final runs pass 638 React tests (370 complete source-ID +owner, 259 startup owner including four controls, nine ordinary tests) and 85 +Svelte histories. Native partial-construction fault injection in the ordinary +React file throws after acquiring a mount but before returning its handle. Root +inventory cleanup releases its subscriber, a later cleanup still runs, and the +primary/secondary Error identities survive in the AggregateError. +React/Svelte typechecks, changed-file lint and diff whitespace checks pass. +The existing node_modules/lockfile override mismatch is preserved. + +Initial draft failures were setup mistakes, not production RED evidence: +passing a callback instead of an array to cleanup; retaining a constant explicit +key while changing the authored query; re-acquiring Collections after cleanup; +and not catching an intentionally failing lazy start. Two scratch overlays +resolved built output and never applied; they supply no kill evidence. A +review run spanning the policy-field edit had transient calibration errors and +is excluded. Final logs supersede these runs. + +**ORC outcome and limits.** ORC-001–006, 008–010, 013, 015–016 have the law, +independent source-fact judgment, grammar controls, public native paths, +calibrations, reach and failure-preserving cleanup above. ORC-007 is inapplicable +to the finite enumeration. No additional concrete shared-fault hypothesis +requires a second formulation under ORC-011. ORC-014 is bounded to native +React/Svelte with controlled synchronous adapters, not arbitrary provider +behavior. This versioned entry supplies ORC-012. Sources throw Error values; +arbitrary thrown values, provider cleanup before returning a resource handle, +async startup failures, and arbitrary render schedules remain outside scope. +Abandoned-render histories establish eventual release on the encountered +schedules without separately measuring both reader-versus-reclamation orders. +The outer Suspense catch remains unreached. No product repair/closure is claimed. + +Evidence directory: `/private/tmp/rc2171-oracle-challenge/`. +Final baseline: `chunk11-final-react-independent-review.log`, +`chunk11-final-svelte-independent-review.log`, `chunk11-release-r4.log`. +Mutants: `chunk11-drop-subscription-updates-repaired.log`, +`chunk11-stop-first-release-final-mutant.log`, +`chunk11-stop-first-svelte-final-mutant.log`, +`chunk11-replaced-rejection-final-mutant.log`. +Types/lint: `chunk11-react-types-final.log`, `chunk11-svelte-types-final.log`, +`chunk11-lint-final.log`. +Fingerprints (SHA256): source-ID owner +`af236fff80f7d21be7d458b0be70416ec48d08f8fb26c3f762f2f3e91a9d28a0`; +shared startup model +`c6c9bd6ab72ae057062da04f4d81be953b4387cb70950ee8add5b9c37da13add`. + +## Chunk 12: first registration without a transported query result + +Reviewed unchanged production HEAD: `858b174a65f160d13d01f0324c7481cc8e4eba3a`. + +**Law and paths.** The approved local-declaration admission rule also applies +when no query result has been preloaded or transported. First registration must +retain the caller's getKey/schema claim before another same-identity caller can +read or attach. Conflicts depend on supplied declarations, so a held source +cannot defer their rejection until delivery. Compatible callers retain the +original keys and ordinary values. The SSR guide's initial server capture stays +initial until observer attachment and its microtask handoff; it is not a live +server view. The shared reference judgment remains independent of cache/hash +machinery. + +**Grammar and observations.** Core now crosses 144 declaration histories with +three first claimants (explicit preload, streaming observer preload and server +read), five receiving paths and held/settled source delivery: 4,320 histories. +Each of the 15 routes reaches 288 first registrations and 144 held deliveries. +Public dehydration proves no prior query result and positive first registration. +The server-first driver preloads only its source. Two admission attempts, +compatible original reuse, callback-derived rows, exact keys, source updates and +subscriber ownership remain checked. Removing claimant variation loses the +observer registration path; removing held delivery loses conflict rejection +before readiness. The finite domain includes absent/added/removed declarations +and independent references; schema transformations remain excluded. + +The new React `cache-first-registration-oracle.test.tsx` uses actual +`renderToPipeableStream` for both ordinary and Suspense readers. Its 1,152 +histories cross the same declarations with first reader, second preload/server +caller and held/ready delivery. Streaming is enabled without Router transport +or hydration seeds. Every raw render error is validated, including later errors +after an expected conflict. Compatible reuse continues through a core observer +and the next source update. The file supplies no client DOM receiving claim. +Aborted renders retain the exact authored abort diagnostic and all render +completion promises settle during cleanup. + +**Instrument repairs.** Independent oracle-review agents +`handler_compiler_review` and `handler_types_review` challenged the driver: + +- Initial source writes shared the flat expected row objects. A mutant corrupted + initial labels and left later updates distinct; both core and React retained + their baseline outcomes, demonstrating a false green. All five cache drivers + now write copies. The same mutant produces 4,320 core history failures (66 + controls pass) and 1,152 new React failures. Previously passing compatible + cells fail the authored ordinary-row comparison. Existing admission failures + are not credited again as mutant kills. +- A wrongly accepted observer preload could remain pending while delivery was + held, preventing cleanup. The shared checker now records promise outcome at + one host-task cut before delivery. Pending and fulfilled outcomes fail there; + the original rejection judgment still rejects unrelated errors. Four checker + controls pass. An unconditional observer-options omission now finishes with + assertion failures and releases the provider, rather than timing out. +- Focused checker selection previously ran zero histories but asserted all route + reach. Execution is now recorded before each fixture starts; every executed + route retains all declared-state requirements. Four selected controls and one + selected complete route (288 histories) pass separately. The parent inspected + this selector repair independently. + +**Unchanged-production results.** Core: 640 intended admission failures and +3,750 passes (4,390 tests). New React first-registration receiver: 192 intended +failures and 960 passes. Existing core lifecycle: 640 intended failures and +1,526 passes. Existing React cache receiver: 64 intended failures and 224 passes. +Svelte cache receiver: 144 passes. These are product RED evidence, not repaired +GREEN. Baseline controls and neighboring legal histories supply bounded GREEN. +DB/React TypeScript and the changed-file lint implementation pass. The pnpm +wrapper in one review attempted automatic install and stopped before installation; +the exact lint script was then run directly. The existing override mismatch is +preserved; no dependency or lockfile change was made. + +**Calibration.** Omitting observer registration's row options adds 448 core +assertion failures (1,088 fail / 3,302 pass), including 64 held observer-preload +cells now rejected at the pre-delivery outcome cut. No timeout or suite error +remains. React's unconditional omission adds 128 failures (320 fail / 832 pass), +through held Suspense registration. A ready-only omission legitimately survives +React because ready server capture establishes the claim first; it adds 192 +core failures. Source mutation uses memory overlays only. Initial filtered probes +with aggregate reach errors, a pnpm setup abort, unsupported mixed renderer +warnings in an abandoned draft and a pre-repair hanging omission supply no +intended-comparison evidence. Final runs supersede them. + +**Review scope.** ORC-001–006, 009–010 and 013–016 are supported by the authority, +independent declaration judgment, finite grammar, real paths, public cuts, +calibrations, cleanup and per-route reach above. ORC-007 is inapplicable to this +exhaustive finite enumeration. ORC-008 introduces no new stateful reference +transition. No concrete shared semantic fault requires a second formulation +under ORC-011. This append-only head-specific record supplies ORC-012. Both +reviewers found no remaining supported blocker in the stated boundary. Schema +transformations, arbitrary transport/concurrent schedules, Router delivery, +hydrateRoot and first-registration receivers in other frameworks remain open +limits. No production repair or product-class closure is claimed. + +Evidence directory: `/private/tmp/rc2171-oracle-challenge/`. +Baseline: `chunk12-held-admission-route-final-review.log`, +`chunk12-react-first-final-independent-review.log`, `chunk12-svelte-final.log`. +Controls: `chunk12-held-controls-route-final-review.log`, +`chunk12-selected-route-final-review.log`. +Mutants: `chunk12-omit-observer-row-options-final.log`, +`chunk12-core-initial-shared-row-mutant-before.log`, +`chunk12-core-initial-shared-row-mutant-after.log`, with equivalent React logs. +Core test SHA256: +`7ebf875a183111904b46d7905de3572287ccee5f28af9c7267f9969c9b7d60aa`. +Shared checker SHA256: +`05b42da4457279807137f408d9184e8c3bd0e57fbdc636199e465895e512d7e1`. +React first-registration driver SHA256: +`2247d32fc1e47c95136dafcf796f6c6a8dd8b93124d05b7981043156dafdd9e5`. + +## Chunk 13: release histories, actual Changesets commands and finite replay + +Production remains unchanged at `858b174a65f160d13d01f0324c7481cc8e4eba3a`. +This chunk changes tests and their lint/replay instruments only. + +**Authority and independent judgment.** CONTRIBUTING.md's Changesets/Releases +policy defines first `1.0.0-rc.1`, patch suffix increments, minor/major candidate +increments, stable semver/latest and candidate rc tags. The stable Query minor +is an explicit maintainer decision, not a rejected semver policy. The new +`scripts/release-history-oracle.js` uses numeric tuples and an authored pending/ +consumed/token ledger. It imports no Changesets planner or production version +helper. A bounded workspace:* dependency model requires a patch when its exact +published target changes; compatible peers retain their range. Arbitrary semver +range planning is outside this model. + +**Version grammar and cuts.** Seventy-two histories cross first/candidate-nine/ +patch-nine boundaries, direct versus peer stable dependencies, minor/major +precedence, insertion order and three exit forms (no pending work, stable-only, +candidate-plus-stable). Each runs first seeding, standalone stable work, mixed +work, no-op versioning, strongest pending bump, independent candidate work and +exit. Every action compares manifests, version-attributed changelog token bags, +consumed IDs, retained files, dependency protocols and private package state. +No-op actions compare all fixture bytes. The real Changesets CLI supplies pre +entry/exit. Fourteen first-seed failure histories cross seven failure points +with both dependency profiles, then compare exact restoration and successful +retry. The after-apply fault first observes generated changelog bytes, so it +cannot pass by failing before those files exist. + +Each version history enforces its own required cuts. The normal campaign also +enforces aggregate reach: first candidate, standalone stable work, mixed work, +precedence, no-op and post-exit no-op each occur 72 times; each numeric-nine +boundary and exit form occurs 24 times; stable-dependent exit occurs 36 times; +consumed-minor/pending-patch exit occurs 24 times. Each of the 14 failure/profile +states occurs once. Removing standalone stable work loses a plan with no +candidate release. Removing no-candidate exits hides synthetic exit planning. +Unknown packages, reused IDs and unsupported bump values are outside the grammar. + +**Actual command receiver.** `publish-history-oracle.property.test.js` invokes +production `publishDuringRc` with its default command. A strict local pnpm shim +forwards to the installed real Changesets CLI; registry info/publish shims supply +only metadata and retain every raw attempt, selected tag, version, access and +success. They select no tag. Forty histories cross four numeric versions, +stable/only-RC/never/current histories and publish failure in either phase. +Already-current versions cannot have a publish failure; those cells are excluded. +Partial successful writes persist through retry. Each attempt must restore +manifest/pre-state bytes. Every public fixture package must have an info request; +success bags preserve duplicates and omissions while allowing independent order. +All 40 histories reach registry reads/restoration; 24 reach controlled failure +and retry. Twenty-eight pass the final judgment and 12 fail there as described +below. Six permanent wrong-observation controls challenge multiplicity, omission, +tag, version, registry and access. + +**Independent review repairs.** `handler_types_review` found batched stable-only +work and candidate work on every exit. The expanded grammar rejects an omitted +synthetic exit plan that survived the former 36 tests. It also requested a real +late write fault; removing changelog backups now fails after-apply restoration. +`facade_metadata_review` challenged duplicate/foreign argument masking, inherited +Git configuration, unknown-command lock cleanup and an overly strict command +shape that disallowed a legitimate explicit tag. Strict shims now reject malformed +arguments, isolate Git/npm configuration without repurposing HOME, and permit +known explicit rc/latest forwarding. Six ordinary fixture controls cover this +boundary. The receiver supplies command evidence, not npm HTTP or installation. + +**Finite replay.** Registered properties `scripts.release-version` and +`scripts.release-seed-failure` use the standard PROPERTY/SEED/PATH interface. +PATH is one finite case index; SEED is required request identity, not a source +of random cases. Direct replay runs that case and preserves its local cuts. +The shared manifest points at the actual script runner. A completed finite +comparison emits a truthful witness without fabricated fast-check RunDetails. +Setup before comparison emits none. The wrapper preserves primary, witness and +cleanup failures, including thrown undefined. Nineteen ordinary controls pass; +41 existing shared replay checks pass. Independent matching/mismatching-channel +and real EISDIR probes, actual guarded replays and a wrong-family zero-execution +control test the new API. These probes do not close the older standalone writer +policy conflict P03, which remains recorded separately. + +**Lint instrument law.** Canonical replay exposed a false positive: recorded +`numRuns: 1` was treated as a one-run random input budget. Before changing that +test tool, the new `scripts/eslint-plugin-oracle/replay-observation-oracle.test.js` +ran an independent authored-intent source model. It knows the program's role; +it does not parse the generated source or reproduce the rule's AST classifier. +The final grammar contains 3,848 programs and two checker controls: location, +annotation, value flow, binding spelling, type-query and JSON binding axes, +plus five custom-hook initializer forms at otherwise admissible recording states. +All 1,924 declared states reach twice. The repaired allowance recognizes only +the shared writer's typed plain-data record, exclusively serialized by standard +unshadowed one-argument JSON.stringify. Actual budgets, aliases, exports, +mutations, field reads, replacers and custom toJSON/prototype hooks stay reported. +Type-only references and declarations do not invent runtime shadows; a hidden +outer runtime shadow still matters. + +Every newly exposed distinction ran RED before its tool repair: two original +false positives, eight export/type-query mismatches, 32 JSON type/value +mismatches and 200 custom-hook escapes. Final source owner plus ordinary pins +passes 3,984 tests. A broad binding-flow bypass fails 520 comparisons; removing +the plain-initializer guard fails 200. Both fail at the report comparison. +The controlled premise trusts unmodified standard built-ins; this is not whole- +program runtime dataflow analysis. Other serializers/destructuring/intermodule +flows are outside the allowance. A calibration-only selector runs two checks; +any entered source campaign still owes every declared source state. An ordinary +subprocess control retains that selection behavior (bounded 16 MiB output). + +**Results and calibration.** Version/seed: 86 pass. Publish: 34 pass (28 histories +plus six controls), 12 intended tag assertion failures. In those 12 only-RC +histories, real Changesets selects latest for both candidate packages; expected +rc remains unchanged. This is P01, deliberately unrepaired. Fixture controls: +19 pass; shared replay: 41 pass; classifier: 3,984 pass. DB TypeScript and final +changed-file oracle lint pass. Canonical hostile replays reject missing exit +planning at direct-dependent path 12, missing changelog backup after application +and missing local reach. Peer-only path 0 legitimately survives the exit mutant; +it cannot observe a stable direct-dependent bump. Each failed canonical replay +emits an exact failed:true witness. No package install, production repair, +commit, push or generated-reference change occurred. + +Initial attempts using an unavailable vi.getConfig, a nonexistent shared-replay +config, a private selector rejected by lint, old zero-campaign reach behavior, +and an undersized subprocess output buffer are setup/harness failures, not +product RED or mutant kills. Earlier classifier runs with reach marked after +the assertion are superseded. Runtime evidence is reused only where final source +fingerprints did not change during later test-tool repairs. + +**ORC outcome.** 001–006: explicit policy, independent ledger/source-intent model, +literate layers, bounded legal grammar, actual CLI/parser paths, public file/ +request/report observations and hostile calibrations above. 007 is inapplicable +to exhaustive finite enumeration; canonical direct replay is nevertheless tested. +008–009: pending/consumed work and numeric counters retain distinguishable future +actions; source intent is explicitly model-only. 010: exact rollback and +failure-preserving cleanup/witness controls. 011: no unaddressed shared-semantic +hypothesis in the declared boundary. 012: this head-specific versioned entry. +013: standalone stable work, no-pending exit, numeric 9/10 neighbors and +independent registry shapes distinguish the weaker rules. 014: actual Changesets +command/parser reception; npm HTTP, tarballs, installed consumers, credential +services and Actions remain separate. 015: local and aggregate reach above. +016: histories and calibrations share their judgment; ordinary harness examples +remain in ordinary test files. Independent reviewers found no remaining supported +blocker in this bounded chunk. No product-class closure is claimed. + +Evidence directory: `/private/tmp/rc2171-oracle-challenge/`. +Runtime: `chunk13-release-version-final-r7.log`, +`chunk13-publish-final-r7.log`, `chunk13-fixture-final-r7.log`, +`chunk13-shared-replay-r7.log`. +Classifier: `chunk13-lint-observation-independent-final-r9.log`. +Mutants: `chunk13-exit-plan-mutant-r9.log`, +`chunk13-changelog-mutant-r8.log`, `chunk13-local-reach-mutant-r8.log`; +source-classifier RED/GREEN/mutant logs have the chunk13-lint-observation prefix. +Final lint: `chunk13-lint-final-r12.log`; types: `chunk13-db-types-final.log`. +SHA256 model: `ad378df8f61783fbdba2d53609de5a62b1e5b0f8e0f15643753c55744c4baf8d`; +version runner: `79567b1d43b48d7c9d1d0125057b1056f7eb024bfe8a4456646d16f5d99b0eb3`; +publish runner: `512218cbed6cfdd007620a3fed1ae1ce34a303a72fa0cf5dc30ad66b960856b7`; +fixture: `a4c3a87dd719160866d6fd2749d55996df2f4e1e40f8fadced4025c4391fcc29`; +classifier owner: `f4e51294ebcfefc2bfdb9127dae91a5f557a75b7e1e2763635038e60fd308f4d`; +classifier rule: `3a8139f2c2fa1fd55291ce68ab3593dad754b14ad35e3bf9cbe367e676a9fa28`. + +### Work remaining after chunk 13 + +Production repair stays deferred. O13 now has version and actual Changesets +command evidence; chunk 14 is the real loopback HTTP/npm receiving witness. +Packed consumers and clean workspace/build reception remain O22. Test:release +and CI wiring for the new script owners remains part of final O26 execution; +the PR's existing test:lint-rules already discovers their property files. + +The following boundaries still need implementation/review before the campaign +can finish: + +- Transforming schema retention through actual client mutations in the shared + bound-options owner and React/Preact receivers (O05). Schemas do not transform + synced query rows. Runtime absent/trim/uppercase probes establish the correct + mutation observation. Typed live-query schema support remains an existing + separately recorded limitation. +- Effect source metadata copied to ordinary selected fields, with exact batch + and named delta value/previousValue ledgers (O06–O11 receiving limit). Effects + have no output Collection ID. Implicit reserved Effect-root attribution is + not authorized by the Collection decision. +- O12 subquery reserved-field sanitation still awaits the already-asked + maintainer decision. Do not silently select a normative answer. +- O20 proxy diagnostics and actual ListsScreen/ListCard via controlled React DOM + host primitives; native emulator/renderer tools are unavailable. The actual + component scratch probe shows Saving absent/held/settled transitions. It + supplies no native-host scheduling claim. +- O21 actual fenced mutation guidance through the shared confirmation judgment, + active API consistency and diagnostic-specific snippet baselines. The actual + error-handling POST retains its current intended stale-Collection RED. P02 + identifies another stale current instruction. +- O22 clean frozen install/build graph, public package packing and supported/ + rejected installed consumers. Inventory25 is correct. Offline lacks a local + DB project edge in pnpm's closure; the lock still contains its DB link, so + installation failure itself is unproven. The exact SQLite core source + typecheck already fails on the Promise<{}> handler (G08). +- O23 retired exports/modes/wrappers; O24 compile-invariant work placement; + O25 built diagnostics and ESM/CommonJS/public export receiving checks. Read- + only inventory finds the originally cited code already removed/hoisted. + Calibrated checks must preserve that evidence without inventing an all-adapter + coded-message promise. Preact intentionally lacks CJS; Svelte has a distinct + build layout. O26 must tie actual selected/build inputs and all remaining + affected validation to exact fingerprints, and retain P03's older writer + policy conflict rather than declaring the whole instrumentation class closed. + +## Chunk 14: actual npm/pnpm HTTP publication + +Library production remains unchanged at +`858b174a65f160d13d01f0324c7481cc8e4eba3a`. The release publisher is deliberately +unrepaired. This chunk adds a receiving driver and fixture controls. + +**Law and receiving boundary.** The accepted release policy and chunk 13's +independent `expectedPublishes` ledger still require candidates on `rc`, stable +packages on `latest`, and each unpublished version once. The new +`scripts/publish-http-oracle.property.test.js` invokes the default production +publisher through actual Changesets 2.29.8, npm 11.17.0 and pnpm 11.1.0. A child +registry binds a loopback ephemeral port because the production command blocks +its parent event loop. It supplies authored package metadata and controlled +failure responses; it never chooses the client's tag. Raw GET/PUT records retain +origin, attempted identity, access, tag, failure and duplicate multiplicity. +The server reads each actual uploaded tarball's manifest without extracting +paths and checks its name, version, byte length and digest before recording it. +Requests are persisted before the HTTP response, so response receipt establishes +that the recorder has the request. + +**Grammar and observations.** Fourteen histories select candidate +`1.0.0-rc.1.10` from the shared grammar. Stable, prior-RC, never-published and +already-current histories cross every possible failure phase. Prior-RC and +current metadata separately use an `rc` tag alone or an additional `latest` +alias. This distinction matters to actual npm's default metadata lookup. +Each failure history receives one controlled HTTP 400 and retries over retained +successful publications. Already-current versions have no injected publication +failure. Both attempts compare exact manifest/pre-state bytes before judging +raw failed attempts, fulfillment, packed identities, final publication bags +and registry state. Independent package order is permitted; duplicate or omitted +successes are not. A failed retry is recorded before comparison, so it cannot +escape as an unexamined command error. + +Final measured reach: `registry-read` and `restored` each occur in all 14 +histories; `controlled-failure`, `partial-publication` and `retry` each occur in +eight. Later `packed-upload` occurs ten times, `already-current` once and +`compared` eight times. The six intended RED histories stop at earlier violated +comparisons; they do not receive credit for unreached later states. Each passing +history enforces its own declared reach. Optional statistics report zeros on +RED histories too, while preserving the primary error if reporting fails. + +**RED and neighboring results.** The focused HTTP command is: + +```sh +TANSTACK_DB_ORACLE_STATISTICS=1 node node_modules/vitest/vitest.mjs run \ + --config scripts/oracle-scripts.vitest.config.mjs \ + scripts/publish-http-oracle.property.test.js +``` + +It has 11 passes (eight histories and three origin controls) and six assertion +failures. Three prior-RC histories with a `latest` alias fail the shared tag +comparison: candidates use `latest` (P01). Three RC-only/no-latest histories +fail the no-republication comparison: an already-current release, plus retries +after candidate failure from prior-RC and never-published starting states, +attempt to upload an existing RC and receive HTTP 409 (P05). Exact local byte +restoration is checked before those failures. No library repair GREEN is claimed. +The real receiving path expands the earlier shim's evidence; it does not make +the shim's RC-only metadata forms equivalent to actual npm lookup. + +**Fixture repairs and calibration.** The receiver discovers installed tools, +isolates npm/Git configuration, uses fixture credentials and preserves HOME. +Its disposable workspace disables pnpm's automatic dependency installation and +supplies local links only for packing. An early pnpm probe attempted automatic +installation against the fixture registry and failed before Changesets; it is +a setup failure, not product evidence or a successful install. Initial loopback +and `ps` EPERM results are also setup failures; final local-only runs use the +necessary socket/owned-child access. + +Owned process groups bound the command and include its descendants. Eight +ordinary controls check successful/timeout descendant termination, executable +startup failure, bounded output, primary and secondary termination errors, +callback Error/undefined preservation and unsupported routes. The output control +first failed because an exact 16 MiB boundary discarded later chunks and +reported timeout. An independent trace then exposed repeated group signals after +the first successful kill. Counting incoming bytes before truncation and making +termination one-shot repair these test-tool defects. Final eight controls pass; +three repeated formerly failing overflow probes also pass. The secondary-error +control first ran GREEN after a concurrent helper change; no original RED is +claimed for that subcase. The earlier misleading scratch filename +`chunk14-secondary-control-red.log` contains that GREEN result. + +The shared publish checker adds only an optional expected registry argument; +its original fixed-origin default remains. All six existing checker controls +and three new default/variable/foreign-origin controls pass. The version model +is unchanged, so the 86 version/seed histories are not rerun for this parameter. +Raw observed origins are never normalized to the expected origin. + +**Independent review and ORC outcome.** `facade_metadata_review` independently +executed the HTTP histories and fixture controls, challenged tool portability, +process ownership, response/record ordering, failure fidelity and raw duplicate +observation, then accepted the bounded instrument. ORC-001–006 have the policy, +shared independent ledger, finite grammar, actual client/server path, full raw +attempts and wrong-tag/duplicate/origin witnesses above. ORC-007 is inapplicable +to this finite enumeration. ORC-008–009 retain registry versions, tags and +partial success because retry distinguishes them. ORC-010 has the cleanup and +diagnostic controls. No further concrete shared-model fault invokes ORC-011. +This head-specific entry supplies ORC-012. ORC-013 has the RC-only/latest-alias +neighbor; ORC-014 receives metadata through actual clients; ORC-015 has local +measured reach. Under ORC-016, ordinary fixture controls remain separate. + +Limits: four fixture packages, one candidate version, the installed tool +versions, POSIX process groups and controlled HTTP responses. Tarball dependency +closure, exports, installed consumer APIs, clean workspace installation, +production authentication and GitHub Actions remain separate O22/O25/O26 work. +No commit, push, installation workaround or generated-reference edit occurred. + +Evidence directory: `/private/tmp/rc2171-oracle-challenge/`. +Final baseline/reach: `chunk14-http-baseline-final-r3.log`. +Independent HTTP: `chunk14-http-independent-final.log`. +Controls: `chunk14-http-fixture-controls-final-r2.log`, +`chunk14-http-fixture-independent-r3.log`, +`chunk14-output-bound-independent-r3.log`, +`chunk14-checker-calibrations-r1.log`. +Instrument REDs: `chunk14-fixture-process-controls-r1.log`, +`chunk14-output-bound-kill-trace.log`. +Final lint: `chunk14-lint-final-r4.log`. +SHA256 model: +`973e20a17d2faaafb2b459eab904008b85304a7722bdf92f1fb4dd86f8292759`; +HTTP owner: +`bc919160a72f3a6ce87c24f4a929ea113febec42fac78ae349df46e6e39cf10e`; +fixture: +`2390c87bacc10b4f948d58ec3724d01297bfb3f7d2587173f09eaa7f8c0d0dc7`; +registry: +`5847b68adc499871b4c6958fa204fd8dc124f6d554bc02ae388d2f739f010d95`; +runner: +`e804c8674a25f8ef18a937d79208e6e592ea0f3d78dc1f4fc908b7146c969618`; +ordinary controls: +`ac3062fa2b910c769bc3553bf7e9c72b779355c9f6f582f4a6b01c2fedd8cafe`. + +### Work remaining after chunk 14 + +The HTTP release receiver above is now reviewed. Chunk 15 begins the transforming +schema/client-mutation receiving extension described after chunk 13. Effect +metadata, the pending O12 authority decision, actual UI/doc guidance, clean +workspace and packed consumers, calibrated source/artifact checks and final +exact-input validation/CI wiring remain. All library repairs stay deferred. + +## Chunk 15: bound schemas govern client mutations (reviewed) + +Frozen production head: `858b174a65f160d13d01f0324c7481cc8e4eba3a`. +Independent reviewer: `handler_types_review`; author: `facade_lifecycle_review`. +No library production file changed. The schema guide's client-input contract +and the maintainer's identity-retention decision authorize this receiving law: +sync rows stay raw; same-identity renders retain schema/key behavior; query or +queryKey replacement applies the proposed behavior to the next mutation. + +The shared `live-query-bound-options-oracle.ts` now models all nine ordered +absent/trim/uppercase schema pairs, two identity boundaries, independently +same/different key proposals and two padded lowercase inputs. Its word/ASCII +computation is independent of the fixture's String transformations. React live, +ready-source React Suspense and Preact live each execute 72 native histories. +Held updates compare immediate Collection reads, committed hook data/state and +handler original/modified/changes, followed by raw synced updates, identity +replacement and a further mutation. Ordinary own enumerable fields remain +observable; only four virtual metadata fields are removed from this judgment. + +Independent GREEN: 216 native histories and 72 shared calibration tests pass. +The 72 calibrations reject 3,816 wrong observations and accept 648 permitted +unordered comparisons; those are checker evidence, not native histories. +All 2,592 path × history × cut reach cells execute exactly once. The twelve +cuts distinguish raw initial/source/followup input, held mutations, settlement +and replacement. DB, React and Preact typechecks and changed-file lint pass. + +Review exposed two instrument defects. Loose equality admitted extra own +undefined fields; five independent rejection probes reproduced this before +strict equality repaired it. Omitting the final next-use mutation originally +left the whole run green because repeated reaches collapsed into earlier cuts. +Distinct followup-held/settled declarations now reject that omission, after +individual assertions pass. Read-only hostile overlays that preserve configured +schema references but bypass validation fail 192 immediate native value +comparisons, while 24 absent-schema controls pass. Corrupting initial or later +production sync inputs fails every native history at the raw-row comparison. +These are intended assertion/reach failures, not setup errors or timeouts. + +ORC-001–005: authority, finite bounds, literate independent model, all native +receiving paths and value checkpoints are explicit. ORC-006: checker controls, +validator bypass, input corruption and omission attacks reject as above. +ORC-007 does not apply to this exhaustive finite grammar. ORC-008–009: binding +phase is model-only and identity replacement distinguishes retained options; +twelve observation cuts do not introduce a second production state machine. +ORC-010: cleanup preserves failures while releasing handler gates, transactions, +hosts and Collections; hostile runs exit without timeout. ORC-011: review found +no remaining concrete shared-semantic-fault hypothesis in this bounded model. +ORC-012: this frozen record supplies review evidence. ORC-013: all ordered +schema pairs, both identity boundaries and independent key proposals distinguish +wrong retention policies. ORC-014: the three real hook paths receive the same +model, without broader provider claims. ORC-015: all 2,592 cells have min=max=1; +live-only, Suspense-only and calibration-only selections each pass 72 tests, +while a partial entered-host selection fails mandatory lane reach. +ORC-016: native histories and generated calibration observations share the +model and refinement checks; no fixed expectations were added to oracle files. + +Limits: valid synchronous schemas, flat scalar ordinary rows, explicit string +keys and mutations settled before identity replacement. Ready-source Suspense +does not establish initial suspended retry. There is no SSR, concurrent-render, +provider or typed-schema claim: the public schema option remains typed never. +Default-getKey absence, invalid/async schemas and mutations spanning replacement +remain outside this receiver. Passing bounded histories do not prove universal +production correctness or finish the campaign. + +Evidence directory: `/private/tmp/rc2171-oracle-challenge/`. +Independent GREEN: `chunk15-react-independent-final-r1.log`, +`chunk15-preact-independent-final-r1.log`. +Author GREEN: `chunk15-react-final-r1.log`, `chunk15-preact-final-r1.log`. +Controls: `chunk15-react-schema-bypass-mutant-r1.log`, +`chunk15-preact-schema-bypass-mutant-r1.log`, +`chunk15-{react,preact}-{initial,later}-input-mutant-r1.log`, +`chunk15-omit-followup-mutant-before-r1.log`, +`chunk15-omit-followup-mutant-final-r2.log`. +Types: `chunk15-{db,react-db,preact-db}-types-final-r1.log`. +Lint: `chunk15-lint-changed-final-r1.log`. +Strict-undefined instrument RED/GREEN: +`chunk15-extra-undefined-calibration-r1.log` (five failed rejection checks), +`chunk15-extra-undefined-calibration-r2.log` (five pass). +Selectors: `chunk15-live-only-selection-final-r2.log`, +`chunk15-suspense-only-selection-final-r2.log`, +`chunk15-controls-only-selection-final-r2.log`, +`chunk15-partial-live-selection-final-r2.log`. +SHA256 shared model: +`c6b6da5fe6bbeb0d3d6dd8e389d2825468acc7aa94ecb1ece11fb741bf85952a`; +support: +`ed4bd6bb340d8d16d64914d500e14c597f28ef124ce0439cae6d606770b81e75`; +React: +`8d4707534b5962b8f249885b10104045869a86d9e86faf8c8e0ac289866b41c6`; +Preact: +`97c32b831caf5e7d4c6349197e356594fffee0c0b4cfc113a479717fe3a76f0c`. + +### Work remaining after chunk 15 + +Effect metadata is the next edit chunk. Pending O12 authority, actual UI/doc +guidance, clean workspace and packed consumers, calibrated source/artifact +checks and exact-input validation/CI wiring remain. Library repairs stay +deferred until all oracle chunks are through independent review. + +### Subsequent authority for O12 + +The maintainer answered the pending callback-input question on 2026-10-28: +strip reserved metadata fields from new projected subquery objects before +downstream `.fn.select()`; direct source rows keep metadata and inline include +values keep authored shape. The earlier pending references describe the state +at their recorded review. O12 can now proceed as the next chunk after Effect +metadata review. This decision changes no production file. + +Pre-edit O12 inspection identified one narrower authority edge: structured +whole-row spreads have an existing source-owned attribution law, although they +construct an object. The maintainer has been asked whether that rule also +preserves metadata at callback entry. New-shape projections, direct source +returns and inline values have the decision above; spread-dependent assertions +remain pending that answer. + +The maintainer subsequently chose preservation for structured whole-row source +spreads at callback entry. O12's authority is now complete: direct source rows +and structured whole-row spreads retain genuine source metadata; own-shape +projections lose reserved fields; inline values retain authored shape. + +### Remaining receiving plans (read-only preparation) + +These plans do not claim implemented coverage. Chunk 16 Effect metadata is +currently under construction and independent review. Its first LEFT-join run +exposed P09's phantom initial update; raw startup observations remain retained. +The revised driver will collect cut-labelled mismatches and continue through +the authored history, distinguishing exercised cuts from passing comparisons. +O12 callback-input work starts only after this chunk's review finishes. + +During chunk 16 review, omitting the right-source remote-followup action from +both authored model and driver preserved every aggregate reach and the same +64 startup REDs. The independent read-only attack demonstrates a reach gap, +not another production bug. Side-specific followup/pending/acceptance/settlement +declarations and actual held-handler premises are being added before final +review. Evidence: `chunk16-before-side-reach-{baseline,omission}.log` under the +scratch directory. This is an in-progress note, not a reviewed-chunk verdict. + +O21 is divided into actual mutation-fence receiving, diagnostic-specific snippet +baseline checks, and active/historical guidance checks. Its read-only baseline +is 802 snippets in 300 files, 252 known failures including 59 parse failures, +zero new failures. The successful unconfirmed POST reproduction still lives +only in scratch; the durable mutation owner currently supplies checker controls, +so a real fenced receiving driver remains necessary. Shared mutation judgment +must accept complete authored row shapes rather than force todo snippets into +the existing numeric id/name/stable fixture. Required confirmation derives from +the guidance obligation, never from whether a snippet calls refetch. P02/P04/ +P06/P07 and candidate P08 remain under these checks. Authored guidance is not +repaired yet. + +O20 mounts actual ListsScreen and its private ListCard through a controlled +ReactDOM/RN-primitive host. No native tooling is available. The old unsaved +screen probe establishes feasibility only and needs retained rerun evidence. +Reuse the source metadata and mutation judgment, deduplicate React runtimes, +and judge Saving after delivered delay callbacks without inventing an exact +200 ms product threshold. Include fulfilled/rejected, confirmed/unconfirmed, +early/delayed settlement, independent peers and next-use histories. + +O22 stages a clean snapshot without node_modules/dist, attempts frozen offline +installation, then actual build graph and source typechecks. Missing cache is +a setup failure. Preserve the confirmed persisted-handler TS2769 and missing +Offline build-graph edge separately from unproven frozen-install failure; the +committed lock already links DB. Run the actual version CLI in staging only. +The proposed four release cuts (RC1, RC1.1, RC2, stable) receive all 25 public +tarball manifests/exports, 32 minimal installed Query/Offline runtime histories, +eight declaration consumers and strict positive/negative peer installations. +Installed consumers must resolve only disposable packed entries, with no source +alias or DB/adapter override standing in for peer admission. Existing workspace +installation and user lock/override state remain untouched. + +O23–25 use calibrated, narrow source-maintenance/work checks and actual supported +artifact formats. Core's coded-error law does not imply zero plain errors in +all adapters. The census omits errors.ts, TSX and Svelte files, so an adapter +inventory needs explicit public classes and package-aware source enumeration. +Preact and Solid are ESM-only; Svelte has its distinct svelte-package output. +The current minified tool's db-ivm source alias cannot prove packed-artifact +reception. Fresh builds, fingerprints and bundle-input checks are required. + +O26 joins all affected selections, per-path reach, exact source/build inputs and +CI entry points without duplicate script-owner execution. P03's standalone +recorder currently throws EISDIR before owner analysis in three of twelve real +RunDetails boundary cases. Planned test-infrastructure repair separates a +nonthrowing recording outcome from a caller wrapper that preserves owner +failure as primary and writer failure as secondary, failing on writer-only +failure. This is authorized routine harness design under ORC-010. An artificial +two-body it.fails experiment does not establish a legal-owner counterexample +and is not another confirmed defect. Guarded child-process witnesses remain +necessary. No library repair, commit, push or dependency workaround is part of +these read-only preparations. + +Additional O26 instrumentation audit: chunk 16's finite owner initially +inherited createOracleReach's seed/path exemption despite having no replay +mode. With an unrelated seed, a partial single-source selection passed while +omitting required join/union lanes. Effect now declares its finite replay +policy explicitly; independent final verification is pending. The remaining +campaign finite owners are being inventoried read-only for the same class, +distinguishing actual direct replay from irrelevant seed inputs and wholly +unentered campaigns. Evidence before repair: +`chunk16-independent-replay-env-selector-before.log`. No library defect or +repair is inferred from this test-infrastructure failure. + +## Chunk 16: explicit source metadata reaches Effect deltas (reviewed) + +Production head remains `858b174a65f160d13d01f0324c7481cc8e4eba3a`. +Author: `metadata_timeline_review`; independent reviewer: +`effect_metadata_review`. No library implementation changed. + +VirtualRowProps/VirtualOrigin, public DeltaEvent/EffectConfig and the Effect +guide authorize the law: source metadata explicitly selected into ordinary +fields reaches both actual onBatch and named callbacks, with exact values, +previous values and multiplicity. No output Collection exists for an Effect; +this owner assigns no implicit root metadata or Collection ID. Event keys are +opaque: distinct active occurrences have distinct keys, updates retain the +active key, exit retires it, and reentry may choose a fresh key. Typed source +keys are independently compared through the ordinary observedKey field. + +The independent model authors base rows, overlays, queued confirmations and +applied origins, recomputes projected rows and differences before/after arrays. +The small source judgment is shared with the existing root metadata owner; +output attribution remains separate. The finite grammar executes 200 native +histories: structured/functional selection, single source, LEFT join with both +matched and unmatched rows, and populated unions in both branch orders. +Confirmation masks, outcomes and two-source settlement orders vary independently. +Held writes, accepted-but-held confirmation, each settlement, remote followups, +peer exit/reentry, survivor next-use and final drain are compared. Both ledgers +preserve every event and copy scalar fields before later source changes. + +Independent baseline at the final freeze: 64 initial LEFT-join assertion REDs +and 152 passes (136 native histories plus 16 controls). All 5,512 per-history × +channel × applicable-cut reach cells execute exactly once. Of 2,756 combined +checkpoint comparisons, 2,692 pass; every mismatch is at initial delivery. +Each history collects cut-labelled errors and continues authored actions, so +the initial defect neither disappears nor prevents later evidence. P09 is the +actual startup update for a never-entered unmatched occurrence, after its load +settled. No class closure or product GREEN is claimed. + +Review repaired four instrument gaps with prior executed evidence. Omitting a +right-source followup from both model and driver escaped aggregate reach; +per-lane side cuts now fail exactly the two missing channel cells. Checker-only +selection previously failed all native reach; wholly unentered native campaigns +now stand aside while partial entered selections still owe every lane. A new +nonempty-batch assertion rejected lawful empty partitions; it had no public +authority in this owner's unconstrained partition judgment, so it was removed +and replaced with permitted empty-partition controls. Finally, an unrelated +replay seed disabled finite reach: eight selected histories passed before the +explicit finite policy. They now fail for 5,376 missing cells, while seeded +checker-only selection passes all 16 controls. Invalid seed/path invocations +without required property selection were setup failures, not rejection evidence. + +Five deliberately wrong production overlays change named dispatch, substitute +current for previous value, duplicate delayed events, collide active keys, or +add an own undefined field. Each adds intended comparison failures in all 200 +native histories; all 16 controls pass. The joined baseline RED is distinguished +from the added mutant violations. Lawful opaque-key renaming, fresh reentry keys +and empty partitions preserve exactly the baseline result. No production file +was changed for these overlays. Existing root metadata coverage remains +1,604/1,604 GREEN after extraction; six ordinary regression/cleanup controls pass. +DB TypeScript and scoped ESLint exit zero; ESLint retains two existing +require-await warnings in the root owner. + +ORC-001–006: sourced bounded law, independent recomputation, literate layers, +exhaustive legal grammar, actual callback receiving paths and calibrated wrong +observations/overlays. ORC-007 is inapplicable to finite enumeration. ORC-008: +settlement distinguishes base/overlay/confirmation/origin; no production queue +is reconstructed. ORC-009: source/public delta vocabulary and opaque active +intervals are explicit. ORC-010: cut and later driver errors remain aggregated; +copied ledgers and shared failure-preserving cleanup retain the first mismatch. +ORC-011: no further concrete shared-semantic-fault hypothesis remained in scope. +ORC-012: this frozen independent review record. ORC-013: confirmation/outcome +neighbors, typed 1/"1", both settlement orders and exit/reentry distinguish the +law. ORC-014: controlled eager core sources only, no real-provider claim. +ORC-015: all 5,512 cells reach once, source/partial/seeded selector attacks and +unentered controls above. ORC-016: generated histories and calibration observations +use the same independent judgment, without fixed product expected answers. + +Limits: flat scalar projections, eager controlled sources, one persisting +mutation per source, and the named single/LEFT-join/populated-union paths. +No arbitrary provider scheduling, frameworks, ordered repair, same-source +overlap, callback-input sanitization or exact internal graph-run callback-count +claim follows. P09 stays open for the later production-repair phase. + +Final evidence under `/private/tmp/rc2171-oracle-challenge/`: +`chunk16-independent-baseline-final-r3.log`, +`chunk16-independent-reach-final-r3.json`, +`chunk16-independent-replay-env-selector-before.log`, +`chunk16-independent-replay-env-selector-after-r3.log`, +`chunk16-independent-replay-env-checker-r3.log`. +The `chunk16-independent-*-final-r2.log` overlays remain applicable: the only +later source difference is the explicit finite reach option/comment; model, +driver, recorder and comparisons are unchanged. Earlier gap evidence: +`chunk16-before-side-reach-{baseline,omission}.log`, +`chunk16-independent-checker-selector-before.log`, +`chunk16-independent-empty-partition-before.log`, +`chunk16-one-lane-omission-after.log`. +Author checks: `chunk16-{effect,eslint,typecheck}-final.log`, +`chunk16-root-extraction-r2.log`, `chunk16-ordinary-controls.log`. +SHA256 Effect owner: +`4b738962e30f2b0f55b1894bab1f76334da8d66e6001773bbe24599408bb6130`; +source judgment: +`d60c483456f6d222060976ebd10dd68ddbb9481c685f785f7a36e0c82ec1f80f`; +root owner: +`c925791e375f2f2a0941e0ab35b965cabe89570fb13a981b14826b874eaca98f`. + +### Work remaining after chunk 16 + +O12's callback-input metadata law now has full maintainer authority and is the +next edit chunk. Actual guidance and example UI, clean installed/packed +consumers, source/work/artifact checks, final exact-input validation/CI wiring +and the broader finite-reach/P03 instrumentation work remain. The read-only +finite audit has already reconstructed the seed bypass in Preact; its wider +inventory belongs to O26, without erasing prior normal-run evidence. All +library production repairs remain deferred until every oracle chunk is through. + +The read-only finite inventory is preserved in +`2026-10-28-pr-2171-finite-reach-inventory.md` with 19 source fingerprints: +28 default-policy sites need examination/repair; five other files have explicit +policies, including genuine release replay, and must not be sweep-changed. +Only Preact's irrelevant-seed bypass has an executed receiving witness so far; +the other rows are source inspection, not independently executed failures. +Exact-final-hash chunk 16 type/lint logs are +`chunk16-typecheck-final-r2.log` and `chunk16-eslint-final-r2.log`. + +Read-only O21a refinement: parameterize the existing mutation judgment with +complete before/optimistic/confirmed/peer rows, retaining numeric defaults and +existing callers. Actual handlers use string todo IDs and text/completed or +product fields; no field-dropping projection into name/stable is acceptable. +The proposed receiving inventory is 18 fences, 30 current handler ASTs (21 Query, +nine Electric), plus three explicitly negative Before/WRONG examples. Three +collaborator outcomes and peer absence/presence give 186 histories (130 Query, +56 Electric); negative examples use only successful collaborators. This is +exact-handler reception through real adapters, not full schema/query/component +execution. It must retain the successful unconfirmed POST RED and rerun all +unchanged default adapter/ordered/post-handler evidence after generalization. + +Independent pre-edit O21a review identified a necessary distinction: the old +reject-after-write histories reject after confirmation acceptance, while an API +collaborator can commit and reject before returning to its handler. The latter +never reaches the handler's later refetch/acknowledgement, so its settled +Collection may correctly retain the old base even though server data changed. +Confirmation intent and independently authored confirmation acceptance must be +separate. Preserve old default histories and the unconfirmed-success RED; +do not infer either promise from observed utility calls. + +## Chunk 17: metadata is correct before functional callbacks (reviewed) + +Production executable baseline: `858b174a65f160d13d01f0324c7481cc8e4eba3a`. +Author: `metadata_types_review`; independent reviewer: `handler_types_review`. +Only narrow architecture contract prose changed under src; no executable +library repair, generated documentation, installation, commit or push occurred. + +The two maintainer decisions authorize this law: direct source rows and +structured whole-row source spreads expose genuine metadata before downstream +fn.select, including when a source spread selects conflicting reserved names. +Own-shape projections, including functional new objects/spreads, expose none +of the four reserved fields. Nested inline values keep authored selected shape; +compiled Collection-valued inputs reject before callback invocation. The +architecture now states this boundary explicitly. Metadata's readable presence +and value matter; genuine source metadata's own-property layout is not promised. +Ordinary selected fields, including nested authored reserved names, retain +strict own shape and own undefined. + +The small input model uses authored source facts and the shared source metadata +judgment, not compiler provenance. Thirty-three legal shape/collision +descriptions cross seven consumers and two actual compiler receivers: root and +correlated inline child, 462 histories. Two direct-source controls, 66 nested +array/materialized preservation histories and 33 Collection-input rejections +complete 563 native histories. Seventeen controls calibrate the same judgments. +Typed numeric/string peers, each-name/all/no collisions and both authored +pending booleans distinguish sanitizer shortcuts. QueryRefs reach depth two, +with one LEFT join and both populated union positions. + +Actual callback inputs are copied before returning an ordinary observed value. +Every captured input must match one coherent authored before/after source row; +this permits legitimate retractions without accepting old/new field mixtures. +Startup joins may lack a side, but established public output is the strict +after-state. Initial readiness, held successful mutation, settlement and later +remote use all execute. Errors accumulate through those four cuts and are +thrown inside failure-preserving cleanup. Final publication cannot erase a bad +input already copied into an ordinary nested output. + +Independent final result: 580 tests, 377 pass and 203 intended +MetadataCallbackOracleFailure REDs, all from correlated-child receivers. +Root's 231 histories, nested inline preservation, direct-source controls and +Collection rejection pass. P10's leaks/spoofing remain unfixed. All 1,856 callback +and 297 inline/rejection states reach their minimum once (2,153 total), including +later cuts after an earlier mismatch. Valid unrelated seed/path/property inputs +still run the full matrix. Seventeen checker controls pass with native campaigns +unentered; the two inherited-field controls pass independently. Selecting only +two native direct-source histories fails mandatory missing-matrix reach, with +or without unrelated seed configuration. An invalid unknown-property selection +was a setup error and is not counted as rejection evidence. + +Review corrected a representation overconstraint before acceptance. Genuine +metadata need not be own; the checker now compares readable presence/value. +Nested authored inline fields are ordinary children data and remain strictly +compared. Two durable controls reject replacing an authored nested field or an +own undefined with inheritance. Independent scratch probes also reject mixed +before-label/current-pending facts and verify that an initial correlated-child +RED retains callback observations/reach at all four cuts. Illegal initial +correlation and mixed-key ordering fixtures were repaired as setup premises, +not claimed as product RED. Raw structured literal undefined remains a separate +P11 investigation, not an approved normalization or a law of this model. + +Existing owners remain unchanged in behavior: root metadata 1,604 GREEN, +functional projection 125 GREEN, functional boundary 18 GREEN; Effect retains +152 passes/64 known P09 REDs. Whole DB test TypeScript, targeted ESLint and +changed-file lint pass; two pre-existing require-await warnings remain. + +ORC-001–006: explicit decisions/architecture, independent authored facts, +literate layers, finite legal syntax/path grammar, real root/child callbacks, +ordinary derived outputs and 17 calibrated controls plus actual P10 REDs. +ORC-007 is inapplicable to bounded enumeration, with replaying:false explicit. +ORC-008: complete before/after facts distinguish retractions, without a second +queue model. ORC-009: callback input differs from published row; top-level +metadata descriptor freedom differs from nested authored shape. ORC-010: +all-cut reports and failure-preserving cleanup retain primary violations. +ORC-011: no further concrete shared-semantic-fault hypothesis remained. +ORC-012: this exact frozen record. ORC-013: individual/all/no collisions, +booleans, source/new/inline boundaries, typed peers and four cuts distinguish +the law. ORC-014: controlled core boundary only. ORC-015: all 2,153 states reach, +including valid seeded/unentered/partial selection controls. ORC-016: every +history/control uses the shared independent judgments, without fixed product +answers. This is instrument acceptance, not closure of P10's production class. + +Limits: bounded syntax/key grammar, one held successful mutation, flat inline +children, QueryRef depth at most two and one LEFT join. Schemas, complex +descriptors/prototypes, greater depth, other joins, same-key overlapping +transactions, truncate and real provider/framework paths are outside it. + +Evidence under `/private/tmp/rc2171-oracle-challenge/`: +`chunk17-independent-final-full.{json,log}`, +`chunk17-independent-final-seed.log`, +`chunk17-independent-final-controls.log`, +`chunk17-independent-final-unentered.log`, +`chunk17-independent-final-partial-seed.log`, +`chunk17-independent-controls.{test.ts,log}`. +Pre-delta no-seed selector: `chunk17-independent-partial-selection.log`. +Author current checks: `chunk17-final-delta-{full,lint,types,lint-changed}.log`. +SHA256 model: +`8ed276ebe90b678ad94ccfa83fbd76b3c0d0cbcb6e3397ffce7bd4ff6de76d1c`; +driver: +`330d7e0084af2848aed36829f5194c000e224be72141b71a35d0971cf847d84d`; +runner: +`817e7d63c4d560b511727f92434f2a176ba32342fd40d78adecf84a5d410d7a7`; +shared grammar: +`301154bdaa1fe42fbd9b0f668947bac3a54f7cdb33a83cd4df7579a9f12c16c1`; +source judgment: +`d60c483456f6d222060976ebd10dd68ddbb9481c685f785f7a36e0c82ec1f80f`; +architecture contract: +`fdfa5e2adef393dfd0818d2847ce1af22d231ff513fdaa07a0c34802e3a84ecb`. + +### Work remaining after chunk 17 + +Actual mutation guidance reception (O21a) is next. P11 literal semantics has +read-only evidence and needs owner/law scope and its own oracle work. Actual +example UI, diagnostic/guidance consistency, clean installed/packed consumers, +source/work/artifact checks and final finite-reach/P03/input-manifest/CI work +remain. P09, P10 and every earlier production RED stay deferred for the later +repair phase. + +## Chunk 18: actual authored mutation handlers (reviewed) + +Executable production baseline remains `858b174a65f160d13d01f0324c7481cc8e4eba3a`. +Author: `handler_compiler_review`; independent reviewer: `mutation_electric_review`. +No library, guidance, generated-documentation, dependency or lockfile repair, +commit or push occurred. The first pnpm exec attempted automatic dependency +verification and aborted with `ERR_PNPM_ABORTED_REMOVE_MODULES_DIR_NO_TTY`. +Subsequent commands use installed Node entry points directly. Initial combined +runtime/typecheck rootDir errors are setup evidence, not product failures. + +Law: recommended successful handlers explicitly confirm their server changes +before settlement. Legacy returned values coordinate nothing. Authority is the +maintainer's explicit-confirmation decision, the optimistic acceptance/settlement +owner and the current After/CORRECT guidance. An API can commit and reject before +its handler reaches confirmation; that transaction may correctly settle on old +Collection base. Later explicit confirmation exposes committed server data +without changing the captured settlement-waiter rows. Resolved HTTP failure +differs from transport rejection. Its independently specified Error kind/message +is checked before comparing actual handler and transaction rejection identity. + +The shared mutation model now accepts complete authored rows while preserving +all original defaults. `confirmationEntry: unreachable` distinguishes API +rejection before acknowledgement from the old rejection-after-acceptance lane. +The new documentation owner supplies a separate actual-handler Promise judgment. +Expected values never come from observed utility calls or Collection output. +Strict ordinary rows preserve Dates and own undefined; metadata remains with its +own owner. API request operands are checked and used to construct the controlled +server ledger, rather than installing expected rows irrespective of the request. + +Eighteen actual fences supply 30 current handlers (21 Query, nine Electric) and +three explicit Before/WRONG examples. Current handlers cross success, rejection +before write and rejection after server write, with absent/present peer. Four +additional fetch histories resolve HTTP 503 before/after server commit with both +peer states. This is 190 bounded histories: 134 Query and 56 Electric. Only exact +initializer ASTs execute. Real QueryClient/Query and Electric adapters receive +controlled backend/SDK collaborators. Source delivery and actual utility +acceptance are separately held and observed; no missing confirmation is supplied. + +Independent frozen baseline: Query 132 PASS and two intended G01 settled-row REDs; +Electric 56 PASS; 43 shared/checker/ordinary support controls PASS. Query reaches +134 handler entries/API requests/settlement waiters, 90 server writes, 40 of 42 +required acceptances, 32 of 34 required source holds and 48 later confirmations. +G01's missing acknowledgement stages/comparisons remain zero and separately fail +reach; declared intent earns no execution credit. Electric reaches 56 entries/API +requests/waiters, 38 writes, 18 acceptances/holds and 20 later confirmations. +The frozen waiter is checked again after later source confirmation. Default +receivers still pass: Query 78 mutation plus six post-handler histories and three +controls (87), Electric 42, ordered refetch 24 histories plus three controls (27). + +Independent review found and repaired an instrumentation false green. An exit +gate kept the transaction pending after the actual handler returned. In-memory +transpile overlays removed exactly one actual refetch or awaitTxId await; both +wrong handlers passed at the initial freeze with all selected reach satisfied. +R3 removes that return gate and observes the actual extracted handler Promise. +A Node event-loop turn drains runnable continuations while real source delivery +remains held. Only the accepted asynchronous utility return is held for its +accepted-before-handler-return cut. Identical omitted-await overlays now fail +at `confirmation-requested: actual documented handler Promise`, without timeout. +Removing the HTTP status guard fails its independent handler-outcome comparison. +Removing await from already accepted synchronous writeUpsert and writeBatch +neighbors remains permitted and passes. Await spelling is not itself a law. + +Review also demonstrated an overbroad seed rejection: a valid shared seed failed +before any native handler ran. R3 follows the shared configuration contract: +seed 42 preserves full outcomes/reach, an own finite case plus seed 1 passes, +and unrelated actual path replay skips all 134/56 cases. A partial native +`mutations-after` selection runs six passing cases and skips 128, then fails +missing declared reach equally with seed unset or 42. Exact finite selection uses +`TANSTACK_DB_DOCUMENTED_MUTATION_CASE`; G01 replay preserves its settled mismatch. +Own selector reduction and unrelated shared seeds are separate policies. + +Direct bounded TypeScript, targeted ESLint and changed-file lint pass. Runtime +commands use the installed Vitest entry from each package, with coverage and +typecheck disabled; typechecking is a separate command. This does not claim +whole-workspace type correctness. The independent reviewer checked all seven +frozen SHA256 values before and after its native, selector, original-owner and +overlay executions, and found no further supported instrument defect. + +ORC-001–006: explicit authority, independent shared outcome/handler judgments, +literate layers, bounded exact-handler grammar, real adapter cuts, full rows and +executed hostile controls. ORC-007: randomized campaigns are inapplicable to this +finite enumeration; own direct replay is verified. ORC-008–009: API commit, +confirmation entry/acceptance, handler settlement and transaction settlement are +independently distinguishable. ORC-010: exact errors, frozen waiter rows and +failure-preserving all-resource cleanup. ORC-011: no further concrete shared +semantic hypothesis requiring a second formulation remains after the direct +handler observation repair; no universal independence claim follows. ORC-012: +this versioned frozen entry. ORC-013: genuine holds reject detached confirmation, +while synchronous accepted-write neighbors remain lawful. ORC-014: no real +provider/host handoff is claimed beyond the controlled boundary. ORC-015: actual +per-case stages and comparison counts above, including G01's unreached stages. +ORC-016: fixed histories use the shared judgment; extraction/request/selector +examples remain in ordinary support tests. This is instrument acceptance, +not a full GREEN, readiness or production bug-class closure claim. + +Limits: selected initializer ASTs, one edited key and optional peer. Full +factories, schemas, query/component execution, awaitMatch/timeout guidance, +concurrent handlers, persistence wrappers, durable direct-write receipt holding, +live Electric/backend providers and framework scheduler cuts remain outside it. +G01 stays open for the later repair phase. + +Evidence under `/private/tmp/rc2171-oracle-challenge/`: +`chunk18-author-evidence-r3.md`, `chunk18-freeze-r3-sha256.json`, +`chunk18-{query,electric,controls}-independent-r3.log`, +`chunk18-original-{query-owners,electric-owner}-r3.log`, +`chunk18-ordered-owner-r3.log`, `chunk18-unawaited-{query,electric}-before-r2.log`, +`chunk18-unawaited-{query,electric}-r3.log`, `chunk18-http-status-guard-r3.log`, +`chunk18-write-{upsert,batch}-neighbor-r3.log`, `chunk18-seed-before-query.log`, +`chunk18-partial-query-{no-seed,seed}-r3.log`, +`chunk18-{query,electric}-seed-r3.log`, +`chunk18-{query,electric}-unrelated-path-r3.log`, +`chunk18-{typecheck,eslint,lint-changed}-r3.log`. +Frozen file hashes: + +- Shared mutation model: `a521be9b638934556542837670261c1cb0eee0eae44c9912968c1add40a14e8d`. +- Documentation owner: `478405dd2af4964da9c3981caa5ddaba6e792e2151c2c1c9faf6e03e53c6f0d2`. +- Support driver: `d35aad2629ef81d9f8ccec7226e33e85be08df00119bf838714a2d3a0bc826d7`. +- Documentation controls: `450e51629ce8a885211dd35c4c1941d5e3585864053b405bf3bdc2d08254bf65`. +- Ordinary support tests: `8a7778c86642d10e0fa74382b859e42ad2dd7191c52ea34624937ef95c00a85e`. +- Query receiver: `86092d4b33c4640a78fe5e385ad9afcffb4fb9af9d5cc8fc66957ab2c78fbdc3`. +- Electric receiver: `72ced5986b32722b9bdb2a891bd634d0e7bb8d064e72474c37aa275ba5039dbe`. + +### Work remaining after chunk 18 + +P11 now has separately supported runtime literal-preservation and public type +inference findings. Its next three review gates are shared root/QueryRef runtime, +same-model correlated inline array/singleton/facade receivers, and exact public +type receivers. Current rejection of a broader mixed-expression array is an +unresolved admission question, not authority that the input is invalid. Types +judge required keys; runtime judges own properties. The corrected pre-edit +inventory and evidence manifest are retained in scratch. Actual example UI, +diagnostic/guidance consistency, clean installed/packed consumers, source/work/ +artifact checks and O26 finite-reach/P03/input-manifest/CI work remain. Every +library and guidance repair remains deferred until all oracle chunks finish. + +## Chunk 19: selected literal values at root and QueryRef boundaries + +Accepted after independent oracle review at unchanged production HEAD +`858b174a65f160d13d01f0324c7481cc8e4eba3a`. The final R2 freeze changes three +files: the shared literal model, its production driver, and the existing +`subquery-user-value-oracle.test.ts` registration owner. The independent reviewer +found one conformance defect: the companion module lacked its own declaration. +R2 adds only that declaration and its import. Removing those additions +reconstructs the exact R1 hash; both evidence sets remain available. + +Law and authority: selected null and undefined pass through, and recursive +authored records preserve their selected members, including empty records. +The public builder descriptions at `builder/types.ts:402–437`, plus the +architecture's user-value and publication laws, supply this authority. +Ordinary JavaScript interprets test-only data/reference descriptions. The model +imports no production IR or semantic classifier. Virtual-field attribution and +public type accuracy remain separate judgments. + +Before edits, 48 exact emitted public query forms compiled without diagnostics. +Nested omission then exposed empty-record loss. Five adjacent forms were +predicted from the same recursive-record law before their addition; their +20 exact translations also compiled without diagnostics. The expanded grammar +has 17 forms, two property positions and two receivers: 68 histories, each +observed at initial readiness and two synchronous full-row source commits. + +RED on unchanged production: **103 tests, 85 pass and 18 fail**. The new +histories contribute 50 passes and 18 intended assertion failures: eight P11 +undefined-to-null cases and ten P12 empty-container-loss cases. The other +passing tests are six existing relational histories, 24 hostile controls, +four lawful controls and one grammar calibration. An independent one-row +P12 witness selects only id/version references and `value: {}`; no raw nullish +member is needed to expose the missing selected property. + +Reach: all **204 receiving states and 102 QueryRef callback states** execute +exactly once. Root independently verified frozen hashes, reran the native owner +and recomputed these counts. Eager tagged captures retain own undefined, +absence, null, dense array indices/length, complete ordinary rows, event +type/key/current/previous values, event-time public reads and callback inputs. +All lawful later cuts are recorded before judgment, so initial REDs do not +erase subsequent evidence. Cleanup diagnostics remain distinct. + +The shared comparison rejects 24 wrong observations, including container loss, +wrong or duplicate changes, stale previous values, mixed callback inputs and +missing current callback inputs. Four positive controls preserve legal event +partition, change ordering, repeated callbacks and empty startup callbacks. +Independent scratch controls additionally verify immutable nested capture, +root-only metadata exclusion and continuation through all cuts of an actual +failing QueryRef history. Partial native selection still fails missing reach +with a valid unrelated shared seed/path; calibration-only selection passes. + +Strict compilation of the actual three-file authored program has zero +diagnostics. It proves driver admission, not public inferred-type correctness. +Targeted ESLint, formatting and scoped diff checks pass. R1 and R2 changed-file +lint both pass. The final R2 hashes were verified again after lint. + +ORC-001–006: authority, independent judgment, literate responsibilities, +finite legal grammar, actual public receivers and executed negative/positive +controls are evidenced above. ORC-007 does not apply to exhaustive finite +enumeration; selection/reach behavior is checked. ORC-008–009: stateless +before/after recomputation and explicitly mapped expression descriptions avoid +duplicated lifecycle machinery. ORC-010: eager capture, continued lawful cuts +and separate cleanup diagnostics; cleanup-fault injection remains unclaimed. +ORC-011: no concrete remaining shared-semantic hypothesis requires another +formulation. ORC-012: this exact R2 versioned entry and the independent audit. +ORC-013: undefined/null/omission, raw/explicit/reference, pure/mixed and +empty/nonempty neighbors distinguish the wrong rules. ORC-014: the claim ends +at controlled core sync sources. ORC-015: all 306 declared states execute and +partial campaigns fail. ORC-016: every history and calibration uses the same +model/check; there are no standalone concrete type assertions. + +Limits: dense finite arrays, bounded records and two source updates. Correlated +arrays/singletons/facades and generated public-type receivers remain separate +sequential gates. Sparse authored arrays, symbols, descriptor flags, arbitrary +recursion, cleanup/restart, provider/framework scheduling and broad mixed +raw-nullish/IR.Value admission are unproved. The latter admission question is +not an invalid-input exclusion. All product REDs remain open; this accepts an +instrument, not a production repair, GREEN result or PR readiness claim. + +Evidence under `/private/tmp/rc2171-oracle-challenge/`: +`chunk19-freeze-manifest.json`, `chunk19-r2-freeze-manifest.json`, +`chunk19-freeze-evidence.md`, `chunk19-reproduction-commands.md`, +`chunk19-typecheck.mjs`, `chunk19-root-native.log`, +`chunk19-independent-r2-{native,types,controls,partial-seed}.log`, +`chunk19-emitted-types-preedit.log`, `chunk19-empty-container-types-preedit.log`, +`chunk19-minimal-empty-record-red.log`, `chunk19-r1-r2-model.diff`. +Final frozen file hashes: + +- Model: `898ef5e00aec151cbb9367dc1f8b0fe5f488b02e75f57f504dc8224b9dbdc5b6`. +- Driver: `4afcf5213153c285ba9e917fffd6e44c816bf0953bdd872aced065a087e865d9`. +- Registration owner: `3cb39cfb878b7bbd8682838cc1958c70dc36536c2ea0de200f2952ba18f44ebf`. + +### Work remaining after chunk 19 + +Next are same-model correlated inline array/singleton/facade receivers, followed +by exact public-type receivers. The actual example UI, guidance/diagnostic +consistency, clean installed/packed consumers, source/work/artifact checks and +O26 finite-reach/P03/input-manifest/CI work remain. Source-inspected P13/P14 +guidance conflicts join that inventory. Library and guidance repairs remain +deferred until all oracle chunks finish. + +## Chunk 20: correlated literal receiving paths + +Accepted after independent oracle review at unchanged production HEAD +`858b174a65f160d13d01f0324c7481cc8e4eba3a`. Freeze manifest SHA256: +`df44856c6d4772f3af501cfb4a0abc82e40cc1e8605a721852a3fe2787de097b`. +Four new test modules supply the materialization judgment, concrete factories, +production driver and campaign. The fifth frozen input is DB's package manifest, +which registers the new campaign once and preserves earlier registrations. +Accepted chunk 19 files remain unchanged. + +The same ordinary-JavaScript literal model now receives 17 forms × two selected +positions × direct/QueryRef child composition × four public materializers: +`toArray`, array `materialize`, singleton `materialize`, and Collection-valued +`include`. Architecture's nested-materialization/publication laws add ordered +children, canonical empty values and coherent Collection-facade observations. +Two parents share a populated bucket, a third has an empty bucket, and two +atomic full-row child updates reuse the literal source states. The existing +facade interval judgment checks handle identity; it is not reimplemented here. + +Exact public queries compiled before receiver edits. The first native run +rejected 136 QueryRef constructions before comparison: their outer child had +no correlation WHERE. Public live-query guidance at lines 1307–1323 requires +the child query's own WHERE to contain the parent/child equality. This is missing +premise evidence, not a literal-law RED. The revised legal query explicitly +selects `routeGroup`, correlates it in the outer WHERE, captures that complete +authored callback input and removes only that field in the actual returned +JavaScript object. No recorder normalization hides it. Every revised exact form, +including a later lexical rename to satisfy lint, compiled before factory edits. + +Native result: **305 tests, 233 pass and 72 intended assertion REDs**. The 272 +main histories supply 200 passes and 72 failures exposing the same P11/P12 +literal defects through all four materializers and both child compositions. +All **3,264 declared path × cut states** execute exactly once, including public +rows, root events, legal functional inputs and facade events/identity/empty +readiness. No driver-execution failures remain. Independent native and unrelated +seed-123 runs match. Root separately verifies all five hashes and reproduces +the 72/233 result and 272 distinct main titles. + +Every observation retains complete ordinary rows, ordered inline values and +facade layouts, event type/key/current/previous values, callback-time root and +facade snapshots, authored callback inputs and retained inline values. Facade +handles in old parent payloads remain live; inline snapshots remain immutable. +All lawful later cuts run before judgment, preserving the first mismatch and +distinct execution/cleanup diagnostics. No compiled Collection-valued include +enters a parent functional callback. + +Review repaired three instrumentation boundaries. Facade rows and event-time +layouts now use ordered comparison, so bag equality cannot hide reversal. +Startup callback inputs may have the canonical empty child value before the +initial snapshot arrives; each complete current parent must still be consumed +by readiness. This prospective correction follows the architecture, with old +and new predictions recorded before editing; the native run already passed +that facet. After readiness, atomic before/after comparisons remain strict. +One initial startup calibration mutated an aliased public array. Those runs are +preserved but excluded from RED evidence; the corrected isolated callback +control rejects three inputs under the copied old checker and passes under the +corrected law. Wrong partial/mixed startup inputs still fail. + +Vitest's formatted table labels also truncated distinct cells into duplicate +names: the first JSON has 272 main histories but 221 unique names. Literal +titles now preserve all 272 IDs. Exact failing and benign selectors each enter +one intended cell; even a passing selected cell fails mandatory missing-matrix +reach. Calibration-only selections enter no campaign, and unrelated seeds do +not waive reach. O26 separately inventories similar title-identification gaps +in older accepted campaigns without retracting their normal reach evidence. + +All **29 hostile wrong-observation controls and four lawful controls** pass. +These are checker calibrations, not production mutants. Six independent +scratch controls isolate facade layout, previous event values, startup callback +freedom, complete ready inputs, mixed/routing-field corruption and continuation +of an actual failing singleton. They verify intended violation names and +unchanged unrelated public facets. The reviewer source-audited all shipped +attacks for calibration alias leakage. Strict authored-program TypeScript +compilation has zero diagnostics; targeted lint, formatting and scoped diff +checks pass. The existing installed-dependency mismatch remains unchanged. + +ORC-001–006: public authority, independent literal/materialization judgment, +literate responsibilities, exact legal grammar, actual receiving paths and +isolated negative/positive controls. ORC-007 is inapplicable to exhaustive +finite syntax; exact selection/reach is verified. ORC-008: stateless +recomputation plus the existing facade interval model. ORC-009: explicit +data-description and routing-field mappings. ORC-010: eager captures, all-cut +continuation and distinct cleanup diagnostics; cleanup-fault injection remains +unclaimed. ORC-011: no concrete remaining shared-semantic hypothesis requires a +second formulation. ORC-012: this exact frozen record and independent audit. +ORC-013: empty/full, ordered/reversed, literal/carrier and startup/ready neighbors +distinguish the wrong rules. ORC-014: controlled eager sources only. ORC-015: +all 3,264 states execute once and partial selections fail. ORC-016: all histories +and calibrations use shared judgments, without standalone expected product rows. + +Limits: finite dense values, fixed routes, eager controlled sources and two +atomic child updates. Providers, framework schedules, arbitrary recursion, +descriptors, metadata attribution, optimistic histories and route churn remain +outside this slice. Public inferred-type accuracy is the next gate. All 72 +production REDs remain open; instrument acceptance is not a GREEN repair or +PR readiness claim. + +Evidence under `/private/tmp/rc2171-oracle-challenge/`: `chunk20-freeze.json`, +`chunk20-preedit{,-v2,-v3}-inventory.json`, `chunk20-native-first.{log,json}`, +`chunk20-native-final.{log,json}`, `chunk20-root-native.{log,json}`, +`chunk20-independent-*`, `chunk20-exact-selection.{log,json}`, +`chunk20-unrelated-seed.log`, `chunk20-startup-prediction-before.json`, +`chunk20-startup-control-{before,after}.log`, retained +`chunk20-startup-control-aliased-{before,after}.log`, +`chunk20-root-title-collision-before.json`, `chunk20-root-order-control-after.log`, +`chunk20-runner-registration.diff`, and `chunk20-types-final.log`. +Frozen artifact hashes: + +- Model: `87b11a0f33f7d6da215470c0a1e2d331484463735a8e974698e5d95ca9bf954d`. +- Factories: `c0dc046789b30179a43d3e7bb9405b232709a47611e7c214826e14ac4d60ca70`. +- Driver: `36dacf502763f264a57e9e3e7c4d9a241c450437e2d13088b8c135a9d8cc0b86`. +- Campaign: `17c701f2c31bfa74888777e817e5357d87b16413228c8ebc30adc3fac3153f7c`. +- Package input: `8838fe02c99c58b51535823e3f3e565fa61e2a2433d89d239dde7636ae8ffb57`. + +### Work remaining after chunk 20 + +Next is the generated public-type slice for the exact accepted standalone and +correlated syntax. Actual example UI, diagnostics/guidance consistency, +P14's swallowed source-health call outcome, clean installed/packed consumers, +source/work/artifact checks, and O26 finite-reach/replay/input-manifest/CI work +remain. The O21 plan preserves historical diagnostic-baseline reconstruction: +today's errors cannot be automatically adopted through old file/hash waivers. +Library and guidance repairs remain deferred until all oracle chunks finish. + + +## Chunk 21: exact source-public literal type receivers + +Authored and frozen for independent review at unchanged executable-library HEAD +`858b174a65f160d13d01f0324c7481cc8e4eba3a`. This entry does not claim independent +acceptance. Three new test files supply an independent recursive type law, +compiler/source driver and finite campaign. Accepted chunk 19/20 runtime inputs +and DB's package manifest remain unchanged. + +**Law and authority.** The public `ResultTypeFromSelect` documentation at +`builder/types.ts:402–437` promises exact null/undefined, extracted reference +values and recursive selected records. The live-query guide's select and virtual +properties sections distinguish selected ordinary values from the separate +public-output virtual layer. Architecture's nested-materialization and callback +input laws supply array, absent singleton, Collection and strict own-shape +callback boundaries. The model imports authored history descriptions, with no +production conditional-type, IR-classifier or projection helper. Primitive +literal widening remains permitted within its kind; `{}` means an empty +declared static surface, not an exact empty runtime inhabitant set. + +**Paths and reach.** The campaign extracts the accepted runtime AST expressions +into 68 standalone and 272 correlated concrete modules. Query syntax is guarded +against semantic drift. Actual callback inputs are marked before the real +consume call and routing-field destructuring. Each module receives actual +QueryResult and inferred Collection-row types; correlated paths add child, +inline/singleton or public facade/get/toArray/downstream markers. There are +1,870 marker judgments and 2,550 declared syntax/admission/marker states. +TypeScript 5.9.3 compiles every source without diagnostics against this +checkout's public `src/index.ts`; strict null checks are enabled. All 2,550 +states execute exactly once. All 387 test names and 340 native IDs are unique. +The exact 340 sources and their fingerprints are retained in scratch evidence. + +**RED.** The corrected native run and unrelated seed-1 run agree: 387 tests, +107 passes and 280 intended assertion failures. Of 340 native histories, +60 pass and 280 fail. Forty histories infer raw null/undefined as unknown; +272 expose forbidden contextual-input metadata or its propagation into nested +inline values, with 32 overlapping. No other native mismatch category remains. +Every marker comparison executes before the history reports its mismatches. +These are pre-repair observations; no production change is included. + +**Formulation correction.** The initial admission-clean run judged public +QueryResult roots too strictly. Before editing that model, a retained authority +note recorded old/new predictions and followed the guide's public-output +promise to its source. Root and the independent reviewer approved the bounded +correction before editing: exclude exactly four virtual fields at named public +query-output and actual published/source Collection row roots from this +ordinary-value judgment. Callback inputs and nested inline shapes remain +strict. Ordinary `routeGroup` is required on the actual routed input and omitted +only by the authored rest destructuring. Raw-nullish unknown remains RED. No +metadata correctness follows from the separate-layer exclusion. Full static +callback metadata coverage belongs to existing +`virtual-row-fields-types-oracle.test.ts`; this slice adds receiving evidence +for that accepted law, rather than changing product policy. + +**Controls and selection.** All 47 controls pass: 34 compiler-recorded wrong +static observations, eight AST/actual-marker-binding attacks, two lawful +formatting freedoms and three native marker-isolation controls. Wrong answers +include any/unknown/never, lost nullish alternatives, optional selected members, +wrong containers/array mutability, facade/get loss, routing-field corruption and +reserved-name leakage at strict inputs/inline children. These are checker +calibrations, not production mutants. Checker-only selection passes 47 tests +and leaves native reach unentered. Selecting the positive standalone zero cell +passes its actual comparison but fails the mandatory missing-matrix reach, +even with an unrelated shared seed; 386 tests are skipped. Exhaustive finite +syntax has no seed replay waiver. + +**Setup failures kept separate.** The first exported synthetic receiver hit +TS4058 declaration portability before any intended law comparison. The current +source-public inference module has an unexported synthetic wrapper; native +query inference is unchanged. This repairs the test path but does not establish +that exported inferred factories are unsupported. The exact repro and a +handoff preserve exported factory, exported Collection, explicit declaration +settings, and source versus built/installed controls for O22. An early AST +printer also treated trailing commas as drift; semantic canonicalization now +permits formatting while preserving ordered node kinds, names and children. +Neither setup failure is counted as intended product RED. + +**ORC review.** ORC-001–006: independent authority-derived recursive judgment, +literate responsibilities, exact legal inputs, actual compiler receivers, +per-marker observations and executed wrong-result controls are recorded above. +ORC-004 reconstruction/ablation/range: 17 accepted forms cross root/nested +positions, direct/QueryRef and all four correlated materializers. Removing each +axis loses its matching callback/container obligation. AST controls reject +nearby cast, carrier, routing and marker substitutions. ORC-007 is inapplicable +to finite exhaustive syntax; selection/reach remains enforced. ORC-008–009: +stateless static surfaces, explicit separate virtual-layer and authored +routing-field abstractions, with no lifecycle state model. ORC-010: compiler +diagnostics retain file/span/code and remain separate from all type judgments; +no runtime cleanup or shrink path is involved. ORC-011: direct public compiler +facts avoid DB helper-alias self-agreement; no remaining concrete shared-semantic +hypothesis requires another model in this slice. ORC-012: this frozen entry and +the pending independent audit. ORC-013: exact nullish/omission, required/optional, +raw/explicit/reference, mutable/readonly/tuple and facade/inline neighbors reject +wrong boundaries. ORC-014: actual installed TypeScript source-public compiler, +with packed/npm receiving work explicitly deferred. ORC-015: every declared +state reaches once; a partial native selection fails. ORC-016: all native and +calibration judgments reuse the recursive type law, with no standalone +concrete type assertions. + +**Limits.** This checks selected declared value surfaces, property optionality, +nullish alternatives and array mutability, not ordinary-record readonly +modifiers or metadata correctness at excluded output roots. Empty-array element +precision is unclaimed beyond no any. Broader mixed raw-nullish/IR.Value array +admission remains unresolved; no rejected form is silently blessed. Runtime +publication, provider/framework scheduling, arbitrary recursion and packed +exports are separate owners. The 280 product REDs remain open. Production +repair, GREEN result, production-mutant kill and PR readiness are unclaimed. + +Evidence under `/private/tmp/rc2171-oracle-challenge/`: +`chunk21-native-r3.{json,log}`, `chunk21-native-seed.{json,log}`, +`chunk21-corrected-marker-failures.json`, `chunk21-{compiler,reach}.json`, +`chunk21-controls-only.{json,log}`, `chunk21-partial.{json,log}`, +`chunk21-consumer-inventory.json` and `chunk21-consumers/`, +`chunk21-output-vs-input-authority.md`, +`chunk21-exported-receiver-{repro.mts,o22-handoff.md}`, +`chunk21-metadata-type-owner-next-plan.md`, `chunk21-types-r3.log` and +`chunk21-eslint-r2.log`. The final freeze manifest records source and evidence +hashes after formatting and changed-file lint. + + +### Chunk 21 R2: declared record capabilities + +Independent frozen review reproduced the original native/seed/reach evidence +and found a recorder defect. A callable or constructable record with no named +members was recorded as an empty record. The model therefore accepted an +inferred `value: () => void` or `value: new () => {}` for authored `value: {}`. +This concerns declared static capabilities, not the empty-record inhabitant +limit: the wrong type permits invocation that the selected object does not. + +The original manifest, logs and exact three source files are preserved in +`chunk21-freeze.json` and `chunk21-r1-source/`. Before changing the recorder or +comparison, eight calibration histories add call/construct signatures to the +existing empty/nonempty selected record forms at existing root/nested positions. +All eight fail because the old shared judgment returns zero violations; lawful +companions admit. R2 records call/construct presence with public TypeScript APIs +and rejects invented signatures in the existing recursive record rule. + +The R2 native campaign has **395 tests, 115 passes and the same 280 intended +native REDs**; its failed native test-name set exactly matches R1. All 340 +consumers still admit without diagnostics, all 2,550 states execute once and +all 395 titles are unique. All **55 controls** pass, including 42 compiler +recorder controls. Package TypeScript compilation and targeted ESLint pass. +This repairs an instrument only. It changes no product behavior or expected +native law, and supplies no production GREEN or closure claim. The R1 +partial-selection and unrelated-seed evidence remains unchanged. + +R2 evidence uses the same scratch directory: `chunk21-r2-signatures-red.*`, +`chunk21-r2-native.*`, `chunk21-r2-{compiler,reach}.json`, +`chunk21-r2-{types,eslint}.log`, and `chunk21-r2-freeze.json`. Independent R2 +acceptance remains pending. All prior limits and deferred owners still apply. + +Independent R2 review accepted the bounded instrument at the exact frozen +sources. Owned files, accepted inputs and preserved R1 snapshots match their +hashes. The independent native run reproduces 395 tests: 115 pass and the same +280 native assertion REDs, with all 2,550 states once and 395 unique titles. +Twenty independent compiler/AST controls pass, including both original erased +signature witnesses. Strict three-file authored-program typechecking has zero +diagnostics. The eight retained pre-repair controls demonstrate the old +checker failed at its intended comparison. Independent evidence: +`chunk21-independent-r2-native.{log,json}`, +`chunk21-independent-r2-controls.log` and +`chunk21-independent-r2-typecheck.log`. R1 full-seed, checker-only and partial +selection evidence remains preserved. No supported finding remains within this +bounded slice; native product REDs and all stated limits remain open. Final R2 +freeze manifest SHA256: +`da237233542b78a0de9eb836ecbeed12b6b14c2e80f91837eb577c8813bcf0a7`. + +## Chunk 22: publication eligibility without dist-tags + +Accepted at merged HEAD `5fa6eca05530bb9e1f4748b7c00ae60976da2808` with the +publisher unchanged. The independent release ledger still defines eligibility +by exact registry-version membership. Candidate uploads use rc and stable +uploads use latest; partial successful uploads survive retry. Missing tags do +not permit another upload. Repairing historical wrong tags is unclaimed. + +The existing HTTP receiver retains all 14 histories and adds 15. New histories +cross absent/stale tags with present/missing exact versions, reversed +heterogeneous candidate versions and mixed current/missing packages. Later +candidate failure occurs only after observing an actual successful candidate +upload, independent of package order. Independent review added the exact P01 +aftermath (older rc / current latest) and stable current versions without a +latest tag. Invalid tags pointing at nonexistent versions remain excluded. + +The real default publisher runs through npm 11.17.0, pnpm 11.1.0 and Changesets +2.29.8. The isolated HTTP registry checks first-GET facts, raw attempts, actual +packed identity/digest and final registry state. Exact local restoration covers +pre.json and four public fixture manifests before subsequent judgments. An +early stable duplicate is compared before requiring candidate metadata; that +history records candidate reach zero. + +Native result: 35 tests, 21 passes / 14 intended assertion REDs. Seven reject +wrong tags (P01); seven reject real duplicate attempts (P05). The old histories +retain eight native passes / six REDs. Stable/candidate metadata reach is +29/28, restoration 29, completed comparison 15, packed upload 19, and controlled +failure/partial success/retry 11 each. Heterogeneous versions reach eight, +mixed presence four, and actual candidate-success-before-failure two. The +retained raw per-history records include all unreached zeros. Sibling version, +seed and command campaigns give 120 passes / 12 original tag REDs; ordinary +fixture/publisher controls give 29 passes. Scoped and branch oracle lint pass. + +Fresh-tool scratch hosts use an explicit node_modules link to Gate A's installed +Vitest 4.1.12 tools; source/config hashes match the worktree. This is test-tool +reuse, not another clean install or installed-package consumer. Gate A's 3,400 +frozen source hashes remain unchanged. The initial socket EPERM is a setup +failure and supplies no product evidence. + +Independent reviewer `facade_metadata_review` accepted v2 after independently +running seven new neighbors across v1/v2. They observed the stable-path pair of +409 responses before any candidate GET and the exact wrong-tag aftermath's +zero-upload pass. Root verified all final source/evidence hashes and parsed +35 unique titles with the authored 21/14 split; root did not rerun the full +HTTP campaign. Exact-title replay selected one stable-path RED with 34 tests +unselected. Production remains unrepaired. + +ORC-001–006: authority, independent ledger, literate layers, reconstruction, +real HTTP comparisons and calibrated raw multiplicity are retained. ORC-007 +random campaigns do not apply to this finite enumeration; actual title replay +is verified. ORC-008–009: no reference state-machine change; versions, tags and +uploads retain distinct meanings. ORC-010: raw primary failures and existing +cleanup controls are preserved. ORC-011: no additional shared-semantic fault +requiring a second model was identified. ORC-012: exact record and frozen +independent audit are retained. ORC-013: target presence, absent/stale tags, +stable/candidate paths, reversed versions and retained-success neighbors +distinguish wrong rules. ORC-014: actual tools receive controlled HTTP facts; +public npm infrastructure/authentication is unclaimed. ORC-015: mandatory +per-history/path reach, including zero later reach, is measured. ORC-016: +histories and calibrations reuse the shared judgment; ordinary controls remain +separate. + +Evidence under `/private/tmp/rc2171-oracle-challenge/`: +`chunk22-author-evidence-v2.md`, `chunk22-http-v2-{traces,evidence}.json`, +`chunk22-command-neighbors-v2.json`, `chunk22-controls-v2.log`, +`chunk22-independent-review-v2.md`, and `chunk22-root-validation.json`. +Final freeze SHA256: +`62d27c1e152e5ce77d09ba4d863b0685370df15342fac69f2fc132db49a09a66`. +Independent review SHA256: +`e4d4a9cabdc4e8961a4785d67b948694a3384ad2e90d707d827efdc83cba8fc8`. +Limits remain four fixture packages, bounded versions/histories, POSIX process +cleanup and controlled registry. Arbitrary topologies, public credentials, +Actions, installed APIs/declarations and production GREEN/mutant closure remain +unclaimed. + +## Chunk 23: constructive deferred-row order reach + +Accepted at merged HEAD `5fa6eca05530bb9e1f4748b7c00ae60976da2808`. +The SortedMap law and independent Map/full-sort model are unchanged: a row +changed while still deferred sorts by its current value at the restoring read. +The CI failure was reach four against minimum five, without a row mismatch. + +The generated prefix places a peer, defers a distinct target, moves the target +across the peer and compares the restoring read. A clear then restores the +original empty starting condition before executing the entire original random +suffix. Both comparator directions distinguish stale placement. All four +fixed examples, random domains, seed 20260912, both 100-run budgets and the +minimum five remain. Reach is credited only at a successful promised-order +comparison; equal ranks, replacements, deletions, clear and unpromised order +receive no credit. + +Native and independent runs pass 48/48 tests: 26 oracle/calibration tests and +22 supplemental ordinary examples. Each comparator campaign reaches the +changed-deferred comparison 96 times. Positive read/prune controls reach 16; +negative controls reach zero. An omitted-observation challenge fails the +unchanged reach obligation, separately from the product-mutant result. Exact +native and in-memory eager-placement-mutant replays each execute one actual +history. Native passes; the mutant fails the shared ordering comparison with +current row values in stale order. Full DB test-project typechecking and direct +lint pass. A pnpm freshness-triggered install attempt aborted and supplies no +lint evidence. + +Independent reviewer `metadata_timeline_review` accepted the frozen oracle +SHA256 `0c56bb680efba1fa1c66ec5d0da02716324f12d9383159a2321adced2a375754`. +Root verified the saved source, evidence and scratch-config hashes and the +48-pass native report; root did not rerun the campaign. Production is unchanged. +The 3,400 Gate A source files remain intact. Evidence under +`/private/tmp/rc2171-oracle-challenge/`: `chunk23-author-evidence.md`, +`chunk23-independent-review.md`, `chunk23-freeze.json` and +`chunk23-root-validation.json`. Freeze SHA256: +`9f9df6df1cd9da3e592d3aa6e44c2bc9674f2f4138fa4366a1ef8c1e3c670406`. +Independent review SHA256: +`fe53302e71ef0d1f6e9d902295eb3f56c1aceccf2e3faf2ada66b38dbd7a5bd5`. + +ORC-001–006 retain the accepted authority, independent model, literate layers, +unchanged suffix grammar, actual read and mutant comparison. ORC-007 retains +fixed/random parity and verified direct replay. ORC-008–010 concern reach-only +bookkeeping and preserve primary failure identity. ORC-011 has no new named +shared semantic fault requiring another formulation. This exact record supplies +ORC-012. ORC-013–016 have the crossing-rank distinction, unchanged internal +receiving scope, measured reach and separate ordinary examples. No replay of +the original CI random history is claimed because its seed/path was not saved. +Other reach states remain sampled. Existing key, comparator and mutation-during- +iteration limits remain; no public Collection or provider expansion follows. + +## Chunk 24 — declared ordinary metadata type receivers + +Author freeze on merged head `5fa6eca05530bb9e1f4748b7c00ae60976da2808`. +Independent review is pending. Production is unchanged. + +The existing public metadata type owner now imports the receiver-types oracle +and native compiler support. Its original 24 source-description campaigns remain. +The separate chunk-21 owner and its 374 strict callback/parent markers remain +byte-identical. This extension enumerates 34 context families over those same +24 source descriptions: 14 recursive callbacks, 16 direct/QueryRef child +materializers and four source-row positive neighbors, for 816 receiving pairs. + +The declared ordinary shape has required id, scalar, selected payload and +routeGroup members. An optional source profile yields a required selected value +that permits undefined. The nested ordinary record deliberately uses all four +reserved field names with ordinary source-derived types. The child callback +observes its real input before removing routeGroup with rest destructuring. +Inline parents observe the resulting child values before publication. Compiled +Collection-valued includes stay outside parent functional callbacks. Their +published facade row, get, downstream callback, QueryResult and Collection +receivers supply separate positive observations. + +- **Law:** ARCHITECTURE.md's reserved-public-field callback law requires own-shape + selected inputs to remain ordinary before user JavaScript. Its nested + materialization law preserves inline shape and publishes Collection metadata + at facade boundaries. The existing public type law supplies readonly virtual + fields, union members, ordinary property modifiers and nullable get results. +- **Reach:** all 816 consumers admit with zero diagnostics. The independent + signature law judges 4,608 actual type markers. All 6,240 declared + syntax/admission/marker states execute exactly once. The finite policy is + explicit: an entered subset owes the entire receiving matrix; unrelated seed + settings waive none; controls-only selection enters no native matrix. +- **RED:** unchanged production has 684 failing new histories: 672 expose the + ordinary callback/parent/inline metadata leak, and 12 expose loss of + discriminant-specific ordinary fields through structured source spreads. + The other 132 histories pass. The original 24 native campaigns remain RED. + A full run has 882 distinct titles: 174 pass and 708 intended native REDs. +- **GREEN:** all 42 compiler/checker/AST controls pass, as does strict checking + of the three authored files and targeted oracle lint. Targeted ESLint has + zero errors and one existing require-await warning in the legacy construction + helper. No repaired-production GREEN is claimed. +- **Mutant:** wrong observations pass source admission but fail the same + independent judgment for fabricated input metadata, lost source metadata, + nested ordinary reserved fields, routing fields, readonly/optional modifiers, + wrong singleton/array/facade containers and missing absence. AST controls + reject model-rebound or late markers, wrong rest bindings and forced callback, + query or result inference. These are checker/syntax controls, not a + production mutation campaign. +- **Limits:** the new functions are compiled rather than invoked. Runtime + callback ownership remains with the existing metadata input owner. This does + not establish built declarations, npm resolution, provider/framework timing, + arbitrary recursion or exact allocation provenance for opaque equal-typed + callbacks. No production repair or bug-class closure is claimed. + +The static allocation limitation was executed separately before this edit. +A cast-free annotated callback returning its source row and one returning +`Object.assign({}, row)` have equal callback signatures, returned public query +and output types, downstream input types and downstream query types under +TypeScript 5.9.3, with zero diagnostics. That evidence prevents opposite static +expectations based only on syntax known to the generator. It grants no exception +for the 374 existing knowable ordinary-shape markers, and changes no runtime +law. Top-level reserved-name collisions also retain their explicitly recorded +static provenance limit. + +Author tools are borrowed from the fresh Gate-A installation through symlinks, +without installs or dependency/version changes. Initial author runs inherited +Vite's package-local `.vite/vitest` cache, and configuration bundling could use +`.vite-temp` through those symlinks. That prevents a no-cache-write claim. +All 3,400 frozen Gate-A source files and critical module, lock and executable +digests remain unchanged. The final author configuration uses a separate scratch +cacheDir and the runner config loader. Shared caches were not cleaned up. + +The reproducible host is `/private/tmp/rc2171-oracle-challenge/chunk24-host`. +From its `packages/db` directory, the full native command is: + +```sh +TANSTACK_DB_ORACLE_STATISTICS=1 node ../../node_modules/vitest/vitest.mjs run --config ../../chunk24.config.mts --configLoader runner tests/query/virtual-row-fields-types-oracle.test.ts +``` + +The source/host/input/result hashes and exact reporter commands are preserved in +`/private/tmp/rc2171-oracle-challenge/chunk24-freeze.json`. The actual 24 generated +modules and 816 marker descriptions are retained alongside that manifest. +The prior rejected optional-source-root fixture and its TS2769 diagnostics remain +in the R2 artifacts. A legal LEFT-joined callback receives undefined at absence; +it now returns an ordinary record containing that optional selected value, +rather than attempting to publish undefined as a root row. Those admission +errors are separate from the intended native type-law REDs above. + +ORC-001–006: authority, independent declared type signatures, literate layers, +bounded grammar and admission, actual unannotated markers and sensitive controls +are present. Each source/API/receiver dimension supplies a named distinction; +removing union sources loses the 12 spread counterexamples, and removing nested +reserved fields loses the recursive ordinary-name boundary. ORC-007 and ORC-008 +are inapplicable to this exhaustive stateless grammar. ORC-009 uses Collection, +QueryRef, facade, input and publication as defined in the glossary; context-family +is only a source-grammar grouping. ORC-010 preserves admission diagnostics before +law comparisons and does not allocate runtime resources; the original runtime +construction cleanup remains intact. ORC-011 adds no speculative second semantic +model; the equal-type experiment records the concrete inference limitation. +ORC-012 uses this versioned record and exact frozen artifacts. ORC-013 pairs +ordinary pre-publication input with genuine source and published-facade metadata, +and checks nested ordinary names and rest removal. ORC-014 makes no provider or +runtime handoff claim. ORC-015 measures every finite receiving state, including +partial/unrelated-seed rejection. ORC-016 keeps all native inputs and hostile +observations on the shared generated signature judgment; supplemental concrete +helper tests remain in their ordinary files. + +### Chunk 24 R2 — retain the actual functional callback boundary + +Independent review found a recorder gap in the R1 syntax guard. Moving the whole +callbackInput marker stanza into an unannotated map helper could reconstruct a +clean ordinary record from the real input. The real fn.select input still had +unwanted metadata, but the displaced source admitted without diagnostics and +all four type judgments passed. The syntax guard also accepted it. The retained +reviewer script and result are `chunk24-independent-bindings.mjs` and +`chunk24-independent-bindings-result.json` in the scratch evidence directory. + +The new shared-grammar calibration first failed against the unchanged R1 guard: +`chunk24-r2-helper-red.json` records its single intended assertion RED. Source +admission and all type judgments succeeded first; this is an observation-boundary +failure rather than a type-law or setup failure. The guard now requires the +containing arrow to be the actual fn.select argument. A rest declaration must +also be direct in that same block. The calibration then passes, retaining its +zero-diagnostic, all-type-judgments-pass premise. + +The 816 native pairs, 4,608 marker laws and 6,240 finite reach states are +unchanged. R1 sources were reconstructed and verified byte-for-byte against +`chunk24-freeze.json`; `chunk24-r1-source/` preserves them. The RED calibration +sources are separately retained in `chunk24-r2-prerepair-source/`. R1 review and +author evidence remain unchanged. R2 adds one checker calibration, for 43 total +controls; it changes no production code or source-type expectation. The native +672 metadata-leak and 12 union-spread failures remain open. R2 hashes and final +comparison results are in `chunk24-r2-freeze.json`; independent re-review is +pending. + +R2 author result: 883 unique tests, 175 pass and the same 708 intended native +REDs. All 882 prior title/status pairs remain unchanged. The 43 checker/AST +controls pass, all 6,240 receiving states still count one, and strict authored +TypeScript plus targeted lint pass. A passing positive-only selection with an +unrelated seed still fails the missing finite reach obligation. The R2 freeze +SHA256 is `3ccfabf4cd7a94f7e39a2438740d9c0c01fcbde1aebceeb945d85e4b97c285bf`. + +### Chunk 24 R2 independent acceptance + +Reviewer `/root/handler_types_review` independently accepted the bounded R2 +scope. Its full native result has the same 883 unique titles and statuses: +175 pass, 708 intended native REDs. All 6,240 reach states count one. A +controls-only run passes 43 controls with 840 native tests skipped and no +unentered-matrix reach failure. A valid unrelated-seed positive-only selection +passes one native history, skips 882 tests and fails the 6,234 missing-state +reach obligation. An earlier malformed replay setup remains recorded without +evidence credit. Strict checking of the three authored files passes. + +The reviewer's exact helper relocation retains zero diagnostics and four true +type judgments, but the repaired observation guard rejects its map callback. +Ten additional independent compiler controls reject one wrong facet each, +covering required selected and nested fields, nested null, call/construct +capabilities, union fields, readonly modifiers, array any and Date extras. +There is no remaining supported oracle gap within this bounded static slice. +Production defects remain RED; this acceptance authorizes no runtime ownership, +opaque-allocation provenance, built declaration, npm or provider/framework claim. + +The final reviewer report is +`/private/tmp/rc2171-oracle-challenge/chunk24-independent-review.md`, SHA256 +`8b79a8ab77f9cc5c5722b0958909cb166fcdc91288524e073310ad5b008d1ab5`. +The report's artifact index is `chunk24-independent-review-manifest.json` in +that directory. It indexes independent native, controls, valid partial, reach, +strict-type, helper-boundary and facet-control results. R1's accepted helper +false green and R2's rejection remain separately reproducible. + +The original-worktree `pnpm lint:changed` closeout attempt invoked pnpm's +freshness install and aborted before module removal with +`ERR_PNPM_ABORTED_REMOVE_MODULES_DIR_NO_TTY`. No install completed. The exact +underlying `node scripts/agent-lint-feedback.mjs` ran directly instead; scoped +and whole-branch direct oracle lint pass. No pnpm command ran in the borrowed-tool +hosts. No dependency/version workaround or generated API documentation changed. +## Chunk 25: native Solid publication recording and CI import census + +Independently accepted at production HEAD +`5fa6eca05530bb9e1f4748b7c00ae60976da2808`, freeze SHA256 +`96059399e2365b02b83e2cac2e73fb792f2215c4b31df9c10f412243ee66620f`. +The four owned paths change only test machinery: Solid's conformance driver, +the shared suite and its small publication checker, and the ordinary operator +import census. No library, release-tool, dependency or generated-doc edit ran. + +**Law and authority.** Solid's public result is a callable Accessor. The shared +conformance contract and publication-log cut require settled selected rows to +match the independent source/history model. Empty loading turns remain legal; +earlier turns may not contain the previous source/group's IDs. The old recorder +read removed `.data` fields, while the old subset check silently accepted the +resulting undefined. This repairs the observation, without changing scheduling +or query-identity policy. + +**RED and GREEN.** Before fixing either recorder site, the stronger shared +comparison gives 60 passes and two intended Solid assertion failures. Full +Solid source typechecking also rejects `.data` and broad fixture indexing. +The ordinary import census gives one obsolete-import failure and 18 passes. +After invoking both Accessors, retaining literal fixture keys and removing the +obsolete census entry, native Solid has 62 passes with typechecking enabled; +whole-package tsc has zero diagnostics. Census/operator cases give 19 passes. +The affected actual React, Preact, Vue, Svelte and Angular selections give +10, 5, 5, 5 and 3 passes respectively. Unselected scenarios receive no credit. + +**Reach and hostile checks.** Every one of seven receiving registrations keeps +all 32 source-ID histories; React registers twice. Reactive drivers each reach +32 initial and nine swapped nonempty comparisons. Pooled counters deduplicate +within their one history, so independent per-target observations additionally +verify both unconditional cuts in every registration: b has exact final +data/state IDs [2], and a has [1,3]. Eight checker controls preserve allowed +empty/loading prefixes and empty modeled results, while rejecting omitted, +undefined, dropped and stale final publications and stale prefixes. + +Independent in-memory effect-reader and first-turn-reader mutants fail at the +shared selected-row comparison. The effect mutant fails both stories; the +first-turn mutant fails source-ID swapping while pooled publication is repaired +within the permitted turn. That pooled survival is not counted as a kill. +Returned mounts dispose exactly once under both failures: 3/3 and 8/8. The +original assertion remains primary through ScenarioLifetime. An early typed +scratch observer failed transformation before tests and receives no kill credit. + +Root verified all 233 source, 15 config and 50 evidence hashes before this +append. The independent reviewer verified the same frozen inputs and all +3,400 Gate A source hashes plus critical tool hashes/mtimes. Exact pre-fix +Solid bytes are retained; one formatting-only blank-line difference in a later +reconstruction was reconciled against that original hash. Neither that +reconstruction nor root's hash check substitutes for independent execution. + +ORC-001–006 retain public/shared authority, independent source rows, literate +layers, unchanged grammar, real receiving paths and calibrated comparisons. +ORC-007–008 are inapplicable to this finite grammar with unchanged model state. +ORC-009 preserves canonical binding and publication vocabulary. ORC-010 has +measured primary-error and disposal fidelity. ORC-011 has no new named shared +semantic fault requiring another model. This record supplies ORC-012. +ORC-013 distinguishes missing/stale output from legal loading/model-empty +neighbors. ORC-014 has actual framework receiving hosts with controlled source +sync, without a backend/device claim. ORC-015 measures each registration and +both pooled targets without lowering budgets. ORC-016 keeps the declared +checker separate from ordinary driver controls. + +Evidence under `/private/tmp/rc2171-oracle-challenge/`: +`chunk25-author-evidence.md`, `chunk25-independent-review.md`, +`chunk25-independent-evidence.json`, `chunk25-independent-final-integrity.json` +and `chunk25-root-validation.json`. Independent report SHA256: +`1400b016e492c5e65aa84f1622292df76a98bade26386d52e07b98dc03a5dd20`. +The claim remains bounded to the existing list-query cuts: no arbitrary +scheduler interleavings, within-turn transients, findOne, complete status or +keyed-state traces, backend/provider behavior or native-device scheduling. + + +## Chunk 26: generated error catalog observation and command ownership + +Reviewed merged HEAD: `5fa6eca05530bb9e1f4748b7c00ae60976da2808` plus the four frozen test/support paths in `chunk26-r3-freeze.json`. Reviewer: `/root/facade_metadata_review`. This is an independent review under `.agents/skills/oracle-review/SKILL.md`; the rubric, glossary, coverage-map Production error messages owner, source oracle, fixture, CLI controls, command controls, actual old generator, and erasure boundary were read. No production/tooling repairs, generated-page edits, installations, commits, or pushes were performed by this reviewer. + +### Verdict + +ACCEPT the R3 oracle/control chunk as a bounded pre-repair gate. The named class-link comparison gap is repaired. No further finding remains inside that named gap. The old CLI and root commands still produce twelve intended assertion failures; this verdict does not claim product GREEN or authorize broader closure. Root controls repair authorization and the versioned coverage/review record. + +### Independent execution and provenance + +The reviewer checked670 recorded source/host/config/protected/artifact hashes against the R3 freeze: zero mismatches. The four active and host test/support paths match. All154 DB/IVM source paths match their captured bytes, both old root command bodies remain unchanged, and the protected generator, erasure implementation, full catalogs, sample arguments, census, formatter, and tracked generated errors page remain unchanged. An additional fresh check of the immutable GateA source manifest found3400 matching records and zero mismatches. GateA supplies installed test tools only; no clean-install, packed-consumer, or fresh-build credit is claimed. + +From `/private/tmp/rc2171-oracle-challenge/chunk26-host/packages/db`, the reviewer independently ran: + +```sh +TANSTACK_DB_ORACLE_STATISTICS=1 node ../../node_modules/vitest/vitest.mjs run --config ../../chunk26-db.config.mts --configLoader runner --coverage.enabled=false tests/production-error-messages-oracle.test.ts tests/generate-error-docs.test.ts tests/error-docs-commands.test.ts --reporter=default --reporter=json --outputFile=/private/tmp/rc2171-oracle-challenge/chunk26-r3-independent-native.json +``` + +Result:39tests,27PASS,12intended assertionRED. Every status matches the author's R3 run by full test name; all prior38statuses are unchanged. The17 existing runtime/class/source-census checks pass. Four shared-checker calibrations pass. CLI ordinary controls retain6PASS/9RED; controlled root invocation controls retain2RED. The actual copied old CLI exits0 and emits empty stdout, which reaches the catalog comparison and fails the missing DuplicateDbInstanceError heading. Declared reach is cli-stdout-received1/catalog-compared0; completed-comparison0 follows from the intended assertion mismatch, rather than a fixture setup failure. The test process exits1 for these assertions. Vite also prints missing Expo tsconfig warnings for unrelated example directories; all selected39tests execute, so those warnings are not credited as RED evidence. + +### Concrete comparison challenge and correction + +The class-link law requires an HTML ID matching each coded production-message URL. The R2 checker parsed raw Markdown as HTML. In a separate disposable copy, the reviewer moved only the first required class anchor, error-2, into the actual generator's canonical fenced text-message form. The unchanged shared checker accepted that wrong observation: the negative calibration failed with `expected [Function] to throw an error`. No frozen source was changed. The installed marked17 renderer independently renders that fenced anchor as escaped code with zero HTML IDs, whereas raw DOM parsing reports error-2. This is an enforcement/model-observation defect under the accepted link law, rather than a proposal for a new renderer contract. + +The author retained the failing old-checker calibration before repairing the oracle. R3 excludes canonical generator text fences before class-ID extraction, retains HTML parsing for element/quote/attribute-spacing freedom, and states the bounded extraction limit beside the checker. The reviewer executed the repaired negative calibration through the same checker: PASS. The existing class-anchor calibration also executes the alternate span/single-quoted/spaced-ID positive observation and then rejects the deleted ID: PASS. Neither calibration derives expected catalog facts from renderer output. + +The original missing-class-anchor acceptance is separately preserved in the author's earlier old-checker negative calibration. Both oracle repairs change only comparison/calibration logic, with original runtime/source/census laws and old tooling unchanged. + +### Rubric outcomes + +| Requirement | Outcome | +| --- | --- | +| ORC-001 | PASS within scope. Frozen catalogs and established coded-link contract supply catalog authority; user decision supplies generated-doc ownership. Runtime, bundle and host limits remain distinct. | +| ORC-002 | PASS. Expected codes, names, files and targets come from frozen facts; production generator supplies observations only. The synthetic calibration page supplies minimal facts, not copied rendering machinery. | +| ORC-003 | PASS. Opening prose and adjacent checker/fixture prose distinguish authority, full-catalog input, copied production CLI, stdout checkpoint, comparison and limits. | +| ORC-004 | Not newly triggered: full frozen catalog census is fixed exhaustive input, not a new generated-history claim. Ordinary CLI/control tables remain bounded examples. | +| ORC-005 | PASS as pre-repair evidence. Actual copied old source/full catalogs reach stdout and the real comparison. Empty stdout is representable and rejected. No completed-comparison/product-GREEN claim is made. | +| ORC-006 | PASS. Missing heading, missing class ID, missing site anchor and fenced-only class ID are rejected by the shared comparison; the old fenced-ID checker demonstrably accepted the wrong observation. Native old-tooling failures occur at assertions. | +| ORC-007 | No new important generated property: no new random/replay obligation for this fixed catalog census. Existing unchanged campaigns are not credited as rerun here. | +| ORC-008 | Not triggered: no stateful reference-model change. | +| ORC-009 | PASS. No new model-only subsystem state or lifecycle vocabulary; catalog/stdout/checkpoint retain their direct meanings. | +| ORC-010 | PASS by inspection and native execution. Disposable synchronous fixture preserves primary body failure, including thrown undefined, retains cleanup diagnostics through AggregateError, and releases temporary directories. | +| ORC-011 | PASS for the concrete named shared-semantics hypothesis. Actual installed Markdown rendering distinguishes code text from HTML IDs; R3 adds the same wrong-observation calibration. No general Markdown-equivalence claim is made. | +| ORC-012 | This exact-head/freeze report records all outcomes, execution and limits; root must retain the report/hash in its versioned review record. This is an oracle gate, not bug-class/product closure. | +| ORC-013 | PASS within bounded controls. Valid HTML-ID variants versus absent/fenced-only ID distinguish target semantics; missing/stale pages and invalid-argument controls distinguish the named CLI boundaries. | +| ORC-014 | PASS with explicit limits. Real Node runs the copied source CLI. Controlled erasure/Typedoc recipients prove root-command wiring only; real bundle erasure, actual Typedoc and release Actions remain later receiving gates. | +| ORC-015 | PASS for reported reach. The actual stdout boundary is reached1; completed comparison0 is honestly recorded during intended RED. No generated-history reach claim is added. | +| ORC-016 | PASS. New oracle tests are shared-checker calibrations and the production receiver. Ordinary CLI expectations and root invocation examples stay in ordinary test files. | + +### Limits and next evidence + +This acceptance is limited to the actual frozen catalogs, current generator source and canonical fenced-message class-ID distinction. It does not establish arbitrary Markdown layouts, full rendered-heading validation, duplicate-heading multiplicity, every prose/template byte, all possible CLI argument lists, asynchronous fixture bodies, pnpm lifecycle execution, installed-package consumers, browser host behavior, or CI/Actions execution. Existing runtime/source/census checks remain supplemental to their declared frozen-input scope. The erasure script itself remains unchanged and must later run against freshly admitted ESM/CommonJS dist while generated docs are stale. The real docs-generation/Typedoc path must later run in an isolated generation workspace, followed by generator --check. Later tooling repair still needs native GREEN and restored-old-source hostile-mutant evidence before product closure. The user prohibition on committing generated docs remains in force. + +### Evidence hashes + +- `chunk26-r3-freeze.json`: `2baf81b3904bf1f8005dee31aef2b0c7b2d46bb2b758825ee59ecffaa3fbce7f` +- `chunk26-r3-independent-native.json`: `e53c6525a97ffb73f00f7881fb9abff5ea56160a35ebd970ee616ba6168924c0` +- `chunk26-r3-independent-native.log`: `8033eae6254b1bb62f9f333de2bee63cbf6b842d55a0f4978f59728b6304d805` +- `chunk26-independent-fenced-id.json`: `e19d6c3b0f5117f6a6740d5e1ad17930dd64670a41649a0693beb541743e0774` +- `chunk26-independent-fenced-id.log`: `8a86ccb9dd59201e722036ee701b9fe69d072588db4eeb158504fed6d64d48fa` +- `chunk26-independent-fenced-renderer.json`: `f85c7153269d4c2bfd08641d56ea40c7150143f84ad07c6fe7df386ca3c7ffcf` + +Root verified 658 frozen scope/host/config/artifact hashes and 12 protected files before repair. No mismatches. The 26-chunk oracle phase is independently accepted; accepted REDs remain release repair gates or explicitly recorded follow-up scope. No executable production repair preceded this checkpoint. diff --git a/docs/contributing/oracle-reviews/2026-10-28-pr-2171-review-coverage-matrix.md b/docs/contributing/oracle-reviews/2026-10-28-pr-2171-review-coverage-matrix.md new file mode 100644 index 0000000000..4d39958bd5 --- /dev/null +++ b/docs/contributing/oracle-reviews/2026-10-28-pr-2171-review-coverage-matrix.md @@ -0,0 +1,296 @@ +# PR #2171: review-to-check coverage matrix + +Audit head: `858b174a65f160d13d01f0324c7481cc8e4eba3a`. +This is a self-review of the proposed checks, with temporary executable probes. +No production repair or permanent oracle extension was made in this audit. + +The answer to “would the original 15 catch everything?” is **no**. The +[expanded plan](2026-10-28-pr-2171-oracle-challenge.md) has 26 checks. This +matrix accounts for 53 findings and four evidence caveats. Accounting is not +proof that all checks intercept their targets. Known current failures keep +those laws open, even though their existing narrower oracles pass. + +## Laws, experiments, and remaining enforcement + +No law change needs a new maintainer decision. Independent cached callers +reject conflicting local row options. Mounted hooks retain inline options +until identity changes. Metadata follows authoring shape, with the last present +structured spread winning. New functional objects receive output metadata; +direct source returns retain source metadata. Inline values keep authored +shape. Query and Electric ignore legacy runtime returns, and their types reject +them. Query does no automatic refetch. The maintainer explicitly selected a +stable Query minor and coordinated RC DB peers for the accidentally stable +Query/Offline packages. + +| Law and authority | Existing evidence and missing boundary | Experiment on current unchanged head | Inference and enforcement destination | +| ---------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Independent same-identity callers reject conflicting getKey/schema; explicit maintainer decision | Existing admission model/drivers check preload, observer preload, subscription, and client render. Cached SSR before subscription is missing. | Core server read: three conflict assertion failures, three same-option controls pass. Actual React server render: the same three failures/three controls. | G09 is confirmed-open. O16 must extend the shared model's receiving paths before production repair. A subscription-only check is insufficient. | +| A reusable local option claim survives transport replacement; same decision, hydration transports no function references | Source claims have hydration histories, but local row-option claims do not receive that lifecycle. | Newer hydration replaces a one-row preloaded result; three conflicting declarations are accepted, three compatible controls pass. | G10 is confirmed-open. O04 needs claim lifecycle histories and post-replacement admission, with local claims preserved independently of transport. | +| The guide's successful write example confirms its row before optimistic settlement; explicit-confirmation migration contract | Query's generic handler owner checks confirmation, but does not execute this fenced example. Typechecking cannot detect its omitted confirmation. | Extracted successful POST handler writes one remote row; Collection has zero rows after `when('settled')`. | G01 is confirmed-open. O21 must execute the actual guidance through O17's law. No production adapter failure is alleged: its reversion follows the approved behavior. | +| Rejected cache admission cannot corrupt the first claim; same cache contract | Current grammar changes both options together and ends at first admission. | Previous pass: two wrong guards survive all 144 cases; independent-option and repeated-admission comparisons reject them with 12 and 24 failures. | O01/O02 remain unimplemented test gaps. Current correct behavior passes these distinguishing probes; that does not supply SSR/hydration coverage. | + +The probe inputs, checkpoint construction, commands, setup failure, and logs +are described in the expanded plan's follow-up experiment section. These are +RED witnesses, with compatible controls. They are not repair GREEN results. + +## Source inventory and evidence notation + +- **A01–A15:** first pasted review, preserved in the task attachment `Pasted text.txt`. Its reviewer read the worktree diff and Changesets 6.0.9, queried published manifests, and ran no tests. Original line references are retained below; that review supplied no commit SHA. +- **B01–B10:** next supplied review, evaluated from `7c53f271a` in the [first versioned evaluation](2026-10-28-pr-2171-review.md). B10 has two subclaims, both retained here. +- **C01–C15:** latest supplied review, targeting `0a60ad85b`; evaluated from `66454803b` with repairs in `68228aaf3`. The [second versioned evaluation](2026-10-28-pr-2171-second-review.md) records original claims, repros, mutants, and limits. C01 includes core and React paths. +- **G01–G11:** seven inline comments, three outside-diff findings, and one additional prose suggestion in GitHub's two reviews. First review covers through `66454803b`; second through the audit head. Stable links identify inline comments. Outside-diff findings are in the corresponding review body. +- **W01–W02:** the two warnings in the bot's [mutable walkthrough](https://github.com/TanStack/db/pull/2171#issuecomment-6196397964), inspected at the audit head. Its merge-risk summary repeats G05/G08/G09/G10; it is not another independent finding. + +`O01`–`O26` refer to the numbered checks in the expanded plan. Their owner, +independent judgment, legal histories, cut, and distinguishing experiment are +specified there. They are proposals except where the evidence explicitly says +an existing owner already executes them. + +**Historical** means the linked evaluation records the experiment at its own +head; this audit did not replay every original fault. **Source** means direct +source inspection, not an executed behavior/build failure. **Current RED** +means an intended assertion failed at this audit head. **Proposed** means the +broader check has not run. `already-fixed` and `stale` describe the reported +trace; they do not close the broader unimplemented improvements. + +## A: first supplied review + +The source calls A01–A04 high severity. A05–A08 are release follow-ons, +A09–A11 correctness findings, and A12–A15 design/cleanup/docs findings. + +| ID | Original claim and proposed correction | Technical disposition/evidence | Concrete check and distinguishing cut | Remaining work/durable destination | +| --- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| A01 | `compiler/index.ts:2062`: joined source spread uses FROM metadata; right-join missing-left rows fall back to output metadata. Honor selected source. | `already-fixed` for the reported trace; historical metadata attribution repairs. | O06/O08/O10/O11: right-side source with pending write, matched/unmatched join, full four-field comparison at publication. | Complete multi-row/join-transition/facade receiving grammar; metadata owner. | +| A02 | `useLiveQuery.ts:674`: getKey/schema in derived identity causes documented config to throw without queryKey; restore supported initial render and useful diagnostics. | `already-fixed`; historical real React derived-identity and retention witnesses. | O05/O19/O25/O26: initial config render without queryKey succeeds, actual identity replacement applies options, preparation errors remain coded. | Other applicable framework receivers and exact-head execution. O16 is distinct SSR admission, not evidence for this initial browser path. | +| A03 | `version-packages.mjs:58`: first RC ignores stable adapters, leaving exact old DB pins and duplicate DB copies. Republish compatible adapters. | `stale` exact-pin mechanism; stable adapters now declare RC peers. Original reviewer supplied source evidence only. | O13/O22: actual first-RC inventory, pack both adapters, resolve one DB in consumer and exercise required APIs. | Real package/consumer receiving fixture; source-only manifest strings are insufficient. | +| A04 | `version-packages.mjs:145`: each RC patch bumps stable adapters on latest and pins an exact RC DB; fixture omits those dependencies. | `stale` exact dependency pins; stable 1.x RC peers are now deliberate. | O13/O22: repeated patch/minor releases, packed manifests/dist-tags, no invented adapter bump, supported consumer install. | Generated release histories and actual registry-command fixture. Preserve stable-tag policy separately. | +| A05 | `version-packages.mjs:208`: RC exit drops planned dependent bumps, leaving stable adapter pinned to RC. Preserve dependency updates at exit. | `stale` old exact pins; historical release-script repairs/fixed fixtures. | O13: exit after candidate/patch work; compare every packed dependency and required bump with independent plan. | Generated dependency graph, including stable dependents; release owner. | +| A06 | `version-packages.mjs:194`: exit reconsumes released changesets; consumed minor plus pending patch yields wrong version and duplicate changelog. | `already-fixed` reported release path; current baseline release fixtures pass. Original-fault replay not run here. | O13: minor consumed in RC, pending patch, exit, repeated no-op; version and changelog entries exactly once. | Generated consumption ledger and changelog comparison. | +| A07 | `version-packages.mjs:110`: pending mixed changeset aborts; stable-only changeset marked consumed without release. | `already-fixed` reported path; existing release fixtures. Broader sequence unrun. | O13: enter RC with mixed and stable-only work; compare releases and consumption independently. | Generalize fixed workspaces into generated release histories. | +| A08 | `version-packages.mjs:87`: failure leaves rc.0 rewrites/pre.json; rerun skips setup and bumps wrong packages. Restore files on failure. | `already-fixed` reported path; release failure/retry fixture. | O13: inject failure at each mutation boundary, compare workspace restoration, retry and publish once. | Extend failure-stage grammar and registry-command receiving path. | +| A09 | `useLiveQuery.ts:248`, infinite hook: failed hash after descriptor deferral strands later direct reader. Resume all deferred starts. | `already-fixed`; existing source-ID owner has rejection/release and multiple throwing-start witnesses. | O19: rejected live/Suspense/infinite preparation, then direct shared descriptor preload reaches ready/row. | Inventory partial-construction stages; preserve real-host failure/retry receiving evidence. | +| A10 | `electric.ts:1210`: ignoring legacy `{ txid }` stops waiting and can flicker; reviewer favors loud rejection or old waiting. | `accepted-design` for ignored runtime returns, explicitly chosen later. Explicit acknowledgement remains required. | O17/O21: handler awaits actual acknowledgement, pending row persists until confirmation; no-ack control follows settlement law. | Adapter histories plus actual migration snippets. Never restore post-write rejection to satisfy this obsolete recommendation. | +| A11 | `query.ts:4368`: retained 1.x auto-refetch contract warns removal in v1. Correct obsolete warning. | `stale`; contract intentionally changed and automatic-refetch path removed. | O21: changed API/warning/migration consistency check, successful-write behavior. | Authoritative explicit-refetch guide and removed-symbol check. | +| A12 | `compiler/index.ts:409`: metadata protection only at root flag; Effects/include Collections allow reserved-name override. Enforce promised boundaries consistently. | `stale` as a blanket rule: Collection-row traces were repaired; inline values keep authored shape. The current Effect output contract needs separate inspection before imposing Collection metadata. | O10/O11/O12: source/projection collision through root, Effect, facade, callback and inline control at public boundary. | Receiving grammar must preserve the distinct inline/Effect/Collection contracts; no blanket sanitizer. | +| A13 | `useLiveSuspenseQuery.ts:288`: always-string hash makes SSR throw, truthy checks and optional type dead. Remove unreachable code. | `stale`; source cleanup recorded. | O23: narrowed hash type plus actual caller/branch inventory. | Static check; runtime green cannot prove absence of dead code. | +| A14 | `compiler/index.ts:2068`: attachVirtualPropsToSelected creates per-row copy closure even without copy. Hoist/remove allocation. | `stale` reported implementation; source allocation concern retained, no timing claim. | O24: compile once/process N rows; count closure creation versus needed copies. | Deterministic work/AST placement probe, semantics owner unchanged. | +| A15 | `live-queries.md:37` misstates projection collectionId; migration names unexported enrichRowWithVirtualProps. Correct attribution and exported guidance. | `already-fixed` reported docs; historical evaluation/source. | O21/O25: compile actual snippet via public export; compare documented source/output cases. | Docs exports and behavior receivers. Keep internal helper out of public migration instructions. | + +## B: next supplied review + +Original paths/lines refer to its reviewed tree. B01/B03–B08 are behavioral or +compatibility findings; B09/B10 include cleanup and production-error concerns. + +| ID | Original claim and proposed correction | Technical disposition/evidence | Concrete check and distinguishing cut | Remaining work/durable destination | +| --- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | +| B01 | `OfflineExecutor.ts:551`: stable adapter calls RC-only completeWithoutCommit while accepting DB ^0.15; restored work can remain held. Narrow peer or avoid absent API. | `already-fixed`; historical real DB 0.15.2 capability evidence and manifest change. | O13/O18/O22: packed consumer restore under each supported peer, exact outbox/caller settlement. | Workspace dependency and packed consumer checks are separate; G05 remains open. | +| B02 | `first-rc.md:17`: removing Query automatic refetch/return type in a stable minor/latest breaks ^1.4 users; use major. | `accepted-design`; maintainer explicitly changed major request to minor. | O13/O17/O21: verify requested minor/tag and explicit migration cost; prove new handler behavior. | Preserve deliberate compatibility warning; a test must not override this release decision. | +| B03 | `live-query-options.ts:184`: inline key/schema reference rebuilds each render, possibly loops. Retain options by query identity. | `already-fixed`; historical wrong identity mutant fails six React derived cases. | O05/O19: repeated native hook render, source publication, actual identity change, render budget. | Other identity-comparing host witnesses, without imposing React semantics on reactive bindings. | +| B04 | `electric.ts:1291`: rejectReturnedHandlerValue throws uncoded Error after server commit, falsely fails caller and invites duplicate retry. | `already-fixed`; historical original wrapper fails three handler-outcome histories. | O17/O25: server success plus legacy return must fulfill with confirmed row; genuine handler rejection retains exact error. | Keep 12-history owner and error-artifact checks in required validation. | +| B05 | `compiler/index.ts:2119`: functional spread/collision guesses mainSource instead of selected joined source. | `already-fixed` under approved authorship rule; historical metadata grammar rejects source guessing. | O06/O08/O09/O12: direct source versus fresh functional object from either joined source, complete rows/four fields. | New-object ownership is output; do not infer provenance from value equality. | +| B06 | `compiler/index.ts:2129`: authored `$collectionId` changes ordinary projection ownership contrary to docs. | `already-fixed`; historical collision grammar. | O06/O10/O11/O12: toggle one reserved field without changing ordinary fields; same output attribution at every boundary. | Callback/downstream/facade receiving and full-result comparisons. | +| B07 | `first-rc.md:6`: Convex/Preact absent on branch, version command throws; merged packages still need API migration. Merge main and include them. | `already-fixed` missing-package trace after merge; historical actual-inventory fixture. | O13/O14/O22/O26: actual non-private inventory, run version/build/types and receiving contracts for merged packages. | Exact-head clean workspace checks; an older package list is insufficient. | +| B08 | `query.ts:4491`: Query ignores legacy return while Electric throws; stale UI versus caller failure. Align adapters. | `already-fixed`; approved ignore-at-runtime/reject-in-types policy. | O14/O17: same CRUD/outcome/result histories through both adapters and public type input compiler. | Keep explicit confirmation controls and generated type admission. | +| B09 | `query.ts:4491`: three identical pass-through wrappers and four repeated QueryCollectionUtils types. Consolidate wrapper/type alias. | `already-fixed` reported duplication; historical source cleanup. | O23/O24 with O17 semantics: source inventory and shipped bytes, unchanged mutation outcomes. | Static/size evidence, not an invented behavioral oracle. | +| B10 | `useLiveInfiniteQuery.ts:282`: React always false leaves shared equality branch dead; `useLiveQuery.ts:249` uncoded Error ships full text. | `stale` older React shared-policy concern; other framework callers prohibit blanket deletion. `already-fixed` coded identity-error subclaim. | O23 checks all framework callers; O25 executes diagnostic census and production artifact erasure. | Preserve both subclaims. C14 later identified a genuinely unused mode; same claim cannot be inferred from React alone. | + +## C: latest supplied review + +The reviewer reported executed repros for C01–C05 and source-only packaging +claims C06–C08. The second evaluation distinguishes observed results from +specific TypeError/ERESOLVE predictions. + +| ID | Original claim and proposed correction | Technical disposition/evidence | Concrete check and distinguishing cut | Remaining work/durable destination | +| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | +| C01 | `live-query-options.ts:270`, core test 411 and React 3569: own tests fail after retention change. Fix assertions without restoring render churn. | `already-fixed`; historical two core-file failures and real React failure, then GREEN. Core option tests pass again in this audit. | O05/O26: execute both changed files on exact head, preserve real identity replacement controls. | Test selection and build fingerprints; no new runtime model was needed to detect red tests. | +| C02 | `index-suggestion.test.ts:225`: JS `!row.$hasPendingWrites` is false, so expected warning never occurs. Use query expression directly. | `already-fixed`; historical failing diagnostic, current index tests pass. | O20/O26: true/false pending source variation and virtual-field diagnostic at actual query construction. | Actual UI receiver and proxy-expression check. | +| C03 | `compiler/index.ts:1227`: flat union alias loses pending/origin metadata. | `already-fixed`; historical final metadata grammar against old compiler has 220 failures/189 controls. | O06/O07/O09/O10: both union branches nonempty, full rows/four fields at pending/settled/publication cuts. | Existing fixtures cover one populated branch only; simultaneous contributions remain a gap. | +| C04 | `compiler/index.ts:2117`: only outer root blocks selected reserved names; subquery spoofs metadata. | `already-fixed` recursive publication trace; same historical grammar. | O06/O10/O11/O12: colliding QueryRef projection, downstream callback reads ordinary scalar before publication. | Detect input-time spoofing, not just final reserved-field sanitization. | +| C05 | `compiler/index.ts:2079`: later unmatched spread deletes earlier source metadata. | `already-fixed`; historical first-present mutant fails four matched controls. | O08/O09/O10: unmatched→matched→unmatched, both spread orders, callback observations. | Current match grammar is static; transition/rollback coverage remains proposed. | +| C06 | Query peer ^0.15 accepts DB lacking fail/reportError/reportHealthy. Narrow peer to APIs actually used. | `already-fixed`; historical old DB boundary has undefined methods and stuck preload, not an executed uncaught TypeError. | O13/O22: execute health failure/recovery in installed supported consumer; reject unsupported combinations. | Manifest string tests alone do not establish capabilities. | +| C07 | Query breaking minor/latest should be major. | `duplicate` of B02; original compatibility concern preserved, approved minor choice remains. | O13/O17/O21: requested version/tag, confirmation and migration behavior. | No major bump without a new maintainer decision. | +| C08 | Offline stable 1.2/latest with RC-only peer causes old DB peer-resolution failures. | `accepted-design`; coordinated upgrade deliberately required. Specific ERESOLVE wording not run. | O13/O21/O22: actual consumer resolution and documented upgrade/retain-old-adapter alternatives. | Stable tag does not imply 0.15 support. Keep resolver limits honest. | +| C09 | Same hash plus changed key/schema borrows other caller's Collection. Reject independent conflicting callers. | `already-fixed` preload/client-render traces; historical guard-removal RED core 36/React 6. Current G09/G10 expose additional paths. | O01/O02/O03/O04/O05/O16: every admission path, independent declarations, replacement and post-rejection reuse. | Cache owner remains open for the demonstrated SSR/hydration counterexamples. | +| C10 | RN ListsScreen 118 uses removed `$synced`, JS negation and reserved name; Saving never shows. Select ordinary pending field and consume it. | `already-fixed` source wiring plus historical core pending-field observation; native component execution unproven. | O06/O10/O20/O21: actual example projection/component across held/settled write. | Real RN receiving witness; do not credit a copied core query as a device run. | +| C11 | `completeWithoutCommit` can complete failed work; internal API is cross-package and publicly documented. Guard state, remove public instructions for helper. | `already-fixed`; historical missing-guard mutant fails four state histories. Current outcomes suite passes. | O18/O21: repeated attempts from all four states, public rows/outcome unchanged on refusal; guide checks. | Packaged Offline restore still needs its capability receiver. | +| C12 | `local-storage-collection.md:118` retains removed isPersisted. Replace with when('settled'). | `already-fixed` targeted guide; G07 finds other active coverage references. | O21: whole authored-doc migration inventory with historical/before examples explicitly marked. | No generated-doc edits; do not constrain search to one changed guide. | +| C13 | `db/src/index.ts:54`: warnOnce/resetWarnings unused. Remove implementation, exports and obsolete tests/resets. | `already-fixed`; historical caller inventory and cleanup. | O23: production export/caller census, fixtures distinguished from actual API uses. | Static absence and size evidence. | +| C14 | `live-query-window-binding.ts:75`: dependencies recompile mode has no callers. Remove only unused mode. | `already-fixed`; historical source check, framework runtime neighbors pass. | O23/O26: all adapters' supplied modes and normal test graph. | Preserve active structural-equality policy and reactive callers. | +| C15 | `compiler/index.ts:1063`: alias array allocated for every functional row; hoist to compilation. | `already-fixed` source placement; no measured speedup claimed. | O24: alias discovery once per compile for varying rows; output owner unchanged. | Deterministic work probe and shipped-byte measurement. | + +## G: GitHub review comments + +Inline comments link to their stable source. G08 is outside the first review's +diff; G09/G10 are outside the second review's diff. G11 is the second review's +separate “additional/unverified” wording suggestion. The reviewers' current +line references below are checked against the audit head, not assumed from +old inline positions. + +| ID | Original claim and proposed correction | Technical disposition/evidence | Concrete check and distinguishing cut | Remaining work/durable destination | +| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | +| G01 | [Error-handling guide 705](https://github.com/TanStack/db/pull/2171#discussion_r4404724417): successful POST lacks refetch/write confirmation. | `confirmed-open`, current RED: server has row, Collection empty at settlement. | O17/O21: extract actual snippet and run successful/failing write histories with explicit-confirmation judgment. | Correct authored guide and retain a behavior receiver; compilation alone is insufficient. | +| G02 | [RN pending badge](https://github.com/TanStack/db/pull/2171#discussion_r4404724429): select source pending field under ordinary name. | `duplicate` of C10; source correction present. | O20/O21: actual component receives pending/settled source states. | Native execution still unproven; preserve this detail despite duplicate status. | +| G03 | [Index warning 225](https://github.com/TanStack/db/pull/2171#discussion_r4404724438): remove JS negation. | `duplicate` of C02; historical RED/GREEN. | O20/O26: run diagnostic and verify source-state-sensitive expression. | Retain proxy-expression regression. | +| G04 | [Core identity 408](https://github.com/TanStack/db/pull/2171#discussion_r4404724523): identity/hash assertions contradict retention. | `duplicate` of C01 core subclaim; current file passes. | O05/O26: affected test execution and actual identity-change control. | No reference-sensitive render identity restoration. | +| G05 | [Offline package 64](https://github.com/TanStack/db/pull/2171#discussion_r4404724536): RC peer cannot resolve local 0.15.2 in clean workspace, reported TS2307; add workspace devDependency. | `confirmed-open` source/config finding: RC peer, no DB workspace devDependency. Reported fresh-install build failure not replayed here. | O22: disposable frozen install/build before versioning; verify resolved local package and declaration imports. | Workspace dependency repair/build witness remains open. Existing local symlink is not counterevidence. | +| G06 | [React identity 3542](https://github.com/TanStack/db/pull/2171#discussion_r4404724550): expect retained Collection/first key. | `duplicate` of C01 React subclaim; historical real-host RED/GREEN. | O05/O26: real rerender plus identity replacement; execute changed test. | Preserve both assertions, not merely isReady. | +| G07 | [Coverage text 720](https://github.com/TanStack/db/pull/2171#discussion_r4405108728): two remaining active isPersisted references, actual source lines 547/1560 at head; replace with when('settled'). | `confirmed-open` source documentation residue; added audit prose shifts the second line. Original comment's displayed lines were stale. | O21: authored API-reference search with explicit historical exemptions. | Update active coverage prose; distinct from G11 wording. No production behavior repro needed. | +| G08 | `persisted-source-receipts.test.ts:957–960`: async onInsert returns `{}`, violating Promise; remove returned object. | `confirmed-open` source/type mismatch; existing return is present. Whole SQLite typecheck not executed here. | O14/O22: generated result admission plus actual package/test declaration build. | Migrate test handler and run its package typecheck; runtime-only runs cannot prove this. | +| G09 | `live-query-observer-client.ts:137`: cached seed early return skips admission; SSR never subscribes. Validate before returning snapshot. | `confirmed-open`, current RED core 3/React 3 with six compatible controls total. | O16: existing cache model through getServerSnapshot and actual renderToString. | Extend primary core/React receiving drivers before repair; post-rejection non-interference remains O02. | +| G10 | `client.ts:1095`: hydrateLiveQuery copies source claims but drops local rowOptions when replacing record. Carry local claim forward. | `confirmed-open`, current RED 3/compatible 3 after asserted newer replacement. | O04: preload claim, newer hydrate, conflicting/compatible preload, then claim retirement controls. | Extend claim lifecycle grammar before repair; do not serialize function references. | +| G11 | Additional prose comment, coverage 711: clarify “requires joined and single pending-source observations”; bot labels it unverified. | `confirmed-open` editorial suggestion, source only. This is not evidence of a broken reach counter. | O21: readable prose plus O26's per-path reach report; no fabricated runtime RED. | Preserve intent as “observations from joined and single pending sources”; keep separate from G07. | + +## W: bot walkthrough warnings + +| ID | Original claim and proposed correction | Technical disposition/evidence | Concrete check and distinguishing cut | Remaining work/durable destination | +| --- | ------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | +| W01 | Scope warning: metadata/cache laws and RC release automation exceed issue #1319; split work or link coding requirements. | `refuted` as unauthorized scope: user explicitly requested RC automation and approved metadata/cache rules in this task. Issue text alone omits that authority. | O21/O26: requirement→change inventory and PR-body review, including maintainer decisions. | Keep scope rationale visible; no product oracle should reject authorized scope. | +| W02 | Docstring score 17.14% versus 80%, 35 functions/69 files/8 unsupported; generate missing docstrings. | `refuted` as a repository requirement: no such threshold in AGENTS/config/workflows. Metric not independently recomputed; it names no missing contract. | O21/O23: public contract/literate oracle prose review proportional to law, source/config authority check. | Preserve useful documentation review; do not manufacture comments merely to satisfy an unconfigured vendor ratio. | + +## Four evidence caveats + +| ID | Original caveat | Required check and disposition | +| --- | ------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| N01 | A reviewer ran no tests; traced installed Changesets 6.0.9 and published exact pins. | O13/O22/O26: preserve source evidence as such. A source finding does not become an executed release failure. | +| N02 | C reviewer lacked Changesets dependencies, so release tests did not run. | O26: dependency/setup gate. Prior repair record and initial audit both ran11 fixtures successfully; broader registry/packed receiving work remains proposed. | +| N03 | C Query run loaded a stale second DB; no valid runtime finding resulted. | O22/O26: rebuild/fingerprint local DB and verify runtime resolution. Prior evaluation ran 978 Query runtime tests after repair; that result has its own head/scope. | +| N04 | C review branch moved 0a60ad85b→66454803b; about 40 later files, 44 by recorded diff, unreviewed; reviewer detached its clean old worktree. | O26: exact-head inventory and affected revalidation. Neither a clean reviewer tree nor unchanged cited files proves those 44 files reviewed. Leave other worktrees alone. | + +## Loss accounting, reviewer quality, and closeout + +The initial scratch ledger was created before probes. Final raw-source +reconciliation corrected G07's conflation with the distinct G11 suggestion and +added the two walkthrough warnings. No review finding was removed to make the +count fit. Multiple subclaims remain within B10/C01 and are explicitly mapped. +The four caveats are additional to the 53 finding IDs. + +Numbered-row accounting is **53 = 29 already-fixed + seven stale + three +accepted-design + five duplicate + seven confirmed-open + two refuted**. +B10 is counted once as stale for its older shared-policy claim; its distinct +coded-error subclaim is already fixed. C01 counts once but retains both core +and React paths. No row is deferred. No new product policy is adopted here; A12 requires contract inspection before extending assertions to Effects. +These totals classify reported traces, not the completion of all proposed +checks. In particular, the seven confirmed-open rows are G01, G05, G07–G11; +G07/G11 are editorial, G05/G08 have source evidence, and G01/G09/G10 have +executed behavioral failures. + +The first reviewer had useful source-based boundary analysis but no runtime +confirmation. Later reviewers supplied strong executable counterexamples and +honest setup/scope disclosures. The GitHub review identified two real cache +paths missed by the current driver. Policy recommendations still need the +maintainer decisions: a major Query bump, post-write rejection, and scope +splitting do not follow from those defects. These reviewers are useful for +defect discovery; their proposed fixes and coverage claims still need checking. + +Recommendation: **hire for defect discovery, with policy and evidence +calibration**. Their reproducible boundary failures are the strongest evidence. +An unconfigured metric or a scope judgment that omits maintainer instructions +does not establish a required repair. + +The expanded plan accounts for every identified finding in these sources. +It does **not** establish that its proposed checks have caught every original +implementation. Each behavioral row still needs its original failure or a +plausible mutant to fail at the named comparison before that claim is earned. +Static findings need source/build/declaration evidence instead. O16 and the +hydration part of O04 have current RED evidence; G01 has an executed docs +receiver. Those three confirmed failures remain unfixed in this audit. G05/G08 +need their clean build/typecheck witnesses. Broader proposals remain open in +[the coverage map](../oracle-coverage.md). + +The PR is **not ready on this evidence**: G09/G10 violate the accepted cache +law, and G01 teaches an unconfirmed successful mutation. This audit identifies +repairs and checks; it does not postpone them by agreement, retire those laws, +or claim completed implementation. No guarantee of zero future findings is +justified by a complete matrix or a passing random campaign. + +Final inventory assertions pass: 53 unique finding IDs, four caveats, 26 check +definitions, and a mapped check for every row. The final read-only GitHub query +returned the same head, two review bodies, and five general comment bodies, +all unchanged from the first snapshot. Changed-file lint, report formatting, +and `git diff --check` pass. pnpm reports the existing lockfile/installed +override mismatch; no dependency installation or override change was made. + +## Additional findings during oracle construction + +These append to the original inventory; they do not replace or renumber its 53 +findings. Production remains at `858b174a65f160d13d01f0324c7481cc8e4eba3a`. + +| ID | Finding and evidence | Owner and next witness | +| --- | --- | --- | +| P01 | The actual installed Changesets CLI selects `latest` for candidate packages whose registry history contains only prior RC versions. The chunk 13 command receiver has 12 intended tag-comparison failures and 28 passing neighbors. Independent reviewers observed the raw publish commands; strict shims select no tag. | O13 release/publish history owner. Preserve this RED, add real local-registry HTTP reception separately, and defer production repair until the oracle campaign finishes. Command evidence does not establish npm HTTP or installed-consumer behavior. | +| P02 | `packages/db/skills/db-core/custom-adapter/SKILL.md:222` still recommends returning `{ txid }`. Source inspection contradicts the approved explicit-await mutation contract; this is authored guidance, not generated API documentation. | O21 active-guidance consistency and actual snippet receiving checks. Preserve before/historical examples explicitly; repair current guidance after the oracle work. | +| P04 | `docs/guides/mutations.md:521` says Query Collection currently refetches automatically after persisting changes. The adjacent example explicitly awaits refetch. The current prose contradicts the approved removal of automatic refetch; this is source-inspected evidence, not a behavior-test failure. | O21 authored-guidance consistency and actual fenced mutation receivers. Check current prose separately from historical/before examples, then repair the guide after the oracle campaign. | +| P05 | The real npm/pnpm HTTP receiver attempts to upload an already-published RC when its packument has an `rc` tag and no `latest` tag. Three histories fail the raw no-republication comparison: retry after candidate-phase failure from prior-RC and never-published starting states, plus an already-current no-op. Duplicate PUTs receive HTTP 409; exact local manifest/pre-state restoration is checked first. | O13 actual HTTP receiving owner, `scripts/publish-http-oracle.property.test.js`. Preserve the assertion RED at unchanged production, including raw attempts and registry state, and defer publisher repair until the oracle campaign finishes. Installed consumers remain O22. | +| P06 | `packages/db/skills/db-core/custom-adapter/SKILL.md:209` says core will not call `unloadSubset` when `loadSubset` throws synchronously. This current guidance contradicts `query/live/ARCHITECTURE.md:771`: a synchronous throw is a failed load whose owner retains an unload opportunity on release. This is a source-inspected guidance conflict, not a newly executed runtime failure. | O21 active-guidance consistency, receiving the existing subscription lifecycle contract. Check the surrounding ownership guidance as well as deprecated handler return values; do not invent a second acquisition model or repair the skill before the oracle campaign finishes. | +| P07 | The same custom-adapter skill at lines 213–216 says the previous acquisition releases when its replacement returns. Architecture lines 783–786 require detaching and releasing the old lease before reacquisition, and reacquiring only while its demand and replay remain current. Source inspection establishes a guidance-order contradiction, not a new runtime defect. | O21 current-guidance consistency and the existing subscription lifecycle owner's replacement/reentrant-release histories. Preserve the separate ordering claim instead of treating P06's synchronous-throw witness as evidence for it. | +| P08 | The custom-adapter `loadSubset` fence at lines 170–198 ignores the establishing `commit()` receipt before its async callback returns. This is a candidate applied-settlement defect in executable guidance. No held-receipt receiving witness has run; it is not yet classified as a confirmed behavior failure. | O21 actual fenced callback receiver. Hold an establishing commit behind an optimistic transaction, invoke the actual documented callback externally, and compare load settlement with visibility under the existing applied-settlement law. Avoid awaiting this load from its own mutation handler. | +| P09 | Actual LEFT-join Effect startup emits an update for an unmatched occurrence that never entered its callback-visible result. The independent trace has one callback containing that update (identical previous/current values), an unrelated peer enter and the actual matched enter; its literal-true lazy load finished first. This is an executed callback-delta failure, not merely a disputed count of startup batches. | O11 Effect receiving expansion, chunk 16. Preserve the initial RED and full ledger. Public Effect delta semantics and the architecture's absent-to-absent classification prohibit the phantom update; do not accept it as a legal startup prefix or erase initial callbacks. Evidence: `chunk16-startup-independent-trace-r1.log` in the scratch evidence directory. Production repair remains deferred. | +| P10 | Author-run correlated-child callback reception exposes reserved fields before downstream fn.select, while all 231 root receiving histories pass. Minimal new-shape witness selects only a fake $key: the callback sees present/own true and `spoof-$key` where the approved law requires absence; an ordinary nested observed output retains that wrong fact. Initial 575-test run has 203 callback-law REDs and 372 passes, including inline-preservation and Collection-input rejection controls. Independent review and final instrument calibration are pending. | O12 callback-input owner, chunk 17. Compare present inputs and derived ordinary outputs under the same model through actual correlated-child compilation; root GREEN supplies no child-boundary credit. Preserve source-spread spoofing and functional-new-spread leakage as adjacent witnesses, distinct from earlier illegal-correlation/order setup failures. Evidence: `chunk17-fourth.json` and `.log` in the scratch directory. Production remains unchanged. | +| P11 | Early metadata-fixture debugging observed a raw structured `optional: undefined` projection arrive as null. The author withdrew its initial normalization explanation: existing source-ref tests and public literal descriptions do not establish that raw-literal policy. This is an unresolved boundary observation, not a confirmed product defect or approved normalization. | Read-only literal-law/owner investigation distinguishes raw undefined select specification, explicit val(undefined), source-ref undefined, nested/array forms and null controls, checking callback and final own-property facts. The new O12 grammar retains strict source/spread/functional undefined coverage without claiming a structured-literal law. Preserve this question separately before any production repair. | + +P01 evidence: `chunk13-publish-independent-final.log` under +`/private/tmp/rc2171-oracle-challenge/`. P02 was independently identified during +read-only O20/O21 preparation. The existing typed live-query schema limitation +is already recorded in the coverage map; it is not relabeled as a newly +confirmed regression here. A scratch schema-role experiment establishes that +client mutation inputs, rather than synced rows, are the appropriate receiving +boundary for a future transforming-schema retention check. + +P03 (existing replay instrumentation): `recordOracleReplay` is documented as +never throwing, but a real `fc.check` RunDetails value and an unwritable witness +channel (a directory) produce `EISDIR` from its synchronous writer. The finite +comparison wrapper added in chunk 13 preserves both primary and writer errors; +that evidence does not establish the older standalone recorder's intended +policy. The guarded replay/standalone recorder owner must resolve this existing +comment/behavior conflict before claiming a broader writer-failure guarantee. +Independent probe: `chunk13-old-record-writer-probe.log` in the scratch evidence +directory. No library production repair was made. + +G08 clarification and current receiver: the unchanged bad handler at +`packages/db-sqlite-persistence-core/tests/persisted-source-receipts.test.ts:957` +is a plain persisted Collection handler, not Query-specific. Core Collection +insert/update/delete handlers all require `Promise`. Running that package's +actual `tsc --noEmit --project tsconfig.json` (which maps DB to current source) +produces the intended TS2769 handler diagnostic plus its cascading call-site +diagnostic. Evidence: `o22-persisted-core-source-types-baseline.log`. Offline's +missing workspace DB build edge is separately demonstrated by pnpm's filtered +project closure; its committed lock already contains a DB link, so this finding +does not by itself prove frozen installation fails. + +P10 final independent receiving evidence: chunk 17 now has 580 tests, +377 passes/203 intended correlated-child callback-law REDs, with all 2,153 +declared states reached. All 203 failures are the expected law exception, +not setup/runtime failures. Controls and valid replay-environment/partial +selection checks pass their intended judgments; production remains unfixed. +The earlier 575-test author run above is retained historical evidence. + +P11 runtime authority and receiving evidence are now resolved by independently +accepted chunk 19. The public builder explicitly promises literal null/undefined +pass-through and recursive record selection. Eight of its 68 exact typed +root/QueryRef histories fail because raw selected undefined becomes null, +including mixed arrays. All three ready cuts, public events and callback inputs +are retained. Public type accuracy and correlated receiving paths remain next +gates; a broader mixed-expression type-admission diagnostic remains unresolved. + +| ID | Finding and evidence | Owner and next witness | +| --- | --- | --- | +| P12 | Structured selection drops an authored empty-record property, including nested empty records and the empty box from a nested omitted field. Ten of chunk 19's 68 histories expose this through real root/QueryRef receivers. A minimal source with only id/version and select `{ id: row.id, version: row.version, value: {} }` loses value at public rows/events, with no nullish member involved. This is an executed recursive-container-law failure, independently reviewed. | Same literal model/registration owner as P11. Preserve the ten REDs and add same-model correlated array/singleton/facade receivers before repair. All 204 receiving and 102 callback states run once; exact typed factories compile. Evidence: `chunk19-r2-freeze-manifest.json` and `chunk19-minimal-empty-record-red.log`. | +| P13 | Public `LoadSubsetFn` prose at `packages/db/src/types.ts:384–389` describes resource ownership transferring only after returning true/Promise and cleanup of partial resources on synchronous throw. The newer architecture/lifecycle contract retains failed-load demand and an unload opportunity on release. This wording requires reconciliation; it does not literally make P06's stronger promise that core never unloads a synchronous throw. Source inspection only; no new runtime failure is claimed. | O21 authored-guidance consistency, under the accepted architecture/subscription lifecycle law. Keep this public source comment distinct from the stale skill text and generated API docs. Repair source guidance after all oracle work; leave generated docs to CI. | +| P14 | Architecture cleanup/restart prose at line 878 and custom-adapter skill lines 350–354 say markReady after an initial fail throws. Current public API prose, glossary and source-health oracle promise a same-run no-op. Source-health's lazy receiver also catches child-ready exceptions while discarding its model's predicted successful call outcome, so status-only checks can hide the wrong throw. This is source-inspected guidance conflict and an oracle observation gap, not an executed production defect. | O21 guidance plus the existing source-health primary oracle. Add legal generated fail→actual child-ready and fresh-run neighbors, compare call outcome with the existing shared model, declare actual callback reach, and reject a throwing in-memory overlay before guidance/production repair. An absent optional callback call must receive no reach credit. | + +Chunk 19's unchanged production run has 103 tests: 85 pass and 18 intended +assertion REDs (eight P11 plus ten P12), with all 306 states reached once. +Independent review repaired only a missing oracle declaration, verified the +semantic-neutral delta, strict driver admission and immutable capture controls. +This is accepted instrument coverage; it does not close these product defects +or change the original 53-finding accounting. P13/P14 read-only preparation and +exact-source evidence are retained in the O21 scratch plan. + +P11/P12 correlated receiving evidence: independently accepted chunk 20 runs +272 exact legal histories through both array spellings, singleton and facade +materialization. All 3,264 declared states execute once; 200 histories pass and +72 preserve the same literal-law REDs, with no driver-execution failures. The +33 checker controls and six independent facet-isolation controls pass. Public +inferred types remain the next gate. Original QueryRef fixtures missing their +own correlation WHERE are retained as documented missing-premise evidence, +not value-law failures. No production or guidance repair has occurred. diff --git a/docs/contributing/oracle-reviews/2026-10-28-pr-2171-review.md b/docs/contributing/oracle-reviews/2026-10-28-pr-2171-review.md new file mode 100644 index 0000000000..edb9b5d12a --- /dev/null +++ b/docs/contributing/oracle-reviews/2026-10-28-pr-2171-review.md @@ -0,0 +1,306 @@ +# PR 2171: review evaluation, October 28 + +Checked code head: `751f9a74500a947050ddba0048a93c99d87339b7`. +Starting review head: `7c53f271acd4be62d40475b509d2751d27479866`. +Merged main: `b305fb2dcfbc296fd1980ceb6e611e0993fec740`. + +This record evaluates the user's ten numbered findings. Finding 10 has two +independent claims, retained as R10a and R10b. The ledger below accounts for all +11 claims. No finding was deferred. The task-local raw ledger and command logs +are under `/private/tmp/rc2171-review`; the conclusions and reproduction commands +needed after that scratch directory disappears are recorded here. + +## Laws, experiments, and inference + +The authority for the three policy choices is the maintainer's explicit answers +in this review. Query and Electric discard legacy handler values at runtime. +Inline `getKey` and `schema` bind until query identity changes. Structured source +selections and spreads retain source metadata; a functional selection retains it +only when it returns a source row directly. New functional objects receive the +output Collection's metadata. Colliding reserved names cannot change that rule. +The migration guide and executable owners now state those decisions. + +### Handler settlement and refresh + +A fulfilled handler must not become a failed transaction merely because it +returned a discarded value. A rejected handler must retain its exact rejection. +The earlier Electric histories did not cross handler verdict with a legacy +return value. The Query owner covered confirmation paths but omitted that +return-value dimension. Those gaps let the adapters disagree at settlement. +The neighboring law is explicit synchronization: completing a Query handler does +not fetch or replace authoritative rows. The handler must await refetch or a +direct write if settlement must expose its confirmed result. + +The Electric owner is `electric-oracle.property.test.ts`. Its finite grammar +crosses insert/update/delete, fulfillment/rejection, and absent/legacy returns: +12 histories. Each history holds the handler, checks optimistic rows, then +compares the exact rejection identity, simulated server-write count, and settled +public rows. Successful handlers explicitly deliver ShapeStream confirmation and +await its txid. Each history requires one optimistic and one settled reach. +The driver uses real Collection transactions and controlled SDK event delivery. +It does not claim a live Electric-server result. + +The reviewed runtime-rejection wrapper fails three histories at the public +Promise-verdict assertion. The other nine pass. Passing those neighbors rejects +a repair that merely suppresses every handler error. With the rejection wrapper +removed, all 12 pass; the surrounding Electric selection passes 334 runtime tests. + +The Query ownership owner crosses CRUD, no confirmation/refetch/direct write, +and absent/legacy return: 18 histories. It checks optimistic rows while held and +exact fetch counts and public rows at settlement. Each history requires both +reach states once. A real QueryClient supplies the fetch boundary. External +focus, reconnect, polling, and invalidation are separate triggers, outside this +grammar. The earlier automatic-refetch calibration failed all nine original +confirmation histories at fetch counts; its evidence remains in +`pr-2171-explicit-query-refresh.md`. The final complete Query runtime suite +passes all 978 tests in 29 files, including both ordered-refetch campaigns. +Moved main tests now initiate refetch explicitly without changing their public +assertions or observation checkpoints. + +### Query identity + +The earlier identity owner did not vary inline option references across host +rerenders. It could stay green while ordinary render inputs rebuilt a Collection. +The React source-ID owner now crosses live/Suspense hooks, getKey/schema/both, +and derived/explicit-key identity: 12 histories. Each starts a ready source, +rerenders three times with new closures, applies a source update, then changes +query identity. Collection and configured option references must persist before +the change and be replaced afterward. Every history requires rerender, +source-update, and identity-change reach once. + +The earlier three derived-identity regressions fail before the repair. The final +matrix with the previous option-reference identity rule fails six derived cases +at Collection identity; the six explicit-key neighbors pass. The repaired source +identity file passes all 92 tests. The broader React selection passes 442 tests. + +`schema` currently has a separate public declaration limitation: its hook config +type is `never`. The runtime histories supply schema with `Object.assign` at the +JavaScript boundary. They do not establish a new schema typing contract. Infinite +hooks do not accept inline Collection options; their existing identity/window +histories run as neighboring guarantees. The shared implementation also serves +other adapters, but this new inline-option matrix supplies a real React-host +witness, not a witness for every framework scheduler. Preact's existing complete +runtime conformance suite passes 98 tests. + +### Metadata at Collection publication + +The earlier metadata owner did not cross selection form, joined source choice, +and individual reserved-name collisions at pending and settled checkpoints. +A source-guessing implementation therefore escaped its narrower comparisons. +The primary virtual-row-fields owner now enumerates 50 legal histories: +single/joined sources, structured projection/spread, functional +projection/spread/direct return, and none/each/all reserved-name collisions. +Direct returns cannot author a collision, so that combination is excluded. +The source's label changes while an optimistic transaction is held. Each history +checks all four fields before mutation, during the hold, and after settlement. +The model decides ownership from the authored selection form, never by comparing +selected values. A clean sibling supplies only the output-generated key. + +Declared reach is 25 single-source pending histories, 25 joined-source pending +histories, 40 colliding histories, and two direct functional returns. The minimum +for each declared state is one. Individual `$collectionId` collisions matter: +a fix for only `$key` or all-fields collisions would miss them. + +The initial expanded tests failed on the reviewed implementation. The final +50-history grammar, run with the previous compiler restored, fails 26 histories +at metadata assertions and passes 24. This is assertion failure, not setup failure. +The repaired primary and supporting metadata tests pass 57 tests. The broader +metadata/retention selection passes 164. Supplemental tests preserve two spread +orders, an unmatched RIGHT join, direct functional returns, facade projections, +and inline-value shape. The new grammar does not establish every join kind, +arbitrary nested histories, custom-key projections, provider behavior, or every +framework receiving path. Those limits remain explicit in the coverage map. + +### Compatibility and integration + +A probe against the actual DB 0.15.2 package confirms that `when` exists and +`completeWithoutCommit` does not. Calling the latter throws a TypeError. +Offline Transactions now peers only on `^1.0.0-rc.0` and receives a stable minor +bump. The release inventory test enforces that manifest boundary. No runtime +fallback preserves a false compatibility claim. Existing restore, serializer, +and settlement owners pass 89 tests. This is package compatibility metadata, +so a manifest/version fixture is the appropriate new instrument. + +The main merge provides Convex and Preact. Convex's obsolete BaseCollectionConfig +return generic is migrated. All 22 Convex runtime files pass, with 108 tests. +Both adapters build and typecheck. The release fixture checks every actual public +workspace manifest against the first-RC inventory; version and publish fixtures +pass 11 tests. Query and Offline Transactions remain stable minors, as requested. +The inventory/version checks are configuration evidence rather than a state model. + +## Reproduction entry points + +Run each command from the named package directory at the checked code head. +These commands isolate the three new finite grammars. Omit `-t` to run each +complete owner file. + +```sh +# packages/db +../../node_modules/.bin/vitest run tests/query/virtual-row-fields-oracle.test.ts -t 'attributes root metadata' --coverage.enabled=false --typecheck.enabled=false + +# packages/react-db +../../node_modules/.bin/vitest run tests/source-id-reuse-oracle.test.tsx -t 'retains inline' --coverage.enabled=false --typecheck.enabled=false + +# packages/electric-db-collection +../../node_modules/.bin/vitest run tests/electric-oracle.property.test.ts -t 'settles a handler independently' --coverage.enabled=false --typecheck.enabled=false +``` + +Calibration used those same commands with one previous production file restored +at a time, while keeping the final tests. For metadata and Electric, restore +`packages/db/src/query/compiler/index.ts` or +`packages/electric-db-collection/src/electric.ts` from the starting review head. +For identity, restore `packages/db/src/live-query-options.ts` from merged main +`b305fb2dcfbc296fd1980ceb6e611e0993fec740`. Restore the checked code after each +experiment. The expected failures and passing neighbors are recorded above. + +## Lossless finding dispositions + +| ID | Original claim and technical verdict | Action and durable value | +| ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| R1 | OfflineExecutor calls RC-only `completeWithoutCommit()` while its peer accepts DB 0.15. Confirmed against published 0.15.2; restoration can throw and remain held. | **fixed-now**: RC-only peer, stable minor bump, migration warning, actual-manifest release check. Keep the restore/settlement owners as the behavioral boundary. | +| R2 | Query's automatic-refetch and handler-type removal breaks callers despite a minor bump to stable 1.x/latest. Technically a compatibility break. | **accepted-design**: the maintainer explicitly corrected the requested major to a minor. Preserve prominent before/after migration instructions, explicit refetch/direct-write examples, removed `{ refetch: false }`, and stable version/tag fixtures. Do not describe this as semver-compatible. | +| R3 | Inline getKey/schema references recreate a live query on render, potentially looping on a ready source. Confirmed Collection identity churn. | **fixed-now**: bind options to query identity; extend primary React owner across both hook paths and both identity forms. Keep schema declaration and cross-framework evidence limits explicit. | +| R4 | Electric throws after a legacy-returning handler already commits, so successful work appears failed and a retry may duplicate it. The error is also uncoded. Confirmed false rejection. | **fixed-now**: remove the post-handler rejection and wrappers. The 12-history owner checks exact fulfillment/rejection and confirmed rows. No replacement error needs a code. | +| R5 | A functional spread overriding virtual metadata can fall back to the wrong joined source. Confirmed source guessing is unreliable; callback result equality cannot identify authorship. | **fixed-now** under the approved rule: direct functional source returns retain metadata; new objects receive output metadata. Remove value-matching provenance guesses. The 50-history owner covers source choice and each collision. | +| R6 | Selecting `$collectionId` changes ordinary projection attribution from the output Collection to its source. Confirmed collision-sensitive attribution. | **fixed-now**: ordinary projections receive output metadata regardless of selected virtual names. Preserve this distinct claim and its single-field collision histories alongside R5. | +| R7 | First-RC changeset lists missing Convex/Preact packages and versioning throws; merging main alone leaves their API migrations unchecked. Confirmed on the starting branch. | **fixed-now**: merge main, migrate Convex generics, build/typecheck both adapters, run both runtime suites, and test actual workspace inventory. | +| R8 | Query silently discards returns while Electric throws; Query migration failures can appear only as stale UI. Confirmed inconsistent behavior. | **fixed-now**, sharing R4's repair: both discard runtime values and reject them in types. The maintainer explicitly chose this policy. The Query oracle still requires explicit confirmation and checks reversion without it. | +| R9 | Three identical Query wrappers and repeated utility types add duplication. Confirmed maintainability issue. | **fixed-now**: one generic wrapper preserves active-handler try/finally accounting and discards returns; one handler-utils alias removes repeated types. Existing CRUD/refetch histories protect the unchanged counter boundary. | +| R10a | React always supplies `dependenciesStructurallyEqual: false`, leaving unused shared-policy logic. Accurate for the older branch, stale after main integration. | **stale**: main uses the shared window-binding implementation. Other frameworks still use that shared policy; deleting it globally would be wrong. Preserve the identity/window/render tests as the useful review idea. | +| R10b | prepareDerivedQuery throws an uncoded Error and ships its message. Accurate for the older branch, stale after main integration. | **stale**: current preparation propagates coded `UnhashableQueryIRError`; no replacement plain Error remains. Identity tests and minified/production-error tests preserve the useful bundle concern. | + +Accounting: 11 claims = eight fixed-now + one accepted-design + two stale. +No refuted, duplicate, deferred, or unresolved product-decision entries. +The original line references belong to the starting review head and moved during +the main merge. No source-order ID was merged away. + +## Adjacent integration repairs + +Main's split ordinary/oracle tests remain split. Mechanical migrations replace +`isPersisted.promise` waits and `$synced` reads without relaxing law assertions. +The virtual-field type oracle retains its declaration and shared type fixture. +A malformed generated `readonly` line is removed from the persistence type +instrument; both fixed and random campaigns pass (four tests total). +The minified-export inventory drops the removed `DuplicateAliasInSubqueryError`. +Published error-code documentation and its frozen code registry remain available +for old errors. The production-error oracle passes 18 tests, and the consumer +bundle check passes for 263 distinctive literals. The minified API checks 113 +error names plus index metadata, rows, and live updates. + +Authored guide/skill examples now pass without adding failing snippets to the +baseline. Twenty obsolete baseline entries were removed. Existing 252 failing +examples remain acknowledged. Pseudo-code fragments explicitly declare nocheck; +current API examples receive concrete row types and required configuration. +The 25 relative migration-guide links resolve. The repository-wide link checker +still reports 51 existing failures, including code links outside its docs root. +Generated API references match main exactly and are absent from the PR diff, +as the maintainer requested. Their CI workflow owns regeneration. + +## Oracle conformance and evidence limits + +- **ORC-001/002/003/005/009:** authority, independent judgments, legal forms, + drivers, and public cuts are stated above and beside their executable owners. + No concrete missing-source-information hypothesis remains for these controlled + checkpoints. Metadata authorship is now an explicit policy, not inferred data. +- **ORC-004/008/013:** removing CRUD, confirmation, verdict, return-value, identity, + selection-shape, or collision axes loses a distinguishing case named above. + The finite bounds are exhaustive for their declared grammar. A direct return + with authored overrides and inline infinite-hook config are excluded invalid + forms. No new production-like state machine was introduced in the models. +- **ORC-006:** previous production designs fail at Collection identity, metadata, + Promise verdict, or fetch-count comparisons. The passing neighboring cases + reject blanket failure suppression, source-always attribution, and + collision-only fixes. Calibration is not a universal proof. +- **ORC-007:** the new matrices enumerate finite grammars rather than claiming + random-campaign coverage. Existing ordered-refetch and persistence type owners + retain their fixed/random campaigns and replay interface. +- **ORC-010:** controlled gates release and Collections clean up after histories; + the metadata owner preserves primary/secondary cleanup failures. The evidence + includes clean green process exits, not merely some passing assertions. +- **ORC-011:** source metadata and output metadata are compared through distinct + source-preserving and clean projection forms. No additional named shared-model + semantic fault required another independent model in this review. +- **ORC-012/015/016:** this versioned record identifies the checked code head. + New reach requirements and counts appear above. Oracle declarations remain + intact; supplemental handwritten examples stay in ordinary files. +- **ORC-014:** React uses the real host. Query uses real QueryClient with controlled + rows. Electric controls SDK delivery. No claim about an actual provider commit + or every receiving framework follows from those premises. + +The full repository CI and native provider suites did not run locally. Early +Vitest combined runtime/type invocations reported cross-package rootDir setup +errors. Clean runtime runs and separate `tsc --rootDir .` checks replace that +invalid combined evidence. Initial all-files ESLint exceeded Node's default +heap; package batches and the normal chunked commit hook supply lint evidence. +These setup failures are not RED or GREEN product evidence. + +## Code weight and review judgment + +`pnpm size:compare --exact-base --base origin/main` measured production app +bundles against main at `b305fb2dc`. Measured packages save 750 gzip bytes and 528 brotli bytes together. +The typical app saves 302 gzip and 230 brotli bytes. DB grows 130 gzip bytes for +explicit metadata-source rules. Deleting value-matching guesses and consolidating +copy-on-write attachment reduces the repair's cost; deleting Electric matching +wrappers, Query auto-refetch, and deprecated APIs makes the total negative. +Tests and contract documentation grow separately and do not enter those figures. + +Reviewer recommendation: **hire for defect discovery, with maintainer review of +policy proposals**. The review found real compatibility, identity, settlement, +and metadata hazards. Its strongest observations crossed package and async +boundaries. It did not supply oracle or test-gap evidence, treated an explicitly +chosen minor as a new decision, and included two claims superseded by main. +Those limits reduce prioritization quality without erasing the useful findings. + +Readiness: **unproven pending current-head CI and broader integration evidence**. +The reviewed repairs and finite-law evidence are complete; this record does not +claim that all release, provider, platform, or repository checks passed. + +## Follow-up main integration + +Checked merge head: `ade3920bf67d804f3d5bbfb30c757be3ec75ba0b`. +Merged main: `2dc655f55fee13bd8f37252c592022ce3848b9bf`. + +The maintainer requested another main merge after the review fixes were pushed. +The merge applied without conflicts. It imports the lazy-demand replay repair, +a persistence guide correction, and Angular/UUID dependency updates. No imported +test required a deprecated-API migration. The authored migration guide now notes +that a synchronous `loadSubset` throw retains demand and that releasing this +failed demand calls `unloadSubset`. + +The combined tree passes 804 DB runtime tests in 11 files, including replay, +ordered loading, virtual-row metadata, and Collection/include publication. +DB TypeScript checking and DB/Query builds pass. All 978 Query runtime tests +in 29 files pass against the rebuilt DB, including both ordered-refetch +campaigns. React's 92-case source identity owner also passes after that build. +All 11 release fixtures pass. Offline frozen-lockfile verification passes +without an installation workaround. The normal merge commit hook passes. + +The first Query run used the previous DB dist and failed the two newly imported +bounded-refetch witnesses. Rebuilding DB makes both pass, followed by the complete +green Query run. That stale-build run is setup evidence, not a product RED. +The broad persisted-session run also started before that rebuild; its 70 passes +are not credited as a current-build integration witness. After rebuilding, both +new persisted-session and browser multi-tab replay witnesses pass their existing +`it.fails` checks. They still reproduce the known source/persistence gaps in +[#2281](https://github.com/TanStack/db/issues/2281), rather than proving those +product behaviors correct. Those sync metadata/durability gaps are separate +from the virtual-row attribution contract evaluated above. + +This integration changes none of the 11 finding dispositions. The original +calibration remains evidence for the unchanged compiler attribution, option +identity, and handler-return policies; the affected comparisons pass on the +combined tree. Generated references still match main. The earlier byte figures +retain their stated `b305fb2dc` base and were not remeasured for this merge. +Angular/TrailBase runtime suites and current-head CI remain unverified locally. +Readiness remains unproven, separate from the completed review accounting. + +### Dependency and CI formatting follow-up + +Integration head: `e46d67b2d` includes main `bc77952e5` and the PR's automated +formatting commit `b05e4052c`. The latter changes quote style in three project +example files. The main follow-up updates Vite dependencies. A direct diff +confirms that DB, Query, React, Electric, and Offline Transactions production +sources are unchanged since the runtime-checked merge `ade3920bf`. +Frozen offline lockfile verification and all 11 release fixtures pass again. +The updated Vite toolchain was not installed or runtime-tested locally; CI must +validate it. No generated reference diff or installation workaround is included. diff --git a/docs/contributing/oracle-reviews/2026-10-28-pr-2171-second-review.md b/docs/contributing/oracle-reviews/2026-10-28-pr-2171-second-review.md new file mode 100644 index 0000000000..4dae0454ab --- /dev/null +++ b/docs/contributing/oracle-reviews/2026-10-28-pr-2171-second-review.md @@ -0,0 +1,138 @@ +# PR #2171: second review evaluation, 2026-10-28 + +This record evaluates the 15-item review supplied by the maintainer. The reviewer targeted `0a60ad85b`; this evaluation started at `66454803bcd200bf56fcf569179c3007a51e859e`. Repairs and executable coverage are in `68228aaf3a878075af3dc04636133aef95480a09`. This report is a subsequent documentation commit. The earlier [review record](2026-10-28-pr-2171-review.md) remains historical evidence for its own commits. + +## Laws, experiments, and inference + +No product decision remains pending. The maintainer explicitly chose these rules: + +- Mounted hooks retain inline `getKey` and `schema` until query identity changes. Independent DbClient callers must reject conflicting option references under one cached identity. Distinct queryKeys permit different behavior. +- Structured whole-row selections and spreads retain source metadata. The last **present** spread supplies it. Direct functional source returns retain it. New functional objects receive output Collection metadata. Selected reserved names cannot choose attribution. Inline include values keep their authored shape. +- Query and Offline Transactions remain stable 1.x packages because both accidentally reached 1.0. Query's automatic-refetch removal ships as a **minor**, as explicitly requested. Both new stable minors require the RC DB APIs. + +The scratch ledger inventoried the whole review before any production repair. The pre-edit gate ran the expanded metadata, cache, React receiving, and transaction comparisons against unchanged production. It also checked existing React retention histories, Offline restoration callers, release fixtures, and DB 0.15 capabilities. Later metadata composition additions were calibrated separately against the original compiler; that later experiment is not evidence of their presence in the initial gate. + +| Law and authority | Earlier coverage and missing distinction | Decisive experiment | Repair and enforced boundary | +| --- | --- | --- | --- | +| Published Collection metadata follows authoring shape, through query composition. Authority: `VirtualRowProps`, the architecture's facade boundary, issue #1319's reserved-name concern, and the approved functional-selection rule. | The primary virtual-row-fields owner compared source/output attribution directly, without QueryRef/union composition or a null later spread. The model did not require a new rule for reserved names; its driver failed to reach recursive publication. | Initial 209-case grammar: 92 assertion failures, 117 controls pass. Final 409-case grammar against the original compiler: 220 failures, 189 pass. A first-present-spread mutant fails four comparisons, although it fixes the reported null-last example. Repaired grammar: all 409 pass. | Propagate the Collection-row boundary through recursive query compilation, preserve flat union branch metadata, and choose the last non-null source spread. Compare all four public fields after preload, during a held source update, and after settlement. New ordinary `hasPendingWrites` projections also match the source at those cuts. | +| A reusable DbClient result cannot substitute incompatible local row behavior. Authority: the maintainer's same-identity conflict decision. Mounted retention remains a separate approved law. | Source-object identity and mounted option retention already had tests. Neither generated independent callers differing only in row options. Portable hashes correctly omit runtime function references. | Core gate: 36 failures/108 controls across 144 cases. Real React gate: 6 failures/18 controls across 24 cases. Removing only the new option guard reproduces those exact failure counts through both drivers. Repaired core and React matrices pass. Existing React retention/source-ID owner: 92 pass before and after. | Keep local option-reference claims with the reusable client record, outside the transport hash. Check preload, observer preload, subscription, and real React initial-render admission. Hooks compare the retained Collection's options, preserving inline render behavior. | +| External completion admits only pending transactions and cannot revise another transaction verdict. Authority: commit admission and terminal settlement outcomes. | The optimistic-history model already distinguishes pending/persisting/completed/failed but had no external-completion action. No state observation caught verdict resurrection. | The extended primary model fails on the unguarded helper. Removing the repair again fails all four state histories, including the second completion of initially pending work. The repaired outcomes suite passes 27 tests. Offline baseline restoration/interruption callers pass 16; full repaired Offline suite passes 302. | Admit pending work only, using the existing coded admission error. Compare transaction state, request outcome, and public rows after each model action. The finite addition crosses four states with 1–3 completion attempts. | +| A declared DB peer provides the APIs the adapter calls. Authority: Query's actual sync boundary and honest package compatibility. | The release fixture still expected the broad Query peer. The old DB capability path was absent from the reviewer's experiments. | DB 0.15.2 tag source, instantiated through its real Collection sync boundary, supplies `undefined` for `fail`, `reportError`, and `reportHealthy`. A provider failure leaves preload unresolved until cleanup. The strengthened release fixture rejects the old range; all 11 repaired release fixtures pass. | Query now peers only on `^1.0.0-rc.0`, as Offline already does. The guide requires coordinated upgrades and tells DB 0.15 applications to keep earlier adapter versions. The probe does **not** establish an observed TypeError or a particular package manager's ERESOLVE wording. | + +### Grammar, reach, and public observations + +The metadata model judges source ownership from authoring shape rather than field values. Its 50 shape/source/collision inputs cross eight compositions: direct; QueryRef depths one/two; joined and functional QueryRef consumers; union first/last branch; and union through QueryRef. Eight further histories cross matched/unmatched later sources, spread order, and the source with an optimistic update. One existing classification history protects nested values, Dates, arrays, whole-row children, and inline projection shapes. Projection keys use a clean sibling as a trusted comparison for compiler-generated key spelling; this is an anti-spoofing judgment, not an independent key-generation model. + +Normal-run declared reach was: single pending source 200; joined pending source 200; reserved collision 320; functional direct return 16; QueryRef pending 250; union pending 150; null later spread 2. The same field comparison runs at preload, held-update visibility, and fulfilled settlement. Removing composition loses the subquery/union counterexamples. Removing match/spread-order controls permits a first-spread shortcut. Removing collision controls permits authored reserved names to choose attribution. + +The cache model is the independent relation `different options && same identity => reject`. Its 24 histories cross getKey/schema/both, same/different references, same/distinct queryKey, and derived/keyed identity. Each crosses pending/settled reuse and three core admission paths, for 144 cases. Each path has 48 histories, each reaching its declared admission once. React supplies 24 real mounted initial-render histories, each reaching render admission once, using the **same** model and comparison. Same-reference and distinct-key controls reject an always-throwing policy. The existing 92-case receiving owner separately checks retained options and real query-identity replacement. + +The transaction addition reaches three histories per starting state (four states, 1–3 attempts). It uses the primary optimistic-history driver and row/outcome/state comparison rather than a replacement expected result. Pending and persisting cannot merge in the model: completion is legal only in the former. Completed and failed cannot merge because their settled outcomes differ. The normal broader optimistic history campaigns retain their original budgets. + +These are finite boundaries. They do not prove arbitrary nesting depth, all join kinds and retractions, every framework, actual remote-provider timing, or arbitrary SSR hydration orders. Cache option coverage judges local result reuse; it does not claim semantic equality of distinct functions. Source-identity hydration histories remain separate evidence, not a claim that every option/hydration combination ran. Metadata's held update fulfills; the existing optimistic owners separately cover rollback/queued publication. No known reachable counterexample remains within these declared repair boundaries. + +## Lossless finding ledger + +Line references below are from the supplied review. They are not current line numbers. + +| ID | Original claim | Technical verdict and evidence | PR action | Durable value/destination | +| --- | --- | --- | --- | --- | +| R1a | `live-query-options.ts:270`; core identity test at 411 fails after removing getKey/schema from identity. | Confirmed test failure at starting head. Its replacement expectation contradicted the approved retention rule. | `fixed-now`: assert identity equality, without restoring reference-sensitive render identity. | Core identity test, cache conflict oracle, and receiving retention owner distinguish local claims from query identity. | +| R1b | `useLiveQuery.test.tsx:3569` retains the old Collection and fails the PR's test. | Confirmed on the real React path. Retention is approved behavior, so the stale expectation was the defect. | `fixed-now`: expect the original Collection/key after an option-only rerender. | The existing 92-case React oracle preserves replacement on actual identity changes. | +| R2 | `index-suggestion.test.ts:225` negates a query proxy, yielding constant false and no expected virtual-field warning. | Confirmed failing warning test. JavaScript negation does not construct a query expression. | `fixed-now`: order directly by `$hasPendingWrites`; index diagnostics and the corrected test pass. | Keep proxy-expression construction distinct from JavaScript evaluation. No production warning waiver was added. | +| R3 | `compiler/index.ts:1227`; unionAll uses the empty alias as source and loses pending/origin metadata. | Confirmed by the composed metadata comparison, including both branch positions and union through QueryRef. | `fixed-now`: use the flat branch row as union metadata source. | Primary metadata owner now generates union compositions and checks all four fields at three cuts. | +| R4 | `compiler/index.ts:2117`; a subquery can spoof reserved metadata because protection runs only at the outer root. | Confirmed. The gap also affected new functional objects behind recursive queries. | `fixed-now`: propagate the Collection publication boundary through FROM, JOIN, and union branches. | QueryRef depth, joined/functional consumers, and collision axes stay in the primary owner. Inline values retain authored shapes. | +| R5 | `compiler/index.ts:2079`; a later null outer-join spread deletes earlier metadata and hides pending state. | Confirmed. A simplistic first-spread repair would break matched two-source histories. | `fixed-now`: choose the last present whole-row spread. | Eight primary-owner histories and the four-failure first-spread mutant distinguish the wider law. | +| R6 | Query accepts DB ^0.15 but calls absent fail/reportError/reportHealthy; users can hit TypeError. | API incompatibility confirmed. The actual old-boundary probe observed missing functions and stuck preload, not an uncaught TypeError. | `fixed-now`: require RC DB; update importer and release fixture. | Capability probe, release fixture, and coordinated migration instructions prevent false peer claims. | +| R7 | `.changeset/first-rc.md:17` publishes removal of implicit refetch/Promise handlers as stable minor/latest. | Factual semver concern. The maintainer explicitly selected a minor after retracting the major request. | `accepted-design`: retain the requested minor and explicit breaking-change guide. | Preserve the deliberate compatibility cost and major-version alternative here; do not silently override the release decision. | +| R8 | Offline stable 1.2/latest requires prerelease DB and older DB applications can fail peer resolution. | Incompatible-peer fact is correct. Resolver-specific ERESOLVE behavior was not executed. The narrow peer prevents the previously identified missing-method runtime path. | `accepted-design`: stable 1.x release policy with mandatory coordinated DB upgrade. | Guide and changeset now explain both adapters' RC requirement and the option to retain earlier adapters. | +| R9 | Same hash plus different getKey/schema can return another caller's cached Collection rather than reject. | Confirmed through all three core paths and real React initial render. | `fixed-now`: store/check local option references on the reusable result. | Shared cache model and core/React drivers preserve the newly approved conflict rule. | +| R10 | RN `ListsScreen.tsx:118` uses removed `$synced`, proxy negation, and a selected reserved field, breaking Saving. | Confirmed source defect. The metadata owner now compares an ordinary selected pending field with actual source updates. | `fixed-now`: select `hasPendingWrites: list.$hasPendingWrites` and read that ordinary property in the card. | Query-runtime pending-field witness plus static component wiring. No native device run is claimed. | +| R11 | `completeWithoutCommit()` can complete failed work, yet is @internal, cross-package used, and documented publicly. | Confirmed state-admission failure in the primary transaction model. Cross-package use is intentional; public migration instructions should not name the internal helper. | `fixed-now`: pending-only guard; guide describes restoration capability without advertising the method. | External completion action, repeated-attempt grammar, state/outcome assertions, and Offline caller tests. | +| R12 | `local-storage-collection.md:118` still uses removed `isPersisted`. | Confirmed authored-doc residue. | `fixed-now`: use `when('settled')`. | Source check and migration guide; generated references remain CI-owned. | +| R13 | `db/src/index.ts:54` exports unused warnOnce/resetWarnings. | Confirmed no remaining production callers. Some Query tests still reset the obsolete helper. | `fixed-now`: remove implementation, Set, exports, direct tests, and obsolete Query resets. | Generic error-census fixtures remain: they test census behavior, not this removed API. | +| R14 | `live-query-window-binding.ts:75` retains unused dependencies recompile mode. | Confirmed no active caller. The structural-equality policy still serves real identity/reactive paths. | `fixed-now`: remove only the unused mode and comment. | Full React runtime and source-ID owners preserve window/identity behavior. | +| R15 | `compiler/index.ts:1063` allocates functional source aliases for every row. | Confirmed source placement. No timing or throughput improvement is claimed. | `fixed-now`: compute once per compiled select. | Existing functional and metadata comparisons enforce unchanged semantics; size measurement records shipped cost. | +| U1 | Reviewer could not run release fixtures because dependencies were absent. | Local installed fixture path works. | `fixed-now` (evidence gap): 11 release fixtures pass. | Commands and outputs below; no publishing command ran. | +| U2 | Reviewer Query run loaded an old second DB, so supplied no valid runtime evidence. | Setup failure did not establish product RED. | `fixed-now` (evidence gap): rebuild local DB, then all 29 Query runtime files/978 tests pass. | Preserve the single-runtime requirement and build-before-adapter-test workflow. | +| C1 | Branch moved from reviewed 0a60ad85b to 66454803b; roughly 40 later files unreviewed; detached review worktree preserves clean old tree. | Scope caveat confirmed: 44 files changed. Designated implementation worktree remained the only edited checkout. | `fixed-now` (scope accounting): reevaluated all findings at 66454803b, inspected changed production replay/ordered code, and ran its five receiving suites, 534 tests. This is not a claim to have independently reviewed every changed dependency/example. | Record exact heads, the integration evidence, and the separate readiness limit. The detached review checkout remains untouched. | + +The 15 numbered items account for **13 fixed-now and two accepted-design**. Splitting R1 into its two receiving paths produces 16 numbered subclaims: 14 fixed-now and two accepted-design. Including U1, U2, and C1 gives **19 ledger rows = 17 fixed-now + two accepted-design**. No confirmed-open, deferred, refuted, stale, duplicate, or unanswered design-decision item remains in this review's authorized repair scope. + +A follow-up census check exposed a stale frozen development message for error 234 after the approved cache-conflict expansion. It failed exactly on that text. The fixture now records the same code with its expanded getKey/schema diagnostic; the oracle prose states the authority. No generated API reference or error documentation was edited. This was a contract-fixture update, not a relaxed classifier. + +## Verification and calibration + +Commands use the implementation worktree. Package runtime commands run from the named package; `../../node_modules/.bin/vitest` is the installed root binary. `--coverage.enabled=false --typecheck.enabled=false` separates runtime checks from the independent TypeScript checks below. + +- DB final law run: metadata 409, cache 144, external outcomes 27, core identity and index warning tests: **611 pass**. Metadata reach counts are above. +- DB wider neighbors: **1293 tests/12 files**, plus **210/5**, passed. These include source identity, union, inline values, functional/context materialization, transaction and publication owners. Results overlap; these counts are not a unique-test total. +- Main integration: `vitest run tests/query/load-subset-replay-refinement-oracle.test.ts tests/query/ordered-source-loader-state-oracle.test.ts tests/query/ordered-work-oracle.property.test.ts tests/query/live-query-collection.test.ts tests/collection-subscription-replay.test.ts`: **534 pass/5 files**, fixed and random campaigns intact. +- Query: rebuild DB, then `vitest run --config vite.config.ts --coverage.enabled=false --typecheck.enabled=false`: **978 pass/29 files**, including both ordered-refetch campaigns. +- React: `vitest run --coverage.enabled=false --typecheck.enabled=false`: **765 pass/28 runtime files**. The final selected cache/source-ID/useLiveQuery run passes **172/3** after restoring the mutant and rebuilding DB. +- Offline: `vitest run --coverage.enabled=false`: **302 pass/24 files**. The pre-edit restoration/interruption witness was **16 pass**. +- Release: `node --test scripts/version-packages.test.mjs scripts/publish-packages.test.mjs`: **11 pass**. +- TypeScript: `tsc --noEmit -p packages/db/tsconfig.test.json`; `tsc --noEmit --rootDir . -p packages/react-db/tsconfig.json`; the same rootDir command for Query: **all pass**. The workspace rootDir includes the shared source/test owners legitimately imported across packages. +- DB build, frozen offline lockfile-only verification, changed-file oracle lint, ordinary ESLint, and Prettier pass. ESLint retains existing Query async/no-shadow warnings in unchanged lines. +- Consumer bundles: minified public API passes (113 error names, index metadata, rows, live updates). Production error erasure keeps development text and erases 263 literals, including 22 shared literals, in ESM and CommonJS. +- Production error census: **19 pass/2 files** after the approved error-234 fixture update. Its initial single text mismatch is preserved as an intermediate failure, not product compatibility evidence. + +Calibration reinstates one wrong implementation at a time, runs the same comparison, and restores source in a `finally` block. Cross-package calibration rebuilds DB before the React witness, then restores and rebuilds the repaired DB: + +| Wrong implementation | Comparison result | Classification | +| --- | --- | --- | +| Original compiler at 66454803b, final 409-case grammar | 220 failures /189 pass | Metadata assertion failures; expanded compositions calibrated after the initial gate. | +| First-present rather than last-present structured spread | Four failures /405 pass | Field comparison rejects matched two-source cases; distinguishes an incomplete R5 repair. | +| Remove only local option-conflict guard, core | 36 failures /108 pass | Admission throw/rejection comparisons. | +| Same missing guard, rebuilt DB and real React mount | Six failures /18 pass | Initial-render admission comparisons. | +| Remove pending-state external-completion guard | Four failing state tests /23 pass | Primary state/outcome law; persisting cleanup diagnostics preserve the original admission failure as cause. | + +Initial failed package-local binary paths and an attempted nonexistent old DB dist import were setup failures. They supplied no product evidence. The first broad Query/React commands also enabled Vitest's typecheck under a package-only rootDir: they emitted 211/212 cross-package source diagnostics. These are not passing commands. Runtime-only commands and explicit workspace-root TypeScript commands above resolve that test setup ambiguity without changing production or suppressing an assertion. + +The initial stale-test reproduction ran `vitest run --coverage.enabled=false tests/live-query-options.test.ts tests/index-suggestion.test.ts` from DB (29 pass/two fail), and `vitest run --coverage.enabled=false tests/useLiveQuery.test.tsx -t 'accepts getKey without queryKey'` from React (one fail). The oracle comparisons use these replay entry points from DB: + +```sh +../../node_modules/.bin/vitest run --coverage.enabled=false --typecheck.enabled=false \ + tests/query/virtual-row-fields-oracle.test.ts \ + tests/live-query-cache-options-oracle.test.ts \ + tests/optimistic-history-outcomes-oracle.test.ts +``` + +The React receiving calibration runs `vitest run --coverage.enabled=false --typecheck.enabled=false tests/cache-options-oracle.test.tsx` from React after rebuilding DB. No run-count reduction, failed-history waiver, or assertion relaxation supplies a GREEN result. + +## Oracle audit + +This is a self-review, not an independent second reviewer. It follows the oracle-authoring and oracle-review skills. + +| Requirement | Outcome | +| --- | --- | +| ORC-001 | Authorities and finite limits are explicit above and beside the models. | +| ORC-002 | Metadata uses authoring shape, cache uses caller declarations, and completion uses primary transaction state. None imports the production semantic classifier for its expected answer. Compiler-generated projection key spelling is the declared trusted comparison. | +| ORC-003 | Laws, models, grammars, drivers, and comparisons remain distinct in the executable owners and named shared model. | +| ORC-004 | Known witnesses reconstruct from the enumerated dimensions. Removing composition, match/order, option, or transaction-state axes loses the corresponding counterexamples. Bounds are 8 compositions, QueryRef depth 2, one held source update, 24 cache inputs, and 1–3 completion attempts. Invalid forms such as a spoofed direct functional row are not fabricated as legal direct returns. | +| ORC-005 | Public Collection fields at three cuts; public admission throws/rejections; transaction state/outcome/rows after each action. Held-source state is observed before the metadata comparison. No concrete missing-input hypothesis remains within those cuts. | +| ORC-006 | Original and narrow hostile implementations fail at the intended comparisons, with counts above. | +| ORC-007 | The new metadata/cache/completion additions are exhaustive finite grammars, not sampled generated properties. Existing important random optimistic, identity, and replay campaigns keep their fixed/random parity and original replay controls/budgets. | +| ORC-008 | Cache/metadata judgments are stateless. The transaction model reuses its existing four states; legal completion and terminal outcomes distinguish them. | +| ORC-009 | Collection, query identity, publication, pending/persisting, and settlement follow the project glossary. Local option claims are runtime references; transport hashes carry none. | +| ORC-010 | Metadata uses existing checkWithCleanup. Transaction cleanup retains the primary failure as AggregateError cause. Cache drivers release observers, client state, and source Collections in finally blocks. | +| ORC-011 | No unaddressed shared-fault hypothesis was identified. The clean sibling supplies key spelling only. Independent authoring rules and hostile mutants challenge the semantic judgments without cloning compiler control flow. | +| ORC-012 | This versioned record identifies the exact repaired commit, evidence, process order, all outcomes, and finite limits. | +| ORC-013 | Nullable/matched spread-order neighbors, same/distinct cache-key controls, and pending/persisting/completed/failed attempt histories distinguish the conditional laws. | +| ORC-014 | Real React reuses the shared cache model/comparison. Offline callers exercise pending external completion in restoration. Metadata is explicitly a real-core Collection boundary claim, not an assertion about a remote SDK or native host. | +| ORC-015 | Metadata's measured counts are above. Every core/React cache case asserts its path's admission reach. Every completion starting state reaches three histories. | +| ORC-016 | New regressions are inputs to the shared model/driver/checker. Ordinary identity/warning tests remain outside oracle files. | + +The complete declared laws are encoded and asserted at their named cuts for these finite grammars. No in-scope cell with a known counterexample is being relabeled as an external limit. Coverage ownership and bounds are recorded in [oracle-coverage.md](../oracle-coverage.md). + +## Code weight + +`pnpm size:compare --packages db,react-db,query-db-collection --files` compares production bundles with merge-base `bc77952e5`. DB grows 199 gzip/139 brotli bytes; React saves 199/190; Query saves 340/224. Total: **340 gzip and 275 brotli bytes saved**. The typical app saves **276 gzip/134 brotli bytes**. + +Against starting PR head 66454803b with `--base 66454803b --exact-base`, these repairs add **84 gzip/91 brotli bytes** to DB and **39 gzip/100 brotli bytes** to the typical app. React/Query package runtime bundles are unchanged. Added local claims distinguish two incompatible callers that portable hashes cannot distinguish; recursive metadata enforcement and transaction admission also need checks. Refactors removed the unused warning Set/API and recompile mode, hoisted per-row alias construction, reused error 234 and the existing transaction admission error, and avoided a new metadata registry or lifecycle. Those savings do not offset every guard byte. Tests and contract documentation grow separately and do not ship in these measurements. + +## Reviewer assessment and readiness + +**Hire, with calibration guidance.** The reviewer found reproducible failing tests, composition bugs, a real missing cache boundary, an honest peer mismatch, and inexpensive cleanup. The runtime repros had high value. Prioritization correctly put failing tests and metadata above code weight. The proposed direction needed context: restoring option-reference identity would reintroduce render churn, a major release would contradict the maintainer's explicit minor decision, and a broader Offline peer would claim nonexistent APIs. The downstream TypeError/ERESOLVE wording also exceeded the experiments performed. The reviewer clearly disclosed their setup failures and moved-head scope, which improves trust. + +Item accounting and authorized repairs are complete for this review. **Whole-PR readiness remains unproven.** CI on the starting pushed head was red in several DB, adapter, framework, SQLite, and browser jobs. This evaluation did not run native-provider E2E or independently review all 44 intervening files. Existing main-imported expected-failure persistence witnesses in #2281 remain separately documented product gaps, not evidence that these repairs failed. Later origin/main commits were fetched for context but are not merged by this repair. The new pushed head needs its own CI result; local green comparisons cannot supply it. diff --git a/docs/contributing/oracle-reviews/2026-10-29-pr-2171-deferred-oracles.md b/docs/contributing/oracle-reviews/2026-10-29-pr-2171-deferred-oracles.md new file mode 100644 index 0000000000..fa6afe5e13 --- /dev/null +++ b/docs/contributing/oracle-reviews/2026-10-29-pr-2171-deferred-oracles.md @@ -0,0 +1,251 @@ +# RC release scope: retained broader oracle failures + +This release deliberately defers the broader production discoveries below. +Their laws remain accepted and their failing comparisons remain preserved. +No GREEN, retirement, or complete metadata/literal closure is claimed. + +The exact original sources and deletion status are recoverable from the full +campaign snapshot `accepted-campaign-20261029T065509Z` (manifest SHA256 `7eb801592ddc3f5041f7db862052800e86523403784827552ad683c8eb40f0aa`). Its tracked patch, complete new/modified source +archive and per-file manifest are retained together. The accepted chunk records +and independent evidence remain in the original campaign evidence directory. +The complete accepted source tree is also preserved on local branch +`codex/rc-oracle-followup`, commit `db38b4e009146af53e46aeeef3206269f5968af8`. +No follow-up PR is claimed until one is actually created. + +- P09: Effect LEFT-join startup emits a phantom update. The accepted owner has + 152 passes / 64 intended REDs and 5,512 reached cells; its concrete witness + also fails frozen main. +- P10: reserved metadata leaks or is spoofed before correlated-child functional + callbacks. The approved callback law remains normative. The accepted runtime + owner has 377 passes / 203 intended REDs and all 2,153 cells; the bounded + concrete lane has the same seven failures and one compatible pass on main. +- P11/P12: raw undefined becomes null and selected empty records disappear. + Their root/QueryRef runtime owner retains 18 REDs, correlated receiver 72 REDs, + and source-public type receiver 280 REDs. Every declared receiving cell and + accepted calibration remains preserved. These paths are source-unchanged; + a full main-baseline campaign was not executed. +- The appended chunk-24 type receiver retains 672 ordinary-metadata leak REDs + and 12 discriminant-specific union-spread REDs, all 6,240 declared states and + its corrected actual-callback observation/43-control evidence. Static and + runtime observations remain distinct. + +The original 24 nullable-facade/public-output type campaigns and nine controls +remain active on the release branch, as do root/union/join/timeline/facade +metadata regressions, their shared grammar/source/interval models, and the +already-green descriptor/schema/source-ID improvements. The release's cache, +Offline, mutation, publisher, CI and packed-artifact gates remain required. + +The following complete files travel together to follow-up. The appended literal +slice of `subquery-user-value-oracle.test.ts` and appended receiving slice of +`virtual-row-fields-types-oracle.test.ts` are also preserved in that snapshot. +No skipped tests or narrowed expected comparisons implement this partition. + +- `packages/db/tests/query/virtual-row-fields-effect-oracle.test.ts` +- `packages/db/tests/query/virtual-row-fields-input-oracle.ts` +- `packages/db/tests/query/virtual-row-fields-input-driver.ts` +- `packages/db/tests/query/virtual-row-fields-input-oracle.test.ts` +- `packages/db/tests/query/subquery-literal-value-oracle.ts` +- `packages/db/tests/query/subquery-literal-value-driver.ts` +- `packages/db/tests/query/subquery-literal-correlated-oracle.ts` +- `packages/db/tests/query/subquery-literal-correlated-driver.ts` +- `packages/db/tests/query/subquery-literal-correlated-factories.ts` +- `packages/db/tests/query/subquery-literal-correlated-oracle.test.ts` +- `packages/db/tests/query/subquery-literal-types-oracle.ts` +- `packages/db/tests/query/subquery-literal-types-support.ts` +- `packages/db/tests/query/subquery-literal-types-oracle.test.ts` +- `packages/db/tests/query/virtual-row-fields-receiver-types-oracle.ts` +- `packages/db/tests/query/virtual-row-fields-receiver-types-support.ts` + +The coverage map and architecture point here so historical full-campaign claims +below those entries cannot be mistaken for current release registrations. +Before follow-up repair, restore the exact dependent owner/driver/model slices, +reproduce their retained RED and reach, and then supply GREEN/mutant evidence. + +## Further retained receiving and guidance gaps + +P06/P07/P13/P14 prose conflicts are corrected on the release branch. Two +coverage obligations remain separate from that correction: P08 needs the actual +custom-adapter fenced callback to hold an establishing applied receipt before +load settlement; P14 needs the actual lazy child-ready call outcome compared +with the existing source-health model, instead of catching and discarding a +throw. Preserve the accepted owner, fresh-run neighbors and omitted-callback +reach controls before any follow-up production repair. + +Installed declaration emission also exposes an unchanged-main portability gap. +The exact initial RC artifacts and strict installed main `95460bc875` both fail +in ESM and CommonJS for an exported inferred live-query factory (TS2742 twice, +TS4058) and an exported inferred Collection value (TS2742 twice, TS4023). +Private compiler/live types and `LIVE_QUERY_INTERNAL` cannot be named. This +affects libraries emitting declarations for these inferred exports, rather than +ordinary runtime use. Handler admission passes in both formats; it cannot prove +this separate portability law. The release does not repair this broader gap. + +The following original factory witness is preserved verbatim so follow-up does +not depend only on temporary artifacts. Compile its `.mts` and `.cts` forms +with strict NodeNext declaration emission against actual installed tarballs. +Keep the original export unannotated. A separate named public view containing +only readonly `toArray` emits on both cuts, but is not a full-Collection +annotation workaround. Add legal generated row/key shapes and both factory and +inferred-value emission paths to the public-type owner before production repair. + +```text +import { IR, Query, createLiveQueryCollection, eq, include, materialize, toArray } from '@tanstack/db'; +import type { Collection, QueryResult } from '@tanstack/db'; +type LiteralSourceRow = { + id: number; + version: number; + optional?: string | null; +}; +declare const source: Collection; +declare const parents: Collection<{ + id: number; + group: number; +}>; +declare function consume(row: object): void; +declare function consumeChild(row: object): void; +declare function consumeParent(row: object): void; +type __ArrayBase = Array; +export function receiver() { + const selected = new Query() + .from({ row: source }) + .select(({ row }) => ({ id: row.id, version: row.version, value: 0 })); + const __observedQuery = selected; + const __observedCollection = createLiveQueryCollection({ + query: __observedQuery, + getKey: (row) => row.id + }); + type __Observe_finalQuery = QueryResult; + type __Observe_finalCollection = typeof __observedCollection.toArray[number]; + return __observedCollection; +} + +``` + +Original source SHA256 is +`c6c8c1da415901f4935f611387e7f6a3234b5f9753544256f0680038dcc1b50d`. +Current/main final receiving receipts are +`eb157fdc5f567fc04647d206762d794455a998620adc3d9383abf74e0bfc503c` / +`2987feb2620ba1699869b12dbe51c811b6b0e6c389d06a176bf9a417d1827495`. +The original strict emitted factory/value REDs remain required follow-up +receivers. An agent-added narrow `Collection` annotation was an +invalid control on both cuts and supplies no product-failure credit. + +## Live-query mutation input and full-handler receiving types + +The release also defers a public read/write type conflation. Independent source +checking compared current `180b920edd6c88a7024ed407cd04934678c7d5bf` with main +`95460bc875a2259b34eea534e5592a9d0394e73f` using TypeScript 5.9.3. Both already +carry required virtual metadata from a no-select query's published result into +its default mutation input and invariant full handler Transaction/Collection. +The nullable-facade and union-brand repairs do not change this path. Required +readonly metadata on published read rows remains the accepted law; this record +does not authorize removing it. + +A stable public witness configures `onInsert`, then calls +`collection.insert({ id: 'one', label: 'new', rank: 1 })` on a live-query +Collection of that ordinary row. Both revisions reject the business row with +TS2345 because computed virtual fields are missing. Separately, a reusable +`UpdateMutationFn` on explicit live-query configs, core creation and React +config hooks passes when a fresh Program requests diagnostics first, but fails +with TS2322/TS2769 after observing its context/published aliases. This checking +order sensitivity reproduces on both cuts; it is not a stable CLI RED. Ordinary +source handlers, contextual live handlers and the free-result options factory +are positive controls. All 26 final independent Programs have no setup errors. + +Root explicitly retained this public defect for follow-up while permitting a +type-only structural recorder annotation in the JavaScript runtime schema +receiver. That receiver still checks every ordinary handler field and makes no +public mutation-type claim. Its green histories cannot close this separate gap. + +Extend the public metadata type owner and mutation-handler admission owner with +the same authored business row through live-query factories and framework +configs. Compare plain mutation input and reusable full-handler admission while +read results retain required metadata. Add source-public, built/installed and +framework receiving checkpoints; retain ordinary diagnostics and observed-type +checking orders, or a stable source discriminator, before production repair. + +The exact source/diagnostic matrix SHA256 is +`3aac69adf834870f402e3334cdfe372f6f07b924ff5f67fd54e406eba69ef14d`; +the classification report is +`2e60e015e5f42b24027eb53d13863d76c2b2530c01a49616cc746c70952906ab`. +Both remain under the original campaign evidence directory as +`metadata-mutation-classification-matrix.json` and +`metadata-mutation-type-classification.md`. No production repair or bug-class +closure is claimed. + +## Shared-source replay with late include demand + +The release defers a replay publication defect confirmed on release head +`d9911f17b5e9de872cac5c9fd745c656ff50d3ad` and exact main +`95460bc875a2259b34eea534e5592a9d0394e73f`. The primary owner is +`packages/db/tests/query/load-subset-replay-refinement-oracle.test.ts`. +Its native named property `replay.ordered-failed-replay`, seed `521312968`, +path `255:3:3:8:11:9` fails on both cuts at `checkRetention`, after the final +settle: row1 is public while the retained complete window is `[]`. An +independent reviewer reproduced both failures and accepted this classification. +The owner is byte-identical on both cuts (SHA256 +`27f76be612a3058235953e5b984b77ba9274f1aab073035543161b71d4d14e48`). + +The accepted authority is `packages/db/src/query/live/ARCHITECTURE.md`, +“Replay participants and failure,” and normative law 9. A live query's join +and include subscriptions to one source share one publication barrier; demand +started while that source replay remains open must hold the prior complete +result until its current acquisition settles or aborts. Here the include has +no demand at truncate. It acquires and replaces marker demand while the join +replay remains open. Its failed acquisition retires, but its replacement for +key1 is still pending and nonaborted when the final join load settles. The +model has no retained failure at that cut. Publishing row1 therefore violates +the shared-source barrier; a stale model failure does not explain the RED. + +The preserved seven-command witness is: + +```json +[ + { "type": "hold" }, + { "type": "truncateMarkers" }, + { "type": "settle", "ok": true, "keepHolding": true, "newest": false }, + { "type": "settle", "ok": false, "newest": true, "keepHolding": true }, + { "type": "settle", "ok": true, "keepHolding": true, "newest": false }, + { "type": "mark", "id": 1, "on": false }, + { "type": "settle", "ok": true, "keepHolding": false, "newest": false } +] +``` + +Its scenario is descending, offset1, limit2, no ties, no peer, `joined: none` +(anti-join), on-demand markers, includes enabled, aborted marker loads resolve, +and history starts after preload. From each frozen snapshot's `packages/db`, +the actual noninstrumented replay command was: + +```sh +TANSTACK_DB_ORACLE_PROPERTY=replay.ordered-failed-replay \ +TANSTACK_DB_ORACLE_SEED=521312968 \ +TANSTACK_DB_ORACLE_PATH=255:3:3:8:11:9 \ +../../node_modules/.bin/vitest run --config ../../replay-db.config.mts \ + --coverage.enabled=false --typecheck.enabled=false \ + tests/query/load-subset-replay-refinement-oracle.test.ts \ + -t 'keeps the last publication through a failed replay' +``` + +Both runs used Node 24.19.0, Vitest 4.1.12 and fast-check 3.23.2. The scratch +config merges each cut's DB config and aliases DB/IVM to that cut's own source; +tools alone are borrowed. Independent verification compared all 154 DB/IVM +source files with their Git objects, with no differences or foreign source. +This reproduces the named assertion failure, not CI's entire environment. +The original CI log remains under the campaign evidence directory at +`ci-head-d9911f17b/failed-123338231520.log`; the frozen cuts, exact logs, +diagnostic logs and independent review remain in `ci-replay-521312968/`. +The independent report +`independent-retention-review.md` has SHA256 +`a36d5793c87495efb5516ca42ee4adeede867da462bc2e869e6c3698a44b63db`; +its source verification receipt has SHA256 +`7385d6a029caa6daeab3b1d285164df7a8dd3c4598c1e892b4bba0f944b51bde`. + +Before production repair, preserve this replay and add measured reach for a +receiving subscription with no demand at truncate, late demand while a sibling +replay is loading, failure retirement followed by a held replacement, and +success/abort neighbors across multiple subscriptions to the same source. +Then demonstrate the retained-publication comparison before replacement +settlement and after it. The original oracle remains active and unchanged; +there is no skip, narrowed comparison, retry-away, GREEN, or bug-class closure +claim. Its CI RED remains an open baseline defect under this explicit release +scope deferral. diff --git a/docs/contributing/oracle-reviews/2026-10-29-pr-2171-release-gates.md b/docs/contributing/oracle-reviews/2026-10-29-pr-2171-release-gates.md new file mode 100644 index 0000000000..f4c4dba77e --- /dev/null +++ b/docs/contributing/oracle-reviews/2026-10-29-pr-2171-release-gates.md @@ -0,0 +1,265 @@ +# PR #2171 release gates and deferred investigation + +Recorded on 2026-10-29 after chunk 21 R2 acceptance. Production baseline: +`858b174a65f160d13d01f0324c7481cc8e4eba3a`. The maintainer requires a merge +in about eight hours from 04:50 UTC: approximately 12:50 UTC / 06:50 Denver. +This is a prioritization record, not release approval or a green test report. + +The existing Version Packages action is connected to the custom version and +publish commands. The independent version owner passes 86 histories. The +first-RC changeset inventories 23 candidate packages and the two accidentally +stable adapters, Query and Offline, with their approved minor releases. +Generated API docs remain owned by the separate CI-created PR. + +## Required before merge + +- Integrate current main. At this audit, GitHub reports merge conflicts and + has no full test CI on the current published head. Latest main includes the + Vitest and Zod security upgrades. A read-only merge forecast identifies + conflicts in the root manifest, lockfile and React source-ID test. +- Repair demonstrated publisher failures using the accepted release owners: + P01 permits the wrong candidate dist-tag; P05 attempts duplicate uploads on + retry or an already-current release. These are concrete defects in the + release process, not undefined product behavior. +- Repair PR-introduced cached-server admission and hydration replacement + claim gaps, and restored-sibling transaction cleanup. Their accepted oracles + already demonstrate the failures on unchanged production. +- Complete the Query mutation migration at the documented confirmation path + (G01) and the persisted test handler returning an object (G08). +- Check clean Offline installation and build ordering (G05). Its DB workspace + build edge is absent; an actual fresh-build failure remains unmeasured. +- Validate clean installation/build, initial packed exports and same-release + Query/Offline consumers, then run required CI on the final intended head. + Current workspace links and source-public type tests do not supply packed + consumer evidence. A dependency fetch/setup failure is not a product RED. + +No executable production repair has begun during this campaign. Relevant +oracle checks and real receiving paths remain the gate before each repair. +This deadline does not authorize weakening comparisons to obtain green CI. + +## Preserve separately from the release gate + +Chunks 1–21 are independently accepted as instruments; many deliberately +expose product defects. Acceptance does not mean production passes. The chunk +record and coverage map retain each exact law, boundary, evidence and limit. + +P11 (raw selected undefined becoming null) and P12 (selected empty records +disappearing) have current failing witnesses and source paths unchanged from +the merge-base. Their broader literal-value work is a follow-up candidate; +no baseline runtime campaign has yet established historical behavior. + +P09 (Effect LEFT-join startup publication) and P10 (callback reserved metadata) +are confirmed current failures. Their baseline causality still needs bounded +checking. P10 also spans an explicitly approved callback contract. Keep these +open until that check determines their release disposition; do not label them +undefined merely to defer them. + +Broader literal/type grammars, additional native UI histories, declaration +portability, diagnostic-baseline reconstruction, recorder improvements and +static/work investigations remain separately tracked. The immediate order is +main integration and release-critical gates. No test has been removed or +relaxed by this record, and no follow-up PR has yet been created. + +## Evidence at this checkpoint + +The versioned [chunk record](2026-10-28-pr-2171-oracle-chunks.md) and +[review matrix](2026-10-28-pr-2171-review-coverage-matrix.md) remain the detailed +inventory. Chunk 21 R2 manifest SHA256 is +`da237233542b78a0de9eb836ecbeed12b6b14c2e80f91837eb577c8813bcf0a7`. +Its independent result is 115 passes / 280 intended native type REDs, all +2,550 reach states once, 55 shipped controls and 20 independent controls. +Root independently verified the frozen input/evidence hashes and parsed the +395 unique test titles; root did not rerun that native campaign. + +Scratch audit details, without claims of additional execution: +`/private/tmp/rc2171-oracle-challenge/o22-release-readiness-audit.md` and +`/private/tmp/rc2171-oracle-challenge/release-critical-triage.md`. +All campaign work remains uncommitted at this checkpoint. + +### Main integration checkpoint + +Main `95460bc875a2259b34eea534e5592a9d0394e73f` is integrated in normal merge +commit `5fa6eca05530bb9e1f4748b7c00ae60976da2808`. The root manifest and +lockfile retain the release dependencies alongside Vitest 4 / Zod 4. The React +resolution preserves this branch's removal of legacy dependency-list tests. +The merge commit's lint hooks pass. That is not a full test-CI result. + +Before merging, all 82 dirty/deleted/untracked campaign paths were saved in +an independent hashed file archive and Git stash +`2570e0bd0980f6db12cbdfe58d9a13a0b743779c`. The stash was applied after the +merge and retained as a backup. Restoration checks find all 82 paths present +or deleted as intended. Eighty retain their exact saved bytes; DB's package +manifest takes main's coverage-tool upgrade and the Electric oracle takes +main's constructor-compatible mocks. No library source differs between the +pre-merge and merged commits. Campaign evidence from the older toolchain +remains labeled with its original provenance; it is not silently credited to +Vitest 4. A disposable clean-install/build check is the next receiving gate. + +### Executed clean setup and build gate + +The standalone stage is +`/private/tmp/rc2171-oracle-challenge/gate-a-merged-5fa6eca05/source`. +Its manifest freezes 3,400 source files at the merged commit plus the restored +campaign overlay. The independent reviewer verified every hash, the captured +tracked diff, the absent inherited build/install trees and the confined DB +workspace links. No source/config/lock input changed during execution. + +Two offline frozen attempts failed for missing cached +`@changesets/apply-release-plan@7.0.14`: first in the default temporary store, +then in the explicit existing source content store. These are setup failures. +The exact online frozen install then passed, adding 2,035 packages. It uses +Vitest 4.1.12, Zod 4.6.7, TypeScript 5.9.3 and Changesets 2.29.8. UUID 13.0.2 +installed; no dependency override or downgrade was needed. The original +worktree's installed dependencies remain untouched. Node 24.19.0 differs from +the repository's 24.8.0 declaration, so exact Node parity remains unclaimed. +An example postinstall intentionally treats a missing native build command as +nonfatal; this is not native-host validation. + +The actual `pnpm run build` is RED. Its first topological tier builds DB-IVM +and Offline together; Offline fails with TS2307 for DB's absent declarations. +The measured `ivm-offline` dependency closure contains only DB-IVM and Offline, +despite Offline's installed DB link resolving correctly inside staging. This +is G05's clean-build failure, rather than an installation failure. + +`typecheck:tests` also fails on the missing DB declarations and resulting +cascades. SQLite-core's actual source typecheck independently reproduces G08: +the persisted receipt test's handler returns `Promise<{}>` where +`Promise` is required. Convex's source typecheck and Svelte's native +source check pass; Svelte reports zero errors and warnings. Neither pass +establishes a complete successful build or packed declarations. + +Both executor and independent reviewer accept this evidence classification. +No production repair, versioning or packing ran. Final result and hash records +are in the stage's `evidence/gate-a-final-results.json` and +`evidence/gate-a-evidence-hashes.json`; the latter SHA256 is +`a52ef757bc31346f043f59fee11b72762f808f5bddf84535913c4126eceeab33`. + +### Bounded baseline attribution + +The retained accepted Effect history fails at the same initial phantom-update +comparison on current code and frozen main `95460bc875a2259b34eea534e5592a9d0394e73f`. +The bounded inline-child / QueryRef / structured-projection metadata lane also +produces the same seven assertion failures and one compatible control pass on +both graphs. The execution verifies coherent DB/DB-IVM source resolution and +all loaded source hashes. Whole-matrix reach errors from targeted selection are +recorded separately from the actual law failures. + +These witnesses establish that the concrete P09 and runtime P10 failures are +present on main; they do not attribute every related type or runtime path. +P10's explicitly approved callback law remains distinct from that historical +attribution. No comparison or product law changed. Exact commands, outputs, +source graphs and earlier setup failures are retained under +`/private/tmp/rc2171-oracle-challenge/bounded-main-disposition/`. + +### Deadline scope checkpoint, 2026-10-29 06:00 UTC + +The release is not ready yet. Current main is merged and pushed, all 25 public +packages are represented exactly once in the first-RC changeset, and the +version owner passes. The existing release action invokes the intended version +and publish commands. The migration guide is present in navigation. These +verified parts do not establish successful publication or final release CI. + +The remaining merge gates are the demonstrated publisher failures, introduced +cache/hydration and Offline settlement defects, incomplete mutation migration, +clean-build dependency ordering, CI migration/tooling failures, and final +packed-package and CI checks. Current CI run 40821046528 is red. Its seven +diagnosed groups are retained under +`/private/tmp/rc2171-oracle-challenge/ci-5fa6eca05-40821046528/diagnosis.md`. +Chunk 23 repairs the SortedMap reach sampling failure with independent review; +it has not yet been committed or tested in CI. The mobile deadline receiver's +failure still needs causal diagnosis. + +Finish the active bounded static-metadata chunk 24, then limit further oracle +work for this release to the receiving/recorder checks necessary for these +merge gates. Broader grammar expansion and investigations are follow-up scope +candidates. Preserve their owners, exact counterexamples and evidence; no +comparison is relaxed or silently excluded. In particular, baseline-present +P09/P10 and source-unchanged P11/P12 are not reclassified as undefined behavior. +P10 also implements an explicitly approved law, so any deferred implementation +must identify the omitted promise and preserve its failing receiving witnesses. + +No follow-up split has been applied yet. Before shipping, the final diff must +separate deferred evidence from the release's green comparisons and state the +remaining limitations. Reserve time before the approximately 12:50 UTC merge +deadline for a clean build, packed consumers and required final-head CI; +completion of the oracle work alone is not release readiness. + +The completed read-only audit and dependency-aware split proposal are retained +as `readiness-after-chunk23.md` and `release-followup-partition-proposal.md` +under `/private/tmp/rc2171-oracle-challenge/`. The latter classifies all 85 dirty +paths at its capture and explicitly holds the three actively authored chunk 24 +files. It is a proposed partition, not an applied split or accepted final freeze. +Keep the already-green descriptor/schema receiving improvements if splitting +their shared files would add integration risk. Current mutation-guidance P02 +and P04, plus editorial G07/G11, remain part of the authored migration cleanup. + +Pinned Changesets Action `e0145edc7d9d8679003495b11f87bd8ef63c0cba` source was +retrieved and independently inspected. It captures all custom-command stdout, +scans every `New tag:` line and rereads package manifests after the command +returns. The proposed split publisher preserves those source-level conditions. +No Action execution or final published-package output has been observed. +The proposal remains unexecuted production; the accepted HTTP REDs still block. + +One unchanged CI07 covered-demand history passes locally with exact merged +sources, Vitest 4.1.12, BetterSqlite 12.8.0 and Node 24.19.0 on macOS: one +selected pass, five unselected, 1,121 ms. This is non-reproduction on that host, +not a repair or closure of the original Linux/Node 22 CI assertion. The timing +premise remains unmeasured. Evidence is retained under +`ci07-unchanged-retry-5fa6eca05-kqu_7670/` in the same scratch directory. +All 3,400 Gate A source hashes, prior evidence and critical tool-package hashes +remain unchanged. Vite created an empty borrowed-tools `.vite-temp` directory; +the record explicitly withdraws any claim of zero tool-cache writes. + + +### Oracle phase checkpoint, 2026-10-29 06:55 UTC + +All 26 planned oracle chunks are independently accepted as instruments. This +includes the native Solid/census corrections and generated-error command +receivers; no executable production repair has begun. Chunk 26 R3 preserves +all previous results and adds a calibration rejecting anchors inside fenced +message examples. Its old-tooling result is 27 PASS / 12 assertion RED. The +freeze is `2baf81b3904bf1f8005dee31aef2b0c7b2d46bb2b758825ee59ecffaa3fbce7f`; +independent review is `cf7cc0ca73dd7805df42392213a8f1371fd95fd20b702525bc29eb1b78cb35b6`. +Root verified the frozen source and protected inputs before authorizing repair. + +GitHub still reports the published head mergeable with failing CI. The release +remains blocked by the concrete repair and receiving gates above. Preserve the +full accepted campaign before separating the broader baseline discoveries. +Keep already-green framework receiver improvements to avoid risky shared-file +splitting. Later four-cut artifact campaigns remain unexecuted; the immediate +artifact gate is the actual first-RC CLI and all 25 initial packed artifacts, +with installed Query/Offline consumers. No publication or final-CI success is +claimed by this checkpoint. + + +### Applied release/follow-up partition, before executable repairs + +The complete accepted 93-path campaign is preserved on local branch +`codex/rc-oracle-followup` at `db38b4e009146af53e46aeeef3206269f5968af8`. +The independent archive is `accepted-campaign-20261029T065509Z`, manifest +SHA256 `7eb801592ddc3f5041f7db862052800e86523403784827552ad683c8eb40f0aa`. +The archive includes tracked changes, new owners and deleted-path status. +No generated API docs or generated errors page entered this snapshot. + +Independent inspection verified every archived path against that Git tree, +all 21 proposed transformations and the inverse patch, and 1,701 retained +code paths for imports of deferred files. The applied partition moves 15 +complete broader RED files and their two appended shared-file slices. +Seventy-three dirty paths remain byte-identical; already-green framework +receivers stay intact. Forward patch SHA256 is +`85825e99a43a2d5d05a4e205d54c2893c2cfa2875375e883dca662f8b9743cc3`; +inverse is `aba2a751ffa2038f5e1826b427585461345e44c1cc9543fc35b3687cdd41b044`. + +The retained 505-line public-output type owner preserves all 24 generated +campaigns and nine controls, including existing structured-union-spread +precision laws. Deferring the appended receiver does not retire those laws +or establish that the nullable repair alone makes every retained check green. +The [deferral record](2026-10-29-pr-2171-deferred-oracles.md) identifies each +open class, accepted law and historical RED. The architecture states the +correlated callback limitation. No comparison was weakened or skipped. + +Only after the 26th independent acceptance and this recoverable partition +did executable production repairs begin. They are confined to demonstrated +publisher, cache/hydration, Offline caller/build, nullable facade type, +mutation-guidance and generated-doc ownership defects. Each repair still +requires its unchanged accepted comparisons, GREEN and hostile-mutant evidence. diff --git a/docs/contributing/oracle-reviews/2026-10-29-pr-2171-release-repairs.md b/docs/contributing/oracle-reviews/2026-10-29-pr-2171-release-repairs.md new file mode 100644 index 0000000000..02bc73e9d7 --- /dev/null +++ b/docs/contributing/oracle-reviews/2026-10-29-pr-2171-release-repairs.md @@ -0,0 +1,399 @@ +# RC release repairs after the oracle campaign + +All 26 instrument chunks received independent acceptance before executable +production repairs began. The parent is `5fa6eca05530bb9e1f4748b7c00ae60976da2808`. +The full pre-repair campaign remains recoverable at +`db38b4e009146af53e46aeeef3206269f5968af8`. The +[scope record](2026-10-29-pr-2171-deferred-oracles.md) preserves broader open +classes and their accepted laws. No generated documentation enters this change. + +## Law, reach, RED, GREEN, mutant and limits + +### Publication + +CONTRIBUTING requires stable versions on `latest` and candidates on `rc`. +The independent release ledger judges exact versions, tags, duplicate uploads, +retained successes and raw manifest/prerelease-state restoration. The old +publisher gives 14 HTTP assertion failures and 12 command-receiver tag +failures. Exact-version metadata selection and explicit upload tags make all +167 owner checks and 38 ordinary controls pass. Each of the 29 HTTP histories +reaches its declared metadata, comparison and restoration cuts. Original-code, +wrong-tag and default-selector mutants fail the intended public comparisons. +The commands select the release-history, publish-history and publish-http +oracle runners and ordinary version/publisher/release-fixture tests. This covers actual +npm/pnpm/Changesets against a loopback registry, not public publication or live +Actions authentication. The normal inherited tag output is preserved. + +### Cache admission and hydration + +The approved rule rejects conflicting source/getKey/schema claims under one +cached identity while mounted hooks retain their inline options. Portable +hydration accepts its first local claim, preserves active claims across newer +data, and retires them on failure or consumption. Two production lines enforce +server admission and preserve row options on non-error hydration replacement. +Fresh pre-edit runs reproduce 1,536 admission failures across core and real +React server receivers. Repaired results: 6,556 core and 1,440 React owner checks, +plus 258 neighboring checks, all pass. Declared core routes and real React +server cuts run. Omitting either line restores its intended admission failures; +retirement controls remain green. Commands select the cache options/lifecycle +owners and React cache options/first-registration owners. Arbitrary concurrent +rendering, hydrateRoot, Router transport and other-framework first registration +remain outside this bounded evidence. + +### Restored Offline transactions and the clean build + +A failed core restoration snapshot stays failed even if independent durable +outbox work later succeeds. The caller now avoids externally completing an +already settled snapshot. The core pending-only admission guard stays intact. +The unchanged Offline owner initially has 14 warning-comparison failures. The +repair passes all 87 Offline checks, all 287 core outcome checks and all 33 +persisted-receipt examples. All 220 receiving cuts run once. Restoring the old +caller reproduces exactly 14 intended failures. Three source type checks pass. +The modeled scope is controlled storage/provider delivery with bounded restored +sibling histories; it does not establish native durability or arbitrary retries. + +Offline now explicitly declares its DB workspace development dependency, and +the persisted receipt fixture drops its obsolete object return without changing +its awaited barrier. Lock generation attempts encountered unrelated registry +resolution failures. The final importer move is mechanical, not represented as +pnpm-generated. A separate fresh frozen-lockfile install succeeds against that +exact lock; the actual clean full build, root test typecheck and SQLite core +typecheck pass. No local override or dependency downgrade is included. + +### Published row types + +The retained generated public-output owner independently requires metadata on +an unmatched facade's empty row and discriminant-specific ordinary union fields. +The old implementation fails 24 campaigns: nullable observations and 96 union +spread comparisons. Optional reserved fields are removed only when required +metadata attaches, and brand removal distributes its existing rule per union +member. All 33 owner tests pass, with 70 states reached by each of 24 histories. +The paired runtime owner passes 1,604 checks and six declaration neighbors +compile. Independent review reruns the 33 checks and declaration neighbors. +An isolated original-intersection mutant fails 24 nullable comparisons; an +isolated nondistributive-brand mutant fails 12 union campaigns. Broader callback +type ownership and installed declaration portability remain separate receiving +boundaries. + +### Authored mutation guidance + +The accepted mutation law requires server confirmation before a successful +handler returns. The actual error-handling POST example now awaits explicit +refetch. Current prose removes automatic-refetch and returned-txid advice. +Pre-edit extraction reproduces two settled-row failures. Native Vitest 4 runs +pass 134 Query, 56 Electric and 85 shared-control/type checks. The exact original +POST handler mutant executes and fails the settled-row comparison. Independent +review repeats these results. A constructor-compatible Electric mock changes +only the receiving fixture, preserving its model, histories and comparisons; +its earlier Vitest 4 setup failure is retained separately. This evidence covers +selected actual handlers and controlled collaborators, not live services. + +### Generated error docs and production messages + +Source PRs judge the actual generator against frozen catalogs. The separate +docs PR owns generated-page freshness. Strict read-only stdout and command +wiring repair the old tooling's 12 assertion failures. Exact final-source +results are 39 passes, stdout reception once and catalog comparison once. +Restored-old-generator and command-body mutants produce 10 and two intended +failures respectively. No catalog or oracle expectation changes accompany the +repair. Actual stale-page checks first demonstrate the premise. The unchanged +real ESM/CommonJS erasure script then checks 263 literals, including 22 shared +literals, with development retention and production erasure. Actual Typedoc +generates all 14 output directories with no errors and 15 warnings; the final +generator check passes. All generated files remain confined to disposable +workspaces. This is not a live Actions execution claim. + +## Exact source and receiving status + +The integrated product/input cut freezes 3,393 entries at 07:06:43 UTC, SHA256 +`e3c7f444478fa49412d18f18d44058baf8c2c91cff090f975b5d430d60b49bc2`. +The normal precommit hook then reordered imports in ten test runners. An AST +audit at `6e6034dcb14d35012e1bf41eb035d3c49a52845f` verifies identical import +sets and test bodies: 3,382 entries remain byte-identical, one deletion remains +absent, and ten differ only in import order. Frozen receiving results therefore +do not claim byte equality for those ten runners. CI tests the pushed sources. +The actual initial version CLI succeeds with private existing +GitHub authentication for changelog reads. All 25 actual tarballs validate: 23 +versions at `1.0.0-rc.1`, Query `1.5.0` and Offline `1.2.0`; 156 export targets +and 50 wrong-version/missing-export controls run. Packed consumer runtime and +declaration checks, plus final-head CI, remain pending at this record's cut. +A pnpm direct-file receiving attempt cannot resolve unpublished transitive IVM +from the registry; no runtime/declaration comparison runs, so it supplies no +API GREEN. This is retained as a setup-premise limitation, not a product defect. + +A separate exact frozen stage runs the actual size command against +`origin/main` at `95460bc875a2259b34eea534e5592a9d0394e73f`. The default nine +package selection saves 665 gzip bytes and 499 Brotli bytes. DB grows 215 gzip +bytes while React, Query and Electric shrink. The typical app saves 276 gzip +bytes; all eight app scenarios shrink. Authoritative inputs still match after +execution. An earlier overlapping version/size attempt is discarded. Tests and +contract documentation grow separately from these shipped-byte measurements. + +## Evidence fingerprints + +Raw logs and source/tool manifests are retained under the session's +`rc2171-oracle-challenge` evidence directory. These report fingerprints bind +the recorded outcomes; the executable owners retain their contracts and limits. + +| Report | SHA256 | +| --- | --- | +| Publisher repair | `94fbcd4e3c77657b79ac1aea973d93d5dc5cab97f3529fd861c8c0a436487eca` | +| Cache repair | `f921b66ea01860b6a948ceec134f4dcb04cf45ebc705ae238ca57f4d78f15156` | +| Offline repair | `dac0746f32ce697fb7595dfbad1e1d3b881e765ad5815645e41c0e43f1219174` | +| Published type repair | `876b4f7620fe93fa59d470fe40066d58da4e95cef7cbebbeb4195501a48b7053` | +| Guidance repair | `7fa678d03b118b1e413568292a378255bcfdd7746b64a24c05970204f010ef15` | +| Error-doc receiving checks | `2c94a4b1388014a3329d80b9e8f852f5e6eb0c4e6cd555ca69ebe5948abb2c29` | + +Independent oracle review records all applicable ORC requirements per chunk. +The initial packed instrument has its own bounded independent review and wrong +observation calibrations. Passing package manifests or source-public types +cannot substitute for its still-pending installed API comparisons. + +## Post-push receiving checks and mechanical corrections + +At `6e6034dcb`, every core, Collection-adapter, SQLite and Offline CI test +group passes. Desktop and cloud/mobile E2E pass. The first pushed cut fails +three fixture gates: a local name conflicts with the private-member mangle +inventory; React's native source typecheck loses an inferred query context; +and relocating CI's pnpm shell launcher breaks its relative CLI lookup. +Browser E2E and the corrected final-head CI remain pending at this entry. + +The local array rename passes the actual 1,152-case React owner and the +441-name mangle check. Explicitly deriving the schema receiver's context from +its actual query passes the complete 2,956-case React package and the final +237-case native/focused selection, including 1,728 receiving cuts. Independent +transpilation proves identical emitted runtime code for the context annotation +and alpha-equivalent code for the array rename. Models, histories, comparisons, +cleanup assertions and run budgets stay unchanged. The CI inference failure +does not reproduce locally even before the annotation, so the environmental +cause remains unknown and only a successful actual CI rerun closes that gate. + +P06/P07/P13/P14 authored guidance now matches the existing logical-owner, +release-before-reacquisition and same-run `markReady()` no-op laws. Changes to +`src/types.ts` are confined to its `LoadSubsetFn` comment. Scoped validation and +an exact executable-source comparison pass. P08's actual held-receipt callback +witness and P14's discarded child-ready call-outcome observation remain open; +correct prose does not establish behavioral coverage. + +The posthook AST audit has SHA256 +`92a2a5c7ef45596f27bc669a20b991e7ec7d46529930b7607957fcc7c130223e`. +The schema fixture report is +`892d79f14ff46b2dd14ba8da6033f536d1e0a8ed6f750e68d0a793cd84267090`; +the guidance correction report is +`626e724401575f4149f9857ed886d2274f0c8ab390c60aae537b4de03eccb75c`. +Independent React fixture review retains the original CI RED and locally-green +unchanged baseline rather than claiming a reproduced production repair. + +The HTTP fixture now invokes PATH-selected pnpm at its installed path through a +quoted POSIX `exec` forwarder. Nine ordinary controls pass. The unchanged HTTP +owner passes 35 cases with native pnpm and 35 through a relative shell launcher; +all 29 histories reach each declared state once. Restoring the original +relocation fails the actual relative-CLI setup control. This is setup evidence, +not a new product-law RED. Publisher, models, histories and semantic assertions +stay unchanged. Independent replay accepts the bounded correction. Report SHA256: +`219dc2db851d73af211d377d46165f9233f9435ee96e73a1cc0b0c2b8cbbdb60`. + +### Initial installed package reception and baseline declaration attribution + +The initial candidate artifact cut passes eight installed runtime cells: ESM/CJS × Query/Offline × fulfilled/rejected. The shared mutation-confirmation and restored-settlement comparisons ran through real installed package imports, with 68 checkpoint/control receipts. Both installed declaration formats also pass the shared handler-admission law: 117 candidates and 72 expected negative diagnostics per format, including missing-negative calibration. + +The native npm install used a controlled loopback registry serving unchanged actual release tarballs plus seven explicitly cached external repacks. All 11 installed packages have actual metadata/archive HTTP receipts and exact lock version/origin/SRI checks; selected installed exports match the tarballs. This is native installed reception under a controlled registry premise, without a claim about public-registry archive provenance for external repacks. + +The original exported-inference witness remains RED in both formats. Exporting the inferred live-query factory produces TS2742 twice (private `query/compiler/types` and `query/live/internal`) and TS4058 (`LIVE_QUERY_INTERNAL`); exporting its inferred Collection value produces TS2742 twice and TS4023. A strict installed same-path baseline from exact fetched main `95460bc875a2259b34eea534e5592a9d0394e73f` reproduces the same diagnostics. This broader unchanged-main declaration-portability defect is preserved for followup, with no production repair or full declaration-compatibility claim. An agent-added narrow `Collection` annotation separately produces TS2322 on both cuts and is an invalid control, not the original product defect. + +A separately executed minimal public-view annotation emits without diagnostics in both formats on both cuts: + +```ts +type PublicRows = { + readonly toArray: ReadonlyArray<{ id: number; version: number; value: number }> +} +// receiver is private in this separate control only. +export function annotated(): PublicRows { return receiver() } +``` + +This preserves only the declared `toArray` view; it does not establish a full-Collection annotation workaround. The original factory remains exported and unannotated in its own preserved witness. The independently inferred Offline executor also emits in both candidate formats. + +One supplemental runtime assertion initially expected cleared health slots to be `undefined`; the public `HealthNode` contract requires `null`. That instrument error and original run remain preserved. The authority-corrected assertion received independent review and negative controls before all eight cells were rerun. Initial failed public/direct-file install attempts and a rejected npm update-notifier request in the first baseline lane also remain separate setup evidence, with no product-comparison credit. + +Artifacts: `/private/tmp/rc2171-oracle-challenge/o22-initial-frozen-070643/evidence/final-receiving-classification.json` (SHA256 `b8e2e501b3ca87b90ba76f61772d99b9e7272620efa886df8a82ec67bed4d13d`). Original factory source: `/private/tmp/rc2171-oracle-challenge/o22-initial-packed-support/exported-receiver-repro.mts` (SHA256 `c6c8c1da415901f4935f611387e7f6a3234b5f9753544256f0680038dcc1b50d`). Current/main final probe receipts have SHA256 `eb157fdc5f567fc04647d206762d794455a998620adc3d9383abf74e0bfc503c` / `2987feb2620ba1699869b12dbe51c811b6b0e6c389d06a176bf9a417d1827495`; each includes exact factory, const and control source paths. + +The tarballs derive from frozen input HEAD `5fa6eca05530bb9e1f4748b7c00ae60976da2808`. All 300 frozen production-source files matched committed `6e6034dcb14d35012e1bf41eb035d3c49a52845f` at the recorded continuity check. Later uncommitted LoadSubsetFn guidance comments and fixture-only edits were not rebuilt in this artifact cut. The scope is the initial candidate cut, not the full four-cut release matrix, hosted Actions, production authentication, browser/native hosts or live providers. Offline receiving seeds durable outbox data through the public OutboxManager, then starts a fresh executor; it does not exercise two actual executors creating durable state. + + +## Second CI schema-fixture correction + +At pushed head `180b920edd6c88a7024ed407cd04934678c7d5bf`, the framework +job still rejects the two actual React schema-hook calls with TS2769. The +previous context annotation did not close that gate. Independent source +checking now reproduces both diagnostics after observing the context and hook +return types before requesting diagnostics. The identical cold program passes. +This is a TypeScript comparison-order distinction, not evidence of different +runtime dependencies. The wider public mutation-input/full-handler problem +also reproduces on exact main; its separate follow-up remains open in the +[scope record](2026-10-29-pr-2171-deferred-oracles.md). + +The recorder now declares only the transaction mutation fields it consumes: +key, original, modified and changes. It still records every ordinary payload +field and compares against the unchanged independent model. Original and +corrected runtime JavaScript are byte-identical, SHA256 +`3a560ea22d9bbb4f683b695a245869c8b1e71996e97ca35fac4374b5ae549091`. +Actual corrected React and Preact programs pass cold and after type observation. +Injected source-local TS2322 controls still fail in each checked mode. These +controls establish compiler admission, not a new product-law mutant kill. + +Native React runs pass 237 checks in three files, including 21 typed cases; +Preact passes 80 in three files, including eight typed cases. React live, +ready-source Suspense and Preact each reach all 864 declared cells exactly once. +Both full raw package typechecks and focused lint pass. No model, grammar, +comparison, cleanup, reach requirement, runtime hook or production behavior +changes. Two independent reviewers accept the bounded correction. Actual +final-head CI remains required; these local results do not close public +mutation typing, typed-schema inference, initial suspension or concurrent/SSR +receiving paths. + +Report fingerprints, under the original campaign evidence directory: + +- `schema-180-structural-fixture-final-evidence.md`: + `a3a7710c5420f970b0b7105b780e50e24bebe354b07fd6301d4c4ee180ba3ed2`. +- `schema-structural-driver-independent-review.md`: + `00456d80b6e356a77c93cc2df67f57961d7dbee075413684b9e4c039c9aa2540`. +- `schema-structural-handler-second-review.md`: + `3c8a12ceada729cee932ff7090f486932c65175239fff76a371904cc800d2fd8`. + + +## SQLite generated reach correction + +At `180b920ed`, all 40 random histories complete their public comparisons, +but the campaign fails because none reaches an aborted-only demand. The +post-campaign guard did not preserve its seed. Unchanged native seed 18 +reproduces that reach failure after 40 comparisons; this is an honest grammar +gap, not a production mismatch. + +The original unrestricted history grammar and 40-run budget remain unchanged. +Each normal campaign adds two generated packs of nine legal histories. Every +pack independently requires all 15 existing reach states through the unchanged +production driver, independent model and comparison. The required packs have +their own registered seed/path replay coordinate. Seed-only full campaigns +still enforce reach; direct path replay runs only its selected property. The +native failure formatter now preserves the actual campaign seed and cause. + +The complete native owner passes five tests. Fixed and fresh-random campaigns +each run 40 unrestricted plus 18 required histories and pass in 90–92 seconds. +The formerly failing seed 18 now passes. Direct original and required-property +replays pass separately; independent review observes the required replay's +single guarded witness and all 15 states. Removing abort from the required +pack fails its own reach requirement. Removing the production abort fence in +an isolated host fails the actual offline status comparison: expected loading, +observed ready with a row. Independent review repeats both controls. + +Only the two outer campaign timeouts increase from 120 to 180 seconds to allow +for the additional histories. Internal deadlines, observations, comparisons, +minimum reach and cleanup remain unchanged. Scoped source types, full ESLint, +oracle admission and the run-budget audit pass. No executable production or +model changes enter this correction. Controlled Node SQLite observations do +not establish arbitrary-history, browser or live-provider behavior. + +The author report `demand-reach-final-evidence.md` has SHA256 +`cacc43eac3d276157606aefa09136a479e41e8be7db2b79dd805e60bee48ef68`. +The final source freeze is +`f2d80fbb4d1f2c2b82cbb0451b3d352b5f4cf1240017e63e45c9f6a0a96887ab`. +Both are retained under the original campaign evidence directory. Actual +final-head CI remains required. + +The independent verdict `demand-reach-independent-review.md` has SHA256 +`45c2e7d843a6ebd4c3898957c1b4d698450341beda8af8382b13e72959060e43`. +It binds the copied source hashes, direct replay, both intended control failures +and the two outer timeout literals. + + +## Named replay registration receiving correction + +CI on `d9911f17b` passes 40 of 41 replay controls but rejects the new +`persisted-demand-coverage.required` coordinate as `no-named-owner`. Earlier +direct Vitest replay proved the property and its guard could run but bypassed +the repository's manifest-to-command lookup. The existing independent manifest +check correctly catches this integration omission; its expectations stay intact. + +Adding `required` beside `history` in the existing owner entry repairs that +lookup. No production, model, grammar, guard, comparison or budget changes. +The author reproduces the manifest RED, then passes all 41 existing replay +controls. Author and reviewer each execute the actual repository named replay +CLI through the default SQLite package config at required/seed18/path0. Both +runs emit exactly one successful matching guarded witness with numRuns1. +The reviewer also independently reproduces pre-edit CLI rejection and passes +all six manifest controls. A custom runner alone no longer supplies the claimed +named-command receiving evidence. Hosted final-head CI remains required. + +Author evidence `demand-registration-author-evidence.md` has SHA256 +`3cd8bb335a916fc073a7ae2139aa104e9351ad4f75d22ee99a2c44b8a2c218d9`. +Independent verdict `demand-registration-independent-review.md` has SHA256 +`b22e5109bd08f0436d9fcc62d78de5d26da686584b9a2882e626621f66177385`. +The manifest source hash is +`3f1c724c48171b94413c787021d9d948212bdd28edcd84e783996b86722ac9eb`. +Evidence remains in the original campaign directory. + + +## Formatting continuity and later CI discoveries + +The formatting bot's `d9911f17b` changes only the schema recorder's callback +parameter layout and optional trailing comma. Its TypeScript output differs +only by that comma; minified runtime output is byte-identical. Strict raw-output +hash checks initially reject the formatting difference, and the retained audit +records both hashes and the exact normalized difference. Audit +`release-autofix-d9911f17b-audit.json` has SHA256 `16c36655accb99744919b7e4d97a9e035f6a85b85f13ac2ccf900d4ea9b87f6b`. +No product code changes in that bot commit. + +On this cut, framework and SQLite core CI pass. The replay manifest omission +above and source-health's missing random reach still fail separate gates. +DB core also finds a retained-publication mismatch that independently +reproduces on exact main. Its active oracle remains unchanged, and the +[scope record](2026-10-29-pr-2171-deferred-oracles.md#shared-source-replay-with-late-include-demand) +preserves that open baseline defect. A later passing random run cannot close +its recorded counterexample. Final-head CI and release readiness are separate +judgments from this deferred class. + + +## Source-health structural reach correction + +CI seed -2135407433 passes all 60 original source-health history comparisons +but never selects the opt-in snapshot prefix. The unchanged campaign reproduces +its afterAll RED: snapshot outside reload, markReady after fail and blind insert +of a kept key during reload each have zero reach. The established snapshot, +reload and source-health laws remain unchanged; this is missing sampling reach. + +The additive `source-health.required-history` lanes execute every one of the +nine existing structural prefixes, followed by 0–12 generated existing steps. +Each fixed/random lane runs six cases, independently enforces all 26 declared +states and reaches every state at least six times. Required cases use join or +nested contexts with direct subscribers. The unchanged original 60 random +histories retain all six shapes and both subscriber choices. Models, driver, +public comparisons, named histories, original grammar and budgets stay intact. +Both replay registries and selectors include the new coordinate, preserving +the separately accepted SQLite required mapping. + +Author and independent reviewer each pass the complete 506-test owner and +scoped TypeScript checks. Actual default-config named CLI replay selects the +new coordinate with one matching successful receipt at seed -2135407433/path0; +all four existing coordinates also execute in author checks. Global manifest +controls pass six tests. Prettier, scoped lint, changed-oracle lint and the +441-name mangle check pass. All 154 DB/IVM production source files match d991. +No executable production repair enters this change. + +Omitting a required prefix rejects its lane's missing reach. The fixed-only +ablation fails even when the other lane covers the aggregate. A scratch-only +snapshot mutant preserves begin but merges commit; the guarded CLI rejects +public Collection rows at step3 commit, actual [[1,1]] versus expected []. +Restoring production passes the captured seed/path0:0. The restored formerly +shrunk path reports one property receipt and ten fast-check runs, which are +preserved rather than reported as one history. These are assertion failures, +not setup failures. Independent review repeats both sensitivity checks. + +This closes the bounded sampling gap for the owner's declared premises, not +all source-health, provider, framework or concurrency behavior. Author evidence +`source-health-reach-repair-evidence.md` has SHA256 +`0a711cfeeb1f3e3ef5d6bd4270f2f74a3b3532edb08c4c9790df537cf9a2f62e`. +Independent verdict `source-health-independent-review.md` has SHA256 +`51693ba468e03c347988877d865faf0328bc8a637f2364eafa3a963dd6e80023`. +Both remain under the original campaign evidence directory. Actual final-head +CI remains required, and the independently confirmed baseline replay defect +above stays open under the documented release scope. diff --git a/docs/contributing/oracle-reviews/pr-2171-explicit-query-refresh.md b/docs/contributing/oracle-reviews/pr-2171-explicit-query-refresh.md new file mode 100644 index 0000000000..1dfb68d4b8 --- /dev/null +++ b/docs/contributing/oracle-reviews/pr-2171-explicit-query-refresh.md @@ -0,0 +1,65 @@ +# Query Collection explicit mutation refresh + +This record applies to the change that contains it, after merge commit +`621833269`. The first-RC API decision removes automatic refetch after mutation +handlers. Handlers return `Promise` and synchronize explicitly. + +## Distinguishing observations + +The ownership oracle adds a bounded table over insert, update, and delete, +with no confirmation, an explicit refetch, or a direct write. The backend starts +empty for insert and with one row for update and delete. A gate keeps the +optimistic transaction persisting until its optimistic public rows are checked. +The backend then accepts the edit. At transaction settlement: + +| Handler confirmation | Query calls, including initial load | Public rows | +| --- | --- | --- | +| None | 1 | Initial synced rows | +| Explicit refetch | 2 | Confirmed server rows | +| Direct write | 1 | Confirmed server rows | + +The old automatic-refetch implementation fails all nine cases at the exact +fetch-count comparison. In particular, an explicit refetch produces three calls. +After removing it, all nine cases pass. Expectations come from the initial and +confirmed row arrays and the declared confirmation action, without production +ownership or fetch classifiers. + +Existing ownership and ordered-refetch assertions remain intact. Their handlers +now refetch explicitly where the old wrapper supplied the request. Obsolete +opt-outs disappear from handlers that already refetch or write confirmed data. + +## Evidence and rubric scope + +- ORC-001, ORC-002, ORC-003: the migration decision supplies authority. The + table's prose explains its independent reference, finite histories, real + Collection and QueryClient driver, and public observations. +- ORC-004, ORC-007: no generated grammar or campaign changes. The new table + exhausts its nine declared histories. Existing ordered-prefix fixed and + random campaigns run in the package suite. +- ORC-005, ORC-006: the real transaction reaches both the persisting and + settled checkpoints. The old implementation fails all nine cases by + assertion, rather than timeout or setup failure. +- ORC-008, ORC-009: no stateful model or vocabulary changes. Initial and + confirmed rows denote public data, not production bookkeeping. +- ORC-010: cleanup releases the gate and disposes the Collection and QueryClient. + The table has no shrinker or failure capture that can replace its assertion. +- ORC-011: no shared-classifier fault hypothesis requires a second model. +- ORC-012: this record bounds the added evidence. It does not claim universal + closure of ownership or scheduling defects. +- ORC-013: no confirmation and explicit confirmation distinguish the boundary + at settlement for each mutation. The explicit-refetch case also rejects + duplicate fetching. Claims cover these finite cases only. +- ORC-014: a controlled backend supplies complete arrays. The test proves the + Collection and QueryClient contract, not HTTP transport or backend ordering. + +Verification: Query Collection's complete suite passes with no type errors +(42 files, 1466 assertions/tests reported by Vitest). The first-RC release +fixtures cover a stable minor bump and reject a stable major bump. The merged +LocalStorage value-fidelity oracle passes 234 cases after migrating its wait to +`when('settled')`. The Node Browser reload driver passes all 29 fixed histories +after the same API migration; native Chromium and Electron reload drivers were +not rerun for this mechanical change. + +Excluded: independent Query polling, focus, reconnect, and external invalidation +triggers. The coverage map names the existing ownership and ordered-refetch +owners for their broader lifecycle laws. diff --git a/docs/framework/react/overview.md b/docs/framework/react/overview.md index 9bfd0dbc0a..3ef7282608 100644 --- a/docs/framework/react/overview.md +++ b/docs/framework/react/overview.md @@ -161,8 +161,6 @@ const { data } = useLiveQuery({ }) ``` -Dependency arrays are still accepted for backwards compatibility, but they warn in development and will be removed in 1.0. - For SSR setup, collection hydration, and migration details, see the [SSR and Hydration guide](../../guides/ssr.md). ### useLiveInfiniteQuery @@ -190,7 +188,7 @@ or skip remote rows. For server pagination, use an on-demand Query Collection whose `queryFn` fulfills `meta.loadSubsetOptions`. See the [server pagination guide](../../collections/query-collection.md#server-pagination-with-live-queries). -The deprecated dependency array is only available when using the query function variant, not when passing a pre-created collection. +React hook dependency arrays were removed in the first 1.0 RC. Use derived query identity or an explicit `queryKey`. ### useLiveSuspenseQuery diff --git a/docs/framework/solid/overview.md b/docs/framework/solid/overview.md index d69a75b750..48bb01a360 100644 --- a/docs/framework/solid/overview.md +++ b/docs/framework/solid/overview.md @@ -19,7 +19,7 @@ For comprehensive documentation on writing queries (filtering, joins, aggregatio ### useLiveQuery -The `useLiveQuery` primitive creates a live query that automatically updates your component when data changes. It returns an object where `data` is a plain array and status fields (e.g. `isLoading()`, `status()`) are accessors: +The `useLiveQuery` primitive creates a live query that automatically updates your component when data changes. Call the returned accessor to read its data; status fields such as `isLoading` and `status` are reactive properties: ```tsx import { useLiveQuery } from '@tanstack/solid-db' @@ -34,9 +34,9 @@ function TodoList() { ) return ( - Loading...}> + Loading...}>
    - + {(todo) =>
  • {todo.text}
  • }
@@ -45,7 +45,7 @@ function TodoList() { } ``` -**Note:** `query.data` returns an array directly (not a function), but status fields like `isLoading()`, `status()`, etc. are accessor functions. +**Note:** `query()` reads the array; `query.isLoading` and `query.status` read reactive status properties. ### Reactive Queries with Signals @@ -62,7 +62,7 @@ function FilteredTodos(props: { minPriority: number }) { .where(({ todos }) => gt(todos.priority, props.minPriority)) ) - return
{query.data.length} high-priority todos
+ return
{query().length} high-priority todos
} ``` @@ -98,7 +98,7 @@ function TodoList() { -
{query.data.length} todos
+
{query().length} todos
) } @@ -125,7 +125,7 @@ function TodoList() { ) // Solid automatically tracks minPriority() and recomputes when it changes - return
{query.data.length} todos
+ return
{query().length} todos
} ``` @@ -147,7 +147,7 @@ function TodoList() { ) // Won't update when minPriority changes! - return
{query.data.length} todos
+ return
{query().length} todos
} ``` @@ -162,7 +162,7 @@ function AllTodos() { q.from({ todos: todosCollection }) ) - return
{query.data.length} todos
+ return
{query().length} todos
} ``` @@ -171,7 +171,7 @@ function AllTodos() { You can also pass an existing collection to `useLiveQuery`. This is useful for sharing queries across components: ```tsx -import { createLiveQueryCollection } from '@tanstack/db' +import { createLiveQueryCollection, eq } from '@tanstack/db' import { useLiveQuery } from '@tanstack/solid-db' // Create collection outside component @@ -184,6 +184,6 @@ function TodoList() { // Pass existing collection const query = useLiveQuery(() => todosQuery) - return
{query.data.length} todos
+ return
{query().length} todos
} ``` diff --git a/docs/guides/collection-options-creator.md b/docs/guides/collection-options-creator.md index e26cee1ee9..f362cea00c 100644 --- a/docs/guides/collection-options-creator.md +++ b/docs/guides/collection-options-creator.md @@ -387,8 +387,11 @@ For simple APIs or when users want full control, they handle parsing/serializati ```typescript // Query Collection: User handles all transformations const collection = createCollection( - queryCollectionOptions({ + queryCollectionOptions<{ id: string; created_at: Date }>({ schema: todoSchema, + queryKey: ['todos'], + queryClient, + getKey: (todo) => todo.id, queryFn: async () => { const response = await fetch('/api/todos') const todos = await response.json() @@ -398,7 +401,7 @@ const collection = createCollection( created_at: new Date(todo.created_at) // ISO string → Date })) }, - onInsert: async ({ transaction }) => { + onInsert: async ({ transaction, collection }) => { // User manually serializes for their backend await fetch('/api/todos', { method: 'POST', @@ -407,6 +410,7 @@ const collection = createCollection( created_at: transaction.mutations[0].modified.created_at.toISOString() // Date → ISO string }) }) + await collection.utils.refetch() } }) ) @@ -455,7 +459,7 @@ There are two distinct patterns for handling mutations in collection options cre The user provides mutation handlers in the config. Your collection creator passes them through. -**Note:** Handler return values are deprecated. Users should trigger refetch/sync within their handlers. +Mutation handlers return `Promise`. Trigger refetch or sync within the handler and await it before the handler completes. ```typescript interface MyCollectionConfig { @@ -990,7 +994,7 @@ const waitForSync = (afterTime: number): Promise => { The query collection pattern has users refetch after mutations: -```typescript +```typescript nocheck // Pattern A: User provides handlers and manages refetch export function queryCollectionOptions(config) { return { @@ -1017,8 +1021,6 @@ const collection = createCollection( await api.createTodos(transaction.mutations.map(m => m.modified)) // User explicitly triggers refetch await collection.utils.refetch() - // Prevent the pre-1.0 compatibility wrapper from refetching again. - return { refetch: false } } }) ) @@ -1048,7 +1050,7 @@ const collection = createCollection( 5. **Race Conditions** - Start listeners before initial fetch and buffer events 6. **Type safety** - Use TypeScript generics to maintain type safety throughout 7. **Provide utilities** - Export sync-engine-specific utilities for advanced use cases -8. **Handler return values are deprecated** - Mutation handlers should coordinate sync internally (via `await`) rather than returning values. For Electric collections, use `await collection.utils.awaitTxId(txid)` instead of returning the txid +8. **Coordinate sync in handlers** - Mutation handlers should await sync internally. For Electric collections, use `await collection.utils.awaitTxId(txid)`. ## Testing Your Collection diff --git a/docs/guides/error-handling.md b/docs/guides/error-handling.md index 79b2473235..d0a2b91880 100644 --- a/docs/guides/error-handling.md +++ b/docs/guides/error-handling.md @@ -683,9 +683,14 @@ sync: ({ begin, write, commit, markReady, reportError, fail, reportHealthy }) => When mutations fail, TanStack DB automatically rolls back optimistic updates: ```ts -const todoCollection = createCollection({ +const todoCollection = createCollection( + queryCollectionOptions<{ id: string; text: string; completed: boolean }>({ + queryKey: ["todos"], + queryClient, + queryFn: fetchTodos, + getKey: (todo) => todo.id, id: "todos", - onInsert: async ({ transaction }) => { + onInsert: async ({ transaction, collection }) => { const response = await fetch("/api/todos", { method: "POST", body: JSON.stringify(transaction.mutations[0].modified), @@ -695,10 +700,10 @@ const todoCollection = createCollection({ // Throwing an error will rollback the optimistic state throw new Error(`HTTP Error: ${response.status}`) } - - return response.json() + + await collection.utils.refetch() }, -}) +})) // Usage - optimistic update will be rolled back if the mutation fails try { @@ -947,10 +952,11 @@ try { Query collections distinguish an initial load failure from a later refetch failure: ```ts +import { createCollection } from "@tanstack/db" import { queryCollectionOptions } from "@tanstack/query-db-collection" const todoCollection = createCollection( - queryCollectionOptions({ + queryCollectionOptions<{ id: string; text: string; completed: boolean }>({ queryKey: ["todos"], queryFn: async () => { const response = await fetch("/api/todos") @@ -1264,7 +1270,6 @@ const todoCollection = createCollection({ throw new Error(`HTTP ${response.status}: ${response.statusText}`) } - return response.json() }, sync: { sync: ({ begin, write, commit, markReady }) => { diff --git a/docs/guides/live-queries.md b/docs/guides/live-queries.md index a63b20c40f..dc79743bb3 100644 --- a/docs/guides/live-queries.md +++ b/docs/guides/live-queries.md @@ -32,10 +32,9 @@ The result types are automatically inferred from your query structure, providing Live query results include computed, read-only virtual properties on every row: - `$hasPendingWrites`: `true` while a pending local optimistic mutation affects the row; otherwise `false`. It is always `false` for local-only collections. It does not indicate server acknowledgement. -- `$synced` (deprecated): the inverse of `$hasPendingWrites`. It will be removed in the 1.0 RC. Replace `row.$synced` with `!row.$hasPendingWrites`, and `eq(row.$synced, true)` with `eq(row.$hasPendingWrites, false)`. - `$origin`: Collection attribution for the current row. Optimistic rows are `"local"`; source rows use key and timing, not a source client ID. A peer write can be `"local"`. A truncate does not consume an active mutation's attribution for an untouched key. See [VirtualOrigin](../reference/type-aliases/VirtualOrigin.md) for settlement, truncate, and overlap rules. Local-only Collections always use `"local"`. - `$key`: the row key for the result. -- `$collectionId`: the source collection ID. +- `$collectionId`: the selected source Collection's ID for a whole-row `.select` or a `.select` projection that spreads a source row, including a joined row. With multiple source spreads, the last spread supplies the metadata. Other projections receive the live-query Collection's ID, regardless of selected virtual field names. A functional `.fn.select()` retains source metadata only when it returns a source row directly. A new object, including a spread, receives the live-query Collection's metadata. These props can be used in `where`, `select`, and `orderBy` clauses. They are added to query outputs automatically and should not be persisted back to storage. @@ -207,9 +206,10 @@ export class UserListComponent { ``` > **Note:** React hooks derive query identity from structured query IR by -> default. Dependency arrays are still accepted for backwards compatibility, -> but warn in development and will be removed in 1.0. In React and Preact, an -> unhashable query without `queryKey` throws `UnhashableQueryIRError`. See the +> default. Dependency-array hook forms were removed in the first 1.0 RC. Use +> derived query identity or an explicit, nonempty `queryKey`. In React and +> Preact, an unhashable query without a nonempty `queryKey` throws +> `UnhashableQueryIRError`. See the > [React Adapter > documentation](../framework/react/overview#query-identity) for details. diff --git a/docs/guides/migrate-to-1-0-rc.md b/docs/guides/migrate-to-1-0-rc.md new file mode 100644 index 0000000000..87a56c71f6 --- /dev/null +++ b/docs/guides/migrate-to-1-0-rc.md @@ -0,0 +1,763 @@ +--- +title: Migrating to the 1.0 RC +id: migrate-to-1-0-rc +--- + +This guide covers the API changes in `1.0.0-rc.1` and the changes shipped since +`@tanstack/db@0.9.2` in September 2026 that may require changes to an app. It +includes changes merged through October 28, 2026. For the earlier `DbClient` +and SSR migration, see the +[SSR and Hydration guide](./ssr.md#migration-guide). + +## Update your packages + +Update the pre-1.0 TanStack DB packages your app uses together. Once the RC is +published, install them from npm's `rc` tag. For example: + +```sh +npm install @tanstack/react-db@rc @tanstack/electric-db-collection@rc +``` + +The first candidate is `1.0.0-rc.1`. `@tanstack/query-db-collection` and +`@tanstack/offline-transactions` were both accidentally released as 1.0 before +the RC. Keep them on 1.x and update to their latest 1.x releases. These +releases use the app's `@tanstack/db` as a peer dependency, so the RC and stable +packages share one DB instance. Both accompanying minor releases require +`@tanstack/db@^1.0.0-rc.0`; upgrade DB at the same time. Query Collection uses +the RC's status and health API, and Offline Transactions uses the RC's +restoration settlement API. DB 0.15 is no longer a supported peer for either +new minor. Apps staying on DB 0.15 must keep their earlier adapter versions. + +## Replace `$synced` with `$hasPendingWrites` + +The deprecated `$synced` virtual row property has been removed. Its replacement +has the opposite boolean meaning: + +| Before | After | +| --- | --- | +| `row.$synced` | `!row.$hasPendingWrites` | +| `!row.$synced` | `row.$hasPendingWrites` | +| `eq(row.$synced, true)` | `eq(row.$hasPendingWrites, false)` | +| `eq(row.$synced, false)` | `eq(row.$hasPendingWrites, true)` | + +`$hasPendingWrites` reports whether a pending local optimistic mutation affects +the row. A `false` value does not, by itself, prove that the server confirmed +the write. See [Virtual properties](./live-queries.md#virtual-properties) for +the other row metadata fields. + +If you create virtual rows yourself, include `$hasPendingWrites`: +`hasVirtualProps(row)` now requires it. + +### Keep virtual field names for row metadata + +The fields `$hasPendingWrites`, `$origin`, `$key`, and `$collectionId` are +reserved on published Collection rows. A selected field with one of those names +cannot author conflicting metadata at a live-query row root. Structured +`.select()` calls that return or spread a source row retain its metadata, +including a joined source. With several source spreads, the last supplies the +metadata. Other projections receive the live-query Collection's metadata, +regardless of selected virtual field names. + +A functional `.fn.select()` preserves source metadata only when it returns a +source row directly, such as `({ user }) => user`. A new object, including +`({ user }) => ({ ...user })`, receives the live-query Collection's metadata. +Identical field values do not establish which source authored that object. +These rules also apply to Collection-valued include rows. Inline `toArray()` +and `materialize()` results keep their selected shape. Rename application data fields that collide with these names before +selecting them as row roots. + +## Wait for transaction settlement + +Replace `transaction.isPersisted.promise` with `transaction.when('settled')`: + +```ts +const todoId = "todo-1" +const transaction = todoCollection.update(todoId, (draft) => { + draft.completed = true +}) + +// Before: await transaction.isPersisted.promise +await transaction.when('settled') +``` + +`when('settled')` resolves with the transaction after its mutation handler +completes successfully and rejects when the transaction fails or rolls back. +Transaction settlement confirms a backend write only if the handler waits for +that confirmation before returning. See the [Mutation Lifecycle](./mutations.md#mutation-lifecycle) +for the full boundary. + +## Await synchronization inside mutation handlers + +The pre-1.0 Collection adapters no longer use values returned by +`onInsert`, `onUpdate`, or `onDelete` to coordinate synchronization. Their +handlers now return `Promise`. Query and Electric ignore legacy return +values at runtime. A discarded value does not turn a successful server write +into a failed transaction, and it does not coordinate synchronization. Await the work that must finish before +the transaction settles: + +```ts +onUpdate: async ({ transaction }) => { + await api.todos.update(transaction.mutations[0].changes) + // Await any required backend confirmation or read-back here. +} +``` + +For Electric Collection, replace a returned `{ txid }` matching strategy with +`collection.utils.awaitTxId(txid)` inside the handler: + +```ts +// Before +onUpdate: async ({ transaction }) => { + const { txid } = await api.todos.update(transaction.mutations[0].changes) + return { txid, timeout: 10_000 } +} + +// After +onUpdate: async ({ transaction, collection }) => { + const { txid } = await api.todos.update(transaction.mutations[0].changes) + await collection.utils.awaitTxId(txid, 10_000) +} +``` + +For several transaction IDs, await all of them with `Promise.all`. The +[Electric Collection guide](../collections/electric-collection.md#persistence-handlers--synchronization) +also covers custom matching when your API cannot return transaction IDs. + +### Query Collection handlers no longer refetch + +The accompanying Query Collection 1.x minor release removes automatic refetch +from `onInsert`, `onUpdate`, and `onDelete`. Refresh explicitly inside each +handler: + +```ts +// Before: returning triggered an automatic refetch. +onUpdate: async ({ transaction }) => { + await api.todos.update(transaction.mutations[0].changes) +} + +// After +onUpdate: async ({ transaction, collection }) => { + await api.todos.update(transaction.mutations[0].changes) + await collection.utils.refetch() +} +``` + +Alternatively, await a `writeInsert`, `writeUpdate`, `writeUpsert`, or +`writeDelete` call with the confirmed server result. These utilities do not +fetch. If the handler neither refreshes nor writes confirmed data, its +optimistic changes revert to the previous synced rows when the transaction +settles, even if the server accepted the mutation. + +Handlers now return `Promise`. Remove `{ refetch: false }`; the types +reject it, and runtime code ignores it. A handler that already explicitly refetches or writes its +server response needs only to remove that return value. See +[Controlling Refetch Behavior](../collections/query-collection.md#controlling-refetch-behavior). + +For TypeScript code that uses the handler type aliases directly, +`InsertMutationFn`, `UpdateMutationFn`, and `DeleteMutationFn` no longer take a +return-value type parameter. `BaseCollectionConfig` no longer takes its old +`TReturn` type parameter. The Electric `MatchingStrategy` type was removed with +the returned matching strategy. + +## Opt in to includes, grouping, and pooling + +These operators now need an explicit import so apps bundle only the operators +they use. + +### Wrap nested selections in `include()` + +A child query returned directly from `.select()` now throws +`IncludeOperatorRequiredError`. Wrap it in `include()` to keep a live child +Collection: + +```ts +import { eq, include } from '@tanstack/db' + +// Before +q.from({ project: projectsCollection }).select(({ project }) => ({ + issues: q.from({ issue: issuesCollection }) + .where(({ issue }) => eq(issue.projectId, project.id)), +})) + +// After +q.from({ project: projectsCollection }).select(({ project }) => ({ + issues: include( + q.from({ issue: issuesCollection }) + .where(({ issue }) => eq(issue.projectId, project.id)), + ), +})) +``` + +Existing `toArray()` and `materialize()` wrappers already opt in; they need no +additional `include()`. Apply the wrapper at every nested selection. See +[Includes](./live-queries.md#includes). + +### Wrap grouping keys when there is no aggregate + +Aggregates such as `count()` already include the grouping compiler. A query +that groups without an aggregate must use `groupKeys()`, or it throws +`GroupingOperatorRequiredError`: + +```ts +import { groupKeys } from '@tanstack/db' + +// Before: .groupBy(({ todo }) => todo.status) +q.from({ todo: todoCollection }) + .groupBy(groupKeys(({ todo }) => todo.status)) + .select(({ todo }) => ({ status: todo.status })) +``` + +If you construct query IR directly, pass `groupingCompiler` as the third +argument to `IR.Aggregate`, and `includesRuntime` as the fifth argument to +`IR.IncludesSubquery`, before `parentFilters`. Both values are exported from +`@tanstack/db`. Queries built through the query builder use the operators above. + +### Enable pooling explicitly + +Framework hooks no longer pool queries by default. To retain shared work for +eligible equality-filtered queries, pass the exported `pooledLiveQueries` +value in the query config: + +```tsx +import { eq, pooledLiveQueries } from '@tanstack/db' +import { useLiveQuery } from '@tanstack/react-db' + +useLiveQuery({ + query: (q) => q.from({ todo: todoCollection }) + .where(({ todo }) => eq(todo.listId, listId)), + pool: pooledLiveQueries, +}) +``` + +Omit `pool` to use an independent query. Boolean `pool` values now throw +`InvalidPoolOptionError`, including on disabled queries; replace `true` with +`pooledLiveQueries` and remove `false`. Results do not change. Suspense queries +and queries backed by a `DbClient` do not pool. Angular's `{ params, query }` +form accepts the same `pool` value. See +[Pooled Live Queries](./live-queries.md#pooled-live-queries). + +Custom framework adapters that use the unstable `resolveLiveQueryValue` helper +must pass `pooledLiveQueries` or `false` as its `pool` option and validate +caller config with `configPool`. Window helpers such as +`resolveLiveQueryWindowInput` and `liveQueryWindowMatches` are no longer +exported; use `bindLiveQueryWindow` for the shared infinite-query binding. + +## Update React live-query hooks + +Dependency arrays were removed from `useLiveQuery`, `useLiveSuspenseQuery`, and +`useLiveInfiniteQuery`. For structured queries, omit the array: the hooks +derive identity from the query, including captured values used in structured +expressions. + +```tsx +// Before +useLiveQuery( + (q) => q.from({ todos: todoCollection }) + .where(({ todos }) => eq(todos.status, status)), + // @ts-expect-error Dependency arrays are removed in the RC. + [status], +) + +// After +useLiveQuery({ + query: (q) => q.from({ todos: todoCollection }) + .where(({ todos }) => eq(todos.status, status)), +}) +``` + +Opaque functional query clauses, such as `.fn.where`, need an explicit, +serializable `queryKey` that includes their captured values: + +```tsx +useLiveQuery({ + queryKey: [todoCollection.id, 'status', status], + query: (q) => q.from({ todos: todoCollection }) + .fn.where(({ todos }) => todos.status === status), +}) +``` + +For config-object queries, `getKey` and runtime `schema` options bind when the +query identity changes. Independent `DbClient` callers that reuse a cached query +identity must use the same `getKey` and `schema` references. Use distinct +`queryKey` values for different row behavior. Allocating an inline callback or schema object during a +render does not rebuild the Collection. Change the query or a nonempty +`queryKey` when you need to apply different options. + +An unhashable query without a nonempty `queryKey` now throws +`UnhashableQueryIRError`; `queryKey: []` counts as absent. Put the key in the +config object for `useLiveQuery` or `useLiveSuspenseQuery`; pass it in the second +argument's config for `useLiveInfiniteQuery(query, { pageSize, queryKey })`. +See [Query Identity](../framework/react/overview.md#query-identity) for details. + +### Check identity in other frameworks and keep sources stable + +The nonempty `queryKey` requirement also applies to Preact and to any hook +backed by a `DbClient`, including Svelte. It covers `.fn.where`, `.fn.select`, +and `.fn.having`. Without a `DbClient`, Vue, Solid, Angular, and Svelte can +recompile from reactive reads and do not require a key for opaque clauses. + +For a mounted hook that compares derived identity, a source Collection ID +must keep naming the same Collection object in that client scope. Replacing +the object while reusing its ID now throws `SourceCollectionIdReuseError`. +Keep source Collections stable across renders. To reuse an ID in that binding, +unmount it, clean up its source and client scope, then mount the replacement. +Separate bindings and independent client scopes can use the same ID. + +An explicit `queryKey` makes identity your responsibility: include a primitive +scope token in it and change the key when replacing the source. Do not put a +Collection object in the key. See +[Query Identity](./ssr.md#react-query-identity). + +## Read Solid query data through the accessor + +Solid `useLiveQuery` no longer exposes the deprecated `.data` alias. Call the +returned accessor wherever you read the result: + +```tsx +import { For } from "solid-js" +import { useLiveQuery } from "@tanstack/solid-db" + +const todosQuery = useLiveQuery((q) => q.from({ todos: todoCollection })) + +// Before: ... +const TodoList = () => ( + {(todo) =>
  • {todo.text}
  • }
    +) +``` + +Status fields remain reactive properties, such as `todosQuery.isLoading`. See the +[Solid adapter guide](../framework/solid/overview.md). + +## Remove the obsolete alias error import + +`DuplicateAliasInSubqueryError` has been removed. Nested queries may reuse an +ancestor's alias; aliases must still be unique within one lexical scope. +Remove imports and catches for this obsolete error. + +## Separate loading failures from sync problems + +Collection `status` now describes whether data can be shown. After readiness, +a network or backend failure keeps the rows and `status: 'ready'`; the problem +appears in `health`. Local storage failures, a throwing sync function, and +internal invariant failures use the new terminal `crashed` status. + +Live-query hooks no longer expose `isError`. Replace it with `isLoadingError` +for a failed first load, `isCrashed` for a crash, and `isSyncError` for sync +problems while ready rows remain visible. + +Update status switches, hook result destructuring, error screens, and retry +buttons: + +| Before | After | +| --- | --- | +| Hook `isError` | `isLoadingError` for a failed first load; `isCrashed` for a crash | +| `status === 'error'` for every failure | `error` only for a failed first load; `crashed` for local failure; `health` for sync problems after readiness | +| Hide rows on any sync error | Keep ready rows visible and use `isSyncError` for a stale-data banner | +| Query Collection `utils.lastError`, `utils.isError`, `utils.errorCount` | Read `collection.health` or the hook's `health` tree | +| Query Collection `utils.clearError()` | `collection.reload()` for a failed first load; `utils.refetch({ throwOnError: true })` to retry a fetch and observe rejection | +| Infinite hook `error` in React, Preact, Vue, or Svelte | Read the root node's `health.error` (through a ref in Vue) | + +`isSyncError` is true when the query is ready and any node in its health tree +has a problem. Each source alias has a node: `failureReason` means the adapter +is still trying, and `error` means it stopped. On the query's own node, `error` +can also mean a request for more data failed. `findProblems(health)` from +`@tanstack/db` finds the nodes with problems. A problem's `cause` preserves the +reported value; its `errors` array lists failures still open, oldest first. +Do not treat that array's length as a count of all failed fetches. + +`fetchNextPage()` still resolves when a page load fails. Inspect `health` +afterward; catching its promise does not detect a failed page load. +`useLiveSuspenseQuery` throws for a failed first load or a crash. Sync problems +after readiness stay in `health` and do not reach the error boundary. See +[Loading and Sync Errors](./error-handling.md#loading-and-sync-errors). + +Effects also keep running through sync problems after their initial load. +Move handling of those problems from `onSourceError` to `onHealthChange`. +`onSourceError` still ends the Effect on a failed first load, crash, or failed +load that the Effect needs. + +### Retry with `reload()` and update cleanup calls + +For a failed load or a stopped sync adapter, replace cleanup-and-preload retry +code with `reload()`: + +```ts +// Before +await todosQuery.cleanup() +await todoCollection.cleanup() +await todosQuery.preload() + +// After +await todosQuery.reload() +``` + +A live query reloads errored sources recursively, then retries its own error +when needed. Existing rows, status, and error stay visible while it reloads; +check `health.reloading` for progress. A failed reload rejects. A local-only +Collection's reload preserves its rows and does not reset them to `initialData`. + +`cleanup()` now rejects with `CollectionHasSubscribersError` if an active live +query or Effect reads the Collection. Unmount or dispose those readers first, +or use `await collection.cleanup({ dependents: true })` to end them. This ends +the dependents; mount or create them again after restarting. Inspect +`collection.subscribers` to find them. Raw `subscribeChanges()` subscriptions +do not block cleanup and are not ended by it. New subscriptions and deferred +acquisition resumption throw while cleanup is in progress; wait for cleanup +to settle before restarting. + +A crashed Collection cannot reload. Recover with +`await collection.cleanup({ dependents: true })`, then start it again, for +example with `await collection.preload()`, and recreate its readers. Cleanup +discards its rows. For a closed IndexedDB connection, create a new connection +and affected Collections. See +[When a Collection crashes](./error-handling.md#when-a-collection-crashes). + +### Use one readiness signal for persisted data + +Remove `persistedStatus`, `isPersistedReady`, `persistedError`, +`preloadForInitialRender()`, and `PERSISTED_READINESS`. Use normal `status`, +`preload()`, and `health` instead. + +With `initialRender: { strategy: 'network-first', networkTimeoutMs }`, an eager +persisted Collection becomes ready from restored rows when the deadline passes +or the remote first load fails. The restore must finish and contain at least +one row. An empty restore waits for the network and cannot rescue a failed +first load; a failed restore crashes the Collection. The default deadline is +three seconds, and it does not stop sync. +Without `initialRender`, restored rows do not make the Collection ready before +its sync adapter does. Suspense and ordinary hooks now follow the same status. +A storage reload triggered by another tab's commit keeps this tab's status: +it cannot mark a loading source ready or recover its failed first load. Use +`reload()` to retry that failure. +See [Render after SQLite restore](./sqlite-persistence.md#render-after-sqlite-restore). + +### Enable network-first for on-demand persisted queries + +`initialRender: { strategy: 'network-first', networkTimeoutMs }` now also works +with `syncMode: 'on-demand'` and Electric's `syncMode: 'progressive'`. These +configurations previously threw `InvalidPersistedCollectionConfigError`. + +On-demand fallback uses persisted demand coverage: an earlier online load of +the exact filter, order, and window must have settled and saved its rows in +SQLite. A later load of that demand can settle from SQLite after its deadline +or when its network load fails. Coverage can include a confirmed empty result. +This settles the query's load; it does not change the source Collection's own +status. Network loading continues, and later results update the visible rows. + +Check these limits when enabling the option: + +- Rows in SQLite alone do not establish coverage. An overlapping query or a + different order or window does not cover a demand that never loaded online. +- A source truncate, snapshot transaction, scoped recovery, or cache-generation + rotation clears coverage. Loads during the rest of a scoped-recovery sync + run cannot use or record it. Released or obsolete loads record none. +- Coverage requires at least the recorded row count to remain in SQLite. + Limited ordered windows require the same rows. Deletion or cache retention + can therefore require another online load. +- `refetch: true` loads always wait for the network. Only the default + single-process coordinator supports on-demand fallback; multi-tab + coordinators such as `BrowserCollectionCoordinator` still wait for the + network. + +A joined query waits for every eager source to become ready and every +on-demand load it needs to settle. Coverage proves earlier completeness, not +current server data. See +[On-demand Collections offline](./sqlite-persistence.md#on-demand-collections-offline). + +## Prepare SSR data explicitly + +An ordinary server hook can render empty/loading state without preloading. +Rendering rows or readiness now requires a matching `preloadLiveQuery()` or +explicit preparation of its source Collections. Another query incidentally +warming the same source is insufficient and now throws with preparation +guidance. Streaming Suspense prepares its own result. + +Share a config object between the loader and the hook: + +```tsx +import { eq, type InitialQueryBuilder } from '@tanstack/db' +import { useLiveQuery } from '@tanstack/react-db' + +const openTodos = { + query: (q: InitialQueryBuilder) => q.from({ todo: todoCollection }) + .where(({ todo }) => eq(todo.status, 'open')), +} + +// In the server loader: +await dbClient.preloadLiveQuery(openTodos) + +// In the component: +const { data } = useLiveQuery(openTodos) +``` + +Keep the input form consistent: `useLiveQuery(openTodos.query)` currently has a +different identity from `useLiveQuery(openTodos)`. If using an explicit +`queryKey`, use the same key and query configuration in the preload and hook. +For infinite queries, the identity also includes the initial limit and offset: +preload the first window with `limit(pageSize + 1).offset(0)` to include its +one-row peek. See [Prepare every server snapshot](./ssr.md#prepare-every-server-snapshot). + +Create manual indexes when initializing the request or browser client, before +preloading or rendering queries that depend on them. Installing an index inside +the sync callback can be too late for query compilation. See +[Indexes before query compilation](./ssr.md#indexes-before-query-compilation). + +Svelte roots initialized with `hydrate()` must mark their provider: + +```svelte + + + +``` + +Nested providers inherit `hydrating`, and each provider clears it after mount. +This also applies to hooks that receive `client` explicitly. Omit it for a root +created with `mount()`. It preserves each reader's server snapshot through +hydration, even when browser data has already changed. See +[Svelte SSR](./ssr.md#svelte). + +### Treat hydrated rows as provisional seeds + +`DbClient` hydration and `initialData` supply provisional rows. A source's +first insert for a seeded key replaces that seed, including its row metadata; +an insert without metadata clears the seed's metadata. An update merges over +the seed unless the adapter uses `rowUpdateMode: 'full'`. + +A later hydration seed cannot overwrite source-written row data or metadata, +or restore a key the source deleted or truncated. Row data and row metadata +are tracked separately, so a seed may still supply metadata when the source +wrote only the row data. For row data or metadata the source has not written, +the latest seed wins. Persisted cache rows also remain provisional to later +`DbClient` seeds, including after a storage reload from another tab. See +[Initial Data](./ssr.md#initial-data). + +## Changes since `@tanstack/db@0.9.2` + +The following changes shipped before the RC. Check the sections that match the +packages and APIs your app uses when upgrading directly from an older release. + +### Check transaction and outbox lifetimes + +A persisting transaction now retains every Collection it touches until its +handler settles; the `gcTime` delay starts again after the last persisting +transaction finishes. Unmounting a screen no longer permits garbage collection +to interrupt its persisting write. A pending manual transaction still does not +retain its Collections: keep a subscription while its draft rows are needed. +Explicit `cleanup()` still discards rows during a persisting transaction. See +[Manual Transactions](./mutations.md#manual-transactions). + +Offline Transactions keeps its default outbox format, so existing apps need +no data conversion. If adopting the optional `mutationCodec`, keep its codec ID +and decoder available across restarts: encoded records use wire version 4, +which older readers cannot replay. Default writers still use version 3. See +[Mutation value codecs](./offline-transactions.md#mutation-value-codecs). + +### Await Query Collection direct writes + +`writeInsert`, `writeUpdate`, `writeDelete`, `writeUpsert`, and `writeBatch` now +return promises whose validation errors reject. Catch errors from an awaited +write, including `DuplicateKeySyncError` when `writeInsert` uses an existing key +in a persisted Collection: + +```ts +try { + await todosCollection.utils.writeInsert(todo) +} catch (error) { + // Handle the rejected write. +} +``` + +Direct writes no longer automatically refetch active on-demand Query subsets. +They update Collection and Query cache rows where the write can be represented. +Call `await todosCollection.utils.refetch()` when you need current server rows. +Mutation handlers also require explicit synchronization as described above. +Use TanStack Query Core +5.90.15 or later for post-write refetch replacement and error recovery. See +[Direct Writes](../collections/query-collection.md#direct-writes). + +### Import IndexedDB APIs from core + +The standalone `@tanstack/indexeddb-db-collection` package was removed. Import +`createIndexedDB` and `indexedDBCollectionOptions` from `@tanstack/db` and +remove the old package dependency. See the +[IndexedDB Collection guide](../collections/indexed-db-collection.md). + +### Update custom sync adapters + +- Remove the `immediate` argument from `begin({ immediate: true })` in sync + callbacks. Call `begin()` instead. Sync writes made while a mutation persists + now queue behind it; code must not rely on them appearing immediately. +- Replace `markError(error)` with `fail(error)` when the adapter stops trying. + Before the run's first `markReady()`, this fails loading; afterward it keeps + ready rows and records `health.error`. Retry a failed run with `reload()`. +- While retrying the first load, call neither `fail` nor `reportError`. After + the run's first `markReady()`, use `reportError(error)` while retrying, and + `reportHealthy()` on recovery. Both throw before the run is ready or after + `fail()`. Use `reportHealthy(error)` to clear just one reported failure if + other independent failures remain. Stop the run's work after `fail()`; later + writes and health reports are invalid. Repeated `markReady()` and `fail()` + calls in the same run do nothing. +- Track readiness inside each `sync` invocation. During a reload, + `collection.status` still describes the previous run and cannot tell you + whether the new run has reached its first ready. +- Remove `begin({ direct: true })`. Ordinary `begin()` writes against the rows + the Collection holds, including during reload. To replace all synced rows + with a complete authoritative snapshot, use `begin({ snapshot: true })` or + `truncate()` in the new run. An ordinary first commit no longer implies + replacement. Core does not delete rows omitted by ordinary writes. +- When keeping rows during reload, update existing keys and explicitly delete + removed rows. Inserting an existing key with a changed value throws + `DuplicateKeySyncError`. A partial on-demand `loadSubset` result must never + claim to be a full snapshot. Honor its `signal`: aborted loads no longer hold + readiness, and core ignores their later promise results. +- Treat a synchronous `loadSubset` throw like a rejected load. Core keeps its + logical demand and calls `unloadSubset` when that demand is released. Ensure + `unloadSubset` can clean up partially initialized or failed loads. +- Sync metadata reads now include the source's own committed writes that an + optimistic transaction still holds, followed by its open transactions. + This applies to `metadata.row.get`, `metadata.collection.get`, and + `metadata.collection.list`. Do not treat these reads as proof that the + writes are publicly visible; commit receipts still establish application. +- With `rowUpdateMode: 'full'`, supply a complete replacement row. Persistence + now replaces the stored row too, so fields omitted from an update no longer + return after a restart. Use `partial` for updates that should merge fields. + +See [For sync adapter authors](./error-handling.md#for-sync-adapter-authors) +for an example and the full reload contract. + +### Update persistence integrations and browser assets + +- Custom SQLite `PersistenceAdapter` implementations must implement + `loadResumeSnapshot`. A custom sync wrapper must forward + `metadata.persistence` unchanged. When metadata is present, its `persistence` + field is required and is `null` when there is no persistence capability. +- Lower-level SQLite adapter data operations now take an explicit + `PersistedStorageTarget`, such as `eagerStorageTarget(collectionId)` or a + claim-bound `managedStorageTarget(storageCollectionId, claimId)`, instead of a + bare Collection ID. Ordinary Collection configuration does not need a target. +- Keep Electron persistence code in the main and renderer processes on matching + package versions; mixed IPC protocol versions fail closed. +- `openBrowserWASQLiteOPFSDatabase` now rejects if opening takes more than 30 + seconds. Set `timeoutMs: 0` only if the previous unlimited wait is required. +- If a custom React Native op-sqlite wrapper returns ambiguous bare arrays, set + `arrayResultMode` to `rows` or `statement-results`. These arrays now reject + without an explicit mode. +- Local persisted Collections without a `sync` option reserve the utility names + `acceptMutations`, `getLeadershipState`, and `forceReloadSubset`. Rename custom + utilities with those names. `persistedCollectionOptions` now throws + `InvalidPersistedCollectionConfigError` for them, even when inherited or set + to `undefined`. Collections with a `sync` option keep their adapter utilities. + +Custom sync adapters using `metadata.persistence.reserveCommitTurn()` must +finish every reserved sync transaction. Every persisted hydration now waits +for that transaction, including reloads from peer commit notices and recovery +after a gap in notification sequence numbers. Commit it, or abandon it by +calling `commit()` with an already-aborted signal when the source stops for +good. Abandonment releases waiting hydrations too. A peer reset can instead +abandon an older reservation; its commit rejects with +`SyncTransactionAbortedError`. + +Finish any needed `hydrateBaseline()` before reserving the commit turn. Calling +it with a reservation open now rejects, and awaiting an earlier baseline while +holding a reservation can deadlock. Built-in Electric handling already ends +its open transaction when its stream stops for good. + +Browser SQLite now ships `wa-sqlite.wasm` separately from its OPFS worker. +Deploy both emitted assets. Vite, webpack 5, and Next.js include them +automatically; ordinary app setup needs no asset copying or WASM URL option. + +In Vite development, pre-bundled installs reached only through another library +or an npm alias may fail to resolve the asset package. Multiple installed +versions can select a mismatched worker. If opening rejects with +`PersistenceUnavailableError` in these cases, exclude the package from +pre-bundling: + +```ts +// vite.config.ts +export default defineConfig({ + optimizeDeps: { + exclude: ['@tanstack/browser-db-sqlite-persistence'], + }, +}) +``` + +For an npm alias, list the alias used by the importing code instead. The same +exclusion is needed if the database opens inside a worker or the page's Vite +client has no script `src`. See the +[Browser SQLite runtime notes](https://github.com/TanStack/db/tree/main/packages/browser-db-sqlite-persistence#notes). + +### Review query windows, joins, and aggregates + +- An ordered `findOne` keeps its compiled limit of one, or zero after + `.limit(0).findOne()`. `setWindow` may move its offset but cannot change its + limit. An unordered `findOne` stays at offset zero. Move to an ordered, + limited multi-row query if the result needs a larger or movable window. + `setWindow` controls only the outer query's own window, not a window inside a + subquery or include. +- Joined row keys now use JSON arrays of source keys, with `null` for a missing + side. Code that constructs a key to call `collection.get()` must use the new + format. Reading the published row's `$key` avoids reconstructing it, + especially for non-finite numeric keys. +- A source field selected outside an aggregate or `groupBy` key now throws + `NonAggregateExpressionNotInGroupByError`; the same rule applies in `having`. + Aggregate the field or include it in `groupBy`. Nested includes inside a + grouped or aggregate select now throw as well. +- An aggregate used in `orderBy` or `having` must also appear in `select()`. + Otherwise the query throws `AggregateFunctionNotInSelectError`. +- Clean up the owning live query rather than calling `cleanup()` on one of its + included child Collections. The child cleanup now rejects because several + parent rows can share that Collection. + +### Use string or number keys throughout + +`sync.write` now enforces the same key restriction as optimistic writes: +`getKey` must return a string or number. An undefined key throws +`UndefinedKeyError`; other invalid keys throw `InvalidKeyError`. Convert +boolean, bigint, nullable, or compound identifiers to a stable string or number +in your adapter's `getKey`. + +Single-field grouped results now encode `null`, `undefined`, boolean, and +bigint group values as string keys. String and number group values keep their +keys, except strings beginning with the reserved `\u0000tanstack-db:` prefix, +which are encoded too. Read the published row's `$key` when retrieving a group; +do not reconstruct a key from its grouping value. A custom live-query `getKey` +still overrides the default. See [groupBy and Aggregations](./live-queries.md#groupby-and-aggregations). + +`SortedMap` is no longer exported from `@tanstack/db`. Remove imports of this +internal ordered map; use public Collection reads or an application-owned map. + +### Check stricter inputs and adapter requirements + +- A custom index `compareFn` or collation `compare` must return a number other + than `NaN`. Invalid comparator results now throw instead of silently + misordering an index. +- Browser development builds now detect two loaded copies of `@tanstack/db` + and throw `DuplicateDbInstanceError`. Deduplicate the package if the check + appears after upgrading. +- Pass fresh persisted or LocalStorage sync options to each direct + `createCollection` call. Reusing mutable options across Collections now + rejects. Reusable `DbClient` descriptors are unaffected. +- `Collection.update` keys must match the Collection's declared key type. + Narrow possibly undefined keys, and use the branded key type if the + Collection declares one. Reusable `Ref` helpers that require virtual row + metadata should set their third type parameter to `true`. +- Upgrade to PowerSync 2 before updating `@tanstack/powersync-db-collection`, + and to RxDB 17 before updating `@tanstack/rxdb-db-collection`. +- Vue live-query `data.value` is a snapshot. Read `data.value` again to get + later rows instead of keeping a previously read array. If a changed query + fails to compile, the hook no longer displays the previous query's rows. +- A default auto-committed offline transaction accepts only its first + `mutate()` call. Create a new offline transaction for each later write; + calling `mutate()` again on the committed transaction now throws + `TransactionNotPendingMutateError`. +- If you import `@tanstack/db-ivm` directly, update removed operator imports. + The old join helpers become `join(other, type)`; use + `orderByWithFractionalIndex` or `groupedOrderByWithFractionalIndex` in place + of the removed ordering helpers. See the + [0.2.0 release notes](https://github.com/TanStack/db/blob/main/packages/db-ivm/CHANGELOG.md#020) + for the full list. + +Production error messages now contain a short code and a link to the +[error reference](../errors.md). Code that compared the old message text should +use error classes, names, or fields instead. The development messages remain +descriptive. diff --git a/docs/guides/mutations.md b/docs/guides/mutations.md index dda3c01963..ba6f5d6e5c 100644 --- a/docs/guides/mutations.md +++ b/docs/guides/mutations.md @@ -66,17 +66,19 @@ const addTodoMutation = useMutation({ ```typescript const todoCollection = createCollection( - queryCollectionOptions({ + queryCollectionOptions<{ id: string; text: string; completed: boolean }>({ queryKey: ['todos'], + queryClient, queryFn: async () => api.todos.getAll(), getKey: (item) => item.id, schema: todoSchema, - onInsert: async ({ transaction }) => { + onInsert: async ({ transaction, collection }) => { await Promise.all( transaction.mutations.map((mutation) => api.todos.create(mutation.modified) ) ) + await collection.utils.refetch() }, }) ) @@ -271,9 +273,7 @@ with the transaction on success and rejects with the original error on failure (or `undefined` for a rollback without an error). It does not by itself prove that a server uploaded, confirmed, or returned the write. It proves those stronger guarantees only when the handler waits for that backend observation -before returning. The old `tx.isPersisted.promise` remains available for the -pre-RC releases but is deprecated and will be removed in the 1.0 RC; replace -each use with `tx.when('settled')`. +before returning. ### Concurrent Optimistic Transactions @@ -518,7 +518,7 @@ const todoCollection = createCollection({ Different collection types have specific patterns for their handlers: -**QueryCollection** - currently refetches automatically after persisting changes. To adopt the explicit v1.0 pattern now: +**QueryCollection** - explicitly refetch or apply the server response with the write utilities before the handler returns: ```typescript onUpdate: async ({ transaction, collection }) => { await Promise.all( @@ -528,9 +528,6 @@ onUpdate: async ({ transaction, collection }) => { ) // Trigger refetch to sync server state await collection.utils.refetch() - // Prevent the current pre-1.0 wrapper from refetching a second time. - // Remove this return in v1.0. - return { refetch: false } } ``` @@ -997,7 +994,7 @@ You can mix local and server collections in the same transaction: ```ts const localSettings = createCollection( - localStorageCollectionOptions({ + localStorageCollectionOptions<{ id: string; mode: string }>({ id: "user-settings", storageKey: "app-settings", getKey: (item) => item.id, @@ -1005,12 +1002,14 @@ const localSettings = createCollection( ) const userProfile = createCollection( - queryCollectionOptions({ + queryCollectionOptions<{ id: string; name: string }>({ queryKey: ["profile"], + queryClient, queryFn: async () => api.profile.get(), getKey: (item) => item.id, - onUpdate: async ({ transaction }) => { + onUpdate: async ({ transaction, collection }) => { await api.profile.update(transaction.mutations[0].modified) + await collection.utils.refetch() }, }) ) @@ -1020,7 +1019,7 @@ const tx = createTransaction({ // Handle server collection mutations explicitly in mutationFn await Promise.all( transaction.mutations - .filter((m) => m.collection === userProfile) + .filter((m) => Object.is(m.collection, userProfile)) .map((m) => api.profile.update(m.modified)) ) diff --git a/docs/guides/schemas.md b/docs/guides/schemas.md index 7b74ccc24b..cbcb213fa8 100644 --- a/docs/guides/schemas.md +++ b/docs/guides/schemas.md @@ -233,7 +233,6 @@ const collection = createCollection( await api.todos.create(payload) } await collection.utils.refetch() - return { refetch: false } }, }) ) @@ -896,8 +895,8 @@ A complete todo application demonstrating validation, transformations, and defau ```typescript import { z } from 'zod' -import { createCollection } from '@tanstack/react-db' -import { not } from '@tanstack/db' +import { createCollection, useLiveQuery } from '@tanstack/react-db' +import { not, SchemaValidationError } from '@tanstack/db' import { queryCollectionOptions } from '@tanstack/query-db-collection' // Schema with validation, transformations, and defaults @@ -934,7 +933,7 @@ const todoCollection = createCollection( schema: todoSchema, queryClient, - onInsert: async ({ transaction }) => { + onInsert: async ({ transaction, collection }) => { const todo = transaction.mutations[0].modified // Serialize dates for API @@ -947,9 +946,10 @@ const todoCollection = createCollection( created_at: todo.created_at.toISOString() }) }) + await collection.utils.refetch() }, - onUpdate: async ({ transaction }) => { + onUpdate: async ({ transaction, collection }) => { await Promise.all( transaction.mutations.map(async (mutation) => { const { original, changes } = mutation @@ -969,9 +969,10 @@ const todoCollection = createCollection( }) }) ) + await collection.utils.refetch() }, - onDelete: async ({ transaction }) => { + onDelete: async ({ transaction, collection }) => { await Promise.all( transaction.mutations.map(async (mutation) => { await fetch(`/api/todos/${mutation.original.id}`, { @@ -979,6 +980,7 @@ const todoCollection = createCollection( }) }) ) + await collection.utils.refetch() } }) ) @@ -991,7 +993,7 @@ function TodoApp() { .orderBy(({ todo }) => todo.created_at, 'desc') ) - const [errors, setErrors] = useState>({}) + const [errors, setErrors] = useState({} as Record) const addTodo = (text: string, priority: 'low' | 'medium' | 'high') => { try { @@ -1007,7 +1009,7 @@ function TodoApp() { if (error instanceof SchemaValidationError) { const newErrors: Record = {} error.issues.forEach(issue => { - const field = issue.path?.[0] || 'form' + const field = String(issue.path?.[0] ?? 'form') newErrors[field] = issue.message }) setErrors(newErrors) @@ -1090,7 +1092,7 @@ const productCollection = createCollection( schema: productSchema, queryClient, - onInsert: async ({ transaction }) => { + onInsert: async ({ transaction, collection }) => { const product = transaction.mutations[0].modified // API only needs base fields, not computed ones @@ -1104,6 +1106,7 @@ const productCollection = createCollection( category: product.category, tags: product.tags }) + await collection.utils.refetch() } }) ) diff --git a/docs/guides/ssr.md b/docs/guides/ssr.md index e188b392c7..5cca15b228 100644 --- a/docs/guides/ssr.md +++ b/docs/guides/ssr.md @@ -37,13 +37,13 @@ instances still exist. The migration is required when you want SSR-safe request isolation, hydration, incremental chunks, Suspense streaming, or the 1.0-ready React hook shape. -The old dependency-array form now warns: +The dependency-array form was removed in the first 1.0 RC: ```tsx useLiveQuery((q) => q.from({ todos }).where(...), [status]) ``` -It still works, but warns in development and will be removed in 1.0. Prefer: +Use the query config form instead: ```tsx useLiveQuery({ @@ -714,14 +714,7 @@ captures in `queryKey`, so the query recompiles when they change. DB also warns once in development if deriving identity becomes expensive enough that an explicit `queryKey` would be better. -Dependency arrays are accepted for backwards compatibility: - -```tsx -useLiveQuery((q) => q.from({ todo: todoCollection }), [status]) -``` - -They warn in development and will be removed in 1.0. Migrate to the config -object form: +Use the config object form: ```tsx useLiveQuery({ @@ -820,6 +813,7 @@ useLiveQuery( q .from({ todo: todoCollection }) .where(({ todo }) => eq(todo.status, status)), + // @ts-expect-error Dependency arrays are removed in the RC. [status], ) @@ -884,24 +878,26 @@ rendering instead. ## Compatibility -No existing public API is removed by this change. +The SSR APIs remain available in the first 1.0 RC. React dependency-array hook forms have been removed. Still supported: - `createCollection(...)` - passing collection instances to `useLiveQuery(...)` -- `useLiveQuery(queryFn, deps)` -- `useLiveSuspenseQuery(queryFn, deps)` - mutation APIs such as `insert`, `update`, `delete`, `subscribe`, and optimistic mutation helpers -Warnings: +Query identity checks: -- React dependency arrays warn in development and will be removed in 1.0. -- Opaque query IR without `queryKey` throws `UnhashableQueryIRError` in a - hook that compares identity (React, Preact, or any `DbClient`-backed hook). +- React hooks use derived query identity or an explicit, nonempty `queryKey`. +- Opaque query IR without a nonempty `queryKey` throws + `UnhashableQueryIRError` in a hook that compares identity (React, Preact, or + any `DbClient`-backed hook). - Expensive derived identity warns in development and suggests `queryKey`. +Opaque query IR without a serializable `queryKey` throws when React cannot +derive a stable identity. + Required for SSR: - stable explicit collection ids @@ -916,7 +912,10 @@ Required for render-time Suspense streaming: - `useLiveSuspenseQuery(...)` inside a Suspense boundary - a stable derived query identity or explicit serializable `queryKey` -## Detailed Changelog +## SSR Introduction Changelog + +This section records the original SSR feature release. Its React dependency +array deprecation was completed in the first 1.0 RC. ### Added @@ -956,7 +955,7 @@ Required for render-time Suspense streaming: `from`, `join`, `leftJoin`, and `unionAll` sources when a `DbProvider` is present. - React live query identity is derived from normalized structured IR when no - explicit `queryKey` or legacy dependency array is supplied. + explicit `queryKey` is supplied. - Explicit collection preloading serializes normalized collection rows. - Live-query preloading and render-time discovery serialize ordered result snapshots without implicitly serializing source collections. @@ -971,10 +970,10 @@ Required for render-time Suspense streaming: - Incremental chunks use the same collection payload shape as full dehydration. - Streamed live-query promises resolve to live-query result snapshots. -### Deprecated +### Since Removed - React dependency arrays for `useLiveQuery` and wrappers that delegate to it. - They still work and warn in development. They are planned for removal in 1.0. + They were removed in the first 1.0 RC. ### Not Changed @@ -992,7 +991,8 @@ The SSR strategy is covered by: - core `DbClient` tests for hydration, streaming chunks, sync metadata, initial data precedence, explicit ids, and no optimistic serialization - React tests for `DbProvider`, descriptor resolution, derived query identity, - `queryKey`, deprecation warnings, SSR result snapshots, and atomic handoff + `queryKey`, derived-identity performance warnings, SSR result snapshots, and + atomic handoff - Svelte tests for provider ownership, server snapshot rendering, and browser handoff - query adapter tests to ensure Query cache behavior still holds diff --git a/docs/overview.md b/docs/overview.md index 454afb1877..66c02463b8 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -32,6 +32,7 @@ It extends TanStack Query with collections, live queries and optimistic mutation ## Contents +- [Migrating to the 1.0 RC](./guides/migrate-to-1-0-rc.md) — update apps from pre-1.0 releases - [How it works](#how-it-works) — understand the TanStack DB development model and how the pieces fit together - [SSR and hydration](./guides/ssr.md) — use `DbClient` to transport explicit collection rows or live-query result snapshots - [SQLite persistence](./guides/sqlite-persistence.md) — retain Collection rows across restarts and save sync metadata for supported adapters @@ -440,6 +441,7 @@ import { collectionOptions, useLiveQuery, } from "@tanstack/react-db" +import { eq } from "@tanstack/db" import { queryCollectionOptions } from "@tanstack/query-db-collection" import { QueryClient } from "@tanstack/query-core" @@ -449,7 +451,7 @@ const dbClient = new DbClient({ queryClient }) // Load data into collections using TanStack Query. // It's common to define these in a `collections` module. const todoCollection = collectionOptions("todos", (client) => - queryCollectionOptions({ + queryCollectionOptions<{ id: string; list_id: string; text: string; status: string }>({ id: "todos", queryKey: ["todos"], queryClient: client.requireDependency("queryClient"), @@ -462,14 +464,12 @@ const todoCollection = collectionOptions("todos", (client) => // Handle the local write by sending it to your API. await api.todos.create(newTodo) await collection.utils.refetch() - // Prevent the pre-1.0 compatibility wrapper from refetching again. - return { refetch: false } }, // also add onUpdate, onDelete as needed. }) ) const listCollection = collectionOptions("todo-lists", (client) => - queryCollectionOptions({ + queryCollectionOptions<{ id: string; name: string; active: boolean }>({ id: "todo-lists", queryKey: ["todo-lists"], queryClient: client.requireDependency("queryClient"), @@ -483,8 +483,6 @@ const listCollection = collectionOptions("todo-lists", (client) => // Handle the local write by sending it to your API. await api.todoLists.create(newTodo) await collection.utils.refetch() - // Prevent the pre-1.0 compatibility wrapper from refetching again. - return { refetch: false } }, // also add onUpdate, onDelete as needed. }) @@ -511,7 +509,7 @@ const Todos = () => { })), }) - // ... + return
      {todos.map((todo) =>
    • {todo.text}
    • )}
    } const App = () => ( diff --git a/docs/quick-start.md b/docs/quick-start.md index 04a5eb3cb6..409811fbce 100644 --- a/docs/quick-start.md +++ b/docs/quick-start.md @@ -26,7 +26,7 @@ const dbClient = new DbClient({ queryClient }) // Define a stable collection descriptor that loads data using TanStack Query const todoCollection = collectionOptions('todos', (client) => - queryCollectionOptions({ + queryCollectionOptions<{ id: string; text: string; completed: boolean; createdAt: string }>({ id: 'todos', queryKey: ['todos'], queryClient: client.requireDependency('queryClient'), @@ -35,12 +35,13 @@ const todoCollection = collectionOptions('todos', (client) => return response.json() }, getKey: (item) => item.id, - onUpdate: async ({ transaction }) => { + onUpdate: async ({ transaction, collection }) => { const { original, modified } = transaction.mutations[0] await fetch(`/api/todos/${original.id}`, { method: 'PUT', body: JSON.stringify(modified), }) + await collection.utils.refetch() }, }) ) @@ -60,7 +61,7 @@ function Todos() { .orderBy(({ todo }) => todo.createdAt, 'desc'), }) - const toggleTodo = (todo) => { + const toggleTodo = (todo: { id: string }) => { // Instantly applies optimistic state, then syncs to server todosCollection.update(todo.id, (draft) => { draft.completed = !draft.completed @@ -108,7 +109,7 @@ Collections store your data and handle persistence. The `queryCollectionOptions` ```tsx const todoCollection = collectionOptions('todos', (client) => - queryCollectionOptions({ + queryCollectionOptions<{ id: string; text: string; completed: boolean; createdAt: string }>({ id: 'todos', queryKey: ['todos'], queryClient: client.requireDependency('queryClient'), @@ -118,23 +119,26 @@ const todoCollection = collectionOptions('todos', (client) => }, getKey: (item) => item.id, // Handle all CRUD operations - onInsert: async ({ transaction }) => { + onInsert: async ({ transaction, collection }) => { const { modified: newTodo } = transaction.mutations[0] await fetch('/api/todos', { method: 'POST', body: JSON.stringify(newTodo), }) + await collection.utils.refetch() }, - onUpdate: async ({ transaction }) => { + onUpdate: async ({ transaction, collection }) => { const { original, modified } = transaction.mutations[0] await fetch(`/api/todos/${original.id}`, { method: 'PUT', body: JSON.stringify(modified), }) + await collection.utils.refetch() }, - onDelete: async ({ transaction }) => { + onDelete: async ({ transaction, collection }) => { const { original } = transaction.mutations[0] await fetch(`/api/todos/${original.id}`, { method: 'DELETE' }) + await collection.utils.refetch() }, }) ) diff --git a/examples/react-native/shopping-list/src/components/ListDetail.tsx b/examples/react-native/shopping-list/src/components/ListDetail.tsx index 2b3e300e3a..fd15f2a593 100644 --- a/examples/react-native/shopping-list/src/components/ListDetail.tsx +++ b/examples/react-native/shopping-list/src/components/ListDetail.tsx @@ -21,7 +21,7 @@ type ListItemRow = { text: string checked: boolean createdAt: string - $synced?: boolean + $hasPendingWrites?: boolean } function ItemRow({ @@ -36,7 +36,7 @@ function ItemRow({ const [showSavingBadge, setShowSavingBadge] = useState(false) useEffect(() => { - if (item.$synced !== false) { + if (!item.$hasPendingWrites) { setShowSavingBadge(false) return } @@ -48,7 +48,7 @@ function ItemRow({ return () => { clearTimeout(timer) } - }, [item.id, item.$synced]) + }, [item.id, item.$hasPendingWrites]) return ( diff --git a/examples/react-native/shopping-list/src/components/ListsScreen.tsx b/examples/react-native/shopping-list/src/components/ListsScreen.tsx index bd8c4dcf14..45e7376e11 100644 --- a/examples/react-native/shopping-list/src/components/ListsScreen.tsx +++ b/examples/react-native/shopping-list/src/components/ListsScreen.tsx @@ -26,7 +26,7 @@ function ListCard({ totalItems: any checkedItems: any uncheckedPreview: any - $synced?: boolean + hasPendingWrites?: boolean } onPress: () => void onDelete: () => void @@ -48,7 +48,7 @@ function ListCard({ const [showSavingBadge, setShowSavingBadge] = useState(false) useEffect(() => { - if (list.$synced !== false) { + if (!list.hasPendingWrites) { setShowSavingBadge(false) return } @@ -60,7 +60,7 @@ function ListCard({ return () => { clearTimeout(timer) } - }, [list.id, list.$synced]) + }, [list.id, list.hasPendingWrites]) return ( { setTransactions((prev) => prev.map((t) => { diff --git a/examples/react/projects/README.md b/examples/react/projects/README.md index 7b1d62065b..bc636fce1a 100644 --- a/examples/react/projects/README.md +++ b/examples/react/projects/README.md @@ -210,9 +210,6 @@ export const todoCollection = createCollection( }) // Trigger refetch to sync server state await collection.utils.refetch() - // Prevent the current pre-1.0 wrapper from refetching a second time. - // Remove this return in v1.0. - return { refetch: false } }, // You can also implement onUpdate, onDelete as needed }) diff --git a/examples/react/projects/src/lib/collections.ts b/examples/react/projects/src/lib/collections.ts index 235969dd20..86e36b3b9e 100644 --- a/examples/react/projects/src/lib/collections.ts +++ b/examples/react/projects/src/lib/collections.ts @@ -2,8 +2,8 @@ import { createCollection } from '@tanstack/react-db' import { queryCollectionOptions } from '@tanstack/query-db-collection' import { QueryClient } from '@tanstack/query-core' import { - selectTodoSchema, selectProjectSchema, + selectTodoSchema, selectUsersSchema, } from '@/db/schema' import { trpc } from '@/lib/trpc-client' @@ -47,7 +47,7 @@ export const projectCollection = createCollection( queryClient, schema: selectProjectSchema, getKey: (item) => item.id, - onInsert: async ({ transaction }) => { + onInsert: async ({ transaction, collection }) => { const { modified: newProject } = transaction.mutations[0] await trpc.projects.create.mutate({ name: newProject.name, @@ -55,8 +55,9 @@ export const projectCollection = createCollection( owner_id: newProject.owner_id, shared_user_ids: newProject.shared_user_ids, }) + await collection.utils.refetch() }, - onUpdate: async ({ transaction }) => { + onUpdate: async ({ transaction, collection }) => { const { modified: updatedProject } = transaction.mutations[0] await trpc.projects.update.mutate({ id: updatedProject.id, @@ -66,12 +67,14 @@ export const projectCollection = createCollection( shared_user_ids: updatedProject.shared_user_ids, }, }) + await collection.utils.refetch() }, - onDelete: async ({ transaction }) => { + onDelete: async ({ transaction, collection }) => { const { original: deletedProject } = transaction.mutations[0] await trpc.projects.delete.mutate({ id: deletedProject.id, }) + await collection.utils.refetch() }, }) ) @@ -93,7 +96,7 @@ export const todoCollection = createCollection( queryClient, schema: selectTodoSchema, getKey: (item) => item.id, - onInsert: async ({ transaction }) => { + onInsert: async ({ transaction, collection }) => { const { modified: newTodo } = transaction.mutations[0] await trpc.todos.create.mutate({ user_id: newTodo.user_id, @@ -102,8 +105,9 @@ export const todoCollection = createCollection( project_id: newTodo.project_id, user_ids: newTodo.user_ids, }) + await collection.utils.refetch() }, - onUpdate: async ({ transaction }) => { + onUpdate: async ({ transaction, collection }) => { const { modified: updatedTodo } = transaction.mutations[0] await trpc.todos.update.mutate({ id: updatedTodo.id, @@ -112,12 +116,14 @@ export const todoCollection = createCollection( completed: updatedTodo.completed, }, }) + await collection.utils.refetch() }, - onDelete: async ({ transaction }) => { + onDelete: async ({ transaction, collection }) => { const { original: deletedTodo } = transaction.mutations[0] await trpc.todos.delete.mutate({ id: deletedTodo.id, }) + await collection.utils.refetch() }, }) ) diff --git a/examples/react/projects/src/routes/_authenticated/index.tsx b/examples/react/projects/src/routes/_authenticated/index.tsx index 603a1c5f6b..097a84c00f 100644 --- a/examples/react/projects/src/routes/_authenticated/index.tsx +++ b/examples/react/projects/src/routes/_authenticated/index.tsx @@ -31,7 +31,7 @@ export const Route = createFileRoute(`/_authenticated/`)({ created_at: new Date(), updated_at: new Date(), }) - await tx.isPersisted.promise + await tx.when(`settled`) const serverProjectId = projectCollection.toArray[0].id throw redirect({ to: `/project/$projectId`, diff --git a/examples/react/todo/src/lib/collections.ts b/examples/react/todo/src/lib/collections.ts index e8154c5a2b..c0208a7667 100644 --- a/examples/react/todo/src/lib/collections.ts +++ b/examples/react/todo/src/lib/collections.ts @@ -29,7 +29,7 @@ export const electricTodoCollection = createCollection( }, getKey: (item) => item.id, schema: selectTodoSchema, - onInsert: async ({ transaction }) => { + onInsert: async ({ transaction, collection }) => { const { id: _id, created_at: _f, @@ -37,9 +37,9 @@ export const electricTodoCollection = createCollection( ...modified } = transaction.mutations[0].modified const response = await api.todos.create(modified) - return { txid: response.txid } + await collection.utils.awaitTxId(response.txid) }, - onUpdate: async ({ transaction }) => { + onUpdate: async ({ transaction, collection }) => { const txids = await Promise.all( transaction.mutations.map(async (mutation) => { const { original, changes } = mutation @@ -50,9 +50,9 @@ export const electricTodoCollection = createCollection( return response.txid }), ) - return { txid: txids } + await Promise.all(txids.map((txid) => collection.utils.awaitTxId(txid))) }, - onDelete: async ({ transaction }) => { + onDelete: async ({ transaction, collection }) => { const txids = await Promise.all( transaction.mutations.map(async (mutation) => { const { original } = mutation @@ -63,7 +63,7 @@ export const electricTodoCollection = createCollection( return response.txid }), ) - return { txid: txids } + await Promise.all(txids.map((txid) => collection.utils.awaitTxId(txid))) }, }), ) @@ -85,17 +85,18 @@ export const queryTodoCollection = createCollection( getKey: (item) => item.id, schema: selectTodoSchema, queryClient, - onInsert: async ({ transaction }) => { + onInsert: async ({ transaction, collection }) => { const { id: _id, created_at: _crea, updated_at: _up, ...modified } = transaction.mutations[0].modified - return await api.todos.create(modified) + await api.todos.create(modified) + await collection.utils.refetch() }, - onUpdate: async ({ transaction }) => { - return await Promise.all( + onUpdate: async ({ transaction, collection }) => { + await Promise.all( transaction.mutations.map(async (mutation) => { const { original, changes } = mutation if (!(`id` in original)) { @@ -104,9 +105,10 @@ export const queryTodoCollection = createCollection( return await api.todos.update(original.id, changes) }), ) + await collection.utils.refetch() }, - onDelete: async ({ transaction }) => { - return await Promise.all( + onDelete: async ({ transaction, collection }) => { + await Promise.all( transaction.mutations.map(async (mutation) => { const { original } = mutation if (!(`id` in original)) { @@ -115,6 +117,7 @@ export const queryTodoCollection = createCollection( await api.todos.delete(original.id) }), ) + await collection.utils.refetch() }, }), ) @@ -160,12 +163,12 @@ export const electricConfigCollection = createCollection( }, getKey: (item) => item.id, schema: selectConfigSchema, - onInsert: async ({ transaction }) => { + onInsert: async ({ transaction, collection }) => { const modified = transaction.mutations[0].modified const response = await api.config.create(modified) - return { txid: response.txid } + await collection.utils.awaitTxId(response.txid) }, - onUpdate: async ({ transaction }) => { + onUpdate: async ({ transaction, collection }) => { const txids = await Promise.all( transaction.mutations.map(async (mutation) => { const { original, changes } = mutation @@ -176,7 +179,7 @@ export const electricConfigCollection = createCollection( return response.txid }), ) - return { txid: txids } + await Promise.all(txids.map((txid) => collection.utils.awaitTxId(txid))) }, }), ) @@ -198,23 +201,22 @@ export const queryConfigCollection = createCollection( getKey: (item) => item.id, schema: selectConfigSchema, queryClient, - onInsert: async ({ transaction }) => { + onInsert: async ({ transaction, collection }) => { const modified = transaction.mutations[0].modified - const response = await api.config.create(modified) - return { txid: response.txid } + await api.config.create(modified) + await collection.utils.refetch() }, - onUpdate: async ({ transaction }) => { - const txids = await Promise.all( + onUpdate: async ({ transaction, collection }) => { + await Promise.all( transaction.mutations.map(async (mutation) => { const { original, changes } = mutation if (!(`id` in original)) { throw new Error(`Original config not found for update`) } - const response = await api.config.update(original.id, changes) - return response.txid + await api.config.update(original.id, changes) }), ) - return { txid: txids } + await collection.utils.refetch() }, }), ) diff --git a/examples/react/todo/src/routes/electric.tsx b/examples/react/todo/src/routes/electric.tsx index 16da41b9ff..398f968f3f 100644 --- a/examples/react/todo/src/routes/electric.tsx +++ b/examples/react/todo/src/routes/electric.tsx @@ -65,7 +65,7 @@ function ElectricPage() { // Wait for all txids to sync back to the collection await Promise.all( - txids.map((txid) => electricConfigCollection.utils.awaitTxid(txid)), + txids.map((txid) => electricConfigCollection.utils.awaitTxId(txid)), ) } diff --git a/examples/react/todo/src/routes/query.tsx b/examples/react/todo/src/routes/query.tsx index 5cbf4f28de..04281f3c9c 100644 --- a/examples/react/todo/src/routes/query.tsx +++ b/examples/react/todo/src/routes/query.tsx @@ -32,7 +32,7 @@ function QueryPage() { query: (q) => q.from({ config: queryConfigCollection }), }) - // Query collections automatically refetch after handler completes + // Custom mutation functions also explicitly refresh confirmed server state const configMutationFn = async ({ transaction, }: { diff --git a/examples/solid/todo/src/lib/collections.ts b/examples/solid/todo/src/lib/collections.ts index e252809e55..e662290514 100644 --- a/examples/solid/todo/src/lib/collections.ts +++ b/examples/solid/todo/src/lib/collections.ts @@ -29,7 +29,7 @@ export const electricTodoCollection = createCollection( }, getKey: (item) => item.id, schema: selectTodoSchema, - onInsert: async ({ transaction }) => { + onInsert: async ({ transaction, collection }) => { const { id: _id, created_at: _f, @@ -37,9 +37,9 @@ export const electricTodoCollection = createCollection( ...modified } = transaction.mutations[0].modified const response = await api.todos.create(modified) - return { txid: response.txid } + await collection.utils.awaitTxId(response.txid) }, - onUpdate: async ({ transaction }) => { + onUpdate: async ({ transaction, collection }) => { const txids = await Promise.all( transaction.mutations.map(async (mutation) => { const { original, changes } = mutation @@ -47,9 +47,9 @@ export const electricTodoCollection = createCollection( return response.txid }), ) - return { txid: txids } + await Promise.all(txids.map((txid) => collection.utils.awaitTxId(txid))) }, - onDelete: async ({ transaction }) => { + onDelete: async ({ transaction, collection }) => { const txids = await Promise.all( transaction.mutations.map(async (mutation) => { const { original } = mutation @@ -57,7 +57,7 @@ export const electricTodoCollection = createCollection( return response.txid }), ) - return { txid: txids } + await Promise.all(txids.map((txid) => collection.utils.awaitTxId(txid))) }, }), ) @@ -79,30 +79,33 @@ export const queryTodoCollection = createCollection( getKey: (item) => item.id, schema: selectTodoSchema, queryClient, - onInsert: async ({ transaction }) => { + onInsert: async ({ transaction, collection }) => { const { id: _id, created_at: _crea, updated_at: _up, ...modified } = transaction.mutations[0].modified - return await api.todos.create(modified) + await api.todos.create(modified) + await collection.utils.refetch() }, - onUpdate: async ({ transaction }) => { - return await Promise.all( + onUpdate: async ({ transaction, collection }) => { + await Promise.all( transaction.mutations.map(async (mutation) => { const { original, changes } = mutation return await api.todos.update(original.id, changes) }), ) + await collection.utils.refetch() }, - onDelete: async ({ transaction }) => { - return await Promise.all( + onDelete: async ({ transaction, collection }) => { + await Promise.all( transaction.mutations.map(async (mutation) => { const { original } = mutation await api.todos.delete(original.id) }), ) + await collection.utils.refetch() }, }), ) @@ -148,12 +151,12 @@ export const electricConfigCollection = createCollection( }, getKey: (item) => item.id, schema: selectConfigSchema, - onInsert: async ({ transaction }) => { + onInsert: async ({ transaction, collection }) => { const modified = transaction.mutations[0].modified const response = await api.config.create(modified) - return { txid: response.txid } + await collection.utils.awaitTxId(response.txid) }, - onUpdate: async ({ transaction }) => { + onUpdate: async ({ transaction, collection }) => { const txids = await Promise.all( transaction.mutations.map(async (mutation) => { const { original, changes } = mutation @@ -161,7 +164,7 @@ export const electricConfigCollection = createCollection( return response.txid }), ) - return { txid: txids } + await Promise.all(txids.map((txid) => collection.utils.awaitTxId(txid))) }, }), ) @@ -183,20 +186,19 @@ export const queryConfigCollection = createCollection( getKey: (item) => item.id, schema: selectConfigSchema, queryClient, - onInsert: async ({ transaction }) => { + onInsert: async ({ transaction, collection }) => { const modified = transaction.mutations[0].modified - const response = await api.config.create(modified) - return { txid: response.txid } + await api.config.create(modified) + await collection.utils.refetch() }, - onUpdate: async ({ transaction }) => { - const txids = await Promise.all( + onUpdate: async ({ transaction, collection }) => { + await Promise.all( transaction.mutations.map(async (mutation) => { const { original, changes } = mutation - const response = await api.config.update(original.id, changes) - return response.txid + await api.config.update(original.id, changes) }), ) - return { txid: txids } + await collection.utils.refetch() }, }), ) diff --git a/examples/solid/todo/src/routes/trailbase.tsx b/examples/solid/todo/src/routes/trailbase.tsx index c9f450ea47..dcc7f6e985 100644 --- a/examples/solid/todo/src/routes/trailbase.tsx +++ b/examples/solid/todo/src/routes/trailbase.tsx @@ -20,21 +20,21 @@ export const Route = createFileRoute(`/trailbase`)({ }) function TrailBasePage() { - // Get data using live queries with Electric collections - const { data: todos } = useLiveQuery((q) => + // Get data using live queries with TrailBase collections + const todos = useLiveQuery((q) => q .from({ todo: trailBaseTodoCollection }) .orderBy(({ todo }) => todo.created_at, `asc`), ) - const { data: configData } = useLiveQuery((q) => + const configData = useLiveQuery((q) => q.from({ config: trailBaseConfigCollection }), ) return ( ( } type PersistedTransactionHandle = { - isPersisted: { - promise: Promise - } + when: (state: 'settled') => Promise } async function waitForPersisted( transaction: PersistedTransactionHandle, ): Promise { - await transaction.isPersisted.promise + await transaction.when(`settled`) } async function seedCollection( diff --git a/packages/browser-db-sqlite-persistence/e2e/electric-immediate-reload.opfs.ts b/packages/browser-db-sqlite-persistence/e2e/electric-immediate-reload.opfs.ts index 460ecefc14..8d9d8cc9b0 100644 --- a/packages/browser-db-sqlite-persistence/e2e/electric-immediate-reload.opfs.ts +++ b/packages/browser-db-sqlite-persistence/e2e/electric-immediate-reload.opfs.ts @@ -208,7 +208,7 @@ try { insert = async (row) => { try { const transaction = collection.insert(row) - await transaction.isPersisted.promise + await transaction.when(`settled`) insertAcknowledged = true } catch (error) { failure = failureMessage(error) diff --git a/packages/capacitor-db-sqlite-persistence/e2e/shared/capacitor-persisted-collection-harness.ts b/packages/capacitor-db-sqlite-persistence/e2e/shared/capacitor-persisted-collection-harness.ts index a036efdd3b..6a83269346 100644 --- a/packages/capacitor-db-sqlite-persistence/e2e/shared/capacitor-persisted-collection-harness.ts +++ b/packages/capacitor-db-sqlite-persistence/e2e/shared/capacitor-persisted-collection-harness.ts @@ -23,9 +23,7 @@ type PersistedCollectionHarness = { } type PersistedTransactionHandle = { - isPersisted: { - promise: Promise - } + when: (state: 'settled') => Promise } type PersistenceFactory = ( @@ -89,7 +87,7 @@ function createPersistedCollection( async function waitForPersisted( transaction: PersistedTransactionHandle, ): Promise { - await transaction.isPersisted.promise + await transaction.when(`settled`) } async function seedCollection( diff --git a/packages/capacitor-db-sqlite-persistence/tests/capacitor-persistence.test.ts b/packages/capacitor-db-sqlite-persistence/tests/capacitor-persistence.test.ts index ebe2e711ee..4f8fc197b4 100644 --- a/packages/capacitor-db-sqlite-persistence/tests/capacitor-persistence.test.ts +++ b/packages/capacitor-db-sqlite-persistence/tests/capacitor-persistence.test.ts @@ -250,7 +250,7 @@ it(`resumes persisted sync after simulated app lifecycle transitions`, async () title: `Before background`, score: 1, }) - await initialInsert.isPersisted.promise + await initialInsert.when(`settled`) await collection.cleanup() collection.startSyncImmediate() @@ -261,7 +261,7 @@ it(`resumes persisted sync after simulated app lifecycle transitions`, async () title: `Post resume write`, score: 2, }) - await postResumeInsert.isPersisted.promise + await postResumeInsert.when(`settled`) const persistedRows = await persistence.adapter.loadSubset( { kind: `eager`, collectionId: collectionId }, diff --git a/packages/cloudflare-durable-objects-db-sqlite-persistence/e2e/fixtures/worker.mjs b/packages/cloudflare-durable-objects-db-sqlite-persistence/e2e/fixtures/worker.mjs index 7cd1e550ba..7ba07fa768 100644 --- a/packages/cloudflare-durable-objects-db-sqlite-persistence/e2e/fixtures/worker.mjs +++ b/packages/cloudflare-durable-objects-db-sqlite-persistence/e2e/fixtures/worker.mjs @@ -216,7 +216,7 @@ export class PersistenceObject extends DurableObject { } await this.collectionReady const tx = this.collection.insert(requestBody.todo) - await tx.isPersisted.promise + await tx.when(`settled`) return jsonResponse(200, { ok: true, diff --git a/packages/convex-db-collection/e2e/lazy-app-write-oracle.test.ts b/packages/convex-db-collection/e2e/lazy-app-write-oracle.test.ts index f951971721..e969143e06 100644 --- a/packages/convex-db-collection/e2e/lazy-app-write-oracle.test.ts +++ b/packages/convex-db-collection/e2e/lazy-app-write-oracle.test.ts @@ -341,7 +341,7 @@ async function receive(h: History, sdk: `javascript` | `react`) { draft.rank = row.rank draft.text = row.text }) - const result = transaction.isPersisted.promise + const result = transaction.when('settled') void result.catch(() => undefined) persisted.push(result) // The direct Collection read is synchronous; graph propagation has its diff --git a/packages/convex-db-collection/e2e/lazy-boundary-oracle.test.ts b/packages/convex-db-collection/e2e/lazy-boundary-oracle.test.ts index 788544ffce..612c80ec9b 100644 --- a/packages/convex-db-collection/e2e/lazy-boundary-oracle.test.ts +++ b/packages/convex-db-collection/e2e/lazy-boundary-oracle.test.ts @@ -308,7 +308,7 @@ async function receive(h: History, kind: `javascript` | `react`) { text: `hold`, }), ) - persisted = transaction.isPersisted.promise + persisted = transaction.when('settled') } const drain = async () => { release() diff --git a/packages/convex-db-collection/e2e/lazy-concurrency-oracle.test.ts b/packages/convex-db-collection/e2e/lazy-concurrency-oracle.test.ts index a594f5c658..7b56342ae1 100644 --- a/packages/convex-db-collection/e2e/lazy-concurrency-oracle.test.ts +++ b/packages/convex-db-collection/e2e/lazy-concurrency-oracle.test.ts @@ -321,7 +321,7 @@ async function receive( text: `optimistic`, }), ) - persisted = transaction.isPersisted.promise + persisted = transaction.when('settled') } const drain = async () => { release() diff --git a/packages/convex-db-collection/src/index.ts b/packages/convex-db-collection/src/index.ts index 85ab6cb29b..901cda9b4a 100644 --- a/packages/convex-db-collection/src/index.ts +++ b/packages/convex-db-collection/src/index.ts @@ -24,7 +24,7 @@ export type ConvexCollectionConfig< TKey extends string | number = string | number, TSchema extends StandardSchemaV1 = never, > = Omit< - BaseCollectionConfig, + BaseCollectionConfig, `syncMode` | `utils` | `schema` > & { /** Share the client used for mutations, authentication, and reconnects. */ diff --git a/packages/convex-db-collection/src/options.ts b/packages/convex-db-collection/src/options.ts index acd3db6a7c..cb001231a6 100644 --- a/packages/convex-db-collection/src/options.ts +++ b/packages/convex-db-collection/src/options.ts @@ -25,7 +25,7 @@ export function createConvexOptions< TSchema extends StandardSchemaV1, >( config: Omit< - BaseCollectionConfig, + BaseCollectionConfig, `utils` > & { select?: (result: TResult) => ReadonlyArray diff --git a/packages/convex-db-collection/src/paginated-options.ts b/packages/convex-db-collection/src/paginated-options.ts index 43f0377f29..62ddf57e7b 100644 --- a/packages/convex-db-collection/src/paginated-options.ts +++ b/packages/convex-db-collection/src/paginated-options.ts @@ -33,7 +33,7 @@ export type ConvexPaginatedCollectionConfig< TKey extends string | number = string | number, TSchema extends StandardSchemaV1 = never, > = Omit< - BaseCollectionConfig, + BaseCollectionConfig, `syncMode` | `utils` | `schema` > & { client: ConvexCollectionClient diff --git a/packages/convex-db-collection/tests/collection.test.ts b/packages/convex-db-collection/tests/collection.test.ts index 20e5130229..119614734b 100644 --- a/packages/convex-db-collection/tests/collection.test.ts +++ b/packages/convex-db-collection/tests/collection.test.ts @@ -43,7 +43,7 @@ describe(`Convex collection`, () => { const transaction = collection.update(`a`, (row) => { row.text = `optimistic` }) - await transaction.isPersisted.promise + await transaction.when('settled') expect(collection.get(`a`)?.text).toBe(`server`) await collection.cleanup() }) @@ -131,7 +131,7 @@ describe(`Convex collection`, () => { expect(collection.get(`a`)?.text).toBe(`optimistic`) transport.cache([{ _id: `a`, text: `server` }]) resolve() - await tx.isPersisted.promise + await tx.when('settled') expect(collection.get(`a`)?.text).toBe(`server`) await collection.cleanup() }) @@ -143,7 +143,7 @@ describe(`Convex collection`, () => { }) transport.emit([{ _id: `a`, text: `keep` }]) const tx = collection.delete(`a`) - await expect(tx.isPersisted.promise).rejects.toBe(error) + await expect(tx.when('settled')).rejects.toBe(error) expect(collection.get(`a`)?.text).toBe(`keep`) await collection.cleanup() }) @@ -234,7 +234,7 @@ it(`waits for deferred sync acceptance without waiting for its own visibility`, expect(transaction.state).toBe(`persisting`) expect(collection.get(`a`)?.text).toBe(`optimistic`) accept() - await transaction.isPersisted.promise + await transaction.when('settled') expect(collection.get(`a`)?.text).toBe(`server`) await collection.cleanup() }) @@ -323,10 +323,10 @@ it(`confirms optimistic inserts and deletes from subscription results`, async () collection.startSyncImmediate() transport.emit([]) const inserted = collection.insert({ _id: `a`, text: `optimistic` }) - await inserted.isPersisted.promise + await inserted.when('settled') expect(collection.get(`a`)?.text).toBe(`server insert`) const deleted = collection.delete(`a`) - await deleted.isPersisted.promise + await deleted.when('settled') expect(collection.size).toBe(0) await collection.cleanup() }) @@ -343,7 +343,7 @@ it(`reports a cached query error discovered while settling a mutation`, async () const transaction = collection.update(`a`, (row) => { row.text = `optimistic` }) - await expect(transaction.isPersisted.promise).rejects.toBe(error) + await expect(transaction.when('settled')).rejects.toBe(error) expect(collection.utils.getSyncError()).toBe(error) expect(onError).toHaveBeenCalledExactlyOnceWith(error) expect(collection.get(`a`)?.text).toBe(`old`) diff --git a/packages/convex-db-collection/tests/lazy-paging-failure-history-oracle.property.test.ts b/packages/convex-db-collection/tests/lazy-paging-failure-history-oracle.property.test.ts index 0d1228f6c9..6a4ef96e97 100644 --- a/packages/convex-db-collection/tests/lazy-paging-failure-history-oracle.property.test.ts +++ b/packages/convex-db-collection/tests/lazy-paging-failure-history-oracle.property.test.ts @@ -100,7 +100,7 @@ async function runFailureHistory(h: History) { ) releaseApplication = async () => { pending.resolve() - await transaction.isPersisted.promise + await transaction.when('settled') await drain() } } diff --git a/packages/convex-db-collection/tests/lazy-paging-history-oracle.property.test.ts b/packages/convex-db-collection/tests/lazy-paging-history-oracle.property.test.ts index 06dc2b5026..43813e2f0a 100644 --- a/packages/convex-db-collection/tests/lazy-paging-history-oracle.property.test.ts +++ b/packages/convex-db-collection/tests/lazy-paging-history-oracle.property.test.ts @@ -126,7 +126,7 @@ it(`generates ownership and callback schedules against one independent scope law ) applicationHold = { release, - persisted: transaction.isPersisted.promise, + persisted: transaction.when('settled'), } } const attempts: Array<{ diff --git a/packages/convex-db-collection/tests/lazy-paging-interleaving-oracle.property.test.ts b/packages/convex-db-collection/tests/lazy-paging-interleaving-oracle.property.test.ts index 67c5ed12b4..4e1b7a1fd2 100644 --- a/packages/convex-db-collection/tests/lazy-paging-interleaving-oracle.property.test.ts +++ b/packages/convex-db-collection/tests/lazy-paging-interleaving-oracle.property.test.ts @@ -185,7 +185,7 @@ async function runInterleaving( transaction.mutate(() => source.insert({ id: `overlay-${actionNumber}` })) held = { release: pending.resolve, - persisted: transaction.isPersisted.promise, + persisted: transaction.when('settled'), } } const release = async () => { diff --git a/packages/convex-db-collection/tests/lazy-paging-native-receipt-oracle.property.test.ts b/packages/convex-db-collection/tests/lazy-paging-native-receipt-oracle.property.test.ts index 02f1726372..1e9eaba0ee 100644 --- a/packages/convex-db-collection/tests/lazy-paging-native-receipt-oracle.property.test.ts +++ b/packages/convex-db-collection/tests/lazy-paging-native-receipt-oracle.property.test.ts @@ -152,7 +152,7 @@ async function nativeHistory( tx.mutate(() => f.source.insert({ id, rank: 10000, text: `overlay` })) return async () => { resolve() - await tx.isPersisted.promise + await tx.when('settled') await turns() } } diff --git a/packages/convex-db-collection/tests/lazy-paging-oracle.property.test.ts b/packages/convex-db-collection/tests/lazy-paging-oracle.property.test.ts index 31cca2d890..9dc15d3d31 100644 --- a/packages/convex-db-collection/tests/lazy-paging-oracle.property.test.ts +++ b/packages/convex-db-collection/tests/lazy-paging-oracle.property.test.ts @@ -568,7 +568,7 @@ it(`waits for applied receipts, respects abort and fences callbacks across resta `lazy abort: accepted rows must apply first`, ).toBe(false) release() - await transaction.isPersisted.promise + await transaction.when('settled') await f.remote.pump() await expect(loading.result).rejects.toMatchObject({ name: `AbortError`, @@ -808,7 +808,7 @@ it(`keeps the newer establishing receipt through publication reentry`, async () await withTestCleanup( async () => { open1() - await firstBlock.isPersisted.promise + await firstBlock.when('settled') await f.remote.pump() expect(started).toBe(true) expect( @@ -833,7 +833,7 @@ it(`keeps the newer establishing receipt through publication reentry`, async () ).toBe(false) reach.reach(`cached-load-waits-for-latest`) open2() - await secondBlock!.isPersisted.promise + await secondBlock!.when('settled') await f.remote.pump() await first.complete() await second!.complete() diff --git a/packages/convex-db-collection/tests/lazy-paging-receipt-oracle.property.test.ts b/packages/convex-db-collection/tests/lazy-paging-receipt-oracle.property.test.ts index 381fb575f7..0ba8aff8ff 100644 --- a/packages/convex-db-collection/tests/lazy-paging-receipt-oracle.property.test.ts +++ b/packages/convex-db-collection/tests/lazy-paging-receipt-oracle.property.test.ts @@ -288,7 +288,7 @@ async function receiptHistory( // Accepted work is no longer cancelable; even the canceled caller owes // its visibility before settlement, while the shared peer must succeed. release() - await blocker.isPersisted.promise + await blocker.when('settled') await turns() if (errors.length) throw errors[0] expect(shared.outcome).toBe(`success`) @@ -313,7 +313,7 @@ async function receiptHistory( `visible facts hid a held establishing receipt`, ).toBeUndefined() release() - await secondBlocker.isPersisted.promise + await secondBlocker.when('settled') await turns() if (errors.length) throw errors[0] expect(same.outcome).toBe(`success`) diff --git a/packages/convex-db-collection/tests/lazy-paging-revalidation-oracle.property.test.ts b/packages/convex-db-collection/tests/lazy-paging-revalidation-oracle.property.test.ts index c65eb059c2..055a0e6f0a 100644 --- a/packages/convex-db-collection/tests/lazy-paging-revalidation-oracle.property.test.ts +++ b/packages/convex-db-collection/tests/lazy-paging-revalidation-oracle.property.test.ts @@ -240,7 +240,7 @@ async function receive( `revalidation settled before accepted application`, ).toBe(0) release() - await transaction.isPersisted.promise + await transaction.when('settled') await turns() checkpoint() await Promise.allSettled([equal.result, newer.result, reuse.result]) diff --git a/packages/convex-db-collection/tests/lazy-paging-version-oracle.property.test.ts b/packages/convex-db-collection/tests/lazy-paging-version-oracle.property.test.ts index b60b846a0e..51312d762c 100644 --- a/packages/convex-db-collection/tests/lazy-paging-version-oracle.property.test.ts +++ b/packages/convex-db-collection/tests/lazy-paging-version-oracle.property.test.ts @@ -124,7 +124,7 @@ async function runVersions(h: History, fault: Fault = `none`) { transaction.mutate(() => f.source.insert({ id: `overlay`, rank: -1, text: `optimistic only` }), ) - persisted = transaction.isPersisted.promise + persisted = transaction.when('settled') const accepted = f.receipts.length change() await f.remote.deliver(h.choices[0]) diff --git a/packages/convex-db-collection/tests/ownership-oracle.property.test.ts b/packages/convex-db-collection/tests/ownership-oracle.property.test.ts index 43ed4cd160..80eadc03cd 100644 --- a/packages/convex-db-collection/tests/ownership-oracle.property.test.ts +++ b/packages/convex-db-collection/tests/ownership-oracle.property.test.ts @@ -93,7 +93,7 @@ it(`keeps concrete options ownership across generated lifecycle checkpoints`, as const transaction = second.update(`a`, (row) => { row.text = `optimistic` }) - await transaction.isPersisted.promise + await transaction.when('settled') expect(second.get(`a`)?.text).toBe(text) reach.reach(`independent-confirmation`) } @@ -105,7 +105,7 @@ it(`keeps concrete options ownership across generated lifecycle checkpoints`, as const transaction = first.update(`a`, (row) => { row.text = `optimistic` }) - await transaction.isPersisted.promise + await transaction.when('settled') expect(first.get(`a`)?.text).toBe(`owner:${text}`) reach.reach(`${form}:${checkpoint}`) reach.reach( diff --git a/packages/convex-db-collection/tests/sdk.test.ts b/packages/convex-db-collection/tests/sdk.test.ts index d24b77a551..a4b7483438 100644 --- a/packages/convex-db-collection/tests/sdk.test.ts +++ b/packages/convex-db-collection/tests/sdk.test.ts @@ -210,7 +210,7 @@ describe.each([`javascript`, `react`] as const)( expect(transaction.state).toBe(`persisting`) expect(collection.get(`a`)?.text).toBe(`optimistic`) remote.snapshot([{ _id: `a`, text: `server` }]) - await transaction.isPersisted.promise + await transaction.when('settled') expect(collection.get(`a`)?.text).toBe(`server`) expect(seen).not.toContain(`old`) subscription.unsubscribe() @@ -242,7 +242,7 @@ describe.each([`javascript`, `react`] as const)( const nextRemote = server(replacement) nextRemote.acknowledge() nextRemote.snapshot([{ _id: `a`, text: `reconnected` }]) - await transaction.isPersisted.promise + await transaction.when('settled') expect(collection.get(`a`)?.text).toBe(`reconnected`) }, () => collection.cleanup(), @@ -268,7 +268,7 @@ describe.each([`javascript`, `react`] as const)( row.text = `optimistic` }) remote.acknowledge(false) - await expect(transaction.isPersisted.promise).rejects.toThrow( + await expect(transaction.when('settled')).rejects.toThrow( `permission denied`, ) expect(collection.get(`a`)?.text).toBe(`old`) diff --git a/packages/convex-db-collection/tests/values-oracle.property.test.ts b/packages/convex-db-collection/tests/values-oracle.property.test.ts index bac889a425..f8cebcdd02 100644 --- a/packages/convex-db-collection/tests/values-oracle.property.test.ts +++ b/packages/convex-db-collection/tests/values-oracle.property.test.ts @@ -174,7 +174,7 @@ it(`preserves the Convex mutation value domain across durable serialization`, as autoCommit: false, mutationFn: async () => {}, }) - const rollback = local.isPersisted.promise.catch(() => undefined) + const rollback = local.when('settled').catch(() => undefined) let hasPrimaryFailure = false try { local.mutate(() => { diff --git a/packages/db-sqlite-persistence-core/src/persisted.ts b/packages/db-sqlite-persistence-core/src/persisted.ts index d5f170b0f5..666fbf6a1a 100644 --- a/packages/db-sqlite-persistence-core/src/persisted.ts +++ b/packages/db-sqlite-persistence-core/src/persisted.ts @@ -7876,54 +7876,42 @@ export function persistedCollectionOptions< params: InsertMutationFnParams, ) => { const lifecycleGeneration = runtime.getLifecycleGeneration() - const handlerResult = localOnlyOptions.onInsert - ? await localOnlyOptions.onInsert( - params as unknown as InsertMutationFnParams, - ) - : undefined + await localOnlyOptions.onInsert?.( + params as unknown as InsertMutationFnParams, + ) await runtime.persistAndConfirmCollectionMutations( params.transaction.mutations as Array>, lifecycleGeneration, ) - - return handlerResult ?? {} } const wrappedOnUpdate = async ( params: UpdateMutationFnParams, ) => { const lifecycleGeneration = runtime.getLifecycleGeneration() - const handlerResult = localOnlyOptions.onUpdate - ? await localOnlyOptions.onUpdate( - params as unknown as UpdateMutationFnParams, - ) - : undefined + await localOnlyOptions.onUpdate?.( + params as unknown as UpdateMutationFnParams, + ) await runtime.persistAndConfirmCollectionMutations( params.transaction.mutations as Array>, lifecycleGeneration, ) - - return handlerResult ?? {} } const wrappedOnDelete = async ( params: DeleteMutationFnParams, ) => { const lifecycleGeneration = runtime.getLifecycleGeneration() - const handlerResult = localOnlyOptions.onDelete - ? await localOnlyOptions.onDelete( - params as unknown as DeleteMutationFnParams, - ) - : undefined + await localOnlyOptions.onDelete?.( + params as unknown as DeleteMutationFnParams, + ) await runtime.persistAndConfirmCollectionMutations( params.transaction.mutations as Array>, lifecycleGeneration, ) - - return handlerResult ?? {} } const mergedUtils = { diff --git a/packages/db-sqlite-persistence-core/tests/contracts/runtime-persistence-contract.ts b/packages/db-sqlite-persistence-core/tests/contracts/runtime-persistence-contract.ts index ae8af28237..3eedd2fb51 100644 --- a/packages/db-sqlite-persistence-core/tests/contracts/runtime-persistence-contract.ts +++ b/packages/db-sqlite-persistence-core/tests/contracts/runtime-persistence-contract.ts @@ -120,7 +120,7 @@ export function runRuntimePersistenceContractSuite( title: `Persisted from collection`, score: 42, }) - await insertTx.isPersisted.promise + await insertTx.when(`settled`) const readerAdapter = factory.createAdapter(readerDriver) const loadedRows = await readerAdapter.loadSubset( diff --git a/packages/db-sqlite-persistence-core/tests/persisted-cache-claims.test.ts b/packages/db-sqlite-persistence-core/tests/persisted-cache-claims.test.ts index f39d9443ed..8f91c566e7 100644 --- a/packages/db-sqlite-persistence-core/tests/persisted-cache-claims.test.ts +++ b/packages/db-sqlite-persistence-core/tests/persisted-cache-claims.test.ts @@ -2178,7 +2178,7 @@ describe(`persistedCollectionOptions`, () => { expect(collection.get(`local`)).toMatchObject({ id: `local` }) expect(collection.get(`old`)).toBeUndefined() release.resolve() - await mutation.isPersisted.promise + await mutation.when('settled') source.begin() source.write({ type: `insert`, @@ -2195,7 +2195,7 @@ describe(`persistedCollectionOptions`, () => { ).toEqual([`optimistic-expiry-new`]) } finally { release.resolve() - await mutation?.isPersisted.promise.catch(() => undefined) + await mutation?.when('settled').catch(() => undefined) await collection.cleanup() } }) @@ -2631,10 +2631,10 @@ describe(`persistedCollectionOptions`, () => { expect(recovery.read()).toEqual({ status: `fulfilled` }) expect(collection.get(`local`)).toBeDefined() release.resolve() - await mutation.isPersisted.promise + await mutation.when('settled') } finally { release.resolve() - await mutation?.isPersisted.promise.catch(() => undefined) + await mutation?.when('settled').catch(() => undefined) await collection.cleanup() } }) diff --git a/packages/db-sqlite-persistence-core/tests/persisted-demand-coverage-campaign-oracle.test.ts b/packages/db-sqlite-persistence-core/tests/persisted-demand-coverage-campaign-oracle.test.ts index 1a0e077d15..257f112e13 100644 --- a/packages/db-sqlite-persistence-core/tests/persisted-demand-coverage-campaign-oracle.test.ts +++ b/packages/db-sqlite-persistence-core/tests/persisted-demand-coverage-campaign-oracle.test.ts @@ -10,7 +10,9 @@ import { import { oraclePropertyOptions, oracleReplayRequested, + oracleReplaySkips, oracleRuns, + recordOracleReplay, } from '../../db/tests/oracle-config' import { declareOracle } from '../../db/tests/oracle-declaration' import { createOracleReach } from '../../db/tests/oracle-reach' @@ -135,7 +137,13 @@ import type { SQLiteDriver } from '../src/index.js' * other rows than it recorded, and a window whose tie-boundary load is * uncovered. Teardown drops every waiting load before it aborts them, so * only a release during the commands settles an aborted load. The fixed and random - * campaigns run the same property with 40 histories each. + * campaigns retain the same unconstrained property with 40 histories each. + * Each campaign also generates two packs of nine required histories. Row-key + * permutations, the unordered demand, and row ranks vary inside those legal + * structures. Their actual source logs must reach all declared states through + * the same model, driver, and comparison; random sampling cannot supply that + * guarantee. The pack has its own seed/path replay coordinate, so a failure + * inside a required history replays directly without the 40-history campaign. * * Parts 2 and 3 sweep a storage failure over every adapter call and a * release over every step of a load; their laws are stated beside them. @@ -1026,8 +1034,106 @@ const historyArb: fc.Arbitrary = fc.record({ .map((steps) => steps.flat()), }) +// The unrestricted grammar can erase a rare premise before the offline check: +// a later successful load covers an aborted demand, and a reset ends its log. +// These generated structures keep every required premise until that check. +// They contain only the commands above and carry no expected observations. +const requiredHistoriesArb = fc + .tuple( + fc.shuffledSubarray([...IDS], { minLength: 3, maxLength: 3 }), + groupArb, + rankArb, + ) + .map(([ids, group, rank]) => { + const [first, second, third] = ids as [Id, Id, Id] + const other: Group = group === `a` ? `b` : `a` + const write = (id: Id, inGroup = group, atRank = rank): Command => ({ + type: `write`, + id, + group: inGroup, + rank: atRank, + }) + const history = ( + name: string, + commands: ReadonlyArray, + { + abort = `rejects`, + waitMs = 0, + upstream = `never answers`, + }: { + abort?: AbortBehavior + waitMs?: 0 | 1000 + upstream?: OfflineUpstream + } = {}, + ): { name: string; history: History } => ({ + name, + history: { initial: {}, commands, abort, offline: { waitMs, upstream } }, + }) + const windowRows = [write(first, `a`, 2), write(second, `a`, 3)] + return [ + history( + `only an aborted load settles`, + [ + write(first), + { type: `start`, demand: group }, + { type: `release`, demand: group }, + ], + { abort: `stops and resolves` }, + ), + history( + `repeated coverage waits for its own deadline`, + [write(first), ...cycle(group), ...cycle(group)], + { waitMs: 1000 }, + ), + history( + `covered and uncovered loads meet upstream failure`, + [write(first), ...cycle(group)], + { upstream: `fails at once` }, + ), + history( + `a truncate ends coverage before an obsolete load settles`, + [ + write(first, other), + ...cycle(other), + { type: `start`, demand: group }, + { type: `truncate` }, + { type: `settle-oldest`, demand: group }, + { type: `release`, demand: group }, + ], + { abort: `ignores` }, + ), + history(`a snapshot ends coverage`, [ + write(first), + ...cycle(group), + { type: `snapshot` }, + ]), + history(`a covered demand loses a row to another demand`, [ + write(first), + ...cycle(group), + write(first, other), + ]), + history(`every load of a window is covered`, [ + ...windowRows, + ...cycle(`w`), + ]), + history(`a covered window holds another row`, [ + ...windowRows, + ...cycle(`w`), + write(third, `a`, 1), + ]), + history(`a window has no covered tie-boundary load`, [ + ...windowRows, + { type: `start`, demand: `w` }, + { type: `answer`, demand: `w` }, + { type: `release`, demand: `w` }, + ]), + ] + }) + const PROPERTY = `persisted-demand-coverage.history` +const REQUIRED_PROPERTY = `persisted-demand-coverage.required` const RUNS = 40 +const REQUIRED_RUNS = 2 const REACH_STATES: ReadonlyArray = [ `covered offline`, `uncovered offline`, @@ -1046,20 +1152,95 @@ const REACH_STATES: ReadonlyArray = [ `uncovered load fails with its upstream`, ] +async function checkHistory( + history: History, + ...reaches: ReadonlyArray>> +): Promise { + for (const reach of reaches) reach.beginHistory() + for (const record of await runHistory(history)) { + expect( + coverageViolations(record, (state) => { + for (const reach of reaches) reach.reach(state) + }), + ).toEqual([]) + } +} + +// fc.check exposes the actual random seed even when comparisons pass but the +// subsequent reach assertion fails. Preserve fast-check's native failure text +// and the guarded replay witness, rather than losing a shrink path or cause. +async function assertRun(property: string, result: fc.RunDetails) { + recordOracleReplay(property, result) + const message = await fc.asyncDefaultReportMessage(result) + if (message !== undefined) + throw new Error(message, { cause: result.errorInstance }) +} + async function campaign(options: Parameters[1]) { - const reach = createOracleReach(PROPERTY, REACH_STATES) - await fc.assert( - fc.asyncProperty(historyArb, async (history) => { - reach.beginHistory() - for (const record of await runHistory(history)) { - expect( - coverageViolations(record, (state) => reach.reach(state)), - ).toEqual([]) - } - }), - options, - ) - reach.assertReached() + // A seed alone replays the whole campaign and must still enforce reach. + // A path replays one input directly and does not owe campaign-wide reach. + const reach = createOracleReach(PROPERTY, REACH_STATES, { + replaying: oracleReplayRequested(), + }) + let seed: number | undefined + if (!oracleReplaySkips(PROPERTY)) { + const result = await fc.check( + fc.asyncProperty(historyArb, (history) => checkHistory(history, reach)), + options, + ) + seed = result.seed + await assertRun(PROPERTY, result) + } + if (!oracleReplayRequested() || !oracleReplaySkips(REQUIRED_PROPERTY)) { + const result = await fc.check( + fc.asyncProperty(requiredHistoriesArb, async (histories) => { + const requiredReach = createOracleReach( + REQUIRED_PROPERTY, + REACH_STATES, + { replaying: false }, + ) + for (const { name, history } of histories) { + try { + await checkHistory(history, requiredReach, reach) + } catch (error) { + throw new Error( + `required history: ${name}\n${JSON.stringify(history)}`, + { cause: error }, + ) + } + } + // Credit only real model/driver observations inside this generated pack. + // An unrelated random history cannot hide a missing required witness. + requiredReach.assertReached() + if (process.env.TANSTACK_DB_ORACLE_REACH_REPORT) { + console.log( + REQUIRED_PROPERTY, + JSON.stringify(Object.fromEntries(requiredReach.counts())), + ) + } + }), + oracleReplayRequested() + ? oraclePropertyOptions(REQUIRED_RUNS, REQUIRED_PROPERTY) + : { seed, numRuns: oracleRuns(REQUIRED_RUNS) }, + ) + seed = result.seed + await assertRun(REQUIRED_PROPERTY, result) + } + if (process.env.TANSTACK_DB_ORACLE_REACH_REPORT) { + console.log( + PROPERTY, + `seed=${seed}`, + JSON.stringify(Object.fromEntries(reach.counts())), + ) + } + try { + reach.assertReached() + } catch (error) { + throw new Error( + `${PROPERTY}: seed=${seed}, numRuns=${options?.numRuns}; replay the full campaign with this seed`, + { cause: error }, + ) + } return reach.counts() } @@ -1079,160 +1260,187 @@ function checkRelease( declareOracle({ check: [coverageViolations, checkFault, checkRelease], - grammar: [historyArb], + grammar: [historyArb, requiredHistoriesArb], }) describe(`persisted demand coverage over generated histories`, () => { - it(`keeps the law when any one storage call fails`, async () => { - quietConsole() - const baseline = await runWithFault(undefined) - // Positive witness: without a failure, session 2 settles D offline. - expect(baseline.session2).toEqual({ status: `ready`, rows: [`r1`, `r2`] }) - const reach = createOracleReach( - `persisted demand coverage storage failures`, - [ - `coverage read failed`, - `row write failed`, - `coverage check failed`, - `coverage write failed`, - ], - ) - for (const session of [1, 2] as const) { - const count = baseline.calls.filter((c) => c.session === session).length - for (let index = 0; index < count; index++) { - for (const timing of [`before effect`, `after effect`] as const) { - reach.beginHistory() - const run = await runWithFault({ session, index, timing }) - const failed = run.calls.filter((c) => c.session === session)[index] - if (!failed) continue - try { - checkFault(failed, timing, run, (state) => reach.reach(state)) - } catch (error) { - throw new Error( - `failure at session ${session} call ${index} ` + - `(${failed.method}, ${timing}, late: ${failed.late})`, - { cause: error }, - ) + it.skipIf(oracleReplayRequested())( + `keeps the law when any one storage call fails`, + async () => { + quietConsole() + const baseline = await runWithFault(undefined) + // Positive witness: without a failure, session 2 settles D offline. + expect(baseline.session2).toEqual({ status: `ready`, rows: [`r1`, `r2`] }) + const reach = createOracleReach( + `persisted demand coverage storage failures`, + [ + `coverage read failed`, + `row write failed`, + `coverage check failed`, + `coverage write failed`, + ], + ) + for (const session of [1, 2] as const) { + const count = baseline.calls.filter((c) => c.session === session).length + for (let index = 0; index < count; index++) { + for (const timing of [`before effect`, `after effect`] as const) { + reach.beginHistory() + const run = await runWithFault({ session, index, timing }) + const failed = run.calls.filter((c) => c.session === session)[index] + if (!failed) continue + try { + checkFault(failed, timing, run, (state) => reach.reach(state)) + } catch (error) { + throw new Error( + `failure at session ${session} call ${index} ` + + `(${failed.method}, ${timing}, late: ${failed.late})`, + { cause: error }, + ) + } } } } - } - reach.assertReached() - }, 600_000) + reach.assertReached() + }, + 600_000, + ) - it(`owns no wait after a release at any step of a load`, async () => { - quietConsole() - const driver = createDriver(new DatabaseSync(`:memory:`)) - await coverGroupA(driver) - const reach = createOracleReach( - `persisted demand coverage releases`, - [ - `released before the source load`, - `released while the source load waited`, - `released during a coverage read`, - ], - ) - for (let steps = 0; steps <= 24; steps++) { - reach.beginHistory() - checkRelease( - steps, - await runReleaseAt(driver, steps, (state) => reach.reach(state)), + it.skipIf(oracleReplayRequested())( + `owns no wait after a release at any step of a load`, + async () => { + quietConsole() + const driver = createDriver(new DatabaseSync(`:memory:`)) + await coverGroupA(driver) + const reach = createOracleReach( + `persisted demand coverage releases`, + [ + `released before the source load`, + `released while the source load waited`, + `released during a coverage read`, + ], ) - } - reach.assertReached() - }, 300_000) + for (let steps = 0; steps <= 24; steps++) { + reach.beginHistory() + checkRelease( + steps, + await runReleaseAt(driver, steps, (state) => reach.reach(state)), + ) + } + reach.assertReached() + }, + 300_000, + ) - it(`rejects observations that ignore lost rows, obsolete loads, or other window rows`, () => { - // Checker calibration (ORC-006): the shared judgment must reject a wrong - // observation of each kind. Each record below states what production - // would wrongly show; the comparison must report it. - const loadA: LoadSubsetOptions = { - where: new IR.Func(`eq`, [new IR.PropRef([`group`]), new IR.Value(`a`)]), - } - const loadW = { - ...loadA, - orderBy: [ - { - expression: new IR.PropRef([`rank`]), - compareOptions: { direction: `asc`, nulls: `first` }, + it.skipIf(oracleReplayRequested())( + `rejects observations that ignore lost rows, obsolete loads, or other window rows`, + () => { + // Checker calibration (ORC-006): the shared judgment must reject a wrong + // observation of each kind. Each record below states what production + // would wrongly show; the comparison must report it. + const loadA: LoadSubsetOptions = { + where: new IR.Func(`eq`, [ + new IR.PropRef([`group`]), + new IR.Value(`a`), + ]), + } + const loadW = { + ...loadA, + orderBy: [ + { + expression: new IR.PropRef([`rank`]), + compareOptions: { direction: `asc`, nulls: `first` }, + }, + ], + limit: WINDOW_LIMIT, + } as unknown as LoadSubsetOptions + const rows: SourceEvent = { + type: `rows`, + rows: [ + [`r1`, { group: `a`, rank: 1 }], + [`r2`, { group: `a`, rank: 2 }], + ], + } + const covered: Array = [ + rows, + { type: `settled`, load: loadA, current: true }, + { type: `settled`, load: loadW, current: true }, + ] + const ready = (ids: Array): QueryObservation => ({ + status: `ready`, + rows: ids, + }) + const record = ( + events: ReadonlyArray, + a: QueryObservation, + w: QueryObservation, + ): CoverageRecord => ({ + events, + // The offline session's own loads of the same demands. + queries: { + a: { spec: SPECS.a, sent: [{ ...loadA }], observed: a }, + w: { spec: SPECS.w, sent: [{ ...loadW }], observed: w }, }, - ], - limit: WINDOW_LIMIT, - } as unknown as LoadSubsetOptions - const rows: SourceEvent = { - type: `rows`, - rows: [ - [`r1`, { group: `a`, rank: 1 }], - [`r2`, { group: `a`, rank: 2 }], - ], - } - const covered: Array = [ - rows, - { type: `settled`, load: loadA, current: true }, - { type: `settled`, load: loadW, current: true }, - ] - const ready = (ids: Array): QueryObservation => ({ - status: `ready`, - rows: ids, - }) - const record = ( - events: ReadonlyArray, - a: QueryObservation, - w: QueryObservation, - ): CoverageRecord => ({ - events, - // The offline session's own loads of the same demands. - queries: { - a: { spec: SPECS.a, sent: [{ ...loadA }], observed: a }, - w: { spec: SPECS.w, sent: [{ ...loadW }], observed: w }, - }, - upstream: `never answers`, - waitPassed: true, - }) - // The right observations pass. - expect( - coverageViolations( - record(covered, ready([`r1`, `r2`]), ready([`r1`, `r2`])), - ), - ).toEqual([]) - // A demand that lost a row, settled anyway. - expect( - coverageViolations( - record([...covered, { type: `delete`, id: `r1` }], ready([`r2`]), { - status: `loading`, - }), - ), - ).toHaveLength(1) - // An obsolete load treated as coverage. - expect( - coverageViolations( - record( - [rows, { type: `settled`, load: loadA, current: false }], - ready([`r1`, `r2`]), - { status: `loading` }, + upstream: `never answers`, + waitPassed: true, + }) + // The right observations pass. + expect( + coverageViolations( + record(covered, ready([`r1`, `r2`]), ready([`r1`, `r2`])), ), - ), - ).toHaveLength(1) - // A window that holds another row, settled anyway. - expect( - coverageViolations( - record( - [ - ...covered, - { type: `rows`, rows: [[`r3`, { group: `a`, rank: 0 }]] }, - ], - ready([`r1`, `r2`, `r3`]), - ready([`r3`, `r1`]), + ).toEqual([]) + // A demand that lost a row, settled anyway. + expect( + coverageViolations( + record([...covered, { type: `delete`, id: `r1` }], ready([`r2`]), { + status: `loading`, + }), ), - ), - ).toHaveLength(1) - // A window shown out of order. - expect( - coverageViolations( - record(covered, ready([`r1`, `r2`]), ready([`r2`, `r1`])), - ), - ).toHaveLength(1) - }) + ).toHaveLength(1) + // An obsolete load treated as coverage. + expect( + coverageViolations( + record( + [rows, { type: `settled`, load: loadA, current: false }], + ready([`r1`, `r2`]), + { status: `loading` }, + ), + ), + ).toHaveLength(1) + // A released load that the source resolves must not certify its demand. + expect( + coverageViolations( + record( + [ + rows, + { type: `settled`, load: loadA, current: true, aborted: true }, + ], + ready([`r1`, `r2`]), + { status: `loading` }, + ), + ), + ).toHaveLength(1) + // A window that holds another row, settled anyway. + expect( + coverageViolations( + record( + [ + ...covered, + { type: `rows`, rows: [[`r3`, { group: `a`, rank: 0 }]] }, + ], + ready([`r1`, `r2`, `r3`]), + ready([`r3`, `r1`]), + ), + ), + ).toHaveLength(1) + // A window shown out of order. + expect( + coverageViolations( + record(covered, ready([`r1`, `r2`]), ready([`r2`, `r1`])), + ), + ).toHaveLength(1) + }, + ) it.skipIf(oracleReplayRequested())( `refines the model over a fixed campaign`, @@ -1240,11 +1448,15 @@ describe(`persisted demand coverage over generated histories`, () => { quietConsole() await campaign({ seed: 2102, numRuns: oracleRuns(RUNS) }) }, - 120_000, + 180_000, ) - it(`refines the model over a random or replayed campaign`, async () => { - quietConsole() - await campaign(oraclePropertyOptions(RUNS, PROPERTY)) - }, 120_000) + it.skipIf(oracleReplaySkips(PROPERTY, REQUIRED_PROPERTY))( + `refines the model over a random or replayed campaign`, + async () => { + quietConsole() + await campaign(oraclePropertyOptions(RUNS, PROPERTY)) + }, + 180_000, + ) }) diff --git a/packages/db-sqlite-persistence-core/tests/persisted-demand-coverage-oracle.test.ts b/packages/db-sqlite-persistence-core/tests/persisted-demand-coverage-oracle.test.ts index 39ef83f68d..9c97efc803 100644 --- a/packages/db-sqlite-persistence-core/tests/persisted-demand-coverage-oracle.test.ts +++ b/packages/db-sqlite-persistence-core/tests/persisted-demand-coverage-oracle.test.ts @@ -2008,7 +2008,7 @@ async function runJoined( const { inner, query } = shapedQuery(shape, parents, children.collection) const subscription = query.subscribeChanges(() => {}) - // Virtual row properties (`$key`, `$synced`, ...) are not part of the law. + // Virtual row properties (`$key`, `$hasPendingWrites`, ...) are not part of the law. const visibleRows = () => (query.toArray as Array).map((row) => Object.fromEntries( diff --git a/packages/db-sqlite-persistence-core/tests/persisted-demand-coverage-supplemental.test.ts b/packages/db-sqlite-persistence-core/tests/persisted-demand-coverage-supplemental.test.ts index de1c94ebd4..7035909a08 100644 --- a/packages/db-sqlite-persistence-core/tests/persisted-demand-coverage-supplemental.test.ts +++ b/packages/db-sqlite-persistence-core/tests/persisted-demand-coverage-supplemental.test.ts @@ -93,7 +93,7 @@ async function runOptimisticWrite(): Promise { rank: 9, value: `local`, }) - void transaction.isPersisted.promise.catch(() => undefined) + void transaction.when('settled').catch(() => undefined) await drain() readyThroughout &&= query.status === `ready` const duringWrite = ids() diff --git a/packages/db-sqlite-persistence-core/tests/persisted-follower-indexes.test.ts b/packages/db-sqlite-persistence-core/tests/persisted-follower-indexes.test.ts index 911b0b9071..4b3f4d100e 100644 --- a/packages/db-sqlite-persistence-core/tests/persisted-follower-indexes.test.ts +++ b/packages/db-sqlite-persistence-core/tests/persisted-follower-indexes.test.ts @@ -1083,7 +1083,7 @@ describe(`persistedCollectionOptions`, () => { title: `Ack mismatch`, }) - await expect(tx.isPersisted.promise).rejects.toThrow( + await expect(tx.when('settled')).rejects.toThrow( /partial acceptance is not supported/, ) expect(collection.get(`ack-mismatch`)).toBeUndefined() @@ -1161,7 +1161,7 @@ describe(`persistedCollectionOptions`, () => { id: `local-failure`, title: `Must not persist`, }) - const rejection = await tx.isPersisted.promise.then( + const rejection = await tx.when('settled').then( () => undefined, (error: unknown) => error, ) @@ -1286,7 +1286,7 @@ describe(`persistedCollectionOptions`, () => { id: `primary-local`, title: `must install terminal state`, }) - const primaryOutcome = await primary.isPersisted.promise.then( + const primaryOutcome = await primary.when('settled').then( () => ({ status: `fulfilled` as const }), (reason: unknown) => ({ status: `rejected` as const, reason }), ) @@ -1351,7 +1351,7 @@ describe(`persistedCollectionOptions`, () => { id: `old-local`, title: `durable only in the replaced lifecycle`, }) - const settlement = transaction.isPersisted.promise.then( + const settlement = transaction.when('settled').then( () => ({ status: `fulfilled` as const }), (reason: unknown) => ({ status: `rejected` as const, reason }), ) diff --git a/packages/db-sqlite-persistence-core/tests/persisted-hydration-queue.test.ts b/packages/db-sqlite-persistence-core/tests/persisted-hydration-queue.test.ts index f50df4d92c..8da6eedb30 100644 --- a/packages/db-sqlite-persistence-core/tests/persisted-hydration-queue.test.ts +++ b/packages/db-sqlite-persistence-core/tests/persisted-hydration-queue.test.ts @@ -1070,7 +1070,7 @@ describe(`persistedCollectionOptions`, () => { } finally { hydration.resolve() localPersistence.reject(appliedError) - await localTransaction.isPersisted.promise.catch(() => undefined) + await localTransaction.when('settled').catch(() => undefined) await cleanupPersistedOracle( [() => collection.cleanup()], hasPrimaryFailure, @@ -1171,7 +1171,7 @@ describe(`persistedCollectionOptions`, () => { } outbound.reject(refused) - await expect(transaction.isPersisted.promise).rejects.toBe(refused) + await expect(transaction.when('settled')).rejects.toBe(refused) expect(transaction.state).toBe(`failed`) checkSource(`after refused mutation settled`) if (firstReceipt !== undefined) { @@ -1204,7 +1204,7 @@ describe(`persistedCollectionOptions`, () => { if (transaction) outbound.reject(refused) await cleanupPersistedOracle( [ - () => transaction?.isPersisted.promise.catch(() => undefined), + () => transaction?.when('settled').catch(() => undefined), () => firstReceipt === undefined ? undefined @@ -1281,7 +1281,7 @@ describe(`persistedCollectionOptions`, () => { releaseHandler.resolve() await atPersistedOracleCheckpoint( - transaction.isPersisted.promise.then(() => undefined), + transaction.when('settled').then(() => undefined), `successful persisted origin mutation settled`, ) await Promise.all(receipts.map((receipt) => Promise.resolve(receipt))) @@ -1299,7 +1299,7 @@ describe(`persistedCollectionOptions`, () => { releaseHandler.resolve() await cleanupPersistedOracle( [ - () => transaction?.isPersisted.promise.catch(() => undefined), + () => transaction?.when('settled').catch(() => undefined), ...receipts.map( (receipt) => () => Promise.resolve(receipt).catch(() => undefined), @@ -1390,7 +1390,7 @@ describe(`persistedCollectionOptions`, () => { localPersistence.resolve() await atPersistedOracleCheckpoint( - localTransaction.isPersisted.promise, + localTransaction.when('settled'), `local settlement publishes the source commit`, ) expect(stripVirtualProps(collection.get(`remote`))).toEqual({ @@ -1489,7 +1489,7 @@ describe(`persistedCollectionOptions`, () => { `handler dependency submitted`, ) await atPersistedOracleCheckpoint( - localTransaction.isPersisted.promise, + localTransaction.when('settled'), `user persistence completes after its awaited source commit`, ) await atPersistedOracleCheckpoint( @@ -1533,7 +1533,7 @@ describe(`persistedCollectionOptions`, () => { normalAbort.abort() await cleanupPersistedOracle( [ - () => localTransaction.isPersisted.promise.catch(() => undefined), + () => localTransaction.when('settled').catch(() => undefined), () => normalReceipt?.catch(() => undefined), () => dependencyReceipt === true @@ -1929,7 +1929,7 @@ describe(`persistedCollectionOptions`, () => { local.mutate(() => { collection.insert({ id: `local`, title: `optimistic local` }) }) - localReceipt = local.isPersisted.promise + localReceipt = local.when('settled') void localReceipt.catch(() => undefined) await mutationEntered.promise @@ -2035,7 +2035,7 @@ describe(`persistedCollectionOptions`, () => { local.mutate(() => { collection.insert({ id: `local`, title: `optimistic local` }) }) - localReceipt = local.isPersisted.promise + localReceipt = local.when('settled') void localReceipt.catch(() => undefined) await mutationEntered.promise diff --git a/packages/db-sqlite-persistence-core/tests/persisted-options-type-oracle.ts b/packages/db-sqlite-persistence-core/tests/persisted-options-type-oracle.ts index 61807f018e..2608a7fe9c 100644 --- a/packages/db-sqlite-persistence-core/tests/persisted-options-type-oracle.ts +++ b/packages/db-sqlite-persistence-core/tests/persisted-options-type-oracle.ts @@ -241,7 +241,6 @@ function comparison( type Read = NonNullable; type Published = Output & { readonly $hasPendingWrites: boolean; - readonly $synced: boolean; readonly $origin: "local" | "remote"; readonly $key: Key; readonly $collectionId: string; diff --git a/packages/db-sqlite-persistence-core/tests/persisted-oracle.test.ts b/packages/db-sqlite-persistence-core/tests/persisted-oracle.test.ts index 92cd8050fb..04a1ad80d7 100644 --- a/packages/db-sqlite-persistence-core/tests/persisted-oracle.test.ts +++ b/packages/db-sqlite-persistence-core/tests/persisted-oracle.test.ts @@ -4940,7 +4940,7 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { ) const mutation = collection.insert({ id: `local`, title: `Pending` }) - mutationPersisted = mutation.isPersisted.promise + mutationPersisted = mutation.when(`settled`) void mutationPersisted.catch(() => undefined) const bufferedCommit = await atPersistedOracleCheckpoint( bufferedCommitReturned.promise, diff --git a/packages/db-sqlite-persistence-core/tests/persisted-real-adapter-lifecycle.test.ts b/packages/db-sqlite-persistence-core/tests/persisted-real-adapter-lifecycle.test.ts index 70b8f1c98d..c56f836108 100644 --- a/packages/db-sqlite-persistence-core/tests/persisted-real-adapter-lifecycle.test.ts +++ b/packages/db-sqlite-persistence-core/tests/persisted-real-adapter-lifecycle.test.ts @@ -385,7 +385,7 @@ it.each([ ).toEqual([]) rejectOutbound(refused) - await expect(transaction.isPersisted.promise).rejects.toBe(refused) + await expect(transaction.when(`settled`)).rejects.toBe(refused) await Promise.resolve(heldDelete) expect(transaction.state).toBe(`failed`) expect(collection.has(`local`)).toBe(false) @@ -490,7 +490,7 @@ it.each([ if (transaction) rejectOutbound(refused) await cleanupTestActions( [ - () => transaction?.isPersisted.promise.catch(() => undefined), + () => transaction?.when(`settled`).catch(() => undefined), () => heldDelete === undefined ? undefined diff --git a/packages/db-sqlite-persistence-core/tests/persisted-reset-startup.test.ts b/packages/db-sqlite-persistence-core/tests/persisted-reset-startup.test.ts index 41d85a2966..e30c0fb77d 100644 --- a/packages/db-sqlite-persistence-core/tests/persisted-reset-startup.test.ts +++ b/packages/db-sqlite-persistence-core/tests/persisted-reset-startup.test.ts @@ -1205,13 +1205,13 @@ describe(`persistedCollectionOptions`, () => { // so the load settles once that mutation does. It must not also wait // for the unrelated receipt. releaseMutation.resolve() - await mutation.isPersisted.promise + await mutation.when('settled') await expect(load).resolves.toBeUndefined() await unrelated.receipt.catch(() => undefined) } finally { unrelated?.abortController.abort() releaseMutation.resolve() - await mutation?.isPersisted.promise.catch(() => undefined) + await mutation?.when('settled').catch(() => undefined) await load?.catch(() => undefined) await collection.cleanup() } diff --git a/packages/db-sqlite-persistence-core/tests/persisted-source-receipts.test.ts b/packages/db-sqlite-persistence-core/tests/persisted-source-receipts.test.ts index 531ab20bbd..9a4e7a6435 100644 --- a/packages/db-sqlite-persistence-core/tests/persisted-source-receipts.test.ts +++ b/packages/db-sqlite-persistence-core/tests/persisted-source-receipts.test.ts @@ -919,7 +919,7 @@ describe(`persistedCollectionOptions`, () => { releaseReplacementHydration.resolve() await cleanupPersistedOracle( [ - () => localTransaction?.isPersisted.promise.catch(() => undefined), + () => localTransaction?.when('settled').catch(() => undefined), () => replacementReady, () => (cleanedUp ? undefined : collection.cleanup()), ], @@ -957,7 +957,6 @@ describe(`persistedCollectionOptions`, () => { onInsert: async () => { hookEntered.resolve() await releaseHook.promise - return {} }, persistence: { adapter, coordinator }, }), @@ -985,7 +984,7 @@ describe(`persistedCollectionOptions`, () => { id: `old-hook`, title: `must not enter replacement coordinator`, }) - transactionOutcome = transaction.isPersisted.promise.then( + transactionOutcome = transaction.when('settled').then( () => ({ status: `fulfilled` as const }), (reason: unknown) => ({ status: `rejected` as const, reason }), ) @@ -1942,7 +1941,7 @@ describe(`persistedCollectionOptions`, () => { id: `local`, title: `durable before confirmation settles`, }) - const transactionOutcome = transaction.isPersisted.promise.then( + const transactionOutcome = transaction.when('settled').then( () => ({ status: `fulfilled` as const }), (reason: unknown) => ({ status: `rejected` as const, reason }), ) @@ -2768,7 +2767,7 @@ describe(`persistedCollectionOptions`, () => { title: `must roll back`, }) const firstOutcome = await atPersistedOracleCheckpoint( - firstTransaction.isPersisted.promise.then( + firstTransaction.when('settled').then( () => ({ status: `fulfilled` as const }), (reason: unknown) => ({ status: `rejected` as const, reason }), ), @@ -2806,7 +2805,7 @@ describe(`persistedCollectionOptions`, () => { id: `late-local`, title: `must not be admitted`, }) - await lateTransaction.isPersisted.promise + await lateTransaction.when('settled') }) .then( () => ({ status: `fulfilled` as const }), diff --git a/packages/db-sqlite-persistence-core/tests/persisted-startup.test.ts b/packages/db-sqlite-persistence-core/tests/persisted-startup.test.ts index 3a12db7129..8d108855ed 100644 --- a/packages/db-sqlite-persistence-core/tests/persisted-startup.test.ts +++ b/packages/db-sqlite-persistence-core/tests/persisted-startup.test.ts @@ -54,7 +54,7 @@ describe(`persistedCollectionOptions`, () => { unexpectedThird = createCollection(options) }).toThrow(InvalidPersistedCollectionConfigError) const receipt = collection.insert({ id: `owned`, title: `Retried owner` }) - await receipt.isPersisted.promise + await receipt.when('settled') expect(stripVirtualProps(collection.get(`owned`))).toEqual({ id: `owned`, title: `Retried owner`, @@ -156,7 +156,7 @@ describe(`persistedCollectionOptions`, () => { source.write({ type: `insert`, value: row }) await source.commit() } else { - await collection.insert(row).isPersisted.promise + await collection.insert(row).when('settled') } expect(stripVirtualProps(collection.get(`owned`))).toEqual({ id: `owned`, @@ -212,7 +212,7 @@ describe(`persistedCollectionOptions`, () => { }).toThrow(InvalidPersistedCollectionConfigError) const receipt = first.insert({ id: `owned`, title: `First owner` }) - await receipt.isPersisted.promise + await receipt.when('settled') expect(stripVirtualProps(first.get(`owned`))).toEqual({ id: `owned`, title: `First owner`, @@ -347,8 +347,9 @@ describe(`persistedCollectionOptions`, () => { if (mode === `sync-present`) { await Promise.resolve(source.commit()) } else { - await first.insert({ id: `owned`, title: `First owner` }).isPersisted - .promise + await first + .insert({ id: `owned`, title: `First owner` }) + .when('settled') } expect(first.has(`owned`)).toBe(true) expect(second.has(`owned`)).toBe(false) @@ -509,8 +510,9 @@ describe(`persistedCollectionOptions`, () => { }) await Promise.resolve(source.commit()) } else { - await first.insert({ id: `after-restart`, title: `First owner` }) - .isPersisted.promise + await first + .insert({ id: `after-restart`, title: `First owner` }) + .when('settled') } expect(first.has(`after-restart`)).toBe(true) expect(second.has(`after-restart`)).toBe(false) @@ -608,7 +610,7 @@ describe(`persistedCollectionOptions`, () => { title: `Phase 0`, }) - await insertTx.isPersisted.promise + await insertTx.when('settled') expect(stripVirtualProps(collection.get(`1`))).toEqual({ id: `1`, @@ -664,7 +666,7 @@ describe(`persistedCollectionOptions`, () => { title: `Managed during startup`, }) releaseInitialSnapshot() - await insert.isPersisted.promise + await insert.when('settled') await collection.stateWhenReady() await vi.waitFor(() => expect(snapshotCalls).toBeGreaterThanOrEqual(2)) diff --git a/packages/db-sqlite-persistence-core/tests/persisted-support.ts b/packages/db-sqlite-persistence-core/tests/persisted-support.ts index 9113feb50f..72a4afd7db 100644 --- a/packages/db-sqlite-persistence-core/tests/persisted-support.ts +++ b/packages/db-sqlite-persistence-core/tests/persisted-support.ts @@ -437,7 +437,6 @@ export const stripVirtualProps = | undefined>( if (!value || typeof value !== `object`) return value const { $hasPendingWrites: _hasPendingWrites, - $synced: _synced, $origin: _origin, $key: _key, $collectionId: _collectionId, @@ -809,7 +808,7 @@ export async function runRejectedHydrationBufferWitness( const freshTransaction = reopened.insert(freshRow) await atPersistedOracleCheckpoint( (async () => { - await freshTransaction.isPersisted.promise + await freshTransaction.when('settled') })(), `${id} fresh transaction persisted`, ) @@ -1244,7 +1243,7 @@ export const assertGenerationNoticeHistory = async ({ expect(publishedFreshTitles).toEqual([]) releaseMutation.resolve() await atPersistedOracleCheckpoint( - mutation.isPersisted.promise, + mutation.when('settled'), `optimistic publication released`, ) await atPersistedOracleCheckpoint( @@ -1294,7 +1293,7 @@ export const assertGenerationNoticeHistory = async ({ await cleanupPersistedOracle( [ () => recovery, - () => mutation?.isPersisted.promise.catch(() => undefined), + () => mutation?.when('settled').catch(() => undefined), () => demand?.catch(() => undefined), () => collection.cleanup(), ], diff --git a/packages/db-sqlite-persistence-core/tests/persisted.test.ts b/packages/db-sqlite-persistence-core/tests/persisted.test.ts index f742fe44f7..be77ca368d 100644 --- a/packages/db-sqlite-persistence-core/tests/persisted.test.ts +++ b/packages/db-sqlite-persistence-core/tests/persisted.test.ts @@ -182,7 +182,7 @@ describe(`persistedCollectionOptions`, () => { expect(receipt).toBeInstanceOf(Promise) releaseMutation() - await transaction.isPersisted.promise + await transaction.when('settled') await receipt subscription.unsubscribe() @@ -195,7 +195,7 @@ describe(`persistedCollectionOptions`, () => { expect(adapter.applyCommittedTxCalls).toHaveLength(1) } finally { releaseMutation() - await transaction.isPersisted.promise.catch(() => undefined) + await transaction.when('settled').catch(() => undefined) await collection.cleanup() } }) @@ -268,7 +268,7 @@ describe(`persistedCollectionOptions`, () => { aborted.abort() expect(collection.get(`dependent`)).toBeUndefined() localPersistence.resolve() - await localTransaction.isPersisted.promise + await localTransaction.when('settled') const abortedOutcome = await atPersistedOracleCheckpoint( abortedReceipt.then( () => ({ status: `fulfilled` as const }), @@ -305,7 +305,7 @@ describe(`persistedCollectionOptions`, () => { localPersistence.resolve() await cleanupPersistedOracle( [ - () => localTransaction.isPersisted.promise.catch(() => undefined), + () => localTransaction.when('settled').catch(() => undefined), () => abortedReceipt?.catch(() => undefined), () => dependentReceipt?.catch(() => undefined), () => collection.cleanup(), diff --git a/packages/db-sqlite-persistence-core/tests/reload-class-oracle.ts b/packages/db-sqlite-persistence-core/tests/reload-class-oracle.ts index fb7c7d9bcb..776779caf8 100644 --- a/packages/db-sqlite-persistence-core/tests/reload-class-oracle.ts +++ b/packages/db-sqlite-persistence-core/tests/reload-class-oracle.ts @@ -550,8 +550,9 @@ export async function observeReload( await Promise.all(filled.map((collection) => collection.preload())) for (const [index, collection] of filled.entries()) { if (history.collections[index]!.kind !== `eager`) continue - await collection.insert({ id: `row`, title: collection.id }).isPersisted - .promise + await collection + .insert({ id: `row`, title: collection.id }) + .when(`settled`) } await closeTab(filler, filled) const generations = await host.rawQuery( @@ -669,8 +670,9 @@ export async function observeReload( if ((history.need === `local-write` || writeBeforeGrant) && hold) { await waitFor(hold.isHeld, `held startup read`) await preloads[0] - const write = leaderCollections[0]!.insert({ id: `local`, title: `l` }) - .isPersisted.promise + const write = leaderCollections[0]! + .insert({ id: `local`, title: `l` }) + .when(`settled`) writeIssued = true // The write asks for its route within a few tasks; then it is granted. for (let task = 0; task < 5; task++) await nextTask() @@ -692,8 +694,9 @@ export async function observeReload( () => leader.isLeader(queuedWrite.writer), `writer leadership`, ) - const write = leaderCollections[0]!.insert({ id: `local`, title: `l` }) - .isPersisted.promise + const write = leaderCollections[0]! + .insert({ id: `local`, title: `l` }) + .when(`settled`) await waitFor(queuedWrite.hold.isHeld, `held queued write`) openGate() await waitFor( @@ -790,8 +793,10 @@ export async function observeReload( ) .at(-1)! if (history.need === `follower-write`) { - await followerCollections[lastEager]!.insert({ id: `f`, title: `f` }) - .isPersisted.promise + await followerCollections[lastEager]!.insert({ + id: `f`, + title: `f`, + }).when(`settled`) needSettled = true } if (history.need === `bystander-write`) { @@ -807,8 +812,10 @@ export async function observeReload( await Promise.all( bystanderCollections.map((collection) => collection.preload()), ) - await bystanderCollections[lastEager]!.insert({ id: `b`, title: `b` }) - .isPersisted.promise + await bystanderCollections[lastEager]!.insert({ + id: `b`, + title: `b`, + }).when(`settled`) await closeTab(bystander, bystanderCollections) needSettled = true } diff --git a/packages/db-sqlite-persistence-core/tests/sqlite-resume-snapshot-oracle.test.ts b/packages/db-sqlite-persistence-core/tests/sqlite-resume-snapshot-oracle.test.ts index 70b2c4dfc8..56884b1d2d 100644 --- a/packages/db-sqlite-persistence-core/tests/sqlite-resume-snapshot-oracle.test.ts +++ b/packages/db-sqlite-persistence-core/tests/sqlite-resume-snapshot-oracle.test.ts @@ -708,7 +708,7 @@ describe(`SQLite resume snapshots`, () => { let managedPersistence: Promise | undefined let managedPersistenceSettled = false if (history.transition === `managed-insert`) { - managedPersistence = collection.insert(managedRow).isPersisted.promise + managedPersistence = collection.insert(managedRow).when(`settled`) void managedPersistence.then( () => { managedPersistenceSettled = true diff --git a/packages/db-sqlite-persistence-core/tests/sync-metadata-session-persisted.test.ts b/packages/db-sqlite-persistence-core/tests/sync-metadata-session-persisted.test.ts index 169016d3c5..7e50570609 100644 --- a/packages/db-sqlite-persistence-core/tests/sync-metadata-session-persisted.test.ts +++ b/packages/db-sqlite-persistence-core/tests/sync-metadata-session-persisted.test.ts @@ -279,7 +279,7 @@ async function createPersistedDriver( let holdCount = 0 // Signals of the outermost transactions committed during a read hold. let readCommits: Array = [] - let blocker: { isPersisted: { promise: Promise } } | undefined + let blocker: { when: (state: 'settled') => Promise } | undefined const receipts: Array> = [] const rejections: Array = [] const receiptStates: Array = [] @@ -326,7 +326,7 @@ async function createPersistedDriver( blocker = undefined resolveMutation?.() resolveMutation = undefined - await pending?.isPersisted.promise + await pending?.when('settled') } const asMetadata = (value: unknown) => value === undefined ? {} : { metadata: value as Record } diff --git a/packages/db/e2e/indexed-db/browser.ts b/packages/db/e2e/indexed-db/browser.ts index fea6df599f..7da38ed7e0 100644 --- a/packages/db/e2e/indexed-db/browser.ts +++ b/packages/db/e2e/indexed-db/browser.ts @@ -297,7 +297,7 @@ async function setup() { else draft.optional = row.optional }) : collection.insert(structuredClone(row)) - await transaction.isPersisted.promise + await transaction.when(`settled`) }, // Low-level writers own invalidation: wrapper writes do not broadcast for // them. This native provider fixture writes without version metadata and @@ -327,7 +327,7 @@ async function setup() { } }), remove: async (key: string | number) => { - await collection.delete(key).isPersisted.promise + await collection.delete(key).when(`settled`) }, omit: (row: OracleRow) => { // Manual acceptance admits both native transactions before the first @@ -357,13 +357,14 @@ async function setup() { : kind === 'import' ? collection.utils.importData([{ id: 2, name: 'replacement' }]) : kind === 'insert' - ? collection.insert({ id: 2, name: 'inserted' }).isPersisted - .promise + ? collection.insert({ id: 2, name: 'inserted' }).when(`settled`) : kind === 'delete' - ? collection.delete(1).isPersisted.promise - : collection.update(1, (row) => { - row.name = 'updated' - }).isPersisted.promise + ? collection.delete(1).when(`settled`) + : collection + .update(1, (row) => { + row.name = 'updated' + }) + .when(`settled`) retirementDone = operation.then( () => { retirement.outcome = 'fulfilled' @@ -497,7 +498,7 @@ async function setup() { id: 'held', name: 'unaccepted local intent', }) - void transaction.isPersisted.promise.then( + void transaction.when(`settled`).then( () => { handlerStatus = 'fulfilled' }, diff --git a/packages/db/e2e/indexed-db/coverage-browser.ts b/packages/db/e2e/indexed-db/coverage-browser.ts index 5387fb5580..8e12501186 100644 --- a/packages/db/e2e/indexed-db/coverage-browser.ts +++ b/packages/db/e2e/indexed-db/coverage-browser.ts @@ -81,10 +81,12 @@ async function valuePeer(name: string) { const pending = entry === 'import' ? c.utils.importData([row]) - : c.update(1, (draft) => { - draft.name = row.name - draft.nested = row.nested - }).isPersisted.promise + : c + .update(1, (draft) => { + draft.name = row.name + draft.nested = row.nested + }) + .when(`settled`) // Attach rejection immediately so failure cannot become an unhandled event // while the oracle records the deliberately held native boundary. const done = pending.then( @@ -139,7 +141,7 @@ async function valuePeer(name: string) { }) const immediate = c.get(1)! bytes.fill(99) - await tx.isPersisted.promise + await tx.when(`settled`) function observe(row: ValueRow) { const copy = row.nested.value as typeof value return { @@ -167,16 +169,20 @@ async function valuePeer(name: string) { exported: await observeValueRows(await c.utils.exportData()), }), insert: async (kind: ValueKind) => { - await c.insert({ - id: 1, - name: 'initial', - nested: { value: createValue(kind) }, - }).isPersisted.promise + await c + .insert({ + id: 1, + name: 'initial', + nested: { value: createValue(kind) }, + }) + .when(`settled`) }, update: async () => { - await c.update(1, (draft) => { - draft.name = 'updated' - }).isPersisted.promise + await c + .update(1, (draft) => { + draft.name = 'updated' + }) + .when(`settled`) }, replace: async () => { const rows = await c.utils.exportData() @@ -196,7 +202,7 @@ async function valuePeer(name: string) { }, })) try { - if (entry === 'insert') await c.insert(rows).isPersisted.promise + if (entry === 'insert') await c.insert(rows).when(`settled`) else await c.utils.importData(rows) return 'fulfilled' } catch { @@ -264,7 +270,7 @@ async function initialize(name: string, shared: boolean, store: string) { nested: { value: id }, })) await Promise.all( - collections.map((c, index) => c.insert(rows[index]!).isPersisted.promise), + collections.map((c, index) => c.insert(rows[index]!).when(`settled`)), ) return { evidence: { admission, schema, initialVersions, held, restored }, diff --git a/packages/db/e2e/indexed-db/host-driver.ts b/packages/db/e2e/indexed-db/host-driver.ts index 24ee0c72ad..433a59e17c 100644 --- a/packages/db/e2e/indexed-db/host-driver.ts +++ b/packages/db/e2e/indexed-db/host-driver.ts @@ -107,14 +107,15 @@ export async function createHost({ database, stores }: HostSetup) { })), ) const c = collections.get(command.store)! - if (command.type === 'insert') - await c.insert(command.rows).isPersisted.promise + if (command.type === 'insert') await c.insert(command.rows).when(`settled`) else if (command.type === 'import') await c.utils.importData(command.rows) else if (command.type === 'clear') await c.utils.clearObjectStore() else - await c.update(command.key, (draft) => { - draft.name = command.name - }).isPersisted.promise + await c + .update(command.key, (draft) => { + draft.name = command.name + }) + .when(`settled`) return undefined } } diff --git a/packages/db/package-tests/indexed-db/packed-consumer.ts b/packages/db/package-tests/indexed-db/packed-consumer.ts index e811885d10..c5da2955fd 100644 --- a/packages/db/package-tests/indexed-db/packed-consumer.ts +++ b/packages/db/package-tests/indexed-db/packed-consumer.ts @@ -42,8 +42,8 @@ export async function run(factory) { const collection = make(db) try { await collection.preload() - await collection.insert({ id: 1, name: 'inserted' }).isPersisted.promise - await collection.update(1, draft => { draft.name = 'updated' }).isPersisted.promise + await collection.insert({ id: 1, name: 'inserted' }).when('settled') + await collection.update(1, draft => { draft.name = 'updated' }).when('settled') } finally { await collection.cleanup(); db.close() } const reopened = await open() const restored = make(reopened) diff --git a/packages/db/package.json b/packages/db/package.json index 440a6fc943..816995a325 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -23,7 +23,7 @@ "test": "vitest --run", "test:dist": "vitest run --config vitest.dist.config.ts", "test:facade-retention": "node --expose-gc --import tsx tests/facade-retention.probe.ts", - "test:oracles": "vitest --run --coverage.enabled=false tests/reverse-index-oracle.property.test.ts tests/query/compiler/in-evaluator-oracle.property.test.ts tests/cleanup-queue-oracle.property.test.ts tests/comparison-oracle.property.test.ts tests/duration-query-equality-oracle.test.ts tests/cursor-oracle.property.test.ts tests/index-update-oracle.property.test.ts tests/utils-oracle.property.test.ts tests/change-event-history-oracle.test.ts tests/index-suggestion-oracle.test.ts tests/btree-map-oracle.test.ts tests/paced-mutations-oracle.test.ts tests/db-client-hydration-authority-oracle.test.ts tests/local-storage-order-oracle.test.ts tests/local-storage-value-fidelity-oracle.property.test.ts tests/collection-cleanup-restart-oracle.test.ts tests/collection-gc-transaction-oracle.property.test.ts tests/collection-gc-retention-oracle.property.test.ts tests/collection-gc-mutation-oracle.property.test.ts tests/effect-disposal-oracle.test.ts tests/optimistic-transaction-oracle.property.test.ts tests/transaction-ownership-oracle.property.test.ts tests/optimistic-settlement-boundaries-oracle.test.ts tests/optimistic-history-publication-oracle.test.ts tests/optimistic-history-outcomes-oracle.test.ts tests/collection-metadata-publication-oracle.property.test.ts tests/collection-index-value-oracle.test.ts tests/collection-state-retention-oracle.property.test.ts tests/collection-subscription-lifecycle-history-oracle.property.test.ts tests/collection-subscription-lifecycle-oracle.test.ts tests/collection-subscription-reentrancy-oracle.test.ts tests/collection-subscription-lifecycle-publication-oracle.property.test.ts tests/collection-subscription-replay-oracle.property.test.ts tests/d2-source-reconciliation-oracle.property.test.ts tests/live-query-observer-history-oracle.property.test.ts tests/live-query-observer-hydration-oracle.property.test.ts tests/live-query-observer-snapshot-oracle.property.test.ts tests/query/cold-join-reconciliation-oracle.test.ts tests/query/identity-output-shape-oracle.test.ts tests/query/optimizer-semantics-oracle.test.ts tests/query/index-path-collision-oracle.test.ts tests/query/custom-index-order-selection-oracle.test.ts tests/query/mixed-index-order-oracle.test.ts tests/query/includes-collection-oracle.property.test.ts tests/query/includes-functional-projection-oracle.test.ts tests/query/includes-functional-input-boundary.test.ts tests/query/includes-context-transport-oracle.test.ts tests/query/includes-cross-formulation-oracle.property.test.ts tests/query/includes-optimistic-oracle.property.test.ts tests/query/includes-oracle.property.test.ts tests/query/includes-alias-shadowing-oracle.test.ts tests/query/includes-outer-join-route-work-oracle.test.ts tests/query/includes-publication-oracle.test.ts tests/query/includes-query-shape-oracle.test.ts tests/query/subquery-user-value-oracle.test.ts tests/query/includes-temporal-oracle.test.ts tests/query/includes-work-counter-oracle.test.ts tests/query/load-subset-oracle.property.test.ts tests/query/load-subset-replay-refinement-oracle.test.ts tests/query/load-subset-source-readiness-refinement-oracle.test.ts tests/query/load-subset-transaction-refinement-oracle.test.ts tests/query/ordered-source-loader-state-oracle.test.ts tests/query/ordered-demand-retirement.test.ts tests/query/ordered-default-work.test.ts tests/query/ordered-lifecycle-oracle.property.test.ts tests/query/ordered-work-oracle.property.test.ts tests/query/pagination-oracle.property.test.ts tests/query/includes-space-oracle.test.ts tests/query/virtual-row-fields-oracle.test.ts tests/query/where-predicate-publication-oracle.property.test.ts tests/query/join-result-key-oracle.property.test.ts tests/query/pooled-live-query-oracle.property.test.ts tests/live-query-deferred-acquisition-oracle.test.ts tests/flat-change-tracking-oracle.property.test.ts tests/truncate-readiness-oracle.test.ts tests/proxy-detachment-contract-oracle.test.ts tests/production-error-messages-oracle.test.ts tests/query/bucket-facade-rollback-oracle.property.test.ts tests/query/aggregate-select-oracle.property.test.ts tests/query/load-subset-joint-replay-oracle.property.test.ts", + "test:oracles": "vitest --run --coverage.enabled=false tests/reverse-index-oracle.property.test.ts tests/query/compiler/in-evaluator-oracle.property.test.ts tests/cleanup-queue-oracle.property.test.ts tests/comparison-oracle.property.test.ts tests/duration-query-equality-oracle.test.ts tests/cursor-oracle.property.test.ts tests/index-update-oracle.property.test.ts tests/utils-oracle.property.test.ts tests/change-event-history-oracle.test.ts tests/index-suggestion-oracle.test.ts tests/btree-map-oracle.test.ts tests/paced-mutations-oracle.test.ts tests/db-client-hydration-authority-oracle.test.ts tests/local-storage-order-oracle.test.ts tests/local-storage-value-fidelity-oracle.property.test.ts tests/collection-cleanup-restart-oracle.test.ts tests/collection-gc-transaction-oracle.property.test.ts tests/collection-gc-retention-oracle.property.test.ts tests/collection-gc-mutation-oracle.property.test.ts tests/effect-disposal-oracle.test.ts tests/optimistic-transaction-oracle.property.test.ts tests/transaction-ownership-oracle.property.test.ts tests/optimistic-settlement-boundaries-oracle.test.ts tests/optimistic-history-publication-oracle.test.ts tests/optimistic-history-outcomes-oracle.test.ts tests/collection-metadata-publication-oracle.property.test.ts tests/collection-index-value-oracle.test.ts tests/collection-state-retention-oracle.property.test.ts tests/collection-subscription-lifecycle-history-oracle.property.test.ts tests/collection-subscription-lifecycle-oracle.test.ts tests/collection-subscription-reentrancy-oracle.test.ts tests/collection-subscription-lifecycle-publication-oracle.property.test.ts tests/collection-subscription-replay-oracle.property.test.ts tests/d2-source-reconciliation-oracle.property.test.ts tests/live-query-cache-options-oracle.test.ts tests/mutation-confirmation-oracle.test.ts tests/mutation-handler-types-oracle.test.ts tests/live-query-observer-history-oracle.property.test.ts tests/live-query-observer-hydration-oracle.property.test.ts tests/live-query-observer-snapshot-oracle.property.test.ts tests/query/cold-join-reconciliation-oracle.test.ts tests/query/identity-output-shape-oracle.test.ts tests/query/optimizer-semantics-oracle.test.ts tests/query/index-path-collision-oracle.test.ts tests/query/custom-index-order-selection-oracle.test.ts tests/query/mixed-index-order-oracle.test.ts tests/query/includes-collection-oracle.property.test.ts tests/query/includes-functional-projection-oracle.test.ts tests/query/includes-functional-input-boundary.test.ts tests/query/includes-context-transport-oracle.test.ts tests/query/includes-cross-formulation-oracle.property.test.ts tests/query/includes-optimistic-oracle.property.test.ts tests/query/includes-oracle.property.test.ts tests/query/includes-alias-shadowing-oracle.test.ts tests/query/includes-outer-join-route-work-oracle.test.ts tests/query/includes-publication-oracle.test.ts tests/query/includes-query-shape-oracle.test.ts tests/query/subquery-user-value-oracle.test.ts tests/query/includes-temporal-oracle.test.ts tests/query/includes-work-counter-oracle.test.ts tests/query/load-subset-oracle.property.test.ts tests/query/load-subset-replay-refinement-oracle.test.ts tests/query/load-subset-source-readiness-refinement-oracle.test.ts tests/query/load-subset-transaction-refinement-oracle.test.ts tests/query/ordered-source-loader-state-oracle.test.ts tests/query/ordered-demand-retirement.test.ts tests/query/ordered-default-work.test.ts tests/query/ordered-lifecycle-oracle.property.test.ts tests/query/ordered-work-oracle.property.test.ts tests/query/pagination-oracle.property.test.ts tests/query/includes-space-oracle.test.ts tests/query/virtual-row-fields-oracle.test.ts tests/query/virtual-row-fields-types-oracle.test.ts tests/query/where-predicate-publication-oracle.property.test.ts tests/query/join-result-key-oracle.property.test.ts tests/query/pooled-live-query-oracle.property.test.ts tests/live-query-deferred-acquisition-oracle.test.ts tests/flat-change-tracking-oracle.property.test.ts tests/truncate-readiness-oracle.test.ts tests/proxy-detachment-contract-oracle.test.ts tests/production-error-messages-oracle.test.ts tests/query/bucket-facade-rollback-oracle.property.test.ts tests/query/aggregate-select-oracle.property.test.ts tests/query/load-subset-joint-replay-oracle.property.test.ts", "bench:nested-includes": "vitest bench tests/query/includes-performance.bench.ts --run", "test:indexed-db:package": "pnpm --filter @tanstack/db... build && vitest run --config vitest.indexed-db.package.config.ts", "test:collection-gc:browser": "playwright test --config playwright.collection-gc.config.ts", diff --git a/packages/db/skills/db-core/collection-setup/SKILL.md b/packages/db/skills/db-core/collection-setup/SKILL.md index 71cbe97b50..0d672663e4 100644 --- a/packages/db/skills/db-core/collection-setup/SKILL.md +++ b/packages/db/skills/db-core/collection-setup/SKILL.md @@ -61,15 +61,18 @@ const todoCollection = createCollection( queryClient, getKey: (item) => item.id, schema: todoSchema, - onInsert: async ({ transaction }) => { + onInsert: async ({ transaction, collection }) => { await api.todos.create(transaction.mutations[0].modified) + await collection.utils.refetch() }, - onUpdate: async ({ transaction }) => { + onUpdate: async ({ transaction, collection }) => { const mut = transaction.mutations[0] await api.todos.update(mut.key, mut.changes) + await collection.utils.refetch() }, - onDelete: async ({ transaction }) => { + onDelete: async ({ transaction, collection }) => { await api.todos.delete(transaction.mutations[0].key) + await collection.utils.refetch() }, }), ) @@ -169,6 +172,7 @@ Use `z.union([z.string(), z.date()])` for transformed fields — this ensures `T Always use a schema with Electric — without one, the collection types as `Record`. ```ts +import { createCollection } from '@tanstack/db' import { electricCollectionOptions } from '@tanstack/electric-db-collection' import { z } from 'zod' @@ -184,15 +188,15 @@ const todoCollection = createCollection( schema: todoSchema, shapeOptions: { url: '/api/electric/todos' }, getKey: (item) => item.id, - onInsert: async ({ transaction }) => { + onInsert: async ({ transaction, collection }) => { const res = await api.todos.create(transaction.mutations[0].modified) - return { txid: res.txid } + await collection.utils.awaitTxId(res.txid) }, }), ) ``` -The returned `txid` tells the collection to hold optimistic state until Electric streams back that transaction. See the [Electric adapter reference](references/electric-adapter.md) for the full dual-path pattern (schema + parser). +The handler waits for Electric to stream the `txid` before it completes, keeping optimistic state visible until then. See the [Electric adapter reference](references/electric-adapter.md) for the full dual-path pattern (schema + parser). ## Common Mistakes @@ -446,7 +450,7 @@ Correct: ```ts todoCollection.utils.writeInsert(newItem) // Use staleTime to prevent immediate refetch -// Or return { refetch: false } from mutation handlers +// Handlers do not refetch automatically; await confirmed direct writes ``` Direct writes update the collection immediately, but the next `queryFn` returns complete server state which overwrites them. diff --git a/packages/db/skills/db-core/collection-setup/references/electric-adapter.md b/packages/db/skills/db-core/collection-setup/references/electric-adapter.md index 98927cf408..4b0006ab06 100644 --- a/packages/db/skills/db-core/collection-setup/references/electric-adapter.md +++ b/packages/db/skills/db-core/collection-setup/references/electric-adapter.md @@ -31,9 +31,9 @@ const collection = createCollection( | `schema` | (none) | StandardSchema validator | | `shapeOptions.params` | (none) | Additional shape params (e.g. `{ table: 'todos' }`) | | `syncMode` | `eager` | `eager`, `on-demand`, or `progressive` | -| `onInsert` | (none) | Persistence handler; should return `{ txid }` | -| `onUpdate` | (none) | Persistence handler; should return `{ txid }` | -| `onDelete` | (none) | Persistence handler; should return `{ txid }` | +| `onInsert` | (none) | Persistence handler; await sync before completing | +| `onUpdate` | (none) | Persistence handler; await sync before completing | +| `onDelete` | (none) | Persistence handler; await sync before completing | ## Sync Modes @@ -49,14 +49,14 @@ forever. ## Three Sync Strategies -### 1. Txid Return (Recommended) +### 1. Await Txid (Recommended) -Handler returns `{ txid }`. Client waits for that txid in the Electric stream. +The handler waits for the backend's txid in the Electric stream. -```typescript -onInsert: async ({ transaction }) => { +```typescript nocheck +onInsert: async ({ transaction, collection }) => { const response = await api.todos.create(transaction.mutations[0].modified) - return { txid: response.txid } + await collection.utils.awaitTxId(response.txid) }, ``` @@ -232,21 +232,21 @@ const todosCollection = createCollection( timestamptz: (value: string) => new Date(value), // sync path }, }, - onInsert: async ({ transaction }) => { + onInsert: async ({ transaction, collection }) => { const response = await api.todos.create(transaction.mutations[0].modified) - return { txid: response.txid } + await collection.utils.awaitTxId(response.txid) }, - onUpdate: async ({ transaction }) => { + onUpdate: async ({ transaction, collection }) => { const { original, changes } = transaction.mutations[0] const response = await api.todos.update({ where: { id: original.id }, data: changes, }) - return { txid: response.txid } + await collection.utils.awaitTxId(response.txid) }, - onDelete: async ({ transaction }) => { + onDelete: async ({ transaction, collection }) => { const response = await api.todos.delete(transaction.mutations[0].key) - return { txid: response.txid } + await collection.utils.awaitTxId(response.txid) }, }), ) diff --git a/packages/db/skills/db-core/collection-setup/references/query-adapter.md b/packages/db/skills/db-core/collection-setup/references/query-adapter.md index 595d264c3a..a5aa098a89 100644 --- a/packages/db/skills/db-core/collection-setup/references/query-adapter.md +++ b/packages/db/skills/db-core/collection-setup/references/query-adapter.md @@ -57,17 +57,22 @@ not cache data, so it must not become collection-wide rows. ### Persistence Handlers -```typescript -onInsert: async ({ transaction }) => { +Handlers return `Promise` and must explicitly refresh or write confirmed +server data before settlement. Without that confirmation, optimistic changes +revert to the previous synced rows. `{ refetch: false }` is no longer supported. + +```typescript nocheck +onInsert: async ({ transaction, collection }) => { await api.createTodos(transaction.mutations.map((m) => m.modified)) - // Query Collection automatically refetches and awaits the result. - // return { refetch: false } to skip refetch + await collection.utils.refetch() }, -onUpdate: async ({ transaction }) => { +onUpdate: async ({ transaction, collection }) => { await api.updateTodos(transaction.mutations.map((m) => ({ id: m.key, changes: m.changes }))) + await collection.utils.refetch() }, -onDelete: async ({ transaction }) => { +onDelete: async ({ transaction, collection }) => { await api.deleteTodos(transaction.mutations.map((m) => m.key)) + await collection.utils.refetch() }, ``` @@ -293,7 +298,7 @@ import { const queryClient = new QueryClient() const productsCollection = createCollection( - queryCollectionOptions({ + queryCollectionOptions<{ id: string; name: string }>({ id: 'products', queryKey: ['products'], queryClient, @@ -318,24 +323,23 @@ const productsCollection = createCollection( signal: ctx.signal, }).then((r) => r.json()) }, - onInsert: async ({ transaction }) => { + onInsert: async ({ transaction, collection }) => { const serverItems = await api.createProducts( transaction.mutations.map((m) => m.modified), ) - productsCollection.utils.writeBatch(() => { - serverItems.forEach((item) => - productsCollection.utils.writeInsert(item), - ) + await collection.utils.writeBatch(() => { + serverItems.forEach((item) => collection.utils.writeInsert(item)) }) - return { refetch: false } }, - onUpdate: async ({ transaction }) => { + onUpdate: async ({ transaction, collection }) => { await api.updateProducts( transaction.mutations.map((m) => ({ id: m.key, changes: m.changes })), ) + await collection.utils.refetch() }, - onDelete: async ({ transaction }) => { + onDelete: async ({ transaction, collection }) => { await api.deleteProducts(transaction.mutations.map((m) => m.key)) + await collection.utils.refetch() }, }), ) @@ -380,5 +384,5 @@ When using a function-based `queryKey`, all derived keys must share the base key - An empty subset removes that subset's ownership; overlapping subsets can keep the same rows materialized - Direct writes update TQ cache but are overridden by subsequent `queryFn` results -- Persistence handlers automatically refetch unless they return `{ refetch: false }` +- Persistence handlers explicitly await `collection.utils.refetch()` or direct writes with confirmed data - On-demand `collection.preload()` is a no-op; preload the live query instead diff --git a/packages/db/skills/db-core/collection-setup/references/schema-patterns.md b/packages/db/skills/db-core/collection-setup/references/schema-patterns.md index 1bee2c134f..97439a8eab 100644 --- a/packages/db/skills/db-core/collection-setup/references/schema-patterns.md +++ b/packages/db/skills/db-core/collection-setup/references/schema-patterns.md @@ -185,12 +185,13 @@ const todosCollection = createCollection( queryClient, getKey: (item) => item.id, schema: todoSchema, - onInsert: async ({ transaction }) => { + onInsert: async ({ transaction, collection }) => { const todo = transaction.mutations[0].modified await api.todos.create({ ...todo, created_at: todo.created_at.toISOString(), }) + await collection.utils.refetch() }, }), ) diff --git a/packages/db/skills/db-core/custom-adapter/SKILL.md b/packages/db/skills/db-core/custom-adapter/SKILL.md index f7d8204738..d040e77666 100644 --- a/packages/db/skills/db-core/custom-adapter/SKILL.md +++ b/packages/db/skills/db-core/custom-adapter/SKILL.md @@ -206,20 +206,21 @@ return the fetched rows. `parseLoadSubsetOptions()` returns only `filters`, opaque backend cursor; translate or combine those expressions for your API. Return `unloadSubset` only when `loadSubset` creates an ongoing resource, such as a per-subset server subscription, that must be released. -Ownership transfers to core only when `loadSubset` returns `true` or a promise. -If it throws synchronously after partial setup, release that partial resource -before throwing; core will not call `unloadSubset` for a request that never -returned. A must-refetch can call `loadSubset` again with the same options. Each -successful return is a fresh acquisition: core releases the previous -acquisition when its replacement returns, then releases the current one when -the demand ends. +A Collection subscription installs its logical owner before calling +`loadSubset`. A synchronous throw fails the load but keeps that owner; when +the owner is released, core calls `unloadSubset` for the failed load too. +Make `unloadSubset` safe after partial setup. A must-refetch can call +`loadSubset` again with the same options. Replay first detaches and releases +the old acquisition lease, then starts a fresh acquisition if the logical +demand and replay are still current. Core releases the current acquisition +when the demand ends. ### Managing optimistic state duration Mutation handlers must not resolve until server changes have synced back to the collection. Five strategies: 1. **Refetch** (simplest): `await collection.utils.refetch()` -2. **Transaction ID**: return `{ txid }` and track via sync stream +2. **Transaction ID**: await a utility that observes the mutation's transaction ID in the sync stream, such as Electric's `await collection.utils.awaitTxId(txid)` 3. **ID-based tracking**: await specific record ID appearing in sync stream 4. **Version/timestamp**: wait until sync stream catches up to mutation time 5. **Provider method**: `await backend.waitForPendingWrites()` @@ -351,8 +352,8 @@ sync: ({ begin, write, commit, markReady }) => { If initial sync fails before it produces a usable snapshot and you stop retrying, call `fail(error)` instead. The status becomes `error`, readiness waits reject with the supplied cause, and dependent live queries move to -`error`. Every later call in the sync run throws, including `markReady()`. -Only `collection.reload()` starts a new sync run. +`error`. A later `markReady()` in the same sync run does nothing; only a new +sync run can reach `ready`. Use `collection.reload()` to start a new sync run. After the first `markReady()`, the rows stay. Report a sync problem with `reportError(error)` while you keep trying; it sets the node's diff --git a/packages/db/skills/db-core/live-queries/SKILL.md b/packages/db/skills/db-core/live-queries/SKILL.md index dcadb2d7a5..c360b35ab2 100644 --- a/packages/db/skills/db-core/live-queries/SKILL.md +++ b/packages/db/skills/db-core/live-queries/SKILL.md @@ -226,8 +226,6 @@ Live query results include computed, read-only virtual properties on every row: - `$collectionId`: the source collection ID. These props are added automatically and can be used in `where`, `select`, and `orderBy` clauses. Do not persist them back to storage. -The deprecated `$synced` alias is the inverse of `$hasPendingWrites` and will -be removed in the 1.0 RC. ## Includes (Subqueries in Select) diff --git a/packages/db/skills/db-core/mutations-optimistic/SKILL.md b/packages/db/skills/db-core/mutations-optimistic/SKILL.md index 048259135b..ad39bdb75a 100644 --- a/packages/db/skills/db-core/mutations-optimistic/SKILL.md +++ b/packages/db/skills/db-core/mutations-optimistic/SKILL.md @@ -6,7 +6,7 @@ description: > createPacedMutations with debounceStrategy, throttleStrategy, queueStrategy. createTransaction, getActiveTransaction, ambient transaction context. Transaction lifecycle (pending/persisting/completed/failed). Mutation merging. - onInsert/onUpdate/onDelete handlers. PendingMutation type. Transaction.isPersisted. + onInsert/onUpdate/onDelete handlers. PendingMutation type. Transaction.when('settled'). type: sub-skill library: db library_version: '0.6.17' @@ -228,47 +228,50 @@ a good fit for draft-style flows where local state updates immediately but the server call waits for Save/Blur; call `tx.rollback()` to discard the optimistic changes. -### 4. Mutation handlers with automatic refetch (QueryCollection pattern) +### 4. Mutation handlers with explicit refetch (QueryCollection pattern) ```ts const todoCollection = createCollection( - queryCollectionOptions({ + queryCollectionOptions<{ id: string; text: string; completed: boolean }>({ queryKey: ['todos'], + queryClient, queryFn: () => api.todos.getAll(), getKey: (t) => t.id, - onInsert: async ({ transaction }) => { + onInsert: async ({ transaction, collection }) => { await Promise.all( transaction.mutations.map((m) => api.todos.create(m.modified)), ) - // Query Collection refetches after the handler completes and awaits it. + await collection.utils.refetch() }, - onUpdate: async ({ transaction }) => { + onUpdate: async ({ transaction, collection }) => { await Promise.all( transaction.mutations.map((m) => api.todos.update(m.original.id, m.changes), ), ) + await collection.utils.refetch() }, - onDelete: async ({ transaction }) => { + onDelete: async ({ transaction, collection }) => { await Promise.all( transaction.mutations.map((m) => api.todos.delete(m.original.id)), ) + await collection.utils.refetch() }, }), ) ``` -For ElectricCollection, return `{ txid }` instead of refetching: +For ElectricCollection, wait for each txid in the Electric stream: ```ts -onUpdate: async ({ transaction }) => { +onUpdate: async ({ transaction, collection }) => { const txids = await Promise.all( transaction.mutations.map(async (m) => { const res = await api.todos.update(m.original.id, m.changes) return res.txid }), ) - return { txid: txids } + await Promise.all(txids.map((txid) => collection.utils.awaitTxId(txid))) } ``` @@ -351,27 +354,27 @@ If an item with the same key already exists (synced or optimistic), throws `DuplicateKeyError`. Always generate a unique key (e.g. `safeRandomUUID()`) or check before inserting. -### HIGH: Manually refetching inside a Query Collection handler +### HIGH: Returning before Query Collection has confirmed data -Query Collection automatically refetches after `onInsert`, `onUpdate`, and -`onDelete` complete, and waits for that refetch before the mutation finishes. -Calling `utils.refetch()` inside the handler sends a redundant request. +Query Collection does not refetch after `onInsert`, `onUpdate`, or `onDelete`. +A server write alone leaves synced rows unchanged. When the handler returns, +its optimistic state drops, so the previous synced rows reappear. ```ts -// WRONG -- causes one manual and one automatic refetch +// WRONG -- the optimistic insert disappears at settlement onInsert: async ({ transaction }) => { await api.createTodo(transaction.mutations[0].modified) - await collection.utils.refetch() } -// CORRECT -- automatic refetch is awaited after this returns -onInsert: async ({ transaction }) => { +// CORRECT -- explicitly read back the server state +onInsert: async ({ transaction, collection }) => { await api.createTodo(transaction.mutations[0].modified) + await collection.utils.refetch() } ``` -When the handler writes the confirmed server result with direct-write utilities, -return `{ refetch: false }` to skip the automatic refetch. +Alternatively, await a direct-write utility with the confirmed server result. +Handlers return `Promise`; `{ refetch: false }` is no longer supported. --- diff --git a/packages/db/skills/db-core/mutations-optimistic/references/transaction-api.md b/packages/db/skills/db-core/mutations-optimistic/references/transaction-api.md index 66eb96bfd4..5ea6327067 100644 --- a/packages/db/skills/db-core/mutations-optimistic/references/transaction-api.md +++ b/packages/db/skills/db-core/mutations-optimistic/references/transaction-api.md @@ -16,7 +16,7 @@ const tx = createTransaction({ ## Transaction Object ```ts -interface Transaction { +interface Transaction { id: string state: 'pending' | 'persisting' | 'completed' | 'failed' mutations: Array> @@ -26,13 +26,6 @@ interface Transaction { metadata: Record error?: { message: string; error: Error } - // Deprecated alias for the settlement promise; retained until the 1.0 RC - isPersisted: { - promise: Promise> - resolve: (value: Transaction) => void - reject: (reason?: any) => void - } - // Resolves when the transaction settles; rejects on failure or rollback when(state: 'settled'): Promise> @@ -228,6 +221,3 @@ For a non-empty commit, `mutationFn` is the normal success boundary. `when('settled')` does not by itself prove that a backend uploaded, confirmed, or read back the write. It proves those stronger guarantees only when `mutationFn` waits for them before returning. - -The old `isPersisted.promise` remains available until the 1.0 RC but is -deprecated. Replace it with `when('settled')`. diff --git a/packages/db/src/client.ts b/packages/db/src/client.ts index 1f0122053f..614a41b48c 100644 --- a/packages/db/src/client.ts +++ b/packages/db/src/client.ts @@ -13,6 +13,7 @@ import { LiveQueryObserverClient } from './live-query-observer-client.js' import { createDeferred } from './deferred.js' import { getLiveQueryHash, + getLiveQueryRowOptions, getPreparedLiveQuerySources, prepareLiveQueryValue, resumeDeferredLiveQueryCollections, @@ -36,6 +37,7 @@ import type { import type { DeferredLiveQueryCollections, LiveQueryOptions, + LiveQueryRowOptions, } from './live-query-options.js' import type { CollectionStateManager, @@ -201,6 +203,7 @@ type CollectionRecord = { } type LiveQueryRecord = { + rowOptions?: LiveQueryRowOptions queryHash: string sources?: ReadonlyArray dehydratedAt: number @@ -516,7 +519,8 @@ export class DbClient { const queryHash = getLiveQueryHash(prepared, options.queryKey) const sources = getPreparedLiveQuerySources(prepared) const existing = this.liveQueries.get(queryHash) - this._assertLiveQuerySources(queryHash, sources) + const rowOptions = getLiveQueryRowOptions(prepared) + this._assertLiveQuerySources(queryHash, sources, rowOptions) if ( existing && (existing.status === `pending` || @@ -547,6 +551,7 @@ export class DbClient { queryHash, collection.preload().then(() => observer.dehydrate()), sources, + rowOptions, ) return { result, @@ -887,23 +892,32 @@ export class DbClient { ) } - /** @internal Reject a local stream whose hash already names different sources. */ + /** @internal Reject incompatible local sources or row behavior for a reusable stream. */ _assertLiveQuerySources( queryHash: string, sources: ReadonlyArray, + rowOptions?: LiveQueryRowOptions, ): void { const record = this.liveQueries.get(queryHash) // A failed or consumed stream has no result to reuse and claims no sources. if (!record || record.status === `error`) return const priorSources = record.sources ?? this.preloadedLiveQueries.get(queryHash)?.sources - if (priorSources && !sameSourcesAtEachPosition(priorSources, sources)) { + if ( + (priorSources && !sameSourcesAtEachPosition(priorSources, sources)) || + (rowOptions && + record.rowOptions && + rowOptions.some( + (option, index) => option !== record.rowOptions![index], + )) + ) { throw new Error( devBuild() && process.env.NODE_ENV !== `production` - ? `DbClient cannot reuse live query "${queryHash}" with different source Collections. Use the same Collection objects or a distinct query key.` + ? `DbClient cannot reuse live query "${queryHash}" with different source Collections or getKey/schema options. Use the same Collection objects and options, or a distinct query key.` : codedMessage(234, { queryHash }), ) } + record.rowOptions ??= rowOptions // Hydration has a portable hash but no server-side Collection objects. // The first local consumer claims them for later same-hash requests. if (!record.sources) record.sources = [...sources] @@ -922,10 +936,11 @@ export class DbClient { queryHash: string, snapshot: DehydratedLiveQueryResult, sources?: ReadonlyArray, + rowOptions?: LiveQueryRowOptions, ): void { const existing = this.liveQueries.get(queryHash) if (existing) { - if (sources) this._assertLiveQuerySources(queryHash, sources) + if (sources) this._assertLiveQuerySources(queryHash, sources, rowOptions) if ( existing.snapshot && (snapshot.status === undefined || existing.initialSnapshot) @@ -943,6 +958,7 @@ export class DbClient { queryHash, this.nextLiveQueryTimestamp(), ) + record.rowOptions = rowOptions record.sources = sources if (snapshot.status !== undefined) record.initialSnapshot = snapshot this.liveQueries.set(queryHash, record) @@ -955,10 +971,11 @@ export class DbClient { queryHash: string, promise: Promise, sources?: ReadonlyArray, + rowOptions?: LiveQueryRowOptions, ): Promise { // Observe even a rejected incoming promise when a source conflict discards it. void Promise.resolve(promise).catch(() => {}) - if (sources) this._assertLiveQuerySources(queryHash, sources) + if (sources) this._assertLiveQuerySources(queryHash, sources, rowOptions) const existing = this.liveQueries.get(queryHash) if ( existing && @@ -973,6 +990,7 @@ export class DbClient { queryHash, this.nextLiveQueryTimestamp(), ) + record.rowOptions = rowOptions record.sources = sources record.initialSnapshot = existing?.initialSnapshot @@ -1075,6 +1093,7 @@ export class DbClient { this.discardPreloadedLiveQuery(dehydratedQuery.queryHash) } else { record.sources = existing?.sources + record.rowOptions = existing?.rowOptions } this.liveQueries.set(record.queryHash, record) if (existing?.status === `pending`) { diff --git a/packages/db/src/collection/changes.ts b/packages/db/src/collection/changes.ts index baab04f9b8..d8cd6eb195 100644 --- a/packages/db/src/collection/changes.ts +++ b/packages/db/src/collection/changes.ts @@ -114,7 +114,7 @@ export class CollectionChangesManager< } /** - * Enriches a change message with virtual properties ($hasPendingWrites, $synced, $origin, $key, $collectionId). + * Enriches a change message with virtual properties ($hasPendingWrites, $origin, $key, $collectionId). * Uses the "add-if-missing" pattern to preserve virtual properties from upstream collections. */ private enrichChangeWithVirtualProps( diff --git a/packages/db/src/collection/mutations.ts b/packages/db/src/collection/mutations.ts index 0c29728a58..6c5bc89a21 100644 --- a/packages/db/src/collection/mutations.ts +++ b/packages/db/src/collection/mutations.ts @@ -217,8 +217,7 @@ export class CollectionMutationsManager< try { direct.write(mutations) } finally { - transaction.setState(`completed`) - transaction.isPersisted.resolve(transaction) + transaction.completeWithoutCommit() } return transaction } @@ -282,7 +281,7 @@ export class CollectionMutationsManager< } throw error } - // Errors still reject `isPersisted.promise`. This catch only prevents an + // Errors still reject `when('settled')`. This catch only prevents an // unhandled rejection from the fire-and-forget commit. transaction.commit().catch(() => undefined) return transaction @@ -524,7 +523,7 @@ export class CollectionMutationsManager< const emptyTransaction = this.createTransaction({ mutationFn: async () => {}, }) - // Errors still propagate through tx.isPersisted.promise; suppress the background commit from warning + // Errors still propagate through tx.when(`settled`); suppress the background commit from warning emptyTransaction.commit().catch(() => undefined) return emptyTransaction } diff --git a/packages/db/src/collection/state.ts b/packages/db/src/collection/state.ts index 4de9b42c2e..cc728023b1 100644 --- a/packages/db/src/collection/state.ts +++ b/packages/db/src/collection/state.ts @@ -219,7 +219,7 @@ export class CollectionStateManager< TKey, { row: TOutput - synced: boolean + hasPendingWrites: boolean origin: VirtualOrigin collectionId: string enriched: WithVirtualProps @@ -281,10 +281,10 @@ export class CollectionStateManager< /** * Snapshot virtual properties from the current or supplied prior state. - * A row with an optimistic write is local and unsynced; otherwise it keeps - * the source row's attribution. Local-only rows are always local and synced. + * A row with an optimistic write is local and pending; otherwise it keeps + * the source row's attribution. Local-only rows are always local without pending writes. * - * This is local mutation status, not backend confirmation: `$synced: true` means the + * This is local mutation status, not backend confirmation: `$hasPendingWrites: false` means the * row is not currently affected by an optimistic transaction in this * collection's visible state. */ @@ -302,7 +302,6 @@ export class CollectionStateManager< return { $hasPendingWrites: hasOptimisticChange, - $synced: !hasOptimisticChange, $origin: this.isLocalOnly || hasOptimisticChange ? 'local' @@ -332,7 +331,8 @@ export class CollectionStateManager< virtualProps: VirtualRowProps, ): WithVirtualProps { const existingRow = row as Partial> - const synced = existingRow.$synced ?? virtualProps.$synced + const hasPendingWrites = + existingRow.$hasPendingWrites ?? virtualProps.$hasPendingWrites const origin = existingRow.$origin ?? virtualProps.$origin const resolvedKey = existingRow.$key ?? virtualProps.$key const collectionId = existingRow.$collectionId ?? virtualProps.$collectionId @@ -341,7 +341,7 @@ export class CollectionStateManager< if ( cached && cached.row === row && - cached.synced === synced && + cached.hasPendingWrites === hasPendingWrites && cached.origin === origin && cached.collectionId === collectionId ) { @@ -350,8 +350,7 @@ export class CollectionStateManager< const enriched = { ...row, - $hasPendingWrites: !synced, - $synced: synced, + $hasPendingWrites: hasPendingWrites, $origin: origin, $key: resolvedKey, $collectionId: collectionId, @@ -359,7 +358,7 @@ export class CollectionStateManager< this.virtualPropsCache.set(resolvedKey, { row, - synced, + hasPendingWrites, origin, collectionId, enriched, @@ -790,7 +789,8 @@ export class CollectionStateManager< currentValue !== undefined && (!deepEquals(previousValue, currentValue) || previousVirtualProps.$origin !== nextVirtualProps.$origin || - previousVirtualProps.$synced !== nextVirtualProps.$synced) + previousVirtualProps.$hasPendingWrites !== + nextVirtualProps.$hasPendingWrites) ) { events.push({ type: `update`, @@ -1436,7 +1436,7 @@ export class CollectionStateManager< value, key, this.collection.id, - () => previousVirtualProps.$synced, + () => previousVirtualProps.$hasPendingWrites, () => previousVirtualProps.$origin, ) @@ -1450,7 +1450,8 @@ export class CollectionStateManager< } const nextVirtualProps = this.getVirtualPropsSnapshotForState(key) if ( - previousVirtualProps.$synced !== nextVirtualProps.$synced || + previousVirtualProps.$hasPendingWrites !== + nextVirtualProps.$hasPendingWrites || previousVirtualProps.$origin !== nextVirtualProps.$origin || !deepEquals(previousVisibleValue, newVisibleValue) ) { diff --git a/packages/db/src/errors.ts b/packages/db/src/errors.ts index fc3d3fc6b3..611e28a6d8 100644 --- a/packages/db/src/errors.ts +++ b/packages/db/src/errors.ts @@ -844,25 +844,6 @@ export class CollectionInputNotFoundError extends QueryCompilationError { } } -/** - * Retained for callers that imported this formerly thrown error. - * - * @deprecated Nested queries may now shadow ancestor aliases. Duplicate aliases - * are rejected only within one lexical scope or union branch namespace. - */ -export class DuplicateAliasInSubqueryError extends QueryCompilationError { - constructor(alias: string, parentAliases: Array) { - super( - devBuild() && process.env.NODE_ENV !== `production` - ? `Subquery uses alias "${alias}" which is already used in the parent query. ` + - `Each alias must be unique across parent and subquery contexts. ` + - `Parent query aliases: ${parentAliases.join(`, `)}. ` + - `Please rename "${alias}" in either the parent query or subquery to avoid conflicts.` - : codedMessage(57, { alias, parentAliases }), - ) - } -} - export class UnsupportedFromTypeError extends QueryCompilationError { constructor(type: string) { super( diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index d4c78fbf59..dbea33cfad 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -50,8 +50,6 @@ export * from './errors' export * from './sync-persistence' export * from './sync-receipt' export { deepEquals, equalPersistedSnapshotValues } from './utils' -/** @internal Used by first-party collection adapters. */ -export { warnOnce, resetWarnings } from './utils' /** @internal Reports an adapter contract breach uncaught, as core does. */ export { reportUncaught } from './utils/callbacks' export * from './paced-mutations' diff --git a/packages/db/src/indexed-db.ts b/packages/db/src/indexed-db.ts index 5c360a1ccb..2fafe471c0 100644 --- a/packages/db/src/indexed-db.ts +++ b/packages/db/src/indexed-db.ts @@ -825,21 +825,20 @@ export function indexedDBCollectionOptions( // is held. Each rejected decision releases its place without persisting. function persistInOrder( transaction: { mutations: Array> }, - handler: () => unknown, - ): Promise { + handler: () => void | Promise, + ): Promise { const result = acceptance.then(async () => { const outcome = await decision if (!outcome.accepted) throw outcome.error await acceptMutations(transaction) - return outcome.value }) acceptance = result.then( () => undefined, () => undefined, ) // Reserve the position before application code can author another write. - const decision = new Promise((resolve) => resolve(handler())).then( - (value) => ({ accepted: true as const, value }), + const decision = new Promise((resolve) => resolve(handler())).then( + () => ({ accepted: true as const }), (error: unknown) => ({ accepted: false as const, error }), ) return result diff --git a/packages/db/src/live-query-observer-client.ts b/packages/db/src/live-query-observer-client.ts index cc579bc70c..cac2f6d6e4 100644 --- a/packages/db/src/live-query-observer-client.ts +++ b/packages/db/src/live-query-observer-client.ts @@ -1,3 +1,4 @@ +import { getLiveQueryRowOptions } from './live-query-options.js' import { codedMessage, devBuild } from './error-message.js' import { getBuilderFromConfig } from './query/live/collection-registry.js' import { getPooledLiveQuerySource } from './query/pooled-source.js' @@ -131,6 +132,7 @@ export class LiveQueryObserverClient< if (preloadRequested) { void this.host.observer.preload().catch(() => {}) } + if (typeof window === `undefined`) this.assertSources() const snapshot = this.host.read(true) const collection = this.collection if (!collection || this.seed) return snapshot @@ -173,6 +175,7 @@ export class LiveQueryObserverClient< }), }, sources, + getLiveQueryRowOptions(collection), ) return this.host.read(true) } @@ -242,7 +245,11 @@ export class LiveQueryObserverClient< assertSources(): void { const { sources } = this.getSourceInfo() if (this.queryHash && sources) - this.client._assertLiveQuerySources(this.queryHash, sources) + this.client._assertLiveQuerySources( + this.queryHash, + sources, + getLiveQueryRowOptions(this.collection), + ) } subscribe(): () => void { @@ -303,7 +310,11 @@ export class LiveQueryObserverClient< try { this.client._waitForSsrLiveQuery(this.queryHash) if (sources) - this.client._assertLiveQuerySources(this.queryHash, sources) + this.client._assertLiveQuerySources( + this.queryHash, + sources, + getLiveQueryRowOptions(this.collection), + ) if ( query?.status === `pending` || (query?.status === `success` && query.snapshot?.status === undefined) @@ -328,6 +339,7 @@ export class LiveQueryObserverClient< this.queryHash, collectionPromise.then(() => this.host.observer.dehydrate()), sources, + getLiveQueryRowOptions(this.collection), ) : collectionPromise this.preloadPromise = promise diff --git a/packages/db/src/live-query-options.ts b/packages/db/src/live-query-options.ts index 118b0433ce..e5c30e2fad 100644 --- a/packages/db/src/live-query-options.ts +++ b/packages/db/src/live-query-options.ts @@ -6,6 +6,7 @@ import { UNSUBSCRIBED_GC_FLOOR_MS, } from './collection/lifecycle.js' import { createLiveQueryCollection } from './query/live-query-collection.js' +import { LIVE_QUERY_INTERNAL } from './query/live/internal.js' import { getBuilderFromConfig } from './query/live/collection-registry.js' import { getStableQueryBuilderHash, @@ -254,6 +255,24 @@ export function getPreparedLiveQuerySources( return isCollection(query) ? [query] : [] } +/** Local row behavior, kept separate from the portable query identity. */ +export type LiveQueryRowOptions = readonly [getKey: unknown, schema: unknown] + +export function getLiveQueryRowOptions(value: unknown): LiveQueryRowOptions { + if (isCollection(value)) { + const { config } = value + const internal = config.utils?.[LIVE_QUERY_INTERNAL] as + { hasCustomGetKey?: boolean } | undefined + return [ + internal?.hasCustomGetKey ? config.getKey : undefined, + config.schema, + ] + } + const config = value as + { getKey?: unknown; schema?: unknown } | null | undefined + return [config?.getKey, config?.schema] +} + export function getPreparedLiveQueryIdentity( value: unknown, pool?: PooledLiveQueries, @@ -270,10 +289,10 @@ export function getPreparedLiveQueryIdentity( if (value && typeof value === `object` && `query` in value) { const config = value as LiveQueryCollectionConfig return [ + // getKey and schema bind for this query identity. Inline options must + // not turn a normal framework render into a new Collection. `config`, getPreparedLiveQueryIdentity(config.query, configPool(config)), - [`getKey`, config.getKey], - [`schema`, config.schema], [`singleResult`, config.singleResult === true], [ `defaultStringCollation`, diff --git a/packages/db/src/live-query-window-binding.ts b/packages/db/src/live-query-window-binding.ts index a3e0bf268d..e40858570c 100644 --- a/packages/db/src/live-query-window-binding.ts +++ b/packages/db/src/live-query-window-binding.ts @@ -67,12 +67,10 @@ export function readLiveQueryWindowPageShape(config: { * * - `identity`: when its identity dependencies change by structure, as for a * derived query identity or a `queryKey`. - * - `dependencies`: when a dependency changes by reference, as for a - * dependency list. * - `always`: on every update, as for a reactive rerun, which happens only * when something the query reads changed. */ -export type LiveQueryWindowRecompile = `identity` | `dependencies` | `always` +export type LiveQueryWindowRecompile = `identity` | `always` export interface LiveQueryWindowUpdate extends LiveQueryWindowPageShape { /** diff --git a/packages/db/src/local-only.ts b/packages/db/src/local-only.ts index 8cfa0136e9..cfb7bb3b6b 100644 --- a/packages/db/src/local-only.ts +++ b/packages/db/src/local-only.ts @@ -201,16 +201,10 @@ export function localOnlyCollectionOptions< const wrappedOnInsert = async ( params: InsertMutationFnParams, ) => { - // Call user handler first if provided - let handlerResult - if (onInsert) { - handlerResult = (await onInsert(params)) ?? {} - } + if (onInsert) await onInsert(params) // Then synchronously confirm the transaction by looping through mutations syncResult.confirmOperationsSync(params.transaction.mutations) - - return handlerResult } /** @@ -219,16 +213,10 @@ export function localOnlyCollectionOptions< const wrappedOnUpdate = async ( params: UpdateMutationFnParams, ) => { - // Call user handler first if provided - let handlerResult - if (onUpdate) { - handlerResult = (await onUpdate(params)) ?? {} - } + if (onUpdate) await onUpdate(params) // Then synchronously confirm the transaction by looping through mutations syncResult.confirmOperationsSync(params.transaction.mutations) - - return handlerResult } /** @@ -237,16 +225,10 @@ export function localOnlyCollectionOptions< const wrappedOnDelete = async ( params: DeleteMutationFnParams, ) => { - // Call user handler first if provided - let handlerResult - if (onDelete) { - handlerResult = (await onDelete(params)) ?? {} - } + if (onDelete) await onDelete(params) // Then synchronously confirm the transaction by looping through mutations syncResult.confirmOperationsSync(params.transaction.mutations) - - return handlerResult } /** diff --git a/packages/db/src/local-storage.ts b/packages/db/src/local-storage.ts index 0d40912227..2ba856db76 100644 --- a/packages/db/src/local-storage.ts +++ b/packages/db/src/local-storage.ts @@ -746,8 +746,8 @@ export function localStorageCollectionOptions( const persistAutomatic = ( mutations: Array>>, - handler?: () => unknown | Promise, - ): Promise => { + handler?: () => void | Promise, + ): Promise => { const { previous, retire } = reserveWrite() // Native Storage writes synchronously. With no handler or earlier write, @@ -755,7 +755,7 @@ export function localStorageCollectionOptions( if (!handler && previous === undefined) { try { persistMutations(mutations) - return Promise.resolve({}) + return Promise.resolve() } catch (error) { return Promise.reject(error) } finally { @@ -763,7 +763,7 @@ export function localStorageCollectionOptions( } } - let result: unknown + let result: void | Promise try { result = handler?.() } catch (error) { @@ -772,11 +772,10 @@ export function localStorageCollectionOptions( return Promise.reject(error) } return Promise.resolve(result).then( - async (value) => { + async () => { await previous try { persistMutations(mutations) - return value ?? {} } finally { retire() } diff --git a/packages/db/src/paced-mutations.ts b/packages/db/src/paced-mutations.ts index b634d4c717..bef70628b1 100644 --- a/packages/db/src/paced-mutations.ts +++ b/packages/db/src/paced-mutations.ts @@ -153,7 +153,7 @@ export function createPacedMutations< const completion = strategyCommit() onStarted?.(completion) completion.catch(() => { - // Persistence failures are reported by transaction.isPersisted.promise. + // Persistence failures are reported by transaction.when('settled'). }) return transaction } diff --git a/packages/db/src/query/builder/ref-proxy.ts b/packages/db/src/query/builder/ref-proxy.ts index 6d4738dd84..660078f7e6 100644 --- a/packages/db/src/query/builder/ref-proxy.ts +++ b/packages/db/src/query/builder/ref-proxy.ts @@ -4,7 +4,7 @@ import { REF_PROXY_BRAND, readRefProxyBrand } from './ref-proxy-identity.js' import { getWrapperExpressionName } from './wrapper-identity.js' import type { BasicExpression } from '../ir.js' import type { IsPlainObject, RefLeaf } from './types.js' -import type { PublishedVirtualRowProps } from '../../virtual-props.js' +import type { VirtualRowProps } from '../../virtual-props.js' export { isRefProxy } from './ref-proxy-identity.js' @@ -33,9 +33,7 @@ export interface RefProxy { export type VirtualPropsRefProxy< TKey extends string | number = string | number, > = { - readonly [K in keyof PublishedVirtualRowProps]: RefLeaf< - PublishedVirtualRowProps[K] - > + readonly [K in keyof VirtualRowProps]: RefLeaf[K]> } // Strip nullish members before deciding whether a schema field is traversable, diff --git a/packages/db/src/query/builder/types.ts b/packages/db/src/query/builder/types.ts index b92af21d6f..c142082386 100644 --- a/packages/db/src/query/builder/types.ts +++ b/packages/db/src/query/builder/types.ts @@ -10,10 +10,7 @@ import type { Value, } from '../ir.js' import type { InitialQueryBuilder, QueryBuilder } from './index.js' -import type { - PublishedVirtualRowProps, - WithVirtualProps, -} from '../../virtual-props.js' +import type { VirtualRowProps, WithVirtualProps } from '../../virtual-props.js' import type { CaseWhenWrapper, ConcatToArrayWrapper, @@ -894,9 +891,7 @@ type NonNull = T extends null ? never : T * ``` */ type VirtualPropsRef = { - readonly [K in keyof PublishedVirtualRowProps]: RefLeaf< - PublishedVirtualRowProps[K] - > + readonly [K in keyof VirtualRowProps]: RefLeaf[K]> } /** @@ -987,13 +982,14 @@ export type RefLeaf = { // Detect NullableBrand by checking for the key's presence type IsNullableRef = typeof NullableBrand extends keyof T ? true : false -// Helper type to remove RefBrand and NullableBrand from objects -type WithoutRefBrand = - IsPlainObject extends true +// Remove brands per union member so Omit preserves discriminant-specific fields. +type WithoutRefBrand = T extends unknown + ? IsPlainObject extends true ? Extract extends never ? T : Omit : T + : never /** * MergeContextWithJoinType - Creates a new context after a join operation @@ -1106,11 +1102,22 @@ export type InferResultType = type WithVirtualPropsIfAttachable = TResult extends unknown ? TResult extends object ? IsPlainObject extends true - ? WithVirtualProps + ? WithRequiredVirtualProps : TResult : TResult : never +// An unmatched whole-row selection has optional, absent virtual fields. +// Publication supplies metadata on that empty row instead of intersecting +// required metadata with those absent fields and losing the empty alternative. +type WithRequiredVirtualProps = + Extract, keyof VirtualRowProps> extends never + ? WithVirtualProps + : WithVirtualProps< + Omit, keyof VirtualRowProps>>, + string | number + > + type OptionalKeys = { [K in keyof T]-?: {} extends Pick ? K : never }[keyof T] diff --git a/packages/db/src/query/compiler/group-by.ts b/packages/db/src/query/compiler/group-by.ts index 1a15fb7b34..7d8f976339 100644 --- a/packages/db/src/query/compiler/group-by.ts +++ b/packages/db/src/query/compiler/group-by.ts @@ -83,7 +83,7 @@ function createInternalGroupFields(groupCount: number, selectClause?: Select) { } type RowVirtualMetadata = { - synced: boolean + hasPendingWrites: boolean hasLocal: boolean } @@ -262,27 +262,20 @@ function getGroupEvaluationRow( } function getRowVirtualMetadata(row: NamespacedRow): RowVirtualMetadata { - let found = false - let allSynced = true + let hasPendingWrites = false let hasLocal = false for (const [alias, value] of Object.entries(row as Record)) { if (alias === `$selected`) continue if (value === null || typeof value !== `object`) continue const asRecord = value as Record - const hasSyncedProp = `$synced` in asRecord const hasPendingWritesProp = `$hasPendingWrites` in asRecord const hasOriginProp = `$origin` in asRecord - if (!hasSyncedProp && !hasPendingWritesProp && !hasOriginProp) { + if (!hasPendingWritesProp && !hasOriginProp) { continue } - found = true - // Preserve the existing alias while present; accept new-field-only rows too. - if ( - asRecord.$synced === false || - (!hasSyncedProp && asRecord.$hasPendingWrites === true) - ) { - allSynced = false + if (asRecord.$hasPendingWrites === true) { + hasPendingWrites = true } if (asRecord.$origin === `local`) { hasLocal = true @@ -290,7 +283,7 @@ function getRowVirtualMetadata(row: NamespacedRow): RowVirtualMetadata { } return { - synced: found ? allSynced : true, + hasPendingWrites, hasLocal, } } @@ -317,10 +310,13 @@ export function processGroupBy( [fields.virtual]: { preMap: ([, row]: [string, NamespacedRow]) => getRowVirtualMetadata(row), reduce: (values: Array<[RowVirtualMetadata, number]>) => { - const group: RowVirtualMetadata = { synced: true, hasLocal: false } + const group: RowVirtualMetadata = { + hasPendingWrites: false, + hasLocal: false, + } for (const [metadata, multiplicity] of values) { if (multiplicity <= 0) continue - if (!metadata.synced) group.synced = false + if (metadata.hasPendingWrites) group.hasPendingWrites = true if (metadata.hasLocal) group.hasLocal = true } return group @@ -533,8 +529,7 @@ export function processGroupBy( } const virtual = (aggregatedRow as Record)[fields.virtual] as RowVirtualMetadata | undefined - resultRow.$synced = virtual?.synced ?? true - resultRow.$hasPendingWrites = !resultRow.$synced + resultRow.$hasPendingWrites = virtual?.hasPendingWrites ?? false resultRow.$origin = ( virtual?.hasLocal ? `local` : `remote` ) satisfies VirtualOrigin diff --git a/packages/db/src/query/compiler/index.ts b/packages/db/src/query/compiler/index.ts index 3e69e8b70c..2fe86d06ad 100644 --- a/packages/db/src/query/compiler/index.ts +++ b/packages/db/src/query/compiler/index.ts @@ -412,6 +412,12 @@ export function compileQuery(...args: CompileQueryArgs): CompilationResult { return compileQueryInternal(args, false) } +export function compileCollectionQuery( + ...args: CompileQueryArgs +): CompilationResult { + return compileQueryInternal(args, false, true) +} + function compileQueryInternal( [ rawQuery, @@ -429,6 +435,7 @@ function compileQueryInternal( ancestorHasTopK = false, ]: CompileQueryArgs, scopeValidated: boolean, + publishCollectionRoot = false, ): CompilationResult { // Check if the original raw query has already been compiled const cachedResult = @@ -502,6 +509,7 @@ function compileQueryInternal( aliasRemapping, sourceWhereClauses, parentKeyStream, + publishCollectionRoot, ) Object.assign(sources, fromSources) const sourceCarriesInternalRouteState = @@ -614,7 +622,7 @@ function compileQueryInternal( ignoreWindow, rawQuery, query.from, - (...args) => compileQueryInternal(args, true), + (...args) => compileQueryInternal(args, true, publishCollectionRoot), aliasToCollectionId, aliasRemapping, sourceWhereClauses, @@ -919,6 +927,7 @@ function compileQueryInternal( ancestorHasTopK || parentHasTopK, ], true, + subquery.materialization === `collection`, ) // Each include retains its own compilation result and lexical aliases. @@ -1027,6 +1036,10 @@ function compileQueryInternal( includesRoutingFns = [] } + const sourceBindings = new Map( + getAllSources(query).map((source) => [source.alias, source.bindingId]), + ) + const unionRows = query.from.type === `unionAll` // Process the SELECT clause early - always create $selected // This eliminates duplication and allows for DISTINCT implementation if (query.fnSelect) { @@ -1035,6 +1048,7 @@ function compileQueryInternal( validateFnSelectResult(selected) return selected } + const sourceAliases = [...sourceBindings.keys()] // Handle functional select - apply the function to transform the row const projectRow = (namespacedRow: NamespacedRow) => { const callbackRow = sourceCarriesInternalRouteState @@ -1050,6 +1064,14 @@ function compileQueryInternal( selected = Array.isArray(selectResults) ? [...selectResults] : { ...selectResults } + if ( + publishCollectionRoot && + !sourceAliases.some((alias) => selectResults === callbackRow[alias]) + ) { + // New functional objects are projections. Equal field values cannot + // prove which source (if any) authored them. + for (const prop of VIRTUAL_PROP_NAMES) delete selected[prop] + } const routing = (namespacedRow as any)[INCLUDES_ROUTING] if (routing) { selected[INCLUDES_ROUTING] = routing @@ -1110,10 +1132,6 @@ function compileQueryInternal( // A ref reads the query's own rows when it is bound to one of its sources. // A ref bound to an ancestor scope can reuse a source alias. After a // unionAll, an unbound ref names a field of the union's rows. - const sourceBindings = new Map( - getAllSources(query).map((source) => [source.alias, source.bindingId]), - ) - const unionRows = query.from.type === `unionAll` const isSourceRef = (ref: PropRef) => { const binding = sourceBindings.get(ref.path[0]!) if (binding !== undefined) return (ref.bindingId ?? binding) === binding @@ -1202,15 +1220,25 @@ function compileQueryInternal( pipeline = pipeline.pipe(distinct(([_key, row]) => row.$selected)) } + const metadataSource = query.select + ? getWholeRowSourceAliases(query.select) + : query.from.type === `unionAll` + ? undefined + : [mainSource] const finalizeRow = ( key: unknown, row: Record, orderByIndex: string | undefined, ) => { - const finalResults = attachVirtualPropsToSelected( - unwrapValue(row.$selected), - row, - ) + const finalResults = + query.fnSelect && publishCollectionRoot + ? unwrapValue(row.$selected) + : attachVirtualPropsToSelected( + unwrapValue(row.$selected), + row, + publishCollectionRoot, + metadataSource, + ) // When in includes mode, embed the correlation key and parentContext if (parentKeyStream) { return [ @@ -1569,6 +1597,7 @@ function processFromClause( aliasRemapping: Record, sourceWhereClauses: Map>, parentKeyStream?: KeyedStream, + publishCollectionRoot = false, ): { alias: string pipeline: NamespacedAndKeyedStream @@ -1596,6 +1625,7 @@ function processFromClause( aliasRemapping, sourceWhereClauses, parentKeyStream, + publishCollectionRoot, ) } @@ -1616,6 +1646,7 @@ function processFromClause( aliasRemapping, sourceWhereClauses, parentKeyStream, + publishCollectionRoot, ) return { @@ -1662,6 +1693,7 @@ function processFromClause( aliasRemapping, sourceWhereClauses, parentKeyStream, + publishCollectionRoot, ) if (!mainAlias) { @@ -1730,6 +1762,7 @@ function processUnionAll( aliasRemapping: Record, sourceWhereClauses: Map>, parentKeyStream?: KeyedStream, + publishCollectionRoot = false, ): { alias: string pipeline: NamespacedAndKeyedStream @@ -1780,6 +1813,7 @@ function processUnionAll( undefined, ], true, + publishCollectionRoot, ) if (!mainCollectionId) { @@ -1901,6 +1935,7 @@ function processFrom( aliasRemapping: Record, sourceWhereClauses: Map>, parentKeyStream?: KeyedStream, + publishCollectionRoot = false, ): { alias: string input: KeyedStream @@ -1954,6 +1989,7 @@ function processFrom( undefined, ], true, + publishCollectionRoot, ) // Pull up alias mappings from subquery to parent scope. @@ -2050,9 +2086,30 @@ function unwrapValue(value: any): any { return value instanceof ValClass ? value.value : value } +// A nullish spread contributes no properties. Keep source order so the last +// present whole-row spread supplies metadata, just as it supplies row fields. +function getWholeRowSourceAliases(select: unknown): Array { + if (select instanceof PropRef && select.path.length === 1) + return [select.path[0]!] + if (!isPlainObject(select)) return [] + return Object.entries(select).flatMap(([name, value]) => + name.startsWith(`__SPREAD_SENTINEL__`) && + value instanceof PropRef && + value.path.length === 1 + ? [value.path[0]!] + : [], + ) +} + +function copySelectedRow(selected: Record) { + return Array.isArray(selected) ? [...selected] : { ...selected } +} + function attachVirtualPropsToSelected( selected: any, row: Record, + isCollectionRoot: boolean, + metadataSources: ReadonlyArray | undefined, ): any { if ( !selected || @@ -2063,23 +2120,38 @@ function attachVirtualPropsToSelected( } const selectedRecord = selected as Record - let needsMerge = false - for (const prop of VIRTUAL_PROP_NAMES) { - if (selectedRecord[prop] == null && prop in row) { - needsMerge = true - break - } - } - - if (!needsMerge) { - return selected - } - - const result = ( - Array.isArray(selected) ? [...selected] : { ...selected } - ) as Record + // Union output is already a branch row, rather than an aliased source. + const source = + metadataSources === undefined + ? row + : metadataSources.reduce( + (prior, alias) => row[alias] ?? prior, + undefined, + ) + const sourceRecord = + isCollectionRoot && source !== null && typeof source === `object` + ? (source as Record) + : undefined + let result = selectedRecord for (const prop of VIRTUAL_PROP_NAMES) { - if (selectedRecord[prop] == null && prop in row) { + if (isCollectionRoot && prop in selectedRecord) { + // An explicit virtual name in a root projection cannot replace source + // metadata. Ordinary projections acquire their own Collection metadata. + const owner = + sourceRecord && prop in sourceRecord ? sourceRecord : undefined + if (owner) { + const value = owner[prop] + if (selectedRecord[prop] !== value) { + if (result === selectedRecord) + result = copySelectedRow(selectedRecord) + result[prop] = value + } + } else { + if (result === selectedRecord) result = copySelectedRow(selectedRecord) + delete result[prop] + } + } else if (selectedRecord[prop] == null && prop in row) { + if (result === selectedRecord) result = copySelectedRow(selectedRecord) result[prop] = row[prop] } } diff --git a/packages/db/src/query/live/ARCHITECTURE.md b/packages/db/src/query/live/ARCHITECTURE.md index 5a942f1491..6dc031ae33 100644 --- a/packages/db/src/query/live/ARCHITECTURE.md +++ b/packages/db/src/query/live/ARCHITECTURE.md @@ -385,6 +385,25 @@ Relaxing cross-publication reference stability does not permit changing these internal matches. The public-container copy matrix crosses reference-key type, ordered and unordered subqueries, materialization form, and parent/child updates. +Reserved public row fields have a separate callback-input law (maintainer +decision, 2026-10-28). Direct source rows and structured whole-row source +spreads expose genuine source `$key`, `$origin`, `$hasPendingWrites`, and +`$collectionId`, including when a spread selects conflicting reserved names. +A subquery projection that creates its own row shape, including a new +functional object or functional spread, exposes none of those reserved fields +before downstream `fn.select()` code runs. An inline include value keeps its +authored selected shape. Sanitizing only the eventual Collection publication +cannot satisfy this law, because user code may already have derived an +ordinary field from a spoofed input. + +Known implementation gap for the first RC: correlated-child functional callback +inputs can still expose reserved fields from a new shape or authored collisions +from a structured source spread. The root callback receiving path passes its +bounded checks. The callback-input law above remains accepted; final publication +sanitization does not close this earlier boundary. The separate runtime/type +owners and exact RED witnesses are preserved in the +[deferred oracle record](../../../../../docs/contributing/oracle-reviews/2026-10-29-pr-2171-deferred-oracles.md). + D2 hashes enumerable symbol keys and uses exact local-symbol identity plus registry keys for registered symbols. D2 rejects structural cycles with a clear error, including cycles through arrays, Maps, Sets, and enumerable symbol keys. @@ -642,6 +661,15 @@ layout. select throws `IncludeOperatorRequiredError`. The include exposes one stable public Collection facade per active bucket in that edge: +Facade rows obey the Collection virtual-field boundary. Structured whole-row +selections and source spreads retain the selected source's metadata. Other +projections receive the facade Collection's metadata. A functional selection +preserves source metadata only by returning that source row directly; a new +object, including a spread, receives its output Collection's metadata +(maintainer decision). Selected `$hasPendingWrites`, `$origin`, `$key`, and +`$collectionId` fields cannot change this attribution. Inline include values keep their selected +shape because they are not published as Collection rows. + ```text ActiveBucket + BucketRow -> ActiveBucketRow -> BucketFacade(bucket, Collection) Route + BucketFacade -> CellValue(cell, Collection) @@ -855,8 +883,8 @@ query active behind its replay publication barrier. An initial sync error also leaves newly requested demand detached, even when the adapter has installed a loader. The error is final for its sync run: -`markReady()` in the same sync run is a contract violation and throws, and -only a restart, which begins a new sync run, acquires that demand. Releasing it +`markReady()` in the same sync run does nothing, and only a restart, which +begins a new sync run, acquires that demand. Releasing it before the restart creates no physical acquisition or unload. Queued reacquisition must not retry a failed attempt merely because both loading and ready notifications scheduled it. diff --git a/packages/db/src/query/live/collection-config-builder.ts b/packages/db/src/query/live/collection-config-builder.ts index 3044a18a70..ee60295643 100644 --- a/packages/db/src/query/live/collection-config-builder.ts +++ b/packages/db/src/query/live/collection-config-builder.ts @@ -1,5 +1,5 @@ import { D2, output } from '@tanstack/db-ivm' -import { compileQuery } from '../compiler/index.js' +import { compileCollectionQuery } from '../compiler/index.js' import { LoadSubsetOperationAbortedError, MissingAliasInputsError, @@ -1093,7 +1093,7 @@ export class CollectionConfigBuilder< } this.inputsCache = inputs - const compilation = compileQuery( + const compilation = compileCollectionQuery( this.query, this.inputsCache as Record, this.collections, diff --git a/packages/db/src/strategies/commit-completion.ts b/packages/db/src/strategies/commit-completion.ts index aeb06c1a2f..f8ce904c53 100644 --- a/packages/db/src/strategies/commit-completion.ts +++ b/packages/db/src/strategies/commit-completion.ts @@ -6,5 +6,5 @@ export function runWithCommitCompletion( onCommit?: () => Promise | undefined, ): Promise { const transaction = run() - return onCommit?.() ?? transaction.isPersisted.promise + return onCommit?.() ?? transaction.when(`settled`) } diff --git a/packages/db/src/strategies/queueStrategy.ts b/packages/db/src/strategies/queueStrategy.ts index 9e3bc52913..842a91fa2c 100644 --- a/packages/db/src/strategies/queueStrategy.ts +++ b/packages/db/src/strategies/queueStrategy.ts @@ -74,7 +74,7 @@ export function queueStrategy(options?: QueueStrategyOptions): QueueStrategy { await runWithCommitCompletion(run, onCommit) }) .catch(() => { - // Errors are handled via transaction.isPersisted.promise and surfaced there. + // Errors are handled via transaction.when(`settled`) and surfaced there. // This catch prevents unhandled promise rejections from breaking the chain, // ensuring subsequent transactions can still execute even if one fails. }) diff --git a/packages/db/src/transactions.ts b/packages/db/src/transactions.ts index 8f5222e15f..2c2dc23e11 100644 --- a/packages/db/src/transactions.ts +++ b/packages/db/src/transactions.ts @@ -337,6 +337,16 @@ export function getActiveTransaction(): Transaction | undefined { return defaultTransactionScope.getActiveTransaction() } +// TransactionWithMutations is an Omit-based structural view of Transaction, +// so the settlement cannot be a private class field. +const transactionSettlements = new WeakMap() + +function settlementFor( + transaction: Transaction, +): Deferred> { + return transactionSettlements.get(transaction) as Deferred> +} + class Transaction> { public id: string public state: TransactionState @@ -349,23 +359,6 @@ class Transaction> { public readonly collections: Set> = new Set() private captureMutations?: () => void - /** - * Deferred that settles when this transaction settles. - * - * Await `when('settled')` instead. This legacy promise resolves - * when the transaction completes successfully and rejects if the transaction - * fails or is rolled back. - * - * For non-empty commits, the mutation function is the normal settlement - * boundary. This does not inherently prove that a backend has uploaded, - * confirmed, or read back the write unless the mutation function waits for - * that backend observation before returning. An adapter can also register - * commit work during the mutation function; settlement waits for that work. - * - * @deprecated Use `when('settled')` instead. This alias will be removed in - * the 1.0 RC. - */ - public isPersisted: Deferred> public autoCommit: boolean public createdAt: Date public sequenceNumber: number @@ -387,10 +380,11 @@ class Transaction> { this.mutationFn = config.mutationFn this.state = `pending` this.mutations = [] - this.isPersisted = createDeferred>() + const settlement = createDeferred>() + transactionSettlements.set(this, settlement) // A rejection nobody reads is not an unhandled rejection; a caller reads // the outcome from `when('settled')`. - this.isPersisted.promise.catch(() => undefined) + settlement.promise.catch(() => undefined) this.autoCommit = config.autoCommit ?? true this.createdAt = new Date() this.sequenceNumber = sequenceNumber @@ -410,7 +404,14 @@ class Transaction> { * or an adapter-registered commit work item waits for it. */ when(_state: 'settled'): Promise> { - return this.isPersisted.promise + return settlementFor(this).promise + } + + /** Complete a transaction whose work was already applied outside commit(). @internal */ + completeWithoutCommit(): void { + if (this.state !== `pending`) throw new TransactionNotPendingCommitError() + this.setState(`completed`) + settlementFor(this).resolve(this) } setState(newState: TransactionState) { @@ -536,7 +537,7 @@ class Transaction> { if (this.autoCommit) { this.commit().catch(() => { - // Errors from autoCommit are handled via isPersisted.promise + // Errors from autoCommit are handled via when('settled') // This catch prevents unhandled promise rejections }) } @@ -666,7 +667,7 @@ class Transaction> { } } finally { // Reject the promise - this.isPersisted.reject(this.error?.error) + settlementFor(this).reject(this.error?.error) this.touchCollection(wasPersisting) } @@ -801,7 +802,7 @@ class Transaction> { try { this.touchCollection(true) } finally { - this.isPersisted.resolve(this) + settlementFor(this).resolve(this) } return this diff --git a/packages/db/src/types.ts b/packages/db/src/types.ts index 5e54ba9e7b..ce37781c99 100644 --- a/packages/db/src/types.ts +++ b/packages/db/src/types.ts @@ -382,9 +382,10 @@ export type LoadSubsetOptions = { export type LoadSubsetRequestResult = true | Promise /** - * Loads one subset and transfers its ongoing resource ownership only after - * returning `true` or a promise. An implementation that throws synchronously - * must release any partially acquired resource before throwing. A successful + * Loads one subset. A Collection subscription installs its logical owner + * before calling this function. A synchronous throw fails the load but keeps + * that owner; releasing the owner still calls `unloadSubset`, if provided, + * for the failed load. Make that handler safe after partial setup. A successful * implementation must await or return every receipt from the sync * `commit()` calls that establish the loaded subset. A result describes only * the exact `options` passed to this call. @@ -743,35 +744,23 @@ export type DeleteMutationFnParams< collection: Collection } -/** - * @typeParam TReturn - DEPRECATED: Return values are kept for backward compatibility and will be removed in v1.0. - */ export type InsertMutationFn< T extends object = Record, TKey extends string | number = string | number, TUtils extends UtilsRecord = UtilsRecord, - TReturn = any, -> = (params: InsertMutationFnParams) => Promise +> = (params: InsertMutationFnParams) => Promise -/** - * @typeParam TReturn - DEPRECATED: Return values are kept for backward compatibility and will be removed in v1.0. - */ export type UpdateMutationFn< T extends object = Record, TKey extends string | number = string | number, TUtils extends UtilsRecord = UtilsRecord, - TReturn = any, -> = (params: UpdateMutationFnParams) => Promise +> = (params: UpdateMutationFnParams) => Promise -/** - * @typeParam TReturn - DEPRECATED: Return values are kept for backward compatibility and will be removed in v1.0. - */ export type DeleteMutationFn< T extends object = Record, TKey extends string | number = string | number, TUtils extends UtilsRecord = UtilsRecord, - TReturn = any, -> = (params: DeleteMutationFnParams) => Promise +> = (params: DeleteMutationFnParams) => Promise /** * Collection status values for lifecycle management @@ -877,7 +866,6 @@ export interface BaseCollectionConfig< // requires either T to be provided or a schema to be provided but not both! TSchema extends StandardSchemaV1 = never, TUtils extends UtilsRecord = UtilsRecord, - TReturn = any, > { // If an id isn't passed in, a UUID will be // generated for it. @@ -968,7 +956,6 @@ export interface BaseCollectionConfig< syncMode?: SyncMode /** * Optional asynchronous handler function called before an insert operation - * Returning a value is deprecated; coordinate synchronization through collection utilities instead. * * @example * // Basic insert handler @@ -984,8 +971,6 @@ export interface BaseCollectionConfig< * await api.createTodo(newItem) * // Trigger refetch to sync server state * await collection.utils.refetch() - * // Prevent the pre-1.0 compatibility wrapper from refetching again. - * return { refetch: false } * } * * @example @@ -1004,7 +989,6 @@ export interface BaseCollectionConfig< * await api.createTodos(items) * // Refetch to get updated data from server * await collection.utils.refetch() - * return { refetch: false } * } * * @example @@ -1019,14 +1003,10 @@ export interface BaseCollectionConfig< * } * } */ - onInsert?: - | InsertMutationFn - // Return-value compatibility will be removed in v1.0. - | InsertMutationFn + onInsert?: InsertMutationFn /** * Optional asynchronous handler function called before an update operation - * Returning a value is deprecated; coordinate synchronization through collection utilities instead. * * @example * // Basic update handler @@ -1043,8 +1023,6 @@ export interface BaseCollectionConfig< * await api.updateTodo(mutation.original.id, changes) * // Trigger refetch to sync server state * await collection.utils.refetch() - * // Prevent the pre-1.0 compatibility wrapper from refetching again. - * return { refetch: false } * } * * @example @@ -1065,7 +1043,6 @@ export interface BaseCollectionConfig< * })) * await api.updateTodos(updates) * await collection.utils.refetch() - * return { refetch: false } * } * * @example @@ -1081,13 +1058,9 @@ export interface BaseCollectionConfig< * } * } */ - onUpdate?: - | UpdateMutationFn - // Return-value compatibility will be removed in v1.0. - | UpdateMutationFn + onUpdate?: UpdateMutationFn /** * Optional asynchronous handler function called before a delete operation - * Returning a value is deprecated; coordinate synchronization through collection utilities instead. * * @example * // Basic delete handler @@ -1103,8 +1076,6 @@ export interface BaseCollectionConfig< * await api.deleteTodos(keysToDelete) * // Trigger refetch to sync server state * await collection.utils.refetch() - * // Prevent the pre-1.0 compatibility wrapper from refetching again. - * return { refetch: false } * } * * @example @@ -1140,10 +1111,7 @@ export interface BaseCollectionConfig< * } * } */ - onDelete?: - | DeleteMutationFn - // Return-value compatibility will be removed in v1.0. - | DeleteMutationFn + onDelete?: DeleteMutationFn /** * Specifies how to compare data in the collection. diff --git a/packages/db/src/utils.ts b/packages/db/src/utils.ts index 67541bddea..5c973f7627 100644 --- a/packages/db/src/utils.ts +++ b/packages/db/src/utils.ts @@ -408,47 +408,6 @@ export const DEFAULT_COMPARE_OPTIONS: Exclude< stringSort: `locale`, } -/** - * Set of warning keys that have already been shown. - * Used to prevent duplicate warnings from spamming the console. - */ -const warnedKeys = new Set() - -/** - * Log a warning message only once per unique key. - * Subsequent calls with the same key will be silently ignored. - * - * @internal Used by first-party collection adapters. - * - * @param key - Unique identifier for this warning - * @param message - The warning message to display - * - * @example - * ```typescript - * // First call logs the warning - * warnOnce('deprecated-api', 'This API is deprecated') - * - * // Subsequent calls with same key are ignored - * warnOnce('deprecated-api', 'This API is deprecated') // silent - * ``` - */ -export function warnOnce(key: string, message: string): void { - if (warnedKeys.has(key)) { - return - } - warnedKeys.add(key) - console.warn(message) -} - -/** - * Reset all warning states. Primarily useful for testing. - * - * @internal Used by first-party collection adapter tests. - */ -export function resetWarnings(): void { - warnedKeys.clear() -} - /** Throws unless a key has the Collection key type: a string or a number. */ export function assertCollectionKey(key: unknown, item: unknown): void { if (typeof key !== `string` && typeof key !== `number`) { diff --git a/packages/db/src/virtual-props.ts b/packages/db/src/virtual-props.ts index c11a95c83b..a566439add 100644 --- a/packages/db/src/virtual-props.ts +++ b/packages/db/src/virtual-props.ts @@ -38,10 +38,7 @@ export type VirtualOrigin = 'local' | 'remote' /** - * Virtual properties recognized on TanStack DB rows. The new - * `$hasPendingWrites` field is optional here so legacy four-field rows accepted - * by `hasVirtualProps` remain assignable. Rows returned by collections use - * `WithVirtualProps`, which requires it. + * Virtual properties recognized on TanStack DB rows. * * These properties are: * - Computed (not stored in the data model) @@ -49,6 +46,11 @@ export type VirtualOrigin = 'local' | 'remote' * - Available in queries (WHERE, ORDER BY, SELECT) * - Included when spreading rows (`...user`) * + * Structured whole-row selections and spreads retain source metadata. + * A functional selection retains it only when returning a source row directly. + * New functional objects and other projections receive the output Collection's + * metadata. Selected virtual names cannot change that attribution. + * * @template TKey - The type of the row's key (string or number) * * @example @@ -80,30 +82,9 @@ export interface VirtualRowProps< * Whether this row currently has pending local optimistic writes. * * This describes the row's local optimistic state, not backend upload or - * acknowledgement. It is always `false` for local-only collections. It is - * optional only for compatibility with legacy rows; collection-published - * rows always provide it. + * acknowledgement. It is always `false` for local-only collections. */ - readonly $hasPendingWrites?: boolean - - /** - * Whether this row currently has no pending local optimistic writes. - * - * - `true`: No pending local optimistic mutation currently affects this row - * - `false`: One or more pending local optimistic mutations currently affect this row - * - * This is local mutation status. It does not prove that a backend has uploaded, - * confirmed, or read back the row. If you need backend-confirmed status, keep - * your mutation function pending until that backend observation has happened, - * or expose adapter-specific status. - * - * For local-only collections (no sync), this is always `true`. - * For live query collections, this is passed through from the source collection. - * - * @deprecated Use `!row.$hasPendingWrites` instead. This alias will be - * removed in the 1.0 RC. - */ - readonly $synced: boolean + readonly $hasPendingWrites: boolean /** * Collection attribution for this row's current value. `'local'` covers @@ -112,7 +93,8 @@ export interface VirtualRowProps< * key-and-timing rules, including truncate and overlapping mutations. * * For local-only collections, this is always `'local'`. - * For live query collections, this is passed through from the source collection. + * Whole-row live queries preserve source attribution. Projected row roots + * otherwise use the live-query Collection's attribution. */ readonly $origin: VirtualOrigin @@ -128,18 +110,12 @@ export interface VirtualRowProps< * The ID of the source collection this row originated from. * * In joins, this can help identify which collection each row came from. - * For live query collections, this is the ID of the upstream collection. + * Whole-row live queries preserve the upstream Collection's ID. Projected + * row roots otherwise use the live-query Collection's ID. */ readonly $collectionId: string } -/** Virtual properties guaranteed on rows published by this version. @internal */ -export interface PublishedVirtualRowProps< - TKey extends string | number = string | number, -> extends VirtualRowProps { - readonly $hasPendingWrites: boolean -} - /** * Adds virtual properties to a row type. * @@ -150,14 +126,13 @@ export interface PublishedVirtualRowProps< * ```typescript * type User = { id: string; name: string } * type UserWithVirtual = WithVirtualProps - * // { id: string; name: string; $hasPendingWrites: boolean; $synced: boolean; $origin: 'local' | 'remote'; $key: string; $collectionId: string } - * // $synced is deprecated; use !$hasPendingWrites instead. + * // { id: string; name: string; $hasPendingWrites: boolean; $origin: 'local' | 'remote'; $key: string; $collectionId: string } * ``` */ export type WithVirtualProps< T extends object, TKey extends string | number = string | number, -> = T & PublishedVirtualRowProps +> = T & VirtualRowProps /** * Extracts the base type from a type that may have virtual properties. @@ -177,16 +152,14 @@ export type WithoutVirtualProps = T extends unknown : never /** - * Checks if a value has virtual properties attached. Legacy rows with the - * original four properties still match; only rows published by this version - * are guaranteed to carry `$hasPendingWrites`. + * Checks if a value has all virtual properties attached. * * @param value - The value to check * @returns true if the value has virtual properties * * @example * ```typescript - * if (hasVirtualProps(row) && row.$hasPendingWrites !== undefined) { + * if (hasVirtualProps(row)) { * console.log('Pending local writes:', row.$hasPendingWrites) * } * ``` @@ -197,7 +170,7 @@ export function hasVirtualProps( return ( typeof value === 'object' && value !== null && - ['$synced', '$origin', '$key', '$collectionId'].every( + ['$hasPendingWrites', '$origin', '$key', '$collectionId'].every( (name) => name in value, ) ) @@ -215,7 +188,7 @@ export function hasVirtualProps( * @param row - The row to enrich * @param key - The row's key * @param collectionId - The collection's ID - * @param computeSynced - Function to compute $synced if missing + * @param computePendingWrites - Function to compute $hasPendingWrites if missing * @param computeOrigin - Function to compute $origin if missing * @returns The row with virtual properties (possibly the same object if already present) * @@ -228,18 +201,18 @@ export function enrichRowWithVirtualProps< row: T, key: TKey, collectionId: string, - computeSynced: () => boolean, + computePendingWrites: () => boolean, computeOrigin: () => VirtualOrigin, ): WithVirtualProps { // Use nullish coalescing to preserve existing virtual properties (pass-through) // This is the "add-if-missing" pattern described in the RFC const existingRow = row as Partial> - const synced = existingRow.$synced ?? computeSynced() + const hasPendingWrites = + existingRow.$hasPendingWrites ?? computePendingWrites() return { ...row, - $hasPendingWrites: !synced, - $synced: synced, + $hasPendingWrites: hasPendingWrites, $origin: existingRow.$origin ?? computeOrigin(), $key: existingRow.$key ?? key, $collectionId: existingRow.$collectionId ?? collectionId, @@ -252,7 +225,6 @@ export function enrichRowWithVirtualProps< */ export const VIRTUAL_PROP_NAMES = [ '$hasPendingWrites', - '$synced', '$origin', '$key', '$collectionId', diff --git a/packages/db/tests/SortedMap-oracle.test.ts b/packages/db/tests/SortedMap-oracle.test.ts index fd1acf5387..037e3d4255 100644 --- a/packages/db/tests/SortedMap-oracle.test.ts +++ b/packages/db/tests/SortedMap-oracle.test.ts @@ -1,7 +1,11 @@ import { describe, expect, it } from 'vitest' import fc from 'fast-check' import { SortedMap } from '../src/SortedMap' -import { oraclePropertyOptions, oracleRuns } from './oracle-config' +import { + oraclePropertyOptions, + oracleReplayTest, + oracleRuns, +} from './oracle-config' import { createOracleReach } from './oracle-reach' import { declareOracle } from './oracle-declaration' import { expectMap, expectView, modelOrder } from './sorted-map-support' @@ -134,7 +138,220 @@ const fixedHistories: Array> = [ ], ] -declareOracle({ check: [expectMap], model: [modelOrder, restoreBound] }) +// This is reach bookkeeping, not a second ordering model. It remembers the +// authored rank before an in-place change until a restoring comparison, write, +// delete, or clear consumes that evidence. +type ReachState = + | `deferred-burst-observed` + | `entry-yielded-after-pruning` + | `placed-nan-key-written` + | `restore-bound-below-order-size` + | `placed-row-changed-then-written` + | `deferred-row-changed-before-restore` + +type HistoryReach = Pick< + ReturnType>, + `beginHistory` | `reach` +> + +function runHistory( + actions: ReadonlyArray, + order: `key` | `ascending` | `descending`, + reach: HistoryReach, +): void { + reach.beginHistory() + // The driver counts comparator calls to check the work law. + let comparatorCalls = 0 + const actual = new SortedMap( + order === `key` + ? undefined + : (a, b) => { + comparatorCalls++ + return order === `ascending` ? a.rank - b.rank : b.rank - a.rank + }, + ) + const model = new Map() + // Keys written or deleted with deferOrder since the last ordered + // read or immediate write; the next ordered read restores them. + const deferred = new Set() + // Rows changed in place after they were placed. Once one exists, + // comparator order is no longer promised in this history. + const changedPlaced = new Set() + // A key counts only while its current rank differs from the authored rank + // before its first in-place mutation. Returning to that rank loses the + // distinguishing state. Replacing the row loses this evidence too. + const changedDeferred = new Map() + let orderPromised = true + const restored = () => { + deferred.clear() + changedDeferred.clear() + } + const changedDeferredAtRead = () => + order !== `key` && + orderPromised && + [...changedDeferred.keys()].some( + (key) => deferred.has(key) && model.has(key), + ) + const observe = () => { + const observesDeferredChange = changedDeferredAtRead() + if (deferred.size > 16) reach.reach(`deferred-burst-observed`) + if (order !== `key`) { + // The first ordered read restores the deferred keys. + const k = [...deferred].filter((key) => model.has(key)).length + const bound = restoreBound(k, model.size - k) + if (k > 0 && bound < model.size - k) + reach.reach(`restore-bound-below-order-size`) + comparatorCalls = 0 + void [...actual.keys()] + expect(comparatorCalls).toBeLessThanOrEqual(bound) + } + restored() + expectMap(actual, model, order, orderPromised) + // Credit the state after the restoring comparison actually enforced order, + // never merely because a mutation once ran before some later clear. + if (observesDeferredChange) + reach.reach(`deferred-row-changed-before-restore`) + } + const written = (key: ModelKey) => { + changedDeferred.delete(key) + if (changedPlaced.delete(key)) + reach.reach(`placed-row-changed-then-written`) + } + observe() + for (const action of actions) { + const key = action.key + if ( + (action.kind === `set` || action.kind === `delete`) && + !action.deferOrder && + Number.isNaN(key) && + model.has(key) && + !deferred.has(key) + ) + reach.reach(`placed-nan-key-written`) + if (action.kind === `set`) { + written(key) + expect(actual.set(key, { rank: action.value }, action.deferOrder)).toBe( + actual, + ) + model.set(key, action.value) + if (action.deferOrder) { + deferred.add(key) + } else restored() + } else if (action.kind === `delete`) { + written(key) + expect(actual.delete(key, action.deferOrder)).toBe(model.delete(key)) + if (action.deferOrder) deferred.add(key) + else restored() + } else if (action.kind === `mutate`) { + // Change a stored row object without a write, as a sync + // source that reuses its row objects does. The target is a + // deferred row when deferOrder is set, else a placed row. + const targets = [...model.keys()].filter( + (candidate) => deferred.has(candidate) === action.deferOrder, + ) + if (targets.length > 0) { + const target = targets[(action.value + 3) % targets.length]! + const before = changedDeferred.get(target) ?? model.get(target)! + actual.get(target)!.rank = action.value + model.set(target, action.value) + if (order !== `key`) { + if (action.deferOrder) { + if (before === action.value) changedDeferred.delete(target) + else changedDeferred.set(target, before) + } else { + changedDeferred.clear() + changedPlaced.add(target) + orderPromised = false + } + } + } + } else if (action.kind === `burst`) { + for (const [burstKey, value] of burstEntries(action.value)) { + written(burstKey) + actual.set(burstKey, { rank: value }, true) + model.set(burstKey, value) + deferred.add(burstKey) + } + } else if (action.kind === `prune`) { + // Iterate in order, deleting each entry whose value has the + // action's parity with deferOrder, as settled transactions + // are pruned. The iteration yields the order it began with. + const observesDeferredChange = changedDeferredAtRead() + const expected = modelOrder(model, order) + const yielded: Array<[ModelKey, number]> = [] + let pruned = false + for (const [entryKey, row] of actual) { + if (pruned) reach.reach(`entry-yielded-after-pruning`) + yielded.push([entryKey, row.rank]) + if ((row.rank - action.value) % 2 === 0) { + expect(actual.delete(entryKey, true)).toBe(true) + pruned = true + } + } + expectView(yielded, expected, orderPromised) + if (observesDeferredChange) + reach.reach(`deferred-row-changed-before-restore`) + // Iteration restored the order before the deletes. + restored() + for (const [entryKey, value] of expected) { + if ((value - action.value) % 2 !== 0) continue + written(entryKey) + model.delete(entryKey) + deferred.add(entryKey) + } + } else { + actual.clear() + model.clear() + deferred.clear() + changedDeferred.clear() + changedPlaced.clear() + // No slot survives clear, so order is promised again. + orderPromised = true + } + expect(actual.size).toBe(model.size) + expect(actual.has(key)).toBe(model.has(key)) + expect(actual.get(key)?.rank).toBe(model.get(key)) + if ( + action.kind === `clear` || + ((action.kind === `set` || action.kind === `delete`) && + !action.deferOrder) || + action.observe + ) + observe() + } + observe() +} + +/** + * Every generated array keeps its original actions as an unchanged suffix. + * Before it, a fresh two-row episode changes the only deferred row across its + * peer's rank and observes its restored order. A clear then restores the old + * suffix's empty starting condition, including the original final read. + * + * Keys and crossing direction come from generated values, not a selected seed. + * Both signs, finite keys, and NaN remain legal. The peer always has a distinct + * key. An empty input stays empty; the campaign's arrays have 6 to 40 actions. + * Existing fixed examples bypass this prefix and still replay unchanged. + */ +function withDeferredChange(actions: Array): Array { + const first = actions[0] + if (first === undefined) return actions + const peer = first.key === 0 ? 1 : 0 + const before = first.value < 0 ? -3 : 3 + return [ + fixedStep(`set`, peer, 0), + fixedStep(`set`, first.key, before, true), + fixedStep(`mutate`, first.key, -before, true, true), + fixedStep(`clear`, 0), + ...actions, + ] +} + +declareOracle({ + check: [expectMap, expectView], + model: [modelOrder, restoreBound], + grammar: [withDeferredChange], +}) describe.each([`key`, `ascending`, `descending`] as const)( `SortedMap %s history`, @@ -167,174 +384,35 @@ describe.each([`key`, `ascending`, `descending`] as const)( ) await fc.assert( fc.asyncProperty( - fc.array( - fc.record({ - kind: fc.constantFrom( - `set`, - `set`, - `set`, - `delete`, - `delete`, - `delete`, - `clear`, - `burst`, - `prune`, - `mutate`, - `mutate`, - ), - key: fc.oneof( - { arbitrary: fc.integer({ min: -3, max: 3 }), weight: 6 }, - { arbitrary: fc.constant(Number.NaN), weight: 2 }, - ), - value: fc.integer({ min: -3, max: 3 }), - deferOrder: fc.boolean(), - observe: fc.boolean(), - }), - { minLength: 6, maxLength: 40 }, - ), - (actions) => { - reach.beginHistory() - // The driver counts comparator calls to check the work law. - let comparatorCalls = 0 - const actual = new SortedMap( - order === `key` - ? undefined - : (a, b) => { - comparatorCalls++ - return order === `ascending` - ? a.rank - b.rank - : b.rank - a.rank - }, + fc + .array( + fc.record({ + kind: fc.constantFrom( + `set`, + `set`, + `set`, + `delete`, + `delete`, + `delete`, + `clear`, + `burst`, + `prune`, + `mutate`, + `mutate`, + ), + key: fc.oneof( + { arbitrary: fc.integer({ min: -3, max: 3 }), weight: 6 }, + { arbitrary: fc.constant(Number.NaN), weight: 2 }, + ), + value: fc.integer({ min: -3, max: 3 }), + deferOrder: fc.boolean(), + observe: fc.boolean(), + }), + { minLength: 6, maxLength: 40 }, ) - const model = new Map() - // Keys written or deleted with deferOrder since the last ordered - // read or immediate write; the next ordered read restores them. - const deferred = new Set() - // Rows changed in place after they were placed. Once one exists, - // comparator order is no longer promised in this history. - const changedPlaced = new Set() - let orderPromised = true - const restored = () => { - deferred.clear() - } - const observe = () => { - if (deferred.size > 16) reach.reach(`deferred-burst-observed`) - if (order !== `key`) { - // The first ordered read restores the deferred keys. - const k = [...deferred].filter((key) => model.has(key)).length - const bound = restoreBound(k, model.size - k) - if (k > 0 && bound < model.size - k) - reach.reach(`restore-bound-below-order-size`) - comparatorCalls = 0 - void [...actual.keys()] - expect(comparatorCalls).toBeLessThanOrEqual(bound) - } - restored() - expectMap(actual, model, order, orderPromised) - } - const written = (key: ModelKey) => { - if (changedPlaced.delete(key)) - reach.reach(`placed-row-changed-then-written`) - } - observe() - for (const action of actions) { - const key = action.key - if ( - (action.kind === `set` || action.kind === `delete`) && - !action.deferOrder && - Number.isNaN(key) && - model.has(key) && - !deferred.has(key) - ) - reach.reach(`placed-nan-key-written`) - if (action.kind === `set`) { - written(key) - expect( - actual.set(key, { rank: action.value }, action.deferOrder), - ).toBe(actual) - model.set(key, action.value) - if (action.deferOrder) { - deferred.add(key) - } else restored() - } else if (action.kind === `delete`) { - written(key) - expect(actual.delete(key, action.deferOrder)).toBe( - model.delete(key), - ) - if (action.deferOrder) deferred.add(key) - else restored() - } else if (action.kind === `mutate`) { - // Change a stored row object without a write, as a sync - // source that reuses its row objects does. The target is a - // deferred row when deferOrder is set, else a placed row. - const targets = [...model.keys()].filter( - (candidate) => - deferred.has(candidate) === action.deferOrder, - ) - if (targets.length > 0) { - const target = targets[(action.value + 3) % targets.length]! - actual.get(target)!.rank = action.value - model.set(target, action.value) - if (order !== `key`) { - if (action.deferOrder) - reach.reach(`deferred-row-changed-before-restore`) - else { - changedPlaced.add(target) - orderPromised = false - } - } - } - } else if (action.kind === `burst`) { - for (const [burstKey, value] of burstEntries(action.value)) { - written(burstKey) - actual.set(burstKey, { rank: value }, true) - model.set(burstKey, value) - deferred.add(burstKey) - } - } else if (action.kind === `prune`) { - // Iterate in order, deleting each entry whose value has the - // action's parity with deferOrder, as settled transactions - // are pruned. The iteration yields the order it began with. - const expected = modelOrder(model, order) - const yielded: Array<[ModelKey, number]> = [] - let pruned = false - for (const [entryKey, row] of actual) { - if (pruned) reach.reach(`entry-yielded-after-pruning`) - yielded.push([entryKey, row.rank]) - if ((row.rank - action.value) % 2 === 0) { - expect(actual.delete(entryKey, true)).toBe(true) - pruned = true - } - } - expectView(yielded, expected, orderPromised) - // Iteration restored the order before the deletes. - restored() - for (const [entryKey, value] of expected) { - if ((value - action.value) % 2 !== 0) continue - written(entryKey) - model.delete(entryKey) - deferred.add(entryKey) - } - } else { - actual.clear() - model.clear() - deferred.clear() - changedPlaced.clear() - // No slot survives clear, so order is promised again. - orderPromised = true - } - expect(actual.size).toBe(model.size) - expect(actual.has(key)).toBe(model.has(key)) - expect(actual.get(key)?.rank).toBe(model.get(key)) - if ( - action.kind === `clear` || - ((action.kind === `set` || action.kind === `delete`) && - !action.deferOrder) || - action.observe - ) - observe() - } - observe() + .map(withDeferredChange), + (actions) => { + runHistory(actions, order, reach) return Promise.resolve() }, ), @@ -352,3 +430,103 @@ describe.each([`key`, `ascending`, `descending`] as const)( ) }, ) + +/** + * These grammar ablations replay the shared production driver and Map/full-sort + * check. Only a still-present changed deferred row at a promised-order read + * earns reach. Reading after a clear, replacement, delete, eager restore, or a + * placed-row change cannot stand in for that observation. Returning to the + * authored pre-mutation rank is also not a distinguishing changed-row state. + * + * The 16 bounded inputs vary every small key, NaN, and both crossing directions. + * Each positive control exceeds the unchanged minimum of five histories. Each + * ablation must fail that same reach obligation; it is a missing-premise control, + * not a rejection of wrong production output. + */ +describe.each([`ascending`, `descending`] as const)( + `SortedMap %s deferred-change reach calibration`, + (order) => { + oracleReplayTest(it).each([ + `observed`, + `omitted observation`, + `empty history`, + `equal rank`, + `returned rank`, + `overwrite`, + `delete`, + `eager restore`, + `unpromised order`, + `prune observation`, + ] as const)(`credits only a restoring comparison: %s`, (control) => { + const state = `deferred-row-changed-before-restore` + const reach = createOracleReach( + `sorted-map.${order}.${control}`, + [state], + { minimum: 5, replaying: false }, + ) + const watchedReach: HistoryReach = { + beginHistory: reach.beginHistory, + reach: (reached) => { + if (reached === state) reach.reach(reached) + }, + } + for (const key of [-3, -2, -1, 0, 1, 2, 3, Number.NaN]) { + for (const value of [-3, 3]) { + const prefix = withDeferredChange([ + fixedStep(`set`, key, value), + ]).slice(0, 3) + const target = prefix[1]! + const mutation = prefix[2]! + const held = { ...mutation, observe: false } + const peer = prefix[0]! + let actions: Array + switch (control) { + case `observed`: + actions = prefix + break + case `prune observation`: + actions = [...prefix.slice(0, 2), held, fixedStep(`prune`, 0)] + break + case `unpromised order`: + actions = [ + ...prefix.slice(0, 2), + fixedStep(`mutate`, peer.key, 1), + mutation, + ] + break + case `empty history`: + actions = withDeferredChange([]) + break + default: { + const after = + control === `returned rank` + ? fixedStep(`mutate`, target.key, target.value, true, true) + : control === `overwrite` + ? fixedStep(`set`, target.key, 2, true, true) + : control === `delete` + ? fixedStep(`delete`, target.key, 0, true, true) + : control === `eager restore` + ? fixedStep(`set`, peer.key, 0) + : fixedStep(`clear`, 0) + actions = [ + ...prefix.slice(0, 2), + control === `equal rank` + ? { ...mutation, value: target.value } + : held, + after, + ] + } + } + runHistory(actions, order, watchedReach) + } + } + if (process.env.TANSTACK_DB_ORACLE_REACH_REPORT) + console.log(`sorted-map.${order}.${control}`, [...reach.counts()]) + if (control === `observed` || control === `prune observation`) { + reach.assertReached() + } else { + expect(() => reach.assertReached()).toThrow(`${state} (0)`) + } + }) + }, +) diff --git a/packages/db/tests/change-event-history-oracle.test.ts b/packages/db/tests/change-event-history-oracle.test.ts index da1445116a..61fe073a5f 100644 --- a/packages/db/tests/change-event-history-oracle.test.ts +++ b/packages/db/tests/change-event-history-oracle.test.ts @@ -209,7 +209,7 @@ function expectedEventTrace( function applyOp( collection: Collection, op: Op, -): { isPersisted: { promise: Promise } } { +): { when: (state: 'settled') => Promise } { switch (op.kind) { case `insert`: return collection.insert({ @@ -226,8 +226,8 @@ function applyOp( } } -const settle = (tx: { isPersisted: { promise: Promise } }) => - tx.isPersisted.promise +const settle = (tx: { when: (state: 'settled') => Promise }) => + tx.when(`settled`) function publicRows( collection: Collection, diff --git a/packages/db/tests/collection-gc-mutation-oracle.property.test.ts b/packages/db/tests/collection-gc-mutation-oracle.property.test.ts index 1d211e057b..6e36107659 100644 --- a/packages/db/tests/collection-gc-mutation-oracle.property.test.ts +++ b/packages/db/tests/collection-gc-mutation-oracle.property.test.ts @@ -146,7 +146,7 @@ async function runMutationHistory(history: History): Promise { state: string mutations: ReadonlyArray<{ collection: unknown }> rollback: () => unknown - isPersisted: { promise: Promise } + when: (state: 'settled') => Promise } | undefined let receipt: `pending` | `fulfilled` | `rejected` = `pending` @@ -253,7 +253,7 @@ async function runMutationHistory(history: History): Promise { void manual.commit().catch(() => {}) transaction = manual } - void transaction.isPersisted.promise.then( + void transaction.when('settled').then( () => { receipt = `fulfilled` }, diff --git a/packages/db/tests/collection-gc-transaction-oracle.property.test.ts b/packages/db/tests/collection-gc-transaction-oracle.property.test.ts index edfe442564..3d5d61a43e 100644 --- a/packages/db/tests/collection-gc-transaction-oracle.property.test.ts +++ b/packages/db/tests/collection-gc-transaction-oracle.property.test.ts @@ -250,7 +250,8 @@ async function runHistory(history: History): Promise { } else { collections[step.into] .insert({ id }) - .isPersisted.promise.catch(() => {}) + .when('settled') + .catch(() => {}) } model.pendingHandlers.push({ id, sides: into }) for (const side of into) { diff --git a/packages/db/tests/collection-indexes.test.ts b/packages/db/tests/collection-indexes.test.ts index d08920b07d..72d8fcf83e 100644 --- a/packages/db/tests/collection-indexes.test.ts +++ b/packages/db/tests/collection-indexes.test.ts @@ -451,7 +451,7 @@ describe(`Collection Indexes`, () => { const tx = createTransaction({ mutationFn }) tx.mutate(() => collection.insert(newItem)) - await tx.isPersisted.promise + await tx.when(`settled`) // Item should be in collection state expect(collection.size).toBe(6) @@ -488,7 +488,7 @@ describe(`Collection Indexes`, () => { draft.age = 26 }), ) - await tx.isPersisted.promise + await tx.when(`settled`) // Updated item should be in collection state const updatedItem = collection.get(`1`) @@ -518,7 +518,7 @@ describe(`Collection Indexes`, () => { draft.age = 26 }), ) - await tx.isPersisted.promise + await tx.when(`settled`) // Updated item should be in collection state const updatedItem = collection.get(`1`) @@ -551,7 +551,7 @@ describe(`Collection Indexes`, () => { const tx = createTransaction({ mutationFn }) tx.mutate(() => collection.delete(`1`)) - await tx.isPersisted.promise + await tx.when(`settled`) // Item should be removed from collection state expect(collection.size).toBe(4) @@ -580,7 +580,7 @@ describe(`Collection Indexes`, () => { const tx = createTransaction({ mutationFn }) tx.mutate(() => collection.delete(`1`)) - await tx.isPersisted.promise + await tx.when(`settled`) // Item should be removed from collection state expect(collection.size).toBe(4) @@ -615,7 +615,7 @@ describe(`Collection Indexes`, () => { draft.status = `active` }), ) - await tx1.isPersisted.promise + await tx1.when(`settled`) const dataChanges = stripVirtualOnlyUpdates(activeChanges) expect(dataChanges).toHaveLength(1) @@ -629,7 +629,7 @@ describe(`Collection Indexes`, () => { draft.status = `inactive` }), ) - await tx2.isPersisted.promise + await tx2.when(`settled`) // Should trigger delete event for item that no longer matches filter const filteredChanges = stripVirtualOnlyUpdates(activeChanges) @@ -660,7 +660,7 @@ describe(`Collection Indexes`, () => { draft.status = `active` }), ) - await tx1.isPersisted.promise + await tx1.when(`settled`) const dataChanges = stripVirtualOnlyUpdates(activeChanges) expect(dataChanges).toHaveLength(1) @@ -674,7 +674,7 @@ describe(`Collection Indexes`, () => { draft.status = `inactive` }), ) - await tx2.isPersisted.promise + await tx2.when(`settled`) // Subscriber shoiuld not receive any changes // because it is not aware of that key @@ -2160,7 +2160,7 @@ describe(`Collection Indexes`, () => { createdAt: new Date(), }), ) - await tx1.isPersisted.promise + await tx1.when(`settled`) const dataChanges = stripVirtualOnlyUpdates(changes) expect(dataChanges).toHaveLength(1) @@ -2178,7 +2178,7 @@ describe(`Collection Indexes`, () => { createdAt: new Date(), }), ) - await tx2.isPersisted.promise + await tx2.when(`settled`) expect(changes).toHaveLength(0) @@ -2190,7 +2190,7 @@ describe(`Collection Indexes`, () => { draft.status = `inactive` }), ) - await tx3.isPersisted.promise + await tx3.when(`settled`) expect(changes).toHaveLength(1) // Should emit delete event for item leaving filter expect(changes[0]?.type).toBe(`delete`) @@ -2237,7 +2237,7 @@ describe(`Collection Indexes`, () => { draft.age = 32 }), ) - await tx.isPersisted.promise + await tx.when(`settled`) const dataChanges = stripVirtualOnlyUpdates(changes) expect(dataChanges).toHaveLength(1) @@ -2413,9 +2413,9 @@ describe(`Collection Indexes`, () => { tx3.mutate(() => collection.delete(`2`)) await Promise.all([ - tx1.isPersisted.promise, - tx2.isPersisted.promise, - tx3.isPersisted.promise, + tx1.when(`settled`), + tx2.when(`settled`), + tx3.when(`settled`), ]) // Wait a bit for sync to complete and indexes to update diff --git a/packages/db/tests/collection-metadata-publication-oracle.property.test.ts b/packages/db/tests/collection-metadata-publication-oracle.property.test.ts index 5f843de378..f962d8ce50 100644 --- a/packages/db/tests/collection-metadata-publication-oracle.property.test.ts +++ b/packages/db/tests/collection-metadata-publication-oracle.property.test.ts @@ -83,7 +83,6 @@ type PublishedPublicationRow = PublicationRow & { $key: number $origin: `local` | `remote` $hasPendingWrites: boolean - $synced: boolean } // Grammar controls: the fixed same-key and cancellation witnesses below are @@ -231,7 +230,6 @@ function selectPublishedRow( $key: published.$key, $origin: published.$origin, $hasPendingWrites: published.$hasPendingWrites, - $synced: published.$synced, } } @@ -329,7 +327,7 @@ async function applyRound( draft.position = next.position }) }) - await transaction.isPersisted.promise + await transaction.when(`settled`) model.set(round.key, next) if (round.outcome === `commit`) { @@ -345,26 +343,25 @@ async function applyRound( corruptCut?.(harness.cuts.slice(cutCountBefore)) const virtualRow = ( row: PublicationRow, - synced: boolean, + hasPendingWrites: boolean, ): PublishedPublicationRow => ({ ...row, $collectionId: harness.rows.id, $key: row.id, $origin: `local`, - $hasPendingWrites: !synced, - $synced: synced, + $hasPendingWrites: hasPendingWrites, }) const expectedOptimisticChange = keyWasPreviouslyPublished ? { type: `update`, key: round.key, - value: virtualRow(next, false), - previousValue: virtualRow(previous, true), + value: virtualRow(next, true), + previousValue: virtualRow(previous, false), } : { type: `insert`, key: round.key, - value: virtualRow(next, false), + value: virtualRow(next, true), previousValue: undefined, } expect( @@ -377,8 +374,8 @@ async function applyRound( { type: `update`, key: round.key, - value: virtualRow(next, true), - previousValue: virtualRow(next, false), + value: virtualRow(next, false), + previousValue: virtualRow(next, true), }, ], ]) @@ -545,7 +542,7 @@ async function expectMetadataCancellationOwnership( ) persistence.resolve() - await heldTransaction.isPersisted.promise + await heldTransaction.when(`settled`) await expect(retained).resolves.toBeUndefined() expect(readMetadata(harness, [0, 1, 2])).toEqual( observableMetadata(expectedMetadata, [0, 1, 2]), @@ -557,7 +554,7 @@ async function expectMetadataCancellationOwnership( }, () => [ () => persistence.resolve(), - () => heldTransaction.isPersisted.promise.catch(() => undefined), + () => heldTransaction.when(`settled`).catch(() => undefined), () => canceled.catch(() => undefined), () => retained.catch(() => undefined), ], diff --git a/packages/db/tests/collection-mutation-startup.test.ts b/packages/db/tests/collection-mutation-startup.test.ts index 63dacaf38c..49808b4c43 100644 --- a/packages/db/tests/collection-mutation-startup.test.ts +++ b/packages/db/tests/collection-mutation-startup.test.ts @@ -318,7 +318,7 @@ describe(`Collection mutation startup oracle`, () => { ).toEqual( operation === `update` ? [{ id: `target`, value: `updated` }] : [], ) - await transaction.isPersisted.promise + await transaction.when(`settled`) expect(onUpdate).toHaveBeenCalledTimes(operation === `update` ? 1 : 0) expect(onDelete).toHaveBeenCalledTimes(operation === `delete` ? 1 : 0) } finally { @@ -475,7 +475,7 @@ describe(`Collection mutation startup oracle`, () => { try { const transaction = collection.insert(local) - await transaction.isPersisted.promise + await transaction.when(`settled`) expect(syncStarts).toBe(1) expect(onInsert).toHaveBeenCalledTimes(1) expect( @@ -521,7 +521,7 @@ describe(`Collection mutation startup oracle`, () => { expect(syncStarts).toBe(1) expect(collection.status).toBe(`ready`) const second = collection.insert({ id: `second`, value: `second` }) - await Promise.all([first.isPersisted.promise, second.isPersisted.promise]) + await Promise.all([first.when(`settled`), second.when(`settled`)]) expect(syncStarts).toBe(1) expect(collection.status).toBe(`ready`) expect(handlerCalls).toBe(2) @@ -663,7 +663,7 @@ describe(`Collection readiness during a pending mutation`, () => { await handlerEntered if (source === `outside-handler`) startup() const settled = await Promise.race([ - insert.isPersisted.promise.then(() => `settled` as const), + insert.when(`settled`).then(() => `settled` as const), new Promise<`pending`>((resolve) => setTimeout(() => resolve(`pending`), 200), ), diff --git a/packages/db/tests/collection-query-publication-boundaries.test.ts b/packages/db/tests/collection-query-publication-boundaries.test.ts index 958c44e53a..75be378f3a 100644 --- a/packages/db/tests/collection-query-publication-boundaries.test.ts +++ b/packages/db/tests/collection-query-publication-boundaries.test.ts @@ -120,12 +120,12 @@ it.each([1, 99])( sync.write({ type: `insert`, value: { id: 3, value: 3 } }) const blocked = sync.commit() first.resolve() - await tx1.isPersisted.promise + await tx1.when(`settled`) sync.begin() sync.write({ type: `insert`, value: { id: 1, value: echoValue } }) const echo = sync.commit() second.resolve() - await tx2.isPersisted.promise + await tx2.when(`settled`) await blocked await echo await flushPromises() diff --git a/packages/db/tests/collection-row-metadata-composition-oracle.test.ts b/packages/db/tests/collection-row-metadata-composition-oracle.test.ts index 762c1bd571..80356d763c 100644 --- a/packages/db/tests/collection-row-metadata-composition-oracle.test.ts +++ b/packages/db/tests/collection-row-metadata-composition-oracle.test.ts @@ -460,7 +460,7 @@ async function observeMetadata( if (lane === `rebuilt`) { expect(earlierReceipt).not.toBe(true) release() - await blocker!.isPersisted.promise + await blocker!.when(`settled`) await earlierReceipt } const receipt = sync.commit() @@ -471,7 +471,7 @@ async function observeMetadata( expect(receipt).toBe(true) else expect(receipt).not.toBe(true) release() - await blocker!.isPersisted.promise + await blocker!.when(`settled`) } await receipt return sync.metadata!.row.get(1) @@ -636,7 +636,7 @@ async function observeHeldReads( await expect(sync.commit(abort.signal)).rejects.toThrow() release() - await blocker.isPersisted.promise + await blocker.when('settled') await firstReceipt await laterReceipt return { committed, open: openReads, applied: readHeld(sync) } @@ -734,7 +734,7 @@ async function observeNested( const receipt = sync.commit() if (blocker) { release() - await blocker.isPersisted.promise + await blocker.when('settled') } await nestedReceipt await receipt diff --git a/packages/db/tests/collection-state-retention-oracle.property.test.ts b/packages/db/tests/collection-state-retention-oracle.property.test.ts index 64f764319a..4130e98fb6 100644 --- a/packages/db/tests/collection-state-retention-oracle.property.test.ts +++ b/packages/db/tests/collection-state-retention-oracle.property.test.ts @@ -939,7 +939,7 @@ it(`rejects a partial update applied as a full replacement`, async () => { ), // A row observation must reject the replaced row, not the mutant checkpoint. ).rejects.toThrow( - /whole forward publication|: reads .* expected |rows visible when isPersisted settled/, + /whole forward publication|: reads .* expected |rows visible when settlement completed/, ) }) it(`rejects a default lost only after settlement`, async () => { diff --git a/packages/db/tests/collection-state-retention.test.ts b/packages/db/tests/collection-state-retention.test.ts index 0bb5a15e67..941b1e1a6b 100644 --- a/packages/db/tests/collection-state-retention.test.ts +++ b/packages/db/tests/collection-state-retention.test.ts @@ -217,7 +217,6 @@ it(`publishes a virtual-state update when a restarted optimistic row is confirme $key: number $origin: `local` | `remote` $hasPendingWrites: boolean - $synced: boolean } type ObservedChange = { type: string @@ -232,7 +231,6 @@ it(`publishes a virtual-state update when a restarted optimistic row is confirme $key: row.$key, $origin: row.$origin, $hasPendingWrites: row.$hasPendingWrites, - $synced: row.$synced, }) const publications: Array<{ changes: Array @@ -278,7 +276,7 @@ it(`publishes a virtual-state update when a restarted optimistic row is confirme }) readMutationState = () => transaction.state rollbackMutation = () => transaction.rollback() - void transaction.isPersisted.promise.catch(() => undefined) + void transaction.when('settled').catch(() => undefined) transaction.mutate(() => collection.insert({ id: 2, value: 2 })) mutationCommit = transaction.commit() @@ -301,7 +299,6 @@ it(`publishes a virtual-state update when a restarted optimistic row is confirme $key: id, $origin: `remote`, $hasPendingWrites: false, - $synced: true, }) const localRow = (id: number): ObservedRow => ({ id, @@ -310,7 +307,6 @@ it(`publishes a virtual-state update when a restarted optimistic row is confirme $key: id, $origin: `local`, $hasPendingWrites: true, - $synced: false, }) const expectedPublications = [ { @@ -626,7 +622,7 @@ it.each([false, true])( await expect(Promise.resolve(inner)).resolves.not.toThrow() if (blocker) { release() - await blocker.isPersisted.promise + await blocker.when('settled') } expect([...collection._state.syncedData.keys()]).toEqual([3]) expect(collection._state.pendingSyncedTransactions).toHaveLength(0) @@ -1069,13 +1065,16 @@ it(`snapshots a buffered delete before its row object is reused`, async () => { publications.push( changes.map((change) => { const previous = change.previousValue as - | (LivePreviousRow & { $synced: boolean; $origin: string }) + | (LivePreviousRow & { + $hasPendingWrites: boolean + $origin: string + }) | undefined return { type: change.type, value: change.value.value, previousValue: previous?.value, - previousSynced: previous?.$synced, + previousSynced: previous ? !previous.$hasPendingWrites : undefined, previousOrigin: previous?.$origin, } }), @@ -1540,7 +1539,7 @@ it(`does not carry previous-value state across a failed sync run`, async () => { throw optimisticFailure }, }) - const persistence = optimistic.isPersisted.promise.catch((error) => error) + const persistence = optimistic.when('settled').catch((error) => error) optimistic.mutate(() => collection.insert({ id: 3, value: 30 })) expect(collection.has(3), `failed-sync optimistic overlay appears`).toBe( true, @@ -1629,9 +1628,9 @@ it(`drops a completed direct insert whose handler wrote no sync row`, async () = await collection.stateWhenReady() sync.begin() sync.truncate() - await collection.insert({ id: 1, value: 1 }).isPersisted.promise + await collection.insert({ id: 1, value: 1 }).when('settled') expect(sync.commit()).toBe(true) - await collection.insert({ id: 2, value: 2 }).isPersisted.promise + await collection.insert({ id: 2, value: 2 }).when('settled') expect(events).toEqual([`insert:1`, `delete:1`, `insert:2`, `delete:2`]) expect([...collection.state.keys()]).toEqual([]) diff --git a/packages/db/tests/collection-subscribe-changes.test.ts b/packages/db/tests/collection-subscribe-changes.test.ts index 3afe5cd9fb..050fc07340 100644 --- a/packages/db/tests/collection-subscribe-changes.test.ts +++ b/packages/db/tests/collection-subscribe-changes.test.ts @@ -464,9 +464,9 @@ describe(`Collection.subscribeChanges`, () => { ]) callback.mockReset() - await tx.isPersisted.promise + await tx.when(`settled`) - // Sync confirmation should only change virtual props ($synced/$origin) + // Sync confirmation should only change virtual props ($hasPendingWrites/$origin) expect(callback).toHaveBeenCalledTimes(1) expect( normalizeChangesWithoutVirtualUpdates(callback.mock.calls[0]![0]), @@ -502,9 +502,9 @@ describe(`Collection.subscribeChanges`, () => { ]) callback.mockReset() - await updateTx.isPersisted.promise + await updateTx.when(`settled`) - // Sync confirmation should only change virtual props ($synced/$origin) + // Sync confirmation should only change virtual props ($hasPendingWrites/$origin) expect(callback).toHaveBeenCalledTimes(1) expect( normalizeChangesWithoutVirtualUpdates(callback.mock.calls[0]![0]), @@ -624,7 +624,7 @@ describe(`Collection.subscribeChanges`, () => { // Wait for changes to propagate await waitForChanges() - // Sync confirmation should only change virtual props ($synced/$origin) + // Sync confirmation should only change virtual props ($hasPendingWrites/$origin) expect(callback).toHaveBeenCalledTimes(1) expect( normalizeChangesWithoutVirtualUpdates(callback.mock.calls[0]![0]), @@ -851,10 +851,10 @@ describe(`Collection.subscribeChanges`, () => { // Keep the immediate optimistic assertions isolated above, then prove that // every auto-commit also completes through applied-receipt settlement. await Promise.all([ - tx1.isPersisted.promise, - tx2.isPersisted.promise, - tx3.isPersisted.promise, - tx4.isPersisted.promise, + tx1.when(`settled`), + tx2.when(`settled`), + tx3.when(`settled`), + tx4.when(`settled`), ]) // Clean up @@ -1057,7 +1057,7 @@ describe(`Collection.subscribeChanges`, () => { }) // Wait for transactions to complete - await Promise.all([tx1.isPersisted.promise, tx2.isPersisted.promise]) + await Promise.all([tx1.when(`settled`), tx2.when(`settled`)]) }) it(`should emit insert events for new data after truncate`, async () => { @@ -1357,7 +1357,7 @@ describe(`Collection.subscribeChanges`, () => { callback.mockClear() const tx = collection.delete(1, { optimistic: false }) - await tx.isPersisted.promise + await tx.when(`settled`) expect(callback).toHaveBeenCalledTimes(1) const deleteChanges = callback.mock.calls[0]![0] as ChangesPayload<{ @@ -2263,7 +2263,7 @@ describe(`Virtual properties`, () => { expect(insertChange).toBeDefined() const value = insertChange!.value as Record - expect(value.$synced).toBe(true) + expect(value.$hasPendingWrites).toBe(false) expect(value.$origin).toBe(`remote`) expect(value.$key).toBe(`row-1`) expect(value.$collectionId).toBe(`virtual-props-change-test`) @@ -2271,7 +2271,7 @@ describe(`Virtual properties`, () => { subscription.unsubscribe() }) - it(`should set $synced false and $origin local for optimistic inserts`, async () => { + it(`should set pending writes and local origin for optimistic inserts`, async () => { const changes: Array> = [] const collection = createCollection<{ id: string; value: string }, string>({ @@ -2299,13 +2299,13 @@ describe(`Virtual properties`, () => { expect(insertChange).toBeDefined() const value = insertChange!.value as Record - expect(value.$synced).toBe(false) + expect(value.$hasPendingWrites).toBe(true) expect(value.$origin).toBe(`local`) subscription.unsubscribe() }) - it(`should emit an update when $synced flips on confirmation`, async () => { + it(`should emit an update when pending writes clear on confirmation`, async () => { const changes: Array< ChangeMessage> > = [] @@ -2356,7 +2356,7 @@ describe(`Virtual properties`, () => { (change) => change.type === `insert` && change.key === `row-1`, ) expect(optimisticInsert).toBeDefined() - expect(optimisticInsert!.value.$synced).toBe(false) + expect(optimisticInsert!.value.$hasPendingWrites).toBe(true) changes.length = 0 @@ -2377,8 +2377,8 @@ describe(`Virtual properties`, () => { (change) => change.type === `update` && change.key === `row-1`, ) expect(confirmedUpdate).toBeDefined() - expect(confirmedUpdate!.value.$synced).toBe(true) - expect(confirmedUpdate!.previousValue?.$synced).toBe(false) + expect(confirmedUpdate!.value.$hasPendingWrites).toBe(false) + expect(confirmedUpdate!.previousValue?.$hasPendingWrites).toBe(true) subscription.unsubscribe() }) @@ -2424,9 +2424,9 @@ describe(`Virtual properties`, () => { ) expect(updateChange).toBeDefined() - expect(updateChange!.value.$synced).toBe(false) + expect(updateChange!.value.$hasPendingWrites).toBe(true) expect(updateChange!.value.$origin).toBe(`local`) - expect(updateChange!.previousValue?.$synced).toBe(true) + expect(updateChange!.previousValue?.$hasPendingWrites).toBe(false) expect(updateChange!.previousValue?.$origin).toBe(`remote`) subscription.unsubscribe() @@ -2486,7 +2486,7 @@ describe(`Virtual properties`, () => { expect(insertChange).toBeDefined() const value = insertChange!.value as Record - expect(value.$synced).toBe(true) + expect(value.$hasPendingWrites).toBe(false) expect(value.$origin).toBe(`local`) subscription.unsubscribe() @@ -2528,7 +2528,7 @@ describe(`Virtual properties`, () => { const tx = collection.insert({ id: `row-1`, value: `optimistic` }) try { - await tx.isPersisted.promise + await tx.when(`settled`) } catch { // Expected failure } @@ -2552,7 +2552,7 @@ describe(`Virtual properties`, () => { expect(latestChange).toBeDefined() const value = latestChange!.value as Record - expect(value.$synced).toBe(true) + expect(value.$hasPendingWrites).toBe(false) expect(value.$origin).toBe(`remote`) subscription.unsubscribe() @@ -2593,7 +2593,7 @@ describe(`Virtual properties`, () => { { id: `row-1`, value: `local` }, { optimistic: false }, ) - await expect(tx.isPersisted.promise).rejects.toThrow(`insert failed`) + await expect(tx.when(`settled`)).rejects.toThrow(`insert failed`) if (!syncFns) { throw new Error(`Sync not ready`) @@ -2608,7 +2608,7 @@ describe(`Virtual properties`, () => { await waitForChanges() - expect(collection.state.get(`row-1`)?.$synced).toBe(true) + expect(collection.state.get(`row-1`)?.$hasPendingWrites).toBe(false) expect(collection.state.get(`row-1`)?.$origin).toBe(`remote`) }) @@ -2664,7 +2664,7 @@ describe(`Virtual properties`, () => { { id: `row-1`, value: `local` }, { optimistic: false }, ) - await insertTx.isPersisted.promise + await insertTx.when(`settled`) await waitForChanges() expect(collection.state.get(`row-1`)?.$origin).toBe(`local`) @@ -2716,14 +2716,15 @@ describe(`Virtual properties`, () => { onInsert: () => Promise.resolve(), }) try { - await collection.delete(`row`).isPersisted.promise + await collection.delete(`row`).when(`settled`) // The delete settled without its echo, so the synced row returns. expect(collection.get(`row`)?.value).toBe(`original`) if (deleteEchoed) echoDelete() expect(collection.has(`row`)).toBe(!deleteEchoed) if (deleteEchoed) { - await collection.insert({ id: `row`, value: `replacement` }) - .isPersisted.promise + await collection + .insert({ id: `row`, value: `replacement` }) + .when(`settled`) expect(collection.has(`row`)).toBe(false) } } finally { @@ -2771,7 +2772,7 @@ describe(`Virtual properties`, () => { const transaction = collection.insert({ id: `row-1`, value: `client` }) if (settlement === `before`) { finishMutation() - await transaction.isPersisted.promise + await transaction.when(`settled`) } // A settled insert without a confirmation has dropped. if (settlement === `before`) @@ -2795,8 +2796,8 @@ describe(`Virtual properties`, () => { // acknowledged insertion completes; it must not erase that evidence. const peer = collection.insert({ id: `other`, value: `peer` }) finishMutation() - await transaction.isPersisted.promise - await peer.isPersisted.promise + await transaction.when(`settled`) + await peer.when(`settled`) } if (applied !== true) await applied await waitForChanges() @@ -2805,7 +2806,7 @@ describe(`Virtual properties`, () => { id: `row-1`, value: `server`, }) - expect(collection.state.get(`row-1`)?.$synced).toBe(true) + expect(collection.state.get(`row-1`)?.$hasPendingWrites).toBe(false) // A same-key sync during the active mutation is a local acknowledgement; // after completion, truncate is an independent remote replacement. expect(collection.state.get(`row-1`)?.$origin).toBe( @@ -2866,24 +2867,32 @@ describe(`Virtual properties`, () => { { id: `row-1`, value: `local` }, { optimistic: false }, ) - await tx.isPersisted.promise + await tx.when(`settled`) await waitForChanges() - expect(collection.state.get(`row-1`)?.$synced).toBe(true) + expect(collection.state.get(`row-1`)?.$hasPendingWrites).toBe(false) expect(collection.state.get(`row-1`)?.$origin).toBe(`local`) }) - it(`hasVirtualProps recognizes the legacy four-field shape`, () => { + it(`hasVirtualProps requires the pending-write field`, () => { + expect( + hasVirtualProps({ + $hasPendingWrites: false, + $origin: `remote`, + }), + ).toBe(false) + expect( hasVirtualProps({ - $synced: true, $origin: `remote`, + $key: `row-1`, + $collectionId: `collection-1`, }), ).toBe(false) expect( hasVirtualProps({ - $synced: true, + $hasPendingWrites: false, $origin: `remote`, $key: `row-1`, $collectionId: `collection-1`, @@ -2923,7 +2932,7 @@ describe(`Virtual properties`, () => { await waitForChanges() const liveRow = liveChanges[0]?.value as Record - expect(liveRow.$synced).toBe(true) + expect(liveRow.$hasPendingWrites).toBe(false) expect(liveRow.$origin).toBe(`remote`) expect(liveRow.$collectionId).toBe(`virtual-props-source`) @@ -2932,7 +2941,7 @@ describe(`Virtual properties`, () => { await live.cleanup() }) - it(`should allow filtering on $synced in live queries`, async () => { + it(`should allow filtering on pending writes in live queries`, async () => { const source = createCollection<{ id: string; value: string }, string>({ id: `virtual-props-filter-source`, getKey: (item) => item.id, @@ -2955,7 +2964,9 @@ describe(`Virtual properties`, () => { const syncedOnly = createLiveQueryCollection({ id: `virtual-props-filter-live`, query: (q) => - q.from({ item: source }).where(({ item }) => eq(item.$synced, true)), + q + .from({ item: source }) + .where(({ item }) => eq(item.$hasPendingWrites, false)), }) const liveChanges: Array> = [] @@ -3046,7 +3057,6 @@ describe(`Virtual properties`, () => { const groupRow = grouped.toArray.find((row) => row.group === `g1`) expect(groupRow).toBeDefined() expect(groupRow!.$hasPendingWrites).toBe(true) - expect(groupRow!.$synced).toBe(false) expect(groupRow!.$origin).toBe(`local`) expect(groupRow!.$collectionId).toBe(`virtual-props-aggregate-source`) @@ -3081,7 +3091,7 @@ describe(`Virtual properties`, () => { const value = insertChange!.value as Record expect(value.$hasPendingWrites).toBe(false) - expect(value.$synced).toBe(true) + expect(value.$hasPendingWrites).toBe(false) expect(value.$origin).toBe(`local`) subscription.unsubscribe() diff --git a/packages/db/tests/collection-subscription-replay-oracle.property.test.ts b/packages/db/tests/collection-subscription-replay-oracle.property.test.ts index 5c2c24ce2f..63ef5d389d 100644 --- a/packages/db/tests/collection-subscription-replay-oracle.property.test.ts +++ b/packages/db/tests/collection-subscription-replay-oracle.property.test.ts @@ -1411,7 +1411,7 @@ async function runOptimisticReplayScenario( const transaction = createTransaction({ mutationFn: () => mutation.promise, }) - void transaction.isPersisted.promise.catch(() => {}) + void transaction.when(`settled`).catch(() => {}) let unsubscribed = false try { @@ -1454,7 +1454,7 @@ async function runOptimisticReplayScenario( else replay.reject(replayFailure) await flushPromises() mutation.resolve() - await transaction.isPersisted.promise + await transaction.when(`settled`) await flushPromises() await flushPromises() diff --git a/packages/db/tests/collection-sync-reentrancy-oracle.test.ts b/packages/db/tests/collection-sync-reentrancy-oracle.test.ts index cb7cd398f6..3960a5f7eb 100644 --- a/packages/db/tests/collection-sync-reentrancy-oracle.test.ts +++ b/packages/db/tests/collection-sync-reentrancy-oracle.test.ts @@ -325,7 +325,8 @@ describe(`sync publication reentrancy`, () => { onUpdate: () => persistence.promise, onDelete: () => persistence.promise, }) - const requests: Array<{ isPersisted: { promise: Promise } }> = [] + const requests: Array<{ when: (state: 'settled') => Promise }> = + [] const apply = (intent: ReadyIntent) => { requests.push( intent.type === `delete` @@ -361,7 +362,8 @@ describe(`sync publication reentrancy`, () => { change.type !== `delete` && sourceValues.has(change.value.value) && prior?.key !== change.key && - (!change.value.$synced || change.value.$origin !== `remote`) + (change.value.$hasPendingWrites || + change.value.$origin !== `remote`) ) violations.push(`source row ${change.key} published as local`) if (change.type === `delete`) mirror.delete(change.key) @@ -448,7 +450,7 @@ describe(`sync publication reentrancy`, () => { persistence.resolve() await Promise.all( requests.map((request) => - request.isPersisted.promise.catch(() => undefined), + request.when(`settled`).catch(() => undefined), ), ) await collection.cleanup() diff --git a/packages/db/tests/collection-sync-reentrancy.test.ts b/packages/db/tests/collection-sync-reentrancy.test.ts index b9f1713ddd..90cd199db6 100644 --- a/packages/db/tests/collection-sync-reentrancy.test.ts +++ b/packages/db/tests/collection-sync-reentrancy.test.ts @@ -320,7 +320,7 @@ describe(`sync publication reentrancy`, () => { expect([...collection.keys()]).toEqual([1, 2]) updatePersistence.resolve() - await update.isPersisted.promise + await update.when('settled') if (receipt !== true) await receipt expect([...collection.keys()]).toEqual([2, 1]) @@ -348,7 +348,7 @@ describe(`sync publication reentrancy`, () => { expect(markedReceiptSettled).toBe(true) } finally { updatePersistence.resolve() - await update.isPersisted.promise.catch(() => undefined) + await update.when('settled').catch(() => undefined) subscription.unsubscribe() await collection.cleanup() } @@ -453,8 +453,8 @@ describe(`sync publication reentrancy`, () => { subscription.unsubscribe() updatePersistence.resolve() insertPersistence.resolve() - await update.isPersisted.promise.catch(() => undefined) - await insert?.isPersisted.promise.catch(() => undefined) + await update.when('settled').catch(() => undefined) + await insert?.when('settled').catch(() => undefined) await collection.cleanup() } }) @@ -536,7 +536,7 @@ describe(`sync publication reentrancy`, () => { } finally { subscription?.unsubscribe() persistence.resolve() - await blocker.isPersisted.promise.catch(() => undefined) + await blocker.when('settled').catch(() => undefined) await collection.cleanup() } }, diff --git a/packages/db/tests/collection-truncate.test.ts b/packages/db/tests/collection-truncate.test.ts index 53c039e398..977c66088e 100644 --- a/packages/db/tests/collection-truncate.test.ts +++ b/packages/db/tests/collection-truncate.test.ts @@ -85,7 +85,7 @@ describe(`Collection truncate operations`, () => { syncOps!.write({ type: `insert`, value: { id: 2, value: `initial-2` } }) syncOps!.commit() - await tx.isPersisted.promise + await tx.when(`settled`) // Verify final state includes all items expect(collection.state.size).toBe(3) @@ -144,7 +144,7 @@ describe(`Collection truncate operations`, () => { syncOps!.truncate() syncOps!.write({ type: `insert`, value: { id: 1, value: `initial-1` } }) - await tx.isPersisted.promise + await tx.when(`settled`) syncOps!.commit() @@ -205,7 +205,7 @@ describe(`Collection truncate operations`, () => { syncOps!.truncate() syncOps!.commit() - await tx.isPersisted.promise + await tx.when(`settled`) // Item should be present in final state expect(getStateValue(collection, 1)).toEqual({ id: 1, value: `user-item` }) @@ -286,7 +286,7 @@ describe(`Collection truncate operations`, () => { // Now explicitly complete the onInsert resolveOnInsert!() - await tx.isPersisted.promise + await tx.when(`settled`) }) it(`should preserve optimistic inserts started after truncate begins`, async () => { @@ -357,7 +357,7 @@ describe(`Collection truncate operations`, () => { while (onInsertResolvers.length > 0) { onInsertResolvers.pop()!() } - await lateTx.isPersisted.promise + await lateTx.when(`settled`) }) it(`should preserve all optimistic inserts when truncate occurs during async mutation handler`, async () => { @@ -411,7 +411,7 @@ describe(`Collection truncate operations`, () => { syncOps!.write({ type: `insert`, value: { id: 1, value: `server-item` } }) syncOps!.commit() - await tx.isPersisted.promise + await tx.when(`settled`) // Both items should be present in final state expect(collection.state.size).toBe(2) @@ -476,7 +476,7 @@ describe(`Collection truncate operations`, () => { // Clean up onDeleteResolvers.forEach((r) => r()) - await deleteTx.isPersisted.promise + await deleteTx.when(`settled`) }) it(`should preserve optimistic value over server value when transaction active during truncate`, async () => { @@ -545,7 +545,7 @@ describe(`Collection truncate operations`, () => { // Clean up onUpdateResolvers.forEach((r) => r()) - await updateTx.isPersisted.promise + await updateTx.when(`settled`) }) it(`should handle multiple consecutive truncate operations`, async () => { @@ -611,7 +611,7 @@ describe(`Collection truncate operations`, () => { // Clean up onInsertResolvers.forEach((r) => r()) - await Promise.all([tx1.isPersisted.promise, tx2.isPersisted.promise]) + await Promise.all([tx1.when(`settled`), tx2.when(`settled`)]) }) it(`should handle new mutation on same key after truncate snapshot`, async () => { @@ -671,7 +671,7 @@ describe(`Collection truncate operations`, () => { // Clean up onUpdateResolvers.forEach((r) => r()) - await Promise.all([tx1.isPersisted.promise, tx2.isPersisted.promise]) + await Promise.all([tx1.when(`settled`), tx2.when(`settled`)]) }) it(`should handle transaction completing between truncate and commit`, async () => { @@ -718,7 +718,7 @@ describe(`Collection truncate operations`, () => { // Transaction completes BEFORE commit onInsertResolver!() - await tx.isPersisted.promise + await tx.when(`settled`) // Now commit the truncate syncOps!.write({ type: `insert`, value: { id: 1, value: `initial` } }) diff --git a/packages/db/tests/collection.test.ts b/packages/db/tests/collection.test.ts index f6dba1745b..317ed9d66b 100644 --- a/packages/db/tests/collection.test.ts +++ b/packages/db/tests/collection.test.ts @@ -85,11 +85,13 @@ describe(`Collection`, () => { const collection = createCollection(config) try { await collection.stateWhenReady() - await collection.insert({ id: 3, v: 0 }).isPersisted.promise - await collection.update(1, (draft) => { - draft.v = 1 - }).isPersisted.promise - await collection.delete(2).isPersisted.promise + await collection.insert({ id: 3, v: 0 }).when(`settled`) + await collection + .update(1, (draft) => { + draft.v = 1 + }) + .when(`settled`) + await collection.delete(2).when(`settled`) expect(calls).toEqual([`insert:config`, `update:config`, `delete:config`]) } finally { await collection.cleanup() @@ -194,7 +196,7 @@ describe(`Collection`, () => { const failed = collection.update(1, (draft) => { draft.value = cycle + 1 }) - await expect(failed.isPersisted.promise).rejects.toThrow(`rejected`) + await expect(failed.when(`settled`)).rejects.toThrow(`rejected`) const rolledBack = createTransaction({ autoCommit: false, @@ -206,7 +208,7 @@ describe(`Collection`, () => { }), ) rolledBack.rollback() - await rolledBack.isPersisted.promise.catch(() => undefined) + await rolledBack.when(`settled`).catch(() => undefined) expect(collection._state.transactions.size).toBe(0) expect(collection.get(1)).toMatchObject({ id: 1, value: 0 }) @@ -245,7 +247,7 @@ describe(`Collection`, () => { }), ) transaction.rollback() - await transaction.isPersisted.promise.catch(() => undefined) + await transaction.when(`settled`).catch(() => undefined) } } const walkedForOneMutation = async () => { @@ -279,7 +281,7 @@ describe(`Collection`, () => { for (const name of methods) Reflect.deleteProperty(transactions, name) } transaction.rollback() - await transaction.isPersisted.promise.catch(() => undefined) + await transaction.when(`settled`).catch(() => undefined) return walked } @@ -316,11 +318,9 @@ describe(`Collection`, () => { ) return transaction }) - // Settle all three before the Collection recomputes once. + // Mark all three failed before the Collection recomputes once. for (const transaction of settled) { transaction.setState(`failed`) - transaction.isPersisted.reject(new Error(`settled`)) - transaction.isPersisted.promise.catch(() => undefined) } collection._state.recomputeOptimisticState(false) @@ -367,7 +367,7 @@ describe(`Collection`, () => { draft.value = 2 }), ) - await first.isPersisted.promise.catch(() => undefined) + await first.when(`settled`).catch(() => undefined) collection._state.recomputeOptimisticState(false) expect(collection._state.transactions.get(`retry`)).toBe(successor) @@ -403,7 +403,7 @@ describe(`Collection`, () => { ) await collection.cleanup() expect(() => transaction.rollback()).not.toThrow() - await transaction.isPersisted.promise.catch(() => undefined) + await transaction.when(`settled`).catch(() => undefined) expect(transaction.collections.size).toBe(0) expect(collection._state.transactions.size).toBe(0) @@ -443,7 +443,7 @@ describe(`Collection`, () => { }) expect(() => transaction.rollback()).toThrow(`subscriber failed`) - await transaction.isPersisted.promise.catch(() => undefined) + await transaction.when(`settled`).catch(() => undefined) expect(second.get(1)).toMatchObject({ id: 1, value: 0 }) expect(second._state.transactions.size).toBe(0) @@ -516,14 +516,14 @@ describe(`Collection`, () => { // Without an explicit temp-key -> server-key mapping, core cannot know // whether key 24 is this optimistic insert's server echo or an unrelated // row, so it must not expose both rows at the same time. - expect(tx.isPersisted.isPending()).toBe(true) + expect(tx.state).toBe(`persisting`) expect(collection.has(24)).toBe(false) expect(getStateEntries(collection)).toEqual([ [4733, { id: 4733, text: `two` }], ]) options.utils.resolveSync() - await tx.isPersisted.promise + await tx.when(`settled`) await flushPromises() expect(getStateEntries(collection)).toEqual([[24, { id: 24, text: `two` }]]) @@ -548,7 +548,7 @@ describe(`Collection`, () => { id: item.id, text: item.text, project_id: item.project_id, - $synced: item.$synced, + $hasPendingWrites: item.$hasPendingWrites, $origin: item.$origin, $key: item.$key, })), @@ -569,17 +569,17 @@ describe(`Collection`, () => { options.utils.commit() options.utils.resolveSync() - await tx.isPersisted.promise + await tx.when(`settled`) await flushPromises() expect( liveCollection.toArray.map((todo) => ({ id: todo.id, - synced: todo.$synced, + hasPendingWrites: todo.$hasPendingWrites, origin: todo.$origin, key: todo.$key, })), - ).toEqual([{ id: 24, synced: true, origin: `remote`, key: 24 }]) + ).toEqual([{ id: 24, hasPendingWrites: false, origin: `remote`, key: 24 }]) }) it(`should throw an error when trying to use mutation operations outside of a transaction`, async () => { @@ -817,7 +817,7 @@ describe(`Collection`, () => { value: `bar`, }) - await tx.isPersisted.promise + await tx.when(`settled`) // @ts-expect-error possibly undefined is ok in test const syncData = syncMock.mock.calls[0][0] @@ -846,7 +846,7 @@ describe(`Collection`, () => { id: 2, value: `baz`, }) - await tx2.isPersisted.promise + await tx2.when(`settled`) // Test bulk insert const tx3 = createTransaction({ mutationFn }) @@ -864,7 +864,7 @@ describe(`Collection`, () => { expect(stripVirtualProps(collection.state.get(keys[3]))).toEqual( bulkData[1], ) - await tx3.isPersisted.promise + await tx3.when(`settled`) const tx4 = createTransaction({ mutationFn }) // Test update with callback @@ -880,7 +880,7 @@ describe(`Collection`, () => { id: 1, value: `bar2`, }) - await tx4.isPersisted.promise + await tx4.when(`settled`) const tx5 = createTransaction({ mutationFn }) // Test update with config and callback @@ -902,7 +902,7 @@ describe(`Collection`, () => { newProp: `new value`, }) - await tx5.isPersisted.promise + await tx5.when(`settled`) // If there are two updates, the second should overwrite the first. const tx55 = createTransaction({ mutationFn }) @@ -934,7 +934,7 @@ describe(`Collection`, () => { }) expect(tx55.mutations).toHaveLength(1) - await tx55.isPersisted.promise + await tx55.when(`settled`) const tx6 = createTransaction({ mutationFn }) // Test bulk update @@ -964,21 +964,21 @@ describe(`Collection`, () => { id: 4, value: `item2-updated`, }) - await tx6.isPersisted.promise + await tx6.when(`settled`) const tx7 = createTransaction({ mutationFn }) // Test delete single item tx7.mutate(() => collection.delete(insertedKey)) expect(collection.state.has(insertedKey)).toBe(false) // objectKeyMap check removed as it no longer exists - await tx7.isPersisted.promise + await tx7.when(`settled`) // Test delete with metadata const tx8Insert = createTransaction({ mutationFn }) tx8Insert.mutate(() => collection.insert({ id: 5, value: `foostyle` })) // @ts-expect-error possibly undefined is ok in test const tx8insertKey = tx8Insert.mutations[0].key - await tx8Insert.isPersisted.promise + await tx8Insert.when(`settled`) const tx8 = createTransaction({ mutationFn }) tx8.mutate(() => collection.delete(tx8insertKey, { @@ -987,7 +987,7 @@ describe(`Collection`, () => { ) expect(tx8.mutations[0]?.metadata).toEqual({ reason: `test delete` }) expect(collection.state.has(tx8insertKey)).toBe(false) - await tx8.isPersisted.promise + await tx8.when(`settled`) // Test bulk delete const tx9 = createTransaction({ mutationFn }) @@ -996,7 +996,7 @@ describe(`Collection`, () => { expect(collection.state.has(keys[2])).toBe(false) // @ts-expect-error possibly undefined is ok in test expect(collection.state.has(keys[3])).toBe(false) - await tx9.isPersisted.promise + await tx9.when(`settled`) }) it(`synced updates should *not* be applied while there's a persisting transaction`, async () => { @@ -1076,7 +1076,7 @@ describe(`Collection`, () => { value: `bar`, }) - await tx1.isPersisted.promise + await tx1.when(`settled`) expect(getStateEntries(collection)).toEqual([[1, { id: 1, value: `bar` }]]) }) @@ -1496,9 +1496,9 @@ describe(`Collection`, () => { // Wait for all transactions to complete await Promise.all([ - insertTx.isPersisted.promise, - updateTx.isPersisted.promise, - deleteTx.isPersisted.promise, + insertTx.when(`settled`), + updateTx.when(`settled`), + deleteTx.when(`settled`), ]) // Verify the transactions were created with the correct configuration @@ -1613,7 +1613,7 @@ describe(`Collection`, () => { // Now resolve the mutation and wait for completion pendingMutations[0]?.() - await nonOptimisticInsertTx.isPersisted.promise + await nonOptimisticInsertTx.when(`settled`) // Now the item should appear after server confirmation expect(collection.state.has(2)).toBe(true) @@ -1637,7 +1637,7 @@ describe(`Collection`, () => { // Now resolve the update mutation and wait for completion pendingMutations[1]?.() - await nonOptimisticUpdateTx.isPersisted.promise + await nonOptimisticUpdateTx.when(`settled`) // Now the update should be reflected expect(getStateValue(collection, 1)?.value).toBe(`non-optimistic update`) @@ -1651,7 +1651,7 @@ describe(`Collection`, () => { // Now resolve the delete mutation and wait for completion pendingMutations[2]?.() - await nonOptimisticDeleteTx.isPersisted.promise + await nonOptimisticDeleteTx.when(`settled`) // Now the item should be gone expect(collection.state.has(2)).toBe(false) @@ -1715,7 +1715,7 @@ describe(`Collection`, () => { value: `default optimistic`, }) - await defaultOptimisticTx.isPersisted.promise + await defaultOptimisticTx.when(`settled`) // Test explicit optimistic: true const explicitOptimisticTx = collection.insert( @@ -1731,7 +1731,7 @@ describe(`Collection`, () => { value: `explicit optimistic`, }) - await explicitOptimisticTx.isPersisted.promise + await explicitOptimisticTx.when(`settled`) // Test optimistic update const optimisticUpdateTx = collection.update( @@ -1746,7 +1746,7 @@ describe(`Collection`, () => { expect(getStateValue(collection, 1)?.value).toBe(`optimistic update`) expect(collection._state.optimisticUpserts.has(1)).toBe(true) - await optimisticUpdateTx.isPersisted.promise + await optimisticUpdateTx.when(`settled`) // Test optimistic delete const optimisticDeleteTx = collection.delete(3, { optimistic: true }) @@ -1755,7 +1755,7 @@ describe(`Collection`, () => { expect(collection.state.has(3)).toBe(false) expect(collection._state.optimisticDeletes.has(3)).toBe(true) - await optimisticDeleteTx.isPersisted.promise + await optimisticDeleteTx.when(`settled`) }) it(`should not block user actions when keys are recently synced`, async () => { @@ -1846,7 +1846,7 @@ describe(`Collection`, () => { // Now complete the first transaction to trigger sync and batching txResolvers[0]?.() - await tx1.isPersisted.promise + await tx1.when(`settled`) // Step 4: More rapid clicks after sync starts (this is where the bug occurred) const eventCountBeforeRapidClicks = changeEvents.length @@ -1867,10 +1867,10 @@ describe(`Collection`, () => { txResolvers[i]?.() } await Promise.all([ - tx2.isPersisted.promise, - tx3.isPersisted.promise, - tx4.isPersisted.promise, - tx5.isPersisted.promise, + tx2.when(`settled`), + tx3.when(`settled`), + tx4.when(`settled`), + tx5.when(`settled`), ]) }) diff --git a/packages/db/tests/conformance-result-laws.test.ts b/packages/db/tests/conformance-result-laws.test.ts index 1c23d5c967..b0d4c73ca3 100644 --- a/packages/db/tests/conformance-result-laws.test.ts +++ b/packages/db/tests/conformance-result-laws.test.ts @@ -38,7 +38,6 @@ it(`separates only the five documented virtual fields from selected values`, () const actual = rows.map((row) => ({ ...row, $hasPendingWrites: false, - $synced: true, $origin: `remote`, $key: row.id, $collectionId: `source`, diff --git a/packages/db/tests/conformance/contract.ts b/packages/db/tests/conformance/contract.ts index c03c58c907..6469f4f085 100644 --- a/packages/db/tests/conformance/contract.ts +++ b/packages/db/tests/conformance/contract.ts @@ -154,7 +154,7 @@ export interface DbOps { createOptimisticAction: (config: { onMutate: (variables: any) => void mutationFn: (variables: any) => Promise - }) => (variables?: any) => { isPersisted: { promise: Promise } } + }) => (variables?: any) => { when: (state: 'settled') => Promise } } /** Normalized, adapter-agnostic view of a live query's current result. */ diff --git a/packages/db/tests/conformance/publication-laws-oracle.ts b/packages/db/tests/conformance/publication-laws-oracle.ts new file mode 100644 index 0000000000..4fdad0f529 --- /dev/null +++ b/packages/db/tests/conformance/publication-laws-oracle.ts @@ -0,0 +1,41 @@ +/** + * Publication rows at the shared conformance suite's settled cut. + * + * The authority is contract.ts's reactive source-swap law and public binding + * result law, plus publication-log.ts's end-of-turn observation cut. Empty + * intermediate values remain legal while a new list query loads. Every + * recorded turn must contain only the selected input's rows; once the driver + * has settled, the final turn must contain the source model's complete rows. + * + * The existing source-ID and pooled histories supply those rows independently + * of the hook. This check does not infer readiness from production output. + * Limits: the tiny list-query histories in suite.ts, selected values at the + * final cut and ID membership at earlier cuts. It does not judge findOne, + * arbitrary scheduling, status traces, or changes hidden within one turn. + */ +import { expect } from 'vitest' +import { declareOracle } from '../oracle-declaration.js' +import { expectUnorderedRows } from './result-laws' + +export function expectPublishedRows( + values: ReadonlyArray, + rows: ReadonlyArray<{ id: string }>, +): void { + expect(values.length, `no recorded publication`).toBeGreaterThan(0) + const allowed = new Set(rows.map(({ id }) => id)) + for (const value of values) { + const published = (Array.isArray(value) ? value : value ? [value] : []).map( + (row: { id: string }) => row.id, + ) + expect( + published.filter((id) => !allowed.has(id)), + `published rows ${JSON.stringify(published)}`, + ).toEqual([]) + } + // A dropped accessor value is not proof of a legal empty loading turn once + // the driver has reached the settled cut. Use the same selected-row law as + // the scenarios' independent final-row model, including a legal empty set. + expectUnorderedRows(values.at(-1), rows) +} + +declareOracle({ check: [expectPublishedRows] }) diff --git a/packages/db/tests/conformance/result-laws.ts b/packages/db/tests/conformance/result-laws.ts index ad63103da8..9013f86925 100644 --- a/packages/db/tests/conformance/result-laws.ts +++ b/packages/db/tests/conformance/result-laws.ts @@ -20,13 +20,7 @@ export function selectedRow(row: unknown): Record { expect(row).not.toBeNull() expect(typeof row).toBe(`object`) const value = row as Record - const virtual = [ - `$hasPendingWrites`, - `$synced`, - `$origin`, - `$key`, - `$collectionId`, - ] + const virtual = [`$hasPendingWrites`, `$origin`, `$key`, `$collectionId`] return Object.fromEntries( Reflect.ownKeys(value) // Solid stores nonenumerable bookkeeping symbols on its row wrappers. diff --git a/packages/db/tests/conformance/suite.ts b/packages/db/tests/conformance/suite.ts index a3790f8079..f8705b3e04 100644 --- a/packages/db/tests/conformance/suite.ts +++ b/packages/db/tests/conformance/suite.ts @@ -60,6 +60,7 @@ import { ScenarioLifetime } from './scenario-lifetime' import { ScenarioSources } from './scenario-sources' import { scenarioRegistry } from './registration' import { expectDisabledResult } from './disabled-laws' +import { expectPublishedRows } from './publication-laws-oracle' import { expectKeyedRows, expectOrderedRows, @@ -135,21 +136,6 @@ function expectNoRowsAfterRejection(h: LiveQueryHandle): void { } } -/** A published `data` value holds no row outside `rows`. */ -function expectOnlyRowsOf( - value: unknown, - rows: ReadonlyArray<{ id: string }>, -): void { - const allowed = new Set(rows.map(({ id }) => id)) - const published = (Array.isArray(value) ? value : value ? [value] : []).map( - (row: { id: string }) => row.id, - ) - expect( - published.filter((id) => !allowed.has(id)), - `published rows ${JSON.stringify(published)}`, - ).toEqual([]) -} - async function wait(ms: number): Promise { await new Promise((resolve) => setTimeout(resolve, ms)) } @@ -402,6 +388,17 @@ export function runSuite(rawDriver: LiveQueryDriver) { const shared = driver.features?.pooledEqFilters ? 1 : 2 expect(source.collection.subscriberCount).toBe(shared) + // The current result and the publication recorder are separate + // observations. A ready, nonempty accessor must reach the log before + // setParam's explicit first-turn read can supply another value. + const publicationReach = createOracleReach( + `pooled parameter publication`, + [`initial data rows`, `switched data rows`, `switched state rows`], + ) + publicationReach.beginHistory() + expectPublishedRows(moving.publications(), [SEED[0]!, SEED[2]!]) + publicationReach.reach(`initial data rows`) + // Every value the binding publishes after a switch holds only rows of // the new group, or none while it loads; the old group's rows never // show again. The driver logs the first paint, so the law cannot @@ -415,9 +412,10 @@ export function runSuite(rawDriver: LiveQueryDriver) { const published = moving.publications().slice(before) // The keyed state map follows the same law as `data`. const states = moving.statePublications().slice(before) - expect(published.length).toBeGreaterThan(0) - for (const value of [...published, ...states]) - expectOnlyRowsOf(value, rows) + expectPublishedRows(published, rows) + publicationReach.reach(`switched data rows`) + expectPublishedRows(states, rows) + publicationReach.reach(`switched state rows`) } await switchTo(`b`, [SEED[1]!]) @@ -439,6 +437,7 @@ export function runSuite(rawDriver: LiveQueryDriver) { [`1`, `3`], [`1`, `3`], ]) + publicationReach.assertReached() for (const h of [moving, peer]) h.unmount() await expect .poll(() => source.collection.subscriberCount, { @@ -1589,11 +1588,19 @@ export function runSuite(rawDriver: LiveQueryDriver) { } keyedReach.assertReached() } + // The same 32 histories also witness the reactive driver's actual + // accessor recorder. Comparing bindings have a rejection obligation + // instead, so these publication states do not apply to their path. + const publicationReach = createOracleReach( + `reactive source-ID publication`, + comparesIdentity ? [] : [`initial data rows`, `swapped data rows`], + ) const picks: ReadonlyArray = [`A`, `B`, `C`, `D`] for (const first of [`A`, `C`] as const) { for (const second of picks) { for (const third of picks) { reach.beginHistory() + publicationReach.beginHistory() const history: Array = [first] const h = driver.mountControllable( (q, pick) => @@ -1606,6 +1613,10 @@ export function runSuite(rawDriver: LiveQueryDriver) { ) await h.flush() expectUnorderedRows(h.current().data, rowsOf[first]) + if (!comparesIdentity) { + expectPublishedRows(h.publications(), rowsOf[first]) + publicationReach.reach(`initial data rows`) + } for (const next of [second, third]) { const state = reachedState(history, next) if (state) reach.reach(state) @@ -1627,12 +1638,13 @@ export function runSuite(rawDriver: LiveQueryDriver) { // value it published since then holds only the new // source's rows, or none while it loads. const swapped = h.publications().slice(published) - // The driver logs the first paint, so the law cannot pass - // on an empty log. - expect(swapped.length).toBeGreaterThan(0) - for (const value of swapped) { - expectOnlyRowsOf(value, rowsOf[next as Exclude]) - } + // Empty/loading intermediate cuts remain legal. At the + // settled cut, the log must contain the model's new rows. + expectPublishedRows( + swapped, + rowsOf[next as Exclude], + ) + publicationReach.reach(`swapped data rows`) } history.push(next) if (next === `D`) { @@ -1649,6 +1661,7 @@ export function runSuite(rawDriver: LiveQueryDriver) { } } reach.assertReached() + publicationReach.assertReached() if (comparesIdentity) await runKeyedClaimGrammar() @@ -1736,17 +1749,17 @@ export function runSuite(rawDriver: LiveQueryDriver) { }, }) - let tx!: { isPersisted: { promise: Promise } } + let tx!: { when: (state: 'settled') => Promise } await h.apply(() => { tx = add() - void tx.isPersisted.promise.catch(() => undefined) - lifetime!.defer(() => tx.isPersisted.promise) + void tx.when(`settled`).catch(() => undefined) + lifetime!.defer(() => tx.when(`settled`)) }) // Optimistic row is visible before the server confirms. expect(h.current().data.find((r: any) => r.id === `temp`)).toBeDefined() confirmServer() - await tx.isPersisted.promise + await tx.when(`settled`) await h.flush() // Reconciled: temp replaced by the permanent key. expect( diff --git a/packages/db/tests/db-client-hydration-authority-oracle.test.ts b/packages/db/tests/db-client-hydration-authority-oracle.test.ts index 0e411cbef1..a494dbaaa2 100644 --- a/packages/db/tests/db-client-hydration-authority-oracle.test.ts +++ b/packages/db/tests/db-client-hydration-authority-oracle.test.ts @@ -258,7 +258,7 @@ function referenceRows( .sort((left, right) => left.id.localeCompare(right.id)) } -/** Row data fields only; collection virtual props such as `$synced` are not data. */ +/** Row data fields only; collection virtual props such as `$hasPendingWrites` are not data. */ function rowData(row: Row): Row { return { id: row.id, name: row.name, seeded: row.seeded } } @@ -863,7 +863,7 @@ describe(`DbClient hydration interleaving oracle`, () => { void Promise.resolve(receipt).catch(() => undefined) } release() - await mutation?.isPersisted.promise.catch(() => undefined) + await mutation?.when('settled').catch(() => undefined) expectInterleavedRows( observedRows(collection.values()).filter((row) => row.id !== `local`), history, diff --git a/packages/db/tests/db-client.test.ts b/packages/db/tests/db-client.test.ts index d23110cc94..508b5d19ea 100644 --- a/packages/db/tests/db-client.test.ts +++ b/packages/db/tests/db-client.test.ts @@ -61,7 +61,7 @@ describe(`DbClient`, () => { const peopleB = clientB.collection(descriptor) const transaction = peopleA.insert(people[0]!) - await transaction.isPersisted.promise + await transaction.when(`settled`) expect(peopleA.get(`1`)).toMatchObject(people[0]!) expect(peopleB.get(`1`)).toBeUndefined() @@ -96,7 +96,7 @@ describe(`DbClient`, () => { autoCommit: false, mutationFn: async () => {}, }) - const rolledBack = transactionA.isPersisted.promise.catch(() => undefined) + const rolledBack = transactionA.when(`settled`).catch(() => undefined) let transactionB: ReturnType | undefined transactionA.mutate(() => { @@ -107,7 +107,7 @@ describe(`DbClient`, () => { expect(clientB.activeTransaction).toBeUndefined() }) - await transactionB!.isPersisted.promise + await transactionB!.when(`settled`) transactionA.rollback() await rolledBack @@ -130,7 +130,7 @@ describe(`DbClient`, () => { autoCommit: false, mutationFn: async () => {}, }) - const rolledBack = transaction.isPersisted.promise.catch(() => undefined) + const rolledBack = transaction.when(`settled`).catch(() => undefined) transaction.mutate(() => { expect(peopleA.insert(people[0]!)).toBe(transaction) @@ -1240,7 +1240,7 @@ describe(`DbClient`, () => { }) collection.utils.resolveSync() - await tx.isPersisted.promise + await tx.when(`settled`) }) it(`applies initialData precedence before hydrated rows`, () => { diff --git a/packages/db/tests/descriptor-start-release-oracle.ts b/packages/db/tests/descriptor-start-release-oracle.ts new file mode 100644 index 0000000000..cfd3e6698b --- /dev/null +++ b/packages/db/tests/descriptor-start-release-oracle.ts @@ -0,0 +1,116 @@ +/** + * Descriptor preparation can fail after acquiring several source Collections. + * Each acquired deferral must be released independently. One eager source's + * throwing sync adapter cannot block another source's start. The preparation + * error remains the caller's error; acquired healthy resources clean up once. + * + * Authority: the descriptor cleanup contract in docs/guides/ssr.md + * and the established failed-preparation histories in React/Svelte. Source + * crashes and cleanup use the glossary's Collection lifecycle laws. + * + * This finite model combines one descriptor's binding and deferral acquisition + * as `acquired`. It does not model graph demand. The receiving hosts reject + * during binding, before a graph or subscription can own a source. A prefix of + * zero through three descriptors is acquired; the next factory throws, and one + * tail descriptor must remain unacquired. Every sync-failure mask and eager/lazy + * policy is represented. A lazy source needs independent demand; an eager one + * starts on release. Failed starts acquire no adapter resource. + * + * Limits: synchronous factory/start failure only, no provider shutdown, graph + * compiler failure, or arbitrary concurrent render scheduling. React/Svelte + * supply native receiving drivers for the same judgment. + */ +import { expect } from 'vitest' +import { declareOracle } from './oracle-declaration' + +export type DescriptorStartHistory = { + acquired: number + failedStarts: number + eagerStarts: number +} + +// Bounds include failure before acquisition, after one resource, and after +// multiple resources. A mask outside its acquired prefix is not a legal input. +export const descriptorStartHistories: Array = ( + [0, 1, 2, 3] as const +).flatMap((acquired) => + Array.from({ length: 2 ** acquired }, (_, failedStarts) => + Array.from({ length: 2 ** acquired }, (_, eagerStarts) => ({ + acquired, + failedStarts, + eagerStarts, + })), + ).flat(), +) + +export type DescriptorStartObservation = { + factories: Array + starts: Array + cleanups: Array + statuses: Array + rows: Array> +} + +/** Each acquired source owns its start policy, independent of its neighbors. + * Factory failure prevents acquisition of the failing descriptor and its tail. + * Before demand, lazy sources stay idle. Demand attempts each source once; + * a throwing adapter crashes its Collection and publishes no row. + */ +export function expectedDescriptorStart( + history: DescriptorStartHistory, + cut: `rejected` | `read` | `cleaned`, +): DescriptorStartObservation { + const { acquired, failedStarts, eagerStarts } = history + if ( + !Number.isInteger(acquired) || + acquired < 0 || + !Number.isInteger(failedStarts) || + failedStarts < 0 || + failedStarts >= 2 ** acquired || + !Number.isInteger(eagerStarts) || + eagerStarts < 0 || + eagerStarts >= 2 ** acquired + ) + throw new Error(`Invalid descriptor start history`) + const started = (index: number) => + Boolean(eagerStarts & (1 << index)) || cut !== `rejected` + return { + // The failing factory may be retried by its host. Drivers record only + // successful factory calls and the unvisited tail, preserving duplicates. + factories: [...Array.from({ length: acquired }, () => 1), 0], + starts: Array.from({ length: acquired }, (_, index) => + Number(started(index)), + ), + cleanups: Array.from({ length: acquired }, (_, index) => + Number(cut === `cleaned` && !(failedStarts & (1 << index))), + ), + statuses: Array.from({ length: acquired }, (_, index) => + cut === `cleaned` + ? `cleaned-up` + : !started(index) + ? `idle` + : failedStarts & (1 << index) + ? `crashed` + : `ready`, + ), + rows: Array.from({ length: acquired }, (_, index) => + cut !== `cleaned` && started(index) && !(failedStarts & (1 << index)) + ? [{ id: `row-${index}`, value: `value-${index}` }] + : [], + ), + } +} + +export function checkDescriptorStart( + actual: DescriptorStartObservation, + history: DescriptorStartHistory, + cut: `rejected` | `read` | `cleaned`, +): void { + expect(actual, cut).toEqual(expectedDescriptorStart(history, cut)) +} + +declareOracle({ + check: [checkDescriptorStart], + model: [expectedDescriptorStart], + grammar: [descriptorStartHistories], +}) diff --git a/packages/db/tests/descriptor-start-release-support.ts b/packages/db/tests/descriptor-start-release-support.ts new file mode 100644 index 0000000000..353f2f5881 --- /dev/null +++ b/packages/db/tests/descriptor-start-release-support.ts @@ -0,0 +1,91 @@ +// Shared production fixture only. The independently derived expected result +// remains in descriptor-start-release-oracle.ts; native hosts own failed mounts. +import { DbClient, Query, collectionOptions } from '@tanstack/db' +import type { DescriptorStartHistory } from './descriptor-start-release-oracle' + +type Row = { id: string; value: string } +let sequence = 0 +export function descriptorStartFixture( + history: DescriptorStartHistory, + host: string, +) { + const client = new DbClient() + const id = `descriptor-start-${host}-${sequence++}` + const factories = Array.from({ length: history.acquired + 1 }, () => 0) + const starts = Array.from({ length: history.acquired }, () => 0) + const cleanups = Array.from({ length: history.acquired }, () => 0) + const startErrors = starts.map((_, index) => new Error(`start ${index}`)) + const failure = new Error(`factory failed ${id}`) + let failures = 0 + const descriptors = starts.map((_, index) => + collectionOptions(`${id}-${index}`, () => { + factories[index] = factories[index]! + 1 + return { + id: `${id}-${index}`, + getKey: (row: Row) => row.id, + startSync: Boolean(history.eagerStarts & (1 << index)), + sync: { + sync: ({ begin, write, commit, markReady }) => { + starts[index] = starts[index]! + 1 + if (history.failedStarts & (1 << index)) throw startErrors[index] + begin() + write({ + type: `insert`, + value: { id: `row-${index}`, value: `value-${index}` }, + }) + commit() + markReady() + return () => { + cleanups[index] = cleanups[index]! + 1 + } + }, + }, + } + }), + ) + const rejected = collectionOptions(`${id}-failure`, () => { + failures++ + throw failure + }) + const tail = collectionOptions(`${id}-tail`, () => { + factories[history.acquired] = factories[history.acquired]! + 1 + return { + getKey: (row: Row) => row.id, + sync: { sync: () => {} }, + } + }) + // Union arms preserve authored binding order without requiring any source + // rows or graph execution. The rejected factory prevents the tail's binding. + const arms = [...descriptors, rejected, tail].map((row) => + new Query().from({ row }).select(({ row: value }) => ({ + id: value.id, + value: value.value, + })), + ) + const query = new Query().unionAll(arms[0]!, arms[1]!, ...arms.slice(2)) + const collections = () => descriptors.map((row) => client.collection(row)) + let retained: ReturnType = [] + const observe = () => ({ + factories: [...factories], + starts: [...starts], + cleanups: [...cleanups], + statuses: retained.map((row) => row.status), + rows: retained.map((row) => + row.toArray.map(({ id: key, value }) => ({ id: key, value })), + ), + }) + return { + client, + query, + id, + failure, + startErrors, + factories, + failures: () => failures, + retain: () => { + retained = collections() + }, + collections: () => retained, + observe, + } +} diff --git a/packages/db/tests/documented-mutations-oracle.test.ts b/packages/db/tests/documented-mutations-oracle.test.ts new file mode 100644 index 0000000000..648e6e94ea --- /dev/null +++ b/packages/db/tests/documented-mutations-oracle.test.ts @@ -0,0 +1,247 @@ +/** + * Calibrate exact-handler receivers with complete authored rows and an upstream + * API rejection. Every control uses the documentation grammar, existing shared + * outcome model and shared comparison; it supplies no separate expected answer. + * These controls establish judgment sensitivity, not execution of an adapter. + */ +import { expect, test } from 'vitest' +import { declareOracle } from './oracle-declaration' +import { + checkDocumentedMutation, + documentedMutationHistories, + documentedMutationInputs, +} from './documented-mutations-oracle' +import { + checkMutationConfirmation, + expectedMutationConfirmation, +} from './mutation-confirmation-oracle' +import type { + DocumentedMutationObservation, + DocumentedRow, +} from './documented-mutations-oracle' + +const histories = [ + ...documentedMutationHistories(`query`), + ...documentedMutationHistories(`electric`), +] +const completeRowControls: Array<{ + name: string + shape: `dated-todo` | `product` + corrupt: (row: DocumentedRow) => void +}> = [ + { + name: `omitted ordinary field`, + shape: `dated-todo`, + corrupt: (row) => { + delete row.completed + }, + }, + { + name: `changed Date value`, + shape: `dated-todo`, + corrupt: (row) => { + row.created_at = new Date(`2026-10-02T00:00:00Z`) + }, + }, + { + name: `serialized Date`, + shape: `dated-todo`, + corrupt: (row) => { + row.created_at = (row.created_at as Date).toISOString() + }, + }, + { + name: `missing own undefined`, + shape: `product`, + corrupt: (row) => { + delete row.server_value + }, + }, + { + name: `extra own undefined`, + shape: `product`, + corrupt: (row) => { + row.unexpected = undefined + }, + }, +] + +test.each(completeRowControls)( + `rejects incomplete ordinary rows: $name`, + ({ shape, corrupt }) => { + const failure = new Error(`API failure`) + const selected = histories.filter( + (h) => + h.handler.shape === shape && + h.apiOutcome === `success` && + h.handler.confirmation !== `none` && + h.handler.operation !== `delete`, + ) + expect(selected.length).toBeGreaterThan(0) + for (const history of selected) { + const inputs = documentedMutationInputs(history.handler) + const actual = expectedMutationConfirmation( + history.mutation, + `settled`, + failure, + inputs, + ) + checkMutationConfirmation( + history.mutation, + `settled`, + actual, + failure, + inputs, + ) + corrupt(actual.rows[0]!) + expect(() => + checkMutationConfirmation( + history.mutation, + `settled`, + actual, + failure, + inputs, + ), + ).toThrow() + } + }, +) + +test(`rejects acknowledgement attributed to an API that rejected before returning`, () => { + const failure = new Error(`API committed then rejected`) + const selected = histories.filter( + (h) => h.apiOutcome === `reject-after-write`, + ) + expect(selected.length).toBeGreaterThan(0) + for (const history of selected) { + const inputs = documentedMutationInputs(history.handler) + const actual = expectedMutationConfirmation( + history.mutation, + `settled`, + failure, + inputs, + ) + checkMutationConfirmation( + history.mutation, + `settled`, + actual, + failure, + inputs, + ) + const afterAcknowledgement = expectedMutationConfirmation( + { ...history.mutation, confirmationEntry: `reached` }, + `settled`, + failure, + inputs, + ) + expect(() => + checkMutationConfirmation( + history.mutation, + `settled`, + afterAcknowledgement, + failure, + inputs, + ), + ).toThrow() + } +}) + +test.each([`fulfilled`, `rejected`] as const)( + `rejects an extracted handler that is %s while its source remains held`, + (outcome) => { + const failure = new Error(`API failure`) + const selected = histories.filter( + (h) => + h.apiOutcome === `success` && + (h.handler.confirmation === `refetch` || + h.handler.confirmation === `txid`), + ) + expect(selected.length).toBeGreaterThan(0) + for (const history of selected) { + const inputs = documentedMutationInputs(history.handler) + const actual: DocumentedMutationObservation = { + ...expectedMutationConfirmation( + history.mutation, + `confirmation-requested`, + failure, + inputs, + ), + handlerOutcome: `pending`, + handlerError: undefined, + } + checkDocumentedMutation( + history, + `confirmation-requested`, + actual, + failure, + inputs, + ) + actual.handlerOutcome = outcome + if (outcome === `rejected`) actual.handlerError = failure + expect(() => + checkDocumentedMutation( + history, + `confirmation-requested`, + actual, + failure, + inputs, + ), + ).toThrow(/actual documented handler Promise/) + } + }, +) + +test.each([ + `missing status guard`, + `wrong error kind`, + `wrong error message`, + `replaced transaction error`, +])(`rejects an incorrect HTTP-status outcome: %s`, (fault) => { + const transportFailure = new Error(`transport failed`) + const selected = histories.filter((h) => h.failureResponse === `http-status`) + expect(selected.length).toBeGreaterThan(0) + for (const history of selected) { + const inputs = documentedMutationInputs(history.handler) + const handlerError = new Error(`HTTP Error: 503`) + const actual: DocumentedMutationObservation = { + ...expectedMutationConfirmation( + history.mutation, + `settled`, + handlerError, + inputs, + ), + handlerOutcome: `rejected`, + handlerError, + } + checkDocumentedMutation( + history, + `settled`, + actual, + transportFailure, + inputs, + ) + if (fault === `missing status guard`) { + actual.handlerOutcome = `fulfilled` + actual.handlerError = undefined + } else if (fault === `wrong error kind`) + actual.handlerError = `HTTP Error: 503` + else if (fault === `wrong error message`) + actual.handlerError = new Error(`HTTP Error: 200`) + else actual.error = new Error(`HTTP Error: 503`) + expect(() => + checkDocumentedMutation( + history, + `settled`, + actual, + transportFailure, + inputs, + ), + ).toThrow() + } +}) + +declareOracle({ + check: [checkMutationConfirmation, checkDocumentedMutation], + model: [expectedMutationConfirmation], + grammar: [documentedMutationHistories], +}) diff --git a/packages/db/tests/documented-mutations-oracle.ts b/packages/db/tests/documented-mutations-oracle.ts new file mode 100644 index 0000000000..b62724403f --- /dev/null +++ b/packages/db/tests/documented-mutations-oracle.ts @@ -0,0 +1,484 @@ +import { expect } from 'vitest' +import { declareOracle } from './oracle-declaration' +import { + checkMutationConfirmation, + expectedMutationConfirmation, +} from './mutation-confirmation-oracle' +import type { + MutationConfirmationCut, + MutationConfirmationHistory, + MutationConfirmationInputs, + MutationConfirmationObservation, + MutationOperation, +} from './mutation-confirmation-oracle' + +/** + * Recommended mutation handlers must confirm successful server writes before + * settlement. Before/WRONG examples deliberately omit that obligation. Their + * old return values have no coordinating effect (the approved RC contract). + * + * This grammar receives the existing mutation-confirmation judgment; it owns + * no second outcome model. A declared strategy is independent of execution: + * an API may commit and reject before the handler can request confirmation. + * Such a rejection drops the optimistic state onto the old base. A later + * explicit read/stream delivery reveals the committed server row. + * + * Scope: exact handler ASTs, one mutation, one edited key and an optional peer. + * Complete ordinary fields, including Dates and own undefined, are observed. + * Transport rejection and resolved HTTP 503 responses are separate lanes for + * the fetch handler, with server-write fact varied independently. The HTTP + * branch owes an Error with its declared message and exact error propagation. + * Full schema/factory/query/component execution, awaitMatch, timeout guidance, + * multiple mutations, persistence wrappers and live providers are not claimed. + */ +export type DocumentedRow = Record & { id: string | number } +export type DocumentedApi = + `bulk-todos` | `todos` | `products` | `profile` | `single-todo` | `fetch` +export type DocumentedPayload = + | `modified` + | `changes` + | `id-changes` + | `where-data` + | `key` + | `rows` + | `updates` + | `keys` +export type DocumentedHandler = { + id: string + file: string + heading: string + fence: number + selection?: `after` | `before` | `correct` | `wrong` + operation: MutationOperation + adapter: `query` | `electric` + confirmation: MutationConfirmationHistory[`confirmation`] + api: DocumentedApi + payload: DocumentedPayload + shape: `todo` | `dated-todo` | `product` | `profile` + numeric?: boolean + legacy?: boolean +} + +const queryGuide = `docs/collections/query-collection.md` +const electricGuide = `docs/collections/electric-collection.md` +const mutations = `docs/guides/mutations.md` +const migration = `docs/guides/migrate-to-1-0-rc.md` +const skillRoot = `packages/db/skills/db-core/` +type Fence = Omit & { + id: string + handlers: Array<[MutationOperation, DocumentedPayload]> +} + +// These declarations specify the published recommendations and collaborators. +// They do not inspect the extracted handler to choose its required outcome. +const fences: Array = [ + { + id: `error-post`, + file: `docs/guides/error-handling.md`, + heading: `## Transaction Error Handling`, + fence: 0, + adapter: `query`, + confirmation: `refetch`, + api: `fetch`, + shape: `todo`, + handlers: [[`insert`, `modified`]], + }, + { + id: `query-handlers`, + file: queryGuide, + heading: `## Persistence Handlers`, + fence: 0, + adapter: `query`, + confirmation: `refetch`, + api: `bulk-todos`, + shape: `todo`, + handlers: [ + [`insert`, `rows`], + [`update`, `updates`], + [`delete`, `keys`], + ], + }, + { + id: `query-upsert`, + file: queryGuide, + heading: `#### Applying a Server Response Without Refetching`, + fence: 0, + adapter: `query`, + confirmation: `write`, + api: `bulk-todos`, + shape: `todo`, + handlers: [[`insert`, `rows`]], + }, + { + id: `query-incremental`, + file: queryGuide, + heading: `### Example: Incremental Updates`, + fence: 0, + adapter: `query`, + confirmation: `write`, + api: `bulk-todos`, + shape: `todo`, + handlers: [ + [`insert`, `rows`], + [`update`, `updates`], + ], + }, + { + id: `mutations-after`, + file: mutations, + heading: `### Simplified Mutations vs Traditional Approaches`, + fence: 1, + adapter: `query`, + confirmation: `refetch`, + api: `todos`, + shape: `todo`, + handlers: [[`insert`, `modified`]], + }, + { + id: `mutations-query`, + file: mutations, + heading: `### Collection-Specific Handler Patterns`, + fence: 0, + adapter: `query`, + confirmation: `refetch`, + api: `todos`, + shape: `todo`, + handlers: [[`update`, `id-changes`]], + }, + { + id: `mutations-electric`, + file: mutations, + heading: `### Collection-Specific Handler Patterns`, + fence: 1, + adapter: `electric`, + confirmation: `txid`, + api: `todos`, + shape: `todo`, + handlers: [[`update`, `id-changes`]], + }, + { + id: `mutations-profile`, + file: mutations, + heading: `#### Combining Local and Server Collections`, + fence: 0, + adapter: `query`, + confirmation: `refetch`, + api: `profile`, + shape: `profile`, + handlers: [[`update`, `modified`]], + }, + { + id: `migration-electric`, + file: migration, + heading: `## Await synchronization inside mutation handlers`, + fence: 1, + selection: `after`, + adapter: `electric`, + confirmation: `txid`, + api: `todos`, + shape: `todo`, + handlers: [[`update`, `changes`]], + }, + { + id: `migration-query`, + file: migration, + heading: `### Query Collection handlers no longer refetch`, + fence: 0, + selection: `after`, + adapter: `query`, + confirmation: `refetch`, + api: `todos`, + shape: `todo`, + handlers: [[`update`, `changes`]], + }, + { + id: `skill-query`, + file: `${skillRoot}mutations-optimistic/SKILL.md`, + heading: `### 4. Mutation handlers with explicit refetch (QueryCollection pattern)`, + fence: 0, + adapter: `query`, + confirmation: `refetch`, + api: `todos`, + shape: `todo`, + handlers: [ + [`insert`, `modified`], + [`update`, `id-changes`], + [`delete`, `key`], + ], + }, + { + id: `skill-electric`, + file: `${skillRoot}mutations-optimistic/SKILL.md`, + heading: `### 4. Mutation handlers with explicit refetch (QueryCollection pattern)`, + fence: 1, + adapter: `electric`, + confirmation: `txid`, + api: `todos`, + shape: `todo`, + handlers: [[`update`, `id-changes`]], + }, + { + id: `skill-correct`, + file: `${skillRoot}mutations-optimistic/SKILL.md`, + heading: `### HIGH: Returning before Query Collection has confirmed data`, + fence: 0, + selection: `correct`, + adapter: `query`, + confirmation: `refetch`, + api: `single-todo`, + shape: `todo`, + handlers: [[`insert`, `modified`]], + }, + { + id: `skill-products`, + file: `${skillRoot}collection-setup/references/query-adapter.md`, + heading: `## Complete Example`, + fence: 0, + adapter: `query`, + confirmation: `refetch`, + api: `products`, + shape: `product`, + handlers: [ + [`insert`, `rows`], + [`update`, `updates`], + [`delete`, `keys`], + ], + }, + { + id: `skill-electric-complete`, + file: `${skillRoot}collection-setup/references/electric-adapter.md`, + heading: `## Complete Example`, + fence: 0, + adapter: `electric`, + confirmation: `txid`, + api: `todos`, + shape: `dated-todo`, + handlers: [ + [`insert`, `modified`], + [`update`, `where-data`], + [`delete`, `key`], + ], + }, + { + id: `skill-setup-query`, + file: `${skillRoot}collection-setup/SKILL.md`, + heading: `## Setup`, + fence: 0, + adapter: `query`, + confirmation: `refetch`, + api: `todos`, + shape: `dated-todo`, + numeric: true, + handlers: [ + [`insert`, `modified`], + [`update`, `id-changes`], + [`delete`, `key`], + ], + }, + { + id: `skill-setup-electric`, + file: `${skillRoot}collection-setup/SKILL.md`, + heading: `### ElectricSQL with txid tracking`, + fence: 0, + adapter: `electric`, + confirmation: `txid`, + api: `todos`, + shape: `dated-todo`, + handlers: [[`insert`, `modified`]], + }, + { + id: `electric-handlers`, + file: electricGuide, + heading: `### 1. Using Txid (Recommended)`, + fence: 0, + adapter: `electric`, + confirmation: `txid`, + api: `todos`, + shape: `todo`, + handlers: [ + [`insert`, `modified`], + [`update`, `where-data`], + ], + }, +] + +export const documentedHandlers: Array = fences.flatMap( + ({ handlers, ...fence }) => + handlers.map(([operation, payload]) => ({ + ...fence, + operation, + payload, + id: `${fence.id}/${operation}`, + confirmation: + fence.id === `skill-products` && operation === `insert` + ? `write` + : fence.confirmation, + })), +) +export const negativeDocumentedHandlers: Array = [ + { + ...documentedHandlers.find((h) => h.id === `migration-electric/update`)!, + id: `migration-electric-before/update`, + selection: `before`, + confirmation: `none`, + legacy: true, + }, + { + ...documentedHandlers.find((h) => h.id === `migration-query/update`)!, + id: `migration-query-before/update`, + selection: `before`, + confirmation: `none`, + }, + { + ...documentedHandlers.find((h) => h.id === `skill-correct/insert`)!, + id: `skill-wrong/insert`, + selection: `wrong`, + confirmation: `none`, + }, +] + +export type DocumentedMutationHistory = { + id: string + handler: DocumentedHandler + apiOutcome: `success` | `reject-before-write` | `reject-after-write` + // This splits an unsuccessful mutation attempt from its transport result: + // fetch may resolve to a non-OK HTTP response, with either ledger-write fact. + failureResponse?: `http-status` + mutation: MutationConfirmationHistory +} + +export type DocumentedMutationObservation = + MutationConfirmationObservation & { + handlerOutcome: `pending` | `fulfilled` | `rejected` + handlerError: unknown + } + +// The authored handler's own Promise is observed independently of transaction +// settlement. At a held source cut it must still be pending; no fixture gate +// may supply a missing await. This uses the existing outcome model's result, +// not another account of optimistic settlement. Already accepted direct writes +// have no held-source cut and impose no requirement about await spelling. +export function checkDocumentedMutation( + history: DocumentedMutationHistory, + cut: MutationConfirmationCut, + observed: DocumentedMutationObservation, + failure: Error, + inputs: MutationConfirmationInputs, +): void { + const { handlerOutcome, handlerError, ...transaction } = observed + const settled = cut === `settled` || cut === `late-confirmed` + let expectedFailure = failure + if (history.failureResponse === `http-status` && settled) { + // Kind/message are independently declared for the actual guide's HTTP + // branch. Only after that judgment does its captured error become the + // identity token that adapter settlement must preserve. + expect(handlerOutcome, `${cut}: HTTP status must reject the handler`).toBe( + `rejected`, + ) + expect(handlerError, `${cut}: HTTP error kind`).toBeInstanceOf(Error) + if (!(handlerError instanceof Error)) throw new Error(`Missing HTTP Error`) + expect(handlerError.message, `${cut}: HTTP error message`).toBe( + `HTTP Error: 503`, + ) + expectedFailure = handlerError + } + const expected = expectedMutationConfirmation( + history.mutation, + cut, + expectedFailure, + inputs, + ) + expect(handlerOutcome, `${cut}: actual documented handler Promise`).toBe( + expected.settlement, + ) + expect(handlerError, `${cut}: actual documented handler rejection`).toBe( + expected.error, + ) + checkMutationConfirmation( + history.mutation, + cut, + transaction, + expectedFailure, + inputs, + ) +} + +// API failure is upstream of the handler's acknowledgement. It commits no +// source transaction. The existing default rejection-after-ack stays unchanged. +export function documentedMutationHistories( + adapter: `query` | `electric`, +): Array { + const histories: Array = [ + ...documentedHandlers, + ...negativeDocumentedHandlers, + ] + .filter((h) => h.adapter === adapter) + .flatMap((handler) => + (handler.confirmation === `none` + ? ([`success`] as const) + : ([`success`, `reject-before-write`, `reject-after-write`] as const) + ).flatMap((apiOutcome) => + [false, true].map((peer) => ({ + id: `${handler.id}/${apiOutcome}/peer:${peer}`, + handler, + apiOutcome, + mutation: { + adapter, + operation: handler.operation, + peer, + confirmation: handler.confirmation, + confirmationEntry: + apiOutcome === `success` ? `reached` : `unreachable`, + ...(apiOutcome === `success` + ? { + outcome: `fulfilled` as const, + returned: handler.legacy + ? (`legacy-txid` as const) + : (`void` as const), + } + : { outcome: apiOutcome, returned: `void` as const }), + }, + })), + ), + ) + return histories.flatMap((history) => + history.handler.api === `fetch` && history.apiOutcome !== `success` + ? [ + history, + { + ...history, + id: `${history.id}/http:503`, + failureResponse: `http-status` as const, + }, + ] + : [history], + ) +} + +export function documentedMutationInputs( + handler: DocumentedHandler, +): MutationConfirmationInputs { + const id = handler.numeric ? 1 : `1` + const peerId = handler.numeric ? 2 : `2` + if (handler.shape === `profile` || handler.shape === `product`) + return { + before: { id, name: `before` }, + optimistic: { id, name: `optimistic` }, + confirmed: { id, name: `confirmed`, server_value: undefined }, + peer: { id: peerId, name: `peer` }, + } + const dated = handler.shape === `dated-todo` + const extra = dated ? { created_at: new Date(`2026-10-01T00:00:00Z`) } : {} + return { + before: { id, text: `before`, completed: false, ...extra }, + optimistic: { id, text: `optimistic`, completed: false, ...extra }, + confirmed: { id, text: `confirmed`, completed: true, ...extra }, + peer: { id: peerId, text: `peer`, completed: false, ...extra }, + } +} + +declareOracle({ + check: [checkDocumentedMutation], + grammar: [documentedMutationHistories, documentedHandlers], + model: [documentedMutationInputs], +}) diff --git a/packages/db/tests/documented-mutations-support.test.ts b/packages/db/tests/documented-mutations-support.test.ts new file mode 100644 index 0000000000..a8b9e680bc --- /dev/null +++ b/packages/db/tests/documented-mutations-support.test.ts @@ -0,0 +1,172 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { resolve } from 'node:path' +import { afterEach, expect, test, vi } from 'vitest' +import { + documentedHandlers, + documentedMutationHistories, + documentedMutationInputs, + negativeDocumentedHandlers, +} from './documented-mutations-oracle' +import { + checkDocumentedRequest, + createDocumentedReach, + extractDocumentedHandler, + selectDocumentedHistories, +} from './documented-mutations-support' + +afterEach(() => { + vi.unstubAllEnvs() + vi.restoreAllMocks() +}) + +const root = resolve(process.cwd(), `../..`) + +test(`inventory locates every authored current and negative handler`, () => { + expect(documentedHandlers).toHaveLength(30) + expect(negativeDocumentedHandlers).toHaveLength(3) + expect( + new Set(documentedHandlers.map((h) => `${h.file}:${h.heading}:${h.fence}`)) + .size, + ).toBe(18) + for (const handler of [...documentedHandlers, ...negativeDocumentedHandlers]) + expect(extractDocumentedHandler(handler, root)).toMatch(/^async\b/) + expect(documentedMutationHistories(`query`)).toHaveLength(134) + expect(documentedMutationHistories(`electric`)).toHaveLength(56) +}) + +test.each([`migration-query`, `migration-electric`, `skill-correct`])( + `current and negative markers select distinct actual ASTs: %s`, + (id) => { + const current = documentedHandlers.find((h) => h.id.startsWith(`${id}/`))! + const previous = negativeDocumentedHandlers.find( + (h) => h.file === current.file && h.heading === current.heading, + )! + expect(extractDocumentedHandler(current, root)).not.toBe( + extractDocumentedHandler(previous, root), + ) + }, +) + +test.each([ + { name: `missing heading`, body: `# Another\n` }, + { name: `duplicate heading`, body: `# Section\n# Section\n` }, + { + name: `missing typed fence`, + body: `# Section\n\n\x60\x60\x60js\n\x60\x60\x60`, + }, + { + name: `ambiguous handler`, + body: `# Section\n\x60\x60\x60ts\nconst x = { onInsert: async () => {}, onInsert: async () => {} }\n\x60\x60\x60`, + }, +])(`refuses an invalid exact locator: $name`, ({ body }) => { + const directory = mkdtempSync(resolve(tmpdir(), `documented-handler-`)) + try { + writeFileSync(resolve(directory, `fixture.md`), body) + expect(() => + extractDocumentedHandler( + { + ...documentedHandlers[0]!, + file: `fixture.md`, + heading: `# Section`, + }, + directory, + ), + ).toThrow() + } finally { + rmSync(directory, { recursive: true, force: true }) + } +}) + +test(`finite replay selects the exact authored history and rejects an unknown id`, () => { + const histories = documentedMutationHistories(`query`) + vi.stubEnv(`TANSTACK_DB_DOCUMENTED_MUTATION_CASE`, histories[4]!.id) + expect(selectDocumentedHistories(histories)).toStrictEqual([histories[4]]) + vi.stubEnv(`TANSTACK_DB_DOCUMENTED_MUTATION_CASE`, `unknown`) + expect(() => selectDocumentedHistories(histories)).toThrow(/Unknown/) +}) + +test.each([undefined, `42`])( + `an irrelevant shared seed %s changes neither complete nor missing-lane reach`, + (seed) => { + vi.stubEnv(`TANSTACK_DB_ORACLE_SEED`, seed) + vi.spyOn(console, `info`).mockImplementation(() => {}) + const histories = documentedMutationHistories(`query`).filter( + (h) => + h.handler.id === `query-handlers/insert` && h.apiOutcome === `success`, + ) + const complete = createDocumentedReach(`controls`, histories.slice(0, 1)) + const partial = createDocumentedReach(`controls`, histories) + for (const reach of [complete, partial]) { + reach.begin(histories[0]!.id) + for (const state of [ + `entered`, + `api-request`, + `settlement-waiter`, + `server-written`, + `confirmation-held`, + `confirmation-accepted`, + `comparison:entered`, + `comparison:server-written`, + `comparison:confirmation-requested`, + `comparison:confirmation-accepted`, + `comparison:settled`, + ]) + reach.reach(state) + } + expect(() => complete.finish()).not.toThrow() + expect(() => partial.finish()).toThrow(/peer:true/) + }, +) + +test(`unentered controls impose no reach but a partial finite campaign does`, () => { + vi.spyOn(console, `info`).mockImplementation(() => {}) + const histories = documentedMutationHistories(`query`).slice(0, 2) + const reach = createDocumentedReach(`controls`, histories) + expect(() => reach.finish()).not.toThrow() + reach.begin(histories[0]!.id) + reach.reach(`entered`) + expect(() => reach.finish()).toThrow(/settlement-waiter/) +}) + +test.each([`original instead of modified`, `wrong key`, `omitted field`])( + `request collaborator rejects %s`, + (fault) => { + const history = documentedMutationHistories(`query`).find( + (h) => h.handler.id === `mutations-after/insert`, + )! + const inputs = documentedMutationInputs(history.handler) + const payload = structuredClone(inputs.optimistic) + checkDocumentedRequest(history, inputs.before, inputs.optimistic, [payload]) + if (fault === `original instead of modified`) + Object.assign(payload, inputs.before) + else if (fault === `wrong key`) payload.id = `wrong` + else delete payload.completed + expect(() => + checkDocumentedRequest(history, inputs.before, inputs.optimistic, [ + payload, + ]), + ).toThrow() + }, +) + +test.each([`wrong URL`, `wrong method`, `wrong body`])( + `fetch collaborator rejects %s`, + (fault) => { + const history = documentedMutationHistories(`query`)[0]! + const inputs = documentedMutationInputs(history.handler) + const options = { method: `POST`, body: JSON.stringify(inputs.optimistic) } + checkDocumentedRequest(history, inputs.before, inputs.optimistic, [ + `/api/todos`, + options, + ]) + if (fault === `wrong method`) options.method = `GET` + if (fault === `wrong body`) options.body = JSON.stringify(inputs.before) + expect(() => + checkDocumentedRequest(history, inputs.before, inputs.optimistic, [ + fault === `wrong URL` ? `/other` : `/api/todos`, + options, + ]), + ).toThrow() + }, +) diff --git a/packages/db/tests/documented-mutations-support.ts b/packages/db/tests/documented-mutations-support.ts new file mode 100644 index 0000000000..ada1e6be74 --- /dev/null +++ b/packages/db/tests/documented-mutations-support.ts @@ -0,0 +1,635 @@ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { setImmediate } from 'node:timers/promises' +import ts from 'typescript' +import { expect } from 'vitest' +import { createDeferred } from '../src/deferred' +import { mutationConfirmationRows } from './mutation-confirmation-oracle' +import { + checkDocumentedMutation, + documentedMutationInputs, +} from './documented-mutations-oracle' +import { atMutationCheckpoint } from './mutation-confirmation-support' +import { withOracleCleanup } from './utils' +import { createOracleReach } from './oracle-reach' +import type { + DocumentedHandler, + DocumentedMutationHistory, + DocumentedMutationObservation, + DocumentedRow, +} from './documented-mutations-oracle' +import type { MutationConfirmationObservation } from './mutation-confirmation-oracle' +import type { Collection, MutationFnParams } from '../src/index' + +// Finite replay selects one explicit history. A shared random seed is irrelevant +// and cannot disable reach. Unrelated path replays stand aside at registration. +// A named test subset without this selector keeps the full reach obligation. +export function selectDocumentedHistories( + histories: Array, +) { + const requested = process.env.TANSTACK_DB_DOCUMENTED_MUTATION_CASE + if (!requested) return histories + const selected = histories.filter((history) => history.id === requested) + if (selected.length !== 1) + throw new Error(`Unknown documented mutation case: ${requested}`) + return selected +} + +export function createDocumentedReach( + adapter: string, + histories: Array, +) { + const states = histories.flatMap((h) => [ + `${h.id}:entered`, + `${h.id}:api-request`, + `${h.id}:settlement-waiter`, + ...(h.apiOutcome === `reject-before-write` + ? [] + : [`${h.id}:server-written`]), + ...(h.apiOutcome === `success` && h.handler.confirmation !== `none` + ? [ + `${h.id}:confirmation-accepted`, + ...(h.handler.confirmation === `write` + ? [] + : [`${h.id}:confirmation-held`]), + ] + : []), + ...(h.apiOutcome === `reject-after-write` || + h.handler.confirmation === `none` + ? [`${h.id}:late-confirmed`] + : []), + ...[ + `entered`, + `settled`, + ...(h.apiOutcome === `reject-before-write` ? [] : [`server-written`]), + ...(h.apiOutcome === `success` && h.handler.confirmation !== `none` + ? [ + ...(h.handler.confirmation === `write` + ? [] + : [`confirmation-requested`, `confirmation-accepted`]), + ] + : []), + ...(h.apiOutcome === `reject-after-write` || + h.handler.confirmation === `none` + ? [`late-confirmed`] + : []), + ].map((cut) => `${h.id}:comparison:${cut}`), + ]) + const counter = createOracleReach(`documented-${adapter}`, states, { + replaying: false, + }) + let current = ``, + entered = 0 + const cuts = new Map() + return { + begin: (id: string) => { + current = id + entered++ + counter.beginHistory() + }, + reach: (state: string) => { + if (state.startsWith(`comparison:`)) { + const key = `${current}:${state}` + cuts.set(key, (cuts.get(key) ?? 0) + 1) + } + counter.reach(`${current}:${state}`) + }, + finish: () => { + if (entered === 0) return + console.info( + `documented-${adapter} reach`, + Object.fromEntries(counter.counts()), + `comparisons`, + Object.fromEntries(cuts), + ) + counter.assertReached() + }, + } +} + +export type DocumentedRuntimeHandler = ( + params: MutationFnParams, +) => Promise +export type DocumentedReceiver = { + collection: Collection + start: () => Promise + deliver: () => Promise + late: () => Promise + sourceRequested: Promise + fetches: () => number + providerRequests: () => number + cleanup: () => Promise +} +export type DocumentedReceiverControls = { + history: DocumentedMutationHistory + initial: Array + server: () => Array + handler: DocumentedRuntimeHandler + confirmation: (work: () => Promise) => Promise +} + +// The locator is an exact heading plus fence ordinal. AST extraction accepts a +// complete factory or an object-property fragment. It refuses missing/duplicate +// headings, markers and handlers; no copied handler body stands in for a fence. +export function extractDocumentedHandler( + handler: DocumentedHandler, + root: string, +): string { + const text = readFileSync(resolve(root, handler.file), `utf8`) + const headings = [...text.matchAll(/^#{1,6} .+$/gm)] + const matching = headings.filter((m) => m[0] === handler.heading) + if (matching.length !== 1) + throw new Error(`${handler.id}: heading count ${matching.length}`) + const start = matching[0]! + const level = start[0].indexOf(` `) + const end = + headings.find((m) => m.index > start.index && m[0].indexOf(` `) <= level) + ?.index ?? text.length + const section = text.slice(start.index, end) + const fences = [ + ...section.matchAll( + /^(`{3,})(?:ts|typescript|tsx)[ \t]*\n([\s\S]*?)^\1[ \t]*$/gm, + ), + ] + let code = fences[handler.fence]?.[2] + if (code === undefined) + throw new Error(`${handler.id}: missing fence ${handler.fence}`) + if (handler.selection) { + const labels = { + after: `After`, + before: `Before`, + correct: `CORRECT`, + wrong: `WRONG`, + } + const markers = [ + ...code.matchAll(/^\/\/ (After|Before|CORRECT|WRONG)\b[^\n]*$/gm), + ] + const selected = markers.filter((m) => m[1] === labels[handler.selection!]) + if (selected.length !== 1) + throw new Error(`${handler.id}: marker count ${selected.length}`) + const marker = selected[0]! + const next = + markers.find((m) => m.index > marker.index)?.index ?? code.length + code = code.slice(marker.index + marker[0].length, next) + } + const find = (source: ts.SourceFile) => { + const found: Array = [] + const visit = (node: ts.Node) => { + if ( + ts.isPropertyAssignment(node) && + node.name.getText(source) === + `on${handler.operation[0]!.toUpperCase()}${handler.operation.slice(1)}` && + (ts.isArrowFunction(node.initializer) || + ts.isFunctionExpression(node.initializer)) + ) + found.push(node) + ts.forEachChild(node, visit) + } + visit(source) + return found + } + let source = ts.createSourceFile( + `guidance.ts`, + code, + ts.ScriptTarget.Latest, + true, + ) + let found = find(source) + if (found.length === 0) { + source = ts.createSourceFile( + `guidance.ts`, + `const fragment = ({\n${code}\n})`, + ts.ScriptTarget.Latest, + true, + ) + found = find(source) + } + if (found.length !== 1) + throw new Error(`${handler.id}: handler count ${found.length}`) + return found[0]!.initializer.getText(source) +} + +export function compileDocumentedHandler( + source: string, + api: object, + fetch: (...args: Array) => Promise, +): DocumentedRuntimeHandler { + const compiled = ts.transpileModule(`const handler = ${source}\n`, { + compilerOptions: { + target: ts.ScriptTarget.ES2022, + module: ts.ModuleKind.ESNext, + }, + reportDiagnostics: true, + }) + if (compiled.diagnostics?.length) + throw new Error(`Documented handler does not transpile`) + const result: unknown = new Function( + `api`, + `fetch`, + `${compiled.outputText}\nreturn handler`, + )(api, fetch) + if (typeof result !== `function`) + throw new Error(`Documented handler is not callable`) + return result as DocumentedRuntimeHandler +} + +// Only public virtual metadata is outside this row-value law. Object rest keeps +// every ordinary own field; structuredClone preserves Dates and own undefined. +export function documentedRows( + collection: Collection, + editedKey: string | number, +): Array { + return collection.toArray + .map((row) => { + const { + $key: _key, + $collectionId: _id, + $origin: _origin, + $hasPendingWrites: _pending, + ...data + } = row + return structuredClone(data) + }) + .sort((a, b) => Number(b.id === editedKey) - Number(a.id === editedKey)) +} + +function expectedArguments( + history: DocumentedMutationHistory, + before: DocumentedRow, + optimistic: DocumentedRow, +): Array { + const changes = Object.fromEntries( + Object.entries(optimistic).filter( + ([key, value]) => !Object.is(value, before[key]) && key !== `id`, + ), + ) + // Dates are unchanged in these payloads. Compare them by value rather than + // inventing a change because model/driver inputs use independent copies. + for (const [key, value] of Object.entries(changes)) + if ( + value instanceof Date && + before[key] instanceof Date && + value.getTime() === before[key].getTime() + ) + delete changes[key] + switch (history.handler.payload) { + case `modified`: + return [optimistic] + case `changes`: + return [changes] + case `id-changes`: + return [before.id, changes] + case `where-data`: + return [{ where: { id: before.id }, data: changes }] + case `key`: + return [before.id] + case `rows`: + return [[optimistic]] + case `updates`: + return [[{ id: before.id, changes }]] + case `keys`: + return [[before.id]] + } +} + +// Request checking is shared with hostile collaborator calibrations. It returns +// the actual submitted data, never the independent expected payload. +export function checkDocumentedRequest( + history: DocumentedMutationHistory, + before: DocumentedRow, + optimistic: DocumentedRow, + raw: Array, +): Array { + const args = + history.handler.api === `fetch` + ? [JSON.parse((raw[1] as { body: string }).body) as unknown] + : raw + expect(args, `${history.id}: actual documented request`).toStrictEqual( + expectedArguments(history, before, optimistic), + ) + if (history.handler.api === `fetch`) { + expect(raw[0]).toBe(`/api/todos`) + expect((raw[1] as { method: string }).method).toBe(`POST`) + } + return args +} + +function committedRow( + history: DocumentedMutationHistory, + args: Array, + before: DocumentedRow, +): DocumentedRow { + const first = args[0] + let request: DocumentedRow + switch (history.handler.payload) { + case `modified`: + request = first as DocumentedRow + break + case `rows`: + request = (first as Array)[0]! + break + case `changes`: + request = { ...before, ...(first as object) } + break + case `id-changes`: + request = { ...before, ...(args[1] as object) } + break + case `where-data`: + request = { ...before, ...(first as { data: object }).data } + break + case `updates`: + request = { + ...before, + ...(first as Array<{ changes: object }>)[0]!.changes, + } + break + default: + request = before + } + // The controlled server canonicalizes display text and completion, keeping + // every other submitted field. This premise never repairs a missing payload: + // exact actual request arguments were compared before this function ran. + return history.handler.shape === `product` || + history.handler.shape === `profile` + ? { + ...structuredClone(request), + name: `confirmed`, + server_value: undefined, + } + : { ...structuredClone(request), text: `confirmed`, completed: true } +} + +/** + * One production loop receives Query and Electric through their real adapters. + * API commit, API response, source delivery and handler-Promise settlement are + * separately held. The fake backend uses the extracted handler's actual request. + * No missing refetch is supplied by the fixture. Settlement races utility entry, + * so a missing acknowledgement fails at the promised settlement comparison. + */ +export async function runDocumentedMutation( + history: DocumentedMutationHistory, + root: string, + createReceiver: (controls: DocumentedReceiverControls) => DocumentedReceiver, + reach: (state: string) => void, +): Promise { + const inputs = documentedMutationInputs(history.handler) + const initial = mutationConfirmationRows( + history.mutation.operation, + `initial`, + history.mutation.peer, + inputs, + ) + const confirmed = mutationConfirmationRows( + history.mutation.operation, + `confirmed`, + history.mutation.peer, + inputs, + ) + let server = structuredClone(initial) + const entryGate = createDeferred(), + responseGate = createDeferred(), + acceptedCutGate = createDeferred() + const entered = createDeferred(), + called = createDeferred(), + returned = createDeferred() + const confirmationRequested = createDeferred(), + confirmationAccepted = createDeferred() + const failure = new Error(`documented API ${history.apiOutcome}`) + let handlerWrites = 0, + calls = 0, + confirmationCalls = 0 + let confirmationStatus: MutationConfirmationObservation[`confirmationStatus`] = `not-requested` + let settlement: MutationConfirmationObservation[`settlement`] = `pending` + let handlerOutcome: DocumentedMutationObservation[`handlerOutcome`] = `pending` + let handlerError: unknown + let error: unknown, + requestFailure: unknown, + receiver: DocumentedReceiver | undefined + let verdict: Promise | undefined + let inConfirmation = false, + handlerActive = true + const invoke = async (...raw: Array) => { + calls++ + try { + const args = checkDocumentedRequest( + history, + inputs.before, + inputs.optimistic, + raw, + ) + if (history.apiOutcome !== `reject-before-write`) { + handlerWrites++ + const row = committedRow(history, args, inputs.before) + server = history.mutation.peer ? [structuredClone(inputs.peer)] : [] + if (history.mutation.operation !== `delete`) server.unshift(row) + } + } catch (requestError) { + requestFailure = requestError + throw requestError + } finally { + called.resolve() + } + await responseGate.promise + if (history.failureResponse === `http-status`) + return { ok: false, status: 503 } + if (history.apiOutcome !== `success`) throw failure + return history.handler.adapter === `electric` + ? { txid: 55 } + : history.handler.api === `fetch` + ? { ok: true, status: 200 } + : structuredClone( + server.filter((row) => row.id === inputs.optimistic.id), + ) + } + const method = + history.mutation.operation === `insert` + ? `create` + : history.mutation.operation + const api = + history.handler.api === `todos` + ? { todos: { [method]: invoke } } + : history.handler.api === `profile` + ? { profile: { update: invoke } } + : history.handler.api === `single-todo` + ? { createTodo: invoke } + : history.handler.api === `products` + ? { [`${method}Products`]: invoke } + : history.handler.api === `bulk-todos` + ? { [`${method}Todos`]: invoke } + : {} + const actual = compileDocumentedHandler( + extractDocumentedHandler(history.handler, root), + api, + invoke, + ) + await withOracleCleanup(async () => { + receiver = createReceiver({ + history, + initial: structuredClone(initial), + server: () => structuredClone(server), + handler: async (params) => { + entered.resolve() + await entryGate.promise + try { + const value = await actual(params) + handlerOutcome = `fulfilled` + return value + } catch (reason) { + handlerOutcome = `rejected` + handlerError = reason + throw reason + } finally { + returned.resolve() + handlerActive = false + } + }, + confirmation: async (work) => { + if (!handlerActive || inConfirmation) return work() + inConfirmation = true + confirmationCalls++ + confirmationStatus = `pending` + try { + const pending = work() + confirmationRequested.resolve() + const result = await pending + confirmationStatus = `accepted` + confirmationAccepted.resolve() + if (history.handler.confirmation !== `write`) + await acceptedCutGate.promise + return result + } finally { + inConfirmation = false + } + }, + }) + await atMutationCheckpoint(receiver.start(), `documented initial source`) + const collection = receiver.collection + const transaction = + history.mutation.operation === `insert` + ? collection.insert(structuredClone(inputs.optimistic)) + : history.mutation.operation === `delete` + ? collection.delete(inputs.before.id) + : collection.update(inputs.before.id, (draft) => { + Object.assign(draft, structuredClone(inputs.optimistic)) + }) + const observe = (): DocumentedMutationObservation => ({ + rows: documentedRows(collection, inputs.optimistic.id), + handlerWrites, + confirmationCalls, + confirmationStatus, + fetches: receiver!.fetches(), + providerRequests: receiver!.providerRequests(), + state: transaction.state, + settlement, + error, + handlerOutcome, + handlerError, + }) + verdict = transaction.when(`settled`).then( + () => { + settlement = `fulfilled` + return observe() + }, + (reason: unknown) => { + settlement = `rejected` + error = reason + return observe() + }, + ) + const compare = ( + cut: Parameters[1], + observation = observe(), + ) => { + reach(`comparison:${cut}`) + checkDocumentedMutation(history, cut, observation, failure, inputs) + } + await atMutationCheckpoint(entered.promise, `documented handler entry`) + reach(`entered`) + compare(`entered`) + entryGate.resolve() + await atMutationCheckpoint(called.promise, `actual documented API request`) + reach(`api-request`) + if (requestFailure !== undefined) throw requestFailure + expect(calls).toBe(1) + expect(server, `actual server ledger`).toStrictEqual( + history.apiOutcome === `reject-before-write` ? initial : confirmed, + ) + if (history.apiOutcome !== `reject-before-write`) { + reach(`server-written`) + compare(`server-written`) + } + responseGate.resolve() + if ( + history.apiOutcome === `success` && + history.handler.confirmation !== `none` + ) { + const next = await atMutationCheckpoint( + Promise.race([ + confirmationRequested.promise.then(() => `confirmation` as const), + returned.promise.then(() => `returned` as const), + ]), + `confirmation request or actual handler return`, + ) + if (next === `confirmation`) { + if (history.handler.confirmation !== `write`) { + await atMutationCheckpoint( + receiver.sourceRequested, + `real source request held`, + ) + reach(`confirmation-held`) + // Drain runnable Promise continuations while the real source remains + // unavailable. An unawaited utility lets the actual handler return; + // a handler that awaits confirmation cannot return at this cut. + await setImmediate() + compare(`confirmation-requested`) + await atMutationCheckpoint( + receiver.deliver(), + `documented source delivery`, + ) + } + await atMutationCheckpoint( + confirmationAccepted.promise, + `real utility accepted`, + ) + reach(`confirmation-accepted`) + if (history.handler.confirmation !== `write`) + compare(`confirmation-accepted`) + acceptedCutGate.resolve() + } + } + await atMutationCheckpoint( + returned.promise, + `actual documented handler return`, + ) + const atWaiter = await atMutationCheckpoint( + verdict, + `documented transaction settlement`, + ) + reach(`settlement-waiter`) + compare(`settled`, atWaiter) + compare(`settled`) + if ( + history.apiOutcome !== `reject-before-write` && + (history.apiOutcome !== `success` || + history.handler.confirmation === `none`) + ) { + await atMutationCheckpoint( + receiver.late(), + `later explicit source confirmation`, + ) + reach(`late-confirmed`) + compare(`late-confirmed`) + compare(`settled`, atWaiter) + } + }, [ + () => { + entryGate.resolve() + responseGate.resolve() + acceptedCutGate.resolve() + }, + () => receiver?.deliver(), + () => + verdict && atMutationCheckpoint(verdict, `documented cleanup settlement`), + () => receiver?.cleanup(), + ]) +} diff --git a/packages/db/tests/error-docs-commands.test.ts b/packages/db/tests/error-docs-commands.test.ts new file mode 100644 index 0000000000..12c1c5970e --- /dev/null +++ b/packages/db/tests/error-docs-commands.test.ts @@ -0,0 +1,65 @@ +// Ordinary package-command invocation controls with controlled recipients. +// These establish wiring, not real bundle erasure or Typedoc correctness. +import { spawnSync } from 'node:child_process' +import { existsSync, readFileSync, writeFileSync } from 'node:fs' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { expect, it } from 'vitest' +import { + withErrorDocsFixture, + writeErrorDocsExample, +} from './generate-error-docs-fixture' +import type { ErrorDocsFixture } from './generate-error-docs-fixture' + +const repository = resolve(dirname(fileURLToPath(import.meta.url)), `../../..`) +const { scripts }: { scripts: Record } = JSON.parse( + readFileSync(join(repository, `package.json`), `utf8`), +) + +function runPackageCommand(fixture: ErrorDocsFixture, name: string) { + return spawnSync(`/bin/sh`, [`-c`, scripts[name]!], { + cwd: fixture.root, + encoding: `utf8`, + timeout: 10_000, + env: { ...process.env, PATH: `${dirname(process.execPath)}:/usr/bin:/bin` }, + }) +} + +it(`runs the erasure recipient while the committed errors page is stale`, () => { + withErrorDocsFixture((fixture) => { + writeErrorDocsExample(fixture) + writeFileSync(fixture.pagePath, `stale sentinel`) + writeFileSync( + join(fixture.root, `scripts/test-production-errors.mjs`), + `import { writeFileSync } from 'node:fs'; writeFileSync('erasure-entered', 'yes')\n`, + ) + const result = runPackageCommand(fixture, `test:production-errors`) + expect(result.error).toBeUndefined() + expect(result.status, result.stderr).toBe(0) + expect(existsSync(join(fixture.root, `erasure-entered`))).toBe(true) + expect(readFileSync(fixture.pagePath, `utf8`)).toBe(`stale sentinel`) + }) +}) + +it(`generates the error page before the Typedoc recipient`, () => { + withErrorDocsFixture((fixture) => { + writeErrorDocsExample(fixture) + writeFileSync(fixture.pagePath, `stale sentinel`) + writeFileSync( + join(fixture.root, `scripts/generate-docs.ts`), + ` + import assert from 'node:assert/strict' + import { readFileSync, writeFileSync } from 'node:fs' + writeFileSync('typedoc-entered', 'yes') + assert(readFileSync('docs/errors.md', 'utf8').includes('fixture class message')) + `, + ) + const result = runPackageCommand(fixture, `generate-docs`) + expect(result.error).toBeUndefined() + expect(result.status, result.stderr).toBe(0) + expect(existsSync(join(fixture.root, `typedoc-entered`))).toBe(true) + const check = fixture.run(`--check`) + expect(check.error).toBeUndefined() + expect(check.status, check.stderr).toBe(0) + }) +}) diff --git a/packages/db/tests/error-sample-arguments.ts b/packages/db/tests/error-sample-arguments.ts index f009a2a716..b590125ad1 100644 --- a/packages/db/tests/error-sample-arguments.ts +++ b/packages/db/tests/error-sample-arguments.ts @@ -114,7 +114,7 @@ export const errorSampleArguments: Record>> = { ['alias', 'collection-1', ['key1', 'key2']], ['alias'], ], - DuplicateAliasInSubqueryError: [['alias', ['parent1', 'parent2']]], + // Error 57 is retired. Keep its code and documentation for older logs. UnsupportedFromTypeError: [['string']], UnknownExpressionTypeError: [['custom']], EmptyReferencePathError: [[]], diff --git a/packages/db/tests/fixtures/error-site-messages.json b/packages/db/tests/fixtures/error-site-messages.json index 18a7f80255..cd0475ebdd 100644 --- a/packages/db/tests/fixtures/error-site-messages.json +++ b/packages/db/tests/fixtures/error-site-messages.json @@ -868,10 +868,10 @@ }, "234": { "file": "client.ts", - "template": "DbClient cannot reuse live query \"${queryHash}\" with different source Collections. Use the same Collection objects or a distinct query key.", + "template": "DbClient cannot reuse live query \"${queryHash}\" with different source Collections or getKey/schema options. Use the same Collection objects and options, or a distinct query key.", "literals": [ "DbClient cannot reuse live query \"", - "\" with different source Collections. Use the same Collection objects or a distinct query key." + "\" with different source Collections or getKey/schema options. Use the same Collection objects and options, or a distinct query key." ] }, "235": { diff --git a/packages/db/tests/generate-error-docs-fixture.ts b/packages/db/tests/generate-error-docs-fixture.ts new file mode 100644 index 0000000000..9762c0e28a --- /dev/null +++ b/packages/db/tests/generate-error-docs-fixture.ts @@ -0,0 +1,93 @@ +// Copies the actual generator and catalog into a disposable repository. This +// driver records CLI behavior; it supplies no catalog/rendering judgment. +import { spawnSync } from 'node:child_process' +import { + copyFileSync, + mkdirSync, + mkdtempSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import type { SpawnSyncReturns } from 'node:child_process' + +const repository = resolve(dirname(fileURLToPath(import.meta.url)), `../../..`) +const fixtureNames = [ + `error-codes.json`, + `error-messages.json`, + `error-site-messages.json`, +] as const + +export interface ErrorDocsFixture { + root: string + pagePath: string + fixturesDirectory: string + run: (...args: Array) => SpawnSyncReturns +} + +export function withErrorDocsFixture( + body: (fixture: ErrorDocsFixture) => T, +): T { + const root = mkdtempSync(join(tmpdir(), `tanstack-error-docs-`)) + let outcome: { ok: true; value: T } | { ok: false; error: unknown } + try { + const script = join(root, `scripts/generate-error-docs.mjs`) + const fixturesDirectory = join(root, `packages/db/tests/fixtures`) + mkdirSync(dirname(script), { recursive: true }) + mkdirSync(fixturesDirectory, { recursive: true }) + mkdirSync(join(root, `docs`)) + copyFileSync(join(repository, `scripts/generate-error-docs.mjs`), script) + for (const name of fixtureNames) + copyFileSync( + join(repository, `packages/db/tests/fixtures`, name), + join(fixturesDirectory, name), + ) + const value = body({ + root, + pagePath: join(root, `docs/errors.md`), + fixturesDirectory, + run: (...args) => + spawnSync(process.execPath, [script, ...args], { + cwd: root, + encoding: `utf8`, + timeout: 10_000, + maxBuffer: 4 * 1024 * 1024, + }), + }) + outcome = { ok: true, value } + } catch (error) { + outcome = { ok: false, error } + } + try { + rmSync(root, { recursive: true, force: true }) + } catch (cleanup) { + throw outcome.ok + ? cleanup + : new AggregateError( + [outcome.error, cleanup], + `Error docs control and cleanup failed`, + { cause: outcome.error }, + ) + } + if (!outcome.ok) throw outcome.error + return outcome.value +} + +// Small ordinary-control inputs. The source oracle keeps the copied full +// frozen catalog and does not call this example-fixture writer. +export function writeErrorDocsExample( + fixture: ErrorDocsFixture, + message = `fixture class message`, +): void { + for (const [name, value] of Object.entries({ + 'error-codes.json': { FixtureError: 7 }, + 'error-messages.json': { FixtureError: [message] }, + 'error-site-messages.json': { + 8: { file: `fixture.ts`, template: `fixture failure \${id}` }, + 9: { file: `warning.ts`, template: `fixture warning`, kind: `warning` }, + }, + })) + writeFileSync(join(fixture.fixturesDirectory, name), JSON.stringify(value)) +} diff --git a/packages/db/tests/generate-error-docs.test.ts b/packages/db/tests/generate-error-docs.test.ts new file mode 100644 index 0000000000..b479b2d149 --- /dev/null +++ b/packages/db/tests/generate-error-docs.test.ts @@ -0,0 +1,153 @@ +// Ordinary CLI controls. The existing production-error oracle owns catalog +// judgment; these examples check modes, file effects and malformed inputs. +import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { expect, it } from 'vitest' +import { + withErrorDocsFixture, + writeErrorDocsExample, +} from './generate-error-docs-fixture' +import type { ErrorDocsFixture } from './generate-error-docs-fixture' + +function fulfilled(result: ReturnType): void { + expect(result.error).toBeUndefined() + expect(result.status, result.stderr).toBe(0) +} + +it(`emits exactly the default generated bytes without stdout from default mode`, () => { + withErrorDocsFixture((fixture) => { + writeErrorDocsExample(fixture) + const generated = fixture.run() + fulfilled(generated) + expect(generated.stdout).toBe(``) + const page = readFileSync(fixture.pagePath, `utf8`) + for (const value of [ + `## Error 7: \`FixtureError\``, + ``, + `fixture class message`, + `## Error 8: \`fixture.ts\``, + ``, + `fixture failure \${id}`, + `## Warning 9: \`warning.ts\``, + ``, + `fixture warning`, + ]) + expect(page).toContain(value) + const printed = fixture.run(`--stdout`) + fulfilled(printed) + expect(printed.stdout).toBe(page) + expect(printed.stderr).toBe(``) + expect(readFileSync(fixture.pagePath, `utf8`)).toBe(page) + }) +}) + +it.each([`missing`, `stale`] as const)( + `stdout leaves a %s page untouched`, + (state) => { + withErrorDocsFixture((fixture) => { + writeErrorDocsExample(fixture) + if (state === `stale`) writeFileSync(fixture.pagePath, `stale sentinel`) + const result = fixture.run(`--stdout`) + fulfilled(result) + expect(result.stdout).toContain(`fixture class message`) + expect(result.stderr).toBe(``) + expect(existsSync(fixture.pagePath)).toBe(state === `stale`) + if (state === `stale`) + expect(readFileSync(fixture.pagePath, `utf8`)).toBe(`stale sentinel`) + }) + }, +) + +it(`reads current fixtures independently of a stale generated page`, () => { + withErrorDocsFixture((fixture) => { + writeErrorDocsExample(fixture, `first catalog message`) + fulfilled(fixture.run()) + const before = readFileSync(fixture.pagePath, `utf8`) + writeErrorDocsExample(fixture, `changed catalog message`) + const result = fixture.run(`--stdout`) + fulfilled(result) + expect(result.stdout).toContain(`changed catalog message`) + expect(result.stdout).not.toContain(`first catalog message`) + expect(readFileSync(fixture.pagePath, `utf8`)).toBe(before) + }) +}) + +it(`check accepts matching generated bytes without writing`, () => { + withErrorDocsFixture((fixture) => { + writeErrorDocsExample(fixture) + fulfilled(fixture.run()) + const before = readFileSync(fixture.pagePath) + const result = fixture.run(`--check`) + fulfilled(result) + expect(result.stdout).toBe(``) + expect(readFileSync(fixture.pagePath)).toEqual(before) + }) +}) + +it.each([`missing`, `stale`] as const)( + `check rejects a %s page without writing`, + (state) => { + withErrorDocsFixture((fixture) => { + writeErrorDocsExample(fixture) + if (state === `stale`) writeFileSync(fixture.pagePath, `stale sentinel`) + const result = fixture.run(`--check`) + expect(result.error).toBeUndefined() + expect(result.status).toBe(1) + expect(result.stderr).toContain(`docs/errors.md is out of date`) + expect(result.stdout).toBe(``) + expect(existsSync(fixture.pagePath)).toBe(state === `stale`) + if (state === `stale`) + expect(readFileSync(fixture.pagePath, `utf8`)).toBe(`stale sentinel`) + }) + }, +) + +it.each([ + { args: [`--stdout`, `--check`] }, + { args: [`--stdout`, `--stdout`] }, + { args: [`--unknown`] }, + { args: [`unexpected`] }, +])(`rejects invalid CLI arguments %j before writing`, ({ args }) => { + withErrorDocsFixture((fixture) => { + writeErrorDocsExample(fixture) + writeFileSync(fixture.pagePath, `stale sentinel`) + const result = fixture.run(...args) + expect(result.error).toBeUndefined() + expect(result.status).toBe(1) + expect(result.stderr).toContain(`Usage:`) + expect(result.stdout).toBe(``) + expect(readFileSync(fixture.pagePath, `utf8`)).toBe(`stale sentinel`) + }) +}) + +it(`rejects invalid arguments before reading fixtures`, () => { + withErrorDocsFixture((fixture) => { + rmSync(join(fixture.fixturesDirectory, `error-codes.json`)) + writeFileSync(fixture.pagePath, `stale sentinel`) + const result = fixture.run(`--unknown`) + expect(result.error).toBeUndefined() + expect(result.status).toBe(1) + expect(result.stderr).toContain(`Usage:`) + expect(result.stderr).not.toContain(`ENOENT`) + expect(readFileSync(fixture.pagePath, `utf8`)).toBe(`stale sentinel`) + }) +}) + +it.each([`missing`, `malformed`, `missing-message`] as const)( + `stdout preserves a %s fixture failure`, + (fault) => { + withErrorDocsFixture((fixture) => { + writeErrorDocsExample(fixture) + writeFileSync(fixture.pagePath, `stale sentinel`) + const messages = join(fixture.fixturesDirectory, `error-messages.json`) + if (fault === `missing`) rmSync(messages) + else writeFileSync(messages, fault === `malformed` ? `{` : `{}`) + const result = fixture.run(`--stdout`) + expect(result.error).toBeUndefined() + expect(result.status).not.toBe(0) + expect(result.stderr.length).toBeGreaterThan(0) + expect(result.stdout).toBe(``) + expect(readFileSync(fixture.pagePath, `utf8`)).toBe(`stale sentinel`) + }) + }, +) diff --git a/packages/db/tests/index-suggestion.test.ts b/packages/db/tests/index-suggestion.test.ts index 001ccb5d08..4c17e6fb19 100644 --- a/packages/db/tests/index-suggestion.test.ts +++ b/packages/db/tests/index-suggestion.test.ts @@ -222,7 +222,7 @@ describe(`bounded orderBy index diagnostics`, () => { query = createLiveQueryCollection((q) => q .from({ row: source }) - .orderBy(({ row }) => row.$synced) + .orderBy(({ row }) => row.$hasPendingWrites) .limit(1), ) await query.preload() diff --git a/packages/db/tests/indexed-db/api.test.ts b/packages/db/tests/indexed-db/api.test.ts index 067c66f359..c8112d408f 100644 --- a/packages/db/tests/indexed-db/api.test.ts +++ b/packages/db/tests/indexed-db/api.test.ts @@ -64,7 +64,7 @@ it('validates schema inputs before persistence', async () => { try { await collection.preload() expect(() => collection.insert({ id: 1, name: '' })).toThrow() - await collection.insert({ id: 1, name: 'valid' }).isPersisted.promise + await collection.insert({ id: 1, name: 'valid' }).when(`settled`) expect(await collection.utils.exportData()).toEqual([ { id: 1, name: 'valid' }, ]) @@ -78,7 +78,7 @@ it('supports local persistence when BroadcastChannel is unavailable', async () = await withHarness(async (h) => { vi.stubGlobal('BroadcastChannel', undefined) const collection = await h.open() - await collection.insert({ id: 1, name: 'local' }).isPersisted.promise + await collection.insert({ id: 1, name: 'local' }).when(`settled`) expect(await collection.utils.exportData()).toEqual([ { id: 1, name: 'local' }, ]) @@ -99,7 +99,7 @@ it('rejects opening without IndexedDB', async () => { it('exposes deletion only as an administrative function', async () => { await withHarness(async (h) => { const collection = await h.open() - await collection.insert({ id: 1, name: 'removed' }).isPersisted.promise + await collection.insert({ id: 1, name: 'removed' }).when(`settled`) expect(collection.utils).not.toHaveProperty('deleteDatabase') await deleteDatabase(h.db.name) const reopened = await createIndexedDB({ @@ -250,8 +250,9 @@ it('preserves exported transformed rows when invalid restore input is rejected', ) try { await c.preload() - await c.insert({ id: 1, date: '2026-01-01T00:00:00.000Z' }).isPersisted - .promise + await c + .insert({ id: 1, date: '2026-01-01T00:00:00.000Z' }) + .when(`settled`) const backup = await c.utils.exportData() // @ts-expect-error Schema output is not the string input expected by import. await expect(c.utils.importData(backup)).rejects.toThrow( diff --git a/packages/db/tests/indexed-db/compatibility.test.ts b/packages/db/tests/indexed-db/compatibility.test.ts index 92df49ab5a..a0545a764f 100644 --- a/packages/db/tests/indexed-db/compatibility.test.ts +++ b/packages/db/tests/indexed-db/compatibility.test.ts @@ -53,7 +53,7 @@ for (const capability of ['randomUUID', 'getRandomValues'] as const) { { id: 0, name: 'number' }, { id: '1', name: 'string' }, ] - await writer.insert(expected).isPersisted.promise + await writer.insert(expected).when(`settled`) expect(Channel.sent, 'one completed write notification').toHaveLength(1) expect(await Channel.deliver(), 'receiver path reached').toBe(1) assertRows(writer.values(), expected, 'writer after persistence') @@ -88,7 +88,7 @@ for (const retired of ['first', 'second'] as const) { await first.preload() await second.preload() const expected: Array = [{ id: 1, name: 'before cleanup' }] - await first.insert(expected[0]!).isPersisted.promise + await first.insert(expected[0]!).when(`settled`) expect( await Channel.deliver(), 'second client receives first write', @@ -106,7 +106,7 @@ for (const retired of ['first', 'second'] as const) { 'only the retired sync run releases its channel', ).toBe(1) const added: Row = { id: '1', name: 'after cleanup' } - await survivor.insert(added).isPersisted.promise + await survivor.insert(added).when(`settled`) expected.push(added) assertRows(survivor.values(), expected, 'survivor write') assertRows( @@ -119,7 +119,7 @@ for (const retired of ['first', 'second'] as const) { await restored.preload() assertRows(restored.values(), expected, 'third client restore') const suffix: Row = { id: 2, name: 'new peer' } - await survivor.insert(suffix).isPersisted.promise + await survivor.insert(suffix).when(`settled`) expected.push(suffix) expect( await Channel.deliver(), @@ -286,8 +286,7 @@ for (const idLocation of ['options', 'collection'] as const) { h.collections.push(c) await c.preload() const expected = [{ id: 1, name: 'owned' }] - if (entry === 'automatic') - await c.insert(expected[0]!).isPersisted.promise + if (entry === 'automatic') await c.insert(expected[0]!).when(`settled`) else { const tx = createTransaction({ autoCommit: false, @@ -361,9 +360,7 @@ for (const sharedId of [false, true]) { } }, }) - const outcome = tx.isPersisted.promise.catch( - (error: unknown) => error, - ) + const outcome = tx.when(`settled`).catch((error: unknown) => error) tx.mutate(() => collections.forEach((collection, index) => collection.insert(rows[index]!), @@ -456,14 +453,16 @@ for (const name of legalNames) { // Separate transactions isolate name routing from the already tracked // core same-transaction numeric/string key collision (HC005). for (const row of rows) - await writers[index]!.insert({ ...row }).isPersisted.promise + await writers[index]!.insert({ ...row }).when(`settled`) expected.set(store, rows) } await checkpoint() const versions = await readStore(db, '_versions') - await writers[0]!.update(0, (draft) => { - draft.name = 'changed' - }).isPersisted.promise + await writers[0]! + .update(0, (draft) => { + draft.name = 'changed' + }) + .when(`settled`) expected.set(name, [ { id: 0, name: 'changed' }, { id: '0', name: name + ':string' }, @@ -574,9 +573,8 @@ for (const count of [2, 3]) { } const rows = collections.map((_, id) => ({ id, name: `writer-${id}` })) await Promise.all( - collections.map( - (collection, index) => - collection.insert({ ...rows[index]! }).isPersisted.promise, + collections.map((collection, index) => + collection.insert({ ...rows[index]! }).when(`settled`), ), ) await Channel.deliver() diff --git a/packages/db/tests/indexed-db/cross-tab-boundary-oracle.test.ts b/packages/db/tests/indexed-db/cross-tab-boundary-oracle.test.ts index e9d53989fc..e7de82d620 100644 --- a/packages/db/tests/indexed-db/cross-tab-boundary-oracle.test.ts +++ b/packages/db/tests/indexed-db/cross-tab-boundary-oracle.test.ts @@ -66,8 +66,8 @@ async function omission(fault: 'none' | 'partial' | 'error') { await receiver.preload() const record = recordCollection(receiver, 'reader') h.disposers.push(record.stop) - await writer.delete(0).isPersisted.promise - await writer.insert({ ...after }).isPersisted.promise + await writer.delete(0).when(`settled`) + await writer.insert({ ...after }).when(`settled`) const receipts = [] while (Channel.pending.length) { const receipt = Channel.dispatch() @@ -172,7 +172,7 @@ for (const abort of [false, true]) { const record = recordCollection(receiver, 'receiver') h.disposers.push(record.stop) const row = { id: 1, name: 'durable before cleanup' } - await writer.insert(row).isPersisted.promise + await writer.insert(row).when(`settled`) const gate = holdStore(h.db.db) h.disposers.push(gate.release) await gate.started @@ -193,9 +193,11 @@ for (const abort of [false, true]) { 'obsolete receiver completion', ) assertSnapshot(receiver.values(), [row], 'new sync run restore') - await writer.update(1, (draft) => { - draft.name = 'later version' - }).isPersisted.promise + await writer + .update(1, (draft) => { + draft.name = 'later version' + }) + .when(`settled`) await Channel.drain() assertSnapshot( receiver.values(), @@ -213,7 +215,7 @@ it('reports an active receiver abort as the Collection error while its callback const receiver = await h.open('items', { db: descriptor }) const record = recordCollection(receiver, 'receiver') h.disposers.push(record.stop) - await writer.insert({ id: 1, name: 'durable' }).isPersisted.promise + await writer.insert({ id: 1, name: 'durable' }).when(`settled`) const gate = holdStore(h.db.db) h.disposers.push(gate.release) await gate.started @@ -280,7 +282,7 @@ for (const deliver of [false, true]) await seed(h.db, 'items', [before]) const a = await h.open(), b = await h.open('items', { db: await h.connect() }) - await a.delete(0).isPersisted.promise + await a.delete(0).when(`settled`) if (deliver) { await Channel.drain() assertSnapshot(b.values(), [], 'delivered admission') @@ -291,9 +293,11 @@ for (const deliver of [false, true]) ).toThrow() } else { assertSnapshot(b.values(), [before], 'stale admission') - await b.update(0, (row) => { - row.name = 'updated' - }).isPersisted.promise + await b + .update(0, (row) => { + row.name = 'updated' + }) + .when(`settled`) await Channel.drain() assertSnapshot( a.values(), diff --git a/packages/db/tests/indexed-db/cross-tab-driver.ts b/packages/db/tests/indexed-db/cross-tab-driver.ts index a8e03ef91e..5221a512fc 100644 --- a/packages/db/tests/indexed-db/cross-tab-driver.ts +++ b/packages/db/tests/indexed-db/cross-tab-driver.ts @@ -85,16 +85,18 @@ export async function runTransportHistory( else if (operation === 'import') await subject.utils.importData(structuredClone(change.rows)) else if (operation === 'delete') - await subject.delete(change.deletes[0]!).isPersisted.promise + await subject.delete(change.deletes[0]!).when(`settled`) else { const row = change.rows[0]! if (before.some((item) => item.id === row.id)) - await subject.update(row.id, (draft) => { - draft.name = row.name - if (row.optional === undefined) delete draft.optional - else draft.optional = row.optional - }).isPersisted.promise - else await subject.insert(structuredClone(row)).isPersisted.promise + await subject + .update(row.id, (draft) => { + draft.name = row.name + if (row.optional === undefined) delete draft.optional + else draft.optional = row.optional + }) + .when(`settled`) + else await subject.insert(structuredClone(row)).when(`settled`) } model.accept(tab, change) for (const message of Channel.pending) diff --git a/packages/db/tests/indexed-db/cross-tab-oracle.ts b/packages/db/tests/indexed-db/cross-tab-oracle.ts index 5420e5c15a..a9b3b0c9e1 100644 --- a/packages/db/tests/indexed-db/cross-tab-oracle.ts +++ b/packages/db/tests/indexed-db/cross-tab-oracle.ts @@ -55,7 +55,6 @@ export function plain(row: OracleRow): OracleRow { $key: _key, $origin: _origin, $hasPendingWrites: _pending, - $synced: _synced, $collectionId: _id, ...value } = row as OracleRow & Record diff --git a/packages/db/tests/indexed-db/harness.ts b/packages/db/tests/indexed-db/harness.ts index cbf5b78831..8286201fdc 100644 --- a/packages/db/tests/indexed-db/harness.ts +++ b/packages/db/tests/indexed-db/harness.ts @@ -112,7 +112,6 @@ export function userRow(value: Row): Row { $key: _key, $origin: _origin, $hasPendingWrites: _pending, - $synced: _synced, $collectionId: _collectionId, ...row } = value as Row & Record diff --git a/packages/db/tests/indexed-db/local-write-order-oracle.test.ts b/packages/db/tests/indexed-db/local-write-order-oracle.test.ts index 10f0e549d8..2d91e17660 100644 --- a/packages/db/tests/indexed-db/local-write-order-oracle.test.ts +++ b/packages/db/tests/indexed-db/local-write-order-oracle.test.ts @@ -102,7 +102,7 @@ for (const [kind, effects] of Object.entries(grammars)) { : c.insert({ ...effect.row }) : c.delete(effect.key) outcomes.push( - tx.isPersisted.promise.then( + tx.when(`settled`).then( () => { succeeded.add(index) return true @@ -126,11 +126,12 @@ for (const [kind, effects] of Object.entries(grammars)) { ? [peerRow, initial[1]!] : initial if (peerAction === 'update') - await peer.update(1, (row) => { - row.name = 'peer' - }).isPersisted.promise - if (peerAction === 'delete') - await peer.delete(1).isPersisted.promise + await peer + .update(1, (row) => { + row.name = 'peer' + }) + .when(`settled`) + if (peerAction === 'delete') await peer.delete(1).when(`settled`) if (peerAction === 'replace') await peer.utils.importData([peerRow]) await Channel.deliver() assertRows( diff --git a/packages/db/tests/indexed-db/local-write-order.test.ts b/packages/db/tests/indexed-db/local-write-order.test.ts index 698c33ba3b..59f2ed21f4 100644 --- a/packages/db/tests/indexed-db/local-write-order.test.ts +++ b/packages/db/tests/indexed-db/local-write-order.test.ts @@ -35,13 +35,13 @@ it('holds a later accepted update until the earlier handler settles', async () = const a = c.update(1, (row) => { row.name = 'A' }), - aOutcome = a.isPersisted.promise + aOutcome = a.when('settled') await entered.promise const b = c.update(1, (row) => { row.name = 'B' }) let settled = false - const bOutcome = b.isPersisted.promise.then(() => { + const bOutcome = b.when('settled').then(() => { settled = true }) await later.promise @@ -72,9 +72,11 @@ it('reserves mutation order before a handler authors another update', async () = onUpdate: async () => { if (!reentered) { reentered = true - later = c.update(1, (row) => { - row.name = 'B' - }).isPersisted.promise + later = c + .update(1, (row) => { + row.name = 'B' + }) + .when('settled') return gate.promise } return undefined @@ -85,7 +87,7 @@ it('reserves mutation order before a handler authors another update', async () = }) const held = await readStore(h.db, 'items') gate.resolve() - await Promise.all([first.isPersisted.promise, later]) + await Promise.all([first.when('settled'), later]) expect(reentered).toBe(true) assertRows(held.rows, initial, 'reentered handler held') assertRows( @@ -106,7 +108,7 @@ for (const sameKey of [false, true]) { const c = await h.open() const peer = await h.open('items', { db: await h.connect() }) const first = c.insert({ id: 1, name: 'invalid', unsupported: () => {} }) - const rejected = first.isPersisted.promise.then( + const rejected = first.when('settled').then( () => false, () => true, ) @@ -116,7 +118,7 @@ for (const sameKey of [false, true]) { delete row.unsupported }) : c.insert({ id: 2, name: 'valid' }) - await next.isPersisted.promise + await next.when('settled') expect(await rejected, 'native failure reaches the earlier caller').toBe( true, ) diff --git a/packages/db/tests/indexed-db/pending-history-oracle.test.ts b/packages/db/tests/indexed-db/pending-history-oracle.test.ts index 0ed2a3e9b1..9f9f429abe 100644 --- a/packages/db/tests/indexed-db/pending-history-oracle.test.ts +++ b/packages/db/tests/indexed-db/pending-history-oracle.test.ts @@ -187,7 +187,6 @@ async function run( row.id, { pending: row.$hasPendingWrites, - synced: row.$synced, origin: row.$origin, }, ]), @@ -224,7 +223,6 @@ async function run( const metadata = observation.metadata.get(row.id)! expect(metadata, `${label} metadata ${row.id}`).toEqual({ pending, - synced: !pending, origin: pending ? 'local' : origins.get(row.id), }) } @@ -255,7 +253,7 @@ async function run( if (metadata) metadata.origin = 'remote' } } - const outcome = tx.isPersisted.promise.then( + const outcome = tx.when(`settled`).then( () => { captureSettlement() return undefined @@ -315,14 +313,15 @@ async function run( await peer.utils.importData(structuredClone(change.rows)) else if (step.kind === 'delete') { if (!durable.some((row) => row.id === change.deletes[0])) continue - await peer.delete(change.deletes[0]!).isPersisted.promise + await peer.delete(change.deletes[0]!).when(`settled`) } else if (durable.some((row) => row.id === id)) - await peer.update(id, (row) => { - row.name = `peer-${step.value}` - delete row.optional - }).isPersisted.promise - else - await peer.insert(structuredClone(change.rows[0]!)).isPersisted.promise + await peer + .update(id, (row) => { + row.name = `peer-${step.value}` + delete row.optional + }) + .when(`settled`) + else await peer.insert(structuredClone(change.rows[0]!)).when(`settled`) // Collection updates that leave every authored value unchanged author no // mutation, so they provide no source acknowledgement or notification. if ( @@ -380,7 +379,7 @@ async function run( 'settled durable', ) const suffix = { id: 'suffix', name: 'ordinary suffix' } - await subject.insert(suffix).isPersisted.promise + await subject.insert(suffix).when(`settled`) await Channel.drain() durable = apply(durable, { rows: [suffix], deletes: [], replace: false }) assertSnapshot(peer.values(), durable, 'suffix peer') diff --git a/packages/db/tests/indexed-db/persistence-oracle.test.ts b/packages/db/tests/indexed-db/persistence-oracle.test.ts index ec503d093c..14bd0950ff 100644 --- a/packages/db/tests/indexed-db/persistence-oracle.test.ts +++ b/packages/db/tests/indexed-db/persistence-oracle.test.ts @@ -175,17 +175,17 @@ async function runHistory(steps: Array) { } switch (action.kind) { case 'write': - await write(slot, row).isPersisted.promise + await write(slot, row).when(`settled`) remember(slot, row) break case 'delete': { if (!model[slot]!.length) { - await collection.insert({ ...row }).isPersisted.promise + await collection.insert({ ...row }).when(`settled`) remember(slot, row) await Channel.deliver() } const removed = model[slot]![action.key % model[slot]!.length]! - await collection.delete(removed.id).isPersisted.promise + await collection.delete(removed.id).when(`settled`) model[slot] = model[slot]!.filter((item) => item.id !== removed.id) touched.add(JSON.stringify([names[slot], removed.id])) break @@ -218,7 +218,7 @@ async function runHistory(steps: Array) { write(0, row) write(1, other) }) - await tx.isPersisted.promise + await tx.when(`settled`) remember(0, row) remember(1, other) break @@ -446,7 +446,7 @@ it('manual acceptance partitions mixed payloads across three Collections in ever } }, }) - const outcome = tx.isPersisted.promise + const outcome = tx.when(`settled`) void outcome.catch(() => undefined) tx.mutate(() => { for (const [index, collection] of collections.entries()) { diff --git a/packages/db/tests/indexed-db/persistence-values-oracle.test.ts b/packages/db/tests/indexed-db/persistence-values-oracle.test.ts index be49ac9b2a..610bc38200 100644 --- a/packages/db/tests/indexed-db/persistence-values-oracle.test.ts +++ b/packages/db/tests/indexed-db/persistence-values-oracle.test.ts @@ -61,15 +61,19 @@ for (const kind of valueKinds) { expected, ) } - await writer.insert({ - id: 1, - name: 'initial', - nested: { value: createValue(kind) }, - }).isPersisted.promise + await writer + .insert({ + id: 1, + name: 'initial', + nested: { value: createValue(kind) }, + }) + .when(`settled`) await checkpoint('insert') - await writer.update(1, (draft) => { - draft.name = 'updated' - }).isPersisted.promise + await writer + .update(1, (draft) => { + draft.name = 'updated' + }) + .when(`settled`) expected = expectedValueRows(kind, 'updated') await checkpoint('unrelated update') const exported = await writer.utils.exportData() @@ -93,7 +97,7 @@ for (const kind of valueKinds) { })) const outcome = entry === 'insert' - ? writer.insert(batch).isPersisted.promise + ? writer.insert(batch).when(`settled`) : writer.utils.importData(batch) await expect(outcome).rejects.toBeInstanceOf(SerializationError) await checkpoint(`${entry} rejection at ${badIndex}`) @@ -103,9 +107,11 @@ for (const kind of valueKinds) { ).toEqual(before) } } - await writer.update(1, (draft) => { - draft.name = 'suffix' - }).isPersisted.promise + await writer + .update(1, (draft) => { + draft.name = 'suffix' + }) + .when(`settled`) expected = expectedValueRows(kind, 'suffix') await checkpoint('successful suffix') const freshDb = await createIndexedDB({ @@ -178,8 +184,9 @@ for (const kind of valueKinds.filter((value) => value !== 'blob')) { const c = make(h.db) const peer = make(await h.connect()) await Promise.all([c.preload(), peer.preload()]) - await c.insert({ id: 1, name: 'before', nested: { value: null } }) - .isPersisted.promise + await c + .insert({ id: 1, name: 'before', nested: { value: null } }) + .when(`settled`) await Channel.deliver() const gate = holdStore(h.db.db) h.disposers.push(() => gate.release()) @@ -189,10 +196,12 @@ for (const kind of valueKinds.filter((value) => value !== 'blob')) { const pending = entry === 'import' ? c.utils.importData([row]) - : c.update(1, (draft) => { - draft.name = row.name - draft.nested = row.nested - }).isPersisted.promise + : c + .update(1, (draft) => { + draft.name = row.name + draft.nested = row.nested + }) + .when(`settled`) // Date, buffers, views and nested containers each have a native mutation // that preserves their shape but changes content. A shape-only check // would miss the bug; the independent byte/timestamp description cannot. diff --git a/packages/db/tests/indexed-db/persistence.test.ts b/packages/db/tests/indexed-db/persistence.test.ts index d4d1de67d8..213719b3f3 100644 --- a/packages/db/tests/indexed-db/persistence.test.ts +++ b/packages/db/tests/indexed-db/persistence.test.ts @@ -56,9 +56,11 @@ for (const timestamp of [undefined, -1, Number.MAX_SAFE_INTEGER]) { const source = await h.open() const peer = await h.open('items', { db: await h.connect() }) assertRows(source.values(), initial, 'legacy restore') - await source.update(1, (row) => { - row.name = 'after' - }).isPersisted.promise + await source + .update(1, (row) => { + row.name = 'after' + }) + .when('settled') await Channel.deliver() const after = [{ id: 1, name: 'after' }, initial[1]!] assertRows( diff --git a/packages/db/tests/indexed-db/retirement-oracle.test.ts b/packages/db/tests/indexed-db/retirement-oracle.test.ts index e005f8955a..f5f45dda43 100644 --- a/packages/db/tests/indexed-db/retirement-oracle.test.ts +++ b/packages/db/tests/indexed-db/retirement-oracle.test.ts @@ -219,7 +219,7 @@ for (const reason of reasons) { tx.mutate(() => { void start(c, kind, 1) }) - const caller = observe(tx.isPersisted.promise, () => + const caller = observe(tx.when(`settled`), () => sorted(c.base.values()), ) await accepted.promise @@ -374,7 +374,7 @@ it.each(mixedCells)( tx.mutate(() => { void start(c, op.kind, slot + 1) }) - callers[slot] = observe(tx.isPersisted.promise, capture(slot)) + callers[slot] = observe(tx.when(`settled`), capture(slot)) await accepted.promise durable = apply(durable, op.kind, slot + 1) assertRows( diff --git a/packages/db/tests/indexed-db/retirement-support.ts b/packages/db/tests/indexed-db/retirement-support.ts index 17b31a8bdc..b38faf1660 100644 --- a/packages/db/tests/indexed-db/retirement-support.ts +++ b/packages/db/tests/indexed-db/retirement-support.ts @@ -52,12 +52,15 @@ export function start(c: Subject, kind: Kind, owner: number): Promise { if (kind === 'import') return c.utils.importData([{ id: owner, name: `after-${owner}` }]) if (kind === 'insert') - return c.insert({ id: `new-${owner}`, name: `after-${owner}` }).isPersisted - .promise - if (kind === 'delete') return c.delete(owner).isPersisted.promise - return c.update(owner, (row) => { - row.name = `after-${owner}` - }).isPersisted.promise + return c + .insert({ id: `new-${owner}`, name: `after-${owner}` }) + .when('settled') + if (kind === 'delete') return c.delete(owner).when('settled') + return c + .update(owner, (row) => { + row.name = `after-${owner}` + }) + .when('settled') } export function recordStatus(c: Subject) { const statuses: Array = [] diff --git a/packages/db/tests/indexed-db/retirement.test.ts b/packages/db/tests/indexed-db/retirement.test.ts index 1ba450fe35..624c338f38 100644 --- a/packages/db/tests/indexed-db/retirement.test.ts +++ b/packages/db/tests/indexed-db/retirement.test.ts @@ -80,9 +80,11 @@ for (const reason of reasons) { if (read === 'replacement') await sender.utils.importData([{ id: 3, name: 'new snapshot' }]) else - await sender.update(1, (row) => { - row.name = 'peer update' - }).isPersisted.promise + await sender + .update(1, (row) => { + row.name = 'peer update' + }) + .when('settled') } const gate = holdStore(h.db.db) h.disposers.push(() => gate.release()) @@ -191,7 +193,7 @@ for (const reason of reasons) const native = observeTransactions(h.db.db) h.disposers.push(() => native.restore()) const row = { id: 3, name: 'admitted during startup' } - const caller = observe(c.insert(row).isPersisted.promise, () => + const caller = observe(c.insert(row).when('settled'), () => sorted(c.base.values()), ) await vi.waitFor(() => diff --git a/packages/db/tests/indexed-db/settlement.test.ts b/packages/db/tests/indexed-db/settlement.test.ts index 38de157f2b..20ad8bb18f 100644 --- a/packages/db/tests/indexed-db/settlement.test.ts +++ b/packages/db/tests/indexed-db/settlement.test.ts @@ -91,7 +91,7 @@ describe('application decision precedes durability', () => { }) : collection.delete(1) let settled = false - const outcome = tx.isPersisted.promise.then( + const outcome = tx.when(`settled`).then( () => { settled = true return undefined @@ -134,7 +134,7 @@ describe('application decision precedes durability', () => { // separate cut because clearing the store could otherwise hide it. const suffix = { id: 3, name: 'suffix' } const afterSuffix = [...expected, suffix] - await collection.insert({ ...suffix }).isPersisted.promise + await collection.insert({ ...suffix }).when(`settled`) await Channel.deliver() assertRows( collection.values(), @@ -189,7 +189,7 @@ describe('atomic persistence and truthful settlement', () => { const beforeVersions = await readStore(h.db, '_versions') let promise: Promise if (entry === 'automatic') - promise = collection.insert(rows).isPersisted.promise + promise = collection.insert(rows).when(`settled`) else if (entry === 'import') promise = collection.utils.importData(rows) else { @@ -198,7 +198,7 @@ describe('atomic persistence and truthful settlement', () => { collection.utils.acceptMutations(transaction), }) tx.mutate(() => collection.insert(rows)) - promise = tx.isPersisted.promise + promise = tx.when(`settled`) } const outcome = await promise.then( () => 'fulfilled', @@ -223,7 +223,7 @@ describe('atomic persistence and truthful settlement', () => { before, 'failure cannot publish a peer change', ) - await collection.insert({ id: 5, name: 'suffix' }).isPersisted.promise + await collection.insert({ id: 5, name: 'suffix' }).when(`settled`) await Channel.deliver() assertRows( (await h.open()).values(), @@ -264,7 +264,7 @@ describe('atomic persistence and truthful settlement', () => { row.name = 'new' }) : collection.delete(1) - const outcome = await tx.isPersisted.promise.then( + const outcome = await tx.when(`settled`).then( () => 'fulfilled', () => 'rejected', ) @@ -295,10 +295,10 @@ for (const remote of ['delete', 'clear'] as const) { autoCommit: false, mutationFn: async () => {}, }) - const done = tx.isPersisted.promise.catch(() => undefined) + const done = tx.when(`settled`).catch(() => undefined) tx.mutate(() => receiver.delete(1)) expect(receiver.has(1), 'pending delete reached').toBe(false) - if (remote === 'delete') await source.delete(1).isPersisted.promise + if (remote === 'delete') await source.delete(1).when(`settled`) else await source.utils.clearObjectStore() expect( await Channel.deliver(), @@ -442,18 +442,19 @@ for (const entry of ['import', 'insert', 'update', 'delete'] as const) { }, ) if (entry === 'import') await c.utils.importData(authored) - else if (entry === 'insert') - await c.insert(authored).isPersisted.promise + else if (entry === 'insert') await c.insert(authored).when(`settled`) else if (entry === 'update') - await c.update( - authored.map((row) => row.id), - (drafts) => { - drafts.forEach((draft) => { - draft.name = String(draft.id) - }) - }, - ).isPersisted.promise - else await c.delete(authored.map((row) => row.id)).isPersisted.promise + await c + .update( + authored.map((row) => row.id), + (drafts) => { + drafts.forEach((draft) => { + draft.name = String(draft.id) + }) + }, + ) + .when(`settled`) + else await c.delete(authored.map((row) => row.id)).when(`settled`) const expected = entry === 'delete' ? [] : authored expect(issued).toBe(2 * count) expect(beforeFirstSuccess).toBe(2 * count) diff --git a/packages/db/tests/indexed-db/transport-oracle.test.ts b/packages/db/tests/indexed-db/transport-oracle.test.ts index cfc607787c..e21e9b4068 100644 --- a/packages/db/tests/indexed-db/transport-oracle.test.ts +++ b/packages/db/tests/indexed-db/transport-oracle.test.ts @@ -56,7 +56,7 @@ for (const replacement of ['clear', 'import'] as const) { if (replacement === 'clear') await source.utils.clearObjectStore() else await source.utils.importData([imported]) const inserted = { id: 'later', name: 'accepted after replacement' } - await receiver.insert(inserted).isPersisted.promise + await receiver.insert(inserted).when('settled') const expected = expectedAfterReplacement( replacement, imported, @@ -135,10 +135,12 @@ for (const metadata of ['none', 'mixed', 'all'] as const) { const receiver = await h.open() const sender = await h.open('items', { db: await h.connect() }) if (action === 'update') - await sender.update(key, (row) => { - row.name = 'new' - }).isPersisted.promise - else await sender.delete(key).isPersisted.promise + await sender + .update(key, (row) => { + row.name = 'new' + }) + .when(`settled`) + else await sender.delete(key).when(`settled`) // Repeat delivery: missing versions cannot turn an update into a second // insert, and repeated deletes must leave the independent anchor intact. Channel.pending.push(...Channel.pending) diff --git a/packages/db/tests/indexed-db/transport.test.ts b/packages/db/tests/indexed-db/transport.test.ts index 3bdd349bec..cb493faf62 100644 --- a/packages/db/tests/indexed-db/transport.test.ts +++ b/packages/db/tests/indexed-db/transport.test.ts @@ -39,8 +39,8 @@ for (const reverse of [false, true]) { { id: '0', name: 'typed key' }, ] await Promise.all([ - left.insert([rows[0]!, rows[2]!]).isPersisted.promise, - right.insert(rows[1]!).isPersisted.promise, + left.insert([rows[0]!, rows[2]!]).when('settled'), + right.insert(rows[1]!).when('settled'), ]) expect(Channel.pending.length).toBe(2) // Redelivery retains the original messages, making duplicate output @@ -50,13 +50,14 @@ for (const reverse of [false, true]) { expect(await Channel.deliver()).toBe(4) for (const subject of [left, right, await h.open()]) assertRows(subject.values(), rows, 'disjoint writer convergence') - await left.update(rows[0]!.id, (row) => { - delete row.optional - }).isPersisted.promise - await left.delete(rows[2]!.id).isPersisted.promise - await left.insert({ id: 'temporary', name: 'removed' }).isPersisted - .promise - await left.delete('temporary').isPersisted.promise + await left + .update(rows[0]!.id, (row) => { + delete row.optional + }) + .when('settled') + await left.delete(rows[2]!.id).when('settled') + await left.insert({ id: 'temporary', name: 'removed' }).when('settled') + await left.delete('temporary').when('settled') await Channel.deliver() const expected = [{ id: rows[0]!.id, name: 'left' }, rows[1]!] for (const subject of [left, right, await h.open()]) @@ -73,7 +74,7 @@ for (const reverse of [false, true]) { it('ignores self, foreign database and foreign store notifications', async () => { await withHarness(async (h) => { const collection = await h.open() - await collection.insert({ id: 1, name: 'kept' }).isPersisted.promise + await collection.insert({ id: 1, name: 'kept' }).when('settled') const original = Channel.sent[0] as { tabId: string database: string @@ -208,7 +209,7 @@ it('publishes manual acceptance after the writer sync run is cleaned up', async 'cleaned-up', ) await transaction.commit() - await transaction.isPersisted.promise + await transaction.when('settled') const delivered = await Channel.deliver() assertRows( (await readStore(h.db, 'items')).rows, @@ -600,8 +601,8 @@ for (const count of [2, 3]) { name: 'written through new descriptor', } const addedRow = { id: 'added', name: 'written in new store' } - await restored.insert({ ...suffix }).isPersisted.promise - await added.insert({ ...addedRow }).isPersisted.promise + await restored.insert({ ...suffix }).when('settled') + await added.insert({ ...addedRow }).when('settled') await Channel.deliver() assertRows( restored.values(), @@ -722,7 +723,7 @@ it('ignores an old native deletion receipt after database recreation', async () }) h.descriptors.push(recreated) const fresh = await h.open('items', { db: recreated }) - await fresh.insert({ id: 1, name: 'new incarnation' }).isPersisted.promise + await fresh.insert({ id: 1, name: 'new incarnation' }).when('settled') Channel.pending = [] receipt.resolve() await deletion @@ -764,7 +765,7 @@ it('reports connection closure immediately and supports fresh Collections', asyn error: expect.anything(), }) const fresh = await h.open('items', { db: upgraded }) - await fresh.insert({ id: 1, name: 'new' }).isPersisted.promise + await fresh.insert({ id: 1, name: 'new' }).when('settled') await Channel.deliver() expect(old.status).toBe('crashed') expect(live.status).toBe('crashed') @@ -795,12 +796,13 @@ for (const present of [false, true]) { const receiver = await h.open() const sender = await h.open('items', { db: await h.connect() }) if (present) - await sender.update(key, (row) => { - row.name = 'notified' - }).isPersisted.promise + await sender + .update(key, (row) => { + row.name = 'notified' + }) + .when('settled') else - await sender.insert({ id: key, name: 'notified' }).isPersisted - .promise + await sender.insert({ id: key, name: 'notified' }).when('settled') const current = { id: key, name: 'current' } await executeTransaction( h.db.db, @@ -830,9 +832,11 @@ for (const present of [false, true]) { ) const fresh = await h.open() assertRows(fresh.values(), expected, 'current restore') - await fresh.update(key, (row) => { - row.name = 'suffix' - }).isPersisted.promise + await fresh + .update(key, (row) => { + row.name = 'suffix' + }) + .when('settled') await Channel.deliver() assertRows( receiver.values(), @@ -885,7 +889,7 @@ it('preserves a recreated peer while a newer deletion is blocked', async () => { const fresh = await h.open('items', { db: freshDb }) const peer = await h.open('items', { db: peerDb }) const expected = [{ id: 1, name: 'recreated' }] - await fresh.insert(expected[0]!).isPersisted.promise + await fresh.insert(expected[0]!).when('settled') await Channel.deliver() const blockerRequest = indexedDB.open(h.db.name, 1) const blocker = await request(blockerRequest) diff --git a/packages/db/tests/live-query-bound-options-oracle.ts b/packages/db/tests/live-query-bound-options-oracle.ts new file mode 100644 index 0000000000..ac31c74301 --- /dev/null +++ b/packages/db/tests/live-query-bound-options-oracle.ts @@ -0,0 +1,258 @@ +/** + * Identity-comparing bindings retain inline options until identity changes. + * Authority: the maintainer's inline-option decision. Function/reference + * allocation on normal renders cannot change the Collection; a query or + * explicit queryKey change binds the new options. Reactive hosts that do not + * compare identity are outside this law. React and Preact are receiving drivers. + */ +import { expect } from 'vitest' +import { declareOracle } from './oracle-declaration.js' + +export const boundOptionHistories = ( + [`getKey`, `schema`, `both`] as const +).flatMap((option) => + ([`query`, `queryKey`] as const).map((identity) => ({ option, identity })), +) +// Capture references before any later render can mutate the Collection config. +export function captureBoundOptions(collection: { + config: { getKey: unknown; schema?: unknown } +}) { + return { + collection, + getKey: collection.config.getKey, + schema: collection.config.schema, + } +} +// A binding's identity, rather than its current option references, determines +// whether the next Collection and configured option references must be new. +export function checkBoundOptions( + initial: ReturnType, + current: { config: { getKey: unknown; schema?: unknown } }, + changed: boolean, + option: string, +): void { + expect(current === initial.collection, `Collection identity`).toBe(!changed) + if (option !== `schema`) + expect(current.config.getKey === initial.getKey, `getKey binding`).toBe( + !changed, + ) + if (option !== `getKey`) + expect(current.config.schema === initial.schema, `schema binding`).toBe( + !changed, + ) +} + +/** + * Which schema governs the next client mutation? + * + * Reference retention alone cannot show that a Collection still uses those + * references. The schema guide's "TInput vs TOutput" contract supplies the + * receiving law: schemas validate client inserts/updates, never sync writes. + * A held update must expose the bound schema's output and give that output to + * its handler. The optimistic-history contract restores the authored base when + * the unconfirmed handler settles. The two native drivers below receive this + * law through React live/Suspense and Preact live hooks. + * + * This finite grammar varies the proposed schema independently from query + * identity and getKey. Absence is a real declaration, including on both sides + * of a render. Same-mode pairs preserve allocating-but-equivalent callbacks. + * All transformations accept their own outputs, as the schema guide requires. + * The grammar excludes async/invalid schemas, key edits inside mutations, and + * a mutation that spans query replacement. Only explicit plain/prefixed key + * functions are covered; default-getKey absence remains outside this receiver. + * The public schema type currently resolves to never. These are documented + * JavaScript configuration witnesses, not evidence for typed schema options. + */ +export type BoundSchemaMode = `absent` | `trim` | `uppercase` +export type BoundSchemaPhase = `initial` | `retained` | `rebound` +export type BoundSchemaRow = { id: string; label: string; rank: number } +export const boundSchemaModes: ReadonlyArray = [ + `absent`, + `trim`, + `uppercase`, +] +export const boundSchemaHistories = boundSchemaModes.flatMap((firstSchema) => + boundSchemaModes.flatMap((nextSchema) => + ([`query`, `queryKey`] as const).flatMap((identity) => + [false, true].flatMap((changeKey) => + [ + { word: `alpha`, padding: ` ` }, + { word: `beta gamma`, padding: ` ` }, + ].map((input) => ({ + firstSchema, + nextSchema, + identity, + changeKey, + input, + })), + ), + ), + ), +) +export type BoundSchemaHistory = (typeof boundSchemaHistories)[number] + +// Binding names are model-only declarations. They combine no Collection, +// compiler, or schema-parser state: phase names the history's identity cut. +export function modelBoundSchemaBinding( + history: BoundSchemaHistory, + phase: BoundSchemaPhase, +) { + return { + schema: phase === `rebound` ? history.nextSchema : history.firstSchema, + key: phase === `rebound` && history.changeKey ? `prefixed` : `plain`, + } as const +} +export function modelBoundSchemaKey(id: string, key: `plain` | `prefixed`) { + return key === `plain` ? id : `bound-${id}` +} + +// Expected text comes from the authored input grammar. Trim keeps its word; +// uppercase converts ASCII code points. Neither computation calls a fixture's +// validate() or the production mutation/schema machinery. +export function modelBoundSchemaLabel( + mode: BoundSchemaMode, + input: BoundSchemaHistory[`input`], +) { + const raw = `${input.padding}${input.word}${input.padding}` + if (mode === `trim`) return input.word + if (mode === `absent`) return raw + return [...raw] + .map((letter) => { + const code = letter.charCodeAt(0) + return code >= 97 && code <= 122 ? String.fromCharCode(code - 32) : letter + }) + .join(``) +} +export type BoundSchemaRows = ReadonlyArray<{ + key: unknown + value: Readonly> +}> +export type BoundSchemaMutation = { + key: unknown + original: Readonly> + modified: Readonly> + changes: Readonly> +} +export function modelBoundSchemaRows( + rows: ReadonlyArray, + key: `plain` | `prefixed`, +): BoundSchemaRows { + return rows.map((row) => ({ + key: modelBoundSchemaKey(row.id, key), + value: { ...row }, + })) +} +export function modelBoundSchemaMutation( + history: BoundSchemaHistory, + phase: BoundSchemaPhase, + base: ReadonlyArray, +) { + const binding = modelBoundSchemaBinding(history, phase) + const original = base[0]! + const label = modelBoundSchemaLabel(binding.schema, history.input) + const modified = { ...original, label } + return { + rows: modelBoundSchemaRows([modified, ...base.slice(1)], binding.key), + mutations: [ + { + key: modelBoundSchemaKey(original.id, binding.key), + original: { ...original }, + modified, + changes: { label }, + }, + ], + } +} + +function orderedBoundSchemaRows(rows: BoundSchemaRows) { + return [...rows].sort((a, b) => + `${typeof a.key}:${String(a.key)}`.localeCompare( + `${typeof b.key}:${String(b.key)}`, + ), + ) +} +export function checkBoundSchemaRows( + actual: BoundSchemaRows, + expected: BoundSchemaRows, + cut: string, +): void { + // Only unspecified row order is normalized. Extra fields, duplicate rows, + // missing peers and wrong keys remain representable and fail here. + expect(orderedBoundSchemaRows(actual), cut).toStrictEqual( + orderedBoundSchemaRows(expected), + ) +} +export type BoundSchemaObservation = { + immediate: BoundSchemaRows + collection: BoundSchemaRows + state: BoundSchemaRows + data: ReadonlyArray>> + mutations: ReadonlyArray +} +export function checkBoundSchemaValues( + actual: ReadonlyArray>>, + expected: ReadonlyArray>>, + cut: string, +): void { + const byId = ( + a: Readonly>, + b: Readonly>, + ) => String(a.id).localeCompare(String(b.id)) + expect([...actual].sort(byId), cut).toStrictEqual([...expected].sort(byId)) +} +export function checkBoundSchemaMutation( + actual: BoundSchemaObservation, + expected: ReturnType, + cut: string, +): void { + for (const field of [`immediate`, `collection`, `state`] as const) + checkBoundSchemaRows(actual[field], expected.rows, `${cut}/${field}`) + checkBoundSchemaValues( + actual.data, + expected.rows.map(({ value }) => value), + `${cut}/host data`, + ) + expect(actual.mutations, `${cut}/handler payload`).toStrictEqual( + expected.mutations, + ) +} + +// Every lane is required separately in each native receiving driver. A held +// observation counts only after immediate/public/handler comparisons succeed. +export const boundSchemaCuts = [ + `initial-source-raw`, + `initial-held`, + `initial-settled`, + `retained-held`, + `retained-settled`, + `retained-source-raw`, + `rebound-source-raw`, + `rebound-held`, + `rebound-settled`, + `rebound-followup-source-raw`, + `rebound-followup-held`, + `rebound-followup-settled`, +] as const +export function boundSchemaLane(history: BoundSchemaHistory) { + return `${history.identity}/${history.firstSchema}->${history.nextSchema}/${history.changeKey ? `new-key` : `same-key`}/${history.input.word}` +} +export const boundSchemaReachStates = boundSchemaHistories.flatMap((history) => + boundSchemaCuts.map((cut) => `${boundSchemaLane(history)}/${cut}`), +) + +declareOracle({ + grammar: [boundOptionHistories, boundSchemaHistories], + model: [ + modelBoundSchemaBinding, + modelBoundSchemaKey, + modelBoundSchemaLabel, + modelBoundSchemaRows, + modelBoundSchemaMutation, + ], + check: [ + checkBoundOptions, + checkBoundSchemaRows, + checkBoundSchemaValues, + checkBoundSchemaMutation, + ], +}) diff --git a/packages/db/tests/live-query-bound-options-support.ts b/packages/db/tests/live-query-bound-options-support.ts new file mode 100644 index 0000000000..048aa410af --- /dev/null +++ b/packages/db/tests/live-query-bound-options-support.ts @@ -0,0 +1,322 @@ +/** + * Native receiving mechanics for the shared bound-options oracle. This module + * controls real Collections and held mutation handlers; it computes no expected + * schema result. Hosts supply mount, commit flushing and teardown only. + */ +import { expect } from 'vitest' +import { createCollection } from '../src/collection/index.js' +import { gt } from '../src/query/index.js' +import { createDeferred } from '../src/deferred.js' +import { mockSyncCollectionOptions, withOracleCleanup } from './utils.js' +import { + boundSchemaLane, + captureBoundOptions, + checkBoundSchemaMutation, + checkBoundSchemaRows, + checkBoundSchemaValues, + modelBoundSchemaBinding, + modelBoundSchemaKey, + modelBoundSchemaMutation, + modelBoundSchemaRows, +} from './live-query-bound-options-oracle.js' +import type { + BoundSchemaHistory, + BoundSchemaMutation, + BoundSchemaPhase, + BoundSchemaRow, + BoundSchemaRows, +} from './live-query-bound-options-oracle.js' +import type { Collection } from '../src/collection/index.js' +import type { InitialQueryBuilder } from '../src/query/index.js' +import type { createOracleReach } from './oracle-reach.js' + +type SchemaUpdateHandler = (params: { + // The recorder consumes these mutation fields, not the invariant full + // Transaction and Collection types. Published row metadata remains allowed; + // captureRow records every ordinary field before the shared comparison. + transaction: { + mutations: ReadonlyArray<{ + key: string | number + original: object + modified: object + changes: object + }> + } +}) => Promise + +type RenderInput = { revision: number; proposed: boolean } +type SchemaCollection = Pick< + Collection, + `entries` | `update` | `cleanup` +> & { config: { getKey: unknown; schema?: unknown } } +export type BoundSchemaResult = { + collection: SchemaCollection + state: ReadonlyMap + data: ReadonlyArray +} +type NativeBinding = { + current: () => BoundSchemaResult + rerender: (input: RenderInput) => void + flush: (action?: () => void) => Promise + unmount: () => void +} + +// The runtime fixture deliberately uses ordinary String operations. The model +// derives its output from authored grammar parts instead of calling this code. +function fixtureSchema(mode: `trim` | `uppercase`) { + return { + '~standard': { + version: 1 as const, + vendor: `bound-schema-options`, + validate(value: unknown) { + if ( + typeof value !== `object` || + value === null || + !(`label` in value) || + typeof value.label !== `string` + ) + return { issues: [{ message: `A label is required` }] } + return { + value: { + ...value, + label: + mode === `trim` ? value.label.trim() : value.label.toUpperCase(), + }, + } + }, + }, + } +} +function makeConfig( + source: ReturnType, + onUpdate: SchemaUpdateHandler, + history: BoundSchemaHistory, + { revision, proposed }: RenderInput, +) { + const keyPrefix = proposed && history.changeKey ? `bound-` : `` + const config = { + query: (q: InitialQueryBuilder) => + q + .from({ row: source }) + .where(({ row }) => + gt(row.rank, history.identity === `query` ? -revision : 0), + ), + ...(history.identity === `queryKey` ? { queryKey: [revision] } : {}), + getKey: (row: BoundSchemaRow) => `${keyPrefix}${row.id}`, + onUpdate, + } + const schema = proposed ? history.nextSchema : history.firstSchema + // LiveQueryCollectionConfig currently types schema as never. This is the + // documented JavaScript configuration boundary, with no typing claim. + if (schema !== `absent`) + Object.assign(config, { schema: fixtureSchema(schema) }) + return config +} +export type BoundSchemaNativeHost = { + mount: ( + input: (props: RenderInput) => ReturnType, + ) => NativeBinding +} +function createSource(rows: ReadonlyArray) { + return createCollection( + mockSyncCollectionOptions({ + id: `bound-schema-source`, + getKey: (row) => row.id, + initialData: rows.map((row) => ({ ...row })), + }), + ) +} +function captureRow(row: object): Record { + return Object.fromEntries( + Object.entries(row).filter( + ([name]) => + ![`$key`, `$origin`, `$collectionId`, `$hasPendingWrites`].includes( + name, + ), + ), + ) +} +function captureEntries( + rows: Iterable, +): BoundSchemaRows { + return [...rows].map(([key, row]) => ({ key, value: captureRow(row) })) +} + +/** + * Each held update is observed at its immediate Collection return cut and at + * the host's native committed-data cut. The handler receives a fresh gate for + * each probe; no mutation spans identity replacement. Raw source updates are + * independent controls, since schemas must never parse graph sync output. + */ +export async function runBoundSchemaHistory( + history: BoundSchemaHistory, + host: BoundSchemaNativeHost, + reach: ReturnType>, +): Promise { + reach.beginHistory() + const lane = boundSchemaLane(history) + const mark = (cut: string) => reach.reach(`${lane}/${cut}`) + let base: Array = [ + { id: `one`, label: ` source alpha `, rank: 1 }, + { id: `peer`, label: ` source beta `, rank: 2 }, + ] + const source = createSource(base) + const collections = new Set() + let binding: NativeBinding | undefined + let gate: ReturnType> | undefined + let pending: ReturnType | undefined + let mutations: Array = [] + let renders = 0 + const onUpdate: SchemaUpdateHandler = async ({ transaction }) => { + expect(gate, `the authored handler hold exists`).toBeDefined() + mutations.push( + ...transaction.mutations.map((mutation) => ({ + key: mutation.key, + original: captureRow(mutation.original), + modified: captureRow(mutation.modified), + changes: captureRow(mutation.changes), + })), + ) + await gate!.promise + } + await withOracleCleanup(async () => { + binding = host.mount((props) => { + if (++renders > 90) + throw new Error(`inline schema rebuilt on every render`) + return makeConfig(source, onUpdate, history, props) + }) + await binding.flush() + const initial = captureBoundOptions(binding.current().collection) + collections.add(binding.current().collection) + const observeBase = (phase: BoundSchemaPhase, cut: string) => { + const current = binding!.current() + const expected = modelBoundSchemaRows( + base, + modelBoundSchemaBinding(history, phase).key, + ) + checkBoundSchemaRows( + captureEntries(current.collection.entries()), + expected, + `${cut}/Collection`, + ) + checkBoundSchemaRows( + captureEntries(current.state), + expected, + `${cut}/host state`, + ) + checkBoundSchemaValues( + current.data.map(captureRow), + expected.map(({ value }) => value), + `${cut}/host data`, + ) + } + const probe = async ( + phase: BoundSchemaPhase, + cut: BoundSchemaPhase | `rebound-followup` = phase, + ) => { + const current = binding!.current() + collections.add(current.collection) + gate = createDeferred() + mutations = [] + let immediate: BoundSchemaRows = [] + let settled = false + const key = modelBoundSchemaKey( + base[0]!.id, + modelBoundSchemaBinding(history, phase).key, + ) + await binding!.flush(() => { + pending = current.collection.update(key, (draft) => { + draft.label = `${history.input.padding}${history.input.word}${history.input.padding}` + }) + // Capture in the same call stack, before the host gets an await. + immediate = captureEntries(current.collection.entries()) + void pending.when(`settled`).then( + () => { + settled = true + }, + () => { + settled = true + }, + ) + }) + expect(settled, `${cut}/handler remains held`).toBe(false) + const committed = binding!.current() + checkBoundSchemaMutation( + { + immediate, + collection: captureEntries(committed.collection.entries()), + state: captureEntries(committed.state), + data: committed.data.map(captureRow), + mutations, + }, + modelBoundSchemaMutation(history, phase, base), + cut, + ) + mark(`${cut}-held`) + await binding!.flush(() => gate!.resolve()) + await pending!.when(`settled`) + pending = undefined + await binding!.flush() + observeBase(phase, `${cut}/settled`) + mark(`${cut}-settled`) + } + observeBase(`initial`, `initial raw source`) + mark(`initial-source-raw`) + await probe(`initial`) + binding.rerender({ revision: 0, proposed: true }) + await binding.flush() + expect(binding.current().collection, `same query keeps Collection`).toBe( + initial.collection, + ) + expect( + binding.current().collection.config.getKey, + `same query keeps key`, + ).toBe(initial.getKey) + expect( + binding.current().collection.config.schema, + `same query keeps schema including absence`, + ).toBe(initial.schema) + await probe(`retained`) + const publish = async (phase: BoundSchemaPhase) => { + base = base.map((row) => + row.id === `one` ? { ...row, label: ` later source ${phase} ` } : row, + ) + await binding!.flush(() => { + source.utils.begin() + source.utils.write({ type: `update`, value: { ...base[0]! } }) + source.utils.commit() + }) + observeBase(phase, `${phase}/raw sync control`) + mark( + phase === `retained` + ? `retained-source-raw` + : `rebound-followup-source-raw`, + ) + } + await publish(`retained`) + binding.rerender({ revision: 1, proposed: true }) + await binding.flush() + collections.add(binding.current().collection) + expect( + binding.current().collection, + `new identity replaces Collection`, + ).not.toBe(initial.collection) + observeBase(`rebound`, `replacement raw source`) + mark(`rebound-source-raw`) + await probe(`rebound`) + await publish(`rebound`) + // Another use after the later source publication checks the binding's next + // mutation rather than ending at successful replacement/readiness. + await probe(`rebound`, `rebound-followup`) + }, [ + () => (binding ? binding.flush(() => gate?.resolve()) : gate?.resolve()), + () => pending?.when(`settled`), + () => binding?.unmount(), + () => + withOracleCleanup( + () => {}, + [...collections].map((collection) => () => collection.cleanup()), + ), + () => source.cleanup({ dependents: true }), + ]) +} diff --git a/packages/db/tests/live-query-cache-lifecycle-oracle.test.ts b/packages/db/tests/live-query-cache-lifecycle-oracle.test.ts new file mode 100644 index 0000000000..42cf1b5249 --- /dev/null +++ b/packages/db/tests/live-query-cache-lifecycle-oracle.test.ts @@ -0,0 +1,479 @@ +/** + * Local row-option claims live exactly as long as their reusable result. + * Authority: the maintainer's cache-conflict decision and the source-claim + * lifecycle documented by db-client-preload-identity.test.ts. Portable hydration + * carries data, not server function references. The first local admission owns + * those references; newer data preserves them, failure/consumption retires them. + * + * The model retains only whether the original local claim still applies. Each + * finite history claims a real JSON-round-tripped server result through preload, + * observer preload or a server read, then keeps/replaces/fails/consumes it before + * the next declaration. Hydration can be pending or settled. Rejection and exact + * local keys/ordinary values after live handoff are public comparison cuts. + * No arbitrary transport serializer, hydrateRoot, or schema transform is claimed. + */ +import { afterAll, expect, test, vi } from 'vitest' +import { + DbClient, + Query, + collectionOptions, + createLiveQueryObserver, + getLiveQueryHash, + resolveLiveQueryValue, +} from '../src' +import { declareOracle } from './oracle-declaration' +import { createOracleReach } from './oracle-reach' +import { withOracleCleanup } from './utils' +import { + cacheOptionDeclarations, + cacheOptionHistories, + cacheOptionReach, + cacheOptionReachStates, + checkCacheOptionRows, + conflictingCacheOptions, +} from './live-query-cache-options-oracle' +import type { + CacheOptionHistory, + CacheRow, +} from './live-query-cache-options-oracle' +import type { DehydratedLiveQueryResult } from '../src/client' + +type Phase = `active` | `replaced` | `failed` | `consumed` +// A portable result has no local claim. Admission claims it, transport updates +// do not end reuse, and terminal failure or live consumption ends reuse. +function claimSurvives(phase: Phase): boolean { + return phase === `active` || phase === `replaced` +} +function checkLifecycleAdmission( + action: () => unknown, + history: CacheOptionHistory, + phase: Phase, +) { + if (claimSurvives(phase) && conflictingCacheOptions(history)) + expect(action, `active local claim survives ${phase}`).toThrow( + /getKey|schema/, + ) + else expect(action, `retired or compatible claim at ${phase}`).not.toThrow() +} +// Raw transport is checked before JSON can silently erase a leaked function. +function checkPortableCacheState( + value: unknown, + seen = new Set(), +): void { + expect(typeof value, `portable hydration has no function reference`).not.toBe( + `function`, + ) + if (!value || typeof value !== `object` || seen.has(value)) return + seen.add(value) + for (const child of Object.values(value)) checkPortableCacheState(child, seen) +} +const claimants = [`preload`, `observer-preload`, `server-read`] as const +const phases: ReadonlyArray = [ + `active`, + `replaced`, + `failed`, + `consumed`, +] +const reaches = Object.fromEntries( + claimants.map((claimant) => [ + claimant, + createOracleReach(`cache claim lifecycle ${claimant}`, [ + `portable`, + `pending`, + `claimed`, + `pending-conflict`, + `pending-compatible`, + `replaced-while-pending`, + `obsolete-transport-settled`, + `replaced-pending-conflict`, + `replaced-pending-compatible`, + `failed-while-pending`, + `failed-pending-conflict`, + `failed-pending-compatible`, + ...phases.flatMap((phase) => [ + `${phase}-conflict`, + `${phase}-compatible`, + ]), + ...phases, + ...cacheOptionReachStates.filter((state) => state !== `distinct-key`), + ]), + ]), +) +afterAll(() => { + for (const reach of Object.values(reaches)) reach.assertReached() + if (process.env.TANSTACK_DB_ORACLE_STATISTICS === `1`) + console.info( + `cache lifecycle reach`, + Object.fromEntries( + Object.entries(reaches).map(([path, reach]) => [ + path, + Object.fromEntries(reach.counts()), + ]), + ), + ) +}) +declareOracle({ + check: [ + checkLifecycleAdmission, + checkCacheOptionRows, + checkPortableCacheState, + ], + model: [claimSurvives, conflictingCacheOptions], + grammar: [cacheOptionHistories], +}) + +test.each( + cacheOptionHistories + .filter((history) => !history.distinctKey) + .flatMap((history) => + claimants.flatMap((claimant) => + phases.flatMap((phase) => + [false, true].flatMap((pending) => + ((phase === `replaced` || phase === `failed`) && pending + ? [false, true] + : [false] + ).map((replaceBeforeRelease) => ({ + history, + claimant, + phase, + pending, + replaceBeforeRelease, + })), + ), + ), + ), + ), +)( + `cache claim lifetime %j`, + async ({ history, claimant, phase, pending, replaceBeforeRelease }) => { + const reach = reaches[claimant]! + reach.beginHistory() + const server = new DbClient() + const browser = new DbClient() + const rows: Array = [ + { id: `one`, label: `authored` }, + { id: `two`, label: `peer` }, + ] + const descriptor = collectionOptions(`portable-cache-source`, () => ({ + getKey: (row: CacheRow) => row.id, + sync: { + sync: ({ begin, write, commit, markReady }) => { + begin() + for (const value of rows) + write({ type: `insert`, value: { ...value } }) + commit() + markReady() + }, + }, + })) + const serverSource = server.collection(descriptor) + const source = browser.collection(descriptor) + const query = new Query().from({ row: source }) + const [a, b] = cacheOptionDeclarations(history) + const first = { query, getKey: a!.getKey, queryKey: a!.queryKey } + const second = { query, getKey: b!.getKey, queryKey: b!.queryKey } + Object.assign(first, { schema: a!.schema }) + Object.assign(second, { schema: b!.schema }) + const queryHash = getLiveQueryHash(first, first.queryKey) + const views: Array>> = + [] + const observers: Array> = [] + const releases: Array<() => void> = [] + const promises: Array> = [] + let releaseTransport: (() => void) | undefined + let rejectTransport: ((error: Error) => void) | undefined + const observerFor = (config: typeof first) => { + const view = resolveLiveQueryValue(config)! + views.push(view) + const observer = createLiveQueryObserver(view, { + client: browser, + queryHash, + }) + observers.push(observer) + return observer + } + await withOracleCleanup(async () => { + const serverConfig = { + query: new Query().from({ row: serverSource }), + queryKey: first.queryKey, + getKey: + history.getKey[0] === `absent` + ? undefined + : (row: CacheRow) => `${history.getKey[0]}-${row.id}`, + } + if (history.schema[0] !== `absent`) + Object.assign(serverConfig, { + schema: { + '~standard': { + version: 1, + vendor: `server-cache-schema`, + validate: (value: unknown) => ({ value }), + }, + }, + }) + await server.preloadLiveQuery(serverConfig) + const raw = server.dehydrate() + checkPortableCacheState(raw) + const portable = JSON.parse(JSON.stringify(raw)) as ReturnType< + DbClient[`dehydrate`] + > + const record = portable.liveQueries![0]! + expect(record.queryHash).toBe(queryHash) + const snapshot = record.snapshot! + const result = new Promise( + (resolve, reject) => { + releaseTransport = () => resolve(snapshot) + rejectTransport = reject + }, + ) + void result.catch(() => {}) + const pendingReuses: Array> = [] + const acceptedCandidates: Array> = [] + browser.hydrate({ + ...portable, + liveQueries: [ + { + ...record, + ...(pending ? { snapshot: undefined, promise: result } : {}), + }, + ], + }) + reach.reach(`portable`) + if (pending) reach.reach(`pending`) + let claim: Promise | undefined + if (claimant === `preload`) claim = browser.preloadLiveQuery(first) + else { + const observer = observerFor(first) + if (claimant === `observer-preload`) claim = observer.preload() + else { + // Server reads can return a loading snapshot before the transport settles. + vi.stubGlobal(`window`, undefined) + observer.getServerSnapshot() + vi.unstubAllGlobals() + } + } + if (claim) { + promises.push(claim) + void claim.catch(() => {}) + } + if (pending) { + expect( + browser.dehydrate({ shouldDehydrateLiveQuery: () => true }) + .liveQueries?.[0]?.promise, + ).toBeInstanceOf(Promise) + reach.reach( + conflictingCacheOptions(history) + ? `pending-conflict` + : `pending-compatible`, + ) + for (let attempt = 0; attempt < 2; attempt++) { + let candidateSettled = false + checkLifecycleAdmission( + () => { + const candidate = browser.preloadLiveQuery(second) + promises.push(candidate) + pendingReuses.push(candidate) + void candidate.then( + () => { + candidateSettled = true + }, + () => { + candidateSettled = true + }, + ) + }, + history, + `active`, + ) + if (!conflictingCacheOptions(history)) { + await Promise.resolve() + expect( + candidateSettled, + `compatible pending reuse still awaits transport`, + ).toBe(false) + } + } + } + const newerAt = + browser.dehydrate({ shouldDehydrateLiveQuery: () => true }) + .liveQueries![0]!.dehydratedAt + 1 + const newerSnapshot: DehydratedLiveQueryResult = { + ...snapshot, + rows: snapshot.rows.map((entry) => ({ + ...entry, + value: { + ...(entry.value as CacheRow), + label: `newer-${(entry.value as CacheRow).id}`, + }, + })), + } + const replace = () => { + browser.hydrate({ + collections: [], + liveQueries: [ + { ...record, snapshot: newerSnapshot, dehydratedAt: newerAt }, + ], + }) + expect(browser.dehydrate().liveQueries?.[0]?.dehydratedAt).toBe(newerAt) + expect(browser.dehydrate().liveQueries?.[0]?.snapshot?.rows).toEqual( + newerSnapshot.rows, + ) + } + if (replaceBeforeRelease && phase === `replaced`) { + replace() + reach.reach(`replaced-while-pending`) + reach.reach( + conflictingCacheOptions(history) + ? `replaced-pending-conflict` + : `replaced-pending-compatible`, + ) + for (let attempt = 0; attempt < 2; attempt++) + checkLifecycleAdmission( + () => { + const candidate = browser.preloadLiveQuery(second) + promises.push(candidate) + void candidate.catch(() => {}) + }, + history, + `replaced`, + ) + } + if (replaceBeforeRelease && phase === `failed`) { + const error = new Error(`initial transport failed`) + rejectTransport!(error) + if (claim) await expect(claim).rejects.toBe(error) + await expect(result).rejects.toBe(error) + for (const reuse of pendingReuses) + await expect(reuse).rejects.toBe(error) + expect(browser.dehydrate().liveQueries).toBeUndefined() + reach.reach(`failed-while-pending`) + reach.reach( + conflictingCacheOptions(history) + ? `failed-pending-conflict` + : `failed-pending-compatible`, + ) + } else { + releaseTransport!() + if (claim) await claim + await result + // An active transported result is observable before the next local caller. + expect(browser.dehydrate().liveQueries?.[0]?.snapshot?.rows).toEqual( + replaceBeforeRelease && phase === `replaced` + ? newerSnapshot.rows + : snapshot.rows, + ) + if (replaceBeforeRelease && phase === `replaced`) + reach.reach(`obsolete-transport-settled`) + reach.reach(`claimed`) + if (phase === `replaced`) { + if (!replaceBeforeRelease) replace() + } else if (phase === `failed`) { + const error = new Error(`transport failed`) + let reject!: (error: unknown) => void + const failure = new Promise((_, fail) => { + reject = fail + }) + browser.hydrate({ + collections: [], + liveQueries: [ + { + queryHash, + dehydratedAt: newerAt, + promise: failure, + }, + ], + }) + reject(error) + await expect(failure).rejects.toBe(error) + // A failed result is not a reusable dehydrated success. + expect(browser.dehydrate().liveQueries).toBeUndefined() + } else if (phase === `consumed`) { + const consumer = observerFor(first) + releases.push(consumer.subscribe(() => {})) + await consumer.preload() + await vi.waitFor(() => + expect(browser.dehydrate().liveQueries).toBeUndefined(), + ) + checkCacheOptionRows( + Array.from(consumer.getSnapshot().state!, ([key, value]) => ({ + key, + value, + })), + rows, + history.getKey[0], + ) + } + await Promise.all(pendingReuses) + } + reach.reach(phase) + reach.reach( + `${phase}-${conflictingCacheOptions(history) ? `conflict` : `compatible`}`, + ) + for (const state of cacheOptionReach(history)) + if (state !== `distinct-key`) reach.reach(state) + for (let attempt = 0; attempt < 2; attempt++) + checkLifecycleAdmission( + () => { + const next = browser.preloadLiveQuery(second) + promises.push(next) + acceptedCandidates.push(next) + void next.catch(() => {}) + }, + history, + phase, + ) + if (!claimSurvives(phase) || !conflictingCacheOptions(history)) { + await Promise.all(acceptedCandidates) + const consumer = observerFor(second) + releases.push(consumer.subscribe(() => {})) + await consumer.preload() + await vi.waitFor(() => + checkCacheOptionRows( + Array.from(consumer.getSnapshot().state!, ([key, value]) => ({ + key, + value, + })), + rows, + history.getKey[1], + ), + ) + } + }, [ + () => { + vi.unstubAllGlobals() + releaseTransport?.() + }, + () => Promise.allSettled(promises), + () => + withOracleCleanup(() => {}, [ + ...releases.map((release) => () => release()), + ...observers.map((observer) => () => observer.dispose()), + ...views.map((view) => () => view.cleanup({ dependents: true })), + () => browser.cleanup(), + () => server.cleanup(), + ]), + ]) + }, +) + +// Transport must not inherit a local claim merely because a serializer would +// discard it. Deliberately leaking either kind of callable fails this judgment. +test.each([`getKey`, `schema`] as const)( + `portable cache rejects a leaked %s function`, + (option) => { + expect(() => checkPortableCacheState({ [option]: () => {} })).toThrow() + }, +) + +// A wrong forever-claim policy rejects retired results; a no-claim policy +// accepts active conflicts. Both are judged by the same lifecycle comparison. +test.each(phases)( + `lifecycle checker rejects the opposite claim policy at %s`, + (phase) => { + const history = cacheOptionHistories.find(conflictingCacheOptions)! + const wrong = () => { + if (!claimSurvives(phase)) throw new Error(`getKey conflict`) + } + expect(() => checkLifecycleAdmission(wrong, history, phase)).toThrow() + }, +) diff --git a/packages/db/tests/live-query-cache-options-oracle.test.ts b/packages/db/tests/live-query-cache-options-oracle.test.ts new file mode 100644 index 0000000000..dddebf3447 --- /dev/null +++ b/packages/db/tests/live-query-cache-options-oracle.test.ts @@ -0,0 +1,428 @@ +/** + * Reusable local declarations must be checked at every public admission path, + * before a result is returned and before a rejected caller retains resources. + * The shared declaration model judges two attempts, then a compatible original + * caller and a later source update. Transport replacement must preserve local + * claims. It cannot serialize or invent server function references. + * + * The finite grammar crosses independent reference/presence pairs, query keys, + * held/settled first delivery, first registration by explicit preload, observer + * preload or server read, and preload, observer preload, subscribe, SSR read, + * and hydration replacement. The held driver withholds writes, not just a + * returned Promise; request start and unresolved preload are checked explicitly. + * Public admission, dehydration rows, observer delivery, and subscriber ownership + * are the observation cuts. React supplies client/server receiving paths using + * the same judgment. Failure/consumption retirement is a separate lifecycle lane. + */ +import { afterAll, expect, test, vi } from 'vitest' +import { + DbClient, + Query, + collectionOptions, + createLiveQueryObserver, + getLiveQueryHash, + resolveLiveQueryValue, +} from '../src' +import { declareOracle } from './oracle-declaration' +import { createOracleReach } from './oracle-reach' +import { withOracleCleanup } from './utils' +import { + cacheOptionDeclarations, + cacheOptionHistories, + cacheOptionReach, + cacheOptionReachStates, + checkCacheOptionAdmission, + checkCacheOptionPromise, + checkCacheOptionPublications, + checkCacheOptionRows, + conflictingCacheOptions, +} from './live-query-cache-options-oracle' +import type { CacheRow } from './live-query-cache-options-oracle' + +const claimants = [`preload`, `observer-preload`, `server-read`] as const +const paths = [ + `preload`, + `observer-preload`, + `subscribe`, + `server-read`, + `hydrate-preload`, +] as const +const reaches = Object.fromEntries( + claimants.flatMap((claimant) => + paths.map((path) => [ + `${claimant}/${path}`, + createOracleReach(`cache options ${claimant}/${path}`, [ + `first-registration`, + `admission`, + `retry`, + `original-reuse`, + `source-update`, + `held-delivery`, + ...cacheOptionReachStates, + ]), + ]), + ), +) +const executedRoutes = new Set() +afterAll(() => { + // A calibration-only selector runs no history grammar. Every selected route + // still owes all of its declared states, including those it failed to reach. + for (const [route, reach] of Object.entries(reaches)) + if (executedRoutes.has(route)) reach.assertReached() + if (process.env.TANSTACK_DB_ORACLE_STATISTICS === `1`) + console.info( + `cache option reach`, + Object.fromEntries( + Object.entries(reaches).map(([path, reach]) => [ + path, + Object.fromEntries(reach.counts()), + ]), + ), + ) +}) +declareOracle({ + check: [ + checkCacheOptionAdmission, + checkCacheOptionPromise, + checkCacheOptionRows, + checkCacheOptionPublications, + ], + model: [conflictingCacheOptions], + grammar: [cacheOptionHistories], +}) + +test.each( + cacheOptionHistories.flatMap((history) => + [false, true].flatMap((held) => + claimants.flatMap((claimant) => + paths.map((path) => ({ history, held, path, claimant })), + ), + ), + ), +)(`cache admission history %j`, async ({ history, held, path, claimant }) => { + const route = `${claimant}/${path}` + executedRoutes.add(route) + const reach = reaches[route]! + reach.beginHistory() + const client = new DbClient() + let starts = 0 + let delivered = false + let publish: ((rows: Array) => void) | undefined + let deliver = () => {} + const initialRows = [ + { id: `one`, label: `before` }, + { id: `two`, label: `peer` }, + ] + const source = client.collection( + collectionOptions(`cache-option-source`, () => ({ + getKey: (row: CacheRow) => row.id, + sync: { + sync: ({ begin, write, commit, markReady }) => { + starts++ + publish = (rows) => { + begin() + for (const value of rows) + write({ + type: source.has(value.id) ? `update` : `insert`, + value: { ...value }, + }) + commit() + } + deliver = () => { + if (delivered) return + delivered = true + publish!(initialRows) + markReady() + } + if (!held) deliver() + }, + }, + })), + ) + const query = new Query().from({ row: source }) + const [firstOptions, secondOptions] = cacheOptionDeclarations(history) + // Schema refs test the JavaScript configuration boundary. Public schema + // declarations are checked separately; this identity schema claims no transform. + const first = { + query, + getKey: firstOptions!.getKey, + queryKey: firstOptions!.queryKey, + } + const second = { + query, + getKey: secondOptions!.getKey, + queryKey: secondOptions!.queryKey, + } + Object.assign(first, { schema: firstOptions!.schema }) + Object.assign(second, { schema: secondOptions!.schema }) + + const views: Array>> = [] + const observers: Array> = [] + const subscriptions: Array<() => void> = [] + const pending: Array> = [] + const accepted: Array> = [] + const track = (promise: Promise, compatible = true) => { + pending.push(promise) + if (compatible) accepted.push(promise) + void promise.catch(() => {}) + return promise + } + const stream = () => + client + .dehydrate({ shouldDehydrateLiveQuery: () => true }) + .liveQueries!.find( + (entry) => entry.queryHash === getLiveQueryHash(first, first.queryKey), + )! + await withOracleCleanup(async () => { + expect( + client.dehydrate().liveQueries ?? [], + `no prior query result`, + ).toEqual([]) + let settled = false + let initial: Promise + if (claimant === `preload`) initial = track(client.preloadLiveQuery(first)) + else { + // Source-only preload supplies SSR's permitted local rows/readiness, + // without registering a reusable live-query result. No transport seed + // or explicit query preload is allowed to make the claim for this path. + if (claimant === `server-read`) { + const sourceLoad = track(source.preload()) + if (!held) await sourceLoad + } else client._setSsrStreamingEnabled(true) + const view = resolveLiveQueryValue(first)! + views.push(view) + const firstObserver = createLiveQueryObserver(view, { + client, + queryHash: getLiveQueryHash(first, first.queryKey), + }) + observers.push(firstObserver) + if (claimant === `server-read`) { + vi.stubGlobal(`window`, undefined) + const snapshot = firstObserver.getServerSnapshot() + checkCacheOptionRows( + Array.from(snapshot.state ?? [], ([key, value]) => ({ key, value })), + held ? [] : initialRows, + history.getKey[0], + ) + vi.unstubAllGlobals() + initial = track(view.preload()) + } else initial = track(firstObserver.preload()) + } + expect( + stream(), + `first caller registered without borrowed transport`, + ).toBeDefined() + reach.reach(`first-registration`) + void initial.then(() => { + settled = true + }) + expect(starts).toBe(1) + if (held) { + await Promise.resolve() + expect(delivered).toBe(false) + expect(settled).toBe(false) + reach.reach(`held-delivery`) + } else await initial + if (path === `hydrate-preload`) { + const prior = stream() + client.hydrate({ + collections: [], + liveQueries: [{ ...prior, dehydratedAt: prior.dehydratedAt + 1 }], + }) + expect(stream().dehydratedAt).toBe(prior.dehydratedAt + 1) + } + const subscriberCount = source.subscriberCount + let observer: ReturnType | undefined + if (path !== `preload` && path !== `hydrate-preload`) { + const view = resolveLiveQueryValue(second)! + views.push(view) + observer = createLiveQueryObserver(view, { + client, + queryHash: getLiveQueryHash(second, second.queryKey), + }) + observers.push(observer) + } + let rejectedCallbacks = 0 + for (let attempt = 0; attempt < 2; attempt++) { + for (const state of cacheOptionReach(history)) reach.reach(state) + if (path === `observer-preload`) { + const result = track( + observer!.preload(), + !conflictingCacheOptions(history), + ) + if (conflictingCacheOptions(history)) + await checkCacheOptionPromise(result, history, { deliveryHeld: held }) + } else { + checkCacheOptionAdmission(() => { + if (path === `preload` || path === `hydrate-preload`) + track(client.preloadLiveQuery(second)) + else if (path === `subscribe`) + subscriptions.push( + observer!.subscribe(() => { + if (conflictingCacheOptions(history)) rejectedCallbacks++ + }), + ) + else { + vi.stubGlobal(`window`, undefined) + const snapshot = observer!.getServerSnapshot() + // SSR captures its initial view. Held writes are absent at this + // read; later delivery must not rewrite the captured server view. + if (!conflictingCacheOptions(history)) + checkCacheOptionRows( + Array.from(snapshot.state ?? [], ([key, value]) => ({ + key, + value, + })), + held ? [] : initialRows, + history.getKey[1], + ) + } + }, history) + } + reach.reach(attempt === 0 ? `admission` : `retry`) + } + if (conflictingCacheOptions(history)) + expect(source.subscriberCount).toBe(subscriberCount) + track(client.preloadLiveQuery(first)) + reach.reach(`original-reuse`) + deliver() + await initial + for (const promise of accepted) await promise + if (!conflictingCacheOptions(history) && path !== `server-read`) { + if (observer) { + await observer.preload() + // SSR's earlier loading capture remains this observer's initial view + // until attachment (SSR guide: Prepare every server snapshot). The row + // comparison is a live-handoff cut, so commit that receiving demand. + subscriptions.push(observer.subscribe(() => {})) + await Promise.resolve() // committed hydration handoff + checkCacheOptionRows( + Array.from(observer.getSnapshot().state!, ([key, value]) => ({ + key, + value, + })), + initialRows, + history.getKey[1], + ) + } else { + const result = client + .dehydrate() + .liveQueries!.find( + (entry) => + entry.queryHash === getLiveQueryHash(second, second.queryKey), + )! + checkCacheOptionRows( + result.snapshot!.rows, + initialRows, + history.getKey[1], + ) + } + } + // A compatible observer reads the original declaration after rejection. + // Compare its next public delivery as well as a later synchronous read. + vi.unstubAllGlobals() + const originalView = resolveLiveQueryValue(first)! + views.push(originalView) + const originalObserver = createLiveQueryObserver(originalView, { + client, + queryHash: getLiveQueryHash(first, first.queryKey), + }) + observers.push(originalObserver) + const deliveries: Array> = [] + subscriptions.push( + originalObserver.subscribe(() => { + deliveries.push( + Array.from(originalObserver.getSnapshot().state!, ([key, value]) => ({ + key, + value, + })), + ) + }), + ) + await originalObserver.preload() + checkCacheOptionRows( + Array.from(originalObserver.getSnapshot().state!, ([key, value]) => ({ + key, + value, + })), + initialRows, + history.getKey[0], + ) + const updated = [{ id: `one`, label: `after` }, initialRows[1]!] + deliveries.length = 0 + publish!(updated) + const snapshot = originalObserver.getSnapshot() + checkCacheOptionRows( + Array.from(snapshot.state!, ([key, value]) => ({ key, value })), + updated, + history.getKey[0], + ) + checkCacheOptionPublications(deliveries, updated, history.getKey[0]) + expect(rejectedCallbacks, `refused listeners receive no publication`).toBe( + 0, + ) + reach.reach(`source-update`) + }, [ + () => { + vi.unstubAllGlobals() + deliver() + }, + () => Promise.allSettled(pending), + () => + withOracleCleanup(() => {}, [ + ...subscriptions.map((unsubscribe) => () => unsubscribe()), + ...observers.map((observer) => () => observer.dispose()), + ...views.map((view) => () => view.cleanup({ dependents: true })), + () => client.cleanup(), + ]), + ]) +}) + +// Deliberately wrong observations use the same judgment as every driver. The +// all-options-differ policy and claim-overwrite policy would accept these +// conflicts; checking both attempts is what distinguishes claim corruption. +test.each( + cacheOptionHistories.filter((history) => conflictingCacheOptions(history)), +)(`cache admission rejects hostile acceptance %j`, (history) => { + for (let attempt = 0; attempt < 2; attempt++) + expect(() => checkCacheOptionAdmission(() => {}, history)).toThrow() +}) +test.each([`first`, `other`] as const)( + `cache rows reject hostile keys and leading publication for %s`, + (key) => { + const rows = [{ id: `one`, label: `authored` }] + const correct = [{ key: `${key}-one`, value: rows[0] }] + const wrong = [{ key: `borrowed`, value: rows[0] }] + checkCacheOptionRows(correct, rows, key) + expect(() => checkCacheOptionRows(wrong, rows, key)).toThrow() + expect(() => + checkCacheOptionPublications([wrong, correct], rows, key), + ).toThrow() + }, +) + +// Admission calibration includes a promise that never settles. The check must +// reject it at the pre-delivery cut, allowing its driver's cleanup to proceed. +test.each([`pending`, `fulfilled`, `unrelated-error`, `conflict`] as const)( + `rejects wrong pre-delivery admission outcome: %s`, + async (outcome) => { + const history = cacheOptionHistories.find(conflictingCacheOptions)! + const result = + outcome === `pending` + ? new Promise(() => {}) + : outcome === `fulfilled` + ? Promise.resolve() + : Promise.reject( + new Error( + outcome === `conflict` + ? `getKey/schema conflict` + : `unrelated failure`, + ), + ) + const check = checkCacheOptionPromise(result, history, { + deliveryHeld: true, + }) + if (outcome === `conflict`) await check + else await expect(check).rejects.toThrow() + }, +) diff --git a/packages/db/tests/live-query-cache-options-oracle.ts b/packages/db/tests/live-query-cache-options-oracle.ts new file mode 100644 index 0000000000..9ebd241c8c --- /dev/null +++ b/packages/db/tests/live-query-cache-options-oracle.ts @@ -0,0 +1,187 @@ +/** + * A reusable DbClient result cannot substitute different local row behavior. + * Authority: the maintainer's same-identity conflict decision. Each local + * getKey/schema declaration must agree by reference, including its absence. + * A distinct queryKey permits independent declarations. Mounted bindings instead + * retain their first options until query identity changes; that receiving law + * is owned by the source-ID reuse oracle. Hydration carries no function refs. + * + * The independent model compares declaration names, not production config, + * hashes, records, or the helper that production uses to admit them. Six legal + * reference pairs per option cover absent/custom in both directions, equal + * refs, and changed refs. Crossing the two options prevents coupled changes + * from hiding a guard that requires every option to differ. Identity controls + * keep both derived and explicitly keyed callers. Reference renaming adds no + * observation, so the first custom declaration is always named `first`. + */ +import { expect } from 'vitest' + +export type CacheRow = { id: string; label: string } +type Declaration = `absent` | `first` | `other` +const declarations: ReadonlyArray = [ + [`absent`, `absent`], + [`absent`, `first`], + [`first`, `absent`], + [`first`, `first`], + [`first`, `other`], + [`absent`, `other`], +] + +export const cacheOptionHistories = declarations.flatMap((getKey) => + declarations.flatMap((schema) => + [false, true].flatMap((keyed) => + [false, true].map((distinctKey) => ({ + getKey, + schema, + keyed, + distinctKey, + })), + ), + ), +) +export type CacheOptionHistory = (typeof cacheOptionHistories)[number] + +// Either incompatible declaration is enough to reject same-identity reuse. +export function conflictingCacheOptions(history: CacheOptionHistory): boolean { + return ( + !history.distinctKey && + (history.getKey[0] !== history.getKey[1] || + history.schema[0] !== history.schema[1]) + ) +} + +/** Each driver supplies its own production query. Only reference fixtures are shared. */ +export function cacheOptionDeclarations(history: CacheOptionHistory) { + const firstKey = (row: CacheRow) => `first-${row.id}` + const otherKey = (row: CacheRow) => `other-${row.id}` + const firstSchema = { + '~standard': { + version: 1 as const, + vendor: `cache-options`, + validate: (value: unknown) => ({ value }), + }, + } + const keys = { absent: undefined, first: firstKey, other: otherKey } + const schemas = { + absent: undefined, + first: firstSchema, + other: { ...firstSchema }, + } + return [0, 1].map((index) => ({ + getKey: keys[history.getKey[index]!], + schema: schemas[history.schema[index]!], + queryKey: + index === 1 && history.distinctKey + ? [`other-cache-options`] + : history.keyed + ? [`cache-options`] + : undefined, + })) +} + +/** Admission is checked before returning a result, and again on rejected retries. */ +export function checkCacheOptionAdmission( + action: () => unknown, + history: CacheOptionHistory, +): void { + if (conflictingCacheOptions(history)) + expect(action, `conflicting cached row options`).toThrow(/getKey|schema/) + else expect(action, `compatible cached row options`).not.toThrow() +} + +export async function checkCacheOptionPromise( + result: Promise, + history: CacheOptionHistory, + { deliveryHeld = false }: { deliveryHeld?: boolean } = {}, +): Promise { + if (deliveryHeld && conflictingCacheOptions(history)) { + // Admission depends on declarations already supplied at call time. It + // cannot wait for source data. One host task drains the current promise + // jobs while the driver keeps delivery held; this is an admission cut, + // not a provider timeout. A pending verdict must fail here so cleanup can + // release the provider instead of hanging inside this comparison. + let verdict: `pending` | `fulfilled` | `rejected` = `pending` + void result.then( + () => { + verdict = `fulfilled` + }, + () => { + verdict = `rejected` + }, + ) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(verdict, `conflict rejects before held source delivery`).toBe( + `rejected`, + ) + } + if (conflictingCacheOptions(history)) + await expect(result, `conflicting observer preload`).rejects.toThrow( + /getKey|schema/, + ) + else await result +} + +// These public snapshots must still describe the original declaration after +// rejection and the next source publication. Ordinary rows are authored input; +// their keys follow the explicit key fixture or the single-source default key. +export function checkCacheOptionRows( + actual: ReadonlyArray<{ key: unknown; value: unknown }>, + rows: ReadonlyArray, + key: Declaration, +): void { + expect( + actual.map(({ key: actualKey, value }) => { + const row = value as CacheRow + return { key: actualKey, value: { id: row.id, label: row.label } } + }), + ).toEqual( + rows.map((row) => ({ + key: key === `absent` ? row.id : `${key}-${row.id}`, + value: row, + })), + ) +} + +/** These distinctions must reach each receiving path independently. */ +export const cacheOptionReachStates = [ + `getKey-only-conflict`, + `schema-only-conflict`, + `both-conflict`, + `distinct-key`, + `getKey-added`, + `getKey-removed`, + `schema-added`, + `schema-removed`, +] as const +export function cacheOptionReach( + history: CacheOptionHistory, +): Array<(typeof cacheOptionReachStates)[number]> { + const states: Array<(typeof cacheOptionReachStates)[number]> = [] + const keyChanged = history.getKey[0] !== history.getKey[1] + const schemaChanged = history.schema[0] !== history.schema[1] + if (history.distinctKey) states.push(`distinct-key`) + else { + if (keyChanged && !schemaChanged) states.push(`getKey-only-conflict`) + if (schemaChanged && !keyChanged) states.push(`schema-only-conflict`) + if (schemaChanged && keyChanged) states.push(`both-conflict`) + for (const option of [`getKey`, `schema`] as const) { + if (history[option][0] === `absent` && history[option][1] !== `absent`) + states.push(`${option}-added`) + if (history[option][0] !== `absent` && history[option][1] === `absent`) + states.push(`${option}-removed`) + } + } + return states +} + +// No callback may publish wrong keys or values and then hide them behind a +// correct final read. Duplicate equivalent notifications remain permitted. +export function checkCacheOptionPublications( + publications: ReadonlyArray>, + rows: ReadonlyArray, + key: Declaration, +): void { + expect(publications.length, `source update delivered`).toBeGreaterThan(0) + for (const publication of publications) + checkCacheOptionRows(publication, rows, key) +} diff --git a/packages/db/tests/live-query-options.test.ts b/packages/db/tests/live-query-options.test.ts index 83ff147664..0b4dbe7807 100644 --- a/packages/db/tests/live-query-options.test.ts +++ b/packages/db/tests/live-query-options.test.ts @@ -392,21 +392,28 @@ describe(`live query identity`, () => { expect(getLiveQueryHash(first, [])).not.toBe(getLiveQueryHash(second, [])) }) - it(`does not collapse configs with opaque row identity behavior`, () => { + it(`binds row identity and schema options outside query identity`, () => { const source = createCollection<{ id: string }>({ id: `live-query-config-identity-source`, getKey: (row) => row.id, sync: { sync: ({ markReady }) => markReady() }, }) const query = new BaseQueryBuilder().from({ source }) - const first = { query, getKey: (row: { id: string }) => row.id } - const second = { query, getKey: (row: { id: string }) => `x-${row.id}` } - - expect(getPreparedLiveQueryIdentity(first)).not.toEqual( + const getKey = (row: { id: string }) => row.id + const otherGetKey = (row: { id: string }) => `x-${row.id}` + const schema = { parse: (row: unknown) => row } + const otherSchema = { parse: (row: unknown) => row } + const first = { query, getKey, schema } + const same = { query, getKey, schema } + const second = { query, getKey: otherGetKey, schema } + const third = { query, getKey, schema: otherSchema } + + expect(getPreparedLiveQueryIdentity(first)).toEqual( getPreparedLiveQueryIdentity(second), ) - expect(() => getLiveQueryHash(first)).toThrow(/function value/) - expect(() => getLiveQueryHash(second)).toThrow(/function value/) + expect(getLiveQueryHash(first)).toBe(getLiveQueryHash(same)) + expect(getLiveQueryHash(first)).toBe(getLiveQueryHash(second)) + expect(getLiveQueryHash(first)).toBe(getLiveQueryHash(third)) }) it(`uses exact custom comparator identity in live-query config hashes`, () => { diff --git a/packages/db/tests/live-query-order-only-move.test.ts b/packages/db/tests/live-query-order-only-move.test.ts index 25617c1821..6abcd3a5be 100644 --- a/packages/db/tests/live-query-order-only-move.test.ts +++ b/packages/db/tests/live-query-order-only-move.test.ts @@ -168,7 +168,7 @@ describe(`order-only move (RFC #1623 phase 4)`, () => { expect(lq._layoutRevision).toBe(collectionLayoutRevisionBefore) persist.resolve() - await mutation.isPersisted.promise + await mutation.when(`settled`) await flush() const after = observer.getSnapshot() diff --git a/packages/db/tests/local-only-direct-write-oracle.test.ts b/packages/db/tests/local-only-direct-write-oracle.test.ts index bfb0b639fa..c4e27410b2 100644 --- a/packages/db/tests/local-only-direct-write-oracle.test.ts +++ b/packages/db/tests/local-only-direct-write-oracle.test.ts @@ -43,7 +43,7 @@ async function observe(handlers: Handlers, writes: Array) { changes .map( ({ type, key, value }) => - `${type}:${key}:${value.value}:${String(value.$synced)}`, + `${type}:${key}:${value.value}:${String(value.$hasPendingWrites)}`, ) .sort(), ), @@ -58,7 +58,7 @@ async function observe(handlers: Handlers, writes: Array) { errors.push(`${(error as Error).name}: ${(error as Error).message}`) } } - await Promise.all(transactions.map((t) => t.isPersisted.promise)) + await Promise.all(transactions.map((t) => t.when(`settled`))) return { rows: orders.toArray.map(({ id, value }) => ({ id, value })), batches, diff --git a/packages/db/tests/local-only-direct-write.test.ts b/packages/db/tests/local-only-direct-write.test.ts index 18d6ec0947..9f7ff3148e 100644 --- a/packages/db/tests/local-only-direct-write.test.ts +++ b/packages/db/tests/local-only-direct-write.test.ts @@ -36,7 +36,7 @@ describe(`local-only direct writes`, () => { for (const transaction of [inserted, updated, deleted]) { expect(transaction.state).toBe(`completed`) - await expect(transaction.isPersisted.promise).resolves.toBe(transaction) + await expect(transaction.when('settled')).resolves.toBe(transaction) await expect(transaction.when(`settled`)).resolves.toBe(transaction) } expect(batches).toEqual([ @@ -47,7 +47,7 @@ describe(`local-only direct writes`, () => { ]) expect(orders.get(1)).toMatchObject({ value: `c`, - $synced: true, + $hasPendingWrites: !true, $origin: `local`, }) expect(orders.has(2)).toBe(false) @@ -89,7 +89,7 @@ describe(`local-only direct writes`, () => { expect(orders.get(1)?.value).toBe(`direct`) release.resolve() - await persisting.isPersisted.promise + await persisting.when('settled') }) it(`joins an ambient transaction`, () => { @@ -130,7 +130,7 @@ describe(`local-only direct writes`, () => { expect(created.map((transaction) => transaction.state)).toEqual([ `completed`, ]) - await expect(created[0]!.isPersisted.promise).resolves.toBe(created[0]) + await expect(created[0]!.when('settled')).resolves.toBe(created[0]) // The next direct write still takes the direct path. expect(orders.insert({ id: 3, value: `c` }).state).toBe(`completed`) }) @@ -163,7 +163,7 @@ describe(`local-only direct writes`, () => { kind === `update` ? `normalized` : undefined, ) await Promise.all( - created.map((transaction) => transaction.isPersisted.promise), + created.map((transaction) => transaction.when('settled')), ) expect(created.map((transaction) => transaction.state)).toEqual([ `completed`, @@ -190,7 +190,7 @@ describe(`local-only direct writes`, () => { expect(created.map((transaction) => transaction.state)).toEqual([`failed`]) expect(created[0]!.error?.error).toBeInstanceOf(Error) expect(created[0]!.error?.message).toBe(`bad`) - await expect(created[0]!.isPersisted.promise).rejects.toBeInstanceOf(Error) + await expect(created[0]!.when('settled')).rejects.toBeInstanceOf(Error) }) it(`runs the user's handler for that operation type`, async () => { @@ -207,7 +207,7 @@ describe(`local-only direct writes`, () => { }) expect(transaction.state).not.toBe(`completed`) - await transaction.isPersisted.promise + await transaction.when('settled') expect(calls).toBe(1) expect(orders.get(1)?.value).toBe(`handled`) // Inserts have no handler, so they still take the direct path. @@ -252,8 +252,8 @@ describe(`local-only fallbacks for every operation type`, () => { expect(transaction.state).not.toBe(`completed`) expect(visible[kind](orders)).toBe(true) release.resolve() - await persisting.isPersisted.promise - await transaction.isPersisted.promise + await persisting.when('settled') + await transaction.when('settled') expect(visible[kind](orders)).toBe(true) }) @@ -274,7 +274,7 @@ describe(`local-only fallbacks for every operation type`, () => { expect(transaction.state).not.toBe(`completed`) expect(visible[kind](orders)).toBe(true) pending.rollback() - await transaction.isPersisted.promise + await transaction.when('settled') expect(visible[kind](orders)).toBe(true) }) @@ -307,7 +307,7 @@ describe(`local-only fallbacks for every operation type`, () => { const transaction = write[kind](orders) expect(visible[kind](orders)).toBe(true) - await expect(transaction.isPersisted.promise).rejects.toThrow( + await expect(transaction.when('settled')).rejects.toThrow( `handler failed`, ) expect(transaction.state).toBe(`failed`) diff --git a/packages/db/tests/local-only.test-d.ts b/packages/db/tests/local-only.test-d.ts index 6ed48ce81c..319d2facbf 100644 --- a/packages/db/tests/local-only.test-d.ts +++ b/packages/db/tests/local-only.test-d.ts @@ -86,9 +86,9 @@ describe(`LocalOnly Collection Types`, () => { const configWithCallbacks = { id: `test-with-callbacks`, getKey: (item: TestItem) => item.id, - onInsert: () => Promise.resolve({}), - onUpdate: () => Promise.resolve({}), - onDelete: () => Promise.resolve({}), + onInsert: () => Promise.resolve(), + onUpdate: () => Promise.resolve(), + onDelete: () => Promise.resolve(), } const options = localOnlyCollectionOptions(configWithCallbacks) diff --git a/packages/db/tests/local-only.test.ts b/packages/db/tests/local-only.test.ts index d932ced739..9b6c601271 100644 --- a/packages/db/tests/local-only.test.ts +++ b/packages/db/tests/local-only.test.ts @@ -771,7 +771,7 @@ describe(`LocalOnly Collection`, () => { } // Catch the rejected promise to avoid unhandled rejection - tx.isPersisted.promise.catch(() => {}) + tx.when(`settled`).catch(() => {}) // Item should be rolled back expect(collection.has(700)).toBe(false) @@ -817,7 +817,7 @@ describe(`LocalOnly Collection`, () => { transaction.state === `persisting` ) transaction.rollback() - await transaction.isPersisted.promise.catch(() => undefined) + await transaction.when(`settled`).catch(() => undefined) await replacementCollection.cleanup() } }) diff --git a/packages/db/tests/local-storage-order-oracle.test.ts b/packages/db/tests/local-storage-order-oracle.test.ts index a6f3fdf25c..6b21328620 100644 --- a/packages/db/tests/local-storage-order-oracle.test.ts +++ b/packages/db/tests/local-storage-order-oracle.test.ts @@ -1,7 +1,7 @@ /** * LocalStorage automatic writes preserve mutation order within one Collection. * The public LocalStorage guide promises direct mutations that persist, while - * Collection settlement promises that a fulfilled `isPersisted` reflects the + * Collection settlement promises that a fulfilled `when('settled')` reflects the * accepted mutation. A later authored update must not be replaced by an older * handler that happens to finish later. This law says nothing about concurrent * writers in different Collections or browser tabs. @@ -91,8 +91,9 @@ it('persists a nested fire-and-forget mutation after its handler returns', async getKey: (row) => row.id, onInsert: async ({ transaction }) => { if (transaction.mutations[0].modified.id === 'outer') { - nestedReceipt = collection.insert({ id: 'inner', value: 2 }) - .isPersisted.promise + nestedReceipt = collection + .insert({ id: 'inner', value: 2 }) + .when(`settled`) entered.resolve() await release.promise } @@ -107,7 +108,7 @@ it('persists a nested fire-and-forget mutation after its handler returns', async expect(nestedReceipt, 'nested mutation was admitted').toBeDefined() expect(snapshots, 'no write before the outer handler returns').toEqual([]) release.resolve() - await Promise.all([outer.isPersisted.promise, nestedReceipt]) + await Promise.all([outer.when(`settled`), nestedReceipt]) expect(snapshots).toEqual([ expectedRows([{ id: 'outer', value: 1 }], [true]), expectedRows( @@ -169,7 +170,7 @@ it('orders module-level manual acceptance with a DbClient Collection', async () draft.value = 2 }) }) - const receipt = observeHistoryPromise(manual.isPersisted.promise) + const receipt = observeHistoryPromise(manual.when(`settled`)) const commit = observeHistoryPromise(manual.commit()) await manualEntered.promise // The receipt check must occur after mutationFn registers its write. @@ -181,7 +182,7 @@ it('orders module-level manual acceptance with a DbClient Collection', async () expect(receipt.read().status, 'held manual receipt').toBe('pending') release.resolve() await Promise.all([ - automatic.isPersisted.promise, + automatic.when(`settled`), receipt.settled, commit.settled, ]) @@ -246,15 +247,15 @@ it('persists a direct write at return after a synchronous handler failure', asyn const rejected = collection.update('a', (draft) => { draft.value = 1 }) - void rejected.isPersisted.promise.catch(() => undefined) + void rejected.when(`settled`).catch(() => undefined) const next = collection.insert({ id: 'next', value: 2 }) const expected = expectedRows([{ id: 'next', value: 2 }], [true]) expect( storedRows(storage.getItem('rows')), 'direct return after decided failure', ).toEqual(expected) - await expect(rejected.isPersisted.promise).rejects.toBe(failure) - await next.isPersisted.promise + await expect(rejected.when(`settled`)).rejects.toBe(failure) + await next.when(`settled`) expect(storedRows(storage.getItem('rows')), 'settled durable').toEqual( expected, ) @@ -271,7 +272,7 @@ it('persists a direct write at return after a synchronous handler failure', asyn [true, true], ), ) - await after.isPersisted.promise + await after.when(`settled`) }, () => [() => collection.cleanup()], ) @@ -335,7 +336,7 @@ for (const secondId of ['a', 'b']) { ) const settled = [false, false] const outcomes = transactions.map((tx, index) => - tx.isPersisted.promise.then( + tx.when(`settled`).then( () => { settled[index] = true return true @@ -421,7 +422,7 @@ for (const accepted of [ }) const deletion = collection.delete('a') const outcomes = [update, deletion].map((tx) => - tx.isPersisted.promise.then( + tx.when(`settled`).then( () => true, () => false, ), diff --git a/packages/db/tests/local-storage-order.test.ts b/packages/db/tests/local-storage-order.test.ts index a61d333838..9ed77950eb 100644 --- a/packages/db/tests/local-storage-order.test.ts +++ b/packages/db/tests/local-storage-order.test.ts @@ -62,7 +62,7 @@ it('routes module-level manual acceptance before explicit preload', async () => () => [ () => { if (transaction) { - void transaction.isPersisted.promise.catch(() => undefined) + void transaction.when('settled').catch(() => undefined) if (transaction.state === 'pending') transaction.rollback() } }, @@ -105,7 +105,7 @@ for (const directState of ['idle', 'ready', 'cleaned-up'] as const) { }) manual.mutate(() => materialized.insert({ id: 'owned', value: 1 })) await manual.commit() - await manual.isPersisted.promise + await manual.when('settled') const expected = [{ id: 'owned', value: 1 }] expect( storedRows(storage.getItem('rows')), @@ -163,17 +163,17 @@ it('persists handler-free direct mutations before their calls return', async () expect(storedRows(storage.getItem('rows'))).toEqual([ { id: 'a', value: 1 }, ]) - await insert.isPersisted.promise + await insert.when('settled') const update = collection.update('a', (draft) => { draft.value = 2 }) expect(storedRows(storage.getItem('rows'))).toEqual([ { id: 'a', value: 2 }, ]) - await update.isPersisted.promise + await update.when('settled') const deletion = collection.delete('a') expect(storedRows(storage.getItem('rows'))).toEqual([]) - await deletion.isPersisted.promise + await deletion.when('settled') }, () => [() => collection.cleanup()], ) @@ -208,7 +208,7 @@ it('holds a handler-free write behind an earlier pending handler', async () => { }) expect(storedRows(storage.getItem('rows'))).toEqual(initial) release.resolve() - await Promise.all([first.isPersisted.promise, second.isPersisted.promise]) + await Promise.all([first.when('settled'), second.when('settled')]) expect(storedRows(storage.getItem('rows'))).toEqual([ { id: 'a', value: 2 }, { id: 'b', value: 0 }, @@ -222,7 +222,7 @@ it('holds a handler-free write behind an earlier pending handler', async () => { { id: 'b', value: 4 }, { id: 'new', value: 3 }, ]) - await afterDrain.isPersisted.promise + await afterDrain.when('settled') }, () => [() => release.resolve(), () => collection.cleanup()], ) @@ -269,13 +269,13 @@ it('waits for queued acceptance before reporting a later mutationFn failure', as }) manual.mutate(() => collection.insert({ id: 'manual', value: 2 })) const commit = observeHistoryPromise(manual.commit()) - const receipt = observeHistoryPromise(manual.isPersisted.promise) + const receipt = observeHistoryPromise(manual.when('settled')) await accepted.promise await new Promise((resolve) => setTimeout(resolve, 0)) expect(commit.read().status, 'held commit').toBe('pending') expect(receipt.read().status, 'held persistence receipt').toBe('pending') release.resolve() - await first.isPersisted.promise + await first.when('settled') await Promise.all([commit.settled, receipt.settled]) expect(commit.read()).toMatchObject({ status: 'rejected', @@ -367,7 +367,7 @@ it('waits for every un-awaited manual acceptance in one transaction', async () = slow.insert({ id: 'slow-manual', value: 2 }) fast.insert({ id: 'fast-manual', value: 3 }) }) - const receipt = observeHistoryPromise(manual.isPersisted.promise) + const receipt = observeHistoryPromise(manual.when('settled')) const commit = observeHistoryPromise(manual.commit()) await new Promise((resolve) => setTimeout(resolve, 0)) expect(storedRows(fastStorage.getItem('fast-rows'))).toEqual([ @@ -378,7 +378,7 @@ it('waits for every un-awaited manual acceptance in one transaction', async () = expect(commit.read().status).toBe('pending') release.resolve() await Promise.all([ - first.isPersisted.promise, + first.when('settled'), receipt.settled, commit.settled, ]) @@ -450,7 +450,7 @@ for (const queued of [false, true]) { if (queued) { expect(storage.getItem('rows')).toBeNull() release.resolve() - await first!.isPersisted.promise + await first!.when('settled') } await expect(receipt).rejects.toBe(storageError) if (queued) @@ -463,7 +463,7 @@ for (const queued of [false, true]) { () => release.resolve(), () => { if (manual) { - void manual.isPersisted.promise.catch(() => undefined) + void manual.when('settled').catch(() => undefined) manual.rollback() } }, @@ -519,7 +519,7 @@ it('returns a rejected manual acceptance Promise for invalid serialized data', a () => [ () => { if (manual) { - void manual.isPersisted.promise.catch(() => undefined) + void manual.when('settled').catch(() => undefined) manual.rollback() } }, @@ -564,7 +564,7 @@ for (const secondId of ['a', 'b']) { const second = collection.update(secondId, (draft) => { draft.value = 2 }) - const rejected = second.isPersisted.promise.then( + const rejected = second.when('settled').then( () => false, () => true, ) @@ -582,7 +582,7 @@ for (const secondId of ['a', 'b']) { ]) expect(storedRows(storage.getItem('rows'))).toEqual(initial) releaseFirst.resolve() - await first.isPersisted.promise + await first.when('settled') expect(storedRows(storage.getItem('rows'))).toEqual([ { id: 'a', value: 1 }, { id: 'b', value: 0 }, @@ -656,7 +656,7 @@ for (const sameKey of [true, false]) { async () => { await collection.preload() const first = collection.insert({ id: 'k', value: 1 }) - const firstOutcome = first.isPersisted.promise.then( + const firstOutcome = first.when('settled').then( () => true, () => false, ) @@ -767,7 +767,7 @@ for (const queued of [false, true]) { }) manual.mutate(() => collection.insert({ id: 'manual', value: 2 })) const commit = observeHistoryPromise(manual.commit()) - const receipt = observeHistoryPromise(manual.isPersisted.promise) + const receipt = observeHistoryPromise(manual.when('settled')) await accepted.promise if (queued) { await new Promise((resolve) => setTimeout(resolve, 0)) @@ -777,7 +777,7 @@ for (const queued of [false, true]) { ) expect(storage.getItem('rows')).toBeNull() release.resolve() - await first!.isPersisted.promise + await first!.when('settled') } await Promise.all([commit.settled, receipt.settled]) if (storageFails) { diff --git a/packages/db/tests/local-storage-peer-history.test.ts b/packages/db/tests/local-storage-peer-history.test.ts index c13d69434b..0d3fbd710d 100644 --- a/packages/db/tests/local-storage-peer-history.test.ts +++ b/packages/db/tests/local-storage-peer-history.test.ts @@ -107,14 +107,14 @@ for (const firstId of [1, '1'] as const) { { id: firstId, value: 1 }, { id: firstId === 1 ? '1' : 1, value: 2 }, ] - await first.insert({ ...authored[0]! }).isPersisted.promise + await first.insert({ ...authored[0]! }).when('settled') expect(durableRows(host, 'shared'), 'first durable receipt').toEqual( expectedRows(authored.slice(0, 1)), ) expect(publicRows(second), 'first same-tab peer receipt').toEqual( expectedRows(authored.slice(0, 1)), ) - await second.insert({ ...authored[1]! }).isPersisted.promise + await second.insert({ ...authored[1]! }).when('settled') const expected = expectedRows(authored) expect( durableRows(host, 'shared'), @@ -159,7 +159,7 @@ it('publishes manual and automatic same-key edits to a same-tab peer', async () await withHistoryCleanup( async () => { await Promise.all([first.preload(), second.preload()]) - await first.insert({ id: 'row', value: 1 }).isPersisted.promise + await first.insert({ id: 'row', value: 1 }).when('settled') expect(publicRows(second)).toEqual([{ id: 'row', value: 1 }]) const transaction = createTransaction({ @@ -182,15 +182,17 @@ it('publishes manual and automatic same-key edits to a same-tab peer', async () expected, ) expect(publicRows(second), 'manual writer receipt').toEqual(expected) - await first.update('row', (draft) => { - draft.value = 3 - }).isPersisted.promise + await first + .update('row', (draft) => { + draft.value = 3 + }) + .when('settled') const updated = expectedRows([{ id: 'row', value: 3 }]) expect(durableRows(host, 'shared'), 'automatic update durable').toEqual( updated, ) expect(publicRows(second), 'automatic update peer').toEqual(updated) - await second.delete('row').isPersisted.promise + await second.delete('row').when('settled') expect(durableRows(host, 'shared'), 'automatic delete durable').toEqual( [], ) @@ -214,7 +216,7 @@ it('does not publish a rejected same-tab write', async () => { await withHistoryCleanup( async () => { await Promise.all([first.preload(), second.preload()]) - await first.insert({ id: 'kept', value: 1 }).isPersisted.promise + await first.insert({ id: 'kept', value: 1 }).when('settled') const writeError = new Error('storage write failed') const setItem = host.storage.setItem host.storage.setItem = () => { @@ -222,7 +224,7 @@ it('does not publish a rejected same-tab write', async () => { } try { await expect( - second.insert({ id: 'rejected', value: 2 }).isPersisted.promise, + second.insert({ id: 'rejected', value: 2 }).when('settled'), ).rejects.toBe(writeError) } finally { host.storage.setItem = setItem @@ -262,7 +264,7 @@ it('keeps a durable receipt when a writer subscriber throws', async () => { }, { includeInitialState: false }, ) - await writer.insert({ id: 'saved', value: 1 }).isPersisted.promise + await writer.insert({ id: 'saved', value: 1 }).when('settled') expect( postStorageCallbacks, 'post-Storage callback reached', @@ -314,7 +316,7 @@ it('isolates a failing same-tab receiver after a durable write', async () => { await withHistoryCleanup( async () => { await Promise.all([writer.preload(), failed.preload(), later.preload()]) - await writer.insert({ id: 'accepted', value: 1 }).isPersisted.promise + await writer.insert({ id: 'accepted', value: 1 }).when('settled') const expected = [{ id: 'accepted', value: 1 }] expect(durableRows(host, 'shared'), 'durable receipt').toEqual(expected) expect(publicRows(writer), 'writer receipt').toEqual(expected) @@ -368,7 +370,7 @@ it('publishes a durable row even when local confirmation fails', async () => { async () => { await Promise.all([writer.preload(), peer.preload()]) await expect( - writer.insert({ id: 'durable', value: 1 }).isPersisted.promise, + writer.insert({ id: 'durable', value: 1 }).when('settled'), ).rejects.toBe(confirmationError) const expected = [{ id: 'durable', value: 1 }] expect( @@ -398,8 +400,7 @@ it('preserves a nested same-tab write during peer publication', async () => { (changes) => { if (!admitted && changes.some((change) => change.key === 'first')) { admitted = true - nested = nestedWriter.insert({ id: 'nested', value: 2 }).isPersisted - .promise + nested = nestedWriter.insert({ id: 'nested', value: 2 }).when('settled') } }, { includeInitialState: false }, @@ -411,7 +412,7 @@ it('preserves a nested same-tab write during peer publication', async () => { writer.preload(), nestedWriter.preload(), ]) - await writer.insert({ id: 'first', value: 1 }).isPersisted.promise + await writer.insert({ id: 'first', value: 1 }).when('settled') expect(nested, 'peer admitted nested write').toBeDefined() await nested const expected = expectedRows([ @@ -426,9 +427,11 @@ it('preserves a nested same-tab write during peer publication', async () => { expect(publicRows(nestedWriter), 'nested writer public receipt').toEqual( expected, ) - await nestedWriter.update('nested', (draft) => { - draft.value = 3 - }).isPersisted.promise + await nestedWriter + .update('nested', (draft) => { + draft.value = 3 + }) + .when('settled') const final = expectedRows([ { id: 'first', value: 1 }, { id: 'nested', value: 3 }, @@ -491,7 +494,7 @@ it('avoids reparsing the writer snapshot during same-tab publication', async () await Promise.all(collections.map((collection) => collection.preload())) defaultParse.mockClear() peerParses.fill(0) - await writer.insert({ id: 'new', value: 1 }).isPersisted.promise + await writer.insert({ id: 'new', value: 1 }).when('settled') expect( defaultParse.mock.calls.filter( ([raw]) => typeof raw === 'string' && raw.includes('s:prior'), @@ -572,7 +575,7 @@ for (const changesVersion of [false, true]) { async () => { await Promise.all([writer.preload(), peer.preload()]) parses = 0 - await writer.insert({ id: 'new', value: 1 }).isPersisted.promise + await writer.insert({ id: 'new', value: 1 }).when('settled') const expected = expectedRows([ { id: 'prior', value: 9 }, { id: 'new', value: 1 }, @@ -630,16 +633,18 @@ it('publishes a parser-normalized authored row with an unchanged version token', await withHistoryCleanup( async () => { await Promise.all([writer.preload(), peer.preload()]) - await writer.insert({ id: 'row', value: 1 }).isPersisted.promise + await writer.insert({ id: 'row', value: 1 }).when('settled') const expected = [{ id: 'row', value: 9 }] expect(durableRows(host, 'shared'), 'normalized insert durable').toEqual( expected, ) expect(publicRows(writer), 'normalized insert writer').toEqual(expected) expect(publicRows(peer), 'normalized insert peer').toEqual(expected) - await writer.update('row', (draft) => { - draft.value = 2 - }).isPersisted.promise + await writer + .update('row', (draft) => { + draft.value = 2 + }) + .when('settled') expect(durableRows(host, 'shared'), 'normalized update durable').toEqual( expected, ) @@ -669,14 +674,13 @@ it('restores same-tab publication after cleanup and restart', async () => { async () => { await Promise.all([first.preload(), second.preload()]) await first.cleanup() - await second.insert({ id: 'during-cleanup', value: 1 }).isPersisted - .promise + await second.insert({ id: 'during-cleanup', value: 1 }).when('settled') expect(first.status).toBe('cleaned-up') first.startSyncImmediate() expect(publicRows(first), 'restart restore').toEqual([ { id: 'during-cleanup', value: 1 }, ]) - await second.insert({ id: 'after-restart', value: 2 }).isPersisted.promise + await second.insert({ id: 'after-restart', value: 2 }).when('settled') const expected = expectedRows([ { id: 'during-cleanup', value: 1 }, { id: 'after-restart', value: 2 }, @@ -711,29 +715,33 @@ for (const otherTab of [`update`, `delete`, `clear`] as const) { { id: 2, value: 2 }, ] for (const row of authored) - await first.insert({ ...row }).isPersisted.promise + await first.insert({ ...row }).when('settled') if (localEdit) { authored = [{ id: 1, value: 10 }, authored[1]!] - await first.update(1, (draft) => { - draft.value = 10 - }).isPersisted.promise + await first + .update(1, (draft) => { + draft.value = 10 + }) + .when('settled') } await tab.preload() if (otherTab === `update`) { authored = [{ id: 1, value: 11 }, authored[1]!] - await tab.update(1, (draft) => { - draft.value = 11 - }).isPersisted.promise + await tab + .update(1, (draft) => { + draft.value = 11 + }) + .when('settled') } else if (otherTab === `delete`) { authored = [authored[1]!] - await tab.delete(1).isPersisted.promise + await tab.delete(1).when('settled') } if (otherTab === `clear`) { authored = [] tab.utils.clearStorage() } else { authored = [...authored, { id: 3, value: 3 }] - await tab.insert({ id: 3, value: 3 }).isPersisted.promise + await tab.insert({ id: 3, value: 3 }).when('settled') } const expected = expectedRows(authored) expect(durableRows(host, 'shared'), 'other tab durable').toEqual( @@ -754,7 +762,7 @@ for (const otherTab of [`update`, `delete`, `clear`] as const) { // The reloaded run confirms the first tab's next write. authored = [...authored, { id: 4, value: 4 }] - await first.insert({ id: 4, value: 4 }).isPersisted.promise + await first.insert({ id: 4, value: 4 }).when('settled') expect(durableRows(host, 'shared'), 'durable after write').toEqual( expectedRows(authored), ) @@ -791,15 +799,16 @@ it('receives a peer write authored during persisted restore publication', async (changes) => { if (!admitted && changes.some((change) => change.key === 'seed')) { admitted = true - nestedReceipt = first.insert({ id: 'nested', value: 2 }).isPersisted - .promise + nestedReceipt = first + .insert({ id: 'nested', value: 2 }) + .when('settled') } }, { includeInitialState: false }, ) try { await second.cleanup() - await first.insert({ id: 'seed', value: 1 }).isPersisted.promise + await first.insert({ id: 'seed', value: 1 }).when('settled') second.startSyncImmediate() expect(admitted, 'restore callback admitted peer write').toBe(true) await nestedReceipt @@ -813,9 +822,11 @@ it('receives a peer write authored during persisted restore publication', async ) expect(publicRows(first), 'nested writer receipt').toEqual(expected) expect(publicRows(second), 'restored peer receipt').toEqual(expected) - await first.update('nested', (draft) => { - draft.value = 3 - }).isPersisted.promise + await first + .update('nested', (draft) => { + draft.value = 3 + }) + .when('settled') const later = expectedRows([ { id: 'seed', value: 1 }, { id: 'nested', value: 3 }, @@ -845,8 +856,7 @@ it('receives a same-tab write started by its ready listener', async () => { let writeReceipt: Promise | undefined const off = second.on('status:change', ({ status }) => { if (status === 'ready') { - writeReceipt = first.insert({ id: 'at-ready', value: 1 }).isPersisted - .promise + writeReceipt = first.insert({ id: 'at-ready', value: 1 }).when('settled') } }) await withHistoryCleanup( @@ -890,7 +900,7 @@ it('publishes through the default same-tab Storage object', async () => { await withHistoryCleanup( async () => { await Promise.all([first.preload(), second.preload()]) - await first.insert({ id: 'row', value: 1 }).isPersisted.promise + await first.insert({ id: 'row', value: 1 }).when('settled') expect(publicRows(second)).toEqual([{ id: 'row', value: 1 }]) }, () => [ @@ -1044,7 +1054,7 @@ it.each([`copy first`, `owner first`] as const)( await first.stateWhenReady() } expect(host.listenerCount()).toBe(1) - await first.insert({ id: `owned`, value: 1 }).isPersisted.promise + await first.insert({ id: `owned`, value: 1 }).when('settled') expect(publicRows(first)).toEqual([{ id: `owned`, value: 1 }]) expect([...selected.values()]).toEqual([]) expect(durableRows(host, `selected-owner`)).toEqual([ @@ -1219,8 +1229,8 @@ it('does not retain a same-tab callback after a late constructor failure', async await Promise.all([failedWriter.preload(), controlWriter.preload()]) failedHost.resetReadCount() controlHost.resetReadCount() - await failedWriter.insert({ id: 'row', value: 1 }).isPersisted.promise - await controlWriter.insert({ id: 'row', value: 1 }).isPersisted.promise + await failedWriter.insert({ id: 'row', value: 1 }).when('settled') + await controlWriter.insert({ id: 'row', value: 1 }).when('settled') const failedWriteReads = failedHost.readCount() const controlWriteReads = controlHost.readCount() expect(durableRows(failedHost, 'shared')).toEqual( @@ -1277,7 +1287,7 @@ it('does not retain cleared rows after the next local write settles', async () = await withHistoryCleanup( async () => { await Promise.all([collection.preload(), peer.preload()]) - await collection.insert({ id: 'old', value: 1 }).isPersisted.promise + await collection.insert({ id: 'old', value: 1 }).when('settled') host.deliver('shared') expect(publicRows(peer)).toEqual([{ id: 'old', value: 1 }]) collection.utils.clearStorage() @@ -1289,7 +1299,7 @@ it('does not retain cleared rows after the next local write settles', async () = expect(publicRows(peer), 'peer clear without event').toEqual([]) host.deliver('shared') expect(publicRows(peer)).toEqual([]) - await collection.insert({ id: 'new', value: 2 }).isPersisted.promise + await collection.insert({ id: 'new', value: 2 }).when('settled') const expected = [{ id: 'new', value: 2 }] expect(durableRows(host, 'shared')).toEqual(expected) expect(publicRows(collection)).toEqual(expected) @@ -1331,7 +1341,7 @@ it('keeps a pending mutation through clear until it settles', async () => { await withHistoryCleanup( async () => { await collection.preload() - await collection.insert({ id: 'pending', value: 1 }).isPersisted.promise + await collection.insert({ id: 'pending', value: 1 }).when('settled') const update = collection.update('pending', (draft) => { draft.value = 2 }) @@ -1340,7 +1350,7 @@ it('keeps a pending mutation through clear until it settles', async () => { expect(host.storage.getItem('shared')).toBeNull() expect(publicRows(collection)).toEqual([{ id: 'pending', value: 2 }]) release.resolve() - await update.isPersisted.promise + await update.when('settled') expect(durableRows(host, 'shared')).toEqual([{ id: 'pending', value: 2 }]) expect(publicRows(collection)).toEqual([{ id: 'pending', value: 2 }]) }, @@ -1359,7 +1369,7 @@ it('refreshes each active Collection once when clearing storage', async () => { await withHistoryCleanup( async () => { await Promise.all([writer.preload(), peer.preload()]) - await writer.insert({ id: 'row', value: 1 }).isPersisted.promise + await writer.insert({ id: 'row', value: 1 }).when('settled') const getItem = host.storage.getItem let reads = 0 host.storage.getItem = (key) => { @@ -1386,14 +1396,14 @@ it('distinguishes a missing storage key from malformed empty content', async () await withHistoryCleanup( async () => { await collection.preload() - await collection.insert({ id: 'first', value: 1 }).isPersisted.promise + await collection.insert({ id: 'first', value: 1 }).when('settled') expect(durableRows(host, 'shared')).toEqual([{ id: 'first', value: 1 }]) host.storage.setItem('shared', '') const rejected = collection.insert({ id: 'second', value: 2 }) - await expect(rejected.isPersisted.promise).rejects.toThrow(SyntaxError) + await expect(rejected.when('settled')).rejects.toThrow(SyntaxError) expect(host.storage.getItem('shared')).toBe('') collection.utils.clearStorage() - await collection.insert({ id: 'third', value: 3 }).isPersisted.promise + await collection.insert({ id: 'third', value: 3 }).when('settled') expect(durableRows(host, 'shared')).toEqual([{ id: 'third', value: 3 }]) }, () => [() => collection.cleanup()], @@ -1488,9 +1498,11 @@ it('keeps one legacy unprefixed string key writable and restorable', async () => async () => { await writer.preload() expect(publicRows(writer)).toEqual([{ id: 'legacy', value: 1 }]) - await writer.update('legacy', (draft) => { - draft.value = 2 - }).isPersisted.promise + await writer + .update('legacy', (draft) => { + draft.value = 2 + }) + .when('settled') expect(durableRows(host, 'shared')).toEqual([{ id: 'legacy', value: 2 }]) reopened = makeCollection(host, 'shared', 'reopened') await reopened.preload() @@ -1512,7 +1524,7 @@ for (const invalid of [NaN, Infinity, -Infinity]) { async () => { await writer.preload() await expect( - writer.insert({ id: invalid, value: 1 }).isPersisted.promise, + writer.insert({ id: invalid, value: 1 }).when('settled'), ).rejects.toThrow() expect(host.storage.getItem('shared')).toBeNull() expect(publicRows(writer)).toEqual([]) @@ -1533,7 +1545,7 @@ it('retains the last valid peer snapshot through a null row and its repair', asy await withHistoryCleanup( async () => { await collection.preload() - await collection.insert({ id: 'row', value: 1 }).isPersisted.promise + await collection.insert({ id: 'row', value: 1 }).when('settled') const old = [{ id: 'row', value: 1 }] host.storage.setItem( 'shared', @@ -1721,9 +1733,9 @@ it('a peer insert during a pending local insert confirms the final local row', a async () => { await Promise.all([local.preload(), peer.preload()]) const localWrite = local.insert({ id: 'same', value: 1 }) - const outcome = localWrite.isPersisted.promise + const outcome = localWrite.when('settled') await entered.promise - await peer.insert({ id: 'same', value: 2 }).isPersisted.promise + await peer.insert({ id: 'same', value: 2 }).when('settled') expect(durableRows(host, 'shared')).toEqual([{ id: 'same', value: 2 }]) expect(publicRows(local), 'pending local intent overlays peer').toEqual([ { id: 'same', value: 1 }, @@ -1787,10 +1799,10 @@ for (const failureKind of ['storage', 'parser'] as const) { await withHistoryCleanup( async () => { await collection.preload() - await collection.insert({ id: 'before', value: 1 }).isPersisted.promise + await collection.insert({ id: 'before', value: 1 }).when('settled') failRead = true await expect( - collection.insert({ id: 'rejected', value: 2 }).isPersisted.promise, + collection.insert({ id: 'rejected', value: 2 }).when('settled'), ).rejects.toBe(readError) expect(durableRows(host, 'shared')).toEqual([ { id: 'before', value: 1 }, @@ -1801,7 +1813,7 @@ for (const failureKind of ['storage', 'parser'] as const) { publicRows(collection), 'failed event read retains public rows', ).toEqual([{ id: 'before', value: 1 }]) - await collection.insert({ id: 'after', value: 3 }).isPersisted.promise + await collection.insert({ id: 'after', value: 3 }).when('settled') const expected = expectedRows([ { id: 'before', value: 1 }, { id: 'after', value: 3 }, @@ -1849,7 +1861,7 @@ it('settles an accepted custom-parser write through a postwrite read fault', asy async () => { await Promise.all([writer.preload(), peer.preload()]) faultOnRead = reads + 2 - await writer.insert({ id: 'saved', value: 1 }).isPersisted.promise + await writer.insert({ id: 'saved', value: 1 }).when('settled') const expected = [{ id: 'saved', value: 1 }] expect(durableRows(host, 'shared'), 'durable receipt').toEqual(expected) expect(publicRows(writer), 'writer receipt').toEqual(expected) diff --git a/packages/db/tests/local-storage-peer.test.ts b/packages/db/tests/local-storage-peer.test.ts index 20d2b937ce..7bc9e0966c 100644 --- a/packages/db/tests/local-storage-peer.test.ts +++ b/packages/db/tests/local-storage-peer.test.ts @@ -37,7 +37,7 @@ it('keeps an authored Date in the writer while peers read JSON bytes', async () async () => { await Promise.all([writer.preload(), peer.preload()]) const at = new Date('2020-01-02T03:04:05.000Z') - await writer.insert({ id: 'row', at }).isPersisted.promise + await writer.insert({ id: 'row', at }).when('settled') const expected = [{ id: 'row', at: at.toISOString() }] const durable = JSON.parse(host.storage.getItem('dated')!) as Record< string, @@ -88,8 +88,8 @@ it('keeps a native authored Date through a disjoint peer write', async () => { async () => { await Promise.all([first.preload(), second.preload()]) const at = new Date('2020-01-02T03:04:05.000Z') - await first.insert({ id: 'first-row', at }).isPersisted.promise - await second.insert({ id: 'second-row', at: 'later' }).isPersisted.promise + await first.insert({ id: 'first-row', at }).when('settled') + await second.insert({ id: 'second-row', at: 'later' }).when('settled') const durable = JSON.parse(host.storage.getItem('dated')!) as Record< string, { data: DatedRow } @@ -129,9 +129,11 @@ it('keeps a native authored Date through a disjoint peer write', async () => { // A peer's actual same-key edit has a new version and must replace the // retained native value in the first Collection. - await second.update('first-row', (draft) => { - draft.at = 'changed' - }).isPersisted.promise + await second + .update('first-row', (draft) => { + draft.at = 'changed' + }) + .when('settled') expect( [...first.values()].find(({ id }) => id === 'first-row'), ).toMatchObject({ at: 'changed' }) @@ -184,7 +186,7 @@ for (const unrelatedKey of ['first-store', 'second-store']) { () => [ () => { if (transaction) { - void transaction.isPersisted.promise.catch(() => undefined) + void transaction.when('settled').catch(() => undefined) transaction.rollback() } }, @@ -221,7 +223,7 @@ it('restores a serializable NaN key alongside distinct number and string keys', { id: 'number', key: 1, value: 2 }, { id: 'string', key: '1', value: 3 }, ] - for (const row of authored) await writer.insert(row).isPersisted.promise + for (const row of authored) await writer.insert(row).when('settled') expect( [...writer.values()].map(({ id, key, value }) => ({ id, key, value })), ).toEqual(expect.arrayContaining(authored)) diff --git a/packages/db/tests/local-storage-persistence-failure.test.ts b/packages/db/tests/local-storage-persistence-failure.test.ts index 7c65efe5ba..2caefe9809 100644 --- a/packages/db/tests/local-storage-persistence-failure.test.ts +++ b/packages/db/tests/local-storage-persistence-failure.test.ts @@ -56,7 +56,7 @@ it.each(cases)( const log = vi.spyOn(console, `error`).mockImplementation(() => {}) try { await collection.preload() - await collection.insert({ id: `seed`, value: 1 }).isPersisted.promise + await collection.insert({ id: `seed`, value: 1 }).when(`settled`) const mutate = () => { if (operation === `insert`) return collection.insert({ id: `bad`, value: 2 }) @@ -74,14 +74,14 @@ it.each(cases)( }, }) : mutate() - const rejection = expect(failed.isPersisted.promise).rejects.toBe(error) + const rejection = expect(failed.when(`settled`)).rejects.toBe(error) if (manual) { failed.mutate(mutate) await failed.commit().catch(() => {}) } await rejection fail = false - await collection.insert({ id: `good`, value: 3 }).isPersisted.promise + await collection.insert({ id: `good`, value: 3 }).when(`settled`) const restored = makeCollection() try { await restored.preload() diff --git a/packages/db/tests/local-storage-value-fidelity-oracle.property.test.ts b/packages/db/tests/local-storage-value-fidelity-oracle.property.test.ts index 85d8552f49..297747cd1d 100644 --- a/packages/db/tests/local-storage-value-fidelity-oracle.property.test.ts +++ b/packages/db/tests/local-storage-value-fidelity-oracle.property.test.ts @@ -288,7 +288,7 @@ function open(storage: Storage, onWrite?: () => void) { // --------------------------------------------------------------------------- // Production driver and refinement check. The checkpoint is the settlement of -// the write's `isPersisted` promise. A faithful payload must fulfil it; the +// the write's `when('settled')` promise. A faithful payload must fulfil it; the // writer keeps its authored value (the guide lets the writer hold it until // restore); durable storage and a fresh Collection hold the model's restored // form. A lost payload must reject with `SerializationError`; the writer's @@ -311,12 +311,13 @@ async function runHistory({ entry, handler, payload }: History) { const previous = collection.get(`row`) const row = { id: `row`, payload: payload.authored } let receipt: Promise - if (entry === `insert`) - receipt = collection.insert(row).isPersisted.promise + if (entry === `insert`) receipt = collection.insert(row).when(`settled`) else if (entry === `update`) - receipt = collection.update(`row`, (draft) => { - draft.payload = payload.authored - }).isPersisted.promise + receipt = collection + .update(`row`, (draft) => { + draft.payload = payload.authored + }) + .when(`settled`) else { const tx = createTransaction({ mutationFn: ({ transaction }) => @@ -329,7 +330,7 @@ async function runHistory({ entry, handler, payload }: History) { draft.payload = payload.authored }) }) - receipt = tx.isPersisted.promise + receipt = tx.when(`settled`) } const outcome = observeHistoryPromise(receipt) await outcome.settled diff --git a/packages/db/tests/local-storage.test-d.ts b/packages/db/tests/local-storage.test-d.ts index 0ca85ccde2..385d3ee9ca 100644 --- a/packages/db/tests/local-storage.test-d.ts +++ b/packages/db/tests/local-storage.test-d.ts @@ -178,21 +178,21 @@ describe(`LocalStorage collection type resolution tests`, () => { expectTypeOf( params.transaction.mutations[0].modified, ).toEqualTypeOf() - return Promise.resolve({ success: true }) + return Promise.resolve() }, onUpdate: (params) => { // Verify that the mutation value has the correct type expectTypeOf( params.transaction.mutations[0].modified, ).toEqualTypeOf() - return Promise.resolve({ success: true }) + return Promise.resolve() }, onDelete: (params) => { // Verify that the mutation value has the correct type expectTypeOf( params.transaction.mutations[0].original, ).toEqualTypeOf() - return Promise.resolve({ success: true }) + return Promise.resolve() }, }) diff --git a/packages/db/tests/local-storage.test.ts b/packages/db/tests/local-storage.test.ts index b824eac66b..0dc4dbaef0 100644 --- a/packages/db/tests/local-storage.test.ts +++ b/packages/db/tests/local-storage.test.ts @@ -89,7 +89,6 @@ const publicRemoteTodo = { $key: `remote`, $origin: `remote`, $hasPendingWrites: false, - $synced: true, } function installRemoteTodo(storage: Storage): StorageEvent { @@ -146,7 +145,6 @@ function assertStoredTodos( delete userRow.$key delete userRow.$origin delete userRow.$hasPendingWrites - delete userRow.$synced return userRow }), ).toStrictEqual(expected) @@ -278,7 +276,7 @@ describe(`localStorage collection`, () => { const insertTx = collection.insert(todo) - await insertTx.isPersisted.promise + await insertTx.when(`settled`) const storedData = mockStorage.getItem(`todos`) expect(storedData).toBeDefined() @@ -416,7 +414,7 @@ describe(`localStorage collection`, () => { // Insert without handlers should still persist const insertTx = collection.insert(todo) - await insertTx.isPersisted.promise + await insertTx.when(`settled`) // Check that it was saved to storage let storedData = mockStorage.getItem(`todos`) @@ -428,7 +426,7 @@ describe(`localStorage collection`, () => { const updateTx = collection.update(`1`, (draft) => { draft.title = `Updated Without Handlers` }) - await updateTx.isPersisted.promise + await updateTx.when(`settled`) // Check that update was saved to storage storedData = mockStorage.getItem(`todos`) @@ -438,7 +436,7 @@ describe(`localStorage collection`, () => { // Delete without handlers should still persist const deleteTx = collection.delete(`1`) - await deleteTx.isPersisted.promise + await deleteTx.when(`settled`) // Check that deletion was saved to storage storedData = mockStorage.getItem(`todos`) @@ -478,7 +476,7 @@ describe(`localStorage collection`, () => { // Insert should call handler AND persist const insertTx = collection.insert(todo) - await insertTx.isPersisted.promise + await insertTx.when(`settled`) expect(insertSpy).toHaveBeenCalledOnce() let storedData = mockStorage.getItem(`todos`) @@ -490,7 +488,7 @@ describe(`localStorage collection`, () => { const updateTx = collection.update(`1`, (draft) => { draft.title = `Updated With Handlers` }) - await updateTx.isPersisted.promise + await updateTx.when(`settled`) expect(updateSpy).toHaveBeenCalledOnce() storedData = mockStorage.getItem(`todos`) @@ -500,7 +498,7 @@ describe(`localStorage collection`, () => { // Delete should call handler AND persist const deleteTx = collection.delete(`1`) - await deleteTx.isPersisted.promise + await deleteTx.when(`settled`) expect(deleteSpy).toHaveBeenCalledOnce() storedData = mockStorage.getItem(`todos`) @@ -518,7 +516,7 @@ describe(`localStorage collection`, () => { storage: mockStorage, storageEventApi: mockStorageEventApi, getKey: (todo) => todo.id, - onInsert: () => Promise.resolve({ success: true }), + onInsert: () => Promise.resolve(), }), ) @@ -532,7 +530,7 @@ describe(`localStorage collection`, () => { // When a collection has mutation handlers, calling insert() automatically creates // a transaction and calls the onInsert handler const tx = collection.insert(todo) - await tx.isPersisted.promise + await tx.when(`settled`) // Check that it was saved to storage with version key structure const storedData = mockStorage.getItem(`todos`) @@ -568,7 +566,7 @@ describe(`localStorage collection`, () => { storage: mockStorage, storageEventApi: mockStorageEventApi, getKey: (todo) => todo.id, - onUpdate: () => Promise.resolve({ success: true }), + onUpdate: () => Promise.resolve(), }), ) @@ -579,7 +577,7 @@ describe(`localStorage collection`, () => { const tx = collection.update(`1`, (draft) => { draft.title = `Updated Todo` }) - await tx.isPersisted.promise + await tx.when(`settled`) // Check that it was updated in storage with a new version key const storedData = mockStorage.getItem(`todos`) @@ -613,7 +611,7 @@ describe(`localStorage collection`, () => { storage: mockStorage, storageEventApi: mockStorageEventApi, getKey: (todo) => todo.id, - onDelete: () => Promise.resolve({ success: true }), + onDelete: () => Promise.resolve(), }), ) @@ -622,7 +620,7 @@ describe(`localStorage collection`, () => { // Delete the todo - this automatically creates a transaction and calls onDelete const tx = collection.delete(`1`) - await tx.isPersisted.promise + await tx.when(`settled`) // Check that it was removed from storage const storedData = mockStorage.getItem(`todos`) @@ -1047,7 +1045,7 @@ describe(`localStorage collection`, () => { title: `replacement`, removed: `old`, }) - await insert.isPersisted.promise + await insert.when(`settled`) transaction.mutate(() => { collection.delete(`row`) @@ -1075,7 +1073,7 @@ describe(`localStorage collection`, () => { transaction.state === `persisting` ) transaction.rollback() - await transaction.isPersisted.promise.catch(() => undefined) + await transaction.when(`settled`).catch(() => undefined) await reopened?.cleanup() await collection.cleanup() } @@ -1163,7 +1161,7 @@ describe(`localStorage collection`, () => { createdAt: new Date(), } const existingTx = collection.insert(existingTodo) - await existingTx.isPersisted.promise + await existingTx.when(`settled`) const tx = createTransaction({ mutationFn: async ({ transaction }: any) => { @@ -1221,7 +1219,7 @@ describe(`localStorage collection`, () => { createdAt: new Date(), } const insertTx = collection.insert(todo) - await insertTx.isPersisted.promise + await insertTx.when(`settled`) const tx = createTransaction({ mutationFn: async ({ transaction }: any) => { @@ -1270,7 +1268,7 @@ describe(`localStorage collection`, () => { }, autoCommit: false, }) - const persisted = observeHistoryPromise(tx.isPersisted.promise) + const persisted = observeHistoryPromise(tx.when(`settled`)) await withHistoryCleanup( async () => { tx.mutate(() => { @@ -1309,7 +1307,7 @@ describe(`localStorage collection`, () => { ...next, createdAt: new Date(next.createdAt), }) - await accepted.isPersisted.promise + await accepted.when(`settled`) const storedData = mockStorage.getItem(`todos`) assertStoredTodos(storedData, [...collection.values()], [next]) expect(JSON.parse(storedData!)[`s:rollback-test`]).toBeUndefined() @@ -1430,11 +1428,11 @@ describe(`localStorage collection`, () => { // Wait for all mutations to complete await Promise.all([ - tx1.isPersisted.promise, - tx2.isPersisted.promise, - tx3.isPersisted.promise, - tx4.isPersisted.promise, - tx5.isPersisted.promise, + tx1.when(`settled`), + tx2.when(`settled`), + tx3.when(`settled`), + tx4.when(`settled`), + tx5.when(`settled`), ]) // Verify final state in storage @@ -1567,7 +1565,7 @@ describe(`localStorage collection`, () => { mockStorageEventApi.triggerStorageEvent(storageEvent) // Wait for local mutation to complete - await localTx.isPersisted.promise + await localTx.when(`settled`) // Both items should exist expect(collection.has(`local`)).toBe(true) @@ -1595,7 +1593,7 @@ describe(`localStorage collection`, () => { completed: false, createdAt: new Date(), }) - await tx1.isPersisted.promise + await tx1.when(`settled`) // Simulate another tab updating the item const remoteData = { @@ -1627,7 +1625,7 @@ describe(`localStorage collection`, () => { const tx2 = collection.update(`1`, (draft) => { draft.title = `Local Update After Remote` }) - await tx2.isPersisted.promise + await tx2.when(`settled`) // Verify final state const storedData = mockStorage.getItem(`todos`) @@ -1707,7 +1705,7 @@ describe(`localStorage collection`, () => { completed: false, createdAt: new Date(), }) - await auto1.isPersisted.promise + await auto1.when(`settled`) // Manual transaction const tx = createTransaction({ @@ -1739,7 +1737,7 @@ describe(`localStorage collection`, () => { completed: false, createdAt: new Date(), }) - await auto2.isPersisted.promise + await auto2.when(`settled`) // Verify all items in storage const storedData = mockStorage.getItem(`todos`) @@ -1788,7 +1786,7 @@ describe(`localStorage collection`, () => { }) // The transaction should reject - await expect(tx.isPersisted.promise).rejects.toThrow() + await expect(tx.when(`settled`)).rejects.toThrow() subscription.unsubscribe() }) @@ -1833,22 +1831,22 @@ describe(`localStorage collection`, () => { ...first, createdAt: new Date(first.createdAt), }) - await tx1.isPersisted.promise + await tx1.when(`settled`) verifyConsistency([first]) const tx2 = collection.update(`1`, (draft) => { draft.title = `Updated` }) - await tx2.isPersisted.promise + await tx2.when(`settled`) const updated = { ...first, title: `Updated` } verifyConsistency([updated]) const tx3 = collection.insert({ ...second, createdAt: new Date(second.createdAt), }) - await tx3.isPersisted.promise + await tx3.when(`settled`) verifyConsistency([updated, second]) const tx4 = collection.delete(`1`) - await tx4.isPersisted.promise + await tx4.when(`settled`) verifyConsistency([second]) // Test the exact checker, not membership in the production cache. @@ -1903,7 +1901,7 @@ describe(`localStorage collection`, () => { completed: false, createdAt: new Date(), }) - await tx1.isPersisted.promise + await tx1.when(`settled`) const tx2 = collection.insert({ id: `second`, @@ -1911,7 +1909,7 @@ describe(`localStorage collection`, () => { completed: false, createdAt: new Date(), }) - await tx2.isPersisted.promise + await tx2.when(`settled`) const tx3 = collection.insert({ id: `third`, @@ -1919,13 +1917,13 @@ describe(`localStorage collection`, () => { completed: false, createdAt: new Date(), }) - await tx3.isPersisted.promise + await tx3.when(`settled`) // Update the FIRST item specifically const updateTx = collection.update(`first`, (draft) => { draft.completed = true }) - await updateTx.isPersisted.promise + await updateTx.when(`settled`) // Verify that the FIRST item was updated, not the last one expect(collection.get(`first`)?.completed).toBe(true) @@ -1962,7 +1960,7 @@ describe(`localStorage collection`, () => { completed: false, createdAt: new Date(), }) - await tx1.isPersisted.promise + await tx1.when(`settled`) const tx2 = collection.insert({ id: `second`, @@ -1970,7 +1968,7 @@ describe(`localStorage collection`, () => { completed: false, createdAt: new Date(), }) - await tx2.isPersisted.promise + await tx2.when(`settled`) const tx3 = collection.insert({ id: `third`, @@ -1978,11 +1976,11 @@ describe(`localStorage collection`, () => { completed: false, createdAt: new Date(), }) - await tx3.isPersisted.promise + await tx3.when(`settled`) // Delete the FIRST item specifically const deleteTx = collection.delete(`first`) - await deleteTx.isPersisted.promise + await deleteTx.when(`settled`) // Verify that the FIRST item was deleted, not the last one expect(collection.has(`first`)).toBe(false) @@ -2024,27 +2022,27 @@ describe(`localStorage collection`, () => { title: `First Todo`, completed: false, }) - await tx1.isPersisted.promise + await tx1.when(`settled`) const tx2 = collection.insert({ id: 2, title: `Second Todo`, completed: false, }) - await tx2.isPersisted.promise + await tx2.when(`settled`) const tx3 = collection.insert({ id: 3, title: `Third Todo`, completed: false, }) - await tx3.isPersisted.promise + await tx3.when(`settled`) // Update the FIRST item (id: 1) specifically const updateTx = collection.update(1, (draft) => { draft.completed = true }) - await updateTx.isPersisted.promise + await updateTx.when(`settled`) // Verify that item with id:1 was updated, not the last one expect(collection.get(1)?.completed).toBe(true) @@ -2086,25 +2084,25 @@ describe(`localStorage collection`, () => { title: `First Todo`, completed: false, }) - await tx1.isPersisted.promise + await tx1.when(`settled`) const tx2 = collection.insert({ id: 2, title: `Second Todo`, completed: false, }) - await tx2.isPersisted.promise + await tx2.when(`settled`) const tx3 = collection.insert({ id: 3, title: `Third Todo`, completed: false, }) - await tx3.isPersisted.promise + await tx3.when(`settled`) // Delete the FIRST item (id: 1) specifically const deleteTx = collection.delete(1) - await deleteTx.isPersisted.promise + await deleteTx.when(`settled`) // Verify that item with id:1 was deleted, not the last one expect(collection.has(1)).toBe(false) @@ -2169,7 +2167,7 @@ describe(`localStorage collection`, () => { const updateTx = collection.update(1, (draft) => { draft.completed = true }) - await updateTx.isPersisted.promise + await updateTx.when(`settled`) // Verify the correct item was updated expect(collection.get(1)?.completed).toBe(true) @@ -2235,7 +2233,7 @@ describe(`localStorage collection`, () => { // Now try to delete item with numeric id 1 const deleteTx = collection.delete(1) - await deleteTx.isPersisted.promise + await deleteTx.when(`settled`) // Verify the correct item was deleted expect(collection.has(1)).toBe(false) @@ -2275,7 +2273,7 @@ describe(`localStorage collection`, () => { id: 1, title: `Numeric ID`, }) - await tx1.isPersisted.promise + await tx1.when(`settled`) // Insert item with string ID "1" // With prefixing, these won't collide: @@ -2285,7 +2283,7 @@ describe(`localStorage collection`, () => { id: `1`, title: `String ID`, }) - await tx2.isPersisted.promise + await tx2.when(`settled`) // Both should exist in collection state expect(collection.has(1)).toBe(true) @@ -2330,14 +2328,14 @@ describe(`localStorage collection`, () => { id: 1, title: `Numeric 1`, }) - await tx1.isPersisted.promise + await tx1.when(`settled`) // Insert item with string ID "n:1" (which would collide with old "__number__1" approach) const tx2 = collection.insert({ id: `n:1`, title: `String n:1`, }) - await tx2.isPersisted.promise + await tx2.when(`settled`) // Both should exist in collection expect(collection.has(1)).toBe(true) diff --git a/packages/db/tests/mutation-confirmation-oracle.test.ts b/packages/db/tests/mutation-confirmation-oracle.test.ts new file mode 100644 index 0000000000..f8a28f5eb8 --- /dev/null +++ b/packages/db/tests/mutation-confirmation-oracle.test.ts @@ -0,0 +1,170 @@ +/** + * Calibrate the shared adapter judgment with wrong public observations. These + * controls use the same grammar, model and comparison as the Query and Electric + * receivers. A legal model observation passes before each mutation is applied. + * They establish checker sensitivity, not execution of either adapter. + */ +import { expect, test } from 'vitest' +import { + checkMutationConfirmation, + expectedMutationConfirmation, + mutationConfirmationHistories, + mutationConfirmationRows, +} from './mutation-confirmation-oracle' +import { declareOracle } from './oracle-declaration' +import type { + MutationConfirmationHistory, + MutationConfirmationObservation, +} from './mutation-confirmation-oracle' + +const histories = [ + ...mutationConfirmationHistories(`query`), + ...mutationConfirmationHistories(`electric`), +] +const controls: Array<{ + name: string + applies: (history: MutationConfirmationHistory) => boolean + corrupt: ( + observation: MutationConfirmationObservation, + history: MutationConfirmationHistory, + ) => void +}> = [ + { + name: `automatic refetch`, + applies: (h) => h.adapter === `query`, + corrupt: (o) => { + o.fetches++ + }, + }, + { + name: `legacy return starts Electric refresh work`, + applies: (h) => h.adapter === `electric`, + corrupt: (o) => { + o.providerRequests++ + }, + }, + { + name: `lost peer`, + applies: (h) => h.peer, + corrupt: (o) => { + o.rows = o.rows.filter((row) => row.id !== 2) + }, + }, + { + name: `partial server replacement`, + applies: (h) => h.confirmation !== `none` && h.operation !== `delete`, + corrupt: (o) => { + o.rows.find((row) => row.id === 1)!.stable = `stable-1` + }, + }, + { + name: `success reported as failure`, + applies: (h) => h.outcome === `fulfilled`, + corrupt: (o) => { + o.state = `failed` + o.settlement = `rejected` + o.error = new Error(`discarded legacy return`) + }, + }, + { + name: `failure reported as success`, + applies: (h) => h.outcome !== `fulfilled`, + corrupt: (o) => { + o.state = `completed` + o.settlement = `fulfilled` + o.error = undefined + }, + }, + { + name: `replaced handler error`, + applies: (h) => h.outcome !== `fulfilled`, + corrupt: (o) => { + o.error = new Error(`same text, different error`) + }, + }, + { + name: `accepted confirmation rolled back`, + applies: (h) => h.confirmation !== `none`, + corrupt: (o, h) => { + o.rows = mutationConfirmationRows(h.operation, `initial`, h.peer) + }, + }, + { + name: `unconfirmed optimistic row leaked`, + applies: (h) => h.confirmation === `none`, + corrupt: (o, h) => { + o.rows = mutationConfirmationRows(h.operation, `optimistic`, h.peer) + }, + }, + { + name: `settlement waiter ran before publication`, + applies: () => true, + corrupt: (o) => { + o.state = `persisting` + }, + }, + { + name: `duplicate server write`, + applies: () => true, + corrupt: (o) => { + o.handlerWrites++ + }, + }, +] + +test.each(controls)( + `rejects wrong mutation observation: $name`, + ({ applies, corrupt }) => { + const failure = new Error(`handler failure`) + const selected = histories.filter(applies) + expect(selected.length).toBeGreaterThan(0) + for (const history of selected) { + const actual = expectedMutationConfirmation(history, `settled`, failure) + checkMutationConfirmation(history, `settled`, actual, failure) + corrupt(actual, history) + expect( + () => checkMutationConfirmation(history, `settled`, actual, failure), + JSON.stringify(history), + ).toThrow() + } + }, +) + +// Holding the actual fetch/stream response must distinguish acknowledgement +// registration from acceptance. A prematurely fulfilled utility is not enough. +test.each([`query`, `electric`] as const)( + `rejects early acknowledgement in %s`, + (adapter) => { + const failure = new Error(`handler failure`) + for (const history of mutationConfirmationHistories(adapter).filter( + (h) => h.confirmation === (adapter === `query` ? `refetch` : `txid`), + )) { + const actual = expectedMutationConfirmation( + history, + `confirmation-requested`, + failure, + ) + checkMutationConfirmation( + history, + `confirmation-requested`, + actual, + failure, + ) + actual.confirmationStatus = `accepted` + expect(() => + checkMutationConfirmation( + history, + `confirmation-requested`, + actual, + failure, + ), + ).toThrow() + } + }, +) + +declareOracle({ + check: [checkMutationConfirmation], + model: [expectedMutationConfirmation], + grammar: [mutationConfirmationHistories], +}) diff --git a/packages/db/tests/mutation-confirmation-oracle.ts b/packages/db/tests/mutation-confirmation-oracle.ts new file mode 100644 index 0000000000..1470107307 --- /dev/null +++ b/packages/db/tests/mutation-confirmation-oracle.ts @@ -0,0 +1,244 @@ +import { expect } from 'vitest' +import { declareOracle } from './oracle-declaration.js' + +/** + * A handler's Promise decides its transaction outcome. Its discarded return + * value decides neither outcome nor confirmation (maintainer decision for the + * first RC). Query starts no refetch when that Promise settles. An explicit + * confirmation is independent of success: accepted source writes apply when + * optimistic state drops, even if the handler rejects afterward. + * + * Authority: optimistic-history-oracle.ts's acceptance/settlement law and the + * approved explicit-confirmation adapter contract. This small projection models + * one handler, one edited key, and one untouched peer. `server-written` + * represents the backend ledger write; its response delivery is outside this + * projection. Source acceptance and transaction settlement stay separate. It + * judges ordinary rows, handler work, + * transaction state, and the public settlement Promise. It does not judge row + * attribution, publication batches, provider framing, or concurrent handlers. + */ +export type MutationConfirmationRow = { + id: number + name: string + stable: string +} +export type MutationOperation = `insert` | `update` | `delete` +export type HandlerEnding = + | { + outcome: `fulfilled` + returned: `void` | `legacy-disable` | `legacy-enable` | `legacy-txid` + } + | { outcome: `reject-before-write` | `reject-after-write`; returned: `void` } +export type MutationConfirmationHistory = HandlerEnding & { + adapter: `query` | `electric` + operation: MutationOperation + peer: boolean + confirmation: `none` | `refetch` | `write` | `txid` + // A collaborator can commit its write and reject before returning to the + // handler. Its authored outcome then makes confirmation unreachable. The + // default preserves the original histories: their rejection follows ack. + confirmationEntry?: `reached` | `unreachable` +} +export type MutationConfirmationCut = + | `entered` + | `server-written` + | `confirmation-requested` + | `confirmation-accepted` + | `settled` + | `late-confirmed` +export type MutationConfirmationObservation< + Row extends object = MutationConfirmationRow, +> = { + rows: Array + handlerWrites: number + confirmationCalls: number + fetches: number + providerRequests: number + confirmationStatus: `not-requested` | `pending` | `accepted` + state: string + settlement: `pending` | `fulfilled` | `rejected` + error: unknown +} + +// The peer and the untouched scalar make row omission and partial replacement +// observable. Expected rows are rebuilt from these inputs, never from production. +export const mutationConfirmationInputs = { + peer: { id: 2, name: `peer`, stable: `stable-2` }, + before: { id: 1, name: `before`, stable: `stable-1` }, + optimistic: { id: 1, name: `optimistic`, stable: `stable-1` }, + confirmed: { id: 1, name: `confirmed`, stable: `server-1` }, +} as const + +/** Complete authored rows are inputs, not a projection of production output. */ +export type MutationConfirmationInputs = { + peer: Row + before: Row + optimistic: Row + confirmed: Row +} + +export function mutationConfirmationRows( + operation: MutationOperation, + version: `initial` | `optimistic` | `confirmed`, + includePeer?: boolean, +): Array +export function mutationConfirmationRows( + operation: MutationOperation, + version: `initial` | `optimistic` | `confirmed`, + includePeer: boolean, + inputs: MutationConfirmationInputs, +): Array +export function mutationConfirmationRows( + operation: MutationOperation, + version: `initial` | `optimistic` | `confirmed`, + includePeer = true, + inputs: MutationConfirmationInputs = mutationConfirmationInputs, +): Array { + const { peer, before, optimistic, confirmed } = inputs + const rows: Array = [] + if (version === `initial` && operation !== `insert`) rows.push({ ...before }) + if (version !== `initial` && operation !== `delete`) + rows.push({ ...(version === `optimistic` ? optimistic : confirmed) }) + if (includePeer) rows.push({ ...peer }) + // Edited row then peer is the comparison's canonical role order. It is not + // a promise about Collection iteration, and keeps the original id 1/2 order. + return structuredClone(rows) +} + +// A thrown handler never reaches its return statement. Rejection-before-write +// also cannot request confirmation. Excluding those meaningless combinations +// keeps each axis associated with an observable action. +export function mutationConfirmationHistories( + adapter: `query` | `electric`, +): Array { + const confirmations = + adapter === `query` + ? ([`none`, `refetch`, `write`] as const) + : ([`none`, `txid`] as const) + const returns = + adapter === `query` + ? ([`void`, `legacy-disable`, `legacy-enable`] as const) + : ([`void`, `legacy-txid`] as const) + return [false, true].flatMap((peer) => + ([`insert`, `update`, `delete`] as const).flatMap((operation) => [ + ...confirmations.flatMap((confirmation) => + returns.map((returned) => ({ + adapter, + peer, + operation, + confirmation, + outcome: `fulfilled` as const, + returned, + })), + ), + { + adapter, + peer, + operation, + confirmation: `none`, + outcome: `reject-before-write`, + returned: `void`, + } as const, + ...confirmations.map((confirmation) => ({ + adapter, + peer, + operation, + confirmation, + outcome: `reject-after-write` as const, + returned: `void` as const, + })), + ]), + ) +} + +export function expectedMutationConfirmation( + history: MutationConfirmationHistory, + cut: MutationConfirmationCut, + failure: Error, +): MutationConfirmationObservation +export function expectedMutationConfirmation( + history: MutationConfirmationHistory, + cut: MutationConfirmationCut, + failure: Error, + inputs: MutationConfirmationInputs, +): MutationConfirmationObservation +export function expectedMutationConfirmation( + history: MutationConfirmationHistory, + cut: MutationConfirmationCut, + failure: Error, + inputs: MutationConfirmationInputs = mutationConfirmationInputs, +): MutationConfirmationObservation { + const settled = cut === `settled` || cut === `late-confirmed` + const wrote = cut !== `entered` && history.outcome !== `reject-before-write` + const accepted = + history.confirmation !== `none` && + history.confirmationEntry !== `unreachable` && + (cut === `confirmation-accepted` || settled) + const requested = + accepted || + (history.confirmationEntry !== `unreachable` && + cut === `confirmation-requested`) + const late = cut === `late-confirmed` + const rejected = settled && history.outcome !== `fulfilled` + return { + rows: mutationConfirmationRows( + history.operation, + settled ? (accepted || late ? `confirmed` : `initial`) : `optimistic`, + history.peer, + inputs, + ), + handlerWrites: wrote ? 1 : 0, + confirmationCalls: requested ? 1 : 0, + confirmationStatus: accepted + ? `accepted` + : requested + ? `pending` + : `not-requested`, + providerRequests: 0, + fetches: + history.adapter === `query` + ? 1 + + (requested && history.confirmation === `refetch` ? 1 : 0) + + (late ? 1 : 0) + : 0, + state: settled ? (rejected ? `failed` : `completed`) : `persisting`, + settlement: settled ? (rejected ? `rejected` : `fulfilled`) : `pending`, + error: rejected ? failure : undefined, + } +} + +// Call this at each held cut, at the settlement waiter itself, and afterward. +// The waiter snapshot is copied by the driver before any later confirmation. +export function checkMutationConfirmation( + history: MutationConfirmationHistory, + cut: MutationConfirmationCut, + observed: MutationConfirmationObservation, + failure: Error, +): void +export function checkMutationConfirmation( + history: MutationConfirmationHistory, + cut: MutationConfirmationCut, + observed: MutationConfirmationObservation, + failure: Error, + inputs: MutationConfirmationInputs, +): void +export function checkMutationConfirmation( + history: MutationConfirmationHistory, + cut: MutationConfirmationCut, + observed: MutationConfirmationObservation, + failure: Error, + inputs: MutationConfirmationInputs = mutationConfirmationInputs, +): void { + const expected = expectedMutationConfirmation(history, cut, failure, inputs) + expect(observed.error, `${cut}: exact handler rejection`).toBe(expected.error) + expect({ ...observed, error: undefined }, cut).toStrictEqual({ + ...expected, + error: undefined, + }) +} + +declareOracle({ + check: [checkMutationConfirmation], + model: [expectedMutationConfirmation, mutationConfirmationRows], + grammar: [mutationConfirmationHistories], +}) diff --git a/packages/db/tests/mutation-confirmation-support.ts b/packages/db/tests/mutation-confirmation-support.ts new file mode 100644 index 0000000000..7c7da36a4d --- /dev/null +++ b/packages/db/tests/mutation-confirmation-support.ts @@ -0,0 +1,22 @@ +// A timeout identifies an unreached semantic checkpoint. It never supplies +// confirmation, cancels the handler, or turns a setup failure into a comparison. +export async function atMutationCheckpoint( + promise: Promise, + label: string, +): Promise { + let timer: ReturnType | undefined + try { + return await Promise.race([ + promise, + new Promise((_resolve, reject) => { + timer = setTimeout( + () => + reject(new Error(`Mutation oracle checkpoint timed out: ${label}`)), + 2000, + ) + }), + ]) + } finally { + clearTimeout(timer) + } +} diff --git a/packages/db/tests/mutation-handler-compatibility.test-d.ts b/packages/db/tests/mutation-handler-compatibility.test-d.ts index d7135369a4..a28c5c545e 100644 --- a/packages/db/tests/mutation-handler-compatibility.test-d.ts +++ b/packages/db/tests/mutation-handler-compatibility.test-d.ts @@ -1,38 +1,25 @@ import { describe, expectTypeOf, it } from 'vitest' import type { Transaction } from '../src/transactions' import type { - BaseCollectionConfig, DeleteMutationFn, InsertMutationFn, TransactionWithMutations, UpdateMutationFn, - UtilsRecord, } from '../src/types' -describe(`mutation handler return compatibility`, () => { +describe(`mutation handler return types`, () => { type Row = { id: string } - type LegacyResult = { txid: number } - it(`keeps default handler aliases compatible with legacy returns`, () => { - const insert: InsertMutationFn = () => Promise.resolve({ txid: 1 }) - const update: UpdateMutationFn = () => Promise.resolve({ txid: 1 }) - const remove: DeleteMutationFn = () => Promise.resolve({ txid: 1 }) - - expectTypeOf(insert).toBeFunction() - expectTypeOf(update).toBeFunction() - expectTypeOf(remove).toBeFunction() - }) - - it(`retains the deprecated fifth BaseCollectionConfig parameter`, () => { - type LegacyConfig = BaseCollectionConfig< - Row, - string, - never, - UtilsRecord, - LegacyResult - > - - expectTypeOf().toBeObject() + it(`requires handlers to settle without a return value`, () => { + expectTypeOf>>().toEqualTypeOf< + Promise + >() + expectTypeOf>>().toEqualTypeOf< + Promise + >() + expectTypeOf>>().toEqualTypeOf< + Promise + >() }) // Adapters pass a handler's transaction to code that takes a Transaction. diff --git a/packages/db/tests/mutation-handler-type.test-d.ts b/packages/db/tests/mutation-handler-type.test-d.ts index 5e4f173743..7d3bbbc4fb 100644 --- a/packages/db/tests/mutation-handler-type.test-d.ts +++ b/packages/db/tests/mutation-handler-type.test-d.ts @@ -8,7 +8,7 @@ import type { Transaction, TransactionState } from '../src/index.js' * `docs/reference/interfaces/BaseCollectionConfig.md` document direct * insert/update/delete handlers with a typed transaction and sibling Collection. * Every nested mutation carries that operation's row, key, and utilities. - * `docs/reference/interfaces/Transaction.md` documents `isPersisted.promise` + * `docs/reference/interfaces/Transaction.md` documents `when('settled')` * as local transaction settlement, not backend confirmation. * * Model and finite input grammar: independently declared Row, key, and utility @@ -162,7 +162,7 @@ describe(`mutation handler type oracle`, () => { expectTypeOf(collection.utils).toEqualTypeOf() expectTypeOf(collection.get).parameter(0).toEqualTypeOf() expectTypeOf(transaction.state).toEqualTypeOf() - expectTypeOf(transaction.isPersisted.promise).toEqualTypeOf< + expectTypeOf(transaction.when(`settled`)).toEqualTypeOf< Promise>> >() diff --git a/packages/db/tests/mutation-handler-types-oracle.test.ts b/packages/db/tests/mutation-handler-types-oracle.test.ts new file mode 100644 index 0000000000..6a37d856d1 --- /dev/null +++ b/packages/db/tests/mutation-handler-types-oracle.test.ts @@ -0,0 +1,416 @@ +/** + * Query and Electric handlers return Promise. A thrown handler is legal; + * coordination objects and ordinary values are not. Undefined is a void result, + * but a synchronous void handler does not supply the required Promise. + * Authority: the approved RC handler migration and the two public Config types. + * + * The finite grammar crosses adapter, key/scalar domain, CRUD handler, direct + * Config annotation/factory call, schema presence, Query row/envelope selection, + * Promise construction and return-value shape. + * An independent output/transport rule predicts admission. The actual compiler + * receives generated consumers importing public source entries. Diagnostics + * must match exactly the rejected candidate lines; missing imports or unrelated + * compiler failures never count as rejection evidence. Built declarations/npm + * resolution and actual application migrations remain separate O22 receivers. + */ +import { resolve } from 'node:path' +import ts from 'typescript' +import { afterAll, describe, expect, test } from 'vitest' +import { declareOracle } from './oracle-declaration' +import { createOracleReach } from './oracle-reach' + +type ResultDescription = { + name: string + transport: `async` | `promise` | `sync` + output: `void` | `undefined` | `never` | `value` + body: string +} +const results: Array = [ + { name: `void`, transport: `async`, output: `void`, body: `` }, + { + name: `undefined`, + transport: `async`, + output: `undefined`, + body: `return undefined`, + }, + { + name: `throw`, + transport: `async`, + output: `never`, + body: `throw new Error('server failed')`, + }, + { + name: `promise-void`, + transport: `promise`, + output: `void`, + body: `return Promise.resolve()`, + }, + { + name: `synchronous-throw`, + transport: `sync`, + output: `never`, + body: `throw new Error('server failed')`, + }, + { + name: `legacy-disable`, + transport: `async`, + output: `value`, + body: `return { refetch: false }`, + }, + { + name: `legacy-enable`, + transport: `async`, + output: `value`, + body: `return { refetch: true }`, + }, + { + name: `legacy-txid`, + transport: `async`, + output: `value`, + body: `return { txid: 55 }`, + }, + { + name: `empty-record`, + transport: `async`, + output: `value`, + body: `return {}`, + }, + { name: `row`, transport: `async`, output: `value`, body: `return row` }, + { name: `number`, transport: `async`, output: `value`, body: `return 1` }, + { name: `null`, transport: `async`, output: `value`, body: `return null` }, + { name: `synchronous-void`, transport: `sync`, output: `void`, body: `` }, +] +const handlers = [`onInsert`, `onUpdate`, `onDelete`] as const +const surfaces = [`config`, `factory-explicit`, `factory-inferred`] as const +const typeShapes = ([`query`, `electric`] as const).flatMap((adapter) => + [`string`, `number`, `string | number`].flatMap((key) => + [`string`, `number`].flatMap((scalar) => + [false, true].flatMap((schema) => + (adapter === `query` ? [`rows`, `envelope`] : [`rows`]).map((data) => ({ + adapter, + key, + scalar, + schema, + data, + })), + ), + ), + ), +) +type TypeShape = (typeof typeShapes)[number] + +// Promise accepts fulfilled void/undefined and rejection. A synchronous +// never-returning function is also assignable; a synchronous void one is not. +// This rule uses the declared result description, not production handler types. +function admitsHandler(result: ResultDescription): boolean { + return ( + result.output === `never` || + (result.transport !== `sync` && result.output !== `value`) + ) +} + +type Candidate = { + id: string + line: number + admitted: boolean + surface: string + start: number + end: number + handlerStart: number + handlerEnd: number +} +function handlerConsumer(shape: TypeShape): { + source: string + candidates: Array +} { + const { adapter, key, scalar, schema, data } = shape + const config = + adapter === `query` ? `QueryCollectionConfig` : `ElectricCollectionConfig` + const factory = + adapter === `query` ? `queryCollectionOptions` : `electricCollectionOptions` + const entry = + adapter === `query` + ? `@tanstack/query-db-collection` + : `@tanstack/electric-db-collection` + const lines = [ + `import { ${factory}, type ${config} } from '${entry}';`, + ...(adapter === `query` + ? [`import { QueryClient } from '@tanstack/query-core';`] + : []), + ...(schema ? [`import { z } from 'zod';`] : []), + `type Row = { id: ${key}; value: ${scalar} };`, + `const row: Row = { id: ${key === `string` ? `'row'` : `1`}, value: ${scalar === `string` ? `'value'` : `7`} };`, + ...(schema + ? [ + `const schema = z.object({ id: ${key === `string | number` ? `z.union([z.string(), z.number()])` : `z.${key}()`}, value: z.${scalar}() });`, + ] + : []), + adapter === `query` + ? `const base = { queryKey: ['handler-types'], queryClient: new QueryClient(), queryFn: async () => ${data === `envelope` ? `({ rows: [row] })` : `[row]`}, ${data === `envelope` ? `select: (data: { rows: Row[] }) => data.rows,` : ``} ${schema ? `schema,` : ``} getKey: (r: Row) => r.id };` + : `const base = { shapeOptions: { url: 'http://localhost/shape' }, ${schema ? `schema,` : ``} getKey: (r: Row) => r.id };`, + ] + const candidates: Array = [] + for (const surface of surfaces) { + for (const handler of handlers) { + for (const result of results) { + const id = `${surface}/${handler}/${result.name}` + const variable = `candidate_${candidates.length}` + const callback = `${result.transport === `async` ? `async ` : ``}({ collection }) => { void collection.id; ${result.body} }` + const object = `{ ...base, ${handler}: ${callback} }` + const configArguments = + adapter === `query` + ? `Row, typeof base.queryFn, unknown, typeof base.queryKey, Row['id'], ${schema ? `typeof schema` : `never`}` + : `Row${schema ? `, typeof schema` : ``}` + const factoryArguments = `${schema ? `typeof schema` : `Row`}${data === `envelope` ? `, typeof base.queryFn` : ``}` + const declaration = + surface === `config` + ? `const ${variable}: ${config}<${configArguments}> = ${object};` + : `const ${variable} = ${factory}${surface === `factory-explicit` ? `<${factoryArguments}>` : ``}(${object});` + const start = lines.join(`\n`).length + 1 + candidates.push({ + id, + line: lines.length + 1, + admitted: admitsHandler(result), + surface, + start, + end: start + declaration.length, + handlerStart: start + declaration.indexOf(`${handler}:`), + handlerEnd: + start + declaration.indexOf(`${handler}:`) + handler.length, + }) + lines.push(`${declaration} // handler candidate: ${id}`) + } + } + } + return { source: lines.join(`\n`), candidates } +} + +// Only compiler mechanics are cached. Every history gets a fresh consumer AST; +// all dependency diagnostics are retained. Public imports resolve to source so +// stale installed declaration files cannot make this source contract pass. +const packageRoot = resolve(process.cwd()) +const filename = resolve(packageRoot, `tests/__generated-handler-types.ts`) +const config = ts.readConfigFile( + resolve(packageRoot, `tsconfig.json`), + ts.sys.readFile, +) +if (config.error) + throw new Error( + ts.flattenDiagnosticMessageText(config.error.messageText, `\n`), + ) +const parsed = ts.parseJsonConfigFileContent(config.config, ts.sys, packageRoot) +const options: ts.CompilerOptions = { + ...parsed.options, + rootDir: resolve(packageRoot, `../..`), + noEmit: true, + noUnusedLocals: false, + noUnusedParameters: false, + paths: { + ...parsed.options.paths, + '@tanstack/db': [resolve(packageRoot, `src/index.ts`)], + '@tanstack/query-core': [ + resolve( + packageRoot, + `../query-db-collection/node_modules/@tanstack/query-core`, + ), + ], + '@tanstack/query-db-collection': [ + resolve(packageRoot, `../query-db-collection/src/index.ts`), + ], + '@tanstack/electric-db-collection': [ + resolve(packageRoot, `../electric-db-collection/src/index.ts`), + ], + }, +} +const host = ts.createCompilerHost(options) +const originalGetSource = host.getSourceFile.bind(host) +const dependencies = new Map() +let generated = `` +host.getSourceFile = (name, version, onError, fresh) => { + if (name === filename) + return ts.createSourceFile(name, generated, version, true) + let source = dependencies.get(name) + if (!source) { + source = originalGetSource(name, version, onError, fresh) + if (source) dependencies.set(name, source) + } + return source +} +let previous: ts.Program | undefined +function compileHandlerConsumer(source: string): ReadonlyArray { + generated = source + const program = ts.createProgram([filename], options, host, previous) + previous = program + return [...parsed.errors, ...ts.getPreEmitDiagnostics(program)] +} + +// A negative candidate owes a handler-result incompatibility inside its own +// declaration, anchored on its CRUD property; positive neighbors owe none. +// TypeScript may report both schema and handler overload errors for one call. Keep those distinct diagnostics, +// reject duplicates, and compare candidate membership rather than multiplicity. +// The Promise cause prevents unrelated same-code errors from substituting +// for the required rejection. The property anchor also rejects an unrelated +// Promise error inside the callback. Built diagnostics are an O22 receiver. +function checkHandlerAdmission( + candidates: ReadonlyArray, + diagnostics: ReadonlyArray, +): void { + const observed = diagnostics.map((diagnostic) => { + const line = + diagnostic.file && diagnostic.start !== undefined + ? diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start).line + + 1 + : undefined + const candidate = candidates.find((entry) => entry.line === line) + expect( + diagnostic.file?.fileName, + ts.flattenDiagnosticMessageText(diagnostic.messageText, `\n`), + ).toBe(filename) + expect( + candidate, + `unexpected diagnostic at line ${line}: TS${diagnostic.code} ${ts.flattenDiagnosticMessageText(diagnostic.messageText, ` `)}`, + ).toBeDefined() + expect(candidate!.admitted, `admitted handler ${candidate!.id}`).toBe(false) + expect(candidate!.surface === `config` ? [2322] : [2322, 2769]).toContain( + diagnostic.code, + ) + expect(diagnostic.start).toBeGreaterThanOrEqual(candidate!.start) + expect(diagnostic.start! + (diagnostic.length ?? 0)).toBeLessThanOrEqual( + candidate!.end, + ) + expect( + ts.flattenDiagnosticMessageText(diagnostic.messageText, ` `), + ).toContain(`Promise`) + return { + id: candidate!.id, + handler: + diagnostic.start === candidate!.handlerStart && + diagnostic.start + (diagnostic.length ?? 0) === candidate!.handlerEnd, + diagnostic: `${diagnostic.start}/${diagnostic.length}/${diagnostic.code}/${ts.flattenDiagnosticMessageText(diagnostic.messageText, ` `)}`, + } + }) + expect(new Set(observed.map((entry) => entry.diagnostic)).size).toBe( + observed.length, + ) + expect( + [ + ...new Set( + observed.filter((entry) => entry.handler).map((entry) => entry.id), + ), + ].sort(), + ).toEqual( + candidates + .filter((entry) => !entry.admitted) + .map((entry) => entry.id) + .sort(), + ) +} + +const states = typeShapes.flatMap((shape) => + surfaces.flatMap((surface) => + handlers.flatMap((handler) => + results.map( + (result) => + `${shape.adapter}/${shape.schema ? `schema` : `plain`}/${shape.data}/${surface}/${handler}/${result.name}`, + ), + ), + ), +) +const reach = createOracleReach(`adapter.handler-types`, states, { minimum: 6 }) +describe(`public handler result admission`, () => { + afterAll(() => { + reach.assertReached() + console.info( + `adapter.handler-types reach`, + Object.fromEntries(reach.counts()), + ) + }) + test.each(typeShapes)( + `admits only promised handler results: %j`, + (shape) => { + reach.beginHistory() + const consumer = handlerConsumer(shape) + const diagnostics = compileHandlerConsumer(consumer.source) + checkHandlerAdmission(consumer.candidates, diagnostics) + for (const candidate of consumer.candidates) + reach.reach( + `${shape.adapter}/${shape.schema ? `schema` : `plain`}/${shape.data}/${candidate.id}`, + ) + }, + 30000, + ) +}) + +// Controls alter actual compiler observations, not the expected grammar. They +// first establish that the generated neighbor is admitted/rejected correctly. +test.each([ + `missing-rejection`, + `duplicate-rejection`, + `setup-error`, + `reject-void`, + `unrelated-same-code`, + `unrelated-promise-in-body`, +] as const)( + `rejects wrong handler compiler observation: %s`, + (fault) => { + const consumer = handlerConsumer(typeShapes[0]!) + const diagnostics = [...compileHandlerConsumer(consumer.source)] + checkHandlerAdmission(consumer.candidates, diagnostics) + if (fault === `missing-rejection`) { + const line = diagnostics[0]!.file!.getLineAndCharacterOfPosition( + diagnostics[0]!.start!, + ).line + for (let i = diagnostics.length - 1; i >= 0; i--) + if ( + diagnostics[i]!.file!.getLineAndCharacterOfPosition( + diagnostics[i]!.start!, + ).line === line + ) + diagnostics.splice(i, 1) + } + if (fault === `duplicate-rejection`) diagnostics.push(diagnostics[0]!) + if (fault === `setup-error`) + diagnostics[0] = { ...diagnostics[0]!, code: 2307 } + if (fault === `unrelated-same-code`) + diagnostics[0] = { + ...diagnostics[0]!, + code: 2322, + messageText: `Type 'number' is not assignable to type 'string'.`, + } + if (fault === `unrelated-promise-in-body`) { + const candidate = consumer.candidates.find( + (entry) => + entry.line === + diagnostics[0]!.file!.getLineAndCharacterOfPosition( + diagnostics[0]!.start!, + ).line + + 1, + )! + diagnostics[0] = { + ...diagnostics[0]!, + start: candidate.handlerEnd + 3, + length: 1, + } + } + if (fault === `reject-void`) { + const admitted = consumer.candidates.find( + (candidate) => candidate.admitted, + )! + diagnostics.push({ + ...diagnostics[0]!, + start: admitted.handlerStart, + length: admitted.handlerEnd - admitted.handlerStart, + }) + } + expect(() => + checkHandlerAdmission(consumer.candidates, diagnostics), + ).toThrow() + }, + 30000, +) + +declareOracle({ + check: [checkHandlerAdmission], + model: [admitsHandler], + grammar: [handlerConsumer, typeShapes, results], +}) diff --git a/packages/db/tests/opt-in-operator-bundle.test.ts b/packages/db/tests/opt-in-operator-bundle.test.ts index e937180416..91a607a6f8 100644 --- a/packages/db/tests/opt-in-operator-bundle.test.ts +++ b/packages/db/tests/opt-in-operator-bundle.test.ts @@ -109,7 +109,6 @@ const adapterValues = [ `getLiveQueryHash`, `getLiveQueryKey`, `getLiveQueryStatusFlags`, - `getPreparedLiveQueryIdentity`, `getPreparedLiveQuerySources`, `getPublicCollection`, `getSourceObjectToken`, diff --git a/packages/db/tests/optimistic-action.test.ts b/packages/db/tests/optimistic-action.test.ts index a9c8d72fda..8bf6d9c8bf 100644 --- a/packages/db/tests/optimistic-action.test.ts +++ b/packages/db/tests/optimistic-action.test.ts @@ -54,7 +54,7 @@ describe(`createOptimisticAction`, () => { }) // Wait for the mutation to complete - await transaction.isPersisted.promise + await transaction.when(`settled`) // Verify mutationFn was called with the correct arguments expect(mutationFnMock).toHaveBeenCalledTimes(1) @@ -149,7 +149,7 @@ describe(`createOptimisticAction`, () => { expect(stripVirtualProps(collection.get(`2`))).toEqual(todoData) // Wait for the mutation to complete - await transaction.isPersisted.promise + await transaction.when(`settled`) // Verify mutationFn was called with the correct arguments expect(mutationFnMock).toHaveBeenCalledTimes(1) @@ -246,7 +246,7 @@ describe(`createOptimisticAction`, () => { // Wait for the transaction to complete (it will fail) try { - await transaction.isPersisted.promise + await transaction.when(`settled`) // Should not reach here expect(true).toBe(false) } catch (error) { diff --git a/packages/db/tests/optimistic-composition-fixture.ts b/packages/db/tests/optimistic-composition-fixture.ts index b7014920fb..ae843361ef 100644 --- a/packages/db/tests/optimistic-composition-fixture.ts +++ b/packages/db/tests/optimistic-composition-fixture.ts @@ -45,6 +45,6 @@ export function pendingTransaction() { autoCommit: false, mutationFn: () => done.promise, }) - const settled = tx.isPersisted.promise.catch((error: unknown) => error) + const settled = tx.when('settled').catch((error: unknown) => error) return { tx, done, settled } } diff --git a/packages/db/tests/optimistic-composition-oracle.test.ts b/packages/db/tests/optimistic-composition-oracle.test.ts index 40d21bc259..e80e5973a0 100644 --- a/packages/db/tests/optimistic-composition-oracle.test.ts +++ b/packages/db/tests/optimistic-composition-oracle.test.ts @@ -30,7 +30,7 @@ import type { Row } from './optimistic-composition-fixture.js' * Production path and observation: public `update`, `createTransaction`, sync * `begin`/`write`/`commit`, and `get`. The publication case also compares a * subscriber replica and a downstream live query. Each check runs at the named - * cut, after the mutation or rollback returns, or after `isPersisted` and the + * cut, after the mutation or rollback returns, or after `when('settled')` and the * held receipts settle. * * Limits: this file owns one key and simple row snapshots. Generated diff --git a/packages/db/tests/optimistic-composition.test.ts b/packages/db/tests/optimistic-composition.test.ts index 1a69a1ee6c..928c921847 100644 --- a/packages/db/tests/optimistic-composition.test.ts +++ b/packages/db/tests/optimistic-composition.test.ts @@ -27,7 +27,7 @@ describe(`optimistic snapshot ownership`, () => { const tx = fixture.collection.update(1, { optimistic }, (row) => { row.a = `failed` }) - const settled = tx.isPersisted.promise.catch(() => {}) + const settled = tx.when('settled').catch(() => {}) try { done.reject(new Error(`failed update`)) await settled @@ -62,7 +62,7 @@ describe(`optimistic snapshot ownership`, () => { const tx = fixture.collection.update(1, { optimistic }, (row) => { row.a = `accepted` }) - await tx.isPersisted.promise + await tx.when('settled') expect(fixture.collection.get(1)?.$origin).toBe(`local`) expect(stripVirtualProps(fixture.collection.get(1))).toEqual({ ...initial, @@ -91,17 +91,17 @@ describe(`optimistic snapshot ownership`, () => { const second = fixture.collection.update(1, (row) => { row.b = `rejected` }) - const secondSettled = second.isPersisted.promise.catch(() => {}) + const secondSettled = second.when('settled').catch(() => {}) try { if (completedFirst) { done[0]!.resolve() - await first.isPersisted.promise + await first.when('settled') } done[1]!.reject(new Error(`sibling failure`)) await secondSettled if (!completedFirst) { done[0]!.resolve() - await first.isPersisted.promise + await first.when('settled') } fixture.sync.begin() fixture.sync.write({ @@ -118,8 +118,8 @@ describe(`optimistic snapshot ownership`, () => { } finally { done.forEach((entry) => entry.resolve()) await Promise.allSettled([ - first.isPersisted.promise, - second.isPersisted.promise, + first.when('settled'), + second.when('settled'), ]) await fixture.collection.cleanup() } @@ -165,7 +165,7 @@ describe(`optimistic snapshot ownership`, () => { expect(applied).not.toBe(true) } done.resolve() - await tx.isPersisted.promise + await tx.when('settled') await held await applied // The optimistic row drops, and the held sync rows publish with it. @@ -227,7 +227,7 @@ describe(`optimistic snapshot ownership`, () => { const settled = fixture.collection.update(1, (row) => { row.b = `settled` }) - await settled.isPersisted.promise + await settled.when('settled') fixture.sync.begin() fixture.sync.write({ type: `insert`, value: { ...initial, id: 2 } }) expect(fixture.sync.commit()).not.toBe(true) diff --git a/packages/db/tests/optimistic-history-oracle.ts b/packages/db/tests/optimistic-history-oracle.ts index e0c98e7c65..68c2413633 100644 --- a/packages/db/tests/optimistic-history-oracle.ts +++ b/packages/db/tests/optimistic-history-oracle.ts @@ -44,7 +44,7 @@ import type { CollectionConfig, SyncConfig } from '../src/types.js' * settlement, even if a peer supplied it. Every same-key write in that atomic * transaction keeps that attribution. A later transaction is `'remote'` * without another local owner. A failed mutation gives queued source writes no - * local attribution. `isPersisted` settles after that publication. + * local attribution. `when('settled')` settles after that publication. * A truncate applies at once, with every queued sync transaction before it. * Its same-key source row can receive local attribution while a mutation still * persists, even if that mutation later fails. Still-persisting transactions @@ -74,11 +74,17 @@ import type { CollectionConfig, SyncConfig } from '../src/types.js' * writes are `'remote'` unless another active local transaction touches their * key; if it aborts, its writes never apply, and both its receipt and its acceptance * moment (`whenSyncAccepted`) reject. + * External completion, used when work was applied outside commit, admits only + * pending transactions. A completion attempt in persisting, completed or failed + * state rejects and preserves the state, settlement verdict and visible rows. + * Authority: commit admission and terminal request outcomes in the public + * transaction contract; the internal completion path cannot revoke them. + * * A manual local transaction can remain pending before its mutation function * starts. Source writes on its key apply at once and receive local attribution; * rolling it back exposes that source row with the same origin. A pending * manual transaction starts its mutation function at commit. If that function - * rejects, the transaction rolls back and `isPersisted` rejects with its + * rejects, the transaction rolls back and `when('settled')` rejects with its * reason. A later edit cannot determine the earlier manual request's outcome. * The model keeps pending distinct from persisting because the same source * commit publishes immediately in the former state and waits in the latter. @@ -125,7 +131,7 @@ import type { CollectionConfig, SyncConfig } from '../src/types.js' * each optimistic mutation call, before any await, the driver checks that the * row is already visible. After every step it compares rows, * metadata, immutable handler payloads, promise outcomes, the rows visible - * when `isPersisted` fulfills, downstream query state, and complete + * when the settlement waiter fulfills, downstream query state, and complete * publication cuts. A second subscriber starts without initial state, so no * sent-key filter can hide an invalid message. Each of its batches must be * valid for its replica: an insert names an absent key, and an update or @@ -188,6 +194,8 @@ export type OptimisticStep = cascade: boolean failure?: `rollback` | `reject` } + // External completion never runs a handler and admits only pending work. + | { type: `complete`; slot: number } | SourceBatch // A sync transaction that begins and writes now but commits or aborts at its // `close` step. While it is open, other sync batches are skipped, because the @@ -221,7 +229,6 @@ type ModelTransaction = { type ObservedRow = HistoryRow & { $origin: `local` | `remote` $hasPendingWrites: boolean - $synced: boolean } /** Pure event-history model. It never reads production caches or callbacks. */ @@ -286,7 +293,6 @@ class HistoryModel { ...row, $origin: this.origins.get(key)!, $hasPendingWrites: false, - $synced: true, }, ]), ) @@ -306,7 +312,6 @@ class HistoryModel { ...transaction.row, $origin: `local`, $hasPendingWrites: true, - $synced: false, }) } return result @@ -512,7 +517,6 @@ const observed = ( row: HistoryRow & { $origin: `local` | `remote` $hasPendingWrites: boolean - $synced: boolean }, ): ObservedRow => ({ ...Object.fromEntries( @@ -526,7 +530,6 @@ const observed = ( c: row.c, $origin: row.$origin, $hasPendingWrites: row.$hasPendingWrites, - $synced: row.$synced, }) const sorted = (rows: Iterable) => [...rows].sort((a, b) => a.id - b.id) @@ -970,7 +973,7 @@ export async function runOptimisticHistory( | ReturnType> done: ReturnType> outcome: ReturnType> - // Rows visible when `isPersisted` fulfilled or rejected. + // Rows visible when `when('settled')` fulfilled or rejected. settledRows?: Array expected: HistoryOutcome changes: object @@ -985,6 +988,8 @@ export async function runOptimisticHistory( edits: 0, deletes: 0, settlements: 0, + completionAttempts: 0, + completionRejections: 0, replacements: 0, queued: 0, failures: 0, @@ -1308,6 +1313,10 @@ export async function runOptimisticHistory( ? `fulfilled` : `rejected`, ) + expect( + operation.tx.state, + `${label}: transaction state ${index}`, + ).toBe(model.transactions[index]!.state) expect( operation.tx.mutations[0]!.modified, `${label}: immutable request ${index}`, @@ -1451,7 +1460,7 @@ export async function runOptimisticHistory( tx, done, outcome: observeHistoryPromise( - tx.isPersisted.promise.finally(() => { + tx.when(`settled`).finally(() => { operation.settledRows = sorted( [...collection.values()].map(observed), ) @@ -1487,6 +1496,30 @@ export async function runOptimisticHistory( expect(handlerBatch, `handler wrote its source batch`).toBeUndefined() counts.edits++ if (step.type === `delete`) counts.deletes++ + } else if (step.type === `complete`) { + if (!operations.length) continue + const index = step.slot % operations.length + const op = operations[index]! + const admitted = model.transactions[index]!.state === `pending` + counts.completionAttempts++ + if (admitted) { + model.settle(index, true, false) + cuts = [sorted(model.visible().values())] + op.expected = { status: `fulfilled`, value: op.tx } + op.tx.completeWithoutCommit() + op.tx.touchCollection() + await op.outcome.settled + expect( + op.settledRows, + `${position}: rows visible when external completion settled`, + ).toEqual(cuts[0]) + } else { + counts.completionRejections++ + expect( + () => op.tx.completeWithoutCommit(), + `external completion rejects ${model.transactions[index]!.state}`, + ).toThrow() + } } else if (step.type === `settle`) { const active = model.transactions.flatMap((entry, index) => entry.state === `persisting` || entry.state === `pending` @@ -1528,7 +1561,7 @@ export async function runOptimisticHistory( await Promise.resolve() expect( op.settledRows, - `${position}: rows visible when isPersisted settled`, + `${position}: rows visible when settlement completed`, ).toEqual(cuts[0]) counts.settlements++ if (!step.success) counts.failures++ diff --git a/packages/db/tests/optimistic-history-outcomes-oracle.test.ts b/packages/db/tests/optimistic-history-outcomes-oracle.test.ts index bbf329efd3..ed713a6b51 100644 --- a/packages/db/tests/optimistic-history-outcomes-oracle.test.ts +++ b/packages/db/tests/optimistic-history-outcomes-oracle.test.ts @@ -3,6 +3,7 @@ import fc from 'fast-check' import { createDeferred } from '../src/deferred.js' import { oraclePropertyOptions, oracleRuns } from './oracle-config.js' import { declareOracle } from './oracle-declaration.js' +import { createOracleReach } from './oracle-reach.js' import { expectHistoryOutcome, observeHistoryPromise, @@ -274,3 +275,121 @@ describe(`Outcome observer calibration`, () => { } }) }) + +/** External completion shares the primary optimistic model. The finite grammar + * crosses each admission state with independent pending/persisting peers and + * source transactions on the target key, another key, or a truncate. These + * histories distinguish rejecting an inadmissible completion from preserving + * the peer, queued source work and the later usable Collection. + * + * State, immutable requests, rows, metadata, public callbacks, downstream rows + * and request verdicts are compared by runOptimisticHistory after every action. + * Only pending target work admits its first completion; all repeated or other + * state attempts reject. A same-key persisting peer holds accepted source work + * through the admitted completion, so fulfillment alone cannot prove publication. + */ +const completionStates = [ + `pending`, + `persisting`, + `completed`, + `failed`, +] as const +const completionPeers = [ + `none`, + `pending-same`, + `pending-other`, + `persisting-same`, + `persisting-other`, +] as const +const completionSources = [`none`, `same`, `other`, `truncate`] as const +const completionHistories = completionStates.flatMap((state) => + completionPeers.flatMap((peer) => + (peer.endsWith(`other`) + ? [...completionSources, `peer` as const] + : completionSources + ).flatMap((source) => + [1, 2, 3].map((attempts) => ({ state, peer, source, attempts })), + ), + ), +) + +describe(`external completion with peers and source work`, () => { + it.each(completionHistories)( + `preserves completion admission and independent work: %j`, + async ({ state, peer, source, attempts }) => { + const reach = createOracleReach( + `external completion ${state}/${peer}/${source}/${attempts}`, + [`attempts-compared`, `later-source-compared`], + ) + reach.beginHistory() + const steps: Array = [ + { + type: `edit`, + key: 1, + fields: { a: 1 }, + optimistic: true, + pending: state === `pending`, + }, + ] + if (peer !== `none`) + steps.push({ + type: `edit`, + key: peer.endsWith(`same`) ? 1 : 2, + fields: { b: 2 }, + optimistic: true, + pending: peer.startsWith(`pending`), + }) + if (state === `completed` || state === `failed`) + steps.push({ + type: `settle`, + slot: 0, + success: state === `completed`, + cascade: false, + }) + if (source !== `none`) + steps.push({ + type: `sync`, + rows: [ + { + id: source === `other` ? 3 : source === `peer` ? 2 : 1, + a: 7, + b: 8, + c: 9, + }, + ], + truncate: source === `truncate`, + copies: 1, + }) + for (let i = 0; i < attempts; i++) + steps.push({ type: `complete`, slot: 0 }) + // Drain each still-active request by its own explicit success. Target + // and peer are independently held; an accepted completion removes only + // the pending target from this active ordering. + if (state === `persisting`) + steps.push({ type: `settle`, slot: 0, success: true, cascade: false }) + if (peer !== `none`) + steps.push({ type: `settle`, slot: 0, success: true, cascade: false }) + steps.push({ + type: `sync`, + rows: [ + { id: 1, a: 11, b: 12, c: 13 }, + { id: 2, a: 21, b: 22, c: 23 }, + ], + truncate: false, + copies: 1, + }) + const counts = await runOptimisticHistory( + [{ id: 1, a: 0, b: 0, c: 0 }], + steps, + ) + expect(counts.edits).toBe(peer === `none` ? 1 : 2) + expect(counts.completionAttempts).toBe(attempts) + expect(counts.completionRejections).toBe( + attempts - (state === `pending` ? 1 : 0), + ) + reach.reach(`attempts-compared`) + reach.reach(`later-source-compared`) + reach.assertReached() + }, + ) +}) diff --git a/packages/db/tests/optimistic-transaction-oracle.property.test.ts b/packages/db/tests/optimistic-transaction-oracle.property.test.ts index 96e9c90cdc..b3536770b1 100644 --- a/packages/db/tests/optimistic-transaction-oracle.property.test.ts +++ b/packages/db/tests/optimistic-transaction-oracle.property.test.ts @@ -153,7 +153,7 @@ async function runSameKey( return gate.promise }, }) - const persisted = observeHistoryPromise(tx.isPersisted.promise) + const persisted = observeHistoryPromise(tx.when(`settled`)) let committed: ReturnType | undefined await withHistoryCleanup( async () => { @@ -360,7 +360,7 @@ async function runDeleteInsertReplacement( return Promise.resolve() }, }) - const settlement = transaction.isPersisted.promise.catch(() => undefined) + const settlement = transaction.when(`settled`).catch(() => undefined) return withHistoryCleanup( async () => { @@ -611,13 +611,7 @@ describe(`Delete then insert transaction laws`, () => { // Observe user fields without discarding unexpected fields or undefined keys. function userRow(value: T): T { const copy = { ...value } as Record - for (const field of [ - `$hasPendingWrites`, - `$synced`, - `$origin`, - `$key`, - `$collectionId`, - ]) + for (const field of [`$hasPendingWrites`, `$origin`, `$key`, `$collectionId`]) delete copy[field] return copy as T } @@ -691,7 +685,7 @@ async function runTransaction(scenario: Scenario, fault?: ObservationFault) { return gate.promise }, }) - const persisted = observeHistoryPromise(tx.isPersisted.promise) + const persisted = observeHistoryPromise(tx.when(`settled`)) const settled = observeHistoryPromise(tx.when(`settled`)) let committed: ReturnType> | undefined let subscription: ReturnType | undefined @@ -720,10 +714,6 @@ async function runTransaction(scenario: Scenario, fault?: ObservationFault) { row.$hasPendingWrites, `${label}: ${surface} row ${row.id} pending local write`, ).toBe(expectedPending) - expect( - row.$hasPendingWrites, - `${label}: ${surface} row ${row.id} legacy inverse`, - ).toBe(!row.$synced) } } const read = ordered(source.values()) @@ -1116,7 +1106,7 @@ for (const { numberKey, reversed } of [0, -1, 1, 1.5].flatMap((key) => return Promise.resolve() }, }) - const persisted = observeHistoryPromise(tx.isPersisted.promise) + const persisted = observeHistoryPromise(tx.when(`settled`)) await withHistoryCleanup( async () => { await source.preload() diff --git a/packages/db/tests/optimistic-transaction.test.ts b/packages/db/tests/optimistic-transaction.test.ts index 940884af7a..08861fb96a 100644 --- a/packages/db/tests/optimistic-transaction.test.ts +++ b/packages/db/tests/optimistic-transaction.test.ts @@ -428,10 +428,10 @@ describe(`Delete then insert transaction laws`, () => { return Promise.resolve() }, }) - const overlaySettlement = overlay.isPersisted.promise.catch(() => undefined) - const replacementSettlement = replacement.isPersisted.promise.catch( - () => undefined, - ) + const overlaySettlement = overlay.when('settled').catch(() => undefined) + const replacementSettlement = replacement + .when('settled') + .catch(() => undefined) await withHistoryCleanup( async () => { @@ -506,12 +506,12 @@ describe(`Delete then insert transaction laws`, () => { return Promise.resolve() }, }) - const insertSettlement = pendingInsert.isPersisted.promise.catch( - () => undefined, - ) - const replacementSettlement = replacement.isPersisted.promise.catch( - () => undefined, - ) + const insertSettlement = pendingInsert + .when('settled') + .catch(() => undefined) + const replacementSettlement = replacement + .when('settled') + .catch(() => undefined) await withHistoryCleanup( async () => { diff --git a/packages/db/tests/oracle-config.ts b/packages/db/tests/oracle-config.ts index 8025a8e987..ca9c1a8927 100644 --- a/packages/db/tests/oracle-config.ts +++ b/packages/db/tests/oracle-config.ts @@ -8,6 +8,8 @@ const staticOracleProperties = [ `lint.part-reach`, `scripts.lint-scope`, `scripts.run-budget`, + `scripts.release-version`, + `scripts.release-seed-failure`, `react.ssr-ordinary`, `react.ssr-infinite`, `react.ssr-stream-cut`, @@ -142,6 +144,7 @@ const staticOracleProperties = [ `sqlite-persistence.optimistic-history`, `trailbase.lifecycle`, `source-health.history`, + `source-health.required-history`, `source-health.on-demand`, `source-health.lazy-demand`, `source-health.request-errors`, @@ -160,6 +163,7 @@ const staticOracleProperties = [ `sqlite-resume.managed-claim-read`, `sqlite-resume.retired-storage`, `persisted-demand-coverage.history`, + `persisted-demand-coverage.required`, `sqlite-reload.class`, `browser.multi-tab-reserved-history`, `sqlite-reload.class.electron`, diff --git a/packages/db/tests/oracle-replay-manifest.ts b/packages/db/tests/oracle-replay-manifest.ts index 48390ef814..5de3ae76b1 100644 --- a/packages/db/tests/oracle-replay-manifest.ts +++ b/packages/db/tests/oracle-replay-manifest.ts @@ -20,6 +20,11 @@ const ownerGroups: ReadonlyArray< ], [`../scripts/run-budget-oracle.property.test.js`, `scripts`, `run-budget`], [`../scripts/lint-scope-oracle.property.test.js`, `scripts`, `lint-scope`], + [ + `../scripts/release-history-oracle.property.test.js`, + `scripts`, + `release-version release-seed-failure`, + ], [ `db/tests/ssr-stream-record-wait-oracle.test.ts`, `ssr`, @@ -195,7 +200,7 @@ const ownerGroups: ReadonlyArray< [ `db/tests/source-health-oracle.test.ts`, `source-health`, - `history on-demand lazy-demand request-errors`, + `history required-history on-demand lazy-demand request-errors`, ], [ `db/tests/collection-subscription-lifecycle-oracle.test.ts`, @@ -390,7 +395,7 @@ const ownerGroups: ReadonlyArray< [ `db-sqlite-persistence-core/tests/persisted-demand-coverage-campaign-oracle.test.ts`, `persisted-demand-coverage`, - `history`, + `history required`, ], [ `trailbase-db-collection/tests/lifecycle-oracle.property.test.ts`, @@ -719,6 +724,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([ + [ + `source-health.required-history`, + `required histories refine the model.*random`, + ], [ `collection-lifecycle.gc-mutation-shapes`, `retains every touched Collection across mutation shapes.*seed undefined`, diff --git a/packages/db/tests/oracle-replay-witness.ts b/packages/db/tests/oracle-replay-witness.ts index 25a8a00a76..a6727ecc76 100644 --- a/packages/db/tests/oracle-replay-witness.ts +++ b/packages/db/tests/oracle-replay-witness.ts @@ -57,6 +57,23 @@ export function recordOracleReplay( if (channel !== undefined) appendWitness(channel, property, details) } +/** A completed finite-history comparison has no fast-check RunDetails. */ +export function recordOracleFiniteReplay( + property: string, + { seed, path, failed }: { seed: number; path: string; failed: boolean }, +): void { + const channel = witnessChannel(property, seed, path) + if (channel === undefined) return + const witness: OracleReplayWitness = { + property, + seed, + path, + numRuns: 1, + failed, + } + appendFileSync(channel, `${JSON.stringify(witness)}\n`) +} + /** * Attach a witness only when the guarded runner's property, seed, and path all * match. Constructing fast-check options is not evidence that a property ran. diff --git a/packages/db/tests/paced-mutations-oracle.test.ts b/packages/db/tests/paced-mutations-oracle.test.ts index b49d2318ab..a36267df89 100644 --- a/packages/db/tests/paced-mutations-oracle.test.ts +++ b/packages/db/tests/paced-mutations-oracle.test.ts @@ -261,7 +261,7 @@ async function runProduction( returnedSame: undefined as boolean | undefined, } persistence.set(transaction, observation) - void transaction.isPersisted.promise.then( + void transaction.when(`settled`).then( (resolved) => { observation.outcome = `fulfilled` observation.returnedSame = resolved === transaction diff --git a/packages/db/tests/paced-mutations-scenarios.test.ts b/packages/db/tests/paced-mutations-scenarios.test.ts index 2b46d28ac2..07e18b6508 100644 --- a/packages/db/tests/paced-mutations-scenarios.test.ts +++ b/packages/db/tests/paced-mutations-scenarios.test.ts @@ -678,7 +678,7 @@ describe(`paced mutation timeline oracle`, () => { await vi.advanceTimersByTimeAsync(0) expect(started).toEqual([1, 2, 3]) for (const transaction of [first, second, third]) { - expect(await transaction.isPersisted.promise).toBe(transaction) + expect(await transaction.when('settled')).toBe(transaction) } }, async () => { @@ -1968,7 +1968,7 @@ for (const admission of [`admitted`, `dropped`, `queued`] as const) const transaction = create.mock.results.at(-1)?.value as Transaction<{ id: number }> | undefined if (transaction?.state === `pending`) { - void transaction.isPersisted.promise.catch(() => {}) + void transaction.when('settled').catch(() => {}) transaction.rollback({ isSecondaryRollback: true }) } create.mockRestore() diff --git a/packages/db/tests/paced-mutations-support.ts b/packages/db/tests/paced-mutations-support.ts index 1339320a90..6db1cf9912 100644 --- a/packages/db/tests/paced-mutations-support.ts +++ b/packages/db/tests/paced-mutations-support.ts @@ -83,7 +83,7 @@ export function observeReceipt(transaction: Transaction) { returnedSame?: boolean error?: unknown } = { outcome: `pending` } - void transaction.isPersisted.promise.then( + void transaction.when('settled').then( (resolved) => { receipt.outcome = `fulfilled` receipt.returnedSame = resolved === transaction diff --git a/packages/db/tests/paced-mutations.test.ts b/packages/db/tests/paced-mutations.test.ts index 83d1692066..47a05efe3a 100644 --- a/packages/db/tests/paced-mutations.test.ts +++ b/packages/db/tests/paced-mutations.test.ts @@ -260,7 +260,7 @@ describe(`createPacedMutations`, () => { // Now trailing edge should have executed expect(mutationFn).toHaveBeenCalledTimes(1) - await tx1.isPersisted.promise + await tx1.when(`settled`) expect(tx1.state).toBe(`completed`) }) }) @@ -299,7 +299,7 @@ describe(`createPacedMutations`, () => { expect(mutationFn).toHaveBeenCalledTimes(1) // Wait for first transaction to complete - await tx1.isPersisted.promise + await tx1.when(`settled`) expect(tx1.state).toBe(`completed`) // Each mutation should be in its own transaction @@ -348,9 +348,9 @@ describe(`createPacedMutations`, () => { // Wait for all to complete await Promise.all([ - tx1.isPersisted.promise, - tx2.isPersisted.promise, - tx3.isPersisted.promise, + tx1.when(`settled`), + tx2.when(`settled`), + tx3.when(`settled`), ]) // Should have executed in order @@ -390,9 +390,9 @@ describe(`createPacedMutations`, () => { expect(tx1).not.toBe(tx3) await Promise.all([ - tx1.isPersisted.promise, - tx2.isPersisted.promise, - tx3.isPersisted.promise, + tx1.when(`settled`), + tx2.when(`settled`), + tx3.when(`settled`), ]) // All mutations should have been executed @@ -427,7 +427,7 @@ describe(`createPacedMutations`, () => { const tx2 = mutateExplicitZero({ id: 2, value: 2 }) // Should still process sequentially even with zero wait - await Promise.all([tx1.isPersisted.promise, tx2.isPersisted.promise]) + await Promise.all([tx1.when(`settled`), tx2.when(`settled`)]) expect(mutationFn).toHaveBeenCalledTimes(2) expect(tx1.state).toBe(`completed`) @@ -447,7 +447,7 @@ describe(`createPacedMutations`, () => { const tx3 = mutateNoWait({ id: 3, value: 3 }) const tx4 = mutateNoWait({ id: 4, value: 4 }) - await Promise.all([tx3.isPersisted.promise, tx4.isPersisted.promise]) + await Promise.all([tx3.when(`settled`), tx4.when(`settled`)]) expect(mutationFn).toHaveBeenCalledTimes(2) expect(tx3.state).toBe(`completed`) @@ -482,7 +482,7 @@ describe(`createPacedMutations`, () => { // Transaction should be in failed state expect(tx.state).toBe(`failed`) - await expect(tx.isPersisted.promise).rejects.toThrow(`Mutation failed`) + await expect(tx.when(`settled`)).rejects.toThrow(`Mutation failed`) }) it(`should continue processing queue after an error`, async () => { @@ -511,9 +511,9 @@ describe(`createPacedMutations`, () => { // Wait for all to settle await Promise.allSettled([ - tx1.isPersisted.promise, - tx2.isPersisted.promise, - tx3.isPersisted.promise, + tx1.when(`settled`), + tx2.when(`settled`), + tx3.when(`settled`), ]) // First should fail, rest should succeed diff --git a/packages/db/tests/production-error-messages-oracle.test.ts b/packages/db/tests/production-error-messages-oracle.test.ts index 03424c1a4e..f44e6e3965 100644 --- a/packages/db/tests/production-error-messages-oracle.test.ts +++ b/packages/db/tests/production-error-messages-oracle.test.ts @@ -23,8 +23,11 @@ * - **Both:** the class, its `name`, `instanceof`, and its extra fields are the * same. The switch is an inline `process.env.NODE_ENV` check, so a production * bundler drops the full text; a separate bundle check owns that. - * - **Docs:** `docs/errors.md` has a heading for every code that names its - * class. + * - **Docs:** the freshly generated errors page has a heading for every code + * that names its class, and an HTML ID matching its production-message link. + * Source PRs judge the actual generator's stdout. + * The release docs pipeline owns tracked generated-page freshness, following + * the maintainer's decision to keep generated docs out of source PRs. * * - **Error sites:** code outside `errors.ts` also throws plain `Error`, * `TypeError`, and `RangeError` values with library text. Each such site @@ -35,6 +38,8 @@ * expressions in the same scope give the same message for every input. * Descriptor error 113 follows the same missing-client condition from source * construction to query consumption; its new template is framework-neutral. + * Cache conflict 234 also names getKey/schema options after the maintainer's + * decision to reject incompatible local row behavior under one query identity. * Every interpolated expression whose values production can show, or one of * its sub-expressions, is passed to the code line. Plain objects, such as * rows, never reach it (maintainer decision, 2026-10-07). `AggregateError` messages count too. A message built only @@ -67,9 +72,11 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import * as Errors from '../src/index' import { codedWarning } from '../src/error-message' import { declareOracle } from './oracle-declaration.js' +import { createOracleReach } from './oracle-reach.js' import { errorSampleArguments } from './error-sample-arguments' import { exportedErrorClasses } from './production-error-messages-fixture' import { findErrorSites } from './error-sites-oracle' +import { withErrorDocsFixture } from './generate-error-docs-fixture' const testsDirectory = dirname(fileURLToPath(import.meta.url)) const devMessages: Record> = JSON.parse( @@ -81,7 +88,6 @@ const developmentOnlyPath = resolve( testsDirectory, `fixtures/development-only-messages.json`, ) -const docsPath = resolve(testsDirectory, `../../../docs/errors.md`) const docsUrl = `https://tanstack.com/db/latest/docs/errors` /** The production line, as the decision states it. */ @@ -301,14 +307,27 @@ function expectUnencodableValuesShown(): void { } // The check: the production outputs match the expected record for this claim. -function expectCodesDocumented(): void { +function expectCodesDocumented(docs: string): void { const codes: Record = JSON.parse( readFileSync(codesPath, `utf8`), ) - const docs = readFileSync(docsPath, `utf8`) + // The link names an HTML ID, not one spelling of an anchor tag. Parsing + // admits valid quote, attribute-spacing and element choices. + // The actual generator puts literal messages in canonical ```text fences. + // Their contents render as code, so they cannot supply link targets. This + // bounded extraction covers those fences, not arbitrary Markdown layouts. + const html = docs.replace(/^```text\r?\n[\s\S]*?^```(?:\r?\n|$)/gm, ``) + const ids = new Set( + [ + ...new DOMParser() + .parseFromString(html, `text/html`) + .querySelectorAll(`[id]`), + ].map((element) => element.id), + ) for (const [name, code] of Object.entries(codes)) { const heading = new RegExp(`^## Error ${code}\\b.*${name}`, `m`) expect(docs, `${name} (${code})`).toMatch(heading) + expect(ids, `${name} (${code}) anchor`).toContain(`error-${code}`) } const sites: Record = JSON.parse( readFileSync(sitesPath, `utf8`), @@ -322,7 +341,45 @@ function expectCodesDocumented(): void { } } +// Checker calibration supplies only the catalog facts the law requires. +// This forged observation has no prose/examples/layout from the renderer and +// supplies no production evidence. Its validity is independent of CLI stdout. +function catalogObservation(): string { + const codes: Record = JSON.parse( + readFileSync(codesPath, `utf8`), + ) + const sites: Record = JSON.parse( + readFileSync(sitesPath, `utf8`), + ) + return [ + ...Object.entries(codes).flatMap(([name, code]) => [ + ``, + `## Error ${code}: ${name}`, + ]), + ...Object.entries(sites).flatMap(([code, { file, kind }]) => [ + ``, + `## ${kind === `warning` ? `Warning` : `Error`} ${code}: ${file}`, + ]), + ].join(`\n`) +} + +// This receiving driver copies the actual CLI and full frozen catalog into a +// disposable repository, then judges its real stdout. Even a broken stdout +// mode cannot write the active checkout's generated page. +function renderErrorDocs(): string { + return withErrorDocsFixture((fixture) => { + const result = fixture.run(`--stdout`) + if (result.error) throw result.error + if (result.status !== 0) + throw new Error( + `Error docs CLI failed (${result.status}):\n${result.stderr}`, + ) + return result.stdout + }) +} + declareOracle({ + model: [catalogObservation], check: [ expectEveryClassCoded, expectFullTextInProduction, @@ -518,7 +575,70 @@ describe(`production error messages`, () => { expectUnencodableValuesShown() }) + it.each([`heading`, `class-anchor`, `site-anchor`] as const)( + `rejects a missing generated %s`, + (part) => { + const docs = catalogObservation() + expectCodesDocumented(docs) + let wrong: string + if (part === `heading` || part === `class-anchor`) { + const codes: Record = JSON.parse( + readFileSync(codesPath, `utf8`), + ) + const code = Object.values(codes)[0]! + if (part === `class-anchor`) + expectCodesDocumented( + docs.replace( + ``, + ``, + ), + ) + wrong = + part === `heading` + ? docs.replace(new RegExp(`^## Error ${code}\\b.*$`, `m`), ``) + : docs.replace(``, ``) + } else { + const sites: Record = JSON.parse( + readFileSync(sitesPath, `utf8`), + ) + const [code, { kind = `error` }] = Object.entries(sites)[0]! + wrong = docs.replace(``, ``) + } + expect(() => expectCodesDocumented(wrong)).toThrow() + }, + ) + + it(`rejects class IDs inside canonical fenced message examples`, () => { + const docs = catalogObservation() + const codes: Record = JSON.parse( + readFileSync(codesPath, `utf8`), + ) + const code = Object.values(codes)[0]! + const anchor = `` + const wrong = docs.replace(anchor, `\`\`\`text\n${anchor}\n\`\`\`\n`) + expectCodesDocumented(docs) + expect(() => expectCodesDocumented(wrong)).toThrow() + }) + it(`documents every code with its class`, () => { - expectCodesDocumented() + const reach = createOracleReach( + `production-errors.generated-docs`, + [`cli-stdout-received`, `catalog-compared`], + { replaying: false }, + ) + reach.beginHistory() + try { + const docs = renderErrorDocs() + reach.reach(`cli-stdout-received`) + expectCodesDocumented(docs) + reach.reach(`catalog-compared`) + reach.assertReached() + } finally { + if (process.env.TANSTACK_DB_ORACLE_STATISTICS === `1`) + console.info( + `production-errors.generated-docs reach`, + Object.fromEntries(reach.counts()), + ) + } }) }) diff --git a/packages/db/tests/proxy-detachment-contract-oracle.test.ts b/packages/db/tests/proxy-detachment-contract-oracle.test.ts index 505bb09b5f..8b72f57dfc 100644 --- a/packages/db/tests/proxy-detachment-contract-oracle.test.ts +++ b/packages/db/tests/proxy-detachment-contract-oracle.test.ts @@ -99,7 +99,7 @@ describe(`Mutation result detachment`, () => { const collection = storedDataRow(make(), identitySchema) try { const tx = collection.update(1, run) - await tx.isPersisted.promise + await tx.when(`settled`) expectOwnDataProperty(collection.get(1)!, key, { value: 2 }) } finally { await collection.cleanup() @@ -150,7 +150,7 @@ describe(`Mutation result detachment`, () => { const collection = storedDataRow(make(), identitySchema) try { const tx = collection.update(1, run) - await tx.isPersisted.promise + await tx.when(`settled`) const saved = collection.get(1)! expect(saved.read).toBe(expected.read) // Collection patches represent deletion with an own undefined. @@ -225,10 +225,10 @@ describe(`Mutation result detachment`, () => { draft.link = cycleContainer(kind, draft) if (edit === `after`) draft.count = 1 }) - settled = Promise.allSettled([tx.isPersisted.promise]) + settled = Promise.allSettled([tx.when(`settled`)]) expectCycleSnapshot(collection.get(1)!, kind) expect(original).toEqual({ id: 1, count: 0 }) - await tx.isPersisted.promise + await tx.when(`settled`) await settled expectCycleSnapshot(collection.get(1)!, kind) expect(original).toEqual({ id: 1, count: 0 }) diff --git a/packages/db/tests/proxy-detachment-contract.test.ts b/packages/db/tests/proxy-detachment-contract.test.ts index 4cbcc406d2..11429067df 100644 --- a/packages/db/tests/proxy-detachment-contract.test.ts +++ b/packages/db/tests/proxy-detachment-contract.test.ts @@ -54,7 +54,7 @@ describe(`Mutation result detachment`, () => { const collection = storedRow(make()) try { const tx = collection.update(1, run) - await tx.isPersisted.promise + await tx.when('settled') expect(collection.get(1)!.child).toStrictEqual(expected.child) expect(collection.get(1)!.alias).toStrictEqual(expected.alias) } finally { @@ -101,7 +101,7 @@ describe(`Mutation result detachment`, () => { const tx = collection.update(1, (draft) => { for (const value of values) draft.value = makeValue(value) }) - await tx.isPersisted.promise + await tx.when('settled') expect(observe(collection.get(1)!.value)).toStrictEqual( observe(expected.value), ) @@ -168,7 +168,7 @@ describe(`Mutation result detachment`, () => { const collection = storedRow(make()) try { const tx = collection.update(1, reorder) - await tx.isPersisted.promise + await tx.when('settled') expect(entries(collection.get(1)!.value)).toEqual( entries(expected.value), ) @@ -215,7 +215,7 @@ describe(`Mutation result detachment`, () => { expect(collection.get(1)!.value).toBe(label) label.rename(`after`) expect((collection.get(1)!.value as Label).read()).toBe(`after`) - await tx.isPersisted.promise + await tx.when('settled') } finally { await collection.cleanup() } @@ -259,7 +259,7 @@ describe(`Mutation result detachment`, () => { else if (value instanceof Label) expect((saved as Label).read()).toBe(`saved`) else expect(saved).toEqual(value) - await tx.isPersisted.promise + await tx.when('settled') } finally { await collection.cleanup() } @@ -295,7 +295,7 @@ describe(`Mutation result detachment`, () => { }) value.pathname = `/after` expect((collection.get(1)!.value as URL).pathname).toBe(`/before`) - await tx.isPersisted.promise + await tx.when('settled') } finally { await collection.cleanup() } @@ -338,7 +338,7 @@ describe(`Mutation result detachment`, () => { ? back.s.values().next().value!.back : back.arr[0]!.owner expect(cycle).toBe(back) - await tx.isPersisted.promise + await tx.when('settled') } finally { await collection.cleanup() } diff --git a/packages/db/tests/proxy-iteration-contract-oracle.test.ts b/packages/db/tests/proxy-iteration-contract-oracle.test.ts index 82cb48c749..7e9d98a8af 100644 --- a/packages/db/tests/proxy-iteration-contract-oracle.test.ts +++ b/packages/db/tests/proxy-iteration-contract-oracle.test.ts @@ -272,7 +272,7 @@ it.each( expected, ) expectMembers(collection.get(1)!.values, expected) - await tx.isPersisted.promise + await tx.when(`settled`) expectMembers(collection.get(1)!.values, expected) } finally { await collection.cleanup() diff --git a/packages/db/tests/proxy-iteration-contract.test.ts b/packages/db/tests/proxy-iteration-contract.test.ts index a794726615..bf12ac495a 100644 --- a/packages/db/tests/proxy-iteration-contract.test.ts +++ b/packages/db/tests/proxy-iteration-contract.test.ts @@ -387,7 +387,7 @@ it.each([`Map`, `Set`] as const)( expect([...collection.get(1)!.values.values()]).toEqual([{ x: 2 }]) item.x = 3 expect([...collection.get(1)!.values.values()]).toEqual([{ x: 2 }]) - await tx.isPersisted.promise + await tx.when('settled') } finally { await collection.cleanup() } diff --git a/packages/db/tests/proxy-oracle.test.ts b/packages/db/tests/proxy-oracle.test.ts index c82ef54125..27d4f27593 100644 --- a/packages/db/tests/proxy-oracle.test.ts +++ b/packages/db/tests/proxy-oracle.test.ts @@ -239,7 +239,7 @@ describe(`native array callback oracle`, () => { }) try { const tx = collection.update(1, run) - await tx.isPersisted.promise + await tx.when(`settled`) const saved = collection.get(1)! // This oracle covers row data, not collection-owned virtual fields. expect({ id: saved.id, items: saved.items }).toStrictEqual(expected) diff --git a/packages/db/tests/query/compiler/group-by-pipeline.test.ts b/packages/db/tests/query/compiler/group-by-pipeline.test.ts index 03abcd6fc8..2ebd9d0c3f 100644 --- a/packages/db/tests/query/compiler/group-by-pipeline.test.ts +++ b/packages/db/tests/query/compiler/group-by-pipeline.test.ts @@ -37,32 +37,23 @@ const cases = [false, true].flatMap((grouped) => ) describe(`group-by production pipeline`, () => { - // Current Collections publish both fields. These relation rows also cover - // the new-field-only input expected after the deprecated alias is removed. + // Group metadata reports a pending write when any contributing row has one. test.each([ { - name: `new-field-only pending row`, - rows: [{ key: `first`, pending: true, shape: `new` }], + name: `pending row`, + rows: [{ key: `first`, pending: true }], }, { - name: `new-field-only settled row`, - rows: [{ key: `first`, pending: false, shape: `new` }], + name: `settled row`, + rows: [{ key: `first`, pending: false }], }, { - name: `legacy pending row`, - rows: [{ key: `first`, pending: true, shape: `legacy` }], - }, - { - name: `mixed new and legacy rows`, + name: `mixed pending and settled rows`, rows: [ - { key: `first`, pending: true, shape: `new` }, - { key: `second`, pending: false, shape: `legacy` }, + { key: `first`, pending: true }, + { key: `second`, pending: false }, ], }, - { - name: `current rows with both fields`, - rows: [{ key: `first`, pending: true, shape: `both` }], - }, ] as const)(`derives grouped pending writes from $name`, ({ rows }) => { const graph = new D2() const input = graph.newInput() @@ -85,14 +76,13 @@ describe(`group-by production pipeline`, () => { input.sendData( new MultiSet( - rows.map(({ key, pending, shape }): [KeyedNamespacedRow, number] => [ + rows.map(({ key, pending }): [KeyedNamespacedRow, number] => [ [ key, { row: { id: key, - ...(shape !== `legacy` ? { $hasPendingWrites: pending } : {}), - ...(shape !== `new` ? { $synced: !pending } : {}), + $hasPendingWrites: pending, }, }, ], @@ -106,7 +96,6 @@ describe(`group-by production pipeline`, () => { const grouped = actual.getInner()[0]![0][1] const expectedPending = rows.some((row) => row.pending) expect(grouped.$hasPendingWrites).toBe(expectedPending) - expect(grouped.$synced).toBe(!expectedPending) }) // A source field that is not grouped has no single value, with or without @@ -157,7 +146,7 @@ describe(`group-by production pipeline`, () => { type Result = { key: unknown selected: unknown - synced: unknown + hasPendingWrites: unknown origin: unknown } let actual = new MultiSet() @@ -201,7 +190,7 @@ describe(`group-by production pipeline`, () => { return { key, selected: row.$selected, - synced: row.$synced, + hasPendingWrites: row.$hasPendingWrites, origin: row.$origin, } }), @@ -220,7 +209,7 @@ describe(`group-by production pipeline`, () => { { row: { ...row, - $synced: !row.local, + $hasPendingWrites: row.local, $origin: row.local ? `local` : `remote`, }, }, @@ -257,7 +246,7 @@ describe(`group-by production pipeline`, () => { ...(grouped ? { group: key } : {}), [totalAlias]: amount, }, - synced: members.every((row) => !row.local), + hasPendingWrites: members.some((row) => row.local), origin: members.some((row) => row.local) ? `local` : `remote`, }, ] diff --git a/packages/db/tests/query/derived-delete-reconciliation-oracle.test.ts b/packages/db/tests/query/derived-delete-reconciliation-oracle.test.ts index 0ed2da5bd2..77ae3912d9 100644 --- a/packages/db/tests/query/derived-delete-reconciliation-oracle.test.ts +++ b/packages/db/tests/query/derived-delete-reconciliation-oracle.test.ts @@ -65,7 +65,7 @@ async function runFlushHistory( }) const done = createDeferred() const tx = createTransaction({ mutationFn: () => done.promise }) - const settled = tx.isPersisted.promise.catch((error: unknown) => error) + const settled = tx.when(`settled`).catch((error: unknown) => error) const lookup = vi.spyOn(derived._state, `createSyncedKeyLookup`) try { await derived.preload() @@ -189,7 +189,7 @@ describe(`derived updates beneath optimistic deletes`, () => { }) const done = createDeferred() const tx = createTransaction({ mutationFn: () => done.promise }) - const settled = tx.isPersisted.promise.catch((error: unknown) => error) + const settled = tx.when(`settled`).catch((error: unknown) => error) const read = (collection: { values: () => Iterable }) => [...collection.values()] .map((row) => ({ id: row.id, value: row.value })) diff --git a/packages/db/tests/query/derived-delete-reconciliation.test.ts b/packages/db/tests/query/derived-delete-reconciliation.test.ts index 4b4e62ff9e..b3637ad698 100644 --- a/packages/db/tests/query/derived-delete-reconciliation.test.ts +++ b/packages/db/tests/query/derived-delete-reconciliation.test.ts @@ -109,7 +109,7 @@ describe(`derived updates beneath optimistic deletes`, () => { }) const done = createDeferred() const tx = createTransaction({ mutationFn: () => done.promise }) - const settled = tx.isPersisted.promise.catch(() => {}) + const settled = tx.when('settled').catch(() => {}) try { await derived.preload() tx.mutate(() => derived.delete(0)) diff --git a/packages/db/tests/query/includes-alias-shadowing-oracle.test.ts b/packages/db/tests/query/includes-alias-shadowing-oracle.test.ts index 311583f01f..bfd6b0c795 100644 --- a/packages/db/tests/query/includes-alias-shadowing-oracle.test.ts +++ b/packages/db/tests/query/includes-alias-shadowing-oracle.test.ts @@ -63,7 +63,6 @@ const rootVirtualKeys = [ `$hasPendingWrites`, `$key`, `$origin`, - `$synced`, ] /** diff --git a/packages/db/tests/query/includes-collection-driver-oracle.ts b/packages/db/tests/query/includes-collection-driver-oracle.ts index d7cc3fcf7d..ac7221fc92 100644 --- a/packages/db/tests/query/includes-collection-driver-oracle.ts +++ b/packages/db/tests/query/includes-collection-driver-oracle.ts @@ -1,3 +1,4 @@ +import { expect } from 'vitest' // The Collection-valued include driver and its Map model. The driver runs // generated or fixed source histories through a live include query, and the // model recomputes the parent rows that the public Collection facade must @@ -154,7 +155,74 @@ export function recompute(context: CollectionContext): Array { }) } -declareOracle({ model: [recompute] }) +/** + * Facade lifetime is an active-bucket interval. Current parent routes decide + * active groups; a surviving route retains the interval, while reactivation + * after no route remained starts a new one. Tokens are model-only names for + * these intervals, not production Collection IDs or routing keys. Passing the + * complete current route groups makes route counts unnecessary model state. + * Metadata receiving histories use this same Collection-facade owner. + * Authority: ARCHITECTURE.md, "Nested materialization". Observations follow + * initial readiness. No facade-local optimistic mutation is active: such a + * mutation may legally keep a retired facade nonempty until its settlement. + */ +export type FacadeIntervals = { + active: ReadonlyMap + next: number +} +export function advanceFacadeIntervals( + previous: FacadeIntervals, + currentRouteGroups: Iterable, +): FacadeIntervals { + let next = previous.next + const active = new Map() + for (const group of currentRouteGroups) { + if (!active.has(group)) + active.set(group, previous.active.get(group) ?? next++) + } + return { active, next } +} + +export type MetadataFacade = { + id: string + status: string + toArray: ReadonlyArray +} +export function checkFacadeIntervals( + intervals: FacadeIntervals, + actual: ReadonlyArray<{ group: TGroup; facade: MetadataFacade }>, + observedIntervals: Map, +): void { + expect(new Set(actual.map(({ group }) => group))).toEqual( + new Set(intervals.active.keys()), + ) + for (const { group, facade } of actual) { + expect( + facade.status, + `active facade is ready, including empty buckets`, + ).toBe(`ready`) + const interval = intervals.active.get(group) + expect(interval, `active route ${group}`).toBeDefined() + const previous = observedIntervals.get(interval!) + if (previous) expect(facade, `same active interval`).toBe(previous) + else { + expect( + [...observedIntervals.values()], + `new interval gets a new facade`, + ).not.toContain(facade) + observedIntervals.set(interval!, facade) + } + } + for (const [interval, facade] of observedIntervals) + if (![...intervals.active.values()].includes(interval)) { + expect(facade.status, `retired facade remains ready`).toBe(`ready`) + expect(facade.toArray, `retired facade remains empty`).toEqual([]) + } +} +declareOracle({ + model: [recompute, advanceFacadeIntervals], + check: [checkFacadeIntervals], +}) export function createCollectionDriver( initialParents: ReadonlyArray, diff --git a/packages/db/tests/query/includes-collection-oracle.property.test.ts b/packages/db/tests/query/includes-collection-oracle.property.test.ts index c499595305..7b588b2f4c 100644 --- a/packages/db/tests/query/includes-collection-oracle.property.test.ts +++ b/packages/db/tests/query/includes-collection-oracle.property.test.ts @@ -1,5 +1,5 @@ import { fc, test as fcTest } from '@fast-check/vitest' -import { describe, expect } from 'vitest' +import { describe, expect, test } from 'vitest' import { createCollection } from '../../src/collection/index.js' import { BTreeIndex } from '../../src/indexes/btree-index.js' import { @@ -27,6 +27,8 @@ import { expectedMaterializations, } from './includes-collection-support.js' import { + advanceFacadeIntervals, + checkFacadeIntervals, createCollectionDriver, projectEventValue, projectLive, @@ -35,7 +37,9 @@ import { import type { CollectionAction, CollectionContext, + FacadeIntervals, IncludedChildCollection, + MetadataFacade, ParentEvent, ProjectedParent, } from './includes-collection-driver-oracle.js' @@ -156,7 +160,7 @@ function createOptimisticCollectionDriver( } pending.add(entry) // Observe both outcomes before the first fallible optimistic checkpoint. - entry.receipt = transaction.isPersisted.promise.then( + entry.receipt = transaction.when(`settled`).then( () => { pending.delete(entry) }, @@ -187,13 +191,11 @@ function createOptimisticCollectionDriver( context.children.write(`insert`, action.row) pendingMutation.syncReleased = true context.children.resolveSync() - await transaction.isPersisted.promise + await transaction.when(`settled`) } else { context.model.children.delete(action.row.id) const message = `rollback insert` - const persisted = transaction.isPersisted.promise.catch( - () => undefined, - ) + const persisted = transaction.when(`settled`).catch(() => undefined) await withExpectedRejection(message, async () => { pendingMutation.syncReleased = true context.children.rejectSync(new Error(message)) @@ -217,11 +219,11 @@ function createOptimisticCollectionDriver( context.children.write(`delete`, previous) pendingMutation.syncReleased = true context.children.resolveSync() - await transaction.isPersisted.promise + await transaction.when(`settled`) } else { context.model.children.set(action.id, previous) const message = `rollback delete` - const persisted = transaction.isPersisted.promise.catch(() => undefined) + const persisted = transaction.when(`settled`).catch(() => undefined) await withExpectedRejection(message, async () => { pendingMutation.syncReleased = true context.children.rejectSync(new Error(message)) @@ -908,28 +910,47 @@ describe(`Collection-valued includes oracle`, () => { const context = await driver.setup() await driver.start?.(context) const sharedFacade = context.live.get(1)!.children + let intervals: FacadeIntervals = { active: new Map(), next: 0 } + const observedIntervals = new Map() + const checkIntervals = (groups: ReadonlyArray) => { + intervals = advanceFacadeIntervals(intervals, groups) + checkFacadeIntervals( + intervals, + context.live.toArray.map((row) => ({ + group: row.group, + facade: row.children, + })), + observedIntervals, + ) + } try { + checkIntervals([1, 1]) expect(context.live.get(2)!.children).toBe(sharedFacade) context.parents.write(`delete`, { id: 1, group: 1 }) + checkIntervals([1]) expect(context.live.get(2)!.children).toBe(sharedFacade) expect([...sharedFacade.keys()]).toEqual([10]) expect(sharedFacade.status).toBe(`ready`) context.parents.write(`delete`, { id: 2, group: 1 }) + checkIntervals([]) expect(context.live.toArray).toEqual([]) expect(sharedFacade.toArray).toEqual([]) expect(sharedFacade.status).toBe(`ready`) context.children.write(`insert`, { id: 20, parentGroup: 1, value: 2 }) + checkIntervals([]) expect(context.live.toArray).toEqual([]) expect(sharedFacade.toArray).toEqual([]) expect(sharedFacade.status).toBe(`ready`) context.parents.write(`insert`, { id: 1, group: 1 }) + checkIntervals([1]) const reactivated = context.live.get(1)!.children expect(reactivated).not.toBe(sharedFacade) const assertCurrent = (value: number) => { + checkIntervals([1]) const rows = [ { id: 10, parentGroup: 1, value: 1 }, { id: 20, parentGroup: 1, value }, @@ -1608,3 +1629,67 @@ describe(`Collection-valued includes oracle`, () => { runOptimisticChildHistory, ) }) + +// These public-handle controls replay the same active-interval model used by +// the metadata receiver. They challenge sharing, retirement and reactivation +// separately; ordinary child values remain outside this partial checker. +test.each([ + `split-shared`, + `retargeted`, + `retired-rows`, + `retired-status`, + `missing-group`, + `active-status`, +] as const)(`rejects a hostile facade interval observation: %s`, (fault) => { + const original: MetadataFacade = { + id: `original`, + status: `ready`, + toArray: [], + } + const replacement: MetadataFacade = { + id: `replacement`, + status: `ready`, + toArray: [], + } + const seen = new Map() + const first = advanceFacadeIntervals({ active: new Map(), next: 0 }, [ + `a`, + `a`, + ]) + checkFacadeIntervals( + first, + [ + { group: `a`, facade: original }, + { group: `a`, facade: original }, + ], + seen, + ) + if (fault === `split-shared`) { + expect(() => + checkFacadeIntervals( + first, + [ + { group: `a`, facade: original }, + { group: `a`, facade: replacement }, + ], + seen, + ), + ).toThrow() + return + } + const retired = advanceFacadeIntervals(first, []) + checkFacadeIntervals(retired, [], seen) + const returned = advanceFacadeIntervals(retired, [`a`]) + if (fault === `retargeted`) { + expect(() => + checkFacadeIntervals(returned, [{ group: `a`, facade: original }], seen), + ).toThrow() + return + } + if (fault === `active-status`) replacement.status = `cleaned-up` + if (fault === `retired-rows`) original.toArray = [{ id: `stale` }] + if (fault === `retired-status`) original.status = `cleaned-up` + const actual = + fault === `missing-group` ? [] : [{ group: `a`, facade: replacement }] + expect(() => checkFacadeIntervals(returned, actual, seen)).toThrow() +}) diff --git a/packages/db/tests/query/includes-context-transport.test.ts b/packages/db/tests/query/includes-context-transport.test.ts index 6d4264c06a..e6b9e835da 100644 --- a/packages/db/tests/query/includes-context-transport.test.ts +++ b/packages/db/tests/query/includes-context-transport.test.ts @@ -225,7 +225,6 @@ function expectNoRouteMetadata(row: object): void { // Public VirtualRowProps names these five keys, not arbitrary $-prefixed data. const publicMetadataKeys = new Set([ `$hasPendingWrites`, - `$synced`, `$origin`, `$key`, `$collectionId`, diff --git a/packages/db/tests/query/includes-cross-formulation-support.ts b/packages/db/tests/query/includes-cross-formulation-support.ts index 4632b44e31..943f4c4a34 100644 --- a/packages/db/tests/query/includes-cross-formulation-support.ts +++ b/packages/db/tests/query/includes-cross-formulation-support.ts @@ -121,7 +121,6 @@ export function referenceRequestPredicate( // this projection. const virtualKeys = new Set([ `$hasPendingWrites`, - `$synced`, `$origin`, `$key`, `$collectionId`, diff --git a/packages/db/tests/query/includes-cross-formulation.test.ts b/packages/db/tests/query/includes-cross-formulation.test.ts index 859fd9a3d5..deb0992fd4 100644 --- a/packages/db/tests/query/includes-cross-formulation.test.ts +++ b/packages/db/tests/query/includes-cross-formulation.test.ts @@ -263,7 +263,7 @@ describe(`includes cross-formulation oracle`, () => { const rows = captureOrderedNested(expected) Object.assign(rows[0]!, { $hasPendingWrites: false, - $synced: true, + $origin: `remote`, $key: 0, $collectionId: `source`, diff --git a/packages/db/tests/query/includes-functional-projection.test.ts b/packages/db/tests/query/includes-functional-projection.test.ts index 20133a016a..b82fdbb02f 100644 --- a/packages/db/tests/query/includes-functional-projection.test.ts +++ b/packages/db/tests/query/includes-functional-projection.test.ts @@ -512,7 +512,7 @@ describe(`functional projection output compatibility`, () => { case `virtual-metadata`: ids = view.toArray.map((child) => { expect(child.$collectionId).toBe(children.collection.id) - expect(child.$synced).toBe(true) + expect(!child.$hasPendingWrites).toBe(true) expect(child.$origin).toBe(`remote`) return child.id }) diff --git a/packages/db/tests/query/includes-optimistic-oracle.property.test.ts b/packages/db/tests/query/includes-optimistic-oracle.property.test.ts index 308b470683..70f1a42315 100644 --- a/packages/db/tests/query/includes-optimistic-oracle.property.test.ts +++ b/packages/db/tests/query/includes-optimistic-oracle.property.test.ts @@ -197,7 +197,7 @@ function trackRuntimeMutation( if (entry.syncReleased) context.runtimePending.delete(entry) } // Attach both outcome handlers before a checkpoint or sibling source write. - entry.receipt = transaction.isPersisted.promise.then(settled, settled) + entry.receipt = transaction.when(`settled`).then(settled, settled) return entry } @@ -387,7 +387,7 @@ async function rollback( runtime: PendingRuntimeMutation, ) { const message = `optimistic relationship oracle rollback` - const persisted = transaction.isPersisted.promise.catch(() => undefined) + const persisted = transaction.when(`settled`).catch(() => undefined) await withExpectedRejection(message, async () => { releaseRuntimeMutation(context, runtime, () => source.rejectSync(new Error(message)), @@ -488,7 +488,7 @@ function createDriver( // Sync delivery must not displace the pending optimistic projection. checkpoint() releaseRuntimeMutation(context, pending.runtime, source.resolveSync) - await pending.transaction.isPersisted.promise + await pending.transaction.when(`settled`) context.pending.delete(step.handle) }, cleanup: async (context) => { @@ -808,7 +808,7 @@ describe(`optimistic relationship-transition oracle`, () => { const transaction = roots.update(1, (draft) => { draft.value = `B` }) - persistence = transaction.isPersisted.promise.then( + persistence = transaction.when(`settled`).then( () => undefined, () => undefined, ) diff --git a/packages/db/tests/query/includes-oracle.property.test.ts b/packages/db/tests/query/includes-oracle.property.test.ts index a38d292e04..e263a1497b 100644 --- a/packages/db/tests/query/includes-oracle.property.test.ts +++ b/packages/db/tests/query/includes-oracle.property.test.ts @@ -802,7 +802,7 @@ async function applyAction( action, sources.roots.resolveSync, sources.roots.rejectSync, - transaction.isPersisted.promise, + transaction.when(`settled`), pending, entry, ) @@ -864,7 +864,7 @@ async function applyAction( action, source.resolveSync, source.rejectSync, - transaction.isPersisted.promise, + transaction.when(`settled`), pending, entry, ) @@ -4517,7 +4517,7 @@ describe(`includes recompute oracle`, () => { position: 0, }) releasePendingWork(pending, entry, children.resolveSync) - await transaction.isPersisted.promise + await transaction.when(`settled`) expect(stripVirtualProperties(live.toArray)).toEqual([ { id: 1, children: [{ id: 1, value: confirmedValue }] }, diff --git a/packages/db/tests/query/includes-publication-oracle.test.ts b/packages/db/tests/query/includes-publication-oracle.test.ts index e97df9f258..8326dcdfb6 100644 --- a/packages/db/tests/query/includes-publication-oracle.test.ts +++ b/packages/db/tests/query/includes-publication-oracle.test.ts @@ -516,13 +516,13 @@ function createPublicationDriver( if (action.type === `optimisticConfirm`) { context.sources.parents.write(`update`, next) context.sources.parents.resolveSync() - await transaction.isPersisted.promise + await transaction.when(`settled`) } else { const beforeRollbackCallbacks = context.callbacks.length context.model.parents.set(previous.id, previous) await settleRollback( context.sources.parents.rejectSync, - transaction.isPersisted.promise, + transaction.when(`settled`), ) checkpointWithCallbackReach(beforeRollbackCallbacks) } diff --git a/packages/db/tests/query/includes-structural-support.ts b/packages/db/tests/query/includes-structural-support.ts index b61b418a4e..b43066472e 100644 --- a/packages/db/tests/query/includes-structural-support.ts +++ b/packages/db/tests/query/includes-structural-support.ts @@ -94,7 +94,7 @@ export function trackPendingWork( entry.settled = true if (entry.released) pending.delete(entry) } - entry.receipt = transaction.isPersisted.promise.then(settled, settled) + entry.receipt = transaction.when('settled').then(settled, settled) return entry } diff --git a/packages/db/tests/query/includes-temporal.test.ts b/packages/db/tests/query/includes-temporal.test.ts index 5ae48a73e2..b188170134 100644 --- a/packages/db/tests/query/includes-temporal.test.ts +++ b/packages/db/tests/query/includes-temporal.test.ts @@ -596,7 +596,7 @@ it.each( postId: 999, body: `unrelated`, }) - transactionOutcome = transaction.isPersisted.promise.then( + transactionOutcome = transaction.when('settled').then( () => undefined, () => undefined, ) @@ -624,7 +624,7 @@ it.each( }) } mutation.resolve() - if (transaction) await transaction.isPersisted.promise + if (transaction) await transaction.when('settled') await preload await Promise.all(requests.map(({ outcome }) => outcome)) expect(requests.every(({ settled }) => settled)).toBe(true) diff --git a/packages/db/tests/query/includes-work-counter-oracle.test.ts b/packages/db/tests/query/includes-work-counter-oracle.test.ts index 375258f484..0219ff873c 100644 --- a/packages/db/tests/query/includes-work-counter-oracle.test.ts +++ b/packages/db/tests/query/includes-work-counter-oracle.test.ts @@ -681,7 +681,7 @@ async function checkWindowedIncludeWork(testCase: WindowCase): Promise { } await ( type === `insert` ? childSource.insert(row) : childSource.delete(row.id) - ).isPersisted.promise + ).when(`settled`) } const live = createLiveQueryCollection((q) => { @@ -905,9 +905,11 @@ async function checkWindowedIncludeWork(testCase: WindowCase): Promise { if (!source) { const added = addChildren(group, testCase.childrenPerGroup) for (const child of added) await changeChild(`insert`, child) - await grandchildSource.insert( - added.map((child) => grandchildren.get(`grand-${child.id}`)!), - ).isPersisted.promise + await grandchildSource + .insert( + added.map((child) => grandchildren.get(`grand-${child.id}`)!), + ) + .when(`settled`) } const parent = { id: `parent-new-${step}`, @@ -915,12 +917,12 @@ async function checkWindowedIncludeWork(testCase: WindowCase): Promise { group, } parents.set(parent.id, parent) - await parentSource.insert(parent).isPersisted.promise + await parentSource.insert(parent).when(`settled`) } else if (command.type === `delete-parent`) { if (ids.length === 0) continue const id = ids[command.pick % ids.length]! parents.delete(id) - await parentSource.delete(id).isPersisted.promise + await parentSource.delete(id).when(`settled`) } else if (command.type === `rank`) { if (ids.length === 0) continue const id = ids[command.pick % ids.length]! @@ -930,9 +932,11 @@ async function checkWindowedIncludeWork(testCase: WindowCase): Promise { ? Math.max(...ranks) + 1 : Math.min(...ranks) - 1 parents.set(id, { ...parents.get(id)!, rank }) - await parentSource.update(id, (draft) => { - draft.rank = rank - }).isPersisted.promise + await parentSource + .update(id, (draft) => { + draft.rank = rank + }) + .when(`settled`) } else if (command.type === `insert-child`) { const groups = [...new Set([...parents.values()].map((p) => p.group))] if (groups.length === 0) continue diff --git a/packages/db/tests/query/includes.test-d.ts b/packages/db/tests/query/includes.test-d.ts index d24c9d3b12..57a29a7bc3 100644 --- a/packages/db/tests/query/includes.test-d.ts +++ b/packages/db/tests/query/includes.test-d.ts @@ -120,7 +120,7 @@ describe(`includes subquery types`, () => { const result = collection.toArray[0]! expectTypeOf(result.id).toEqualTypeOf() expectTypeOf(result.name).toEqualTypeOf() - expectTypeOf(result.$synced).toEqualTypeOf() + expectTypeOf(result.$hasPendingWrites).toEqualTypeOf() expectTypeOf(result.$origin).toEqualTypeOf<`local` | `remote`>() expectTypeOf(result.$key).toEqualTypeOf() expectTypeOf(result.$collectionId).toEqualTypeOf() @@ -141,7 +141,7 @@ describe(`includes subquery types`, () => { const result = collection.toArray[0]! expectTypeOf(result.id).toEqualTypeOf() - expectTypeOf(result.$synced).toEqualTypeOf() + expectTypeOf(result.$hasPendingWrites).toEqualTypeOf() expectTypeOf(result.$origin).toEqualTypeOf<`local` | `remote`>() expectTypeOf(result.$key).toEqualTypeOf() expectTypeOf(result.$collectionId).toEqualTypeOf() @@ -212,7 +212,9 @@ describe(`includes subquery types`, () => { const result = collection.toArray[0]! expectTypeOf(result.issues.toArray[0]!.id).toEqualTypeOf() expectTypeOf(result.issues.toArray[0]!.title).toEqualTypeOf() - expectTypeOf(result.issues.toArray[0]!.$synced).toEqualTypeOf() + expectTypeOf( + result.issues.toArray[0]!.$hasPendingWrites, + ).toEqualTypeOf() expectTypeOf(result.issues.toArray[0]!.$origin).toEqualTypeOf< `local` | `remote` >() @@ -249,7 +251,7 @@ describe(`includes subquery types`, () => { const result = collection.toArray[0]! expectTypeOf(result.id).toEqualTypeOf() expectTypeOf(result.name).toEqualTypeOf() - expectTypeOf(result.$synced).toEqualTypeOf() + expectTypeOf(result.$hasPendingWrites).toEqualTypeOf() expectTypeOf(result.$origin).toEqualTypeOf<`local` | `remote`>() expectTypeOf(result.$key).toEqualTypeOf() expectTypeOf(result.$collectionId).toEqualTypeOf() @@ -271,7 +273,7 @@ describe(`includes subquery types`, () => { const result = collection.toArray[0]! expectTypeOf(result.id).toEqualTypeOf() - expectTypeOf(result.$synced).toEqualTypeOf() + expectTypeOf(result.$hasPendingWrites).toEqualTypeOf() expectTypeOf(result.$origin).toEqualTypeOf<`local` | `remote`>() expectTypeOf(result.$key).toEqualTypeOf() expectTypeOf(result.$collectionId).toEqualTypeOf() @@ -305,7 +307,7 @@ describe(`includes subquery types`, () => { const result = collection.toArray[0]! expectTypeOf(result.id).toEqualTypeOf() - expectTypeOf(result.$synced).toEqualTypeOf() + expectTypeOf(result.$hasPendingWrites).toEqualTypeOf() expectTypeOf(result.$origin).toEqualTypeOf<`local` | `remote`>() expectTypeOf(result.$key).toEqualTypeOf() expectTypeOf(result.$collectionId).toEqualTypeOf() diff --git a/packages/db/tests/query/live-query-collection.test.ts b/packages/db/tests/query/live-query-collection.test.ts index 520ef8ee29..e155b5fdc1 100644 --- a/packages/db/tests/query/live-query-collection.test.ts +++ b/packages/db/tests/query/live-query-collection.test.ts @@ -1281,7 +1281,7 @@ describe(`createLiveQueryCollection`, () => { const tx2 = base.insert({ id: `2`, created_at: Date.now() + 1 }) await vi.advanceTimersByTimeAsync(2000) - await Promise.all([tx1.isPersisted.promise, tx2.isPersisted.promise]) + await Promise.all([tx1.when(`settled`), tx2.when(`settled`)]) expect(base.size).toBe(2) expect(live.size).toBe(2) @@ -1342,7 +1342,7 @@ describe(`createLiveQueryCollection`, () => { ) await vi.advanceTimersByTimeAsync(5000) - await Promise.all(transactions.map((tx) => tx.isPersisted.promise)) + await Promise.all(transactions.map((tx) => tx.when(`settled`))) expect(base.size).toBe(5) expect(live.size).toBe(5) @@ -1465,8 +1465,8 @@ describe(`createLiveQueryCollection`, () => { draft.completed = !draft.completed }) // Observe each real receipt before any assertion can abandon it. - void tx.isPersisted.promise.catch(() => {}) - receipts.push(tx.isPersisted.promise) + void tx.when(`settled`).catch(() => {}) + receipts.push(tx.when(`settled`)) const previous = expected.get(id)! expected.set(id, { ...previous, diff --git a/packages/db/tests/query/load-subset-joint-replay-oracle.property.test.ts b/packages/db/tests/query/load-subset-joint-replay-oracle.property.test.ts index bd60cff2ba..ed862e1fa4 100644 --- a/packages/db/tests/query/load-subset-joint-replay-oracle.property.test.ts +++ b/packages/db/tests/query/load-subset-joint-replay-oracle.property.test.ts @@ -273,7 +273,7 @@ async function runHistory(h: Scenario) { transaction.mutate(() => source.insert({ id: `hold`, version: 0, value: 0, kind: `hold` }), ) - held = { release: pending.resolve, done: transaction.isPersisted.promise } + held = { release: pending.resolve, done: transaction.when('settled') } } const release = async () => { if (!held) return diff --git a/packages/db/tests/query/load-subset-transaction-refinement-oracle.test.ts b/packages/db/tests/query/load-subset-transaction-refinement-oracle.test.ts index 59c4fcdafe..1f6635d0e6 100644 --- a/packages/db/tests/query/load-subset-transaction-refinement-oracle.test.ts +++ b/packages/db/tests/query/load-subset-transaction-refinement-oracle.test.ts @@ -160,7 +160,7 @@ describe(`loadSubset transaction refinement`, () => { } persistence.resolve() - await blocker.isPersisted.promise + await blocker.when(`settled`) if (expected.load === `rejects`) { await expect(load).rejects.toMatchObject({ name: `AbortError` }) @@ -182,7 +182,7 @@ describe(`loadSubset transaction refinement`, () => { } finally { persistence.resolve() try { - await blocker.isPersisted.promise + await blocker.when(`settled`) } catch (error) { cleanupFailures.push(error) } diff --git a/packages/db/tests/query/load-subset.test.ts b/packages/db/tests/query/load-subset.test.ts index e507ef0756..73d6d331ea 100644 --- a/packages/db/tests/query/load-subset.test.ts +++ b/packages/db/tests/query/load-subset.test.ts @@ -143,7 +143,7 @@ async function expectPersistingLoadIsApplied( const transaction = createTransaction({ mutationFn: () => persistence.promise, }) - settlement = transaction.isPersisted.promise + settlement = transaction.when('settled') if (persisting) { transaction.mutate(() => source.insert({ id: `other`, projectId: `p2` })) } @@ -169,7 +169,7 @@ async function expectPersistingLoadIsApplied( expect(source.get(`r2`)).toBeUndefined() persistence.resolve() - await transaction.isPersisted.promise + await transaction.when('settled') } const result = await ready @@ -243,7 +243,7 @@ async function expectAppliedReceiptTiming( const transaction = createTransaction({ mutationFn: () => persistence.promise, }) - settlement = transaction.isPersisted.promise + settlement = transaction.when('settled') if (gate === `parked`) { transaction.mutate(() => source.insert({ id: `local`, projectId: `p2` })) } @@ -279,7 +279,7 @@ async function expectAppliedReceiptTiming( expect(fulfilled).toBe(false) expect(source.get(`remote`)).toBeUndefined() persistence.resolve() - await transaction.isPersisted.promise + await transaction.when('settled') } await pending @@ -348,7 +348,7 @@ async function expectAppliedLoadDoesNotFlushEarlierParkedSync() { const transaction = createTransaction({ mutationFn: () => persistence.promise, }) - settlement = transaction.isPersisted.promise + settlement = transaction.when('settled') transaction.mutate(() => source.insert({ id: `other`, projectId: `p2` })) publishUnrelated() const live = createLiveQueryCollection((query) => @@ -371,7 +371,7 @@ async function expectAppliedLoadDoesNotFlushEarlierParkedSync() { expect(source.get(`unrelated`)).toBeUndefined() persistence.resolve() - await transaction.isPersisted.promise + await transaction.when('settled') await expect(ready).resolves.toEqual( expect.arrayContaining([ expect.objectContaining({ id: `r1` }), @@ -438,7 +438,7 @@ async function expectCompletionWaitsForAppliedRows() { const transaction = createTransaction({ mutationFn: () => persistence.promise, }) - settlement = transaction.isPersisted.promise + settlement = transaction.when('settled') transaction.mutate(() => source.insert({ id: `other`, projectId: `p2` })) publishUnrelated() const first = source._sync.loadSubset({}) @@ -452,7 +452,7 @@ async function expectCompletionWaitsForAppliedRows() { expect(source.get(`r1`)).toBeUndefined() persistence.resolve() - await transaction.isPersisted.promise + await transaction.when('settled') await Promise.all([first, concurrent]) expect(source.get(`r1`)).toEqual( expect.objectContaining({ id: `r1`, projectId: `p1` }), @@ -539,7 +539,7 @@ async function expectConcurrentStreamCommitStaysParked( const transaction = createTransaction({ mutationFn: () => persistence.promise, }) - settlement = transaction.isPersisted.promise + settlement = transaction.when('settled') transaction.mutate(() => source.insert({ id: `other`, projectId: `p2` })) const load = requirePendingLoadSubsetResult(source._sync.loadSubset({})) void load.catch(() => undefined) @@ -564,7 +564,7 @@ async function expectConcurrentStreamCommitStaysParked( return } persistence.resolve() - await transaction.isPersisted.promise + await transaction.when('settled') await load expect(source.get(`unrelated`)).toEqual( expect.objectContaining({ id: `unrelated`, projectId: `p2` }), @@ -652,7 +652,7 @@ async function expectAbortedReceiptDoesNotSettleDemand( const transaction = createTransaction({ mutationFn: () => persistence.promise, }) - settlement = transaction.isPersisted.promise + settlement = transaction.when('settled') transaction.mutate(() => source.insert({ id: `local`, projectId: `p2` })) const first = requirePendingLoadSubsetResult( source._sync.loadSubset({ signal: controller.signal }), @@ -662,7 +662,7 @@ async function expectAbortedReceiptDoesNotSettleDemand( } controller.abort() persistence.resolve() - await transaction.isPersisted.promise + await transaction.when('settled') // An accepted transaction always applies, so the row publishes when the // mutation settles. A caller that aborted still sees `AbortError`. if (abortPhase === `while-parked`) @@ -726,7 +726,7 @@ async function expectAbortDuringPublicationDoesNotCancelReceipt() { const transaction = createTransaction({ mutationFn: () => persistence.promise, }) - settlement = transaction.isPersisted.promise + settlement = transaction.when('settled') transaction.mutate(() => source.insert({ id: `local`, projectId: `pending` }), ) @@ -740,7 +740,7 @@ async function expectAbortDuringPublicationDoesNotCancelReceipt() { source._sync.loadSubset({ signal: controller.signal }), ) persistence.resolve() - await transaction.isPersisted.promise + await transaction.when('settled') await expect(load).resolves.toBeUndefined() expect(controller.signal.aborted).toBe(true) expect(source.get(`row`)).toEqual(expect.objectContaining({ id: `row` })) @@ -792,7 +792,7 @@ async function expectCanceledReceiptReleasesOnlyItsSuppression() { const transaction = createTransaction({ mutationFn: () => persistence.promise, }) - settlement = transaction.isPersisted.promise + settlement = transaction.when('settled') transaction.mutate(() => source.insert({ id: `local`, projectId: `pending` }), ) @@ -893,7 +893,7 @@ async function expectCleanupRejectsDemandOnce() { const transaction = createTransaction({ mutationFn: () => persistence.promise, }) - settlement = transaction.isPersisted.promise + settlement = transaction.when('settled') transaction.mutate(() => source.insert({ id: `local`, projectId: `p2` })) const load = requirePendingLoadSubsetResult(source._sync.loadSubset({})) void load.catch(() => undefined) @@ -911,7 +911,7 @@ async function expectCleanupRejectsDemandOnce() { expect(settlements).toBe(1) persistence.resolve() - await transaction.isPersisted.promise.catch(() => undefined) + await transaction.when('settled').catch(() => undefined) await Promise.resolve() expect(settlements).toBe(1) @@ -984,7 +984,7 @@ async function expectDerivedSyncDuringOptimisticMutation(): Promise { id: `optimistic`, value: `optimistic`, }) - settlement = transaction.isPersisted.promise + settlement = transaction.when('settled') begin() write({ type: `insert`, value: { id: `synced`, value: `synced` } }) commit() diff --git a/packages/db/tests/query/ordered-lifecycle-oracle.property.test.ts b/packages/db/tests/query/ordered-lifecycle-oracle.property.test.ts index e9f1254b6c..1ce880490d 100644 --- a/packages/db/tests/query/ordered-lifecycle-oracle.property.test.ts +++ b/packages/db/tests/query/ordered-lifecycle-oracle.property.test.ts @@ -1559,7 +1559,7 @@ async function observeLaterFallback( let markerSync!: MarkerSync let markerCommitImmediate = false let blockerStarted = false - let blockerPersistence: Promise | undefined + let blockerSettlement: Promise | undefined let receiptPending = false let fallbackEntry: { rows: Array; status: string } | undefined @@ -1657,10 +1657,13 @@ async function observeLaterFallback( ) { // The page's own receipt applied. Only the later full-source // transaction waits behind this unrelated optimistic update. - blockerPersistence = rootCollection.update(1, (draft) => { - draft.note = `pending` - }).isPersisted.promise - void blockerPersistence.catch(() => {}) + const blocker = rootCollection + .update(1, (draft) => { + draft.note = `pending` + }) + .when(`settled`) + blockerSettlement = blocker + void blocker.catch(() => {}) } } if (isFullSource) { @@ -1937,14 +1940,14 @@ async function observeLaterFallback( blockerGate.resolve() providerGate.resolve() childGate.resolve() - // A persistence failure is a distinct cleanup diagnostic if the public + // A settlement failure is a distinct cleanup diagnostic if the public // comparison already failed. Bound the wait so cleanup still reports it. - const awaitBlockerPersistence = async () => { - if (!blockerPersistence) return + const awaitBlockerSettlement = async () => { + if (!blockerSettlement) return let timeout: ReturnType | undefined try { await Promise.race([ - blockerPersistence, + blockerSettlement, new Promise((_, reject) => { timeout = setTimeout( () => @@ -1960,7 +1963,7 @@ async function observeLaterFallback( await finishOracleCleanup( primaryFailure, [ - awaitBlockerPersistence, + awaitBlockerSettlement, () => observer?.unsubscribe(), () => unsubscribeRootLoading?.(), () => unsubscribeLiveLoading?.(), diff --git a/packages/db/tests/query/ordered-work-oracle.property.test.ts b/packages/db/tests/query/ordered-work-oracle.property.test.ts index 26b27425b5..fd11aed199 100644 --- a/packages/db/tests/query/ordered-work-oracle.property.test.ts +++ b/packages/db/tests/query/ordered-work-oracle.property.test.ts @@ -158,13 +158,7 @@ let harnessId = 0 function copiedRow(row: Row): Row { const copy: Record = { ...row } - for (const field of [ - `$key`, - `$collectionId`, - `$origin`, - `$hasPendingWrites`, - `$synced`, - ]) + for (const field of [`$key`, `$collectionId`, `$origin`, `$hasPendingWrites`]) delete copy[field] return copy as Row } @@ -4801,7 +4795,7 @@ async function runEagerWindow(testCase: EagerCase): Promise<{ matched ? markers.insert(marker(rowId)) : markers.delete(current.map(({ key }) => key)) - ).isPersisted.promise + ).when(`settled`) } const markersOf = (rowId: number) => [...markerModel.values()].filter((entry) => entry.rowId === rowId) @@ -5326,7 +5320,7 @@ async function runEagerWindow(testCase: EagerCase): Promise<{ if (testCase.path !== `local-only`) { sourceWrite(`delete`, before) } else { - await source.delete(command.id).isPersisted.promise + await source.delete(command.id).when(`settled`) } // A full join keeps the marker, which becomes a rootless row. An // anti-join's deleted row needs no marker change. @@ -5345,18 +5339,19 @@ async function runEagerWindow(testCase: EagerCase): Promise<{ if (testCase.path !== `local-only`) { sourceWrite(`update`, next) } else { - await source.update(command.id, (draft) => { - draft.eligible = next.eligible - draft.link = next.link - }).isPersisted.promise + await source + .update(command.id, (draft) => { + draft.eligible = next.eligible + draft.link = next.link + }) + .when(`settled`) } await setMarker(command.id, eligible) } else if (command.type === `dup`) { if (!(joined || fullJoin) || viaLink || !before) continue if (markersOf(before.link ?? before.id).length === 0) continue if (ledgerHasRoot(before.link ?? before.id)) noteChange() - await markers.insert(marker(before.link ?? before.id)).isPersisted - .promise + await markers.insert(marker(before.link ?? before.id)).when(`settled`) } else if (command.type === `touch`) { if (!before) continue const next = { ...before, label: `touched-${step}` } @@ -5364,9 +5359,11 @@ async function runEagerWindow(testCase: EagerCase): Promise<{ if (testCase.path !== `local-only`) { sourceWrite(`update`, next) } else { - await source.update(command.id, (draft) => { - draft.label = next.label - }).isPersisted.promise + await source + .update(command.id, (draft) => { + draft.label = next.label + }) + .when(`settled`) } } else { const next = { @@ -5380,13 +5377,15 @@ async function runEagerWindow(testCase: EagerCase): Promise<{ if (testCase.path !== `local-only`) { sourceWrite(before ? `update` : `insert`, next) } else if (before) { - await source.update(command.id, (draft) => { - draft.rank = next.rank - draft.eligible = next.eligible - draft.link = next.link - }).isPersisted.promise + await source + .update(command.id, (draft) => { + draft.rank = next.rank + draft.eligible = next.eligible + draft.link = next.link + }) + .when(`settled`) } else { - await source.insert(next).isPersisted.promise + await source.insert(next).when(`settled`) } await setMarker(command.id, next.eligible) } @@ -6097,7 +6096,7 @@ async function checkOptimisticWindow( else if (shape !== `source`) draft.link = -draft.id }) let settlementOutcome: `fulfilled` | `rejected` = `fulfilled` - settled = transaction.isPersisted.promise.then( + settled = transaction.when(`settled`).then( () => undefined, () => { settlementOutcome = `rejected` @@ -6195,7 +6194,7 @@ async function checkOptimisticWindow( .map(copyOptimisticWindowPublication) await awaitOptimisticCheckpoint( `marker restoration`, - restoration.isPersisted.promise, + restoration.when(`settled`), ) } @@ -7421,7 +7420,7 @@ describe(`eager indexed ordered windows`, () => { ]) if (outcome === `acknowledge`) persistence.resolve() else persistence.reject(new Error(`rollback`)) - await transaction.isPersisted.promise.catch(() => undefined) + await transaction.when(`settled`).catch(() => undefined) expect(ids(), `settled installed rows`).toEqual( outcome === `acknowledge` ? expected : initial, ) diff --git a/packages/db/tests/query/pagination-oracle.property.test.ts b/packages/db/tests/query/pagination-oracle.property.test.ts index 4a87646e25..ce2a0ddaec 100644 --- a/packages/db/tests/query/pagination-oracle.property.test.ts +++ b/packages/db/tests/query/pagination-oracle.property.test.ts @@ -1901,7 +1901,6 @@ async function runPendingMutationScenario( `$collectionId`, `$origin`, `$hasPendingWrites`, - `$synced`, ].includes(key), ), ) @@ -2475,13 +2474,9 @@ async function runPendingHistoryScenario( Object.fromEntries( Object.entries(row).filter( ([key]) => - ![ - `$key`, - `$collectionId`, - `$origin`, - `$hasPendingWrites`, - `$synced`, - ].includes(key), + ![`$key`, `$collectionId`, `$origin`, `$hasPendingWrites`].includes( + key, + ), ), ) const capture = ( @@ -5595,7 +5590,7 @@ describe(`pagination recomputation oracle`, () => { } const receipt = sourceOps.commit() heldMutation.resolve() - await Promise.all(transactions.map((tx) => tx.isPersisted.promise)) + await Promise.all(transactions.map((tx) => tx.when(`settled`))) await receipt await flushPromises() expect([...live.values()].map(({ id }) => id)).toEqual(expected) @@ -5750,7 +5745,7 @@ describe(`pagination recomputation oracle`, () => { } const receipt = sourceOps.commit() heldMutation.resolve() - await Promise.all(transactions.map((tx) => tx.isPersisted.promise)) + await Promise.all(transactions.map((tx) => tx.when(`settled`))) await receipt await windowResult await flushPromises() diff --git a/packages/db/tests/query/pooled-live-query-oracle.property.test.ts b/packages/db/tests/query/pooled-live-query-oracle.property.test.ts index 0f10d28c43..64e62362f1 100644 --- a/packages/db/tests/query/pooled-live-query-oracle.property.test.ts +++ b/packages/db/tests/query/pooled-live-query-oracle.property.test.ts @@ -584,7 +584,7 @@ function describeFields(row: Record) { } function describeRow(row: Record) { - return `${describeFields(row)}:${String(row.$synced)}` + return `${describeFields(row)}:${String(row.$hasPendingWrites)}` } type Change = ChangeMessage, string | number> @@ -961,7 +961,7 @@ async function runHistory(history: History, reach?: Reach): Promise { draft.g = op.g }) : source.delete(key) - const persisted = transaction.isPersisted.promise.catch(() => undefined) + const persisted = transaction.when(`settled`).catch(() => undefined) check(`${checkpoint} pending`) if (cleanupFirst) { // The write settles after cleanup, before the restart. The mock diff --git a/packages/db/tests/query/scheduler.test.ts b/packages/db/tests/query/scheduler.test.ts index cea6cfba98..76a82bee8e 100644 --- a/packages/db/tests/query/scheduler.test.ts +++ b/packages/db/tests/query/scheduler.test.ts @@ -581,7 +581,6 @@ describe(`live query scheduler`, () => { $key, $origin, $hasPendingWrites, - $synced, }: UserWithVirtual): UserWithVirtual => ({ id, name: userName, @@ -589,7 +588,6 @@ describe(`live query scheduler`, () => { $key, $origin, $hasPendingWrites, - $synced, }) const source = createCollection({ id: `falsy-row-listener-${name.replaceAll(` `, `-`)}`, @@ -680,7 +678,6 @@ describe(`live query scheduler`, () => { $key: 1, $origin: `remote`, $hasPendingWrites: false, - $synced: true, } return { changes: [ diff --git a/packages/db/tests/query/virtual-row-fields-grammar-oracle.ts b/packages/db/tests/query/virtual-row-fields-grammar-oracle.ts new file mode 100644 index 0000000000..f54e49c521 --- /dev/null +++ b/packages/db/tests/query/virtual-row-fields-grammar-oracle.ts @@ -0,0 +1,49 @@ +/** + * Shared authoring dimensions for the runtime and public type metadata owners. + * A projection description names user syntax, never a production classifier. + * Composition changes where that same syntax is consumed. Each receiver owns + * its model and legal boundary-specific extensions. + */ +import { declareOracle } from '../oracle-declaration.js' + +export const metadataProjectionShapes = [ + `structured-projection`, + `structured-spread`, + `functional-projection`, + `functional-spread`, + `functional-direct`, +] as const +export const metadataCompositions = [ + `direct`, + `query-ref`, + `nested-query-ref`, + `joined-query-ref`, + `functional-query-ref`, + `union`, + `union-last`, + `union-query-ref`, +] as const + +// Authored collisions are input syntax, not a classification of production +// output. Both booleans distinguish a genuine source flag from an authored one. +export const metadataCollisionCases = [ + `none`, + `$key`, + `$hasPendingWrites`, + `$origin`, + `$collectionId`, + `all`, +].flatMap((collision) => + (collision === `$hasPendingWrites` || collision === `all` + ? [false, true] + : [false] + ).map((authoredPending) => ({ collision, authoredPending })), +) + +declareOracle({ + grammar: [ + metadataProjectionShapes, + metadataCompositions, + metadataCollisionCases, + ], +}) diff --git a/packages/db/tests/query/virtual-row-fields-oracle.test.ts b/packages/db/tests/query/virtual-row-fields-oracle.test.ts index 8548e1cfef..1e694938c2 100644 --- a/packages/db/tests/query/virtual-row-fields-oracle.test.ts +++ b/packages/db/tests/query/virtual-row-fields-oracle.test.ts @@ -2,10 +2,12 @@ * # Which published values carry virtual row fields? * * Law and source: `VirtualRowProps` gives Collection and live-query row roots - * `$hasPendingWrites`, `$synced`, `$origin`, `$key`, and `$collectionId`. The output contract + * `$hasPendingWrites`, `$origin`, `$key`, and `$collectionId`. The output contract * established by `includes.test.ts` keeps inline `toArray` and `materialize` * selections in their selected shape instead of publishing each selected value - * as a Collection row. + * as a Collection row. Whole-row roots retain source metadata; ordinary + * projections receive the live-query Collection's metadata. An explicit + * selected virtual name cannot replace source metadata at root publication. * * `expectsVirtualFields` is the independent model: row roots and unprojected * whole-row children are rows. Projected children, nested values, and opaque @@ -13,10 +15,24 @@ * A directly selected unmatched nullable row is `{}` because direct selection * merges the source row into a new object. Its known row fields are optional. * + * The attribution grammar crosses direct queries, QueryRef depths one/two, + * joined and functional QueryRef consumers, both union branch positions and + * a union consumed through a QueryRef. Null later spreads contribute nothing. + * Every form observes the same metadata law at preload, pending and settlement. + * The 1,056 attribution histories compare two rows, then remove the second. + * Half configure public keys whose expected values are computed independently. + * The other half compare compiler-generated keys with a clean sibling only as + * supplemental evidence. Another 264 histories populate both union branches + * with numeric 1 and string "1", hold either branch's update, and delete/reinsert + * the peer. Full observations preserve every ordinary field and occurrence. + * * The legal query forms exercised here are expression-projected, unprojected, * and functional root rows; unprojected and directly selected whole-row - * children; an unmatched left-joined whole row; and object, nested-object, - * array, `findOne`, and Date child projections. The production + * children; an unmatched left-joined whole row; matched and unmatched + * whole-row joins, including an extended spread and both orders of two source + * spreads; a Collection-valued child with colliding selected metadata + * and its inline-value control; and object, nested-object, array, `findOne`, + * and Date child projections. The production * driver calls `createLiveQueryCollection`, `preload`, `toArray`, and * `materialize`. The checkpoint is `live.toArray` after `preload` resolves. * @@ -24,29 +40,61 @@ * `hasVirtualProps`, so a missing field cannot hide behind the production * classifier. Exact `$key`, selected shapes, and nonempty child results prove * the intended paths ran. This oracle - * does not cover matched join shapes, ordering, updates after publication, - * framework receiving boundaries, or sync-state transitions. The root-shape + * does not cover contractual ordering, arbitrary joined projections, framework + * receiving boundaries or arbitrary ordering. The later timeline grammar adds + * changing left-join matches, independent-source overlapping/rejected writes, + * callback payloads and downstream metadata observations. Same-source overlap, + * functional callback inputs remain separate work. The facade grammar below + * adds shared/empty buckets, retirement and reactivation across child settlement. + * The selected joined row is compared with the joined source's public row at + * preload, pending-write, and settled cuts; a FROM-source selection, two + * opposite spread orders, and an unmatched RIGHT join distinguish a + * main-source, first-spread, or last-join shortcut. The + * projection grammar compares source metadata for direct source returns and + * structured spreads, and output Collection metadata for new objects, including + * functional spreads (maintainer decision). Virtual-name collisions cannot + * alter attribution. Supplemental facade and inline witnesses live in + * `virtual-row-fields.test.ts`. The root-shape * matrix rejects the wrong rule that only expression projections receive * virtual row fields. Prior hostile controls made projected-value * enrichment and a missing whole-row classification fail at these * observations. `checkWithCleanup` retains the primary mismatch as the cause * when Collection cleanup also fails and attempts every cleanup. */ -import { describe, expect, test } from 'vitest' +import { afterAll, describe, expect, test, vi } from 'vitest' import { + Query, createLiveQueryCollection, eq, + include, materialize, toArray, } from '../../src/query/index.js' import { createCollection } from '../../src/collection/index.js' import { hasVirtualProps } from '../../src/virtual-props.js' +import { BTreeIndex } from '../../src/indexes/btree-index.js' import { declareOracle } from '../oracle-declaration.js' +import { createOracleReach } from '../oracle-reach.js' import { mockSyncCollectionOptions } from '../utils.js' +import { + metadataCollisionCases, + metadataCompositions, + metadataProjectionShapes, +} from './virtual-row-fields-grammar-oracle.js' import { checkWithCleanup, virtualFieldNames, } from './virtual-row-fields-support.js' +import { expectedSourceMetadata } from './virtual-row-fields-source-oracle.js' +import { + advanceFacadeIntervals, + checkFacadeIntervals, +} from './includes-collection-driver-oracle.js' +import type { QueryBuilder } from '../../src/query/builder/index.js' +import type { + FacadeIntervals, + MetadataFacade, +} from './includes-collection-driver-oracle.js' type Profile = { label: string } @@ -96,10 +144,391 @@ function expectPublishedFieldClassification( } declareOracle({ - check: [expectPublishedFieldClassification], - model: [expectsVirtualFields], + check: [ + expectPublishedFieldClassification, + checkRootMetadata, + checkAttributedRows, + ], + model: [expectsVirtualFields, sourceOwnsMetadata, expectedAttributedRows], }) +// The independent attribution law uses authoring shape, never selected values. +function sourceOwnsMetadata(shape: string): boolean { + return shape === `structured-spread` || shape === `functional-direct` +} + +function checkRootMetadata( + actual: Record<(typeof virtualFieldNames)[number], unknown>, + expected: Record<(typeof virtualFieldNames)[number], unknown>, +): void { + for (const field of virtualFieldNames) + expect(actual[field], field).toBe(expected[field]) +} + +type AttributionKey = string | number +type AttributionRow = { id: AttributionKey; parentId: string; label: string } +// The finite grammar varies compiler context types, but every accepted root +// has these ordinary fields. Erase only that context union at the driver edge; +// the full runtime shape remains independently checked below. +type AttributionContext = { + schema: {} + baseSchema: {} + fromSourceName: string + result: { id: AttributionKey; label: string } + hasResult: true +} +// Model rows are authored source facts, never sampled from the production +// Collection. Pending overlays affect only their named key; settlement without +// source confirmation restores the authored base (optimistic-history contract). +function expectedAttributedRows( + rows: ReadonlyArray, + shape: string, + pending: ReadonlySet, + sourceId: string, + outputId: string, + outputKey: (id: AttributionKey) => unknown, +) { + return rows.map((row) => ({ + ordinary: + shape === `structured-projection` + ? { + id: row.id, + label: row.label, + hasPendingWrites: pending.has(row.id), + } + : shape === `functional-projection` + ? { id: row.id, label: row.label } + : { ...row }, + metadata: sourceOwnsMetadata(shape) + ? expectedSourceMetadata(row.id, sourceId, pending.has(row.id)) + : { + $key: outputKey(row.id), + $collectionId: outputId, + $hasPendingWrites: false, + $origin: `remote`, + }, + })) +} +function checkAttributedRows( + actual: ReadonlyArray, + expected: ReturnType, +): void { + const observed = actual.map((row) => ({ + ordinary: Object.fromEntries( + Object.entries(row).filter( + ([name]) => !virtualFieldNames.some((field) => field === name), + ), + ), + metadata: Object.fromEntries( + virtualFieldNames.map((field) => [ + field, + (row as Record)[field], + ]), + ), + })) + const identity = (row: { ordinary: object }) => { + const { id } = row.ordinary as { id: unknown } + return `${typeof id}:${String(id)}` + } + const byId = (a: { ordinary: object }, b: { ordinary: object }) => + identity(a).localeCompare(identity(b)) + // Sorting permits unspecified result order but preserves every occurrence, + // field and extra row. No Map can collapse duplicate contributions here. + expect(observed.sort(byId)).toEqual([...expected].sort(byId)) +} + +const metadataReach = createOracleReach(`root metadata`, [ + `joined-pending-source`, + `single-pending-source`, + `colliding-projection`, + `functional-direct`, + `query-ref-pending`, + `union-pending`, + `null-later-spread`, + `multi-row`, + `independent-output-key`, + `non-first-row-deleted`, + `two-populated-union-branches`, + `typed-union-keys`, + `union-peer-reinserted`, + `output-rejects-authored-pending-true`, +]) +afterAll(() => { + metadataReach.assertReached() + unionMetadataReach.assertReached() + if (process.env.TANSTACK_DB_ORACLE_STATISTICS === `1`) { + console.info( + `root metadata reach`, + Object.fromEntries(metadataReach.counts()), + ) + console.info( + `populated union reach`, + Object.fromEntries(unionMetadataReach.counts()), + ) + } +}) + +// Both boolean values are legal authored collisions. True distinguishes an +// output-owned row's mandatory false pending flag; false distinguishes a +// pending source-owned row. Use a legal local origin to challenge remote roots. +const collisionCases = metadataCollisionCases + +/** + * Projection shape controls attribution, not the spelling of selected fields. + * An authored new object has its own live-query metadata; a direct source or + * structured spread retains source metadata. A functional spread is a new + * object (maintainer decision). The finite grammar crosses all four reserved + * collisions and their union with joined and single sources. The clean sibling + * supplies only the compiler-generated key; Collection IDs and pending status + * come from the independent shape rule. A held source update distinguishes + * source metadata from output metadata before and after settlement. + */ +test.each( + [false, true].flatMap((joined) => + collisionCases.flatMap(({ collision, authoredPending }) => + metadataProjectionShapes + .filter( + (shape) => shape !== `functional-direct` || collision === `none`, + ) + .flatMap((shape) => + metadataCompositions.flatMap((composition) => + [false, true].map((customKey) => ({ + joined, + collision, + authoredPending, + shape, + composition, + customKey, + })), + ), + ), + ), + ), +)( + `attributes root metadata by projection shape: %j`, + async ({ + joined, + collision, + authoredPending, + shape, + composition, + customKey, + }) => { + metadataReach.beginHistory() + const parents = createCollection( + mockSyncCollectionOptions<{ id: string }>({ + id: `metadata-parent`, + getKey: (row) => row.id, + initialData: [{ id: `parent` }], + }), + ) + const rows = createCollection( + mockSyncCollectionOptions<{ + id: string + parentId: string + label: string + }>({ + id: `metadata-child`, + getKey: (row) => row.id, + initialData: [ + { id: `child`, parentId: `parent`, label: `before` }, + { id: `peer`, parentId: `parent`, label: `untouched` }, + ], + }), + ) + const overrides: Record = {} + for (const field of virtualFieldNames) { + if (collision === field || collision === `all`) + overrides[field] = + field === `$hasPendingWrites` + ? authoredPending + : field === `$origin` + ? `local` + : `spoof` + } + const selectedQuery = (() => { + const q = new Query() + const base = joined + ? q + .from({ parent: parents }) + .innerJoin({ chosen: rows }, ({ parent, chosen }) => + eq(parent.id, chosen.parentId), + ) + : q.from({ chosen: rows }) + if (shape === `functional-direct`) + return base.fn.select(({ chosen }) => chosen) + if (shape === `functional-spread`) + return base.fn.select(({ chosen }) => ({ ...chosen, ...overrides })) + if (shape === `functional-projection`) + return base.fn.select(({ chosen }) => ({ + id: chosen.id, + label: chosen.label, + ...overrides, + })) + if (shape === `structured-spread`) + return base.select(({ chosen }) => ({ ...chosen, ...overrides })) + return base.select(({ chosen }) => ({ + id: chosen.id, + label: chosen.label, + hasPendingWrites: chosen.$hasPendingWrites, + ...overrides, + })) + })() + const compose = (query: typeof selectedQuery) => { + if (composition === `direct`) return query + if (composition.startsWith(`union`)) { + const emptyBranch = new Query() + .from({ empty: parents }) + .where(({ empty }) => eq(empty.id, `absent`)) + .select(({ empty }) => ({ id: empty.id, label: empty.id })) + const union = + composition === `union-last` + ? new Query().unionAll(emptyBranch, query) + : new Query().unionAll(query, emptyBranch) + return composition === `union-query-ref` + ? new Query().from({ union }) + : union + } + if (composition === `joined-query-ref`) + return new Query() + .from({ peer: rows }) + .innerJoin({ nested: query }, ({ peer, nested }) => + eq(peer.id, nested.id), + ) + .select(({ nested }) => nested) + const once = new Query().from({ nested: query }) + if (composition === `functional-query-ref`) + return once.fn.select(({ nested }) => nested) + return composition === `query-ref` + ? once + : new Query().from({ outer: once }) + } + const sourceOwned = sourceOwnsMetadata(shape) + const live = createLiveQueryCollection({ + query: () => + compose(selectedQuery) as unknown as QueryBuilder, + ...(customKey + ? { + getKey: (row: { id: AttributionKey }) => + sourceOwned ? row.id : `projected-${row.id}`, + } + : {}), + }) + const clean = createLiveQueryCollection(() => + compose( + (() => { + const q = new Query() + const base = joined + ? q + .from({ parent: parents }) + .innerJoin({ chosen: rows }, ({ parent, chosen }) => + eq(parent.id, chosen.parentId), + ) + : q.from({ chosen: rows }) + return base.select(({ chosen }) => ({ + id: chosen.id, + label: chosen.label, + })) + })(), + ), + ) + let truth: Array = [ + { id: `child`, parentId: `parent`, label: `before` }, + { id: `peer`, parentId: `parent`, label: `untouched` }, + ] + const pendingKeys = new Set() + let finishMutation = async () => {} + const check = () => { + // The custom-key lane has a completely independent key prediction. The + // default lane retains the old clean-sibling key comparison only as a + // differential check of undocumented compiler-generated key encoding. + const outputKey = (id: AttributionKey) => + customKey + ? `projected-${id}` + : clean.toArray.find((row) => `id` in row && row.id === id)!.$key + checkAttributedRows( + live.toArray, + expectedAttributedRows( + truth, + shape, + pendingKeys, + rows.id, + live.id, + outputKey, + ), + ) + if (customKey) + expect([...live.keys()].sort()).toEqual( + truth + .map((row) => (sourceOwned ? row.id : `projected-${row.id}`)) + .sort(), + ) + } + await checkWithCleanup(async () => { + await Promise.all([live.preload(), clean.preload()]) + check() + if ( + !sourceOwned && + authoredPending && + (collision === `$hasPendingWrites` || collision === `all`) + ) + metadataReach.reach(`output-rejects-authored-pending-true`) + metadataReach.reach(`multi-row`) + if (customKey && !sourceOwned) + metadataReach.reach(`independent-output-key`) + truth = truth.map((row) => + row.id === `child` ? { ...row, label: `pending` } : row, + ) + pendingKeys.add(`child`) + const mutation = rows.update(`child`, (row) => { + row.label = `pending` + }) + finishMutation = async () => { + rows.utils.resolveSync() + finishMutation = async () => {} + await mutation.when(`settled`) + } + await vi.waitFor(() => + expect(live.toArray.find((row) => row.id === `child`)).toMatchObject({ + label: `pending`, + }), + ) + expect(rows.get(`child`)!.$hasPendingWrites).toBe(true) + metadataReach.reach( + joined ? `joined-pending-source` : `single-pending-source`, + ) + if (collision !== `none`) metadataReach.reach(`colliding-projection`) + if (shape === `functional-direct`) + metadataReach.reach(`functional-direct`) + if (composition.startsWith(`union`)) metadataReach.reach(`union-pending`) + if (composition.endsWith(`query-ref`)) + metadataReach.reach(`query-ref-pending`) + check() + await finishMutation() + pendingKeys.clear() + truth = truth.map((row) => + row.id === `child` ? { ...row, label: `before` } : row, + ) + check() + rows.utils.begin() + rows.utils.write({ + type: `delete`, + value: { id: `peer`, parentId: `parent`, label: `untouched` }, + }) + rows.utils.commit() + truth = truth.filter((row) => row.id !== `peer`) + check() + metadataReach.reach(`non-first-row-deleted`) + }, [ + () => finishMutation(), + () => live.cleanup(), + () => clean.cleanup(), + () => rows.cleanup(), + () => parents.cleanup(), + ]) + }, +) + describe(`virtual row field runtime boundary`, () => { test(`matches the row and value classification after publication`, async () => { const createdAt = new Date(`2026-09-20T12:34:56.000Z`) @@ -259,13 +688,9 @@ describe(`virtual row field runtime boundary`, () => { expectPublishedFieldClassification(observations) expect(result.$key).toBe(`row-1`) - expect(result.$hasPendingWrites).toBe(!result.$synced) - expect(result.wholeRows[0]!.$hasPendingWrites).toBe( - !result.wholeRows[0]!.$synced, - ) - expect(result.selectedWholeRows[0]!.$hasPendingWrites).toBe( - !result.selectedWholeRows[0]!.$synced, - ) + expect(result.$hasPendingWrites).toBe(false) + expect(result.wholeRows[0]!.$hasPendingWrites).toBe(false) + expect(result.selectedWholeRows[0]!.$hasPendingWrites).toBe(false) expect(result.profile).toEqual({ label: `nested` }) expect(`$key` in result.profile).toBe(false) @@ -292,3 +717,1434 @@ describe(`virtual row field runtime boundary`, () => { ]) }) }) + +/** A nullish source contributes no fields to a JS spread. The last present + * structured source owns metadata, independent of join side and spread order. + * This finite grammar crosses match presence, spread order and which source + * has a held optimistic update. All checkpoints compare the same public law. + */ +test.each( + [false, true].flatMap((matched) => + [false, true].flatMap((reverse) => + [false, true].map((pendingRight) => ({ matched, reverse, pendingRight })), + ), + ), +)( + `retains metadata from the last present spread: %j`, + async ({ matched, reverse, pendingRight }) => { + metadataReach.beginHistory() + const left = createCollection( + mockSyncCollectionOptions<{ id: string; label: string }>({ + id: `spread-left`, + getKey: (row) => row.id, + initialData: [{ id: `left`, label: `left` }], + }), + ) + const right = createCollection( + mockSyncCollectionOptions<{ + id: string + parentId: string + label: string + }>({ + id: `spread-right`, + getKey: (row) => row.id, + initialData: [ + { + id: `right`, + parentId: matched ? `left` : `missing`, + label: `right`, + }, + ], + }), + ) + const live = createLiveQueryCollection((q) => + q + .from({ left }) + .leftJoin({ right }, ({ left: a, right: b }) => eq(a.id, b.parentId)) + .select(({ left: a, right: b }) => + reverse ? { ...b, ...a } : { ...a, ...b }, + ), + ) + const owner = matched && !reverse ? right : left + const key = owner === right ? `right` : `left` + const check = () => checkRootMetadata(live.toArray[0]!, owner.get(key)!) + await checkWithCleanup(async () => { + await live.preload() + check() + const mutation = pendingRight + ? right.update(`right`, (row) => { + row.label = `pending` + }) + : left.update(`left`, (row) => { + row.label = `pending` + }) + await vi.waitFor(() => + expect( + (pendingRight ? right.get(`right`) : left.get(`left`))! + .$hasPendingWrites, + ).toBe(true), + ) + if (!matched && !reverse) metadataReach.reach(`null-later-spread`) + check() + ;(pendingRight ? right : left).utils.resolveSync() + await mutation.when(`settled`) + check() + }, [() => live.cleanup(), () => left.cleanup(), () => right.cleanup()]) + }, +) + +/** + * Populated union branches retain both contributions, including numeric 1 and + * string "1". Equal labels cannot stand in for row identity. Branch order, + * QueryRef consumption, projection shape and collisions vary independently. + * A held write in either branch must not change the other's metadata. Removing + * and reinserting that peer checks retractions and subsequent contributions. + * Each checkpoint reuses the authored-facts model; no sibling query supplies + * expected keys in this lane. + */ +const unionHistories = [false, true].flatMap((reverse) => + [false, true].flatMap((nested) => + [false, true].flatMap((pendingRight) => + collisionCases.flatMap(({ collision, authoredPending }) => + [ + `structured-projection`, + `structured-spread`, + `functional-projection`, + `functional-spread`, + `functional-direct`, + ] + .filter( + (shape) => shape !== `functional-direct` || collision === `none`, + ) + .map((shape) => ({ + reverse, + nested, + pendingRight, + collision, + authoredPending, + shape, + })), + ), + ), + ), +) + +// Each receiving form must reach held and post-retraction comparisons for +// both metadata policies and both pending branches. Aggregate reach cannot +// borrow a direct-output witness for a QueryRef consumer. +const unionMetadataReach = createOracleReach( + `populated union metadata`, + [`direct`, `query-ref`].flatMap((path) => + [`source`, `output`].flatMap((owner) => + [`left`, `right`].flatMap((pending) => + [`held`, `reinserted`, `settled`].map( + (cut) => `${path}/${owner}/${pending}/${cut}`, + ), + ), + ), + ), +) + +test.each(unionHistories)( + `preserves each populated union branch: %j`, + async ({ + reverse, + nested, + pendingRight, + collision, + authoredPending, + shape, + }) => { + metadataReach.beginHistory() + unionMetadataReach.beginHistory() + const reach = (cut: string) => + unionMetadataReach.reach( + `${nested ? `query-ref` : `direct`}/${sourceOwnsMetadata(shape) ? `source` : `output`}/${pendingRight ? `right` : `left`}/${cut}`, + ) + const initialLeft: AttributionRow = { + id: 1, + parentId: `parent`, + label: `same`, + } + const initialRight: AttributionRow = { + id: `1`, + parentId: `parent`, + label: `same`, + } + const left = createCollection( + mockSyncCollectionOptions({ + id: `union-left`, + getKey: (row) => row.id, + initialData: [{ ...initialLeft }], + }), + ) + const right = createCollection( + mockSyncCollectionOptions({ + id: `union-right`, + getKey: (row) => row.id, + initialData: [{ ...initialRight }], + }), + ) + const overrides: Record = {} + for (const field of virtualFieldNames) + if (collision === field || collision === `all`) + overrides[field] = + field === `$hasPendingWrites` + ? authoredPending + : field === `$origin` + ? `local` + : `spoof` + const branch = (source: typeof left, alias: string) => { + const base = new Query().from({ [alias]: source }) + if (shape === `functional-direct`) + return base.fn.select((refs) => refs[alias]!) + if (shape === `functional-spread`) + return base.fn.select((refs) => ({ ...refs[alias]!, ...overrides })) + if (shape === `functional-projection`) + return base.fn.select((refs) => ({ + id: refs[alias]!.id, + label: refs[alias]!.label, + ...overrides, + })) + if (shape === `structured-spread`) + return base.select((refs) => ({ ...refs[alias]!, ...overrides })) + return base.select((refs) => ({ + id: refs[alias]!.id, + label: refs[alias]!.label, + hasPendingWrites: refs[alias]!.$hasPendingWrites, + ...overrides, + })) + } + const a = branch(left, `leftBranch`) + const b = branch(right, `rightBranch`) + const union = reverse + ? new Query().unionAll(b, a) + : new Query().unionAll(a, b) + const selected = nested ? new Query().from({ union }) : union + const keyOf = (id: AttributionKey) => `${typeof id}:${id}` + const live = createLiveQueryCollection({ + query: () => selected as unknown as QueryBuilder, + getKey: (row) => (sourceOwnsMetadata(shape) ? row.id : keyOf(row.id)), + }) + let leftTruth = [{ ...initialLeft }] + let rightTruth = [{ ...initialRight }] + const pendingKeys = new Set() + const check = () => + checkAttributedRows(live.toArray, [ + ...expectedAttributedRows( + leftTruth, + shape, + pendingKeys, + left.id, + live.id, + keyOf, + ), + ...expectedAttributedRows( + rightTruth, + shape, + pendingKeys, + right.id, + live.id, + keyOf, + ), + ]) + const pendingSource = pendingRight ? right : left + const pendingId = pendingRight ? initialRight.id : initialLeft.id + const peer = pendingRight ? left : right + const peerInitial = pendingRight ? initialLeft : initialRight + let finishMutation = async () => {} + await checkWithCleanup(async () => { + await live.preload() + check() + metadataReach.reach(`two-populated-union-branches`) + metadataReach.reach(`typed-union-keys`) + pendingKeys.add(pendingId) + if (pendingRight) rightTruth = [{ ...initialRight, label: `pending` }] + else leftTruth = [{ ...initialLeft, label: `pending` }] + const mutation = pendingSource.update(pendingId, (row) => { + row.label = `pending` + }) + finishMutation = async () => { + pendingSource.utils.resolveSync() + finishMutation = async () => {} + await mutation.when(`settled`) + } + await vi.waitFor(() => + expect(pendingSource.get(pendingId)!.$hasPendingWrites).toBe(true), + ) + check() + reach(`held`) + peer.utils.begin() + peer.utils.write({ type: `delete`, value: { ...peerInitial } }) + peer.utils.commit() + if (pendingRight) leftTruth = [] + else rightTruth = [] + check() + peer.utils.begin() + peer.utils.write({ + type: `insert`, + value: { ...peerInitial, label: `returned` }, + }) + peer.utils.commit() + if (pendingRight) leftTruth = [{ ...initialLeft, label: `returned` }] + else rightTruth = [{ ...initialRight, label: `returned` }] + check() + metadataReach.reach(`union-peer-reinserted`) + reach(`reinserted`) + await finishMutation() + pendingKeys.clear() + if (pendingRight) rightTruth = [{ ...initialRight }] + else leftTruth = [{ ...initialLeft }] + check() + reach(`settled`) + }, [ + () => finishMutation(), + () => live.cleanup(), + () => left.cleanup(), + () => right.cleanup(), + ]) + }, +) + +// Calibration feeds allowed and hostile full results through the very same +// checker. It must see faults away from the first row and retain multiplicity. +test.each( + [`functional-projection`, `structured-spread`].flatMap((shape) => + [ + `missing-peer`, + `duplicate-peer`, + `missing-id`, + `wrong-key`, + `wrong-owner`, + `stale-pending`, + `wrong-origin`, + ].map((fault) => ({ shape, fault })), + ), +)(`rejects a hostile complete metadata result: %j`, ({ shape, fault }) => { + const keyOf = (id: AttributionKey) => `key-${typeof id}:${id}` + const expected = [ + ...expectedAttributedRows( + [{ id: 1, parentId: `parent`, label: `same` }], + shape, + new Set(), + `left`, + `output`, + keyOf, + ), + ...expectedAttributedRows( + [{ id: `1`, parentId: `parent`, label: `same` }], + shape, + new Set([`1`]), + `right`, + `output`, + keyOf, + ), + ] + const allowed = expected.map((row) => ({ ...row.ordinary, ...row.metadata })) + checkAttributedRows(allowed, expected) + // A different legal output order remains accepted. + checkAttributedRows([...allowed].reverse(), expected) + const hostile: Array> = allowed.map((row) => ({ + ...row, + })) + if (fault === `missing-peer`) hostile.pop() + if (fault === `duplicate-peer`) hostile.push({ ...hostile[1]! }) + if (fault === `missing-id`) delete hostile[1]!.id + if (fault === `wrong-key`) hostile[1]!.$key = `wrong` + if (fault === `wrong-owner`) hostile[1]!.$collectionId = `left` + if (fault === `stale-pending`) + hostile[1]!.$hasPendingWrites = !expected[1]!.metadata.$hasPendingWrites + if (fault === `wrong-origin`) + hostile[1]!.$origin = + expected[1]!.metadata.$origin === `local` ? `remote` : `local` + expect(() => checkAttributedRows(hostile, expected)).toThrow() +}) + +/** + * # Can attribution follow a changing join and independent write outcomes? + * + * The last present structured spread owns metadata at every publication. A new + * functional object owns output metadata; direct functional returns own their + * selected source metadata. The maintainer decision and ARCHITECTURE's + * publication law apply at each callback, not just after a later microtask. + * + * This model recomputes a left join from authored arrays. Optimistic snapshots + * are separate authored rows. Settling either source removes only its overlay. + * A same-key sync update accepted during persistence applies at settlement: + * successful work attributes that first queued write locally; rejected work + * does not (optimistic-history-oracle.ts). No production attribution machinery + * participates in this judgment. + * + * The finite histories cross join-key/membership topology changes, spread + * order, five projection shapes, either settlement order and independent + * success/rejection with distinct caller errors. A held left write spans unmatched -> matched -> + * unmatched -> matched. A right write then overlaps it and receives a queued + * authoritative update. An untouched parent checks isolation. These bounds do + * not cover same-source overlapping writes, truncate or provider scheduling. + */ +type MetadataResult = ReturnType[number] +function expectedJoinedMetadata( + leftRows: ReadonlyArray, + rightRows: ReadonlyArray, + shape: string, + reverse: boolean, + pending: ReadonlySet, + rightOrigin: `local` | `remote`, + leftId: string, + rightId: string, + outputId: string, +): Array { + return leftRows.flatMap((a) => { + const matches = rightRows.filter((b) => b.parentId === a.id) + return (matches.length ? matches : [undefined]).map((b) => { + const spread = reverse ? { ...b, ...a } : { ...a, ...b } + const owner = shape === `functional-direct` || reverse || !b ? a : b + const selected = + shape.endsWith(`projection`) || shape === `functional-direct` + ? a + : spread + const expected = expectedAttributedRows( + [selected], + shape, + pending, + owner === b ? rightId : leftId, + outputId, + (id) => `projection-${id}`, + )[0]! + if (sourceOwnsMetadata(shape) && owner === b && !pending.has(b.id)) + expected.metadata.$origin = rightOrigin + return expected + }) + }) +} + +// Capture all row fields now. Retaining production row handles could let a +// later metadata getter or mutation rewrite evidence from an earlier callback. +function captureMetadataRows(rows: ReadonlyArray): Array { + return rows.map((row) => ({ + ...row, + ...Object.fromEntries( + virtualFieldNames.map((field) => [ + field, + (row as Record)[field], + ]), + ), + })) +} +type MetadataBatch = { + rows: Array + changes: Array<{ + type: string + key: AttributionKey + value: object + previousValue?: object + }> +} +function publishedModelRows(rows: ReadonlyArray) { + return rows.map((row) => ({ ...row.ordinary, ...row.metadata })) +} +// Collection change messages are the exact before/after delta for one atomic +// publication. Their ordering is unspecified; duplicate or omitted messages +// remain visible because neither side collapses actual messages into a Map. +function expectedMetadataChanges( + before: ReadonlyArray, + after: ReadonlyArray, +) { + const changes: MetadataBatch[`changes`] = [] + const same = (a: MetadataResult, b: MetadataResult) => + JSON.stringify(a) === JSON.stringify(b) + for (const row of before) { + if (!after.some((next) => next.metadata.$key === row.metadata.$key)) + changes.push({ + type: `delete`, + key: row.metadata.$key as AttributionKey, + value: publishedModelRows([row])[0]!, + }) + } + for (const row of after) { + const previous = before.find( + (old) => old.metadata.$key === row.metadata.$key, + ) + if (!previous || !same(previous, row)) + changes.push({ + type: previous ? `update` : `insert`, + key: row.metadata.$key as AttributionKey, + value: publishedModelRows([row])[0]!, + ...(previous + ? { previousValue: publishedModelRows([previous])[0]! } + : {}), + }) + } + return changes +} +function checkMetadataPublications( + actual: ReadonlyArray, + before: ReadonlyArray, + after: ReadonlyArray, +): void { + const expectedChanges = expectedMetadataChanges(before, after) + if (expectedChanges.length) + expect(actual.length, `owed metadata publication`).toBeGreaterThan(0) + const byChange = ( + a: MetadataBatch[`changes`][number], + b: MetadataBatch[`changes`][number], + ) => `${typeof a.key}:${a.key}`.localeCompare(`${typeof b.key}:${b.key}`) + for (const batch of actual) { + checkAttributedRows(batch.rows, [...after]) + } + // Compare every callback's delta together. Splitting a batch stays legal; + // repeating a whole batch cannot consume the same public change twice. + expect(actual.flatMap((batch) => batch.changes).sort(byChange)).toEqual( + [...expectedChanges].sort(byChange), + ) +} +const metadataTimelineShapes = [ + `structured-spread`, + `functional-spread`, + `functional-direct`, + `structured-projection`, + `functional-projection`, +] as const +const metadataTimelineHistories = metadataTimelineShapes.flatMap((shape) => + (shape.endsWith(`spread`) ? [false, true] : [false]).flatMap((reverse) => + [`join-key`, `membership`].flatMap((topology) => + [`none`, `left`, `right`, `both`].flatMap((reject) => + [false, true].map((rightFirst) => ({ + shape, + reverse, + topology, + reject, + rightFirst, + })), + ), + ), + ), +) +const metadataTimelineReach = createOracleReach( + `metadata timeline`, + metadataTimelineShapes.flatMap((shape) => + [`join-key`, `membership`].flatMap((topology) => + [ + `rematched-held`, + `two-pending`, + `first-settled`, + `both-settled`, + `remote-followup`, + `left-rejected`, + `right-rejected`, + `both-rejected`, + `left-first`, + `right-first`, + `root-callback-held`, + `downstream-callback-held`, + ].map((cut) => `${shape}/${topology}/${cut}`), + ), + ), +) +afterAll(() => { + metadataTimelineReach.assertReached() + if (process.env.TANSTACK_DB_ORACLE_STATISTICS === `1`) + console.info( + `metadata timeline reach`, + Object.fromEntries(metadataTimelineReach.counts()), + ) +}) +declareOracle({ + model: [expectedJoinedMetadata, expectedMetadataChanges], + check: [checkMetadataPublications], + grammar: [metadataTimelineHistories], +}) + +test.each(metadataTimelineHistories)( + `publishes metadata through join and write histories: %j`, + async ({ shape, reverse, topology, reject, rightFirst }) => { + metadataTimelineReach.beginHistory() + const reach = (cut: string) => + metadataTimelineReach.reach(`${shape}/${topology}/${cut}`) + let releaseLeft!: () => void + let rejectLeft!: (reason: Error) => void + let releaseRight!: () => void + let rejectRight!: (reason: Error) => void + const leftGate = new Promise((resolve, rejectGate) => { + releaseLeft = resolve + rejectLeft = rejectGate + }) + const rightGate = new Promise((resolve, rejectGate) => { + releaseRight = resolve + rejectRight = rejectGate + }) + // Attach rejection observers before any path can abandon construction. + void leftGate.catch(() => {}) + void rightGate.catch(() => {}) + const baseLeft: Array = [ + { id: `a`, parentId: `unused`, label: `left-base` }, + { id: `anchor`, parentId: `unused`, label: `untouched` }, + ] + const rightRow: AttributionRow = { + id: `r`, + parentId: `a`, + label: `right-base`, + } + let rightBase = + topology === `join-key` ? [{ ...rightRow, parentId: `missing` }] : [] + const left = createCollection({ + ...mockSyncCollectionOptions({ + id: `timeline-left`, + getKey: (row) => row.id, + initialData: baseLeft.map((row) => ({ ...row })), + }), + onUpdate: async () => leftGate, + }) + const right = createCollection({ + ...mockSyncCollectionOptions({ + id: `timeline-right`, + getKey: (row) => row.id, + initialData: rightBase.map((row) => ({ ...row })), + }), + onUpdate: async () => rightGate, + }) + const baseQuery = new Query() + .from({ a: left }) + .leftJoin({ b: right }, ({ a, b }) => eq(a.id, b.parentId)) + const selected = + shape === `functional-direct` + ? baseQuery.fn.select(({ a }) => a) + : shape === `functional-spread` + ? baseQuery.fn.select(({ a, b }) => + reverse ? { ...b, ...a } : { ...a, ...b }, + ) + : shape === `functional-projection` + ? baseQuery.fn.select(({ a }) => ({ id: a.id, label: a.label })) + : shape === `structured-projection` + ? baseQuery.select(({ a }) => ({ + id: a.id, + label: a.label, + hasPendingWrites: a.$hasPendingWrites, + })) + : baseQuery.select(({ a, b }) => + reverse ? { ...b, ...a } : { ...a, ...b }, + ) + const root = createLiveQueryCollection({ + query: () => selected as unknown as QueryBuilder, + getKey: (row) => + sourceOwnsMetadata(shape) ? row.id : `projection-${row.id}`, + }) + const downstream = createLiveQueryCollection({ + query: (q) => + q.from({ root }).select(({ root: row }) => ({ + id: row.id, + label: row.label, + observedKey: row.$key, + observedCollectionId: row.$collectionId, + observedOrigin: row.$origin, + observedPending: row.$hasPendingWrites, + })), + getKey: (row) => `downstream-${row.id}`, + }) + const pending = new Set() + let rightOrigin: `remote` | `local` = `remote` + let leftOverlay: AttributionRow | undefined + let rightOverlay: AttributionRow | undefined + const expectedRoot = () => + expectedJoinedMetadata( + baseLeft.map((row) => + row.id === `a` && leftOverlay ? { ...leftOverlay } : { ...row }, + ), + rightOverlay + ? [{ ...rightOverlay }] + : rightBase.map((row) => ({ ...row })), + shape, + reverse, + pending, + rightOrigin, + left.id, + right.id, + root.id, + ) + const expectedDownstream = (rows: ReadonlyArray) => + rows.map((row) => ({ + ordinary: { + id: (row.ordinary as { id: AttributionKey }).id, + label: (row.ordinary as { label: string }).label, + observedKey: row.metadata.$key, + observedCollectionId: row.metadata.$collectionId, + observedOrigin: row.metadata.$origin, + observedPending: row.metadata.$hasPendingWrites, + }, + metadata: { + $key: `downstream-${(row.ordinary as { id: AttributionKey }).id}`, + $collectionId: downstream.id, + $hasPendingWrites: false, + $origin: `remote`, + }, + })) + let previous = expectedRoot() + const rootBatches: Array = [] + const downstreamBatches: Array = [] + const record = ( + collection: typeof root | typeof downstream, + batches: Array, + ) => + collection.subscribeChanges( + (changes) => + batches.push({ + rows: captureMetadataRows(collection.toArray), + changes: changes.map((change) => ({ + type: change.type, + key: change.key, + value: captureMetadataRows([change.value])[0]!, + ...(change.previousValue + ? { + previousValue: captureMetadataRows([ + change.previousValue, + ])[0]!, + } + : {}), + })), + }), + { includeInitialState: true }, + ) + let rootSubscription: ReturnType | undefined + let downstreamSubscription: ReturnType | undefined + let leftSettlement: Promise | undefined + let rightSettlement: Promise | undefined + const observe = () => { + const next = expectedRoot() + const nextDownstream = expectedDownstream(next) + checkAttributedRows(root.toArray, next) + checkAttributedRows(downstream.toArray, nextDownstream) + checkMetadataPublications(rootBatches, previous, next) + checkMetadataPublications( + downstreamBatches, + expectedDownstream(previous), + nextDownstream, + ) + if (pending.size && expectedMetadataChanges(previous, next).length) + reach(`root-callback-held`) + if ( + pending.size && + expectedMetadataChanges(expectedDownstream(previous), nextDownstream) + .length + ) + reach(`downstream-callback-held`) + rootBatches.length = 0 + downstreamBatches.length = 0 + previous = next + } + const writeRight = ( + type: `insert` | `update` | `delete`, + row: AttributionRow, + ) => { + right.utils.begin() + right.utils.write({ type, value: { ...row } }) + right.utils.commit() + } + await checkWithCleanup(async () => { + await downstream.preload() + checkAttributedRows(root.toArray, previous) + checkAttributedRows(downstream.toArray, expectedDownstream(previous)) + rootSubscription = record(root, rootBatches) + downstreamSubscription = record(downstream, downstreamBatches) + checkMetadataPublications(rootBatches, [], previous) + checkMetadataPublications( + downstreamBatches, + [], + expectedDownstream(previous), + ) + rootBatches.length = 0 + downstreamBatches.length = 0 + leftOverlay = { ...baseLeft[0]!, label: `left-pending` } + pending.add(`a`) + const leftMutation = left.update(`a`, (row) => { + row.label = `left-pending` + }) + leftSettlement = leftMutation.when(`settled`).then( + () => undefined, + (error: unknown) => error, + ) + observe() + rightBase = [{ ...rightRow }] + writeRight(topology === `join-key` ? `update` : `insert`, rightRow) + observe() + rightBase = + topology === `join-key` ? [{ ...rightRow, parentId: `missing` }] : [] + writeRight( + topology === `join-key` ? `update` : `delete`, + topology === `join-key` + ? { ...rightRow, parentId: `missing` } + : rightRow, + ) + observe() + rightBase = [{ ...rightRow }] + writeRight(topology === `join-key` ? `update` : `insert`, rightRow) + observe() + reach(`rematched-held`) + rightOverlay = { ...rightRow, label: `right-pending` } + pending.add(`r`) + const rightMutation = right.update(`r`, (row) => { + row.label = `right-pending` + }) + rightSettlement = rightMutation.when(`settled`).then( + () => undefined, + (error: unknown) => error, + ) + observe() + reach(`two-pending`) + writeRight(`update`, { ...rightRow, label: `server` }) + // Acceptance alone changes neither the applied base nor the active overlay. + observe() + const failures = { + left: new Error(`authored left rejection`), + right: new Error(`authored right rejection`), + } + const settle = async (side: `left` | `right`) => { + const fails = reject === side || reject === `both` + const failure = failures[side] + if (side === `left`) { + pending.delete(`a`) + leftOverlay = undefined + if (fails) rejectLeft(failure) + else releaseLeft() + expect(await leftSettlement).toBe(fails ? failure : undefined) + } else { + pending.delete(`r`) + rightOverlay = undefined + rightBase = [{ ...rightRow, label: `server` }] + rightOrigin = fails ? `remote` : `local` + if (fails) rejectRight(failure) + else releaseRight() + expect(await rightSettlement).toBe(fails ? failure : undefined) + } + observe() + if (fails) reach(`${side}-rejected`) + } + await settle(rightFirst ? `right` : `left`) + reach(`first-settled`) + reach(rightFirst ? `right-first` : `left-first`) + await settle(rightFirst ? `left` : `right`) + reach(`both-settled`) + if (reject === `both`) reach(`both-rejected`) + rightBase = [{ ...rightRow, label: `later` }] + rightOrigin = `remote` + writeRight(`update`, rightBase[0]!) + observe() + reach(`remote-followup`) + }, [ + async () => { + releaseLeft() + releaseRight() + await Promise.all([leftSettlement, rightSettlement]) + }, + async () => { + rootSubscription?.unsubscribe() + }, + async () => { + downstreamSubscription?.unsubscribe() + }, + () => downstream.cleanup(), + () => root.cleanup(), + () => right.cleanup(), + () => left.cleanup(), + ]) + }, +) + +// Publication calibrations corrupt an earlier callback while the final public +// answer remains correct. A final-snapshot-only check would miss these faults. +test.each([ + `stale-read`, + `stale-event`, + `new-previous-value`, + `duplicate-message`, + `duplicate-batch`, + `missing-message`, + `no-callback`, + `early-wrong-later-correct`, +] as const)(`rejects a hostile metadata publication: %s`, (fault) => { + const base: AttributionRow = { id: `a`, parentId: `parent`, label: `before` } + const before = expectedAttributedRows( + [base], + `structured-spread`, + new Set(), + `source`, + `output`, + (id) => id, + ) + const after = expectedAttributedRows( + [{ ...base, label: `pending` }], + `structured-spread`, + new Set([base.id]), + `source`, + `output`, + (id) => id, + ) + const allowed = { + rows: publishedModelRows(after), + changes: expectedMetadataChanges(before, after), + } + checkMetadataPublications([allowed], before, after) + const hostile = structuredClone(allowed) + if (fault === `stale-read`) + (hostile.rows[0] as Record).$hasPendingWrites = false + if (fault === `stale-event`) + (hostile.changes[0]!.value as Record).$origin = `remote` + if (fault === `new-previous-value`) + hostile.changes[0]!.previousValue = { ...hostile.changes[0]!.value } + if (fault === `duplicate-message`) + hostile.changes.push({ ...hostile.changes[0]! }) + if (fault === `missing-message`) hostile.changes = [] + if (fault === `early-wrong-later-correct`) + (hostile.rows[0] as Record).$collectionId = `wrong` + const trace = + fault === `no-callback` + ? [] + : fault === `duplicate-batch` + ? [allowed, structuredClone(allowed)] + : fault === `early-wrong-later-correct` + ? [hostile, allowed] + : [hostile] + expect(() => checkMetadataPublications(trace, before, after)).toThrow() +}) + +/** + * # Does child metadata survive facade sharing and a new active interval? + * + * Collection-valued includes publish child rows. Inline includes publish + * selected values, including authored reserved names. This receiving driver + * reuses the attribution model and the facade owner's active-interval model + * (includes-collection-driver-oracle.ts). It does not infer child attribution + * from the containing parent or create a second route-lifetime policy. + * + * Histories cross all projection/collision descriptions, populated/empty route + * destinations and successful/rejected unconfirmed writes. Two parents share + * one bucket. One moves, then the last moves, then a route returns while the + * child write is held. The returned interval must have a new facade with the + * current child metadata. Inline toArray/materialize siblings keep their + * authored shapes. Only single-source child keys are modeled here; composite + * compiler key encoding, facade mutation and provider scheduling are excluded. + */ +function expectedInlineMetadataValues( + rows: ReadonlyArray, + shape: string, + pending: ReadonlySet, + sourceId: string, + overrides: Readonly>, +): Array { + return rows.map((row) => { + const source = expectedAttributedRows( + [row], + `structured-spread`, + pending, + sourceId, + `unused`, + (id) => id, + )[0]! + const selected = shape.endsWith(`projection`) + ? expectedAttributedRows( + [row], + shape, + pending, + sourceId, + `unused`, + (id) => id, + )[0]!.ordinary + : { ...source.ordinary, ...source.metadata } + return shape === `functional-direct` + ? selected + : { ...selected, ...overrides } + }) +} +function checkInlineMetadataValues( + actual: ReadonlyArray, + expected: ReadonlyArray, +): void { + // These values are flat authored records. Sorting ignores unspecified order, + // but preserves every selected property and duplicate occurrence. + const byId = (a: object, b: object) => + String((a as { id: unknown }).id).localeCompare( + String((b as { id: unknown }).id), + ) + expect([...actual].sort(byId)).toEqual([...expected].sort(byId)) +} +const facadeMetadataHistories = collisionCases.flatMap( + ({ collision, authoredPending }) => + metadataTimelineShapes + .filter((shape) => shape !== `functional-direct` || collision === `none`) + .flatMap((shape) => + [false, true].flatMap((destinationPopulated) => + [false, true].map((reject) => ({ + collision, + authoredPending, + shape, + destinationPopulated, + reject, + })), + ), + ), +) +const facadeMetadataReach = createOracleReach(`facade metadata`, [ + ...metadataTimelineShapes.flatMap((shape) => + [ + `shared-held`, + `last-route-retired`, + `reactivated-held`, + `settled`, + `later-source`, + `reshared`, + `active-empty-ready`, + ].map((cut) => `${shape}/${cut}`), + ), + ...metadataTimelineShapes.flatMap((shape) => + [`facade`, `toArray`, `materialize`].flatMap((form) => + [`held`, `reactivated`].map((cut) => `${shape}/${form}/${cut}`), + ), + ), +]) +afterAll(() => { + facadeMetadataReach.assertReached() + if (process.env.TANSTACK_DB_ORACLE_STATISTICS === `1`) + console.info( + `facade metadata reach`, + Object.fromEntries(facadeMetadataReach.counts()), + ) +}) +declareOracle({ + model: [expectedInlineMetadataValues], + check: [checkInlineMetadataValues], + grammar: [facadeMetadataHistories], +}) +test.each(facadeMetadataHistories)( + `receives metadata through shared facade intervals: %j`, + async ({ + collision, + authoredPending, + shape, + destinationPopulated, + reject, + }) => { + facadeMetadataReach.beginHistory() + const reach = (cut: string) => facadeMetadataReach.reach(`${shape}/${cut}`) + let release!: () => void + let rejectWrite!: (error: Error) => void + const gate = new Promise((resolve, rejectGate) => { + release = resolve + rejectWrite = rejectGate + }) + void gate.catch(() => {}) + let parentTruth = [ + { id: `p1`, parentId: `a` }, + { id: `p2`, parentId: `a` }, + { id: `empty`, parentId: `empty` }, + ] + const initialChildren: Array = [ + { id: `c1`, parentId: `a`, label: `first` }, + { id: `c2`, parentId: `a`, label: `second` }, + ...(destinationPopulated + ? [{ id: `c3`, parentId: `b`, label: `destination` }] + : []), + ] + let childTruth = initialChildren.map((row) => ({ ...row })) + const parents = createCollection( + mockSyncCollectionOptions({ + id: `facade-metadata-parents`, + getKey: (row: { id: string; parentId: string }) => row.id, + initialData: parentTruth.map((row) => ({ ...row })), + }), + ) + const children = createCollection({ + ...mockSyncCollectionOptions({ + id: `facade-metadata-children`, + getKey: (row) => row.id, + initialData: initialChildren.map((row) => ({ ...row })), + }), + onUpdate: async () => gate, + }) + const overrides: Record = {} + for (const field of virtualFieldNames) + if (collision === field || collision === `all`) + overrides[field] = + field === `$hasPendingWrites` + ? authoredPending + : field === `$origin` + ? `local` + : `spoof` + const live = createLiveQueryCollection((q) => + q.from({ parent: parents }).select(({ parent }) => { + const childRows = () => { + const base = new Query() + .from({ child: children }) + .where(({ child }) => eq(child.parentId, parent.parentId)) + const selected = + shape === `functional-direct` + ? base.fn.select(({ child }) => child) + : shape === `functional-projection` + ? base.fn.select(({ child }) => ({ + id: child.id, + label: child.label, + ...overrides, + })) + : shape === `structured-projection` + ? base.select(({ child }) => ({ + id: child.id, + label: child.label, + hasPendingWrites: child.$hasPendingWrites, + ...overrides, + })) + : // Explicit virtual reads make the inline selected shape independent of + // whether source metadata properties happen to be enumerable. + shape === `functional-spread` + ? base.fn.select(({ child }) => ({ + ...child, + $key: child.$key, + $collectionId: child.$collectionId, + $origin: child.$origin, + $hasPendingWrites: child.$hasPendingWrites, + ...overrides, + })) + : base.select(({ child }) => ({ + ...child, + $key: child.$key, + $collectionId: child.$collectionId, + $origin: child.$origin, + $hasPendingWrites: child.$hasPendingWrites, + ...overrides, + })) + return selected as unknown as QueryBuilder + } + return { + id: parent.id, + parentId: parent.parentId, + children: include(childRows()), + array: toArray(childRows()), + materialized: materialize(childRows()), + } + }), + ) + const pending = new Set() + let intervals: FacadeIntervals = { active: new Map(), next: 0 } + const observedIntervals = new Map() + const observe = (cut?: `held` | `reactivated`) => { + expect( + live.toArray + .map(({ id, parentId }) => ({ id, parentId })) + .sort((a, b) => a.id.localeCompare(b.id)), + ).toEqual([...parentTruth].sort((a, b) => a.id.localeCompare(b.id))) + intervals = advanceFacadeIntervals( + intervals, + parentTruth.map((row) => row.parentId), + ) + checkFacadeIntervals( + intervals, + live.toArray.map((row) => ({ + group: row.parentId, + facade: row.children, + })), + observedIntervals, + ) + for (const parent of parentTruth) { + const actual = live.get(parent.id)! + const expectedChildren = childTruth.filter( + (child) => child.parentId === parent.parentId, + ) + if (!expectedChildren.length) reach(`active-empty-ready`) + checkAttributedRows( + actual.children.toArray, + expectedAttributedRows( + expectedChildren, + shape, + pending, + children.id, + actual.children.id, + (id) => id, + ), + ) + const expectedInline = expectedInlineMetadataValues( + expectedChildren, + shape, + pending, + children.id, + overrides, + ) + checkInlineMetadataValues(actual.array, expectedInline) + checkInlineMetadataValues(actual.materialized, expectedInline) + if (cut && expectedChildren.length) { + for (const form of [`facade`, `toArray`, `materialize`]) + facadeMetadataReach.reach(`${shape}/${form}/${cut}`) + } + } + } + const move = (id: string, parentId: string) => { + parentTruth = parentTruth.map((row) => + row.id === id ? { ...row, parentId } : row, + ) + parents.utils.begin() + parents.utils.write({ type: `update`, value: { id, parentId } }) + parents.utils.commit() + observe() + } + let settlement: Promise | undefined + await checkWithCleanup(async () => { + await live.preload() + observe() + childTruth = childTruth.map((row) => + row.id === `c1` ? { ...row, label: `pending` } : row, + ) + pending.add(`c1`) + const mutation = children.update(`c1`, (row) => { + row.label = `pending` + }) + settlement = mutation.when(`settled`).then( + () => undefined, + (error: unknown) => error, + ) + observe(`held`) + reach(`shared-held`) + move(`p1`, `b`) + move(`p2`, `b`) + reach(`last-route-retired`) + move(`p1`, `a`) + observe(`reactivated`) + reach(`reactivated-held`) + childTruth = initialChildren.map((row) => ({ ...row })) + pending.clear() + const failure = new Error(`authored facade child rejection`) + if (reject) rejectWrite(failure) + else release() + expect(await settlement).toBe(reject ? failure : undefined) + observe() + reach(`settled`) + childTruth = childTruth.map((row) => + row.id === `c1` ? { ...row, label: `later` } : row, + ) + children.utils.begin() + children.utils.write({ + type: `update`, + value: { id: `c1`, parentId: `a`, label: `later` }, + }) + children.utils.commit() + observe() + reach(`later-source`) + move(`p2`, `a`) + reach(`reshared`) + }, [ + async () => { + release() + await settlement + }, + () => live.cleanup(), + () => children.cleanup(), + () => parents.cleanup(), + ]) + }, +) + +// Inline values must not receive a facade's publication metadata. These +// controls run the same selected-shape model for absent and authored fields. +test.each([`none`, `$key`, `$hasPendingWrites`, `all`] as const)( + `rejects facade enrichment of an inline value: %s`, + (collision) => { + const overrides: Record = {} + for (const field of virtualFieldNames) + if (collision === field || collision === `all`) + overrides[field] = + field === `$hasPendingWrites` + ? true + : field === `$origin` + ? `local` + : `authored` + const expected = expectedInlineMetadataValues( + [{ id: `child`, parentId: `a`, label: `value` }], + `functional-projection`, + new Set(), + `source`, + overrides, + ) + checkInlineMetadataValues(expected, expected) + const hostile = [ + { + ...expected[0], + $key: `compiler-key`, + $collectionId: `facade`, + $origin: `remote`, + $hasPendingWrites: false, + }, + ] + expect(() => checkInlineMetadataValues(hostile, expected)).toThrow() + }, +) + +/** + * A nullable whole-row selection yields either the complete source row or an + * empty object. Inline values keep that shape; a facade publishes the empty + * object as a Collection row with required output metadata. This is the runtime + * receiving witness for the generated public type law. Presence changes in both + * directions distinguish a never-matched fixture from the nullable boundary. + * Compiler-generated empty-row keys are checked against the facade's own public + * keys, not against an independent key-encoding model. + */ +function expectedNullableSelection( + match: { id: string; label: string } | undefined, + sourceId: string, + facadeId: string, + facadeKey: string | number, +) { + const inline = match + ? [ + { + ...match, + $key: match.id, + $collectionId: sourceId, + $origin: `remote`, + $hasPendingWrites: false, + }, + ] + : [{}] + return { + inline, + facade: match + ? inline + : [ + { + $key: facadeKey, + $collectionId: facadeId, + $origin: `remote`, + $hasPendingWrites: false, + }, + ], + } +} +function checkNullableSelection( + actual: { + facade: ReadonlyArray + inline: ReadonlyArray> + }, + expected: ReturnType, +): void { + checkInlineMetadataValues(actual.facade, expected.facade) + for (const value of actual.inline) + checkInlineMetadataValues(value, expected.inline) +} +const nullableFacadeHistories = [false, true].map((initialMatch) => ({ + initialMatch, +})) +const nullableFacadeReach = createOracleReach(`nullable whole-row facade`, [ + `matched`, + `unmatched`, +]) +afterAll(() => nullableFacadeReach.assertReached()) +declareOracle({ + model: [expectedNullableSelection], + check: [checkNullableSelection], + grammar: [nullableFacadeHistories], +}) +test.each(nullableFacadeHistories)( + `publishes nullable whole rows through a facade: %j`, + async ({ initialMatch }) => { + nullableFacadeReach.beginHistory() + const children = createCollection( + mockSyncCollectionOptions<{ id: string }>({ + id: `nullable-facade-children`, + getKey: (row) => row.id, + initialData: [{ id: `child` }], + }), + ) + const authored = { id: `child`, label: `source` } + let match = initialMatch ? { ...authored } : undefined + const matches = createCollection( + mockSyncCollectionOptions({ + id: `nullable-facade-matches`, + getKey: (row) => row.id, + initialData: match ? [{ ...authored }] : [], + }), + ) + children.createIndex((row) => row.id, { indexType: BTreeIndex }) + matches.createIndex((row) => row.id, { indexType: BTreeIndex }) + const live = createLiveQueryCollection((q) => + q.from({ parent: children }).select(({ parent }) => { + const selected = q + .from({ child: children }) + .where(({ child }) => eq(child.id, parent.id)) + .leftJoin({ maybe: matches }, ({ child, maybe }) => + eq(child.id, maybe.id), + ) + .select(({ maybe }) => maybe) + return { + id: parent.id, + children: include(selected), + array: toArray(selected), + materialized: materialize(selected), + first: materialize(selected.findOne()), + } + }), + ) + const observe = () => { + const parent = live.toArray[0]! + expect(live.toArray).toHaveLength(1) + const keys = [...parent.children.keys()] + expect(keys).toHaveLength(1) + checkNullableSelection( + { + facade: parent.children.toArray, + inline: [parent.array, parent.materialized, [parent.first!]], + }, + expectedNullableSelection( + match, + matches.id, + parent.children.id, + keys[0]!, + ), + ) + nullableFacadeReach.reach(match ? `matched` : `unmatched`) + } + await checkWithCleanup(async () => { + await live.preload() + observe() + for (let i = 0; i < 2; i++) { + matches.utils.begin() + matches.utils.write({ + type: match ? `delete` : `insert`, + value: { ...authored }, + }) + matches.utils.commit() + match = match ? undefined : { ...authored } + observe() + } + }, [ + () => live.cleanup(), + () => matches.cleanup(), + () => children.cleanup(), + ]) + }, +) + +// The same nullable-boundary checker must reject lost facade metadata, enriched +// inline emptiness and a missing ordinary field on a complete source row. +test.each([ + `empty-facade-metadata`, + `empty-inline-enriched`, + `matched-field-missing`, +] as const)(`rejects a hostile nullable whole-row observation: %s`, (fault) => { + const expected = expectedNullableSelection( + fault === `matched-field-missing` + ? { id: `child`, label: `source` } + : undefined, + `source`, + `facade`, + `generated-key`, + ) + const observed = { + facade: structuredClone(expected.facade), + inline: [structuredClone(expected.inline)], + } + checkNullableSelection(observed, expected) + if (fault === `empty-facade-metadata`) observed.facade = [{}] + else if (fault === `empty-inline-enriched`) + observed.inline = [structuredClone(expected.facade)] + else + observed.facade = observed.facade.map((row) => + Object.fromEntries( + Object.entries(row).filter(([key]) => key !== `label`), + ), + ) + expect(() => checkNullableSelection(observed, expected)).toThrow() +}) diff --git a/packages/db/tests/query/virtual-row-fields-oracle.test-d.ts b/packages/db/tests/query/virtual-row-fields-reference-types.test-d.ts similarity index 93% rename from packages/db/tests/query/virtual-row-fields-oracle.test-d.ts rename to packages/db/tests/query/virtual-row-fields-reference-types.test-d.ts index 32f6465102..18599d98d3 100644 --- a/packages/db/tests/query/virtual-row-fields-oracle.test-d.ts +++ b/packages/db/tests/query/virtual-row-fields-reference-types.test-d.ts @@ -1,8 +1,10 @@ +// Supplemental concrete type examples. The generated public consumer law +// lives in virtual-row-fields-types-oracle.test.ts. /** * # Which types represent virtual-field-bearing rows? * * Law and source: `VirtualRowProps` gives Collection and live-query row roots - * `$hasPendingWrites`, `$synced`, `$origin`, `$key`, and `$collectionId`. The reusable `Ref` + * `$hasPendingWrites`, `$origin`, `$key`, and `$collectionId`. The reusable `Ref` * contract in `docs/guides/live-queries.md` accepts nested refs without claiming * those refs are rows. The output contract established by `includes.test.ts` * keeps inline `toArray` and `materialize` selections in their selected shape. @@ -40,7 +42,6 @@ import { materialize, toArray, } from '../../src/query/index.js' -import { declareOracle } from '../oracle-declaration.js' import { rows } from './virtual-row-fields-type-fixture.js' import type { Profile, Row } from './virtual-row-fields-type-fixture.js' import type { RefLeaf } from '../../src/query/builder/types.js' @@ -67,7 +68,6 @@ type PublishedValueFor< > = TSubject extends `row-root` | `whole-row-child` ? T & { readonly $hasPendingWrites: boolean - readonly $synced: boolean readonly $origin: `local` | `remote` readonly $key: string | number readonly $collectionId: string @@ -88,10 +88,6 @@ type IndexRefFor< type VirtualVariant = PublishedValueFor -declareOracle({ - typeModel: [`PublishedValueFor`, `QueryRefFor`, `IndexRefFor`], -}) - describe(`virtual row field type boundary`, () => { test(`WithoutVirtualProps preserves discriminated row unions`, () => { expectTypeOf>().toEqualTypeOf() @@ -106,7 +102,8 @@ describe(`virtual row field type boundary`, () => { q.from({ row: rows }).select(({ row }) => { expectTypeOf(row.$key).toEqualTypeOf>() expectTypeOf(row.$hasPendingWrites).toEqualTypeOf>() - expectTypeOf(row.$synced).toEqualTypeOf>() + // @ts-expect-error The removed alias is absent from row types. + row.$synced expectTypeOf(row.$origin).toEqualTypeOf>() expectTypeOf(row.$collectionId).toEqualTypeOf>() profileLabelIs(row.profile) @@ -117,7 +114,7 @@ describe(`virtual row field type boundary`, () => { // @ts-expect-error Nested user objects are not collection rows. row.profile.$key // @ts-expect-error Optional nested user objects are not collection rows. - row.optionalProfile?.$synced + row.optionalProfile?.$hasPendingWrites // @ts-expect-error Nullable nested user objects are not collection rows. row.nullableProfile?.$origin @@ -129,7 +126,8 @@ describe(`virtual row field type boundary`, () => { expectTypeOf(result.profile).toEqualTypeOf() expectTypeOf(result.$key).toEqualTypeOf() expectTypeOf(result.$hasPendingWrites).toEqualTypeOf() - expectTypeOf(result.$synced).toEqualTypeOf() + // @ts-expect-error The removed alias is absent from row types. + result.$synced expectTypeOf(result.$origin).toEqualTypeOf<`local` | `remote`>() expectTypeOf(result.$collectionId).toEqualTypeOf() // @ts-expect-error A selected nested user object remains a value, not a row. @@ -145,7 +143,8 @@ describe(`virtual row field type boundary`, () => { expectTypeOf(row.$collectionId).toEqualTypeOf>() expectTypeOf(row.$key).toEqualTypeOf>() expectTypeOf(row.$hasPendingWrites).toEqualTypeOf>() - expectTypeOf(row.$synced).toEqualTypeOf>() + // @ts-expect-error The removed alias is absent from row types. + row.$synced expectTypeOf(row.$origin).toEqualTypeOf>() profileLabelIs(row.profile) if (row.optionalProfile) profileLabelIs(row.optionalProfile) @@ -155,7 +154,7 @@ describe(`virtual row field type boundary`, () => { // @ts-expect-error Nested user objects are not collection rows. row.profile.$key // @ts-expect-error Optional nested user objects are not collection rows. - row.optionalProfile?.$synced + row.optionalProfile?.$hasPendingWrites // @ts-expect-error Nullable nested user objects are not collection rows. row.nullableProfile?.$origin @@ -229,7 +228,8 @@ describe(`virtual row field type boundary`, () => { expectTypeOf( result.selectedWholeRows[0]!.$hasPendingWrites, ).toEqualTypeOf() - expectTypeOf(result.selectedWholeRows[0]!.$synced).toEqualTypeOf() + // @ts-expect-error The removed alias is absent from row types. + result.selectedWholeRows[0]!.$synced expectTypeOf(result.selectedWholeRows[0]!.$origin).toEqualTypeOf< `local` | `remote` >() @@ -248,9 +248,8 @@ describe(`virtual row field type boundary`, () => { expectTypeOf(result.unmatchedRows[0]!.$hasPendingWrites).toEqualTypeOf< boolean | undefined >() - expectTypeOf(result.unmatchedRows[0]!.$synced).toEqualTypeOf< - boolean | undefined - >() + // @ts-expect-error The removed alias is absent from row types. + result.unmatchedRows[0]!.$synced expectTypeOf(result.unmatchedRows[0]!.$origin).toEqualTypeOf< `local` | `remote` | undefined >() diff --git a/packages/db/tests/query/virtual-row-fields-source-oracle.ts b/packages/db/tests/query/virtual-row-fields-source-oracle.ts new file mode 100644 index 0000000000..55a0b74c87 --- /dev/null +++ b/packages/db/tests/query/virtual-row-fields-source-oracle.ts @@ -0,0 +1,25 @@ +import { declareOracle } from '../oracle-declaration.js' + +/** + * Source metadata is a source fact, separate from output-row attribution. + * VirtualRowProps supplies the authored key and Collection ID. An optimistic + * snapshot is pending and local; otherwise the applied source row supplies its + * origin. VirtualOrigin and optimistic-history-oracle own how settlement sets + * that applied origin. This small judgment does not choose an output owner, + * reconstruct a sync queue, or assign metadata to an Effect result object. + */ +export function expectedSourceMetadata( + key: string | number, + collectionId: string, + pending: boolean, + appliedOrigin: `local` | `remote` = `remote`, +) { + return { + $key: key, + $collectionId: collectionId, + $hasPendingWrites: pending, + $origin: pending ? (`local` as const) : appliedOrigin, + } +} + +declareOracle({ model: [expectedSourceMetadata] }) diff --git a/packages/db/tests/query/virtual-row-fields-support.ts b/packages/db/tests/query/virtual-row-fields-support.ts index c9d55557d3..1ddf0308a2 100644 --- a/packages/db/tests/query/virtual-row-fields-support.ts +++ b/packages/db/tests/query/virtual-row-fields-support.ts @@ -38,7 +38,6 @@ export async function checkWithCleanup( export const virtualFieldNames = [ `$key`, `$hasPendingWrites`, - `$synced`, `$origin`, `$collectionId`, ] as const diff --git a/packages/db/tests/query/virtual-row-fields-types-oracle.test.ts b/packages/db/tests/query/virtual-row-fields-types-oracle.test.ts new file mode 100644 index 0000000000..c045229eec --- /dev/null +++ b/packages/db/tests/query/virtual-row-fields-types-oracle.test.ts @@ -0,0 +1,505 @@ +/** + * # Do generated public query types respect the row/value boundary? + * + * Authority: VirtualRowProps and ARCHITECTURE.md, Nested materialization. + * Published Collection roots and whole source rows carry four readonly virtual + * fields. Inline projected objects, nested objects, arrays and opaque values + * retain their authored shape. Nullable whole-row selections are absent or + * complete; facade rows always receive required metadata. WithoutVirtualProps + * distributes over discriminated row unions. + * + * The source generator enacts that law with independently written Published, + * Missing and SameShape types. It never imports production metadata classifiers. + * Each finite input varies key domain, ordinary value type, nested optionality + * and discriminated source unions. Every input crosses root projection shape, + * direct/recursive/union composition and inline materialization form. Expected + * shapes follow authored declarations, not inferred production types. + * + * The real TypeScript compiler receives consumer source importing only the + * public @tanstack/db entry. Every diagnostic, including dependencies, survives + * comparison with the empty diagnostic set. This checks source public exports; + * built declarations, npm resolution, adapter handlers and runtime values are + * separate receiving paths. Concrete helper examples remain supplemental in + * virtual-row-fields-reference-types.test-d.ts. + */ +import { resolve } from 'node:path' +import ts from 'typescript' +import { afterAll, expect, test } from 'vitest' +import * as publicDb from '../../src/index.js' +import { declareOracle } from '../oracle-declaration.js' +import { createOracleReach } from '../oracle-reach.js' +import { checkWithCleanup } from './virtual-row-fields-support.js' +import { + metadataCompositions as compositions, + metadataProjectionShapes as projections, +} from './virtual-row-fields-grammar-oracle.js' + +// Exhaustive finite grammar. Removing an axis loses a distinct public type: +// numeric/union keys, scalar types, optional nested values or union narrowing. +// Boolean keys and nullable Collection rows are excluded as illegal inputs. +const typeShapes = [`string`, `number`, `string | number`].flatMap((key) => + [`string`, `number`].flatMap((value) => + [false, true].flatMap((optional) => + [false, true].map((union) => ({ key, value, optional, union })), + ), + ), +) +type TypeShape = (typeof typeShapes)[number] +const inlineShapes = [ + `whole`, + `selected-whole`, + `nullable-whole`, + `object`, + `nested-object`, + `array`, + `date`, +] as const +const inlineForms = [`facade`, `toArray`, `materialize`, `findOne`] as const +const states = [ + ...projections.flatMap((shape) => + compositions.map((composition) => `root/${shape}/${composition}`), + ), + ...inlineShapes.flatMap((shape) => + inlineForms + .filter( + (form) => form !== `facade` || (shape !== `array` && shape !== `date`), + ) + .map((form) => `inline/${shape}/${form}`), + ), + `nested-query-ref`, + `nested-index-ref`, + `union-removal`, + `construction-admitted`, +] +const reach = createOracleReach(`virtual row public types`, states, { + replaying: false, +}) +let publicTypesEntered = false +afterAll(() => { + if (!publicTypesEntered) return + reach.assertReached() + console.info( + `virtual row public types reach`, + Object.fromEntries(reach.counts()), + ) +}) + +function rootQuery(shape: (typeof projections)[number], alias: string) { + const base = `new Query().from({ ${alias}: rows })` + if (shape === `functional-direct`) + return `${base}.fn.select(({ ${alias} }) => ${alias})` + const select = shape.startsWith(`functional`) ? `fn.select` : `select` + const result = shape.endsWith(`spread`) + ? `{ ...${alias} }` + : `{ id: ${alias}.id, payload: ${alias}.profile }` + return `${base}.${select}(({ ${alias} }) => (${result}))` +} +function composeQuery(query: string, peer: string, composition: string) { + const outer = (input: string) => `new Query().from({ outer: ${input} })` + if (composition === `query-ref`) return outer(query) + if (composition === `nested-query-ref`) return outer(outer(query)) + if (composition === `functional-query-ref`) + return `${outer(query)}.fn.select(({ outer }) => outer)` + if (composition === `joined-query-ref`) + return `new Query().from({ anchor: rows }).innerJoin({ outer: ${query} }, ({ anchor, outer }) => eq(anchor.id, outer.id)).select(({ outer }) => outer)` + const union = `new Query().unionAll(${query}, ${peer})` + if (composition === `union`) return union + if (composition === `union-last`) + return `new Query().unionAll(${peer}, ${query})` + if (composition === `union-query-ref`) return outer(union) + return query +} + +/** + * The independent type law is textual because TypeScript checks generated + * consumers. Normalize distributes only the outer record; nested values retain + * optionality, unions and opaque types. No cast or never-running runtime path + * supplies a missing type. Callback bodies are compiled with real inference. + */ +function consumerSource( + shape: TypeShape, + only?: ReadonlySet, +): { + source: string + states: Array +} { + const witnesses: Array = [] + const observed: Array = [] + const addWitness = (state: string, source: string) => { + if (!only || only.has(state)) { + witnesses.push(source) + observed.push(state) + } + } + const row = `{ id: ${shape.key}; value: ${shape.value}; profile${shape.optional ? `?` : ``}: Profile; nullable: Profile | null; tags: Array<${shape.value}>; createdAt: Date }` + for (const projection of projections) { + for (const composition of compositions) { + const query = composeQuery( + rootQuery(projection, `chosen`), + rootQuery(projection, `peer`), + composition, + ) + const expected = projection.endsWith(`projection`) + ? `{ id: ${shape.key}; payload: Profile${shape.optional ? ` | undefined` : ``} }` + : `Row` + addWitness( + `root/${projection}/${composition}`, + `{ + const collection = createLiveQueryCollection({ query: ${query} }); + type Observation_${projection.replaceAll(`-`, `_`)}_${composition.replaceAll(`-`, `_`)} = Assert, Normalize>>>; + }`, + ) + } + } + for (const projection of inlineShapes) { + let query = `new Query().from({ child: rows }).where(({ child }) => eq(child.id, parent.id))` + let value = `Row` + let rowBearing = true + if (projection === `selected-whole`) + query += `.select(({ child }) => child)` + if (projection === `nullable-whole`) { + query += `.leftJoin({ missing: rows }, ({ child, missing }) => eq(child.id, missing.id)).select(({ missing }) => missing)` + value = `Published | Missing>` + } + if (projection === `object`) { + query += `.select(({ child }) => ({ value: child.value }))` + value = `{ value: ${shape.value} }` + rowBearing = false + } + if (projection === `nested-object`) { + query += `.select(({ child }) => ({ nested: { value: child.value } }))` + value = `{ nested: { value: ${shape.value} } }` + rowBearing = false + } + if (projection === `array` || projection === `date`) { + query += `.select(({ child }) => child.${projection === `array` ? `tags` : `createdAt`})` + value = projection === `array` ? `Array<${shape.value}>` : `Date` + rowBearing = false + } + for (const form of inlineForms) { + // Collection-valued includes require record projections. Arrays and Date + // are legal only in the inline forms, as query construction enforces. + if ( + form === `facade` && + (projection === `array` || projection === `date`) + ) + continue + // Facades publish ordinary projections as rows; inline forms do not. + const expected = + projection === `nullable-whole` + ? form === `facade` + ? `Published>` + : value + : rowBearing || form === `facade` + ? `Published<${value}>` + : value + const selected = + form === `findOne` + ? `materialize((${query}).findOne())` + : `${form === `facade` ? `include` : form}(${query})` + const observation = + form === `facade` + ? `typeof collection.toArray[number]['children']['toArray'][number]` + : form === `findOne` + ? `typeof collection.toArray[number]['children']` + : `typeof collection.toArray[number]['children'][number]` + addWitness( + `inline/${projection}/${form}`, + `{ + const collection = createLiveQueryCollection({ query: new Query().from({ parent: rows }).select(({ parent }) => ({ id: parent.id, children: ${selected} })) }); + type Observation_${projection.replaceAll(`-`, `_`)}_${form} = Assert, Normalize<${expected}${form === `findOne` ? ` | undefined` : ``}>>>; + }`, + ) + } + } + return { + source: ` + import { BTreeIndex, Query, createCollection, createLiveQueryCollection, eq, include, materialize, toArray } from '@tanstack/db'; + import type { WithoutVirtualProps } from '@tanstack/db'; + type Equal = (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; + type Assert = T; + type Normalize = T extends unknown ? { [K in keyof T]: T[K] } : never; + type Published = T extends unknown ? T & { readonly $key: string | number; readonly $collectionId: string; readonly $origin: 'local' | 'remote'; readonly $hasPendingWrites: boolean } : never; + type Missing = T extends unknown ? { [K in keyof T]?: undefined } : never; + type IsAny = 0 extends (1 & T) ? true : false; + type HasAny = IsAny extends true ? true : T extends Date ? true extends { [K in Exclude]-?: HasAny }[Exclude] ? true : false : T extends ReadonlyArray ? HasAny : T extends object ? true extends { [K in keyof T]-?: HasAny }[keyof T] ? true : false : false; + type AllKeys = T extends unknown ? keyof T : never; + type ReadonlyKeys = T extends unknown ? { [K in keyof T]-?: Equal<{ [P in K]: T[P] }, { -readonly [P in K]: T[P] }> extends true ? never : K }[keyof T] : never; + // Keep modifiers attached to each union member. A global readonly-key union + // would let a correct member hide a mutable sibling with the same key. + type RecordSignature = { keys: keyof T; fields: { [K in keyof T]-?: { value: Signature; readonly: K extends ReadonlyKeys ? true : false; optional: {} extends Pick ? true : false } } }; + type Signature = T extends Date ? { opaque: 'Date'; extras: RecordSignature> } : T extends ReadonlyArray ? { array: Signature; mutable: T extends Array ? true : false; extras: RecordSignature | keyof ReadonlyArray>> } : T extends object ? RecordSignature : { scalar: T }; + type SameShape = true extends HasAny | HasAny ? false : [Signature] extends [Signature] ? [Signature] extends [Signature] ? Equal, AllKeys> : false : false; + type MutableOne = T extends { kind: 'b' } ? Omit & { $key: string | number } : T; + type Profile = { label: ${shape.value} }; + type Base = ${row}; + type Row = Base${shape.union ? ` & ({ kind: 'a'; left: string } | { kind: 'b'; right: number })` : ``}; + const rows = createCollection({ getKey: row => row.id, sync: { sync: ({ markReady }) => { markReady(); } } }); + type Removal = Assert>>, Normalize>>; + const nestedQuery = createLiveQueryCollection({ query: new Query().from({ row: rows }).select(({ row }) => { + type NestedHasNoVirtualNames = Assert, '$key' | '$collectionId' | '$origin' | '$hasPendingWrites'>, never>>; + type RootNames = Assert, '$key' | '$collectionId' | '$origin' | '$hasPendingWrites'>>; + type RemovedAlias = Assert, never>>; + return { id: row.id, label: row.nullable?.label, sourceKey: row.$key, sourceCollectionId: row.$collectionId, sourceOrigin: row.$origin, sourcePending: row.$hasPendingWrites }; + }) }); + type CallbackValues = Assert, Normalize>>>; + rows.createIndex(row => { + type RootNames = Assert, '$key' | '$collectionId' | '$origin' | '$hasPendingWrites'>>; + type RemovedAlias = Assert, never>>; + type NestedHasNoVirtualNames = Assert, '$key' | '$collectionId' | '$origin' | '$hasPendingWrites'>, never>>; + return row.id; + }, { indexType: BTreeIndex }); + ${witnesses.join(`\n`)} + `, + states: [ + ...observed, + `nested-query-ref`, + `nested-index-ref`, + `union-removal`, + ], + } +} + +// Cache only dependency syntax trees. The consumer gets a new tree per history; +// TypeScript retains full diagnostics and their exact file/span/code identity. +const packageRoot = resolve(process.cwd()) +const filename = resolve(packageRoot, `tests/__generated-virtual-types.ts`) +const config = ts.readConfigFile( + resolve(packageRoot, `tsconfig.json`), + ts.sys.readFile, +) +if (config.error) + throw new Error( + ts.flattenDiagnosticMessageText(config.error.messageText, `\n`), + ) +const parsed = ts.parseJsonConfigFileContent(config.config, ts.sys, packageRoot) +const options: ts.CompilerOptions = { + ...parsed.options, + noEmit: true, + noUnusedLocals: false, + noUnusedParameters: false, + paths: { + ...parsed.options.paths, + '@tanstack/db': [resolve(packageRoot, `src/index.ts`)], + }, +} +const host = ts.createCompilerHost(options) +const original = host.getSourceFile.bind(host) +const cached = new Map() +let generated = `` +host.getSourceFile = ( + name, + languageVersion, + onError, + shouldCreateNewSourceFile, +) => { + if (name === filename) + return ts.createSourceFile(name, generated, languageVersion, true) + let source = cached.get(name) + if (!source) { + source = original(name, languageVersion, onError, shouldCreateNewSourceFile) + if (source) cached.set(name, source) + } + return source +} +let previous: ts.Program | undefined +function compileConsumer(source: string): ReadonlyArray { + generated = source + const program = ts.createProgram([filename], options, host, previous) + previous = program + const diagnostics = [...parsed.errors, ...ts.getPreEmitDiagnostics(program)] + return diagnostics +} +/** + * Compiler acceptance alone does not establish a legal query grammar. Erase + * types and replay the exact consumer through public query/index construction. + * This supplies no row/publication evidence. Every acquired Collection registers + * cleanup immediately, including if a later construction throws. + */ +async function checkConsumerConstruction(source: string): Promise { + const cleanups: Array<() => Promise> = [] + const track = < + T extends { cleanup: (options: { dependents: boolean }) => Promise }, + >( + collection: T, + ): T => { + cleanups.unshift(() => collection.cleanup({ dependents: true })) + return collection + } + await checkWithCleanup(async () => { + const parsedConsumer = ts.createSourceFile( + filename, + source, + ts.ScriptTarget.ES2022, + true, + ) + const importFree = ts.createPrinter().printFile( + ts.factory.updateSourceFile( + parsedConsumer, + parsedConsumer.statements.filter( + (statement) => !ts.isImportDeclaration(statement), + ), + ), + ) + // Reparse printed source before binding. Removing imports only in an emit + // transform leaves CommonJS's bound import substitutions in the output. + const { outputText, diagnostics = [] } = ts.transpileModule(importFree, { + compilerOptions: { + target: ts.ScriptTarget.ES2022, + module: ts.ModuleKind.CommonJS, + }, + reportDiagnostics: true, + }) + checkConsumer(diagnostics) + const api = { + ...publicDb, + createCollection: ( + ...arguments_: Parameters + ) => track(publicDb.createCollection(...arguments_)), + createLiveQueryCollection: ( + ...arguments_: Parameters + ) => track(publicDb.createLiveQueryCollection(...arguments_)), + } + // The source is generated solely from this file's closed grammar. Imports + // are removed by TypeScript's AST transform; public exports supply bindings. + const construct = new Function( + `api`, + `exports`, + `const { BTreeIndex, Query, createCollection, createLiveQueryCollection, eq, include, materialize, toArray } = api;\n${outputText}`, + ) + construct(api, {}) + }, cleanups) +} +function checkConsumer(diagnostics: ReadonlyArray): void { + expect( + ts.formatDiagnosticsWithColorAndContext(diagnostics, { + getCanonicalFileName: (name) => name, + getCurrentDirectory: () => packageRoot, + getNewLine: () => `\n`, + }), + ).toBe(``) +} +declareOracle({ + grammar: [typeShapes], + model: [consumerSource], + check: [checkConsumer, checkConsumerConstruction], +}) +test.each(typeShapes)( + `preserves generated public row/value types: %j`, + async (shape) => { + publicTypesEntered = true + reach.beginHistory() + const consumer = consumerSource(shape) + await checkConsumerConstruction(consumer.source) + reach.reach(`construction-admitted`) + const diagnostics = compileConsumer(consumer.source) + for (const state of consumer.states) reach.reach(state) + checkConsumer(diagnostics) + }, + 30_000, +) + +// Feed a hostile type observation through the actual compiler/checker. Altering +// only the expected law emulates leaked value metadata or lost nullability; +// the legal production query remains identical to its accepted neighbor. +test.each([ + `value-metadata`, + `lost-nullability`, + `one-branch-readonly`, + `nested-any`, + `date-metadata-any`, + `nested-optional-metadata`, + `nested-readonly`, +] as const)( + `rejects a hostile public type observation: %s`, + (fault) => { + const consumer = consumerSource( + fault === `one-branch-readonly` ? typeShapes[1]! : typeShapes[0]!, + new Set([ + fault === `value-metadata` + ? `inline/object/toArray` + : fault === `lost-nullability` + ? `inline/nullable-whole/toArray` + : fault === `date-metadata-any` + ? `inline/date/toArray` + : `root/functional-direct/direct`, + ]), + ) + checkConsumer(compileConsumer(consumer.source)) + const wrong = + fault === `value-metadata` + ? consumer.source.replace( + `Normalize<{ value: string }>>>`, + `Normalize>>>`, + ) + : fault === `lost-nullability` + ? consumer.source.replaceAll( + `Published | Missing>`, + `Published`, + ) + : fault === `one-branch-readonly` + ? consumer.source.replace( + `Normalize>>>`, + `Normalize>>>>`, + ) + : fault === `date-metadata-any` + ? consumer.source.replace( + `Normalize>>`, + `Normalize>>`, + ) + : fault === `nested-optional-metadata` + ? consumer.source.replace( + `Normalize>>>`, + `Normalize & { profile: Profile & { readonly $key?: string } }>>>>`, + ) + : fault === `nested-readonly` + ? consumer.source.replace( + `Normalize>>>`, + `Normalize & { profile: { readonly label: string } }>>>>`, + ) + : consumer.source.replace( + `type Profile = { label: string };`, + `type Profile = { label: any };`, + ) + expect(wrong).not.toBe(consumer.source) + const diagnostics = compileConsumer(wrong) + // A syntax/setup error cannot stand in for a rejected wrong type. Require + // only failed Assert constraints in this generated consumer's law aliases. + expect(diagnostics.length).toBeGreaterThan(0) + for (const diagnostic of diagnostics) { + expect(diagnostic.file?.fileName).toBe(filename) + expect(diagnostic.code).toBe(2344) + const text = diagnostic.file!.text + const lineStart = text.lastIndexOf(`\n`, diagnostic.start) + 1 + const lineEnd = text.indexOf(`\n`, diagnostic.start) + expect(text.slice(lineStart, lineEnd)).toMatch( + /type (Observation_|Removal|CallbackValues)/, + ) + } + expect(() => checkConsumer(diagnostics)).toThrow() + }, + 30_000, +) + +// Grammar controls preserve the admission checkpoint that exposed two invalid +// early fixtures. Their legal neighbors run the identical generated body. +test.each([`non-record-facade`, `missing-index-type`] as const)( + `rejects an illegal generated construction at admission: %s`, + async (fault) => { + const consumer = consumerSource( + typeShapes[0]!, + new Set([`inline/array/toArray`]), + ) + await checkConsumerConstruction(consumer.source) + const wrong = + fault === `non-record-facade` + ? consumer.source.replace(`children: toArray(`, `children: include(`) + : consumer.source.replace(`, { indexType: BTreeIndex }`, ``) + expect(wrong).not.toBe(consumer.source) + await expect(checkConsumerConstruction(wrong)).rejects.toThrow( + fault === `non-record-facade` + ? /must select an object when materializing as a Collection/ + : /No index type specified and no defaultIndexType set/, + ) + }, +) diff --git a/packages/db/tests/query/virtual-row-fields.test-d.ts b/packages/db/tests/query/virtual-row-fields.test-d.ts index 4ba538c822..d34800ff9f 100644 --- a/packages/db/tests/query/virtual-row-fields.test-d.ts +++ b/packages/db/tests/query/virtual-row-fields.test-d.ts @@ -1,6 +1,6 @@ // Ordinary type test for virtual row fields: opaque scalar child results, // compared with written types. The row and value classification law is owned -// by virtual-row-fields-oracle.test-d.ts. +// by virtual-row-fields-types-oracle.test.ts. import { describe, expectTypeOf, test } from 'vitest' import { createLiveQueryCollection, @@ -38,6 +38,6 @@ describe(`virtual row field type boundary`, () => { // @ts-expect-error Date values are not rows and have no virtual row fields. result.dates[0]!.$key // @ts-expect-error Materialized Date values are not rows either. - result.firstDate?.$synced + result.firstDate?.$hasPendingWrites }) }) diff --git a/packages/db/tests/query/virtual-row-fields.test.ts b/packages/db/tests/query/virtual-row-fields.test.ts index dd0d7ce80a..cf1a16eea8 100644 --- a/packages/db/tests/query/virtual-row-fields.test.ts +++ b/packages/db/tests/query/virtual-row-fields.test.ts @@ -1,9 +1,14 @@ // Ordinary tests for virtual row fields: a fixed root-shape scenario compared // with written values, and a harness self-test for checkWithCleanup. The // published-row classification law is owned by virtual-row-fields-oracle.test.ts. -import { describe, expect, test } from 'vitest' +import { describe, expect, test, vi } from 'vitest' import { createCollection } from '../../src/collection/index.js' -import { createLiveQueryCollection } from '../../src/query/index.js' +import { + createLiveQueryCollection, + eq, + include, + toArray, +} from '../../src/query/index.js' import { hasVirtualProps } from '../../src/virtual-props.js' import { mockSyncCollectionOptions } from '../utils.js' import { @@ -79,7 +84,7 @@ describe(`virtual row field runtime boundary`, () => { false, ) expect(row!.$hasPendingWrites, `${name} legacy inverse`).toBe( - !row!.$synced, + !!row!.$hasPendingWrites, ) } @@ -141,3 +146,273 @@ describe(`virtual row field runtime boundary`, () => { expect(attempted).toEqual([`first`, `middle`, `last`]) }) }) + +describe(`virtual metadata attribution regressions`, () => { + test(`gives projected Collection-valued include rows their facade metadata`, async () => { + const parents = createCollection( + mockSyncCollectionOptions<{ id: string }>({ + id: `included-virtual-parents`, + getKey: (row) => row.id, + initialData: [{ id: `parent-1` }], + }), + ) + const children = createCollection( + mockSyncCollectionOptions<{ id: string; parentId: string }>({ + id: `included-virtual-children`, + getKey: (row) => row.id, + initialData: [{ id: `child-1`, parentId: `parent-1` }], + }), + ) + const live = createLiveQueryCollection((q) => + q.from({ parent: parents }).select(({ parent }) => ({ + id: parent.id, + children: include( + q + .from({ child: children }) + .where(({ child }) => eq(child.parentId, parent.id)) + .select(({ child }) => ({ + id: child.id, + $key: `selected-key`, + $collectionId: `selected-collection`, + $hasPendingWrites: true, + $origin: `local`, + })), + ), + inlineChildren: toArray( + q + .from({ child: children }) + .where(({ child }) => eq(child.parentId, parent.id)) + .select(({ child }) => ({ + id: child.id, + $key: `inline-selected-key`, + })), + ), + })), + ) + // An authored projection acquires its output Collection metadata, including + // inside a child facade. Inline values keep their selected shape. + await checkWithCleanup(async () => { + await live.preload() + const child = live.toArray[0]!.children.toArray[0]! + expect(child.$collectionId).toBe(live.toArray[0]!.children.id) + expect(child.$key).toBe(`child-1`) + expect(child.$hasPendingWrites).toBe(false) + expect(child.$origin).toBe(`remote`) + expect(live.toArray[0]!.inlineChildren).toEqual([ + { id: `child-1`, $key: `inline-selected-key` }, + ]) + }, [ + () => live.cleanup(), + () => children.cleanup(), + () => parents.cleanup(), + ]) + }) + + test(`keeps a selected joined row's source metadata across match and pending update`, async () => { + const lists = createCollection( + mockSyncCollectionOptions<{ id: string }>({ + id: `joined-virtual-lists`, + getKey: (row) => row.id, + initialData: [{ id: `list-1` }], + }), + ) + const items = createCollection( + mockSyncCollectionOptions<{ + id: string + listId: string + label: string + }>({ + id: `joined-virtual-items`, + getKey: (row) => row.id, + initialData: [{ id: `item-1`, listId: `list-1`, label: `before` }], + }), + ) + const live = createLiveQueryCollection((q) => + q + .from({ list: lists }) + .join({ item: items }, ({ list, item }) => eq(list.id, item.listId)) + .select(({ item }) => ({ ...item })), + ) + const fromLive = createLiveQueryCollection((q) => + q + .from({ list: lists }) + .join({ item: items }, ({ list, item }) => eq(list.id, item.listId)) + .select(({ list }) => ({ ...list })), + ) + const extended = createLiveQueryCollection((q) => + q + .from({ list: lists }) + .join({ item: items }, ({ list, item }) => eq(list.id, item.listId)) + .select(({ item }) => ({ + ...item, + copyOfLabel: item.label, + $key: `selected-key`, + })), + ) + const lastItem = createLiveQueryCollection((q) => + q + .from({ list: lists }) + .join({ item: items }, ({ list, item }) => eq(list.id, item.listId)) + .select(({ list, item }) => ({ ...list, ...item })), + ) + const lastList = createLiveQueryCollection((q) => + q + .from({ list: lists }) + .join({ item: items }, ({ list, item }) => eq(list.id, item.listId)) + .select(({ list, item }) => ({ ...item, ...list })), + ) + const functional = createLiveQueryCollection((q) => + q + .from({ list: lists }) + .join({ item: items }, ({ list, item }) => eq(list.id, item.listId)) + .fn.select(({ item }) => item ?? {}), + ) + + // A whole-row selection names the joined item as its metadata source. + // The independent expected row is the item's public Collection row at + // each checkpoint; list metadata has a different key and Collection ID. + await checkWithCleanup(async () => { + await Promise.all([ + live.preload(), + fromLive.preload(), + extended.preload(), + lastItem.preload(), + lastList.preload(), + functional.preload(), + ]) + const expectItemMetadata = () => { + const actual = live.toArray[0]! + const item = items.get(`item-1`)! + expect(actual.label).toBe(item.label) + for (const name of virtualFieldNames) { + expect(actual[name], name).toBe(item[name]) + expect(extended.toArray[0]![name], `extended ${name}`).toBe( + item[name], + ) + expect(lastItem.toArray[0]![name], `last item ${name}`).toBe( + item[name], + ) + expect(functional.toArray[0]![name], `functional ${name}`).toBe( + item[name], + ) + } + } + + expectItemMetadata() + for (const name of virtualFieldNames) { + expect(fromLive.toArray[0]![name], name).toBe( + lists.get(`list-1`)![name], + ) + expect(lastList.toArray[0]![name], `last list ${name}`).toBe( + lists.get(`list-1`)![name], + ) + } + const update = items.update(`item-1`, (draft) => { + draft.label = `pending` + }) + await vi.waitFor(() => expect(live.toArray[0]?.label).toBe(`pending`)) + expect(items.get(`item-1`)?.$hasPendingWrites).toBe(true) + expectItemMetadata() + items.utils.resolveSync() + await update.when(`settled`) + expectItemMetadata() + }, [ + () => fromLive.cleanup(), + () => extended.cleanup(), + () => lastItem.cleanup(), + () => lastList.cleanup(), + () => functional.cleanup(), + () => live.cleanup(), + () => items.cleanup(), + () => lists.cleanup(), + ]) + }) + + test(`keeps the selected right-join row's metadata when the FROM row is absent`, async () => { + const lists = createCollection( + mockSyncCollectionOptions<{ id: string }>({ + id: `right-virtual-lists`, + getKey: (row) => row.id, + initialData: [], + }), + ) + const items = createCollection( + mockSyncCollectionOptions<{ id: string; listId: string }>({ + id: `right-virtual-items`, + getKey: (row) => row.id, + initialData: [{ id: `item-1`, listId: `missing` }], + }), + ) + const live = createLiveQueryCollection((q) => + q + .from({ list: lists }) + .rightJoin({ item: items }, ({ list, item }) => + eq(list.id, item.listId), + ) + .select(({ item }) => item), + ) + + await checkWithCleanup(async () => { + await live.preload() + expect(live.toArray).toHaveLength(1) + for (const name of virtualFieldNames) { + expect(live.toArray[0]![name], name).toBe(items.get(`item-1`)![name]) + } + }, [() => live.cleanup(), () => items.cleanup(), () => lists.cleanup()]) + }) + + test(`keeps source write metadata authoritative over selected field names`, async () => { + const rows = createCollection( + mockSyncCollectionOptions<{ id: string; label: string }>({ + id: `virtual-row-fields-colliding-projection-source`, + getKey: (row) => row.id, + initialData: [{ id: `row-1`, label: `before` }], + }), + ) + const live = createLiveQueryCollection((q) => + q.from({ row: rows }).select(({ row }) => ({ + id: row.id, + label: row.label, + $hasPendingWrites: false, + $origin: `remote`, + $key: `selected-key`, + $collectionId: `selected-collection`, + })), + ) + const functional = createLiveQueryCollection((q) => + q.from({ row: rows }).fn.select(({ row }) => ({ + id: row.id, + label: row.label, + $hasPendingWrites: false, + $origin: `remote`, + $key: `selected-key`, + $collectionId: `selected-collection`, + })), + ) + + // Ordinary projections receive output metadata even while the source has + // a pending optimistic update; their fields cannot spoof system metadata. + await checkWithCleanup(async () => { + await Promise.all([live.preload(), functional.preload()]) + const update = rows.update(`row-1`, (draft) => { + draft.label = `pending` + }) + await vi.waitFor(() => { + expect(rows.get(`row-1`)?.$hasPendingWrites).toBe(true) + expect(live.toArray[0]?.label).toBe(`pending`) + }) + + for (const output of [live, functional]) { + expect(output.toArray[0]).toMatchObject({ + $hasPendingWrites: false, + $origin: `remote`, + $collectionId: output.id, + }) + expect(output.toArray[0]!.$key).not.toBe(`selected-key`) + } + + rows.utils.resolveSync() + await update.when(`settled`) + }, [() => functional.cleanup(), () => live.cleanup(), () => rows.cleanup()]) + }) +}) diff --git a/packages/db/tests/query/where-predicate-publication-oracle.property.test.ts b/packages/db/tests/query/where-predicate-publication-oracle.property.test.ts index b92f8d870d..7e5c1ed157 100644 --- a/packages/db/tests/query/where-predicate-publication-oracle.property.test.ts +++ b/packages/db/tests/query/where-predicate-publication-oracle.property.test.ts @@ -7,10 +7,10 @@ * null or undefined operand is UNKNOWN, `NaN` equals `NaN` (PostgreSQL float * semantics), a valid Date compares as its millisecond timestamp, and other * values of different types are unequal. `not`, `and`, and `or` follow Kleene - * logic, so `not(UNKNOWN)` is UNKNOWN. Virtual row fields (`$synced`, - * `$origin`, `$key`) are row fields for filtering; a pending optimistic insert - * is `$synced: false` and `$origin: 'local'`, and a synced row from this - * adapter is `$synced: true` and `$origin: 'remote'`. + * logic, so `not(UNKNOWN)` is UNKNOWN. Virtual row fields + * (`$hasPendingWrites`, `$origin`, `$key`) are row fields for filtering; a + * pending optimistic insert has `$hasPendingWrites: true` and local origin. + * A settled source row has `$hasPendingWrites: false` and remote origin. * * Filtered publication must agree with that predicate after every source * transaction. `Collection.subscribeChanges` with a `whereExpression` turns an @@ -164,7 +164,7 @@ type History = type ModelRow = { id: string v: FieldValue - $synced: boolean + $hasPendingWrites: boolean $origin: `local` | `remote` $key: string } @@ -242,8 +242,8 @@ function equivalentFieldValues(a: FieldValue, b: FieldValue): boolean { return Object.is(a, b) } -function syncedModelRow(id: string, v: FieldValue): ModelRow { - return { id, v, $synced: true, $origin: `remote`, $key: id } +function settledModelRow(id: string, v: FieldValue): ModelRow { + return { id, v, $hasPendingWrites: false, $origin: `remote`, $key: id } } // --------------------------------------------------------------------------- @@ -262,7 +262,11 @@ const operandArbitrary: fc.Arbitrary = fc.oneof( { weight: 1, arbitrary: fc - .constantFrom(`$synced` as const, `$origin` as const, `$key` as const) + .constantFrom( + `$hasPendingWrites` as const, + `$origin` as const, + `$key` as const, + ) .map((name): Operand => ({ kind: `virtual`, name })), }, ) @@ -397,8 +401,8 @@ const pinnedHistories: ReadonlyArray = [ }, { kind: `eq`, - left: { kind: `virtual`, name: `$synced` }, - right: { kind: `literal`, value: false }, + left: { kind: `virtual`, name: `$hasPendingWrites` }, + right: { kind: `literal`, value: true }, }, ], }, @@ -568,8 +572,8 @@ const peerPredicates: ReadonlyArray = [ ...[`a`, `b`, PREFIXED, true, false].map(fieldEq), { kind: `eq`, - left: { kind: `virtual`, name: `$synced` }, - right: { kind: `literal`, value: true }, + left: { kind: `virtual`, name: `$hasPendingWrites` }, + right: { kind: `literal`, value: false }, }, ] @@ -613,8 +617,8 @@ async function observeHistory( seeds.map((seed) => [ seed.id, { - ...syncedModelRow(seed.id, seed.v), - $synced: !seed.optimistic, + ...settledModelRow(seed.id, seed.v), + $hasPendingWrites: seed.optimistic, $origin: seed.optimistic ? `local` : `remote`, }, ]), @@ -728,7 +732,7 @@ async function observeHistory( type: `insert`, value: sourceRow(id, operation.v), }) - modelRows.set(id, syncedModelRow(id, operation.v)) + modelRows.set(id, settledModelRow(id, operation.v)) touched.add(id) continue } @@ -755,7 +759,7 @@ async function observeHistory( type: `update`, value: { id, v: operation.v === MISSING ? undefined : operation.v }, }) - modelRows.set(id, syncedModelRow(id, operation.v)) + modelRows.set(id, settledModelRow(id, operation.v)) touched.add(id) } collection.utils.commit() @@ -856,8 +860,8 @@ describe(`WHERE predicate publication oracle`, () => { history.rows.map((seed, index): [string, ModelRow] => [ `r${index}`, { - ...syncedModelRow(`r${index}`, seed.v), - $synced: !seed.optimistic, + ...settledModelRow(`r${index}`, seed.v), + $hasPendingWrites: seed.optimistic, $origin: seed.optimistic ? `local` : `remote`, }, ]), diff --git a/packages/db/tests/query/where-predicate-support.ts b/packages/db/tests/query/where-predicate-support.ts index 33972d8791..2605561e89 100644 --- a/packages/db/tests/query/where-predicate-support.ts +++ b/packages/db/tests/query/where-predicate-support.ts @@ -14,7 +14,7 @@ export type Row = { id: string; v?: unknown } export type Operand = | { kind: `field` } | { kind: `literal`; value: FieldValue } - | { kind: `virtual`; name: `$synced` | `$origin` | `$key` } + | { kind: `virtual`; name: `$hasPendingWrites` | `$origin` | `$key` } export type Predicate = | { kind: `eq`; left: Operand; right: Operand } diff --git a/packages/db/tests/source-health-oracle.test.ts b/packages/db/tests/source-health-oracle.test.ts index 428da270ef..4ba8252a10 100644 --- a/packages/db/tests/source-health-oracle.test.ts +++ b/packages/db/tests/source-health-oracle.test.ts @@ -221,7 +221,7 @@ * started after the failure. */ import { fc, test as fcTest } from '@fast-check/vitest' -import { afterAll, describe, expect, it } from 'vitest' +import { afterAll, describe, expect, it as vitestIt } from 'vitest' import { createCollection, findProblems } from '../src/collection/index.js' import { createLiveQueryCollection } from '../src/query/live-query-collection.js' import { createEffect } from '../src/query/effect.js' @@ -236,7 +236,12 @@ import { import { RequestErrors } from '../src/collection/lifecycle.js' import { createDeferred } from '../src/deferred.js' import { createOracleReach } from './oracle-reach.js' -import { oraclePropertyOptions } from './oracle-config.js' +import { + oraclePropertyOptions, + oracleReplayRequested, + oracleReplayTest, + readOracleRunConfig, +} from './oracle-config.js' import { declareOracle } from './oracle-declaration.js' import { flushPromises } from './utils.js' import { createSource } from './source-health-support.js' @@ -3117,6 +3122,9 @@ const namedHistories: Record> = { ], } +// Named path replay executes its generated property without the fixed examples. +const it = oracleReplayTest(vitestIt) + describe(`source failure and health oracle`, () => { for (const shape of shapes) { for (const [name, steps] of Object.entries(namedHistories)) { @@ -3358,7 +3366,7 @@ describe(`source failure and health oracle`, () => { historyReachStates, ) let historiesRan = false - fcTest.prop( + oracleReplayTest(fcTest, `source-health.history`).prop( [ fc .tuple( @@ -3384,6 +3392,66 @@ describe(`source failure and health oracle`, () => { }, 120_000, ) + // Optional prefixes preserve broad exploration, but cannot guarantee rare + // premises at a finite random budget. Every structural campaign therefore + // executes each existing prefix in a context that can observe its law. + // The same generated suffix follows each prefix; runHistory still compares + // every step, including snapshot commits, against the unchanged model. + // Join exercises both sources; only the inner-ready prefix requires nested. + // Direct subscribers remain present here. The original 60 random histories + // retain all six shapes and both direct-subscriber choices above. + const requiredProperty = `source-health.required-history` + const config = readOracleRunConfig() + for (const seed of config.replaySeed === undefined + ? [20261029, undefined] + : [undefined]) { + const requiredReach = createOracleReach( + requiredProperty, + historyReachStates, + { replaying: oracleReplayRequested() }, + ) + let requiredRan = false + oracleReplayTest(fcTest, requiredProperty).prop( + [fc.array(stepArb, { maxLength: 12 })], + { + ...oraclePropertyOptions(6, requiredProperty), + ...(seed === undefined ? {} : { seed }), + }, + )( + `required histories refine the model (${seed ?? `random`})`, + async (rest) => { + requiredRan = true + historiesRan = true + for (const prefix of reachPrefixes) { + historyReach.beginHistory() + requiredReach.beginHistory() + await withDeadline( + runHistory( + prefixShapes.get(prefix) ?? `join`, + [...prefix, ...rest], + true, + { + ...requiredReach, + reach(state) { + requiredReach.reach(state) + historyReach.reach(state) + }, + }, + ), + ) + } + }, + 120_000, + ) + afterAll(() => { + if (requiredRan) { + requiredReach.assertReached() + console.info( + `source-health required reach (${seed ?? `random`}): ${JSON.stringify(Object.fromEntries(requiredReach.counts()))}`, + ) + } + }) + } afterAll(() => { if (historiesRan) historyReach.assertReached() }) @@ -5231,7 +5299,7 @@ describe(`on-demand demand grammar`, () => { `restart`, ], ] - fcTest.prop( + oracleReplayTest(fcTest, `source-health.on-demand`).prop( [ fc .tuple( @@ -7060,7 +7128,7 @@ describe(`lazy join demand grammar`, () => { holdChildReady: false, }, ] - fcTest.prop( + oracleReplayTest(fcTest, `source-health.lazy-demand`).prop( [ fc .tuple( @@ -7165,7 +7233,7 @@ describe(`request error bookkeeping`, () => { fc.record({ kind: fc.constant(`fail-again` as const), key }), fc.record({ kind: fc.constant(`settle` as const), key }), ) - fcTest.prop( + oracleReplayTest(fcTest, `source-health.request-errors`).prop( [fc.array(step, { maxLength: 12 })], oraclePropertyOptions(100, `source-health.request-errors`), )( diff --git a/packages/db/tests/sync-metadata-session-oracle.property.test.ts b/packages/db/tests/sync-metadata-session-oracle.property.test.ts index 9745fdb2fa..02fb003863 100644 --- a/packages/db/tests/sync-metadata-session-oracle.property.test.ts +++ b/packages/db/tests/sync-metadata-session-oracle.property.test.ts @@ -86,7 +86,7 @@ async function createCoreDriver(fullRows = false): Promise { let depth = 0 let holdCount = 0 - let blocker: { isPersisted: { promise: Promise } } | undefined + let blocker: { when: (state: 'settled') => Promise } | undefined const receipts: Array> = [] const rejections: Array = [] const receiptStates: Array = [] @@ -125,7 +125,7 @@ async function createCoreDriver(fullRows = false): Promise { blocker = undefined resolveMutation?.() resolveMutation = undefined - await pending?.isPersisted.promise + await pending?.when('settled') } return { diff --git a/packages/db/tests/transaction-ownership-oracle.property.test.ts b/packages/db/tests/transaction-ownership-oracle.property.test.ts index 1974b78f43..e7b5f356d2 100644 --- a/packages/db/tests/transaction-ownership-oracle.property.test.ts +++ b/packages/db/tests/transaction-ownership-oracle.property.test.ts @@ -18,7 +18,7 @@ import type { Transaction } from '../src/transactions.js' * what the Collection shows. The contract: once a transaction settles, by * success, failure, or rollback, it leaves every Collection that ever tracked * it, and each of those Collections stops showing its optimistic rows. - * Settlement always settles `isPersisted`, even when a subscriber throws + * Settlement always settles `when('settled')`, even when a subscriber throws * during it. * * Ownership is wider than the transaction's current mutations. An insert and @@ -38,7 +38,7 @@ import type { Transaction } from '../src/transactions.js' * surviving writes, in creation order. * - A rollback, or a failed commit, also rolls back every pending (not yet * committed) transaction that wrote one of the same Collection keys. - * - A settled transaction's `isPersisted` has settled, and its set of + * - A settled transaction's `when('settled')` has settled, and its set of * tracking Collections is empty. Its mutations still name their * Collection; this oracle does not claim the Collection is collectable. * - Transaction ids are unique among unsettled transactions in a Collection. @@ -56,7 +56,7 @@ import type { Transaction } from '../src/transactions.js' * admitted makes the write fail: the call rethrows that error, the write's * transaction rolls back without rolling back other transactions, and no * Collection keeps it. Either way the write's own transaction has settled, - * with its `isPersisted`. So a direct write never changes the model. + * with its `when('settled')`. So a direct write never changes the model. * - Error shape: a settling call that ran no throwing subscriber reports no * subscriber error. Otherwise it throws one of the subscriber errors, as * is. The contract does not say which one, because these failures are rare @@ -83,7 +83,7 @@ import type { Transaction } from '../src/transactions.js' * step it then flushes promises and compares, for each * Collection, the tracked transaction ids with the model's set, the rows of * keys 1, 2 and 9 with the model's overlay, and each transaction's - * `isPersisted` settlement with the model's state. It compares tracked + * `when('settled')` settlement with the model's state. It compares tracked * transactions by identity. Each settling call's thrown error, or its * commit's rejection, is compared with the error-shape law, counting the * subscriber errors the step raised. A settled transaction's `collections` @@ -395,7 +395,7 @@ async function runHistory(steps: ReadonlyArray): Promise { const model = new OwnershipModel() const driven: Array = [] const settle = (entry: Driven) => { - entry.tx.isPersisted.promise.then( + entry.tx.when(`settled`).then( () => (entry.settled = true), () => (entry.settled = true), ) @@ -561,7 +561,7 @@ async function runHistory(steps: ReadonlyArray): Promise { } finally { restoreCreate() for (const tx of created) - tx.isPersisted.promise.then( + tx.when(`settled`).then( () => settled.add(tx), () => settled.add(tx), ) @@ -598,7 +598,7 @@ async function runHistory(steps: ReadonlyArray): Promise { expect(tx.state, `${label}: direct transaction settled`).toBe( rejected ? `failed` : `completed`, ) - expect(settled.has(tx), `${label}: direct isPersisted settled`).toBe( + expect(settled.has(tx), `${label}: direct transaction settled`).toBe( true, ) } diff --git a/packages/db/tests/transaction-types.test.ts b/packages/db/tests/transaction-types.test.ts index 0efc4498ea..fc102a5b5b 100644 --- a/packages/db/tests/transaction-types.test.ts +++ b/packages/db/tests/transaction-types.test.ts @@ -64,7 +64,7 @@ describe(`Transaction Types`, () => { mutations: [], metadata: {}, mutationFn: mockMutationFn, - isPersisted: false, + when: async () => Promise.resolve(), autoCommit: false, setState: () => {}, commit: async () => Promise.resolve(), diff --git a/packages/db/tests/transactions.test.ts b/packages/db/tests/transactions.test.ts index b8deb32b41..14ca0f1a1f 100644 --- a/packages/db/tests/transactions.test.ts +++ b/packages/db/tests/transactions.test.ts @@ -63,7 +63,7 @@ describe(`Transactions`, () => { if (batch.some((change) => change.value.value === `server`)) throw failure }) let persisted = false - const receipt = tx.isPersisted.promise.then( + const receipt = tx.when(`settled`).then( () => { persisted = true }, @@ -119,9 +119,9 @@ describe(`Transactions`, () => { autoCommit: false, mutationFn: () => Promise.reject(reason), }) - const persisted = transaction.isPersisted.promise.catch( - (error: unknown) => error, - ) + const persisted = transaction + .when(`settled`) + .catch((error: unknown) => error) try { transaction.mutate(() => collection.insert({ id: 1 })) await expect(transaction.commit()).rejects.toThrow(message) @@ -209,7 +209,7 @@ describe(`Transactions`, () => { transaction.commit() expect(transaction.state).toBe(`completed`) - await expect(transaction.isPersisted.promise).resolves.toBeDefined() + await expect(transaction.when(`settled`)).resolves.toBeDefined() }) it(`thows an error if you don't pass in mutationFn`, () => { // @ts-expect-error missing argument on purpose @@ -239,7 +239,7 @@ describe(`Transactions`, () => { autoCommit: false, }) failed.rollback({ error: first }) - await expect(failed.isPersisted.promise).rejects.toBe(first) + await expect(failed.when(`settled`)).rejects.toBe(first) failed.rollback({ error: new Error(`second`) }) expect(failed.error?.error).toBe(first) @@ -360,7 +360,7 @@ describe(`Transactions`, () => { transaction.rollback() - transaction.isPersisted.promise.catch(() => {}) + transaction.when(`settled`).catch(() => {}) expect(transaction.state).toBe(`failed`) }) it(`keeps a persisting transaction failed when rollback wins`, async () => { @@ -380,7 +380,7 @@ describe(`Transactions`, () => { try { transaction.mutate(() => collection.insert({ id: 1 })) - const persisted = transaction.isPersisted.promise.then( + const persisted = transaction.when(`settled`).then( (value) => ({ status: `fulfilled` as const, value }), (reason: unknown) => ({ status: `rejected` as const, reason }), ) @@ -446,7 +446,7 @@ describe(`Transactions`, () => { }) try { - const persisted = first.isPersisted.promise.then( + const persisted = first.when(`settled`).then( (value) => ({ status: `fulfilled` as const, value }), (reason: unknown) => ({ status: `rejected` as const, reason }), ) @@ -479,7 +479,6 @@ describe(`Transactions`, () => { $key: 1, $origin: `local`, $hasPendingWrites: true, - $synced: false, }) expect(batches).toEqual([ [{ type: `insert`, key: 1 }], @@ -513,7 +512,7 @@ describe(`Transactions`, () => { }) try { - void first.isPersisted.promise.catch(() => undefined) + void first.when(`settled`).catch(() => undefined) first.mutate(() => collection.insert({ id: 1, owner: `first` })) first.rollback() @@ -561,8 +560,8 @@ describe(`Transactions`, () => { await expect(transaction.commit()).rejects.toThrow(`bad`) - await expect(transaction.isPersisted.promise).rejects.toThrow(`bad`) - transaction.isPersisted.promise.catch(() => {}) + await expect(transaction.when(`settled`)).rejects.toThrow(`bad`) + transaction.when(`settled`).catch(() => {}) expect(transaction.state).toBe(`failed`) expect(transaction.error?.message).toBe(`bad`) expect(transaction.error?.error).toBeInstanceOf(Error) @@ -597,8 +596,8 @@ describe(`Transactions`, () => { await expect(transaction.commit()).rejects.toThrow(`bad`) - await expect(transaction.isPersisted.promise).rejects.toThrow(`bad`) - transaction.isPersisted.promise.catch(() => {}) + await expect(transaction.when(`settled`)).rejects.toThrow(`bad`) + transaction.when(`settled`).catch(() => {}) expect(transaction.state).toBe(`failed`) expect(transaction.error?.message).toBe(`bad`) expect(transaction.error?.error).toBeInstanceOf(Error) @@ -638,7 +637,7 @@ describe(`Transactions`, () => { } }) - it(`commit() and isPersisted.promise should reject with the same error instance`, async () => { + it(`commit() and when('settled') should reject with the same error instance`, async () => { const originalError = new Error(`Original API error`) const tx = createTransaction({ mutationFn: () => { @@ -672,9 +671,9 @@ describe(`Transactions`, () => { commitError = error } - // Capture error from isPersisted.promise + // Capture error from when('settled') try { - await tx.isPersisted.promise + await tx.when(`settled`) } catch (error) { persistedError = error } @@ -716,9 +715,9 @@ describe(`Transactions`, () => { expect((error as Error).message).toBe(`string error`) } - // Same error from isPersisted.promise + // Same error from when('settled') try { - await tx.isPersisted.promise + await tx.when(`settled`) } catch (error) { expect(error).toBeInstanceOf(Error) expect((error as Error).message).toBe(`string error`) @@ -861,8 +860,8 @@ describe(`Transactions`, () => { // tx1 should throw the original error expect(tx1CommitError).toBe(originalError) - // tx2's isPersisted.promise should also be rejected (but with undefined since it's a cascading rollback) - await expect(tx2.isPersisted.promise).rejects.toBeUndefined() + // tx2's when('settled') should also be rejected (but with undefined since it's a cascading rollback) + await expect(tx2.when(`settled`)).rejects.toBeUndefined() }) it(`should, when rolling back, find any other pending transactions w/ overlapping mutations and roll them back as well`, async () => { @@ -911,7 +910,7 @@ describe(`Transactions`, () => { }) transaction2.commit() - await transaction2.isPersisted.promise + await transaction2.when(`settled`) transaction3.mutate(() => { collection.state.forEach((object) => { @@ -930,9 +929,9 @@ describe(`Transactions`, () => { }) transaction1.rollback() - transaction1.isPersisted.promise.catch(() => {}) - transaction3.isPersisted.promise.catch(() => {}) - transaction4.isPersisted.promise.catch(() => {}) + transaction1.when(`settled`).catch(() => {}) + transaction3.when(`settled`).catch(() => {}) + transaction4.when(`settled`).catch(() => {}) expect(transaction1.state).toBe(`failed`) expect(transaction2.state).toBe(`completed`) diff --git a/packages/db/tests/truncate-readiness-oracle.test.ts b/packages/db/tests/truncate-readiness-oracle.test.ts index 8e918a8d3f..195d31bc00 100644 --- a/packages/db/tests/truncate-readiness-oracle.test.ts +++ b/packages/db/tests/truncate-readiness-oracle.test.ts @@ -123,7 +123,7 @@ it.each(cells)( await receipt if (held) expect(c.get(9)?.value).toBe('optimistic') finish.resolve() - await optimistic?.isPersisted.promise + await optimistic?.when(`settled`) expect.soft(c.status).toBe(expectedStatus) // A later independent ordinary replacement is ready. sync.begin() diff --git a/packages/db/tests/utils.test.ts b/packages/db/tests/utils.test.ts index 57f0a41c52..24dfc7c3a2 100644 --- a/packages/db/tests/utils.test.ts +++ b/packages/db/tests/utils.test.ts @@ -1,13 +1,9 @@ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' import { Temporal } from 'temporal-polyfill' -import { - deepEquals, - equalPersistedSnapshotValues, - resetWarnings, - warnOnce, -} from '../src/utils' +import { deepEquals, equalPersistedSnapshotValues } from '../src/utils' import { normalizeError } from '../src/utils/error' import { isPromiseLike } from '../src/utils/type-guards' +import { withOracleCleanup } from './utils' import { assertDistinctTypes, assertExtraProperty, @@ -51,28 +47,6 @@ describe(`normalizeError`, () => { }) }) -describe(`warnOnce`, () => { - it(`logs each warning key once and permits an explicit reset`, () => { - const warn = vi.spyOn(console, `warn`).mockImplementation(() => {}) - - try { - resetWarnings() - warnOnce(`first`, `first message`) - warnOnce(`first`, `ignored replacement`) - warnOnce(`second`, `second message`) - - expect(warn.mock.calls).toEqual([[`first message`], [`second message`]]) - - resetWarnings() - warnOnce(`first`, `after reset`) - expect(warn).toHaveBeenLastCalledWith(`after reset`) - } finally { - resetWarnings() - warn.mockRestore() - } - }) -}) - describe(`oracle run configuration`, () => { it(`reads the multiplier and replay coordinates from an explicit environment`, () => { expect( @@ -967,3 +941,28 @@ describe(`persisted-snapshot prototype equality`, () => { ).toBe(false) }) }) + +// Failure-fidelity calibration for oracle drivers: a comparison is primary, +// cleanup errors remain distinguishable, and all later releases still run. +it.each([0, 1, 2])( + `preserves an oracle mismatch when cleanup %s fails`, + async (failedCleanup) => { + const mismatch = new Error(`row mismatch at source publication`) + const cleanupFailure = new Error(`cleanup ${failedCleanup}`) + const released: Array = [] + const result = withOracleCleanup( + () => { + throw mismatch + }, + [0, 1, 2].map((index) => () => { + released.push(index) + if (index === failedCleanup) throw cleanupFailure + }), + ) + await expect(result).rejects.toMatchObject({ + cause: mismatch, + errors: [mismatch, cleanupFailure], + }) + expect(released).toEqual([0, 1, 2]) + }, +) diff --git a/packages/db/tests/utils.ts b/packages/db/tests/utils.ts index d3d55db99c..0e092c2e2a 100644 --- a/packages/db/tests/utils.ts +++ b/packages/db/tests/utils.ts @@ -70,7 +70,6 @@ export const stripVirtualProps = | undefined>( if (!value || typeof value !== `object`) return value const { $hasPendingWrites: _hasPendingWrites, - $synced: _synced, $origin: _origin, $key: _key, $collectionId: _collectionId, @@ -81,13 +80,9 @@ export const stripVirtualProps = | undefined>( export const omitVirtualProps = >( value: T, -): Omit< - T, - '$hasPendingWrites' | '$synced' | '$origin' | '$key' | '$collectionId' -> => { +): Omit => { const { $hasPendingWrites: _hasPendingWrites, - $synced: _synced, $origin: _origin, $key: _key, $collectionId: _collectionId, diff --git a/packages/db/tests/virtual-props-cache.test.ts b/packages/db/tests/virtual-props-cache.test.ts index b9420665f4..7bca5712bf 100644 --- a/packages/db/tests/virtual-props-cache.test.ts +++ b/packages/db/tests/virtual-props-cache.test.ts @@ -84,7 +84,7 @@ describe(`virtual props cache`, () => { const transaction = collection.insert({ id: `y`, a: 1 }) collection.get(`y`) collection.utils.rejectSync(new Error(`rolled back`)) - await transaction.isPersisted.promise.catch(() => undefined) + await transaction.when(`settled`).catch(() => undefined) }) expect(collection.has(`y`)).toBe(false) expect(cacheSize(collection)).toBe(0) @@ -143,7 +143,7 @@ describe(`virtual props cache`, () => { const receipt = sync.commit() if (blocker) { release() - await blocker.isPersisted.promise + await blocker.when(`settled`) } await receipt expect(collection.get(1)?.a).toBe(2) diff --git a/packages/db/tests/virtual-row-legacy-guard.test-d.ts b/packages/db/tests/virtual-row-guard.test-d.ts similarity index 61% rename from packages/db/tests/virtual-row-legacy-guard.test-d.ts rename to packages/db/tests/virtual-row-guard.test-d.ts index 1cf06edd28..9642b7dcee 100644 --- a/packages/db/tests/virtual-row-legacy-guard.test-d.ts +++ b/packages/db/tests/virtual-row-guard.test-d.ts @@ -2,9 +2,9 @@ import { expectTypeOf, test } from 'vitest' import { hasVirtualProps } from '../src/virtual-props.js' import type { VirtualRowProps, WithVirtualProps } from '../src/virtual-props.js' -test(`the legacy guard keeps its VirtualRowProps type contract`, () => { +test(`the guard narrows to the complete virtual row shape`, () => { const value: unknown = { - $synced: true, + $hasPendingWrites: false, $origin: `remote`, $key: `row-1`, $collectionId: `collection-1`, @@ -12,11 +12,13 @@ test(`the legacy guard keeps its VirtualRowProps type contract`, () => { if (hasVirtualProps(value)) { const row: VirtualRowProps = value - expectTypeOf(row.$hasPendingWrites).toEqualTypeOf() + expectTypeOf(row.$hasPendingWrites).toEqualTypeOf() + // @ts-expect-error The removed alias is absent from guarded rows. + row.$synced } }) -test(`collection-published rows guarantee the new field`, () => { +test(`collection-published rows guarantee the pending-write field`, () => { type PublishedRow = WithVirtualProps<{ id: string }, string> expectTypeOf().toEqualTypeOf() }) diff --git a/packages/electric-db-collection/e2e/collection-gc.e2e.test.ts b/packages/electric-db-collection/e2e/collection-gc.e2e.test.ts index 503d08115f..790572bffb 100644 --- a/packages/electric-db-collection/e2e/collection-gc.e2e.test.ts +++ b/packages/electric-db-collection/e2e/collection-gc.e2e.test.ts @@ -276,7 +276,7 @@ for (const gcTime of [10, 100]) { expect(collection.get(id)).toMatchObject({ id, value: `confirmed`, - $synced: true, + $hasPendingWrites: false, }) expect(idleHost.callbacks.size).toBe(0) diff --git a/packages/electric-db-collection/src/electric.ts b/packages/electric-db-collection/src/electric.ts index 9605b30a19..14ecda8745 100644 --- a/packages/electric-db-collection/src/electric.ts +++ b/packages/electric-db-collection/src/electric.ts @@ -11,7 +11,6 @@ import { SyncTransactionAbortedError, and, validateSyncPersistenceCapability, - warnOnce, whenSyncAccepted, withCollectionConfigFactory, withCollectionSyncConfigCleanup, @@ -288,20 +287,6 @@ export type MatchFunction> = ( message: Message, ) => boolean -/** - * Matching strategies for Electric synchronization - * Handlers can return: - * - Txid strategy: { txid: number | number[], timeout?: number } (deprecated compatibility behavior) - * - Void (no return value) - mutation completes without waiting - * - * The optional timeout property specifies how long to wait for the txid(s) in milliseconds. - * If not specified, defaults to 15000ms. - */ -export type MatchingStrategy = { - txid: Txid | Array - timeout?: number -} | void - /** * Type representing a snapshot end message */ @@ -375,9 +360,6 @@ export interface ElectricCollectionConfig< * @param params Object containing transaction and collection information * @returns Promise that should resolve after synchronization is complete * - * **Deprecation notice:** Returning `{ txid }` from handlers is deprecated and will be removed in v1.0. - * Use `await collection.utils.awaitTxId(txid)` within the handler instead. - * * @example * // Recommended: Wait for txid to sync * onInsert: async ({ transaction, collection }) => { @@ -452,7 +434,7 @@ export interface ElectricCollectionConfig< string | number, ElectricCollectionUtils >, - ) => Promise + ) => Promise /** * Optional asynchronous handler function called before an update operation @@ -468,9 +450,6 @@ export interface ElectricCollectionConfig< * @param params Object containing transaction and collection information * @returns Promise that should resolve after synchronization is complete * - * **Deprecation notice:** Returning `{ txid }` from handlers is deprecated and will be removed in v1.0. - * Use `await collection.utils.awaitTxId(txid)` within the handler instead. - * * @example * // Recommended: Wait for txid to sync * onUpdate: async ({ transaction, collection }) => { @@ -502,7 +481,7 @@ export interface ElectricCollectionConfig< string | number, ElectricCollectionUtils >, - ) => Promise + ) => Promise /** * Optional asynchronous handler function called before a delete operation @@ -518,9 +497,6 @@ export interface ElectricCollectionConfig< * @param params Object containing transaction and collection information * @returns Promise that should resolve after synchronization is complete * - * **Deprecation notice:** Returning `{ txid }` from handlers is deprecated and will be removed in v1.0. - * Use `await collection.utils.awaitTxId(txid)` within the handler instead. - * * @example * // Recommended: Wait for txid to sync * onDelete: async ({ transaction, collection }) => { @@ -551,7 +527,7 @@ export interface ElectricCollectionConfig< string | number, ElectricCollectionUtils >, - ) => Promise + ) => Promise } function isUpToDateMessage>( @@ -1235,107 +1211,7 @@ export function electricCollectionOptions>( }) const sync = createSync() - /** - * Process matching strategy and wait for synchronization - */ - const processMatchingStrategy = async ( - result: unknown, - waitForTxId: AwaitTxIdFn, - ): Promise => { - const canHaveProperties = - (typeof result === `object` && result !== null) || - typeof result === `function` - - // Only wait if result contains txid - if (canHaveProperties && `txid` in result) { - const strategy = result as Exclude - // Warn about deprecated return value pattern - warnOnce( - 'electric-collection-txid-return', - '[TanStack DB] DEPRECATED: Returning { txid } from mutation handlers is deprecated and will be removed in v1.0. ' + - 'Use `await collection.utils.awaitTxId(txid)` instead of returning { txid }. ' + - 'See migration guide: https://tanstack.com/db/latest/docs/collections/electric-collection#persistence-handlers--synchronization', - ) - - const timeout = strategy.timeout - // Handle both single txid and array of txids - if (Array.isArray(strategy.txid)) { - await Promise.all( - strategy.txid.map((txid) => waitForTxId(txid, timeout)), - ) - } else { - await waitForTxId(strategy.txid, timeout) - } - } - // If result is void/undefined, don't wait - mutation completes immediately - } - const getMutationAwaitTxId = (params: unknown): AwaitTxIdFn => { - const collection = ( - params as { - collection?: { utils?: { awaitTxId?: AwaitTxIdFn } } - } - ).collection - return collection?.utils?.awaitTxId ?? awaitTxId - } - - // Create wrapper handlers for direct persistence operations that handle different matching strategies - // These wrappers process deprecated return values but don't pass them through - const wrappedOnInsert = config.onInsert - ? async ( - params: InsertMutationFnParams< - any, - string | number, - ElectricCollectionUtils - >, - ): Promise => { - const handlerResult = await config.onInsert!(params) - await processMatchingStrategy( - handlerResult, - getMutationAwaitTxId(params), - ) - } - : undefined - - const wrappedOnUpdate = config.onUpdate - ? async ( - params: UpdateMutationFnParams< - any, - string | number, - ElectricCollectionUtils - >, - ): Promise => { - const handlerResult = await config.onUpdate!(params) - await processMatchingStrategy( - handlerResult, - getMutationAwaitTxId(params), - ) - } - : undefined - - const wrappedOnDelete = config.onDelete - ? async ( - params: DeleteMutationFnParams< - any, - string | number, - ElectricCollectionUtils - >, - ): Promise => { - const handlerResult = await config.onDelete!(params) - await processMatchingStrategy( - handlerResult, - getMutationAwaitTxId(params), - ) - } - : undefined - - // Extract standard Collection config properties - const { - shapeOptions: _shapeOptions, - onInsert: _onInsert, - onUpdate: _onUpdate, - onDelete: _onDelete, - ...restConfig - } = config + const { shapeOptions: _shapeOptions, ...restConfig } = config const utilityTemplate: ElectricCollectionUtils = { awaitTxId, @@ -1387,11 +1263,11 @@ export function electricCollectionOptions>( ) const options = { ...restConfig, + onInsert: config.onInsert, + onUpdate: config.onUpdate, + onDelete: config.onDelete, syncMode: finalSyncMode, sync: syncTemplate, - onInsert: wrappedOnInsert, - onUpdate: wrappedOnUpdate, - onDelete: wrappedOnDelete, utils: utilityTemplate, } Object.defineProperty(options, `utils`, { diff --git a/packages/electric-db-collection/tests/documented-mutations-oracle.test.ts b/packages/electric-db-collection/tests/documented-mutations-oracle.test.ts new file mode 100644 index 0000000000..4889a5fe6d --- /dev/null +++ b/packages/electric-db-collection/tests/documented-mutations-oracle.test.ts @@ -0,0 +1,134 @@ +/** + * Exact Electric guidance handlers receive the real Electric adapter. The SDK + * stream stays controlled: actual awaitTxId registration precedes delivery of + * its change and up-to-date message. This supplies no live-provider evidence. + */ +import { resolve } from 'node:path' +import { afterAll, test, vi } from 'vitest' +import { createCollection } from '@tanstack/db' +import { electricCollectionOptions } from '../src/electric' +import { createDeferred } from '../../db/src/deferred' +import { declareOracle } from '../../db/tests/oracle-declaration' +import { oracleReplayTest } from '../../db/tests/oracle-config' +import { + checkDocumentedMutation, + documentedMutationHistories, +} from '../../db/tests/documented-mutations-oracle' +import { + createDocumentedReach, + runDocumentedMutation, + selectDocumentedHistories, +} from '../../db/tests/documented-mutations-support' +import { createMockStream } from './electric-stream-mock' +import { + mockStream, + mockSubscribe, + resetElectricOracleMocks, + upToDate, +} from './electric-adapter-support' +import type { Message } from '@electric-sql/client' +import type { DocumentedRow } from '../../db/tests/documented-mutations-oracle' + +vi.mock(`@electric-sql/client`, async () => ({ + ...(await vi.importActual(`@electric-sql/client`)), + ShapeStream: vi.fn(function () { + return createMockStream(mockStream) + }), +})) +const histories = selectDocumentedHistories( + documentedMutationHistories(`electric`), +) +const reach = createDocumentedReach(`electric`, histories) +afterAll(() => reach.finish()) + +oracleReplayTest(test).each(histories)( + `runs the actual documented handler: $id`, + async (history) => { + reach.begin(history.id) + await runDocumentedMutation( + history, + resolve(process.cwd(), `../..`), + (controls) => { + resetElectricOracleMocks() + let subscriber!: (messages: Array>) => void + mockSubscribe.mockImplementationOnce((callback) => { + subscriber = callback + return () => {} + }) + const sourceRequested = createDeferred() + const collection = createCollection( + electricCollectionOptions({ + id: `documented-${history.id}`, + shapeOptions: { + url: `http://test-url`, + params: { table: `test_table` }, + }, + getKey: (row) => row.id, + startSync: true, + onInsert: controls.handler as () => Promise, + onUpdate: controls.handler as () => Promise, + onDelete: controls.handler as () => Promise, + }), + ) + const awaitTxId = collection.utils.awaitTxId + collection.utils.awaitTxId = (...args) => + controls.confirmation(async () => { + const pending = awaitTxId(...args) + sourceRequested.resolve() + return pending + }) + let delivered = false + const message = ( + row: DocumentedRow, + operation: `insert` | `update` | `delete`, + txids?: Array, + ): Message => ({ + key: String(row.id), + value: structuredClone(row), + headers: { operation, ...(txids ? { txids } : {}) }, + }) + const deliver = () => { + if (delivered) return Promise.resolve() + delivered = true + const row = controls + .server() + .find((item) => item.id === (history.handler.numeric ? 1 : `1`)) + subscriber([ + message( + row ?? { id: history.handler.numeric ? 1 : `1` }, + history.mutation.operation, + [55], + ), + upToDate, + ]) + return Promise.resolve() + } + return { + collection, + start: async () => { + subscriber([ + ...controls.initial.map((row) => message(row, `insert`)), + upToDate, + ]) + await collection.preload() + }, + sourceRequested: sourceRequested.promise, + deliver, + late: deliver, + fetches: () => 0, + providerRequests: () => + mockStream.requestSnapshot.mock.calls.length + + mockStream.fetchSnapshot.mock.calls.length + + mockStream.forceDisconnectAndRefresh.mock.calls.length, + cleanup: () => collection.cleanup(), + } + }, + reach.reach, + ) + }, +) + +declareOracle({ + check: [checkDocumentedMutation], + grammar: [documentedMutationHistories], +}) diff --git a/packages/electric-db-collection/tests/electric-adapter.test.ts b/packages/electric-db-collection/tests/electric-adapter.test.ts index b85adcdcd8..d01399fdfc 100644 --- a/packages/electric-db-collection/tests/electric-adapter.test.ts +++ b/packages/electric-db-collection/tests/electric-adapter.test.ts @@ -149,12 +149,12 @@ describe(`Electric adapter laws`, () => { void acquired.catch(() => {}) trace.subscriber([change(`update`, 1, `partial`), upToDate]) persistence.resolve() - await transaction.isPersisted.promise + await transaction.when('settled') await acquired expect(trace.collection.has(1)).toBe(false) } finally { persistence.resolve() - await transaction?.isPersisted.promise + await transaction?.when('settled') await trace.collection.cleanup() await acquired } @@ -1211,7 +1211,7 @@ describe(`Electric adapter laws`, () => { expect(trace.collection.has(2)).toBe(false) persistence.resolve() - await transaction.isPersisted.promise + await transaction.when('settled') await trace.collection.cleanup() }) // Readiness counts accepted rows; they publish when the mutation settles. @@ -1241,7 +1241,7 @@ describe(`Electric adapter laws`, () => { expect(trace.collection.has(1)).toBe(false) persistence.resolve() - await transaction.isPersisted.promise + await transaction.when('settled') await trace.collection.stateWhenReady() expect(trace.collection.status).toBe(`ready`) @@ -1307,7 +1307,7 @@ describe(`Electric adapter laws`, () => { `pending`, ) persistence.resolve() - await transaction.isPersisted.promise + await transaction.when('settled') await atCheckpoint(loading!, `full recovery applied settlement`) expect(outcome).toBe(`fulfilled`) expect(trace.collection.get(1)).toMatchObject({ @@ -1660,7 +1660,7 @@ describe(`Electric adapter laws`, () => { expect(trace.collection.has(1), `applied receipt is held`).toBe(false) external.abort() persistence.resolve() - await transaction.isPersisted.promise + await transaction.when('settled') await expect( atCheckpoint(first, `externally aborted held subset`), ).rejects.toMatchObject({ name: `AbortError` }) @@ -1730,7 +1730,7 @@ describe(`Electric adapter laws`, () => { expect(firstOutcome).toBe(`rejected`) expect(firstError).toMatchObject({ name: `AbortError` }) persistence.resolve() - await transaction.isPersisted.promise + await transaction.when('settled') await observedFirst expect(trace.collection.has(1)).toBe(true) }, [ @@ -1854,7 +1854,7 @@ describe(`Electric adapter laws`, () => { await trace.collection.cleanup() persistence.resolve() - await transaction.isPersisted.promise + await transaction.when('settled') await Promise.resolve() expect(trace.collection.status).toBe(`cleaned-up`) diff --git a/packages/electric-db-collection/tests/electric-descriptor-isolation.test.ts b/packages/electric-db-collection/tests/electric-descriptor-isolation.test.ts index 19c805a20a..1603ea862f 100644 --- a/packages/electric-db-collection/tests/electric-descriptor-isolation.test.ts +++ b/packages/electric-db-collection/tests/electric-descriptor-isolation.test.ts @@ -241,7 +241,7 @@ it(`restarts a provider session before an old subset can publish into a new cach mutation.mutate(() => collection.insert({ id: 10, name: `optimistic`, stable: `stable-10` }), ) - const handlerOutcome = mutation.isPersisted.promise.then( + const handlerOutcome = mutation.when('settled').then( () => undefined, (error: unknown) => error, ) @@ -1110,7 +1110,9 @@ it.each([`shared storage`, `distinct storage`] as const)( }, getKey: (row) => row.id, startSync: false, - onInsert: () => Promise.resolve({ txid: 200, timeout: 100 }), + onInsert: async ({ collection }) => { + await collection.utils.awaitTxId(200, 100) + }, }) // The Electric descriptor is reusable; each persisted wrapper binds one // Collection so its receipt and sync controls cannot move to another owner. @@ -1150,12 +1152,12 @@ it.each([`shared storage`, `distinct storage`] as const)( name: `own`, stable: `stable-1`, }) - void transaction.isPersisted.promise.catch(() => undefined) + void transaction.when('settled').catch(() => undefined) streams[0]!.send([ insert(1, `own`), { headers: { control: `up-to-date`, txids: [200] } }, ]) - await expect(transaction.isPersisted.promise).resolves.toBeDefined() + await expect(transaction.when('settled')).resolves.toBeDefined() expect(first.get(1)?.name).toBe(`own`) expect(second.has(1)).toBe(false) } finally { diff --git a/packages/electric-db-collection/tests/electric-oracle.property.test.ts b/packages/electric-db-collection/tests/electric-oracle.property.test.ts index 17fb8c87de..c88b0aa197 100644 --- a/packages/electric-db-collection/tests/electric-oracle.property.test.ts +++ b/packages/electric-db-collection/tests/electric-oracle.property.test.ts @@ -1,6 +1,6 @@ import { isDeepStrictEqual } from 'node:util' import { fc, test as fcTest } from '@fast-check/vitest' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' import { IR, createCollection, @@ -21,6 +21,12 @@ import { oracleRuns, } from '../../db/tests/oracle-config' import { declareOracle } from '../../db/tests/oracle-declaration' +import { createOracleReach } from '../../db/tests/oracle-reach' +import { + checkMutationConfirmation, + mutationConfirmationHistories, + mutationConfirmationInputs, +} from '../../db/tests/mutation-confirmation-oracle' import { atCheckpoint, withElectricCleanup } from './electric-oracle-lifecycle' import { createMockStream } from './electric-stream-mock' import { @@ -54,6 +60,7 @@ import { subsetEnd, upToDate, } from './electric-adapter-support' +import type { MutationConfirmationObservation } from '../../db/tests/mutation-confirmation-oracle' import type { ObservedPreload, OracleRow, @@ -117,6 +124,240 @@ vi.mock(`@electric-sql/client`, async () => { } }) +/** + * The same outcome/confirmation model receives a real Electric adapter through + * controlled ShapeStream delivery. The fixture keeps backend work separate from + * txid acknowledgement. A legacy txid return supplies no acknowledgement. A + * rejection after explicit acceptance preserves source data and error identity. + * This is a controlled SDK boundary, not a live Electric-service claim. + */ +describe(`mutation outcome and confirmation`, () => { + const mutationHistories = mutationConfirmationHistories(`electric`) + const mutationReach = createOracleReach( + `electric.mutation-confirmation`, + mutationHistories.map( + (h) => + `${h.operation}/${h.outcome}/${h.confirmation}/${h.returned}/peer:${h.peer}`, + ), + ) + afterAll(() => { + mutationReach.assertReached() + console.info( + `electric.mutation-confirmation reach`, + Object.fromEntries(mutationReach.counts()), + ) + }) + it.each(mutationHistories)( + `settles a handler independently of discarded values: %j`, + async (history) => { + mutationReach.beginHistory() + const { operation, confirmation, outcome, returned } = history + const localReach = createOracleReach(`electric.mutation-cuts`, [ + `entered`, + `settled`, + `settlement-waiter`, + ...(outcome === `reject-before-write` ? [] : [`server-written`]), + ...(confirmation === `none` + ? [] + : [`confirmation-requested`, `confirmation-accepted`]), + ...(confirmation === `none` && outcome !== `reject-before-write` + ? [`late-confirmed`] + : []), + ]) + localReach.beginHistory() + resetElectricOracleMocks() + let subscriber!: (messages: Array>) => void + mockSubscribe.mockImplementationOnce((callback) => { + subscriber = callback + return () => {} + }) + const enterRelease = createDeferred() + const confirmationRelease = createDeferred() + const settleRelease = createDeferred() + const entered = createDeferred() + const serverWritten = createDeferred() + const confirmationAccepted = createDeferred() + const confirmationRequested = createDeferred() + const failure = new Error(`handler ${outcome}`) + let handlerWrites = 0 + let confirmationCalls = 0 + let confirmationStatus: MutationConfirmationObservation[`confirmationStatus`] = `not-requested` + let settlement: MutationConfirmationObservation[`settlement`] = `pending` + let error: unknown + let verdict: Promise | undefined + let releaseCollection: (() => Promise) | undefined + await withElectricCleanup(async () => { + const message = change(operation, 1, `confirmed`) + if (operation !== `delete`) + message.value = { ...mutationConfirmationInputs.confirmed } + message.headers.txids = [55] + const handler = async () => { + entered.resolve() + await enterRelease.promise + if (outcome === `reject-before-write`) throw failure + handlerWrites++ + serverWritten.resolve() + await confirmationRelease.promise + if (confirmation === `txid`) { + confirmationCalls++ + confirmationStatus = `pending` + const ack = collection.utils.awaitTxId(55).then(() => { + confirmationStatus = `accepted` + }) + confirmationRequested.resolve() + await ack + } + confirmationAccepted.resolve() + await settleRelease.promise + if (outcome === `reject-after-write`) throw failure + return returned === `legacy-txid` ? { txid: 55 } : undefined + } + const runtimeHandler = handler as unknown as () => Promise + const collection = createCollection( + electricCollectionOptions({ + id: `discarded-value-${JSON.stringify(history)}`, + shapeOptions: { + url: `http://test-url`, + params: { table: `test_table` }, + }, + startSync: true, + getKey: (row) => row.id, + onInsert: runtimeHandler, + onUpdate: runtimeHandler, + onDelete: runtimeHandler, + }), + ) + releaseCollection = () => collection.cleanup() + subscriber([ + ...(history.peer ? [change(`insert`, 2, `peer`)] : []), + ...(operation === `insert` ? [] : [change(`insert`, 1, `before`)]), + upToDate, + ]) + await atCheckpoint(collection.preload(), `initial Electric preload`) + const transaction = + operation === `insert` + ? collection.insert({ ...mutationConfirmationInputs.optimistic }) + : operation === `delete` + ? collection.delete(1) + : collection.update(1, (draft) => { + draft.name = `optimistic` + }) + const observe = (): MutationConfirmationObservation => ({ + rows: collection.toArray + .map(({ id, name, stable }) => ({ id, name, stable })) + .sort((a, b) => a.id - b.id), + handlerWrites, + confirmationCalls, + fetches: 0, + confirmationStatus, + providerRequests: + mockStream.requestSnapshot.mock.calls.length + + mockStream.fetchSnapshot.mock.calls.length + + mockStream.forceDisconnectAndRefresh.mock.calls.length, + state: transaction.state, + settlement, + error, + }) + verdict = transaction.when(`settled`).then( + () => { + settlement = `fulfilled` + return observe() + }, + (reason: unknown) => { + settlement = `rejected` + error = reason + return observe() + }, + ) + await atCheckpoint(entered.promise, `Electric handler entry`) + checkMutationConfirmation(history, `entered`, observe(), failure) + localReach.reach(`entered`) + enterRelease.resolve() + if (outcome !== `reject-before-write`) { + await atCheckpoint(serverWritten.promise, `Electric server write`) + checkMutationConfirmation( + history, + `server-written`, + observe(), + failure, + ) + localReach.reach(`server-written`) + confirmationRelease.resolve() + if (confirmation === `txid`) { + await atCheckpoint( + confirmationRequested.promise, + `Electric acknowledgement registration`, + ) + await Promise.resolve() + checkMutationConfirmation( + history, + `confirmation-requested`, + observe(), + failure, + ) + localReach.reach(`confirmation-requested`) + subscriber([message, upToDate]) + } + await atCheckpoint( + confirmationAccepted.promise, + `Electric confirmation acceptance`, + ) + if (confirmation !== `none`) { + checkMutationConfirmation( + history, + `confirmation-accepted`, + observe(), + failure, + ) + localReach.reach(`confirmation-accepted`) + } + settleRelease.resolve() + } + const atWaiter = await atCheckpoint( + verdict, + `Electric handler settlement`, + ) + checkMutationConfirmation(history, `settled`, atWaiter, failure) + localReach.reach(`settlement-waiter`) + checkMutationConfirmation(history, `settled`, observe(), failure) + localReach.reach(`settled`) + if (confirmation === `none` && outcome !== `reject-before-write`) { + const ack = collection.utils.awaitTxId(55) + subscriber([message, upToDate]) + await atCheckpoint(ack, `later Electric stream confirmation`) + checkMutationConfirmation( + history, + `late-confirmed`, + observe(), + failure, + ) + localReach.reach(`late-confirmed`) + checkMutationConfirmation(history, `settled`, atWaiter, failure) + } + localReach.assertReached() + mutationReach.reach( + `${operation}/${outcome}/${confirmation}/${returned}/peer:${history.peer}`, + ) + }, [ + () => { + enterRelease.resolve() + confirmationRelease.resolve() + settleRelease.resolve() + if (confirmationStatus === `pending`) { + const message = change(operation, 1, `confirmed`) + if (operation !== `delete`) + message.value = { ...mutationConfirmationInputs.confirmed } + message.headers.txids = [55] + subscriber([message, upToDate]) + } + }, + () => verdict && atCheckpoint(verdict, `Electric cleanup settlement`), + () => releaseCollection?.(), + ]) + }, + ) +}) + function isSdkResetFramedPartition( batches: Array>>, ): boolean { @@ -3832,7 +4073,7 @@ describe(`Electric adapter laws`, () => { break case `release`: persistence.resolve() - await transaction!.isPersisted.promise + await transaction!.when(`settled`) break case `hold-acceptance`: armAcceptance = true @@ -4668,6 +4909,7 @@ oracleReplayTest(describe, queuedPresenceProperty)( // presence and persistence interleaving campaigns. declareOracle({ check: [ + checkMutationConfirmation, runProcessGrammar, runQueuedPresenceCampaign, expectQueuedPresenceAtDurabilityHold, diff --git a/packages/electric-db-collection/tests/electric-recovery-support.ts b/packages/electric-db-collection/tests/electric-recovery-support.ts index ac6886f12c..deb13c4769 100644 --- a/packages/electric-db-collection/tests/electric-recovery-support.ts +++ b/packages/electric-db-collection/tests/electric-recovery-support.ts @@ -198,7 +198,6 @@ export function fixture( Array.from(collection.values(), (row) => { const { $hasPendingWrites: _pending, - $synced: _synced, $origin: _origin, $key: _key, $collectionId: _collectionId, diff --git a/packages/electric-db-collection/tests/electric.test-d.ts b/packages/electric-db-collection/tests/electric.test-d.ts index f1d4ee109c..6033adbec2 100644 --- a/packages/electric-db-collection/tests/electric.test-d.ts +++ b/packages/electric-db-collection/tests/electric.test-d.ts @@ -124,21 +124,21 @@ describe(`Electric collection type resolution tests`, () => { collection.utils expectTypeOf(testCollectionUtils.awaitTxId).toBeFunction expectTypeOf(collection.utils.awaitTxId).toBeFunction - return Promise.resolve({ txid: 1 }) + return Promise.resolve() }, onUpdate: async ({ collection }) => { const testCollectionUtils: ElectricCollectionUtils = collection.utils expectTypeOf(testCollectionUtils.awaitTxId).toBeFunction expectTypeOf(collection.utils.awaitTxId).toBeFunction - return Promise.resolve({ txid: 1 }) + return Promise.resolve() }, onDelete: async ({ collection }) => { const testCollectionUtils: ElectricCollectionUtils = collection.utils expectTypeOf(testCollectionUtils.awaitTxId).toBeFunction expectTypeOf(collection.utils.awaitTxId).toBeFunction - return Promise.resolve({ txid: 1 }) + return Promise.resolve() }, }) @@ -175,13 +175,13 @@ describe(`Electric collection type resolution tests`, () => { schema: todoSchema, getKey: (item) => item.id, onInsert: async () => { - return Promise.resolve({ txid: 1 }) + return Promise.resolve() }, onUpdate: async () => { - return Promise.resolve({ txid: 1 }) + return Promise.resolve() }, onDelete: async () => { - return Promise.resolve({ txid: 1 }) + return Promise.resolve() }, }) @@ -217,7 +217,7 @@ describe(`Electric collection type resolution tests`, () => { ).toEqualTypeOf() // @ts-expect-error Electric Collection does not expose Query refetch utilities params.transaction.mutations[0].collection.utils.refetch() - return Promise.resolve({ txid: 1 }) + return Promise.resolve() }, onUpdate: (params) => { // Verify that the mutation value has the correct type @@ -230,7 +230,7 @@ describe(`Electric collection type resolution tests`, () => { expectTypeOf( params.transaction.mutations[0].collection.utils.awaitTxId, ).toEqualTypeOf() - return Promise.resolve({ txid: 1 }) + return Promise.resolve() }, onDelete: (params) => { // Verify that the mutation value has the correct type @@ -243,7 +243,7 @@ describe(`Electric collection type resolution tests`, () => { expectTypeOf( params.transaction.mutations[0].collection.utils.awaitTxId, ).toEqualTypeOf() - return Promise.resolve({ txid: 1 }) + return Promise.resolve() }, }) @@ -313,7 +313,7 @@ describe(`Electric collection type resolution tests`, () => { return mutation.original.id }) - return Promise.resolve({ txid: 1 }) + return Promise.resolve() }, onInsert: (params) => { // Direct index access should be correctly typed @@ -327,7 +327,7 @@ describe(`Electric collection type resolution tests`, () => { return mutation.modified.id }) - return Promise.resolve({ txid: 1 }) + return Promise.resolve() }, onUpdate: (params) => { // Direct index access should be correctly typed @@ -345,7 +345,7 @@ describe(`Electric collection type resolution tests`, () => { return mutation.modified.id }) - return Promise.resolve({ txid: 1 }) + return Promise.resolve() }, }) diff --git a/packages/electric-db-collection/tests/electric.test.ts b/packages/electric-db-collection/tests/electric.test.ts index 46b271d978..f9dbc6423c 100644 --- a/packages/electric-db-collection/tests/electric.test.ts +++ b/packages/electric-db-collection/tests/electric.test.ts @@ -1,13 +1,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { ShapeStream } from '@electric-sql/client' import { - CollectionImpl, IR, createCollection, createLiveQueryCollection, createTransaction, gt, - resetWarnings, withAcceptedReceipt, } from '@tanstack/db' import { persistedCollectionOptions } from '../../db-sqlite-persistence-core/src' @@ -23,7 +21,6 @@ import type { PendingMutation, SyncMetadataApi, Transaction, - TransactionWithMutations, } from '@tanstack/db' import type { Message, Row } from '@electric-sql/client' import type { StandardSchemaV1 } from '@standard-schema/spec' @@ -816,7 +813,7 @@ describe(`Electric Integration`, () => { expect(collection.get(1)).toBeUndefined() persistence.resolve() - await transaction.isPersisted.promise + await transaction.when(`settled`) await collection.stateWhenReady() expect(collection.status).toBe(`ready`) @@ -1383,7 +1380,7 @@ describe(`Electric Integration`, () => { collection.insert({ id: 1, name: `Test item 1` }), ) - await transaction.isPersisted.promise + await transaction.when(`settled`) transaction = collection._state.transactions.get(transaction.id)! @@ -1402,9 +1399,9 @@ describe(`Electric Integration`, () => { describe(`Direct persistence handlers`, () => { it(`should pass through direct persistence handlers to collection options`, () => { // Create mock handlers - const onInsert = vi.fn().mockResolvedValue({ txid: 123 }) - const onUpdate = vi.fn().mockResolvedValue({ txid: 456 }) - const onDelete = vi.fn().mockResolvedValue({ txid: 789 }) + const onInsert = vi.fn().mockResolvedValue(undefined) + const onUpdate = vi.fn().mockResolvedValue(undefined) + const onDelete = vi.fn().mockResolvedValue(undefined) const config = { id: `test-handlers`, @@ -1428,123 +1425,6 @@ describe(`Electric Integration`, () => { expect(options.onDelete).toBeDefined() }) - it(`warns once while continuing to handle legacy txid returns`, async () => { - resetWarnings() - const warning = vi.spyOn(console, `warn`).mockImplementation(() => {}) - const awaitTxId = vi.fn().mockResolvedValue(true) - const onInsert = vi.fn().mockResolvedValue({ txid: 123 }) - const options = electricCollectionOptions({ - id: `legacy-handler-warning`, - shapeOptions: { - url: `http://test-url`, - params: { table: `test_table` }, - }, - getKey: (item: Row) => item.id as number, - onInsert, - }) - const params = { - transaction: { - id: `legacy-handler-transaction`, - mutations: [], - }, - collection: { utils: { awaitTxId } }, - } as unknown as InsertMutationFnParams< - Row, - string | number, - ElectricCollectionUtils - > - - try { - await options.onInsert!(params) - await options.onInsert!(params) - - expect(onInsert).toHaveBeenCalledTimes(2) - expect(awaitTxId).toHaveBeenCalledTimes(2) - expect(warning).toHaveBeenCalledTimes(1) - expect(warning).toHaveBeenCalledWith( - expect.stringContaining(`Returning { txid }`), - ) - } finally { - resetWarnings() - warning.mockRestore() - } - }) - - it(`treats an empty handler result as no synchronization strategy`, async () => { - // Create a mock transaction for testing - const mockTransaction = { - id: `test-transaction`, - mutations: [], - } as unknown as TransactionWithMutations - const mockParams: InsertMutationFnParams< - Row, - string | number, - ElectricCollectionUtils - > = { - transaction: mockTransaction, - // @ts-expect-error not relevant to test - collection: CollectionImpl, - } - - // Create a handler that doesn't return a txid - const onInsert = vi.fn().mockResolvedValue({}) - - const config = { - id: `test-handlers`, - shapeOptions: { - url: `http://test-url`, - params: { - table: `test_table`, - }, - }, - getKey: (item: Row) => item.id as number, - onInsert, - } - - const options = electricCollectionOptions(config) - - // Empty objects retain the existing no-wait behavior. - await expect(options.onInsert!(mockParams)).resolves.not.toThrow() - }) - - it.each([ - [`string`, `legacy-result`], - [`number`, 42], - [`null`, null], - [`function`, () => undefined], - [`object`, {}], - [`void`, undefined], - ])(`tolerates an out-of-contract %s handler result`, async (_, result) => { - const awaitTxId = vi.fn().mockResolvedValue(true) - const options = electricCollectionOptions({ - id: `out-of-contract-handler-result`, - shapeOptions: { - url: `http://test-url`, - params: { table: `test_table` }, - }, - getKey: (item: Row) => item.id as number, - onInsert: vi.fn().mockResolvedValue(result), - }) - const params = { - transaction: { id: `test-transaction`, mutations: [] }, - collection: { utils: { awaitTxId } }, - } as unknown as InsertMutationFnParams< - Row, - string | number, - ElectricCollectionUtils - > - - let thrown: unknown - try { - await options.onInsert!(params) - } catch (error) { - thrown = error - } - - expect(thrown).toBeUndefined() - expect(awaitTxId).not.toHaveBeenCalled() - }) - it(`should simulate complete flow with direct persistence handlers`, async () => { // Create a fake backend store to simulate server-side storage const fakeBackend = { @@ -1601,10 +1481,18 @@ describe(`Electric Integration`, () => { return txid }) - // Create direct persistence handler that returns the txid - const onInsert = vi.fn(async (params: MutationFnParams) => { - return { txid: await mutationFn(params) } - }) + const onInsert = vi.fn( + async ( + params: InsertMutationFnParams< + Row, + string | number, + ElectricCollectionUtils + >, + ) => { + const txid = await mutationFn(params) + await params.collection.utils.awaitTxId(txid) + }, + ) // Create a test collection with our direct persistence handler const config = { @@ -1628,13 +1516,13 @@ describe(`Electric Integration`, () => { name: `Direct Persistence User`, }) - // If awaitTxId wasn't called automatically, this wouldn't be true. + // The handler is still waiting for awaitTxId at this checkpoint. expect(testCollection._state.syncedData.size).toEqual(0) // Verify that our onInsert handler was called expect(onInsert).toHaveBeenCalled() - await tx.isPersisted.promise + await tx.when(`settled`) // Verify that the data was added to the collection via the sync process expect(testCollection.has(1)).toBe(true) @@ -1645,9 +1533,40 @@ describe(`Electric Integration`, () => { expect(testCollection._state.syncedData.size).toEqual(1) }) + it(`ignores a legacy handler return value without waiting for its txid`, async () => { + const testCollection = createCollection( + electricCollectionOptions({ + id: `legacy-return-value`, + shapeOptions: { + url: `http://test-url`, + params: { table: `test_table` }, + }, + startSync: true, + getKey: (item: Row) => item.id as number, + onInsert: (() => + Promise.resolve({ txid: 123 })) as unknown as () => Promise, + }), + ) + + const transaction = testCollection.insert({ id: 1, name: `pending` }) + await expect(transaction.when(`settled`)).resolves.toBe(transaction) + expect(testCollection.has(1)).toBe(false) + await testCollection.cleanup() + }) + it(`should remove optimistic insert when txid sync confirms a different server-generated key`, async () => { const txid = 1234 - const onInsert = vi.fn().mockResolvedValue({ txid }) + const onInsert = vi.fn( + async ({ + collection, + }: InsertMutationFnParams< + Row, + string | number, + ElectricCollectionUtils + >) => { + await collection.utils.awaitTxId(txid) + }, + ) const testCollection = createCollection( electricCollectionOptions({ @@ -1678,7 +1597,7 @@ describe(`Electric Integration`, () => { { headers: { control: `up-to-date` } }, ]) - await tx.isPersisted.promise + await tx.when(`settled`) expect(testCollection.has(4733)).toBe(false) expect(stripVirtualProps(testCollection.get(24))).toEqual({ @@ -1688,7 +1607,7 @@ describe(`Electric Integration`, () => { expect(Array.from(testCollection.state.keys())).toEqual([24]) }) - it(`should support void strategy when handler returns nothing`, async () => { + it(`settles immediately when a handler does not wait for sync`, async () => { const onInsert = vi.fn().mockResolvedValue(undefined) const config = { @@ -1707,15 +1626,22 @@ describe(`Electric Integration`, () => { // Insert with void strategy - should complete immediately without waiting const tx = testCollection.insert({ id: 1, name: `Void Test` }) - await expect(tx.isPersisted.promise).resolves.toBeDefined() + await expect(tx.when(`settled`)).resolves.toBeDefined() expect(onInsert).toHaveBeenCalled() }) - it(`should support custom timeout in matching strategy`, async () => { - const onInsert = vi.fn(() => { - // Return a txid that will never arrive with a very short timeout - return Promise.resolve({ txid: 999999, timeout: 100 }) - }) + it(`supports a custom awaitTxId timeout`, async () => { + const onInsert = vi.fn( + async ({ + collection, + }: InsertMutationFnParams< + Row, + string | number, + ElectricCollectionUtils + >) => { + await collection.utils.awaitTxId(999999, 100) + }, + ) const config = { id: `test-custom-timeout`, @@ -1737,12 +1663,12 @@ describe(`Electric Integration`, () => { expect(onInsert).toHaveBeenCalled() // The transaction should reject due to timeout - await expect(tx.isPersisted.promise).rejects.toThrow( + await expect(tx.when(`settled`)).rejects.toThrow( `Timeout waiting for txId: 999999`, ) }) - it(`should handle array of txids returned from handler`, async () => { + it(`waits for every txid requested by a handler`, async () => { // Create a fake backend that returns multiple txids const fakeBackend = { persist: ( @@ -1754,43 +1680,52 @@ describe(`Electric Integration`, () => { }, } - // Create handler that returns array of txids - const onInsert = vi.fn(async (params: MutationFnParams) => { - const txids = await fakeBackend.persist(params.transaction.mutations) - - // Simulate server sending sync messages on different ticks - // In the real world, multiple txids rarely arrive together - setTimeout(() => { - subscriber([ - { - key: `1`, - value: { id: 1, name: `Item 1` }, - headers: { - operation: `insert`, - txids: [txids[0]!], + // Create handler that waits for each txid + const onInsert = vi.fn( + async ( + params: InsertMutationFnParams< + Row, + string | number, + ElectricCollectionUtils + >, + ) => { + const txids = await fakeBackend.persist(params.transaction.mutations) + + // Simulate server sending sync messages on different ticks + // In the real world, multiple txids rarely arrive together + setTimeout(() => { + subscriber([ + { + key: `1`, + value: { id: 1, name: `Item 1` }, + headers: { + operation: `insert`, + txids: [txids[0]!], + }, }, - }, - { headers: { control: `up-to-date` } }, - ]) - }, 1) + { headers: { control: `up-to-date` } }, + ]) + }, 1) - setTimeout(() => { - subscriber([ - { - key: `2`, - value: { id: 2, name: `Item 2` }, - headers: { - operation: `insert`, - txids: [txids[1]!], + setTimeout(() => { + subscriber([ + { + key: `2`, + value: { id: 2, name: `Item 2` }, + headers: { + operation: `insert`, + txids: [txids[1]!], + }, }, - }, - { headers: { control: `up-to-date` } }, - ]) - }, 2) + { headers: { control: `up-to-date` } }, + ]) + }, 2) - // Return array of txids - this is the pattern that's failing - return { txid: txids } - }) + await Promise.all( + txids.map((txid) => params.collection.utils.awaitTxId(txid)), + ) + }, + ) const config = { id: `test-array-txids`, @@ -1812,7 +1747,7 @@ describe(`Electric Integration`, () => { ]) // This should resolve when all txids are seen - await expect(tx.isPersisted.promise).resolves.toBeDefined() + await expect(tx.when(`settled`)).resolves.toBeDefined() expect(onInsert).toHaveBeenCalled() // Verify both items were added @@ -1875,7 +1810,7 @@ describe(`Electric Integration`, () => { }, 100) // Wait for both the custom match and persistence - await Promise.all([customMatchPromise, insertPromise.isPersisted.promise]) + await Promise.all([customMatchPromise, insertPromise.when(`settled`)]) expect(onInsert).toHaveBeenCalled() expect(testCollection.has(1)).toBe(true) @@ -1911,7 +1846,7 @@ describe(`Electric Integration`, () => { const tx = testCollection.insert({ id: 1, name: `Timeout Test` }) // Capture the rejection promise before advancing timers - const rejectionPromise = expect(tx.isPersisted.promise).rejects.toThrow( + const rejectionPromise = expect(tx.when(`settled`)).rejects.toThrow( `Timeout waiting for custom match function`, ) @@ -1995,7 +1930,7 @@ describe(`Electric Integration`, () => { resolveServerCall!() // Should complete successfully - awaitMatch should find the message in the buffer - await insertPromise.isPersisted.promise + await insertPromise.when(`settled`) expect(onInsert).toHaveBeenCalled() expect(testCollection.has(42)).toBe(true) @@ -2063,7 +1998,7 @@ describe(`Electric Integration`, () => { subscriber([{ headers: { control: `up-to-date` } }]) }, 150) - await insertPromise.isPersisted.promise + await insertPromise.when(`settled`) expect(onInsert).toHaveBeenCalled() // Match should have been found before resolve (both times should exist) @@ -2149,7 +2084,7 @@ describe(`Electric Integration`, () => { // even though item 200 from the first batch is no longer in the buffer resolveServerCall!() - await insertPromise.isPersisted.promise + await insertPromise.when(`settled`) expect(testCollection.has(201)).toBe(true) }) @@ -2196,7 +2131,7 @@ describe(`Electric Integration`, () => { // Insert looking for id 999 which doesn't exist const tx = testCollection.insert({ id: 301, name: `Test` }) - await expect(tx.isPersisted.promise).rejects.toThrow( + await expect(tx.when(`settled`)).rejects.toThrow( `Timeout waiting for custom match function`, ) }) @@ -2279,9 +2214,9 @@ describe(`Electric Integration`, () => { serverCalls.forEach((call) => call.resolve()) await Promise.all([ - insert1.isPersisted.promise, - insert2.isPersisted.promise, - insert3.isPersisted.promise, + insert1.when(`settled`), + insert2.when(`settled`), + insert3.when(`settled`), ]) expect(matches).toContain(400) @@ -2349,7 +2284,7 @@ describe(`Electric Integration`, () => { // Resolve server call for insert 1 const call1 = serverCalls.find((c) => c.id === 500) call1?.resolve() - await insert1.isPersisted.promise + await insert1.when(`settled`) expect(testCollection.has(500)).toBe(true) // Insert 2 - starts waiting on server call @@ -2370,7 +2305,7 @@ describe(`Electric Integration`, () => { // Resolve server call for insert 2 const call2 = serverCalls.find((c) => c.id === 501) call2?.resolve() - await insert2.isPersisted.promise + await insert2.when(`settled`) expect(testCollection.has(501)).toBe(true) // Insert 3 - starts waiting on server call @@ -2391,7 +2326,7 @@ describe(`Electric Integration`, () => { // Resolve server call for insert 3 const call3 = serverCalls.find((c) => c.id === 502) call3?.resolve() - await insert3.isPersisted.promise + await insert3.when(`settled`) expect(testCollection.has(502)).toBe(true) }) @@ -2476,7 +2411,7 @@ describe(`Electric Integration`, () => { resolveServerCall!() // Should complete successfully - await insertPromise.isPersisted.promise + await insertPromise.when(`settled`) expect(onInsert).toHaveBeenCalled() expect(testCollection.has(600)).toBe(true) @@ -2540,9 +2475,9 @@ describe(`Electric Integration`, () => { expect(typeof testCollection.utils.awaitMatch).toBe(`function`) }) - it(`should support multiple strategies in different handlers`, () => { - const onInsert = vi.fn().mockResolvedValue({ txid: 100 }) // Txid strategy - const onUpdate = vi.fn().mockResolvedValue(undefined) // Void strategy (no return) + it(`passes distinct handlers through to collection options`, () => { + const onInsert = vi.fn().mockResolvedValue(undefined) + const onUpdate = vi.fn().mockResolvedValue(undefined) const onDelete = vi .fn() .mockImplementation(async ({ collection: col }) => { @@ -2568,7 +2503,7 @@ describe(`Electric Integration`, () => { const options = electricCollectionOptions(config) - // All handlers should be wrapped properly + // All handlers are present on the resulting options expect(options.onInsert).toBeDefined() expect(options.onUpdate).toBeDefined() expect(options.onDelete).toBeDefined() @@ -2606,7 +2541,7 @@ describe(`Electric Integration`, () => { const tx = testCollection.insert({ id: 1, name: `Timeout Test` }) // Capture the rejection promise before advancing timers - const rejectionPromise = expect(tx.isPersisted.promise).rejects.toThrow( + const rejectionPromise = expect(tx.when(`settled`)).rejects.toThrow( `Timeout waiting for custom match function`, ) @@ -2673,7 +2608,7 @@ describe(`Electric Integration`, () => { }) // Set up persistence completion tracking - insertPromise.isPersisted.promise.then(() => { + insertPromise.when(`settled`).then(() => { persistenceCompleted = true }) @@ -2700,7 +2635,7 @@ describe(`Electric Integration`, () => { subscriber([{ headers: { control: `up-to-date` } }]) // Wait for persistence to complete - await insertPromise.isPersisted.promise + await insertPromise.when(`settled`) // Verify persistence completed after up-to-date expect(persistenceCompleted).toBe(true) @@ -2736,7 +2671,7 @@ describe(`Electric Integration`, () => { // Use runOnlyPendingTimers to execute the timeout await vi.runOnlyPendingTimersAsync() - await expect(tx.isPersisted.promise).resolves.toBeDefined() + await expect(tx.when(`settled`)).resolves.toBeDefined() expect(onInsert).toHaveBeenCalled() vi.useRealTimers() @@ -3536,7 +3471,7 @@ describe(`Electric Integration`, () => { expect(testCollection.has(2)).toBe(false) persistence.resolve() - await transaction.isPersisted.promise + await transaction.when(`settled`) await load expect(stripVirtualProps(testCollection.get(2))).toEqual({ id: 2, @@ -3545,7 +3480,7 @@ describe(`Electric Integration`, () => { } finally { request.resolve() persistence.resolve() - await transaction.isPersisted.promise.catch(() => undefined) + await transaction.when(`settled`).catch(() => undefined) await testCollection.cleanup() } }) @@ -3908,7 +3843,7 @@ describe(`Electric Integration`, () => { expect(testCollection.has(2)).toBe(false) abortController.abort() persistence.resolve() - await transaction.isPersisted.promise + await transaction.when(`settled`) // The accepted snapshot applies, but the aborted caller sees // `AbortError`. if (load === true) throw new Error(`Expected a pending subset load`) @@ -3918,7 +3853,7 @@ describe(`Electric Integration`, () => { } finally { abortController.abort() persistence.resolve() - await transaction.isPersisted.promise.catch(() => undefined) + await transaction.when(`settled`).catch(() => undefined) await testCollection.cleanup() } }) diff --git a/packages/electron-db-sqlite-persistence/tests/electron-persisted-collection.e2e.test.ts b/packages/electron-db-sqlite-persistence/tests/electron-persisted-collection.e2e.test.ts index 0051deb86e..0a85fcb4bd 100644 --- a/packages/electron-db-sqlite-persistence/tests/electron-persisted-collection.e2e.test.ts +++ b/packages/electron-db-sqlite-persistence/tests/electron-persisted-collection.e2e.test.ts @@ -154,15 +154,13 @@ function createPersistedCollection( } type PersistedTransactionHandle = { - isPersisted: { - promise: Promise - } + when: (state: 'settled') => Promise } async function waitForPersisted( transaction: PersistedTransactionHandle, ): Promise { - await transaction.isPersisted.promise + await transaction.when(`settled`) } async function seedCollection( diff --git a/packages/expo-db-sqlite-persistence/e2e/expo-runtime-app/App.tsx b/packages/expo-db-sqlite-persistence/e2e/expo-runtime-app/App.tsx index 178bae89f6..d854184055 100644 --- a/packages/expo-db-sqlite-persistence/e2e/expo-runtime-app/App.tsx +++ b/packages/expo-db-sqlite-persistence/e2e/expo-runtime-app/App.tsx @@ -162,7 +162,7 @@ export default function App() { title: `Persisted from Expo runtime`, score: 7, }) - await insertTx.isPersisted.promise + await insertTx.when(`settled`) await collection.cleanup() collection.startSyncImmediate() await collection.stateWhenReady() diff --git a/packages/expo-db-sqlite-persistence/e2e/mobile-persisted-collection-conformance-suite.ts b/packages/expo-db-sqlite-persistence/e2e/mobile-persisted-collection-conformance-suite.ts index cc5fc9b727..4836c13cf0 100644 --- a/packages/expo-db-sqlite-persistence/e2e/mobile-persisted-collection-conformance-suite.ts +++ b/packages/expo-db-sqlite-persistence/e2e/mobile-persisted-collection-conformance-suite.ts @@ -80,15 +80,13 @@ function createPersistedCollection( } type PersistedTransactionHandle = { - isPersisted: { - promise: Promise - } + when: (state: 'settled') => Promise } async function waitForPersisted( transaction: PersistedTransactionHandle, ): Promise { - await transaction.isPersisted.promise + await transaction.when(`settled`) } async function seedCollection( diff --git a/packages/expo-db-sqlite-persistence/tests/expo-persistence.test.ts b/packages/expo-db-sqlite-persistence/tests/expo-persistence.test.ts index e5bc19f674..dd2f4c9b70 100644 --- a/packages/expo-db-sqlite-persistence/tests/expo-persistence.test.ts +++ b/packages/expo-db-sqlite-persistence/tests/expo-persistence.test.ts @@ -154,7 +154,7 @@ it(`resumes persisted sync after simulated background/foreground transitions`, a title: `Before background`, score: 1, }) - await initialInsert.isPersisted.promise + await initialInsert.when(`settled`) expect(collection.get(`1`)?.title).toBe(`Before background`) await collection.cleanup() @@ -166,7 +166,7 @@ it(`resumes persisted sync after simulated background/foreground transitions`, a title: `Post resume write`, score: 2, }) - await postResumeInsert.isPersisted.promise + await postResumeInsert.when(`settled`) expect(collection.get(`2`)?.title).toBe(`Post resume write`) const persistedRows = await persistence.adapter.loadSubset( diff --git a/packages/node-db-sqlite-persistence/e2e/node-persisted-collection.e2e.test.ts b/packages/node-db-sqlite-persistence/e2e/node-persisted-collection.e2e.test.ts index 465f3c2eb5..1a81a598f4 100644 --- a/packages/node-db-sqlite-persistence/e2e/node-persisted-collection.e2e.test.ts +++ b/packages/node-db-sqlite-persistence/e2e/node-persisted-collection.e2e.test.ts @@ -78,15 +78,13 @@ function createPersistedCollection( } type PersistedTransactionHandle = { - isPersisted: { - promise: Promise - } + when: (state: 'settled') => Promise } async function waitForPersisted( transaction: PersistedTransactionHandle, ): Promise { - await transaction.isPersisted.promise + await transaction.when(`settled`) } async function seedCollection( diff --git a/packages/node-db-sqlite-persistence/tests/node-persistence.test.ts b/packages/node-db-sqlite-persistence/tests/node-persistence.test.ts index df9b102116..3092862150 100644 --- a/packages/node-db-sqlite-persistence/tests/node-persistence.test.ts +++ b/packages/node-db-sqlite-persistence/tests/node-persistence.test.ts @@ -112,7 +112,7 @@ describe(`node persistence helpers`, () => { source.write({ type: `update`, value: { id: `row`, title: `two` } }) const predecessor = Promise.resolve(source.commit()) releaseHandler() - await mutation.isPersisted.promise + await mutation.when(`settled`) await predecessor expect(collection.get(`row`)?.title).toBe(`three`) diff --git a/packages/offline-transactions/README.md b/packages/offline-transactions/README.md index b0aa4ed24e..9587a000e2 100644 --- a/packages/offline-transactions/README.md +++ b/packages/offline-transactions/README.md @@ -86,7 +86,7 @@ const transaction = offlineTx.mutate(() => { // Commit can remain pending while offline. Observe final failure. void offlineTx.commit().catch((error) => console.error(error)) -void transaction.isPersisted.promise.catch((error) => console.error(error)) +void transaction.when('settled').catch((error) => console.error(error)) ``` On React Native, pass a custom `storage` adapter to `startOfflineExecutor`. @@ -291,7 +291,7 @@ await tx.commit() The `Transaction` returned by `mutate()` exposes local transaction state. Its `state` starts as `pending`, becomes `persisting` during commit, and settles as `completed` or `failed`. A rollback can move it to `failed` before or during -persistence. `isPersisted.promise` settles at the same success or failure +persistence. `when('settled')` settles at the same success or failure boundary. On the offline-execution path, successful settlement means the configured @@ -302,7 +302,7 @@ sync observation before returning. After `mutationFn` returns, the executor records a `deletion-pending` outbox phase before removing the row. If recording that phase or removing the row -fails, `commit()`, `isPersisted.promise`, and the per-ID completion waiter reject +fails, `commit()`, `when('settled')`, and the per-ID completion waiter reject with the storage error. The executor stops: it does not retry the deletion, process queued peers, or admit new transactions. A fresh executor can remove a marked row without calling `mutationFn` again; `beforeRetry` only filters rows @@ -333,7 +333,7 @@ const tx = offlineTx.mutate(() => { console.log(tx.state) // 'pending' try { - await Promise.all([offlineTx.commit(), tx.isPersisted.promise]) + await Promise.all([offlineTx.commit(), tx.when('settled')]) console.log(tx.state) // 'completed' } catch (error) { showSubmissionError(error) @@ -365,7 +365,7 @@ function trackPending(itemId: string, tx: Transaction) { // Handle fulfillment and rejection so cleanup does not create another // rejected Promise chain. - void tx.isPersisted.promise.then(removeThisTransaction, removeThisTransaction) + void tx.when('settled').then(removeThisTransaction, removeThisTransaction) } function isPending(itemId: string): boolean { diff --git a/packages/offline-transactions/package.json b/packages/offline-transactions/package.json index 3855887c5f..8da9821867 100644 --- a/packages/offline-transactions/package.json +++ b/packages/offline-transactions/package.json @@ -59,11 +59,9 @@ "src", "skills" ], - "dependencies": { - "@tanstack/db": "workspace:*" - }, "peerDependencies": { "@react-native-community/netinfo": ">=11.0.0", + "@tanstack/db": "^1.0.0-rc.0", "react-native": ">=0.70.0" }, "peerDependenciesMeta": { @@ -76,6 +74,7 @@ }, "devDependencies": { "@react-native-community/netinfo": "11.4.1", + "@tanstack/db": "workspace:*", "@types/node": "^25.2.2", "eslint": "^9.39.2", "react-native": "0.79.6", diff --git a/packages/offline-transactions/src/OfflineExecutor.ts b/packages/offline-transactions/src/OfflineExecutor.ts index a2d6d712e3..ff44c7fc12 100644 --- a/packages/offline-transactions/src/OfflineExecutor.ts +++ b/packages/offline-transactions/src/OfflineExecutor.ts @@ -550,10 +550,12 @@ export class OfflineExecutor { return } + // A sibling rollback can settle this snapshot before its durable work. + if (restorationTx.state !== `pending`) return + // Mark as completed: every Collection that tracked it recomputes and // releases it. The actual data will come from the sync. - restorationTx.setState(`completed`) - restorationTx.isPersisted.resolve(restorationTx) + restorationTx.completeWithoutCommit() restorationTx.touchCollection() } diff --git a/packages/offline-transactions/tests/leadership-replay-oracle.property.test.ts b/packages/offline-transactions/tests/leadership-replay-oracle.property.test.ts index 813ece021f..fb13405d1b 100644 --- a/packages/offline-transactions/tests/leadership-replay-oracle.property.test.ts +++ b/packages/offline-transactions/tests/leadership-replay-oracle.property.test.ts @@ -590,9 +590,7 @@ async function checkReport({ autoCommit: false, mutationFn: async () => {}, }) - const rolledBack = transaction.isPersisted.promise.catch( - () => undefined, - ) + const rolledBack = transaction.when('settled').catch(() => undefined) const expected = [{ id: `valid`, key: `once`, row }] const report = async (count: number) => { expect(callbacks.size).toBe(1) diff --git a/packages/offline-transactions/tests/leadership-replay.test.ts b/packages/offline-transactions/tests/leadership-replay.test.ts index d126b02b66..22081f396c 100644 --- a/packages/offline-transactions/tests/leadership-replay.test.ts +++ b/packages/offline-transactions/tests/leadership-replay.test.ts @@ -164,7 +164,7 @@ it.each([ if (loss === `before-call`) env.leader.setLeader(false) const transaction = action(row) transactionId = transaction.id - const settled = transaction.isPersisted.promise.then( + const settled = transaction.when('settled').then( () => { outcome.status = `fulfilled` }, @@ -249,7 +249,7 @@ it(`rejects a selected manual offline transaction that loses leadership before a }) const transaction = offline.mutate(() => env.collection.insert(row)) transactionId = transaction.id - persistedObserved = transaction.isPersisted.promise.then( + persistedObserved = transaction.when('settled').then( () => `fulfilled`, (error: unknown) => error, ) diff --git a/packages/offline-transactions/tests/restoration-ownership.test.ts b/packages/offline-transactions/tests/restoration-ownership.test.ts index a36bd3a224..5d85104bf2 100644 --- a/packages/offline-transactions/tests/restoration-ownership.test.ts +++ b/packages/offline-transactions/tests/restoration-ownership.test.ts @@ -20,7 +20,7 @@ import type { OfflineTransaction, TransactionSignaler } from '../src/types' * * - One offline transaction is one transaction: repeated `mutate()` calls * add to it, so every written row stays visible and no id is reused. - * - A restoration transaction that completes also settles `isPersisted`, as + * - A restoration transaction that completes also settles `when('settled')`, as * every settled transaction does. * - A restoration that cannot track every Collection it wrote leaves no * Collection tracking it, so no restored row outlives its owner. @@ -89,7 +89,7 @@ it(`rejects a mutate() call after an auto-committed one`, async () => { expect(first.mutations.map((mutation) => mutation.key)).toEqual([`a`]) expect(env.collection.get(`a`)?.value).toBe(`first`) expect(env.collection.get(`b`)).toBeUndefined() - await first.isPersisted.promise + await first.when(`settled`) env.executor.dispose() }) @@ -131,7 +131,7 @@ it(`settles a restoration transaction when its offline transaction completes`, a const restorationTx = tracked[0]! let settled = false - void restorationTx.isPersisted.promise.then(() => { + void restorationTx.when(`settled`).then(() => { settled = true }) release() diff --git a/packages/offline-transactions/tests/transaction-serializer-oracle.property.test.ts b/packages/offline-transactions/tests/transaction-serializer-oracle.property.test.ts index 1ef3fa32c5..a4cdcb8bea 100644 --- a/packages/offline-transactions/tests/transaction-serializer-oracle.property.test.ts +++ b/packages/offline-transactions/tests/transaction-serializer-oracle.property.test.ts @@ -208,7 +208,7 @@ async function checkRoundtrip( autoCommit: false, mutationFn: async () => {}, }) - const rollback = transaction.isPersisted.promise.catch(() => undefined) + const rollback = transaction.when(`settled`).catch(() => undefined) let hasPrimaryFailure = false let primaryFailure: unknown try { @@ -865,7 +865,7 @@ oracleReplayTest(it)( autoCommit: false, mutationFn: async () => {}, }) - const rollback = local.isPersisted.promise.catch(() => undefined) + const rollback = local.when('settled').catch(() => undefined) const storage = new FakeStorageAdapter() let hasPrimaryFailure = false try { diff --git a/packages/offline-transactions/tests/transaction-settlement-oracle.property.test.ts b/packages/offline-transactions/tests/transaction-settlement-oracle.property.test.ts index 85ad378054..7dfe23d709 100644 --- a/packages/offline-transactions/tests/transaction-settlement-oracle.property.test.ts +++ b/packages/offline-transactions/tests/transaction-settlement-oracle.property.test.ts @@ -1,6 +1,6 @@ import fc from 'fast-check' import { createCollection, createTransaction } from '@tanstack/db' -import { expect, it, vi } from 'vitest' +import { afterAll, describe, expect, it, vi } from 'vitest' import { NonRetriableError } from '../src/types' import { OutboxManager } from '../src/outbox/OutboxManager' import { @@ -8,6 +8,7 @@ import { oracleReplayTest, } from '../../db/tests/oracle-config' import { declareOracle } from '../../db/tests/oracle-declaration' +import { createOracleReach } from '../../db/tests/oracle-reach' import { FakeStorageAdapter, createTestOfflineEnvironment, @@ -47,7 +48,7 @@ import type { OfflineConfig } from '../src/types' * keys, and success or permanent failure at each position. Each transaction * either commits manually or auto-commits after its `mutate()` callback, with * `autoCommit: true`, with the option omitted, or as an `OfflineAction`. An - * auto-commit has no `commit()` caller, so its `isPersisted` is the commit + * auto-commit has no `commit()` caller, so its `when('settled')` is the commit * receipt. No failure, manual or auto-committed, may escape as an unhandled * process rejection, be logged again, or write debug output. Any position may * be preceded by an empty transaction on the same commit path: its callback @@ -84,7 +85,7 @@ import type { OfflineConfig } from '../src/types' * * Held and failed deletion witnesses split named mutation function fulfillment * from durable outbox removal. At the held-delete checkpoint, the public caller - * and `isPersisted` promises remain pending. A phase-write or deletion failure + * and `when('settled')` promises remain pending. A phase-write or deletion failure * rejects the caller promise with the storage error, stops the executor, and * holds queued peers. An offline executor restart removes a marked outbox entry * without another named mutation function call. @@ -191,22 +192,176 @@ function expectLocalRows( expect(localRows(actual)).toEqual(localRows(expected)) } -// The model of public Collection rows after a restored snapshot settles. The -// server rows are the durable provider results so far, and each queued restored -// row is still visible. Restoration holds pending snapshots. Core rollback -// cancels conflicting pending peers, but not disjoint peers or their -// independently queued work. -function expectedRestoredLocalRows( - rows: Array, - sharedKey: boolean, - failedIndex: number, - index: number, - expectedServer: Map, -): Map { - const pending = sharedKey && index >= failedIndex ? [] : rows.slice(index + 1) - const expected = new Map(expectedServer) - for (const row of pending) expected.set(row.id, row) - return expected +/** Restored snapshots have a different lifetime from durable outbox work. + * A core rollback cancels conflicting pending snapshots, but their independently + * admitted outbox entries still execute and settle from their own providers. + * A cancelled snapshot stays failed even if its outbox entry later succeeds. + * Authority: core rollback/admission and the README's per-ID durable settlement. + * This stateless prefix fold uses authored keys, whole rows and provider results; + * it does not consult executor queues, restoration maps or actual Collection rows. + */ +type RestoredHistory = { + layout: `shared` | `disjoint` | `chain` + width: number + succeeds: ReadonlyArray +} +function restoredRows(history: RestoredHistory): Array> { + // Alternating field edits preserve the old insert/value-edit/flag-edit + // history. In particular, the flag-only snapshot carries the prior value. + // Its later success must still send that whole snapshot after sibling failure. + const authored = new Map() + return history.succeeds.map((_success, index) => + Array.from({ length: history.width }, (_unused, slot) => { + const id = + history.layout === `shared` + ? `key-${slot}` + : history.layout === `chain` + ? `key-${index + slot}` + : `key-${index}-${slot}` + const before = authored.get(id) + const row = { + id, + value: before + ? index % 2 === 1 + ? `edited-${index}-${id}` + : before.value + : `initial-${id}`, + completed: before ? (index % 2 === 0 ? true : before.completed) : false, + updatedAt: new Date(1700000000000), + } + authored.set(id, row) + return row + }), + ) +} + +type RestoredSnapshot = { rows: Array; pending: Array } +function restoredSnapshot( + rows: ReadonlyArray>, + server: ReadonlyMap, + cancelled: ReadonlySet, + completed: number, +): RestoredSnapshot { + const local = new Map(server) + const pending = new Set() + for (let index = completed + 1; index < rows.length; index++) + if (!cancelled.has(index)) + for (const row of rows[index]!) { + local.set(row.id, row) + pending.add(row.id) + } + return { rows: [...local.values()], pending: [...pending].sort() } +} +type RestoredObservation = RestoredSnapshot & { + waiters: Array + server: Map + outcomes: Array + states: Array + calls: Array<{ id: string; rows: Array }> + outbox: Array + errors: Array> + warnings: Array> + unhandled: Array +} +function expectedRestoredState( + history: RestoredHistory, + failures: ReadonlyArray, + completed: number, +): RestoredObservation { + const rows = restoredRows(history) + const server = new Map() + const cancelled = new Set() + const waiters: RestoredObservation[`waiters`] = rows.map(() => undefined) + for (let index = 0; index <= completed; index++) { + if (history.succeeds[index]) { + for (const row of rows[index]!) server.set(row.id, row) + } else if (!cancelled.has(index)) { + cancelled.add(index) + const keys = new Set(rows[index]!.map((row) => row.id)) + for (let peer = index + 1; peer < rows.length; peer++) + if (rows[peer]!.some((row) => keys.has(row.id))) cancelled.add(peer) + } + waiters[index] = restoredSnapshot(rows, server, cancelled, index) + } + return { + ...restoredSnapshot(rows, server, cancelled, completed), + waiters, + server, + outcomes: history.succeeds.map((success, index) => + index > completed ? `pending` : success ? `fulfilled` : failures[index], + ), + states: history.succeeds.map((_success, index) => + cancelled.has(index) + ? `failed` + : index <= completed + ? `completed` + : `pending`, + ), + calls: rows + .slice(0, Math.min(completed + 2, rows.length)) + .map((batch, index) => ({ id: `restored-${index}`, rows: batch })), + outbox: rows + .slice(completed + 1) + .map((_row, offset) => `restored-${completed + 1 + offset}`), + errors: [], + warnings: [], + unhandled: [], + } +} +function checkRestoredState( + actual: RestoredObservation, + expected: RestoredObservation, +): void { + expectLocalRows(actual.rows, expected.rows) + expect( + actual.pending, + `restored snapshot pending-write contribution`, + ).toEqual(expected.pending) + for (const [index, snapshot] of expected.waiters.entries()) { + expect( + Boolean(actual.waiters[index]), + `waiter snapshot presence ${index}`, + ).toBe(Boolean(snapshot)) + if (snapshot) { + expectLocalRows(actual.waiters[index]!.rows, snapshot.rows) + expect( + actual.waiters[index]!.pending, + `pending writes at waiter ${index}`, + ).toEqual(snapshot.pending) + } + } + expect(actual.server).toEqual(expected.server) + expect(actual.outcomes).toEqual(expected.outcomes) + for (const [index, outcome] of expected.outcomes.entries()) + if (outcome instanceof Error) expect(actual.outcomes[index]).toBe(outcome) + expect(actual.states).toEqual(expected.states) + expect(actual.calls).toEqual(expected.calls) + expect(actual.outbox).toEqual(expected.outbox) + expect( + actual.errors, + `restoration must not log an executor/cleanup failure`, + ).toEqual(expected.errors) + // An intentional permanent provider rejection may be reported as a warning. + // Its exact authored Error is allowed; a new cleanup/executor exception is + // not. Check reasons, without prescribing log text or duplicate-report policy. + const allowed = expected.outcomes.filter( + (outcome) => outcome instanceof Error, + ) + for (const warning of actual.warnings) { + const reasons = warning.filter((value) => value instanceof Error) + expect( + reasons.length, + `warning must identify a known provider failure`, + ).toBeGreaterThan(0) + for (const reason of reasons) + expect( + allowed.some((failure) => failure === reason), + `unexpected restoration warning: ${String(reason)}`, + ).toBe(true) + } + expect(actual.unhandled, `restoration unhandled rejections`).toEqual( + expected.unhandled, + ) } /** @@ -331,7 +486,7 @@ const checkSettlement = async (seed: number | undefined): Promise => { ) const expectedStatuses = Object.fromEntries([ ...emptyReceipts.map((name) => [name, `fulfilled`]), - // A plain rollback rejects isPersisted with no reason (core + // A plain rollback rejects when('settled') with no reason (core // contract); the waiter names the rollback with an Error. ...rolledBack.flatMap(({ wait, receipt }) => [ [receipt, undefined], @@ -389,7 +544,7 @@ const checkSettlement = async (seed: number | undefined): Promise => { // Receipts of transactions rolled back before they commit. const rolledBack: Array<{ wait: string; receipt: string }> = [] // Start a transaction on one commit path. An auto-commit has no - // commit() caller; its receipt is isPersisted, which settles like a + // commit() caller; its receipt is when('settled'), which settles like a // manual commit's. const start = ( commit: SettlementCommit, @@ -421,7 +576,7 @@ const checkSettlement = async (seed: number | undefined): Promise => { } let hasPrimaryFailure = false // No failure, manual or auto-committed, escapes as an unhandled - // process rejection, and none is logged again: isPersisted reports + // process rejection, and none is logged again: when('settled') reports // it. const { rejections: unhandled, cleanup: cleanupUnhandled } = captureUnhandledRejections() @@ -747,7 +902,7 @@ const checkSettlement = async (seed: number | undefined): Promise => { }, ], // A failed auto-commit with the default option, and a failed - // action, settle through isPersisted only. + // action, settle through when('settled') only. [ { sharedKeys: false, @@ -828,175 +983,286 @@ oracleReplayTest(it, SETTLEMENT_PROPERTY).each([undefined])( checkSettlement, ) -it.each([ - { sharedKey: false, failedIndex: 0 }, - { sharedKey: false, failedIndex: 1 }, - { sharedKey: true, failedIndex: 0 }, - { sharedKey: true, failedIndex: 1 }, -])( - `restores whole snapshots across sibling rollback (shared=$sharedKey, failure=$failedIndex)`, - async ({ sharedKey, failedIndex }) => { - const rows: Array = [0, 1, 2].map((index) => ({ - id: sharedKey ? `same` : `row-${index}`, - value: index === 0 ? `initial` : `edited`, - completed: index === 2, - updatedAt: new Date(1700000000000), - })) - // Seed real serialized mutations, not a second executor with ambiguous - // in-flight-disposal semantics. No encoder output defines expected rows. - const storage = new FakeStorageAdapter() - const seed = createCollection({ - id: `test-items`, - getKey: (row) => row.id, - startSync: true, - sync: { - sync: (ops) => { - ops.markReady() +/** Finite restored histories preserve the former shared/disjoint, first/middle + * failure examples and add no failure, last failure, multiple failures, two + * transactions, multirow snapshots and partial overlap. A chain has width two; + * width one would merely repeat the disjoint-key premise. Provider gates hold + * every tail. We seed real serialized outbox rows, then observe actual restore + * registrations without replacing them. At each provider completion, the next + * call has entered (or a task turn lets the final execution return). The check + * compares the complete prefix, including diagnostic output after waiter success. + * Controlled storage/provider behavior does not establish native durability. + */ +const restoredHistories: Array = [2, 3].flatMap((count) => + Array.from({ length: 2 ** count }, (_unused, mask) => + ( + [ + { layout: `shared`, width: 1 }, + { layout: `shared`, width: 2 }, + { layout: `disjoint`, width: 1 }, + { layout: `disjoint`, width: 2 }, + { layout: `chain`, width: 2 }, + ] as const + ).map((shape) => ({ + ...shape, + succeeds: Array.from({ length: count }, (_entry, index) => + Boolean(mask & (1 << index)), + ), + })), + ).flat(), +) +const restoredHistoryId = (history: RestoredHistory) => + `${history.layout}/${history.width}/${history.succeeds.map(Number).join(``)}` +describe(`restored snapshots and independent outbox settlement`, () => { + const reach = createOracleReach( + `offline.restored-settlement`, + restoredHistories.flatMap((history) => [ + `${restoredHistoryId(history)}/registered`, + ...history.succeeds.map( + (_success, index) => `${restoredHistoryId(history)}/settled-${index}`, + ), + ]), + ) + afterAll(() => { + reach.assertReached() + console.info( + `offline.restored-settlement reach`, + Object.fromEntries(reach.counts()), + ) + }) + it.each(restoredHistories)( + `restores whole snapshots through independent outcomes: %j`, + async (history) => { + reach.beginHistory() + const rows = restoredRows(history) + const storage = new FakeStorageAdapter() + const seed = createCollection({ + id: `test-items`, + getKey: (row) => row.id, + startSync: true, + sync: { + sync: (ops) => { + ops.markReady() + }, }, - }, - }) - const outbox = new OutboxManager(storage, { [seed.id]: seed }) - const rollbackSeeds: Array<() => void> = [] - try { - for (const [index, row] of rows.entries()) { - const tx = createTransaction({ - autoCommit: false, - mutationFn: () => Promise.resolve(), - }) - rollbackSeeds.push(() => { - tx.rollback({ isSecondaryRollback: true }) - }) - void tx.isPersisted.promise.catch(() => {}) - tx.mutate(() => { - if (sharedKey && index > 0) - seed.update(row.id, (draft) => { - if (index === 1) draft.value = row.value - else draft.completed = row.completed - }) - else seed.insert(row) - }) - await outbox.add({ - id: `restored-${index}`, - mutationFnName: `syncData`, - mutations: tx.mutations, - keys: tx.mutations.map(({ globalKey }) => globalKey), - idempotencyKey: `restore-${index}`, - createdAt: new Date(1700000000000 + index), - retryCount: 0, - nextAttemptAt: 0, - version: 1, - }) + }) + const outbox = new OutboxManager(storage, { [seed.id]: seed }) + const rollbackSeeds: Array<() => void> = [] + let seedFailed = false + try { + for (const [index, batch] of rows.entries()) { + const tx = createTransaction({ + autoCommit: false, + mutationFn: () => Promise.resolve(), + }) + rollbackSeeds.push(() => { + tx.rollback({ isSecondaryRollback: true }) + }) + void tx.when(`settled`).catch(() => {}) + tx.mutate(() => { + for (const row of batch) + if (seed.has(row.id)) + seed.update(row.id, (draft) => { + Object.assign(draft, row) + }) + else seed.insert(row) + }) + await outbox.add({ + id: `restored-${index}`, + mutationFnName: `syncData`, + mutations: tx.mutations, + keys: tx.mutations.map(({ globalKey }) => globalKey), + idempotencyKey: `restore-${index}`, + createdAt: new Date(1700000000000 + index), + retryCount: 0, + nextAttemptAt: 0, + version: 1, + }) + } + } catch (error) { + seedFailed = true + throw error + } finally { + await cleanupOfflineOracle( + [...rollbackSeeds, () => seed.cleanup()], + seedFailed, + ) } - } finally { - for (const rollback of rollbackSeeds) rollback() - await seed.cleanup() - } - - const entered = rows.map(() => gate()) - const released = rows.map(() => gate()) - const failure = new NonRetriableError(`restored permanent failure`) - const outcomes: Array = rows.map(() => `pending`) - const waits: Array> = [] - const calls: Array<{ id: string; rows: Array> }> = - [] - const env = createTestOfflineEnvironment({ - storage, - mutationFn: async (params) => { - const index = calls.length - const mutations = params.transaction.mutations - calls.push({ - id: params.transaction.id, - rows: mutations.map((mutation) => structuredClone(mutation.modified)), - }) - if (!entered[index]) throw new NonRetriableError(`unexpected replay`) - entered[index].resolve() - await released[index]!.promise - if (index === failedIndex) throw failure - env.applyMutations(mutations) - }, - }) - const expectedServer = new Map() - let hasPrimaryFailure = false - try { - await env.waitForLeader() - for (const index of rows.keys()) - waits.push( - env.executor.waitForTransactionCompletion(`restored-${index}`).then( - () => { - outcomes[index] = `fulfilled` - }, - (error: unknown) => { - outcomes[index] = error - }, - ), + const entered = rows.map(() => gate()) + const released = rows.map(() => gate()) + const failures = rows.map( + (_row, index) => + new NonRetriableError(`restored permanent failure ${index}`), + ) + const outcomes: Array = rows.map(() => `pending`) + const waits: Array> = [] + const calls: RestoredObservation[`calls`] = [] + const logged = vi.spyOn(console, `error`).mockImplementation(() => {}) + const warning = vi.spyOn(console, `warn`).mockImplementation(() => {}) + const { rejections: unhandled, cleanup: cleanupUnhandled } = + captureUnhandledRejections() + const env = createTestOfflineEnvironment({ + storage, + mutationFn: async (params) => { + const index = calls.length + const mutations = params.transaction.mutations + calls.push({ + id: params.transaction.id, + rows: mutations.map((mutation) => + structuredClone(mutation.modified), + ), + }) + if (!entered[index]) throw new NonRetriableError(`unexpected replay`) + entered[index].resolve() + await released[index]!.promise + if (!history.succeeds[index]) throw failures[index] + env.applyMutations(mutations) + }, + }) + const registrations = vi.spyOn( + env.executor, + `registerRestorationTransaction`, + ) + const waiterSnapshots: RestoredObservation[`waiters`] = rows.map( + () => undefined, + ) + const readSnapshot = (): RestoredSnapshot => { + const current = env.collection.toArray + return { + rows: localRows(current), + pending: current + .filter((row) => row.$hasPendingWrites) + .map((row) => row.id) + .sort(), + } + } + let hasPrimaryFailure = false + try { + await env.waitForLeader() + expect(registrations.mock.calls.map(([id]) => id)).toEqual( + rows.map((_row, index) => `restored-${index}`), ) - await atOracleCheckpoint(entered[0]!.promise, `restored provider entered`) - expectLocalRows(env.collection.toArray, sharedKey ? [rows[2]!] : rows) - for (const index of rows.keys()) { - released[index]!.resolve() - await atOracleCheckpoint(waits[index]!, `restored ${index} settled`) - if (index < 2) - await atOracleCheckpoint( - entered[index + 1]!.promise, - `restored sibling still held`, + const restored = new Map(registrations.mock.calls) + expect([...restored.keys()]).toEqual( + rows.map((_row, index) => `restored-${index}`), + ) + reach.reach(`${restoredHistoryId(history)}/registered`) + for (const index of rows.keys()) + waits.push( + env.executor.waitForTransactionCompletion(`restored-${index}`).then( + () => { + outcomes[index] = `fulfilled` + waiterSnapshots[index] = readSnapshot() + }, + (error: unknown) => { + outcomes[index] = error + waiterSnapshots[index] = readSnapshot() + }, + ), ) - await turn() - if (index !== failedIndex) - expectedServer.set(rows[index]!.id, rows[index]!) - const expected = expectedRestoredLocalRows( - rows, - sharedKey, - failedIndex, - index, - expectedServer, + const check = async (completed: number) => + checkRestoredState( + { + ...readSnapshot(), + waiters: [...waiterSnapshots], + server: env.serverState, + outcomes: [...outcomes], + states: rows.map( + (_row, index) => restored.get(`restored-${index}`)!.state, + ), + calls: structuredClone(calls), + outbox: (await env.executor.peekOutbox()).map(({ id }) => id), + errors: [...logged.mock.calls], + warnings: [...warning.mock.calls], + unhandled: [...unhandled], + }, + expectedRestoredState(history, failures, completed), + ) + await atOracleCheckpoint( + entered[0]!.promise, + `restored provider entered`, ) - expectLocalRows(env.collection.toArray, expected.values()) - if (index === failedIndex) { - const wrong = localRows(env.collection.toArray) - if (wrong.length > 0) wrong.pop() - else wrong.push(rows[0]!) - expect(() => expectLocalRows(wrong, expected.values())).toThrowError( - expect.objectContaining({ name: `AssertionError` }), + await check(-1) + for (const index of rows.keys()) { + released[index]!.resolve() + await atOracleCheckpoint(waits[index]!, `restored ${index} settled`) + if (index < rows.length - 1) + await atOracleCheckpoint( + entered[index + 1]!.promise, + `restored sibling still held`, + ) + await turn() + reach.reach(`${restoredHistoryId(history)}/settled-${index}`) + await check(index) + } + } catch (error) { + hasPrimaryFailure = true + throw error + } finally { + released.forEach((item) => item.resolve()) + registrations.mockRestore() + logged.mockRestore() + warning.mockRestore() + try { + await cleanupOfflineOracle( + [ + () => Promise.all(waits), + () => env.executor.dispose(), + () => env.collection.cleanup(), + ], + hasPrimaryFailure, ) + } finally { + cleanupUnhandled() } - expect(env.serverState).toEqual(expectedServer) - expect(calls).toEqual( - rows - .slice(0, Math.min(index + 2, 3)) - .map((row, call) => ({ id: `restored-${call}`, rows: [row] })), - ) - expect(outcomes).toEqual( - rows.map((_row, position) => - position > index - ? `pending` - : position === failedIndex - ? failure - : `fulfilled`, - ), - ) - if (index >= failedIndex) expect(outcomes[failedIndex]).toBe(failure) - expect((await env.executor.peekOutbox()).map(({ id }) => id)).toEqual( - rows - .slice(index + 1) - .map((_row, offset) => `restored-${index + 1 + offset}`), - ) } - } catch (error) { - hasPrimaryFailure = true - throw error - } finally { - released.forEach((item) => item.resolve()) - await cleanupOfflineOracle( - [ - () => Promise.all(waits), - () => env.executor.dispose(), - () => env.collection.cleanup(), - ], - hasPrimaryFailure, - ) - } - }, -) + }, + ) +}) + +// These are checker inputs, not alternate production histories. All expected +// answers come from the same prefix fold as the real restoration receiver. +it.each([ + `extra-error`, + `extra-warning`, + `leaked-pending`, + `stale-waiter`, + `resurrect-failed`, + `early-peer`, + `missing-row`, +] as const)(`rejects a wrong restored observation: %s`, (fault) => { + const history: RestoredHistory = { + layout: `shared`, + width: 2, + succeeds: [false, true, true], + } + const failures = history.succeeds.map(() => new Error(`provider failure`)) + const expected = expectedRestoredState(history, failures, 1) + const actual = { + ...expected, + rows: [...expected.rows], + states: [...expected.states], + outcomes: [...expected.outcomes], + } + checkRestoredState(actual, expected) + if (fault === `extra-error`) actual.errors = [[new Error(`cleanup failed`)]] + if (fault === `extra-warning`) + actual.warnings = [ + [`executor failed`, failures[0], new Error(`cleanup failed`)], + ] + if (fault === `leaked-pending`) + actual.pending = actual.rows.map((row) => row.id) + if (fault === `stale-waiter`) + actual.waiters = actual.waiters.map((snapshot) => + snapshot + ? { ...snapshot, pending: snapshot.rows.map((row) => row.id) } + : snapshot, + ) + if (fault === `resurrect-failed`) actual.states[1] = `completed` + if (fault === `early-peer`) actual.outcomes[2] = `fulfilled` + if (fault === `missing-row`) actual.rows.pop() + expect(() => checkRestoredState(actual, expected)).toThrow() +}) const retryErrorKinds = [ `auth`, @@ -1971,10 +2237,8 @@ declareOracle({ checkRetryDecision, checkRetryRecordFailure, checkDurableAdmissionFailure, + checkRestoredState, ], - model: [ - expectedRetryDecision, - expectedRestoredLocalRows, - expectedStartedPeer, - ], + model: [expectedRetryDecision, expectedRestoredState, expectedStartedPeer], + grammar: [restoredRows, restoredHistories], }) diff --git a/packages/offline-transactions/tests/transaction-settlement.test.ts b/packages/offline-transactions/tests/transaction-settlement.test.ts index a6d07a4744..50b4cb0e22 100644 --- a/packages/offline-transactions/tests/transaction-settlement.test.ts +++ b/packages/offline-transactions/tests/transaction-settlement.test.ts @@ -985,7 +985,7 @@ it(`keeps commit pending until successful outbox deletion`, async () => { updatedAt: new Date(0), }), ) - observedPersistence = localTransaction.isPersisted.promise.then( + observedPersistence = localTransaction.when('settled').then( () => { persistedStatus = `fulfilled` }, @@ -1079,7 +1079,7 @@ it.each([`removeFromOutbox`, `clearOutbox`] as const)( updatedAt: new Date(0), }), ) - observedPersistence = localTransaction.isPersisted.promise.then( + observedPersistence = localTransaction.when('settled').then( () => { persistedStatus = `fulfilled` }, @@ -1204,7 +1204,7 @@ it.each([ updatedAt: new Date(0), }), ) - headPersistence = localHead.isPersisted.promise.then( + headPersistence = localHead.when('settled').then( () => { headPersistenceResult = `fulfilled` }, @@ -1346,7 +1346,7 @@ it(`stops successful provider work when outbox deletion fails`, async () => { updatedAt: new Date(0), }), ) - observedPersistence = localTransaction.isPersisted.promise.then( + observedPersistence = localTransaction.when('settled').then( () => { persistedStatus = `fulfilled` }, @@ -1519,7 +1519,7 @@ it(`stops after a failed deletion-pending write and holds its queued peer`, asyn updatedAt: new Date(0), }), ) - observedPersistence = localTransaction.isPersisted.promise.then( + observedPersistence = localTransaction.when('settled').then( () => { persistedStatus = `fulfilled` }, @@ -1680,7 +1680,7 @@ it(`restarts a fulfilled provider transaction after a stopped deletion failure`, updatedAt: new Date(0), }), ) - observedPersistence = localTransaction.isPersisted.promise.then( + observedPersistence = localTransaction.when('settled').then( () => { persistedStatus = `fulfilled` }, @@ -1802,7 +1802,7 @@ it(`replays an admitted row with no fulfilled-provider checkpoint`, async () => autoCommit: false, mutationFn: () => Promise.resolve(), }) - void seedTransaction.isPersisted.promise.catch(() => {}) + void seedTransaction.when('settled').catch(() => {}) try { seedTransaction.mutate(() => seed.insert({ diff --git a/packages/powersync-db-collection/tests/collection-schema.test.ts b/packages/powersync-db-collection/tests/collection-schema.test.ts index 6d29685a9f..42f634e000 100644 --- a/packages/powersync-db-collection/tests/collection-schema.test.ts +++ b/packages/powersync-db-collection/tests/collection-schema.test.ts @@ -85,10 +85,7 @@ describePowerSyncSchema(`PowerSync Schema Integration`, () => { const first = collection.insert({ id: randomUUID(), name: `aname` }) const second = collection.insert({ id: randomUUID(), name: null }) expect(collection.size).eq(2) - await Promise.all([ - first.isPersisted.promise, - second.isPersisted.promise, - ]) + await Promise.all([first.when(`settled`), second.when(`settled`)]) expectInsertValidation( () => collection.insert({} as any), @@ -206,7 +203,7 @@ describePowerSyncSchema(`PowerSync Schema Integration`, () => { ? item.created_at.getTime() : undefined, ).eq(1710381566535) - await result.isPersisted.promise + await result.when(`settled`) expect( await db.getAll( `SELECT id, name, author, created_at, archived FROM documents WHERE id = ?`, @@ -306,7 +303,7 @@ describePowerSyncSchema(`PowerSync Schema Integration`, () => { expect(item?.created_at instanceof Date).true expect(item?.created_at?.getTime()).eq(1710381566535) expect(item?.archived).toBe(false) - await result.isPersisted.promise + await result.when(`settled`) expect( await db.getAll( `SELECT id, name, author, created_at, archived FROM documents WHERE id = ?`, @@ -391,7 +388,7 @@ describePowerSyncSchema(`PowerSync Schema Integration`, () => { archived: 0, }) const item = collection.get(id) - await result.isPersisted.promise + await result.when(`settled`) expect(item?.name instanceof MyDataClass).true expect(item?.name?.options.value).eq(`document`) expect( diff --git a/packages/powersync-db-collection/tests/correctness.test.ts b/packages/powersync-db-collection/tests/correctness.test.ts index a378af3dff..6c11a9d5b9 100644 --- a/packages/powersync-db-collection/tests/correctness.test.ts +++ b/packages/powersync-db-collection/tests/correctness.test.ts @@ -246,7 +246,7 @@ describe(`PowerSync correctness oracle`, () => { }) await heldChanges.flush() - await transaction.isPersisted.promise + await transaction.when(`settled`) const crud = await db.getAll( `SELECT data FROM ps_crud ORDER BY id`, @@ -547,9 +547,11 @@ describe(`PowerSync correctness oracle`, () => { watcherDeliveries.length = 0 const metadata = { source: `Collection.update` } - await collection.update(`t1`, { metadata }, (draft) => { - draft.done = 1 - }).isPersisted.promise + await collection + .update(`t1`, { metadata }, (draft) => { + draft.done = 1 + }) + .when(`settled`) expect( watcherDeliveries.some((tables) => @@ -630,12 +632,14 @@ describe(`PowerSync correctness oracle`, () => { let failure: { error: unknown } | undefined try { await collection.preload() - await collection.update(`f1`, (draft) => { - draft.label = `` - draft.note = null - draft.count = 0 - draft.active = false - }).isPersisted.promise + await collection + .update(`f1`, (draft) => { + draft.label = `` + draft.note = null + draft.count = 0 + draft.active = false + }) + .when(`settled`) const crud = await db.getAll( `SELECT data FROM ps_crud ORDER BY id`, @@ -679,9 +683,11 @@ describe(`PowerSync correctness oracle`, () => { let failure: { error: unknown } | undefined try { await collection.preload() - await collection.update(`t1`, { metadata: ignoredMetadata }, (draft) => { - draft.title = `after` - }).isPersisted.promise + await collection + .update(`t1`, { metadata: ignoredMetadata }, (draft) => { + draft.title = `after` + }) + .when(`settled`) await collection.cleanup() expect( @@ -766,7 +772,8 @@ describe(`PowerSync correctness oracle`, () => { .update(`t1`, (draft) => { draft.title = `Write report today` }) - .isPersisted.promise.catch((error: unknown) => { + .when(`settled`) + .catch((error: unknown) => { persistenceError = error }) .finally(() => { diff --git a/packages/powersync-db-collection/tests/on-demand-sync.test.ts b/packages/powersync-db-collection/tests/on-demand-sync.test.ts index 0e4bc81b3d..631b195fe8 100644 --- a/packages/powersync-db-collection/tests/on-demand-sync.test.ts +++ b/packages/powersync-db-collection/tests/on-demand-sync.test.ts @@ -651,7 +651,7 @@ describe(`On-Demand Sync Mode`, () => { } resolvePersistence() - await transaction.isPersisted.promise + await transaction.when(`settled`) if (outcome === `apply`) { expect(await preload).toEqual({ status: `fulfilled` }) expect( @@ -681,7 +681,7 @@ describe(`On-Demand Sync Mode`, () => { } }, [ resolvePersistence, - () => transaction.isPersisted.promise, + () => transaction.when(`settled`), () => subscription.unsubscribe(), () => electronics.cleanup(), () => collection.cleanup(), @@ -1572,7 +1572,7 @@ describe(`On-Demand Sync Mode`, () => { ) const tx = collection.delete(productA!.id) - await tx.isPersisted.promise + await tx.when(`settled`) await vi.waitFor( () => { @@ -1640,7 +1640,7 @@ describe(`On-Demand Sync Mode`, () => { price: 99, category: `electronics`, }) - await tx.isPersisted.promise + await tx.when(`settled`) await vi.waitFor( () => { @@ -1708,7 +1708,7 @@ describe(`On-Demand Sync Mode`, () => { const tx = collection.update(productA!.id, (d) => { d.price = 999 }) - await tx.isPersisted.promise + await tx.when(`settled`) await vi.waitFor( () => { @@ -1775,7 +1775,7 @@ describe(`On-Demand Sync Mode`, () => { // Delete Product A const tx = collection.delete(productA.id) - await tx.isPersisted.promise + await tx.when(`settled`) await vi.waitFor( () => { @@ -1843,7 +1843,7 @@ describe(`On-Demand Sync Mode`, () => { price: 99, category: `electronics`, }) - await tx.isPersisted.promise + await tx.when(`settled`) await vi.waitFor( () => { @@ -1908,7 +1908,7 @@ describe(`On-Demand Sync Mode`, () => { const tx = collection.update(productA.id, (d) => { d.price = 999 }) - await tx.isPersisted.promise + await tx.when(`settled`) await vi.waitFor( () => { @@ -2457,7 +2457,7 @@ describe(`On-Demand Sync Mode`, () => { }) describe(`Pending mutations during filter changes`, () => { - it(`should resolve isPersisted when loadSubset is called during a pending mutation`, async () => { + it(`should resolve when('settled') when loadSubset is called during a pending mutation`, async () => { const db = await createDatabase() await createTestProducts(db) @@ -2521,16 +2521,16 @@ describe(`On-Demand Sync Mode`, () => { await clothingQuery.preload() - // isPersisted.promise should resolve — if the bug is present, this hangs forever + // when('settled') should resolve — if the bug is present, this hangs forever await vi.waitFor( async () => { - await insertResult.isPersisted.promise + await insertResult.when(`settled`) }, { timeout: 5000 }, ) }) - it(`should resolve isPersisted when unloadSubset is called during a pending mutation`, async () => { + it(`should resolve when('settled') when unloadSubset is called during a pending mutation`, async () => { const db = await createDatabase() await createTestProducts(db) @@ -2603,10 +2603,10 @@ describe(`On-Demand Sync Mode`, () => { // which rebuilds the diff trigger, potentially dropping unprocessed diff records clothingQuery.cleanup() - // isPersisted.promise should resolve — if the bug is present, this hangs forever + // when('settled') should resolve — if the bug is present, this hangs forever await vi.waitFor( async () => { - await insertResult.isPersisted.promise + await insertResult.when(`settled`) }, { timeout: 5000 }, ) @@ -2667,7 +2667,7 @@ describe(`On-Demand Sync Mode`, () => { }) : collection.delete(id) let settled = false - const observed = mutation.isPersisted.promise.then( + const observed = mutation.when(`settled`).then( () => { settled = true return { status: `fulfilled` as const } diff --git a/packages/powersync-db-collection/tests/powersync.test.ts b/packages/powersync-db-collection/tests/powersync.test.ts index 90698431f4..969f78ec22 100644 --- a/packages/powersync-db-collection/tests/powersync.test.ts +++ b/packages/powersync-db-collection/tests/powersync.test.ts @@ -211,13 +211,14 @@ describePowerSync(`PowerSync Integration`, () => { expect(newDoc!.name).toBe(`new`) expect(newDoc!.author).toBe(`somebody`) - await tx.isPersisted.promise + await tx.when(`settled`) // The item should now be present in PowerSync // We should also have patched it back in to Tanstack DB (removing the optimistic state) // Now do an update - await collection.update(id, (d) => (d.name = `updatedNew`)).isPersisted - .promise + await collection + .update(id, (d) => (d.name = `updatedNew`)) + .when(`settled`) const updatedDoc = collection.get(id) expect(updatedDoc).toBeDefined() @@ -225,7 +226,7 @@ describePowerSync(`PowerSync Integration`, () => { // Only the updated field should be updated expect(updatedDoc!.author).toBe(`somebody`) - await collection.delete(id).isPersisted.promise + await collection.delete(id).when(`settled`) // There should be a crud entries for this const crudEntries = await db.getAll<{ op: string }>(` @@ -282,7 +283,7 @@ describePowerSync(`PowerSync Integration`, () => { }) await addTx.commit() - await addTx.isPersisted.promise + await addTx.when(`settled`) expect(collection.size).toBe(8) @@ -362,7 +363,7 @@ describePowerSync(`PowerSync Integration`, () => { await collection.stateWhenReady() // Seed the row which will be deleted in the transaction - await collection.insert({ id: `a`, name: `row a` }).isPersisted.promise + await collection.insert({ id: `a`, name: `row a` }).when(`settled`) const { trackedTableName } = options.utils.getMeta() @@ -473,7 +474,7 @@ describePowerSync(`PowerSync Integration`, () => { }) await addTx.commit() - await addTx.isPersisted.promise + await addTx.when(`settled`) expect(documentsCollection.size).toBe(8) expect(usersCollection.size).toBe(5) @@ -582,25 +583,25 @@ describePowerSync(`PowerSync Integration`, () => { boolean: true, } const id = randomUUID() - await collection.insert( - { - id, - name: `new`, - author: `somebody`, - }, - { - metadata, - }, - ).isPersisted.promise + await collection + .insert( + { + id, + name: `new`, + author: `somebody`, + }, + { + metadata, + }, + ) + .when(`settled`) // Now do an update - await collection.update( - id, - { metadata: metadata }, - (d) => (d.name = `updatedNew`), - ).isPersisted.promise + await collection + .update(id, { metadata: metadata }, (d) => (d.name = `updatedNew`)) + .when(`settled`) - await collection.delete(id, { metadata }).isPersisted.promise + await collection.delete(id, { metadata }).when(`settled`) // There should be a crud entries for this const crudBatch = await db.getCrudBatch(100) @@ -666,7 +667,7 @@ describePowerSync(`PowerSync Integration`, () => { try { await addTx.commit() - await addTx.isPersisted.promise + await addTx.when(`settled`) expect.fail(`Should have thrown an error`) } catch (error) { expect(error).toBeDefined() @@ -707,14 +708,18 @@ describePowerSync(`PowerSync Integration`, () => { .forEach((change) => bookNames.add(change)) }) - await collection.insert({ - id: randomUUID(), - name: `notabook`, - }).isPersisted.promise - await collection.insert({ - id: randomUUID(), - name: `book`, - }).isPersisted.promise + await collection + .insert({ + id: randomUUID(), + name: `notabook`, + }) + .when(`settled`) + await collection + .insert({ + id: randomUUID(), + name: `book`, + }) + .when(`settled`) expect(collection.size).eq(2) await vi.waitFor( diff --git a/packages/preact-db/tests/bound-options-oracle.test.ts b/packages/preact-db/tests/bound-options-oracle.test.ts new file mode 100644 index 0000000000..5e9023ab8b --- /dev/null +++ b/packages/preact-db/tests/bound-options-oracle.test.ts @@ -0,0 +1,157 @@ +/** + * Preact receives the shared inline-option binding law through its real hook. + * The finite grammar varies each configured option and query/key identity. + * Three ordinary rerenders, a source publication and identity replacement are + * observed at Preact commit after its native act flush. A render budget catches + * repeated reconstruction. Preact has no DbClient input; cache admission is + * therefore not its public receiving boundary. + */ +import { createCollection, gt } from '@tanstack/db' +import { act } from 'preact/test-utils' +import { afterAll, expect, test } from 'vitest' +import { useLiveQuery } from '../src/useLiveQuery' +import { + mockSyncCollectionOptions, + withOracleCleanup, +} from '../../db/tests/utils' +import { + boundOptionHistories, + captureBoundOptions, + checkBoundOptions, +} from '../../db/tests/live-query-bound-options-oracle' +import { declareOracle } from '../../db/tests/oracle-declaration' +import { createOracleReach } from '../../db/tests/oracle-reach' +import { renderHook, settle } from './render-probe' +import type { InitialQueryBuilder } from '@tanstack/db' + +type Row = { id: string; label: string; rank: number } +const reach = createOracleReach(`Preact bound options`, [ + `rerender`, + `source-update`, + `identity-change`, + `getKey`, + `schema`, + `both`, + `query`, + `queryKey`, +]) +afterAll(() => { + reach.assertReached() + if (process.env.TANSTACK_DB_ORACLE_STATISTICS === `1`) + console.info( + `Preact bound option reach`, + Object.fromEntries(reach.counts()), + ) +}) +declareOracle({ check: [checkBoundOptions], grammar: [boundOptionHistories] }) +test.each(boundOptionHistories)( + `Preact retains bound options %j`, + async ({ option, identity }) => { + reach.beginHistory() + const source = createCollection( + mockSyncCollectionOptions({ + id: `preact-bound-options`, + getKey: (row) => row.id, + initialData: [{ id: `one`, label: `before`, rank: 1 }], + }), + ) + const collections = new Set<{ cleanup: () => Promise }>() + let renders = 0 + let unmount: (() => void) | undefined + await withOracleCleanup(async () => { + const hook = renderHook( + ({ revision }: { revision: number }) => { + if (++renders > 30) + throw new Error(`inline options rebuilt on every render`) + const config = { + query: (q: InitialQueryBuilder) => + q + .from({ row: source }) + .where(({ row }) => + gt(row.rank, identity === `query` ? -revision : 0), + ), + ...(identity === `queryKey` ? { queryKey: [revision] } : {}), + ...(option !== `schema` + ? { getKey: (row: Row) => `bound-${revision}-${row.id}` } + : {}), + } + if (option !== `getKey`) + Object.assign(config, { + schema: { + '~standard': { + version: 1, + vendor: `bound-options`, + validate: (value: unknown) => ({ value }), + }, + }, + }) + const result = useLiveQuery(config) + collections.add(result.collection) + return result + }, + { initialProps: { revision: 0 } }, + ) + unmount = hook.unmount + await settle() + const initial = captureBoundOptions(hook.result.current.collection) + for (let turn = 0; turn < 3; turn++) { + hook.rerender({ revision: 0 }) + await settle() + checkBoundOptions( + initial, + hook.result.current.collection, + false, + option, + ) + reach.reach(`rerender`) + } + act(() => { + source.utils.begin() + source.utils.write({ + type: `update`, + value: { id: `one`, label: `after`, rank: 1 }, + }) + source.utils.commit() + }) + await settle() + checkBoundOptions(initial, hook.result.current.collection, false, option) + expect( + hook.result.current.data.map((row: Row) => ({ + id: row.id, + label: row.label, + })), + ).toEqual([{ id: `one`, label: `after` }]) + reach.reach(`source-update`) + hook.rerender({ revision: 1 }) + await settle() + checkBoundOptions(initial, hook.result.current.collection, true, option) + expect([...hook.result.current.state.keys()]).toEqual([ + option === `schema` ? `one` : `bound-1-one`, + ]) + reach.reach(`identity-change`) + reach.reach(option) + reach.reach(identity) + }, [ + () => unmount?.(), + () => + withOracleCleanup(() => {}, [ + ...[...collections].map((collection) => () => collection.cleanup()), + () => source.cleanup({ dependents: true }), + ]), + ]) + }, +) + +// Same-Collection config mutation must not mutate the expected references too. +test.each(boundOptionHistories)( + `bound-option checker rejects in-place replacement %j`, + ({ option }) => { + const collection = { config: { getKey: () => `initial`, schema: {} } } + const initial = captureBoundOptions(collection) + if (option !== `schema`) collection.config.getKey = () => `replacement` + if (option !== `getKey`) collection.config.schema = {} + expect(() => + checkBoundOptions(initial, collection, false, option), + ).toThrow() + }, +) diff --git a/packages/preact-db/tests/bound-schema-options-oracle.test.ts b/packages/preact-db/tests/bound-schema-options-oracle.test.ts new file mode 100644 index 0000000000..19e8e435e2 --- /dev/null +++ b/packages/preact-db/tests/bound-schema-options-oracle.test.ts @@ -0,0 +1,62 @@ +/** + * Preact receives the shared schema-mutation law through its real live hook. + * Its commit-time render probe and native act supply the host observation cut. + * The common driver preserves the synchronous Collection return observation. + * This is a controlled Collection witness, without provider/SSR claims. + */ +import { act } from 'preact/test-utils' +import { afterAll, test } from 'vitest' +import { useLiveQuery } from '../src/useLiveQuery' +import { declareOracle } from '../../db/tests/oracle-declaration' +import { createOracleReach } from '../../db/tests/oracle-reach' +import { + boundSchemaHistories, + boundSchemaReachStates, + checkBoundSchemaMutation, +} from '../../db/tests/live-query-bound-options-oracle' +import { runBoundSchemaHistory } from '../../db/tests/live-query-bound-options-support' +import { renderHook, settle } from './render-probe' +import type { BoundSchemaNativeHost } from '../../db/tests/live-query-bound-options-support' + +const reach = createOracleReach(`Preact schema binding`, boundSchemaReachStates) +let ran = false +afterAll(() => { + if (!ran) return + reach.assertReached() + if (process.env.TANSTACK_DB_ORACLE_STATISTICS === `1`) + console.info( + `Preact schema binding reach`, + Object.fromEntries(reach.counts()), + ) +}) +test.each(boundSchemaHistories)( + `Preact binds mutation schemas %j`, + async (history) => { + ran = true + const host: BoundSchemaNativeHost = { + mount: (input) => { + const mounted = renderHook( + (props: { revision: number; proposed: boolean }) => + useLiveQuery(input(props)), + { initialProps: { revision: 0, proposed: false } }, + ) + return { + current: () => mounted.result.current, + rerender: mounted.rerender, + flush: async (action) => { + act(() => { + action?.() + }) + await settle() + }, + unmount: mounted.unmount, + } + }, + } + await runBoundSchemaHistory(history, host, reach) + }, +) +declareOracle({ + grammar: [boundSchemaHistories], + check: [checkBoundSchemaMutation], +}) diff --git a/packages/query-db-collection/package.json b/packages/query-db-collection/package.json index bb8363dfea..379ce387da 100644 --- a/packages/query-db-collection/package.json +++ b/packages/query-db-collection/package.json @@ -47,10 +47,10 @@ "src" ], "dependencies": { - "@standard-schema/spec": "^1.1.0", - "@tanstack/db": "workspace:*" + "@standard-schema/spec": "^1.1.0" }, "peerDependencies": { + "@tanstack/db": "^1.0.0-rc.0", "@tanstack/query-core": "^5.90.15", "typescript": ">=4.7" }, diff --git a/packages/query-db-collection/src/query.ts b/packages/query-db-collection/src/query.ts index 19f4aab9c6..44122607a1 100644 --- a/packages/query-db-collection/src/query.ts +++ b/packages/query-db-collection/src/query.ts @@ -12,7 +12,6 @@ import { makeComparator, toBooleanPredicate, validateSyncPersistenceCapability, - warnOnce, whenSyncAccepted, withCollectionConfigFactory, withCollectionSyncConfigFactory, @@ -32,9 +31,12 @@ import type { BaseCollectionConfig, ChangeMessage, CollectionConfig, + DeleteMutationFnParams, + InsertMutationFnParams, LoadSubsetOptions, SyncAppliedReceipt, SyncConfig, + UpdateMutationFnParams, } from '@tanstack/db' import type { FetchStatus, @@ -105,6 +107,18 @@ function pickDefinedQueryObserverOptions( return options } +type QueryCollectionHandlerUtils< + T extends object, + TKey extends string | number, + TSchema extends StandardSchemaV1, + TError, +> = QueryCollectionUtils< + T, + TKey, + [TSchema] extends [never] ? T : InferSchemaInput, + TError +> + /** * Configuration options for creating a Query Collection * @template T - The explicit type of items stored in the collection @@ -124,17 +138,37 @@ export interface QueryCollectionConfig< TKey extends string | number = string | number, TSchema extends StandardSchemaV1 = never, TQueryData = Awaited>, -> extends BaseCollectionConfig< - T, - TKey, - TSchema, - QueryCollectionUtils< +> extends Omit< + BaseCollectionConfig< T, TKey, - [TSchema] extends [never] ? T : InferSchemaInput, - TError - > + TSchema, + QueryCollectionHandlerUtils + >, + `onInsert` | `onUpdate` | `onDelete` > { + /** Mutation handlers must await an explicit refetch or write confirmed data before settling. */ + onInsert?: ( + params: InsertMutationFnParams< + T, + TKey, + QueryCollectionHandlerUtils + >, + ) => Promise + onUpdate?: ( + params: UpdateMutationFnParams< + T, + TKey, + QueryCollectionHandlerUtils + >, + ) => Promise + onDelete?: ( + params: DeleteMutationFnParams< + T, + TKey, + QueryCollectionHandlerUtils + >, + ) => Promise /** The query key used by TanStack Query to identify this query */ queryKey: TQueryKey | TQueryKeyBuilder /** Function that fetches data from the server. Must return the complete collection state */ @@ -3927,9 +3961,7 @@ export function queryCollectionOptions( * This matches TanStack Query's hook behavior where refetch() bypasses enabled: false. * See: https://tanstack.com/query/latest/docs/framework/react/guides/disabling-queries * - * Used by both: - * - utils.refetch() - for explicit user-triggered refetches - * - Internal handlers (onInsert/onUpdate/onDelete) - after mutations to get fresh data + * Used by utils.refetch(), including explicit calls inside mutation handlers. * * Calls wait for each accepted Query result to be applied to Collection rows * unless a user mutation is persisting or its handler is active. Normal @@ -4466,73 +4498,19 @@ export function queryCollectionOptions( () => writeContext, ) - // Helper to handle deprecated auto-refetch behavior with warnings - async function handleDeprecatedAutoRefetch( - handlerResult: unknown, - ): Promise { - const canHaveProperties = - (typeof handlerResult === `object` && handlerResult !== null) || - typeof handlerResult === `function` - const explicitRefetchFalse = - canHaveProperties && - 'refetch' in handlerResult && - (handlerResult as Record).refetch === false - - if (explicitRefetchFalse) { - return - } else { - warnOnce( - 'query-collection-auto-refetch', - '[TanStack DB] DEPRECATED: QueryCollection handlers currently auto-refetch after completion. ' + - 'This behavior will be removed in v1.0. To prepare: ' + - '(1) Add `await collection.utils.refetch()` and temporarily return `{ refetch: false }` to avoid a second refetch, or ' + - "(2) Return `{ refetch: false }` to opt out now if you don't need it. " + - 'See: https://tanstack.com/db/latest/docs/collections/query-collection#controlling-refetch-behavior', - ) - await refetchQueryResults(undefined, false) - } - } - - const runMutationHandler = async ( - handler: (params: TParams) => unknown, - params: TParams, - ): Promise => { - activeMutationHandlerCount++ - try { - return await handler(params) - } finally { - activeMutationHandlerCount-- - } - } - - // Create wrapper handlers for direct persistence operations that handle refetching - // These wrappers process deprecated return values but don't pass them through - const wrappedOnInsert = onInsert - ? async ( - params: Parameters>[0], - ): Promise => { - const handlerResult = (await runMutationHandler(onInsert, params)) ?? {} - await handleDeprecatedAutoRefetch(handlerResult) - } - : undefined - - const wrappedOnUpdate = onUpdate - ? async ( - params: Parameters>[0], - ): Promise => { - const handlerResult = (await runMutationHandler(onUpdate, params)) ?? {} - await handleDeprecatedAutoRefetch(handlerResult) - } - : undefined - - const wrappedOnDelete = onDelete - ? async ( - params: Parameters>[0], - ): Promise => { - const handlerResult = (await runMutationHandler(onDelete, params)) ?? {} - await handleDeprecatedAutoRefetch(handlerResult) + // Explicit refetches use the fetch boundary while any handler is active. + const runMutationHandler = ( + handler: ((params: TParams) => Promise) | undefined, + ): ((params: TParams) => Promise) | undefined => + handler && + (async (params) => { + activeMutationHandlerCount++ + try { + await handler(params) + } finally { + activeMutationHandlerCount-- } - : undefined + }) // Create utils instance with state and dependencies passed explicitly const utils: QueryCollectionUtils = new QueryCollectionUtilsImpl( @@ -4564,9 +4542,9 @@ export function queryCollectionOptions( getKey, syncMode, sync, - onInsert: wrappedOnInsert, - onUpdate: wrappedOnUpdate, - onDelete: wrappedOnDelete, + onInsert: runMutationHandler(onInsert), + onUpdate: runMutationHandler(onUpdate), + onDelete: runMutationHandler(onDelete), utils, } diff --git a/packages/query-db-collection/tests/documented-mutations-oracle.test.ts b/packages/query-db-collection/tests/documented-mutations-oracle.test.ts new file mode 100644 index 0000000000..58d0be210b --- /dev/null +++ b/packages/query-db-collection/tests/documented-mutations-oracle.test.ts @@ -0,0 +1,114 @@ +/** + * Exact Query guidance handlers receive a real QueryClient and Query adapter. + * The shared documentation grammar and mutation judgment own all expectations. + * queryFn response delivery is held before it returns server rows; no automatic + * fetch is supplied by the fixture. This is a controlled backend receiver. + */ +import { resolve } from 'node:path' +import { afterAll, test, vi } from 'vitest' +import { QueryClient } from '@tanstack/query-core' +import { createCollection } from '@tanstack/db' +import { queryCollectionOptions } from '../src/query' +import { createDeferred } from '../../db/src/deferred' +import { declareOracle } from '../../db/tests/oracle-declaration' +import { oracleReplayTest } from '../../db/tests/oracle-config' +import { + checkDocumentedMutation, + documentedMutationHistories, +} from '../../db/tests/documented-mutations-oracle' +import { + createDocumentedReach, + runDocumentedMutation, + selectDocumentedHistories, +} from '../../db/tests/documented-mutations-support' +import type { DocumentedRow } from '../../db/tests/documented-mutations-oracle' +import type { QueryCollectionConfig } from '../src/query' + +const histories = selectDocumentedHistories( + documentedMutationHistories(`query`), +) +const reach = createDocumentedReach(`query`, histories) +afterAll(() => reach.finish()) + +oracleReplayTest(test).each(histories)( + `runs the actual documented handler: $id`, + async (history) => { + reach.begin(history.id) + await runDocumentedMutation( + history, + resolve(process.cwd(), `../..`), + (controls) => { + const client = new QueryClient({ + defaultOptions: { + queries: { retry: false, staleTime: Infinity, gcTime: Infinity }, + }, + }) + const sourceRequested = createDeferred(), + sourceGate = createDeferred() + let started = false + const queryFn = vi.fn(async () => { + if (started) { + sourceRequested.resolve() + await sourceGate.promise + } + return controls.server() + }) + type Config = QueryCollectionConfig + const collection = createCollection( + queryCollectionOptions({ + queryKey: [`documented`, history.id], + queryClient: client, + queryFn, + getKey: (row) => row.id, + onInsert: controls.handler as NonNullable, + onUpdate: controls.handler as NonNullable, + onDelete: controls.handler as NonNullable, + }), + ) + const refetch = collection.utils.refetch + collection.utils.refetch = (...args) => + controls.confirmation(() => refetch(...args)) + const writeBatch = collection.utils.writeBatch, + writeInsert = collection.utils.writeInsert, + writeUpdate = collection.utils.writeUpdate, + writeUpsert = collection.utils.writeUpsert + collection.utils.writeBatch = (...args) => + controls.confirmation(() => writeBatch(...args)) + collection.utils.writeInsert = (...args) => + controls.confirmation(() => writeInsert(...args)) + collection.utils.writeUpdate = (...args) => + controls.confirmation(() => writeUpdate(...args)) + collection.utils.writeUpsert = (...args) => + controls.confirmation(() => writeUpsert(...args)) + return { + collection, + start: async () => { + await collection.preload() + started = true + }, + deliver: () => { + sourceGate.resolve() + return Promise.resolve() + }, + sourceRequested: sourceRequested.promise, + late: async () => { + sourceGate.resolve() + await collection.utils.refetch() + }, + fetches: () => queryFn.mock.calls.length, + providerRequests: () => 0, + cleanup: async () => { + await collection.cleanup() + client.clear() + }, + } + }, + reach.reach, + ) + }, +) + +declareOracle({ + check: [checkDocumentedMutation], + grammar: [documentedMutationHistories], +}) diff --git a/packages/query-db-collection/tests/optimistic-ordered-refetch-oracle.test.ts b/packages/query-db-collection/tests/optimistic-ordered-refetch-oracle.test.ts index 3ec8d5e9a8..86e6fa68bf 100644 --- a/packages/query-db-collection/tests/optimistic-ordered-refetch-oracle.test.ts +++ b/packages/query-db-collection/tests/optimistic-ordered-refetch-oracle.test.ts @@ -5,12 +5,16 @@ import { createLiveQueryCollection, eq, } from '@tanstack/db' -import { expect, it, vi } from 'vitest' +import { afterAll, describe, expect, it, vi } from 'vitest' import { createDeferred } from '../../db/src/deferred.js' import { queryCollectionOptions } from '../src/query.js' import { applyPredicates } from '../e2e/query-filter.js' import { declareOracle } from '../../db/tests/oracle-declaration.js' +import { createOracleReach } from '../../db/tests/oracle-reach' +import { withOracleCleanup } from '../../db/tests/utils' +import { atMutationCheckpoint } from '../../db/tests/mutation-confirmation-support' import { isNarrowRankTieProbe } from './optimistic-ordered-refetch-probe.js' +import type { QueryCollectionConfig } from '../src/query' import type { LoadSubsetOptions } from '@tanstack/db' import type { Row } from './optimistic-ordered-refetch-probe.js' @@ -25,12 +29,18 @@ type ProviderPhase = * when only the old two-row prefix is installed. A missing replacement may * arrive through a bounded provider request, but its Collection write cannot * become public before it applies behind the pending mutation. After the - * mutation's authoritative write and refill apply, the window is `[2, 1]`. + * authoritative write and refill, the window matches the backend. + * A prewrite rejection restores the earlier row; a post-confirmation rejection + * preserves the server edit. Insertions keep their optimistic head while held. * The mutation handler calls `utils.refetch()` after changing the provider, * matching the receiving path reported in #2169. A second history adds a new * provider head and invalidates the Query while persistence is held. Query DB * reports shifted loaded subsets, but the resulting repair must not retract - * the immediate `[2]` publication. It settles as `[5, 2]` after persistence. + * the immediate optimistic publication. Its final window follows the backend + * edit and the handler's source confirmation. + * CRUD, fulfilled void/legacy returns and rejection before/after confirmation + * use the same window model. Source publication may need ordered repair after + * handler settlement; the final cut waits for that fixed point. * * The reference is the plain remote row ledger and filter/sort/slice below. * The real QueryClient and queryCollectionOptions adapter receive each request @@ -39,9 +49,55 @@ type ProviderPhase = * immediate, held-refetch, and settled checkpoints. The backend evaluator is * only driver wiring: it never computes the expected window. */ +type OrderedMutationHistory = { + operation: `insert` | `update` | `delete` + ending: + | `fulfilled-void` + | `fulfilled-legacy` + | `reject-before-write` + | `reject-after-write` + reportDuringHold: boolean +} +const orderedMutationHistories: Array = ( + [`insert`, `update`, `delete`] as const +).flatMap((operation) => + ( + [ + `fulfilled-void`, + `fulfilled-legacy`, + `reject-before-write`, + `reject-after-write`, + ] as const + ).flatMap((ending) => + [false, true].map((reportDuringHold) => ({ + operation, + ending, + reportDuringHold, + })), + ), +) +const orderedReach = createOracleReach( + `query.ordered-mutation-outcome`, + orderedMutationHistories.map( + (h) => `${h.operation}/${h.ending}/${h.reportDuringHold}`, + ), +) + async function checkOptimisticOrderedRefetch( - reportDuringHold: boolean, + history: OrderedMutationHistory, ): Promise { + const { operation, ending, reportDuringHold } = history + orderedReach.beginHistory() + const reach = createOracleReach(`ordered-mutation-cuts`, [ + `optimistic`, + `settled-fixed-point`, + ...(operation === `insert` ? [] : [`bounded-refill`]), + ...(reportDuringHold ? [`held-report`] : []), + ...(ending === `reject-before-write` ? [] : [`handler-confirmation`]), + ]) + reach.beginHistory() + const failure = new Error(`ordered handler failure`) + let handlerConfirmed = false const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity, gcTime: Infinity }, @@ -52,6 +108,20 @@ async function checkOptimisticOrderedRefetch( folder: id === 4 ? `archive` : `inbox`, at: id, })) + // Recompute the visible installed prefix under local intent. A departure + // may be short while its replacement's accepted sync rows remain held. + const installed = remote + .filter((row) => expectedPublicWindow(remote).includes(row.id)) + .map((row) => ({ ...row })) + const optimisticRows = + operation === `insert` + ? [...installed, { id: 5, folder: `inbox` as const, at: 7 }] + : operation === `delete` + ? installed.filter(({ id }) => id !== 3) + : installed.map((row) => + row.id === 3 ? { ...row, folder: `archive` as const } : row, + ) + const immediateWindow = expectedPublicWindow(optimisticRows) const persistence = createDeferred() let providerPhase: ProviderPhase = `initial` const requests: Array<{ @@ -59,55 +129,81 @@ async function checkOptimisticOrderedRefetch( ids: Array phase: ProviderPhase }> = [] - const source = createCollection( - queryCollectionOptions({ - id: `optimistic-query-refetch`, - queryKey: [`optimistic-query-refetch`], - queryClient, - getKey: (row) => row.id, - syncMode: `on-demand`, - startSync: true, - queryFn: ({ meta }) => { - const options = meta?.loadSubsetOptions ?? {} - const rows = applyPredicates(remote, options).map((row) => ({ - ...row, - })) - requests.push({ - options, - ids: rows.map(({ id }) => id), - phase: providerPhase, - }) - return rows - }, - onUpdate: async ({ collection }) => { - await persistence.promise - const moved = remote.find(({ id }) => id === 3)! - moved.folder = `archive` - providerPhase = `handler-refetch` - await collection.utils.refetch() - return { refetch: false } - }, - }), - ) - source.createIndex((row) => row.at, { indexType: BTreeIndex }) - let reports = 0 - let requestCountAtReport = 0 - source.on(`loadedSubsets:invalidated`, () => { - reports++ - requestCountAtReport = requests.length - }) - const live = createLiveQueryCollection({ - query: (q) => - q - .from({ row: source }) - .where(({ row }) => eq(row.folder, `inbox`)) - .orderBy(({ row }) => row.at, `desc`) - .limit(2), - }) - const publicIds = () => live.toArray.map(({ id }) => id) - const publications: Array> = [] + const handler = async ({ + collection, + }: Pick< + Parameters[`onUpdate`]>>[0], + `collection` + >) => { + await persistence.promise + if (ending === `reject-before-write`) throw failure + if (operation === `insert`) remote.push({ id: 5, folder: `inbox`, at: 5 }) + else if (operation === `delete`) + remote.splice( + remote.findIndex(({ id }) => id === 3), + 1, + ) + else remote.find(({ id }) => id === 3)!.folder = `archive` + providerPhase = `handler-refetch` + await collection.utils.refetch() + handlerConfirmed = true + if (ending === `reject-after-write`) throw failure + return ending === `fulfilled-legacy` ? { refetch: false } : undefined + } + const runtimeHandler = handler as unknown as ( + params: Parameters[0], + ) => Promise let unsubscribe: (() => void) | undefined - try { + let verdict: Promise | undefined + let cleanupSource: (() => Promise) | undefined + let cleanupLive: (() => Promise) | undefined + await withOracleCleanup(async () => { + const source = createCollection( + queryCollectionOptions({ + id: `optimistic-query-refetch`, + queryKey: [`optimistic-query-refetch`], + queryClient, + getKey: (row) => row.id, + syncMode: `on-demand`, + startSync: true, + queryFn: ({ meta }) => { + const options = meta?.loadSubsetOptions ?? {} + const rows = applyPredicates(remote, options).map((row) => ({ + ...row, + })) + requests.push({ + options, + ids: rows.map(({ id }) => id), + phase: providerPhase, + }) + return rows + }, + // JavaScript legacy results remain runtime inputs; TypeScript admission + // is judged separately by the generated handler type owner. + onInsert: runtimeHandler, + onUpdate: runtimeHandler, + onDelete: runtimeHandler, + }), + ) + cleanupSource = () => source.cleanup() + source.createIndex((row) => row.at, { indexType: BTreeIndex }) + let reports = 0 + let requestCountAtReport = 0 + source.on(`loadedSubsets:invalidated`, () => { + reports++ + requestCountAtReport = requests.length + }) + const live = createLiveQueryCollection({ + query: (q) => + q + .from({ row: source }) + .where(({ row }) => eq(row.folder, `inbox`)) + .orderBy(({ row }) => row.at, `desc`) + .limit(2), + }) + cleanupLive = () => live.cleanup() + const publicIds = () => live.toArray.map(({ id }) => id) + const publications: Array> = [] await live.preload() expect(publicIds()).toEqual(expectedPublicWindow(remote)) expect(source.toArray.map(({ id }) => id).sort()).toEqual([2, 3]) @@ -120,26 +216,39 @@ async function checkOptimisticOrderedRefetch( const initialRequests = requests.length providerPhase = `optimistic` - const transaction = source.update(3, (draft) => { - draft.folder = `archive` - }) - expect(publicIds()).toEqual([2]) - expect(publications).toEqual([[2]]) + const transaction = + operation === `insert` + ? source.insert({ id: 5, folder: `inbox`, at: 7 }) + : operation === `delete` + ? source.delete(3) + : source.update(3, (draft) => { + draft.folder = `archive` + }) + verdict = transaction.when(`settled`).then( + () => undefined, + (error: unknown) => error, + ) + expect(publicIds()).toEqual(immediateWindow) + expect(publications).toEqual([immediateWindow]) + reach.reach(`optimistic`) - await vi.waitFor(() => { - expect(requests.length).toBeGreaterThan(initialRequests) - expect( - requests - .slice(initialRequests) - .some( - ({ options, ids }) => - options.orderBy?.[0]?.compareOptions.direction === `desc` && - options.limit !== undefined && - options.limit <= 4 && - ids.includes(1), - ), - ).toBe(true) - }) + if (operation !== `insert`) { + await vi.waitFor(() => { + expect(requests.length).toBeGreaterThan(initialRequests) + expect( + requests + .slice(initialRequests) + .some( + ({ options, ids }) => + options.orderBy?.[0]?.compareOptions.direction === `desc` && + options.limit !== undefined && + options.limit <= 4 && + ids.includes(1), + ), + ).toBe(true) + }) + reach.reach(`bounded-refill`) + } for (const { options, ids } of requests.filter( ({ phase }) => phase === `optimistic`, )) { @@ -151,15 +260,17 @@ async function checkOptimisticOrderedRefetch( expect(orderedPage || rankTieProbe).toBe(true) expect(ids).not.toContain(4) } - expect(publicIds()).toEqual([2]) + expect(publicIds()).toEqual(immediateWindow) expect( publications.every( - (snapshot) => snapshot.length === 1 && snapshot[0] === 2, + (snapshot) => + JSON.stringify(snapshot) === JSON.stringify(immediateWindow), ), ).toBe(true) if (reportDuringHold) { - remote.push({ id: 5, folder: `inbox`, at: 5 }) + const head = operation === `insert` ? 6 : 5 + remote.push({ id: head, folder: `inbox`, at: head }) providerPhase = `held-invalidation` await queryClient.invalidateQueries({ queryKey: [`optimistic-query-refetch`], @@ -175,17 +286,33 @@ async function checkOptimisticOrderedRefetch( options.orderBy?.[0]?.compareOptions.direction === `desc` && options.limit !== undefined && options.limit <= 4 && - ids.includes(5), + ids.includes(head), ), ).toBe(true), ) - expect(publicIds()).toEqual([2]) - expect(publications).toEqual([[2]]) + expect(publicIds()).toEqual(immediateWindow) + expect(publications).toEqual([immediateWindow]) + reach.reach(`held-report`) } persistence.resolve() - await transaction.isPersisted.promise - if (!reportDuringHold) { + const actualError = await atMutationCheckpoint( + verdict, + `ordered handler settlement`, + ) + expect(actualError).toBe(ending.startsWith(`reject`) ? failure : undefined) + expect(transaction.state).toBe( + ending.startsWith(`reject`) ? `failed` : `completed`, + ) + if (ending !== `reject-before-write`) { + expect(handlerConfirmed).toBe(true) + reach.reach(`handler-confirmation`) + } + if ( + !reportDuringHold && + operation !== `insert` && + ending !== `reject-before-write` + ) { expect( requests.some( ({ phase, ids }) => @@ -194,17 +321,30 @@ async function checkOptimisticOrderedRefetch( ).toBe(true) } await vi.waitFor(() => - expect(publicIds()).toEqual(expectedPublicWindow(remote)), + checkOrderedMutationObservation( + remote, + history.ending, + { + ids: publicIds(), + error: actualError, + state: transaction.state, + }, + failure, + ), ) - expect(publicIds()).toEqual(reportDuringHold ? [5, 2] : [2, 1]) expect(publications.at(-1)).toEqual(publicIds()) - } finally { - persistence.resolve() - unsubscribe?.() - await live.cleanup() - await source.cleanup() - queryClient.clear() - } + reach.reach(`settled-fixed-point`) + reach.assertReached() + orderedReach.reach(`${operation}/${ending}/${reportDuringHold}`) + }, [ + () => persistence.resolve(), + () => + verdict && atMutationCheckpoint(verdict, `ordered cleanup settlement`), + () => unsubscribe?.(), + () => cleanupLive?.(), + () => cleanupSource?.(), + () => queryClient.clear(), + ]) } /** @@ -220,13 +360,75 @@ function expectedPublicWindow(remote: ReadonlyArray): Array { .map(({ id }) => id) } +// Ordering derives from the backend ledger, while outcome derives from the +// handler's Promise. Rejecting after confirmation changes only the latter. +function checkOrderedMutationObservation( + remote: ReadonlyArray, + ending: OrderedMutationHistory[`ending`], + observed: { ids: Array; error: unknown; state: string }, + failure: Error, +): void { + expect(observed.ids).toEqual(expectedPublicWindow(remote)) + expect(observed.error).toBe(ending.startsWith(`reject`) ? failure : undefined) + expect(observed.state).toBe( + ending.startsWith(`reject`) ? `failed` : `completed`, + ) +} + declareOracle({ - check: [checkOptimisticOrderedRefetch], + check: [checkOptimisticOrderedRefetch, checkOrderedMutationObservation], model: [expectedPublicWindow], + grammar: [orderedMutationHistories], }) -it(`publishes a short optimistic window during a mutation-handler refetch`, () => - checkOptimisticOrderedRefetch(false)) +// The original two update/success histories remain rows of this finite +// grammar, alongside neighboring wrapper, error and discarded-value histories. +describe(`ordered mutation histories`, () => { + afterAll(() => { + orderedReach.assertReached() + console.info( + `query.ordered-mutation-outcome reach`, + Object.fromEntries(orderedReach.counts()), + ) + }) + it.each(orderedMutationHistories)( + `preserves ordered mutation outcomes: %j`, + checkOptimisticOrderedRefetch, + ) +}) -it(`keeps the short optimistic window through a held refetch report`, () => - checkOptimisticOrderedRefetch(true)) +// These controls challenge the same public-window/outcome comparison. They +// establish sensitivity without changing any production code. +it.each([ + `rollback-confirmed-row`, + `discarded-value-failure`, + `reversed-order`, +] as const)(`rejects ordered wrong observation: %s`, (fault) => { + const remote: Array = [ + { id: 1, folder: `inbox`, at: 1 }, + { id: 2, folder: `inbox`, at: 2 }, + ] + const ending = + fault === `rollback-confirmed-row` + ? `reject-after-write` + : `fulfilled-legacy` + const failure = new Error(`handler failure`) + const actual = { + ids: expectedPublicWindow(remote), + error: + ending === `reject-after-write` + ? (failure as unknown) + : (undefined as unknown), + state: ending === `reject-after-write` ? `failed` : `completed`, + } + checkOrderedMutationObservation(remote, ending, actual, failure) + if (fault === `rollback-confirmed-row`) actual.ids = [3, 2] + if (fault === `discarded-value-failure`) { + actual.error = new Error(`legacy value`) + actual.state = `failed` + } + if (fault === `reversed-order`) actual.ids.reverse() + expect(() => + checkOrderedMutationObservation(remote, ending, actual, failure), + ).toThrow() +}) diff --git a/packages/query-db-collection/tests/optimistic-writeback.test.ts b/packages/query-db-collection/tests/optimistic-writeback.test.ts index f9ace4cfb9..56c4a59f6c 100644 --- a/packages/query-db-collection/tests/optimistic-writeback.test.ts +++ b/packages/query-db-collection/tests/optimistic-writeback.test.ts @@ -55,12 +55,12 @@ it.each([`insert`, `upsert`] as const)( }) try { await live.preload() - await insert({ id: `one`, text: `created` }).isPersisted.promise + await insert({ id: `one`, text: `created` }).when(`settled`) for (const text of [`renamed`, `renamed again`]) { - await rename({ id: `one`, text }).isPersisted.promise + await rename({ id: `one`, text }).when(`settled`) expect([...live.values()].map((row) => row.text)).toEqual([text]) } - await insert({ id: `two`, text: `second` }).isPersisted.promise + await insert({ id: `two`, text: `second` }).when(`settled`) expect([...live.values()].map((row) => row.text).sort()).toEqual([ `renamed again`, `second`, @@ -103,7 +103,7 @@ it(`keeps repeated optimistic updates valid after direct upsert acknowledgement` draft.position += 1 }), ) - await tx.isPersisted.promise + await tx.when(`settled`) expect([...live.values()].map((row) => row.position)).toEqual([next]) } } finally { diff --git a/packages/query-db-collection/tests/ownership-lifecycle.oracle.test.ts b/packages/query-db-collection/tests/ownership-lifecycle.oracle.test.ts index 53e02d87e2..5156a7df4d 100644 --- a/packages/query-db-collection/tests/ownership-lifecycle.oracle.test.ts +++ b/packages/query-db-collection/tests/ownership-lifecycle.oracle.test.ts @@ -9,8 +9,17 @@ import { eq, getLoadSubsetDemandKey, } from '@tanstack/db' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterAll, afterEach, describe, expect, it, vi } from 'vitest' import { declareOracle } from '../../db/tests/oracle-declaration.js' +import { createOracleReach } from '../../db/tests/oracle-reach' +import { withOracleCleanup } from '../../db/tests/utils' +import { atMutationCheckpoint } from '../../db/tests/mutation-confirmation-support' +import { + checkMutationConfirmation, + mutationConfirmationHistories, + mutationConfirmationInputs, + mutationConfirmationRows, +} from '../../db/tests/mutation-confirmation-oracle' import { createDeferred } from '../../db/src/deferred.js' import { compileSingleRowExpression, @@ -19,6 +28,7 @@ import { import { InvalidQueryResultError } from '../src/errors.js' import { queryCollectionOptions } from '../src/query.js' +import { persistedCollectionOptions } from '../../db-sqlite-persistence-core/src' import { categorySubset, cleanups, @@ -36,10 +46,17 @@ import { shared, storedItems, } from './ownership-lifecycle-support.js' +import type { + MutationConfirmationObservation, + MutationConfirmationRow, +} from '../../db/tests/mutation-confirmation-oracle' import type { LoadSubsetOptions, Transaction } from '@tanstack/db' import type { QueryClient, QueryFunctionContext } from '@tanstack/query-core' -import type { QueryCollectionUtils } from '../src/query.js' +import type { + QueryCollectionConfig, + QueryCollectionUtils, +} from '../src/query.js' import type { Item, MetadataRecorder, @@ -53,7 +70,10 @@ import type { * remains public while any committed owner needs it. Provisional ownership * begins during result application, becomes durable only after publication and * persistence, and retires when superseded, canceled, cleaned up, or released. - * Mutation refetches add a second authority path but do not bypass those rules. + * Explicit mutation refetches add a second authority path but do not bypass + * those rules. Completing a mutation handler starts no Query request and does + * not change synced rows. The first-RC migration requires handlers to refetch + * or write confirmed data explicitly to retain their optimistic changes. * An accepted direct write changes its Collection without starting a Query * request. In eager mode, compatible Collections sharing a physical Query * project representable cache patches made by either writer. In on-demand @@ -295,8 +315,9 @@ function expectPublicRefetchObservation( * application from the handler call while retaining its later error record. * - `HandlerIdentityObservation` is a model-only projection of strict * transaction, mutation, and Collection identity plus identity-keyed - * registry results. Its bounded grammar exhausts insert, update, and delete; - * handler parameter and mutation-alias access; and refetch and clearError. + * registry results. Its bounded grammar crosses insert, update, and delete + * through handler parameter and mutation-alias access during explicit refetch. + * Historical operation labels are replay coordinates only, not model state. * - `MutationRefreshLivenessModel` is a wait-for graph. Its mutation, fetch, * and application nodes map to the user transaction, Query promise, and * Collection publication boundary. Invocation labels test both owned and @@ -699,7 +720,21 @@ function observeHandlerRefetchBoundary(model: HandlerRefetchBoundaryModel): { * create the forbidden cycle mutation -> refresh -> application -> mutation; * for an external caller it would add an unrelated persistence delay. */ -type MutationRefreshOperation = `refetch` | `clearError` +/** + * Before the RC, these matrices exercised refetch and the removed clearError + * utility. Both paths now explicitly refetch. Keep the former operation labels + * only as replay coordinates: existing traces, fixture IDs, and all 12 runs of + * each matrix survive. The labels add no semantic history or model distinction. + * To select the former clearError rows, use the test-name pattern + * `legacy replay:.*clearError`; Vitest can quote interpolated string values. + */ +type LegacyRefreshReplayCoordinate = `refetch` | `clearError` +const legacyRefreshReplayCoordinates = [`refetch`, `clearError`] as const + +type LegacyRefreshReplay = { + legacyOperation: LegacyRefreshReplayCoordinate +} + type MutationRefreshInvocation = | `manual-transaction` | `optimistic-action` @@ -714,7 +749,6 @@ type MutationRefreshSchedule = type MutationRefreshHistory = { invocation: MutationRefreshInvocation - operation: MutationRefreshOperation schedule: MutationRefreshSchedule } @@ -725,8 +759,9 @@ const mutationRefreshInvocations = [ `external-handler-overlap`, `external-persisted-application-overlap`, ] as const -const mutationRefreshOperations = [`refetch`, `clearError`] as const -const mutationRefreshHistoryGrammar: ReadonlyArray = [ +const mutationRefreshHistoryGrammar: ReadonlyArray< + MutationRefreshHistory & LegacyRefreshReplay +> = [ ...mutationRefreshInvocations .filter( (invocation) => @@ -734,24 +769,24 @@ const mutationRefreshHistoryGrammar: ReadonlyArray = [ invocation !== `external-persisted-application-overlap`, ) .flatMap((invocation) => - mutationRefreshOperations.map((operation) => ({ + legacyRefreshReplayCoordinates.map((legacyOperation) => ({ invocation, - operation, + legacyOperation, schedule: `mutation-first` as const, })), ), - ...mutationRefreshOperations.flatMap((operation) => + ...legacyRefreshReplayCoordinates.flatMap((legacyOperation) => ([`mutation-first`, `refresh-first-during-fetch`] as const).map( (schedule) => ({ invocation: `external-handler-overlap` as const, - operation, + legacyOperation, schedule, }), ), ), - ...mutationRefreshOperations.map((operation) => ({ + ...legacyRefreshReplayCoordinates.map((legacyOperation) => ({ invocation: `external-persisted-application-overlap` as const, - operation, + legacyOperation, schedule: `refresh-first-during-application` as const, })), ] @@ -854,12 +889,10 @@ function observeFetchRecordSuccessorWork(model: FetchRecordLifecycleModel): { type MutationHandlerKind = `insert` | `update` | `delete` type HandlerCollectionAccessPath = `parameter` | `mutation-alias` -type HandlerCollectionOperation = `refetch` | `clearError` type HandlerCollectionHistory = { handlerKind: MutationHandlerKind accessPath: HandlerCollectionAccessPath - operation: HandlerCollectionOperation } type HandlerIdentityObservation = HandlerCollectionHistory & { @@ -877,18 +910,17 @@ type HandlerIdentityObservation = HandlerCollectionHistory & { const mutationHandlerKinds = [`insert`, `update`, `delete`] as const const handlerCollectionAccessPaths = [`parameter`, `mutation-alias`] as const -const handlerCollectionOperations = [`refetch`, `clearError`] as const - -const handlerCollectionHistoryGrammar: ReadonlyArray = - mutationHandlerKinds.flatMap((handlerKind) => - handlerCollectionAccessPaths.flatMap((accessPath) => - handlerCollectionOperations.map((operation) => ({ - handlerKind, - accessPath, - operation, - })), - ), - ) +const handlerCollectionHistoryGrammar: ReadonlyArray< + HandlerCollectionHistory & LegacyRefreshReplay +> = mutationHandlerKinds.flatMap((handlerKind) => + handlerCollectionAccessPaths.flatMap((accessPath) => + legacyRefreshReplayCoordinates.map((legacyOperation) => ({ + handlerKind, + accessPath, + legacyOperation, + })), + ), +) /** * A direct mutation handler receives the real Collection. Every matching @@ -904,7 +936,8 @@ function observeHandlerIdentity( throw new Error(`A direct handler requires a matching mutation alias`) } return { - ...history, + handlerKind: history.handlerKind, + accessPath: history.accessPath, matchingAliasCount, transactionIsExternal: true, allMutationsPreserveIdentity: true, @@ -1587,8 +1620,36 @@ function mergeDirectWritesOverFetch( ) } +// A pending application obligation can include persistence after publication. +// While its receipt is held, allow either complete source snapshot; never infer +// receipt settlement merely from visible rows. Once it fulfills, the waiter +// itself must read the confirmed snapshot. This composes the existing call +// ledger with Collection commit coherence without copying persistence machinery. +function checkPostHandlerRefetch( + model: RefetchCallSettlementModel, + observed: { + refetch: ReturnType + rows: string + waiterRows: string | undefined + }, + initial: string, + confirmed: string, +): void { + const expected = observeRefetchCallSettlement(model) + expect(observed.refetch).toEqual(expected) + if (expected.refetch === `pending`) { + expect([initial, confirmed]).toContain(observed.rows) + expect(observed.waiterRows).toBeUndefined() + } else { + expect(observed.rows).toBe(confirmed) + expect(observed.waiterRows).toBe(confirmed) + } +} + declareOracle({ check: [ + checkMutationConfirmation, + checkPostHandlerRefetch, expectPublicRefetchObservation, runDirectRowLifetimeHistory, runDirectWriteOrderHistory, @@ -1616,6 +1677,502 @@ describe(`query collection ownership lifecycle`, () => { await runCleanups() }) + // The shared independent model owns handler outcome versus confirmation. + // This real QueryClient driver controls the server edit, explicit acceptance, + // and handler settlement separately. No focus/poll/invalidation trigger runs. + // Rejections after a committed server edit must not undo accepted source data. + describe(`mutation outcome and confirmation`, () => { + const mutationHistories = mutationConfirmationHistories(`query`) + const mutationReachStates = mutationHistories.map( + (h) => + `${h.operation}/${h.outcome}/${h.confirmation}/${h.returned}/peer:${h.peer}`, + ) + const mutationReach = createOracleReach( + `query.mutation-confirmation`, + mutationReachStates, + ) + afterAll(() => { + mutationReach.assertReached() + console.info( + `query.mutation-confirmation reach`, + Object.fromEntries(mutationReach.counts()), + ) + }) + it.each(mutationHistories)( + `refreshes only when the handler requests it: %j`, + async (history) => { + mutationReach.beginHistory() + const { operation, confirmation, outcome, returned } = history + const localReach = createOracleReach(`query.mutation-cuts`, [ + `entered`, + `settled`, + `settlement-waiter`, + ...(outcome === `reject-before-write` ? [] : [`server-written`]), + ...(confirmation === `none` ? [] : [`confirmation-accepted`]), + ...(confirmation === `refetch` ? [`confirmation-requested`] : []), + ...(confirmation === `none` && outcome !== `reject-before-write` + ? [`late-confirmed`] + : []), + ]) + localReach.beginHistory() + const queryClient = createQueryClient() + const enterRelease = createDeferred() + const confirmationRelease = createDeferred() + const settleRelease = createDeferred() + const entered = createDeferred() + const serverWritten = createDeferred() + const confirmationAccepted = createDeferred() + const refetchRequested = createDeferred() + const refetchResponse = createDeferred() + const failure = new Error(`handler ${outcome}`) + const initial = mutationConfirmationRows( + operation, + `initial`, + history.peer, + ) + const confirmed = mutationConfirmationRows( + operation, + `confirmed`, + history.peer, + ) + let server = structuredClone(initial) + let handlerWrites = 0 + let confirmationCalls = 0 + let confirmationStatus: MutationConfirmationObservation[`confirmationStatus`] = `not-requested` + let settlement: MutationConfirmationObservation[`settlement`] = `pending` + let error: unknown + let holdRefetch = confirmation === `refetch` + const queryFn = vi.fn(async () => { + if (confirmationCalls > 0 && holdRefetch) { + refetchRequested.resolve() + await refetchResponse.promise + } + return structuredClone(server) + }) + let releaseCollection: (() => Promise) | undefined + let verdict: Promise | undefined + await withOracleCleanup(async () => { + const handler = async ({ + collection: target, + }: Pick< + Parameters< + NonNullable< + QueryCollectionConfig[`onInsert`] + > + >[0], + `collection` + >) => { + entered.resolve() + await enterRelease.promise + if (outcome === `reject-before-write`) throw failure + server = structuredClone(confirmed) + handlerWrites++ + serverWritten.resolve() + await confirmationRelease.promise + if (confirmation !== `none`) { + confirmationCalls++ + confirmationStatus = `pending` + } + if (confirmation === `refetch`) await target.utils.refetch() + if (confirmation === `write`) { + if (operation === `delete`) await target.utils.writeDelete(1) + else + await target.utils.writeUpsert({ + ...mutationConfirmationInputs.confirmed, + }) + } + if (confirmation !== `none`) confirmationStatus = `accepted` + confirmationAccepted.resolve() + await settleRelease.promise + if (outcome === `reject-after-write`) throw failure + return returned === `legacy-disable` + ? { refetch: false } + : returned === `legacy-enable` + ? { refetch: true } + : undefined + } + const collection = createCollection( + queryCollectionOptions({ + queryKey: [`explicit-handler-refresh`, history], + queryClient, + queryFn, + getKey: (row) => row.id, + // JS callers may return coordination objects that TS now rejects. + onInsert: handler as unknown as NonNullable< + QueryCollectionConfig[`onInsert`] + >, + onUpdate: handler as unknown as NonNullable< + QueryCollectionConfig[`onUpdate`] + >, + onDelete: handler as unknown as NonNullable< + QueryCollectionConfig[`onDelete`] + >, + }), + ) + releaseCollection = () => collection.cleanup() + await atMutationCheckpoint( + collection.preload(), + `initial Query preload`, + ) + const transaction = + operation === `insert` + ? collection.insert({ ...mutationConfirmationInputs.optimistic }) + : operation === `delete` + ? collection.delete(1) + : collection.update(1, (draft) => { + draft.name = `optimistic` + }) + const observe = (): MutationConfirmationObservation => ({ + rows: collection.toArray + .map(({ id, name, stable }) => ({ id, name, stable })) + .sort((a, b) => a.id - b.id), + handlerWrites, + confirmationCalls, + fetches: queryFn.mock.calls.length, + providerRequests: 0, + confirmationStatus, + state: transaction.state, + settlement, + error, + }) + verdict = transaction.when(`settled`).then( + () => { + settlement = `fulfilled` + return observe() + }, + (reason: unknown) => { + settlement = `rejected` + error = reason + return observe() + }, + ) + await atMutationCheckpoint(entered.promise, `Query handler entry`) + checkMutationConfirmation(history, `entered`, observe(), failure) + localReach.reach(`entered`) + enterRelease.resolve() + if (outcome !== `reject-before-write`) { + await atMutationCheckpoint( + serverWritten.promise, + `Query server write`, + ) + checkMutationConfirmation( + history, + `server-written`, + observe(), + failure, + ) + localReach.reach(`server-written`) + confirmationRelease.resolve() + if (confirmation === `refetch`) { + await atMutationCheckpoint( + refetchRequested.promise, + `held Query refetch response`, + ) + checkMutationConfirmation( + history, + `confirmation-requested`, + observe(), + failure, + ) + localReach.reach(`confirmation-requested`) + holdRefetch = false + refetchResponse.resolve() + } + await atMutationCheckpoint( + confirmationAccepted.promise, + `Query confirmation acceptance`, + ) + if (confirmation !== `none`) { + checkMutationConfirmation( + history, + `confirmation-accepted`, + observe(), + failure, + ) + localReach.reach(`confirmation-accepted`) + } + settleRelease.resolve() + } + const atWaiter = await atMutationCheckpoint( + verdict, + `Query handler settlement`, + ) + checkMutationConfirmation(history, `settled`, atWaiter, failure) + localReach.reach(`settlement-waiter`) + checkMutationConfirmation(history, `settled`, observe(), failure) + localReach.reach(`settled`) + if (confirmation === `none` && outcome !== `reject-before-write`) { + // Backend success alone supplied no source confirmation. A later + // explicit reader may refetch; the frozen settlement cut stays old. + await atMutationCheckpoint( + collection.utils.refetch(), + `later explicit Query refetch`, + ) + checkMutationConfirmation( + history, + `late-confirmed`, + observe(), + failure, + ) + localReach.reach(`late-confirmed`) + checkMutationConfirmation(history, `settled`, atWaiter, failure) + } + localReach.assertReached() + mutationReach.reach( + `${operation}/${outcome}/${confirmation}/${returned}/peer:${history.peer}`, + ) + }, [ + () => { + enterRelease.resolve() + confirmationRelease.resolve() + settleRelease.resolve() + refetchResponse.resolve() + }, + () => + verdict && + atMutationCheckpoint(verdict, `Query cleanup settlement`), + () => releaseCollection?.(), + () => queryClient.clear(), + ]) + }, + ) + }) + + /** + * Handler exit restores the ordinary public-refetch boundary. A later call + * waits for its commit receipt, even if an earlier CRUD handler + * rejected. The existing call-obligation model owns that judgment. A real + * persisted wrapper holds the later durable commit with no optimistic work + * left; this distinguishes a leaked handler count from a legitimate mutation + * publication block. Ordinary source values are serialized without dropping + * fields; ordering is normalized because Collection order is unspecified. + */ + describe(`refetch application after handler exit`, () => { + const histories = ([`insert`, `update`, `delete`] as const).flatMap( + (operation) => [false, true].map((reject) => ({ operation, reject })), + ) + const reach = createOracleReach( + `query.post-handler-application`, + histories.flatMap((h) => [ + `${h.operation}/${h.reject}/application-held`, + `${h.operation}/${h.reject}/applied-waiter`, + ]), + ) + afterAll(() => { + reach.assertReached() + console.info( + `query.post-handler-application reach`, + Object.fromEntries(reach.counts()), + ) + }) + it.each([`early-receipt`, `partial-rows`, `stale-waiter`] as const)( + `rejects post-handler wrong observation: %s`, + (fault) => { + let model = createRefetchCallSettlementModel([`query`], true) + model = reduceRefetchCallSettlement(model, { + type: `accept-application`, + queryId: `query`, + applicationId: `later-result`, + }) + model = reduceRefetchCallSettlement(model, { + type: `settle-fetch`, + queryId: `query`, + outcome: `fulfilled`, + }) + if (fault === `stale-waiter`) + model = reduceRefetchCallSettlement(model, { + type: `settle-application`, + applicationId: `later-result`, + outcome: `fulfilled`, + }) + const actual = { + refetch: observeRefetchCallSettlement(model), + rows: `confirmed`, + waiterRows: fault === `stale-waiter` ? `confirmed` : undefined, + } + checkPostHandlerRefetch(model, actual, `initial`, `confirmed`) + if (fault === `early-receipt`) + actual.refetch = { refetch: `resolved`, resultOrder: [`query`] } + if (fault === `partial-rows`) actual.rows = `partial` + if (fault === `stale-waiter`) actual.waiterRows = `initial` + expect(() => + checkPostHandlerRefetch(model, actual, `initial`, `confirmed`), + ).toThrow() + }, + ) + it.each(histories)( + `waits for later application after handler $operation rejection:$reject`, + async ({ operation, reject }) => { + reach.beginHistory() + const initial: Array = [ + { id: `peer`, category: `peer`, name: `Peer` }, + ...(operation === `insert` + ? [] + : [{ id: `a`, category: `edited`, name: `Before` }]), + ] + const authoritative: Array = [ + { id: `peer`, category: `peer`, name: `New peer` }, + { id: `a`, category: `edited`, name: `New source row` }, + ] + const encode = (values: ReadonlyArray) => + JSON.stringify( + [...values] + .map(({ id, category, name }) => ({ id, category, name })) + .sort((left, right) => left.id.localeCompare(right.id)), + ) + let server = structuredClone(initial) + const handlerFailure = new Error(`handler rejected`) + const handler = async () => { + await Promise.resolve() + if (reject) throw handlerFailure + } + const storage = createOwnershipStorage() + const entered = createDeferred() + const release = createDeferred() + let hold = false + const apply = storage.adapter.applyCommittedTx.bind(storage.adapter) + storage.adapter.applyCommittedTx = async (...args) => { + if (hold && args[1].mutations.length > 0) { + hold = false + entered.resolve() + await release.promise + } + return apply(...args) + } + const queryClient = createQueryClient() + const queryFn = vi.fn(() => Promise.resolve(structuredClone(server))) + let releaseCollection: (() => Promise) | undefined + let external: Promise | undefined + await withOracleCleanup(async () => { + const collection = createCollection( + persistedCollectionOptions< + Item, + string | number, + never, + QueryCollectionUtils + >({ + ...queryCollectionOptions({ + id: `post-handler-${operation}-${reject}`, + queryKey: [`post-handler`, operation, reject], + queryClient, + queryFn, + getKey: (row) => row.id, + onInsert: handler, + onUpdate: handler, + onDelete: handler, + }), + persistence: { adapter: storage.adapter }, + }), + ) + releaseCollection = () => collection.cleanup() + await atMutationCheckpoint( + collection.preload(), + `post-handler initial preload`, + ) + const transaction = + operation === `insert` + ? collection.insert({ + id: `a`, + category: `edited`, + name: `Optimistic`, + }) + : operation === `delete` + ? collection.delete(`a`) + : collection.update(`a`, (draft) => { + draft.name = `Optimistic` + }) + const firstError = await transaction.when(`settled`).then( + () => undefined, + (error: unknown) => error, + ) + expect(firstError).toBe(reject ? handlerFailure : undefined) + expect(transaction.state).toBe(reject ? `failed` : `completed`) + expect(encode(collection.toArray)).toBe(encode(initial)) + hold = true + server = structuredClone(authoritative) + let outcome: RefetchApplicationOutcome = `pending` + let resultOrder: Array | undefined + let waiterRows: string | undefined + const observe = () => ({ + rows: encode(collection.toArray), + waiterRows, + refetch: { + refetch: outcome, + ...(outcome === `resolved` ? { resultOrder } : {}), + }, + }) + let model = createRefetchCallSettlementModel([`query`], true) + model = reduceRefetchCallSettlement(model, { + type: `accept-application`, + queryId: `query`, + applicationId: `later-result`, + }) + external = collection.utils.refetch({ throwOnError: true }).then( + (results) => { + outcome = `resolved` + resultOrder = results.map(() => `query`) + waiterRows = encode(collection.toArray) + }, + (error: unknown) => { + outcome = `rejected` + throw error + }, + ) + void external.catch(() => {}) + await atMutationCheckpoint( + entered.promise, + `post-handler durable application held`, + ) + await vi.waitFor(() => { + expect(queryFn).toHaveBeenCalledTimes(2) + expect(queryClient.isFetching()).toBe(0) + }) + // Query's flags clear before the utility's queued Promise chain + // finishes. Keep the receipt held through a task boundary so every + // already-queued microtask can process that completed fetch. The + // leaked-count transform is the calibration for this timing premise. + await new Promise((resolve) => setTimeout(resolve, 0)) + model = reduceRefetchCallSettlement(model, { + type: `settle-fetch`, + queryId: `query`, + outcome: `fulfilled`, + }) + checkPostHandlerRefetch( + model, + observe(), + encode(initial), + encode(authoritative), + ) + reach.reach(`${operation}/${reject}/application-held`) + release.resolve() + await atMutationCheckpoint( + external, + `post-handler external refetch settlement`, + ) + model = reduceRefetchCallSettlement(model, { + type: `settle-application`, + applicationId: `later-result`, + outcome: `fulfilled`, + }) + checkPostHandlerRefetch( + model, + observe(), + encode(initial), + encode(authoritative), + ) + reach.reach(`${operation}/${reject}/applied-waiter`) + }, [ + () => release.resolve(), + () => + external && + atMutationCheckpoint(external, `post-handler cleanup refetch`), + () => releaseCollection?.(), + () => queryClient.clear(), + ]) + }, + ) + }) + it(`keeps an accepted empty-diff result pending until application`, () => { const initial: ExplicitRefetchApplicationModel = { calls: new Map(), @@ -1948,10 +2505,10 @@ describe(`query collection ownership lifecycle`, () => { }) }) - it(`enumerates every direct handler identity history exactly once`, () => { + it(`preserves every direct handler identity legacy replay coordinate`, () => { const historyKeys = handlerCollectionHistoryGrammar.map( - ({ handlerKind, accessPath, operation }) => - `${handlerKind}:${accessPath}:${operation}`, + ({ handlerKind, accessPath, legacyOperation }) => + `${handlerKind}:${accessPath}:${legacyOperation}`, ) expect(historyKeys).toEqual([ `insert:parameter:refetch`, @@ -1968,6 +2525,14 @@ describe(`query collection ownership lifecycle`, () => { `delete:mutation-alias:clearError`, ]) expect(new Set(historyKeys).size).toBe(historyKeys.length) + // Six semantic histories each keep their two historical replay runs. + expect( + new Set( + handlerCollectionHistoryGrammar.map( + ({ handlerKind, accessPath }) => `${handlerKind}:${accessPath}`, + ), + ).size, + ).toBe(6) }) it(`rejects handler identity wrong-result controls`, () => { @@ -2156,7 +2721,7 @@ describe(`query collection ownership lifecycle`, () => { }) cleanups.push(async () => { persistence.resolve() - await transaction.isPersisted.promise.catch(() => undefined) + await transaction.when(`settled`).catch(() => undefined) consoleError.mockRestore() await collection.cleanup() queryClient.clear() @@ -2204,7 +2769,7 @@ describe(`query collection ownership lifecycle`, () => { expect(collection.get(shared.id)?.name).toBe(`Optimistic`) persistence.resolve() - await transaction.isPersisted.promise + await transaction.when(`settled`) const results = await refetch model = reduceMutationRefreshLiveness(model, { type: `settle-application`, @@ -2376,7 +2941,7 @@ describe(`query collection ownership lifecycle`, () => { cleanups.push(async () => { unsubscribeCache() persistence.resolve() - await transaction?.isPersisted.promise.catch(() => undefined) + await transaction?.when(`settled`).catch(() => undefined) await collection.cleanup() queryClient.clear() }) @@ -2428,7 +2993,7 @@ describe(`query collection ownership lifecycle`, () => { outcome: `rejected`, }) persistence.resolve() - await transaction?.isPersisted.promise + await transaction?.when(`settled`) const results = await refetch expect(actualOutcome).toBe(observeRefetchCallSettlement(model).refetch) @@ -2560,7 +3125,7 @@ describe(`query collection ownership lifecycle`, () => { }) cleanups.push(async () => { persistence.resolve() - await transaction.isPersisted.promise + await transaction.when(`settled`) }) let model: MutationRefreshLivenessModel = { @@ -2608,7 +3173,7 @@ describe(`query collection ownership lifecycle`, () => { }).toEqual(observeMutationRefreshLiveness(model)) persistence.resolve() - await transaction.isPersisted.promise + await transaction.when(`settled`) await refetch model = reduceMutationRefreshLiveness(model, { type: `settle-application`, @@ -2666,7 +3231,7 @@ describe(`query collection ownership lifecycle`, () => { explicitResult.resolve([authoritative]) persistence.resolve() if (transactionStarted) { - await transaction.isPersisted.promise.catch(() => undefined) + await transaction.when(`settled`).catch(() => undefined) } await Promise.allSettled([eager.cleanup(), onDemand.cleanup()]) queryClient.clear() @@ -2723,7 +3288,7 @@ describe(`query collection ownership lifecycle`, () => { }).toEqual(observeMutationRefreshLiveness(model)) persistence.resolve() - await transaction.isPersisted.promise + await transaction.when(`settled`) const results = await refetch model = reduceMutationRefreshLiveness(model, { type: `settle-application`, @@ -2741,8 +3306,8 @@ describe(`query collection ownership lifecycle`, () => { ({ invocation }) => invocation === `external-handler-overlap`, ), )( - `settles an external $operation at fetch with $schedule overlap`, - async ({ operation, schedule }) => { + `settles an external refetch at fetch with $schedule overlap (legacy replay: $legacyOperation)`, + async ({ legacyOperation, schedule }) => { const initial = { ...shared, name: `Initial` } const authoritative = { ...shared, name: `Authoritative` } const refreshResult = createDeferred>() @@ -2755,16 +3320,17 @@ describe(`query collection ownership lifecycle`, () => { .mockReturnValueOnce(refreshResult.promise) const collection = createCollection( queryCollectionOptions({ - id: `external-${operation}-${schedule}-overlaps-handler`, + id: `external-${legacyOperation}-${schedule}-overlaps-handler`, queryClient, - queryKey: [`external-${operation}-${schedule}-overlaps-handler`], + queryKey: [ + `external-${legacyOperation}-${schedule}-overlaps-handler`, + ], queryFn, getKey: (item) => item.id, startSync: true, onUpdate: async () => { handlerStarted.resolve() await releaseHandler.promise - return { refetch: false } }, }), ) @@ -2784,11 +3350,7 @@ describe(`query collection ownership lifecycle`, () => { } let refetchOutcome: RefetchApplicationOutcome = `pending` const startRefresh = () => { - const refresh = ( - operation === `refetch` - ? collection.utils.refetch({ throwOnError: true }) - : collection.utils.refetch({ throwOnError: true }) - ).then( + const refresh = collection.utils.refetch({ throwOnError: true }).then( (result) => { refetchOutcome = `resolved` return result @@ -2841,7 +3403,7 @@ describe(`query collection ownership lifecycle`, () => { }).toEqual(observeMutationRefreshLiveness(model)) releaseHandler.resolve() - await mutation.isPersisted.promise + await mutation.when(`settled`) await refresh model = reduceMutationRefreshLiveness(model, { type: `settle-application`, @@ -2860,9 +3422,9 @@ describe(`query collection ownership lifecycle`, () => { invocation === `external-persisted-application-overlap`, ), )( - `settles an external $operation when mutation starts during persisted application preparation`, - async ({ operation }) => { - const id = `external-${operation}-persisted-application-overlap` + `settles an external refetch when mutation starts during persisted application preparation (legacy replay: $legacyOperation)`, + async ({ legacyOperation }) => { + const id = `external-${legacyOperation}-persisted-application-overlap` const queryHash = hashKey([id]) const initial = { ...shared, name: `Initial` } const authoritative = { ...shared, name: `Authoritative` } @@ -2916,11 +3478,7 @@ describe(`query collection ownership lifecycle`, () => { throwOnError: true, } let refetchOutcome: RefetchApplicationOutcome = `pending` - const refresh = ( - operation === `refetch` - ? collection.utils.refetch({ throwOnError: true }) - : collection.utils.refetch({ throwOnError: true }) - ).then( + const refresh = collection.utils.refetch({ throwOnError: true }).then( (result) => { refetchOutcome = `resolved` return result @@ -2942,7 +3500,7 @@ describe(`query collection ownership lifecycle`, () => { mutationFn: () => persistence.promise, }) transaction.mutate(() => collection.insert(optimistic)) - void transaction.isPersisted.promise.catch(() => undefined) + void transaction.when(`settled`).catch(() => undefined) applicationScan.resolve([]) model = reduceMutationRefreshLiveness(model, { type: `accept-application`, @@ -2959,7 +3517,7 @@ describe(`query collection ownership lifecycle`, () => { expect(collection.get(shared.id)?.name).not.toBe(authoritative.name) persistence.resolve() - await transaction.isPersisted.promise + await transaction.when(`settled`) await refresh model = reduceMutationRefreshLiveness(model, { type: `settle-application`, @@ -2976,9 +3534,9 @@ describe(`query collection ownership lifecycle`, () => { it(`rejects a call-entry-only boundary mutant for refresh-first overlap`, () => { const history = mutationRefreshHistoryGrammar.find( - ({ invocation, operation, schedule }) => + ({ invocation, legacyOperation, schedule }) => invocation === `external-handler-overlap` && - operation === `refetch` && + legacyOperation === `refetch` && schedule === `refresh-first-during-fetch`, ) expect(history).toBeDefined() @@ -3006,16 +3564,23 @@ describe(`query collection ownership lifecycle`, () => { expect(callEntryOnlyMutant).not.toEqual(expected) }) - it(`enumerates every mutation refresh history exactly once`, () => { + it(`preserves every mutation refresh legacy replay coordinate`, () => { expect(mutationRefreshHistoryGrammar).toHaveLength(12) expect( new Set( mutationRefreshHistoryGrammar.map( - ({ invocation, operation, schedule }) => - `${invocation}:${operation}:${schedule}`, + ({ invocation, legacyOperation, schedule }) => + `${invocation}:${legacyOperation}:${schedule}`, ), ).size, ).toBe(mutationRefreshHistoryGrammar.length) + expect( + new Set( + mutationRefreshHistoryGrammar.map( + ({ invocation, schedule }) => `${invocation}:${schedule}`, + ), + ).size, + ).toBe(6) }) it.each( @@ -3025,9 +3590,9 @@ describe(`query collection ownership lifecycle`, () => { invocation === `optimistic-action`, ), )( - `settles a $invocation $operation at the fetch boundary`, - async ({ invocation, operation }) => { - const id = `mutation-refresh-${invocation}-${operation}` + `settles a $invocation refetch at the fetch boundary (legacy replay: $legacyOperation)`, + async ({ invocation, legacyOperation }) => { + const id = `mutation-refresh-${invocation}-${legacyOperation}` const initial = { ...shared, name: `Initial` } const authoritative = { ...shared, name: `Authoritative` } const refreshResult = createDeferred>() @@ -3049,10 +3614,7 @@ describe(`query collection ownership lifecycle`, () => { ) let refreshOutcome: RefetchApplicationOutcome = `pending` const mutationFn = async (): Promise => { - const refresh = - operation === `refetch` - ? collection.utils.refetch({ throwOnError: true }) - : collection.utils.refetch({ throwOnError: true }) + const refresh = collection.utils.refetch({ throwOnError: true }) await refresh.then( () => { refreshOutcome = `resolved` @@ -3074,7 +3636,7 @@ describe(`query collection ownership lifecycle`, () => { // breaks the deliberately reproduced cycle during RED cleanup. collection.utils.writeUpdate(authoritative) releaseMutation.resolve() - await transactionReference.current.isPersisted.promise + await transactionReference.current.when(`settled`) } await collection.cleanup() queryClient.clear() @@ -3103,7 +3665,7 @@ describe(`query collection ownership lifecycle`, () => { transaction = action(`refresh`) } transactionReference.current = transaction - void transaction.isPersisted.promise.catch(() => undefined) + void transaction.when(`settled`).catch(() => undefined) await vi.waitFor(() => expect(queryFn).toHaveBeenCalledTimes(2)) let model: MutationRefreshLivenessModel = { @@ -3138,9 +3700,9 @@ describe(`query collection ownership lifecycle`, () => { ({ invocation }) => invocation === `captured-handler-collection`, ), )( - `settles a captured handler Collection $operation at the fetch boundary`, - async ({ operation }) => { - const id = `captured-handler-refresh-${operation}` + `settles a captured handler Collection refetch at the fetch boundary (legacy replay: $legacyOperation)`, + async ({ legacyOperation }) => { + const id = `captured-handler-refresh-${legacyOperation}` const initial = { ...shared, name: `Initial` } const authoritative = { ...shared, name: `Authoritative` } const refreshResult = createDeferred>() @@ -3166,10 +3728,9 @@ describe(`query collection ownership lifecycle`, () => { if (!capturedCollection) { throw new Error(`The captured Collection is not available`) } - const refresh = - operation === `refetch` - ? capturedCollection.utils.refetch({ throwOnError: true }) - : capturedCollection.utils.refetch({ throwOnError: true }) + const refresh = capturedCollection.utils.refetch({ + throwOnError: true, + }) await refresh.then( () => { refreshOutcome = `resolved` @@ -3180,7 +3741,6 @@ describe(`query collection ownership lifecycle`, () => { }, ) await releaseHandler.promise - return { refetch: false } }, }), ) @@ -3193,7 +3753,7 @@ describe(`query collection ownership lifecycle`, () => { if (transactionReference.current) { collection.utils.writeUpdate(authoritative) releaseHandler.resolve() - await transactionReference.current.isPersisted.promise + await transactionReference.current.when(`settled`) } await collection.cleanup() queryClient.clear() @@ -3204,7 +3764,7 @@ describe(`query collection ownership lifecycle`, () => { draft.name = `Optimistic` }) transactionReference.current = transaction - void transaction.isPersisted.promise.catch(() => undefined) + void transaction.when(`settled`).catch(() => undefined) await vi.waitFor(() => expect(queryFn).toHaveBeenCalledTimes(2)) let model: MutationRefreshLivenessModel = { @@ -3375,9 +3935,9 @@ describe(`query collection ownership lifecycle`, () => { }) it.each(handlerCollectionHistoryGrammar)( - `preserves $handlerKind handler identity through $accessPath $operation`, + `preserves $handlerKind handler identity through $accessPath refetch (legacy replay: $legacyOperation)`, async (history) => { - const { handlerKind, accessPath, operation } = history + const { handlerKind, accessPath, legacyOperation } = history const initial = [ { id: `first`, category: `shared`, name: `First` }, { id: `second`, category: `shared`, name: `Second` }, @@ -3421,7 +3981,7 @@ describe(`query collection ownership lifecycle`, () => { transaction: { mutations: ReadonlyArray<{ collection: unknown }> }, - ): Promise<{ refetch: false }> => { + ): Promise => { await inspectIdentity.promise const externalCollection = externalCollectionReference.current if (!externalCollection) { @@ -3451,7 +4011,6 @@ describe(`query collection ownership lifecycle`, () => { identityObservation = { handlerKind: actualHandlerKind, accessPath, - operation, matchingAliasCount: aliases.length, transactionIsExternal: transaction === externalTransaction, allMutationsPreserveIdentity: transaction.mutations.every( @@ -3481,10 +4040,7 @@ describe(`query collection ownership lifecycle`, () => { accessPath === `parameter` ? handlerCollection : (aliases[0] as typeof handlerCollection) - const boundary = - operation === `refetch` - ? refetchCollection.utils.refetch({ throwOnError: true }) - : refetchCollection.utils.refetch({ throwOnError: true }) + const boundary = refetchCollection.utils.refetch({ throwOnError: true }) await boundary.then( () => { handlerOutcome = `resolved` @@ -3494,13 +4050,12 @@ describe(`query collection ownership lifecycle`, () => { throw error }, ) - return { refetch: false } } const collection = createCollection( queryCollectionOptions({ - id: `handler-scope-${handlerKind}-${accessPath}-${operation}`, + id: `handler-scope-${handlerKind}-${accessPath}-${legacyOperation}`, queryClient, - queryKey: [`handler-scope`, handlerKind, accessPath, operation], + queryKey: [`handler-scope`, handlerKind, accessPath, legacyOperation], queryFn, getKey: (item) => item.id, startSync: true, @@ -3540,7 +4095,7 @@ describe(`query collection ownership lifecycle`, () => { : collection.delete(initial.map(({ id }) => id)) externalTransactionReference.current = mutation inspectIdentity.resolve() - void mutation.isPersisted.promise.catch(() => undefined) + void mutation.when(`settled`).catch(() => undefined) await vi.waitFor(() => expect(queryFn).toHaveBeenCalledTimes(2)) expect(handlerOutcome).toBe(observeHandlerRefetchBoundary(model).refetch) if (!identityObservation) { @@ -3567,7 +4122,7 @@ describe(`query collection ownership lifecycle`, () => { ) }) - await mutation.isPersisted.promise + await mutation.when(`settled`) model = reduceHandlerRefetchBoundary(model, { type: `settle-application`, outcome: `fulfilled`, @@ -3696,7 +4251,6 @@ describe(`query collection ownership lifecycle`, () => { throw error }, ) - return { refetch: false } }, }), ) @@ -3715,7 +4269,7 @@ describe(`query collection ownership lifecycle`, () => { const mutation = collection.update(shared.id, (draft) => { draft.name = `Optimistic` }) - await mutation.isPersisted.promise + await mutation.when(`settled`) model = reduceHandlerRefetchBoundary(model, { type: `accept-application`, }) @@ -4716,7 +5270,7 @@ describe(`query collection ownership lifecycle`, () => { cleanups.push(async () => { persistence.resolve() refetch.reject(new Error(`cleanup`)) - await mutation.isPersisted.promise.catch(() => undefined) + await mutation.when(`settled`).catch(() => undefined) }) // The release's sync transaction is accepted but held behind the blocker. @@ -4737,7 +5291,7 @@ describe(`query collection ownership lifecycle`, () => { await settleTurn() } persistence.resolve() - await mutation.isPersisted.promise + await mutation.when(`settled`) await settleTurn() const published = rows(collection) @@ -4841,7 +5395,7 @@ describe(`query collection ownership lifecycle`, () => { cleanups.push(async () => { persistence.resolve() reacquiredFetch.reject(new Error(`cleanup`)) - await mutation.isPersisted.promise.catch(() => undefined) + await mutation.when(`settled`).catch(() => undefined) }) await server.collection._sync.loadSubset(a) @@ -4870,11 +5424,11 @@ describe(`query collection ownership lifecycle`, () => { await settleTurn() await failReacquiredFetch() persistence.resolve() - await mutation.isPersisted.promise + await mutation.when(`settled`) await claimed } else { persistence.resolve() - await mutation.isPersisted.promise + await mutation.when(`settled`) await settleTurn() await server.collection._sync.loadSubset(b) await failReacquiredFetch() @@ -4911,7 +5465,7 @@ describe(`query collection ownership lifecycle`, () => { // began keeps its accepted row. Reference: the fetched rows, overlaid by the // direct writes made after the fetch started, in order. Checkpoint: visible // rows and the Query cache after the fetch settles. Cuts: the initial load, - // a handler's automatic refetch while another handler writes a different + // a handler's explicit refetch while another handler writes a different // key or the same key, and a user refetch. The review probes are these cuts. type MergeCut = | `initial-load` @@ -4961,11 +5515,11 @@ describe(`query collection ownership lifecycle`, () => { onUpdate: async ({ transaction, collection: handlerCollection }) => { const modified = transaction.mutations[0].modified if (modified.value === `A`) { - // Handler A: the server accepts the change, and the automatic - // refetch confirms it. + // Handler A explicitly reads back the accepted server change. server = server.map((row) => row.id === `1` ? { id: `1`, value: `A` } : row, ) + await handlerCollection.utils.refetch() return } server = server.map((row) => @@ -4975,7 +5529,6 @@ describe(`query collection ownership lifecycle`, () => { id: writtenKey, value: `B`, }) - return { refetch: false } }, }), ) @@ -5038,11 +5591,13 @@ describe(`query collection ownership lifecycle`, () => { await vi.waitFor(() => expect(held).toHaveLength(1)) hold = false const fetched = server - await collection.update(writtenKey, (draft) => { - draft.value = `B-optimistic` - }).isPersisted.promise + await collection + .update(writtenKey, (draft) => { + draft.value = `B-optimistic` + }) + .when(`settled`) held[0]!.resolve() - await first.isPersisted.promise + await first.when('settled') expected = mergeDirectWritesOverFetch(fetched, [ { id: writtenKey, value: `B` }, ]) diff --git a/packages/query-db-collection/tests/ownership-lifecycle.test.ts b/packages/query-db-collection/tests/ownership-lifecycle.test.ts index 24345cdcf1..6bf80c09b3 100644 --- a/packages/query-db-collection/tests/ownership-lifecycle.test.ts +++ b/packages/query-db-collection/tests/ownership-lifecycle.test.ts @@ -122,36 +122,26 @@ describe(`query collection ownership lifecycle`, () => { }) /** - * Error recovery law: a previous Query failure remains public while an - * explicit retry is pending. A failed retry is another consecutive failure; - * a successful retry clears the error. This follows the documented - * `lastError`/`errorCount` reset-on-success contract. The model represents - * only these three public error fields, not Collection status or row changes. + * These fixed error-recovery scenarios observe the exact cause in Collection + * health through `reportedError`. The glossary's `loading error` and + * `failure reason` contracts distinguish the phases. A failed initial load + * is final for its sync run, even if a later refetch succeeds. After readiness, a + * pending retry keeps the prior failure reason; a failed retry reports its + * cause, and a successfully applied result clears the failure reason. * * The bounded history crosses a failed initial load, a failed background * refetch, or a failed Collection application with a held retry. One retry * holds Collection application after Query fetch success. A background * failure crosses an intermediate Query retry attempt before a held final - * attempt. A repeated error at clock time zero distinguishes consecutive - * failures from duplicate observer notifications. The real QueryClient and - * `clearError()` are the driver. - * The refinement check reads the public utils - * when the retry starts but before its result settles, then after settlement. + * attempt. Reusing an Error value at clock time zero checks cause identity + * independently of Query's timestamp. The driver uses a real QueryClient and + * `collection.utils.refetch()`. Checks run while the retry is held and after + * settlement. The persisted-baseline scan is a controlled application hold. * - * ORC-001: `query.ts` getter comments and the error-handling guide promise - * reset on success. ORC-002/003/005: the three-state public model below is - * separate from Query's timestamp and is checked at both named boundaries. - * ORC-004/007: this is a bounded fixed matrix, not a generated property. - * ORC-006: the original `clearError()` fails both initial pending cases; - * an observer-success reset fails the application-error and held-application - * cases; an unconditional error-result classifier recounts the old error - * during a Query retry attempt; a zero timestamp sentinel fails the - * epoch-zero repeated-error case. - * ORC-008: - * no model state is combined or removed. ORC-009: `retry pending` maps to - * a held Query result; no new production concept is named. ORC-010: cleanup - * settles the held result and retains primary assertion failures. ORC-011: - * no plausible shared semantic helper needs a second formulation here. + * An observer-success reset would hide an application error too early; an + * older applied success must not clear a newer Query failure. These ordinary + * scenarios supplement the ownership oracle; they do not add an error-count + * contract or prove native SQLite persistence. * The matrix does not cover concurrent retries for multiple tracked Queries, * mutation-handler fetch-boundary settlement, or deferred result application. */ @@ -384,24 +374,24 @@ describe(`query collection ownership lifecycle`, () => { }) /** - * A completed failure remains visible until a later successful result - * applies. The model counts terminal failures, including reuse of the same - * Error object in one clock tick. The persisted scan holds the older success - * between fetch completion and application; the public checkpoint is after + * A newer Query failure remains visible when an older successful result + * applies, including reuse of the same Error object in one clock tick. + * The persisted scan holds the older success between fetch completion and + * application; the public checkpoint is after * that scan is released. This does not exercise native SQLite persistence. */ - it.each([`clearError`, `reused-error`] as const)( + it.each([`new-error`, `reused-error`] as const)( `does not let an older held revalidation clear a newer Query error after %s`, - async (retryMode) => { + async (errorIdentity) => { const id = `held-revalidation-newer-error` const queryHash = hashKey([id]) const previousError = new Error(`initial fetch failed`) const newerError = - retryMode === `reused-error` + errorIdentity === `reused-error` ? previousError : new Error(`later fetch failed`) const dateNow = - retryMode === `reused-error` + errorIdentity === `reused-error` ? vi.spyOn(Date, `now`).mockReturnValue(1_000) : undefined const firstResult = createDeferred>() @@ -441,14 +431,12 @@ describe(`query collection ownership lifecycle`, () => { ).rejects.toBe(previousError) expect(reportedError(collection)).toBe(previousError) - const firstRetry = ( - retryMode === `clearError` - ? collection.utils.refetch({ throwOnError: true }) - : collection.utils.refetch({ throwOnError: true }) - ).then( - () => ({ outcome: `fulfilled` as const }), - (error: unknown) => ({ outcome: `rejected` as const, error }), - ) + const firstRetry = collection.utils + .refetch({ throwOnError: true }) + .then( + () => ({ outcome: `fulfilled` as const }), + (error: unknown) => ({ outcome: `rejected` as const, error }), + ) await vi.waitFor(() => { expect(queryFn).toHaveBeenCalledTimes(2) expect(scanPersistedRows).toHaveBeenCalledOnce() @@ -674,7 +662,6 @@ describe(`query collection ownership lifecycle`, () => { transaction.mutations.forEach(({ modified }) => writeLate(structuredClone(modified)), ) - return { refetch: false } }, }), ) @@ -692,7 +679,7 @@ describe(`query collection ownership lifecycle`, () => { await handlerEntered.promise await collection.cleanup() releaseHandler.resolve() - await expect(mutation.isPersisted.promise).rejects.toBeInstanceOf( + await expect(mutation.when('settled')).rejects.toBeInstanceOf( SyncNotInitializedError, ) @@ -726,7 +713,7 @@ describe(`query collection ownership lifecycle`, () => { queryFn, getKey: (item) => item.id, startSync: true, - onInsert: async ({ transaction }) => { + onInsert: async ({ transaction, collection }) => { const gate = createDeferred() persistenceGates.push(gate) await gate.promise @@ -735,6 +722,7 @@ describe(`query collection ownership lifecycle`, () => { structuredClone(modified), ), ) + await collection.utils.refetch() }, }), ) @@ -804,7 +792,7 @@ describe(`query collection ownership lifecycle`, () => { const settled = new Set() for (const transactionIndex of settlementOrder) { persistenceGates[transactionIndex]!.resolve() - await transactions[transactionIndex]!.isPersisted.promise + await transactions[transactionIndex]!.when('settled') settled.add(transactionIndex) const authoritativeIds = [...settled].map((index) => items[index]!.id) @@ -841,7 +829,7 @@ describe(`query collection ownership lifecycle`, () => { ) persistenceGates[2]!.resolve() - await third.isPersisted.promise + await third.when('settled') expect(queryFn).toHaveBeenCalledTimes(4) expectMutationLifecycleSnapshot( @@ -948,7 +936,6 @@ describe(`query collection ownership lifecycle`, () => { ) handlerEntered.resolve() await releaseHandler.promise - return { refetch: false } }, }), ) @@ -1018,7 +1005,7 @@ describe(`query collection ownership lifecycle`, () => { ) releaseHandler.resolve() - await mutation.isPersisted.promise + await mutation.when('settled') await waiter await vi.waitFor(() => expect(itemIds(derived.toArray)).toEqual([initial.id, inserted.id]), @@ -1069,7 +1056,6 @@ describe(`query collection ownership lifecycle`, () => { const onUpdate = vi.fn(async () => { handlerEntered.resolve() await releaseHandler.promise - return { refetch: false } }) const collection = createCollection( queryCollectionOptions({ @@ -1113,7 +1099,7 @@ describe(`query collection ownership lifecycle`, () => { ]) releaseHandler.resolve() - await mutation.isPersisted.promise + await mutation.when('settled') await collection._sync.loadSubset({}) expect(queryFn).toHaveBeenCalledOnce() @@ -1319,7 +1305,6 @@ describe(`query collection ownership lifecycle`, () => { const onUpdate = vi.fn(async () => { handlerEntered.resolve() await releaseHandler.promise - return { refetch: false } }) const collection = createCollection( queryCollectionOptions({ @@ -1360,7 +1345,7 @@ describe(`query collection ownership lifecycle`, () => { ]) releaseHandler.resolve() - await mutation.isPersisted.promise + await mutation.when('settled') await vi.waitFor(() => expect(unloaded).toBe(true)) await vi.waitFor(() => { expect({ @@ -1401,7 +1386,7 @@ describe(`query collection ownership lifecycle`, () => { mutation.mutate(() => collection.insert(blocker)) cleanups.push(async () => { persistence.resolve() - await mutation.isPersisted.promise.catch(() => undefined) + await mutation.when('settled').catch(() => undefined) }) const pendingBefore = collection._state.pendingSyncedTransactions.length @@ -1422,7 +1407,7 @@ describe(`query collection ownership lifecycle`, () => { ) persistence.resolve() - await mutation.isPersisted.promise + await mutation.when('settled') await reacquired await vi.waitFor(() => expect(rows(collection)).toEqual([shared.id])) @@ -1502,7 +1487,7 @@ describe(`query collection ownership lifecycle`, () => { }) cleanups.push(async () => { persistence.resolve() - await mutation.isPersisted.promise.catch(() => undefined) + await mutation.when('settled').catch(() => undefined) }) const settleTurn = async () => { for (let turn = 0; turn < 3; turn++) @@ -1533,7 +1518,7 @@ describe(`query collection ownership lifecycle`, () => { void claimed.catch(() => undefined) await settleTurn() persistence.resolve() - await mutation.isPersisted.promise + await mutation.when('settled') await settleTurn() await claimed @@ -1596,7 +1581,7 @@ describe(`query collection ownership lifecycle`, () => { }) cleanups.push(async () => { persistence.resolve() - await mutation.isPersisted.promise.catch(() => undefined) + await mutation.when('settled').catch(() => undefined) }) const settleTurn = async () => { for (let turn = 0; turn < 3; turn++) @@ -1626,7 +1611,7 @@ describe(`query collection ownership lifecycle`, () => { void claimed.catch(() => undefined) await settleTurn() persistence.resolve() - await mutation.isPersisted.promise + await mutation.when('settled') await settleTurn() await claimed @@ -1741,7 +1726,7 @@ describe(`query collection ownership lifecycle`, () => { }) cleanups.push(async () => { persistence.resolve() - await mutation.isPersisted.promise.catch(() => undefined) + await mutation.when('settled').catch(() => undefined) }) const settleTurn = async () => { for (let turn = 0; turn < 3; turn++) @@ -1773,7 +1758,7 @@ describe(`query collection ownership lifecycle`, () => { void claimed.catch(() => undefined) await settleTurn() persistence.resolve() - await mutation.isPersisted.promise + await mutation.when('settled') await settleTurn() await claimed @@ -1843,7 +1828,7 @@ describe(`query collection ownership lifecycle`, () => { }) cleanups.push(async () => { persistence.resolve() - await mutation.isPersisted.promise.catch(() => undefined) + await mutation.when('settled').catch(() => undefined) }) const settleTurn = async () => { for (let turn = 0; turn < 3; turn++) @@ -1874,7 +1859,7 @@ describe(`query collection ownership lifecycle`, () => { void claimed.catch(() => undefined) await settleTurn() persistence.resolve() - await mutation.isPersisted.promise + await mutation.when('settled') await settleTurn() await claimed @@ -1960,7 +1945,7 @@ describe(`query collection ownership lifecycle`, () => { }) cleanups.push(async () => { persistence.resolve() - await mutation.isPersisted.promise.catch(() => undefined) + await mutation.when('settled').catch(() => undefined) }) const settleTurn = async () => { for (let turn = 0; turn < 3; turn++) @@ -1975,7 +1960,7 @@ describe(`query collection ownership lifecycle`, () => { void claimed.catch(() => undefined) await settleTurn() persistence.resolve() - await mutation.isPersisted.promise + await mutation.when('settled') await settleTurn() await Promise.all([held, claimed]) @@ -2032,7 +2017,7 @@ describe(`query collection ownership lifecycle`, () => { }) cleanups.push(async () => { persistence.resolve() - await mutation.isPersisted.promise.catch(() => undefined) + await mutation.when('settled').catch(() => undefined) await server.client.cleanup() server.queryClient.clear() }) @@ -2049,7 +2034,7 @@ describe(`query collection ownership lifecycle`, () => { void claimed.catch(() => undefined) await settleTurn() persistence.resolve() - await mutation.isPersisted.promise + await mutation.when('settled') await settleTurn() await Promise.all([held, claimed]) @@ -2304,7 +2289,7 @@ describe(`query collection ownership lifecycle`, () => { }) cleanups.push(async () => { persistence.resolve() - await mutation.isPersisted.promise.catch(() => undefined) + await mutation.when('settled').catch(() => undefined) storage.release() try { await collection.cleanup() @@ -2332,7 +2317,7 @@ describe(`query collection ownership lifecycle`, () => { void claimed.catch(() => undefined) await settleTurn() persistence.resolve() - await mutation.isPersisted.promise + await mutation.when('settled') await settleTurn() await claimed @@ -2576,7 +2561,7 @@ describe(`query collection ownership lifecycle`, () => { }) cleanups.push(async () => { persistence.resolve() - await mutation.isPersisted.promise.catch(() => undefined) + await mutation.when('settled').catch(() => undefined) await client.cleanup() queryClient.clear() }) @@ -2611,7 +2596,7 @@ describe(`query collection ownership lifecycle`, () => { void claimed.catch(() => undefined) await settleTurn() persistence.resolve() - await mutation.isPersisted.promise + await mutation.when('settled') await settleTurn() await claimed @@ -2671,7 +2656,7 @@ describe(`query collection ownership lifecycle`, () => { collection.deferDataRefresh = null barrier.resolve() persistence.resolve() - await mutation.isPersisted.promise.catch(() => undefined) + await mutation.when('settled').catch(() => undefined) }) await collection._sync.loadSubset(detail) @@ -2696,7 +2681,7 @@ describe(`query collection ownership lifecycle`, () => { await settleTurn() persistence.resolve() - await mutation.isPersisted.promise + await mutation.when('settled') collection.deferDataRefresh = null barrier.resolve() await reacquired.catch(() => undefined) @@ -2971,7 +2956,7 @@ describe(`query collection ownership lifecycle`, () => { mutation.mutate(() => collection.insert(blocker)) cleanups.push(async () => { persistence.resolve() - await mutation.isPersisted.promise.catch(() => undefined) + await mutation.when('settled').catch(() => undefined) }) expect(mutation.state).toBe(`persisting`) @@ -2985,7 +2970,7 @@ describe(`query collection ownership lifecycle`, () => { ) persistence.resolve() - await mutation.isPersisted.promise + await mutation.when('settled') await refetch await vi.waitFor(() => expect(rows(collection)).toEqual([shared.id])) @@ -3057,7 +3042,7 @@ describe(`query collection ownership lifecycle`, () => { cleanups.push(async () => { released.resolve() if (holder === `mutation`) - await mutation.isPersisted.promise.catch(() => undefined) + await mutation.when('settled').catch(() => undefined) try { await collection.cleanup() } finally { @@ -3104,7 +3089,7 @@ describe(`query collection ownership lifecycle`, () => { released.resolve() await Promise.all([first, second]) if (holder === `mutation`) - await mutation.isPersisted.promise.catch(() => undefined) + await mutation.when('settled').catch(() => undefined) await vi.waitFor(() => expect(observe()).toEqual({ visible: [`b`], stored: [`b`] }), ) @@ -3640,13 +3625,13 @@ describe(`query collection ownership lifecycle`, () => { queryFn, getKey: (item) => item.id, startSync: true, - onInsert: ({ transaction }) => { + onInsert: async ({ transaction, collection }) => { serverRows.push( ...transaction.mutations.map(({ modified }) => structuredClone(modified), ), ) - return Promise.resolve() + await collection.utils.refetch() }, }), ) @@ -3657,7 +3642,7 @@ describe(`query collection ownership lifecycle`, () => { await collection.stateWhenReady() const mutation = collection.insert(inserted) - await mutation.isPersisted.promise + await mutation.when('settled') expect({ cache: itemIds(queryClient.getQueryData>(queryKey) ?? []), synced: itemIds(collection._state.syncedData.values()), @@ -3701,7 +3686,7 @@ describe(`query collection ownership lifecycle`, () => { queryFn, getKey: (item) => item.id, startSync: true, - onDelete: async ({ transaction }) => { + onDelete: async ({ transaction, collection }) => { await persistenceGate.promise const deleted = new Set( transaction.mutations.map(({ original }) => original.id), @@ -3711,6 +3696,7 @@ describe(`query collection ownership lifecycle`, () => { serverRows.length, ...serverRows.filter(({ id: rowId }) => !deleted.has(rowId)), ) + await collection.utils.refetch() }, }) const originalSync = baseOptions.sync @@ -3804,7 +3790,7 @@ describe(`query collection ownership lifecycle`, () => { persistenceGate.resolve() await vi.waitFor(() => expect(queryFn).toHaveBeenCalledTimes(3)) mutationRefetchResult.resolve([]) - await mutation.isPersisted.promise + await mutation.when('settled') expect(queryFn).toHaveBeenCalledTimes(3) expect(commitRequests.some(({ writes }) => writes.includes(`delete`))).toBe( diff --git a/packages/query-db-collection/tests/query.test-d.ts b/packages/query-db-collection/tests/query.test-d.ts index c43e381a8e..33647437aa 100644 --- a/packages/query-db-collection/tests/query.test-d.ts +++ b/packages/query-db-collection/tests/query.test-d.ts @@ -60,6 +60,14 @@ describe(`Query collection type resolution tests`, () => { }) }) + it(`rejects mutation handler return-value refetch controls`, () => { + type Config = QueryCollectionConfig + const legacyReturn = async () => ({ refetch: false }) + expectTypeOf(legacyReturn).not.toExtend>() + expectTypeOf(legacyReturn).not.toExtend>() + expectTypeOf(legacyReturn).not.toExtend>() + }) + it(`should prioritize explicit type in QueryCollectionConfig`, () => { const options = queryCollectionOptions({ id: `test`, diff --git a/packages/query-db-collection/tests/query.test.ts b/packages/query-db-collection/tests/query.test.ts index d41b90e889..3bfc49436c 100644 --- a/packages/query-db-collection/tests/query.test.ts +++ b/packages/query-db-collection/tests/query.test.ts @@ -11,7 +11,6 @@ import { inArray, include, or, - resetWarnings, } from '@tanstack/db' import { afterAll, @@ -960,7 +959,7 @@ describe(`QueryCollection`, () => { expect(collection.get(`server`)).toBeUndefined() persistence.resolve() - await transaction.isPersisted.promise + await transaction.when(`settled`) expect(collection.status).toBe(`ready`) expect(collection.get(`server`)).toEqual( @@ -968,7 +967,7 @@ describe(`QueryCollection`, () => { ) } finally { persistence.resolve() - await transaction.isPersisted.promise.catch(() => undefined) + await transaction.when(`settled`).catch(() => undefined) await collection.cleanup() } }) @@ -1005,14 +1004,14 @@ describe(`QueryCollection`, () => { collection._sync.unloadSubset({}) persistence.resolve() - await transaction.isPersisted.promise + await transaction.when(`settled`) await flushPromises() expect(collection.has(`first`)).toBe(false) expect(collection.has(`second`)).toBe(false) } finally { persistence.resolve() - await transaction.isPersisted.promise.catch(() => undefined) + await transaction.when(`settled`).catch(() => undefined) await collection.cleanup() } }) @@ -1180,14 +1179,14 @@ describe(`QueryCollection`, () => { await flushPromises() persistence.resolve() - await transaction.isPersisted.promise + await transaction.when(`settled`) await vi.waitFor(() => { expect(collection.get(`server`)).toBeUndefined() }) } finally { persistence.resolve() - await transaction.isPersisted.promise.catch(() => undefined) + await transaction.when(`settled`).catch(() => undefined) await collection.cleanup() } }) @@ -1399,12 +1398,13 @@ describe(`QueryCollection`, () => { .mockImplementation(() => Promise.resolve(productsData)), getKey: (product) => product.id, startSync: true, - onUpdate: async ({ transaction }) => { + onUpdate: async ({ transaction, collection }) => { for (const mutation of transaction.mutations) { productsData = productsData.map((product) => product.id === mutation.key ? mutation.modified : product, ) } + await collection.utils.refetch() }, }), ) @@ -2474,207 +2474,7 @@ describe(`QueryCollection`, () => { } }) - it(`should call refetch based on handler return value`, async () => { - // Create a mock transaction for testing with proper type - const insertTransaction = { - id: `test-transaction-insert`, - mutations: [] as any, - } as TransactionWithMutations - - // Create handlers with different return values - const onInsertDefault = vi.fn().mockResolvedValue(undefined) // Default behavior should refetch - const onInsertFalse = vi.fn().mockResolvedValue({ refetch: false }) // No refetch - const onInsertPrimitive = vi.fn().mockResolvedValue(`legacy-result`) // Legacy values should still refetch - - // Create configs with the handlers - const queryFnDefault = vi - .fn() - .mockResolvedValue([{ id: `1`, name: `Item 1` }]) - const queryFnFalse = vi - .fn() - .mockResolvedValue([{ id: `1`, name: `Item 1` }]) - const queryFnPrimitive = vi - .fn() - .mockResolvedValue([{ id: `1`, name: `Item 1` }]) - - const configDefault: QueryCollectionConfig = { - id: `test-default`, - queryClient, - queryKey: [`refetchTest`, `default`], - queryFn: queryFnDefault, - getKey, - onInsert: onInsertDefault, - startSync: true, - } - - const configFalse: QueryCollectionConfig = { - id: `test-false`, - queryClient, - queryKey: [`refetchTest`, `false`], - queryFn: queryFnFalse, - getKey, - onInsert: onInsertFalse, - startSync: true, - } - - const configPrimitive: QueryCollectionConfig = { - id: `test-primitive`, - queryClient, - queryKey: [`refetchTest`, `primitive`], - queryFn: queryFnPrimitive, - getKey, - onInsert: onInsertPrimitive, - startSync: true, - } - - // Test case 1: Default behavior (undefined return) should trigger refetch - const optionsDefault = queryCollectionOptions(configDefault) - const collectionDefault = createCollection(optionsDefault) - - // Wait for initial sync - await vi.waitFor(() => { - expect(collectionDefault.status).toBe(`ready`) - }) - - // Clear initial call - queryFnDefault.mockClear() - - const insertParamsDefault = { - transaction: insertTransaction, - collection: collectionDefault, - } satisfies InsertMutationFnParams< - TestItem, - string | number, - QueryCollectionUtils - > - - await optionsDefault.onInsert!(insertParamsDefault) - - // Verify handler was called and refetch was triggered (queryFn called again) - expect(onInsertDefault).toHaveBeenCalledOnce() - expect(onInsertDefault.mock.calls[0]![0].transaction).toBe( - insertTransaction, - ) - await vi.waitFor(() => { - expect(queryFnDefault).toHaveBeenCalledTimes(1) - }) - - // Test case 2: Explicit { refetch: false } should not trigger refetch - const optionsFalse = queryCollectionOptions(configFalse) - const collectionFalse = createCollection(optionsFalse) - - // Wait for initial sync - await vi.waitFor(() => { - expect(collectionFalse.status).toBe(`ready`) - }) - - // Clear initial call - queryFnFalse.mockClear() - - const insertParamsFalse = { - transaction: insertTransaction, - collection: collectionFalse, - } satisfies InsertMutationFnParams< - TestItem, - string | number, - QueryCollectionUtils - > - - await optionsFalse.onInsert!(insertParamsFalse) - - // Verify handler was called but refetch was NOT triggered (queryFn not called) - expect(onInsertFalse).toHaveBeenCalledOnce() - expect(onInsertFalse.mock.calls[0]![0].transaction).toBe( - insertTransaction, - ) - // Wait a bit to ensure no refetch happens - await new Promise((resolve) => setTimeout(resolve, 50)) - expect(queryFnFalse).not.toHaveBeenCalled() - - // Test case 3: Legacy primitive returns should retain auto-refetch behavior - const optionsPrimitive = queryCollectionOptions(configPrimitive) - const collectionPrimitive = createCollection(optionsPrimitive) - - await vi.waitFor(() => { - expect(collectionPrimitive.status).toBe(`ready`) - }) - - queryFnPrimitive.mockClear() - - const insertParamsPrimitive = { - transaction: insertTransaction, - collection: collectionPrimitive, - } satisfies InsertMutationFnParams< - TestItem, - string | number, - QueryCollectionUtils - > - - await optionsPrimitive.onInsert!(insertParamsPrimitive) - - expect(onInsertPrimitive).toHaveBeenCalledOnce() - expect(onInsertPrimitive.mock.calls[0]![0].transaction).toBe( - insertTransaction, - ) - await vi.waitFor(() => { - expect(queryFnPrimitive).toHaveBeenCalledTimes(1) - }) - - await Promise.all([ - collectionDefault.cleanup(), - collectionFalse.cleanup(), - collectionPrimitive.cleanup(), - ]) - }) - - it(`does not reject a deprecated auto-refetch when result application fails`, async () => { - resetWarnings() - const warning = vi.spyOn(console, `warn`).mockImplementation(() => {}) - const consoleError = vi - .spyOn(console, `error`) - .mockImplementation(() => {}) - const queryFn = vi - .fn<() => Promise>>() - .mockResolvedValueOnce([{ id: `1`, name: `Initial` }]) - .mockResolvedValueOnce(42 as unknown as Array) - const onInsert = vi.fn().mockResolvedValue(undefined) - const options = queryCollectionOptions({ - id: `invalid-deprecated-auto-refetch`, - queryClient, - queryKey: [`invalid-deprecated-auto-refetch`], - queryFn, - getKey, - onInsert, - startSync: true, - }) - const collection = createCollection(options) - const transaction = { - id: `invalid-deprecated-auto-refetch-transaction`, - mutations: [], - } as unknown as TransactionWithMutations - - try { - await collection.stateWhenReady() - - await expect( - options.onInsert!({ transaction, collection }), - ).resolves.toBeUndefined() - expect(queryFn).toHaveBeenCalledTimes(2) - await vi.waitFor(() => { - expect(consoleError).toHaveBeenCalledWith( - expect.stringContaining(`[QueryCollection] Error applying query`), - expect.objectContaining({ name: `InvalidQueryResultError` }), - ) - }) - } finally { - warning.mockRestore() - consoleError.mockRestore() - await collection.cleanup() - } - }) - - it(`supports a warning-free single-refetch migration path`, async () => { - resetWarnings() + it(`fetches exactly once when a handler explicitly refetches`, async () => { const warning = vi.spyOn(console, `warn`).mockImplementation(() => {}) const queryFn = vi.fn().mockResolvedValue([{ id: `1`, name: `Item 1` }]) const options = queryCollectionOptions({ @@ -2686,7 +2486,6 @@ describe(`QueryCollection`, () => { startSync: true, onInsert: async ({ collection }) => { await collection.utils.refetch() - return { refetch: false } }, }) const collection = createCollection(options) @@ -2698,13 +2497,12 @@ describe(`QueryCollection`, () => { queryFn.mockClear() const mutation = collection.insert({ id: `2`, name: `Item 2` }) - await mutation.isPersisted.promise + await mutation.when(`settled`) expect(queryFn).toHaveBeenCalledTimes(1) expect(warning).not.toHaveBeenCalled() } finally { await collection.cleanup() - resetWarnings() warning.mockRestore() } }) @@ -4660,8 +4458,6 @@ describe(`QueryCollection`, () => { todosCollection.utils.writeInsert(serverItem) }) }) - - return { refetch: false } }, }), ) @@ -4749,8 +4545,6 @@ describe(`QueryCollection`, () => { } }) rowWhileHandlerRuns = stripVirtualProps(collection.get(`123`)) - - return { refetch: false } }, }), ) @@ -4763,21 +4557,21 @@ describe(`QueryCollection`, () => { const tx = collection.update(`123`, (draft) => { draft.brandName = `B` }) - await tx.isPersisted.promise + await tx.when(`settled`) // The queued server row is hidden behind the optimistic row until the // mutation settles, then both change in one publication. expect(rowWhileHandlerRuns).toEqual({ id: `123`, brandName: `B` }) expect(collection.get(`123`)).toMatchObject({ brandName: `B (server)`, - $synced: true, + $hasPendingWrites: false, }) expect(queryClient.getQueryData(queryKey)).toEqual([ { id: `123`, brandName: `B (server)` }, ]) }) - it(`should not rollback object field updates after server response with refetch: false`, async () => { + it(`should not rollback object field updates after a direct server-response write`, async () => { const queryKey = [`object-field-update-test`] type Todo = { @@ -4827,8 +4621,6 @@ describe(`QueryCollection`, () => { } }) }) - - return { refetch: false } }, }), ) @@ -4899,7 +4691,6 @@ describe(`QueryCollection`, () => { server = { ...request, a: 10, revision: server.revision + 1 } await handlerCollection.utils.writeUpdate({ ...server }) expect(adapter.rows.get(`p`)?.revision).toBe(server.revision) - return { refetch: false } }, }), persistence: { adapter }, @@ -4911,25 +4702,25 @@ describe(`QueryCollection`, () => { const first = collection.update(`p`, (draft) => { draft.a = 1 }) - await first.isPersisted.promise + await first.when(`settled`) expect(adapter.rows.get(`p`)?.revision).toBe(2) expect(collection.get(`p`)).toMatchObject({ a: 10, revision: 2, - $synced: true, + $hasPendingWrites: false, }) const second = collection.update(`p`, (draft) => { draft.b = 1 }) - await second.isPersisted.promise + await second.when(`settled`) expect(requestRevisions).toEqual([1, 2]) expect(adapter.rows.get(`p`)?.revision).toBe(3) expect(collection.get(`p`)).toMatchObject({ a: 10, b: 1, revision: 3, - $synced: true, + $hasPendingWrites: false, }) } finally { await collection.cleanup() @@ -4985,7 +4776,6 @@ describe(`QueryCollection`, () => { onUpdate: async ({ collection: handlerCollection }) => { server = { id: `row`, value: 3 } await handlerCollection.utils.writeUpsert({ ...server }) - return { refetch: false } }, }), persistence: { adapter }, @@ -5006,9 +4796,11 @@ describe(`QueryCollection`, () => { server = { id: `row`, value: 3 } second = collection.utils.refetch() } else { - second = collection.update(`row`, (draft) => { - draft.value = 0 - }).isPersisted.promise + second = collection + .update(`row`, (draft) => { + draft.value = 0 + }) + .when(`settled`) } release.resolve() await Promise.all([first, second]) @@ -5050,7 +4842,6 @@ describe(`QueryCollection`, () => { onUpdate: async ({ collection: handlerCollection }) => { server = { id: `row`, value: 3 } await handlerCollection.utils.writeUpsert({ ...server }) - return { refetch: false } }, }), ) @@ -5062,9 +4853,11 @@ describe(`QueryCollection`, () => { const refetch = collection.utils.refetch() await vi.waitFor(() => expect(fetches).toHaveLength(1)) - await collection.update(`row`, (draft) => { - draft.value = 0 - }).isPersisted.promise + await collection + .update(`row`, (draft) => { + draft.value = 0 + }) + .when(`settled`) expect(collection.get(`row`)?.value).toBe(3) fetches[0]!.resolve([]) @@ -6251,7 +6044,7 @@ describe(`QueryCollection`, () => { const deletedItem = transaction.mutations[0]?.original // Call writeDelete inside onDelete handler - this should work without throwing collection.utils.writeDelete(deletedItem.id) - return Promise.resolve({ refetch: false }) + return Promise.resolve() }) const config: QueryCollectionConfig = { @@ -6273,7 +6066,7 @@ describe(`QueryCollection`, () => { }) const transaction = collection.delete(`1`) - await transaction.isPersisted.promise + await transaction.when(`settled`) // Verify the fix: writeDelete should work, transaction completes, item is deleted expect(transaction.state).toBe(`completed`) @@ -7454,7 +7247,7 @@ describe(`QueryCollection`, () => { syncMode: `on-demand`, // Keep the insert persisting while query2 observes it; its // optimistic row drops when the handler returns. - onInsert: () => insertPersisted.then(() => ({ refetch: false })), + onInsert: () => insertPersisted, } const options = queryCollectionOptions(config) @@ -7523,7 +7316,7 @@ describe(`QueryCollection`, () => { { id: `4`, name: `Item 4` }, ]) persistInsert() - await inserted.isPersisted.promise + await inserted.when(`settled`) expect(queryFn).toHaveBeenCalledTimes(1) queryFn.mockResolvedValue([ { id: `1`, name: `Provider changed`, category: `A` }, @@ -11430,14 +11223,12 @@ describe(`QueryCollection`, () => { const { id: _id, ...rest } = transaction.mutations[0].modified const serverItem = await apiCreate(rest) col.utils.writeInsert(serverItem) - return { refetch: false } }, onDelete: async ({ transaction, collection: col }) => { const id = transaction.mutations[0].key as number await apiDelete(id) col.utils.writeDelete(id) - return { refetch: false } }, }), ) @@ -11559,7 +11350,7 @@ describe(`QueryCollection`, () => { hours_per_day: 8, }) outcomes.push( - firstInsert.isPersisted.promise.then( + firstInsert.when(`settled`).then( () => { settled.push(`first insert`) return `fulfilled` @@ -11583,7 +11374,7 @@ describe(`QueryCollection`, () => { expect(settled).toEqual([]) expect(firstLoad.outcome.status).toBe(`pending`) creates[0]!.release.resolve() - await firstInsert.isPersisted.promise + await firstInsert.when(`settled`) await firstLoad.promise await flushPromises() world.set(100, { @@ -11600,7 +11391,7 @@ describe(`QueryCollection`, () => { // Step 2: Delete const firstDelete = collection.delete(carolId) outcomes.push( - firstDelete.isPersisted.promise.then( + firstDelete.when(`settled`).then( () => { settled.push(`delete`) return `fulfilled` @@ -11624,7 +11415,7 @@ describe(`QueryCollection`, () => { expect(settled).toEqual([`first insert`]) expect(deleteLoad.outcome.status).toBe(`pending`) deletion.release.resolve() - await firstDelete.isPersisted.promise + await firstDelete.when(`settled`) await deleteLoad.promise await flushPromises() world.delete(100) @@ -11655,7 +11446,7 @@ describe(`QueryCollection`, () => { hours_per_day: 8, }) outcomes.push( - t2.isPersisted.promise.then( + t2.when(`settled`).then( () => { settled.push(`second insert`) return `fulfilled` @@ -11680,7 +11471,7 @@ describe(`QueryCollection`, () => { expect(settled).toEqual([`first insert`, `delete`]) expect(secondLoad.outcome.status).toBe(`pending`) creates[1]!.release.resolve() - await t2.isPersisted.promise + await t2.when(`settled`) await secondLoad.promise await flushPromises() world.set(101, { @@ -12815,7 +12606,7 @@ describe(`failureReason follows the still-failing Queries`, () => { failing.add(2) await refetch(2) persisting.resolve() - await write.isPersisted.promise + await write.when('settled') await flushPromises() expect(shown(), `query 2 still fails`).toBe(`subset 2 failed`) failing.delete(2) diff --git a/packages/query-db-collection/tests/refetch-ordered-prefix-oracle.test.ts b/packages/query-db-collection/tests/refetch-ordered-prefix-oracle.test.ts index 1796539141..d3694f2971 100644 --- a/packages/query-db-collection/tests/refetch-ordered-prefix-oracle.test.ts +++ b/packages/query-db-collection/tests/refetch-ordered-prefix-oracle.test.ts @@ -576,11 +576,9 @@ async function startRun(testCase: Case): Promise { // backend does not change. onUpdate: async () => { await heldWrite - return { refetch: false } }, onDelete: async () => { await heldWrite - return { refetch: false } }, }), ) @@ -601,7 +599,7 @@ async function startRun(testCase: Case): Promise { : items.delete(rowId) return async () => { release() - await transaction.isPersisted.promise + await transaction.when(`settled`) await settle() } } diff --git a/packages/react-db/skills/react-db/SKILL.md b/packages/react-db/skills/react-db/SKILL.md index 0f04c45cb6..3d0113ff77 100644 --- a/packages/react-db/skills/react-db/SKILL.md +++ b/packages/react-db/skills/react-db/SKILL.md @@ -250,8 +250,8 @@ See db-core/live-queries/SKILL.md for full includes rules (correlation condition Live query results include computed, read-only virtual properties on every row: -- `$synced`: `true` when no pending local optimistic write affects the row; - `false` while one does. It does not prove backend confirmation. +- `$hasPendingWrites`: `true` while a pending local optimistic write affects + the row; `false` otherwise. It does not prove backend confirmation. - `$origin`: `"local"` if the last confirmed change came from this client, otherwise `"remote"`. - `$key`: the row key for the result. - `$collectionId`: the source collection ID. @@ -263,7 +263,7 @@ const { data } = useLiveQuery({ query: (q) => q .from({ todo: todoCollection }) - .where(({ todo }) => eq(todo.$synced, false)), + .where(({ todo }) => eq(todo.$hasPendingWrites, true)), }) // Shows rows with pending local optimistic writes ``` diff --git a/packages/react-db/src/useLiveInfiniteQuery.ts b/packages/react-db/src/useLiveInfiniteQuery.ts index 94ada9a9d2..1a1b59ecb6 100644 --- a/packages/react-db/src/useLiveInfiniteQuery.ts +++ b/packages/react-db/src/useLiveInfiniteQuery.ts @@ -6,7 +6,6 @@ import { createLiveQuerySourceIdClaims, fetchNextLiveQueryWindowPage, getLiveQueryKey, - identifyLiveQuery, isCollection, readLiveQueryWindowPageShape, rejectLiveQueryInput, @@ -18,7 +17,6 @@ import { getIdentityScope, prepareDerivedQuery, prepareQueryValue, - warnDeprecatedDepsArray, } from './useLiveQuery' import type { DerivedIdentityProfiler, @@ -111,7 +109,6 @@ type InfiniteQueryRender = { * * @param queryFn - Query function that defines what data to fetch. Must include `.orderBy()` for setWindow to work. * @param config - Configuration including pageSize and an optional initial page label - * @param deps - Deprecated array of dependencies that trigger query re-execution when changed * @returns Object with pages, data, and pagination controls */ @@ -129,14 +126,12 @@ export function useLiveInfiniteQuery< export function useLiveInfiniteQuery( queryFn: (q: InitialQueryBuilder) => QueryBuilder, config: UseLiveInfiniteQueryConfig, - deps?: Array, ): UseLiveInfiniteQueryReturn // Implementation export function useLiveInfiniteQuery( queryFnOrCollection: any, config: UseLiveInfiniteQueryConfig, - deps?: Array, ): UseLiveInfiniteQueryReturn { const pageShape = readLiveQueryWindowPageShape(config) const contextDbClient = useOptionalDbClient() @@ -169,8 +164,6 @@ export function useLiveInfiniteQuery( ) const queryKey = getLiveQueryKey(config) - const usesLegacyDeps = - !inputIsCollection && queryKey === undefined && deps !== undefined let preparedQueryValue: unknown | typeof unpreparedQueryValue = unpreparedQueryValue let dependencies: ReadonlyArray | undefined @@ -185,9 +178,6 @@ export function useLiveInfiniteQuery( if (!inputIsCollection) { if (queryKey !== undefined) { dependencies = queryKey - } else if (usesLegacyDeps) { - dependencies = deps - warnDeprecatedDepsArray(`useLiveInfiniteQuery`) } else if ( committed !== null && committed.derivedQuery === queryFnOrCollection && @@ -217,11 +207,6 @@ export function useLiveInfiniteQuery( dbClient, deferredCollections, ) - if (usesLegacyDeps) { - // A dependency list decides reuse, so check the query when it - // compiles: it needs a stable identity and keeps its sources' IDs. - identifyLiveQuery(preparedQueryValue, identityScope()) - } } return preparedQueryValue } @@ -238,7 +223,7 @@ export function useLiveInfiniteQuery( scope: dbClient, client: dbClient, queryKey, - recompile: usesLegacyDeps ? `dependencies` : `identity`, + recompile: `identity`, }, rendered?.binding, ) diff --git a/packages/react-db/src/useLiveQuery.ts b/packages/react-db/src/useLiveQuery.ts index b91d2a32b0..9cb9ad4d5f 100644 --- a/packages/react-db/src/useLiveQuery.ts +++ b/packages/react-db/src/useLiveQuery.ts @@ -7,7 +7,6 @@ import { createLiveQuerySourceIdClaims, deepEquals, getLiveQueryKey, - getPreparedLiveQueryIdentity, getSourceObjectToken, getStableValueHash, identifyLiveQuery, @@ -53,7 +52,6 @@ const DEFAULT_SUSPENSE_GC_TIME_MS = 5000 const DERIVED_IDENTITY_SINGLE_RENDER_WARN_MS = 16 const DERIVED_IDENTITY_RENDER_COUNT_WARN_THRESHOLD = 10 const DERIVED_IDENTITY_TOTAL_WARN_MS = 50 -const warnedDepsCallsites = new Set() const warnedDerivedIdentityCallsites = new Set() const unpreparedQueryValue = Symbol(`unpreparedQueryValue`) @@ -151,30 +149,6 @@ export type ConditionalUseLiveQueryConfig = ) => ConfiguredQueryBuilder | undefined | null) } -export function warnDeprecatedDepsArray( - hookName: `useLiveQuery` | `useLiveInfiniteQuery` = `useLiveQuery`, -): void { - if (!( - shouldWarnInDevelopment(`TANSTACK_DB_DISABLE_DEPRECATION_WARNINGS`) && - process.env.NODE_ENV !== `production` - )) { - return - } - - const callsite = getWarningCallsite(4) - if (warnedDepsCallsites.has(callsite)) { - return - } - warnedDepsCallsites.add(callsite) - const replacement = - hookName === `useLiveQuery` - ? `useLiveQuery({ query })` - : `useLiveInfiniteQuery(query, { queryKey })` - console.warn( - `[${hookName}] The dependency-array form is deprecated and will be removed in 1.0. Use ${replacement} instead. Provide queryKey only for functional/opaque queries or to avoid deriving identity from structured query IR on render.`, - ) -} - function getCurrentTime(): number { return typeof performance !== `undefined` && typeof performance.now === `function` @@ -316,7 +290,6 @@ export function prepareDerivedQuery( /** * Create a live query using a query function. * @param queryFn - Query function that defines what data to fetch - * @param deps - Deprecated array of dependencies that trigger query re-execution when changed * @returns Object with reactive data, state, and status information * @example * // Prefer config object syntax @@ -385,7 +358,6 @@ export function prepareDerivedQuery( // Overload 1: Accept query function that always returns QueryBuilder export function useLiveQuery( queryFn: (q: InitialQueryBuilder) => QueryBuilder, - deps?: Array, ): { state: Map> data: InferResultType @@ -399,7 +371,6 @@ export function useLiveQuery( queryFn: ( q: InitialQueryBuilder, ) => QueryBuilder | undefined | null, - deps?: Array, ): { state: Map> | undefined data: InferResultType | undefined @@ -413,7 +384,6 @@ export function useLiveQuery( queryFn: ( q: InitialQueryBuilder, ) => LiveQueryCollectionConfig | undefined | null, - deps?: Array, ): { state: Map> | undefined data: InferResultType | undefined @@ -431,7 +401,6 @@ export function useLiveQuery< queryFn: ( q: InitialQueryBuilder, ) => Collection | undefined | null, - deps?: Array, ): { state: Map | undefined data: Array | undefined @@ -455,7 +424,6 @@ export function useLiveQuery< | Collection | undefined | null, - deps?: Array, ): { state: Map> | Map | undefined @@ -471,7 +439,6 @@ export function useLiveQuery< /** * Create a live query using configuration object * @param config - Configuration object with query and options - * @param deps - Deprecated array of dependencies that trigger query re-execution when changed * @returns Object with reactive data, state, and status information * @example * // Basic config object usage @@ -525,30 +492,6 @@ export function useLiveQuery( isEnabled: boolean } & LiveQueryStatusFlags -// Overload 8: Accept config object with legacy deps -export function useLiveQuery( - config: LiveQueryCollectionConfig, - deps?: Array, -): { - state: Map> - data: InferResultType - collection: Collection, string | number, {}> - status: CollectionStatus // Can't be disabled when query always returns a builder - isEnabled: true // Always true when query always returns a builder -} & LiveQueryStatusFlags - -// Overload 9: Accept config object with legacy deps and a query that can return undefined/null -export function useLiveQuery( - config: ConditionalUseLiveQueryConfig, - deps: Array, -): { - state: Map> | undefined - data: InferResultType | undefined - collection: Collection, string | number, {}> | undefined - status: UseLiveQueryStatus - isEnabled: boolean -} & LiveQueryStatusFlags - /** * Subscribe to an existing live query collection * @param liveQueryCollection - Pre-created live query collection to subscribe to @@ -609,19 +552,13 @@ export function useLiveQuery< } & LiveQueryStatusFlags // Implementation - use function overloads to infer the actual collection type -export function useLiveQuery( - configOrQueryOrCollection: any, - deps?: Array, -) { - return useLiveQueryImpl(configOrQueryOrCollection, deps) +export function useLiveQuery(configOrQueryOrCollection: any) { + return useLiveQueryImpl(configOrQueryOrCollection) } /** @internal Shared implementation for the Suspense wrapper. */ -export function useLiveQueryForSuspense( - configOrQueryOrCollection: any, - deps: Array | undefined, -) { - return useLiveQueryImpl(configOrQueryOrCollection, deps, true) +export function useLiveQueryForSuspense(configOrQueryOrCollection: any) { + return useLiveQueryImpl(configOrQueryOrCollection, true) } // What one hook instance keeps across renders. It lives in a single ref @@ -629,7 +566,7 @@ export function useLiveQueryForSuspense( function createHookInstance(dbClient: DbClient | undefined) { return { collection: null as Collection | null, - deps: null as Array | null, + identityDeps: null as Array | null, config: null as unknown, client: dbClient, derivedIdentityProfiler: { @@ -651,18 +588,13 @@ function createHookInstance(dbClient: DbClient | undefined) { } } -function useLiveQueryImpl( - configOrQueryOrCollection: any, - deps: Array | undefined, - forSuspense = false, -) { +function useLiveQueryImpl(configOrQueryOrCollection: any, forSuspense = false) { const contextDbClient = useOptionalDbClient() // Check if it's already a collection const inputIsCollection = isCollection(configOrQueryOrCollection) const dbClient = inputIsCollection ? contextDbClient : (getExplicitDbClient(configOrQueryOrCollection) ?? contextDbClient) - const resolvedDeps = deps ?? [] const instanceRef = useRef | null>(null) const instance = (instanceRef.current ??= createHookInstance(dbClient)) @@ -682,34 +614,12 @@ function useLiveQueryImpl( let preparedQueryValue: unknown | typeof unpreparedQueryValue = unpreparedQueryValue let identityDeps: ReadonlyArray - let depsQueryIdentity: unknown = unpreparedQueryValue let streamIdentity: unknown = undefined let identityError: UnhashableQueryIRError | undefined if (queryKey) { identityDeps = queryKey streamIdentity = [`queryKey`, queryKey] - } else if (deps !== undefined) { - identityDeps = resolvedDeps - // Deps decide reuse. Only hydration and Suspense read the query hash - // before compiling; every compile checks the identity below. - if (dbClient || forSuspense) { - preparedQueryValue = prepareQueryValue( - configOrQueryOrCollection, - dbClient, - instance.deferredCollections, - ) - try { - depsQueryIdentity = getPreparedLiveQueryIdentity(preparedQueryValue) - streamIdentity = [`deps`, resolvedDeps, depsQueryIdentity] - } catch (error) { - // The dependency list, not this hash, decides reuse: an unhashable - // query leaves hydration without a key and rejects only when it - // compiles. - if (!(error instanceof UnhashableQueryIRError)) throw error - identityError = error - } - } } else if (inputIsCollection) { identityDeps = [] streamIdentity = [`collection`, configOrQueryOrCollection.id] @@ -739,10 +649,6 @@ function useLiveQueryImpl( } } - if (deps !== undefined) { - warnDeprecatedDepsArray() - } - const canReuseSuspenseKey = forSuspense && !inputIsCollection && @@ -787,11 +693,8 @@ function useLiveQueryImpl( const suspenseCollection = suspenseEntry?.collection const identityChanged = - instance.deps === null || - (deps !== undefined - ? instance.deps.length !== identityDeps.length || - instance.deps.some((dep, index) => dep !== identityDeps[index]) - : !deepEquals(instance.deps, identityDeps)) + instance.identityDeps === null || + !deepEquals(instance.identityDeps, identityDeps) // Check if we need to create/recreate the collection const needsNewCollection = @@ -834,19 +737,7 @@ function useLiveQueryImpl( instance.deferredCollections, ) } - if (queryKey === undefined && deps !== undefined) { - // A dependency list decides reuse, so check the query whenever the - // hook binds a live query, compiled or taken from the Suspense - // cache: it needs a stable identity and keeps its sources' IDs. - identifyLiveQuery( - preparedQueryValue, - getIdentityScope(instance.sourceIds, dbClient, `useLiveQuery`), - // Reuse the identity this render derived for hydration. - depsQueryIdentity === unpreparedQueryValue - ? undefined - : () => depsQueryIdentity, - ) - } + if (suspenseCollection) { instance.collection = suspenseCollection } else { @@ -919,7 +810,7 @@ function useLiveQueryImpl( } } instance.config = configOrQueryOrCollection - instance.deps = [...identityDeps] + instance.identityDeps = [...identityDeps] } instance.client = dbClient instance.queryHash = queryHash diff --git a/packages/react-db/src/useLiveSuspenseQuery.ts b/packages/react-db/src/useLiveSuspenseQuery.ts index 577b5f4f54..fe17a2bdb0 100644 --- a/packages/react-db/src/useLiveSuspenseQuery.ts +++ b/packages/react-db/src/useLiveSuspenseQuery.ts @@ -13,7 +13,6 @@ import type { HealthNode, InferResultType, InitialQueryBuilder, - LiveQueryCollectionConfig, NonSingleResult, QueryBuilder, SingleResult, @@ -62,7 +61,6 @@ function rememberInitialRenderError( /** * Create a live query with React Suspense support * @param queryFn - Query function that defines what data to fetch - * @param deps - Deprecated array of dependencies that trigger query re-execution when changed * @returns Object with reactive data and state - data is guaranteed to be defined * @throws Promise when data is loading (caught by Suspense boundary) * @throws Error when the first load fails or the Collection crashes (caught by @@ -155,7 +153,6 @@ function rememberInitialRenderError( // Overload 1: Accept query function that always returns QueryBuilder export function useLiveSuspenseQuery( queryFn: (q: InitialQueryBuilder) => QueryBuilder, - deps?: Array, ): { state: Map> data: InferResultType @@ -173,17 +170,6 @@ export function useLiveSuspenseQuery( health: HealthNode | undefined } -// Overload 3: Accept legacy config object -export function useLiveSuspenseQuery( - config: LiveQueryCollectionConfig, - deps?: Array, -): { - state: Map> - data: InferResultType - collection: Collection, string | number, {}> - health: HealthNode | undefined -} - // Overload 4: Accept pre-created live query collection export function useLiveSuspenseQuery< TResult extends object, @@ -213,19 +199,13 @@ export function useLiveSuspenseQuery< } // Implementation - uses useLiveQuery internally and adds Suspense logic -export function useLiveSuspenseQuery( - configOrQueryOrCollection: any, - deps?: Array, -) { +export function useLiveSuspenseQuery(configOrQueryOrCollection: any) { const promiseRef = useRef | null>(null) const collectionRef = useRef | null>(null) const hasBeenReadyRef = useRef(false) // Use useLiveQuery to handle collection management and reactivity - const result = - deps === undefined - ? useLiveQueryForSuspense(configOrQueryOrCollection, undefined) - : useLiveQueryForSuspense(configOrQueryOrCollection, deps) + const result = useLiveQueryForSuspense(configOrQueryOrCollection) if (!result.isEnabled) { // Suspense queries cannot be disabled - this matches TanStack Query's useSuspenseQuery behavior diff --git a/packages/react-db/src/usePacedMutations.ts b/packages/react-db/src/usePacedMutations.ts index 9d8ce4fe64..1160a00ef7 100644 --- a/packages/react-db/src/usePacedMutations.ts +++ b/packages/react-db/src/usePacedMutations.ts @@ -36,7 +36,7 @@ import type { PacedMutationsConfig, Transaction } from '@tanstack/db' * * // Optional: await persistence or handle errors * try { - * await tx.isPersisted.promise + * await tx.when('settled') * console.log('Saved!') * } catch (error) { * console.error('Save failed:', error) diff --git a/packages/react-db/tests/bound-schema-options-oracle.test.tsx b/packages/react-db/tests/bound-schema-options-oracle.test.tsx new file mode 100644 index 0000000000..322f90ee3c --- /dev/null +++ b/packages/react-db/tests/bound-schema-options-oracle.test.tsx @@ -0,0 +1,262 @@ +/** + * React's two config hooks receive the shared mutation-schema binding law. + * Native act establishes committed hook data; the common driver also captures + * the Collection immediately when update returns. Schema source passthrough, + * retained mutations and identity replacement use the same independent model. + * Controlled Collections supply no provider, SSR, or concurrent-render claim. + */ +import { act, renderHook } from '@testing-library/react' +import { afterAll, expect, test } from 'vitest' +import { useLiveQuery } from '../src/useLiveQuery' +import { useLiveSuspenseQuery } from '../src/useLiveSuspenseQuery' +import { declareOracle } from '../../db/tests/oracle-declaration' +import { createOracleReach } from '../../db/tests/oracle-reach' +import { + boundSchemaHistories, + boundSchemaModes, + boundSchemaReachStates, + checkBoundSchemaMutation, + checkBoundSchemaRows, + checkBoundSchemaValues, + modelBoundSchemaBinding, + modelBoundSchemaLabel, + modelBoundSchemaMutation, + modelBoundSchemaRows, +} from '../../db/tests/live-query-bound-options-oracle' +import { runBoundSchemaHistory } from '../../db/tests/live-query-bound-options-support' +import type { ExtractContext } from '@tanstack/db' +import type { BoundSchemaNativeHost } from '../../db/tests/live-query-bound-options-support' +import type { BoundSchemaObservation } from '../../db/tests/live-query-bound-options-oracle' + +for (const hook of [`live`, `suspense`] as const) { + const reach = createOracleReach( + `React ${hook} schema binding`, + boundSchemaReachStates, + ) + let ran = false + afterAll(() => { + if (!ran) return + reach.assertReached() + if (process.env.TANSTACK_DB_ORACLE_STATISTICS === `1`) + console.info( + `React ${hook} schema binding reach`, + Object.fromEntries(reach.counts()), + ) + }) + test.each(boundSchemaHistories)( + `React ${hook} binds mutation schemas %j`, + async (history) => { + ran = true + const host: BoundSchemaNativeHost = { + mount: (input) => { + type QueryContext = ExtractContext< + ReturnType[`query`]> + > + const mounted = renderHook( + (props: { revision: number; proposed: boolean }) => + hook === `live` + ? useLiveQuery(input(props)) + : useLiveSuspenseQuery(input(props)), + { initialProps: { revision: 0, proposed: false } }, + ) + return { + current: () => mounted.result.current, + rerender: mounted.rerender, + flush: async (action) => { + await act(() => { + action?.() + }) + }, + unmount: mounted.unmount, + } + }, + } + await runBoundSchemaHistory(history, host, reach) + }, + ) +} + +// These controls send deliberately wrong public observations to the same +// judgment used by every native receiver. An earlier wrong immediate result +// cannot be hidden by correct later host data. Unordered rows may be reordered, +// but occurrences, ordinary fields, keys and handler payloads remain exact. +function observationFromModel( + expected: ReturnType, +): BoundSchemaObservation { + const rows = () => + expected.rows.map(({ key, value }) => ({ key, value: { ...value } })) + return { + immediate: rows(), + collection: rows(), + state: rows(), + data: expected.rows.map(({ value }) => ({ ...value })), + mutations: expected.mutations.map((mutation) => ({ + ...mutation, + original: { ...mutation.original }, + modified: { ...mutation.modified }, + changes: { ...mutation.changes }, + })), + } +} +test.each(boundSchemaHistories)( + `schema judgment rejects wrong binding and mutation observations %j`, + (history) => { + const base = [ + { id: `one`, label: ` untouched first `, rank: 1 }, + { id: `peer`, label: ` untouched peer `, rank: 2 }, + ] + for (const phase of [`initial`, `retained`, `rebound`] as const) { + const expected = modelBoundSchemaMutation(history, phase, base) + const binding = modelBoundSchemaBinding(history, phase) + const faults: Array<{ + label: string + observation: BoundSchemaObservation + }> = [] + for (const schema of boundSchemaModes) { + if (schema === binding.schema) continue + const wrong = modelBoundSchemaMutation( + { ...history, firstSchema: schema, nextSchema: schema }, + phase, + base, + ) + faults.push({ + label: `wrong schema ${schema}`, + observation: observationFromModel(wrong), + }) + } + const immediate = observationFromModel(expected) + immediate.immediate = immediate.immediate.map((row, index) => + index === 0 + ? { ...row, value: { ...row.value, label: `wrong immediate` } } + : row, + ) + faults.push({ + label: `earlier mismatch with correct later state`, + observation: immediate, + }) + for (const field of [`immediate`, `collection`, `state`] as const) { + const undefinedField = observationFromModel(expected) + undefinedField[field] = undefinedField[field].map((row) => ({ + ...row, + value: { ...row.value, unexpected: undefined }, + })) + faults.push({ + label: `extra undefined field in ${field}`, + observation: undefinedField, + }) + } + const undefinedData = observationFromModel(expected) + undefinedData.data = undefinedData.data.map((row) => ({ + ...row, + unexpected: undefined, + })) + faults.push({ + label: `extra undefined field in host data`, + observation: undefinedData, + }) + const undefinedMutation = observationFromModel(expected) + undefinedMutation.mutations = undefinedMutation.mutations.map( + (mutation) => ({ + ...mutation, + changes: { ...mutation.changes, unexpected: undefined }, + }), + ) + faults.push({ + label: `extra undefined field in handler`, + observation: undefinedMutation, + }) + const collection = observationFromModel(expected) + collection.collection = collection.collection.slice(0, 1) + faults.push({ label: `missing Collection peer`, observation: collection }) + const state = observationFromModel(expected) + state.state = [...state.state, state.state[1]!] + faults.push({ label: `duplicate state row`, observation: state }) + const data = observationFromModel(expected) + data.data = data.data.map((row, index) => + index === 1 ? { ...row, extra: `unexpected` } : row, + ) + faults.push({ label: `extra host data field`, observation: data }) + const key = observationFromModel(expected) + key.state = key.state.map((row) => ({ + ...row, + key: `wrong-${String(row.key)}`, + })) + faults.push({ label: `wrong public keys`, observation: key }) + const missing = observationFromModel(expected) + missing.mutations = [] + faults.push({ label: `missing handler mutation`, observation: missing }) + const duplicate = observationFromModel(expected) + duplicate.mutations = [...duplicate.mutations, duplicate.mutations[0]!] + faults.push({ + label: `duplicate handler mutation`, + observation: duplicate, + }) + const original = observationFromModel(expected) + original.mutations = original.mutations.map((mutation) => ({ + ...mutation, + original: { ...mutation.original, label: mutation.modified.label }, + })) + faults.push({ + label: `original replaced with modified`, + observation: original, + }) + if (binding.schema !== `absent`) { + const raw = observationFromModel(expected) + const label = modelBoundSchemaLabel(`absent`, history.input) + raw.mutations = raw.mutations.map((mutation) => ({ + ...mutation, + modified: { ...mutation.modified, label }, + changes: { ...mutation.changes, label }, + })) + faults.push({ + label: `raw handler payload with transformed public rows`, + observation: raw, + }) + } + for (const fault of faults) + expect( + () => + checkBoundSchemaMutation(fault.observation, expected, fault.label), + fault.label, + ).toThrow(fault.label) + const reordered = observationFromModel(expected) + for (const field of [`immediate`, `collection`, `state`] as const) + reordered[field] = [...reordered[field]].reverse() + reordered.data = [...reordered.data].reverse() + checkBoundSchemaMutation(reordered, expected, `allowed unordered rows`) + + const source = modelBoundSchemaRows(base, binding.key) + const transformed = source.map((row) => ({ + ...row, + value: { + ...row.value, + label: modelBoundSchemaLabel(`trim`, history.input), + }, + })) + expect(() => + checkBoundSchemaRows(transformed, source, `sync rows stay raw`), + ).toThrow(`sync rows stay raw`) + expect(() => + checkBoundSchemaValues( + transformed.map(({ value }) => value), + source.map(({ value }) => value), + `sync host data stays raw`, + ), + ).toThrow(`sync host data stays raw`) + checkBoundSchemaRows([...source].reverse(), source, `raw unordered rows`) + checkBoundSchemaValues( + source.map(({ value }) => value).reverse(), + source.map(({ value }) => value), + `raw unordered host data`, + ) + } + }, +) +declareOracle({ + grammar: [boundSchemaHistories], + check: [ + checkBoundSchemaMutation, + checkBoundSchemaRows, + checkBoundSchemaValues, + ], +}) diff --git a/packages/react-db/tests/cache-first-registration-oracle.test.tsx b/packages/react-db/tests/cache-first-registration-oracle.test.tsx new file mode 100644 index 0000000000..2aca20f25e --- /dev/null +++ b/packages/react-db/tests/cache-first-registration-oracle.test.tsx @@ -0,0 +1,349 @@ +/** + * Local option claims must begin when an actual server reader first establishes + * a reusable result, without explicit query preload or transported hydration. + * Authority: the shared cache-declaration law and SSR guide's rendered-view law. + * An ordinary server hook captures its loading/ready view. A streaming Suspense + * hook registers its preload while its source is still held. Independent callers + * owe the same admission rule in both cases; readiness cannot defer rejection. + * + * Histories cross the shared independent declarations, first ordinary/Suspense + * reader, held/ready source delivery and second preload/server-render admission. + * After two attempts, original reuse and a public observer receive complete + * rows and a later source update. React supplies the server host; the core observer supplies the later read; + * source delivery and streaming enablement are controlled. No Router transport, + * hydration serialization, deployed server, or other framework is claimed. + */ +import { Writable } from 'node:stream' +import { + DbClient, + Query, + collectionOptions, + createLiveQueryObserver, + getLiveQueryHash, + resolveLiveQueryValue, +} from '@tanstack/db' +import { Suspense } from 'react' +import { renderToPipeableStream } from 'react-dom/server' +import { expect, test, vi } from 'vitest' +import { DbProvider } from '../src/DbProvider' +import { useLiveQuery } from '../src/useLiveQuery' +import { useLiveSuspenseQuery } from '../src/useLiveSuspenseQuery' +import { + cacheOptionDeclarations, + cacheOptionHistories, + checkCacheOptionAdmission, + checkCacheOptionPromise, + checkCacheOptionRows, + conflictingCacheOptions, +} from '../../db/tests/live-query-cache-options-oracle' +import { createOracleReach } from '../../db/tests/oracle-reach' +import { declareOracle } from '../../db/tests/oracle-declaration' +import { withOracleCleanup } from '../../db/tests/utils' +import type { CacheRow } from '../../db/tests/live-query-cache-options-oracle' + +const firstReaders = [`ordinary`, `suspense`] as const +const secondReaders = [`preload`, `server-render`] as const + +test.each( + cacheOptionHistories.flatMap((history) => + firstReaders.flatMap((firstReader) => + secondReaders.flatMap((secondReader) => + [false, true].map((held) => ({ + history, + firstReader, + secondReader, + held, + })), + ), + ), + ), +)( + `first React registration without transported result: %j`, + async ({ history, firstReader, secondReader, held }) => { + const reach = createOracleReach( + `React first registration ${firstReader}/${secondReader}`, + [ + `first-registration`, + `admission`, + `retry`, + `original-reuse`, + `live-observer`, + `source-update`, + ...(held ? [`held-delivery`] : []), + ], + ) + reach.beginHistory() + const client = new DbClient({ runtime: `server` }) + const rows = [ + { id: `one`, label: `before` }, + { id: `two`, label: `peer` }, + ] + let starts = 0 + let delivered = false + let publish: (rows: Array) => void = () => { + throw new Error(`source not started`) + } + let deliver = () => {} + const source = client.collection( + collectionOptions(`react-first-registration`, () => ({ + getKey: (row: CacheRow) => row.id, + sync: { + sync: ({ begin, write, commit, markReady }) => { + starts++ + publish = (values) => { + begin() + for (const value of values) + write({ + type: source.has(value.id) ? `update` : `insert`, + value: { ...value }, + }) + commit() + } + deliver = () => { + if (delivered) return + delivered = true + publish(rows) + markReady() + } + if (!held) deliver() + }, + }, + })), + ) + const query = new Query().from({ row: source }) + const declarations = cacheOptionDeclarations(history) + const configs = declarations.map((given) => { + const config = { query, getKey: given.getKey, queryKey: given.queryKey } + Object.assign(config, { schema: given.schema }) + return config + }) + const first = configs[0]! + const second = configs[1]! + const pending: Array> = [] + const track = (promise: Promise) => { + pending.push(promise) + void promise.catch(() => {}) + return promise + } + const cleanupAbort = new Error(`oracle stream cleanup`) + const renders: Array<{ + abort: (reason: unknown) => void + shell: Promise + done: Promise + errors: Array + errorAccounted: boolean + }> = [] + const beginServerRender = (node: React.ReactNode) => { + const errors: Array = [] + let shellReady!: () => void + let shellFailed!: (error: unknown) => void + const shell = new Promise((resolve, reject) => { + shellReady = resolve + shellFailed = reject + }) + let finished!: () => void + let failed!: (error: unknown) => void + const done = new Promise((resolve, reject) => { + finished = resolve + failed = reject + }) + void shell.catch(() => {}) + void done.catch(() => {}) + const output = new Writable({ + write(_chunk, _encoding, callback) { + callback() + }, + }) + output.on(`finish`, () => + errors.length ? failed(errors[0]) : finished(), + ) + const stream = renderToPipeableStream(node, { + onShellReady: shellReady, + onShellError: (error) => { + shellFailed(error) + failed(error) + }, + onError: (error) => { + errors.push(error) + }, + }) + stream.pipe(output) + const rendering = { + abort: (reason: unknown) => stream.abort(reason), + shell, + done, + errors, + errorAccounted: false, + } + renders.push(rendering) + return rendering + } + let firstReads = 0 + const resolvedQueryCollections: Array< + NonNullable> + > = [] + const observers: Array> = [] + const subscriptions: Array<() => void> = [] + let readObserver: (() => ReadonlyMap) | undefined + function First() { + const result = + firstReader === `suspense` + ? useLiveSuspenseQuery(first) + : useLiveQuery(first) + checkCacheOptionRows( + Array.from(result.state, ([key, value]) => ({ key, value })), + firstReader === `ordinary` && held ? [] : rows, + history.getKey[0], + ) + firstReads++ + return {result.data.map((row) => row.label).join(`,`)} + } + function Second() { + const result = useLiveQuery(second) + if (!conflictingCacheOptions(history)) + checkCacheOptionRows( + Array.from(result.state, ([key, value]) => ({ key, value })), + held ? [] : rows, + history.getKey[1], + ) + return {result.data.length} + } + const wrap = (child: React.ReactNode) => ( + {child} + ) + const checkObserver = (expected: Array) => + checkCacheOptionRows( + Array.from(readObserver!(), ([key, value]) => ({ + key, + value, + })), + expected, + history.getKey[0], + ) + await withOracleCleanup(async () => { + expect( + client.dehydrate().liveQueries ?? [], + `no prior live-query result`, + ).toEqual([]) + const sourceLoad = track(source.preload()) + if (!held) await sourceLoad + vi.stubGlobal(`window`, undefined) + if (firstReader === `suspense`) { + // The Router enables this same policy. No cache entry or hydration + // input is supplied: the real Suspense hook must register its preload. + client._setSsrStreamingEnabled(true) + } + const firstRender = beginServerRender( + wrap( + firstReader === `suspense` ? ( + pending}> + + + ) : ( + + ), + ), + ) + await firstRender.shell + if (firstReader === `ordinary` || !held) await firstRender.done + expect(starts).toBe(1) + const registered = client + .dehydrate({ shouldDehydrateLiveQuery: () => true }) + .liveQueries!.filter( + (entry) => + entry.queryHash === getLiveQueryHash(first, first.queryKey), + ) + expect(registered).toHaveLength(1) + reach.reach(`first-registration`) + if (held) { + expect(delivered).toBe(false) + if (firstReader === `suspense`) expect(firstReads).toBe(0) + else expect(firstReads).toBeGreaterThan(0) + reach.reach(`held-delivery`) + } + for (let attempt = 0; attempt < 2; attempt++) { + if (secondReader === `preload`) { + checkCacheOptionAdmission( + () => track(client.preloadLiveQuery(second)), + history, + ) + } else { + const rendering = beginServerRender(wrap()) + await checkCacheOptionPromise(rendering.done, history) + // The completion promise preserves the first error. Check every raw + // renderer error as well; an expected conflict cannot hide another + // failure from the same server attempt. + if (conflictingCacheOptions(history)) { + expect(rendering.errors.length).toBeGreaterThan(0) + for (const error of rendering.errors) + await checkCacheOptionPromise(Promise.reject(error), history) + } else expect(rendering.errors).toEqual([]) + rendering.errorAccounted = true + } + reach.reach(attempt === 0 ? `admission` : `retry`) + } + track(client.preloadLiveQuery(first)) + reach.reach(`original-reuse`) + deliver() + await Promise.all(pending) + await firstRender.done + expect(firstRender.errors).toEqual([]) + firstRender.errorAccounted = true + expect(firstReads).toBeGreaterThan(0) + vi.unstubAllGlobals() + const view = resolveLiveQueryValue(first)! + resolvedQueryCollections.push(view) + const observer = createLiveQueryObserver(view, { + client, + queryHash: getLiveQueryHash(first, first.queryKey), + }) + observers.push(observer) + subscriptions.push(observer.subscribe(() => {})) + readObserver = () => observer.getSnapshot().state! + await observer.preload() + await Promise.resolve() // committed initial-view handoff + checkObserver(rows) + reach.reach(`live-observer`) + const updated = [{ id: `one`, label: `after` }, rows[1]!] + publish(updated) + checkObserver(updated) + reach.reach(`source-update`) + reach.assertReached() + }, [ + () => deliver(), + () => { + for (const rendering of renders) rendering.abort(cleanupAbort) + }, + () => Promise.allSettled(renders.map((rendering) => rendering.done)), + () => { + const unexpected = renders + .filter((rendering) => !rendering.errorAccounted) + .flatMap((rendering) => + rendering.errors.filter((error) => error !== cleanupAbort), + ) + if (unexpected.length) + throw new AggregateError(unexpected, `Server rendering failed`) + }, + () => vi.unstubAllGlobals(), + () => + withOracleCleanup(() => {}, [ + ...subscriptions.map((release) => () => release()), + ...observers.map((observer) => () => observer.dispose()), + ...resolvedQueryCollections.map( + (view) => () => view.cleanup({ dependents: true }), + ), + ]), + () => client.cleanup(), + ]) + }, +) + +declareOracle({ + check: [ + checkCacheOptionAdmission, + checkCacheOptionPromise, + checkCacheOptionRows, + ], + model: [conflictingCacheOptions], + grammar: [cacheOptionHistories, firstReaders, secondReaders], +}) diff --git a/packages/react-db/tests/cache-options-oracle.test.tsx b/packages/react-db/tests/cache-options-oracle.test.tsx new file mode 100644 index 0000000000..ab3b462722 --- /dev/null +++ b/packages/react-db/tests/cache-options-oracle.test.tsx @@ -0,0 +1,179 @@ +/** + * The shared local-declaration model receives both mounted browser hooks and + * real React server rendering. Server rendering never subscribes, so a passing + * mounted admission check cannot stand in for its cached-snapshot read. + * + * Each independent option/presence history preloads one real source and query, + * attempts the second config twice, then reads again with the original config. + * Admission and exact original keys/ordinary row values are the comparison cuts. + * The source-ID owner separately checks mounted inline retention. These drivers + * do not claim hydrateRoot scheduling or reactive-framework option semantics. + */ +import { act, cleanup, renderHook } from '@testing-library/react' +import { renderToString } from 'react-dom/server' +import { DbClient, Query, collectionOptions } from '@tanstack/db' +import { afterAll, expect, test, vi } from 'vitest' +import { DbProvider } from '../src/DbProvider' +import { useLiveQuery } from '../src/useLiveQuery' +import { + cacheOptionDeclarations, + cacheOptionHistories, + cacheOptionReach, + cacheOptionReachStates, + checkCacheOptionAdmission, + checkCacheOptionRows, + conflictingCacheOptions, +} from '../../db/tests/live-query-cache-options-oracle' +import { declareOracle } from '../../db/tests/oracle-declaration' +import { createOracleReach } from '../../db/tests/oracle-reach' +import { withOracleCleanup } from '../../db/tests/utils' +import type { CacheRow } from '../../db/tests/live-query-cache-options-oracle' + +declareOracle({ + check: [checkCacheOptionAdmission, checkCacheOptionRows], + model: [conflictingCacheOptions], + grammar: [cacheOptionHistories], +}) +const paths = [`client-render`, `server-render`] as const +const reaches = Object.fromEntries( + paths.map((path) => [ + path, + createOracleReach(`React cache options ${path}`, [ + `admission`, + `retry`, + `original-reuse`, + ...(path === `client-render` ? [`settled-client-rows`] : []), + ...cacheOptionReachStates, + ]), + ]), +) +afterAll(() => { + for (const reach of Object.values(reaches)) reach.assertReached() + if (process.env.TANSTACK_DB_ORACLE_STATISTICS === `1`) + console.info( + `React cache option reach`, + Object.fromEntries( + Object.entries(reaches).map(([path, reach]) => [ + path, + Object.fromEntries(reach.counts()), + ]), + ), + ) +}) + +test.each( + cacheOptionHistories.flatMap((history) => + paths.map((path) => ({ history, path })), + ), +)(`React cache admission %j`, async ({ history, path }) => { + const reach = reaches[path]! + reach.beginHistory() + const client = new DbClient() + const rows: Array = [ + { id: `one`, label: `first` }, + { id: `two`, label: `peer` }, + ] + const source = client.collection( + collectionOptions(`react-cache-options`, () => ({ + getKey: (row: CacheRow) => row.id, + sync: { + sync: ({ begin, write, commit, markReady }) => { + begin() + for (const value of rows) + write({ type: `insert`, value: { ...value } }) + commit() + markReady() + }, + }, + })), + ) + const query = new Query().from({ row: source }) + const [firstOptions, secondOptions] = cacheOptionDeclarations(history) + // Runtime schema declaration identity is intentional here; this driver makes + // no claim about public hook schema typing or schema transformation. + const first = { + query, + getKey: firstOptions!.getKey, + queryKey: firstOptions!.queryKey, + } + const second = { + query, + getKey: secondOptions!.getKey, + queryKey: secondOptions!.queryKey, + } + Object.assign(first, { schema: firstOptions!.schema }) + Object.assign(second, { schema: secondOptions!.schema }) + + const unmounts: Array<() => void> = [] + const settledReads: Array<() => void> = [] + const render = (options: typeof first, declaration: 0 | 1) => { + if (path === `server-render`) { + vi.stubGlobal(`window`, undefined) + function Consumer() { + const result = useLiveQuery(options) + if (declaration === 0 || !conflictingCacheOptions(history)) + checkCacheOptionRows( + Array.from(result.state, ([key, value]) => ({ key, value })), + rows, + history.getKey[declaration], + ) + return {result.data.map((row) => row.label).join(`,`)} + } + const html = renderToString( + + + , + ) + expect(html).toContain(`first,peer`) + } else { + const result = renderHook(() => useLiveQuery(options), { + wrapper: ({ children }) => ( + {children} + ), + }) + unmounts.push(result.unmount) + if (declaration === 0 || !conflictingCacheOptions(history)) { + const checkRows = () => + checkCacheOptionRows( + Array.from(result.result.current.state, ([key, value]) => ({ + key, + value, + })), + rows, + history.getKey[declaration], + ) + checkRows() + settledReads.push(checkRows) + } + } + } + await withOracleCleanup(async () => { + await client.preloadLiveQuery(first) + for (let attempt = 0; attempt < 2; attempt++) { + for (const state of cacheOptionReach(history)) reach.reach(state) + checkCacheOptionAdmission(() => render(second, 1), history) + reach.reach(attempt === 0 ? `admission` : `retry`) + } + render(first, 0) + reach.reach(`original-reuse`) + if (path === `client-render`) { + // Admission stays synchronous. The receiving row law also applies after + // React commits the cached seed-to-live handoff in its native scheduler. + await act(async () => {}) + for (const checkRows of settledReads) checkRows() + reach.reach(`settled-client-rows`) + } + }, [ + () => vi.unstubAllGlobals(), + () => + act(async () => { + await withOracleCleanup(() => {}, [ + ...unmounts.map((unmount) => () => unmount()), + // Testing Library also owns roots whose initial render threw before + // renderHook could return an unmount handle. + () => cleanup(), + () => client.cleanup(), + ]) + }), + ]) +}) diff --git a/packages/react-db/tests/conformance.test.tsx b/packages/react-db/tests/conformance.test.tsx index e5bd997733..9f28cded8d 100644 --- a/packages/react-db/tests/conformance.test.tsx +++ b/packages/react-db/tests/conformance.test.tsx @@ -138,10 +138,10 @@ function mountConfig( return makeHandle(hook) } -// An empty dependency list compiles the query once, at mount, where the hook -// checks its identity and claims its sources. +// Retain this registration for replay coordinates. It uses the current +// derived-identity API, which checks identity and source claims on each render. function mountWithDependencyList(build: QueryBuild) { - const hook = renderHook(() => useLiveQuery(build as any, [])) + const hook = renderHook(() => useLiveQuery(build as any)) return makeHandle(hook) } @@ -150,7 +150,7 @@ function mountConfigWithDependencyList( opts?: { gcTime?: number; pool?: PooledLiveQueries }, ) { const hook = renderHook(() => - useLiveQuery({ query: build as any, ...opts } as any, []), + useLiveQuery({ query: build as any, ...opts } as any), ) return makeHandle(hook) } @@ -161,10 +161,9 @@ function mountDisabled() { return makeHandle(hook) } -// React compares query identity on two production paths: the identity derived -// from the query IR on every render (the 1.0 form), and a deprecated -// dependency list, which decides reuse and checks identity when the query -// compiles. The suite runs once per path. +// The legacy dependency-list label preserves receiving registrations and replay +// coordinates. Both branches now use the current derived-identity API; these +// labels do not represent separate production paths. type IdentityPath = `derived` | `dependency list` const mountControllableWith = (path: IdentityPath) => @@ -177,16 +176,16 @@ const mountControllableWith = (path: IdentityPath) => const hook = renderHook( ({ param }: { param: P }) => { const query = (q: any) => build(q, param) - // A pooled query uses the config form, on either identity path. + // Both retained registrations use the config form for a pooled query. const result = opts?.pool !== undefined ? path === `derived` ? useLiveQuery({ query, pool: opts.pool }) - : useLiveQuery({ query, pool: opts.pool }, [param]) + : useLiveQuery({ query, pool: opts.pool }) : path === `derived` ? // The callback captures param, so its IR identity changes with it. useLiveQuery(query) - : useLiveQuery(query, [param]) + : useLiveQuery(query) // A layout effect records each committed value. useLayoutEffect(() => { published.record(result.data, result.state) @@ -249,7 +248,7 @@ function makeHandle(hook: RenderHookResult) { } // The config-object form with a queryKey chosen per step; an undefined key -// leaves the hook to derive the identity, or to follow the dependency list. +// leaves the hook to derive the identity. const mountKeyedControllableWith = (path: IdentityPath) => function mountKeyedControllable

    ( build: (q: any, param: P) => any, @@ -264,9 +263,7 @@ const mountKeyedControllableWith = (path: IdentityPath) => queryKey: key(param), } as any const result = - path === `derived` - ? useLiveQuery(config) - : useLiveQuery(config, [param]) + path === `derived` ? useLiveQuery(config) : useLiveQuery(config) useLayoutEffect(() => { published.record(result.data, result.state) }) 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 ccbe9118e1..eaa5a39aa7 100644 --- a/packages/react-db/tests/descriptor-query-binding-oracle.test.tsx +++ b/packages/react-db/tests/descriptor-query-binding-oracle.test.tsx @@ -421,7 +421,6 @@ describe(`standalone descriptor query binding`, () => { `$hasPendingWrites`, `$key`, `$origin`, - `$synced`, `id`, `value`, ].sort(), diff --git a/packages/react-db/tests/descriptor-query-binding.test.tsx b/packages/react-db/tests/descriptor-query-binding.test.tsx index 91a11436d2..bd83ff492c 100644 --- a/packages/react-db/tests/descriptor-query-binding.test.tsx +++ b/packages/react-db/tests/descriptor-query-binding.test.tsx @@ -186,7 +186,7 @@ describe(`standalone descriptor query binding`, () => { ), ).toThrow(/requires a DbClient.*DbProvider/) expect(() => - renderHook(() => useLiveQuery({ query: unboundQuery }, [])), + renderHook(() => useLiveQuery({ query: unboundQuery })), ).toThrow(/requires a DbClient.*DbProvider/) expect(() => renderHook(() => @@ -197,7 +197,7 @@ describe(`standalone descriptor query binding`, () => { ), ).toThrow(/requires a DbClient.*DbProvider/) expect(() => - renderHook(() => useLiveSuspenseQuery(() => unboundQuery, [])), + renderHook(() => useLiveSuspenseQuery(() => unboundQuery)), ).toThrow(/requires a DbClient.*DbProvider/) }) it(`rejects a different source after a hook registered the same stream hash`, async () => { diff --git a/packages/react-db/tests/descriptor-start-release-oracle.test.tsx b/packages/react-db/tests/descriptor-start-release-oracle.test.tsx new file mode 100644 index 0000000000..d958c33386 --- /dev/null +++ b/packages/react-db/tests/descriptor-start-release-oracle.test.tsx @@ -0,0 +1,111 @@ +/** + * React receiving driver for descriptor-start-release-oracle. Binding fails + * after zero through three source acquisitions. Native React retries may call + * the failing factory again; successful sources must still be constructed once. + * The exact preparation error crosses React's failed mount. A direct reader then + * starts lazy sources, and client cleanup releases every healthy resource once. + * No graph from the rejected input may start a lazy source on its own. + */ +import { cleanup, renderHook } from '@testing-library/react' +import { expect, it } from 'vitest' +import { useLiveQuery } from '../src/useLiveQuery' +import { useLiveInfiniteQuery } from '../src/useLiveInfiniteQuery' +import { useLiveSuspenseQuery } from '../src/useLiveSuspenseQuery' +import { + checkDescriptorStart, + descriptorStartHistories, + expectedDescriptorStart, +} from '../../db/tests/descriptor-start-release-oracle' +import { createOracleReach } from '../../db/tests/oracle-reach' +import { declareOracle } from '../../db/tests/oracle-declaration' +import { descriptorStartFixture } from '../../db/tests/descriptor-start-release-support' +import { withOracleCleanup } from '../../db/tests/utils' +import type { DescriptorStartHistory } from '../../db/tests/descriptor-start-release-oracle' + +const hooks = [`live`, `infinite`, `suspense`] as const +async function checkHistory( + history: DescriptorStartHistory, + hook: (typeof hooks)[number], +) { + const reach = createOracleReach(`React descriptor starts ${hook}`, [ + `rejected`, + `read`, + `cleaned`, + ]) + reach.beginHistory() + const fixture = descriptorStartFixture(history, `React`) + const { client, query, id, failure, startErrors, factories, observe } = + fixture + // Native root cleanup is registered before renderHook can acquire a root + // and fail without returning its unmount handle. + await withOracleCleanup(async () => { + let rejection: unknown + try { + renderHook(() => { + const config = { client, query, queryKey: [id] } + if (hook === `suspense`) return useLiveSuspenseQuery(config) + if (hook === `infinite`) + return useLiveInfiniteQuery(() => query, { + client, + queryKey: [id], + pageSize: 1, + }) + return useLiveQuery(config) + }) + } catch (error) { + rejection = error + } + expect(rejection).toBe(failure) + expect(factories).toEqual( + expectedDescriptorStart(history, `rejected`).factories, + ) + fixture.retain() + expect(fixture.failures()).toBeGreaterThan(0) + checkDescriptorStart(observe(), history, `rejected`) + reach.reach(`rejected`) + for (const [index, row] of fixture.collections().entries()) { + try { + row.startSyncImmediate() + } catch (error) { + expect(error).toBe(startErrors[index]) + } + } + checkDescriptorStart(observe(), history, `read`) + reach.reach(`read`) + cleanup() + await client.cleanup() + checkDescriptorStart(observe(), history, `cleaned`) + reach.reach(`cleaned`) + reach.assertReached() + }, [() => cleanup(), () => client.cleanup()]) +} + +it.each( + descriptorStartHistories.flatMap((history) => + hooks.map((hook) => ({ history, hook })), + ), +)(`releases independent descriptor starts in React: %j`, ({ history, hook }) => + checkHistory(history, hook), +) + +it.each([ + `blocked-peer`, + `duplicate-start`, + `leaked-resource`, + `wrong-row`, +] as const)(`rejects wrong multi-source release observation: %s`, (fault) => { + const history = { acquired: 3, failedStarts: 3, eagerStarts: 7 } + const actual = expectedDescriptorStart(history, `cleaned`) + checkDescriptorStart(actual, history, `cleaned`) + if (fault === `blocked-peer`) actual.starts[2] = 0 + if (fault === `duplicate-start`) actual.starts[2] = actual.starts[2]! + 1 + if (fault === `leaked-resource`) actual.cleanups[2] = 0 + if (fault === `wrong-row`) actual.rows[2] = [{ id: `bad`, value: `bad` }] + expect(() => checkDescriptorStart(actual, history, `cleaned`)).toThrow() +}) + +declareOracle({ + check: [checkDescriptorStart], + model: [expectedDescriptorStart], + grammar: [descriptorStartHistories], +}) diff --git a/packages/react-db/tests/infinite-query-conformance.test.tsx b/packages/react-db/tests/infinite-query-conformance.test.tsx index 4dddb7b44e..2cde689f47 100644 --- a/packages/react-db/tests/infinite-query-conformance.test.tsx +++ b/packages/react-db/tests/infinite-query-conformance.test.tsx @@ -140,7 +140,8 @@ function mount(build: QueryBuild, config: InfiniteQueryConfig = {}) { ) } -// An empty dependency list compiles the query once, at mount. +// Retain this registration for replay coordinates. It uses the current +// derived-identity API. function mountWithDependencyList( build: QueryBuild, config: InfiniteQueryConfig = {}, @@ -148,15 +149,15 @@ function mountWithDependencyList( const log: Observations = [] return makeHandle( renderHook(() => - useRecorded(useLiveInfiniteQuery(build as any, config as any, []), log), + useRecorded(useLiveInfiniteQuery(build as any, config as any), log), ), log, ) } -// React compares query identity on two production paths: the identity derived -// from the query IR on every render (the 1.0 form), and a deprecated -// dependency list. The suite runs once per path. +// The legacy dependency-list label preserves receiving registrations and replay +// coordinates. Both branches now use the current derived-identity API; these +// labels do not represent separate production paths. type IdentityPath = `derived` | `dependency list` const mountControllableWith = (path: IdentityPath) => @@ -172,9 +173,7 @@ const mountControllableWith = (path: IdentityPath) => path === `derived` ? // The callback captures param, so its IR identity changes with it. useLiveInfiniteQuery((q: any) => build(q, param), config as any) - : useLiveInfiniteQuery((q: any) => build(q, param), config as any, [ - param, - ]), + : useLiveInfiniteQuery((q: any) => build(q, param), config as any), log, ), { initialProps: { param: initial } }, @@ -248,11 +247,10 @@ function mountInputControllable( const hook = renderHook( ({ kind }: { kind: `collection` | `query` }) => useRecorded( - useLiveInfiniteQuery( - kind === `collection` ? collection : build, - config as any, - [kind], - ), + useLiveInfiniteQuery(kind === `collection` ? collection : build, { + ...(config as any), + queryKey: [kind], + }), log, ), { @@ -286,7 +284,7 @@ const mountKeyedControllableWith = (path: IdentityPath) => return useRecorded( path === `derived` ? useLiveInfiniteQuery((q: any) => build(q, param), keyed) - : useLiveInfiniteQuery((q: any) => build(q, param), keyed, [param]), + : useLiveInfiniteQuery((q: any) => build(q, param), keyed), log, ) }, @@ -342,7 +340,7 @@ runInfiniteQuerySuite( registerInfiniteQueryUnitScenarios, ) -it(`publishes query-function pages on the first layout commit after mount and dependency replacement`, async () => { +it(`publishes query-function pages on the first layout commit after mount and queryKey replacement`, async () => { const source = makeSource( Array.from({ length: 10 }, (_, index) => ({ id: String(index + 1), @@ -359,8 +357,7 @@ it(`publishes query-function pages on the first layout commit after mount and de .from({ row: source.collection }) .where(({ row }) => gt(row.rank, minimum)) .orderBy(({ row }) => row.rank, `desc`), - { pageSize: 3 }, - [minimum], + { ...{ pageSize: 3 }, queryKey: [minimum] }, ) useLayoutEffect(() => { if (!result.collection) { @@ -390,7 +387,7 @@ it(`publishes query-function pages on the first layout commit after mount and de await waitFor(() => expect(hook.result.current.data.map(({ rank }) => rank)).toEqual([10, 9]), ) - // Replacing the dependency builds a fresh eager collection, so its first + // Replacing queryKey builds a fresh eager collection, so its first // commit is ready too — no idle flash on re-query. expect(commits[0]).toEqual({ ranks: [10, 9], diff --git a/packages/react-db/tests/infinite-query-render-cuts.test.tsx b/packages/react-db/tests/infinite-query-render-cuts.test.tsx index 0085109f73..00ffc53443 100644 --- a/packages/react-db/tests/infinite-query-render-cuts.test.tsx +++ b/packages/react-db/tests/infinite-query-render-cuts.test.tsx @@ -169,7 +169,6 @@ describe(`retries after GC`, () => { .where(({ items }) => gt(items.rank, minimum)) .orderBy(({ items }) => items.rank, `desc`), { pageSize: 3 }, - [minimum], ) useLayoutEffect(() => { commits.push({ diff --git a/packages/react-db/tests/source-id-reuse-oracle.test.tsx b/packages/react-db/tests/source-id-reuse-oracle.test.tsx index d74d276886..8f684bb64b 100644 --- a/packages/react-db/tests/source-id-reuse-oracle.test.tsx +++ b/packages/react-db/tests/source-id-reuse-oracle.test.tsx @@ -21,12 +21,12 @@ * collision, a query without a stable IR identity, a descriptor factory that * throws while the query binds its sources, a query the live query rejects * when it compiles, a single-result query in an infinite hook, which pages - * many rows, a Suspense query that cannot stream during SSR, a sibling that + * many rows, a sibling that * throws after the hook returns, and a render that suspends and never * commits), every hook - * path (live, infinite, and Suspense, each with derived identity or a - * dependency list), and both direct - * readers (another hook over the Collection, and `startSyncImmediate()`). The + * path (live, infinite and Suspense, derived and explicit queryKey forms), + * initial rejected mounts or rejected rerenders, and four direct readers + * (another hook, preload, a subscription and startSyncImmediate()). The * release must also hold when earlier deferred sync starts throw: each pending * Collection is attempted before the first startup error is rethrown. * The bounded histories below distinguish this law from a hash-only check, @@ -37,14 +37,22 @@ * source. At the rerender boundary, compare a thrown error or the public row. * A nonempty queryKey owns its identity, so a keyed query skips the claim: a * same-ID swap under a changed key reads the new source. The release grammar - * below runs dependency-list hook paths too. This does not assert a + * below runs explicit-key hook paths for rejection causes that remain valid. This does not assert a * process-wide Collection registry or other explicit-key semantics. */ -import { act, render, renderHook, waitFor } from '@testing-library/react' +import { + act, + cleanup as cleanupReact, + render, + renderHook, + waitFor, +} from '@testing-library/react' import { DbClient, SourceCollectionIdReuseError, + UnhashableQueryIRError, + UnsupportedRootScalarSelectError, collectionOptions, eq, gt, @@ -56,9 +64,138 @@ import { useLiveQuery } from '../src/useLiveQuery' import { useLiveInfiniteQuery } from '../src/useLiveInfiniteQuery' import { useLiveSuspenseQuery } from '../src/useLiveSuspenseQuery' import { declareOracle } from '../../db/tests/oracle-declaration' +import { createOracleReach } from '../../db/tests/oracle-reach' +import { + boundOptionHistories, + captureBoundOptions, + checkBoundOptions, +} from '../../db/tests/live-query-bound-options-oracle' +import { withOracleCleanup } from '../../db/tests/utils' import { TestErrorBoundary, source } from './source-id-reuse-support' +import type { + Collection, + InitialQueryBuilder, + NonSingleResult, +} from '@tanstack/db' import type { Row } from './source-id-reuse-support' +/** + * A render may allocate getKey/schema closures without changing the query. + * Maintainer decision: options bind until the query or explicit queryKey changes. + * This finite host grammar crosses both config-object hooks, each option and both identity + * changes. It observes Collection/config references, keys and rows after normal + * renders, a source update and identity replacement. A render budget catches a + * loop. React is the real receiving host; other frameworks are not claimed here. + */ + +it.each( + [`live`, `suspense`].flatMap((hook) => + boundOptionHistories.map((history) => ({ hook, ...history })), + ), +)( + `retains inline options until identity changes: %j`, + async ({ hook, option, identity }) => { + const rows = source( + `inline-options-${hook}-${option}-${identity}`, + `before`, + ) + let renders = 0 + const reach = createOracleReach(`inline options ${hook}`, [ + `rerender`, + `source-update`, + `identity-change`, + ]) + reach.beginHistory() + const { result, rerender, unmount } = renderHook( + ({ revision }) => { + if (++renders > 30) + throw new Error(`inline options rebuilt on every render`) + const getKey = (row: Row) => row.id + const schema = { + '~standard': { + version: 1 as const, + vendor: `identity-oracle`, + validate: (value: unknown) => ({ value: value as Row }), + }, + } + const config = { + query: (q: InitialQueryBuilder) => + q + .from({ row: rows }) + .where(({ row }) => + gt(row.rank, identity === `query` ? -revision : 0), + ), + ...(identity === `queryKey` ? { queryKey: [revision] } : {}), + ...(option !== `schema` ? { getKey } : {}), + } + // Exercise schema at the JavaScript config boundary: its current + // public TS declaration is `never`, independently of runtime identity. + if (option !== `getKey`) Object.assign(config, { schema }) + if (hook === `suspense`) return useLiveSuspenseQuery(config) + return useLiveQuery(config) + }, + { initialProps: { revision: 0 } }, + ) + const collections = new Set<{ cleanup: () => Promise }>() + await withOracleCleanup(async () => { + await waitFor(() => + expect(result.current.collection.status).toBe(`ready`), + ) + const initial = result.current.collection + const initialBinding = captureBoundOptions(initial) + collections.add(initial) + for (let turn = 0; turn < 3; turn++) { + rerender({ revision: 0 }) + // Absent options have no binding to compare; the Collection still must stay. + checkBoundOptions( + initialBinding, + result.current.collection, + false, + option, + ) + expect(result.current.data.map((row) => row.id)).toEqual([`one`]) + reach.reach(`rerender`) + } + act(() => { + rows.utils.begin() + rows.utils.write({ + type: `update`, + value: { id: `one`, value: `after`, rank: 1 }, + }) + rows.utils.commit() + }) + expect(result.current.data[0]?.value).toBe(`after`) + expect(result.current.collection).toBe(initial) + checkBoundOptions( + initialBinding, + result.current.collection, + false, + option, + ) + reach.reach(`source-update`) + rerender({ revision: 1 }) + collections.add(result.current.collection) + await waitFor(() => + expect(result.current.collection.status).toBe(`ready`), + ) + // The comparison judges only configured options; absent options may be + // synthesized by the Collection and have no user binding contract. + expect(result.current.collection).not.toBe(initial) + checkBoundOptions(initialBinding, result.current.collection, true, option) + expect(result.current.data.map((row) => row.id)).toEqual([`one`]) + reach.reach(`identity-change`) + reach.assertReached() + }, [ + () => unmount(), + () => + withOracleCleanup(() => {}, [ + ...[...collections].map((collection) => () => collection.cleanup()), + () => rows.cleanup({ dependents: true }), + ]), + ]) + }, +) + type Binding = { source: ReturnType; client?: DbClient } // A prior source with the same ID in this client scope forbids a new object. @@ -85,8 +222,9 @@ function checkRerender( } declareOracle({ - check: [checkRerender, checkReleaseHistory], + check: [checkRerender, checkReleaseHistory, checkBoundOptions], model: [expectedCollision], + grammar: [boundOptionHistories], }) /* @@ -109,17 +247,15 @@ const releaseCauses: Record< ( shared: any, failing: any, + failures?: { factory: Error; sibling: Error }, ) => { + matchesRejection?: (error: unknown) => boolean rejected: (step: ReleaseStep) => ReleaseStep query: (q: any, step: ReleaseStep, ordered: boolean) => any /** Which hook paths the cause rejects in; all of them by default. */ rejectsIn?: (hook: ReleaseHook) => boolean - /** The dependency values for a hook with a dependency list. */ - deps?: (step: ReleaseStep) => Array - /** Prepare the client, for a cause that depends on its mode. */ - setup?: (client: DbClient) => void - /** Render the rejected step first, for a cause only a mount reaches. */ - atMount?: boolean + /** A source collision requires a prior mounted claim. */ + requiresPriorClaim?: boolean /** * What the component does after the hook returns in the rejected step: * throw to an error boundary, or suspend on a promise that never settles. @@ -129,6 +265,9 @@ const releaseCauses: Record< } > = { 'a source ID collision': (shared) => ({ + matchesRejection: (error) => error instanceof SourceCollectionIdReuseError, + requiresPriorClaim: true, + rejectsIn: (hook) => !hook.keyed, rejected: () => ({ current: source(`settings`, `replacement`), reject: true, @@ -146,6 +285,8 @@ const releaseCauses: Record< ), }), 'a query without a stable IR identity': (shared) => ({ + matchesRejection: (error) => error instanceof UnhashableQueryIRError, + rejectsIn: (hook) => !hook.keyed, rejected: (step) => ({ ...step, reject: true }), query: (q, step, ordered) => orderIf( @@ -160,7 +301,8 @@ const releaseCauses: Record< ordered, ), }), - 'a descriptor factory that throws': (shared, failing) => ({ + 'a descriptor factory that throws': (shared, failing, failures) => ({ + matchesRejection: (error) => error === failures?.factory, rejected: (step) => ({ ...step, reject: true }), query: (q, step, ordered) => orderIf( @@ -178,6 +320,8 @@ const releaseCauses: Record< ), }), 'a query the live query rejects when it compiles': (shared) => ({ + matchesRejection: (error) => + error instanceof UnsupportedRootScalarSelectError, rejected: (step) => ({ ...step, reject: true }), // A root scalar select is valid IR that compilation refuses. query: (q, step, ordered) => @@ -193,6 +337,11 @@ const releaseCauses: Record< : orderIf(q.from({ settings: step.current }), ordered), }), 'a single-result window query': (shared) => ({ + matchesRejection: (error) => + error instanceof Error && + error.message.includes( + `Infinite queries do not support single-result queries`, + ), rejected: (step) => ({ ...step, reject: true }), query: (q, step, ordered) => { const query = orderIf( @@ -210,21 +359,12 @@ const releaseCauses: Record< // A live query may read one row; an infinite query must page many. rejectsIn: (hook) => hook.ordered, }), - 'a Suspense query that cannot stream during SSR': (shared) => ({ - rejected: (step) => ({ ...step, reject: true }), - query: (q, step) => - q - .from({ settings: step.current }) - .join({ shared }, ({ settings, shared: row }: any) => - eq(settings.id, row.id), - ), - // A function dependency gives the stream no stable key. - deps: (step) => [step.current, () => step.reject], - setup: (client) => client._setSsrStreamingEnabled(true), - rejectsIn: (hook) => hook.suspense && hook.usesDeps, - atMount: true, - }), - 'a sibling that throws after the hook returns': (shared) => ({ + 'a sibling that throws after the hook returns': ( + shared, + _failing, + failures, + ) => ({ + matchesRejection: (error) => error === failures?.sibling, rejected: (step) => ({ ...step, reject: true }), query: (q, step, ordered) => orderIf( @@ -268,200 +408,368 @@ function orderIf(query: any, ordered: boolean) { type ReleaseHook = { ordered: boolean suspense: boolean - usesDeps: boolean - use: (query: (q: any) => any, deps: Array) => void + keyed: boolean + use: (query: (q: any) => any, rejectedIdentity: boolean) => void } const releaseHooks: Record = { 'useLiveQuery with derived identity': { ordered: false, suspense: false, - usesDeps: false, + keyed: false, use: (query) => void useLiveQuery(query), }, - 'useLiveQuery with a dependency list': { - ordered: false, - suspense: false, - usesDeps: true, - use: (query, deps) => void useLiveQuery(query, deps), - }, + 'useLiveInfiniteQuery with derived identity': { ordered: true, suspense: false, - usesDeps: false, + keyed: false, use: (query) => void useLiveInfiniteQuery(query, { pageSize: 1 }), }, - 'useLiveInfiniteQuery with a dependency list': { - ordered: true, - suspense: false, - usesDeps: true, - use: (query, deps) => - void useLiveInfiniteQuery(query, { pageSize: 1 }, deps), - }, + // A config can turn off the reclaim: gcTime 0 or Infinity disables GC, and // startSync false never arms it. A render that never commits must still // release its deferral. 'useLiveQuery with gcTime 0': { ordered: false, suspense: false, - usesDeps: false, + keyed: false, use: (query) => void useLiveQuery({ query, gcTime: 0 }), }, 'useLiveQuery with gcTime Infinity': { ordered: false, suspense: false, - usesDeps: false, + keyed: false, use: (query) => void useLiveQuery({ query, gcTime: Infinity }), }, 'useLiveQuery with startSync false': { ordered: false, suspense: false, - usesDeps: false, + keyed: false, use: (query) => void useLiveQuery({ query, startSync: false }), }, 'useLiveSuspenseQuery with derived identity': { ordered: false, suspense: true, - usesDeps: false, + keyed: false, use: (query) => void useLiveSuspenseQuery(query), }, - 'useLiveSuspenseQuery with a dependency list': { - ordered: false, - suspense: true, - usesDeps: true, - use: (query, deps) => void useLiveSuspenseQuery(query, deps), - }, } -// Each reader asks for the source synchronously and returns a read of its -// row. A rejection has released already, so the demand starts the source at -// once. A render that never commits releases its deferral when its live -// query is reclaimed, so the row may arrive after the reader asked. -const releaseReaders: Record< - string, - (shared: ReturnType) => () => unknown -> = { +// Explicit keys skip source-ID and derived-IR guards. Factory, compiler, +// window-shape and abandoned-render failures still owe release on these paths. +// The key changes with the authored query; a constant key would legally retain +// the accepted query and never acquire the rejected render's shared descriptor. +releaseHooks[`useLiveQuery with queryKey`] = { + ordered: false, + suspense: false, + keyed: true, + use: (query, rejectedIdentity) => + void useLiveQuery({ + query, + queryKey: [`release-keyed-live`, rejectedIdentity], + }), +} +releaseHooks[`useLiveInfiniteQuery with queryKey`] = { + ordered: true, + suspense: false, + keyed: true, + use: (query, rejectedIdentity) => + void useLiveInfiniteQuery(query, { + pageSize: 1, + queryKey: [`release-keyed-infinite`, rejectedIdentity], + }), +} +releaseHooks[`useLiveSuspenseQuery with queryKey`] = { + ordered: false, + suspense: true, + keyed: true, + use: (query, rejectedIdentity) => + void useLiveSuspenseQuery({ + query, + queryKey: [`release-keyed-suspense`, rejectedIdentity], + }), +} + +type SharedReleaseCollection = Collection & + NonSingleResult +type ReleaseObservation = { starts: number; status: string; rows: Array } +type ReleaseReader = (shared: SharedReleaseCollection) => { + observe: () => Omit + ready?: Promise + dispose?: () => void +} +const projectReleaseRows = (rows: ReadonlyArray) => + rows + .map(({ id, value, rank }) => ({ id, value, rank })) + .sort((a, b) => a.id.localeCompare(b.id)) +const releaseReaders: Record = { 'another hook over the Collection': (shared) => { const reader = renderHook(() => useLiveQuery(shared)) - return () => reader.result.current.data[0]?.value + return { + observe: () => ({ + status: reader.result.current.status, + rows: projectReleaseRows(reader.result.current.data), + }), + dispose: () => reader.unmount(), + } }, 'startSyncImmediate()': (shared) => { shared.startSyncImmediate() - return () => shared.toArray[0]?.value + return { + observe: () => ({ + status: shared.status, + rows: projectReleaseRows(shared.toArray), + }), + } + }, + 'preload()': (shared) => { + const ready = shared.preload() + void ready.catch(() => {}) + return { + ready, + observe: () => ({ + status: shared.status, + rows: projectReleaseRows(shared.toArray), + }), + } + }, + 'a direct subscription': (shared) => { + // Observe this receiving subscription's deliveries. Reading shared.toArray + // would let missing callbacks pass whenever Collection storage was correct. + const replica = new Map() + const subscription = shared.subscribeChanges( + (changes) => { + for (const change of changes) { + if (change.type === `delete`) replica.delete(change.key) + else { + const { id, value, rank } = change.value + replica.set(change.key, { id, value, rank }) + } + } + }, + { + includeInitialState: true, + }, + ) + return { + observe: () => ({ + status: shared.status, + rows: projectReleaseRows([...replica.values()]), + }), + dispose: () => subscription.unsubscribe(), + } }, } -/** - * Release driver: runs one rejection history (cause, hook, reader) and checks - * when the shared source starts. The law is the release law above: a rejected - * render releases its deferral before the reader asks, and a render that never - * commits releases it at the reclaim. Each cell of the grammar reaches a - * materialized shared descriptor, so the driver asserts that before the reader. +/** The release judgment knows authored source facts, not hook/cache machinery. + * Independent demand starts one healthy source. A later source publication + * replaces the row's value, and every direct reader must receive that whole row. + * An after-hook throw/suspension can be reclaimed before or after the read; it + * owes the same final result. No synchronous reclaim deadline is imposed. + */ +function expectedReleaseObservation(row: Row): ReleaseObservation { + return { starts: 1, status: `ready`, rows: [{ ...row }] } +} +function checkReleaseObservation( + actual: ReleaseObservation, + expected: ReleaseObservation, +): void { + expect(actual).toEqual(expected) +} + +/** Every rendered history must first materialize its shared descriptor. The + * premise is checked before independent demand, rather than inferred from final + * rows. Initial mount and rerender are different receiving paths. The source-ID + * collision requires the rerender's prior claim; a keyed input intentionally + * bypasses that rejection and is excluded only from those identity causes. + * + * Cleanup registers React's native root inventory before calling render. It + * therefore releases a partially constructed mount even without a returned + * handle. Independent subscriptions, client ownership and plain source objects + * are released separately; one cleanup failure cannot erase the primary error. */ let releaseSequence = 0 async function checkReleaseHistory( makeCause: (typeof releaseCauses)[string], hook: ReleaseHook, - read: (typeof releaseReaders)[string], + read: ReleaseReader, + atMount: boolean, ) { + const reach = createOracleReach(`React descriptor release`, [ + `prepared`, + `reader-ready`, + `source-update`, + ]) + reach.beginHistory() const client = new DbClient() + const sources = new Set>() const id = `release-shared-${releaseSequence++}` let syncStarts = 0 let materialized = 0 - const shared = collectionOptions( - id, - () => ( - materialized++, - { - id, - getKey: (row: Row) => row.id, - sync: { - sync: ({ begin, write, commit, markReady }) => { - syncStarts++ + const initialRow: Row = { id: `one`, value: `shared`, rank: 1 } + const nextRow: Row = { ...initialRow, value: `updated`, rank: 2 } + let publish!: (row: Row) => void + const shared = collectionOptions(id, () => { + materialized++ + return { + id, + getKey: (row: Row) => row.id, + sync: { + sync: ({ begin, write, commit, markReady }) => { + syncStarts++ + begin() + write({ type: `insert`, value: { ...initialRow } }) + commit() + markReady() + publish = (row) => { begin() - write({ - type: `insert`, - value: { id: `one`, value: `shared`, rank: 1 }, - }) + write({ type: `update`, value: { ...row } }) commit() - markReady() - }, + } }, - } - ), - ) + }, + } + }) + const failures = { + factory: new Error(`descriptor factory failed`), + sibling: new Error(`sibling failed`), + } const failing = collectionOptions(`${id}-failing`, () => { - throw new Error(`descriptor factory failed`) + throw failures.factory }) - const cause = makeCause(shared, failing) - cause.setup?.(client) + const cause = makeCause(shared, failing, failures) + const rejections: Array = [] + let afterHookReached = 0 + class CaptureBoundary extends TestErrorBoundary { + componentDidCatch(error: Error) { + rejections.push(error) + } + } const never = new Promise(() => {}) function View({ step }: { step: ReleaseStep }) { - hook.use( - (q) => cause.query(q, step, hook.ordered), - cause.deps?.(step) ?? [step.current, step.reject], - ) - if (step.reject && cause.afterHook === `throw`) { - throw new Error(`sibling failed`) + hook.use((q) => cause.query(q, step, hook.ordered), step.reject) + if (step.reject && cause.afterHook) { + afterHookReached++ + if (cause.afterHook === `throw`) throw failures.sibling + throw never } - if (step.reject && cause.afterHook === `suspend`) throw never return

    Query
    } const tree = (step: ReleaseStep) => ( - + Loading}> - + ) const consoleError = vi.spyOn(console, `error`).mockImplementation(() => {}) - try { - const initial = { - current: source(`settings`, `first`), - reject: false, - } - const root = render(tree(cause.atMount ? cause.rejected(initial) : initial)) + let reader: ReturnType | undefined + const cleanups: Array<() => unknown> = [ + () => reader?.dispose?.(), + () => cleanupReact(), + () => client.cleanup(), + () => consoleError.mockRestore(), + ] + const remember = (collection: ReturnType) => { + if (!sources.has(collection)) cleanups.push(() => collection.cleanup()) + sources.add(collection) + } + await withOracleCleanup(async () => { + const initial = { current: source(`settings`, `first`), reject: false } + remember(initial.current) + const rejected = cause.rejected(initial) + remember(rejected.current) + const root = render(tree(atMount ? rejected : initial)) await act(async () => {}) - if (!cause.atMount) root.rerender(tree(cause.rejected(initial))) + if (!atMount) root.rerender(tree(rejected)) expect( root.getByText(cause.afterHook === `suspend` ? `Loading` : `Rejected`), ).toBeDefined() - // Declared reach: the rejected render materialized the shared - // descriptor, so it held a deferral the law must release. - expect(materialized).toBe(1) - // A rejection releases at its render boundary, before the reader. - // A render that never commits releases at the reclaim, which the - // real GC timer may reach before or after the reader asks. - if (!cause.afterHook) expect(syncStarts).toBe(0) - - const value = read(client.collection(shared) as any) - if (cause.afterHook) { - await waitFor(() => expect(value()).toBe(`shared`)) - } else { - // Released at the render boundary, so the demand started it. - expect(syncStarts).toBe(1) - await act(async () => {}) - expect(value()).toBe(`shared`) + expect(materialized, `shared descriptor bound before rejected render`).toBe( + 1, + ) + if (cause.afterHook) expect(afterHookReached).toBeGreaterThan(0) + else expect(afterHookReached).toBe(0) + if (cause.afterHook === `suspend`) expect(rejections).toEqual([]) + else { + expect( + rejections.length, + `authored rejection reached boundary`, + ).toBeGreaterThan(0) + for (const error of rejections) + expect( + cause.matchesRejection?.(error), + `intended rejection: ${String(error)}`, + ).toBe(true) } - expect(syncStarts).toBe(1) - } finally { - consoleError.mockRestore() - } + if (!cause.afterHook) expect(syncStarts).toBe(0) + reach.reach(`prepared`) + const collection = client.collection(shared) + act(() => { + reader = read(collection) + }) + if (cause.afterHook) await waitFor(() => expect(syncStarts).toBe(1)) + else expect(syncStarts).toBe(1) + await reader!.ready + await act(async () => {}) + checkReleaseObservation( + { starts: syncStarts, ...reader!.observe() }, + expectedReleaseObservation(initialRow), + ) + reach.reach(`reader-ready`) + act(() => { + publish(nextRow) + }) + await act(async () => {}) + checkReleaseObservation( + { starts: syncStarts, ...reader!.observe() }, + expectedReleaseObservation(nextRow), + ) + reach.reach(`source-update`) + reach.assertReached() + }, cleanups) } describe(`React source ID reuse: rejected renders release deferred sync starts`, () => { - for (const [causeName, makeCause] of Object.entries(releaseCauses)) { + for (const [causeName, makeCause] of Object.entries(releaseCauses)) for (const [hookName, hook] of Object.entries(releaseHooks)) { const probe = makeCause(undefined, undefined) if (probe.rejectsIn && !probe.rejectsIn(hook)) continue - for (const [readerName, read] of Object.entries(releaseReaders)) { - it(`after ${causeName} in ${hookName}, ${readerName} starts the shared source`, () => - checkReleaseHistory(makeCause, hook, read)) - } + for (const [readerName, read] of Object.entries(releaseReaders)) + for (const atMount of probe.requiresPriorClaim + ? [false] + : [false, true]) + it(`after ${causeName} in ${hookName}, ${readerName} starts the shared source (${atMount ? `mount` : `rerender`})`, () => + checkReleaseHistory(makeCause, hook, read, atMount)) } - } +}) +it.each([ + `missing-start`, + `extra-start`, + `wrong-row`, + `duplicate-row`, +] as const)(`rejects wrong descriptor release observation: %s`, (fault) => { + const expected = expectedReleaseObservation({ + id: `one`, + value: `authored`, + rank: 2, + }) + const actual = { ...expected, rows: expected.rows.map((row) => ({ ...row })) } + checkReleaseObservation(actual, expected) + if (fault === `missing-start`) actual.starts = 0 + if (fault === `extra-start`) actual.starts = 2 + if (fault === `wrong-row`) actual.rows[0]!.rank++ + if (fault === `duplicate-row`) actual.rows.push({ ...actual.rows[0]! }) + expect(() => checkReleaseObservation(actual, expected)).toThrow() +}) + +declareOracle({ + check: [checkReleaseObservation], + model: [expectedReleaseObservation], + grammar: [releaseCauses, releaseHooks, releaseReaders], }) describe(`React source ID reuse`, () => { diff --git a/packages/react-db/tests/source-id-reuse.test.tsx b/packages/react-db/tests/source-id-reuse.test.tsx index 90cd1f16f2..936d20f0e9 100644 --- a/packages/react-db/tests/source-id-reuse.test.tsx +++ b/packages/react-db/tests/source-id-reuse.test.tsx @@ -1,12 +1,17 @@ // Ordinary React tests for source ID reuse: a rejected render releases the sync // deferral it acquired, so a later reader can start a shared Collection. The reuse // law is owned by source-id-reuse-oracle.test.tsx. -import { act, render, renderHook, waitFor } from '@testing-library/react' +import { + act, + cleanup, + render, + renderHook, + waitFor, +} from '@testing-library/react' import { DbClient, SourceCollectionIdReuseError, collectionOptions, - createCollection, eq, } from '@tanstack/db' import { describe, expect, it, vi } from 'vitest' @@ -14,12 +19,62 @@ import { Suspense } from 'react' import { DbProvider } from '../src/DbProvider' import { useLiveQuery } from '../src/useLiveQuery' import { useLiveSuspenseQuery } from '../src/useLiveSuspenseQuery' +import { withOracleCleanup } from '../../db/tests/utils' import { TestErrorBoundary, source } from './source-id-reuse-support' import type { Context, QueryBuilder } from '@tanstack/db' import type { Row } from './source-id-reuse-support' describe(`React source ID reuse`, () => { + it(`preserves a failure after native mount acquisition and releases its root`, async () => { + const rows = source(`partial-native-mount`, `authored`) + const primary = new Error(`driver failed before returning its mount handle`) + const secondary = new Error(`independent cleanup failed`) + let query: + { cleanup: () => Promise; subscriberCount: number } | undefined + let rootReleased = false + let followingCleanup = false + let failure: unknown + try { + await withOracleCleanup(() => { + renderHook(() => { + const result = useLiveQuery({ + query: (q) => q.from({ rows }), + gcTime: 1, + }) + query = result.collection + return result + }) + expect(query?.subscriberCount).toBe(1) + // Model the driver losing control after mount acquisition but before + // returning a handle. The root inventory still owns native cleanup. + throw primary + }, [ + () => { + cleanup() + rootReleased = true + expect(query?.subscriberCount).toBe(0) + }, + () => { + throw secondary + }, + async () => { + await query?.cleanup() + await rows.cleanup() + followingCleanup = true + }, + ]) + } catch (error) { + failure = error + } + expect(failure).toBeInstanceOf(AggregateError) + expect((failure as AggregateError).cause).toBe(primary) + expect((failure as AggregateError).errors).toEqual([primary, secondary]) + expect(rootReleased).toBe(true) + expect(followingCleanup).toBe(true) + expect(rows.subscriberCount).toBe(0) + }) + it(`releases a later shared source when earlier deferred sync starts fail`, async () => { const client = new DbClient() const first = source(`settings`, `first`) @@ -170,108 +225,6 @@ describe(`React source ID reuse`, () => { ) }) - // A Suspense mount suspends before it commits, so its retry render may - // reuse the cached Suspense collection instead of compiling again. With a - // dependency list, that retry must still claim the source's ID. - it(`claims a Suspense query's source when a dependency-list retry reuses its cached collection`, async () => { - let markFirstReady!: () => void - const first = createCollection({ - id: `suspense-settings`, - getKey: (row) => row.id, - startSync: true, - sync: { - sync: ({ begin, write, commit, markReady }) => { - begin() - write({ - type: `insert`, - value: { id: `one`, value: `first`, rank: 1 }, - }) - commit() - markFirstReady = markReady - }, - }, - }) - const replacement = source(`suspense-settings`, `replacement`) - const labels = new Map([ - [first, `first`], - [replacement, `replacement`], - ]) - const View = ({ - current, - }: { - current: typeof first | typeof replacement - }) => { - // A hashable dependency keys the Suspense cache; a Collection cannot. - const { data } = useLiveSuspenseQuery( - (q) => q.from({ settings: current }), - [labels.get(current)], - ) - return
    {data[0]?.value}
    - } - const consoleError = vi.spyOn(console, `error`).mockImplementation(() => {}) - try { - const root = render( - - Loading}> - - - , - ) - expect(root.getByText(`Loading`)).toBeDefined() - await act(async () => markFirstReady()) - await waitFor(() => expect(root.getByText(`first`)).toBeDefined()) - - root.rerender( - - Loading}> - - - , - ) - expect(root.getByText(`Rejected`)).toBeDefined() - } finally { - consoleError.mockRestore() - } - }) - - it(`claims a Suspense query's source when a dependency-list hook mounts from a sibling's cached collection`, async () => { - const first = source(`sibling-suspense`, `first`) - const replacement = source(`sibling-suspense`, `replacement`) - const labels = new Map([ - [first, `first`], - [replacement, `replacement`], - ]) - const View = ({ current }: { current: typeof first }) => { - // A hashable dependency keys the Suspense cache; a Collection cannot. - const { data } = useLiveSuspenseQuery( - (q) => q.from({ settings: current }), - [labels.get(current)], - ) - return
    {data[0]?.value}
    - } - const tree = (second: typeof first) => ( - <> - Loading}> - - - - Loading}> - - - - - ) - const consoleError = vi.spyOn(console, `error`).mockImplementation(() => {}) - try { - const root = render(tree(first)) - await waitFor(() => expect(root.getAllByText(`first`)).toHaveLength(2)) - root.rerender(tree(replacement)) - expect(root.getByText(`Rejected`)).toBeDefined() - } finally { - consoleError.mockRestore() - } - }) - it(`permits independent client scopes to use the same ID`, async () => { const firstClient = new DbClient() const secondClient = new DbClient() diff --git a/packages/react-db/tests/useLiveInfiniteQuery.test.tsx b/packages/react-db/tests/useLiveInfiniteQuery.test.tsx index a44d900a8c..b73f494c91 100644 --- a/packages/react-db/tests/useLiveInfiniteQuery.test.tsx +++ b/packages/react-db/tests/useLiveInfiniteQuery.test.tsx @@ -159,7 +159,7 @@ describe(`useLiveInfiniteQuery`, () => { rendered.unmount() }) - it(`preserves committed pages across an abandoned dependency update`, async () => { + it(`preserves committed pages across an abandoned queryKey update`, async () => { const source = createCollection( mockSyncCollectionOptions({ autoIndex: `eager`, @@ -185,8 +185,7 @@ describe(`useLiveInfiniteQuery`, () => { .from({ post: source }) .where(({ post }) => gt(post.createdAt, minimum)) .orderBy(({ post }) => post.createdAt, `desc`), - { pageSize: 3 }, - [minimum], + { ...{ pageSize: 3 }, queryKey: [minimum] }, ) if (shouldSuspend) throw never return null @@ -422,7 +421,7 @@ describe(`useLiveInfiniteQuery`, () => { }) }) - it(`compares dependencies by identity instead of serialization`, async () => { + it(`uses a serializable query key for values read from a Map`, async () => { const source = createCollection( mockSyncCollectionOptions({ id: `infinite-query-map-deps`, @@ -438,8 +437,7 @@ describe(`useLiveInfiniteQuery`, () => { .from({ post: source }) .where(({ post }) => eq(post.category, filter.get(`category`))) .orderBy(({ post }) => post.createdAt, `desc`), - { pageSize: 3 }, - [filter], + { pageSize: 3, queryKey: [filter.get(`category`)] }, ), { initialProps: { @@ -466,7 +464,7 @@ describe(`useLiveInfiniteQuery`, () => { }) }) - it(`preserves loaded pages when dependencies are structurally unchanged`, async () => { + it(`preserves loaded pages when query keys are structurally unchanged`, async () => { const source = createCollection( mockSyncCollectionOptions({ id: `infinite-query-structurally-equal-deps`, @@ -482,8 +480,7 @@ describe(`useLiveInfiniteQuery`, () => { .from({ post: source }) .where(({ post }) => eq(post.category, filter.category)) .orderBy(({ post }) => post.createdAt, `desc`), - { pageSize: 2 }, - [filter], + { ...{ pageSize: 2 }, queryKey: [filter] }, ), { initialProps: { filter: { category: `tech` } } }, ) @@ -498,7 +495,7 @@ describe(`useLiveInfiniteQuery`, () => { rerender({ filter: { category: `tech` } }) await waitFor(() => { - expect(result.current.collection).not.toBe(firstCollection) + expect(result.current.collection).toBe(firstCollection) expect(result.current.isReady).toBe(true) }) expect(result.current.pages).toHaveLength(2) diff --git a/packages/react-db/tests/useLiveQuery.test-d.tsx b/packages/react-db/tests/useLiveQuery.test-d.tsx index 8148d2f832..440488900c 100644 --- a/packages/react-db/tests/useLiveQuery.test-d.tsx +++ b/packages/react-db/tests/useLiveQuery.test-d.tsx @@ -151,7 +151,7 @@ describe(`useLiveQuery type assertions`, () => { >() }) - it(`types a conditional config with deprecated dependencies`, () => { + it(`types a conditional config with an explicit query key`, () => { const collection = createCollection( mockSyncCollectionOptions({ id: `test-conditional-config-deps`, @@ -162,13 +162,10 @@ describe(`useLiveQuery type assertions`, () => { const enabled = null as unknown as boolean const { result } = renderHook(() => - useLiveQuery( - { - query: (q) => - enabled ? q.from({ collection }).findOne() : undefined, - }, - [enabled], - ), + useLiveQuery({ + query: (q) => (enabled ? q.from({ collection }).findOne() : undefined), + queryKey: [enabled], + }), ) expectTypeOf(result.current.data).toMatchTypeOf< @@ -276,7 +273,7 @@ describe(`useLiveQuery type assertions`, () => { >() }) - it(`keeps the deprecated dependency-array overload typed`, () => { + it(`types an explicit queryKey config`, () => { const collection = createCollection( mockSyncCollectionOptions({ id: `test-persons-deprecated-deps`, @@ -286,13 +283,13 @@ describe(`useLiveQuery type assertions`, () => { ) const { result } = renderHook(() => - useLiveQuery( - (q) => + useLiveQuery({ + query: (q) => q .from({ person: collection }) .where(({ person }) => eq(person.team, `team-1`)), - [`team-1`], - ), + queryKey: [`team-1`], + }), ) expectTypeOf(result.current.data).toMatchTypeOf< diff --git a/packages/react-db/tests/useLiveQuery.test.tsx b/packages/react-db/tests/useLiveQuery.test.tsx index 1201894f62..9066de784c 100644 --- a/packages/react-db/tests/useLiveQuery.test.tsx +++ b/packages/react-db/tests/useLiveQuery.test.tsx @@ -19,8 +19,7 @@ import { import { describe, expect, it, vi } from 'vitest' import { act, renderHook, waitFor } from '@testing-library/react' import { useEffect } from 'react' -import { useLiveQuery, useLiveQueryForSuspense } from '../src/useLiveQuery' -import { getLiveQueryResultInfo } from '../src/live-query-internals' +import { useLiveQuery } from '../src/useLiveQuery' import { DbProvider } from '../src/DbProvider' import { mockSyncCollectionOptions, @@ -566,8 +565,8 @@ describe(`Query Collections`, () => { const { result, rerender } = renderHook( ({ minAge }: { minAge: number }) => { - return useLiveQuery( - (q) => + return useLiveQuery({ + query: (q) => q .from({ collection }) .where(({ collection: c }) => gt(c.age, minAge)) @@ -576,8 +575,8 @@ describe(`Query Collections`, () => { name: c.name, age: c.age, })), - [minAge], - ) + queryKey: [minAge], + }) }, { initialProps: { minAge: 30 } }, ) @@ -640,8 +639,8 @@ describe(`Query Collections`, () => { const { result, rerender } = renderHook( ({ minAge }: { minAge: number }) => { - return useLiveQuery( - (q) => + return useLiveQuery({ + query: (q) => q .from({ collection }) .where(({ collection: c }) => gt(c.age, minAge)) @@ -649,8 +648,8 @@ describe(`Query Collections`, () => { id: c.id, name: c.name, })), - [minAge], - ) + queryKey: [minAge], + }) }, { initialProps: { minAge: 30 } }, ) @@ -927,7 +926,7 @@ describe(`Query Collections`, () => { }) // Wait for the transaction to be committed - await transaction.isPersisted.promise + await transaction.when(`settled`) await waitFor(() => { // Wait for the permanent key to appear @@ -1554,8 +1553,8 @@ describe(`Query Collections`, () => { const { result, rerender } = renderHook( ({ minAge }: { minAge: number }) => { - return useLiveQuery( - (q) => + return useLiveQuery({ + query: (q) => q .from({ collection }) .where(({ collection: c }) => gt(c.age, minAge)) @@ -1563,8 +1562,8 @@ describe(`Query Collections`, () => { id: c.id, name: c.name, })), - [minAge], - ) + queryKey: [minAge], + }) }, { initialProps: { minAge: 30 } }, ) @@ -2111,7 +2110,7 @@ describe(`Query Collections`, () => { expect(result.current.isEnabled).toBe(false) }) - it(`disables a config query with deprecated dependencies`, async () => { + it(`disables a config query with an explicit queryKey`, async () => { const warnSpy = vi.spyOn(console, `warn`).mockImplementation(() => {}) const collection = createCollection( mockSyncCollectionOptions({ @@ -2123,17 +2122,15 @@ describe(`Query Collections`, () => { const { result, rerender } = renderHook( ({ enabled }: { enabled: boolean }) => - useLiveQuery( - { - query: (q) => { - if (!enabled) return undefined - return q - .from({ persons: collection }) - .where(({ persons }) => gt(persons.age, 30)) - }, + useLiveQuery({ + query: (q) => { + if (!enabled) return undefined + return q + .from({ persons: collection }) + .where(({ persons }) => gt(persons.age, 30)) }, - [enabled], - ), + queryKey: [enabled], + }), { initialProps: { enabled: false } }, ) @@ -2204,7 +2201,7 @@ describe(`Query Collections`, () => { expect(result.current.collection).toBeUndefined() }) - it(`should handle callback returning undefined without a dependency array`, async () => { + it(`should handle callback returning undefined with derived query identity`, async () => { const collection = createCollection( mockSyncCollectionOptions({ id: `undefined-callback-test`, @@ -2281,7 +2278,7 @@ describe(`Query Collections`, () => { expect(result.current.isCleanedUp).toBe(false) }) - it(`should handle callback returning null without a dependency array`, async () => { + it(`should handle callback returning null with derived query identity`, async () => { const collection = createCollection( mockSyncCollectionOptions({ id: `null-callback-test`, @@ -2346,34 +2343,31 @@ describe(`Query Collections`, () => { const { result, rerender } = renderHook( ({ useConfig }: { useConfig: boolean }) => { - return useLiveQuery( - (q) => { - if (useConfig) { - return { - query: q - .from({ persons: collection }) - .where(({ persons }) => gt(persons.age, 30)) - .select(({ persons }) => ({ - id: persons.id, - name: persons.name, - age: persons.age, - })), - startSync: true, - gcTime: 0, - } + return useLiveQuery((q) => { + if (useConfig) { + return { + query: q + .from({ persons: collection }) + .where(({ persons }) => gt(persons.age, 30)) + .select(({ persons }) => ({ + id: persons.id, + name: persons.name, + age: persons.age, + })), + startSync: true, + gcTime: 0, } - return q - .from({ persons: collection }) - .where(({ persons }) => lte(persons.age, 30)) - .select(({ persons }) => ({ - id: persons.id, - name: persons.name, - age: persons.age, - })) - .orderBy(({ persons }) => persons.age) - }, - [useConfig], - ) + } + return q + .from({ persons: collection }) + .where(({ persons }) => lte(persons.age, 30)) + .select(({ persons }) => ({ + id: persons.id, + name: persons.name, + age: persons.age, + })) + .orderBy(({ persons }) => persons.age) + }) }, { initialProps: { useConfig: false } }, ) @@ -2446,22 +2440,19 @@ describe(`Query Collections`, () => { const { result, rerender } = renderHook( ({ useCollection }: { useCollection: boolean }) => { - return useLiveQuery( - (q) => { - if (useCollection) { - return liveQueryCollection - } - return q - .from({ persons: collection }) - .where(({ persons }) => lte(persons.age, 30)) - .select(({ persons }) => ({ - id: persons.id, - name: persons.name, - age: persons.age, - })) - }, - [useCollection], - ) + return useLiveQuery((q) => { + if (useCollection) { + return liveQueryCollection + } + return q + .from({ persons: collection }) + .where(({ persons }) => lte(persons.age, 30)) + .select(({ persons }) => ({ + id: persons.id, + name: persons.name, + age: persons.age, + })) + }) }, { initialProps: { useCollection: false } }, ) @@ -2511,7 +2502,7 @@ describe(`Query Collections`, () => { ]) }) - it(`should handle conditional returns with dependencies`, async () => { + it(`should handle conditional returns with queryKey changes`, async () => { const collection = createCollection( mockSyncCollectionOptions({ id: `conditional-deps-test`, @@ -2522,8 +2513,8 @@ describe(`Query Collections`, () => { const { result, rerender } = renderHook( ({ minAge, enabled }: { minAge: number; enabled: boolean }) => { - return useLiveQuery( - (q) => { + return useLiveQuery({ + query: (q) => { if (!enabled) return undefined return q .from({ persons: collection }) @@ -2534,8 +2525,8 @@ describe(`Query Collections`, () => { age: persons.age, })) }, - [minAge, enabled], - ) + queryKey: [minAge, enabled], + }) }, { initialProps: { minAge: 30, enabled: false } }, ) @@ -2638,25 +2629,28 @@ describe(`Query Collections`, () => { ) const { result } = renderHook(() => { - return useLiveQuery((q) => { - const issueCountSubquery = q - .from({ issues: issueCollection }) - .groupBy(({ issues }) => issues.userId) - .select(({ issues }) => ({ - userId: issues.userId, - issueCount: coalesce(count(issues.id), 0), - })) - - return q - .from({ persons: personCollection }) - .leftJoin({ ic: issueCountSubquery }, ({ persons, ic }) => - eq(persons.id, ic.userId), - ) - .select(({ persons, ic }) => ({ - name: persons.name, - issueCount: ic.issueCount, - })) - }, []) + return useLiveQuery({ + query: (q) => { + const issueCountSubquery = q + .from({ issues: issueCollection }) + .groupBy(({ issues }) => issues.userId) + .select(({ issues }) => ({ + userId: issues.userId, + issueCount: coalesce(count(issues.id), 0), + })) + + return q + .from({ persons: personCollection }) + .leftJoin({ ic: issueCountSubquery }, ({ persons, ic }) => + eq(persons.id, ic.userId), + ) + .select(({ persons, ic }) => ({ + name: persons.name, + issueCount: ic.issueCount, + })) + }, + queryKey: [], + }) }) await waitFor(() => { @@ -3296,49 +3290,6 @@ describe(`Query Collections`, () => { ).toThrow(UnhashableQueryIRError) }) - // A dependency list decides reuse, so the identity is checked when the - // query compiles: at mount and when a dependency changes. With a - // DbClient, the hook also hashes the query for hydration on every render; - // an unhashable query there leaves hydration without a key until the - // query compiles again. - it(`rejects an unhashable dependency-list query with a DbClient only when it compiles`, async () => { - const collection = createCollection( - mockSyncCollectionOptions({ - id: `deps-client-unhashable`, - getKey: (person: Person) => person.id, - initialData: initialPersons, - }), - ) - const client = new DbClient() - const wrapper = ({ children }: { children: ReactNode }) => ( - {children} - ) - const hook = renderHook( - ({ functional, version }) => - useLiveQuery( - (q) => - functional - ? q - .from({ people: collection }) - .fn.where(({ people }) => people.age > 25) - : q.from({ people: collection }), - [version], - ), - { initialProps: { functional: false, version: 1 }, wrapper }, - ) - await waitFor(() => expect(hook.result.current.data).toHaveLength(3)) - - // The query changed but its dependency did not, so nothing compiles. - expect(() => - hook.rerender({ functional: true, version: 1 }), - ).not.toThrow() - expect(hook.result.current.data).toHaveLength(3) - - expect(() => hook.rerender({ functional: true, version: 2 })).toThrow( - UnhashableQueryIRError, - ) - }) - it(`uses runtime identity for opaque values in a structured query without queryKey`, () => { const warnSpy = vi.spyOn(console, `warn`).mockImplementation(() => {}) const collection = createCollection( @@ -3459,35 +3410,6 @@ describe(`Query Collections`, () => { expect(queryExecutions).toBe(1) }) - it(`preserves reference semantics for deprecated dependency arrays`, async () => { - const collection = createCollection( - mockSyncCollectionOptions({ - id: `legacy-deps-reference-semantics`, - getKey: (person: Person) => person.id, - initialData: initialPersons, - }), - ) - - const { result, rerender } = renderHook( - ({ filter }: { filter: { minAge: number } }) => - useLiveQuery( - (q) => - q - .from({ people: collection }) - .where(({ people }) => gt(people.age, filter.minAge)), - [filter], - ), - { initialProps: { filter: { minAge: 25 } } }, - ) - - await waitFor(() => expect(result.current.data).toHaveLength(2)) - const firstCollection = result.current.collection - - rerender({ filter: { minAge: 25 } }) - - expect(result.current.collection).not.toBe(firstCollection) - }) - it(`warns when derived query identity is slow enough to need queryKey`, () => { const warnSpy = vi.spyOn(console, `warn`).mockImplementation(() => {}) const nowSpy = vi.spyOn(globalThis.performance, `now`) @@ -3571,88 +3493,6 @@ describe(`Query Collections`, () => { nowSpy.mockRestore() warnSpy.mockRestore() }) - - it(`warns once for the deprecated dependency-array form`, () => { - const warnSpy = vi.spyOn(console, `warn`).mockImplementation(() => {}) - const collection = createCollection( - mockSyncCollectionOptions({ - id: `deps-warning`, - getKey: (person: Person) => person.id, - initialData: initialPersons, - }), - ) - - const { rerender } = renderHook( - ({ minAge }) => - useLiveQuery( - (q) => - q - .from({ people: collection }) - .where(({ people }) => gt(people.age, minAge)), - [minAge], - ), - { initialProps: { minAge: 25 } }, - ) - - rerender({ minAge: 30 }) - rerender({ minAge: 30 }) - - expect(warnSpy).toHaveBeenCalledTimes(1) - expect(warnSpy).toHaveBeenCalledWith( - expect.stringContaining(`will be removed in 1.0`), - ) - - warnSpy.mockRestore() - }) - - it(`warns for an explicitly passed empty dependency array`, () => { - const warnSpy = vi.spyOn(console, `warn`).mockImplementation(() => {}) - const collection = createCollection( - mockSyncCollectionOptions({ - id: `empty-deps-warning`, - getKey: (person: Person) => person.id, - initialData: initialPersons, - }), - ) - - renderHook(() => useLiveQuery((q) => q.from({ people: collection }), [])) - - expect(warnSpy).toHaveBeenCalledWith( - expect.stringContaining(`useLiveQuery({ query })`), - ) - - warnSpy.mockRestore() - }) - - it(`includes the query in legacy dependency-array SSR identity`, () => { - const collection = createCollection( - mockSyncCollectionOptions({ - id: `legacy-deps-query-identity`, - getKey: (person) => person.id, - initialData: initialPersons, - }), - ) - // Suspense is the reader of this identity; plain useLiveQuery skips it. - const first = renderHook(() => - useLiveQueryForSuspense( - (q: any) => q.from({ people: collection }), - [1], - ), - ) - const second = renderHook(() => - useLiveQueryForSuspense( - (q: any) => - q - .from({ people: collection }) - .where(({ people }: any) => gt(people.age, 30)), - [1], - ), - ) - - expect(getLiveQueryResultInfo(first.result.current).queryHash).not.toBe( - getLiveQueryResultInfo(second.result.current).queryHash, - ) - }) }) }) @@ -3692,4 +3532,44 @@ describe(`retained useLiveQuery results`, () => { expect([...read.state.values()].map((row) => row.version)).toEqual([1]) }, ) + + it(`retains getKey until the query identity changes`, async () => { + const source = createCollection( + localOnlyCollectionOptions<{ id: string; value: string }, string>({ + id: `hook-opaque-config-identity`, + getKey: (row) => row.id, + initialData: [{ id: `one`, value: `value` }], + }), + ) + const firstKey = (row: { id: string }) => row.id + const secondKey = (row: { id: string }) => `other-${row.id}` + const { result, rerender, unmount } = renderHook( + ({ getKey }) => + useLiveQuery({ + query: (q) => q.from({ row: source }), + getKey, + }), + { initialProps: { getKey: firstKey } }, + ) + let firstCollection: typeof result.current.collection | undefined + + try { + await waitFor(() => expect(result.current.isReady).toBe(true)) + firstCollection = result.current.collection + expect(firstCollection.has(`one`)).toBe(true) + + rerender({ getKey: secondKey }) + await waitFor(() => expect(result.current.isReady).toBe(true)) + expect(result.current.collection).toBe(firstCollection) + expect(result.current.collection.has(`one`)).toBe(true) + expect(result.current.collection.has(`other-one`)).toBe(false) + } finally { + unmount() + await firstCollection?.cleanup() + if (result.current.collection !== firstCollection) { + await result.current.collection.cleanup() + } + await source.cleanup() + } + }) }) diff --git a/packages/react-db/tests/useLiveSuspenseQuery.test.tsx b/packages/react-db/tests/useLiveSuspenseQuery.test.tsx index b5d760a27e..b19dac8239 100644 --- a/packages/react-db/tests/useLiveSuspenseQuery.test.tsx +++ b/packages/react-db/tests/useLiveSuspenseQuery.test.tsx @@ -1055,7 +1055,7 @@ describe(`useLiveSuspenseQuery`, () => { expect(suspenseCount).toBe(initialSuspenseCount) }) - it(`should only suspend on deps change, not on every re-render`, async () => { + it(`should only suspend on queryKey change, not on every re-render`, async () => { const collection = createCollection( mockSyncCollectionOptions({ id: `test-persons-suspense-12`, @@ -1066,13 +1066,13 @@ describe(`useLiveSuspenseQuery`, () => { const { result, rerender } = renderHook( ({ minAge }) => - useLiveSuspenseQuery( - (q) => + useLiveSuspenseQuery({ + query: (q) => q .from({ persons: collection }) .where(({ persons }) => gt(persons.age, minAge)), - [minAge], - ), + queryKey: [minAge], + }), { wrapper: SuspenseWrapper, initialProps: { minAge: 20 }, @@ -1086,7 +1086,7 @@ describe(`useLiveSuspenseQuery`, () => { const dataAfterInitial = result.current.data - // Re-render with SAME deps - should NOT suspend (data stays available) + // Re-render with the same queryKey - should NOT suspend (data stays available) rerender({ minAge: 20 }) expect(result.current.data).toHaveLength(3) expect(result.current.data).toBe(dataAfterInitial) @@ -1097,7 +1097,7 @@ describe(`useLiveSuspenseQuery`, () => { rerender({ minAge: 20 }) expect(result.current.data).toHaveLength(3) - // Change deps - SHOULD suspend and get new data + // Change queryKey - SHOULD suspend and get new data rerender({ minAge: 30 }) await waitFor(() => { diff --git a/packages/react-db/tests/usePacedMutations.test.tsx b/packages/react-db/tests/usePacedMutations.test.tsx index cbd058c18a..b8b8dc6554 100644 --- a/packages/react-db/tests/usePacedMutations.test.tsx +++ b/packages/react-db/tests/usePacedMutations.test.tsx @@ -123,7 +123,7 @@ describe(`usePacedMutations React hook`, () => { tx = result.current({ id: 1, value: 1 }) }) - await tx!.isPersisted.promise + await tx!.when(`settled`) expect(mutationFn).toHaveBeenCalledTimes(1) expect(tx!.state).toBe(`completed`) }) diff --git a/packages/react-native-db-sqlite-persistence/e2e/mobile-persisted-collection-conformance-suite.ts b/packages/react-native-db-sqlite-persistence/e2e/mobile-persisted-collection-conformance-suite.ts index a6dc32bcee..8080c4f9f4 100644 --- a/packages/react-native-db-sqlite-persistence/e2e/mobile-persisted-collection-conformance-suite.ts +++ b/packages/react-native-db-sqlite-persistence/e2e/mobile-persisted-collection-conformance-suite.ts @@ -80,15 +80,13 @@ function createPersistedCollection( } type PersistedTransactionHandle = { - isPersisted: { - promise: Promise - } + when: (state: 'settled') => Promise } async function waitForPersisted( transaction: PersistedTransactionHandle, ): Promise { - await transaction.isPersisted.promise + await transaction.when(`settled`) } async function seedCollection( diff --git a/packages/react-native-db-sqlite-persistence/tests/react-native-persistence.test.ts b/packages/react-native-db-sqlite-persistence/tests/react-native-persistence.test.ts index aad0c6fe4d..6f6650f4c3 100644 --- a/packages/react-native-db-sqlite-persistence/tests/react-native-persistence.test.ts +++ b/packages/react-native-db-sqlite-persistence/tests/react-native-persistence.test.ts @@ -383,7 +383,7 @@ it(`resumes persisted sync after simulated background/foreground transitions`, a title: `Before background`, score: 1, }) - await initialInsert.isPersisted.promise + await initialInsert.when(`settled`) expect(collection.get(`1`)?.title).toBe(`Before background`) await collection.cleanup() @@ -395,7 +395,7 @@ it(`resumes persisted sync after simulated background/foreground transitions`, a title: `Post resume write`, score: 2, }) - await postResumeInsert.isPersisted.promise + await postResumeInsert.when(`settled`) expect(collection.get(`2`)?.title).toBe(`Post resume write`) const persistedRows = await persistence.adapter.loadSubset( @@ -440,7 +440,7 @@ it(`shared api resumes persisted sync in expo-style lifecycle`, async () => { title: `Before background`, score: 1, }) - await initialInsert.isPersisted.promise + await initialInsert.when(`settled`) expect(collection.get(`1`)?.title).toBe(`Before background`) await collection.cleanup() @@ -452,7 +452,7 @@ it(`shared api resumes persisted sync in expo-style lifecycle`, async () => { title: `Post resume write`, score: 2, }) - await postResumeInsert.isPersisted.promise + await postResumeInsert.when(`settled`) expect(collection.get(`2`)?.title).toBe(`Post resume write`) const persistedRows = await persistence.adapter.loadSubset( diff --git a/packages/rxdb-db-collection/src/rxdb.ts b/packages/rxdb-db-collection/src/rxdb.ts index 3f0c59fd8d..2e7fe00bf2 100644 --- a/packages/rxdb-db-collection/src/rxdb.ts +++ b/packages/rxdb-db-collection/src/rxdb.ts @@ -305,12 +305,10 @@ export function rxdbCollectionOptions( onInsert: async (params) => { debug(`insert`, params) const newItems = params.transaction.mutations.map((m) => m.modified) - return rxCollection.bulkUpsert(newItems).then((result) => { - if (result.error.length > 0) { - throw rxStorageWriteErrorToRxError(ensureNotFalsy(result.error[0])) - } - return result.success - }) + const result = await rxCollection.bulkUpsert(newItems) + if (result.error.length > 0) { + throw rxStorageWriteErrorToRxError(ensureNotFalsy(result.error[0])) + } }, onUpdate: async (params) => { debug(`update`, params) @@ -336,12 +334,10 @@ export function rxdbCollectionOptions( (m) => m.type === `delete`, ) const ids = mutations.map((mutation) => getKey(mutation.original)) - return rxCollection.bulkRemove(ids).then((result) => { - if (result.error.length > 0) { - throw result.error - } - return result.success - }) + const result = await rxCollection.bulkRemove(ids) + if (result.error.length > 0) { + throw result.error + } }, } return withCollectionConfigFactory(collectionConfig, () => diff --git a/packages/rxdb-db-collection/tests/rxdb.test.ts b/packages/rxdb-db-collection/tests/rxdb.test.ts index 17125ab0b7..6e4ffe7a56 100644 --- a/packages/rxdb-db-collection/tests/rxdb.test.ts +++ b/packages/rxdb-db-collection/tests/rxdb.test.ts @@ -372,7 +372,7 @@ describe(`RxDB Integration`, () => { expect(collection.get(`buffered`)).toBeUndefined() persistence.resolve() - await transaction.isPersisted.promise + await transaction.when(`settled`) await ready expect(collection.get(`server`)).toEqual( @@ -385,7 +385,7 @@ describe(`RxDB Integration`, () => { } finally { releaseInitialQuery.resolve() persistence.resolve() - await transaction.isPersisted.promise.catch(() => undefined) + await transaction.when(`settled`).catch(() => undefined) query.mockRestore() await collection.cleanup() await db.remove() @@ -537,7 +537,7 @@ describe(`RxDB Integration`, () => { // inserts const tx = collection.insert({ id: `3`, name: `inserted` }) - await tx.isPersisted.promise + await tx.when(`settled`) let doc = await rxCollection.findOne(`3`).exec(true) expect(doc.name).toEqual(`inserted`) @@ -736,7 +736,7 @@ describe(`RxDB Integration`, () => { // Both old invalid inputs reach the actual provider through persistence. const invalid = { id: `3`, name: `invalid`, foo: `bar` } const insert = collection.insert(invalid) - const inserted = observe(insert.isPersisted.promise) + const inserted = observe(insert.when(`settled`)) expect(collection.has(`3`)).toBe(true) const insertOutcome = await inserted expect(insertOutcome.status).toBe(`rejected`) @@ -750,7 +750,7 @@ describe(`RxDB Integration`, () => { const update = collection.update(`2`, (draft) => { Object.assign(draft, { name: `invalid`, foo: `bar` }) }) - const updated = observe(update.isPersisted.promise) + const updated = observe(update.when(`settled`)) expect(collection.get(`2`)?.name).toBe(`invalid`) const updateOutcome = await updated expect(updateOutcome.status).toBe(`rejected`) @@ -764,7 +764,7 @@ describe(`RxDB Integration`, () => { const next = collection.update(`2`, (draft) => { draft.name = `Valid` }) - expect(await observe(next.isPersisted.promise)).toEqual({ + expect(await observe(next.when(`settled`))).toEqual({ status: `fulfilled`, }) await expectRows([ diff --git a/packages/solid-db/skills/solid-db/SKILL.md b/packages/solid-db/skills/solid-db/SKILL.md index 528a2e185a..f115e1e7b4 100644 --- a/packages/solid-db/skills/solid-db/SKILL.md +++ b/packages/solid-db/skills/solid-db/SKILL.md @@ -59,7 +59,6 @@ Returns an `Accessor>` (or `Accessor` with `findOne`) wi // Query function — call result as function for data const query = useLiveQuery((q) => q.from({ todo: todoCollection })) // query() → Array (data) — or T | undefined when using findOne() -// query.data → DEPRECATED — use query() instead. Migrate any existing code. // query.status → CollectionStatus // query.isLoading, query.isReady // query.isLoadingError → the first load failed: nothing to show @@ -240,7 +239,7 @@ Solid's reactivity tracks signal reads inside reactive contexts. Reading outside Source: docs/framework/solid/overview.md -### MEDIUM Using deprecated query.data instead of query() +### MEDIUM Reading query.data instead of calling query() Wrong: @@ -254,7 +253,7 @@ Correct: {(todo) =>
  • {todo.text}
  • }
    ``` -`query.data` is deprecated. Always use `query()` to access data. If you encounter existing code using `.data`, migrate it to the function call form. +`query.data` has been removed. Call `query()` to access data. See also: db-core/live-queries/SKILL.md — for query builder API. diff --git a/packages/solid-db/src/useLiveQuery.ts b/packages/solid-db/src/useLiveQuery.ts index 875ea1067b..92c69928c1 100644 --- a/packages/solid-db/src/useLiveQuery.ts +++ b/packages/solid-db/src/useLiveQuery.ts @@ -117,11 +117,6 @@ type InferConditionalResultType = export function useLiveQuery( queryFn: (q: InitialQueryBuilder) => QueryBuilder, ): Accessor> & { - /** - * @deprecated use function result instead - * query.data -> query() - */ - data: InferResultType state: ReactiveMap> collection: Collection, string | number, {}> status: CollectionStatus @@ -133,11 +128,6 @@ export function useLiveQuery( q: InitialQueryBuilder, ) => QueryBuilder | undefined | null, ): Accessor> & { - /** - * @deprecated use function result instead - * query.data -> query() - */ - data: InferConditionalResultType state: ReactiveMap> collection: Collection, string | number, {}> | null status: CollectionStatus | `disabled` @@ -187,11 +177,6 @@ export function useLiveQuery( export function useLiveQuery( config: Accessor>, ): Accessor> & { - /** - * @deprecated use function result instead - * query.data -> query() - */ - data: InferResultType state: ReactiveMap> collection: Collection, string | number, {}> status: CollectionStatus @@ -242,11 +227,6 @@ export function useLiveQuery< Collection & NonSingleResult >, ): Accessor> & { - /** - * @deprecated use function result instead - * query.data -> query() - */ - data: Array state: ReactiveMap collection: Collection status: CollectionStatus @@ -262,11 +242,6 @@ export function useLiveQuery< Collection & SingleResult >, ): Accessor & { - /** - * @deprecated use function result instead - * query.data -> query() - */ - data: TResult | undefined state: ReactiveMap collection: Collection & SingleResult status: CollectionStatus @@ -553,11 +528,6 @@ export function useLiveQuery( } Object.defineProperties(getData, { - data: { - get() { - return getData() - }, - }, status: { get() { return status() diff --git a/packages/solid-db/tests/conformance.test.tsx b/packages/solid-db/tests/conformance.test.tsx index a8b314c594..44d074f3be 100644 --- a/packages/solid-db/tests/conformance.test.tsx +++ b/packages/solid-db/tests/conformance.test.tsx @@ -34,6 +34,7 @@ import { import { useLiveQuery } from '../src/useLiveQuery' import { runSuite } from '../../db/tests/conformance/suite' import { createPublicationLog } from '../../db/tests/conformance/publication-log' +import { expectPublishedRows } from '../../db/tests/conformance/publication-laws-oracle' import { makeLaggingSource } from '../../db/tests/conformance/lagging-source' import { makeSyncSource } from '../../db/tests/conformance/sync-source' import { makeErrorSource as makeSharedErrorSource } from '../../db/tests/conformance/error-source' @@ -138,7 +139,7 @@ function makeHandle( current(): ConformanceResult { const result = getResult() return expectResultSurface({ - data: result?.data, + data: result?.(), state: result?.state, status: result?.status, isReady: result?.isReady, @@ -234,7 +235,7 @@ function mountControllable

    ( : // Reading param() inside the query fn makes Solid recompute on change. useLiveQuery((q: any) => build(q, param())) // Solid renders the value each render effect reads. - createRenderEffect(() => published.record(query.data, query.state)) + createRenderEffect(() => published.record(query(), query.state)) return query }) const handle = makeHandle(getResult, dispose) @@ -246,7 +247,7 @@ function mountControllable

    ( setParam(() => next) // The first paint after the change: the framework ran its reactive // work synchronously, but no later task has run. - published.record(getResult().data, getResult().state) + published.record(getResult()(), getResult().state) await settle() }, } @@ -331,8 +332,8 @@ describe(`owned native scope setup`, () => { runSuite(solidDriver) it(`preserves raw result types through the actual driver reader`, () => { - const raw: Record = { - data: [{ id: `a`, value: undefined }], + const rawData = [{ id: `a`, value: undefined }] + const raw = Object.assign(() => rawData, { state: new Map(), status: `disabled`, isReady: false, @@ -340,32 +341,64 @@ it(`preserves raw result types through the actual driver reader`, () => { isCrashed: false, isSyncError: false, isEnabled: false, - } + }) const handle = makeHandle( () => raw, () => {}, ) try { const healthy = handle.current() - expect(healthy.data).toBe(raw.data) + expect(healthy.data).toBe(rawData) expect(healthy.state).toBe(raw.state) expect(healthy.isReady).toBe(false) expect(healthy.isLoadingError).toBe(false) expect(healthy.isEnabled).toBe(false) - for (const key of [`status`, `isReady`, `isLoadingError`]) { + for (const key of [`status`, `isReady`, `isLoadingError`] as const) { const original = raw[key] - delete raw[key] + Reflect.deleteProperty(raw, key) expect(() => handle.current()).toThrowError(new RegExp(`raw ${key}`)) for (const invalid of key === `status` ? [undefined, 0] : [undefined, 0, ``]) { - raw[key] = invalid + Reflect.set(raw, key, invalid) expect(() => handle.current()).toThrowError(new RegExp(`raw ${key}`)) } - raw[key] = original + Reflect.set(raw, key, original) expect(handle.current()[key as keyof ConformanceResult]).toBe(original) } } finally { handle.unmount() } }) + +// These controls supply wrong traces to the same comparison used by the +// shared source-ID and pooled scenarios. They add no production histories. +describe(`settled publication comparison`, () => { + const rows = [{ id: `current` }] + const stale = [{ id: `previous` }] + it.each([ + { name: `settled rows`, values: [rows], expected: rows }, + { + name: `empty loading turns before settled rows`, + values: [undefined, [], rows], + expected: rows, + }, + { + name: `a model whose settled result is empty`, + values: [undefined, []], + expected: [], + }, + ])(`accepts $name`, ({ values, expected }) => { + expectPublishedRows(values, expected) + }) + + it.each([ + { name: `an omitted log`, values: [] }, + { name: `a dropped accessor value`, values: [undefined] }, + { name: `a dropped settled publication`, values: [undefined, []] }, + { name: `stale rows before the settled value`, values: [stale, rows] }, + { name: `stale rows as the settled value`, values: [rows, stale] }, + ])(`rejects $name`, ({ values }) => { + expect(() => expectPublishedRows(values, rows)).toThrow() + }) +}) diff --git a/packages/solid-db/tests/useLiveQuery.test.tsx b/packages/solid-db/tests/useLiveQuery.test.tsx index 612922fcff..c96f7b6997 100644 --- a/packages/solid-db/tests/useLiveQuery.test.tsx +++ b/packages/solid-db/tests/useLiveQuery.test.tsx @@ -731,7 +731,7 @@ describe(`Query Collections`, () => { expect(rendered.result.state.has(`stale`)).toBe(false) expect(rendered.result.state.has(`1`)).toBe(true) - expect(rendered.result.data.map((p: any) => p.id)).toEqual([`1`]) + expect(rendered.result().map((p: any) => p.id)).toEqual([`1`]) expect(rendered.result.status).toBe(`ready`) dispose() @@ -963,7 +963,7 @@ describe(`Query Collections`, () => { expect(result.state.get(`["4","1"]`)).toBeUndefined() // Wait for the transaction to be committed - await transaction.isPersisted.promise + await transaction.when(`settled`) await waitFor(() => { // Wait for the permanent key to appear @@ -2002,7 +2002,7 @@ describe(`Query Collections`, () => { // When callback returns undefined, should return disabled state expect(rendered.result.state.size).toBe(0) - expect(rendered.result.data).toEqual([]) + expect(rendered.result()).toEqual([]) expect(rendered.result.collection).toBeNull() expect(rendered.result.status).toBe(`disabled`) expect(rendered.result.isLoading).toBe(false) @@ -2015,7 +2015,7 @@ describe(`Query Collections`, () => { await waitFor(() => { expect(rendered.result.state.size).toBe(1) // Only John Smith (age 35) }) - expect(rendered.result.data).toHaveLength(1) + expect(rendered.result()).toHaveLength(1) expect(rendered.result.isReady).toBe(true) // Disable the query again @@ -2059,7 +2059,7 @@ describe(`Query Collections`, () => { // When callback returns null, should return disabled state expect(rendered.result.state.size).toBe(0) - expect(rendered.result.data).toEqual([]) + expect(rendered.result()).toEqual([]) expect(rendered.result.collection).toBeNull() expect(rendered.result.status).toBe(`disabled`) expect(rendered.result.isLoading).toBe(false) @@ -2072,7 +2072,7 @@ describe(`Query Collections`, () => { await waitFor(() => { expect(rendered.result.state.size).toBe(1) }) - expect(rendered.result.data).toHaveLength(1) + expect(rendered.result()).toHaveLength(1) expect(rendered.result.isReady).toBe(true) dispose() @@ -2845,7 +2845,7 @@ describe(`Query Collections`, () => { // Resolve the pending sync to complete the transaction collection.utils.resolveSync() - await tx.isPersisted.promise + await tx.when(`settled`) // After sync, should still have 4 items (no duplicates) await waitFor(() => { @@ -2918,7 +2918,7 @@ describe(`Query Collections`, () => { // Resolve the pending sync collection.utils.resolveSync() - await tx.isPersisted.promise + await tx.when(`settled`) // After sync, should still have 3 items with the update persisted await waitFor(() => { @@ -2987,7 +2987,7 @@ describe(`Query Collections`, () => { // Resolve the pending sync collection.utils.resolveSync() - await tx.isPersisted.promise + await tx.when(`settled`) // After sync, should still have 2 items await waitFor(() => { @@ -3083,8 +3083,8 @@ describe(`Query Collections`, () => { // Resolve the shared sync promise — both onInsert and onUpdate complete collection.utils.resolveSync() - await txInsert.isPersisted.promise - await txUpdate.isPersisted.promise + await txInsert.when(`settled`) + await txUpdate.when(`settled`) // After sync complete, should have 4 items with correct data await waitFor(() => { diff --git a/packages/svelte-db/tests/cache-options-oracle.svelte.test.ts b/packages/svelte-db/tests/cache-options-oracle.svelte.test.ts new file mode 100644 index 0000000000..6af5eff543 --- /dev/null +++ b/packages/svelte-db/tests/cache-options-oracle.svelte.test.ts @@ -0,0 +1,114 @@ +/** + * Client-backed Svelte queries receive the same independent-caller conflict + * law as core and React. This driver supplies Svelte's real effect root and + * flushSync cut; it does not restate option conflict semantics. Two admissions, + * original reuse and exact accepted keyed rows are compared after native effect + * delivery. Client-free reactive recompilation is a different contract. + */ +import { afterAll, expect, test } from 'vitest' +import { DbClient, Query, collectionOptions } from '@tanstack/db' +import { flushSync } from 'svelte' +import { useLiveQuery } from '../src/useLiveQuery.svelte.js' +import { + cacheOptionDeclarations, + cacheOptionHistories, + cacheOptionReach, + cacheOptionReachStates, + checkCacheOptionAdmission, + checkCacheOptionRows, + conflictingCacheOptions, +} from '../../db/tests/live-query-cache-options-oracle' +import { withOracleCleanup } from '../../db/tests/utils' +import { declareOracle } from '../../db/tests/oracle-declaration' +import { createOracleReach } from '../../db/tests/oracle-reach' +import type { CacheRow } from '../../db/tests/live-query-cache-options-oracle' + +const reach = createOracleReach(`Svelte cached options`, [ + `admission`, + `retry`, + `original-reuse`, + ...cacheOptionReachStates, +]) +afterAll(() => { + reach.assertReached() + if (process.env.TANSTACK_DB_ORACLE_STATISTICS === `1`) + console.info( + `Svelte cache option reach`, + Object.fromEntries(reach.counts()), + ) +}) +declareOracle({ + check: [checkCacheOptionAdmission, checkCacheOptionRows], + model: [conflictingCacheOptions], + grammar: [cacheOptionHistories], +}) +test.each(cacheOptionHistories)(`Svelte cached options %j`, async (history) => { + reach.beginHistory() + const client = new DbClient() + const rows: Array = [ + { id: `one`, label: `authored` }, + { id: `two`, label: `peer` }, + ] + const source = client.collection( + collectionOptions(`svelte-cache-source`, () => ({ + getKey: (row: CacheRow) => row.id, + sync: { + sync: ({ begin, write, commit, markReady }) => { + begin() + for (const value of rows) + write({ type: `insert`, value: { ...value } }) + commit() + markReady() + }, + }, + })), + ) + const query = new Query().from({ row: source }) + const [a, b] = cacheOptionDeclarations(history) + const first = { client, query, getKey: a!.getKey, queryKey: a!.queryKey } + const second = { client, query, getKey: b!.getKey, queryKey: b!.queryKey } + // Runtime schema identity, with no schema transformation/type claim. + Object.assign(first, { schema: a!.schema }) + Object.assign(second, { schema: b!.schema }) + const disposers: Array<() => void> = [] + const collections = new Set<{ cleanup: () => Promise }>() + const reads: Array<() => void> = [] + const mount = (options: typeof first, declaration: 0 | 1) => { + disposers.push( + $effect.root(() => { + const result = useLiveQuery(options) + collections.add(result.collection) + if (declaration === 0 || !conflictingCacheOptions(history)) + reads.push(() => + checkCacheOptionRows( + Array.from(result.state, ([key, value]) => ({ key, value })), + rows, + history.getKey[declaration], + ), + ) + }), + ) + flushSync() + } + await withOracleCleanup(async () => { + await client.preloadLiveQuery(first) + for (let attempt = 0; attempt < 2; attempt++) { + for (const state of cacheOptionReach(history)) reach.reach(state) + checkCacheOptionAdmission(() => mount(second, 1), history) + reach.reach(attempt === 0 ? `admission` : `retry`) + } + mount(first, 0) + await Promise.resolve() + flushSync() + for (const read of reads) read() + expect(reads.length).toBeGreaterThan(0) + reach.reach(`original-reuse`) + }, [ + () => + withOracleCleanup(() => {}, [ + ...disposers.map((dispose) => () => dispose()), + ...[...collections].map((collection) => () => collection.cleanup()), + () => client.cleanup(), + ]), + ]) +}) diff --git a/packages/svelte-db/tests/descriptor-start-release-oracle.svelte.test.ts b/packages/svelte-db/tests/descriptor-start-release-oracle.svelte.test.ts new file mode 100644 index 0000000000..39b0dd8089 --- /dev/null +++ b/packages/svelte-db/tests/descriptor-start-release-oracle.svelte.test.ts @@ -0,0 +1,80 @@ +/** + * Svelte receiving driver for descriptor-start-release-oracle. The hook rejects + * during descriptor binding inside a native effect root. It must expose the exact factory + * failure and release each earlier acquisition, independently of failed starts. + * Direct demand then starts lazy sources. Disposal and client cleanup release + * every acquired healthy adapter resource. React's render retries do not supply + * evidence for this Svelte setup/effect boundary. + */ +import { expect, it } from 'vitest' +import { flushSync } from 'svelte' +import { useLiveQuery } from '../src/useLiveQuery.svelte.js' +import { + checkDescriptorStart, + descriptorStartHistories, + expectedDescriptorStart, +} from '../../db/tests/descriptor-start-release-oracle' +import { descriptorStartFixture } from '../../db/tests/descriptor-start-release-support' +import { createOracleReach } from '../../db/tests/oracle-reach' +import { declareOracle } from '../../db/tests/oracle-declaration' +import { withOracleCleanup } from '../../db/tests/utils' +import type { DescriptorStartHistory } from '../../db/tests/descriptor-start-release-oracle' + +async function checkHistory(history: DescriptorStartHistory) { + const reach = createOracleReach(`Svelte descriptor starts`, [ + `rejected`, + `read`, + `cleaned`, + ]) + reach.beginHistory() + const fixture = descriptorStartFixture(history, `Svelte`) + const { client, query, failure, factories, startErrors, observe } = fixture + let dispose: (() => void) | undefined + await withOracleCleanup(async () => { + let rejection: unknown + // Acquire the root before the hook can throw, so native disposal remains + // available even when setup does not return its normal result. + dispose = $effect.root(() => { + try { + useLiveQuery({ client, query, queryKey: [fixture.id] }) + } catch (error) { + rejection = error + } + }) + expect(rejection).toBe(failure) + expect(fixture.failures()).toBeGreaterThan(0) + expect(factories).toEqual( + expectedDescriptorStart(history, `rejected`).factories, + ) + fixture.retain() + checkDescriptorStart(observe(), history, `rejected`) + reach.reach(`rejected`) + for (const [index, row] of fixture.collections().entries()) { + try { + row.startSyncImmediate() + } catch (error) { + expect(error).toBe(startErrors[index]) + } + } + flushSync() + checkDescriptorStart(observe(), history, `read`) + reach.reach(`read`) + dispose() + dispose = undefined + await client.cleanup() + checkDescriptorStart(observe(), history, `cleaned`) + reach.reach(`cleaned`) + reach.assertReached() + }, [() => dispose?.(), () => client.cleanup()]) +} + +it.each(descriptorStartHistories)( + `releases independent descriptor starts in Svelte: %j`, + checkHistory, +) + +declareOracle({ + check: [checkDescriptorStart], + model: [expectedDescriptorStart], + grammar: [descriptorStartHistories], +}) diff --git a/packages/tauri-db-sqlite-persistence/e2e/shared/tauri-persisted-collection-harness.ts b/packages/tauri-db-sqlite-persistence/e2e/shared/tauri-persisted-collection-harness.ts index dc31bd5158..0a936ccf70 100644 --- a/packages/tauri-db-sqlite-persistence/e2e/shared/tauri-persisted-collection-harness.ts +++ b/packages/tauri-db-sqlite-persistence/e2e/shared/tauri-persisted-collection-harness.ts @@ -23,9 +23,7 @@ type PersistedCollectionHarness = { } type PersistedTransactionHandle = { - isPersisted: { - promise: Promise - } + when: (state: 'settled') => Promise } type PersistenceFactory = ( @@ -89,7 +87,7 @@ function createPersistedCollection( async function waitForPersisted( transaction: PersistedTransactionHandle, ): Promise { - await transaction.isPersisted.promise + await transaction.when(`settled`) } async function seedCollection( diff --git a/packages/tauri-db-sqlite-persistence/tests/tauri-persistence.test.ts b/packages/tauri-db-sqlite-persistence/tests/tauri-persistence.test.ts index a2a95b4a00..01fd6b7070 100644 --- a/packages/tauri-db-sqlite-persistence/tests/tauri-persistence.test.ts +++ b/packages/tauri-db-sqlite-persistence/tests/tauri-persistence.test.ts @@ -370,7 +370,7 @@ it(`resumes persisted sync after cleanup and restart`, async () => { title: `Before cleanup`, score: 1, }) - await initialInsert.isPersisted.promise + await initialInsert.when(`settled`) expect(collection.get(`1`)?.title).toBe(`Before cleanup`) await collection.cleanup() @@ -382,7 +382,7 @@ it(`resumes persisted sync after cleanup and restart`, async () => { title: `After restart`, score: 2, }) - await resumedInsert.isPersisted.promise + await resumedInsert.when(`settled`) expect(collection.get(`2`)?.title).toBe(`After restart`) const persistedRows = await persistence.adapter.loadSubset( diff --git a/packages/trailbase-db-collection/src/trailbase.ts b/packages/trailbase-db-collection/src/trailbase.ts index 1126fe18c6..c4956328fe 100644 --- a/packages/trailbase-db-collection/src/trailbase.ts +++ b/packages/trailbase-db-collection/src/trailbase.ts @@ -550,9 +550,7 @@ export function trailBaseCollectionOptions< ...collectionConfig, sync, getKey, - onInsert: async ( - params: InsertMutationFnParams, - ): Promise> => { + onInsert: async (params: InsertMutationFnParams) => { const ids = await config.recordApi.createBulk( params.transaction.mutations.map((tx) => { const { type, modified } = tx @@ -567,8 +565,6 @@ export function trailBaseCollectionOptions< // we have to ensure that the new record was properly added to the local // DB by the subscription. await awaitIds(ids.map((id) => String(id))) - - return ids }, onUpdate: async (params: UpdateMutationFnParams) => { const ids: Array = await Promise.all( diff --git a/packages/trailbase-db-collection/tests/lifecycle-oracle.property.test.ts b/packages/trailbase-db-collection/tests/lifecycle-oracle.property.test.ts index 7b191b5a55..d194c3afa8 100644 --- a/packages/trailbase-db-collection/tests/lifecycle-oracle.property.test.ts +++ b/packages/trailbase-db-collection/tests/lifecycle-oracle.property.test.ts @@ -961,9 +961,11 @@ async function checkHeldPage(plan: HeldPlan) { let mutation: ReturnType | undefined if (plan.held) { mutation = observe( - collection.update(2, (draft) => { - draft.data = `local` - }).isPersisted.promise, + collection + .update(2, (draft) => { + draft.data = `local` + }) + .when(`settled`), ) await turn() } diff --git a/packages/trailbase-db-collection/tests/trailbase.test.ts b/packages/trailbase-db-collection/tests/trailbase.test.ts index 2edc529c33..4eda28a43e 100644 --- a/packages/trailbase-db-collection/tests/trailbase.test.ts +++ b/packages/trailbase-db-collection/tests/trailbase.test.ts @@ -399,7 +399,7 @@ describe(`TrailBase Integration`, () => { expect(collection.get(1)).toBeUndefined() resolvePersistence() - await transaction.isPersisted.promise + await transaction.when(`settled`) await preload expect(collection.status).toBe(`ready`) @@ -413,7 +413,7 @@ describe(`TrailBase Integration`, () => { } finally { resolveList({ records: [] }) resolvePersistence() - await transaction.isPersisted.promise.catch(() => undefined) + await transaction.when(`settled`).catch(() => undefined) await collection.cleanup() await Promise.allSettled([preload]) } @@ -557,7 +557,7 @@ describe(`TrailBase Integration`, () => { expect(collection.get(1)).toBeUndefined() abortController.abort() resolvePersistence() - await transaction.isPersisted.promise + await transaction.when(`settled`) if (load === true) { throw new Error(`Expected a pending applied receipt`) } @@ -568,7 +568,7 @@ describe(`TrailBase Integration`, () => { } finally { abortController.abort() resolvePersistence() - await transaction.isPersisted.promise.catch(() => undefined) + await transaction.when(`settled`).catch(() => undefined) await collection.cleanup() } }) @@ -615,14 +615,14 @@ describe(`TrailBase Integration`, () => { expect(collection.get(1)).toBeUndefined() resolvePersistence() - await transaction.isPersisted.promise + await transaction.when(`settled`) if (load instanceof Promise) await load expect(collection.get(1)?.data).toBe(`remote`) expect(collection.get(257)?.data).toBe(`remote`) } finally { resolvePersistence() - await transaction.isPersisted.promise.catch(() => undefined) + await transaction.when(`settled`).catch(() => undefined) await collection.cleanup() } }) @@ -883,12 +883,12 @@ describe(`TrailBase Integration`, () => { } const inserted = collection.insert(data) - outcomes.push(inserted.isPersisted.promise) + outcomes.push(inserted.when(`settled`)) expect(createBulkMock).toHaveBeenCalledOnce() expect(stripState(collection.state)).toEqual(new Map([[data.id, data]])) - await inserted.isPersisted.promise + await inserted.when(`settled`) const updatedData: Data = { ...data, @@ -905,14 +905,14 @@ describe(`TrailBase Integration`, () => { const updated = collection.update(data.id!, (old: Data) => { old.updated = updatedData.updated }) - outcomes.push(updated.isPersisted.promise) + outcomes.push(updated.when(`settled`)) expect(updateMock).toHaveBeenCalledOnce() expect(stripState(collection.state)).toEqual( new Map([[updatedData.id, updatedData]]), ) - await updated.isPersisted.promise + await updated.when(`settled`) const deleteMock = recordApi.delete.mockImplementation( async (_id: string | number) => { @@ -921,12 +921,12 @@ describe(`TrailBase Integration`, () => { ) const deleted = collection.delete(updatedData.id!) - outcomes.push(deleted.isPersisted.promise) + outcomes.push(deleted.when(`settled`)) expect(deleteMock).toHaveBeenCalledOnce() expect(stripState(collection.state)).toEqual(new Map([])) - await deleted.isPersisted.promise + await deleted.when(`settled`) } finally { await collection.cleanup() await Promise.allSettled(outcomes) @@ -1012,8 +1012,9 @@ describe(`TrailBase Integration`, () => { await collection.preload() expect(rows()).toEqual([{ id: 1, updated: 2000, data: `peer` }]) const first = observe( - collection.insert({ id: 2, updated: new Date(7000), data: `local` }) - .isPersisted.promise, + collection + .insert({ id: 2, updated: new Date(7000), data: `local` }) + .when(`settled`), ) await apiEntered.promise expect(first.result).toEqual({ status: `pending` }) @@ -1035,8 +1036,9 @@ describe(`TrailBase Integration`, () => { { id: 2, updated: 8000, data: `acknowledged` }, ]) const rejected = observe( - collection.insert({ id: 3, updated: new Date(9000), data: `rejected` }) - .isPersisted.promise, + collection + .insert({ id: 3, updated: new Date(9000), data: `rejected` }) + .when(`settled`), ) expect(rows()).toContainEqual({ id: 3, updated: 9000, data: `rejected` }) await rejected.outcome @@ -1046,8 +1048,9 @@ describe(`TrailBase Integration`, () => { { id: 2, updated: 8000, data: `acknowledged` }, ]) const next = observe( - collection.insert({ id: 4, updated: new Date(11000), data: `next` }) - .isPersisted.promise, + collection + .insert({ id: 4, updated: new Date(11000), data: `next` }) + .when(`settled`), ) await vi.waitFor(() => expect(recordApi.createBulk).toHaveBeenCalledTimes(3), @@ -1176,7 +1179,7 @@ describe(`TrailBase aborted subset loads`, () => { expect(settled, `load waits for its accepted page`).toBe(false) } release() - await transaction.isPersisted.promise + await transaction.when(`settled`) const outcome = await load.then( () => `resolved`, (error: { name?: string }) => error.name, @@ -1189,7 +1192,7 @@ describe(`TrailBase aborted subset loads`, () => { } finally { controller.abort() release() - await transaction.isPersisted.promise.catch(() => undefined) + await transaction.when(`settled`).catch(() => undefined) await collection.cleanup() } }) diff --git a/packages/vue-db/tests/useLiveQuery.test.ts b/packages/vue-db/tests/useLiveQuery.test.ts index 1379506972..a2fe634376 100644 --- a/packages/vue-db/tests/useLiveQuery.test.ts +++ b/packages/vue-db/tests/useLiveQuery.test.ts @@ -702,7 +702,7 @@ describe(`Query Collections`, () => { expect(state.value.get(`["4","1"]`)).toBeUndefined() // Wait for the transaction to be committed - await transaction.isPersisted.promise + await transaction.when(`settled`) await waitForVueUpdate() diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4cfa0e4632..629123ffc0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,15 +26,33 @@ importers: .: devDependencies: + '@changesets/apply-release-plan': + specifier: 7.0.14 + version: 7.0.14 + '@changesets/assemble-release-plan': + specifier: 6.0.9 + version: 6.0.9 '@changesets/cli': specifier: ^2.29.8 version: 2.29.8(@types/node@25.2.2) + '@changesets/config': + specifier: 3.1.2 + version: 3.1.2 + '@changesets/get-release-plan': + specifier: 4.0.14 + version: 4.0.14 + '@changesets/read': + specifier: 0.6.6 + version: 0.6.6 '@eslint/js': specifier: ^9.39.2 version: 9.39.4 '@fast-check/vitest': specifier: ^0.4.1 version: 0.4.1(vitest@4.1.12) + '@manypkg/get-packages': + specifier: 1.1.3 + version: 1.1.3 '@svitejs/changesets-changelog-github-compact': specifier: ^1.2.0 version: 1.2.0 @@ -1481,14 +1499,13 @@ importers: version: 4.1.12(vitest@4.1.12) packages/offline-transactions: - dependencies: - '@tanstack/db': - specifier: workspace:* - version: link:../db devDependencies: '@react-native-community/netinfo': specifier: 11.4.1 version: 11.4.1(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.2.13)(react@19.2.4)) + '@tanstack/db': + specifier: workspace:* + version: link:../db '@types/node': specifier: ^25.2.2 version: 25.2.2 @@ -1558,7 +1575,7 @@ importers: specifier: ^1.1.0 version: 1.1.0 '@tanstack/db': - specifier: workspace:* + specifier: ^1.0.0-rc.0 version: link:../db typescript: specifier: '>=4.7' diff --git a/scripts/bench/incremental-update.ts b/scripts/bench/incremental-update.ts index c7284020ee..9d146db630 100644 --- a/scripts/bench/incremental-update.ts +++ b/scripts/bench/incremental-update.ts @@ -686,7 +686,7 @@ function writeOptimistic(write: () => void) { autoCommit: false, mutationFn: async () => {}, }) - transaction.isPersisted.promise.catch(() => undefined) + void transaction.when(`settled`).catch(() => undefined) transaction.mutate(write) return transaction } diff --git a/scripts/doc-snippets-baseline.json b/scripts/doc-snippets-baseline.json index ffbdb32f5f..3ecffabb8d 100644 --- a/scripts/doc-snippets-baseline.json +++ b/scripts/doc-snippets-baseline.json @@ -26,8 +26,7 @@ "3c4eb9ac68393d56", "574da77a1ab435b7", "593fdd8f16e7bb3c", - "d939a4d773c025d9", - "de4f60e52a1fded4" + "d939a4d773c025d9" ], "docs/collections/query-collection.md": [ "17ecec465534b4b7", @@ -36,14 +35,12 @@ "591e3701f69c95b4", "81737982980a49c2", "890dfc8753b21594", - "8d67ffec676bff5e", "96e4d491476525eb", "c0a80e7b00dfc5a2", "c189db28619a7013", "c587b8f2c29a2bef", "cf8f717f9e183cca", "d014ee810380decc", - "d0c90e4498c30842", "f4bab217efc97d4d" ], "docs/collections/query-initial-placeholder-data-design.md": [ @@ -63,16 +60,13 @@ "docs/framework/angular/overview.md": ["55d749380b445d1a"], "docs/framework/preact/overview.md": ["a3802a251c868f43"], "docs/framework/react/overview.md": ["1c1f2ec0a48394e3", "d89b6c8f8cab169d"], - "docs/framework/solid/overview.md": ["1d73c50b6fbcdc45"], "docs/guides/collection-options-creator.md": [ "305eb0ca15d244e8", "317682b162fb9c23", "5a39ac2d8bf736c1", - "77c6a60683015923", "7eb96bb3e1a02559", "9516c46255070495", "aa0717c5a01bd68e", - "c73aac2da56ce5f1", "cb2681c7a735a057", "e80ac4d8045ae2ac", "ee3484e10b6ef7a6", @@ -86,11 +80,9 @@ "5b84d211959180fa", "616d91cebb8e0444", "64761975ec476980", - "699b7c7f0952cfb2", "8f931d6f08c9b84e", "acc6b4f0b6366b01", "c1a0fb0441cd511f", - "d0aa6010f82c4b43", "f78881b9544ee2bd" ], "docs/guides/live-queries.md": [ @@ -141,11 +133,9 @@ "3460d25e3e94fea2", "451e79898e806bef", "52d5ff6370f1e6a0", - "6313feb09c9181cf", "6bbdb9e1f2eb68c4", "768ea5ce66daefc9", "8f31e34bc4d76866", - "97a1313a585d3c63", "aaa22bc8fac514ab", "ac00a2c29c780401", "bbc5202625c594e5", @@ -169,7 +159,6 @@ "3c86b5b5cb766d75", "468a5432574ef426", "47ce624e6a63284d", - "84217eb306ebf4d5", "9a89b362c8137d5f", "a36a525b02c6d42e", "b3e1d768a9f479ca", @@ -197,15 +186,9 @@ "6f61f8d0d7505d02", "841621123beacf7a", "a48a6a8b08a220ee", - "b4543f4a42abdb64", - "d8fc3756c3a844a9" - ], - "docs/quick-start.md": [ - "03847424308f7afc", - "4a2027fd4417f1aa", - "5509cb0c141754ce", - "d89b6c8f8cab169d" + "b4543f4a42abdb64" ], + "docs/quick-start.md": ["5509cb0c141754ce", "d89b6c8f8cab169d"], "packages/angular-db/skills/angular-db/SKILL.md": [ "1f8763adf5d59713", "86bdea1b8ff91fb8", @@ -215,7 +198,6 @@ ], "packages/db/skills/db-core/collection-setup/references/electric-adapter.md": [ "02c4838714657357", - "3ce433265b840116", "5bec8f41c9b181c6" ], "packages/db/skills/db-core/collection-setup/references/local-adapters.md": [ @@ -228,8 +210,6 @@ "ff5aa699d975cc63" ], "packages/db/skills/db-core/collection-setup/references/query-adapter.md": [ - "05af94d61ef8f1c4", - "2ab7fed186602b02", "35a41e0298dd191f", "376463dd17f34ad4", "682d8eef9f96dc06", @@ -245,7 +225,6 @@ "packages/db/skills/db-core/collection-setup/SKILL.md": [ "09a82388169989af", "1c3a256040dcd523", - "7c5bcf2fa8e357db", "7c956187bfc7b417", "8ad94a17eef5ac99", "9be49564d49c3537", @@ -280,14 +259,12 @@ "b63ca0e85298580e", "c2607a812bf23a87", "c3641a3e3c7e04b5", - "cef7872ef0ebeef3", - "efb992514857e03c" + "cef7872ef0ebeef3" ], "packages/db/skills/db-core/mutations-optimistic/SKILL.md": [ "0e8055b0d2ce8000", "3ef7d931dfa74957", "46ebcf40013c34ed", - "5413867e633869e0", "691a5679dacefc9a", "c39e554a3dc95a71" ], diff --git a/scripts/eslint-plugin-oracle/no-disabled.test.js b/scripts/eslint-plugin-oracle/no-disabled.test.js index 40cc1a4449..db699657d2 100644 --- a/scripts/eslint-plugin-oracle/no-disabled.test.js +++ b/scripts/eslint-plugin-oracle/no-disabled.test.js @@ -1,4 +1,8 @@ import { describe, it } from 'vitest' +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' import { RuleTester } from 'eslint' import tsParser from '@typescript-eslint/parser' import noDisabled from './rules/no-disabled.js' @@ -10,6 +14,35 @@ const ruleTester = new RuleTester({ languageOptions: { parser: tsParser }, }) +it(`permits selecting only the replay observation checker controls`, () => { + const root = fileURLToPath(new URL('../../', import.meta.url)) + const result = spawnSync( + process.execPath, + [ + join(root, 'node_modules/vitest/vitest.mjs'), + 'run', + '--config', + 'scripts/eslint-plugin-oracle/vitest.config.mjs', + 'scripts/eslint-plugin-oracle/replay-observation-oracle.test.js', + '-t', + 'rejects wrong report observation', + '--reporter=json', + ], + { + cwd: root, + encoding: 'utf8', + timeout: 10_000, + maxBuffer: 16 * 1024 * 1024, + }, + ) + assert.equal(result.error, undefined) + assert.equal(result.status, 0, result.stdout + result.stderr) + const report = JSON.parse(result.stdout) + assert.equal(report.numPassedTests, 2) + assert.equal(report.numFailedTests, 0) + assert.equal(report.numPendingTests, 3848) +}) + ruleTester.run(`oracle/no-disabled`, noDisabled, { valid: [ // A selection helper's result used as a registration, with no modifier. @@ -61,6 +94,18 @@ ruleTester.run(`oracle/no-disabled`, noDisabled, { // runs no property. `expect(report).toMatchObject({ numRuns: 1, failed: true })`, `fc.sample(historyArbitrary, { numRuns: 1 })`, + { + code: `const witness: OracleReplayWitness = { numRuns: 1 }; appendFileSync(channel, JSON.stringify(witness))`, + filename: `/repo/packages/db/tests/oracle-replay-witness.ts`, + }, + { + code: `const witness: OracleReplayWitness = { numRuns: 1 }; type Snapshot = typeof witness; appendFileSync(channel, JSON.stringify(witness)); type Count = typeof witness.numRuns`, + filename: `/repo/packages/db/tests/oracle-replay-witness.ts`, + }, + { + code: `type JSON = { marker: true }; const witness: OracleReplayWitness = { numRuns: 1 }; JSON.stringify(witness)`, + filename: `/repo/packages/db/tests/oracle-replay-witness.ts`, + }, // A known gap is pinned as failing on a fixed seed, not skipped. `it.fails('checks the fixed history', () => fc.assert(property, { seed: 2128, numRuns: oracleRuns(12) }))`, // The shared replay helpers may select a replay, in either polarity for @@ -100,6 +145,24 @@ ruleTester.run(`oracle/no-disabled`, noDisabled, { `it('reads a page', (_, ctx) => { ctx.skip() })`, ], invalid: [ + ...[ + `fc.assert(property, { numRuns: 1 })`, + `const witness: OracleReplayWitness = { numRuns: 1 }; appendFileSync(channel, JSON.stringify(witness)); fc.assert(property, witness)`, + `const witness: OracleReplayWitness = { numRuns: 1 }; const options = witness; fc.assert(property, options)`, + `export const witness: OracleReplayWitness = { numRuns: 1 }; JSON.stringify(witness)`, + `function outer(JSON) { { type JSON = { marker: true }; const witness: OracleReplayWitness = { numRuns: 1 }; JSON.stringify(witness) } }`, + `const budgetHook = { toJSON() { fc.assert(property, this); return null } }; const witness: OracleReplayWitness = { ...budgetHook, numRuns: 1 }; JSON.stringify(witness)`, + `const budgetHook = { toJSON() { fc.assert(property, this); return null } }; const witness: OracleReplayWitness = { __proto__: budgetHook, numRuns: 1 }; JSON.stringify(witness)`, + ].map((code) => ({ + code, + filename: `/repo/packages/db/tests/oracle-replay-witness.ts`, + errors: [{ messageId: `singleRun` }], + })), + { + code: `const witness: OracleReplayWitness = { numRuns: 1 }; appendFileSync(channel, JSON.stringify(witness))`, + filename: `/repo/packages/db/tests/other-output.ts`, + errors: [{ messageId: `singleRun` }], + }, // A private variable that selects a replay or a run count. { code: `const seed = process.env.TANSTACK_DB_LIKE_ORACLE_SEED`, diff --git a/scripts/eslint-plugin-oracle/replay-observation-oracle.test.js b/scripts/eslint-plugin-oracle/replay-observation-oracle.test.js new file mode 100644 index 0000000000..21c226bd1c --- /dev/null +++ b/scripts/eslint-plugin-oracle/replay-observation-oracle.test.js @@ -0,0 +1,250 @@ +/** + * A completed-history count is output data, not a generated-property budget. + * The maintainer's 2026-10-28 decision permits the typed OracleReplayWitness + * record in its shared writer only when every value use serializes that record. + * AGENTS.md still requires one-run unpinned property options to be reported. + * A type annotation cannot excuse an options alias, escape, mutation or second + * use. The initializer must be a plain data record with witness fields; custom + * serialization hooks can receive the record without a binding reference. + * This boundary owns only that distinction, not all no-disabled syntax. + * + * The finite source grammar varies location, annotation, value use, type-only + * queries, JSON bindings and record spelling. Its intent model knows whether + * the authored program is exclusively + * recording a witness; it never parses source or invokes the rule. ESLint's + * real TypeScript parser and no-disabled rule supply the report observation. + * The comparison runs after linting and retains every report, including parser + * errors. There is no random sampling, filesystem fixture or external process. + * Global JSON.stringify is the supported serializer; its unmodified standard + * behavior is a trusted premise. Global monkeypatches are outside this law. + * Other serializers, + * destructuring and intermodule flow are deliberately outside the allowance. + */ +import assert from 'node:assert/strict' +import tsParser from '@typescript-eslint/parser' +import { Linter } from 'eslint' +import { afterAll, test } from 'vitest' +import { declareOracle } from '../../packages/db/tests/oracle-declaration.js' +import { createOracleReach } from '../../packages/db/tests/oracle-reach.js' +import noDisabled from './rules/no-disabled.js' + +const writer = '/repo/packages/db/tests/oracle-replay-witness.ts' +const flows = [ + 'serialize', + 'budget', + 'both', + 'alias', + 'escape', + 'reassign', + 'shadow-json', + 'read-field', + 'replacer', + 'export', + 'runtime-typeof', +] +const jsonBindings = [ + 'builtin', + 'type-alias', + 'interface', + 'import-type', + 'import-type-specifier', + 'ambient-value', + 'outer-value-inner-type', +] +const programs = [] +for (const location of ['writer', 'lookalike']) + for (const annotation of ['witness', 'other', 'none']) + for (const flow of flows) + for (const name of ['record', 'serializedProof']) + for (const typeQuery of ['none', 'direct', 'qualified', 'before']) + for (const jsonBinding of jsonBindings) { + // An export inside a function is illegal TypeScript. Other axes + // remain independent; the grammar never relies on parser recovery. + if (flow === 'export' && jsonBinding === 'outer-value-inner-type') + continue + programs.push({ + location, + annotation, + flow, + name, + typeQuery, + jsonBinding, + initializer: 'plain', + }) + } +// Custom hooks challenge the otherwise admissible recording programs. Crossing +// them with already rejected uses would hide whether this new guard runs. +for (const program of [...programs]) + if ( + program.location === 'writer' && + program.annotation === 'witness' && + program.flow === 'serialize' && + !['ambient-value', 'outer-value-inner-type'].includes(program.jsonBinding) + ) + for (const initializer of [ + 'spread', + 'method', + 'accessor', + 'prototype', + 'computed', + ]) + programs.push({ ...program, initializer }) + +// Only a dedicated recording program emits a count observation. Every other +// authored program has an unpinned literal count whose enforcement must remain. +function expectedReports(program) { + return program.location === 'writer' && + program.annotation === 'witness' && + program.flow === 'serialize' && + program.jsonBinding !== 'ambient-value' && + program.jsonBinding !== 'outer-value-inner-type' && + program.initializer === 'plain' + ? [] + : ['singleRun'] +} + +function source(program) { + const { name, annotation, flow } = program + const type = + annotation === 'none' + ? '' + : `: ${annotation === 'witness' ? 'OracleReplayWitness' : 'OtherRecord'}` + const hook = + 'toJSON(this: OracleReplayWitness) { fc.assert(property, this); return null }' + const custom = + program.initializer === 'spread' + ? '...budgetHook,' + : program.initializer === 'prototype' + ? '__proto__: budgetHook,' + : program.initializer === 'method' + ? `${hook},` + : program.initializer === 'accessor' + ? 'get toJSON() { return function() { fc.assert(property, this); return null } },' + : program.initializer === 'computed' + ? '["toJSON"]() { fc.assert(property, this); return null },' + : '' + const record = `const budgetHook = { ${hook} };\n${flow === 'export' ? 'export ' : ''}${flow === 'reassign' ? 'let' : 'const'} ${name}${type} = { ${custom} numRuns: 1, failed: false }` + const serialize = `appendFileSync(channel, JSON.stringify(${name}));` + const use = + flow === 'serialize' || flow === 'export' + ? `${serialize}\n${serialize}` + : flow === 'budget' + ? `fc.assert(property, ${name})` + : flow === 'both' + ? `${serialize}\nfc.assert(property, ${name})` + : flow === 'alias' + ? `const options = ${name}; fc.assert(property, options)` + : flow === 'escape' + ? `send(${name})` + : flow === 'reassign' + ? `${name} = replacement; ${serialize}` + : flow === 'shadow-json' + ? serialize + : flow === 'replacer' + ? `JSON.stringify(${name}, (_key, value) => { fc.assert(property, value); return value })` + : flow === 'runtime-typeof' + ? `${serialize}\ntypeof ${name}` + : `${serialize}\nfc.assert(property, { numRuns: ${name}.numRuns })` + const query = + program.typeQuery === 'none' + ? '' + : `type Snapshot = typeof ${name}${program.typeQuery === 'qualified' ? '.numRuns' : ''};` + const body = `${record};\n${program.typeQuery === 'before' ? query : ''}\n${use};\n${program.typeQuery === 'before' ? '' : query}` + const rendered = + flow === 'shadow-json' ? `function encode(JSON) { ${body} }` : body + if (program.jsonBinding === 'outer-value-inner-type') + return `function outer(JSON) { { type JSON = { marker: true }; ${rendered} } }` + const prefix = + program.jsonBinding === 'type-alias' + ? 'type JSON = { marker: true };' + : program.jsonBinding === 'interface' + ? 'interface JSON { marker: true };' + : program.jsonBinding === 'import-type' + ? 'import type JSON from "types";' + : program.jsonBinding === 'import-type-specifier' + ? 'import { type JSON } from "types";' + : program.jsonBinding === 'ambient-value' + ? 'declare const JSON: unknown;' + : '' + return `${prefix}\n${rendered}` +} + +// This driver asks the actual rule for all reports, rather than classifying its +// AST ourselves. Binding spelling must not change the observation. +function reports(program) { + return new Linter({ cwd: '/repo' }) + .verify( + source(program), + [ + { + files: ['**/*.ts'], + languageOptions: { + parser: tsParser, + parserOptions: { ecmaVersion: 'latest', sourceType: 'module' }, + }, + plugins: { oracle: { rules: { 'no-disabled': noDisabled } } }, + rules: { 'oracle/no-disabled': 'error' }, + }, + ], + { + filename: + program.location === 'writer' + ? writer + : '/repo/packages/db/tests/witness-output.ts', + }, + ) + .map((report) => report.messageId ?? `parse:${report.message}`) +} + +function checkReports(program, observed) { + assert.deepEqual(observed, expectedReports(program), JSON.stringify(program)) +} + +const stateOf = ({ + location, + annotation, + flow, + typeQuery, + jsonBinding, + initializer, +}) => + `${location}/${annotation}/${flow}/${typeQuery}/${jsonBinding}/${initializer}` +const states = [...new Set(programs.map(stateOf))] +const reach = createOracleReach('lint.replay-observation', states, { + replaying: false, +}) +let campaignEntered = false +afterAll(() => { + // Calibrations can be selected independently. Once any source comparison + // starts, the finite campaign still owes every declared reach state. + if (campaignEntered) reach.assertReached() +}) +declareOracle({ + check: [checkReports], + model: [expectedReports], + grammar: [programs], +}) + +test.each(programs)( + 'separates replay observations from budgets %j', + (program) => { + campaignEntered = true + reach.beginHistory() + const observed = reports(program) + reach.reach(stateOf(program)) + checkReports(program, observed) + }, +) + +test.each(['missing-report', 'spurious-report'])( + 'rejects wrong report observation %s', + (fault) => { + const program = programs.find( + (candidate) => + (expectedReports(candidate).length === 0) === + (fault === 'spurious-report'), + ) + const wrong = expectedReports(program).length === 0 ? ['singleRun'] : [] + assert.throws(() => checkReports(program, wrong)) + }, +) diff --git a/scripts/eslint-plugin-oracle/rules/no-disabled.js b/scripts/eslint-plugin-oracle/rules/no-disabled.js index b795adf68e..2967ac95ef 100644 --- a/scripts/eslint-plugin-oracle/rules/no-disabled.js +++ b/scripts/eslint-plugin-oracle/rules/no-disabled.js @@ -407,6 +407,115 @@ function isPinnedOrObservation(object, sourceCode) { return root === `expect` || (root === `fc` && names[0] === `sample`) } +/** + * The shared replay writer serializes completed-history counts. They are + * observations, not run options. A type name alone proves nothing: every + * value reference must pass directly to the unshadowed one-argument standard + * serializer. Aliases, writes, callbacks and additional uses retain reporting. + */ +function isSerializedReplayWitness(object, filename, sourceCode) { + if ( + !/(^|[/\\])packages[/\\]db[/\\]tests[/\\]oracle-replay-witness\.ts$/.test( + filename, + ) + ) + return false + // Standard JSON invokes own or inherited toJSON hooks. A spread, accessor, + // method or prototype field can expose the record through `this` without + // another binding reference. Only the plain witness data shape qualifies. + const fields = new Set([`property`, `seed`, `path`, `numRuns`, `failed`]) + if ( + !object.properties.every( + (property) => + property.type === `Property` && + property.kind === `init` && + !property.method && + !property.computed && + fields.has(propertyName(property)), + ) + ) + return false + const declaration = object.parent + if ( + declaration.type !== `VariableDeclarator` || + declaration.init !== object || + declaration.id.type !== `Identifier` + ) + return false + if ( + declaration.parent.type === `VariableDeclaration` && + declaration.parent.parent.type === `ExportNamedDeclaration` + ) + return false + const id = declaration.id + const type = id.typeAnnotation?.typeAnnotation + if ( + type?.type !== `TSTypeReference` || + type.typeName.type !== `Identifier` || + type.typeName.name !== `OracleReplayWitness` + ) + return false + const variable = findVariable(sourceCode.getScope(object), id.name) + if (variable === null || variable === undefined) return false + const uses = variable.references.filter(({ identifier }) => { + if (identifier === id) return false + // A TS typeof query resolves a value symbol, so scope-manager marks it as + // a value reference. Its syntax still has no runtime consumption. + let name = identifier + while (name.parent.type === `TSQualifiedName` && name.parent.left === name) + name = name.parent + return name.parent.type !== `TSTypeQuery` || name.parent.exprName !== name + }) + return ( + uses.length > 0 && + uses.every((reference) => { + if (!reference.isReadOnly()) return false + const call = reference.identifier.parent + if ( + call.type !== `CallExpression` || + call.optional || + call.arguments.length !== 1 || + call.arguments[0] !== reference.identifier + ) + return false + const callee = call.callee + if ( + callee.type !== `MemberExpression` || + callee.computed || + callee.optional || + callee.object.type !== `Identifier` || + callee.object.name !== `JSON` || + callee.property.type !== `Identifier` || + callee.property.name !== `stringify` + ) + return false + // Type-only names do not shadow the standard runtime JSON binding. Keep + // walking: an inner type alias can hide an outer runtime parameter from + // findVariable, although that parameter still supplies JSON.stringify. + for ( + let scope = sourceCode.getScope(call); + scope !== null; + scope = scope.upper + ) { + const json = scope.set.get(`JSON`) + if ( + json?.defs.some( + (definition) => + definition.isVariableDefinition !== false && + !( + definition.type === `ImportBinding` && + (definition.parent?.importKind === `type` || + definition.node.importKind === `type`) + ), + ) + ) + return false + } + return true + }) + ) +} + /** * Whether a call skips the test that it runs in through the test context, * as in `it('law', (ctx) => { ctx.skip() })`. @@ -503,7 +612,12 @@ export default { parent.type === `Property` && propertyName(parent) === `numRuns` && parent.parent.type === `ObjectExpression` && - isPinnedOrObservation(parent.parent, sourceCode) + (isPinnedOrObservation(parent.parent, sourceCode) || + isSerializedReplayWitness( + parent.parent, + context.filename, + sourceCode, + )) ) } diff --git a/scripts/finite-comparison.js b/scripts/finite-comparison.js new file mode 100644 index 0000000000..a1c66de252 --- /dev/null +++ b/scripts/finite-comparison.js @@ -0,0 +1,29 @@ +/** Record a finite execution only after its driver enters a law comparison. */ +export async function withFiniteComparison(action, record) { + let compared = false + let failed = false + let primary + try { + return await action(() => { + compared = true + }) + } catch (error) { + failed = true + primary = error + throw error + } finally { + if (compared) { + try { + record({ failed }) + } catch (witness) { + throw failed + ? new AggregateError( + [primary, witness], + 'Finite comparison and witness failures', + { cause: primary }, + ) + : witness + } + } + } +} diff --git a/scripts/generate-error-docs.mjs b/scripts/generate-error-docs.mjs index df87daf75e..2479af0320 100644 --- a/scripts/generate-error-docs.mjs +++ b/scripts/generate-error-docs.mjs @@ -4,11 +4,24 @@ // coded error site, a plain Error thrown elsewhere, gets an anchor, its source // file, and its message template. // With `--check`, it fails instead of writing when the page is out of date. +// With `--stdout`, it emits the fresh page without reading or writing docs. // packages/db/tests/production-error-messages-oracle.test.ts checks the headings. import { readFile, writeFile } from 'node:fs/promises' import path from 'node:path' import { fileURLToPath } from 'node:url' +const args = process.argv.slice(2) +const [mode] = args +if ( + args.length > 1 || + (mode !== undefined && !['--check', '--stdout'].includes(mode)) +) { + console.error( + 'Usage: node scripts/generate-error-docs.mjs [--check | --stdout]', + ) + process.exit(1) +} + const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') const fixtures = path.join(root, 'packages/db/tests/fixtures') const read = async (name) => @@ -69,7 +82,7 @@ Each heading names the source file under \`packages/db/src\` that writes it. ${siteSections.join('\n')}` -if (process.argv.includes('--check')) { +if (mode === '--check') { const current = await readFile(pagePath, 'utf8').catch(() => '') if (current !== page) { console.error( @@ -77,6 +90,8 @@ if (process.argv.includes('--check')) { ) process.exit(1) } +} else if (mode === '--stdout') { + process.stdout.write(page) } else { await writeFile(pagePath, page) } diff --git a/scripts/publish-history-oracle.property.test.js b/scripts/publish-history-oracle.property.test.js new file mode 100644 index 0000000000..538ae0755d --- /dev/null +++ b/scripts/publish-history-oracle.property.test.js @@ -0,0 +1,181 @@ +/** + * Candidates must reach rc and stable versions must reach latest, independent + * of registry history. CONTRIBUTING.md supplies this policy; stable minors are + * the maintainer's deliberate policy. The shared model states the independent + * version/tag law. A registry ledger is controlled input, not a tag classifier. + * + * The receiver invokes production publishDuringRc with its default command. + * A strict pnpm shim forwards that command to the installed real Changesets + * CLI. Strict npm/pnpm shims then supply registry metadata and record every info + * and publish attempt, including failures. No process falls through to npm or + * a real registry. Successful partial publishes persist across one retry; + * manifest/pre-state restoration is compared after each attempt. + * + * This finite grammar crosses numeric candidate boundaries, stable/only-RC/ + * empty/already-current registry histories and failure in either publish phase. + * An already-current workspace cannot have an attempted publish failure, so + * those impossible cells are excluded. Npm HTTP, tarballs, installed consumers, + * credential authentication and GitHub Actions themselves are not claimed. + */ +import assert from 'node:assert/strict' +import { test } from 'vitest' +import { declareOracle } from '../packages/db/tests/oracle-declaration.js' +import { createOracleReach } from '../packages/db/tests/oracle-reach.js' +import { + checkPublishObservation, + expectedPublishes, + packageNames, + publishHistories, +} from './release-history-oracle.js' +import { + preparePublishReceiver, + withReleaseWorkspace, +} from './release-fixture.js' +import { publishDuringRc } from './publish-packages.mjs' + +declareOracle({ + check: [checkPublishObservation], + model: [expectedPublishes], + grammar: [publishHistories], +}) + +test.each(publishHistories)( + 'release command registry history %j', + async (history) => { + const reach = createOracleReach( + 'release.command-registry', + [ + 'registry-read', + 'restored', + 'compared', + ...(history.failure === 'none' ? [] : ['failure-observed', 'retried']), + ...(history.registry === 'current' + ? ['already-current'] + : ['new-version']), + ], + { replaying: false }, + ) + reach.beginHistory() + await withReleaseWorkspace(async ({ root }) => { + const receiver = preparePublishReceiver(root, history) + const expected = expectedPublishes( + receiver.manifests, + receiver.initialRegistry, + ) + const before = receiver.bytes() + let commandError + try { + await receiver.run(publishDuringRc) + } catch (error) { + commandError = error + } + const firstRequests = receiver.readRequests() + if (history.failure === 'none') { + if (commandError) + throw new Error( + 'Command failed outside controlled registry failure', + { cause: commandError }, + ) + } else { + const failure = firstRequests.find( + (entry) => entry.type === 'publish' && !entry.success, + ) + // A setup failure is not evidence for retry or restoration. The exact + // controlled failed request must exist before the expected error counts. + if (!failure || !commandError) + throw new Error( + 'Controlled package-publish failure was not observed', + { cause: commandError }, + ) + assert.equal( + failure.name, + history.failure === 'stable' ? packageNames.query : packageNames.db, + 'failure reaches its requested publish phase', + ) + reach.reach('failure-observed') + } + assert.deepEqual( + receiver.bytes(), + before, + 'publish attempt restores original workspace bytes', + ) + if (history.failure !== 'none') { + await receiver.run(publishDuringRc) + assert.deepEqual( + receiver.bytes(), + before, + 'publish retry restores original workspace bytes', + ) + reach.reach('retried') + } + reach.reach('restored') + const requests = receiver.readRequests() + const infoNames = new Set( + requests + .filter((entry) => entry.type === 'info') + .map((entry) => entry.name), + ) + assert.deepEqual( + [...infoNames].sort(), + Object.values(packageNames).sort(), + 'real Changesets inspected both candidate and stable package phases', + ) + reach.reach('registry-read') + const failures = requests.filter( + (entry) => entry.type === 'publish' && !entry.success, + ) + assert.equal( + failures.length, + history.failure === 'none' ? 0 : 1, + 'the retry has exactly one controlled failed request', + ) + checkPublishObservation( + requests, + expected, + 'registry after publish and optional retry', + ) + reach.reach('compared') + reach.reach( + history.registry === 'current' ? 'already-current' : 'new-version', + ) + reach.assertReached() + }) + }, + 20_000, +) + +// Wrong observations reuse the same judgment as the command receiver. Sorting +// permits concurrent package order, but must retain omission and multiplicity. +test.each(['duplicate', 'omission', 'tag', 'version', 'registry', 'access'])( + 'release publish judgment rejects wrong %s observation', + (fault) => { + const expected = [ + { name: packageNames.db, version: '1.0.0-rc.1.10', tag: 'rc' }, + ] + const correct = [ + { + type: 'publish', + ...expected[0], + success: true, + registry: 'http://127.0.0.1:4873', + access: 'public', + }, + ] + checkPublishObservation(correct, expected, 'legal calibration') + const wrong = structuredClone(correct) + if (fault === 'duplicate') wrong.push({ ...wrong[0] }) + else if (fault === 'omission') wrong.length = 0 + else + wrong[0][fault] = + fault === 'tag' + ? 'latest' + : fault === 'version' + ? '1.0.0-rc.1.9' + : fault === 'registry' + ? 'https://example.invalid' + : 'restricted' + assert.throws(() => + checkPublishObservation(wrong, expected, 'wrong calibration'), + ) + }, +) diff --git a/scripts/publish-http-oracle.property.test.js b/scripts/publish-http-oracle.property.test.js new file mode 100644 index 0000000000..bf1569aefe --- /dev/null +++ b/scripts/publish-http-oracle.property.test.js @@ -0,0 +1,452 @@ +/** + * Candidate releases reach rc; stable releases reach latest exactly once. + * Authority: CONTRIBUTING.md Changesets/Releases and the accepted RC policy. + * The existing release ledger supplies the independent version/tag judgment. + * This receiver crosses the real npm11/pnpm11/Changesets HTTP boundary, with + * the default production publish command and real package-manager archives. + * No command shim chooses a tag or fabricates a successful publication. + * + * The bounded shared grammar retains the original metadata/failure histories + * and varies exact-version presence independently of tags. Current candidates + * can have only latest, no tags, or rc pointing to an older present version. + * The exact wrong-tag aftermath keeps old rc alongside latest=current. A + * separate stable-phase neighbor removes stable latest while versions remain. + * Missing targets also have a no-tags neighbor. Two different candidate + * versions vary which package is already current and reverse their assignment. + * A later-candidate fault follows an actual successful candidate upload, in + * either package order. Retry must retain it without another upload. + * Replay selects the complete unique Vitest test title; this finite enumeration + * has no seed/shrink interface and claims no arbitrary schedules or semver. + * + * The fixture supplies local workspace links directly for packing. It disables + * automatic installation, isolates configuration/cache/credentials and keeps + * HOME unchanged. This establishes HTTP uploads and packed manifest identity, + * not repository installation, consumer APIs, production authentication or + * GitHub Actions. POSIX process groups are required. Standard tools and their + * required loopback socket access are setup premises; missing tools, malformed + * protocol or timeout are fixture/runtime failures, not tag-law RED evidence. + */ +import assert from 'node:assert/strict' +import { test } from 'vitest' +import { declareOracle } from '../packages/db/tests/oracle-declaration.js' +import { createOracleReach } from '../packages/db/tests/oracle-reach.js' +import { + checkPublishObservation, + expectedPublishes, + httpPublishHistories, + packageNames, +} from './release-history-oracle.js' +import { withReleaseWorkspace } from './release-fixture.js' +import { withHttpPublishReceiver } from './release-http-fixture.js' + +const histories = httpPublishHistories + +// Final versions and tags follow the authored initial ledger plus each expected +// successful publication once. This rule does not classify npm's packuments. +function checkRegistry(state, initial, expected) { + const final = structuredClone(initial) + for (const { name, version, tag } of expected) { + final[name].versions.push(version) + final[name].tags[tag] = version + } + assert.deepEqual( + state.packages, + final, + 'HTTP registry retains each version and its promised tag', + ) +} + +// Failed attempts are public too: npm forbids publishing an existing exact +// version. Keep every failure, including duplicates that leave the final ledger +// unchanged. Only the driver's single authored fault is allowed. +function checkHttpUploadFailures(uploads, controlledFailure) { + const failures = uploads + .filter(({ success }) => !success) + .map(({ name, controlledFailure, duplicate }) => ({ + name, + controlledFailure, + duplicate, + })) + assert.deepEqual( + failures, + controlledFailure === undefined + ? [] + : [ + { + name: controlledFailure.name, + controlledFailure: true, + duplicate: false, + }, + ], + 'HTTP retry does not republish successful or already-current versions', + ) +} + +function requireSupportedProtocol(state) { + const unexpected = state.requests.filter( + ({ type }) => type === 'protocol-error' || type === 'request-error', + ) + if (unexpected.length > 0) + throw new Error( + `HTTP fixture does not support the actual tool protocol: ${JSON.stringify(unexpected)}`, + ) +} + +declareOracle({ + check: [checkPublishObservation, checkRegistry, checkHttpUploadFailures], + model: [expectedPublishes], + grammar: [histories], +}) + +test('finite HTTP grammar keeps every history independently selectable', () => { + assert.equal( + new Set(histories.map((history) => JSON.stringify(history))).size, + histories.length, + 'complete JSON test titles identify exactly one history', + ) +}) + +test.each(histories)( + 'publishes through real npm/pnpm HTTP %j', + async (history) => { + const reach = createOracleReach( + 'release.http-registry', + [ + 'stable-registry-read', + 'candidate-registry-read', + 'registry-read', + 'restored', + 'compared', + ...(history.registry === 'current' + ? ['already-current'] + : ['packed-upload']), + ...(history.failure === 'none' + ? [] + : ['controlled-failure', 'partial-publication', 'retry']), + ...(history.tagShape === 'ordinary' + ? [] + : [`metadata-${history.tagShape}`]), + ...(history.stableTagShape === 'no-tags' + ? ['stable-metadata-no-tags'] + : []), + ...(history.reactCandidate === undefined + ? [] + : ['different-candidate-versions']), + ...(history.currentCandidate === undefined + ? [] + : ['mixed-current-and-missing']), + ...(history.failure === 'candidate-after-success' + ? ['candidate-success-before-failure'] + : []), + ...(history.registry === 'current' ? [] : ['missing-exact-candidate']), + ], + { replaying: false }, + ) + reach.beginHistory() + let tools + let observation + let didFail = false + let primary + try { + await withReleaseWorkspace(async ({ root }) => + withHttpPublishReceiver(root, history, async (receiver) => { + tools = receiver.tools + const expected = expectedPublishes( + receiver.manifests, + receiver.initialRegistry, + ) + const before = receiver.bytes() + let firstError + let retryError + let controlledFailure + try { + await receiver.run() + } catch (error) { + firstError = error + } + let state = receiver.readState() + observation = state + requireSupportedProtocol(state) + assert.deepEqual( + receiver.bytes(), + before, + 'first HTTP attempt restores original workspace bytes', + ) + if (history.failure === 'none') { + if ( + firstError && + !state.requests.some(({ type }) => type === 'publish') + ) + throw new Error( + 'HTTP release failed before any publication request', + { cause: firstError }, + ) + } else { + const failed = state.requests.filter( + ({ type, controlledFailure }) => + type === 'publish' && controlledFailure, + ) + if (failed.length === 0 || firstError === undefined) + throw new Error( + 'HTTP controlled failure boundary was not received', + { cause: firstError }, + ) + assert.equal(failed.length, 1, 'one controlled failed HTTP request') + controlledFailure = failed[0] + if (history.failure === 'candidate-after-success') { + assert( + [packageNames.db, packageNames.react].includes( + controlledFailure.name, + ), + 'the later controlled failure belongs to a candidate', + ) + const failedIndex = state.requests.indexOf(controlledFailure) + assert( + state.requests + .slice(0, failedIndex) + .some( + ({ type, success, name }) => + type === 'publish' && + success && + [packageNames.db, packageNames.react].includes(name), + ), + 'an actual candidate success precedes the later candidate failure', + ) + reach.reach('candidate-success-before-failure') + } else + assert.equal( + controlledFailure.name, + history.failure === 'stable' + ? packageNames.query + : packageNames.db, + ) + reach.reach('controlled-failure') + assert( + state.requests.some( + ({ type, success }) => type === 'publish' && success, + ), + 'successful partial publications precede retry', + ) + reach.reach('partial-publication') + try { + await receiver.run() + } catch (error) { + retryError = error + } + state = receiver.readState() + observation = state + requireSupportedProtocol(state) + assert.deepEqual( + receiver.bytes(), + before, + 'HTTP retry restores original workspace bytes', + ) + reach.reach('retry') + } + reach.reach('restored') + const metadata = state.requests.filter(({ type }) => type === 'info') + const infos = new Set(metadata.map(({ name }) => name)) + // The first GET carries source facts before that package can be uploaded. + // A stable-phase duplicate can stop the command before any candidate read. + // Record each actual receiving path before judging raw failed attempts. + for (const [name, initial] of Object.entries( + receiver.initialRegistry, + )) { + const info = metadata.find((entry) => entry.name === name) + if (info !== undefined) + assert.deepEqual( + { versions: info.versions, tags: info.tags }, + initial, + 'real npm receives exact initial version and tag facts', + ) + } + const stableRead = [packageNames.query, packageNames.offline].every( + (name) => infos.has(name), + ) + const candidateRead = [packageNames.db, packageNames.react].every( + (name) => infos.has(name), + ) + if (stableRead) { + reach.reach('stable-registry-read') + if (history.stableTagShape === 'no-tags') + reach.reach('stable-metadata-no-tags') + } + if (candidateRead) { + reach.reach('candidate-registry-read') + if (history.tagShape !== 'ordinary') + reach.reach(`metadata-${history.tagShape}`) + if (history.reactCandidate !== undefined) { + assert.notEqual( + receiver.manifests[0].version, + receiver.manifests[1].version, + ) + reach.reach('different-candidate-versions') + } + const candidateExpected = expected.filter(({ name }) => + [packageNames.db, packageNames.react].includes(name), + ) + if (history.currentCandidate !== undefined) { + assert.equal( + candidateExpected.length, + 1, + 'exactly one candidate target is missing', + ) + reach.reach('mixed-current-and-missing') + } + if (history.registry !== 'current') { + assert( + candidateExpected.length > 0, + 'at least one exact candidate target is missing', + ) + reach.reach('missing-exact-candidate') + } + } + if (stableRead && candidateRead) reach.reach('registry-read') + const uploads = state.requests.filter( + ({ type }) => type === 'publish', + ) + checkHttpUploadFailures(uploads, controlledFailure) + assert.deepEqual( + [...infos].sort(), + Object.values(packageNames).sort(), + 'both package phases reached registry metadata', + ) + if (history.failure === 'none') + assert.equal( + firstError, + undefined, + 'publication without controlled failure fulfills', + ) + else + assert.equal( + retryError, + undefined, + 'retry after controlled failure fulfills', + ) + for (const upload of uploads) { + assert.deepEqual( + { name: upload.archive.name, version: upload.archive.version }, + { name: upload.name, version: upload.version }, + 'real packed manifest matches publication', + ) + assert(upload.archive.files > 0, 'actual packed files arrived') + } + if (history.registry === 'current') { + assert.equal( + uploads.length, + 0, + 'already-current registry requires no uploads', + ) + reach.reach('already-current') + } else { + assert(uploads.length > 0, 'real packed publication path ran') + reach.reach('packed-upload') + } + checkPublishObservation( + state.requests, + expected, + 'real HTTP publication and optional retry', + receiver.origin, + ) + checkRegistry(state, receiver.initialRegistry, expected) + reach.reach('compared') + reach.assertReached() + }), + ) + } catch (error) { + didFail = true + primary = error + throw error + } finally { + if (process.env.TANSTACK_DB_ORACLE_STATISTICS === '1') { + try { + console.info( + 'HTTP release reach', + JSON.stringify({ + history, + tools, + states: Object.fromEntries(reach.counts()), + observation, + }), + ) + } catch (report) { + throw didFail + ? new AggregateError( + [primary, report], + 'HTTP release comparison and reach reporting failures', + { cause: primary }, + ) + : report + } + } + } + }, + 45_000, +) + +test.each(['already-current', 'retained-success'])( + 'rejects duplicate HTTP attempts for %s', + (shape) => { + const fault = { + name: packageNames.query, + success: false, + controlledFailure: true, + duplicate: false, + } + const duplicate = { + name: packageNames.db, + success: false, + controlledFailure: false, + duplicate: true, + } + const actual = + shape === 'already-current' ? [duplicate] : [fault, duplicate] + assert.throws( + () => + checkHttpUploadFailures( + actual, + shape === 'already-current' ? undefined : fault, + ), + /HTTP retry does not republish/, + ) + }, +) + +test.each(['default', 'variable-origin', 'foreign-origin'])( + 'retains registry-origin judgment %s', + (shape) => { + const expected = [ + { name: packageNames.db, version: '1.0.0-rc.1.10', tag: 'rc' }, + ] + const registry = + shape === 'default' ? 'http://127.0.0.1:4873' : 'http://127.0.0.1:61234' + const observation = [ + { + ...expected[0], + type: 'publish', + success: true, + access: 'public', + registry, + }, + ] + if (shape === 'default') + checkPublishObservation(observation, expected, 'default origin') + else { + checkPublishObservation( + observation, + expected, + 'variable loopback origin', + registry, + ) + if (shape === 'foreign-origin') + assert.throws(() => + checkPublishObservation( + [{ ...observation[0], registry: 'https://example.invalid' }], + expected, + 'foreign observation', + registry, + ), + ) + } + }, +) diff --git a/scripts/publish-packages.mjs b/scripts/publish-packages.mjs new file mode 100644 index 0000000000..80e8bc6af2 --- /dev/null +++ b/scripts/publish-packages.mjs @@ -0,0 +1,116 @@ +import { execFileSync } from 'node:child_process' +import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { getPackages } from '@manypkg/get-packages' + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const candidateVersion = /^1\.0\.0-rc\.\d+(?:\.\d+)?$/ +const stableVersion = /^\d+\.\d+\.\d+$/ + +function publishChangesets(rootDirectory, publication) { + const flags = publication ? ['--tag', publication.tag] : [] + execFileSync('pnpm', ['exec', 'changeset', 'publish', ...flags], { + cwd: rootDirectory, + stdio: 'inherit', + ...(publication + ? { + env: { + ...process.env, + npm_config_tag: publication.version, + NPM_CONFIG_TAG: publication.version, + }, + } + : {}), + }) +} + +async function publishWithHiddenPackages( + rootDirectory, + hiddenPackages, + publication, + publishCommand, +) { + const originals = hiddenPackages.map(({ dir }) => { + const path = join(dir, 'package.json') + return { path, contents: readFileSync(path) } + }) + const preStatePath = join(rootDirectory, '.changeset', 'pre.json') + const preState = readFileSync(preStatePath) + + try { + for (const { path, contents } of originals) { + const manifest = JSON.parse(contents.toString()) + writeFileSync( + path, + `${JSON.stringify({ ...manifest, private: true }, null, 2)}\n`, + ) + } + unlinkSync(preStatePath) + await publishCommand(rootDirectory, publication) + } finally { + for (const { path, contents } of originals) writeFileSync(path, contents) + writeFileSync(preStatePath, preState) + } +} + +/** + * Publish stable versions first, then candidates, grouped by exact version. + * npm reads that exact version even when dist-tags are missing or stale. + * Explicit upload tags are independent of the metadata selector. + */ +export async function publishDuringRc( + rootDirectory = root, + publishCommand = publishChangesets, +) { + const { packages } = await getPackages(rootDirectory) + const publicPackages = packages.filter( + ({ packageJson }) => !packageJson.private, + ) + const candidates = publicPackages.filter(({ packageJson }) => + candidateVersion.test(packageJson.version), + ) + const stable = publicPackages.filter(({ packageJson }) => + stableVersion.test(packageJson.version), + ) + if ( + candidates.length === 0 || + candidates.length + stable.length !== publicPackages.length + ) { + throw new Error( + 'RC publishing found a public package with an unexpected version.', + ) + } + + const versions = new Set( + [...stable, ...candidates].map(({ packageJson }) => packageJson.version), + ) + for (const version of versions) { + await publishWithHiddenPackages( + rootDirectory, + publicPackages.filter( + ({ packageJson }) => packageJson.version !== version, + ), + { version, tag: stableVersion.test(version) ? 'latest' : 'rc' }, + publishCommand, + ) + } +} + +async function main() { + const preStatePath = join(root, '.changeset', 'pre.json') + const preState = existsSync(preStatePath) + ? JSON.parse(readFileSync(preStatePath, 'utf8')) + : undefined + if (preState?.mode === 'pre' && preState.tag === 'rc') { + await publishDuringRc() + } else { + publishChangesets(root) + } +} + +if ( + process.argv[1] && + resolve(process.argv[1]) === fileURLToPath(import.meta.url) +) + await main() diff --git a/scripts/publish-packages.test.mjs b/scripts/publish-packages.test.mjs new file mode 100644 index 0000000000..a87713a1e3 --- /dev/null +++ b/scripts/publish-packages.test.mjs @@ -0,0 +1,108 @@ +import assert from 'node:assert/strict' +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { test } from 'node:test' +import { publishDuringRc } from './publish-packages.mjs' + +function createWorkspace() { + const root = mkdtempSync(join(tmpdir(), 'tanstack-db-publish-test-')) + mkdirSync(join(root, '.changeset')) + writeFileSync( + join(root, 'package.json'), + JSON.stringify({ name: 'release-fixture', private: true }), + ) + writeFileSync( + join(root, 'pnpm-workspace.yaml'), + 'packages:\n - packages/*\n', + ) + writeFileSync( + join(root, '.changeset', 'pre.json'), + JSON.stringify({ mode: 'pre', tag: 'rc' }), + ) + const manifests = [ + { name: '@tanstack/db', version: '1.0.0-rc.2' }, + { name: '@tanstack/query-db-collection', version: '1.4.6' }, + { name: '@tanstack/offline-transactions', version: '1.1.3' }, + { name: 'private-fixture', version: '0.1.0', private: true }, + ] + for (const [index, manifest] of manifests.entries()) { + const directory = join(root, 'packages', String(index)) + mkdirSync(directory, { recursive: true }) + writeFileSync(join(directory, 'package.json'), JSON.stringify(manifest)) + } + return root +} + +function packageState(root) { + return [0, 1, 2, 3].map((index) => + JSON.parse( + readFileSync(join(root, 'packages', String(index), 'package.json')), + ), + ) +} + +test('RC publishing gives candidate and stable packages their own npm tags', async (t) => { + const root = createWorkspace() + t.after(() => rmSync(root, { recursive: true, force: true })) + const before = packageState(root) + const phases = [] + + await publishDuringRc(root, (_, publication) => { + phases.push({ + publicNames: packageState(root) + .filter((pkg) => !pkg.private) + .map((pkg) => pkg.name), + preMode: existsSync(join(root, '.changeset', 'pre.json')), + ...publication, + }) + }) + + assert.deepEqual(phases, [ + { + publicNames: ['@tanstack/query-db-collection'], + preMode: false, + version: '1.4.6', + tag: 'latest', + }, + { + publicNames: ['@tanstack/offline-transactions'], + preMode: false, + version: '1.1.3', + tag: 'latest', + }, + { + publicNames: ['@tanstack/db'], + preMode: false, + version: '1.0.0-rc.2', + tag: 'rc', + }, + ]) + assert.deepEqual(packageState(root), before) + assert.equal(existsSync(join(root, '.changeset', 'pre.json')), true) +}) + +test('failed candidate publishing restores package manifests and prerelease state', async (t) => { + const root = createWorkspace() + t.after(() => rmSync(root, { recursive: true, force: true })) + const before = packageState(root) + let phase = 0 + + await assert.rejects( + publishDuringRc(root, (_, publication) => { + phase++ + if (publication.tag === 'rc') throw new Error('publish failed') + }), + /publish failed/, + ) + assert.equal(phase, 3) + assert.deepEqual(packageState(root), before) + assert.equal(existsSync(join(root, '.changeset', 'pre.json')), true) +}) diff --git a/scripts/release-fixture.js b/scripts/release-fixture.js new file mode 100644 index 0000000000..65c904dfbd --- /dev/null +++ b/scripts/release-fixture.js @@ -0,0 +1,440 @@ +/** Production drivers for release-history-oracle.js; no expected release plan. */ +import { execFileSync, spawnSync } from 'node:child_process' +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { packageNames } from './release-history-oracle.js' + +const require = createRequire(import.meta.url) +const cli = require.resolve('@changesets/cli/bin.js') +export const fixtureRegistry = 'http://127.0.0.1:4873' + +export function writeJson(path, value) { + writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`) +} + +export function readJson(path) { + return JSON.parse(readFileSync(path, 'utf8')) +} + +export function manifestPath(root, key) { + return join(root, 'packages', key, 'package.json') +} + +export function addFixtureChangeset(root, id, releases) { + const entries = Object.entries(releases) + .map(([key, type]) => `'${packageNames[key]}': ${type}`) + .join('\n') + writeFileSync( + join(root, '.changeset', `${id}.md`), + `---\n${entries}\n---\n\nrelease-token:${id}\n`, + ) +} + +export function createReleaseWorkspace(stableDependency = false) { + const root = mkdtempSync(join(tmpdir(), 'db-release-oracle-')) + mkdirSync(join(root, '.changeset')) + mkdirSync(join(root, 'examples')) + writeJson(join(root, 'package.json'), { + name: 'release-oracle-fixture', + private: true, + packageManager: 'pnpm@11.1.0', + }) + writeFileSync( + join(root, 'pnpm-workspace.yaml'), + 'packages:\n - packages/*\n', + ) + writeJson(join(root, '.changeset', 'config.json'), { + changelog: ['./changelog.cjs', null], + commit: false, + access: 'public', + baseBranch: 'main', + updateInternalDependencies: 'patch', + fixed: [], + linked: [], + ignore: [], + privatePackages: { version: false, tag: false }, + ___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH: { + onlyUpdatePeerDependentsWhenOutOfRange: true, + }, + }) + // Formatting is deliberately trivial. The callback supplies the raw ID; + // only the independent model decides when/where that ID must occur once. + writeFileSync( + join(root, '.changeset', 'changelog.cjs'), + ` + exports.getReleaseLine = async (changeset) => { + if (process.env.RELEASE_ORACLE_CHANGELOG_FAIL === ${JSON.stringify(root)}) + throw new Error('injected release changelog failure') + return '- ' + changeset.summary + } + exports.getDependencyReleaseLine = async () => '' + `, + ) + const manifests = { + db: { + name: packageNames.db, + version: '0.15.2', + devDependencies: { 'release-private': 'workspace:*' }, + }, + react: { + name: packageNames.react, + version: '0.1.0', + dependencies: { [packageNames.db]: 'workspace:*' }, + }, + query: { + name: packageNames.query, + version: '1.4.8', + peerDependencies: { [packageNames.db]: '^1.0.0-rc.0' }, + }, + offline: { + name: packageNames.offline, + version: '1.1.4', + ...(stableDependency + ? { dependencies: { [packageNames.db]: 'workspace:*' } } + : { peerDependencies: { [packageNames.db]: '^1.0.0-rc.0' } }), + }, + private: { + name: 'release-private', + version: '0.1.0', + private: true, + description: 'must retain exact original manifest bytes', + }, + } + for (const [key, value] of Object.entries(manifests)) { + mkdirSync(join(root, 'packages', key), { recursive: true }) + writeJson(manifestPath(root, key), value) + } + return { root, privateManifest: structuredClone(manifests.private) } +} + +export function enterRealPreMode(root) { + execFileSync(process.execPath, [cli, 'pre', 'enter', 'rc'], { + cwd: root, + stdio: 'pipe', + timeout: 10_000, + env: { ...process.env, CI: 'true' }, + }) +} + +export function exitRealPreMode(root) { + execFileSync(process.execPath, [cli, 'pre', 'exit'], { + cwd: root, + stdio: 'pipe', + timeout: 10_000, + env: { ...process.env, CI: 'true' }, + }) +} + +export function repeatStableVersion(root) { + execFileSync(process.execPath, [cli, 'version'], { + cwd: root, + stdio: 'pipe', + timeout: 10_000, + env: { ...process.env, CI: 'true' }, + }) +} + +export function observeRelease(fixture) { + const { root } = fixture + const manifests = Object.fromEntries( + Object.keys(packageNames).map((key) => [ + key, + readJson(manifestPath(root, key)), + ]), + ) + const prePath = join(root, '.changeset', 'pre.json') + return { + versions: Object.fromEntries( + Object.entries(manifests).map(([key, manifest]) => [ + key, + manifest.version, + ]), + ), + tokens: Object.fromEntries( + Object.keys(packageNames).map((key) => { + const path = join(root, 'packages', key, 'CHANGELOG.md') + const text = existsSync(path) ? readFileSync(path, 'utf8') : '' + let version + const tokens = [] + for (const line of text.split('\n')) { + if (line.startsWith('## ')) version = line.slice(3) + for (const match of line.matchAll(/release-token:([a-z0-9-]+)/g)) + tokens.push({ id: match[1], version }) + } + return [key, tokens] + }), + ), + pre: existsSync(prePath) ? readJson(prePath) : undefined, + changesets: readdirSync(join(root, '.changeset')) + .filter((file) => file.endsWith('.md')) + .map((file) => file.slice(0, -3)), + privateManifest: readJson(manifestPath(root, 'private')), + originalPrivateManifest: fixture.privateManifest, + ignoredDependency: manifests.db.devDependencies['release-private'], + dependencies: { + react: manifests.react.dependencies[packageNames.db], + query: manifests.query.peerDependencies[packageNames.db], + offline: (manifests.offline.dependencies ?? + manifests.offline.peerDependencies)[packageNames.db], + }, + } +} + +export function workspaceBytes(root) { + const result = {} + const visit = (directory, prefix = '') => { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const name = `${prefix}${entry.name}` + if (entry.isDirectory()) visit(join(directory, entry.name), `${name}/`) + else + result[name] = readFileSync(join(directory, entry.name)).toString( + 'base64', + ) + } + } + visit(root) + return result +} + +export async function withReleaseWorkspace( + body, + stableDependency = false, + cleanupWorkspace = rmSync, +) { + const fixture = createReleaseWorkspace(stableDependency) + let failed = false + let primary + try { + return await body(fixture) + } catch (error) { + failed = true + primary = error + throw error + } finally { + try { + cleanupWorkspace(fixture.root, { recursive: true, force: true }) + } catch (cleanup) { + throw failed + ? new AggregateError( + [primary, cleanup], + 'Release mismatch and workspace cleanup failure', + { cause: primary }, + ) + : cleanup + } + } +} + +/** + * Real Changesets runs behind strict local npm/pnpm process shims. Those shims + * only supply registry responses and capture requests; they choose no tag. + * No real npm command, network request, package install or HTTP server runs. + */ +export function preparePublishReceiver(root, history) { + const bin = join(root, 'bin') + mkdirSync(bin) + const registryPath = join(root, 'registry.json') + const logPath = join(root, 'registry-requests.json') + const emptyConfig = join(root, 'empty-config') + writeFileSync(emptyConfig, '') + const emptyHooks = join(root, 'empty-hooks') + mkdirSync(emptyHooks) + const commandEnvironment = () => ({ + PATH: `${bin}:${process.env.PATH}`, + ...(process.env.HOME ? { HOME: process.env.HOME } : {}), + ...(process.env.TMPDIR ? { TMPDIR: process.env.TMPDIR } : {}), + npm_config_registry: fixtureRegistry, + NPM_CONFIG_USERCONFIG: emptyConfig, + NPM_CONFIG_GLOBALCONFIG: emptyConfig, + GIT_CONFIG_GLOBAL: emptyConfig, + GIT_CONFIG_NOSYSTEM: '1', + GIT_TERMINAL_PROMPT: '0', + CI: 'true', + }) + const manifests = Object.keys(packageNames).map((key) => { + const manifest = readJson(manifestPath(root, key)) + manifest.version = + key === 'db' || key === 'react' + ? history.candidate + : key === 'query' + ? '1.5.0' + : '1.2.0' + writeJson(manifestPath(root, key), manifest) + return manifest + }) + const registry = Object.fromEntries( + manifests.map((manifest) => [ + manifest.name, + { + versions: + history.registry === 'never' + ? [] + : history.registry === 'current' + ? [manifest.version] + : manifest.name === packageNames.db || + manifest.name === packageNames.react + ? history.registry === 'only-rc' + ? ['1.0.0-rc.1'] + : ['0.15.2'] + : ['1.0.0'], + tags: {}, + }, + ]), + ) + const failureName = + history.failure === 'stable' + ? packageNames.query + : history.failure === 'candidate' + ? packageNames.db + : undefined + writeJson(registryPath, { packages: registry, failureName, failed: false }) + writeJson(logPath, []) + writeJson(join(root, '.changeset', 'pre.json'), { + mode: 'pre', + tag: 'rc', + initialVersions: Object.fromEntries( + manifests.map((manifest) => [manifest.name, manifest.version]), + ), + changesets: [], + }) + const shim = `#!${process.execPath} +const fs = require('node:fs') +const path = require('node:path') +const cp = require('node:child_process') +const args = process.argv.slice(2) +const tool = path.basename(process.argv[1]) +const statePath = ${JSON.stringify(registryPath)} +const logPath = ${JSON.stringify(logPath)} +const registry = process.env.npm_config_registry +let lockHeld = false +function stop(message) { + if (lockHeld) fs.rmdirSync(statePath + '.lock') + console.error(message); process.exit(91) +} +if (registry !== ${JSON.stringify(fixtureRegistry)}) stop('foreign or missing registry') +if (tool === 'pnpm' && args.join(' ') === '--version') { + console.log('11.1.0'); process.exit(0) +} +if (tool === 'pnpm' && args.slice(0, 3).join(' ') === 'exec changeset publish' && + (args.length === 3 || (args.length === 5 && args[3] === '--tag' && ['rc', 'latest'].includes(args[4])))) { + const result = cp.spawnSync(process.execPath, [${JSON.stringify(cli)}, 'publish', ...args.slice(3)], { + cwd: process.cwd(), env: process.env, stdio: 'inherit', timeout: 10000, + }) + process.exit(result.status ?? 92) +} +// Changesets can publish independent packages concurrently. Serialize the +// fixture's read-modify-write, preserving every raw attempt and result. +const lock = statePath + '.lock' +const deadline = Date.now() + 2000 +while (true) { + try { fs.mkdirSync(lock); lockHeld = true; break } catch (error) { + if (error.code !== 'EEXIST' || Date.now() > deadline) throw error + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 2) + } +} +const state = JSON.parse(fs.readFileSync(statePath)) +const log = JSON.parse(fs.readFileSync(logPath)) +if (tool === 'npm' && args[0] === 'info') { + const name = args[1] + if (!state.packages[name]) stop('unknown registry package ' + name) + if (JSON.stringify(args) !== JSON.stringify(['info', name, '--registry=' + registry, '--json'])) + stop('unrecognized registry info arguments') + log.push({ type: 'info', name, registry, args }) + fs.writeFileSync(logPath, JSON.stringify(log)) + const versions = state.packages[name].versions + console.log(JSON.stringify(versions.length ? { versions } + : { error: { code: 'E404' } })) + fs.rmdirSync(lock) + process.exit(versions.length ? 0 : 1) +} +if (tool === 'pnpm' && args[0] === 'publish') { + const manifest = JSON.parse(fs.readFileSync(path.join(process.cwd(), 'package.json'))) + const tag = args[args.indexOf('--tag') + 1] + const access = args[args.indexOf('--access') + 1] + if (JSON.stringify(args) !== JSON.stringify([ + 'publish', '--json', '--access', access, '--tag', tag, '--no-git-checks', + ]) || !state.packages[manifest.name] || tag.startsWith('-') || access.startsWith('-')) + stop('unrecognized publish command') + const success = state.failed || state.failureName !== manifest.name + log.push({ type: 'publish', name: manifest.name, version: manifest.version, + tag, access, registry, success, args }) + if (success) { + state.packages[manifest.name].versions.push(manifest.version) + state.packages[manifest.name].tags[tag] = manifest.version + } else state.failed = true + fs.writeFileSync(statePath, JSON.stringify(state)) + fs.writeFileSync(logPath, JSON.stringify(log)) + console.log(JSON.stringify(success ? { id: manifest.name + '@' + manifest.version } + : { error: { code: 'EORACLE', summary: 'injected publish failure' } })) + fs.rmdirSync(lock) + process.exit(success ? 0 : 1) +} +stop('unrecognized command ' + tool + ' ' + args.join(' ')) +` + for (const name of ['npm', 'pnpm']) + writeFileSync(join(bin, name), shim, { mode: 0o755 }) + for (const args of [ + ['init', '--quiet', `--template=${emptyHooks}`], + ['config', 'core.hooksPath', emptyHooks], + ['config', 'commit.gpgSign', 'false'], + ['config', 'tag.gpgSign', 'false'], + ['config', 'user.email', 'oracle@example.invalid'], + ['config', 'user.name', 'Release oracle'], + ['add', '.'], + ['commit', '--quiet', '-m', 'release fixture'], + ]) + execFileSync('git', args, { + cwd: root, + stdio: 'pipe', + timeout: 10_000, + env: commandEnvironment(), + }) + return { + manifests, + initialRegistry: structuredClone(registry), + readRequests: () => readJson(logPath), + readRegistry: () => readJson(registryPath), + invokeCommand: (tool, args, key = 'db') => + spawnSync(join(bin, tool), args, { + cwd: key === 'root' ? root : join(root, 'packages', key), + encoding: 'utf8', + timeout: 5_000, + env: commandEnvironment(), + }), + // Only manifest/pre-state bytes belong to the publish restoration promise; + // registry and Git logs deliberately change when a publish succeeds. + bytes: () => + Object.fromEntries( + [ + '.changeset/pre.json', + ...Object.keys(packageNames).map( + (key) => `packages/${key}/package.json`, + ), + ].map((file) => [file, readFileSync(join(root, file), 'base64')]), + ), + async run(publish) { + // Drop credential/scope overrides from child commands. HOME retains its + // normal value; npm and Git config files are explicitly isolated instead. + const before = { ...process.env } + const childEnvironment = commandEnvironment() + for (const name of Object.keys(process.env)) delete process.env[name] + Object.assign(process.env, childEnvironment) + try { + return await publish(root) + } finally { + for (const name of Object.keys(process.env)) delete process.env[name] + Object.assign(process.env, before) + } + }, + } +} diff --git a/scripts/release-fixture.test.mjs b/scripts/release-fixture.test.mjs new file mode 100644 index 0000000000..2572424892 --- /dev/null +++ b/scripts/release-fixture.test.mjs @@ -0,0 +1,206 @@ +// Ordinary harness calibration: malformed commands cannot escape the registry +// fixture or be recorded under a misleading first-occurring option value. +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { existsSync, rmSync, unlinkSync } from 'node:fs' +import { join } from 'node:path' +import { test } from 'node:test' +import { fileURLToPath } from 'node:url' +import { + preparePublishReceiver, + withReleaseWorkspace, +} from './release-fixture.js' +import { withFiniteComparison } from './finite-comparison.js' + +for (const [label, tool, args] of [ + [ + 'duplicate tag', + 'pnpm', + [ + 'publish', + '--json', + '--access', + 'public', + '--tag', + 'rc', + '--no-git-checks', + '--tag', + 'latest', + ], + ], + [ + 'foreign publish registry', + 'pnpm', + [ + 'publish', + '--json', + '--access', + 'public', + '--tag', + 'rc', + '--no-git-checks', + '--registry=https://example.invalid', + ], + ], + [ + 'foreign info registry', + 'npm', + ['info', '@tanstack/db', '--registry=https://example.invalid', '--json'], + ], + ['unknown command', 'pnpm', ['install']], +]) + test(`release command fixture rejects ${label}`, async () => { + await withReleaseWorkspace(async ({ root }) => { + const receiver = preparePublishReceiver(root, { + candidate: '1.0.0-rc.1.1', + registry: 'stable-history', + failure: 'none', + }) + const before = receiver.readRegistry() + const result = receiver.invokeCommand(tool, args) + assert.equal(result.error, undefined) + assert.equal(result.status, 91) + assert.match(result.stderr, /unrecognized/) + assert.deepEqual(receiver.readRegistry(), before) + assert.deepEqual(receiver.readRequests(), []) + assert.equal(existsSync(join(root, 'registry.json.lock')), false) + }) + }) + +for (const stage of [ + 'setup', + 'success', + 'mismatch', + 'cleanup', + 'witness', + 'undefined', + 'undefined-witness', +]) + test(`finite comparison preserves ${stage} outcome`, async () => { + const primary = stage.startsWith('undefined') ? undefined : new Error(stage) + const writerError = new Error('writer') + const records = [] + const result = withFiniteComparison( + async (comparison) => { + if (stage === 'setup') throw primary + comparison() + if (stage === 'cleanup') { + try { + return 'compared' + } finally { + throw primary + } + } + if (stage !== 'success') throw primary + }, + (record) => { + records.push(record) + if (stage === 'witness' || stage === 'undefined-witness') + throw writerError + }, + ) + if (stage === 'success') await result + else + await assert.rejects(result, (error) => { + if (stage === 'witness' || stage === 'undefined-witness') { + assert.equal(error.cause, primary) + assert.deepEqual(error.errors, [primary, writerError]) + } else assert.equal(error, primary) + return true + }) + assert.deepEqual( + records, + stage === 'setup' ? [] : [{ failed: stage !== 'success' }], + ) + }) + +for (const stage of ['success', 'mismatch', 'undefined']) + test(`workspace cleanup preserves ${stage} outcome`, async () => { + const primary = stage === 'undefined' ? undefined : new Error(stage) + const cleanup = new Error('cleanup') + let root + await assert.rejects( + withReleaseWorkspace( + async (fixture) => { + root = fixture.root + if (stage !== 'success') throw primary + }, + false, + (path, options) => { + rmSync(path, options) + throw cleanup + }, + ), + (error) => { + if (stage === 'success') assert.equal(error, cleanup) + else { + assert.equal(error.cause, primary) + assert.deepEqual(error.errors, [primary, cleanup]) + } + return true + }, + ) + assert.equal(existsSync(root), false) + }) + +for (const tag of ['rc', 'latest']) + test(`release command fixture forwards explicit ${tag} tag`, async () => { + await withReleaseWorkspace(async ({ root }) => { + const receiver = preparePublishReceiver(root, { + candidate: '1.0.0-rc.1.1', + registry: 'stable-history', + failure: 'none', + }) + // Changesets forbids an explicit tag while pre mode is visible. A future + // wrapper can legitimately hide pre mode and supply the phase's tag. + unlinkSync(join(root, '.changeset', 'pre.json')) + const result = receiver.invokeCommand( + 'pnpm', + ['exec', 'changeset', 'publish', '--tag', tag], + 'root', + ) + assert.equal(result.error, undefined) + assert.equal(result.status, 0, result.stderr) + const publishes = receiver + .readRequests() + .filter((entry) => entry.type === 'publish') + assert.equal(publishes.length, 4) + assert(publishes.every((entry) => entry.success && entry.tag === tag)) + }) + }) + +for (const [property, path] of [ + ['scripts.release-version', '1:2'], + ['scripts.release-version', '72'], + ['scripts.release-seed-failure', '14'], +]) + test(`finite release replay rejects invalid selector ${property}/${path}`, () => { + const root = fileURLToPath(new URL('../', import.meta.url)) + const result = spawnSync( + process.execPath, + [ + join(root, 'node_modules', 'vitest', 'vitest.mjs'), + 'run', + '--config', + 'scripts/oracle-scripts.vitest.config.mjs', + 'scripts/release-history-oracle.property.test.js', + ], + { + cwd: root, + encoding: 'utf8', + timeout: 10_000, + env: { + ...process.env, + TANSTACK_DB_ORACLE_PROPERTY: property, + TANSTACK_DB_ORACLE_SEED: '1', + TANSTACK_DB_ORACLE_PATH: path, + }, + }, + ) + assert.equal(result.error, undefined) + assert.equal(result.status, 1) + assert.match( + result.stdout + result.stderr, + /Finite release replay PATH must be one existing case index/, + ) + }) diff --git a/scripts/release-history-oracle.js b/scripts/release-history-oracle.js new file mode 100644 index 0000000000..154951dda7 --- /dev/null +++ b/scripts/release-history-oracle.js @@ -0,0 +1,353 @@ +/** + * Release laws come from CONTRIBUTING.md's Changesets and Releases sections: + * candidates start at 1.0.0-rc.1, patches extend the current candidate, and a + * minor or major starts the next candidate. Stable packages use ordinary semver + * and latest; candidates use rc. Versioning consumes each changeset once. + * Exit promotes candidates to 1.0.0 and applies only still-pending work. + * + * This independent model uses numeric tuples and an authored changeset ledger. + * It imports no Changesets planner, production version helper, manifest parser, + * or registry classifier. The bounded dependency law uses workspace:* edges: + * changing their published exact target requires a dependent patch, as the + * repository's updateInternalDependencies=patch policy specifies. Compatible + * peers do not require that patch. This is not an arbitrary semver-range model. + * + * The model predicts manifests, consumed IDs and unique changelog tokens after + * each version action, and successful version/tag writes after publishing. + * npm HTTP, tarballs, installed consumers, general workspace discovery and + * clean installation are separate receiving boundaries, not claims here. + */ +import assert from 'node:assert/strict' + +export const packageNames = { + db: '@tanstack/db', + react: '@tanstack/react-db', + query: '@tanstack/query-db-collection', + offline: '@tanstack/offline-transactions', +} + +// Exhaust these semantic boundaries rather than randomizing a larger grammar. +// Removing boundary loses numeric 9->10; removing dependency loses stable +// dependent exit; removing strongest loses major-vs-minor precedence. Reversing +// pending insertion order must not change the maximum requested release type. +export const releaseHistories = [ + 'first', + 'candidate-nine', + 'patch-nine', +].flatMap((boundary) => + [false, true].flatMap((stableDependency) => + ['minor', 'major'].flatMap((strongest) => + [false, true].flatMap((reverse) => + ['none', 'stable-only', 'candidate-and-stable'].map((exitWork) => ({ + boundary, + stableDependency, + strongest, + reverse, + exitWork, + })), + ), + ), + ), +) + +export const publishHistories = [ + '1.0.0-rc.1.9', + '1.0.0-rc.1.10', + '1.0.0-rc.9', + '1.0.0-rc.10', +].flatMap((candidate) => + ['stable-history', 'only-rc', 'never', 'current'].flatMap((registry) => + (registry === 'current' ? ['none'] : ['none', 'stable', 'candidate']).map( + (failure) => ({ + candidate, + registry, + failure, + }), + ), + ), +) + +// HTTP reception exhausts the original fourteen metadata/failure forms, then +// adds independent neighboring facts. Exact version presence and tag shape are +// separate: a tag is not proof that another version is absent. Heterogeneous +// assignments also prohibit treating all candidate manifests as one version. +// Bounded enumeration has direct replay by the complete unique Vitest title; +// it makes no seeded/random-schedule or arbitrary-semver coverage claim. +export const httpPublishHistories = [ + ...publishHistories + .filter(({ candidate }) => candidate === '1.0.0-rc.1.10') + .flatMap((history) => + ['only-rc', 'current'].includes(history.registry) + ? ['rc-only', 'latest-alias'].map((tagShape) => ({ + ...history, + tagShape, + })) + : [{ ...history, tagShape: 'ordinary' }], + ), + ...['latest-only', 'no-tags', 'older-rc', 'older-rc-latest-current'].map( + (tagShape) => ({ + candidate: '1.0.0-rc.1.10', + registry: 'current', + failure: 'none', + tagShape, + }), + ), + { + candidate: '1.0.0-rc.1.10', + registry: 'current', + failure: 'none', + tagShape: 'latest-alias', + stableTagShape: 'no-tags', + }, + ...['none', 'candidate'].map((failure) => ({ + candidate: '1.0.0-rc.1.10', + registry: 'only-rc', + failure, + tagShape: 'no-tags', + })), + ...[false, true].flatMap((reverse) => + [ + { currentCandidate: undefined, failure: 'none' }, + { currentCandidate: undefined, failure: 'candidate-after-success' }, + { currentCandidate: 'db', failure: 'none' }, + { currentCandidate: 'react', failure: 'none' }, + ].map(({ currentCandidate, failure }) => ({ + candidate: reverse ? '1.0.0-rc.2' : '1.0.0-rc.1.10', + reactCandidate: reverse ? '1.0.0-rc.1.10' : '1.0.0-rc.2', + registry: currentCandidate === undefined ? 'stable-history' : 'only-rc', + tagShape: currentCandidate === undefined ? 'ordinary' : 'latest-alias', + ...(currentCandidate === undefined ? {} : { currentCandidate }), + failure, + })), + ), +] + +export const seedFailureHistories = [ + 'first-manifest', + 'second-manifest', + 'release-config', + 'before-pre', + 'after-pre', + 'changelog', + 'after-apply', +].flatMap((failure) => + [false, true].map((stableDependency) => ({ failure, stableDependency })), +) + +const strength = { none: 0, patch: 1, minor: 2, major: 3 } +const strongest = (a, b) => (strength[a] >= strength[b] ? a : b) +const copy = (value) => structuredClone(value) + +export function createReleaseModel(stableDependency) { + return { + mode: 'before', + versions: { + db: { candidate: 0, patch: 0 }, + react: { candidate: 0, patch: 0 }, + query: [1, 4, 8], + offline: [1, 1, 4], + }, + stableDependency, + pending: [], + consumed: [], + tokens: Object.fromEntries( + Object.keys(packageNames).map((key) => [key, []]), + ), + } +} + +export function queueRelease(model, id, releases) { + assert(!model.pending.some((entry) => entry.id === id)) + assert(!model.consumed.includes(id), 'changeset IDs cannot be reused') + for (const [key, type] of Object.entries(releases)) { + assert( + key in packageNames, + 'the grammar names only public fixture packages', + ) + assert(type in strength && type !== 'none', 'a release requires a bump') + } + model.pending.push({ id, releases: { ...releases } }) +} + +// Pending IDs and consumed IDs differ even when public versions match: a later +// version action must apply one and ignore the other. Candidate patch/counter +// states also differ: the next patch and minor distinguish their numeric value. +export function applyReleaseVersion(model, { exit = false } = {}) { + const next = copy(model) + const requested = Object.fromEntries( + Object.keys(packageNames).map((key) => [key, 'none']), + ) + for (const entry of next.pending) + for (const [key, type] of Object.entries(entry.releases)) + requested[key] = strongest(requested[key], type) + if (exit) { + requested.db = strongest(requested.db, 'patch') + requested.react = strongest(requested.react, 'patch') + } + if (requested.db !== 'none') { + requested.react = strongest(requested.react, 'patch') + if (next.stableDependency) + requested.offline = strongest(requested.offline, 'patch') + } + for (const key of Object.keys(packageNames)) { + const type = requested[key] + if (type === 'none') continue + const old = next.versions[key] + if ('candidate' in old) { + next.versions[key] = exit + ? [1, 0, 0] + : type === 'patch' + ? { candidate: old.candidate, patch: old.patch + 1 } + : { candidate: old.candidate + 1, patch: 0 } + } else { + const [major, minor, patch] = old + next.versions[key] = + type === 'major' + ? [major + 1, 0, 0] + : type === 'minor' + ? [major, minor + 1, 0] + : [major, minor, patch + 1] + } + } + for (const entry of next.pending) { + next.consumed.push(entry.id) + for (const key of Object.keys(entry.releases)) + next.tokens[key].push({ id: entry.id, version: modelVersions(next)[key] }) + } + next.pending = [] + next.mode = exit ? 'stable' : 'rc' + return next +} + +export function modelVersions(model) { + return Object.fromEntries( + Object.entries(model.versions).map(([key, value]) => [ + key, + Array.isArray(value) + ? value.join('.') + : `1.0.0-rc.${value.candidate}${value.patch ? `.${value.patch}` : ''}`, + ]), + ) +} + +export function checkReleaseObservation(actual, model, checkpoint) { + const expected = modelVersions(model) + assert.deepEqual(actual.versions, expected, `${checkpoint}: package versions`) + // Order among independent same-version entries is not promised. Preserve + // multiplicity, package and version attribution while comparing the bag. + const tokens = (ledger) => + Object.fromEntries( + Object.entries(ledger).map(([key, entries]) => [ + key, + entries.map(({ id, version }) => `${version}:${id}`).sort(), + ]), + ) + assert.deepEqual( + tokens(actual.tokens), + tokens(model.tokens), + `${checkpoint}: changelog ledger`, + ) + assert.deepEqual( + actual.privateManifest, + actual.originalPrivateManifest, + `${checkpoint}: private package is unchanged`, + ) + assert.deepEqual( + actual.ignoredDependency, + 'workspace:*', + `${checkpoint}: ignored dev dependency is restored`, + ) + if (model.mode === 'rc') { + assert.deepEqual(actual.pre.mode, 'pre', `${checkpoint}: prerelease mode`) + assert.deepEqual( + [...actual.pre.changesets].sort(), + [...model.consumed].sort(), + `${checkpoint}: exactly consumed IDs`, + ) + assert.deepEqual( + actual.changesets.sort(), + [...model.consumed, ...model.pending.map((entry) => entry.id)].sort(), + `${checkpoint}: prerelease files remain replayable`, + ) + } else { + assert.equal( + actual.pre, + undefined, + `${checkpoint}: exit removes prerelease state`, + ) + assert.deepEqual( + actual.changesets, + [], + `${checkpoint}: exit removes consumed files`, + ) + } + assert.equal( + actual.dependencies.react, + 'workspace:*', + `${checkpoint}: candidate direct dependency retains pack-time protocol`, + ) + if (model.stableDependency) + assert.equal( + actual.dependencies.offline, + 'workspace:*', + `${checkpoint}: stable direct dependency retains pack-time protocol`, + ) + else + assert.equal( + actual.dependencies.offline, + '^1.0.0-rc.0', + `${checkpoint}: compatible stable peer stays broad`, + ) + assert.equal( + actual.dependencies.query, + '^1.0.0-rc.0', + `${checkpoint}: query peer retains RC capability requirement`, + ) +} + +// Registry histories are external input, never a release-tag classifier. A +// successful publish writes the documented tag even if all old versions were +// candidates. Concurrent independent package attempts may arrive in any order. +export function expectedPublishes(manifests, registry) { + return manifests + .filter( + (manifest) => + !manifest.private && + !registry[manifest.name].versions.includes(manifest.version), + ) + .map((manifest) => ({ + name: manifest.name, + version: manifest.version, + tag: + manifest.name === packageNames.db || + manifest.name === packageNames.react + ? 'rc' + : 'latest', + })) +} + +export function checkPublishObservation( + actual, + expected, + checkpoint, + expectedRegistry = 'http://127.0.0.1:4873', +) { + const key = ({ name, version, tag }) => `${name}@${version}:${tag}` + const successes = actual.filter( + (entry) => entry.type === 'publish' && entry.success, + ) + assert.deepEqual( + successes.map(key).sort(), + expected.map(key).sort(), + `${checkpoint}: each unpublished version reaches its documented tag once`, + ) + for (const entry of actual) { + assert.equal( + entry.registry, + expectedRegistry, + `${checkpoint}: local registry only`, + ) + if (entry.type === 'publish') + assert.equal(entry.access, 'public', `${checkpoint}: public access`) + } +} diff --git a/scripts/release-history-oracle.property.test.js b/scripts/release-history-oracle.property.test.js new file mode 100644 index 0000000000..e9dc99e3e4 --- /dev/null +++ b/scripts/release-history-oracle.property.test.js @@ -0,0 +1,413 @@ +/** + * An RC release is a sequence, not an isolated version-string calculation. + * The shared independent ledger states the authority and finite grammar. + * This receiver runs real Changesets pre entry/exit and the production version + * functions in disposable workspaces. After every action it compares package + * versions, dependency protocols, consumed IDs and changelog attribution. + * + * The first/9-counter boundaries, independent stable work, maximum bump type, + * direct versus peer edges and insertion order form a finite exhaustive set. + * Unknown packages, reused IDs and release types outside the grammar are not + * legal histories. Failure histories inject one seed write/pre-entry/changelog + * failure and demand exact restoration before a successful retry. These are + * initial seeding boundaries, not an atomicity promise for every later tool. + * Direct finite replay uses the shared PROPERTY/SEED/PATH interface. Property + * scripts.release-version or scripts.release-seed-failure selects the family; + * PATH is one finite case index. The required SEED is inert for enumeration. + * Replay selects that input immediately and keeps every applicable local cut. + * No install, tarball, registry HTTP, or installed-consumer behavior is claimed. + */ +import assert from 'node:assert/strict' +import { existsSync, readFileSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { afterAll, expect, test, vi } from 'vitest' +import { declareOracle } from '../packages/db/tests/oracle-declaration.js' +import { createOracleReach } from '../packages/db/tests/oracle-reach.js' +import { + oracleReplayRequested, + oracleReplaySkips, + oracleReplayTest, + readOracleRunConfig, +} from '../packages/db/tests/oracle-config.js' +import { recordOracleFiniteReplay } from '../packages/db/tests/oracle-replay-witness.js' +import { withFiniteComparison } from './finite-comparison.js' +import { + applyReleaseVersion, + checkReleaseObservation, + createReleaseModel, + queueRelease, + releaseHistories, + seedFailureHistories, +} from './release-history-oracle.js' +import { + addFixtureChangeset, + enterRealPreMode, + exitRealPreMode, + observeRelease, + repeatStableVersion, + withReleaseWorkspace, + workspaceBytes, +} from './release-fixture.js' +import { + seedFirstCandidate, + versionDuringRc, + versionOnRcExit, +} from './version-packages.mjs' + +// Faults affect only seedFirstCandidate's synchronous write boundary. A single +// failure disarms itself so restoration can proceed. Fixture writes occur while +// disarmed; CJS Changesets writes are not counted as intercepted seed writes. +const fault = vi.hoisted(() => ({ + remaining: undefined, + reached: false, + afterApply: false, + appliedToken: false, +})) +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + writeFileSync(...args) { + if (fault.remaining !== undefined) { + if (fault.remaining-- === 0) { + fault.remaining = undefined + fault.reached = true + throw new Error('injected seed write failure') + } + } + return actual.writeFileSync(...args) + }, + } +}) +vi.mock('@changesets/apply-release-plan', async (importOriginal) => { + const actual = await importOriginal() + const apply = + typeof actual.default === 'function' + ? actual.default + : actual.default.default + return { + ...actual, + default: async (...args) => { + const result = await apply(...args) + if (fault.afterApply) { + fault.afterApply = false + fault.reached = true + const fs = await vi.importActual('node:fs') + const path = join(args[4], 'packages', 'db', 'CHANGELOG.md') + fault.appliedToken = fs + .readFileSync(path, 'utf8') + .includes('release-token:first-rc') + throw new Error('injected failure after release files applied') + } + return result + }, + } +}) + +const first = { db: 'major', react: 'major', query: 'minor', offline: 'minor' } +const VERSION = 'scripts.release-version' +const FAILURE = 'scripts.release-seed-failure' +const replay = readOracleRunConfig() +const selected = oracleReplayRequested() && !oracleReplaySkips(VERSION, FAILURE) +const caseIndex = selected ? Number(replay.replayPath) : 0 +if ( + selected && + (!/^\d+$/.test(replay.replayPath) || + !Number.isSafeInteger(caseIndex) || + caseIndex >= + (replay.replayProperty === VERSION + ? releaseHistories + : seedFailureHistories + ).length) +) + throw new Error('Finite release replay PATH must be one existing case index') +async function recordFiniteComparison(property, action) { + return withFiniteComparison(action, ({ failed }) => { + if (selected && replay.replayProperty === property) + recordOracleFiniteReplay(property, { + seed: replay.replaySeed, + path: replay.replayPath, + failed, + }) + }) +} +const versionReach = createOracleReach( + 'release.version-history', + [ + 'first-candidate', + 'candidate-nine-to-ten', + 'patch-nine-to-ten', + 'mixed-work', + 'stable-only-work', + 'bump-precedence', + 'no-op', + 'consumed-minor-pending-patch-exit', + 'dependent-stable-exit', + 'stable-no-op', + 'exit-no-pending', + 'exit-stable-only', + 'exit-candidate-pending', + ], + { replaying: false }, +) +const failureReach = createOracleReach( + 'release.seed-failure', + seedFailureHistories.map( + ({ failure, stableDependency }) => `${failure}/${stableDependency}`, + ), + { replaying: false }, +) +afterAll(() => { + // Direct finite replay keeps its local required cuts. The normal campaign + // additionally requires all axes. Generic -t slicing is not finite replay. + if (!oracleReplayRequested()) { + versionReach.assertReached() + failureReach.assertReached() + } + if (process.env.TANSTACK_DB_ORACLE_STATISTICS === '1') + console.info('release history reach', { + version: Object.fromEntries(versionReach.counts()), + failure: Object.fromEntries(failureReach.counts()), + }) +}) + +declareOracle({ + check: [checkReleaseObservation], + model: [createReleaseModel, applyReleaseVersion, queueRelease], + grammar: [releaseHistories, seedFailureHistories], +}) + +oracleReplayTest(test, VERSION).each( + oracleReplayRequested() ? [releaseHistories[caseIndex]] : releaseHistories, +)( + 'release version history %j', + async (history) => { + versionReach.beginHistory() + const local = createOracleReach( + 'one release version history', + [ + 'first-candidate', + 'mixed-work', + 'stable-only-work', + 'bump-precedence', + 'no-op', + 'stable-no-op', + ...(history.boundary === 'candidate-nine' + ? ['candidate-nine-to-ten'] + : []), + ...(history.boundary === 'patch-nine' ? ['patch-nine-to-ten'] : []), + ...(history.stableDependency ? ['dependent-stable-exit'] : []), + ...(history.exitWork !== 'none' && history.strongest === 'minor' + ? ['consumed-minor-pending-patch-exit'] + : []), + history.exitWork === 'none' + ? 'exit-no-pending' + : history.exitWork === 'stable-only' + ? 'exit-stable-only' + : 'exit-candidate-pending', + ], + { replaying: false }, + ) + local.beginHistory() + const reached = (state) => { + versionReach.reach(state) + local.reach(state) + } + await recordFiniteComparison(VERSION, (comparison) => + withReleaseWorkspace(async (fixture) => { + const { root } = fixture + let model = createReleaseModel(history.stableDependency) + const queue = (id, releases) => { + addFixtureChangeset(root, id, releases) + queueRelease(model, id, releases) + } + const compare = (checkpoint) => { + comparison() + checkReleaseObservation(observeRelease(fixture), model, checkpoint) + } + const version = async (checkpoint) => { + await versionDuringRc(root) + model = applyReleaseVersion(model) + compare(checkpoint) + } + queue('first-rc', first) + await seedFirstCandidate(root, () => enterRealPreMode(root)) + model = applyReleaseVersion(model) + compare('first candidate') + reached('first-candidate') + + if (history.boundary === 'candidate-nine') { + for (let index = 1; index <= 8; index++) { + queue(`warm-minor-${index}`, { db: 'minor' }) + await version(`candidate warmup ${index}`) + } + } else if (history.boundary === 'patch-nine') { + for (let index = 1; index <= 9; index++) { + queue(`warm-patch-${index}`, { db: 'patch' }) + await version(`patch warmup ${index}`) + } + } + queue('isolated-stable', { offline: 'patch' }) + await version('stable work without any candidate release') + reached('stable-only-work') + const mixed = [ + ['mixed-patch', { db: 'patch', query: 'patch' }], + ['stable-only', { offline: 'patch' }], + ] + if (history.reverse) mixed.reverse() + for (const [id, releases] of mixed) queue(id, releases) + await version('mixed and stable-only work') + reached('mixed-work') + if (history.boundary === 'patch-nine') reached('patch-nine-to-ten') + const beforeNoop = workspaceBytes(root) + await version('repeat without pending work') + assert.deepEqual( + workspaceBytes(root), + beforeNoop, + 'no-op preserves workspace bytes', + ) + reached('no-op') + + const precedence = [ + ['weaker', { db: 'patch', query: 'patch' }], + ['stronger', { db: history.strongest, query: history.strongest }], + ] + if (history.reverse) precedence.reverse() + for (const [id, releases] of precedence) queue(id, releases) + await version('strongest pending bump wins') + reached('bump-precedence') + if (history.boundary === 'candidate-nine') + reached('candidate-nine-to-ten') + queue('independent-candidate', { react: 'patch' }) + await version('independent candidate patch') + if (history.exitWork !== 'none') + queue('pending-at-exit', { + ...(history.exitWork === 'candidate-and-stable' + ? { db: 'patch' } + : {}), + query: 'patch', + }) + exitRealPreMode(root) + await versionOnRcExit(root) + model = applyReleaseVersion(model, { exit: true }) + compare('exit applies pending IDs once') + if (history.exitWork !== 'none' && history.strongest === 'minor') + reached('consumed-minor-pending-patch-exit') + reached( + history.exitWork === 'none' + ? 'exit-no-pending' + : history.exitWork === 'stable-only' + ? 'exit-stable-only' + : 'exit-candidate-pending', + ) + if (history.stableDependency) reached('dependent-stable-exit') + const afterExit = workspaceBytes(root) + repeatStableVersion(root) + compare('stable no-op after exit') + assert.deepEqual( + workspaceBytes(root), + afterExit, + 'stable no-op preserves bytes', + ) + reached('stable-no-op') + local.assertReached() + }, history.stableDependency), + ) + }, + 20_000, +) + +oracleReplayTest(test, FAILURE).each( + oracleReplayRequested() + ? [seedFailureHistories[caseIndex]] + : seedFailureHistories, +)( + 'first candidate failure and retry %j', + async (history) => { + failureReach.beginHistory() + const local = createOracleReach( + 'one seed failure history', + ['restored', 'retried'], + { replaying: false }, + ) + local.beginHistory() + await recordFiniteComparison(FAILURE, (comparison) => + withReleaseWorkspace(async (fixture) => { + const { root } = fixture + const model = createReleaseModel(history.stableDependency) + addFixtureChangeset(root, 'first-rc', first) + queueRelease(model, 'first-rc', first) + // Existing changelog bytes and the absence of others both require restore. + writeFileSync( + join(root, 'packages', 'db', 'CHANGELOG.md'), + '# Existing\n', + ) + const before = workspaceBytes(root) + const writeIndex = [ + 'first-manifest', + 'second-manifest', + 'release-config', + ].indexOf(history.failure) + fault.remaining = writeIndex < 0 ? undefined : writeIndex + fault.reached = false + fault.afterApply = history.failure === 'after-apply' + fault.appliedToken = false + if (history.failure === 'changelog') + process.env.RELEASE_ORACLE_CHANGELOG_FAIL = root + try { + await expect( + seedFirstCandidate(root, () => { + if (history.failure === 'before-pre') { + fault.reached = true + throw new Error('injected pre-entry failure') + } + enterRealPreMode(root) + if (history.failure === 'after-pre') { + fault.reached = true + throw new Error('injected post-entry failure') + } + }), + ).rejects.toThrow(/injected/) + if (history.failure === 'changelog') fault.reached = true + } finally { + fault.remaining = undefined + fault.afterApply = false + delete process.env.RELEASE_ORACLE_CHANGELOG_FAIL + } + assert.equal(fault.reached, true, 'the intended failure boundary ran') + if (history.failure === 'after-apply') + assert.equal( + fault.appliedToken, + true, + 'generated changelog write precedes the fault', + ) + comparison() + assert.deepEqual( + workspaceBytes(root), + before, + 'failed first candidate restores every fixture byte', + ) + assert.equal(existsSync(join(root, '.changeset', 'pre.json')), false) + local.reach('restored') + failureReach.reach(`${history.failure}/${history.stableDependency}`) + await seedFirstCandidate(root, () => enterRealPreMode(root)) + const expected = applyReleaseVersion(model) + checkReleaseObservation( + observeRelease(fixture), + expected, + 'retry after seed failure', + ) + local.reach('retried') + assert.equal( + readFileSync( + join(root, 'packages', 'db', 'CHANGELOG.md'), + 'utf8', + ).match(/release-token:first-rc/g).length, + expected.tokens.db.length, + ) + local.assertReached() + }, history.stableDependency), + ) + }, + 20_000, +) diff --git a/scripts/release-http-fixture.js b/scripts/release-http-fixture.js new file mode 100644 index 0000000000..9c2bc706cd --- /dev/null +++ b/scripts/release-http-fixture.js @@ -0,0 +1,505 @@ +/** Real npm/pnpm HTTP receiver support; no expected release computation. */ +import assert from 'node:assert/strict' +import { execFileSync, fork, spawn } from 'node:child_process' +import { + accessSync, + constants, + mkdirSync, + readFileSync, + realpathSync, + symlinkSync, + writeFileSync, +} from 'node:fs' +import { createRequire } from 'node:module' +import { delimiter, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { packageNames } from './release-history-oracle.js' +import { manifestPath, readJson, writeJson } from './release-fixture.js' + +const require = createRequire(import.meta.url) +const changeset = require.resolve('@changesets/cli/bin.js') +const registryScript = fileURLToPath( + new URL('./release-http-registry.js', import.meta.url), +) +const publishScript = fileURLToPath( + new URL('./release-http-publish.mjs', import.meta.url), +) + +function killGroup(child) { + if (child.pid === undefined) return + try { + process.kill(-child.pid, 'SIGKILL') + } catch (error) { + if (error.code !== 'ESRCH') throw error + } +} + +function waitForExit(child) { + if (child.exitCode !== null || child.signalCode !== null) + return Promise.resolve() + return new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error('Child did not exit after termination')), + 5000, + ) + child.once('exit', () => { + clearTimeout(timer) + resolve() + }) + }) +} + +async function failAfterTermination(child, primary) { + const secondary = [] + try { + killGroup(child) + } catch (error) { + secondary.push(error) + } + try { + await waitForExit(child) + } catch (error) { + secondary.push(error) + } + if (secondary.length > 0) + throw new AggregateError( + [primary, ...secondary], + 'HTTP registry failure and termination failures', + { cause: primary }, + ) + throw primary +} + +/** Own the process group, including pnpm/Changesets/npm descendants. */ +export function runReleaseToolTree(executablePath, args, options) { + return new Promise((resolve) => { + const { timeout = 30_000, ...spawnOptions } = options + const child = spawn(executablePath, args, { + ...spawnOptions, + detached: true, + stdio: ['ignore', 'pipe', 'pipe'], + }) + const stdout = [] + const stderr = [] + const outputLimit = 16 * 1024 * 1024 + let outputBytes = 0 + let error + const cleanupErrors = [] + let terminationRequested = false + const terminate = () => { + if (terminationRequested) return + terminationRequested = true + try { + killGroup(child) + } catch (cleanup) { + cleanupErrors.push(cleanup) + try { + child.kill('SIGKILL') + } catch (fallback) { + cleanupErrors.push(fallback) + } + } + } + const timer = setTimeout(() => { + error ??= new Error('Real release command timed out') + terminate() + }, timeout) + child.once('error', (cause) => { + error ??= cause + terminate() + }) + child.once('exit', () => { + terminate() + }) + for (const [stream, chunks] of [ + [child.stdout, stdout], + [child.stderr, stderr], + ]) + stream.on('data', (data) => { + const remaining = outputLimit - outputBytes + if (remaining > 0) { + const accepted = data.subarray(0, remaining) + chunks.push(accepted) + outputBytes += accepted.length + } + if (data.length > remaining) { + error ??= new Error('Real release output exceeds fixture bound') + terminate() + } + }) + child.once('close', (status, signal) => { + clearTimeout(timer) + if (cleanupErrors.length > 0) + error = new AggregateError( + error === undefined ? cleanupErrors : [error, ...cleanupErrors], + 'Real release command and process termination failures', + { cause: error ?? cleanupErrors[0] }, + ) + resolve({ + status, + signal, + stdout: Buffer.concat(stdout).toString(), + stderr: Buffer.concat(stderr).toString(), + error, + }) + }) + }) +} + +function executable(name) { + for (const directory of (process.env.PATH ?? '').split(delimiter)) { + const path = join(directory, name) + try { + accessSync(path, constants.X_OK) + return realpathSync(path) + } catch {} + } + throw new Error(`HTTP release setup requires an installed ${name} executable`) +} + +async function startRegistry(statePath) { + const child = fork(registryScript, [statePath], { + detached: true, + stdio: ['ignore', 'pipe', 'pipe', 'ipc'], + env: { + ...(process.env.HOME ? { HOME: process.env.HOME } : {}), + ...(process.env.TMPDIR ? { TMPDIR: process.env.TMPDIR } : {}), + }, + }) + let output = '' + for (const stream of [child.stdout, child.stderr]) + stream.on('data', (chunk) => { + output = (output + chunk).slice(-8192) + }) + try { + const origin = await new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error(`HTTP registry setup timed out: ${output}`)), + 5000, + ) + const finish = (error, value) => { + clearTimeout(timer) + error ? reject(error) : resolve(value) + } + child.once('error', (error) => finish(error)) + child.once('exit', (code, signal) => + finish( + new Error( + `HTTP registry exited during setup: ${code}/${signal}: ${output}`, + ), + ), + ) + child.once('message', (message) => + message.origin + ? finish(undefined, message.origin) + : finish( + new Error( + `HTTP registry setup failed: ${message.code}: ${message.setupError}`, + ), + ), + ) + }) + return { child, origin, output: () => output } + } catch (error) { + return failAfterTermination(child, error) + } +} + +async function stopRegistry({ child, output }) { + if (child.exitCode !== null || child.signalCode !== null) + throw new Error( + `HTTP registry exited unexpectedly: ${child.exitCode}/${child.signalCode}: ${output()}`, + ) + try { + await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new Error('HTTP registry cleanup timed out')) + }, 5000) + child.once('exit', (code, signal) => { + clearTimeout(timer) + code === 0 + ? resolve() + : reject( + new Error( + `HTTP registry cleanup failed: ${code}/${signal}: ${output()}`, + ), + ) + }) + child.send('close') + }) + } catch (error) { + return failAfterTermination(child, error) + } +} + +/** The server is a child because publishDuringRc synchronously blocks Node. */ +export async function withHttpPublishReceiver(root, history, body) { + if (process.platform === 'win32') + throw new Error( + 'HTTP release receiver requires POSIX process-group cleanup', + ) + writeFileSync( + join(root, 'pnpm-workspace.yaml'), + readFileSync(join(root, 'pnpm-workspace.yaml'), 'utf8') + + '\nverifyDepsBeforeRun: false\n', + ) + const bin = join(root, 'real-bin') + mkdirSync(bin) + const toolPaths = { + node: process.execPath, + npm: executable('npm'), + pnpm: executable('pnpm'), + git: executable('git'), + } + for (const [name, resolvedPath] of Object.entries(toolPaths)) { + // A pnpm shell launcher can find its CLI relative to $0. Invoke it at its + // installed path instead of relocating that pathname through a symlink. + if (name === 'pnpm') { + const quotedPath = `'${resolvedPath.replaceAll("'", "'\\''")}'` + writeFileSync(join(bin, name), `#!/bin/sh\nexec ${quotedPath} "$@"\n`, { + mode: 0o755, + }) + } else symlinkSync(realpathSync(resolvedPath), join(bin, name)) + } + mkdirSync(join(root, 'node_modules', '.bin'), { recursive: true }) + symlinkSync(changeset, join(root, 'node_modules', '.bin', 'changeset')) + const manifests = Object.keys(packageNames).map((key) => { + const manifest = readJson(manifestPath(root, key)) + manifest.version = + key === 'db' + ? history.candidate + : key === 'react' + ? (history.reactCandidate ?? history.candidate) + : key === 'query' + ? '1.5.0' + : '1.2.0' + writeJson(manifestPath(root, key), manifest) + writeFileSync( + join(root, 'packages', key, 'index.js'), + 'export const fixture = true\n', + ) + return manifest + }) + // pnpm resolves workspace:* from local links while packing. Supply those + // fixture links directly; this receiver performs no dependency installation + // and makes no claim about the repository's installed dependency graph. + for (const [owner, name, target] of [ + ['db', 'release-private', 'private'], + ['react', packageNames.db, 'db'], + ]) { + const directory = join(root, 'packages', owner, 'node_modules') + mkdirSync(join(directory, name.startsWith('@') ? name.split('/')[0] : ''), { + recursive: true, + }) + symlinkSync(join(root, 'packages', target), join(directory, name)) + } + const initialRegistry = Object.fromEntries( + manifests.map(({ name, version }, index) => { + const key = Object.keys(packageNames)[index] + const candidate = name === packageNames.db || name === packageNames.react + const current = + history.registry === 'current' || history.currentCandidate === key + const versions = + history.registry === 'never' + ? [] + : current + ? ['older-rc', 'older-rc-latest-current'].includes( + history.tagShape, + ) && candidate + ? ['1.0.0-rc.1', version] + : [version] + : candidate + ? history.registry === 'only-rc' + ? ['1.0.0-rc.1'] + : ['0.15.2'] + : ['1.0.0'] + const last = versions.at(-1) + // A dist-tag may point only to an existing version. Missing tags are legal, + // including after a candidate was accidentally published under latest. + const tags = + last === undefined || + (candidate && history.tagShape === 'no-tags') || + (!candidate && history.stableTagShape === 'no-tags') + ? {} + : candidate && history.tagShape === 'latest-only' + ? { latest: last } + : candidate && + ['older-rc', 'older-rc-latest-current'].includes( + history.tagShape, + ) + ? { + rc: versions[0], + ...(history.tagShape === 'older-rc-latest-current' + ? { latest: last } + : {}), + } + : candidate && ['only-rc', 'current'].includes(history.registry) + ? { + rc: last, + ...(history.tagShape === 'latest-alias' + ? { latest: last } + : {}), + } + : { latest: last } + return [name, { versions, tags }] + }), + ) + // Reject a nearby invalid packument: every authored tag names a present + // version. Tag absence and exact-target absence remain independent facts. + for (const entry of Object.values(initialRegistry)) + for (const version of Object.values(entry.tags)) + assert( + entry.versions.includes(version), + 'fixture dist-tag names an existing version', + ) + const statePath = join(root, 'http-registry.json') + writeJson(statePath, { + packages: structuredClone(initialRegistry), + failed: false, + requests: [], + candidateNames: [packageNames.db, packageNames.react], + failureMode: + history.failure === 'candidate-after-success' + ? history.failure + : undefined, + failureName: + history.failure === 'stable' + ? packageNames.query + : history.failure === 'candidate' + ? packageNames.db + : undefined, + }) + writeJson(join(root, '.changeset', 'pre.json'), { + mode: 'pre', + tag: 'rc', + initialVersions: Object.fromEntries( + manifests.map(({ name, version }) => [name, version]), + ), + changesets: [], + }) + const registry = await startRegistry(statePath) + let failed = false + let primary + try { + const userConfig = join(root, 'npm-user-config') + const globalConfig = join(root, 'npm-global-config') + const gitConfig = join(root, 'git-global-config') + const hooks = join(root, 'empty-hooks') + mkdirSync(hooks) + writeFileSync(globalConfig, '') + writeFileSync(gitConfig, '') + writeFileSync( + userConfig, + [ + `registry=${registry.origin}`, + `@tanstack:registry=${registry.origin}`, + `//${new URL(registry.origin).host}/:_authToken=release-http-fixture`, + 'ignore-scripts=true', + 'audit=false', + 'fund=false', + 'update-notifier=false', + 'fetch-retries=0', + 'fetch-timeout=5000', + 'provenance=false', + ].join('\n') + '\n', + ) + const environment = { + PATH: `${bin}:/usr/bin:/bin`, + ...(process.env.HOME ? { HOME: process.env.HOME } : {}), + ...(process.env.TMPDIR ? { TMPDIR: process.env.TMPDIR } : {}), + CI: 'true', + COREPACK_ENABLE_NETWORK: '0', + npm_config_registry: registry.origin, + NPM_CONFIG_USERCONFIG: userConfig, + NPM_CONFIG_GLOBALCONFIG: globalConfig, + NPM_CONFIG_CACHE: join(root, 'npm-cache'), + NPM_CONFIG_LOGLEVEL: 'error', + NPM_CONFIG_LOGS_MAX: '0', + XDG_CONFIG_HOME: join(root, 'xdg-config'), + GIT_CONFIG_GLOBAL: gitConfig, + GIT_CONFIG_NOSYSTEM: '1', + GIT_TERMINAL_PROMPT: '0', + } + for (const args of [ + ['init', '--quiet', `--template=${hooks}`], + ['config', 'core.hooksPath', hooks], + ['config', 'commit.gpgSign', 'false'], + ['config', 'tag.gpgSign', 'false'], + ['config', 'user.email', 'oracle@example.invalid'], + ['config', 'user.name', 'Release oracle'], + ['add', '.'], + ['commit', '--quiet', '-m', 'HTTP release fixture'], + ]) + execFileSync(join(bin, 'git'), args, { + cwd: root, + env: environment, + stdio: 'pipe', + timeout: 10_000, + }) + const receiver = { + manifests, + initialRegistry, + origin: registry.origin, + tools: { + paths: toolPaths, + ...Object.fromEntries( + ['npm', 'pnpm'].map((name) => [ + name, + execFileSync(join(bin, name), ['--version'], { + cwd: root, + env: environment, + encoding: 'utf8', + timeout: 10_000, + }).trim(), + ]), + ), + }, + readState: () => readJson(statePath), + bytes: () => + Object.fromEntries( + [ + '.changeset/pre.json', + ...Object.keys(packageNames).map( + (key) => `packages/${key}/package.json`, + ), + ].map((file) => [file, readFileSync(join(root, file), 'base64')]), + ), + async run() { + const result = await runReleaseToolTree( + process.execPath, + [publishScript, root], + { + cwd: root, + env: environment, + timeout: 30_000, + }, + ) + if (result.error) + throw new Error('Real release command setup or timeout failure', { + cause: result.error, + }) + if (result.status !== 0) + throw new Error( + `Real release command failed (${result.status}):\n${result.stdout}\n${result.stderr}`, + ) + }, + } + return await body(receiver) + } catch (error) { + failed = true + primary = error + throw error + } finally { + try { + await stopRegistry(registry) + } catch (cleanup) { + throw failed + ? new AggregateError( + [primary, cleanup], + 'HTTP release comparison and cleanup failures', + { cause: primary }, + ) + : cleanup + } + } +} diff --git a/scripts/release-http-fixture.test.mjs b/scripts/release-http-fixture.test.mjs new file mode 100644 index 0000000000..b79c6569c1 --- /dev/null +++ b/scripts/release-http-fixture.test.mjs @@ -0,0 +1,243 @@ +// Ordinary fixture controls. These check the release receiver's process and +// protocol mechanics; product tag judgments remain in the shared oracle. +import assert from 'node:assert/strict' +import { execFileSync, spawnSync } from 'node:child_process' +import { + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { delimiter, join } from 'node:path' +import { test } from 'node:test' +import { setTimeout as delay } from 'node:timers/promises' +import { withReleaseWorkspace } from './release-fixture.js' +import { + runReleaseToolTree, + withHttpPublishReceiver, +} from './release-http-fixture.js' + +async function stopped(pid) { + for (let attempt = 0; attempt < 100; attempt++) { + try { + process.kill(pid, 0) + } catch (error) { + if (error.code === 'ESRCH') return + throw error + } + // A reparented zombie has terminated but can await its host's reaper. + try { + const state = execFileSync('ps', ['-p', String(pid), '-o', 'stat='], { + encoding: 'utf8', + }).trim() + if (state.startsWith('Z')) return + } catch (error) { + if (error.status === 1) return + throw error + } + await delay(10) + } + assert.fail(`release descendant ${pid} still runs after group cleanup`) +} + +for (const outcome of ['fulfilled', 'timed-out']) + test(`release tool ${outcome} terminates its descendant`, async () => { + const root = mkdtempSync(join(tmpdir(), 'release-tool-control-')) + const receipt = join(root, 'descendant.json') + let pid + try { + const descendant = `require('node:fs').writeFileSync(${JSON.stringify(receipt)}, JSON.stringify({ pid: process.pid, group: process.ppid })); setInterval(() => {}, 1000)` + const parent = ` + const fs = require('node:fs') + require('node:child_process').spawn(process.execPath, ['-e', ${JSON.stringify(descendant)}], { stdio: 'ignore' }) + const timer = setInterval(() => { + if (!fs.existsSync(${JSON.stringify(receipt)})) return + process.stdout.write('descendant-started\\n') + clearInterval(timer) + ${outcome === 'fulfilled' ? 'process.exit(0)' : 'setInterval(() => {}, 1000)'} + }, 10) + ` + const result = await runReleaseToolTree( + process.execPath, + ['-e', parent], + { + cwd: root, + env: { PATH: process.env.PATH }, + timeout: 1500, + }, + ) + pid = JSON.parse(readFileSync(receipt, 'utf8')).pid + assert.match(result.stdout, /descendant-started/) + if (outcome === 'fulfilled') { + assert.equal(result.status, 0) + assert.equal(result.error, undefined) + } else { + assert.match(result.error?.message ?? '', /timed out/) + assert.equal(result.signal, 'SIGKILL') + } + await stopped(pid) + } finally { + if (pid !== undefined) { + try { + process.kill(pid, 'SIGKILL') + } catch (error) { + if (error.code !== 'ESRCH') throw error + } + } + rmSync(root, { recursive: true, force: true }) + } + }) + +test('release tool preserves executable startup failure', async () => { + const result = await runReleaseToolTree('/no-such-release-oracle-tool', [], { + env: { PATH: process.env.PATH }, + timeout: 1500, + }) + assert.equal(result.error?.code, 'ENOENT') + assert.equal(result.stdout, '') + assert.equal(result.stderr, '') +}) + +test('release tool rejects output beyond its bound without replacing it with timeout', async () => { + const result = await runReleaseToolTree( + process.execPath, + [ + '-e', + "process.stdout.write('x'.repeat(17 * 1024 * 1024)); setInterval(() => {}, 1000)", + ], + { env: { PATH: process.env.PATH }, timeout: 3000 }, + ) + assert.match(result.error?.message ?? '', /output exceeds fixture bound/) + assert(result.stdout.length <= 16 * 1024 * 1024) +}) + +test('release tool retains timeout and group termination failure separately', () => { + // The isolated process replaces only its own group signal. A direct-child + // signal remains available, so this fault cannot leave our test child alive. + const helper = new URL('./release-http-fixture.js', import.meta.url).href + const probe = ` + const { runReleaseToolTree } = await import(${JSON.stringify(helper)}) + const realKill = process.kill + process.kill = function(pid, signal) { + if (pid < 0) throw Object.assign(new Error('termination sentinel'), { code: 'EPERM' }) + return realKill.call(process, pid, signal) + } + const result = await runReleaseToolTree(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { + env: { PATH: process.env.PATH }, timeout: 75, + }) + console.log(JSON.stringify({ + primary: result.error?.cause?.message, + secondary: result.error?.errors?.map((error) => error?.message), + signal: result.signal, + })) + ` + const result = spawnSync( + process.execPath, + ['--input-type=module', '-e', probe], + { + encoding: 'utf8', + timeout: 5000, + env: { ...process.env }, + }, + ) + assert.equal(result.error, undefined) + assert.equal(result.status, 0, result.stderr) + const observed = JSON.parse(result.stdout) + assert.equal(observed.primary, 'Real release command timed out') + assert(observed.secondary.includes('termination sentinel')) + assert.equal(observed.signal, 'SIGKILL') +}) + +const history = { + candidate: '1.0.0-rc.1.10', + registry: 'stable-history', + failure: 'none', + tagShape: 'ordinary', +} + +test('HTTP receiver executes the PATH-selected relative pnpm launcher at its original location', () => { + const root = mkdtempSync(join(tmpdir(), "release pnpm's launcher-")) + try { + const native = realpathSync( + execFileSync('which', ['pnpm'], { encoding: 'utf8' }).trim(), + ) + const bin = join(root, 'bin') + const tool = join(root, 'global', 'v11', 'pnpm') + mkdirSync(bin) + mkdirSync(join(root, 'global', 'v11'), { recursive: true }) + const quote = (value) => `'${value.replaceAll("'", "'\\''")}'` + writeFileSync(tool, `#!/bin/sh\nexec ${quote(native)} "$@"\n`, { + mode: 0o755, + }) + const launcher = join(bin, 'pnpm') + writeFileSync( + launcher, + '#!/bin/sh\nbasedir=$(dirname "$0")\nexec "$basedir/../global/v11/pnpm" "$@"\n', + { mode: 0o755 }, + ) + const receiver = new URL('./release-http-fixture.js', import.meta.url).href + const workspace = new URL('./release-fixture.js', import.meta.url).href + const probe = ` + const { withHttpPublishReceiver } = await import(${JSON.stringify(receiver)}) + const { withReleaseWorkspace } = await import(${JSON.stringify(workspace)}) + await withReleaseWorkspace(async ({ root }) => withHttpPublishReceiver(root, ${JSON.stringify(history)}, async (receiver) => { + await receiver.run() + console.log(JSON.stringify(receiver.tools)) + })) + ` + const probePath = join(root, 'probe.mjs') + writeFileSync(probePath, probe) + const result = spawnSync(process.execPath, [probePath], { + encoding: 'utf8', + timeout: 45_000, + env: { ...process.env, PATH: `${bin}${delimiter}${process.env.PATH}` }, + }) + assert.equal(result.error, undefined) + assert.equal(result.status, 0, result.stderr) + const tools = JSON.parse(result.stdout) + assert.equal(tools.paths.pnpm, realpathSync(launcher)) + assert.equal( + tools.pnpm, + execFileSync(native, ['--version'], { encoding: 'utf8' }).trim(), + ) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) +for (const outcome of ['error', 'undefined']) + test(`HTTP receiver preserves callback throwing ${outcome}`, async () => { + const primary = + outcome === 'undefined' ? undefined : new Error('comparison sentinel') + let rejected = false + try { + await withReleaseWorkspace(async ({ root }) => + withHttpPublishReceiver(root, history, () => { + throw primary + }), + ) + } catch (error) { + rejected = true + assert.equal(error, primary) + } + assert.equal(rejected, true) + }) + +test('HTTP receiver rejects and records unsupported scoped routes', async () => { + await withReleaseWorkspace(async ({ root }) => + withHttpPublishReceiver(root, history, async (receiver) => { + const path = '/%40tanstack%2Fnot-a-fixture-package' + const response = await fetch(receiver.origin + path) + assert.equal(response.status, 422) + const state = receiver.readState() + assert.equal(state.requests.length, 1) + assert.equal(state.requests[0].type, 'protocol-error') + assert.equal(state.requests[0].path, path) + assert.equal(state.requests[0].registry, receiver.origin) + assert.match(state.requests[0].error, /known public package route/) + assert.deepEqual(state.packages, receiver.initialRegistry) + }), + ) +}) diff --git a/scripts/release-http-publish.mjs b/scripts/release-http-publish.mjs new file mode 100644 index 0000000000..fa0d71345d --- /dev/null +++ b/scripts/release-http-publish.mjs @@ -0,0 +1,4 @@ +/** Default production entry point, isolated so a stalled tool has a hard bound. */ +import { publishDuringRc } from './publish-packages.mjs' + +await publishDuringRc(process.argv[2]) diff --git a/scripts/release-http-registry.js b/scripts/release-http-registry.js new file mode 100644 index 0000000000..74274ad0be --- /dev/null +++ b/scripts/release-http-registry.js @@ -0,0 +1,236 @@ +/** Loopback HTTP production receiver. It supplies registry facts, not tag policy. */ +import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' +import { readFileSync, renameSync, writeFileSync } from 'node:fs' +import http from 'node:http' +import { gunzipSync } from 'node:zlib' + +const statePath = process.argv[2] +const state = JSON.parse(readFileSync(statePath, 'utf8')) +const requests = [] +let origin +const save = () => { + writeFileSync(`${statePath}.next`, JSON.stringify({ ...state, requests })) + renameSync(`${statePath}.next`, statePath) +} + +// Observe the packed manifest without extracting any filesystem path. Fixture +// archives are small USTAR streams made by the real package manager. +function packedManifest(attachment) { + const compressed = Buffer.from(attachment.data, 'base64') + assert.equal(compressed.length, attachment.length, 'attachment byte length') + const tar = gunzipSync(compressed, { maxOutputLength: 4 * 1024 * 1024 }) + let manifest + let files = 0 + for (let offset = 0; offset + 512 <= tar.length;) { + const header = tar.subarray(offset, offset + 512) + if (header.every((value) => value === 0)) break + const name = header.subarray(0, 100).toString().replace(/\0.*$/, '') + const size = Number.parseInt( + header.subarray(124, 136).toString().replace(/\0.*$/, '').trim(), + 8, + ) + assert(Number.isSafeInteger(size) && size >= 0, 'tar entry size') + const data = tar.subarray(offset + 512, offset + 512 + size) + assert.equal(data.length, size, 'complete tar entry') + files++ + if (name === 'package/package.json') { + assert.equal(manifest, undefined, 'one packed manifest') + manifest = JSON.parse(data.toString()) + } + offset += 512 + Math.ceil(size / 512) * 512 + } + assert(manifest, 'packed package.json exists') + return { + manifest, + files, + shasum: createHash('sha1').update(compressed).digest('hex'), + } +} + +const server = http.createServer((request, response) => { + const chunks = [] + let size = 0 + request.on('data', (chunk) => { + size += chunk.length + if (size > 4 * 1024 * 1024) + request.destroy(new Error('fixture request exceeds limit')) + else chunks.push(chunk) + }) + request.on('end', () => { + const observed = { + method: request.method, + path: request.url, + registry: `http://${request.headers.host}`, + authenticated: + request.headers.authorization === 'Bearer release-http-fixture', + } + const reply = (status, value) => { + save() + response.writeHead(status, { 'content-type': 'application/json' }) + response.end(JSON.stringify(value)) + } + try { + const url = new URL(request.url, origin) + assert.equal(observed.registry, origin, 'loopback Host header') + assert.equal(url.origin, origin, 'loopback request target') + assert.equal(url.search, '', 'registry request has no unrecognized query') + if ( + request.method === 'GET' && + url.pathname === '/-/npm/v1/user/profile' + ) { + requests.push({ ...observed, type: 'profile' }) + return reply(200, { + name: 'release-fixture', + tfa: { mode: 'auth-only' }, + }) + } + const name = decodeURIComponent(url.pathname.slice(1)) + assert(Object.hasOwn(state.packages, name), 'known public package route') + const entry = state.packages[name] + if (request.method === 'GET') { + requests.push({ + ...observed, + type: 'info', + name, + versions: [...entry.versions], + tags: { ...entry.tags }, + }) + if (entry.versions.length === 0) + return reply(404, { error: 'not_found' }) + return reply(200, { + _id: name, + name, + 'dist-tags': entry.tags, + versions: Object.fromEntries( + entry.versions.map((version) => [ + version, + { + name, + version, + dist: { + tarball: `${origin}/${encodeURIComponent(name)}/-/fixture-${version}.tgz`, + }, + }, + ]), + ), + }) + } + assert.equal(request.method, 'PUT', 'registry publish method') + assert.equal( + observed.authenticated, + true, + 'isolated fixture authentication', + ) + let body = Buffer.concat(chunks) + if (request.headers['content-encoding'] === 'gzip') + body = gunzipSync(body, { maxOutputLength: 4 * 1024 * 1024 }) + else + assert.equal( + request.headers['content-encoding'], + undefined, + 'known body encoding', + ) + const document = JSON.parse(body.toString()) + assert.equal(document.name, name, 'document matches route') + const versions = Object.keys(document.versions) + const tags = Object.entries(document['dist-tags']) + const attachments = Object.values(document._attachments) + assert.equal(versions.length, 1, 'one uploaded version') + assert.equal(tags.length, 1, 'one publication tag') + assert.equal(attachments.length, 1, 'one packed attachment') + const [version] = versions + const [[tag, taggedVersion]] = tags + assert.equal(taggedVersion, version, 'tag points to uploaded version') + const archive = packedManifest(attachments[0]) + assert.equal(archive.manifest.name, name, 'packed name matches route') + assert.equal( + archive.manifest.version, + version, + 'packed version matches document', + ) + assert.equal( + document.versions[version].dist.shasum, + archive.shasum, + 'packed digest matches document', + ) + const duplicate = entry.versions.includes(version) + // Fail the next candidate only after an actual candidate upload succeeded. + // This observes arrival order and imposes no package scheduling strategy. + const priorCandidateSuccess = requests.some( + (request) => + request.type === 'publish' && + request.success && + state.candidateNames?.includes(request.name), + ) + const controlledFailure = + !state.failed && + !duplicate && + (name === state.failureName || + (state.failureMode === 'candidate-after-success' && + state.candidateNames.includes(name) && + priorCandidateSuccess)) + const success = !controlledFailure && !duplicate + requests.push({ + ...observed, + type: 'publish', + name, + version, + tag, + access: document.access, + success, + controlledFailure, + duplicate, + archive: { + name: archive.manifest.name, + version: archive.manifest.version, + files: archive.files, + }, + }) + if (controlledFailure) { + state.failed = true + return reply(400, { + error: 'EORACLE', + reason: 'controlled publication failure', + }) + } + if (duplicate) + return reply(409, { + error: 'EPUBLISHCONFLICT', + reason: 'version already exists', + }) + entry.versions.push(version) + entry.tags[tag] = version + return reply(201, { ok: true, id: name, rev: 'fixture' }) + } catch (error) { + requests.push({ + ...observed, + type: 'protocol-error', + error: error.message, + }) + return reply(422, { error: 'EORACLEPROTOCOL', reason: error.message }) + } + }) + request.on('error', (error) => { + requests.push({ type: 'request-error', error: error.message }) + save() + }) +}) +server.on('error', (error) => { + process.send?.({ setupError: error.message, code: error.code }) + process.exitCode = 1 +}) +server.listen(0, '127.0.0.1', () => { + origin = `http://127.0.0.1:${server.address().port}` + process.send?.({ origin }) +}) +process.on('message', (message) => { + if (message === 'close') { + server.close(() => process.exit(0)) + server.closeAllConnections() + } +}) +process.on('disconnect', () => { + server.close(() => process.exit(0)) + server.closeAllConnections() +}) diff --git a/scripts/test-minified-db.mjs b/scripts/test-minified-db.mjs index 137df2cd74..1e43fc5298 100644 --- a/scripts/test-minified-db.mjs +++ b/scripts/test-minified-db.mjs @@ -92,7 +92,7 @@ const errorGroups = { DebounceCallDroppedError: `DebounceCallDroppedError`, OnMutateMustBeSynchronousError: `OnMutateMustBeSynchronousError`, QueryBuilderError: `QueryBuilderError OnlyOneSourceAllowedError SubQueryMustHaveFromClauseError InvalidSourceError InvalidSourceTypeError JoinConditionMustBeEqualityError QueryMustHaveFromClauseError InvalidWhereExpressionError IncludeOperatorRequiredError`, - QueryCompilationError: `QueryCompilationError DistinctRequiresSelectError FnSelectWithGroupByError UnsupportedFnSelectResultError UnsupportedRootScalarSelectError HavingRequiresGroupByError LimitOffsetRequireOrderByError CollectionInputNotFoundError DuplicateAliasInSubqueryError UnsupportedFromTypeError UnknownExpressionTypeError EmptyReferencePathError UnknownFunctionError JoinCollectionNotFoundError MissingAliasInputsError SetWindowRequiresOrderByError SetWindowFindOneLimitError GroupingOperatorRequiredError`, + QueryCompilationError: `QueryCompilationError DistinctRequiresSelectError FnSelectWithGroupByError UnsupportedFnSelectResultError UnsupportedRootScalarSelectError HavingRequiresGroupByError LimitOffsetRequireOrderByError CollectionInputNotFoundError UnsupportedFromTypeError UnknownExpressionTypeError EmptyReferencePathError UnknownFunctionError JoinCollectionNotFoundError MissingAliasInputsError SetWindowRequiresOrderByError SetWindowFindOneLimitError GroupingOperatorRequiredError`, UnsafeAliasPathError: `UnsafeAliasPathError`, JoinError: `JoinError UnsupportedJoinTypeError InvalidJoinConditionSameSourceError InvalidJoinConditionSourceMismatchError InvalidJoinConditionLeftSourceError InvalidJoinConditionRightSourceError InvalidJoinCondition UnsupportedJoinSourceTypeError`, GroupByError: `GroupByError NonAggregateExpressionNotInGroupByError UnsupportedAggregateFunctionError AggregateFunctionNotInSelectError UnknownHavingExpressionTypeError`, @@ -208,7 +208,6 @@ async function checkCollectionsAndQueries(db) { rows.map((row) => { const selected = { ...row } delete selected.$hasPendingWrites - delete selected.$synced delete selected.$origin delete selected.$key delete selected.$collectionId diff --git a/scripts/version-packages.mjs b/scripts/version-packages.mjs new file mode 100644 index 0000000000..9ce8bd0985 --- /dev/null +++ b/scripts/version-packages.mjs @@ -0,0 +1,333 @@ +import { execFileSync } from 'node:child_process' +import { + existsSync, + readFileSync, + readdirSync, + unlinkSync, + writeFileSync, +} from 'node:fs' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import applyReleasePlan from '@changesets/apply-release-plan' +import assembleReleasePlan from '@changesets/assemble-release-plan' +import { read as readChangesetsConfig } from '@changesets/config' +import getReleasePlan from '@changesets/get-release-plan' +import readChangesets from '@changesets/read' +import { getPackages } from '@manypkg/get-packages' + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const changesetPath = join(root, '.changeset', 'first-rc.md') + +function run(command, args, rootDirectory = root) { + execFileSync(command, args, { cwd: rootDirectory, stdio: 'inherit' }) +} + +function packageManifests(rootDirectory) { + return readdirSync(join(rootDirectory, 'packages')).flatMap((directory) => { + const path = join(rootDirectory, 'packages', directory, 'package.json') + return existsSync(path) + ? [{ path, value: JSON.parse(readFileSync(path, 'utf8')) }] + : [] + }) +} + +function privateWorkspaceNames(directory) { + const names = [] + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const path = join(directory, entry.name) + if ( + entry.isDirectory() && + !['node_modules', 'dist', 'build', '.next', '.output'].includes( + entry.name, + ) + ) { + names.push(...privateWorkspaceNames(path)) + } else if (entry.isFile() && entry.name === 'package.json') { + const manifest = JSON.parse(readFileSync(path, 'utf8')) + if (manifest.private && typeof manifest.name === 'string') { + names.push(manifest.name) + } + } + } + return names +} + +export async function seedFirstCandidate( + rootDirectory = root, + enterPreMode = () => + run('pnpm', ['exec', 'changeset', 'pre', 'enter', 'rc'], rootDirectory), +) { + const firstChangesetPath = join(rootDirectory, '.changeset', 'first-rc.md') + const releaseConfigPath = join(rootDirectory, '.changeset', 'config.json') + const preStatePath = join(rootDirectory, '.changeset', 'pre.json') + const changeset = readFileSync(firstChangesetPath, 'utf8') + const releaseEntries = [ + ...changeset.matchAll(/^'([^']+)': (major|minor|patch)$/gm), + ] + const releaseNames = new Set(releaseEntries.map((match) => match[1])) + const manifests = packageManifests(rootDirectory) + const preOne = manifests.filter( + ({ value }) => !value.private && /^0\./.test(value.version), + ) + const alreadyOne = manifests + .filter(({ value }) => !value.private && /^1\./.test(value.version)) + .map(({ value }) => value.name) + const expectedNames = new Set(preOne.map(({ value }) => value.name)) + const stableNames = new Set(alreadyOne) + if ( + releaseEntries.length !== releaseNames.size || + [...expectedNames].some((name) => !releaseNames.has(name)) || + releaseEntries.some( + (match) => + (expectedNames.has(match[1]) && match[2] !== 'major') || + (stableNames.has(match[1]) && match[2] === 'major') || + (!expectedNames.has(match[1]) && !stableNames.has(match[1])), + ) + ) { + throw new Error( + 'The first RC changeset must list every public pre-1.0 package as major and any already-1.x package as patch or minor, each exactly once.', + ) + } + + const originalConfig = readFileSync(releaseConfigPath, 'utf8') + const config = JSON.parse(originalConfig) + const ignored = new Set([ + ...config.ignore, + ...privateWorkspaceNames(join(rootDirectory, 'packages')), + ...privateWorkspaceNames(join(rootDirectory, 'examples')), + ]) + config.ignore = [...ignored] + config.privatePackages = { version: false, tag: false } + const removedDependencies = [] + + // Changesets can rewrite manifests and changelogs before failing. A failed + // first attempt must leave no rc.0 seed or pre.json that changes a rerun. + const restorePaths = [ + releaseConfigPath, + preStatePath, + ...manifests.flatMap(({ path }) => [ + path, + join(dirname(path), 'CHANGELOG.md'), + ]), + ...readdirSync(join(rootDirectory, '.changeset')) + .filter((name) => name.endsWith('.md')) + .map((name) => join(rootDirectory, '.changeset', name)), + ] + const originals = restorePaths.map((path) => ({ + path, + contents: existsSync(path) ? readFileSync(path) : undefined, + })) + + let succeeded = false + try { + for (const { path, value } of preOne) { + // Changesets increments the existing prerelease counter to rc.1. + value.version = '1.0.0-rc.0' + // Changesets requires a package that depends on an ignored package to + // be ignored too. Hide those edges only while calculating this release. + for (const field of [ + 'dependencies', + 'devDependencies', + 'peerDependencies', + 'optionalDependencies', + ]) { + for (const [index, [name, range]] of Object.entries( + value[field] ?? {}, + ).entries()) { + if (ignored.has(name)) { + removedDependencies.push({ path, field, name, range, index }) + delete value[field][name] + } + } + } + writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`) + } + writeFileSync(releaseConfigPath, `${JSON.stringify(config, null, 2)}\n`) + await enterPreMode() + await versionDuringRc(rootDirectory) + succeeded = true + } finally { + if (succeeded) { + writeFileSync(releaseConfigPath, originalConfig) + for (const { path, field, name, range, index } of removedDependencies) { + const value = JSON.parse(readFileSync(path, 'utf8')) + const entries = Object.entries(value[field]) + entries.splice(index, 0, [name, range]) + value[field] = Object.fromEntries(entries) + writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`) + } + } else { + for (const { path, contents } of originals) { + if (contents === undefined) { + if (existsSync(path)) unlinkSync(path) + } else { + writeFileSync(path, contents) + } + } + } + } +} + +/** + * Changesets tracks which changesets were consumed and determines dependent + * releases. Its prerelease counter does not distinguish patch from minor, so + * replace only the planned version for each package during the RC window. + */ +export function versionForRcRelease(release) { + if (release.type === 'none') return release.oldVersion + + const candidate = /^1\.0\.0-rc\.(\d+)(?:\.(\d+))?$/.exec(release.oldVersion) + if (candidate) { + const generation = Number(candidate[1]) + if (release.type === 'patch') { + const patch = Number(candidate[2] ?? 0) + 1 + return `1.0.0-rc.${generation}.${patch}` + } + if (release.type === 'minor' || release.type === 'major') { + return `1.0.0-rc.${generation + 1}` + } + } + + if (/^\d+\.\d+\.\d+$/.test(release.oldVersion)) { + const stableVersion = release.newVersion.replace(/-rc\.0$/, '') + if (/^\d+\.\d+\.\d+$/.test(stableVersion)) return stableVersion + } + + throw new Error( + `Cannot calculate an RC version for ${release.name}: ${release.oldVersion} (${release.type}).`, + ) +} + +async function rcReleasePlan(rootDirectory) { + const packages = await getPackages(rootDirectory) + const config = await readChangesetsConfig(rootDirectory, packages) + const releaseConfig = { + ...config, + privatePackages: { version: false, tag: false }, + } + const plan = await getReleasePlan(rootDirectory, undefined, releaseConfig) + return { packages, releaseConfig, plan } +} + +export async function versionDuringRc(rootDirectory = root) { + const { packages, releaseConfig, plan } = await rcReleasePlan(rootDirectory) + if (plan.releases.length === 0) return + + for (const release of plan.releases) { + release.newVersion = versionForRcRelease(release) + } + await applyReleasePlan( + plan, + packages, + releaseConfig, + undefined, + rootDirectory, + ) +} + +export async function versionOnRcExit(rootDirectory = root) { + const packages = await getPackages(rootDirectory) + const config = await readChangesetsConfig(rootDirectory, packages) + const releaseConfig = { + ...config, + privatePackages: { version: false, tag: false }, + } + const preState = JSON.parse( + readFileSync(join(rootDirectory, '.changeset', 'pre.json'), 'utf8'), + ) + const consumedChangesets = new Set(preState.changesets) + const allChangesets = await readChangesets(rootDirectory) + const pendingChangesets = allChangesets.filter( + ({ id }) => !consumedChangesets.has(id), + ) + const exitChangesetId = 'rc-exit-version-plan' + if (allChangesets.some(({ id }) => id === exitChangesetId)) { + throw new Error(`The reserved changeset ID ${exitChangesetId} is in use.`) + } + const candidateNames = packages.packages + .filter(({ packageJson }) => + /^1\.0\.0-rc\.\d+(?:\.\d+)?$/.test(packageJson.version), + ) + .map(({ packageJson }) => packageJson.name) + const exitChangeset = { + id: exitChangesetId, + summary: '', + releases: candidateNames.map((name) => ({ name, type: 'patch' })), + } + // Exit planning must use only pending changesets. Replaying the changesets + // already applied during pre mode repeats changelog entries and can turn a + // stable package's pending patch into a major release. The synthetic patch + // exposes the candidate-to-1.0 dependency change before dependents are + // calculated; Changesets otherwise adds exit patches after that pass. + const plan = assembleReleasePlan( + [...pendingChangesets, exitChangeset], + packages, + releaseConfig, + preState, + ) + plan.changesets = pendingChangesets + for (const release of plan.releases) { + release.changesets = release.changesets.filter( + (id) => id !== exitChangesetId, + ) + } + const privatePackages = new Set( + packages.packages + .filter(({ packageJson }) => packageJson.private) + .map(({ packageJson }) => packageJson.name), + ) + plan.releases = plan.releases.filter((release) => { + if (privatePackages.has(release.name)) return false + if (/^1\.0\.0-rc\.\d+(?:\.\d+)?$/.test(release.oldVersion)) { + release.newVersion = '1.0.0' + return true + } + if (/^\d+\.\d+\.\d+$/.test(release.oldVersion)) { + return true + } + throw new Error( + `Cannot exit RC mode for ${release.name}: ${release.oldVersion}.`, + ) + }) + if (plan.releases.length === 0) { + throw new Error('No 1.0 RC packages found while exiting prerelease mode.') + } + await applyReleasePlan( + plan, + packages, + releaseConfig, + undefined, + rootDirectory, + ) + for (const { id } of allChangesets) { + if (consumedChangesets.has(id)) { + const path = join(rootDirectory, '.changeset', `${id}.md`) + if (existsSync(path)) unlinkSync(path) + } + } +} + +async function main() { + const preStatePath = join(root, '.changeset', 'pre.json') + const preState = existsSync(preStatePath) + ? JSON.parse(readFileSync(preStatePath, 'utf8')) + : undefined + if (existsSync(changesetPath) && preState === undefined) { + await seedFirstCandidate() + } else if (preState?.mode === 'pre' && preState.tag === 'rc') { + await versionDuringRc() + } else if (preState?.mode === 'exit' && preState.tag === 'rc') { + await versionOnRcExit() + } else { + run('pnpm', ['exec', 'changeset', 'version']) + } + + run('pnpm', ['exec', 'tsx', 'scripts/update-example-deps.ts']) + run('pnpm', ['install', '--no-frozen-lockfile']) +} + +if ( + process.argv[1] && + resolve(process.argv[1]) === fileURLToPath(import.meta.url) +) + await main() diff --git a/scripts/version-packages.test.mjs b/scripts/version-packages.test.mjs new file mode 100644 index 0000000000..65d0189352 --- /dev/null +++ b/scripts/version-packages.test.mjs @@ -0,0 +1,402 @@ +import assert from 'node:assert/strict' +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + unlinkSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { test } from 'node:test' +import { + seedFirstCandidate, + versionDuringRc, + versionForRcRelease, + versionOnRcExit, +} from './version-packages.mjs' + +test('stable packages keep their release line and require a compatible DB API', () => { + const firstRc = readFileSync( + new URL('../.changeset/first-rc.md', import.meta.url), + 'utf8', + ) + for (const [directory, bump, peer] of [ + ['query-db-collection', 'minor', '^1.0.0-rc.0'], + ['offline-transactions', 'minor', '^1.0.0-rc.0'], + ]) { + const manifest = JSON.parse( + readFileSync( + new URL(`../packages/${directory}/package.json`, import.meta.url), + 'utf8', + ), + ) + assert.equal(manifest.dependencies?.['@tanstack/db'], undefined) + assert.equal(manifest.peerDependencies['@tanstack/db'], peer) + assert.match(firstRc, new RegExp(`'${manifest.name}': ${bump}`)) + } +}) + +test('the first RC changeset covers the current public package inventory', () => { + const packages = new URL('../packages/', import.meta.url) + const entries = [ + ...readFileSync( + new URL('../.changeset/first-rc.md', import.meta.url), + 'utf8', + ).matchAll(/^'([^']+)': (major|minor|patch)$/gm), + ] + const publicPackages = readdirSync(packages).flatMap((directory) => { + const path = new URL(`${directory}/package.json`, packages) + if (!existsSync(path)) return [] + const manifest = JSON.parse(readFileSync(path, 'utf8')) + return manifest.private ? [] : [manifest] + }) + assert.deepEqual( + entries.map((entry) => entry[1]).sort(), + publicPackages.map((manifest) => manifest.name).sort(), + ) + for (const manifest of publicPackages) { + const bump = entries.find((entry) => entry[1] === manifest.name)[2] + assert.equal(bump, manifest.version.startsWith('0.') ? 'major' : 'minor') + } +}) + +function writeJson(path, value) { + writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`) +} + +function packageVersion(root, directory) { + return JSON.parse( + readFileSync(join(root, 'packages', directory, 'package.json'), 'utf8'), + ).version +} + +function packageManifest(root, directory) { + return JSON.parse( + readFileSync(join(root, 'packages', directory, 'package.json'), 'utf8'), + ) +} + +function addChangeset(root, id, releases) { + const frontmatter = Object.entries(releases) + .map(([name, type]) => `'${name}': ${type}`) + .join('\n') + writeFileSync( + join(root, '.changeset', `${id}.md`), + `---\n${frontmatter}\n---\n\nFixture change.\n`, + ) +} + +function createRcWorkspace() { + const root = mkdtempSync(join(tmpdir(), 'tanstack-db-rc-test-')) + mkdirSync(join(root, '.changeset')) + mkdirSync(join(root, 'examples', 'app'), { recursive: true }) + mkdirSync(join(root, 'examples', 'no-version'), { recursive: true }) + writeJson(join(root, 'package.json'), { + name: 'release-fixture', + private: true, + packageManager: 'pnpm@11.1.0', + }) + writeFileSync( + join(root, 'pnpm-workspace.yaml'), + 'packages:\n - packages/*\n - examples/*\n', + ) + writeJson(join(root, '.changeset', 'config.json'), { + changelog: false, + commit: false, + access: 'public', + baseBranch: 'main', + updateInternalDependencies: 'patch', + fixed: [], + linked: [], + ignore: [], + ___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH: { + onlyUpdatePeerDependentsWhenOutOfRange: true, + }, + }) + + const packages = { + db: { name: '@tanstack/db', version: '1.0.0-rc.1' }, + 'react-db': { + name: '@tanstack/react-db', + version: '1.0.0-rc.1', + dependencies: { '@tanstack/db': 'workspace:*' }, + }, + 'query-db-collection': { + name: '@tanstack/query-db-collection', + version: '1.4.5', + peerDependencies: { '@tanstack/db': '^0.15.0 || ^1.0.0-rc.0' }, + }, + 'offline-transactions': { + name: '@tanstack/offline-transactions', + version: '1.1.2', + peerDependencies: { '@tanstack/db': '^0.15.0 || ^1.0.0-rc.0' }, + }, + } + for (const [directory, manifest] of Object.entries(packages)) { + mkdirSync(join(root, 'packages', directory), { recursive: true }) + writeJson(join(root, 'packages', directory, 'package.json'), manifest) + } + writeJson(join(root, 'examples', 'app', 'package.json'), { + name: 'release-fixture-app', + version: '0.1.0', + private: true, + dependencies: { '@tanstack/db': 'workspace:*' }, + }) + writeJson(join(root, 'examples', 'no-version', 'package.json'), { + name: 'release-fixture-no-version', + private: true, + }) + writeJson(join(root, '.changeset', 'pre.json'), { + mode: 'pre', + tag: 'rc', + initialVersions: { + 'release-fixture-app': '0.1.0', + '@tanstack/db': '1.0.0-rc.0', + '@tanstack/react-db': '1.0.0-rc.0', + '@tanstack/query-db-collection': '1.4.5', + '@tanstack/offline-transactions': '1.1.2', + }, + changesets: ['first-rc'], + }) + addChangeset(root, 'first-rc', { + '@tanstack/db': 'major', + '@tanstack/react-db': 'major', + }) + return root +} + +function createFirstCandidateWorkspace() { + const root = createRcWorkspace() + unlinkSync(join(root, '.changeset', 'pre.json')) + for (const [directory, version] of [ + ['db', '0.15.0'], + ['react-db', '0.5.10'], + ]) { + const path = join(root, 'packages', directory, 'package.json') + writeJson(path, { ...packageManifest(root, directory), version }) + } + addChangeset(root, 'first-rc', { + '@tanstack/db': 'major', + '@tanstack/react-db': 'major', + '@tanstack/query-db-collection': 'minor', + '@tanstack/offline-transactions': 'patch', + }) + return root +} + +function enterFixturePreMode(root) { + writeJson(join(root, '.changeset', 'pre.json'), { + mode: 'pre', + tag: 'rc', + initialVersions: { + '@tanstack/db': '1.0.0-rc.0', + '@tanstack/react-db': '1.0.0-rc.0', + '@tanstack/query-db-collection': '1.4.5', + '@tanstack/offline-transactions': '1.1.2', + }, + changesets: [], + }) +} + +test('the first RC versions mixed and stable-only changesets without changing stable major lines', async (t) => { + const root = createFirstCandidateWorkspace() + t.after(() => rmSync(root, { recursive: true, force: true })) + addChangeset(root, 'mixed', { + '@tanstack/db': 'patch', + '@tanstack/query-db-collection': 'patch', + }) + addChangeset(root, 'stable-only', { + '@tanstack/offline-transactions': 'patch', + }) + + await seedFirstCandidate(root, () => enterFixturePreMode(root)) + + assert.equal(packageVersion(root, 'db'), '1.0.0-rc.1') + assert.equal(packageVersion(root, 'react-db'), '1.0.0-rc.1') + assert.equal(packageVersion(root, 'query-db-collection'), '1.5.0') + assert.equal(packageVersion(root, 'offline-transactions'), '1.1.3') + assert.deepEqual( + JSON.parse(readFileSync(join(root, '.changeset', 'pre.json'))).changesets, + ['first-rc', 'mixed', 'stable-only'], + ) + assert.deepEqual( + JSON.parse(readFileSync(join(root, '.changeset', 'config.json'))).ignore, + [], + ) +}) + +test('the first RC rejects a major bump for an already-1.x package before changing files', async (t) => { + const root = createFirstCandidateWorkspace() + t.after(() => rmSync(root, { recursive: true, force: true })) + const path = join(root, '.changeset', 'first-rc.md') + writeFileSync( + path, + readFileSync(path, 'utf8').replace( + "'@tanstack/query-db-collection': minor", + "'@tanstack/query-db-collection': major", + ), + ) + await assert.rejects(seedFirstCandidate(root), /patch or minor/) + assert.equal(packageVersion(root, 'db'), '0.15.0') + assert.equal(packageVersion(root, 'query-db-collection'), '1.4.5') + assert.equal(existsSync(join(root, '.changeset', 'pre.json')), false) +}) + +test('a failed first RC restores versions, config, changelogs, and pre-state', async (t) => { + const root = createFirstCandidateWorkspace() + t.after(() => rmSync(root, { recursive: true, force: true })) + addChangeset(root, 'bad-package', { '@tanstack/not-in-workspace': 'patch' }) + const paths = [ + join(root, '.changeset', 'config.json'), + ...['db', 'react-db', 'query-db-collection', 'offline-transactions'].map( + (directory) => join(root, 'packages', directory, 'package.json'), + ), + ] + const originalFiles = paths.map((path) => readFileSync(path, 'utf8')) + + await assert.rejects( + seedFirstCandidate(root, () => enterFixturePreMode(root)), + /not in the workspace/, + ) + assert.deepEqual( + paths.map((path) => readFileSync(path, 'utf8')), + originalFiles, + ) + assert.equal(existsSync(join(root, '.changeset', 'pre.json')), false) + assert.equal(existsSync(join(root, 'packages', 'db', 'CHANGELOG.md')), false) +}) + +test('RC exit retains a dependent bump when a stable package pins db directly', async (t) => { + const root = createRcWorkspace() + t.after(() => rmSync(root, { recursive: true, force: true })) + const path = join(root, 'packages', 'query-db-collection', 'package.json') + const manifest = packageManifest(root, 'query-db-collection') + delete manifest.peerDependencies + manifest.dependencies = { '@tanstack/db': 'workspace:*' } + writeJson(path, manifest) + const preStatePath = join(root, '.changeset', 'pre.json') + writeJson(preStatePath, { + ...JSON.parse(readFileSync(preStatePath, 'utf8')), + mode: 'exit', + }) + + await versionOnRcExit(root) + assert.equal(packageVersion(root, 'db'), '1.0.0') + assert.equal(packageVersion(root, 'query-db-collection'), '1.4.6') +}) + +test('RC exit applies only pending stable changesets after a consumed minor', async (t) => { + const root = createRcWorkspace() + t.after(() => rmSync(root, { recursive: true, force: true })) + addChangeset(root, 'consumed-minor', { + '@tanstack/query-db-collection': 'minor', + }) + await versionDuringRc(root) + assert.equal(packageVersion(root, 'query-db-collection'), '1.5.0') + + addChangeset(root, 'pending-patch', { + '@tanstack/query-db-collection': 'patch', + }) + const preStatePath = join(root, '.changeset', 'pre.json') + writeJson(preStatePath, { + ...JSON.parse(readFileSync(preStatePath, 'utf8')), + mode: 'exit', + }) + await versionOnRcExit(root) + assert.equal(packageVersion(root, 'query-db-collection'), '1.5.1') + assert.equal(existsSync(join(root, '.changeset', 'consumed-minor.md')), false) + assert.equal(existsSync(join(root, '.changeset', 'pending-patch.md')), false) +}) + +test('RC version numbers follow the Changeset release type', () => { + const version = (oldVersion, type, newVersion = '1.0.0-rc.2') => + versionForRcRelease({ + name: '@tanstack/db', + oldVersion, + newVersion, + type, + }) + + assert.equal(version('1.0.0-rc.1', 'patch'), '1.0.0-rc.1.1') + assert.equal(version('1.0.0-rc.1.1', 'patch'), '1.0.0-rc.1.2') + assert.equal(version('1.0.0-rc.1.2', 'minor'), '1.0.0-rc.2') + assert.equal(version('1.0.0-rc.2', 'patch'), '1.0.0-rc.2.1') + assert.equal(version('1.0.0-rc.2.1', 'major'), '1.0.0-rc.3') + assert.equal(version('1.4.5', 'patch', '1.4.6-rc.0'), '1.4.6') + assert.equal(version('1.1.2', 'minor', '1.2.0-rc.0'), '1.2.0') + assert.throws( + () => version('0.9.0', 'patch'), + /Cannot calculate an RC version/, + ) +}) + +test('RC versioning consumes Changesets while preserving stable and private packages', async (t) => { + const root = createRcWorkspace() + t.after(() => rmSync(root, { recursive: true, force: true })) + + addChangeset(root, 'patch-one', { + '@tanstack/db': 'patch', + '@tanstack/query-db-collection': 'patch', + '@tanstack/offline-transactions': 'patch', + }) + await versionDuringRc(root) + assert.equal(packageVersion(root, 'db'), '1.0.0-rc.1.1') + assert.equal(packageVersion(root, 'react-db'), '1.0.0-rc.1.1') + assert.equal(packageVersion(root, 'query-db-collection'), '1.4.6') + assert.equal(packageVersion(root, 'offline-transactions'), '1.1.3') + assert.equal( + JSON.parse(readFileSync(join(root, 'examples', 'app', 'package.json'))) + .version, + '0.1.0', + ) + + await versionDuringRc(root) + assert.equal(packageVersion(root, 'db'), '1.0.0-rc.1.1') + assert.deepEqual( + JSON.parse(readFileSync(join(root, '.changeset', 'pre.json'))).changesets, + ['first-rc', 'patch-one'], + ) + + addChangeset(root, 'patch-two', { '@tanstack/db': 'patch' }) + await versionDuringRc(root) + assert.equal(packageVersion(root, 'db'), '1.0.0-rc.1.2') + assert.equal(packageVersion(root, 'react-db'), '1.0.0-rc.1.2') + assert.equal(packageVersion(root, 'query-db-collection'), '1.4.6') + assert.equal(packageVersion(root, 'offline-transactions'), '1.1.3') + + addChangeset(root, 'patch-three', { '@tanstack/db': 'patch' }) + addChangeset(root, 'minor-one', { '@tanstack/db': 'minor' }) + await versionDuringRc(root) + assert.equal(packageVersion(root, 'db'), '1.0.0-rc.2') + assert.equal(packageVersion(root, 'query-db-collection'), '1.4.6') + + addChangeset(root, 'patch-four', { '@tanstack/db': 'patch' }) + await versionDuringRc(root) + assert.equal(packageVersion(root, 'db'), '1.0.0-rc.2.1') + + addChangeset(root, 'stable-on-exit', { + '@tanstack/query-db-collection': 'patch', + }) + const preStatePath = join(root, '.changeset', 'pre.json') + const preState = JSON.parse(readFileSync(preStatePath, 'utf8')) + writeJson(preStatePath, { ...preState, mode: 'exit' }) + await versionOnRcExit(root) + assert.equal(packageVersion(root, 'db'), '1.0.0') + assert.equal(packageVersion(root, 'react-db'), '1.0.0') + assert.equal(packageVersion(root, 'query-db-collection'), '1.4.7') + assert.equal(packageVersion(root, 'offline-transactions'), '1.1.3') + assert.equal( + JSON.parse( + readFileSync(join(root, 'examples', 'no-version', 'package.json')), + ).version, + undefined, + ) + assert.equal(existsSync(preStatePath), false) + assert.equal(existsSync(join(root, '.changeset', 'first-rc.md')), false) + assert.equal(existsSync(join(root, '.changeset', 'patch-one.md')), false) + assert.equal(existsSync(join(root, '.changeset', 'stable-on-exit.md')), false) +})