From 0e21bb580beacb60c2277917613dd3123ba01e09 Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Thu, 22 Oct 2026 11:56:11 -0700 Subject: [PATCH 01/20] feat: add Convex collection with pagination and durable replay --- .changeset/convex-collection.md | 8 + .github/workflows/e2e-tests.yml | 5 + .prettierignore | 2 + docs/collections/convex-collection.md | 274 ++++++++++++++ docs/config.json | 4 + docs/contributing/oracle-coverage.md | 82 +++++ docs/guides/offline-transactions.md | 8 + .../functions/convexCollectionOptions.md | 40 ++ .../convexPaginatedCollectionOptions.md | 40 ++ docs/reference/convex-db-collection/index.md | 19 + .../interfaces/ConvexCollectionUtils.md | 48 +++ .../functions/convexOfflineMutation.md | 30 ++ .../convex-db-collection/offline/index.md | 12 + .../offline/variables/convexMutationCodec.md | 12 + .../type-aliases/ConvexCollectionClient.md | 12 + .../type-aliases/ConvexCollectionConfig.md | 72 ++++ .../ConvexPaginatedCollectionConfig.md | 74 ++++ eslint.config.js | 1 + packages/convex-db-collection/README.md | 7 + .../e2e/backend-oracle.test.ts | 300 +++++++++++++++ .../e2e/backend/.gitignore | 3 + .../e2e/backend/convex.json | 1 + .../e2e/backend/convex/schema.ts | 14 + .../e2e/backend/convex/tasks.ts | 78 ++++ .../e2e/backend/convex/tsconfig.json | 25 ++ .../e2e/backend/package.json | 8 + .../convex-db-collection/e2e/offline-child.ts | 178 +++++++++ packages/convex-db-collection/e2e/setup.ts | 87 +++++ packages/convex-db-collection/package.json | 73 ++++ packages/convex-db-collection/src/index.ts | 67 ++++ packages/convex-db-collection/src/offline.ts | 109 ++++++ packages/convex-db-collection/src/options.ts | 221 +++++++++++ .../src/paginated-options.ts | 66 ++++ .../convex-db-collection/src/pagination.ts | 178 +++++++++ .../convex-db-collection/src/subscription.ts | 49 +++ .../tests/collection.test.ts | 331 +++++++++++++++++ .../convex-db-collection/tests/helpers.ts | 71 ++++ .../tests/offline-oracle.property.test.ts | 100 +++++ .../tests/offline.test.ts | 56 +++ .../tests/pagination-oracle.property.test.ts | 180 +++++++++ .../convex-db-collection/tests/sdk.test.ts | 310 ++++++++++++++++ .../tests/snapshot-oracle.property.test.ts | 90 +++++ .../tests/types.test-d.ts | 154 ++++++++ .../tests/values-oracle.property.test.ts | 158 ++++++++ .../convex-db-collection/tsconfig.docs.json | 4 + packages/convex-db-collection/tsconfig.json | 27 ++ packages/convex-db-collection/vite.config.ts | 23 ++ .../convex-db-collection/vitest.e2e.config.ts | 12 + .../src/OfflineExecutor.ts | 6 +- packages/offline-transactions/src/index.ts | 1 + .../src/outbox/OutboxManager.ts | 20 +- .../src/outbox/TransactionSerializer.ts | 64 +++- .../src/react-native/index.ts | 1 + packages/offline-transactions/src/types.ts | 13 +- ...saction-serializer-oracle.property.test.ts | 161 +++++++- pnpm-lock.yaml | 344 +++++++++++++++++- scripts/ci-tests.mjs | 1 + scripts/generate-docs.ts | 27 ++ scripts/size-compare.mjs | 3 + 59 files changed, 4342 insertions(+), 22 deletions(-) create mode 100644 .changeset/convex-collection.md create mode 100644 docs/collections/convex-collection.md create mode 100644 docs/reference/convex-db-collection/functions/convexCollectionOptions.md create mode 100644 docs/reference/convex-db-collection/functions/convexPaginatedCollectionOptions.md create mode 100644 docs/reference/convex-db-collection/index.md create mode 100644 docs/reference/convex-db-collection/interfaces/ConvexCollectionUtils.md create mode 100644 docs/reference/convex-db-collection/offline/functions/convexOfflineMutation.md create mode 100644 docs/reference/convex-db-collection/offline/index.md create mode 100644 docs/reference/convex-db-collection/offline/variables/convexMutationCodec.md create mode 100644 docs/reference/convex-db-collection/type-aliases/ConvexCollectionClient.md create mode 100644 docs/reference/convex-db-collection/type-aliases/ConvexCollectionConfig.md create mode 100644 docs/reference/convex-db-collection/type-aliases/ConvexPaginatedCollectionConfig.md create mode 100644 packages/convex-db-collection/README.md create mode 100644 packages/convex-db-collection/e2e/backend-oracle.test.ts create mode 100644 packages/convex-db-collection/e2e/backend/.gitignore create mode 100644 packages/convex-db-collection/e2e/backend/convex.json create mode 100644 packages/convex-db-collection/e2e/backend/convex/schema.ts create mode 100644 packages/convex-db-collection/e2e/backend/convex/tasks.ts create mode 100644 packages/convex-db-collection/e2e/backend/convex/tsconfig.json create mode 100644 packages/convex-db-collection/e2e/backend/package.json create mode 100644 packages/convex-db-collection/e2e/offline-child.ts create mode 100644 packages/convex-db-collection/e2e/setup.ts create mode 100644 packages/convex-db-collection/package.json create mode 100644 packages/convex-db-collection/src/index.ts create mode 100644 packages/convex-db-collection/src/offline.ts create mode 100644 packages/convex-db-collection/src/options.ts create mode 100644 packages/convex-db-collection/src/paginated-options.ts create mode 100644 packages/convex-db-collection/src/pagination.ts create mode 100644 packages/convex-db-collection/src/subscription.ts create mode 100644 packages/convex-db-collection/tests/collection.test.ts create mode 100644 packages/convex-db-collection/tests/helpers.ts create mode 100644 packages/convex-db-collection/tests/offline-oracle.property.test.ts create mode 100644 packages/convex-db-collection/tests/offline.test.ts create mode 100644 packages/convex-db-collection/tests/pagination-oracle.property.test.ts create mode 100644 packages/convex-db-collection/tests/sdk.test.ts create mode 100644 packages/convex-db-collection/tests/snapshot-oracle.property.test.ts create mode 100644 packages/convex-db-collection/tests/types.test-d.ts create mode 100644 packages/convex-db-collection/tests/values-oracle.property.test.ts create mode 100644 packages/convex-db-collection/tsconfig.docs.json create mode 100644 packages/convex-db-collection/tsconfig.json create mode 100644 packages/convex-db-collection/vite.config.ts create mode 100644 packages/convex-db-collection/vitest.e2e.config.ts diff --git a/.changeset/convex-collection.md b/.changeset/convex-collection.md new file mode 100644 index 0000000000..b85a855f9a --- /dev/null +++ b/.changeset/convex-collection.md @@ -0,0 +1,8 @@ +--- +'@tanstack/convex-db-collection': minor +'@tanstack/offline-transactions': minor +--- + +Add a Convex collection adapter with typed query subscriptions, full-row sync, automatic reactive pagination, durable offline mutation replay, optimistic mutation handlers, and support for both Convex JavaScript and React clients. + +Add opt-in mutation codecs to the durable outbox while preserving the default wire format and retaining unreadable codec records for recovery. diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 6a3d2ec495..4d3a6e3b39 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -56,6 +56,7 @@ jobs: --filter '@tanstack/react-router-with-db...' --filter '@tanstack/electric-db-collection...' --filter '@tanstack/query-db-collection...' + --filter '@tanstack/convex-db-collection...' --filter '@tanstack/db...' build @@ -88,6 +89,10 @@ jobs: POSTGRES_PASSWORD: password POSTGRES_DB: e2e_test + - name: Run Convex backend E2E tests + if: matrix.group == 'browser' + run: pnpm --filter @tanstack/convex-db-collection test:e2e + - name: Run Query E2E tests if: matrix.group == 'browser' run: | diff --git a/.prettierignore b/.prettierignore index 9a9a6363b3..20eda14e54 100644 --- a/.prettierignore +++ b/.prettierignore @@ -7,3 +7,5 @@ **/snap pnpm-lock.yaml **/routeTree.gen.ts +packages/convex-db-collection/e2e/backend/.convex/ +packages/convex-db-collection/e2e/backend/convex/_generated/ diff --git a/docs/collections/convex-collection.md b/docs/collections/convex-collection.md new file mode 100644 index 0000000000..f3bda16678 --- /dev/null +++ b/docs/collections/convex-collection.md @@ -0,0 +1,274 @@ +--- +title: Convex Collection +--- + +`@tanstack/convex-db-collection` subscribes to a Convex query and keeps its rows in a TanStack DB collection. Use DB for local filters, joins, and optimistic writes. Convex still runs your server functions and handles authentication, reconnects, and access control. + +## Install + +```sh +npm install @tanstack/convex-db-collection @tanstack/db convex +``` + +## Subscribe to a query + +Use a public Convex query that returns an array of documents. The adapter infers document and argument types from your generated function reference. + +```ts +import { createCollection } from '@tanstack/db' +import { convexCollectionOptions } from '@tanstack/convex-db-collection' +import { ConvexClient } from 'convex/browser' +import { api } from './convex/_generated/api' + +const client = new ConvexClient(import.meta.env.VITE_CONVEX_URL) + +export const tasks = createCollection( + convexCollectionOptions({ + id: 'tasks', + client, + query: api.tasks.list, + queryArgs: { projectId }, + getKey: (task) => task._id, + }), +) + +await tasks.preload() +``` + +An existing `ConvexReactClient`, including the client from `useConvex()`, also works. The adapter imports neither React nor the Convex runtime. Keep the client and collection stable across renders. + +Each result replaces the subscribed query's rows. Missing documents are deleted, and missing fields are removed. An empty array is a ready, empty collection. Keys must be unique strings or finite numbers. Duplicate keys reject the complete result and preserve the last valid snapshot. + +The adapter uses eager sync. DB filters, ordering, and limits apply locally to the subscribed result. They do not change your Convex query or fetch documents outside its result. To change the query arguments, create a new collection with a distinct ID. + +## Write through Convex mutations + +Supply handlers for the operations your application supports. Await mutations on the same client used for the subscription. + +```ts +const tasks = createCollection( + convexCollectionOptions({ + id: 'tasks', + client, + query: api.tasks.list, + queryArgs: { projectId }, + getKey: (task) => task._id, + onUpdate: async ({ transaction }) => { + await Promise.all( + transaction.mutations.map(({ original, changes }) => + client.mutation(api.tasks.update, { + id: original._id, + ...changes, + }), + ), + ) + }, + onDelete: async ({ transaction }) => { + await Promise.all( + transaction.mutations.map(({ original }) => + client.mutation(api.tasks.remove, { id: original._id }), + ), + ) + }, + }), +) + +const transaction = tasks.update(taskId, (task) => { + task.completed = true +}) +await transaction.isPersisted.promise +``` + +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. A rejected handler rolls back its optimistic writes. + +Awaiting an HTTP mutation, an action, a different client's mutation, or a detached promise does not provide this query synchronization guarantee. Avoid applying Convex optimistic updates to the same query, because the adapter would receive those local results as source data. + +A DB transaction does not make several Convex calls atomic. For atomic batches, send the complete batch through one Convex mutation. That mutation defines argument validation, authorization, and server write behavior. + +### Inserts and generated IDs + +Convex assigns `_id` on the server. DB needs a stable key before an optimistic insert. For optimistic inserts, add an application key such as `clientId` to your schema and documents. Use that key in `getKey` and send it through your insert mutation. Use `select` to omit server-only fields from the local row type. + +```ts +select: (tasks) => tasks.map((task) => ({ + clientId: task.clientId, + projectId: task.projectId, + title: task.title, +})), +getKey: (task) => task.clientId, +onInsert: async ({ transaction }) => { + await Promise.all( + transaction.mutations.map(({ modified }) => + client.mutation(api.tasks.create, { + clientId: modified.clientId, + projectId: modified.projectId, + title: modified.title, + }), + ), + ) +}, +``` + +Insert a local row with `tasks.insert({ clientId: crypto.randomUUID(), projectId, title })`. The server query must return the same `clientId`. + +Alternatively, call `client.mutation(api.tasks.create, args)` directly. The subscription adds the resulting document with its server ID. The adapter does not invent Convex IDs or silently replace temporary keys. + +## Project query results + +For an envelope or a nullable document, supply `select`. Its output is the complete set of rows for that collection. + +```ts +convexCollectionOptions({ + client, + query: api.tasks.listWithMetadata, + queryArgs: { projectId }, + select: (result) => result.tasks, + getKey: (task) => task._id, +}) + +convexCollectionOptions({ + client, + query: api.tasks.get, + queryArgs: { id: taskId }, + select: (task) => (task === null ? [] : [task]), + getKey: (task) => task._id, +}) +``` + +Keep projections synchronous and treat query results as immutable. A projection can change the row shape. A standard schema can validate local writes through the collection's `schema` option. + +## Automatic pagination + +Use `convexPaginatedCollectionOptions` for a query that returns Convex's `PaginationResult`. The adapter supplies `paginationOpts` and loads every page automatically. + +```ts +import { convexPaginatedCollectionOptions } from '@tanstack/convex-db-collection' + +const tasks = createCollection( + convexPaginatedCollectionOptions({ + id: 'project-tasks', + client, + query: api.tasks.paginated, + queryArgs: { projectId }, + pageSize: 100, + getKey: (task) => task._id, + }), +) + +await tasks.preload() +``` + +Your server query must pass `paginationOpts` through to the same ordered Convex database query on every call. Use Convex's `paginationOptsValidator` for argument validation. + +The adapter keeps each loaded cursor range subscribed. Fixed `endCursor` boundaries prevent gaps when rows move or are deleted. Convex split hints replace a range with two subscriptions. The adapter publishes a replacement only after every range is available and the final page reports exhaustion. `preload()` waits for this complete initial result. During later page acquisition, the last complete snapshot stays visible. + +`pageSize` defaults to 100 and must be a positive safe integer. It sets the initial request size. Reactive ranges can contain more rows after inserts. Both supported clients use their public query subscription APIs, without experimental pagination methods. + +For paginated collections, `select` receives the complete row array. DB still applies local filters, joins, ordering, and limits. Pagination loads a finite query completely into memory, it does not push DB predicates to your server. + +## Durable offline writes + +Use the existing durable outbox with the Convex bridge: + +```sh +npm install @tanstack/offline-transactions +``` + +Start from a stable Collection that uses an application key such as `clientId`. Keep its ID, registry key, and named mutation function stable across reloads. + +```ts +import { IndexedDBAdapter, startOfflineExecutor } from '@tanstack/offline-transactions' +import { convexMutationCodec, convexOfflineMutation } from '@tanstack/convex-db-collection/offline' + +type Task = { clientId: string; title: string; completed: boolean } + +const executor = startOfflineExecutor({ + mutationCodec: convexMutationCodec, + collections: { tasks }, + storage: new IndexedDBAdapter('project-task-outbox'), + mutationFns: { + saveTask: convexOfflineMutation({ + client, + mutation: api.tasks.saveIdempotently, + collections: [tasks], + getArgs: ({ transaction }) => { + const task = transaction.mutations[0].modified as Task + return { clientId: task.clientId, title: task.title, completed: task.completed } + }, + }), + }, +}) + +await executor.waitForInit() + +const addTask = executor.createOfflineAction({ + mutationFnName: 'saveTask', + onMutate: (task) => { tasks.insert(task) }, +}) + +const transaction = addTask({ + clientId: crypto.randomUUID(), + title: 'Works after a reload', + completed: false, +}) + +void transaction.when('settled').catch(reportError) +``` + +Use the offline action to create durable writes. Calling `tasks.insert()` directly uses its normal handler and does not enter the durable outbox. The example's `tasks` Collection must project the same `Task` shape and use `clientId` as its key. + +The opt-in codec preserves Convex BigInt, binary data, special floats, and undefined fields in mutation changes. Keep the same codec configured across reloads. Missing or incompatible codecs stop replay and retain the stored work. Transaction metadata keeps the outbox’s existing JSON/Date/Temporal behavior. + +The outbox records the transaction before sending it. After a reload, the executor restores pending optimistic writes and replays their named mutation functions. The bridge supplies the original `idempotencyKey`, waits for the Convex mutation, then waits for each configured Collection to accept its source snapshot. It does not wait for visibility held by that same optimistic transaction. + +Convex handles WebSocket interruptions without rejecting the mutation promise. Argument-construction errors and rejected mutation calls are terminal failures. The bridge turns them into `NonRetriableError`, so the executor rolls back instead of retrying invalid writes indefinitely. A later source-sync failure can retry with the original key. + +### Deduplicate on the server + +Your Convex mutation must accept `idempotencyKey: v.string()`. In that same mutation transaction: + +1. Look up the key in an indexed receipts table. +2. If a receipt exists, return its saved result. +3. Apply the complete write or batch. +4. Store the key and result in the receipts table. + +Convex executes these writes atomically. A client can lose an acknowledgement after the server commits, so SDK request deduplication alone cannot replace this durable receipt. Keep receipts for at least the full replay lifetime. Scope receipt keys to the authenticated owner, and apply authorization before returning a saved result. + +The [real backend test fixture](https://github.com/TanStack/db/blob/main/packages/convex-db-collection/e2e/backend/convex/tasks.ts) shows an idempotent write mutation. It is an isolated test backend without application authorization, so add your own access checks. + +For atomic batches, `getArgs` must include every operation in the offline transaction and your server mutation must apply them together. The example handles one insert per action. Supply the platform's storage and coordination adapters when using the executor outside a browser. See the [offline transactions guide](../guides/offline-transactions.md) for leadership, storage failures, retry policy, and disposal. + +The outbox stores pending writes, not a full offline read cache. Pair the Collection with DB's persistence support when it must show previously synced rows after a reload without network access. + +## Errors and cleanup + +```ts +convexCollectionOptions({ + client, + query: api.tasks.list, + queryArgs: { projectId }, + getKey: (task) => task._id, + onError: (error) => reportError(error), +}) +``` + +An initial subscription error rejects `preload()` with the original error. Later errors preserve existing rows and call `onError`. `collection.utils.getSyncError()` returns the latest error. This getter is not reactive. A valid result clears the error and allows initial sync to recover. + +Collection cleanup unsubscribes and invalidates callbacks from that sync run. It does not close the shared Convex client. After a restart, the first result replaces retained data, including when the result is empty. Close the client separately when your application no longer needs it. + +## Scope + +This adapter uses the supported `onUpdate` and `watchQuery` APIs, tested with Convex 1.47.0 and a real isolated local Convex backend. It does not access private client fields or implement a second Convex protocol. + +Convex only exposes data through your server functions. The adapter cannot bypass authorization, subscribe directly to an arbitrary table, or translate arbitrary DB predicates into Convex functions. Queries remain subject to Convex read and result limits. A paginated query result describes one page, not a complete table. Automatic reactive pagination uses `convexPaginatedCollectionOptions`. Arbitrary server predicate pushdown is not implemented. + +Convex manages connection recovery. Use the offline bridge for durable writes. A full read cache and an SSR snapshot fetch remain separate concerns. Reuse your existing Convex authentication setup. For SSR, use request-scoped `DbClient` instances and the DB hydration APIs. A module-scoped descriptor can wrap `convexCollectionOptions` with `collectionOptions`; each client receives fresh adapter state. Supply an appropriate Convex client for each request and close it when finished. + +## Run the backend tests + +```sh +pnpm --filter @tanstack/convex-db-collection... build +pnpm --filter @tanstack/convex-db-collection test:e2e +``` + +The test command starts an anonymous local Convex backend, deploys the fixture, runs both client integrations and generated process-crash histories, then stops the backend. It needs network access on the first run to download Convex's backend binary. It does not use your cloud deployment or deploy key. diff --git a/docs/config.json b/docs/config.json index 14ce831d6a..214305222b 100644 --- a/docs/config.json +++ b/docs/config.json @@ -67,6 +67,10 @@ { "label": "Collections", "children": [ + { + "label": "Convex Collection", + "to": "collections/convex-collection" + }, { "label": "Query Collection", "to": "collections/query-collection" diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index 213d94e262..f4d6954920 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -11,6 +11,88 @@ local terms, but it must not silently rename a production concept. The [2026-10-10 owner-overlap review](oracle-reviews/2026-10-10-owner-overlap-audit.md) distinguishes canonical laws from their receiving witnesses across packages. +## Convex collection integration + +The Convex package has four independent owners: + +- `packages/convex-db-collection/tests/snapshot-oracle.property.test.ts` compares + public rows with the latest valid provider array. It covers missing-row + deletion, full-row replacement, reintroduction, empty results, and atomic + duplicate-key rejection. +- `packages/convex-db-collection/tests/pagination-oracle.property.test.ts` owns + complete finite snapshots across reactive cursor ranges. It varies page sizes + 1-4, forward/reverse callback delivery, row insertion/deletion/movement, and + required/recommended/no split hints. The reference is the latest input array; + the provider fixture independently answers numeric rank intervals. Comparison + occurs after the finite acquisition exhausts and sync acceptance settles. +- `packages/convex-db-collection/tests/offline-oracle.property.test.ts` owns the + bridge's ordered argument/mutation/sync stages, durable key propagation, and + terminal application-error classification. Argument and mutation failures + reject with NonRetriableError; post-confirmation sync failures can retry. + The original argument-error path failed this oracle at seed 20261022, path + 5:1:1. The repair includes argument construction in terminal classification. + +- `packages/convex-db-collection/tests/values-oracle.property.test.ts` owns the + Convex receiving check for native scalar types, bytes, nested containers, and + removed fields across durable storage and fresh readers. + +Each owner runs matching fixed and random campaigns and accepts explicit +seed/path replay through their CONVEX_*_ORACLE_SEED/PATH environment variables. +Snapshot replay uses CONVEX_SNAPSHOT_ORACLE; pagination uses CONVEX_ORACLE; +offline bridge replay uses CONVEX_OFFLINE_ORACLE; native value replay uses +CONVEX_VALUES_ORACLE. + +`packages/convex-db-collection/e2e/backend-oracle.test.ts` receives these contracts +at an actual isolated local Convex backend using both Convex 1.47.0 SDK clients. +It compares pagination with a separately evaluated unpaginated server query, +including real server-generated split responses. Its durable driver runs fixed +and random insert histories through SIGKILL after disk admission and, optionally, +a second SIGKILL after server acceptance but before recording outbox completion. +It requires no offline provider call, restored Collection rows, and one server +row/receipt after replay with the original key. Replay uses +CONVEX_BACKEND_ORACLE_SEED/PATH. The command owns backend startup and shutdown. +The offline engine's interruption, settlement, storage, and leadership owners +remain primary for those laws; this is their Convex receiving witness. + +`packages/offline-transactions/tests/transaction-serializer-oracle.property.test.ts` +remains the primary wire-format owner. Its opt-in codec grammar crosses +insert/update/delete and arrays of signed 64-bit integers, special floats and +binary buffers in modified/original/changes. It uses an independently supplied +V8 codec, recreates the reader, and checks missing/mismatched/throwing codecs +through both get and getAll while preserving the exact stored bytes. Default +unversioned/v2/v3 histories retain their prior expectations. A version-4 record +requires its codec, and configured readers continue to accept old formats. +The original serializer failed at durable admission for seed 20261022/path +0:0:0:0, a single BigInt insert. The repaired codec path preserves the values. +The Convex values receiving owner, +`packages/convex-db-collection/tests/values-oracle.property.test.ts`, checks +bounded nested native trees, marker-like objects and undefined fields against +an explicit type/byte observation. The backend process witness also varies +native payloads. Transaction metadata is intentionally outside codec dispatch. + +The source-snapshot mutant that omits deletion and the partial-row mutant both +fail the snapshot owner at public row comparison. Disabling automatic continuation +or publishing an incomplete required split fails the pagination owner at public +row comparison; replay seeds and shrink paths are recorded beside the property. These bounded tests do not prove +continuous unbounded growth, cross-Collection atomic publication, cloud service +availability, or power-loss atomicity during a filesystem write. The test backend +has no application authorization; production authorization and atomic receipt +storage are requirements of each application's server mutation. + +Validation for this integration: 40 adapter checks, 302 offline-engine tests, +and five real-backend tests pass. The native-value receiving oracle rejects a +scalar-as-JSON decoder at the typed-value comparison (seed 20261022/path 0:0); +its repaired replay passes. `pnpm size:compare --packages +convex-db-collection,offline-transactions --files` measures 2,559 gzip / 2,312 +brotli bytes for the new adapter's combined exports, with dependencies external. +The shared outbox grows by 192 gzip / 174 brotli bytes. A new adapter has no +baseline to subtract; normal and paginated sources share one snapshot writer, +and the existing outbox supplies durability instead of a second write queue. +The codec hook is needed to preserve native values without changing default +JSON semantics. Adapter tests/fixtures are 68,570 source bytes and its +usage/generated API docs are 20,749 source bytes, separate from shipped +production measurements. + ## Scope of the oracle repair project [Issue #1808](https://github.com/TanStack/db/issues/1808) commissioned a bounded diff --git a/docs/guides/offline-transactions.md b/docs/guides/offline-transactions.md index 5ea40eae44..945d8c56f4 100644 --- a/docs/guides/offline-transactions.md +++ b/docs/guides/offline-transactions.md @@ -353,3 +353,11 @@ The final `refetch()` asks the server for current rows after a successful write. The [React Native offline transactions example](https://github.com/TanStack/db/tree/main/examples/react-native/offline-transactions) shows the full app structure. Its API client does not yet send an idempotency key, so use the request pattern above when you adapt it. The [shopping list example](https://github.com/TanStack/db/tree/main/examples/react-native/shopping-list) also shows Electric sync. The [Electron example](https://github.com/TanStack/db/tree/main/examples/electron/offline-first) uses a custom SQLite-backed `StorageAdapter` for the outbox. That adapter is separate from the persisted Collection tables. + +## Mutation value codecs + +The default outbox format preserves its existing JSON, Date, and Temporal behavior. For other mutation value domains, set `mutationCodec` to a codec with a stable `id`, synchronous `encode(value): string`, and synchronous `decode(encoded): unknown`. + +Encoded transactions use wire version 4 and retain the codec ID. Configure the same codec on restart. Missing or mismatched codecs, and decoder failures, reject the outbox read with the transaction ID while keeping its storage entry. Readers without version 4 support cannot replay these records. Default writers still emit version 3. A configured codec can read older records through their original decoder. + +The codec applies to `modified`, `original`, and `changes`. Transaction metadata keeps the default encoding. Codec functions must not perform network calls. TanStack's Convex adapter supplies `convexMutationCodec` for Convex values and removed fields. diff --git a/docs/reference/convex-db-collection/functions/convexCollectionOptions.md b/docs/reference/convex-db-collection/functions/convexCollectionOptions.md new file mode 100644 index 0000000000..b6b054645b --- /dev/null +++ b/docs/reference/convex-db-collection/functions/convexCollectionOptions.md @@ -0,0 +1,40 @@ +--- +id: convexCollectionOptions +title: convexCollectionOptions +--- + +```ts +function convexCollectionOptions(config): CollectionConfig & object & [TSchema] extends [never] ? unknown : object; +``` + +Defined in: index.ts:48 + +Mirror one authoritative Convex query result. Filters and joins run in DB. + +## Type Parameters + +### TQuery + +`TQuery` *extends* `FunctionReference`\<`"query"`\> + +### TItem + +`TItem` *extends* `object` = `QueryRow`\<`TQuery`\> + +### TKey + +`TKey` *extends* `string` \| `number` = `string` \| `number` + +### TSchema + +`TSchema` *extends* `StandardSchemaV1`\<`TItem`, `TItem`\> = `never` + +## Parameters + +### config + +[`ConvexCollectionConfig`](../type-aliases/ConvexCollectionConfig.md)\<`TQuery`, `TItem`, `TKey`, `TSchema`\> + +## Returns + +`CollectionConfig`\<`TItem`, `TKey`, `TSchema`, [`ConvexCollectionUtils`](../interfaces/ConvexCollectionUtils.md)\> & `object` & \[`TSchema`\] *extends* \[`never`\] ? `unknown` : `object` diff --git a/docs/reference/convex-db-collection/functions/convexPaginatedCollectionOptions.md b/docs/reference/convex-db-collection/functions/convexPaginatedCollectionOptions.md new file mode 100644 index 0000000000..0cf440623e --- /dev/null +++ b/docs/reference/convex-db-collection/functions/convexPaginatedCollectionOptions.md @@ -0,0 +1,40 @@ +--- +id: convexPaginatedCollectionOptions +title: convexPaginatedCollectionOptions +--- + +```ts +function convexPaginatedCollectionOptions(config): CollectionConfig & object & [TSchema] extends [never] ? unknown : object; +``` + +Defined in: paginated-options.ts:51 + +Automatically subscribe to every page, retaining fixed reactive cursor ranges. + +## Type Parameters + +### TQuery + +`TQuery` *extends* `FunctionReference`\<`"query"`\> + +### TItem + +`TItem` *extends* `object` = `PaginatedRow`\<`TQuery`\> + +### TKey + +`TKey` *extends* `string` \| `number` = `string` \| `number` + +### TSchema + +`TSchema` *extends* `StandardSchemaV1`\<`TItem`, `TItem`\> = `never` + +## Parameters + +### config + +[`ConvexPaginatedCollectionConfig`](../type-aliases/ConvexPaginatedCollectionConfig.md)\<`TQuery`, `TItem`, `TKey`, `TSchema`\> + +## Returns + +`CollectionConfig`\<`TItem`, `TKey`, `TSchema`, [`ConvexCollectionUtils`](../interfaces/ConvexCollectionUtils.md)\> & `object` & \[`TSchema`\] *extends* \[`never`\] ? `unknown` : `object` diff --git a/docs/reference/convex-db-collection/index.md b/docs/reference/convex-db-collection/index.md new file mode 100644 index 0000000000..8650166963 --- /dev/null +++ b/docs/reference/convex-db-collection/index.md @@ -0,0 +1,19 @@ +--- +id: "@tanstack/convex-db-collection" +title: "@tanstack/convex-db-collection" +--- + +## Interfaces + +- [ConvexCollectionUtils](interfaces/ConvexCollectionUtils.md) + +## Type Aliases + +- [ConvexCollectionClient](type-aliases/ConvexCollectionClient.md) +- [ConvexCollectionConfig](type-aliases/ConvexCollectionConfig.md) +- [ConvexPaginatedCollectionConfig](type-aliases/ConvexPaginatedCollectionConfig.md) + +## Functions + +- [convexCollectionOptions](functions/convexCollectionOptions.md) +- [convexPaginatedCollectionOptions](functions/convexPaginatedCollectionOptions.md) diff --git a/docs/reference/convex-db-collection/interfaces/ConvexCollectionUtils.md b/docs/reference/convex-db-collection/interfaces/ConvexCollectionUtils.md new file mode 100644 index 0000000000..ef71817b50 --- /dev/null +++ b/docs/reference/convex-db-collection/interfaces/ConvexCollectionUtils.md @@ -0,0 +1,48 @@ +--- +id: ConvexCollectionUtils +title: ConvexCollectionUtils +--- + +Defined in: options.ts:11 + +## Extends + +- `UtilsRecord` + +## Indexable + +```ts +[key: string]: any +``` + +## Properties + +### awaitSync() + +```ts +awaitSync: () => Promise; +``` + +Defined in: options.ts:15 + +Refresh the source cache and wait for sync acceptance, safe inside mutation handlers. + +#### Returns + +`Promise`\<`void`\> + +*** + +### getSyncError() + +```ts +getSyncError: () => unknown; +``` + +Defined in: options.ts:13 + +Last subscription or conversion error, cleared by the next valid result. + +#### Returns + +`unknown` diff --git a/docs/reference/convex-db-collection/offline/functions/convexOfflineMutation.md b/docs/reference/convex-db-collection/offline/functions/convexOfflineMutation.md new file mode 100644 index 0000000000..267b423673 --- /dev/null +++ b/docs/reference/convex-db-collection/offline/functions/convexOfflineMutation.md @@ -0,0 +1,30 @@ +--- +id: convexOfflineMutation +title: convexOfflineMutation +--- + +```ts +function convexOfflineMutation(config): OfflineMutationFn; +``` + +Defined in: offline.ts:21 + +Use as a named mutationFn in startOfflineExecutor. The existing outbox owns +durability, restoration, leadership and retries. The server mutation must +atomically deduplicate idempotencyKey with its writes. + +## Type Parameters + +### TMutation + +`TMutation` *extends* `FunctionReference`\<`"mutation"`\> + +## Parameters + +### config + +`object` & `FunctionArgs`\<`TMutation`\> *extends* `object` ? `unknown` : `never` + +## Returns + +`OfflineMutationFn` diff --git a/docs/reference/convex-db-collection/offline/index.md b/docs/reference/convex-db-collection/offline/index.md new file mode 100644 index 0000000000..645dbb7fea --- /dev/null +++ b/docs/reference/convex-db-collection/offline/index.md @@ -0,0 +1,12 @@ +--- +id: "@tanstack/convex-db-collection" +title: "@tanstack/convex-db-collection" +--- + +## Variables + +- [convexMutationCodec](variables/convexMutationCodec.md) + +## Functions + +- [convexOfflineMutation](functions/convexOfflineMutation.md) diff --git a/docs/reference/convex-db-collection/offline/variables/convexMutationCodec.md b/docs/reference/convex-db-collection/offline/variables/convexMutationCodec.md new file mode 100644 index 0000000000..8a4f3c597f --- /dev/null +++ b/docs/reference/convex-db-collection/offline/variables/convexMutationCodec.md @@ -0,0 +1,12 @@ +--- +id: convexMutationCodec +title: convexMutationCodec +--- + +```ts +const convexMutationCodec: MutationValueCodec; +``` + +Defined in: offline.ts:59 + +Lossless, opt-in outbox encoding for Convex values and removed fields. diff --git a/docs/reference/convex-db-collection/type-aliases/ConvexCollectionClient.md b/docs/reference/convex-db-collection/type-aliases/ConvexCollectionClient.md new file mode 100644 index 0000000000..086dc66b79 --- /dev/null +++ b/docs/reference/convex-db-collection/type-aliases/ConvexCollectionClient.md @@ -0,0 +1,12 @@ +--- +id: ConvexCollectionClient +title: ConvexCollectionClient +--- + +```ts +type ConvexCollectionClient = + | Pick +| Pick; +``` + +Defined in: subscription.ts:9 diff --git a/docs/reference/convex-db-collection/type-aliases/ConvexCollectionConfig.md b/docs/reference/convex-db-collection/type-aliases/ConvexCollectionConfig.md new file mode 100644 index 0000000000..9ae6cb7d4b --- /dev/null +++ b/docs/reference/convex-db-collection/type-aliases/ConvexCollectionConfig.md @@ -0,0 +1,72 @@ +--- +id: ConvexCollectionConfig +title: ConvexCollectionConfig +--- + +```ts +type ConvexCollectionConfig = Omit, "syncMode" | "utils" | "schema"> & object & [TSchema] extends [never] ? object : object & + | { + select: (result) => ReadonlyArray; +} + | FunctionReturnType extends ReadonlyArray ? object : never; +``` + +Defined in: index.ts:21 + +## Type Declaration + +### client + +```ts +client: ConvexCollectionClient; +``` + +Share the client used for mutations, authentication, and reconnects. + +### onError()? + +```ts +optional onError: (error) => void; +``` + +Reports subscription and result-conversion failures, including after readiness. + +#### Parameters + +##### error + +`unknown` + +#### Returns + +`void` + +### query + +```ts +query: TQuery; +``` + +### queryArgs + +```ts +queryArgs: FunctionArgs; +``` + +## Type Parameters + +### TQuery + +`TQuery` *extends* `FunctionReference`\<`"query"`\> + +### TItem + +`TItem` *extends* `object` = `QueryRow`\<`TQuery`\> + +### TKey + +`TKey` *extends* `string` \| `number` = `string` \| `number` + +### TSchema + +`TSchema` *extends* `StandardSchemaV1`\<`TItem`, `TItem`\> = `never` diff --git a/docs/reference/convex-db-collection/type-aliases/ConvexPaginatedCollectionConfig.md b/docs/reference/convex-db-collection/type-aliases/ConvexPaginatedCollectionConfig.md new file mode 100644 index 0000000000..d7b068f77d --- /dev/null +++ b/docs/reference/convex-db-collection/type-aliases/ConvexPaginatedCollectionConfig.md @@ -0,0 +1,74 @@ +--- +id: ConvexPaginatedCollectionConfig +title: ConvexPaginatedCollectionConfig +--- + +```ts +type ConvexPaginatedCollectionConfig = Omit, "syncMode" | "utils" | "schema"> & object & [TSchema] extends [never] ? object : object & FunctionReturnType extends PaginationResult ? unknown : never & FunctionArgs extends object ? unknown : never & + | { + select: (rows) => ReadonlyArray; +} + | PaginatedRow extends TItem ? object : never; +``` + +Defined in: paginated-options.ts:20 + +## Type Declaration + +### client + +```ts +client: ConvexCollectionClient; +``` + +### onError()? + +```ts +optional onError: (error) => void; +``` + +#### Parameters + +##### error + +`unknown` + +#### Returns + +`void` + +### pageSize? + +```ts +optional pageSize: number; +``` + +### query + +```ts +query: TQuery; +``` + +### queryArgs + +```ts +queryArgs: Omit, "paginationOpts">; +``` + +## Type Parameters + +### TQuery + +`TQuery` *extends* `FunctionReference`\<`"query"`\> + +### TItem + +`TItem` *extends* `object` = `PaginatedRow`\<`TQuery`\> + +### TKey + +`TKey` *extends* `string` \| `number` = `string` \| `number` + +### TSchema + +`TSchema` *extends* `StandardSchemaV1`\<`TItem`, `TItem`\> = `never` diff --git a/eslint.config.js b/eslint.config.js index 34cfc4c5ef..8006c5f425 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -5,6 +5,7 @@ export default [ { ignores: [ `**/dist/**`, + `packages/convex-db-collection/e2e/backend/convex/_generated/**`, `**/.output/**`, `**/.nitro/**`, `**/traildepot/**`, diff --git a/packages/convex-db-collection/README.md b/packages/convex-db-collection/README.md new file mode 100644 index 0000000000..40c34808ed --- /dev/null +++ b/packages/convex-db-collection/README.md @@ -0,0 +1,7 @@ +# @tanstack/convex-db-collection + +Subscribe to Convex query results with TanStack DB. Supports `ConvexClient`, `ConvexReactClient`, generated query types, automatic reactive pagination, local queries, optimistic mutation handlers, and durable offline writes through TanStack’s outbox. + +See the [Convex collection guide](https://tanstack.com/db/latest/docs/collections/convex-collection) for setup, mutation handling, generated IDs, and supported behavior. + +Run `pnpm --filter @tanstack/convex-db-collection test:e2e` to start an isolated real Convex backend and test both clients, server page splits, and durable replay after process death. diff --git a/packages/convex-db-collection/e2e/backend-oracle.test.ts b/packages/convex-db-collection/e2e/backend-oracle.test.ts new file mode 100644 index 0000000000..e638480c00 --- /dev/null +++ b/packages/convex-db-collection/e2e/backend-oracle.test.ts @@ -0,0 +1,300 @@ +/** + * Receiving oracle for the Convex provider boundary. Automatic pagination must + * match an independent unpaginated server query after each committed mutation. + * The first driver uses real server-assigned cursors, both SDK clients, and + * inserts, deletion at a boundary, and movement between ranges. Expected rows + * come from tasks:all, which does not paginate or use the adapter. + * + * The durable driver checks the outbox's documented process-restart law with + * actual disk storage and SIGKILL. Its independent model is one logical insert, + * hence one final server row and one idempotency receipt. Histories vary the + * draft's text/rank and whether a second crash loses the server acknowledgement. + * A server-accepted checkpoint occurs before the outbox phase can be recorded, + * so replay must reuse its key rather than rely on a completed local marker. + * Checkpoints are observed in child stdout before killing the process. Server + * rows/receipts and replayed Collection rows are checked only after replay. + * + * This is a receiving witness for the offline engine's interruption/settlement + * owners, not another model of leadership. It covers one pending insert in one + * Collection against a real local backend. Shared-key/multi-tab histories stay + * with those primary owners. Cloud infrastructure and an OS power failure + * during a filesystem write are outside this process-crash test. + */ +import fc from 'fast-check' +import { convexToJson } from 'convex/values' +import { createCollection } from '@tanstack/db' +import { ConvexClient } from 'convex/browser' +import { ConvexReactClient } from 'convex/react' +import { makeFunctionReference } from 'convex/server' +import { describe, expect, inject, it } from 'vitest' +import { convexPaginatedCollectionOptions } from '../src/index' +import type { Value } from 'convex/values' +import type { ConvexCollectionClient } from '../src/index' +import type { PaginationOptions, PaginationResult } from 'convex/server' + +type Task = { + _id: string + _creationTime: number + clientId: string + text: string + rank: number + payload?: Value +} +const seed = makeFunctionReference<`mutation`, { count: number }, null>( + `tasks:seed`, +) +const write = makeFunctionReference< + `mutation`, + { + idempotencyKey: string + clientId: string + text: string + rank: number + remove?: boolean + }, + null +>(`tasks:write`) +const all = makeFunctionReference<`query`, {}, Array>(`tasks:all`) +const url = inject(`convexUrl`) + +describe.each([`javascript`, `react`] as const)( + `real backend with %s client`, + (kind) => { + it.each([`list`, `split`] as const)( + `loads %s pages through inserts, moves, deletions, and server splits`, + async (functionName) => { + const client = + kind === `javascript` + ? new ConvexClient(url, { unsavedChangesWarning: false }) + : new ConvexReactClient(url, { unsavedChangesWarning: false }) + await client.mutation(seed, { count: 23 }) + let splitResponses = 0 + const inspect = (value: unknown): void => { + if ( + value && + typeof value === `object` && + `pageStatus` in value && + (value.pageStatus === `SplitRequired` || + value.pageStatus === `SplitRecommended`) + ) + splitResponses++ + } + const source: ConvexCollectionClient = + `onUpdate` in client + ? { + onUpdate: (query, args, callback, onError) => + client.onUpdate( + query, + args, + (value) => { + inspect(value) + return callback(value) + }, + onError, + ), + } + : { + watchQuery: (query, args, options) => { + const watch = client.watchQuery(query, args, options) + return { + ...watch, + localQueryResult: () => { + const value = watch.localQueryResult() + inspect(value) + return value + }, + } + }, + } + + const collection = createCollection( + convexPaginatedCollectionOptions({ + id: `tasks`, + client: source, + query: makeFunctionReference< + `query`, + { paginationOpts: PaginationOptions }, + PaginationResult + >(`tasks:${functionName}`), + queryArgs: {}, + pageSize: 4, + getKey: (task) => task.clientId, + gcTime: 0, + }), + ) + const check = async () => { + const expected = await client.query(all, {}) + await collection.utils.awaitSync() + const actual = [...collection.values()] + .map(({ clientId, text, rank }) => ({ clientId, text, rank })) + .sort((a, b) => a.rank - b.rank) + expect(actual).toEqual( + expected.map(({ clientId, text, rank }) => ({ + clientId, + text, + rank, + })), + ) + } + try { + await collection.preload() + expect(collection.size).toBe(23) + await check() + for (const operation of [ + { + clientId: `new-first`, + rank: -10, + text: `insert before all pages`, + }, + { + clientId: `new-last`, + rank: 100, + text: `append after exhaustion`, + }, + { clientId: `3`, rank: 3, text: `delete a boundary`, remove: true }, + { clientId: `12`, rank: -5, text: `move across cursor ranges` }, + { clientId: `8`, rank: 8, text: `update a middle page` }, + ]) { + await client.mutation(write, { + ...operation, + idempotencyKey: crypto.randomUUID(), + }) + await check() + } + if (functionName === `split`) + expect(splitResponses).toBeGreaterThan(0) + } finally { + await collection.cleanup() + await client.close() + } + }, + ) + }, +) + +async function durableHistory( + draft: { clientId: string; text: string; rank: number; payload: Value }, + uncertain: boolean, +) { + const { spawn } = await import('node:child_process') + const { mkdtemp, rm } = await import('node:fs/promises') + const { tmpdir } = await import('node:os') + const { join } = await import('node:path') + const { fileURLToPath } = await import('node:url') + const directory = await mkdtemp(join(tmpdir(), `convex-outbox-`)) + const client = new ConvexClient(url, { unsavedChangesWarning: false }) + await client.mutation(seed, { count: 0 }) + async function run(mode: string, target: string): Promise> { + const child = spawn( + process.execPath, + [ + `--import`, + `tsx`, + fileURLToPath(new URL(`./offline-child.ts`, import.meta.url)), + mode, + directory, + url, + JSON.stringify(convexToJson(draft)), + ], + { stdio: [`ignore`, `pipe`, `pipe`] }, + ) + let output = `` + let errors = `` + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + child.kill(`SIGKILL`) + reject(new Error(`Child ${mode} timed out: ${output}\n${errors}`)) + }, 15000) + child.stderr.on(`data`, (chunk) => { + errors += String(chunk) + }) + child.stdout.on(`data`, (chunk) => { + output += String(chunk) + if (output.includes(`CHECKPOINT:${target}\n`)) { + clearTimeout(timeout) + child.kill(`SIGKILL`) + resolve() + } + }) + child.on(`error`, reject) + child.on(`exit`, (code, signal) => { + if (code !== 0 && signal !== `SIGKILL`) { + clearTimeout(timeout) + reject(new Error(`Child ${mode} failed: ${errors}`)) + } + }) + }) + await new Promise((resolve) => { + if (child.exitCode !== null || child.signalCode !== null) resolve() + else child.once(`exit`, () => resolve()) + }) + return output + .split(`\n`) + .filter((line) => line.startsWith(`CHECKPOINT:`)) + .map((line) => line.slice(11)) + } + try { + const admitted = await run(`admit`, `durable`) + expect(admitted).not.toContain(`provider-call`) + expect(await client.query(all, {})).toEqual([]) + if (uncertain) { + expect(await run(`uncertain`, `server-accepted`)).toContain( + `provider-call`, + ) + expect((await client.query(all, {})).map((row) => row.clientId)).toEqual([ + draft.clientId, + ]) + } + expect(await run(`replay`, `replayed`)).toContain(`provider-call`) + const receipts = makeFunctionReference<`query`, {}, Array<{ key: string }>>( + `tasks:receipts`, + ) + expect(await client.query(receipts, {})).toHaveLength(1) + expect( + (await client.query(all, {})).map( + ({ clientId, text, rank, payload }) => ({ + clientId, + text, + rank, + payload, + }), + ), + ).toEqual([draft]) + } finally { + await client.close() + await rm(directory, { recursive: true, force: true }) + } +} + +it(`restores durable writes and deduplicates uncertain acknowledgements across generated process histories`, async () => { + const property = fc.asyncProperty( + fc.record({ + text: fc.string({ maxLength: 12 }), + rank: fc.integer({ min: -50, max: 50 }), + uncertain: fc.boolean(), + payload: fc.oneof( + fc.bigInt({ min: -(2n ** 63n), max: 2n ** 63n - 1n }), + fc.constantFrom(NaN, Infinity, -Infinity, -0), + fc + .uint8Array({ maxLength: 12 }) + .map((bytes) => bytes.buffer as ArrayBuffer), + ), + }), + async ({ text, rank, uncertain, payload }) => + durableHistory( + { clientId: `offline-created`, text, rank, payload }, + uncertain, + ), + ) + const replaySeed = process.env.CONVEX_BACKEND_ORACLE_SEED + if (replaySeed) + await fc.assert(property, { + numRuns: 6, + seed: Number(replaySeed), + path: process.env.CONVEX_BACKEND_ORACLE_PATH, + }) + else { + await fc.assert(property, { numRuns: 6, seed: 20261022 }) + await fc.assert(property, { numRuns: 6 }) + } +}, 120000) diff --git a/packages/convex-db-collection/e2e/backend/.gitignore b/packages/convex-db-collection/e2e/backend/.gitignore new file mode 100644 index 0000000000..4db599fa11 --- /dev/null +++ b/packages/convex-db-collection/e2e/backend/.gitignore @@ -0,0 +1,3 @@ +.env.local +.convex/ +convex/_generated/ diff --git a/packages/convex-db-collection/e2e/backend/convex.json b/packages/convex-db-collection/e2e/backend/convex.json new file mode 100644 index 0000000000..a74ec106c4 --- /dev/null +++ b/packages/convex-db-collection/e2e/backend/convex.json @@ -0,0 +1 @@ +{ "functions": "convex/" } diff --git a/packages/convex-db-collection/e2e/backend/convex/schema.ts b/packages/convex-db-collection/e2e/backend/convex/schema.ts new file mode 100644 index 0000000000..f6c27226bd --- /dev/null +++ b/packages/convex-db-collection/e2e/backend/convex/schema.ts @@ -0,0 +1,14 @@ +import { defineSchema, defineTable } from 'convex/server' +import { v } from 'convex/values' + +export default defineSchema({ + tasks: defineTable({ + clientId: v.string(), + text: v.string(), + rank: v.number(), + payload: v.optional(v.any()), + }) + .index('by_rank', ['rank']) + .index('by_clientId', ['clientId']), + receipts: defineTable({ key: v.string() }).index('by_key', ['key']), +}) diff --git a/packages/convex-db-collection/e2e/backend/convex/tasks.ts b/packages/convex-db-collection/e2e/backend/convex/tasks.ts new file mode 100644 index 0000000000..77c165337b --- /dev/null +++ b/packages/convex-db-collection/e2e/backend/convex/tasks.ts @@ -0,0 +1,78 @@ +import { + mutationGeneric, + paginationOptsValidator, + queryGeneric, +} from 'convex/server' +import { v } from 'convex/values' + +export const list = queryGeneric({ + args: { paginationOpts: paginationOptsValidator }, + handler: (ctx, args) => + ctx.db.query('tasks').withIndex('by_rank').paginate(args.paginationOpts), +}) +export const all = queryGeneric({ + args: {}, + handler: (ctx) => ctx.db.query('tasks').withIndex('by_rank').collect(), +}) +export const seed = mutationGeneric({ + args: { count: v.number() }, + handler: async (ctx, { count }) => { + for (const row of await ctx.db.query('tasks').collect()) + await ctx.db.delete(row._id) + for (const row of await ctx.db.query('receipts').collect()) + await ctx.db.delete(row._id) + for (let i = 0; i < count; i++) + await ctx.db.insert('tasks', { + clientId: String(i), + text: `task ${i}`, + rank: i, + }) + }, +}) +export const write = mutationGeneric({ + args: { + idempotencyKey: v.string(), + clientId: v.string(), + text: v.string(), + rank: v.number(), + payload: v.optional(v.any()), + remove: v.optional(v.boolean()), + }, + handler: async (ctx, args) => { + const receipt = await ctx.db + .query('receipts') + .withIndex('by_key', (q) => q.eq('key', args.idempotencyKey)) + .unique() + if (receipt) return + const previous = await ctx.db + .query('tasks') + .withIndex('by_clientId', (q) => q.eq('clientId', args.clientId)) + .unique() + if (args.remove) { + if (previous) await ctx.db.delete(previous._id) + } else if (previous) + await ctx.db.patch(previous._id, { text: args.text, rank: args.rank }) + else + await ctx.db.insert('tasks', { + clientId: args.clientId, + text: args.text, + rank: args.rank, + ...(args.payload === undefined ? {} : { payload: args.payload }), + }) + await ctx.db.insert('receipts', { key: args.idempotencyKey }) + }, +}) +export const receipts = queryGeneric({ + args: {}, + handler: (ctx) => ctx.db.query('receipts').collect(), +}) + +// A low read budget forces genuine server page splits during the range tests. +export const split = queryGeneric({ + args: { paginationOpts: paginationOptsValidator }, + handler: (ctx, args) => + ctx.db + .query('tasks') + .withIndex('by_rank') + .paginate({ ...args.paginationOpts, maximumRowsRead: 3 }), +}) diff --git a/packages/convex-db-collection/e2e/backend/convex/tsconfig.json b/packages/convex-db-collection/e2e/backend/convex/tsconfig.json new file mode 100644 index 0000000000..41bfbb9b9a --- /dev/null +++ b/packages/convex-db-collection/e2e/backend/convex/tsconfig.json @@ -0,0 +1,25 @@ +{ + /* This TypeScript project config describes the environment that + * Convex functions run in and is used to typecheck them. + * You can modify it, but some settings are required to use Convex. + */ + "compilerOptions": { + /* These settings are not required by Convex and can be modified. */ + "allowJs": true, + "strict": true, + "moduleResolution": "Bundler", + "jsx": "react-jsx", + "skipLibCheck": true, + "allowSyntheticDefaultImports": true, + + /* These compiler options are required by Convex */ + "target": "ESNext", + "lib": ["ES2023", "dom"], + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "isolatedModules": true, + "noEmit": true + }, + "include": ["./**/*"], + "exclude": ["./_generated"] +} diff --git a/packages/convex-db-collection/e2e/backend/package.json b/packages/convex-db-collection/e2e/backend/package.json new file mode 100644 index 0000000000..6d70a389ac --- /dev/null +++ b/packages/convex-db-collection/e2e/backend/package.json @@ -0,0 +1,8 @@ +{ + "name": "convex-adapter-backend", + "private": true, + "type": "module", + "dependencies": { + "convex": "^1.47.0" + } +} diff --git a/packages/convex-db-collection/e2e/offline-child.ts b/packages/convex-db-collection/e2e/offline-child.ts new file mode 100644 index 0000000000..29ae5d187b --- /dev/null +++ b/packages/convex-db-collection/e2e/offline-child.ts @@ -0,0 +1,178 @@ +import { + mkdir, + readFile, + readdir, + rename, + unlink, + writeFile, +} from 'node:fs/promises' +import { join } from 'node:path' +import { createCollection } from '@tanstack/db' +import { startOfflineExecutor } from '@tanstack/offline-transactions' +import { ConvexClient } from 'convex/browser' +import { makeFunctionReference } from 'convex/server' +import { jsonToConvex } from 'convex/values' +import { convexPaginatedCollectionOptions } from '../src/index' +import { convexMutationCodec, convexOfflineMutation } from '../src/offline' +import type { Value } from 'convex/values' +import type { StorageAdapter } from '@tanstack/offline-transactions' +import type { PaginationOptions, PaginationResult } from 'convex/server' + +type Task = { clientId: string; text: string; rank: number; payload?: Value } +const [mode, directory, url, draftJSON] = process.argv.slice(2) as [ + string, + string, + string, + string, +] +const draft = jsonToConvex(JSON.parse(draftJSON)) as Task +const checkpoint = (value: string) => { + process.stdout.write(`CHECKPOINT:${value}\n`) +} +await mkdir(directory, { recursive: true }) +const storage: StorageAdapter = { + get: async (key) => { + try { + return await readFile(join(directory, encodeURIComponent(key)), `utf8`) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === `ENOENT`) return null + throw error + } + }, + set: async (key, value) => { + if ( + mode === `uncertain` && + JSON.parse(value).outboxPhase === `deletion-pending` + ) { + checkpoint(`server-accepted`) + await new Promise(() => {}) + } + const path = join(directory, encodeURIComponent(key)) + await writeFile(`${path}.tmp`, value) + await rename(`${path}.tmp`, path) + }, + delete: async (key) => { + await unlink(join(directory, encodeURIComponent(key))) + }, + keys: async () => + (await readdir(directory)) + .filter((name) => !name.endsWith(`.tmp`)) + .map(decodeURIComponent), + clear: async () => { + for (const name of await readdir(directory)) + await unlink(join(directory, name)) + }, +} +const client = new ConvexClient(url, { + unsavedChangesWarning: false, + logger: false, +}) +const query = makeFunctionReference< + `query`, + { paginationOpts: PaginationOptions }, + PaginationResult +>(`tasks:list`) +const mutation = makeFunctionReference< + `mutation`, + { + idempotencyKey: string + clientId: string + text: string + rank: number + payload?: Value + }, + null +>(`tasks:write`) +const collection = createCollection( + convexPaginatedCollectionOptions({ + id: `durable-tasks`, + client, + query, + queryArgs: {}, + pageSize: 3, + getKey: (row) => row.clientId, + gcTime: 0, + select: (rows) => + rows.map(({ clientId, text, rank, payload }) => ({ + clientId, + text, + rank, + ...(payload === undefined ? {} : { payload }), + })), + }), +) +await collection.preload() +const offline = mode === `admit` +if (offline) await client.close() +let leader = true +const persist = convexOfflineMutation({ + client, + mutation, + collections: [collection], + getArgs: ({ transaction }) => { + const row = transaction.mutations[0].modified as Task + return { + clientId: row.clientId, + text: row.text, + rank: row.rank, + ...(row.payload === undefined ? {} : { payload: row.payload }), + } + }, +}) +const executor = startOfflineExecutor({ + mutationCodec: convexMutationCodec, + collections: { tasks: collection }, + storage, + mutationFns: { + save: async (params) => { + checkpoint(`provider-call`) + await persist(params) + }, + }, + leaderElection: { + requestLeadership: () => Promise.resolve(true), + releaseLeadership: () => { + leader = false + }, + isLeader: () => leader, + onLeadershipChange: () => () => {}, + }, + onlineDetector: { + isOnline: () => !offline, + subscribe: () => () => {}, + notifyOnline: () => {}, + dispose: () => {}, + }, +}) +await executor.waitForInit() +if (offline) { + const action = executor.createOfflineAction({ + mutationFnName: `save`, + onMutate: (row) => { + collection.insert(row) + }, + }) + action(draft) + const poll = setInterval(async () => { + if ((await executor.peekOutbox()).length === 1) { + clearInterval(poll) + checkpoint(`durable`) + } + }, 5) +} else if (mode === `replay`) { + const poll = setInterval(async () => { + if ((await executor.peekOutbox()).length === 0) { + clearInterval(poll) + await collection.utils.awaitSync() + if (collection.get(draft.clientId)?.text !== draft.text) + throw new Error(`Restored row did not reach the collection`) + checkpoint(`replayed`) + executor.dispose() + await collection.cleanup() + await client.close() + process.exit(0) + } + }, 5) +} +// Parent sends SIGKILL at the admission/uncertain checkpoint. No graceful +// executor disposal or in-memory persistence is allowed to help restoration. diff --git a/packages/convex-db-collection/e2e/setup.ts b/packages/convex-db-collection/e2e/setup.ts new file mode 100644 index 0000000000..442b602415 --- /dev/null +++ b/packages/convex-db-collection/e2e/setup.ts @@ -0,0 +1,87 @@ +import { spawn } from 'node:child_process' +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import type { TestProject } from 'vitest/node' + +declare module 'vitest' { + interface ProvidedContext { + convexUrl: string + } +} + +/** Own a dedicated local backend for the test run. Never select a cloud project. */ +export default async function setup(project: TestProject) { + const { + CONVEX_DEPLOY_KEY: _deployKey, + CONVEX_SELF_HOSTED_URL: _selfHostedUrl, + CONVEX_SELF_HOSTED_ADMIN_KEY: _adminKey, + CONVEX_DEPLOYMENT: _deployment, + ...environment + } = process.env + const backend = spawn( + process.execPath, + [ + fileURLToPath( + new URL(`../node_modules/convex/bin/main.js`, import.meta.url), + ), + `dev`, + `--typecheck`, + `disable`, + `--tail-logs`, + `disable`, + `--local-cloud-port`, + process.env.CONVEX_TEST_PORT ?? `43310`, + `--local-site-port`, + String(Number(process.env.CONVEX_TEST_PORT ?? `43310`) + 1), + ], + { + cwd: fileURLToPath(new URL(`./backend`, import.meta.url)), + env: { ...environment, CONVEX_AGENT_MODE: `anonymous`, FORCE_COLOR: `0` }, + stdio: [`ignore`, `pipe`, `pipe`], + }, + ) + let output = `` + try { + await new Promise((resolve, reject) => { + const timeout = setTimeout( + () => reject(new Error(`Local Convex startup timed out:\n${output}`)), + 120000, + ) + const receive = (chunk: Buffer) => { + output = (output + String(chunk)).slice(-8000) + if (output.includes(`Convex functions ready!`)) { + clearTimeout(timeout) + resolve() + } + } + backend.stdout.on(`data`, receive) + backend.stderr.on(`data`, receive) + backend.once(`error`, (cause) => { + clearTimeout(timeout) + reject(cause) + }) + backend.once(`exit`, (code) => { + clearTimeout(timeout) + reject(new Error(`Local Convex exited (${code}):\n${output}`)) + }) + }) + const env = await readFile( + new URL(`./backend/.env.local`, import.meta.url), + `utf8`, + ) + const url = /^CONVEX_URL=(.+)$/m.exec(env)?.[1] + if (!url?.startsWith(`http://127.0.0.1:`)) + throw new Error(`Expected an isolated loopback Convex backend`) + project.provide(`convexUrl`, url) + } catch (cause) { + backend.kill(`SIGINT`) + throw cause + } + return async () => { + const exited = new Promise((resolve) => + backend.once(`exit`, () => resolve()), + ) + backend.kill(`SIGINT`) + await exited + } +} diff --git a/packages/convex-db-collection/package.json b/packages/convex-db-collection/package.json new file mode 100644 index 0000000000..f2bb29c40a --- /dev/null +++ b/packages/convex-db-collection/package.json @@ -0,0 +1,73 @@ +{ + "name": "@tanstack/convex-db-collection", + "version": "0.1.0", + "description": "Convex collection for TanStack DB", + "author": "Tanner Linsley", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/TanStack/db.git", + "directory": "packages/convex-db-collection" + }, + "homepage": "https://tanstack.com/db", + "keywords": [ + "convex", + "tanstack", + "optimistic", + "typescript" + ], + "scripts": { + "build": "vite build", + "dev": "vite build --watch", + "lint": "eslint . --fix", + "test": "vitest run", + "test:oracles": "vitest run tests/snapshot-oracle.property.test.ts tests/pagination-oracle.property.test.ts tests/offline-oracle.property.test.ts tests/values-oracle.property.test.ts", + "typecheck": "tsc --noEmit", + "test:e2e": "vitest run --config vitest.e2e.config.ts" + }, + "type": "module", + "main": "dist/cjs/index.cjs", + "module": "dist/esm/index.js", + "types": "dist/esm/index.d.ts", + "exports": { + ".": { + "import": { + "types": "./dist/esm/index.d.ts", + "default": "./dist/esm/index.js" + }, + "require": { + "types": "./dist/cjs/index.d.cts", + "default": "./dist/cjs/index.cjs" + } + }, + "./package.json": "./package.json", + "./offline": { + "import": { + "types": "./dist/esm/offline.d.ts", + "default": "./dist/esm/offline.js" + }, + "require": { + "types": "./dist/cjs/offline.d.cts", + "default": "./dist/cjs/offline.cjs" + } + } + }, + "sideEffects": false, + "files": [ + "dist", + "src" + ], + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@tanstack/db": "workspace:*", + "@tanstack/offline-transactions": "workspace:*" + }, + "peerDependencies": { + "convex": "^1.47.0", + "typescript": ">=4.7" + }, + "devDependencies": { + "@vitest/coverage-istanbul": "^3.2.4", + "convex": "^1.47.0" + } +} diff --git a/packages/convex-db-collection/src/index.ts b/packages/convex-db-collection/src/index.ts new file mode 100644 index 0000000000..67aa8ea84f --- /dev/null +++ b/packages/convex-db-collection/src/index.ts @@ -0,0 +1,67 @@ +import { createConvexOptions } from './options' +import { subscribeQuery } from './subscription' +import type { ConvexCollectionUtils } from './options' +import type { StandardSchemaV1 } from '@standard-schema/spec' +import type { ConvexCollectionClient } from './subscription' +import type { + FunctionArgs, + FunctionReference, + FunctionReturnType, +} from 'convex/server' +import type { BaseCollectionConfig, CollectionConfig } from '@tanstack/db' + +export type { ConvexCollectionUtils } from './options' +export type { ConvexCollectionClient } from './subscription' + +type QueryRow> = + FunctionReturnType extends ReadonlyArray + ? TRow + : never + +export type ConvexCollectionConfig< + TQuery extends FunctionReference<`query`>, + TItem extends object = QueryRow, + TKey extends string | number = string | number, + TSchema extends StandardSchemaV1 = never, +> = Omit< + BaseCollectionConfig, + `syncMode` | `utils` | `schema` +> & { + /** Share the client used for mutations, authentication, and reconnects. */ + client: ConvexCollectionClient + query: TQuery + queryArgs: FunctionArgs + /** Reports subscription and result-conversion failures, including after readiness. */ + onError?: (error: unknown) => void +} & ([TSchema] extends [never] ? { schema?: never } : { schema: TSchema }) & + ( + | { + /** Convert an envelope or nullable result into the complete set of rows. */ + select: (result: FunctionReturnType) => ReadonlyArray + } + | (FunctionReturnType extends ReadonlyArray + ? { select?: never } + : never) + ) + +/** Mirror one authoritative Convex query result. Filters and joins run in DB. */ +export function convexCollectionOptions< + TQuery extends FunctionReference<`query`>, + TItem extends object = QueryRow, + TKey extends string | number = string | number, + TSchema extends StandardSchemaV1 = never, +>( + config: ConvexCollectionConfig, +): CollectionConfig & { + utils: ConvexCollectionUtils +} & ([TSchema] extends [never] ? unknown : { schema: TSchema }) { + const { client, query, queryArgs, ...options } = config + return createConvexOptions(options, (receive, fail) => + subscribeQuery(client, query, queryArgs, receive, fail), + ) +} + +export { + convexPaginatedCollectionOptions, + type ConvexPaginatedCollectionConfig, +} from './paginated-options' diff --git a/packages/convex-db-collection/src/offline.ts b/packages/convex-db-collection/src/offline.ts new file mode 100644 index 0000000000..db0fd4437f --- /dev/null +++ b/packages/convex-db-collection/src/offline.ts @@ -0,0 +1,109 @@ +import { convexToJson, jsonToConvex } from 'convex/values' +import { NonRetriableError } from '@tanstack/offline-transactions' +import type { JSONValue, Value } from 'convex/values' +import type { + MutationValueCodec, + OfflineConfig, +} from '@tanstack/offline-transactions' +import type { ConvexClient } from 'convex/browser' +import type { ConvexReactClient } from 'convex/react' +import type { FunctionArgs, FunctionReference } from 'convex/server' +import type { ConvexCollectionUtils } from './options' + +type OfflineMutation = OfflineConfig[`mutationFns`][string] +type OfflineParams = Parameters[0] + +/** + * Use as a named mutationFn in startOfflineExecutor. The existing outbox owns + * durability, restoration, leadership and retries. The server mutation must + * atomically deduplicate idempotencyKey with its writes. + */ +export function convexOfflineMutation< + TMutation extends FunctionReference<`mutation`>, +>( + config: { + client: Pick | Pick + mutation: TMutation + getArgs: ( + params: OfflineParams, + ) => Omit, `idempotencyKey`> + collections: ReadonlyArray<{ + utils: Pick + }> + } & (FunctionArgs extends { idempotencyKey: string } + ? unknown + : never), +): OfflineMutation { + return async (params) => { + try { + const args = { + ...config.getArgs(params), + idempotencyKey: params.idempotencyKey, + } as FunctionArgs + await config.client.mutation(config.mutation, args) + } catch (cause) { + // The WebSocket clients retry transport interruptions themselves. A + // rejected mutation has a server/function or client configuration error. + const error = new NonRetriableError( + cause instanceof Error ? cause.message : String(cause), + ) + throw error + } + await Promise.all( + config.collections.map((collection) => collection.utils.awaitSync()), + ) + } +} + +/** Lossless, opt-in outbox encoding for Convex values and removed fields. */ +export const convexMutationCodec: MutationValueCodec = { + id: `tanstack-convex-values/1`, + encode: (value) => JSON.stringify(encodeValue(value)), + decode: (value) => decodeValue(JSON.parse(value)), +} + +function encodeValue(value: unknown): unknown { + if (value === undefined) return [`undefined`] + if (Array.isArray(value)) return [`array`, value.map(encodeValue)] + if ( + value !== null && + typeof value === `object` && + !(value instanceof ArrayBuffer) + ) { + const prototype = Object.getPrototypeOf(value) + if (prototype !== Object.prototype && prototype !== null) + throw new TypeError( + `Convex mutation codecs support plain objects and Convex scalar values`, + ) + return [ + `object`, + Object.entries(value).map(([key, field]) => [key, encodeValue(field)]), + ] + } + return [`scalar`, convexToJson(value as Value)] +} + +function decodeValue(value: unknown): unknown { + if (!Array.isArray(value)) + throw new TypeError(`Invalid Convex mutation codec record`) + if (value[0] === `undefined` && value.length === 1) return undefined + if (value.length !== 2) + throw new TypeError(`Invalid Convex mutation codec record`) + if (value[0] === `scalar`) return jsonToConvex(value[1] as JSONValue) + if (!Array.isArray(value[1])) + throw new TypeError(`Invalid Convex mutation codec container`) + if (value[0] === `array`) return value[1].map(decodeValue) + if (value[0] === `object`) + return Object.fromEntries( + value[1].map((entry: unknown) => { + if ( + !Array.isArray(entry) || + entry.length !== 2 || + typeof entry[0] !== `string` + ) + throw new TypeError(`Invalid Convex mutation codec field`) + return [entry[0], decodeValue(entry[1])] + }), + ) + throw new TypeError(`Invalid Convex mutation codec tag`) +} diff --git a/packages/convex-db-collection/src/options.ts b/packages/convex-db-collection/src/options.ts new file mode 100644 index 0000000000..d76d02cbd5 --- /dev/null +++ b/packages/convex-db-collection/src/options.ts @@ -0,0 +1,221 @@ +import { whenSyncAccepted, withCollectionConfigFactory } from '@tanstack/db' +import type { StandardSchemaV1 } from '@standard-schema/spec' +import type { + BaseCollectionConfig, + CollectionConfig, + SyncConfig, + UtilsRecord, +} from '@tanstack/db' +import type { ConvexSubscription } from './subscription' + +export interface ConvexCollectionUtils extends UtilsRecord { + /** Last subscription or conversion error, cleared by the next valid result. */ + getSyncError: () => unknown + /** Refresh the source cache and wait for sync acceptance, safe inside mutation handlers. */ + awaitSync: () => Promise +} +export function createConvexOptions< + TResult, + TItem extends object, + TKey extends string | number, + TSchema extends StandardSchemaV1, +>( + config: Omit< + BaseCollectionConfig, + `syncMode` | `utils` + > & { + select?: (result: TResult) => ReadonlyArray + onError?: (error: unknown) => void + }, + subscribe: ( + receive: (result: TResult) => void, + fail: (error: unknown) => void, + ) => ConvexSubscription, +): CollectionConfig & { + utils: ConvexCollectionUtils +} & ([TSchema] extends [never] ? unknown : { schema: TSchema }) { + const { select, onError, ...baseConfig } = config + let syncError: unknown + let readCurrent: (() => Promise) | undefined + + const sync: SyncConfig = { + rowUpdateMode: `full`, + sync: ({ + begin, + write, + commit, + truncate, + markReady, + markError, + collection, + }) => { + let active = true + let previous = new Map() + let initialized = false + let visible: true | Promise = true + const accepting = new Set>() + const fail = (error: unknown): void => { + if (!active) return + syncError = error + if (collection.status === `loading` || collection.status === `error`) { + markError(error) + } + onError?.(error) + } + const accept = (result: TResult): void => { + if (!active) return + // Validate the entire result before opening a sync transaction. A bad + // snapshot must never leave a half-written transaction behind. + const rows: unknown = select ? select(result) : result + if (!Array.isArray(rows)) { + throw new TypeError( + `A Convex collection query must return an array, or supply select.`, + ) + } + const next = new Map() + for (const candidate of rows as Array) { + if ( + typeof candidate !== `object` || + candidate === null || + Array.isArray(candidate) + ) { + throw new TypeError(`Convex collection rows must be objects.`) + } + const row = candidate as TItem + const key = config.getKey(row) + if ( + (typeof key !== `string` && typeof key !== `number`) || + (typeof key === `number` && !Number.isFinite(key)) + ) { + throw new TypeError( + `Convex collection keys must be strings or finite numbers.`, + ) + } + if (next.has(key)) { + throw new Error(`Duplicate Convex collection key: ${String(key)}`) + } + next.set(key, row) + } + if ( + initialized && + next.size === previous.size && + [...next].every(([key, row]) => previous.get(key) === row) + ) { + syncError = undefined + if (visible === true) markReady() + return + } + begin() + // A restarted run replaces retained or hydrated data, including an + // empty first result. Later results use keyed changes. + if (!initialized) truncate() + for (const [key, row] of next) { + if (!previous.has(key) || !initialized) { + write({ type: `insert`, value: row }) + } else if (previous.get(key) !== row) { + write({ type: `update`, value: row }) + } + } + for (const key of previous.keys()) { + if (!next.has(key)) write({ type: `delete`, key }) + } + previous = next + initialized = true + syncError = undefined + const receipt = commit() + visible = receipt + // Never await visibility from a mutation handler: DB may hold it until + // that same optimistic transaction settles. + const accepted = whenSyncAccepted(receipt) + if (accepted !== true) { + accepting.add(accepted) + void accepted.then( + () => accepting.delete(accepted), + () => accepting.delete(accepted), + ) + } + if (receipt === true) markReady() + else + void receipt.then(() => { + if (active) { + visible = true + markReady() + } + }, fail) + } + const receive = (result: TResult): void => { + try { + accept(result) + } catch (error) { + fail(error) + } + } + let subscription: ConvexSubscription | undefined + const refresh = async (): Promise => { + if (!active || !subscription) return + try { + await subscription.waitForCurrent?.() + // Cleanup can run while the asynchronous page acquisition waits. + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (!active) return + const value = subscription.getCurrentValue() + if (value !== undefined) accept(value) + } catch (error) { + fail(error) + throw error + } + if (syncError !== undefined) throw syncError + await Promise.all([...accepting]) + } + readCurrent = refresh + try { + subscription = subscribe(receive, fail) + const value = subscription.getCurrentValue() + if (value !== undefined) receive(value) + } catch (error) { + fail(error) + } + return () => { + active = false + if (readCurrent === refresh) readCurrent = undefined + subscription?.unsubscribe() + } + }, + } + + // Convex mutation promises resolve after the subscription has caught up. + // Read the cache as well: a newly registered cached listener may still be + // waiting for its scheduled initial callback when the mutation resolves. + function wrapHandler( + handler: (params: TParams) => Promise, + ) { + return async (params: TParams): Promise => { + await handler(params) + await readCurrent?.() + } + } + const options: CollectionConfig< + TItem, + TKey, + TSchema, + ConvexCollectionUtils + > & { utils: ConvexCollectionUtils } = { + ...baseConfig, + syncMode: `eager` as const, + sync, + onInsert: config.onInsert && wrapHandler(config.onInsert), + onUpdate: config.onUpdate && wrapHandler(config.onUpdate), + onDelete: config.onDelete && wrapHandler(config.onDelete), + utils: { + getSyncError: () => syncError, + awaitSync: async () => { + await readCurrent?.() + }, + }, + } + return withCollectionConfigFactory(options, () => + createConvexOptions(config, subscribe), + ) as CollectionConfig & { + utils: ConvexCollectionUtils + } & ([TSchema] extends [never] ? unknown : { schema: TSchema }) +} diff --git a/packages/convex-db-collection/src/paginated-options.ts b/packages/convex-db-collection/src/paginated-options.ts new file mode 100644 index 0000000000..48a8be5341 --- /dev/null +++ b/packages/convex-db-collection/src/paginated-options.ts @@ -0,0 +1,66 @@ +import { createConvexOptions } from './options' +import { subscribePages } from './pagination' +import type { StandardSchemaV1 } from '@standard-schema/spec' +import type { BaseCollectionConfig } from '@tanstack/db' +import type { + FunctionArgs, + FunctionReference, + FunctionReturnType, + PaginationOptions, + PaginationResult, +} from 'convex/server' +import type { ConvexCollectionUtils } from './options' +import type { ConvexCollectionClient } from './subscription' + +type PaginatedRow> = + FunctionReturnType extends PaginationResult + ? TRow + : never + +export type ConvexPaginatedCollectionConfig< + TQuery extends FunctionReference<`query`>, + TItem extends object = PaginatedRow, + TKey extends string | number = string | number, + TSchema extends StandardSchemaV1 = never, +> = Omit< + BaseCollectionConfig, + `syncMode` | `utils` | `schema` +> & { + client: ConvexCollectionClient + query: TQuery + queryArgs: Omit, `paginationOpts`> + pageSize?: number + onError?: (error: unknown) => void +} & ([TSchema] extends [never] ? { schema?: never } : { schema: TSchema }) & + (FunctionReturnType extends PaginationResult + ? unknown + : never) & + (FunctionArgs extends { paginationOpts: PaginationOptions } + ? unknown + : never) & + ( + | { + select: ( + rows: ReadonlyArray>, + ) => ReadonlyArray + } + | (PaginatedRow extends TItem ? { select?: never } : never) + ) + +/** Automatically subscribe to every page, retaining fixed reactive cursor ranges. */ +export function convexPaginatedCollectionOptions< + TQuery extends FunctionReference<`query`>, + TItem extends object = PaginatedRow, + TKey extends string | number = string | number, + TSchema extends StandardSchemaV1 = never, +>(config: ConvexPaginatedCollectionConfig) { + const { client, query, queryArgs, pageSize = 100, ...options } = config + return createConvexOptions< + ReadonlyArray>, + TItem, + TKey, + TSchema + >(options, (receive, fail) => + subscribePages(client, query, queryArgs, pageSize, receive, fail), + ) +} diff --git a/packages/convex-db-collection/src/pagination.ts b/packages/convex-db-collection/src/pagination.ts new file mode 100644 index 0000000000..a5facd4720 --- /dev/null +++ b/packages/convex-db-collection/src/pagination.ts @@ -0,0 +1,178 @@ +import { subscribeQuery } from './subscription' +import type { ConvexCollectionClient, ConvexSubscription } from './subscription' +import type { + FunctionArgs, + FunctionReference, + PaginationResult, +} from 'convex/server' + +type Page = { + cursor: string | null + endCursor?: string + subscription?: ConvexSubscription> +} + +/** + * Each bounded subscription owns (cursor, endCursor]. A changing page cannot + * move its neighbour's boundary. Unbounded tails are sealed before continuing; + * split hints replace a range with its two disjoint halves. Only a complete + * chain ending in isDone can replace the Collection's authoritative snapshot. + */ +export function subscribePages, TRow>( + client: ConvexCollectionClient, + query: TQuery, + args: Omit, `paginationOpts`>, + pageSize: number, + receive: (rows: ReadonlyArray) => void, + fail: (error: unknown) => void, +): ConvexSubscription> { + if (!Number.isSafeInteger(pageSize) || pageSize <= 0) { + throw new RangeError(`Convex pageSize must be a positive safe integer.`) + } + let active = true + let scheduled = false + let flushing = false + let result: ReadonlyArray | undefined + let error: unknown + const waiters = new Set<{ + resolve: () => void + reject: (reason: unknown) => void + }>() + const pages: Array> = [] + const boundaries = new Set() + const reject = (reason: unknown): void => { + if (!active) return + error = reason + result = undefined + for (const waiter of waiters) waiter.reject(reason) + waiters.clear() + fail(reason) + } + const schedule = (): void => { + if (!active || scheduled) return + result = undefined + scheduled = true + queueMicrotask(() => { + scheduled = false + flush() + }) + } + const createPage = ( + cursor: string | null, + endCursor?: string, + ): Page => { + const page: Page = { cursor, endCursor } + const paginationOpts = { + cursor, + numItems: pageSize, + ...(endCursor === undefined ? {} : { endCursor }), + } + page.subscription = subscribeQuery( + client, + query, + { ...args, paginationOpts } as FunctionArgs, + schedule, + reject, + ) as ConvexSubscription> + return page + } + const replace = (index: number, page: Page, boundary: string): void => { + if ( + boundary === page.cursor || + boundary === page.endCursor || + boundaries.has(boundary) + ) { + throw new Error( + `Convex pagination returned a boundary that makes no progress.`, + ) + } + const left = createPage(page.cursor, boundary) + let right: Page + try { + right = createPage(boundary, page.endCursor) + } catch (cause) { + left.subscription?.unsubscribe() + throw cause + } + boundaries.add(boundary) + pages.splice(index, 1, left, right) + page.subscription?.unsubscribe() + schedule() + } + function flush(): void { + if (!active || flushing) return + flushing = true + result = undefined + try { + const rows: Array = [] + for (let index = 0; index < pages.length; index++) { + const page = pages[index]! + const value = page.subscription!.getCurrentValue() + if (value === undefined) return + if ( + !Array.isArray(value.page) || + typeof value.isDone !== `boolean` || + typeof value.continueCursor !== `string` + ) { + throw new TypeError( + `Convex paginated queries must return PaginationResult.`, + ) + } + const split = + value.pageStatus === `SplitRequired` || + value.pageStatus === `SplitRecommended` + if (split) { + if (!value.splitCursor) + throw new Error( + `Convex requested a page split without a splitCursor.`, + ) + replace(index, page, value.splitCursor) + return + } + if (page.endCursor === undefined && !value.isDone) { + replace(index, page, value.continueCursor) + return + } + rows.push(...value.page) + } + error = undefined + result = rows + receive(rows) + for (const waiter of waiters) waiter.resolve() + waiters.clear() + } catch (cause) { + reject(cause) + } finally { + flushing = false + } + } + try { + pages.push(createPage(null)) + schedule() + } catch (cause) { + for (const page of pages) page.subscription?.unsubscribe() + throw cause + } + return { + getCurrentValue: () => { + flush() + return result + }, + waitForCurrent: () => { + flush() + if (error !== undefined) return Promise.reject(error) + if (result !== undefined) return Promise.resolve() + return new Promise((resolve, rejectWaiter) => { + waiters.add({ resolve, reject: rejectWaiter }) + }) + }, + unsubscribe: () => { + active = false + for (const page of pages) page.subscription?.unsubscribe() + const cause = new Error(`Convex pagination was cleaned up.`) + cause.name = `AbortError` + for (const waiter of waiters) waiter.reject(cause) + waiters.clear() + }, + } +} diff --git a/packages/convex-db-collection/src/subscription.ts b/packages/convex-db-collection/src/subscription.ts new file mode 100644 index 0000000000..210dc06dab --- /dev/null +++ b/packages/convex-db-collection/src/subscription.ts @@ -0,0 +1,49 @@ +import type { ConvexClient } from 'convex/browser' +import type { ConvexReactClient } from 'convex/react' +import type { + FunctionArgs, + FunctionReference, + FunctionReturnType, +} from 'convex/server' + +export type ConvexCollectionClient = + Pick | Pick + +export interface ConvexSubscription { + getCurrentValue: () => T | undefined + unsubscribe: () => void + /** Wait for a complete authoritative snapshot, without waiting for DB visibility. */ + waitForCurrent?: () => Promise +} + +export function subscribeQuery>( + client: ConvexCollectionClient, + query: TQuery, + args: FunctionArgs, + receive: (result: FunctionReturnType) => void, + fail: (error: unknown) => void, +): ConvexSubscription> { + if (`disabled` in client && client.disabled === true) { + throw new Error(`A disabled Convex client cannot sync a collection.`) + } + if (`onUpdate` in client) { + if (`closed` in client && client.closed === true) { + throw new Error(`A closed Convex client cannot sync a collection.`) + } + const subscription = client.onUpdate(query, args, receive, fail) + return { + getCurrentValue: () => subscription.getCurrentValue(), + unsubscribe: subscription, + } + } + const watch = client.watchQuery(query, args) + const unsubscribe = watch.onUpdate(() => { + try { + const result = watch.localQueryResult() + if (result !== undefined) receive(result) + } catch (error) { + fail(error) + } + }) + return { getCurrentValue: () => watch.localQueryResult(), unsubscribe } +} diff --git a/packages/convex-db-collection/tests/collection.test.ts b/packages/convex-db-collection/tests/collection.test.ts new file mode 100644 index 0000000000..91b8b1d12a --- /dev/null +++ b/packages/convex-db-collection/tests/collection.test.ts @@ -0,0 +1,331 @@ +import { + DbClient, + collectionOptions, + createCollection, + withAcceptedReceipt, +} from '@tanstack/db' +import { describe, expect, it, vi } from 'vitest' +import { makeFunctionReference } from 'convex/server' +import { convexCollectionOptions } from '../src/index' +import { controlledClient, query } from './helpers' +import type { Row } from './helpers' + +function setup( + handlers: Partial< + Pick< + Parameters>[0], + `onInsert` | `onUpdate` | `onDelete` | `onError` + > + > = {}, +) { + const transport = controlledClient() + const options = convexCollectionOptions({ + client: transport.client, + query, + queryArgs: { owner: `alice` }, + getKey: (row) => row._id, + gcTime: 0, + ...handlers, + }) + const collection = createCollection(options) + collection.startSyncImmediate() + return { transport, collection, options } +} + +describe(`Convex collection`, () => { + it(`waits for a snapshot and accepts an empty initial result`, async () => { + const { transport, collection } = setup() + expect(collection.status).toBe(`loading`) + transport.emit([]) + await collection.preload() + expect(collection.status).toBe(`ready`) + expect(collection.size).toBe(0) + await collection.cleanup() + }) + + it(`replaces full rows and removes rows missing from later snapshots`, async () => { + const { transport, collection } = setup() + transport.emit([ + { _id: `a`, text: `old`, optional: `remove` }, + { _id: `b`, text: `gone` }, + ]) + transport.emit([{ _id: `a`, text: `new` }]) + expect(collection.size).toBe(1) + expect(collection.get(`a`)).toMatchObject({ _id: `a`, text: `new` }) + expect(collection.get(`a`)).not.toHaveProperty(`optional`) + transport.emit([]) + expect(collection.size).toBe(0) + await collection.cleanup() + }) + + it(`rejects invalid snapshots before changing rows and recovers on a valid result`, async () => { + const onError = vi.fn() + const { transport, collection } = setup({ onError }) + transport.emit([{ _id: `a`, text: `valid` }]) + transport.emit([ + { _id: `b`, text: `one` }, + { _id: `b`, text: `two` }, + ]) + expect(collection.get(`a`)?.text).toBe(`valid`) + expect(collection.has(`b`)).toBe(false) + expect(collection.utils.getSyncError()).toBeInstanceOf(Error) + expect(onError).toHaveBeenCalledOnce() + transport.emit([{ _id: `b`, text: `recovered` }]) + expect(collection.utils.getSyncError()).toBeUndefined() + expect(collection.has(`a`)).toBe(false) + await collection.cleanup() + }) + + it(`rejects preload with the original subscription error`, async () => { + const { transport, collection } = setup() + const ready = collection.preload() + const error = new Error(`permission denied`) + transport.error(error) + await expect(ready).rejects.toBe(error) + transport.emit([]) + expect(collection.status).toBe(`ready`) + await collection.cleanup() + }) + + it(`invalidates retired callbacks and replaces retained data after restart`, async () => { + const { transport, collection } = setup() + transport.emit([{ _id: `a`, text: `old` }]) + await collection.cleanup() + transport.late([{ _id: `ghost`, text: `late` }]) + expect(collection.has(`ghost`)).toBe(false) + transport.cache([]) + collection.startSyncImmediate() + await collection.preload() + expect(collection.size).toBe(0) + await collection.cleanup() + expect(transport.counts()).toEqual({ subscriptions: 2, unsubscriptions: 2 }) + }) + + it(`keeps optimistic writes until the cached server result is accepted`, async () => { + let resolve!: () => void + const pending = new Promise((done) => { + resolve = done + }) + const { transport, collection } = setup({ onUpdate: async () => pending }) + transport.emit([{ _id: `a`, text: `old` }]) + const tx = collection.update(`a`, (row) => { + row.text = `optimistic` + }) + expect(collection.get(`a`)?.text).toBe(`optimistic`) + transport.cache([{ _id: `a`, text: `server` }]) + resolve() + await tx.isPersisted.promise + expect(collection.get(`a`)?.text).toBe(`server`) + await collection.cleanup() + }) + + it(`rolls back failed writes`, async () => { + const error = new Error(`rejected`) + const { transport, collection } = setup({ + onDelete: () => Promise.reject(error), + }) + transport.emit([{ _id: `a`, text: `keep` }]) + const tx = collection.delete(`a`) + await expect(tx.isPersisted.promise).rejects.toBe(error) + expect(collection.get(`a`)?.text).toBe(`keep`) + await collection.cleanup() + }) + + it(`supports typed envelope projections`, () => { + const transport = controlledClient() + const envelope = makeFunctionReference<`query`, {}, { rows: Array }>( + `tasks:envelope`, + ) + const options = convexCollectionOptions({ + client: transport.client, + query: envelope, + queryArgs: {}, + select: (result) => result.rows, + getKey: (row) => row._id, + }) + expect(options.syncMode).toBe(`eager`) + }) + + it(`creates independent adapter state for each DbClient`, async () => { + const transport = controlledClient() + transport.cache([{ _id: `a`, text: `cached` }]) + const options = collectionOptions( + convexCollectionOptions({ + id: `tasks`, + client: transport.client, + query, + queryArgs: { owner: `alice` }, + getKey: (row) => row._id, + gcTime: 0, + }), + ) + const first = new DbClient() + const second = new DbClient() + const a = first.collection(options) + const b = second.collection(options) + await Promise.all([a.preload(), b.preload()]) + expect(a).not.toBe(b) + expect(transport.counts().subscriptions).toBe(2) + await a.cleanup() + expect(b.get(`a`)?.text).toBe(`cached`) + await b.cleanup() + }) +}) + +it(`waits for deferred sync acceptance without waiting for its own visibility`, async () => { + const transport = controlledClient() + let delay = false + let accept!: () => void + const accepted = new Promise((resolve) => { + accept = resolve + }) + const options = convexCollectionOptions({ + client: transport.client, + query, + queryArgs: { owner: `alice` }, + getKey: (row) => row._id, + gcTime: 0, + onUpdate: () => Promise.resolve(), + }) + const collection = createCollection({ + ...options, + sync: { + ...options.sync, + sync: (params) => + options.sync.sync({ + ...params, + commit: () => { + const visible = params.commit() + return delay + ? withAcceptedReceipt( + Promise.resolve(visible).then(() => undefined), + accepted, + ) + : visible + }, + }), + }, + }) + collection.startSyncImmediate() + transport.emit([{ _id: `a`, text: `old` }]) + delay = true + const transaction = collection.update(`a`, (row) => { + row.text = `optimistic` + }) + transport.emit([{ _id: `a`, text: `server` }]) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(transaction.state).toBe(`persisting`) + expect(collection.get(`a`)?.text).toBe(`optimistic`) + accept() + await transaction.isPersisted.promise + expect(collection.get(`a`)?.text).toBe(`server`) + await collection.cleanup() +}) + +it.each([null, {}, [null], [1], [[`nested`]]])( + `rejects a malformed query result %j`, + async (result) => { + const { collection, transport } = setup() + const ready = collection.preload() + transport.emit(result as unknown as Array) + await expect(ready).rejects.toBeInstanceOf(TypeError) + transport.emit([]) + expect(collection.status).toBe(`ready`) + await collection.cleanup() + }, +) + +it.each([NaN, Infinity, -Infinity])( + `rejects the invalid numeric key %s`, + async (key) => { + const transport = controlledClient() + const collection = createCollection( + convexCollectionOptions({ + client: transport.client, + query, + queryArgs: { owner: `alice` }, + getKey: () => key, + gcTime: 0, + }), + ) + const ready = collection.preload() + transport.emit([{ _id: `a`, text: `bad key` }]) + await expect(ready).rejects.toThrow(`finite numbers`) + await collection.cleanup() + }, +) + +it(`recovers from a projection failure before opening a sync transaction`, async () => { + const transport = controlledClient() + const error = new Error(`bad projection`) + let broken = true + const collection = createCollection( + convexCollectionOptions({ + client: transport.client, + query, + queryArgs: { owner: `alice` }, + getKey: (row) => row._id, + gcTime: 0, + select: (rows) => { + if (broken) throw error + return rows + }, + }), + ) + const ready = collection.preload() + transport.emit([{ _id: `a`, text: `valid` }]) + await expect(ready).rejects.toBe(error) + broken = false + transport.emit([{ _id: `a`, text: `valid` }]) + expect(collection.get(`a`)?.text).toBe(`valid`) + await collection.cleanup() +}) + +it(`confirms optimistic inserts and deletes from subscription results`, async () => { + const transport = controlledClient() + const collection = createCollection( + convexCollectionOptions({ + client: transport.client, + query, + queryArgs: { owner: `alice` }, + getKey: (row) => row._id, + gcTime: 0, + onInsert: () => { + transport.emit([{ _id: `a`, text: `server insert` }]) + return Promise.resolve() + }, + onDelete: () => { + transport.emit([]) + return Promise.resolve() + }, + }), + ) + collection.startSyncImmediate() + transport.emit([]) + const inserted = collection.insert({ _id: `a`, text: `optimistic` }) + await inserted.isPersisted.promise + expect(collection.get(`a`)?.text).toBe(`server insert`) + const deleted = collection.delete(`a`) + await deleted.isPersisted.promise + expect(collection.size).toBe(0) + await collection.cleanup() +}) + +it(`reports a cached query error discovered while settling a mutation`, async () => { + const onError = vi.fn() + const { transport, collection } = setup({ + onError, + onUpdate: () => Promise.resolve(), + }) + transport.emit([{ _id: `a`, text: `old` }]) + const error = new Error(`cached query failed`) + transport.cacheError(error) + const transaction = collection.update(`a`, (row) => { + row.text = `optimistic` + }) + await expect(transaction.isPersisted.promise).rejects.toBe(error) + expect(collection.utils.getSyncError()).toBe(error) + expect(onError).toHaveBeenCalledExactlyOnceWith(error) + expect(collection.get(`a`)?.text).toBe(`old`) + await collection.cleanup() +}) diff --git a/packages/convex-db-collection/tests/helpers.ts b/packages/convex-db-collection/tests/helpers.ts new file mode 100644 index 0000000000..ba65699cb8 --- /dev/null +++ b/packages/convex-db-collection/tests/helpers.ts @@ -0,0 +1,71 @@ +import { makeFunctionReference } from 'convex/server' +import type { ConvexCollectionClient } from '../src/index' + +export type Row = { _id: string; text: string; optional?: string } +export const query = makeFunctionReference< + `query`, + { owner: string }, + Array +>(`tasks:list`) +export const mutation = makeFunctionReference< + `mutation`, + { text: string }, + null +>(`tasks:update`) + +export function controlledClient() { + let value: Array | undefined + let readError: Error | undefined + let receive: ((rows: Array) => void) | undefined + let fail: ((error: Error) => void) | undefined + let retired: ((rows: Array) => void) | undefined + let subscriptions = 0 + let unsubscriptions = 0 + // The assertion adapts this one-query transport fixture to the SDK's generic + // method. Real SDK compatibility is checked separately, without this cast. + const client = { + onUpdate: ( + _query: unknown, + _args: unknown, + callback: typeof receive, + onError: typeof fail, + ) => { + subscriptions++ + receive = callback + fail = onError + return Object.assign( + () => { + unsubscriptions++ + retired = receive + receive = undefined + }, + { + getCurrentValue: () => { + if (readError) throw readError + return value + }, + }, + ) + }, + } as ConvexCollectionClient + return { + client, + emit(rows: Array) { + value = rows + receive?.(rows) + }, + cache(rows: Array) { + value = rows + }, + cacheError(error: Error) { + readError = error + }, + error(error: Error) { + fail?.(error) + }, + late(rows: Array) { + retired?.(rows) + }, + counts: () => ({ subscriptions, unsubscriptions }), + } +} diff --git a/packages/convex-db-collection/tests/offline-oracle.property.test.ts b/packages/convex-db-collection/tests/offline-oracle.property.test.ts new file mode 100644 index 0000000000..2523e3f404 --- /dev/null +++ b/packages/convex-db-collection/tests/offline-oracle.property.test.ts @@ -0,0 +1,100 @@ +/** + * The Convex offline bridge sends one durable idempotency key, then waits for + * source acceptance. Argument construction and rejected server mutations are + * terminal application failures; a sync failure after server confirmation may + * be retried with that same key. This is the bridge's public retry contract, + * received by the existing outbox engine's NonRetriableError boundary. + * + * The model is an ordered dependency list: args, mutation, sync. A failure + * admits only its prefix. Generated histories choose the failing stage or no + * failure, plus arbitrary text and durable keys. Each stage records its call; + * the comparison checks the prefix and caller error after settlement. It does + * not model outbox storage or leadership, whose owners remain in that package. + * The real process-crash witness uses actual Convex mutations in e2e. + */ +import fc from 'fast-check' +import { NonRetriableError } from '@tanstack/offline-transactions' +import { makeFunctionReference } from 'convex/server' +import { expect, it } from 'vitest' +import { convexOfflineMutation } from '../src/offline' +import type { ConvexClient } from 'convex/browser' +import type { OfflineConfig } from '@tanstack/offline-transactions' + +const mutation = makeFunctionReference< + `mutation`, + { idempotencyKey: string; text: string }, + null +>(`tasks:save`) +const stages = [`args`, `mutation`, `sync`] as const +const property = fc.asyncProperty( + fc.record({ + failure: fc.constantFrom(`none`, ...stages), + key: fc.string({ maxLength: 15 }), + text: fc.string({ maxLength: 15 }), + }), + async ({ failure, key, text }) => { + const trace: Array = [] + const cause = new Error(`injected ${failure}`) + const enter = (stage: string) => { + trace.push(stage) + if (failure === stage) throw cause + } + const send = ( + _ref: unknown, + args: { idempotencyKey: string; text: string }, + ) => { + enter(`mutation`) + expect(args).toEqual({ idempotencyKey: key, text }) + return Promise.resolve(null) + } + const persist = convexOfflineMutation({ + client: { mutation: send } as unknown as Pick, + mutation, + getArgs: () => { + enter(`args`) + return { text } + }, + collections: [ + { + utils: { + awaitSync: () => { + enter(`sync`) + return Promise.resolve() + }, + }, + }, + ], + }) + const params = { + transaction: { mutations: [] }, + idempotencyKey: key, + } as unknown as Parameters[0] + const observed = await persist(params).then( + () => ({ status: `fulfilled` as const }), + (error: unknown) => ({ status: `rejected` as const, error }), + ) + const boundary = stages.indexOf(failure as (typeof stages)[number]) + expect(trace).toEqual( + boundary === -1 ? stages : stages.slice(0, boundary + 1), + ) + expect(observed.status).toBe(boundary === -1 ? `fulfilled` : `rejected`) + if (observed.status === `rejected`) { + if (boundary < 2) expect(observed.error).toBeInstanceOf(NonRetriableError) + else expect(observed.error).toBe(cause) + } + }, +) + +it(`preserves ordered replay and retry classification across bridge failures`, async () => { + const seed = process.env.CONVEX_OFFLINE_ORACLE_SEED + if (seed) + await fc.assert(property, { + seed: Number(seed), + path: process.env.CONVEX_OFFLINE_ORACLE_PATH, + numRuns: 60, + }) + else { + await fc.assert(property, { seed: 20261022, numRuns: 60 }) + await fc.assert(property, { numRuns: 60 }) + } +}) diff --git a/packages/convex-db-collection/tests/offline.test.ts b/packages/convex-db-collection/tests/offline.test.ts new file mode 100644 index 0000000000..46190d52f9 --- /dev/null +++ b/packages/convex-db-collection/tests/offline.test.ts @@ -0,0 +1,56 @@ +import { NonRetriableError } from '@tanstack/offline-transactions' +import { makeFunctionReference } from 'convex/server' +import { expect, it, vi } from 'vitest' +import { convexOfflineMutation } from '../src/offline' +import type { ConvexClient } from 'convex/browser' +import type { OfflineConfig } from '@tanstack/offline-transactions' + +const mutation = makeFunctionReference< + `mutation`, + { idempotencyKey: string; text: string }, + null +>(`tasks:save`) +const params = { + transaction: { mutations: [] }, + idempotencyKey: `durable-id`, +} as unknown as Parameters[0] + +it(`sends the durable idempotency key and waits for sync acceptance`, async () => { + const send = vi.fn(() => Promise.resolve(null)) + let accepted!: () => void + const gate = new Promise((resolve) => { + accepted = resolve + }) + const synced = vi.fn(() => gate) + const persist = convexOfflineMutation({ + client: { mutation: send } as unknown as Pick, + mutation, + getArgs: () => ({ text: `hello` }), + collections: [{ utils: { awaitSync: synced } }], + }) + let settled = false + const pending = persist(params).then(() => { + settled = true + }) + await vi.waitFor(() => expect(synced).toHaveBeenCalledOnce()) + expect(settled).toBe(false) + expect(send).toHaveBeenCalledWith(mutation, { + text: `hello`, + idempotencyKey: `durable-id`, + }) + accepted() + await pending + expect(settled).toBe(true) +}) + +it(`rolls back rejected server functions without durable retry`, async () => { + const persist = convexOfflineMutation({ + client: { + mutation: () => Promise.reject(new Error(`permission denied`)), + } as unknown as Pick, + mutation, + getArgs: () => ({ text: `hello` }), + collections: [], + }) + await expect(persist(params)).rejects.toBeInstanceOf(NonRetriableError) +}) diff --git a/packages/convex-db-collection/tests/pagination-oracle.property.test.ts b/packages/convex-db-collection/tests/pagination-oracle.property.test.ts new file mode 100644 index 0000000000..91247cef0c --- /dev/null +++ b/packages/convex-db-collection/tests/pagination-oracle.property.test.ts @@ -0,0 +1,180 @@ +/** + * Automatic pagination owes the same complete snapshot as an unpaginated + * query. Convex's documented PaginationOptions fixes each range with + * endCursor; SplitRequired means the current page is incomplete and cannot + * be published. Our model is the latest input row array, sorted only for the + * public comparison. It does not compute cursors or adapter sync operations. + * + * The provider fixture below answers mathematical rank intervals, rather than + * following the adapter's page state. Histories replace up to eight distinct + * keyed rows, moving their ranks and adding/removing keys. Page sizes 1-4, + * forward/reverse delivery, and required/recommended/no splits vary separately. + * A pump delivers every active subscription once per source revision, including + * newly acquired ranges, before the exhaustion checkpoint. It never supplies + * an endCursor unless production requested it. The real-backend receiving + * witness in e2e/backend-oracle.test.ts checks these range premises with Convex. + * + * Limits: finite exhausted queries and one Collection. Continuous unbounded + * growth and atomic publication across separate Collections are not promised. + */ +import fc from 'fast-check' +import { createCollection } from '@tanstack/db' +import { makeFunctionReference } from 'convex/server' +import { expect, it } from 'vitest' +import { convexPaginatedCollectionOptions } from '../src/index' +import type { PaginationOptions, PaginationResult } from 'convex/server' +import type { ConvexCollectionClient } from '../src/index' + +type Row = { id: string; rank: number; text: string } +const query = makeFunctionReference< + `query`, + { paginationOpts: PaginationOptions }, + PaginationResult +>(`rows:list`) +type Slot = { + options: PaginationOptions + current?: PaginationResult + callback: (value: PaginationResult) => void + active: boolean +} +function provider( + reverse: boolean, + split: `none` | `required` | `recommended`, +) { + const slots: Array = [] + const client = { + onUpdate: ( + _query: unknown, + args: { paginationOpts: PaginationOptions }, + callback: Slot[`callback`], + ) => { + const slot: Slot = { + options: args.paginationOpts, + callback, + active: true, + } + slots.push(slot) + return Object.assign( + () => { + slot.active = false + }, + { getCurrentValue: () => slot.current }, + ) + }, + } as ConvexCollectionClient + return { + client, + slots, + publish: async (rows: Array) => { + const served = new Set() + for (let turn = 0; turn < 100; turn++) { + const pending = slots.filter((slot) => slot.active && !served.has(slot)) + if (reverse) pending.reverse() + for (const slot of pending) { + served.add(slot) + const { cursor, endCursor, numItems } = slot.options + const from = cursor === null ? -Infinity : Number(cursor) + const to = endCursor == null ? Infinity : Number(endCursor) + const interval = rows + .filter((row) => row.rank > from && row.rank <= to) + .sort((a, b) => a.rank - b.rank) + const page = + endCursor == null ? interval.slice(0, numItems) : interval + const boundary = endCursor ?? String(page.at(-1)?.rank ?? from) + const needsSplit = split !== `none` && interval.length > 2 + slot.current = { + page: needsSplit && split === `required` ? [] : page, + continueCursor: boundary, + isDone: endCursor == null && page.length === interval.length, + ...(needsSplit + ? { + pageStatus: + split === `required` + ? (`SplitRequired` as const) + : (`SplitRecommended` as const), + splitCursor: String( + interval[Math.floor((interval.length - 1) / 2)]!.rank, + ), + } + : {}), + } + slot.callback(slot.current) + } + await Promise.resolve() + if (slots.every((slot) => !slot.active || served.has(slot))) return + } + throw new Error(`Pagination did not exhaust finite fixture`) + }, + } +} + +const snapshot = fc.shuffledSubarray([0, 1, 2, 3, 4, 5, 6, 7]).chain((ids) => + fc + .array(fc.string({ maxLength: 8 }), { + minLength: ids.length, + maxLength: ids.length, + }) + .map((texts) => + ids.map((id, rank): Row => ({ + id: String(id), + rank, + text: texts[rank]!, + })), + ), +) +const history = fc.record({ + snapshots: fc.array(snapshot, { minLength: 1, maxLength: 10 }), + pageSize: fc.integer({ min: 1, max: 4 }), + reverse: fc.boolean(), + split: fc.constantFrom( + `none` as const, + `required` as const, + `recommended` as const, + ), +}) +// Calibration: disabling continuation failed at public-row comparison with +// seed 20261022/path 5:1:1:3:10:1:1:1:1:1:1:1. Ignoring SplitRequired failed +// there with path 14:1:0:2:5:1:1:2. Both reached the exhausted checkpoint; +// neither failure was a setup error or timeout. +const property = fc.asyncProperty( + history, + async ({ snapshots, pageSize, reverse, split }) => { + const source = provider(reverse, split) + const collection = createCollection( + convexPaginatedCollectionOptions({ + client: source.client, + query, + queryArgs: {}, + pageSize, + getKey: (row) => row.id, + gcTime: 0, + }), + ) + collection.startSyncImmediate() + try { + for (const rows of snapshots) { + await source.publish(rows) + await collection.utils.awaitSync() + const actual = [...collection.values()] + .map(({ id, rank, text }) => ({ id, rank, text })) + .sort((a, b) => a.id.localeCompare(b.id)) + const expected = [...rows].sort((a, b) => a.id.localeCompare(b.id)) + expect(actual).toEqual(expected) + expect(collection.status).toBe(`ready`) + } + } finally { + await collection.cleanup() + expect(source.slots.every((slot) => !slot.active)).toBe(true) + } + }, +) + +it(`refines exhausted snapshots across reactive ranges and page splits`, async () => { + const seed = process.env.CONVEX_ORACLE_SEED + const path = process.env.CONVEX_ORACLE_PATH + if (seed) await fc.assert(property, { seed: Number(seed), path, numRuns: 60 }) + else { + await fc.assert(property, { seed: 20261022, numRuns: 60 }) + await fc.assert(property, { numRuns: 60 }) + } +}) diff --git a/packages/convex-db-collection/tests/sdk.test.ts b/packages/convex-db-collection/tests/sdk.test.ts new file mode 100644 index 0000000000..d924220915 --- /dev/null +++ b/packages/convex-db-collection/tests/sdk.test.ts @@ -0,0 +1,310 @@ +import { Buffer } from 'node:buffer' +import { createCollection } from '@tanstack/db' +import { ConvexClient } from 'convex/browser' +import { ConvexReactClient } from 'convex/react' +import { makeFunctionReference } from 'convex/server' +import { convexToJson } from 'convex/values' +import { describe, expect, it, vi } from 'vitest' +import { convexCollectionOptions } from '../src/index' +import { mutation, query } from './helpers' +import type { Value } from 'convex/values' + +// Only the transport is controlled. Both clients, their caches, subscriptions, +// mutation timestamp barriers, decoding, and the DB Collection are real. +class Socket { + static instances: Array = [] + onopen: (() => void) | null = null + onmessage: ((event: { data: string }) => void) | null = null + onclose: ((event: { code: number; reason: string }) => void) | null = null + onerror: (() => void) | null = null + sent: Array> = [] + constructor(_url: string) { + Socket.instances.push(this) + } + send(data: string) { + this.sent.push(JSON.parse(data) as Record) + } + close() { + queueMicrotask(() => this.onclose?.({ code: 1000, reason: `` })) + } + receive(message: unknown) { + this.onmessage?.({ data: JSON.stringify(message) }) + } + open() { + this.onopen?.() + } +} + +function timestamp(value: number): string { + const bytes = Buffer.alloc(8) + bytes.writeBigUInt64LE(BigInt(value)) + return bytes.toString(`base64`) +} + +function server(socket: Socket) { + let time = 0 + let querySet = 0 + return { + snapshot(rows: Value) { + const changes = socket.sent.filter((msg) => msg.type === `ModifyQuerySet`) + const version = changes.at(-1)?.newVersion as number + const modifications = changes.flatMap( + (msg) => msg.modifications as Array<{ type: string; queryId: number }>, + ) + const id = modifications.find((mod) => mod.type === `Add`)!.queryId + socket.receive({ + type: `Transition`, + startVersion: { querySet, ts: timestamp(time), identity: 0 }, + endVersion: { querySet: version, ts: timestamp(++time), identity: 0 }, + modifications: [ + { + type: `QueryUpdated`, + queryId: id, + value: convexToJson(rows), + logLines: [], + journal: null, + }, + ], + }) + querySet = version + }, + acknowledge(success = true) { + const request = [...socket.sent] + .reverse() + .find((msg) => msg.type === `Mutation`)! + socket.receive( + success + ? { + type: `MutationResponse`, + requestId: request.requestId, + success: true, + result: null, + ts: timestamp(time + 1), + logLines: [], + } + : { + type: `MutationResponse`, + requestId: request.requestId, + success: false, + result: `permission denied`, + logLines: [], + }, + ) + }, + } +} + +describe.each([`javascript`, `react`] as const)( + `real Convex %s client`, + (kind) => { + function setup() { + const options = { + webSocketConstructor: Socket as unknown as typeof WebSocket, + unsavedChangesWarning: false, + logger: false as const, + } + const client = + kind === `javascript` + ? new ConvexClient(`https://test.convex.cloud`, options) + : new ConvexReactClient(`https://test.convex.cloud`, options) + const collection = createCollection( + convexCollectionOptions({ + client, + query, + queryArgs: { owner: `alice` }, + getKey: (row) => row._id, + gcTime: 0, + onUpdate: async ({ transaction }) => { + await client.mutation(mutation, { + text: transaction.mutations[0].modified.text, + }) + }, + }), + ) + collection.startSyncImmediate() + const socket = Socket.instances.at(-1)! + socket.open() + return { client, collection, socket, remote: server(socket) } + } + + it(`subscribes with generated function names and typed args`, async () => { + const { client, collection, socket, remote } = setup() + try { + const subscription = socket.sent.find( + (msg) => msg.type === `ModifyQuerySet`, + )! + expect(subscription.modifications).toMatchObject([ + { type: `Add`, udfPath: `tasks:list`, args: [{ owner: `alice` }] }, + ]) + remote.snapshot([{ _id: `a`, text: `hello` }]) + await collection.preload() + expect(collection.get(`a`)?.text).toBe(`hello`) + remote.snapshot([]) + expect(collection.size).toBe(0) + await collection.cleanup() + expect(socket.sent.at(-1)).toMatchObject({ + type: `ModifyQuerySet`, + modifications: [{ type: `Remove` }], + }) + } finally { + await collection.cleanup() + await client.close() + } + }) + + it(`waits for a query transition past the mutation timestamp before settling`, async () => { + const { client, collection, remote } = setup() + try { + remote.snapshot([{ _id: `a`, text: `old` }]) + await collection.preload() + const seen: Array = [] + const subscription = collection.subscribeChanges(() => { + seen.push(collection.get(`a`)?.text) + }) + const transaction = collection.update(`a`, (row) => { + row.text = `optimistic` + }) + remote.acknowledge() + await Promise.resolve() + expect(transaction.state).toBe(`persisting`) + expect(collection.get(`a`)?.text).toBe(`optimistic`) + remote.snapshot([{ _id: `a`, text: `server` }]) + await transaction.isPersisted.promise + expect(collection.get(`a`)?.text).toBe(`server`) + expect(seen).not.toContain(`old`) + subscription.unsubscribe() + } finally { + await collection.cleanup() + await client.close() + } + }) + + it(`resubscribes and replays an in-flight mutation after reconnect`, async () => { + vi.useFakeTimers() + const { client, collection, socket, remote } = setup() + try { + remote.snapshot([{ _id: `a`, text: `old` }]) + await collection.preload() + const transaction = collection.update(`a`, (row) => { + row.text = `optimistic` + }) + socket.onclose?.({ code: 1006, reason: `network lost` }) + await vi.advanceTimersByTimeAsync(3000) + const replacement = Socket.instances.at(-1)! + expect(replacement).not.toBe(socket) + replacement.open() + expect( + replacement.sent.some((message) => message.type === `Mutation`), + ).toBe(true) + expect(collection.get(`a`)?.text).toBe(`optimistic`) + const nextRemote = server(replacement) + nextRemote.acknowledge() + nextRemote.snapshot([{ _id: `a`, text: `reconnected` }]) + await transaction.isPersisted.promise + expect(collection.get(`a`)?.text).toBe(`reconnected`) + } finally { + await collection.cleanup() + // close() uses a microtask, which fake timers otherwise hold. + const closed = client.close() + await vi.runAllTimersAsync() + await closed + vi.useRealTimers() + } + }) + + it(`rolls back on an actual Convex mutation failure`, async () => { + const { client, collection, remote } = setup() + try { + remote.snapshot([{ _id: `a`, text: `old` }]) + await collection.preload() + const transaction = collection.update(`a`, (row) => { + row.text = `optimistic` + }) + remote.acknowledge(false) + await expect(transaction.isPersisted.promise).rejects.toThrow( + `permission denied`, + ) + expect(collection.get(`a`)?.text).toBe(`old`) + } finally { + await collection.cleanup() + await client.close() + } + }) + }, +) + +it(`preserves Convex integers, special floats, nested values, and bytes`, async () => { + const client = new ConvexClient(`https://test.convex.cloud`, { + webSocketConstructor: Socket as unknown as typeof WebSocket, + unsavedChangesWarning: false, + logger: false, + }) + const valuesQuery = makeFunctionReference< + `query`, + {}, + Array<{ _id: string; payload: Value }> + >(`values:list`) + const collection = createCollection( + convexCollectionOptions({ + client, + query: valuesQuery, + queryArgs: {}, + getKey: (row) => row._id, + gcTime: 0, + }), + ) + collection.startSyncImmediate() + const socket = Socket.instances.at(-1)! + socket.open() + const remote = server(socket) + try { + const values: Array = [ + 9223372036854775807n, + NaN, + Infinity, + -Infinity, + -0, + { nested: [null, true, `hello`, { key: `value` }] }, + new Uint8Array([1, 2]).buffer, + ] + for (const payload of values) { + remote.snapshot([{ _id: `a`, payload }]) + await collection.preload() + const actual = collection.get(`a`)!.payload + if (payload instanceof ArrayBuffer) + expect([...new Uint8Array(actual as ArrayBuffer)]).toEqual([ + ...new Uint8Array(payload), + ]) + else expect(actual).toEqual(payload) + } + const changed = vi.fn() + const listener = collection.subscribeChanges(changed) + remote.snapshot([{ _id: `a`, payload: new Uint8Array([3, 4]).buffer }]) + expect(changed).toHaveBeenCalledOnce() + listener.unsubscribe() + expect([ + ...new Uint8Array(collection.get(`a`)!.payload as ArrayBuffer), + ]).toEqual([3, 4]) + } finally { + await collection.cleanup() + await client.close() + } +}) + +it(`rejects preload for a disabled JavaScript client`, async () => { + const client = new ConvexClient(`https://test.convex.cloud`, { + disabled: true, + }) + const collection = createCollection( + convexCollectionOptions({ + client, + query, + queryArgs: { owner: `alice` }, + getKey: (row) => row._id, + gcTime: 0, + }), + ) + await expect(collection.preload()).rejects.toThrow(`disabled Convex client`) + await collection.cleanup() + await client.close() +}) diff --git a/packages/convex-db-collection/tests/snapshot-oracle.property.test.ts b/packages/convex-db-collection/tests/snapshot-oracle.property.test.ts new file mode 100644 index 0000000000..af7076effd --- /dev/null +++ b/packages/convex-db-collection/tests/snapshot-oracle.property.test.ts @@ -0,0 +1,90 @@ +/** + * A Collection mirrors the complete keyed rows of its latest valid Convex + * query result, as promised by convexCollectionOptions. An omitted key is a + * deletion, and an omitted field is absent, rather than retained from an older + * row. Duplicate keys reject the whole result and leave the last valid result. + * + * The reference is simply the last valid input array. It never computes sync + * operations or uses the adapter's key map. Generated histories contain empty, + * reordered, changed, removed, reintroduced, and duplicate-key snapshots. + * The driver invokes the real Collection through a controlled Convex callback. + * After each synchronous delivery, public rows and the error flag are compared. + * This owner covers snapshot refinement only. SDK transport, mutation timing, + * errors, and cleanup have separate focused receiving tests in this package. + */ +import { isDeepStrictEqual } from 'node:util' +import { createCollection } from '@tanstack/db' +import { fc } from '@fast-check/vitest' +import { expect, it } from 'vitest' +import { convexCollectionOptions } from '../src/index' +import { controlledClient, query } from './helpers' +import type { Row } from './helpers' + +const row = fc + .record({ + _id: fc.constantFrom(`a`, `b`, `c`), + text: fc.string({ maxLength: 12 }), + optional: fc.option(fc.string({ maxLength: 6 }), { nil: undefined }), + }) + .map((value): Row => + value.optional === undefined ? { _id: value._id, text: value.text } : value, + ) + +it(`refines complete snapshots throughout generated histories`, async () => { + const property = fc.asyncProperty( + fc.array(fc.array(row, { maxLength: 5 }), { + minLength: 1, + maxLength: 30, + }), + async (history) => { + const transport = controlledClient() + const collection = createCollection( + convexCollectionOptions({ + client: transport.client, + query, + queryArgs: { owner: `alice` }, + getKey: (value) => value._id, + gcTime: 0, + }), + ) + collection.startSyncImmediate() + let expected: Array = [] + try { + for (const snapshot of history) { + // Independent validity judgment: compare every pair of input keys. + const valid = snapshot.every((value, index) => + snapshot.slice(index + 1).every((other) => other._id !== value._id), + ) + if (valid) expected = snapshot + transport.emit(snapshot) + expect(collection.utils.getSyncError() !== undefined).toBe(!valid) + const actual = [...collection.values()] + .map((value): Row => { + const result: Row = { _id: value._id, text: value.text } + if (Object.hasOwn(value, `optional`)) + result.optional = value.optional + return result + }) + .sort((a, b) => a._id.localeCompare(b._id)) + const wanted = [...expected].sort((a, b) => + a._id.localeCompare(b._id), + ) + expect(isDeepStrictEqual(actual, wanted)).toBe(true) + } + } finally { + await collection.cleanup() + } + }, + ) + const seed = process.env.CONVEX_SNAPSHOT_ORACLE_SEED + if (seed) + await fc.assert(property, { + seed: Number(seed), + path: process.env.CONVEX_SNAPSHOT_ORACLE_PATH, + numRuns: 150, + }) + else { + await fc.assert(property, { numRuns: 150, seed: 20261022 }) + await fc.assert(property, { numRuns: 150 }) + } +}) diff --git a/packages/convex-db-collection/tests/types.test-d.ts b/packages/convex-db-collection/tests/types.test-d.ts new file mode 100644 index 0000000000..98c7258abd --- /dev/null +++ b/packages/convex-db-collection/tests/types.test-d.ts @@ -0,0 +1,154 @@ +import { createCollection } from '@tanstack/db' +import { makeFunctionReference } from 'convex/server' +import { expectTypeOf, it } from 'vitest' +import { z } from 'zod' +import { convexOfflineMutation } from '../src/offline' +import { + convexCollectionOptions, + convexPaginatedCollectionOptions, +} from '../src/index' +import { query } from './helpers' +import type { PaginationOptions, PaginationResult } from 'convex/server' +import type { ConvexReactClient } from 'convex/react' +import type { ConvexClient } from 'convex/browser' +import type { Row } from './helpers' + +it(`infers rows, arguments, keys, projections, and schemas`, () => { + const client = {} as ConvexClient + const reactClient = {} as ConvexReactClient + const options = convexCollectionOptions({ + client, + query, + queryArgs: { owner: `alice` }, + getKey: (row) => row._id, + onUpdate: ({ transaction, collection }) => { + expectTypeOf(transaction.mutations[0].modified).toEqualTypeOf() + expectTypeOf(collection.utils.getSyncError()).toEqualTypeOf() + return Promise.resolve() + }, + }) + const collection = createCollection(options) + expectTypeOf(collection.get(`id`)!.text).toEqualTypeOf() + convexCollectionOptions({ + client, + query, + // @ts-expect-error Query arguments are inferred from the function reference. + queryArgs: { owner: 1 }, + getKey: (row: Row) => row._id, + }) + // @ts-expect-error Required query arguments cannot be omitted. + convexCollectionOptions({ client, query, getKey: (row: Row) => row._id }) + convexCollectionOptions({ + client, + query, + queryArgs: { owner: `alice` }, + // @ts-expect-error A misspelled document field cannot be used as a key. + getKey: (row) => row.missing, + }) + + const envelope = makeFunctionReference<`query`, {}, { rows: Array }>( + `tasks:envelope`, + ) + const selected = convexCollectionOptions({ + client: reactClient, + query: envelope, + queryArgs: {}, + select: (result) => + result.rows.map((row) => ({ id: row._id, length: row.text.length })), + getKey: (row) => row.id, + }) + expectTypeOf( + createCollection(selected).get(`id`)!.length, + ).toEqualTypeOf() + // @ts-expect-error Non-array query results need a projection. + convexCollectionOptions({ + client, + query: envelope, + queryArgs: {}, + getKey: (row: Row) => row._id, + }) + + const schema = z.object({ + _id: z.string(), + text: z.string(), + optional: z.string().optional(), + }) + const validated = createCollection( + convexCollectionOptions({ + client, + query, + queryArgs: { owner: `alice` }, + schema, + getKey: (row) => row._id, + }), + ) + expectTypeOf(validated.get(`id`)!.text).toEqualTypeOf() +}) + +it(`infers paginated arguments and projected row types`, () => { + const paginatedQuery = makeFunctionReference< + `query`, + { owner: string; paginationOpts: PaginationOptions }, + PaginationResult + >(`tasks:pages`) + const client = {} as ConvexClient + const options = convexPaginatedCollectionOptions({ + client, + query: paginatedQuery, + queryArgs: { owner: `alice` }, + select: (rows) => + rows.map((row) => ({ id: row._id, length: row.text.length })), + getKey: (row) => row.id, + }) + expectTypeOf( + createCollection(options).get(`a`)!.length, + ).toEqualTypeOf() + convexPaginatedCollectionOptions({ + client, + query: paginatedQuery, + queryArgs: { + owner: `alice`, + // @ts-expect-error The adapter supplies paginationOpts, callers do not supply a cursor. + paginationOpts: { cursor: null, numItems: 4 }, + }, + getKey: (row: Row) => row._id, + }) + const invalid = makeFunctionReference<`mutation`, { text: string }, null>( + `tasks:invalid`, + ) + // @ts-expect-error Durable mutations must declare an idempotencyKey argument. + convexOfflineMutation({ + client, + mutation: invalid, + getArgs: () => ({ text: `hello` }), + collections: [], + }) +}) + +it(`requires a projection for changed paginated row types and server pagination arguments`, () => { + const client = {} as ConvexClient + const paginated = makeFunctionReference< + `query`, + { paginationOpts: PaginationOptions }, + PaginationResult + >(`tasks:pages`) + // @ts-expect-error A changed row type needs a select projection. + convexPaginatedCollectionOptions({ + client, + query: paginated, + queryArgs: {}, + getKey: (row: { id: number }) => row.id, + }) + const missingArguments = makeFunctionReference< + `query`, + {}, + PaginationResult + >(`tasks:invalidPages`) + // @ts-expect-error A paginated server query must accept paginationOpts. + convexPaginatedCollectionOptions({ + client, + query: missingArguments, + queryArgs: {}, + getKey: (row: Row) => row._id, + }) +}) diff --git a/packages/convex-db-collection/tests/values-oracle.property.test.ts b/packages/convex-db-collection/tests/values-oracle.property.test.ts new file mode 100644 index 0000000000..270e924a56 --- /dev/null +++ b/packages/convex-db-collection/tests/values-oracle.property.test.ts @@ -0,0 +1,158 @@ +/** + * Convex values must keep their meaning through the durable outbox, including + * integers, binary data, special floats, and removed (undefined) fields. The + * reference is the authored native tree. An observation projection labels + * scalar types and bytes, so JSON normalization cannot pass as equality. + * + * Bounded trees combine native leaves, arrays, ordinary fields, and marker-like + * user data. A real OutboxManager writes each transaction, then a fresh manager + * and Collection restore it with convexMutationCodec. Comparison happens on all + * three restored mutation payloads, before any provider call. The core serializer + * owner covers wire dispatch and unsupported codecs; this receives that contract + * with Convex's actual public scalar encoding. The backend process oracle then + * receives these values on disk and across killed writer/replay processes. + */ +import fc from 'fast-check' +import { createCollection } from '@tanstack/db' +import { OutboxManager } from '@tanstack/offline-transactions' +import { expect, it } from 'vitest' +import { convexMutationCodec } from '../src/offline' +import type { PendingMutation } from '@tanstack/db' +import type { + OfflineTransaction, + StorageAdapter, +} from '@tanstack/offline-transactions' + +type Tree = + | undefined + | null + | boolean + | number + | bigint + | string + | ArrayBuffer + | Array + | { [key: string]: Tree } +const leaf: fc.Arbitrary = fc.oneof( + fc.constant(undefined), + fc.constant(null), + fc.boolean(), + fc.string({ maxLength: 8 }), + fc.double(), + fc.bigInt({ min: -(2n ** 63n), max: 2n ** 63n - 1n }), + fc.uint8Array({ maxLength: 8 }).map((value) => value.buffer as ArrayBuffer), +) +function tree(depth: number): fc.Arbitrary { + if (!depth) return leaf + const child = tree(depth - 1) + return fc.oneof( + leaf, + fc.array(child, { maxLength: 3 }), + fc.dictionary( + fc.constantFrom( + `__proto__`, + `__type`, + `value`, + `array`, + `scalar`, + `removed`, + ), + child, + { maxKeys: 3 }, + ), + ) +} +function observe(value: unknown): unknown { + if (value instanceof ArrayBuffer) return [`bytes`, [...new Uint8Array(value)]] + if (Array.isArray(value)) return [`array`, value.map(observe)] + if (value && typeof value === `object`) + return [ + `object`, + Object.entries(value) + .map(([key, field]) => [key, observe(field)]) + .sort((a, b) => String(a[0]).localeCompare(String(b[0]))), + ] + if (typeof value === `number`) + return [`number`, Object.is(value, -0) ? `-0` : String(value)] + return [typeof value, String(value)] +} +const property = fc.asyncProperty(tree(2), async (payload) => { + type Row = { id: string; payload: Tree } + const make = (id: string) => + createCollection({ + id, + getKey: (row) => row.id, + sync: { sync: ({ markReady }) => markReady() }, + }) + const writer = make(`writer`) + const reader = make(`reader`) + const data = new Map() + const storage: StorageAdapter = { + get: (key) => Promise.resolve(data.get(key) ?? null), + set: (key, value) => { + data.set(key, value) + return Promise.resolve() + }, + delete: (key) => { + data.delete(key) + return Promise.resolve() + }, + keys: () => Promise.resolve([...data.keys()]), + clear: () => { + data.clear() + return Promise.resolve() + }, + } + const row: Row = { id: `one`, payload } + const transaction: OfflineTransaction = { + id: `durable`, + mutationFnName: `save`, + idempotencyKey: `same`, + createdAt: new Date(0), + retryCount: 0, + nextAttemptAt: 0, + version: 1, + keys: [`one`], + mutations: [ + { + globalKey: `one`, + type: `update`, + modified: row, + original: row, + changes: { payload }, + collection: writer, + } as unknown as PendingMutation, + ], + } + try { + await new OutboxManager(storage, { rows: writer }, convexMutationCodec).add( + transaction, + ) + const restored = await new OutboxManager( + storage, + { rows: reader }, + convexMutationCodec, + ).get(`durable`) + expect(observe(restored!.mutations[0]!.modified)).toEqual(observe(row)) + expect(observe(restored!.mutations[0]!.original)).toEqual(observe(row)) + expect(observe(restored!.mutations[0]!.changes)).toEqual( + observe({ payload }), + ) + } finally { + await writer.cleanup() + await reader.cleanup() + } +}) +it(`preserves the Convex mutation value domain across durable serialization`, async () => { + const seed = process.env.CONVEX_VALUES_ORACLE_SEED + if (seed) + await fc.assert(property, { + seed: Number(seed), + path: process.env.CONVEX_VALUES_ORACLE_PATH, + numRuns: 100, + }) + else { + await fc.assert(property, { seed: 20261022, numRuns: 100 }) + await fc.assert(property, { numRuns: 100 }) + } +}) diff --git a/packages/convex-db-collection/tsconfig.docs.json b/packages/convex-db-collection/tsconfig.docs.json new file mode 100644 index 0000000000..b90fc83e04 --- /dev/null +++ b/packages/convex-db-collection/tsconfig.docs.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "include": ["src"] +} diff --git a/packages/convex-db-collection/tsconfig.json b/packages/convex-db-collection/tsconfig.json new file mode 100644 index 0000000000..48460839d3 --- /dev/null +++ b/packages/convex-db-collection/tsconfig.json @@ -0,0 +1,27 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "Bundler", + "declaration": true, + "outDir": "dist", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "jsx": "react", + "paths": { + "@tanstack/db": ["../db/src"], + "@tanstack/db-ivm": ["../db-ivm/src"] + } + }, + "include": [ + "src", + "tests", + "e2e/*.ts", + "vite.config.ts", + "vitest.e2e.config.ts" + ], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/convex-db-collection/vite.config.ts b/packages/convex-db-collection/vite.config.ts new file mode 100644 index 0000000000..abd0709e33 --- /dev/null +++ b/packages/convex-db-collection/vite.config.ts @@ -0,0 +1,23 @@ +import { defineConfig, mergeConfig } from 'vitest/config' +import { tanstackViteConfig } from '@tanstack/vite-config' +import { commonJsDeclarations } from '../../scripts/commonjs-declarations.mjs' +import packageJson from './package.json' + +const config = defineConfig({ + test: { + name: packageJson.name, + include: [`tests/**/*.test.ts`], + environment: `jsdom`, + coverage: { enabled: true, provider: `istanbul`, include: [`src/**/*`] }, + typecheck: { enabled: true, include: [`tests/**/*.test-d.ts`] }, + }, +}) + +export default mergeConfig( + config, + tanstackViteConfig({ + entry: [`./src/index.ts`, `./src/offline.ts`], + srcDir: `./src`, + beforeWriteDeclarationFile: commonJsDeclarations, + }), +) diff --git a/packages/convex-db-collection/vitest.e2e.config.ts b/packages/convex-db-collection/vitest.e2e.config.ts new file mode 100644 index 0000000000..c998b8ee20 --- /dev/null +++ b/packages/convex-db-collection/vitest.e2e.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + globalSetup: [`./e2e/setup.ts`], + include: [`e2e/**/*.test.ts`], + environment: `node`, + testTimeout: 30000, + hookTimeout: 30000, + fileParallelism: false, + }, +}) diff --git a/packages/offline-transactions/src/OfflineExecutor.ts b/packages/offline-transactions/src/OfflineExecutor.ts index 5690d257a3..a2d6d712e3 100644 --- a/packages/offline-transactions/src/OfflineExecutor.ts +++ b/packages/offline-transactions/src/OfflineExecutor.ts @@ -299,7 +299,11 @@ export class OfflineExecutor { } // Storage available - set up offline components - this.outbox = new OutboxManager(storage, this.config.collections) + this.outbox = new OutboxManager( + storage, + this.config.collections, + this.config.mutationCodec, + ) this.executor = new TransactionExecutor( this.scheduler, this.outbox, diff --git a/packages/offline-transactions/src/index.ts b/packages/offline-transactions/src/index.ts index f43f4633a1..0d1f114498 100644 --- a/packages/offline-transactions/src/index.ts +++ b/packages/offline-transactions/src/index.ts @@ -5,6 +5,7 @@ export { OfflineExecutor, startOfflineExecutor } from './OfflineExecutor' export type { OfflineTransaction, OfflineConfig, + MutationValueCodec, OfflineMode, StorageAdapter, StorageDiagnostic, diff --git a/packages/offline-transactions/src/outbox/OutboxManager.ts b/packages/offline-transactions/src/outbox/OutboxManager.ts index 9b862a7785..ade147717a 100644 --- a/packages/offline-transactions/src/outbox/OutboxManager.ts +++ b/packages/offline-transactions/src/outbox/OutboxManager.ts @@ -1,8 +1,13 @@ import { MissingTemporalConstructorError, + MutationCodecError, TransactionSerializer, } from './TransactionSerializer' -import type { OfflineTransaction, StorageAdapter } from '../types' +import type { + MutationValueCodec, + OfflineTransaction, + StorageAdapter, +} from '../types' import type { Collection } from '@tanstack/db' export class OutboxTransactionNotFoundError extends Error {} @@ -19,9 +24,10 @@ export class OutboxManager { storage: StorageAdapter, collections: Record>, + mutationCodec?: MutationValueCodec, ) { this.storage = storage - this.serializer = new TransactionSerializer(collections) + this.serializer = new TransactionSerializer(collections, mutationCodec) } private getStorageKey(id: string): string { @@ -61,7 +67,10 @@ export class OutboxManager { try { return this.serializer.deserialize(data) } catch (error) { - if (error instanceof MissingTemporalConstructorError) { + if ( + error instanceof MissingTemporalConstructorError || + error instanceof MutationCodecError + ) { error.message = `transaction ${id}: ${error.message}` throw error } @@ -94,7 +103,10 @@ export class OutboxManager { const transaction = this.serializer.deserialize(data) transactions.push(transaction) } catch (error) { - if (error instanceof MissingTemporalConstructorError) { + if ( + error instanceof MissingTemporalConstructorError || + error instanceof MutationCodecError + ) { error.message = `transaction ${key.slice(this.keyPrefix.length)}: ${error.message}` throw error } diff --git a/packages/offline-transactions/src/outbox/TransactionSerializer.ts b/packages/offline-transactions/src/outbox/TransactionSerializer.ts index bb9fbd3c16..3017ec2578 100644 --- a/packages/offline-transactions/src/outbox/TransactionSerializer.ts +++ b/packages/offline-transactions/src/outbox/TransactionSerializer.ts @@ -1,4 +1,5 @@ import type { + MutationValueCodec, OfflineTransaction, SerializedError, SerializedMutation, @@ -79,6 +80,8 @@ function mayBeNativeScalar(value: object): boolean { export class MissingTemporalConstructorError extends Error {} +export class MutationCodecError extends Error {} + function setDataProperty( object: Record, key: string, @@ -111,6 +114,7 @@ export class TransactionSerializer { constructor( collections: Record>, + private readonly mutationCodec?: MutationValueCodec, ) { this.collections = collections // Create reverse lookup from collection.id to registry key @@ -124,7 +128,10 @@ export class TransactionSerializer { assertOutboxPhase(transaction.outboxPhase) const serialized: SerializedOfflineTransaction = { ...transaction, - valueEncoding: 3, + valueEncoding: this.mutationCodec ? 4 : 3, + ...(this.mutationCodec + ? { mutationEncoding: this.mutationCodec.id } + : {}), createdAt: transaction.createdAt.toISOString(), metadata: this.serializeValue(transaction.metadata, `metadata`), mutations: transaction.mutations.map((mutation) => @@ -139,21 +146,33 @@ export class TransactionSerializer { // marker-shaped user objects; the encoding tag is not application metadata. const { valueEncoding, + mutationEncoding, outboxPhase, ...parsed - }: Omit & { + }: Omit< + SerializedOfflineTransaction, + `valueEncoding` | `mutationEncoding` | `outboxPhase` + > & { valueEncoding?: unknown + mutationEncoding?: unknown outboxPhase?: unknown } = JSON.parse(data) if ( valueEncoding !== undefined && valueEncoding !== 2 && - valueEncoding !== 3 + valueEncoding !== 3 && + valueEncoding !== 4 ) { throw new Error( `Unsupported transaction value encoding: ${valueEncoding}`, ) } + if ( + valueEncoding === 4 && + (!this.mutationCodec || mutationEncoding !== this.mutationCodec.id) + ) { + throw new MutationCodecError(`Unsupported mutation codec`) + } assertOutboxPhase(outboxPhase) const createdAt = new Date(parsed.createdAt) @@ -168,8 +187,8 @@ export class TransactionSerializer { ...(outboxPhase === undefined ? {} : { outboxPhase }), createdAt, metadata: - valueEncoding === 3 - ? this.deserializeValue(parsed.metadata, valueEncoding) + valueEncoding === 3 || valueEncoding === 4 + ? this.deserializeValue(parsed.metadata, 3) : parsed.metadata, mutations: parsed.mutations.map((mutationData) => this.deserializeMutation(mutationData, valueEncoding), @@ -188,23 +207,44 @@ export class TransactionSerializer { return { globalKey: mutation.globalKey, type: mutation.type, - modified: this.serializeValue(mutation.modified), - original: this.serializeValue(mutation.original), - changes: this.serializeValue(mutation.changes), + modified: this.encodeMutationValue(mutation.modified), + original: this.encodeMutationValue(mutation.original), + changes: this.encodeMutationValue(mutation.changes), collectionId: registryKey, // Store registry key instead of collection.id } } + private encodeMutationValue(value: unknown): unknown { + if (!this.mutationCodec) return this.serializeValue(value) + const encoded = this.mutationCodec.encode(value) + if (typeof encoded !== `string`) + throw new MutationCodecError(`Mutation codec encode must return a string`) + return encoded + } + private deserializeMutation( data: SerializedMutation, - valueEncoding: 2 | 3 | undefined, + valueEncoding: 2 | 3 | 4 | undefined, ): PendingMutation { const collection = this.collections[data.collectionId] if (!collection) { throw new Error(`Collection with id ${data.collectionId} not found`) } - const modified = this.deserializeValue(data.modified, valueEncoding) + const decode = (value: unknown): unknown => { + if (valueEncoding !== 4) + return this.deserializeValue(value, valueEncoding) + try { + if (typeof value !== `string`) + throw new TypeError(`Expected a mutation codec string`) + return this.mutationCodec!.decode(value) + } catch (cause) { + throw new MutationCodecError(`Failed to decode mutation codec`, { + cause, + }) + } + } + const modified = decode(data.modified) // Extract the key from the modified data using the collection's getKey function // This is needed for optimistic state restoration to work correctly @@ -216,8 +256,8 @@ export class TransactionSerializer { globalKey: data.globalKey, type: data.type as any, modified, - original: this.deserializeValue(data.original, valueEncoding), - changes: this.deserializeValue(data.changes, valueEncoding) ?? {}, + original: decode(data.original), + changes: decode(data.changes) ?? {}, collection, // These fields would need to be reconstructed by the executor mutationId: ``, // Will be regenerated diff --git a/packages/offline-transactions/src/react-native/index.ts b/packages/offline-transactions/src/react-native/index.ts index 977b1e93f5..ed63ca370d 100644 --- a/packages/offline-transactions/src/react-native/index.ts +++ b/packages/offline-transactions/src/react-native/index.ts @@ -3,6 +3,7 @@ export { // Types type OfflineTransaction, type OfflineConfig, + type MutationValueCodec, type OfflineMode, type StorageAdapter, type StorageDiagnostic, diff --git a/packages/offline-transactions/src/types.ts b/packages/offline-transactions/src/types.ts index 3101328496..15f625ae83 100644 --- a/packages/offline-transactions/src/types.ts +++ b/packages/offline-transactions/src/types.ts @@ -60,7 +60,9 @@ export interface OfflineTransaction { // Serialized representation for storage export interface SerializedOfflineTransaction { /** Absent for the original Date-marker format. */ - valueEncoding?: 2 | 3 + valueEncoding?: 2 | 3 | 4 + /** Required for opt-in mutation encoding v4. */ + mutationEncoding?: string id: string mutationFnName: string mutations: Array @@ -95,7 +97,16 @@ export interface StorageDiagnostic { error?: Error } +/** Encodes mutation payloads into JSON-safe values, and restores them on replay. */ +export interface MutationValueCodec { + /** Stable format/version identifier, retained in every encoded transaction. */ + id: string + encode: (value: unknown) => string + decode: (value: string) => unknown +} + export interface OfflineConfig { + mutationCodec?: MutationValueCodec collections: Record> mutationFns: Record storage?: StorageAdapter 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 08e12dc5ed..37ed2d5c9c 100644 --- a/packages/offline-transactions/tests/transaction-serializer-oracle.property.test.ts +++ b/packages/offline-transactions/tests/transaction-serializer-oracle.property.test.ts @@ -18,6 +18,8 @@ import type { PendingMutation } from '@tanstack/db' * Contract and source: SerializedOfflineTransaction and the offline persistence * contract preserve mutation meaning across process boundaries. Encoding v3 * supports JSON trees, Date, and the eight restorable Temporal scalar types. + * Opt-in v4 records delegate mutation payloads to their identified codec. + * Default v3 metadata and mutation behavior stays unchanged. * Marker-shaped user objects remain data. Unversioned and v2 records retain * their prior meanings. Unsupported or malformed encodings fail visibly. * @@ -291,7 +293,7 @@ async function checkRoundtrip( if (fault === `omit-changes`) encoded = encoded.replaceAll(`"changes":`, `"lostChanges":`) if (fault === `unknown-encoding`) - encoded = encoded.replace(`"valueEncoding":3`, `"valueEncoding":4`) + encoded = encoded.replace(`"valueEncoding":3`, `"valueEncoding":5`) return encoded } const serialized = serializer.serialize(offline) @@ -1580,7 +1582,7 @@ it.each( message: fault === `wrong-registry` ? `Collection with id writer:0 not found` - : `Unsupported transaction value encoding: 4`, + : `Unsupported transaction value encoding: 5`, } await expect( checkRoundtrip(pinned, 0, fault, boundary), @@ -1591,3 +1593,158 @@ it.each( } }, ) + +/** + * Opt-in mutation codecs own their mutation-value domain independently of the + * default JSON/Date/Temporal encoding above. The wire records the codec ID and + * a new version so a reader cannot mistake opaque data for ordinary rows. + * Missing, mismatched, or failing codecs reject reads without changing storage. + * + * The supplied fixture codec uses Node's V8 structured serialization. Expected + * runtime values come directly from generated native leaves, not a reimplementation + * of production dispatch or codec output. Histories cross insert/update/delete, + * native values nested in all three mutation payloads, fresh readers, and + * unsupported reader configurations. Existing v3 histories remain unchanged. + * The Convex receiving witness supplies its real public value codec and tests + * disk/process replay against the actual backend. + */ +it(`preserves opt-in mutation values and rejects unreadable codec records without losing work`, async () => { + const { serialize, deserialize } = await import('node:v8') + const codec = { + id: `v8-test/1`, + encode: (value: unknown) => serialize(value).toString(`base64`), + decode: (value: unknown) => + deserialize(Buffer.from(value as string, `base64`)) as unknown, + } + const native = fc.oneof( + fc.bigInt({ min: -(2n ** 63n), max: 2n ** 63n - 1n }), + fc.constantFrom(NaN, Infinity, -Infinity, -0), + fc.uint8Array({ maxLength: 12 }).map((bytes) => bytes.buffer), + ) + const property = fc.asyncProperty( + fc.array(native, { minLength: 1, maxLength: 5 }), + fc.constantFrom(`insert`, `update`, `delete`), + async (values, kind) => { + type CodecRow = { id: string; values: Array } + const makeCollection = (id: string) => + createCollection({ + id, + getKey: (row) => row.id, + sync: { sync: ({ markReady }) => markReady() }, + }) + const writer = makeCollection(`codec-writer`) + const reader = makeCollection(`codec-reader`) + const row: CodecRow = { id: `one`, values } + const transaction: OfflineTransaction = { + id: `codec-transaction`, + mutationFnName: `save`, + keys: [`one`], + idempotencyKey: `stable`, + createdAt: new Date(0), + retryCount: 0, + nextAttemptAt: 0, + version: 1, + metadata: { when: new Date(0) }, + mutations: [ + { + globalKey: `one`, + type: kind, + modified: row, + original: row, + changes: { values }, + collection: writer, + } as unknown as PendingMutation, + ], + } + const storage = new FakeStorageAdapter() + try { + await new OutboxManager(storage, { rows: writer }, codec).add( + transaction, + ) + const before = storage.snapshot() + const restored = await new OutboxManager( + storage, + { rows: reader }, + codec, + ).get(`codec-transaction`) + expect(restored?.mutations[0]?.modified).toEqual(row) + expect(restored?.mutations[0]?.original).toEqual(row) + expect(restored?.mutations[0]?.changes).toEqual({ values }) + expect(restored?.mutations[0]?.collection).toBe(reader) + expect(restored?.metadata).toEqual({ when: new Date(0) }) + const invalidEncoder = { + ...codec, + encode: () => null, + } as unknown as typeof codec + await expect( + new OutboxManager(storage, { rows: writer }, invalidEncoder).add( + transaction, + ), + ).rejects.toThrow(`codec`) + expect(storage.snapshot()).toEqual(before) + const wire = JSON.parse(Object.values(before)[0]!) + expect(wire.valueEncoding).toBe(4) + expect(wire.mutationEncoding).toBe(codec.id) + for (const unsupported of [ + undefined, + { ...codec, id: `other` }, + { + ...codec, + decode: () => { + throw new Error(`broken codec`) + }, + }, + ]) { + const outbox = new OutboxManager( + storage, + { rows: reader }, + unsupported, + ) + await expect(outbox.get(`codec-transaction`)).rejects.toThrow(`codec`) + await expect(outbox.getAll()).rejects.toThrow(`codec`) + expect(storage.snapshot()).toEqual(before) + } + const plain = { id: `one`, values: [] } + const legacy = { + ...transaction, + id: `legacy-transaction`, + mutations: [ + { + ...transaction.mutations[0]!, + modified: plain, + original: plain, + changes: { values: [] }, + }, + ], + } + await new OutboxManager(storage, { rows: writer }).add(legacy) + const mixed = await new OutboxManager( + storage, + { rows: reader }, + codec, + ).getAll() + expect(mixed).toHaveLength(2) + expect( + mixed.find((entry) => entry.id === legacy.id)?.mutations[0]?.modified, + ).toEqual(plain) + } finally { + await writer.cleanup() + await reader.cleanup() + } + }, + ) + const config = readOfflineOracleConfig({ + prefix: `OFFLINE_ORACLE`, + defaultRuns: 60, + }) + if (config.seed !== undefined) + await fc.assert(property, { + numRuns: config.runs, + seed: config.seed, + path: config.path, + }) + else { + await fc.assert(property, { numRuns: config.runs, seed: 20261022 }) + await fc.assert(property, { numRuns: config.runs }) + } +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 54ebf3f39e..30961f1c77 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1205,6 +1205,34 @@ importers: specifier: ^4.64.0 version: 4.76.0 + packages/convex-db-collection: + dependencies: + '@standard-schema/spec': + specifier: ^1.1.0 + version: 1.1.0 + '@tanstack/db': + specifier: workspace:* + version: link:../db + '@tanstack/offline-transactions': + specifier: workspace:* + version: link:../offline-transactions + typescript: + specifier: '>=4.7' + version: 5.9.3 + devDependencies: + '@vitest/coverage-istanbul': + specifier: ^3.2.4 + version: 3.2.4(vitest@3.2.4) + convex: + specifier: ^1.47.0 + version: 1.47.0(react@19.2.4) + + packages/convex-db-collection/e2e/backend: + dependencies: + convex: + specifier: ^1.47.0 + version: 1.47.0(react@19.2.4) + packages/db: dependencies: '@standard-schema/spec': @@ -2846,6 +2874,12 @@ packages: cpu: [ppc64] os: [aix] + '@esbuild/aix-ppc64@0.27.0': + resolution: {integrity: sha512-KuZrd2hRjz01y5JK9mEBSD3Vj3mbCvemhT466rSuJYeE/hjuBrHfjjcjMdTm/sz7au+++sdbJZJmuBwQLuw68A==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/aix-ppc64@0.27.3': resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==} engines: {node: '>=18'} @@ -2876,6 +2910,12 @@ packages: cpu: [arm64] os: [android] + '@esbuild/android-arm64@0.27.0': + resolution: {integrity: sha512-CC3vt4+1xZrs97/PKDkl0yN7w8edvU2vZvAFGD16n9F0Cvniy5qvzRXjfO1l94efczkkQE6g1x0i73Qf5uthOQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm64@0.27.3': resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==} engines: {node: '>=18'} @@ -2906,6 +2946,12 @@ packages: cpu: [arm] os: [android] + '@esbuild/android-arm@0.27.0': + resolution: {integrity: sha512-j67aezrPNYWJEOHUNLPj9maeJte7uSMM6gMoxfPC9hOg8N02JuQi/T7ewumf4tNvJadFkvLZMlAq73b9uwdMyQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-arm@0.27.3': resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==} engines: {node: '>=18'} @@ -2936,6 +2982,12 @@ packages: cpu: [x64] os: [android] + '@esbuild/android-x64@0.27.0': + resolution: {integrity: sha512-wurMkF1nmQajBO1+0CJmcN17U4BP6GqNSROP8t0X/Jiw2ltYGLHpEksp9MpoBqkrFR3kv2/te6Sha26k3+yZ9Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/android-x64@0.27.3': resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==} engines: {node: '>=18'} @@ -2966,6 +3018,12 @@ packages: cpu: [arm64] os: [darwin] + '@esbuild/darwin-arm64@0.27.0': + resolution: {integrity: sha512-uJOQKYCcHhg07DL7i8MzjvS2LaP7W7Pn/7uA0B5S1EnqAirJtbyw4yC5jQ5qcFjHK9l6o/MX9QisBg12kNkdHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-arm64@0.27.3': resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==} engines: {node: '>=18'} @@ -2996,6 +3054,12 @@ packages: cpu: [x64] os: [darwin] + '@esbuild/darwin-x64@0.27.0': + resolution: {integrity: sha512-8mG6arH3yB/4ZXiEnXof5MK72dE6zM9cDvUcPtxhUZsDjESl9JipZYW60C3JGreKCEP+p8P/72r69m4AZGJd5g==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/darwin-x64@0.27.3': resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==} engines: {node: '>=18'} @@ -3026,6 +3090,12 @@ packages: cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-arm64@0.27.0': + resolution: {integrity: sha512-9FHtyO988CwNMMOE3YIeci+UV+x5Zy8fI2qHNpsEtSF83YPBmE8UWmfYAQg6Ux7Gsmd4FejZqnEUZCMGaNQHQw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-arm64@0.27.3': resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==} engines: {node: '>=18'} @@ -3056,6 +3126,12 @@ packages: cpu: [x64] os: [freebsd] + '@esbuild/freebsd-x64@0.27.0': + resolution: {integrity: sha512-zCMeMXI4HS/tXvJz8vWGexpZj2YVtRAihHLk1imZj4efx1BQzN76YFeKqlDr3bUWI26wHwLWPd3rwh6pe4EV7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/freebsd-x64@0.27.3': resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==} engines: {node: '>=18'} @@ -3086,6 +3162,12 @@ packages: cpu: [arm64] os: [linux] + '@esbuild/linux-arm64@0.27.0': + resolution: {integrity: sha512-AS18v0V+vZiLJyi/4LphvBE+OIX682Pu7ZYNsdUHyUKSoRwdnOsMf6FDekwoAFKej14WAkOef3zAORJgAtXnlQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm64@0.27.3': resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==} engines: {node: '>=18'} @@ -3116,6 +3198,12 @@ packages: cpu: [arm] os: [linux] + '@esbuild/linux-arm@0.27.0': + resolution: {integrity: sha512-t76XLQDpxgmq2cNXKTVEB7O7YMb42atj2Re2Haf45HkaUpjM2J0UuJZDuaGbPbamzZ7bawyGFUkodL+zcE+jvQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-arm@0.27.3': resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==} engines: {node: '>=18'} @@ -3146,6 +3234,12 @@ packages: cpu: [ia32] os: [linux] + '@esbuild/linux-ia32@0.27.0': + resolution: {integrity: sha512-Mz1jxqm/kfgKkc/KLHC5qIujMvnnarD9ra1cEcrs7qshTUSksPihGrWHVG5+osAIQ68577Zpww7SGapmzSt4Nw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-ia32@0.27.3': resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==} engines: {node: '>=18'} @@ -3176,6 +3270,12 @@ packages: cpu: [loong64] os: [linux] + '@esbuild/linux-loong64@0.27.0': + resolution: {integrity: sha512-QbEREjdJeIreIAbdG2hLU1yXm1uu+LTdzoq1KCo4G4pFOLlvIspBm36QrQOar9LFduavoWX2msNFAAAY9j4BDg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-loong64@0.27.3': resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==} engines: {node: '>=18'} @@ -3206,6 +3306,12 @@ packages: cpu: [mips64el] os: [linux] + '@esbuild/linux-mips64el@0.27.0': + resolution: {integrity: sha512-sJz3zRNe4tO2wxvDpH/HYJilb6+2YJxo/ZNbVdtFiKDufzWq4JmKAiHy9iGoLjAV7r/W32VgaHGkk35cUXlNOg==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-mips64el@0.27.3': resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==} engines: {node: '>=18'} @@ -3236,6 +3342,12 @@ packages: cpu: [ppc64] os: [linux] + '@esbuild/linux-ppc64@0.27.0': + resolution: {integrity: sha512-z9N10FBD0DCS2dmSABDBb5TLAyF1/ydVb+N4pi88T45efQ/w4ohr/F/QYCkxDPnkhkp6AIpIcQKQ8F0ANoA2JA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-ppc64@0.27.3': resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==} engines: {node: '>=18'} @@ -3266,6 +3378,12 @@ packages: cpu: [riscv64] os: [linux] + '@esbuild/linux-riscv64@0.27.0': + resolution: {integrity: sha512-pQdyAIZ0BWIC5GyvVFn5awDiO14TkT/19FTmFcPdDec94KJ1uZcmFs21Fo8auMXzD4Tt+diXu1LW1gHus9fhFQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-riscv64@0.27.3': resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==} engines: {node: '>=18'} @@ -3296,6 +3414,12 @@ packages: cpu: [s390x] os: [linux] + '@esbuild/linux-s390x@0.27.0': + resolution: {integrity: sha512-hPlRWR4eIDDEci953RI1BLZitgi5uqcsjKMxwYfmi4LcwyWo2IcRP+lThVnKjNtk90pLS8nKdroXYOqW+QQH+w==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-s390x@0.27.3': resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==} engines: {node: '>=18'} @@ -3326,6 +3450,12 @@ packages: cpu: [x64] os: [linux] + '@esbuild/linux-x64@0.27.0': + resolution: {integrity: sha512-1hBWx4OUJE2cab++aVZ7pObD6s+DK4mPGpemtnAORBvb5l/g5xFGk0vc0PjSkrDs0XaXj9yyob3d14XqvnQ4gw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/linux-x64@0.27.3': resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==} engines: {node: '>=18'} @@ -3350,6 +3480,12 @@ packages: cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-arm64@0.27.0': + resolution: {integrity: sha512-6m0sfQfxfQfy1qRuecMkJlf1cIzTOgyaeXaiVaaki8/v+WB+U4hc6ik15ZW6TAllRlg/WuQXxWj1jx6C+dfy3w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-arm64@0.27.3': resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==} engines: {node: '>=18'} @@ -3380,6 +3516,12 @@ packages: cpu: [x64] os: [netbsd] + '@esbuild/netbsd-x64@0.27.0': + resolution: {integrity: sha512-xbbOdfn06FtcJ9d0ShxxvSn2iUsGd/lgPIO2V3VZIPDbEaIj1/3nBBe1AwuEZKXVXkMmpr6LUAgMkLD/4D2PPA==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/netbsd-x64@0.27.3': resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==} engines: {node: '>=18'} @@ -3404,6 +3546,12 @@ packages: cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-arm64@0.27.0': + resolution: {integrity: sha512-fWgqR8uNbCQ/GGv0yhzttj6sU/9Z5/Sv/VGU3F5OuXK6J6SlriONKrQ7tNlwBrJZXRYk5jUhuWvF7GYzGguBZQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-arm64@0.27.3': resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==} engines: {node: '>=18'} @@ -3434,6 +3582,12 @@ packages: cpu: [x64] os: [openbsd] + '@esbuild/openbsd-x64@0.27.0': + resolution: {integrity: sha512-aCwlRdSNMNxkGGqQajMUza6uXzR/U0dIl1QmLjPtRbLOx3Gy3otfFu/VjATy4yQzo9yFDGTxYDo1FfAD9oRD2A==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/openbsd-x64@0.27.3': resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==} engines: {node: '>=18'} @@ -3458,6 +3612,12 @@ packages: cpu: [arm64] os: [openharmony] + '@esbuild/openharmony-arm64@0.27.0': + resolution: {integrity: sha512-nyvsBccxNAsNYz2jVFYwEGuRRomqZ149A39SHWk4hV0jWxKM0hjBPm3AmdxcbHiFLbBSwG6SbpIcUbXjgyECfA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/openharmony-arm64@0.27.3': resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==} engines: {node: '>=18'} @@ -3488,6 +3648,12 @@ packages: cpu: [x64] os: [sunos] + '@esbuild/sunos-x64@0.27.0': + resolution: {integrity: sha512-Q1KY1iJafM+UX6CFEL+F4HRTgygmEW568YMqDA5UV97AuZSm21b7SXIrRJDwXWPzr8MGr75fUZPV67FdtMHlHA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/sunos-x64@0.27.3': resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==} engines: {node: '>=18'} @@ -3518,6 +3684,12 @@ packages: cpu: [arm64] os: [win32] + '@esbuild/win32-arm64@0.27.0': + resolution: {integrity: sha512-W1eyGNi6d+8kOmZIwi/EDjrL9nxQIQ0MiGqe/AWc6+IaHloxHSGoeRgDRKHFISThLmsewZ5nHFvGFWdBYlgKPg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-arm64@0.27.3': resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==} engines: {node: '>=18'} @@ -3548,6 +3720,12 @@ packages: cpu: [ia32] os: [win32] + '@esbuild/win32-ia32@0.27.0': + resolution: {integrity: sha512-30z1aKL9h22kQhilnYkORFYt+3wp7yZsHWus+wSKAJR8JtdfI76LJ4SBdMsCopTR3z/ORqVu5L1vtnHZWVj4cQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-ia32@0.27.3': resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==} engines: {node: '>=18'} @@ -3578,6 +3756,12 @@ packages: cpu: [x64] os: [win32] + '@esbuild/win32-x64@0.27.0': + resolution: {integrity: sha512-aIitBcjQeyOhMTImhLZmtxfdOcuNRpwlPNmlFKPcHQYPhEssw75Cl1TSXJXpMkzaua9FUetx/4OQKq7eJul5Cg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@esbuild/win32-x64@0.27.3': resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==} engines: {node: '>=18'} @@ -7548,6 +7732,25 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + convex@1.47.0: + resolution: {integrity: sha512-B1z/lOmex7i09NASq8+2WG9rZLzGXXOSkpwGOpaNIvOs4awyW4RMgpVBjt8r2wvvHwhEBEsOr2pGYpulnmAxYQ==} + engines: {node: '>=20.0.0', npm: '>=7.0.0'} + hasBin: true + peerDependencies: + '@auth0/auth0-react': ^2.0.1 + '@clerk/clerk-react': ^4.12.8 || ^5.0.0 + '@clerk/react': ^6.4.3 + react: ^18.0.0 || ^19.0.0-0 || ^19.0.0 + peerDependenciesMeta: + '@auth0/auth0-react': + optional: true + '@clerk/clerk-react': + optional: true + '@clerk/react': + optional: true + react: + optional: true + cookie-es@3.1.1: resolution: {integrity: sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==} @@ -8059,6 +8262,11 @@ packages: engines: {node: '>=18'} hasBin: true + esbuild@0.27.0: + resolution: {integrity: sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA==} + engines: {node: '>=18'} + hasBin: true + esbuild@0.27.3: resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==} engines: {node: '>=18'} @@ -12433,6 +12641,18 @@ packages: utf-8-validate: optional: true + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + xcode@3.0.1: resolution: {integrity: sha512-kCz5k7J7XbJtjABOvkc5lJmkiDh8VhjVCGNiqdKCscmVpdVUpEAyXv1xmCLkQJ5dsHqx3IPO4XW+NTDhU/fatA==} engines: {node: '>=10.0.0'} @@ -13847,6 +14067,9 @@ snapshots: '@esbuild/aix-ppc64@0.25.11': optional: true + '@esbuild/aix-ppc64@0.27.0': + optional: true + '@esbuild/aix-ppc64@0.27.3': optional: true @@ -13862,6 +14085,9 @@ snapshots: '@esbuild/android-arm64@0.25.11': optional: true + '@esbuild/android-arm64@0.27.0': + optional: true + '@esbuild/android-arm64@0.27.3': optional: true @@ -13877,6 +14103,9 @@ snapshots: '@esbuild/android-arm@0.25.11': optional: true + '@esbuild/android-arm@0.27.0': + optional: true + '@esbuild/android-arm@0.27.3': optional: true @@ -13892,6 +14121,9 @@ snapshots: '@esbuild/android-x64@0.25.11': optional: true + '@esbuild/android-x64@0.27.0': + optional: true + '@esbuild/android-x64@0.27.3': optional: true @@ -13907,6 +14139,9 @@ snapshots: '@esbuild/darwin-arm64@0.25.11': optional: true + '@esbuild/darwin-arm64@0.27.0': + optional: true + '@esbuild/darwin-arm64@0.27.3': optional: true @@ -13922,6 +14157,9 @@ snapshots: '@esbuild/darwin-x64@0.25.11': optional: true + '@esbuild/darwin-x64@0.27.0': + optional: true + '@esbuild/darwin-x64@0.27.3': optional: true @@ -13937,6 +14175,9 @@ snapshots: '@esbuild/freebsd-arm64@0.25.11': optional: true + '@esbuild/freebsd-arm64@0.27.0': + optional: true + '@esbuild/freebsd-arm64@0.27.3': optional: true @@ -13952,6 +14193,9 @@ snapshots: '@esbuild/freebsd-x64@0.25.11': optional: true + '@esbuild/freebsd-x64@0.27.0': + optional: true + '@esbuild/freebsd-x64@0.27.3': optional: true @@ -13967,6 +14211,9 @@ snapshots: '@esbuild/linux-arm64@0.25.11': optional: true + '@esbuild/linux-arm64@0.27.0': + optional: true + '@esbuild/linux-arm64@0.27.3': optional: true @@ -13982,6 +14229,9 @@ snapshots: '@esbuild/linux-arm@0.25.11': optional: true + '@esbuild/linux-arm@0.27.0': + optional: true + '@esbuild/linux-arm@0.27.3': optional: true @@ -13997,6 +14247,9 @@ snapshots: '@esbuild/linux-ia32@0.25.11': optional: true + '@esbuild/linux-ia32@0.27.0': + optional: true + '@esbuild/linux-ia32@0.27.3': optional: true @@ -14012,6 +14265,9 @@ snapshots: '@esbuild/linux-loong64@0.25.11': optional: true + '@esbuild/linux-loong64@0.27.0': + optional: true + '@esbuild/linux-loong64@0.27.3': optional: true @@ -14027,6 +14283,9 @@ snapshots: '@esbuild/linux-mips64el@0.25.11': optional: true + '@esbuild/linux-mips64el@0.27.0': + optional: true + '@esbuild/linux-mips64el@0.27.3': optional: true @@ -14042,6 +14301,9 @@ snapshots: '@esbuild/linux-ppc64@0.25.11': optional: true + '@esbuild/linux-ppc64@0.27.0': + optional: true + '@esbuild/linux-ppc64@0.27.3': optional: true @@ -14057,6 +14319,9 @@ snapshots: '@esbuild/linux-riscv64@0.25.11': optional: true + '@esbuild/linux-riscv64@0.27.0': + optional: true + '@esbuild/linux-riscv64@0.27.3': optional: true @@ -14072,6 +14337,9 @@ snapshots: '@esbuild/linux-s390x@0.25.11': optional: true + '@esbuild/linux-s390x@0.27.0': + optional: true + '@esbuild/linux-s390x@0.27.3': optional: true @@ -14087,6 +14355,9 @@ snapshots: '@esbuild/linux-x64@0.25.11': optional: true + '@esbuild/linux-x64@0.27.0': + optional: true + '@esbuild/linux-x64@0.27.3': optional: true @@ -14099,6 +14370,9 @@ snapshots: '@esbuild/netbsd-arm64@0.25.11': optional: true + '@esbuild/netbsd-arm64@0.27.0': + optional: true + '@esbuild/netbsd-arm64@0.27.3': optional: true @@ -14114,6 +14388,9 @@ snapshots: '@esbuild/netbsd-x64@0.25.11': optional: true + '@esbuild/netbsd-x64@0.27.0': + optional: true + '@esbuild/netbsd-x64@0.27.3': optional: true @@ -14126,6 +14403,9 @@ snapshots: '@esbuild/openbsd-arm64@0.25.11': optional: true + '@esbuild/openbsd-arm64@0.27.0': + optional: true + '@esbuild/openbsd-arm64@0.27.3': optional: true @@ -14141,6 +14421,9 @@ snapshots: '@esbuild/openbsd-x64@0.25.11': optional: true + '@esbuild/openbsd-x64@0.27.0': + optional: true + '@esbuild/openbsd-x64@0.27.3': optional: true @@ -14153,6 +14436,9 @@ snapshots: '@esbuild/openharmony-arm64@0.25.11': optional: true + '@esbuild/openharmony-arm64@0.27.0': + optional: true + '@esbuild/openharmony-arm64@0.27.3': optional: true @@ -14168,6 +14454,9 @@ snapshots: '@esbuild/sunos-x64@0.25.11': optional: true + '@esbuild/sunos-x64@0.27.0': + optional: true + '@esbuild/sunos-x64@0.27.3': optional: true @@ -14183,6 +14472,9 @@ snapshots: '@esbuild/win32-arm64@0.25.11': optional: true + '@esbuild/win32-arm64@0.27.0': + optional: true + '@esbuild/win32-arm64@0.27.3': optional: true @@ -14198,6 +14490,9 @@ snapshots: '@esbuild/win32-ia32@0.25.11': optional: true + '@esbuild/win32-ia32@0.27.0': + optional: true + '@esbuild/win32-ia32@0.27.3': optional: true @@ -14213,6 +14508,9 @@ snapshots: '@esbuild/win32-x64@0.25.11': optional: true + '@esbuild/win32-x64@0.27.0': + optional: true + '@esbuild/win32-x64@0.27.3': optional: true @@ -14404,7 +14702,7 @@ snapshots: terminal-link: 2.1.1 toqr: 0.1.1 wrap-ansi: 7.0.0 - ws: 8.19.0 + ws: 8.20.0 zod: 3.25.76 optionalDependencies: expo-router: 5.1.11(@types/react@19.2.13)(expo-constants@55.0.9)(expo-linking@7.1.7)(expo@55.0.8)(react-native-safe-area-context@5.4.0(react-native@0.83.2(@babel/core@7.29.7)(@types/react@19.2.13)(react@19.2.0))(react@19.2.0))(react-native-screens@4.11.1(react-native@0.83.2(@babel/core@7.29.7)(@types/react@19.2.13)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.7)(@types/react@19.2.13)(react@19.2.0))(react@19.2.0) @@ -14480,7 +14778,7 @@ snapshots: terminal-link: 2.1.1 toqr: 0.1.1 wrap-ansi: 7.0.0 - ws: 8.19.0 + ws: 8.20.0 zod: 3.25.76 optionalDependencies: expo-router: 5.1.11(@types/react@19.2.13)(expo-constants@55.0.9)(expo-linking@7.1.7)(expo@55.0.8)(react-native-safe-area-context@5.4.0(react-native@0.83.2(@babel/core@7.29.7)(@types/react@19.2.13)(react@19.2.4))(react@19.2.4))(react-native-screens@4.11.1(react-native@0.83.2(@babel/core@7.29.7)(@types/react@19.2.13)(react@19.2.4))(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.7)(@types/react@19.2.13)(react@19.2.4))(react@19.2.4) @@ -19203,6 +19501,17 @@ snapshots: convert-source-map@2.0.0: {} + convex@1.47.0(react@19.2.4): + dependencies: + esbuild: 0.27.0 + prettier: 3.9.9 + ws: 8.21.0 + optionalDependencies: + react: 19.2.4 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + cookie-es@3.1.1: {} cookie-signature@1.2.2: {} @@ -19696,6 +20005,35 @@ snapshots: '@esbuild/win32-ia32': 0.25.11 '@esbuild/win32-x64': 0.25.11 + esbuild@0.27.0: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.0 + '@esbuild/android-arm': 0.27.0 + '@esbuild/android-arm64': 0.27.0 + '@esbuild/android-x64': 0.27.0 + '@esbuild/darwin-arm64': 0.27.0 + '@esbuild/darwin-x64': 0.27.0 + '@esbuild/freebsd-arm64': 0.27.0 + '@esbuild/freebsd-x64': 0.27.0 + '@esbuild/linux-arm': 0.27.0 + '@esbuild/linux-arm64': 0.27.0 + '@esbuild/linux-ia32': 0.27.0 + '@esbuild/linux-loong64': 0.27.0 + '@esbuild/linux-mips64el': 0.27.0 + '@esbuild/linux-ppc64': 0.27.0 + '@esbuild/linux-riscv64': 0.27.0 + '@esbuild/linux-s390x': 0.27.0 + '@esbuild/linux-x64': 0.27.0 + '@esbuild/netbsd-arm64': 0.27.0 + '@esbuild/netbsd-x64': 0.27.0 + '@esbuild/openbsd-arm64': 0.27.0 + '@esbuild/openbsd-x64': 0.27.0 + '@esbuild/openharmony-arm64': 0.27.0 + '@esbuild/sunos-x64': 0.27.0 + '@esbuild/win32-arm64': 0.27.0 + '@esbuild/win32-ia32': 0.27.0 + '@esbuild/win32-x64': 0.27.0 + esbuild@0.27.3: optionalDependencies: '@esbuild/aix-ppc64': 0.27.3 @@ -25041,6 +25379,8 @@ snapshots: ws@8.20.0: {} + ws@8.21.0: {} + xcode@3.0.1: dependencies: simple-plist: 1.3.1 diff --git a/scripts/ci-tests.mjs b/scripts/ci-tests.mjs index 6c43e06889..d49df1f31d 100644 --- a/scripts/ci-tests.mjs +++ b/scripts/ci-tests.mjs @@ -28,6 +28,7 @@ const groups = { `@tanstack/angular-db`, ], collections: [ + `@tanstack/convex-db-collection`, `@tanstack/electric-db-collection`, `@tanstack/query-db-collection`, `@tanstack/powersync-db-collection`, diff --git a/scripts/generate-docs.ts b/scripts/generate-docs.ts index cbedd2a3c1..2a5ab8fdf3 100644 --- a/scripts/generate-docs.ts +++ b/scripts/generate-docs.ts @@ -12,6 +12,33 @@ await generateReferenceDocs({ tsconfig: resolve(__dirname, `../packages/db/tsconfig.docs.json`), outputDir: resolve(__dirname, `../docs/reference`), }, + { + name: `convex-db-collection`, + entryPoints: [ + resolve(__dirname, `../packages/convex-db-collection/src/index.ts`), + ], + tsconfig: resolve( + __dirname, + `../packages/convex-db-collection/tsconfig.docs.json`, + ), + outputDir: resolve(__dirname, `../docs/reference/convex-db-collection`), + exclude: [`packages/db/**/*`], + }, + { + name: `convex-db-collection/offline`, + entryPoints: [ + resolve(__dirname, `../packages/convex-db-collection/src/offline.ts`), + ], + tsconfig: resolve( + __dirname, + `../packages/convex-db-collection/tsconfig.docs.json`, + ), + outputDir: resolve( + __dirname, + `../docs/reference/convex-db-collection/offline`, + ), + exclude: [`packages/db/**/*`, `packages/offline-transactions/**/*`], + }, { name: `electric-db-collection`, entryPoints: [ diff --git a/scripts/size-compare.mjs b/scripts/size-compare.mjs index 72a6ec52ff..9885a9daa9 100644 --- a/scripts/size-compare.mjs +++ b/scripts/size-compare.mjs @@ -47,6 +47,7 @@ const flag = (name) => args.includes(`--${name}`) const DEFAULT_PACKAGES = [ `db`, + `convex-db-collection`, `react-db`, `query-db-collection`, `electric-db-collection`, @@ -199,6 +200,8 @@ function exportedEntries(pkgDir) { // is the compressed bundle; each file's entry is its minified bytes in it. async function measureProduction(dir, pkg) { const pkgDir = join(dir, `packages`, pkg) + // New or removed packages have no shipped bytes on the absent side. + if (!existsSync(pkgDir)) return new Map() const entries = exportedEntries(pkgDir).filter(existsSync) if (entries.length === 0) return new Map() const result = await esbuild({ From a4eeec063d3d214489a421c58dbbf04dabb3e424 Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Thu, 22 Oct 2026 11:59:12 -0700 Subject: [PATCH 02/20] fix: isolate Convex options and defer pagination row copying --- docs/collections/convex-collection.md | 2 + docs/contributing/oracle-coverage.md | 40 ++++-- .../e2e/backend-oracle.test.ts | 124 ++++++++++++------ .../convex-db-collection/e2e/offline-child.ts | 21 ++- packages/convex-db-collection/package.json | 2 +- packages/convex-db-collection/src/options.ts | 17 ++- .../convex-db-collection/src/pagination.ts | 7 +- .../tests/collection.test.ts | 15 +++ .../tests/ownership-oracle.property.test.ts | 101 ++++++++++++++ .../tests/pagination-oracle.property.test.ts | 65 ++++++++- 10 files changed, 331 insertions(+), 63 deletions(-) create mode 100644 packages/convex-db-collection/tests/ownership-oracle.property.test.ts diff --git a/docs/collections/convex-collection.md b/docs/collections/convex-collection.md index f3bda16678..af6d071beb 100644 --- a/docs/collections/convex-collection.md +++ b/docs/collections/convex-collection.md @@ -37,6 +37,8 @@ await tasks.preload() An existing `ConvexReactClient`, including the client from `useConvex()`, also works. The adapter imports neither React nor the Convex runtime. Keep the client and collection stable across renders. +Create fresh adapter options for each direct `createCollection` call. Reusing the same options, including a shallow copy, throws. A `collectionOptions` descriptor can still materialize independent collections in separate `DbClient` instances. + Each result replaces the subscribed query's rows. Missing documents are deleted, and missing fields are removed. An empty array is a ready, empty collection. Keys must be unique strings or finite numbers. Duplicate keys reject the complete result and preserve the last valid snapshot. The adapter uses eager sync. DB filters, ordering, and limits apply locally to the subscribed result. They do not change your Convex query or fetch documents outside its result. To change the query arguments, create a new collection with a distinct ID. diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index c73140e61e..0fe900d973 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -13,18 +13,35 @@ distinguishes canonical laws from their receiving witnesses across packages. ## Convex collection integration -The Convex package has four independent owners: +The Convex package has five independent owners: - `packages/convex-db-collection/tests/snapshot-oracle.property.test.ts` compares public rows with the latest valid provider array. It covers missing-row deletion, full-row replacement, reintroduction, empty results, and atomic duplicate-key rejection. +- `packages/convex-db-collection/tests/ownership-oracle.property.test.ts` owns + concrete options admission separately from snapshot refinement. Its identity + model crosses direct and shallow-copy reuse with idle, ready, and cleaned-up + owners. Reuse rejects before it can replace the first owner's cached mutation + confirmation. Fresh options and separate DbClient descriptor materializations + remain independent, including sibling cleanup and rightful-owner restart. + The original implementation failed admission at seed 20261022/path 2:0:0; + the original two-owner mutation witness settled the first owner to its stale + row. The claim repair passes both fixed and random 60-history campaigns and + direct replay. It follows the existing LocalStorage concrete-options policy. + This owner does not cover arbitrary Collection lifecycle or a real SDK's + multi-listener scheduling. SDK tests receive the cached-listener premise. - `packages/convex-db-collection/tests/pagination-oracle.property.test.ts` owns complete finite snapshots across reactive cursor ranges. It varies page sizes 1-4, forward/reverse callback delivery, row insertion/deletion/movement, and required/recommended/no split hints. The reference is the latest input array; the provider fixture independently answers numeric rank intervals. Comparison occurs after the finite acquisition exhausts and sync acceptance settles. + Its initial-materialization work law also varies 3-40 pages and 1-8 rows per + page. The original implementation copied incomplete prefixes and failed at + seed 20261022/path 0:0:0:0. The repaired fixed/random campaigns copy each + provider row once at first exhaustion. Cache scans still grow quadratically + with page count during sequential acquisition, this is not a total-work bound. - `packages/convex-db-collection/tests/offline-oracle.property.test.ts` owns the bridge's ordered argument/mutation/sync stages, durable key propagation, and terminal application-error classification. Argument and mutation failures @@ -40,16 +57,18 @@ Each owner runs matching fixed and random campaigns and accepts explicit seed/path replay through their CONVEX_*_ORACLE_SEED/PATH environment variables. Snapshot replay uses CONVEX_SNAPSHOT_ORACLE; pagination uses CONVEX_ORACLE; offline bridge replay uses CONVEX_OFFLINE_ORACLE; native value replay uses -CONVEX_VALUES_ORACLE. +CONVEX_VALUES_ORACLE; ownership uses CONVEX_OWNERSHIP_ORACLE. `packages/convex-db-collection/e2e/backend-oracle.test.ts` receives these contracts at an actual isolated local Convex backend using both Convex 1.47.0 SDK clients. It compares pagination with a separately evaluated unpaginated server query, including real server-generated split responses. Its durable driver runs fixed -and random insert histories through SIGKILL after disk admission and, optionally, +and random insert/update/delete histories through SIGKILL after disk admission and, optionally, a second SIGKILL after server acceptance but before recording outbox completion. It requires no offline provider call, restored Collection rows, and one server -row/receipt after replay with the original key. Replay uses +receipt after replay with the original key and the expected final server rows. +Native payload variation is insert-only. Atomic batches and multi-tab leadership +remain outside this backend witness. Replay uses CONVEX_BACKEND_ORACLE_SEED/PATH. The command owns backend startup and shutdown. The offline engine's interruption, settlement, storage, and leadership owners remain primary for those laws; this is their Convex receiving witness. @@ -79,19 +98,20 @@ availability, or power-loss atomicity during a filesystem write. The test backen has no application authorization; production authorization and atomic receipt storage are requirements of each application's server mutation. -Validation for this integration: 40 adapter checks, 302 offline-engine tests, -and five real-backend tests pass. The native-value receiving oracle rejects a +Validation for this integration: 43 adapter checks, 302 offline-engine tests, +and seven real-backend tests (36 generated crash histories) pass. The native-value receiving oracle rejects a scalar-as-JSON decoder at the typed-value comparison (seed 20261022/path 0:0); its repaired replay passes. `pnpm size:compare --packages -convex-db-collection,offline-transactions --files` measures 2,559 gzip / 2,312 +convex-db-collection,offline-transactions --files` measures 2,633 gzip / 2,381 brotli bytes for the new adapter's combined exports, with dependencies external. The shared outbox grows by 192 gzip / 174 brotli bytes. A new adapter has no baseline to subtract; normal and paginated sources share one snapshot writer, and the existing outbox supplies durability instead of a second write queue. The codec hook is needed to preserve native values without changing default -JSON semantics. Adapter tests/fixtures are 68,570 source bytes and its -usage/generated API docs are 20,749 source bytes, separate from shipped -production measurements. +JSON semantics. Review fixes add 74 gzip / 69 brotli bytes over the initial +adapter implementation, paying for concrete-options ownership enforcement and +deferring row materialization. Tests and documentation are separate from these +shipped production measurements. ## Scope of the oracle repair project diff --git a/packages/convex-db-collection/e2e/backend-oracle.test.ts b/packages/convex-db-collection/e2e/backend-oracle.test.ts index e638480c00..f56b7f23a6 100644 --- a/packages/convex-db-collection/e2e/backend-oracle.test.ts +++ b/packages/convex-db-collection/e2e/backend-oracle.test.ts @@ -6,16 +6,17 @@ * come from tasks:all, which does not paginate or use the adapter. * * The durable driver checks the outbox's documented process-restart law with - * actual disk storage and SIGKILL. Its independent model is one logical insert, - * hence one final server row and one idempotency receipt. Histories vary the - * draft's text/rank and whether a second crash loses the server acknowledgement. + * actual disk storage and SIGKILL. Its independent model applies one logical + * insert, update, or delete, hence the draft row or its absence and one receipt. + * Histories vary the operation and draft's text/rank, plus whether a second + * crash loses the server acknowledgement. Native values vary on inserted rows. * A server-accepted checkpoint occurs before the outbox phase can be recorded, * so replay must reuse its key rather than rely on a completed local marker. * Checkpoints are observed in child stdout before killing the process. Server * rows/receipts and replayed Collection rows are checked only after replay. * * This is a receiving witness for the offline engine's interruption/settlement - * owners, not another model of leadership. It covers one pending insert in one + * owners, not another model of leadership. It covers one pending mutation in one * Collection against a real local backend. Shared-key/multi-tab histories stay * with those primary owners. Cloud infrastructure and an OS power failure * during a filesystem write are outside this process-crash test. @@ -173,8 +174,9 @@ describe.each([`javascript`, `react`] as const)( ) async function durableHistory( - draft: { clientId: string; text: string; rank: number; payload: Value }, + draft: { clientId: string; text: string; rank: number; payload?: Value }, uncertain: boolean, + operation: `insert` | `update` | `delete`, ) { const { spawn } = await import('node:child_process') const { mkdtemp, rm } = await import('node:fs/promises') @@ -183,7 +185,14 @@ async function durableHistory( const { fileURLToPath } = await import('node:url') const directory = await mkdtemp(join(tmpdir(), `convex-outbox-`)) const client = new ConvexClient(url, { unsavedChangesWarning: false }) - await client.mutation(seed, { count: 0 }) + await client.mutation(seed, { count: operation === `insert` ? 0 : 1 }) + const before = (await client.query(all, {})).map( + ({ clientId, text, rank, payload }) => ({ clientId, text, rank, payload }), + ) + // The public write contract gives one final draft for insert/update and no + // row for delete. These expectations do not inspect restored mutations. + const expected = + operation === `delete` ? [] : [{ ...draft, payload: draft.payload }] async function run(mode: string, target: string): Promise> { const child = spawn( process.execPath, @@ -195,6 +204,7 @@ async function durableHistory( directory, url, JSON.stringify(convexToJson(draft)), + operation, ], { stdio: [`ignore`, `pipe`, `pipe`] }, ) @@ -236,14 +246,23 @@ async function durableHistory( try { const admitted = await run(`admit`, `durable`) expect(admitted).not.toContain(`provider-call`) - expect(await client.query(all, {})).toEqual([]) + expect( + (await client.query(all, {})).map( + ({ clientId, text, rank, payload }) => ({ + clientId, + text, + rank, + payload, + }), + ), + ).toEqual(before) if (uncertain) { expect(await run(`uncertain`, `server-accepted`)).toContain( `provider-call`, ) - expect((await client.query(all, {})).map((row) => row.clientId)).toEqual([ - draft.clientId, - ]) + expect((await client.query(all, {})).map((row) => row.clientId)).toEqual( + expected.map((row) => row.clientId), + ) } expect(await run(`replay`, `replayed`)).toContain(`provider-call`) const receipts = makeFunctionReference<`query`, {}, Array<{ key: string }>>( @@ -259,42 +278,61 @@ async function durableHistory( payload, }), ), - ).toEqual([draft]) + ).toEqual(expected) } finally { await client.close() await rm(directory, { recursive: true, force: true }) } } -it(`restores durable writes and deduplicates uncertain acknowledgements across generated process histories`, async () => { - const property = fc.asyncProperty( - fc.record({ - text: fc.string({ maxLength: 12 }), - rank: fc.integer({ min: -50, max: 50 }), - uncertain: fc.boolean(), - payload: fc.oneof( - fc.bigInt({ min: -(2n ** 63n), max: 2n ** 63n - 1n }), - fc.constantFrom(NaN, Infinity, -Infinity, -0), - fc - .uint8Array({ maxLength: 12 }) - .map((bytes) => bytes.buffer as ArrayBuffer), - ), - }), - async ({ text, rank, uncertain, payload }) => - durableHistory( - { clientId: `offline-created`, text, rank, payload }, - uncertain, - ), - ) - const replaySeed = process.env.CONVEX_BACKEND_ORACLE_SEED - if (replaySeed) - await fc.assert(property, { - numRuns: 6, - seed: Number(replaySeed), - path: process.env.CONVEX_BACKEND_ORACLE_PATH, - }) - else { - await fc.assert(property, { numRuns: 6, seed: 20261022 }) - await fc.assert(property, { numRuns: 6 }) - } -}, 120000) +it.each([`insert`, `update`, `delete`] as const)( + `restores durable %s and deduplicates uncertain acknowledgements across generated process histories`, + async (operation) => { + const property = fc.asyncProperty( + fc.record({ + text: + operation === `delete` + ? fc.constant(`task 0`) + : fc.string({ maxLength: 12 }), + rank: + operation === `delete` + ? fc.constant(0) + : fc.integer({ min: -50, max: 50 }), + uncertain: fc.boolean(), + payload: + operation === `insert` + ? fc.oneof( + fc.bigInt({ min: -(2n ** 63n), max: 2n ** 63n - 1n }), + fc.constantFrom(NaN, Infinity, -Infinity, -0), + fc + .uint8Array({ maxLength: 12 }) + .map((bytes) => bytes.buffer as ArrayBuffer), + ) + : fc.constant(undefined), + }), + async ({ text, rank, uncertain, payload }) => + durableHistory( + { + clientId: operation === `insert` ? `offline-created` : `0`, + text, + rank, + ...(operation === `insert` ? { payload } : {}), + }, + uncertain, + operation, + ), + ) + const replaySeed = process.env.CONVEX_BACKEND_ORACLE_SEED + if (replaySeed) + await fc.assert(property, { + numRuns: 6, + seed: Number(replaySeed), + path: process.env.CONVEX_BACKEND_ORACLE_PATH, + }) + else { + await fc.assert(property, { numRuns: 6, seed: 20261022 }) + await fc.assert(property, { numRuns: 6 }) + } + }, + 120000, +) diff --git a/packages/convex-db-collection/e2e/offline-child.ts b/packages/convex-db-collection/e2e/offline-child.ts index 29ae5d187b..0bde77fc73 100644 --- a/packages/convex-db-collection/e2e/offline-child.ts +++ b/packages/convex-db-collection/e2e/offline-child.ts @@ -19,11 +19,12 @@ import type { StorageAdapter } from '@tanstack/offline-transactions' import type { PaginationOptions, PaginationResult } from 'convex/server' type Task = { clientId: string; text: string; rank: number; payload?: Value } -const [mode, directory, url, draftJSON] = process.argv.slice(2) as [ +const [mode, directory, url, draftJSON, operation] = process.argv.slice(2) as [ string, string, string, string, + `insert` | `update` | `delete`, ] const draft = jsonToConvex(JSON.parse(draftJSON)) as Task const checkpoint = (value: string) => { @@ -80,6 +81,7 @@ const mutation = makeFunctionReference< text: string rank: number payload?: Value + remove?: boolean }, null >(`tasks:write`) @@ -110,11 +112,13 @@ const persist = convexOfflineMutation({ mutation, collections: [collection], getArgs: ({ transaction }) => { - const row = transaction.mutations[0].modified as Task + const change = transaction.mutations[0] + const row = change.modified as Task return { clientId: row.clientId, text: row.text, rank: row.rank, + ...(change.type === `delete` ? { remove: true } : {}), ...(row.payload === undefined ? {} : { payload: row.payload }), } }, @@ -149,7 +153,12 @@ if (offline) { const action = executor.createOfflineAction({ mutationFnName: `save`, onMutate: (row) => { - collection.insert(row) + if (operation === `insert`) collection.insert(row) + else if (operation === `update`) + collection.update(row.clientId, (current) => + Object.assign(current, row), + ) + else collection.delete(row.clientId) }, }) action(draft) @@ -164,7 +173,11 @@ if (offline) { if ((await executor.peekOutbox()).length === 0) { clearInterval(poll) await collection.utils.awaitSync() - if (collection.get(draft.clientId)?.text !== draft.text) + if ( + operation === `delete` + ? collection.has(draft.clientId) + : collection.get(draft.clientId)?.text !== draft.text + ) throw new Error(`Restored row did not reach the collection`) checkpoint(`replayed`) executor.dispose() diff --git a/packages/convex-db-collection/package.json b/packages/convex-db-collection/package.json index f2bb29c40a..fd9ca5e232 100644 --- a/packages/convex-db-collection/package.json +++ b/packages/convex-db-collection/package.json @@ -21,7 +21,7 @@ "dev": "vite build --watch", "lint": "eslint . --fix", "test": "vitest run", - "test:oracles": "vitest run tests/snapshot-oracle.property.test.ts tests/pagination-oracle.property.test.ts tests/offline-oracle.property.test.ts tests/values-oracle.property.test.ts", + "test:oracles": "vitest run tests/snapshot-oracle.property.test.ts tests/ownership-oracle.property.test.ts tests/pagination-oracle.property.test.ts tests/offline-oracle.property.test.ts tests/values-oracle.property.test.ts", "typecheck": "tsc --noEmit", "test:e2e": "vitest run --config vitest.e2e.config.ts" }, diff --git a/packages/convex-db-collection/src/options.ts b/packages/convex-db-collection/src/options.ts index d76d02cbd5..d3964e71fe 100644 --- a/packages/convex-db-collection/src/options.ts +++ b/packages/convex-db-collection/src/options.ts @@ -213,8 +213,21 @@ export function createConvexOptions< }, }, } - return withCollectionConfigFactory(options, () => - createConvexOptions(config, subscribe), + let claimed = false + return withCollectionConfigFactory( + options, + () => createConvexOptions(config, subscribe), + () => { + if (claimed) { + throw new Error( + `Convex options can create only one Collection. Create fresh options for each Collection.`, + ) + } + claimed = true + return () => { + claimed = false + } + }, ) as CollectionConfig & { utils: ConvexCollectionUtils } & ([TSchema] extends [never] ? unknown : { schema: TSchema }) diff --git a/packages/convex-db-collection/src/pagination.ts b/packages/convex-db-collection/src/pagination.ts index a5facd4720..4e44ad9205 100644 --- a/packages/convex-db-collection/src/pagination.ts +++ b/packages/convex-db-collection/src/pagination.ts @@ -104,7 +104,7 @@ export function subscribePages, TRow>( flushing = true result = undefined try { - const rows: Array = [] + const completePages: Array> = [] for (let index = 0; index < pages.length; index++) { const page = pages[index]! const value = page.subscription!.getCurrentValue() @@ -133,8 +133,11 @@ export function subscribePages, TRow>( replace(index, page, value.continueCursor) return } - rows.push(...value.page) + completePages.push(value.page) } + // Acquisition can add many ranges. Materialize rows only once every + // range is available, rather than copying each incomplete prefix. + const rows = completePages.flat() error = undefined result = rows receive(rows) diff --git a/packages/convex-db-collection/tests/collection.test.ts b/packages/convex-db-collection/tests/collection.test.ts index 91b8b1d12a..9d9309bfa9 100644 --- a/packages/convex-db-collection/tests/collection.test.ts +++ b/packages/convex-db-collection/tests/collection.test.ts @@ -33,6 +33,21 @@ function setup( } describe(`Convex collection`, () => { + it(`rejects concrete options reuse before it can steal cached mutation confirmation`, async () => { + const { transport, collection, options } = setup({ + onUpdate: () => Promise.resolve(), + }) + transport.emit([{ _id: `a`, text: `old` }]) + expect(() => createCollection(options)).toThrow(`only one Collection`) + transport.cache([{ _id: `a`, text: `server` }]) + const transaction = collection.update(`a`, (row) => { + row.text = `optimistic` + }) + await transaction.isPersisted.promise + expect(collection.get(`a`)?.text).toBe(`server`) + await collection.cleanup() + }) + it(`waits for a snapshot and accepts an empty initial result`, async () => { const { transport, collection } = setup() expect(collection.status).toBe(`loading`) diff --git a/packages/convex-db-collection/tests/ownership-oracle.property.test.ts b/packages/convex-db-collection/tests/ownership-oracle.property.test.ts new file mode 100644 index 0000000000..e07a28224e --- /dev/null +++ b/packages/convex-db-collection/tests/ownership-oracle.property.test.ts @@ -0,0 +1,101 @@ +/** + * Concrete adapter options own one Collection, following the existing + * LocalStorage options claim policy. Cleanup ends a sync run, not the options + * owner's identity. A descriptor materializes fresh options for each DbClient. + * This boundary prevents a second Collection from stealing cached mutation + * confirmation or error state from the first. + * + * The model judges identity alone: concrete and shallow-copy attempts reuse + * one owner, while fresh options and separate DbClients have separate owners. + * Generated histories cross reuse form with before-sync, ready, and cleanup + * checkpoints. At admission, a reused owner must reject. Accepted independent + * owners must each settle a mutation against the latest cached provider rows. + * The controlled provider holds callbacks after changing its cache, which is + * the SDK's documented cached-listener window. Real SDK timing is separately + * received in sdk.test.ts. This owner does not model arbitrary DB lifecycle. + */ +import { DbClient, collectionOptions, createCollection } from '@tanstack/db' +import { fc } from '@fast-check/vitest' +import { expect, it } from 'vitest' +import { convexCollectionOptions } from '../src/index' +import { controlledClient, query } from './helpers' + +it(`keeps concrete options ownership across generated lifecycle checkpoints`, async () => { + const property = fc.asyncProperty( + fc.constantFrom(`direct`, `shallow`, `fresh`, `descriptor`), + fc.constantFrom(`idle`, `ready`, `cleaned-up`), + fc.string({ maxLength: 16 }), + async (form, checkpoint, text) => { + const transport = controlledClient() + transport.cache([{ _id: `a`, text: `old` }]) + const makeOptions = () => + convexCollectionOptions({ + id: `review-owned-tasks`, + client: transport.client, + query, + queryArgs: { owner: `alice` }, + getKey: (row) => row._id, + gcTime: 0, + onUpdate: () => Promise.resolve(), + }) + const options = makeOptions() + const descriptor = collectionOptions(options) + const first = + form === `descriptor` + ? new DbClient().collection(descriptor) + : createCollection(options) + const collections = [first] + try { + if (checkpoint !== `idle`) await first.preload() + if (checkpoint === `cleaned-up`) await first.cleanup() + const reusedOwner = form === `direct` || form === `shallow` + const construct = () => + form === `descriptor` + ? new DbClient().collection(descriptor) + : createCollection( + form === `fresh` + ? makeOptions() + : form === `shallow` + ? { ...options } + : options, + ) + if (reusedOwner) { + expect(construct).toThrow(`only one Collection`) + } else { + const second = construct() + collections.push(second) + await second.preload() + transport.cache([{ _id: `a`, text }]) + const transaction = second.update(`a`, (row) => { + row.text = `optimistic` + }) + await transaction.isPersisted.promise + expect(second.get(`a`)?.text).toBe(text) + } + // Failed reuse and sibling cleanup cannot revoke the rightful owner. + for (const collection of collections.slice(1)) + await collection.cleanup() + await first.preload() + transport.cache([{ _id: `a`, text: `owner:${text}` }]) + const transaction = first.update(`a`, (row) => { + row.text = `optimistic` + }) + await transaction.isPersisted.promise + expect(first.get(`a`)?.text).toBe(`owner:${text}`) + } finally { + await Promise.all(collections.map((collection) => collection.cleanup())) + } + }, + ) + const seed = process.env.CONVEX_OWNERSHIP_ORACLE_SEED + if (seed) + await fc.assert(property, { + seed: Number(seed), + path: process.env.CONVEX_OWNERSHIP_ORACLE_PATH, + numRuns: 60, + }) + else { + await fc.assert(property, { seed: 20261022, numRuns: 60 }) + await fc.assert(property, { numRuns: 60 }) + } +}) diff --git a/packages/convex-db-collection/tests/pagination-oracle.property.test.ts b/packages/convex-db-collection/tests/pagination-oracle.property.test.ts index 91247cef0c..11d9b8869a 100644 --- a/packages/convex-db-collection/tests/pagination-oracle.property.test.ts +++ b/packages/convex-db-collection/tests/pagination-oracle.property.test.ts @@ -42,6 +42,7 @@ function provider( split: `none` | `required` | `recommended`, ) { const slots: Array = [] + let rowVisits = 0 const client = { onUpdate: ( _query: unknown, @@ -65,6 +66,9 @@ function provider( return { client, slots, + get rowVisits() { + return rowVisits + }, publish: async (rows: Array) => { const served = new Set() for (let turn = 0; turn < 100; turn++) { @@ -83,7 +87,12 @@ function provider( const boundary = endCursor ?? String(page.at(-1)?.rank ?? from) const needsSplit = split !== `none` && interval.length > 2 slot.current = { - page: needsSplit && split === `required` ? [] : page, + page: new Proxy(needsSplit && split === `required` ? [] : page, { + get(target, key, receiver) { + if (typeof key === `string` && /^\d+$/.test(key)) rowVisits++ + return Reflect.get(target, key, receiver) + }, + }), continueCursor: boundary, isDone: endCursor == null && page.length === interval.length, ...(needsSplit @@ -178,3 +187,57 @@ it(`refines exhausted snapshots across reactive ranges and page splits`, async ( await fc.assert(property, { numRuns: 60 }) } }) + +// A finite initial load must materialize its rows only after all ranges are +// available. Recopying every earlier prefix on each continuation makes initial +// acquisition quadratic. This work law follows the adapter's complete-snapshot +// design: incomplete rows cannot be published, so materializing them earns no +// useful output. Count numeric element reads at the provider boundary, rather +// than copying production's loop or predicting its number of subscriptions. +// This bounds row materialization, not cache lookups, RPC count, or elapsed time. +// Calibration: copying incomplete prefixes failed the row-visit comparison at +// seed 20261022/path 0:0:0:0, three rows at pageSize 1 read four elements. +// The same direct replay passes after deferring materialization until exhaustion. +const initialWork = fc.asyncProperty( + fc.integer({ min: 3, max: 40 }), + fc.integer({ min: 1, max: 8 }), + fc.boolean(), + async (pageCount, pageSize, reverse) => { + const rows = Array.from({ length: pageCount * pageSize }, (_, rank) => ({ + id: String(rank), + rank, + text: String(rank), + })) + const source = provider(reverse, `none`) + const collection = createCollection( + convexPaginatedCollectionOptions({ + client: source.client, + query, + queryArgs: {}, + pageSize, + getKey: (row) => row.id, + gcTime: 0, + }), + ) + collection.startSyncImmediate() + try { + await source.publish(rows) + // Check before awaitSync's explicit cache refreshes. At initial exhaustion + // there is exactly one useful materialization, the complete public result. + expect(collection.size).toBe(rows.length) + expect(source.rowVisits).toBe(rows.length) + } finally { + await collection.cleanup() + } + }, +) +it(`materializes complete rows once during finite initial pagination`, async () => { + const seed = process.env.CONVEX_ORACLE_SEED + const path = process.env.CONVEX_ORACLE_PATH + if (seed) + await fc.assert(initialWork, { seed: Number(seed), path, numRuns: 60 }) + else { + await fc.assert(initialWork, { seed: 20261022, numRuns: 60 }) + await fc.assert(initialWork, { numRuns: 60 }) + } +}) From 4f97ab4ded09a9845f3e722ff6e114c132ae529c Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Thu, 22 Oct 2026 12:03:25 -0700 Subject: [PATCH 03/20] fix: resolve offline types from source before package builds --- packages/convex-db-collection/tsconfig.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/convex-db-collection/tsconfig.json b/packages/convex-db-collection/tsconfig.json index 48460839d3..27b9c12a32 100644 --- a/packages/convex-db-collection/tsconfig.json +++ b/packages/convex-db-collection/tsconfig.json @@ -13,7 +13,8 @@ "jsx": "react", "paths": { "@tanstack/db": ["../db/src"], - "@tanstack/db-ivm": ["../db-ivm/src"] + "@tanstack/db-ivm": ["../db-ivm/src"], + "@tanstack/offline-transactions": ["../offline-transactions/src"] } }, "include": [ From 6511ee4ed0940cd54ee58ceeba7202ca859344b3 Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Fri, 23 Oct 2026 21:39:31 -0600 Subject: [PATCH 04/20] fix: require Convex source acceptance and strengthen integration evidence --- docs/collections/convex-collection.md | 15 +- docs/contributing/oracle-coverage.md | 25 +- .../functions/convexCollectionOptions.md | 2 +- .../convexPaginatedCollectionOptions.md | 2 +- .../interfaces/ConvexCollectionUtils.md | 6 +- .../functions/convexOfflineMutation.md | 2 +- .../offline/variables/convexMutationCodec.md | 2 +- .../type-aliases/ConvexCollectionClient.md | 2 +- .../type-aliases/ConvexCollectionConfig.md | 2 +- .../ConvexPaginatedCollectionConfig.md | 10 +- .../e2e/backend-oracle.test.ts | 225 ++++++++----- .../e2e/backend/convex/schema.ts | 1 + .../e2e/backend/convex/tasks.ts | 20 +- .../convex-db-collection/e2e/offline-child.ts | 16 +- packages/convex-db-collection/src/options.ts | 60 +++- .../src/paginated-options.ts | 13 +- .../convex-db-collection/src/pagination.ts | 11 + .../convex-db-collection/tests/helpers.ts | 33 ++ .../tests/offline-oracle.property.test.ts | 217 +++++++++++++ .../tests/offline.test.ts | 163 ++++++++++ .../tests/ownership-oracle.property.test.ts | 84 ++--- .../tests/pagination-oracle.property.test.ts | 279 +++++++++++++--- .../convex-db-collection/tests/sdk.test.ts | 300 +++++++++++------- .../tests/snapshot-oracle.property.test.ts | 97 +++--- .../tests/values-oracle.property.test.ts | 11 +- ...saction-serializer-oracle.property.test.ts | 16 +- 26 files changed, 1249 insertions(+), 365 deletions(-) diff --git a/docs/collections/convex-collection.md b/docs/collections/convex-collection.md index af6d071beb..fbd524f74e 100644 --- a/docs/collections/convex-collection.md +++ b/docs/collections/convex-collection.md @@ -81,7 +81,7 @@ const transaction = tasks.update(taskId, (task) => { await transaction.isPersisted.promise ``` -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. A rejected handler rolls back its optimistic writes. +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.cleanup()`, then `collection.preload()`, before retrying replay. A provider query error can recover when a later valid result arrives. A rejected handler rolls back its optimistic writes. Awaiting an HTTP mutation, an action, a different client's mutation, or a detached promise does not provide this query synchronization guarantee. Avoid applying Convex optimistic updates to the same query, because the adapter would receive those local results as source data. @@ -139,9 +139,9 @@ convexCollectionOptions({ Keep projections synchronous and treat query results as immutable. A projection can change the row shape. A standard schema can validate local writes through the collection's `schema` option. -## Automatic pagination +## Complete eager pagination -Use `convexPaginatedCollectionOptions` for a query that returns Convex's `PaginationResult`. The adapter supplies `paginationOpts` and loads every page automatically. +Use `convexPaginatedCollectionOptions` for a complete eager mirror of a finite query that returns Convex's `PaginationResult`. The adapter supplies `paginationOpts`, loads every page automatically, and keeps the full result and its page subscriptions in memory. `preload()` waits for the whole result. Set a server-side scope such as a project or workspace before using it with a large table. ```ts import { convexPaginatedCollectionOptions } from '@tanstack/convex-db-collection' @@ -153,6 +153,7 @@ const tasks = createCollection( query: api.tasks.paginated, queryArgs: { projectId }, pageSize: 100, + maxRows: 5_000, getKey: (task) => task._id, }), ) @@ -166,6 +167,12 @@ The adapter keeps each loaded cursor range subscribed. Fixed `endCursor` boundar `pageSize` defaults to 100 and must be a positive safe integer. It sets the initial request size. Reactive ranges can contain more rows after inserts. Both supported clients use their public query subscription APIs, without experimental pagination methods. +`maxRows` defaults to 10,000 and must be a positive safe integer. It caps the accepted complete result. An oversized result rejects with `RangeError` before the adapter publishes it or continues past a known oversized prefix. Initial `preload()` rejects; later growth preserves the last accepted snapshot and reports the error through `onError` and `getSyncError()`. Raise the finite cap only for a source size your application can hold. + +The cap does not limit the bytes in a server response or guarantee peak memory use. A single reactive page can already exceed it. `pageSize` does not bound total memory or initial load time. Initial acquisition can inspect earlier cached pages repeatedly as it extends the cursor chain. + +This API uses eager sync and does not translate DB's `loadSubset` requests. Demand-driven loading remains a separate API decision because it needs an application-supplied filter and ordering translation, ownership of overlapping windows, and rejection of unsupported requests. Until that API exists, narrow the server query or use another adapter when fetching only a requested window is required. + For paginated collections, `select` receives the complete row array. DB still applies local filters, joins, ordering, and limits. Pagination loads a finite query completely into memory, it does not push DB predicates to your server. ## Durable offline writes @@ -221,7 +228,7 @@ Use the offline action to create durable writes. Calling `tasks.insert()` direct The opt-in codec preserves Convex BigInt, binary data, special floats, and undefined fields in mutation changes. Keep the same codec configured across reloads. Missing or incompatible codecs stop replay and retain the stored work. Transaction metadata keeps the outbox’s existing JSON/Date/Temporal behavior. -The outbox records the transaction before sending it. After a reload, the executor restores pending optimistic writes and replays their named mutation functions. The bridge supplies the original `idempotencyKey`, waits for the Convex mutation, then waits for each configured Collection to accept its source snapshot. It does not wait for visibility held by that same optimistic transaction. +The outbox records the transaction before sending it. After a reload, the executor restores pending optimistic writes and replays their named mutation functions. The bridge supplies the original `idempotencyKey`, waits for the Convex mutation, then waits for each configured Collection to accept its source snapshot. 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.cleanup()`, then `collection.preload()`, before retrying replay. A provider query error can recover when a later valid result arrives. Convex handles WebSocket interruptions without rejecting the mutation promise. Argument-construction errors and rejected mutation calls are terminal failures. The bridge turns them into `NonRetriableError`, so the executor rolls back instead of retrying invalid writes indefinitely. A later source-sync failure can retry with the original key. diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index 0fe900d973..82229b4d1a 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -13,12 +13,12 @@ distinguishes canonical laws from their receiving witnesses across packages. ## Convex collection integration -The Convex package has five independent owners: +The Convex package has these independent judgments: - `packages/convex-db-collection/tests/snapshot-oracle.property.test.ts` compares public rows with the latest valid provider array. It covers missing-row deletion, full-row replacement, reintroduction, empty results, and atomic - duplicate-key rejection. + duplicate-key rejection, through both raw results and successful select projections. - `packages/convex-db-collection/tests/ownership-oracle.property.test.ts` owns concrete options admission separately from snapshot refinement. Its identity model crosses direct and shallow-copy reuse with idle, ready, and cleaned-up @@ -30,13 +30,13 @@ The Convex package has five independent owners: row. The claim repair passes both fixed and random 60-history campaigns and direct replay. It follows the existing LocalStorage concrete-options policy. This owner does not cover arbitrary Collection lifecycle or a real SDK's - multi-listener scheduling. SDK tests receive the cached-listener premise. + multi-listener scheduling. SDK tests receive the cached-listener premise and exercise both real clients with either sibling cleaned up before the survivor receives another update. - `packages/convex-db-collection/tests/pagination-oracle.property.test.ts` owns complete finite snapshots across reactive cursor ranges. It varies page sizes 1-4, forward/reverse callback delivery, row insertion/deletion/movement, and required/recommended/no split hints. The reference is the latest input array; the provider fixture independently answers numeric rank intervals. Comparison - occurs after the finite acquisition exhausts and sync acceptance settles. + checks provider row order after exhaustion and preserves the previous public snapshot while replacement ranges are pending. Split cursors stay inside their returned page ranges. The accepted-row bound rejects over-cap results without partial publication. Its initial-materialization work law also varies 3-40 pages and 1-8 rows per page. The original implementation copied incomplete prefixes and failed at seed 20261022/path 0:0:0:0. The repaired fixed/random campaigns copy each @@ -55,7 +55,7 @@ The Convex package has five independent owners: Each owner runs matching fixed and random campaigns and accepts explicit seed/path replay through their CONVEX_*_ORACLE_SEED/PATH environment variables. -Snapshot replay uses CONVEX_SNAPSHOT_ORACLE; pagination uses CONVEX_ORACLE; +Snapshot replay uses CONVEX_SNAPSHOT_ORACLE; pagination has separate CONVEX_PAGINATION_ROWS, CONVEX_PAGINATION_WORK, and CONVEX_PAGINATION_BOUND selectors; offline bridge replay uses CONVEX_OFFLINE_ORACLE; native value replay uses CONVEX_VALUES_ORACLE; ownership uses CONVEX_OWNERSHIP_ORACLE. @@ -67,9 +67,9 @@ and random insert/update/delete histories through SIGKILL after disk admission a a second SIGKILL after server acceptance but before recording outbox completion. It requires no offline provider call, restored Collection rows, and one server receipt after replay with the original key and the expected final server rows. -Native payload variation is insert-only. Atomic batches and multi-tab leadership +Native payload variation covers inserts and updates. A separate non-idempotent application counter must equal one, and the receipt key must match the on-disk durable key. Restored Collections are compared on every application field, including native payloads. Atomic batches and multi-tab leadership remain outside this backend witness. Replay uses -CONVEX_BACKEND_ORACLE_SEED/PATH. The command owns backend startup and shutdown. +CONVEX_BACKEND_INSERT_ORACLE_SEED/PATH, CONVEX_BACKEND_UPDATE_ORACLE_SEED/PATH, or CONVEX_BACKEND_DELETE_ORACLE_SEED/PATH, with matching operation test selection. The command owns backend startup and shutdown. The offline engine's interruption, settlement, storage, and leadership owners remain primary for those laws; this is their Convex receiving witness. @@ -98,13 +98,16 @@ availability, or power-loss atomicity during a filesystem write. The test backen has no application authorization; production authorization and atomic receipt storage are requirements of each application's server mutation. -Validation for this integration: 43 adapter checks, 302 offline-engine tests, -and seven real-backend tests (36 generated crash histories) pass. The native-value receiving oracle rejects a +The October 23 review adds real-Collection source-acceptance histories to the offline bridge owner: idle, pending first result, failed startup, provider failure, and cleanup. Successful empty or nonempty snapshots must actually be accepted. Original idle acceptance fails seed 20261023/path 0:0:0, the repaired fixed/random campaigns and direct replay pass. The same file keeps its ordered-stage model separate from the source-settlement and rejected-acceptance judgments. A rejected durable acceptance stays terminal for that sync run, same or changed cached rows cannot falsely certify a retry, and cleanup/restart with a fresh accepted snapshot recovers. The nonterminal mutant fails seed 20261023/path 0:0 under CONVEX_OFFLINE_ACCEPTANCE_ORACLE_SEED/PATH. Ordinary provider errors retain same-run valid-result recovery. Dedicated codec replay uses OFFLINE_CODEC_ORACLE_SEED/PATH/RUNS; legacy serializer properties keep OFFLINE_ORACLE selectors. + +The bounded eager API defaults maxRows to 10,000. It is not an on-demand window, a network-byte bound, a strict peak-memory bound, or a lifetime subscription-count bound. Cursor ranges remain subscribed, and cache scans still grow quadratically during acquisition. A separate on-demand API needs application-owned filter/order translation and demand-scoped publication/release witnesses before making those promises. + +Validation at the initial review: 43 adapter checks, 302 offline-engine tests, and seven real-backend tests passed. The October 23 strengthened backend suite passes nine tests with 36 generated crash histories. Final local validation passes 62 adapter checks and 302 offline-engine tests. Exact-head review records accompany this follow-up. The native-value receiving oracle rejects a scalar-as-JSON decoder at the typed-value comparison (seed 20261022/path 0:0); its repaired replay passes. `pnpm size:compare --packages -convex-db-collection,offline-transactions --files` measures 2,633 gzip / 2,381 +convex-db-collection,offline-transactions --files` measures 2,863 gzip / 2,594 brotli bytes for the new adapter's combined exports, with dependencies external. -The shared outbox grows by 192 gzip / 174 brotli bytes. A new adapter has no +The shared outbox grows by 192 gzip / 174 brotli bytes. Kyle review repairs add 230 gzip / 213 brotli bytes over the published PR head for actual first-source acceptance and the accepted-row bound. Existing tree-shaken app scenarios remain unchanged. Reusing the sync config materialization hook avoids a parallel startup lifecycle, and the existing page loop enforces the bound without a second pager. A new adapter has no baseline to subtract; normal and paginated sources share one snapshot writer, and the existing outbox supplies durability instead of a second write queue. The codec hook is needed to preserve native values without changing default diff --git a/docs/reference/convex-db-collection/functions/convexCollectionOptions.md b/docs/reference/convex-db-collection/functions/convexCollectionOptions.md index b6b054645b..474b327313 100644 --- a/docs/reference/convex-db-collection/functions/convexCollectionOptions.md +++ b/docs/reference/convex-db-collection/functions/convexCollectionOptions.md @@ -7,7 +7,7 @@ title: convexCollectionOptions function convexCollectionOptions(config): CollectionConfig & object & [TSchema] extends [never] ? unknown : object; ``` -Defined in: index.ts:48 +Defined in: [index.ts:48](https://github.com/tanstack/db/blob/main/packages/convex-db-collection/src/index.ts#L48) Mirror one authoritative Convex query result. Filters and joins run in DB. diff --git a/docs/reference/convex-db-collection/functions/convexPaginatedCollectionOptions.md b/docs/reference/convex-db-collection/functions/convexPaginatedCollectionOptions.md index 0cf440623e..1cbe74a6d8 100644 --- a/docs/reference/convex-db-collection/functions/convexPaginatedCollectionOptions.md +++ b/docs/reference/convex-db-collection/functions/convexPaginatedCollectionOptions.md @@ -7,7 +7,7 @@ title: convexPaginatedCollectionOptions function convexPaginatedCollectionOptions(config): CollectionConfig & object & [TSchema] extends [never] ? unknown : object; ``` -Defined in: paginated-options.ts:51 +Defined in: [paginated-options.ts:53](https://github.com/tanstack/db/blob/main/packages/convex-db-collection/src/paginated-options.ts#L53) Automatically subscribe to every page, retaining fixed reactive cursor ranges. diff --git a/docs/reference/convex-db-collection/interfaces/ConvexCollectionUtils.md b/docs/reference/convex-db-collection/interfaces/ConvexCollectionUtils.md index ef71817b50..c2a658d997 100644 --- a/docs/reference/convex-db-collection/interfaces/ConvexCollectionUtils.md +++ b/docs/reference/convex-db-collection/interfaces/ConvexCollectionUtils.md @@ -3,7 +3,7 @@ id: ConvexCollectionUtils title: ConvexCollectionUtils --- -Defined in: options.ts:11 +Defined in: [options.ts:15](https://github.com/tanstack/db/blob/main/packages/convex-db-collection/src/options.ts#L15) ## Extends @@ -23,7 +23,7 @@ Defined in: options.ts:11 awaitSync: () => Promise; ``` -Defined in: options.ts:15 +Defined in: [options.ts:19](https://github.com/tanstack/db/blob/main/packages/convex-db-collection/src/options.ts#L19) Refresh the source cache and wait for sync acceptance, safe inside mutation handlers. @@ -39,7 +39,7 @@ Refresh the source cache and wait for sync acceptance, safe inside mutation hand getSyncError: () => unknown; ``` -Defined in: options.ts:13 +Defined in: [options.ts:17](https://github.com/tanstack/db/blob/main/packages/convex-db-collection/src/options.ts#L17) Last subscription or conversion error, cleared by the next valid result. diff --git a/docs/reference/convex-db-collection/offline/functions/convexOfflineMutation.md b/docs/reference/convex-db-collection/offline/functions/convexOfflineMutation.md index 267b423673..c6aca1a944 100644 --- a/docs/reference/convex-db-collection/offline/functions/convexOfflineMutation.md +++ b/docs/reference/convex-db-collection/offline/functions/convexOfflineMutation.md @@ -7,7 +7,7 @@ title: convexOfflineMutation function convexOfflineMutation(config): OfflineMutationFn; ``` -Defined in: offline.ts:21 +Defined in: [offline.ts:21](https://github.com/tanstack/db/blob/main/packages/convex-db-collection/src/offline.ts#L21) Use as a named mutationFn in startOfflineExecutor. The existing outbox owns durability, restoration, leadership and retries. The server mutation must diff --git a/docs/reference/convex-db-collection/offline/variables/convexMutationCodec.md b/docs/reference/convex-db-collection/offline/variables/convexMutationCodec.md index 8a4f3c597f..923ca3fdd3 100644 --- a/docs/reference/convex-db-collection/offline/variables/convexMutationCodec.md +++ b/docs/reference/convex-db-collection/offline/variables/convexMutationCodec.md @@ -7,6 +7,6 @@ title: convexMutationCodec const convexMutationCodec: MutationValueCodec; ``` -Defined in: offline.ts:59 +Defined in: [offline.ts:59](https://github.com/tanstack/db/blob/main/packages/convex-db-collection/src/offline.ts#L59) Lossless, opt-in outbox encoding for Convex values and removed fields. diff --git a/docs/reference/convex-db-collection/type-aliases/ConvexCollectionClient.md b/docs/reference/convex-db-collection/type-aliases/ConvexCollectionClient.md index 086dc66b79..993938f0d0 100644 --- a/docs/reference/convex-db-collection/type-aliases/ConvexCollectionClient.md +++ b/docs/reference/convex-db-collection/type-aliases/ConvexCollectionClient.md @@ -9,4 +9,4 @@ type ConvexCollectionClient = | Pick; ``` -Defined in: subscription.ts:9 +Defined in: [subscription.ts:9](https://github.com/tanstack/db/blob/main/packages/convex-db-collection/src/subscription.ts#L9) diff --git a/docs/reference/convex-db-collection/type-aliases/ConvexCollectionConfig.md b/docs/reference/convex-db-collection/type-aliases/ConvexCollectionConfig.md index 9ae6cb7d4b..e5eb698e7e 100644 --- a/docs/reference/convex-db-collection/type-aliases/ConvexCollectionConfig.md +++ b/docs/reference/convex-db-collection/type-aliases/ConvexCollectionConfig.md @@ -11,7 +11,7 @@ type ConvexCollectionConfig = Omit extends ReadonlyArray ? object : never; ``` -Defined in: index.ts:21 +Defined in: [index.ts:21](https://github.com/tanstack/db/blob/main/packages/convex-db-collection/src/index.ts#L21) ## Type Declaration diff --git a/docs/reference/convex-db-collection/type-aliases/ConvexPaginatedCollectionConfig.md b/docs/reference/convex-db-collection/type-aliases/ConvexPaginatedCollectionConfig.md index d7b068f77d..b9d3695d3a 100644 --- a/docs/reference/convex-db-collection/type-aliases/ConvexPaginatedCollectionConfig.md +++ b/docs/reference/convex-db-collection/type-aliases/ConvexPaginatedCollectionConfig.md @@ -11,7 +11,7 @@ type ConvexPaginatedCollectionConfig = Omit extends TItem ? object : never; ``` -Defined in: paginated-options.ts:20 +Defined in: [paginated-options.ts:20](https://github.com/tanstack/db/blob/main/packages/convex-db-collection/src/paginated-options.ts#L20) ## Type Declaration @@ -21,6 +21,14 @@ Defined in: paginated-options.ts:20 client: ConvexCollectionClient; ``` +### maxRows? + +```ts +optional maxRows: number; +``` + +Maximum accepted rows in the complete eager mirror. Defaults to 10,000. + ### onError()? ```ts diff --git a/packages/convex-db-collection/e2e/backend-oracle.test.ts b/packages/convex-db-collection/e2e/backend-oracle.test.ts index f56b7f23a6..df9efada1e 100644 --- a/packages/convex-db-collection/e2e/backend-oracle.test.ts +++ b/packages/convex-db-collection/e2e/backend-oracle.test.ts @@ -7,13 +7,16 @@ * * The durable driver checks the outbox's documented process-restart law with * actual disk storage and SIGKILL. Its independent model applies one logical - * insert, update, or delete, hence the draft row or its absence and one receipt. + * insert, update, or delete, hence the draft row or its absence, one receipt + * containing the durable key, and one increment of a non-idempotent counter. * Histories vary the operation and draft's text/rank, plus whether a second - * crash loses the server acknowledgement. Native values vary on inserted rows. + * crash loses the server acknowledgement. Native values vary on inserted and updated rows. * A server-accepted checkpoint occurs before the outbox phase can be recorded, * so replay must reuse its key rather than rely on a completed local marker. * Checkpoints are observed in child stdout before killing the process. Server - * rows/receipts and replayed Collection rows are checked only after replay. + * rows, receipt keys, application count, and complete replayed Collection rows + * are checked after replay. Equal task rows alone cannot distinguish a duplicate + * server application, while the independently observed counter can. * * This is a receiving witness for the offline engine's interruption/settlement * owners, not another model of leadership. It covers one pending mutation in one @@ -29,6 +32,7 @@ import { ConvexReactClient } from 'convex/react' import { makeFunctionReference } from 'convex/server' import { describe, expect, inject, it } from 'vitest' import { convexPaginatedCollectionOptions } from '../src/index' +import { withTestCleanup } from '../tests/helpers' import type { Value } from 'convex/values' import type { ConvexCollectionClient } from '../src/index' import type { PaginationOptions, PaginationResult } from 'convex/server' @@ -61,6 +65,36 @@ const url = inject(`convexUrl`) describe.each([`javascript`, `react`] as const)( `real backend with %s client`, (kind) => { + it(`rejects a mirror beyond its row bound without publishing a partial result`, async () => { + const client = + kind === `javascript` + ? new ConvexClient(url, { unsavedChangesWarning: false }) + : new ConvexReactClient(url, { unsavedChangesWarning: false }) + const collection = createCollection( + convexPaginatedCollectionOptions({ + client, + query: makeFunctionReference< + `query`, + { paginationOpts: PaginationOptions }, + PaginationResult + >(`tasks:list`), + queryArgs: {}, + pageSize: 4, + maxRows: 5, + getKey: (task) => task.clientId, + gcTime: 0, + }), + ) + await withTestCleanup( + async () => { + await client.mutation(seed, { count: 23 }) + await expect(collection.preload()).rejects.toBeInstanceOf(RangeError) + expect([...collection.values()]).toEqual([]) + }, + () => collection.cleanup(), + () => client.close(), + ) + }) it.each([`list`, `split`] as const)( `loads %s pages through inserts, moves, deletions, and server splits`, async (functionName) => { @@ -68,7 +102,6 @@ describe.each([`javascript`, `react`] as const)( kind === `javascript` ? new ConvexClient(url, { unsavedChangesWarning: false }) : new ConvexReactClient(url, { unsavedChangesWarning: false }) - await client.mutation(seed, { count: 23 }) let splitResponses = 0 const inspect = (value: unknown): void => { if ( @@ -137,37 +170,44 @@ describe.each([`javascript`, `react`] as const)( })), ) } - try { - await collection.preload() - expect(collection.size).toBe(23) - await check() - for (const operation of [ - { - clientId: `new-first`, - rank: -10, - text: `insert before all pages`, - }, - { - clientId: `new-last`, - rank: 100, - text: `append after exhaustion`, - }, - { clientId: `3`, rank: 3, text: `delete a boundary`, remove: true }, - { clientId: `12`, rank: -5, text: `move across cursor ranges` }, - { clientId: `8`, rank: 8, text: `update a middle page` }, - ]) { - await client.mutation(write, { - ...operation, - idempotencyKey: crypto.randomUUID(), - }) + await withTestCleanup( + async () => { + await client.mutation(seed, { count: 23 }) + await collection.preload() + expect(collection.size).toBe(23) await check() - } - if (functionName === `split`) - expect(splitResponses).toBeGreaterThan(0) - } finally { - await collection.cleanup() - await client.close() - } + for (const operation of [ + { + clientId: `new-first`, + rank: -10, + text: `insert before all pages`, + }, + { + clientId: `new-last`, + rank: 100, + text: `append after exhaustion`, + }, + { + clientId: `3`, + rank: 3, + text: `delete a boundary`, + remove: true, + }, + { clientId: `12`, rank: -5, text: `move across cursor ranges` }, + { clientId: `8`, rank: 8, text: `update a middle page` }, + ]) { + await client.mutation(write, { + ...operation, + idempotencyKey: crypto.randomUUID(), + }) + await check() + } + if (functionName === `split`) + expect(splitResponses).toBeGreaterThan(0) + }, + () => collection.cleanup(), + () => client.close(), + ) }, ) }, @@ -179,20 +219,12 @@ async function durableHistory( operation: `insert` | `update` | `delete`, ) { const { spawn } = await import('node:child_process') - const { mkdtemp, rm } = await import('node:fs/promises') + const { mkdtemp, rm, readdir, readFile } = await import('node:fs/promises') const { tmpdir } = await import('node:os') const { join } = await import('node:path') const { fileURLToPath } = await import('node:url') const directory = await mkdtemp(join(tmpdir(), `convex-outbox-`)) const client = new ConvexClient(url, { unsavedChangesWarning: false }) - await client.mutation(seed, { count: operation === `insert` ? 0 : 1 }) - const before = (await client.query(all, {})).map( - ({ clientId, text, rank, payload }) => ({ clientId, text, rank, payload }), - ) - // The public write contract gives one final draft for insert/update and no - // row for delete. These expectations do not inspect restored mutations. - const expected = - operation === `delete` ? [] : [{ ...draft, payload: draft.payload }] async function run(mode: string, target: string): Promise> { const child = spawn( process.execPath, @@ -243,46 +275,78 @@ async function durableHistory( .filter((line) => line.startsWith(`CHECKPOINT:`)) .map((line) => line.slice(11)) } - try { - const admitted = await run(`admit`, `durable`) - expect(admitted).not.toContain(`provider-call`) - expect( - (await client.query(all, {})).map( + await withTestCleanup( + async () => { + await client.mutation(seed, { count: operation === `insert` ? 0 : 1 }) + const before = (await client.query(all, {})).map( ({ clientId, text, rank, payload }) => ({ clientId, text, rank, payload, }), - ), - ).toEqual(before) - if (uncertain) { - expect(await run(`uncertain`, `server-accepted`)).toContain( - `provider-call`, ) - expect((await client.query(all, {})).map((row) => row.clientId)).toEqual( - expected.map((row) => row.clientId), + // The public write contract gives one final draft for insert/update and no + // row for delete. These expectations do not inspect restored mutations. + const expected = + operation === `delete` ? [] : [{ ...draft, payload: draft.payload }] + const admitted = await run(`admit`, `durable`) + expect(admitted).not.toContain(`provider-call`) + // Read the actual admission record after SIGKILL, independently of the + // replay child. A fresh key on replay cannot pass a receipt-count check. + const records = (await readdir(directory)).filter((name) => + decodeURIComponent(name).startsWith(`tx:`), ) - } - expect(await run(`replay`, `replayed`)).toContain(`provider-call`) - const receipts = makeFunctionReference<`query`, {}, Array<{ key: string }>>( - `tasks:receipts`, - ) - expect(await client.query(receipts, {})).toHaveLength(1) - expect( - (await client.query(all, {})).map( - ({ clientId, text, rank, payload }) => ({ - clientId, - text, - rank, - payload, - }), - ), - ).toEqual(expected) - } finally { - await client.close() - await rm(directory, { recursive: true, force: true }) - } + expect(records).toHaveLength(1) + const durable: { idempotencyKey: unknown } = JSON.parse( + await readFile(join(directory, records[0]!), `utf8`), + ) + expect(typeof durable.idempotencyKey).toBe(`string`) + expect( + (await client.query(all, {})).map( + ({ clientId, text, rank, payload }) => ({ + clientId, + text, + rank, + payload, + }), + ), + ).toEqual(before) + if (uncertain) { + expect(await run(`uncertain`, `server-accepted`)).toContain( + `provider-call`, + ) + expect( + (await client.query(all, {})).map((row) => row.clientId), + ).toEqual(expected.map((row) => row.clientId)) + } + expect(await run(`replay`, `replayed`)).toContain(`provider-call`) + const receipts = makeFunctionReference< + `query`, + {}, + Array<{ key: string }> + >(`tasks:receipts`) + expect((await client.query(receipts, {})).map(({ key }) => key)).toEqual([ + durable.idempotencyKey, + ]) + const applicationCount = makeFunctionReference<`query`, {}, number>( + `tasks:applicationCount`, + ) + expect(await client.query(applicationCount, {})).toBe(1) + expect( + (await client.query(all, {})).map( + ({ clientId, text, rank, payload }) => ({ + clientId, + text, + rank, + payload, + }), + ), + ).toEqual(expected) + }, + () => client.close(), + () => rm(directory, { recursive: true, force: true }), + ) } it.each([`insert`, `update`, `delete`] as const)( @@ -300,7 +364,7 @@ it.each([`insert`, `update`, `delete`] as const)( : fc.integer({ min: -50, max: 50 }), uncertain: fc.boolean(), payload: - operation === `insert` + operation !== `delete` ? fc.oneof( fc.bigInt({ min: -(2n ** 63n), max: 2n ** 63n - 1n }), fc.constantFrom(NaN, Infinity, -Infinity, -0), @@ -316,18 +380,21 @@ it.each([`insert`, `update`, `delete`] as const)( clientId: operation === `insert` ? `offline-created` : `0`, text, rank, - ...(operation === `insert` ? { payload } : {}), + ...(operation !== `delete` ? { payload } : {}), }, uncertain, operation, ), ) - const replaySeed = process.env.CONVEX_BACKEND_ORACLE_SEED + // Each grammar owns its seed/path pair. An insert shrink path is not a + // delete path, whose text/rank/payload dimensions are intentionally fixed. + const replayPrefix = `CONVEX_BACKEND_${operation.toUpperCase()}_ORACLE` + const replaySeed = process.env[`${replayPrefix}_SEED`] if (replaySeed) await fc.assert(property, { numRuns: 6, seed: Number(replaySeed), - path: process.env.CONVEX_BACKEND_ORACLE_PATH, + path: process.env[`${replayPrefix}_PATH`], }) else { await fc.assert(property, { numRuns: 6, seed: 20261022 }) diff --git a/packages/convex-db-collection/e2e/backend/convex/schema.ts b/packages/convex-db-collection/e2e/backend/convex/schema.ts index f6c27226bd..3129c58112 100644 --- a/packages/convex-db-collection/e2e/backend/convex/schema.ts +++ b/packages/convex-db-collection/e2e/backend/convex/schema.ts @@ -10,5 +10,6 @@ export default defineSchema({ }) .index('by_rank', ['rank']) .index('by_clientId', ['clientId']), + applications: defineTable({ count: v.number() }), receipts: defineTable({ key: v.string() }).index('by_key', ['key']), }) diff --git a/packages/convex-db-collection/e2e/backend/convex/tasks.ts b/packages/convex-db-collection/e2e/backend/convex/tasks.ts index 77c165337b..10045a4e5d 100644 --- a/packages/convex-db-collection/e2e/backend/convex/tasks.ts +++ b/packages/convex-db-collection/e2e/backend/convex/tasks.ts @@ -21,6 +21,8 @@ export const seed = mutationGeneric({ await ctx.db.delete(row._id) for (const row of await ctx.db.query('receipts').collect()) await ctx.db.delete(row._id) + for (const row of await ctx.db.query('applications').collect()) + await ctx.db.delete(row._id) for (let i = 0; i < count; i++) await ctx.db.insert('tasks', { clientId: String(i), @@ -44,6 +46,12 @@ export const write = mutationGeneric({ .withIndex('by_key', (q) => q.eq('key', args.idempotencyKey)) .unique() if (receipt) return + // This observable effect is deliberately non-idempotent. Reapplying an + // acknowledged write must increment it, even when the final task is equal. + const applications = await ctx.db.query('applications').unique() + if (applications) + await ctx.db.patch(applications._id, { count: applications.count + 1 }) + else await ctx.db.insert('applications', { count: 1 }) const previous = await ctx.db .query('tasks') .withIndex('by_clientId', (q) => q.eq('clientId', args.clientId)) @@ -51,7 +59,11 @@ export const write = mutationGeneric({ if (args.remove) { if (previous) await ctx.db.delete(previous._id) } else if (previous) - await ctx.db.patch(previous._id, { text: args.text, rank: args.rank }) + await ctx.db.patch(previous._id, { + text: args.text, + rank: args.rank, + payload: args.payload, + }) else await ctx.db.insert('tasks', { clientId: args.clientId, @@ -67,6 +79,12 @@ export const receipts = queryGeneric({ handler: (ctx) => ctx.db.query('receipts').collect(), }) +export const applicationCount = queryGeneric({ + args: {}, + handler: async (ctx) => + (await ctx.db.query('applications').unique())?.count ?? 0, +}) + // A low read budget forces genuine server page splits during the range tests. export const split = queryGeneric({ args: { paginationOpts: paginationOptsValidator }, diff --git a/packages/convex-db-collection/e2e/offline-child.ts b/packages/convex-db-collection/e2e/offline-child.ts index 0bde77fc73..6a741e13b7 100644 --- a/packages/convex-db-collection/e2e/offline-child.ts +++ b/packages/convex-db-collection/e2e/offline-child.ts @@ -1,3 +1,4 @@ +import { deepStrictEqual } from 'node:assert' import { mkdir, readFile, @@ -173,12 +174,17 @@ if (offline) { if ((await executor.peekOutbox()).length === 0) { clearInterval(poll) await collection.utils.awaitSync() - if ( - operation === `delete` - ? collection.has(draft.clientId) - : collection.get(draft.clientId)?.text !== draft.text + // Observe complete public rows, including signed zero and binary bytes, + // after replay settles. Text alone cannot detect a stale native payload. + deepStrictEqual( + [...collection.values()].map(({ clientId, text, rank, payload }) => ({ + clientId, + text, + rank, + ...(payload === undefined ? {} : { payload }), + })), + operation === `delete` ? [] : [draft], ) - throw new Error(`Restored row did not reach the collection`) checkpoint(`replayed`) executor.dispose() await collection.cleanup() diff --git a/packages/convex-db-collection/src/options.ts b/packages/convex-db-collection/src/options.ts index d3964e71fe..d8e4c4edab 100644 --- a/packages/convex-db-collection/src/options.ts +++ b/packages/convex-db-collection/src/options.ts @@ -1,4 +1,8 @@ -import { whenSyncAccepted, withCollectionConfigFactory } from '@tanstack/db' +import { + whenSyncAccepted, + withCollectionConfigFactory, + withCollectionSyncConfigFactory, +} from '@tanstack/db' import type { StandardSchemaV1 } from '@standard-schema/spec' import type { BaseCollectionConfig, @@ -53,10 +57,20 @@ export function createConvexOptions< let previous = new Map() let initialized = false let visible: true | Promise = true + let acceptanceFailure: { error: unknown } | undefined + let resolveFirst!: () => void + let rejectFirst!: (error: unknown) => void + const firstResult = new Promise((resolve, reject) => { + resolveFirst = resolve + rejectFirst = reject + }) + // A subscription may fail before any caller asks to await its source. + void firstResult.catch(() => {}) const accepting = new Set>() const fail = (error: unknown): void => { if (!active) return syncError = error + rejectFirst(error) if (collection.status === `loading` || collection.status === `error`) { markError(error) } @@ -64,6 +78,7 @@ export function createConvexOptions< } const accept = (result: TResult): void => { if (!active) return + if (acceptanceFailure) throw acceptanceFailure.error // Validate the entire result before opening a sync transaction. A bad // snapshot must never leave a half-written transaction behind. const rows: unknown = select ? select(result) : result @@ -131,9 +146,16 @@ export function createConvexOptions< accepting.add(accepted) void accepted.then( () => accepting.delete(accepted), - () => accepting.delete(accepted), + (error) => { + accepting.delete(accepted) + // Later diffs may depend on this rejected source proposal. + // A new sync run must establish an authoritative base again. + acceptanceFailure = { error } + fail(error) + }, ) } + resolveFirst() if (receipt === true) markReady() else void receipt.then(() => { @@ -152,18 +174,24 @@ export function createConvexOptions< } let subscription: ConvexSubscription | undefined const refresh = async (): Promise => { - if (!active || !subscription) return + if (acceptanceFailure) throw acceptanceFailure.error + if (!active || !subscription) { + if (syncError !== undefined) throw syncError + throw new Error(`The Convex collection has no active subscription.`) + } try { await subscription.waitForCurrent?.() // Cleanup can run while the asynchronous page acquisition waits. // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (!active) return + if (!active) + throw new Error(`Convex sync ended before source acceptance.`) const value = subscription.getCurrentValue() if (value !== undefined) accept(value) } catch (error) { fail(error) throw error } + if (!initialized) await firstResult if (syncError !== undefined) throw syncError await Promise.all([...accepting]) } @@ -177,6 +205,7 @@ export function createConvexOptions< } return () => { active = false + rejectFirst(new Error(`Convex sync ended before source acceptance.`)) if (readCurrent === refresh) readCurrent = undefined subscription?.unsubscribe() } @@ -191,8 +220,14 @@ export function createConvexOptions< ) { return async (params: TParams): Promise => { await handler(params) - await readCurrent?.() + await awaitSource() + } + } + async function awaitSource(): Promise { + if (!readCurrent) { + throw new Error(`The Convex collection has no active subscription.`) } + await readCurrent() } const options: CollectionConfig< TItem, @@ -202,15 +237,22 @@ export function createConvexOptions< > & { utils: ConvexCollectionUtils } = { ...baseConfig, syncMode: `eager` as const, - sync, + sync: withCollectionSyncConfigFactory( + sync, + (source, utils, startSyncIfIdle) => { + ;(utils as ConvexCollectionUtils).awaitSync = async () => { + startSyncIfIdle() + await awaitSource() + } + return source + }, + ), onInsert: config.onInsert && wrapHandler(config.onInsert), onUpdate: config.onUpdate && wrapHandler(config.onUpdate), onDelete: config.onDelete && wrapHandler(config.onDelete), utils: { getSyncError: () => syncError, - awaitSync: async () => { - await readCurrent?.() - }, + awaitSync: awaitSource, }, } let claimed = false diff --git a/packages/convex-db-collection/src/paginated-options.ts b/packages/convex-db-collection/src/paginated-options.ts index 48a8be5341..1fd8b07291 100644 --- a/packages/convex-db-collection/src/paginated-options.ts +++ b/packages/convex-db-collection/src/paginated-options.ts @@ -30,6 +30,8 @@ export type ConvexPaginatedCollectionConfig< query: TQuery queryArgs: Omit, `paginationOpts`> pageSize?: number + /** Maximum accepted rows in the complete eager mirror. Defaults to 10,000. */ + maxRows?: number onError?: (error: unknown) => void } & ([TSchema] extends [never] ? { schema?: never } : { schema: TSchema }) & (FunctionReturnType extends PaginationResult @@ -54,13 +56,20 @@ export function convexPaginatedCollectionOptions< TKey extends string | number = string | number, TSchema extends StandardSchemaV1 = never, >(config: ConvexPaginatedCollectionConfig) { - const { client, query, queryArgs, pageSize = 100, ...options } = config + const { + client, + query, + queryArgs, + pageSize = 100, + maxRows = 10_000, + ...options + } = config return createConvexOptions< ReadonlyArray>, TItem, TKey, TSchema >(options, (receive, fail) => - subscribePages(client, query, queryArgs, pageSize, receive, fail), + subscribePages(client, query, queryArgs, pageSize, maxRows, receive, fail), ) } diff --git a/packages/convex-db-collection/src/pagination.ts b/packages/convex-db-collection/src/pagination.ts index 4e44ad9205..255f7988de 100644 --- a/packages/convex-db-collection/src/pagination.ts +++ b/packages/convex-db-collection/src/pagination.ts @@ -23,12 +23,16 @@ export function subscribePages, TRow>( query: TQuery, args: Omit, `paginationOpts`>, pageSize: number, + maxRows: number, receive: (rows: ReadonlyArray) => void, fail: (error: unknown) => void, ): ConvexSubscription> { if (!Number.isSafeInteger(pageSize) || pageSize <= 0) { throw new RangeError(`Convex pageSize must be a positive safe integer.`) } + if (!Number.isSafeInteger(maxRows) || maxRows <= 0) { + throw new RangeError(`Convex maxRows must be a positive safe integer.`) + } let active = true let scheduled = false let flushing = false @@ -105,6 +109,7 @@ export function subscribePages, TRow>( result = undefined try { const completePages: Array> = [] + let rowCount = 0 for (let index = 0; index < pages.length; index++) { const page = pages[index]! const value = page.subscription!.getCurrentValue() @@ -118,6 +123,12 @@ export function subscribePages, TRow>( `Convex paginated queries must return PaginationResult.`, ) } + rowCount += value.page.length + if (rowCount > maxRows) { + throw new RangeError( + `Convex eager mirror exceeds maxRows (${maxRows}).`, + ) + } const split = value.pageStatus === `SplitRequired` || value.pageStatus === `SplitRecommended` diff --git a/packages/convex-db-collection/tests/helpers.ts b/packages/convex-db-collection/tests/helpers.ts index ba65699cb8..bb39b210d5 100644 --- a/packages/convex-db-collection/tests/helpers.ts +++ b/packages/convex-db-collection/tests/helpers.ts @@ -69,3 +69,36 @@ export function controlledClient() { counts: () => ({ subscriptions, unsubscriptions }), } } + +/** Preserve the law failure and still attempt every registered resource release. */ +export async function withTestCleanup( + body: () => void | Promise, + ...cleanups: Array<() => void | Promise> +): Promise { + let primary: { error: unknown } | undefined + try { + await body() + } catch (error) { + primary = { error } + } + const errors: Array = [] + for (const cleanup of cleanups) { + try { + await cleanup() + } catch (error) { + errors.push(error) + } + } + if (primary) { + if (errors.length) + throw new AggregateError( + [primary.error, ...errors], + `Comparison and cleanup failed`, + { cause: primary.error }, + ) + throw primary.error + } + if (errors.length === 1) throw errors[0] + if (errors.length) + throw new AggregateError(errors, `Cleanup failed`, { cause: errors[0] }) +} diff --git a/packages/convex-db-collection/tests/offline-oracle.property.test.ts b/packages/convex-db-collection/tests/offline-oracle.property.test.ts index 2523e3f404..51f2e1119e 100644 --- a/packages/convex-db-collection/tests/offline-oracle.property.test.ts +++ b/packages/convex-db-collection/tests/offline-oracle.property.test.ts @@ -12,11 +12,14 @@ * not model outbox storage or leadership, whose owners remain in that package. * The real process-crash witness uses actual Convex mutations in e2e. */ +import { createCollection, withAcceptedReceipt } from '@tanstack/db' import fc from 'fast-check' import { NonRetriableError } from '@tanstack/offline-transactions' import { makeFunctionReference } from 'convex/server' import { expect, it } from 'vitest' +import { convexCollectionOptions } from '../src/index' import { convexOfflineMutation } from '../src/offline' +import { controlledClient, query, withTestCleanup } from './helpers' import type { ConvexClient } from 'convex/browser' import type { OfflineConfig } from '@tanstack/offline-transactions' @@ -98,3 +101,217 @@ it(`preserves ordered replay and retry classification across bridge failures`, a await fc.assert(property, { numRuns: 60 }) } }) + +/** + * Source acceptance is owed by the bridge's documented Collection boundary, + * even on cold replay. This second driver uses real Collections, rather than + * treating an awaitSync stub as evidence of source acceptance. The independent + * model has one fact, whether a valid source snapshot has arrived. Before that + * fact, a successful mutation cannot fulfill replay. A provider failure rejects + * replay and preserves its cause. A valid empty snapshot counts as acceptance. + * + * Histories start idle, awaiting a result, or failed during startup, then cross + * empty/nonempty snapshots and failures before the first result. The provider + * holds callbacks at an explicit cut. We observe bridge settlement and public + * rows before and after that delivery. This does not model durable outbox I/O. + */ +const sourceProperty = fc.asyncProperty( + fc.record({ + state: fc.constantFrom(`idle`, `pending`, `startup-error`), + delivery: fc.constantFrom(`empty`, `rows`, `error`, `cleanup`), + text: fc.string({ maxLength: 15 }), + }), + async ({ state, delivery, text }) => { + const transport = controlledClient() + const cause = new Error(`source unavailable`) + const collection = createCollection( + convexCollectionOptions({ + client: + state === `startup-error` + ? { + onUpdate: () => { + throw cause + }, + } + : transport.client, + query, + queryArgs: { owner: `alice` }, + getKey: (row) => row._id, + gcTime: 0, + }), + ) + if (state !== `idle`) collection.startSyncImmediate() + const persist = convexOfflineMutation({ + client: { mutation: () => Promise.resolve(null) } as unknown as Pick< + ConvexClient, + `mutation` + >, + mutation, + getArgs: () => ({ text }), + collections: [collection], + }) + let outcome: { status: string; error?: unknown } = { status: `pending` } + const pending = persist({ + transaction: { mutations: [] }, + idempotencyKey: `source-key`, + } as unknown as Parameters[0]).then( + () => { + outcome = { status: `fulfilled` } + }, + (error) => { + outcome = { status: `rejected`, error } + }, + ) + await withTestCleanup( + async () => { + await new Promise((resolve) => setTimeout(resolve, 0)) + if (state === `startup-error`) { + expect(outcome).toEqual({ status: `rejected`, error: cause }) + } else { + expect(outcome.status).toBe(`pending`) + expect(transport.counts().subscriptions).toBe(1) + if (delivery === `error`) transport.error(cause) + else if (delivery === `cleanup`) await collection.cleanup() + else transport.emit(delivery === `empty` ? [] : [{ _id: `a`, text }]) + await pending + expect(outcome.status).toBe( + delivery === `error` || delivery === `cleanup` + ? `rejected` + : `fulfilled`, + ) + if (delivery === `error`) { + expect(outcome.error).toBe(cause) + // Provider errors can recover in this run when a valid result arrives. + transport.emit([{ _id: `a`, text }]) + await persist({ + transaction: { mutations: [] }, + idempotencyKey: `source-key`, + } as unknown as Parameters[0]) + expect(collection.get(`a`)?.text).toBe(text) + } + if (delivery === `rows`) expect(collection.get(`a`)?.text).toBe(text) + if (delivery === `empty`) expect(collection.size).toBe(0) + } + }, + async () => { + await collection.cleanup() + }, + ) + }, +) + +it(`requires real source acceptance through cold replay histories`, async () => { + const seed = process.env.CONVEX_OFFLINE_SOURCE_ORACLE_SEED + if (seed) + await fc.assert(sourceProperty, { + seed: Number(seed), + path: process.env.CONVEX_OFFLINE_SOURCE_ORACLE_PATH, + numRuns: 60, + }) + else { + await fc.assert(sourceProperty, { seed: 20261023, numRuns: 60 }) + await fc.assert(sourceProperty, { numRuns: 60 }) + } +}) + +/** + * A durable wrapper can reject before handing a committed source proposal to + * core, as specified by whenSyncAccepted. Once that happens, later diffs cannot + * assume that proposal became the source base. This run stays failed until an + * explicit cleanup/restart establishes a fresh authoritative snapshot. + * + * The reference has two states, failed run and restarted run. Same-cache reads + * and new provider callbacks cannot distinguish failed states, all reject with + * the durable cause. Restart is the one action that changes that judgment. + * Histories vary empty/nonempty initial snapshots and one to four retries with + * unchanged or changed cached rows. Actual commit is deferred behind a rejected + * durability promise, matching the persistence adapter's acceptance boundary. + */ +const acceptanceProperty = fc.asyncProperty( + fc.boolean(), + fc.array(fc.boolean(), { minLength: 1, maxLength: 4 }), + fc.string({ maxLength: 12 }), + async (empty, retries, text) => { + const transport = controlledClient() + const cause = new Error(`durable acceptance failed`) + let rejectDurable!: (error: unknown) => void + let durable = new Promise((_resolve, reject) => { + rejectDurable = reject + }) + const options = convexCollectionOptions({ + client: transport.client, + query, + queryArgs: { owner: `alice` }, + getKey: (row) => row._id, + gcTime: 0, + }) + const collection = createCollection({ + ...options, + sync: { + ...options.sync, + sync: (syncParams) => + options.sync.sync({ + ...syncParams, + commit: () => { + const visible = durable + .then(async () => { + await syncParams.commit() + }) + .then(() => undefined) + return withAcceptedReceipt(visible, durable) + }, + }), + }, + }) + const persist = convexOfflineMutation({ + client: { mutation: () => Promise.resolve(null) } as unknown as Pick< + ConvexClient, + `mutation` + >, + mutation, + getArgs: () => ({ text }), + collections: [collection], + }) + const params = { + transaction: { mutations: [] }, + idempotencyKey: `same-durable-key`, + } as unknown as Parameters[0] + await withTestCleanup( + async () => { + const pending = persist(params) + const rejected = expect(pending).rejects.toBe(cause) + await new Promise((resolve) => setTimeout(resolve, 0)) + const initial = empty ? [] : [{ _id: `a`, text }] + transport.emit(initial) + rejectDurable(cause) + await rejected + for (const changed of retries) { + if (changed) transport.emit([{ _id: `a`, text: `new:${text}` }]) + await expect(persist(params)).rejects.toBe(cause) + expect(collection.size).toBe(0) + } + await collection.cleanup() + durable = Promise.resolve() + transport.cache([{ _id: `a`, text: `restarted:${text}` }]) + collection.startSyncImmediate() + await persist(params) + expect(collection.get(`a`)?.text).toBe(`restarted:${text}`) + }, + () => collection.cleanup(), + ) + }, +) + +it(`keeps rejected source acceptance terminal until a fresh sync run`, async () => { + const seed = process.env.CONVEX_OFFLINE_ACCEPTANCE_ORACLE_SEED + if (seed) + await fc.assert(acceptanceProperty, { + seed: Number(seed), + path: process.env.CONVEX_OFFLINE_ACCEPTANCE_ORACLE_PATH, + numRuns: 60, + }) + else { + await fc.assert(acceptanceProperty, { seed: 20261023, numRuns: 60 }) + await fc.assert(acceptanceProperty, { numRuns: 60 }) + } +}) diff --git a/packages/convex-db-collection/tests/offline.test.ts b/packages/convex-db-collection/tests/offline.test.ts index 46190d52f9..77139e278b 100644 --- a/packages/convex-db-collection/tests/offline.test.ts +++ b/packages/convex-db-collection/tests/offline.test.ts @@ -1,7 +1,10 @@ +import { createCollection, withAcceptedReceipt } from '@tanstack/db' import { NonRetriableError } from '@tanstack/offline-transactions' import { makeFunctionReference } from 'convex/server' import { expect, it, vi } from 'vitest' +import { convexCollectionOptions } from '../src/index' import { convexOfflineMutation } from '../src/offline' +import { controlledClient, query } from './helpers' import type { ConvexClient } from 'convex/browser' import type { OfflineConfig } from '@tanstack/offline-transactions' @@ -54,3 +57,163 @@ it(`rolls back rejected server functions without durable retry`, async () => { }) await expect(persist(params)).rejects.toBeInstanceOf(NonRetriableError) }) + +it.each([`idle`, `pending`, `failed`] as const)( + `requires a real source snapshot during %s replay`, + async (state) => { + const transport = controlledClient() + const startupError = new Error(`startup failed`) + const collection = createCollection( + convexCollectionOptions({ + client: + state === `failed` + ? { + onUpdate: () => { + throw startupError + }, + } + : transport.client, + query, + queryArgs: { owner: `alice` }, + getKey: (row) => row._id, + gcTime: 0, + }), + ) + if (state !== `idle`) collection.startSyncImmediate() + const persist = convexOfflineMutation({ + client: { mutation: () => Promise.resolve(null) } as unknown as Pick< + ConvexClient, + `mutation` + >, + mutation, + getArgs: () => ({ text: `hello` }), + collections: [collection], + }) + let outcome: unknown = `pending` + const pending = persist(params).then( + () => { + outcome = `fulfilled` + }, + (error) => { + outcome = error + }, + ) + await new Promise((resolve) => setTimeout(resolve, 0)) + try { + if (state === `failed`) { + expect(outcome).toBe(startupError) + } else { + expect(outcome).toBe(`pending`) + expect(transport.counts().subscriptions).toBe(1) + transport.emit([]) + await pending + expect(outcome).toBe(`fulfilled`) + } + } finally { + await collection.cleanup() + } + }, +) + +it(`rejects replay when a wrapping sync rejects source acceptance`, async () => { + const transport = controlledClient() + const cause = new Error(`durable sync failed`) + let rejectAcceptance!: (error: unknown) => void + const accepted = new Promise((_resolve, reject) => { + rejectAcceptance = reject + }) + const options = convexCollectionOptions({ + client: transport.client, + query, + queryArgs: { owner: `alice` }, + getKey: (row) => row._id, + gcTime: 0, + }) + const collection = createCollection({ + ...options, + sync: { + ...options.sync, + sync: (syncParams) => + options.sync.sync({ + ...syncParams, + commit: () => { + const visible = syncParams.commit() + return withAcceptedReceipt( + Promise.resolve(visible).then(() => undefined), + accepted, + ) + }, + }), + }, + }) + const persist = convexOfflineMutation({ + client: { mutation: () => Promise.resolve(null) } as unknown as Pick< + ConvexClient, + `mutation` + >, + mutation, + getArgs: () => ({ text: `hello` }), + collections: [collection], + }) + const pending = persist(params) + const rejected = expect(pending).rejects.toBe(cause) + await new Promise((resolve) => setTimeout(resolve, 0)) + transport.emit([]) + rejectAcceptance(cause) + try { + await rejected + } finally { + await collection.cleanup() + } +}) + +it(`requires a new sync run after durable acceptance fails`, async () => { + const transport = controlledClient() + const cause = new Error(`durable source failure`) + let rejectAcceptance!: (error: unknown) => void + let durable = new Promise((_resolve, reject) => { + rejectAcceptance = reject + }) + const options = convexCollectionOptions({ + client: transport.client, + query, + queryArgs: { owner: `alice` }, + getKey: (row) => row._id, + gcTime: 0, + }) + const collection = createCollection({ + ...options, + sync: { + ...options.sync, + sync: (syncParams) => + options.sync.sync({ + ...syncParams, + commit: () => { + const visible = durable + .then(async () => { + await syncParams.commit() + }) + .then(() => undefined) + return withAcceptedReceipt(visible, durable) + }, + }), + }, + }) + const waiting = collection.utils.awaitSync() + const rejected = expect(waiting).rejects.toBe(cause) + transport.emit([]) + rejectAcceptance(cause) + await rejected + try { + await expect(collection.utils.awaitSync()).rejects.toBe(cause) + transport.emit([{ _id: `a`, text: `later callback` }]) + await expect(collection.utils.awaitSync()).rejects.toBe(cause) + await collection.cleanup() + durable = Promise.resolve() + collection.startSyncImmediate() + await collection.utils.awaitSync() + expect(collection.get(`a`)?.text).toBe(`later callback`) + } finally { + await collection.cleanup() + } +}) 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 e07a28224e..9deae66edf 100644 --- a/packages/convex-db-collection/tests/ownership-oracle.property.test.ts +++ b/packages/convex-db-collection/tests/ownership-oracle.property.test.ts @@ -18,7 +18,7 @@ import { DbClient, collectionOptions, createCollection } from '@tanstack/db' import { fc } from '@fast-check/vitest' import { expect, it } from 'vitest' import { convexCollectionOptions } from '../src/index' -import { controlledClient, query } from './helpers' +import { controlledClient, query, withTestCleanup } from './helpers' it(`keeps concrete options ownership across generated lifecycle checkpoints`, async () => { const property = fc.asyncProperty( @@ -45,46 +45,54 @@ it(`keeps concrete options ownership across generated lifecycle checkpoints`, as ? new DbClient().collection(descriptor) : createCollection(options) const collections = [first] - try { - if (checkpoint !== `idle`) await first.preload() - if (checkpoint === `cleaned-up`) await first.cleanup() - const reusedOwner = form === `direct` || form === `shallow` - const construct = () => - form === `descriptor` - ? new DbClient().collection(descriptor) - : createCollection( - form === `fresh` - ? makeOptions() - : form === `shallow` - ? { ...options } - : options, - ) - if (reusedOwner) { - expect(construct).toThrow(`only one Collection`) - } else { - const second = construct() - collections.push(second) - await second.preload() - transport.cache([{ _id: `a`, text }]) - const transaction = second.update(`a`, (row) => { + await withTestCleanup( + async () => { + if (checkpoint !== `idle`) await first.preload() + if (checkpoint === `cleaned-up`) await first.cleanup() + const reusedOwner = form === `direct` || form === `shallow` + const construct = () => + form === `descriptor` + ? new DbClient().collection(descriptor) + : createCollection( + form === `fresh` + ? makeOptions() + : form === `shallow` + ? { ...options } + : options, + ) + if (reusedOwner) { + expect(construct).toThrow(`only one Collection`) + } else { + const second = construct() + collections.push(second) + await second.preload() + transport.cache([{ _id: `a`, text }]) + const transaction = second.update(`a`, (row) => { + row.text = `optimistic` + }) + await transaction.isPersisted.promise + expect(second.get(`a`)?.text).toBe(text) + } + // Failed reuse and sibling cleanup cannot revoke the rightful owner. + for (const collection of collections.slice(1)) + await collection.cleanup() + await first.preload() + transport.cache([{ _id: `a`, text: `owner:${text}` }]) + const transaction = first.update(`a`, (row) => { row.text = `optimistic` }) await transaction.isPersisted.promise - expect(second.get(`a`)?.text).toBe(text) - } - // Failed reuse and sibling cleanup cannot revoke the rightful owner. - for (const collection of collections.slice(1)) - await collection.cleanup() - await first.preload() - transport.cache([{ _id: `a`, text: `owner:${text}` }]) - const transaction = first.update(`a`, (row) => { - row.text = `optimistic` - }) - await transaction.isPersisted.promise - expect(first.get(`a`)?.text).toBe(`owner:${text}`) - } finally { - await Promise.all(collections.map((collection) => collection.cleanup())) - } + expect(first.get(`a`)?.text).toBe(`owner:${text}`) + }, + async () => { + // The second owner can be admitted inside the body. Enumerate all + // resources at cleanup time, then attempt every release. + await withTestCleanup( + () => {}, + ...collections.map((collection) => () => collection.cleanup()), + ) + }, + ) }, ) const seed = process.env.CONVEX_OWNERSHIP_ORACLE_SEED diff --git a/packages/convex-db-collection/tests/pagination-oracle.property.test.ts b/packages/convex-db-collection/tests/pagination-oracle.property.test.ts index 11d9b8869a..7792e69948 100644 --- a/packages/convex-db-collection/tests/pagination-oracle.property.test.ts +++ b/packages/convex-db-collection/tests/pagination-oracle.property.test.ts @@ -2,8 +2,10 @@ * Automatic pagination owes the same complete snapshot as an unpaginated * query. Convex's documented PaginationOptions fixes each range with * endCursor; SplitRequired means the current page is incomplete and cannot - * be published. Our model is the latest input row array, sorted only for the - * public comparison. It does not compute cursors or adapter sync operations. + * be published. Our model is the latest input row array in rank order. The select + * boundary observes page concatenation order; Collection rows are also compared + * by key because Collection iteration order is not query order. The model does + * not compute cursors or adapter sync operations. * * The provider fixture below answers mathematical rank intervals, rather than * following the adapter's page state. Histories replace up to eight distinct @@ -22,6 +24,7 @@ import { createCollection } from '@tanstack/db' import { makeFunctionReference } from 'convex/server' import { expect, it } from 'vitest' import { convexPaginatedCollectionOptions } from '../src/index' +import { withTestCleanup } from './helpers' import type { PaginationOptions, PaginationResult } from 'convex/server' import type { ConvexCollectionClient } from '../src/index' @@ -69,7 +72,7 @@ function provider( get rowVisits() { return rowVisits }, - publish: async (rows: Array) => { + publish: async (rows: Array, checkpoint?: () => void) => { const served = new Set() for (let turn = 0; turn < 100; turn++) { const pending = slots.filter((slot) => slot.active && !served.has(slot)) @@ -85,7 +88,17 @@ function provider( const page = endCursor == null ? interval.slice(0, numItems) : interval const boundary = endCursor ?? String(page.at(-1)?.rank ?? from) - const needsSplit = split !== `none` && interval.length > 2 + // A split belongs strictly inside the returned cursor range. For an + // unbounded query that range ends at the returned page's boundary, + // not at the end of all rows matching the query. + const needsSplit = split !== `none` && page.length > 2 + const splitCursor = needsSplit + ? String(page[Math.floor((page.length - 1) / 2)]!.rank) + : undefined + if (splitCursor !== undefined) { + expect(Number(splitCursor)).toBeGreaterThan(from) + expect(Number(splitCursor)).toBeLessThan(Number(boundary)) + } slot.current = { page: new Proxy(needsSplit && split === `required` ? [] : page, { get(target, key, receiver) { @@ -101,15 +114,20 @@ function provider( split === `required` ? (`SplitRequired` as const) : (`SplitRecommended` as const), - splitCursor: String( - interval[Math.floor((interval.length - 1) / 2)]!.rank, - ), + splitCursor, } : {}), } slot.callback(slot.current) } await Promise.resolve() + if (slots.some((slot) => slot.active && slot.current === undefined)) { + // Drain the acquisition's scheduled flush before checking. Otherwise + // the fixture could fill its new subscriptions before production ever + // reaches the incomplete-range path, hiding partial publication. + await Promise.resolve() + checkpoint?.() + } if (slots.every((slot) => !slot.active || served.has(slot))) return } throw new Error(`Pagination did not exhaust finite fixture`) @@ -117,6 +135,23 @@ function provider( } } +// Shrinking must retain the law mismatch even when disposal also fails. +it(`retains the primary comparison when cleanup also fails`, async () => { + const primary = new Error(`row mismatch`) + const cleanup = new Error(`release mismatch`) + let lastReleased = false + await expect( + withTestCleanup( + () => Promise.reject(primary), + () => Promise.reject(cleanup), + () => { + lastReleased = true + }, + ), + ).rejects.toMatchObject({ cause: primary, errors: [primary, cleanup] }) + expect(lastReleased).toBe(true) +}) + const snapshot = fc.shuffledSubarray([0, 1, 2, 3, 4, 5, 6, 7]).chain((ids) => fc .array(fc.string({ maxLength: 8 }), { @@ -141,46 +176,71 @@ const history = fc.record({ `recommended` as const, ), }) -// Calibration: disabling continuation failed at public-row comparison with -// seed 20261022/path 5:1:1:3:10:1:1:1:1:1:1:1. Ignoring SplitRequired failed -// there with path 14:1:0:2:5:1:1:2. Both reached the exhausted checkpoint; -// neither failure was a setup error or timeout. +// Calibration on the legal provider grammar: reversing page order fails the +// select-boundary comparison at seed 20261022/path 0:1:0:2:3:1:1:1. Publishing +// an available prefix before a missing range fails the intermediate checkpoint +// at seed 20261022/path 0:1:3:1:2:5:7:3:3:0:1:1. Both are assertion failures +// after reaching the named observation, not setup errors or timeouts. const property = fc.asyncProperty( history, async ({ snapshots, pageSize, reverse, split }) => { const source = provider(reverse, split) + let selected: ReadonlyArray = [] + let accepted: ReadonlyArray = [] + let pendingCheckpoints = 0 const collection = createCollection( convexPaginatedCollectionOptions({ client: source.client, query, queryArgs: {}, pageSize, + select: (rows) => { + selected = rows + return rows + }, getKey: (row) => row.id, gcTime: 0, }), ) collection.startSyncImmediate() - try { - for (const rows of snapshots) { - await source.publish(rows) - await collection.utils.awaitSync() - const actual = [...collection.values()] - .map(({ id, rank, text }) => ({ id, rank, text })) - .sort((a, b) => a.id.localeCompare(b.id)) - const expected = [...rows].sort((a, b) => a.id.localeCompare(b.id)) - expect(actual).toEqual(expected) - expect(collection.status).toBe(`ready`) - } - } finally { - await collection.cleanup() - expect(source.slots.every((slot) => !slot.active)).toBe(true) - } + await withTestCleanup( + async () => { + for (const rows of snapshots) { + await source.publish(rows, () => { + pendingCheckpoints++ + // A newly acquired range has no result yet. The last complete + // snapshot, including its order at select, must remain authoritative. + expect(selected).toEqual(accepted) + expect( + [...collection.values()] + .map(({ id, rank, text }) => ({ id, rank, text })) + .sort((a, b) => a.id.localeCompare(b.id)), + ).toEqual([...accepted].sort((a, b) => a.id.localeCompare(b.id))) + }) + await collection.utils.awaitSync() + expect(selected).toEqual([...rows].sort((a, b) => a.rank - b.rank)) + accepted = [...rows].sort((a, b) => a.rank - b.rank) + const actual = [...collection.values()] + .map(({ id, rank, text }) => ({ id, rank, text })) + .sort((a, b) => a.id.localeCompare(b.id)) + const expected = [...rows].sort((a, b) => a.id.localeCompare(b.id)) + expect(actual).toEqual(expected) + expect(collection.status).toBe(`ready`) + } + if (snapshots[0]!.length > pageSize) + expect(pendingCheckpoints).toBeGreaterThan(0) + }, + async () => { + await collection.cleanup() + expect(source.slots.every((slot) => !slot.active)).toBe(true) + }, + ) }, ) it(`refines exhausted snapshots across reactive ranges and page splits`, async () => { - const seed = process.env.CONVEX_ORACLE_SEED - const path = process.env.CONVEX_ORACLE_PATH + const seed = process.env.CONVEX_PAGINATION_ROWS_SEED + const path = process.env.CONVEX_PAGINATION_ROWS_PATH if (seed) await fc.assert(property, { seed: Number(seed), path, numRuns: 60 }) else { await fc.assert(property, { seed: 20261022, numRuns: 60 }) @@ -220,20 +280,23 @@ const initialWork = fc.asyncProperty( }), ) collection.startSyncImmediate() - try { - await source.publish(rows) - // Check before awaitSync's explicit cache refreshes. At initial exhaustion - // there is exactly one useful materialization, the complete public result. - expect(collection.size).toBe(rows.length) - expect(source.rowVisits).toBe(rows.length) - } finally { - await collection.cleanup() - } + await withTestCleanup( + async () => { + await source.publish(rows) + // Check before awaitSync's explicit cache refreshes. At initial exhaustion + // there is exactly one useful materialization, the complete public result. + expect(collection.size).toBe(rows.length) + expect(source.rowVisits).toBe(rows.length) + }, + async () => { + await collection.cleanup() + }, + ) }, ) it(`materializes complete rows once during finite initial pagination`, async () => { - const seed = process.env.CONVEX_ORACLE_SEED - const path = process.env.CONVEX_ORACLE_PATH + const seed = process.env.CONVEX_PAGINATION_WORK_SEED + const path = process.env.CONVEX_PAGINATION_WORK_PATH if (seed) await fc.assert(initialWork, { seed: Number(seed), path, numRuns: 60 }) else { @@ -241,3 +304,141 @@ it(`materializes complete rows once during finite initial pagination`, async () await fc.assert(initialWork, { numRuns: 60 }) } }) + +// A configured maxRows bounds the complete eager mirror's accepted row count. +// It is not a transport-byte or strict peak-memory bound: one provider response +// can itself be larger. The reference accepts a finite snapshot iff its row +// count fits, otherwise preserving the prior public rows. Growing a previously +// exhausted source must reject before opening any continuation beyond that cap. +// Calibration: omitting the row cap fails at the public error observation, +// seed 20261023/path 0:0, maxRows 1 followed by a two-row source snapshot. +const rowBound = fc.asyncProperty( + fc.integer({ min: 1, max: 20 }), + fc.integer({ min: 1, max: 6 }), + fc.integer({ min: 1, max: 6 }), + async (maxRows, pageSize, excess) => { + const rows = Array.from({ length: maxRows + excess }, (_, rank) => ({ + id: String(rank), + rank, + text: String(rank), + })) + const source = provider(false, `none`) + const collection = createCollection( + convexPaginatedCollectionOptions({ + client: source.client, + query, + queryArgs: {}, + pageSize, + maxRows, + getKey: (row) => row.id, + gcTime: 0, + }), + ) + collection.startSyncImmediate() + await withTestCleanup( + async () => { + const accepted = rows.slice(0, maxRows) + await source.publish(accepted) + await collection.utils.awaitSync() + expect( + [...collection.values()] + .map(({ id, rank, text }) => ({ + id, + rank, + text, + })) + .sort((a, b) => a.rank - b.rank), + ).toEqual(accepted) + await source.publish(rows) + expect(collection.utils.getSyncError()).toBeInstanceOf(RangeError) + expect( + [...collection.values()] + .map(({ id, rank, text }) => ({ + id, + rank, + text, + })) + .sort((a, b) => a.rank - b.rank), + ).toEqual(accepted) + // One continuation at the cap may be necessary to distinguish an empty + // remainder from an extra row. No cursor may advance past that first + // excess row once its response establishes that the mirror is too large. + for (const slot of source.slots) { + if (slot.options.cursor !== null) + expect(Number(slot.options.cursor)).toBeLessThan(maxRows) + } + await expect(collection.utils.awaitSync()).rejects.toBeInstanceOf( + RangeError, + ) + // The row cap is a valid source error, not a terminal run failure. + // Removing the excess rows permits this same subscription run to recover. + await source.publish(accepted) + await collection.utils.awaitSync() + expect(collection.utils.getSyncError()).toBeUndefined() + expect(collection.size).toBe(maxRows) + }, + async () => { + await collection.cleanup() + }, + ) + }, +) +it(`rejects an oversized eager mirror before continuing and preserves accepted rows`, async () => { + const seed = process.env.CONVEX_PAGINATION_BOUND_SEED + const path = process.env.CONVEX_PAGINATION_BOUND_PATH + if (seed) await fc.assert(rowBound, { seed: Number(seed), path, numRuns: 60 }) + else { + await fc.assert(rowBound, { seed: 20261023, numRuns: 60 }) + await fc.assert(rowBound, { numRuns: 60 }) + } +}) + +it.each([0, -1, 1.5, Infinity, NaN])( + `rejects invalid maxRows %s`, + async (maxRows) => { + const source = provider(false, `none`) + const collection = createCollection( + convexPaginatedCollectionOptions({ + client: source.client, + query, + queryArgs: {}, + maxRows, + getKey: (row) => row.id, + }), + ) + try { + await expect(collection.preload()).rejects.toThrow(`maxRows`) + expect(source.slots).toHaveLength(0) + } finally { + await collection.cleanup() + } + }, +) + +it(`defaults the eager mirror cap to ten thousand rows`, async () => { + const source = provider(false, `none`) + const collection = createCollection( + convexPaginatedCollectionOptions({ + client: source.client, + query, + queryArgs: {}, + pageSize: 10_001, + getKey: (row) => row.id, + }), + ) + collection.startSyncImmediate() + try { + await source.publish( + Array.from({ length: 10_001 }, (_, rank) => ({ + id: String(rank), + rank, + text: String(rank), + })), + ) + expect(collection.utils.getSyncError()).toBeInstanceOf(RangeError) + expect(collection.size).toBe(0) + expect(source.slots).toHaveLength(1) + } finally { + await collection.cleanup() + } +}) diff --git a/packages/convex-db-collection/tests/sdk.test.ts b/packages/convex-db-collection/tests/sdk.test.ts index d924220915..d24b77a551 100644 --- a/packages/convex-db-collection/tests/sdk.test.ts +++ b/packages/convex-db-collection/tests/sdk.test.ts @@ -6,7 +6,7 @@ import { makeFunctionReference } from 'convex/server' import { convexToJson } from 'convex/values' import { describe, expect, it, vi } from 'vitest' import { convexCollectionOptions } from '../src/index' -import { mutation, query } from './helpers' +import { mutation, query, withTestCleanup } from './helpers' import type { Value } from 'convex/values' // Only the transport is controlled. Both clients, their caches, subscriptions, @@ -127,108 +127,155 @@ describe.each([`javascript`, `react`] as const)( return { client, collection, socket, remote: server(socket) } } + // Receiving witness for the ownership oracle: the actual SDK deduplicates + // two listeners, and releasing either owner leaves the other live. + it.each([`first`, `second`] as const)( + `keeps sibling updates after cleaning up %s listener`, + async (retired) => { + const { client, collection: first, remote } = setup() + remote.snapshot([{ _id: `a`, text: `old` }]) + await first.preload() + const second = createCollection( + convexCollectionOptions({ + client, + query, + queryArgs: { owner: `alice` }, + getKey: (row) => row._id, + select: (rows) => + rows.map((row) => ({ ...row, text: row.text.toUpperCase() })), + gcTime: 0, + }), + ) + await withTestCleanup( + async () => { + await second.preload() + expect(second.get(`a`)?.text).toBe(`OLD`) + const survivor = retired === `first` ? second : first + const removed = retired === `first` ? first : second + await removed.cleanup() + remote.snapshot([{ _id: `a`, text: `updated` }]) + expect(survivor.get(`a`)?.text).toBe( + retired === `first` ? `UPDATED` : `updated`, + ) + expect(removed.status).toBe(`cleaned-up`) + }, + () => first.cleanup(), + () => second.cleanup(), + () => client.close(), + ) + }, + ) + it(`subscribes with generated function names and typed args`, async () => { const { client, collection, socket, remote } = setup() - try { - const subscription = socket.sent.find( - (msg) => msg.type === `ModifyQuerySet`, - )! - expect(subscription.modifications).toMatchObject([ - { type: `Add`, udfPath: `tasks:list`, args: [{ owner: `alice` }] }, - ]) - remote.snapshot([{ _id: `a`, text: `hello` }]) - await collection.preload() - expect(collection.get(`a`)?.text).toBe(`hello`) - remote.snapshot([]) - expect(collection.size).toBe(0) - await collection.cleanup() - expect(socket.sent.at(-1)).toMatchObject({ - type: `ModifyQuerySet`, - modifications: [{ type: `Remove` }], - }) - } finally { - await collection.cleanup() - await client.close() - } + await withTestCleanup( + async () => { + const subscription = socket.sent.find( + (msg) => msg.type === `ModifyQuerySet`, + )! + expect(subscription.modifications).toMatchObject([ + { type: `Add`, udfPath: `tasks:list`, args: [{ owner: `alice` }] }, + ]) + remote.snapshot([{ _id: `a`, text: `hello` }]) + await collection.preload() + expect(collection.get(`a`)?.text).toBe(`hello`) + remote.snapshot([]) + expect(collection.size).toBe(0) + await collection.cleanup() + expect(socket.sent.at(-1)).toMatchObject({ + type: `ModifyQuerySet`, + modifications: [{ type: `Remove` }], + }) + }, + () => collection.cleanup(), + () => client.close(), + ) }) it(`waits for a query transition past the mutation timestamp before settling`, async () => { const { client, collection, remote } = setup() - try { - remote.snapshot([{ _id: `a`, text: `old` }]) - await collection.preload() - const seen: Array = [] - const subscription = collection.subscribeChanges(() => { - seen.push(collection.get(`a`)?.text) - }) - const transaction = collection.update(`a`, (row) => { - row.text = `optimistic` - }) - remote.acknowledge() - await Promise.resolve() - expect(transaction.state).toBe(`persisting`) - expect(collection.get(`a`)?.text).toBe(`optimistic`) - remote.snapshot([{ _id: `a`, text: `server` }]) - await transaction.isPersisted.promise - expect(collection.get(`a`)?.text).toBe(`server`) - expect(seen).not.toContain(`old`) - subscription.unsubscribe() - } finally { - await collection.cleanup() - await client.close() - } + await withTestCleanup( + async () => { + remote.snapshot([{ _id: `a`, text: `old` }]) + await collection.preload() + const seen: Array = [] + const subscription = collection.subscribeChanges(() => { + seen.push(collection.get(`a`)?.text) + }) + const transaction = collection.update(`a`, (row) => { + row.text = `optimistic` + }) + remote.acknowledge() + await Promise.resolve() + expect(transaction.state).toBe(`persisting`) + expect(collection.get(`a`)?.text).toBe(`optimistic`) + remote.snapshot([{ _id: `a`, text: `server` }]) + await transaction.isPersisted.promise + expect(collection.get(`a`)?.text).toBe(`server`) + expect(seen).not.toContain(`old`) + subscription.unsubscribe() + }, + () => collection.cleanup(), + () => client.close(), + ) }) it(`resubscribes and replays an in-flight mutation after reconnect`, async () => { vi.useFakeTimers() const { client, collection, socket, remote } = setup() - try { - remote.snapshot([{ _id: `a`, text: `old` }]) - await collection.preload() - const transaction = collection.update(`a`, (row) => { - row.text = `optimistic` - }) - socket.onclose?.({ code: 1006, reason: `network lost` }) - await vi.advanceTimersByTimeAsync(3000) - const replacement = Socket.instances.at(-1)! - expect(replacement).not.toBe(socket) - replacement.open() - expect( - replacement.sent.some((message) => message.type === `Mutation`), - ).toBe(true) - expect(collection.get(`a`)?.text).toBe(`optimistic`) - const nextRemote = server(replacement) - nextRemote.acknowledge() - nextRemote.snapshot([{ _id: `a`, text: `reconnected` }]) - await transaction.isPersisted.promise - expect(collection.get(`a`)?.text).toBe(`reconnected`) - } finally { - await collection.cleanup() - // close() uses a microtask, which fake timers otherwise hold. - const closed = client.close() - await vi.runAllTimersAsync() - await closed - vi.useRealTimers() - } + await withTestCleanup( + async () => { + remote.snapshot([{ _id: `a`, text: `old` }]) + await collection.preload() + const transaction = collection.update(`a`, (row) => { + row.text = `optimistic` + }) + socket.onclose?.({ code: 1006, reason: `network lost` }) + await vi.advanceTimersByTimeAsync(3000) + const replacement = Socket.instances.at(-1)! + expect(replacement).not.toBe(socket) + replacement.open() + expect( + replacement.sent.some((message) => message.type === `Mutation`), + ).toBe(true) + expect(collection.get(`a`)?.text).toBe(`optimistic`) + const nextRemote = server(replacement) + nextRemote.acknowledge() + nextRemote.snapshot([{ _id: `a`, text: `reconnected` }]) + await transaction.isPersisted.promise + expect(collection.get(`a`)?.text).toBe(`reconnected`) + }, + () => collection.cleanup(), + async () => { + // close() uses a microtask, which fake timers otherwise hold. + const closed = client.close() + await vi.runAllTimersAsync() + await closed + }, + () => { + vi.useRealTimers() + }, + ) }) it(`rolls back on an actual Convex mutation failure`, async () => { const { client, collection, remote } = setup() - try { - remote.snapshot([{ _id: `a`, text: `old` }]) - await collection.preload() - const transaction = collection.update(`a`, (row) => { - row.text = `optimistic` - }) - remote.acknowledge(false) - await expect(transaction.isPersisted.promise).rejects.toThrow( - `permission denied`, - ) - expect(collection.get(`a`)?.text).toBe(`old`) - } finally { - await collection.cleanup() - await client.close() - } + await withTestCleanup( + async () => { + remote.snapshot([{ _id: `a`, text: `old` }]) + await collection.preload() + const transaction = collection.update(`a`, (row) => { + row.text = `optimistic` + }) + remote.acknowledge(false) + await expect(transaction.isPersisted.promise).rejects.toThrow( + `permission denied`, + ) + expect(collection.get(`a`)?.text).toBe(`old`) + }, + () => collection.cleanup(), + () => client.close(), + ) }) }, ) @@ -257,38 +304,39 @@ it(`preserves Convex integers, special floats, nested values, and bytes`, async const socket = Socket.instances.at(-1)! socket.open() const remote = server(socket) - try { - const values: Array = [ - 9223372036854775807n, - NaN, - Infinity, - -Infinity, - -0, - { nested: [null, true, `hello`, { key: `value` }] }, - new Uint8Array([1, 2]).buffer, - ] - for (const payload of values) { - remote.snapshot([{ _id: `a`, payload }]) - await collection.preload() - const actual = collection.get(`a`)!.payload - if (payload instanceof ArrayBuffer) - expect([...new Uint8Array(actual as ArrayBuffer)]).toEqual([ - ...new Uint8Array(payload), - ]) - else expect(actual).toEqual(payload) - } - const changed = vi.fn() - const listener = collection.subscribeChanges(changed) - remote.snapshot([{ _id: `a`, payload: new Uint8Array([3, 4]).buffer }]) - expect(changed).toHaveBeenCalledOnce() - listener.unsubscribe() - expect([ - ...new Uint8Array(collection.get(`a`)!.payload as ArrayBuffer), - ]).toEqual([3, 4]) - } finally { - await collection.cleanup() - await client.close() - } + await withTestCleanup( + async () => { + const values: Array = [ + 9223372036854775807n, + NaN, + Infinity, + -Infinity, + -0, + { nested: [null, true, `hello`, { key: `value` }] }, + new Uint8Array([1, 2]).buffer, + ] + for (const payload of values) { + remote.snapshot([{ _id: `a`, payload }]) + await collection.preload() + const actual = collection.get(`a`)!.payload + if (payload instanceof ArrayBuffer) + expect([...new Uint8Array(actual as ArrayBuffer)]).toEqual([ + ...new Uint8Array(payload), + ]) + else expect(actual).toEqual(payload) + } + const changed = vi.fn() + const listener = collection.subscribeChanges(changed) + remote.snapshot([{ _id: `a`, payload: new Uint8Array([3, 4]).buffer }]) + expect(changed).toHaveBeenCalledOnce() + listener.unsubscribe() + expect([ + ...new Uint8Array(collection.get(`a`)!.payload as ArrayBuffer), + ]).toEqual([3, 4]) + }, + () => collection.cleanup(), + () => client.close(), + ) }) it(`rejects preload for a disabled JavaScript client`, async () => { @@ -304,7 +352,13 @@ it(`rejects preload for a disabled JavaScript client`, async () => { gcTime: 0, }), ) - await expect(collection.preload()).rejects.toThrow(`disabled Convex client`) - await collection.cleanup() - await client.close() + await withTestCleanup( + async () => { + await expect(collection.preload()).rejects.toThrow( + `disabled Convex client`, + ) + }, + () => collection.cleanup(), + () => client.close(), + ) }) diff --git a/packages/convex-db-collection/tests/snapshot-oracle.property.test.ts b/packages/convex-db-collection/tests/snapshot-oracle.property.test.ts index af7076effd..dd6d92d1c7 100644 --- a/packages/convex-db-collection/tests/snapshot-oracle.property.test.ts +++ b/packages/convex-db-collection/tests/snapshot-oracle.property.test.ts @@ -7,6 +7,9 @@ * The reference is simply the last valid input array. It never computes sync * operations or uses the adapter's key map. Generated histories contain empty, * reordered, changed, removed, reintroduced, and duplicate-key snapshots. + * Every history runs both raw rows and a successful text projection. The + * reference labels selected text with an independently specified uppercase + * operation, so omitting select cannot pass merely by preserving row keys. * The driver invokes the real Collection through a controlled Convex callback. * After each synchronous delivery, public rows and the error flag are compared. * This owner covers snapshot refinement only. SDK transport, mutation timing, @@ -17,7 +20,7 @@ import { createCollection } from '@tanstack/db' import { fc } from '@fast-check/vitest' import { expect, it } from 'vitest' import { convexCollectionOptions } from '../src/index' -import { controlledClient, query } from './helpers' +import { controlledClient, query, withTestCleanup } from './helpers' import type { Row } from './helpers' const row = fc @@ -37,42 +40,62 @@ it(`refines complete snapshots throughout generated histories`, async () => { maxLength: 30, }), async (history) => { - const transport = controlledClient() - const collection = createCollection( - convexCollectionOptions({ - client: transport.client, - query, - queryArgs: { owner: `alice` }, - getKey: (value) => value._id, - gcTime: 0, - }), - ) - collection.startSyncImmediate() - let expected: Array = [] - try { - for (const snapshot of history) { - // Independent validity judgment: compare every pair of input keys. - const valid = snapshot.every((value, index) => - snapshot.slice(index + 1).every((other) => other._id !== value._id), - ) - if (valid) expected = snapshot - transport.emit(snapshot) - expect(collection.utils.getSyncError() !== undefined).toBe(!valid) - const actual = [...collection.values()] - .map((value): Row => { - const result: Row = { _id: value._id, text: value.text } - if (Object.hasOwn(value, `optional`)) - result.optional = value.optional - return result - }) - .sort((a, b) => a._id.localeCompare(b._id)) - const wanted = [...expected].sort((a, b) => - a._id.localeCompare(b._id), - ) - expect(isDeepStrictEqual(actual, wanted)).toBe(true) - } - } finally { - await collection.cleanup() + for (const projected of [false, true]) { + const transport = controlledClient() + const collection = createCollection( + convexCollectionOptions({ + client: transport.client, + query, + queryArgs: { owner: `alice` }, + getKey: (value) => value._id, + ...(projected + ? { + select: (rows: Array) => + rows.map((value) => ({ + ...value, + text: value.text.toUpperCase(), + })), + } + : {}), + gcTime: 0, + }), + ) + collection.startSyncImmediate() + let expected: Array = [] + await withTestCleanup( + () => { + for (const snapshot of history) { + // Independent validity judgment: compare every pair of input keys. + const valid = snapshot.every((value, index) => + snapshot + .slice(index + 1) + .every((other) => other._id !== value._id), + ) + if (valid) + expected = snapshot.map((value) => ({ + ...value, + text: projected ? value.text.toUpperCase() : value.text, + })) + transport.emit(snapshot) + expect(collection.utils.getSyncError() !== undefined).toBe(!valid) + const actual = [...collection.values()] + .map((value): Row => { + const result: Row = { _id: value._id, text: value.text } + if (Object.hasOwn(value, `optional`)) + result.optional = value.optional + return result + }) + .sort((a, b) => a._id.localeCompare(b._id)) + const wanted = [...expected].sort((a, b) => + a._id.localeCompare(b._id), + ) + expect(isDeepStrictEqual(actual, wanted)).toBe(true) + } + }, + async () => { + await collection.cleanup() + }, + ) } }, ) 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 270e924a56..08f9ab4885 100644 --- a/packages/convex-db-collection/tests/values-oracle.property.test.ts +++ b/packages/convex-db-collection/tests/values-oracle.property.test.ts @@ -17,6 +17,7 @@ import { createCollection } from '@tanstack/db' import { OutboxManager } from '@tanstack/offline-transactions' import { expect, it } from 'vitest' import { convexMutationCodec } from '../src/offline' +import { cleanupOfflineOracle } from '../../offline-transactions/tests/oracle-lifecycle' import type { PendingMutation } from '@tanstack/db' import type { OfflineTransaction, @@ -124,6 +125,7 @@ const property = fc.asyncProperty(tree(2), async (payload) => { } as unknown as PendingMutation, ], } + let hasPrimaryFailure = false try { await new OutboxManager(storage, { rows: writer }, convexMutationCodec).add( transaction, @@ -138,9 +140,14 @@ const property = fc.asyncProperty(tree(2), async (payload) => { expect(observe(restored!.mutations[0]!.changes)).toEqual( observe({ payload }), ) + } catch (error) { + hasPrimaryFailure = true + throw error } finally { - await writer.cleanup() - await reader.cleanup() + await cleanupOfflineOracle( + [() => writer.cleanup(), () => reader.cleanup()], + hasPrimaryFailure, + ) } }) it(`preserves the Convex mutation value domain across durable serialization`, async () => { 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 37ed2d5c9c..26655204cb 100644 --- a/packages/offline-transactions/tests/transaction-serializer-oracle.property.test.ts +++ b/packages/offline-transactions/tests/transaction-serializer-oracle.property.test.ts @@ -49,8 +49,8 @@ import type { PendingMutation } from '@tanstack/db' * versions. Malformed markers and missing Temporal constructors test failure * paths before durable data can be replaced. * - * Limits: undefined, non-finite numbers, arbitrary native objects, and - * cross-realm boxed values are outside current evidence. Cycles must fail + * Limits for default v3: undefined, non-finite numbers, arbitrary native + * objects, and cross-realm boxed values are outside that domain's evidence. Cycles must fail * visibly while repeated non-cyclic references retain their values. This * oracle does not claim byte stability for object key order beyond JSON's * established rules. Temporal witnesses install controlled constructors; @@ -1657,6 +1657,7 @@ it(`preserves opt-in mutation values and rejects unreadable codec records withou ], } const storage = new FakeStorageAdapter() + let hasPrimaryFailure = false try { await new OutboxManager(storage, { rows: writer }, codec).add( transaction, @@ -1727,14 +1728,19 @@ it(`preserves opt-in mutation values and rejects unreadable codec records withou expect( mixed.find((entry) => entry.id === legacy.id)?.mutations[0]?.modified, ).toEqual(plain) + } catch (error) { + hasPrimaryFailure = true + throw error } finally { - await writer.cleanup() - await reader.cleanup() + await cleanupOfflineOracle( + [() => writer.cleanup(), () => reader.cleanup()], + hasPrimaryFailure, + ) } }, ) const config = readOfflineOracleConfig({ - prefix: `OFFLINE_ORACLE`, + prefix: `OFFLINE_CODEC_ORACLE`, defaultRuns: 60, }) if (config.seed !== undefined) From 794877347e2a91785165efdef8b181b3ffdc4e61 Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Fri, 23 Oct 2026 21:41:17 -0600 Subject: [PATCH 05/20] docs: record exact-head Convex oracle reviews --- docs/collections/convex-collection.md | 2 +- docs/contributing/oracle-coverage.md | 2 + ...2026-10-23-convex-pagination-and-codecs.md | 93 +++++++++++++ .../2026-10-23-convex-source-and-backend.md | 130 ++++++++++++++++++ 4 files changed, 226 insertions(+), 1 deletion(-) create mode 100644 docs/contributing/oracle-reviews/2026-10-23-convex-pagination-and-codecs.md create mode 100644 docs/contributing/oracle-reviews/2026-10-23-convex-source-and-backend.md diff --git a/docs/collections/convex-collection.md b/docs/collections/convex-collection.md index fbd524f74e..8d6e63c7f7 100644 --- a/docs/collections/convex-collection.md +++ b/docs/collections/convex-collection.md @@ -261,7 +261,7 @@ convexCollectionOptions({ }) ``` -An initial subscription error rejects `preload()` with the original error. Later errors preserve existing rows and call `onError`. `collection.utils.getSyncError()` returns the latest error. This getter is not reactive. A valid result clears the error and allows initial sync to recover. +An initial subscription error rejects `preload()` with the original error. Later errors preserve existing rows and call `onError`. `collection.utils.getSyncError()` returns the latest error. This getter is not reactive. A valid query result clears provider or projection errors and allows initial sync to recover. Collection cleanup unsubscribes and invalidates callbacks from that sync run. It does not close the shared Convex client. After a restart, the first result replaces retained data, including when the result is empty. Close the client separately when your application no longer needs it. diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index 82229b4d1a..44d4495069 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -13,6 +13,8 @@ distinguishes canonical laws from their receiving witnesses across packages. ## Convex collection integration +Exact-head follow-up records: [source and backend](oracle-reviews/2026-10-23-convex-source-and-backend.md) and [pagination and codecs](oracle-reviews/2026-10-23-convex-pagination-and-codecs.md), both bound to source revision `6511ee4ed0940cd54ee58ceeba7202ca859344b3`. + The Convex package has these independent judgments: - `packages/convex-db-collection/tests/snapshot-oracle.property.test.ts` compares diff --git a/docs/contributing/oracle-reviews/2026-10-23-convex-pagination-and-codecs.md b/docs/contributing/oracle-reviews/2026-10-23-convex-pagination-and-codecs.md new file mode 100644 index 0000000000..9956d2f196 --- /dev/null +++ b/docs/contributing/oracle-reviews/2026-10-23-convex-pagination-and-codecs.md @@ -0,0 +1,93 @@ +# Convex pagination and mutation codec review + +Reviewed source and test head: `6511ee4ed0940cd54ee58ceeba7202ca859344b3`. +This record reviews these owners against [ORC-001 through ORC-014](../oracle-rubric.md): + +- `packages/convex-db-collection/tests/pagination-oracle.property.test.ts` +- `packages/convex-db-collection/tests/values-oracle.property.test.ts` +- The opt-in codec property in `packages/offline-transactions/tests/transaction-serializer-oracle.property.test.ts` + +The pagination review is independent of its implementation author. The reviewer +also inspected the value models and codec dispatch independently, then repaired +replay routing and cleanup mechanics. That participation is not an independent +review of those harness repairs. This is not a new audit of every historical +serializer law. Source acceptance and snapshot ownership have separate reviews. + +No known product counterexample remains in the bounded domains below. Passing +these histories does not establish an on-demand API, a lifetime resource bound, +or arbitrary storage and process behavior. + +## Executed evidence + +At the reviewed head, the reviewer ran the pagination and Convex values owners, +including their normal fixed and random campaigns. The pagination file has ten +checks, including cleanup calibration. The Convex values property runs 100 +histories per campaign. The shared serializer file passes all 51 checks with +`OFFLINE_CODEC_ORACLE_SEED=20261022` and +`OFFLINE_CODEC_ORACLE_PATH=0:0:0:0`. This full-file command directly replays the +codec property without applying its path to unrelated properties. + +Before the selector repair, the same path under the shared `OFFLINE_ORACLE` +prefix made four unrelated properties fail with `Unable to replay, got wrong +path`. That is a replay-routing failure, not a product mismatch or checker kill. +The dedicated prefix repairs that failure. Other legacy serializer properties +keep their existing selectors. + +The pagination author recorded these assertion failures at their intended +observations, with repaired replays passing: + +| Wrong behavior | Seed and path | Observation | +| --- | --- | --- | +| Reverse concatenated pages | `20261022`, `0:1:0:2:3:1:1:1` | Ordered rows received by `select` | +| Publish a prefix while another range is missing | `20261022`, `0:1:3:1:2:5:7:3:3:0:1:1` | Previous public snapshot while acquisition is pending | +| Copy incomplete prefixes during initial acquisition | `20261022`, `0:0:0:0` | Provider row reads at first exhaustion | +| Omit the accepted-row cap | `20261023`, `0:0` | Error and retained public rows after growth beyond the cap | + +The prior codec calibration is recorded in the coverage map: a decoder that +leaves Convex scalar wire markers as JSON fails the typed-value observation at +`CONVEX_VALUES_ORACLE_SEED=20261022`, path `0:0`. This review inspected the +unchanged Convex encoding and observation boundary, and reran the repaired owner. +It does not claim to have rerun that production mutant after the head was frozen. + +## Requirement outcomes + +| Requirement | Outcome and evidence | +| --- | --- | +| ORC-001, authority and limits | Satisfied for the stated domains. Pagination follows the documented complete eager mirror, `maxRows`, and Convex's fixed `endCursor` and split semantics. The row-read check is a deliberately narrow acquisition cost guard, not a total complexity promise. Codec meaning follows the opt-in `MutationValueCodec` and version-4 wire contract. Metadata retains the existing version-3 contract. The Convex receiver uses its public scalar encoding. Limits are stated in the files and below. | +| ORC-002, independent judgment | Satisfied. Pagination expects authored rows in mathematical rank order, without using production cursor bookkeeping. The numeric interval provider answers requested intervals independently. Convex values are compared with their authored native trees through a type and byte observation. The shared codec fixture uses Node V8 serialization, while expected native values come directly from generated leaves. Neither codec model uses production dispatch to compute its expected result. | +| ORC-003, literate responsibilities | Satisfied within this scope. Pagination explains the complete snapshot law, range fixture, histories, public and select observations, pending cut, and work/cap comparisons beside the code. The Convex values opening distinguishes authored tree, storage driver, fresh reader, and typed comparison. The shared codec section separates its opt-in law, V8 fixture, native grammar, and storage/error comparisons from the older serializer model. | +| ORC-004, grammar controls | Satisfied within the declared bounds. Pagination varies 0-8 distinct rows, 1-10 snapshots, page sizes 1-4, both callback directions, and no/required/recommended splits. Work varies 3-40 pages and 1-8 rows per page. Cap histories vary limits 1-20, page sizes 1-6, and excess 1-6. Empty results distinguish readiness from nonempty data; row removal and movement distinguish complete replacement from append-only accumulation; split modes distinguish replacement from continuation; callback direction removes delivery-order dependence. Split cursors are asserted strictly inside the returned range, excluding the formerly illegal page-size-1 split. Convex values use depth-two trees, arrays up to three, bytes up to eight, bounded strings, signed int64s, special floats, undefined fields, and six marker-like field names. Removing a native leaf would remove evidence for that codec branch. Shared codec histories use 1-5 native leaves, bytes up to twelve, and each mutation kind, plus missing/wrong/throwing readers and a non-string encoder. Cycles, custom native objects, and arbitrary malformed server protocols are not legal histories in these grammars. | +| ORC-005, production path and observation | Satisfied. Pagination uses a real Collection and adapter with controlled provider cache callbacks. It compares public keyed rows and ordered `select` input after exhaustion, and both while a new range lacks a result. The pending pump drains scheduled flushes before checking, so it reaches incomplete acquisition rather than filling new ranges first. The work recorder counts numeric provider element reads before explicit cache refresh. Codec drivers use actual OutboxManager writes and fresh readers; all three mutation payloads are compared. The shared owner also checks registry identity, wire version/codec ID, metadata, mixed legacy records, and byte-for-byte retained storage after failed reads. No concrete missing-input hypothesis remains for these named cuts under the stated provider premises. | +| ORC-006, calibration | Satisfied with the recorded controls above. Pagination's wrong page order, premature prefix, extra row reads, and absent cap each fail the relevant assertion. The typed-value receiver rejects scalar-as-JSON normalization. The original default serializer's BigInt admission failure and repaired codec replay are recorded in the coverage map. Replay-routing failures are reported separately and are not counted as product calibration. | +| ORC-007, campaigns and replay | Satisfied. Each generated property normally runs matching fixed and random budgets. Pagination has separate `CONVEX_PAGINATION_ROWS`, `CONVEX_PAGINATION_WORK`, and `CONVEX_PAGINATION_BOUND` seed/path selectors, each with 60 runs. Values uses `CONVEX_VALUES_ORACLE`, with 100. New shared codec replay uses `OFFLINE_CODEC_ORACLE_SEED/PATH/RUNS`, defaulting to 60. Their direct replay branches bypass the normal two campaigns for the selected property. No reviewed property uses `fc.commands`, so command `replayPath` handling is not applicable. | +| ORC-008, state minimality | Satisfied for pagination. Its last accepted input array is necessary: a missing replacement range must preserve that array, and a later completed acquisition replaces it. Two different accepted snapshots are distinguished by the next pending-range observation. Provider slot/cache state drives production and is not the reference state machine. Not applicable to the value models, which compare one authored tree with one restored tree rather than reducing a stateful history. | +| ORC-009, vocabulary | Satisfied. Collection rows, public snapshot, source result, acceptance, and cleanup retain the glossary's meanings. Numeric rank intervals are an explicit fixture abstraction of opaque provider cursors, not production cursor parsing. The work recorder measures element reads, not network work or subscription count. Codec wire version and codec ID are distinct from the application's transaction version and named mutation function. No hidden production-state equivalence is introduced. | +| ORC-010, failure fidelity | Satisfied for the reviewed generated properties. Pagination uses the shared test cleanup instrument, whose double-fault calibration preserves the original comparison as AggregateError cause, keeps secondary failures, and attempts a later release after an earlier release throws. Values and new shared codec catch and rethrow the primary failure, then use the existing cleanupOfflineOracle instrument to attempt both Collection releases. That instrument logs secondary cleanup failures without replacing an existing primary. The reviewer inspected this control flow; the reviewed value properties do not inject cleanup failures themselves. | +| ORC-011, second formulation | Satisfied where a concrete shared-fault hypothesis applies. A numeric controlled provider could implement the same wrong cursor semantics as production, so the actual backend independently evaluates an unpaginated query and compares it with real cursor-range results, including actual split responses. A JSON-normalizing recorder could hide native value loss, so typed scalar/byte observation distinguishes that hypothesis, with native values also received by process/backend tests. No further specific shared semantic fault was identified that calls for another model. This is not an exhaustive absence claim. | +| ORC-012, review evidence | Satisfied by this versioned record tied to the exact reviewed source/test commit. It lists each requirement, concrete checks, controls, and remaining limits. The record reviews the pagination bug class at complete exhaustion, pending acquisition, ordered selection, initial element-read, and cap/error cuts. It reviews codecs at durable admission and fresh read, with all payloads and unreadable-record retention. Known limits below are not presented as closed classes. | +| ORC-013, distinguishing boundary witnesses | Satisfied within the promised boundaries. Generated cap histories first accept exactly `maxRows` rows, then reject excess, retain the previous snapshot, and recover after shrink. A separate pinned default test distinguishes 10,000 from 10,001, and invalid 0, negative, fractional, infinite, and NaN caps reject before subscription. Work's three-row/page-size-1 failure distinguishes copying an incomplete prefix from the one complete materialization. Codec histories distinguish valid binding from absent/wrong/throwing binding while requiring unchanged durable bytes. These are not claims about arbitrary resource thresholds. | +| ORC-014, controlled-premise handoff | Satisfied with explicit limits. The controlled pager assumes Convex fixed ranges and legal split responses; real local backend tests exercise both actual SDK clients, fixed ranges, and server-generated splits against an unpaginated query. The native tree/storage driver assumes preservation across serialization and fresh readers; backend crash tests receive native payloads on disk and across process death, now on insert and update. These receiving drivers use provider-specific controls and separate expected server queries/application state rather than literally reusing the numeric fixture grammar. Their shared law is documented, but arbitrary cloud scheduling and every generated native tree are not thereby received. | + +## Remaining limits + +The eager cap bounds accepted rows, not network bytes, peak memory, or lifetime +cursor ranges. A reactive range can itself return more than the cap. Old ranges +remain subscribed, so long-running insert/delete histories can accumulate ranges +even while the current row count remains small. Initial acquisition still scans +cached ranges repeatedly. Only row materialization, not total work, is bounded +by the work property. On-demand filtering, ordering, demand release, and a bounded +subscription policy need a separate API and receiving witnesses. + +The numeric interval grammar assumes coherent source revisions and finite +acquisition. It does not prove continuous growth, cross-Collection publication, +or arbitrary contradictory cursor responses. The backend is a real local Convex +backend, not evidence for cloud availability or network latency. + +The direct Convex value receiver uses one update-shaped mutation, checking +modified, original, and changes. Mutation kinds are crossed by the shared owner, +and CRUD is received by the backend process tests. This does not prove every +native tree through every operation and storage implementation. The reviewed +values are bounded acyclic trees; identity sharing, cycles, cross-realm natives, +custom objects, native Temporal, browser storage atomicity, and power-loss +atomicity are outside this evidence. These limits remain with the codec and +backend owners in the coverage map. diff --git a/docs/contributing/oracle-reviews/2026-10-23-convex-source-and-backend.md b/docs/contributing/oracle-reviews/2026-10-23-convex-source-and-backend.md new file mode 100644 index 0000000000..484b6af507 --- /dev/null +++ b/docs/contributing/oracle-reviews/2026-10-23-convex-source-and-backend.md @@ -0,0 +1,130 @@ +# Independent review of Convex snapshot, ownership, bridge, and backend oracles + +Reviewed implementation head: `6511ee4ed0940cd54ee58ceeba7202ca859344b3`. + +The reviewer did not author these semantic models. The reviewer changed their +cleanup harness after finding that cleanup could replace a primary mismatch. +Pagination is excluded from this independent verdict because the reviewer +implemented its changes; another reviewer covers that owner. + +## Scope and observations + +- Snapshot owner: `packages/convex-db-collection/tests/snapshot-oracle.property.test.ts`. + Last valid keyed source array, including absent fields and duplicate rejection, + through raw arrays and a successful uppercase text projection. Observations are + public keyed rows and error presence after synchronous delivery. +- Ownership owner: `packages/convex-db-collection/tests/ownership-oracle.property.test.ts`. + Concrete option identity admission and independent fresh/descriptor owners, + across idle, ready, and cleanup checkpoints. Observations are construction + rejection and public rows after cached mutation confirmation. +- Bridge owner: `packages/convex-db-collection/tests/offline-oracle.property.test.ts`. + Ordered stages and retry classification, plus cold real-Collection source + acceptance and rejected durable acceptance through cleanup/restart. Observations are stage traces, settlement, provider subscription + admission, and public rows before/after the first source delivery. +- Backend receiving owner: `packages/convex-db-collection/e2e/backend-oracle.test.ts`. + Real range pagination and durable insert/update/delete replay through process + death. The replay observations are durable receipt key, server application + counter, complete server fields, and complete replayed Collection fields. +- SDK receiving support: `packages/convex-db-collection/tests/sdk.test.ts`. + Both SDK clients receive subscriptions, mutation timestamp transitions, + reconnects, and sibling listener cleanup. These focused tests do not own + another independent state model. + +Fresh independent execution before the final freeze passed 28 checks across +snapshot, ownership, bridge, SDK, and pagination support. The author then ran +the new durable-acceptance rejection property with 60 fixed and 60 random +histories and direct replay after the independent finding described below. +The final source tree is pinned above; this record adds review evidence after +that implementation commit without changing its production or test sources. + +The backend author reports all nine final backend tests green after the adapter +freeze, including 36 generated process histories. Insert replay at seed 20261022, +path 0:0:0:1, and update replay at seed 20261022, path 0:0:0:0:0, pass with their +operation-specific selectors. The source acceptance, snapshot, ownership and +backend evidence below distinguishes independent source inspection and fresh +review execution from the author's hostile-control calibrations. + +## Requirement outcomes + +| Requirement | Snapshot | Ownership | Bridge, including cold source | Real backend receiving owner | +| --- | --- | --- | --- | --- | +| ORC-001, authority and limits | Complete keyed snapshots and select follow the public adapter guide. Transport and mutation timing are separate boundaries. | The concrete-options claim follows the established LocalStorage policy and documented adapter ownership. Arbitrary Collection lifecycle is excluded. | The guide promises ordered mutation then source acceptance, with terminal application errors and retryable post-confirmation sync failure. Storage/leadership stay with offline-engine owners. | Convex transactions and the application's atomic receipt contract authorize one application per durable key. Finite pagination must equal an independent unpaginated query. Cloud availability, multi-tab leadership, and power-loss writes are excluded. | +| ORC-002, independent judgment | Last valid input array and pairwise duplicate-key judgment do not compute adapter sync operations. The selected reference applies the explicit input transformation. | Identity and materialization form determine admission; no production claim flag or sync state is imported. | A dependency prefix determines stage traces. Cold source expectations depend on whether a valid snapshot was delivered, not adapter state. | Expected task state comes from the operation and draft. An unpaginated server query judges range results, and a counter distinguishes equal final rows after duplicate writes. | +| ORC-003, literate responsibilities | Opening prose explains contract, validity model, grammar, production path, comparison, and exclusions. | Opening prose maps identity forms and checkpoints to admission and mutation confirmation. | Separate prose explains stage-prefix judgment and cold source acceptance, their histories, cuts, and limits. | Opening prose separates pagination and process-replay judgments, SIGKILL cuts, server and Collection observations, and excluded boundaries. | +| ORC-004, grammar controls | Keys a/b/c, text up to 12, optional text up to 6, 0-5 rows and 1-30 snapshots. Empty, changed, absent, duplicate, reordered and reintroduced keys are reconstructible. Raw/projected variants distinguish missing projection. Duplicate snapshots are rejected atomically rather than admitted as valid rows. | Four identity forms cross idle/ready/cleaned-up and text up to 16. Direct/shallow forms must reject, fresh/descriptor forms must admit. Removing reuse or sibling cleanup loses distinct ownership witnesses. | Stage failure chooses none/args/mutation/sync, with keys/text up to 15. Cold histories choose idle/pending/startup-error and empty/rows/error/cleanup delivery. Rejected durable acceptance crosses empty/nonempty snapshots, 1-4 unchanged/changed callback retries, and a cleanup/restart cut. Delivery is irrelevant after failed startup, which is explicitly terminal at that cut. Empty success and rejected startup distinguish readiness from nonempty rows. | Insert/update/delete cross acknowledgement known/uncertain, text up to 12, rank -50..50, native signed int64/special float/0-12 bytes on insert/update. Delete has no payload. One pending transaction excludes batch and shared-key leadership histories. Child checkpoints enforce admission before offline SIGKILL and server acceptance before uncertain SIGKILL. | +| ORC-005, path and checkpoint | Real Collection plus adapter receive controlled callbacks; keyed public rows and error presence are checked after each delivery. | Real constructors, DbClient materialization, adapters, optimistic transactions and public rows run. Actual SDK sibling tests receive the provider multi-listener premise separately. | The stage driver runs the public bridge with explicit collaborators. The cold driver runs the public bridge with actual Collections and proves pending settlement and one subscription before delivering source data. The acceptance-rejection driver defers actual core commit behind durability, matching persistence's withAcceptedReceipt boundary, and checks rejection before and after retries, then successful fresh-run rows. | Both actual SDK clients run against the isolated local backend. Process replay uses fresh child processes and disk storage. Parent reads the durable key independently; child checks public fields only at replay completion. | +| ORC-006, calibration | Omitting projection is rejected at public row comparison, seed 20261022/path 0:1:1:0:3:4:3:7:7:7:7:7:6. Existing deletion and full-row mutants separately calibrate omitted keys/fields. | Original reuse failed at admission, seed 20261022/path 2:0:0; the two-owner confirmation regression additionally exposed stale first-owner rows. | Original argument failure escaped terminal classification, seed 20261022/path 5:1:1. Original cold acceptance resolved before delivery, seed 20261023/path 0:0:0. Removing terminal durable-failure guards fails at rejection observation, seed 20261023/path 0:0. | Duplicate effects with one receipt fail at applicationCount, insert seed 20261022/path 0:0:0:1. A substituted durable key fails at receipt-key equality, update path 0:0:0:0:0. Dropped replay-only payload fails child public-row deepStrictEqual at the same update path. | +| ORC-007, parity and replay | Identical property runs 150 fixed and 150 random histories; CONVEX_SNAPSHOT_ORACLE_SEED/PATH directly selects replay. | Identical property runs 60 fixed and 60 random histories; CONVEX_OWNERSHIP_ORACLE_SEED/PATH directly selects replay. | Stage, cold source, and rejected-acceptance properties each run 60 fixed and 60 random histories. CONVEX_OFFLINE_ORACLE, CONVEX_OFFLINE_SOURCE_ORACLE, and CONVEX_OFFLINE_ACCEPTANCE_ORACLE keep their seed/path bindings separate. | Each operation runs six fixed and six random histories against the same driver. CONVEX_BACKEND_INSERT_ORACLE, CONVEX_BACKEND_UPDATE_ORACLE, and CONVEX_BACKEND_DELETE_ORACLE each own their seed/path, preventing another operation's grammar from interpreting a recorded path. | +| ORC-008, model state | Retained last-valid rows are needed: a duplicate-key rejection followed by observation distinguishes replacement from preserving prior rows. No queue or transport state enters the model. | No stateful reference machine is introduced; admission is a direct identity-form judgment. Lifecycle checkpoints are production driver conditions. | The prefix computation is stateless. The cold source judgment distinguishes no valid source from a delivered valid source; these states allow different successful settlements. Rejected acceptance has failed-run/restarted-run reference states: cached or changed callbacks do not restore authority, while cleanup/restart does. | No stateful reference machine is introduced. The expected operation result and counter increment are recomputed independently from one logical write. | +| ORC-009, vocabulary | Source input and public Collection rows are named separately; retained expected rows mean the latest valid source snapshot. | Concrete owner identity, sync-run cleanup and DbClient materialization are explicitly distinct. | Source acceptance is distinct from visibility held by an optimistic transaction. Idle/pending/startup-error are declared driver starts, not a replacement Collection status enum. | Offline replay is distinguished from pagination replacement. Admission, server acceptance, replay completion, receipt key and application counter name concrete process/server observations. | +| ORC-010, failure fidelity | Shared withTestCleanup retains the primary failure and secondary release errors. | The helper enumerates admitted Collections at cleanup time and attempts every release. | The real-Collection driver's cleanup uses the same preservation helper; the stage driver owns no disposable resource. | The shared preservation helper protects backend bodies and attempts both client close and temporary storage removal. Child timeout/runtime failures are setup/process failures, not successful mutant kills. | +| ORC-011, second formulation | No concrete shared semantic-fault hypothesis remains after pairwise validity and direct source-array comparison; another copy is not needed. | No named shared semantic-fault hypothesis calls for another identity formulation. Real SDK support is a receiving witness, not a second ownership model. | No named shared semantic-fault hypothesis remains for the dependency prefix; the real-Collection receiving path repairs the prior stub assumption at a different boundary. | Equal final task rows could hide duplicate applications. The non-idempotent counter is the independent distinguishing formulation, while durable receipt-key equality also rejects key substitution. | + +## Bug-class boundary and calibration + +The original cold-replay defect violated source acceptance at +`bridge × idle/pending/failed startup × real Collection adapter × replay settlement`. +The repaired source driver rejects premature fulfillment before the first valid +snapshot, accepts empty snapshots, and distinguishes provider-error same-run +recovery from cleanup invalidation. Independent review found an adjacent hole: +a rejected durable proposal could leave the identity fast path clearing its +error on the same cached rows. The receiving fixture now defers core commit +behind durability rather than artificially rejecting after core acceptance. +Empty/nonempty proposals, one to four unchanged/changed retries, and a fresh +cleanup/restart distinguish terminal failed-base authority from provider recovery. +The nonterminal-guard mutant fails at the rejection checkpoint and repaired +fixed/random campaigns and direct replay pass. No known reachable counterexample +remains in these declared cells; no universal concurrency proof is claimed. + +Backend calibration rejected all three hostile controls at intended observations: + +- Applying a write twice while retaining one receipt fails the non-idempotent + application counter, insert seed 20261022/path 0:0:0:1. +- Substituting `wrong-durable-key` fails equality with the admission record's key, + update seed 20261022/path 0:0:0:0:0. +- Omitting payload only from the replay child's select projection fails its + complete public-row comparison, with server payload still correct, using that + same update replay path. + +These were assertion failures after the legal target checkpoint, rather than +setup failures or timeouts. The author restored every mutant in finally and +reran the repaired operation-specific direct replays. The parent independently +reads durable bytes and server effects; the replay child observes its Collection. + +## Remaining limits and verdict + +The cold source driver establishes real Collection acceptance with a controlled +provider, not a cloud or local-backend cold replay. The backend replay fixture +preloads its Collection. SDK listeners receive the actual SDK cache and listener +ownership premise. None of these observations claims cross-Collection atomic +publication, continuous unbounded pagination, native browser storage power-loss +atomicity, or arbitrary coordination histories. + +No confirmed contract defect remains within the reviewed boundaries. This is a +bounded verdict, not a proof of every legal provider or scheduler history. + +## Additional numbered requirements + +| Requirement | Snapshot | Ownership | Bridge, including cold source and rejected acceptance | Real backend receiving owner | +| --- | --- | --- | --- | --- | +| ORC-012, exact-head review evidence | This versioned record names the reviewed head and every applicable outcome above. Missing projection was challenged by a hostile control, not only by a new happy-path example. | The record separates concrete admission from SDK receiving evidence and bounds the claim to the generated identity forms and checkpoints. | The bug-class boundary, original early success, adjacent rejected-durable-base counterexample, wrong-design rejection and remaining provider limits are explicit. No unresolved reachable in-scope trace is known. | The record names the real host, legal SIGKILL cuts, independent effects, exact calibration observations, replay bindings and excluded coordination/power-loss boundaries. | +| ORC-013, distinguishing reusable boundaries | One valid key is admitted; two equal keys reject the complete snapshot and preserve the prior valid array. An absent optional field removes it after a prior present field, rejecting partial-row retention. Nonidentity projection rejects an identity-selector implementation. | Same concrete owner rejects before and after cleanup; fresh options and separate DbClients admit at those same checkpoints. The cleaned-up reuse case rejects the wrong rule that cleanup retires the options owner. | Pending first source cannot fulfill, while valid empty source does. Argument/mutation errors are terminal while post-confirmation sync errors remain retryable. Provider error can recover on a later valid result; rejected durable acceptance stays failed across unchanged/changed retries until a fresh run. Removing the terminal guards is rejected. | Two server attempts with one durable key produce one logical application. One receipt alone is not sufficient: the duplicate-effect mutant still leaves one receipt yet fails the counter. A same-count wrong-key receipt fails exact key comparison, and a server-correct/public-stale payload fails the child's public-row comparison. | +| ORC-014, controlled-premise handoff | Controlled arrays and successful projection exercise the adapter semantic boundary. SDK tests receive actual Convex decoding and listener delivery; backend select projection receives real documents. They are narrower receiving examples, not copies of this full generated source grammar. The source-array owner does not claim provider scheduling coverage. | The controlled fixture supplies cached data before the scheduled callback. Both actual SDK clients receive that cache window in sdk.test.ts. New sibling tests clean up either listener, then supply the next protocol transition and check the survivor, supplying the exact multi-listener premise the controlled fixture cannot. | Stub stage traces claim collaborator ordering/classification only. Cold and rejected-acceptance drivers use real Collections with controlled callback and receipt boundaries. The receipt fixture delays actual core commit behind durability, matching persisted.visibleAfterDurable. These drivers do not claim native SQLite failure or backend cold-replay coverage. SDK tests separately receive query/mutation timestamp ordering. | This owner receives actual local Convex query and mutation behavior and actual process death/storage reads. Its online and single-leader collaborators deliberately limit the coordination premise; native multi-tab election and OS online detection are not claimed. It does not reuse the bridge's stage-prefix model because durable restart, counter effects and server receipts are a different law and observation boundary. | + +The SDK receiving examples use fixed protocol histories to observe the SDK's +cache, mutation timestamp and listener ownership premises. They do not share the +snapshot generator because their purpose is to receive those timing/ownership +conditions rather than repeat the complete snapshot law. Their fixed expected +selected text is derived from the explicitly supplied server text and projection. +The drift risk is that these examples do not receive every generated snapshot +shape; the generated owner remains the authority for those shapes at its stated +adapter boundary. Real-backend pagination and durable rows add provider-boundary +coverage without claiming the SDK fixture proves cloud availability. + +The manually deferred receipt is limited to the documented acceptance interface. +A Convex Collection wrapped by a real SQLite persistence adapter failing its disk +write has not been exercised by this integration. That native combination belongs +to the persistence receiving boundary, not a claim made by this controlled driver. +The coverage map retains the persistence and offline-engine owners for their +storage, leadership and settlement laws. + From 5b7a407a314d1908f9cf66d2b0f2db35801f8abe Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Fri, 23 Oct 2026 21:45:02 -0600 Subject: [PATCH 06/20] test: admit the dedicated offline codec replay selector --- .../2026-10-23-convex-pagination-and-codecs.md | 12 ++++++++++++ packages/offline-transactions/tests/oracle-config.ts | 3 ++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/docs/contributing/oracle-reviews/2026-10-23-convex-pagination-and-codecs.md b/docs/contributing/oracle-reviews/2026-10-23-convex-pagination-and-codecs.md index 9956d2f196..147e2efcd4 100644 --- a/docs/contributing/oracle-reviews/2026-10-23-convex-pagination-and-codecs.md +++ b/docs/contributing/oracle-reviews/2026-10-23-convex-pagination-and-codecs.md @@ -91,3 +91,15 @@ values are bounded acyclic trees; identity sharing, cycles, cross-realm natives, custom objects, native Temporal, browser storage atomicity, and power-loss atomicity are outside this evidence. These limits remain with the codec and backend owners in the coverage map. + +## CI type-check follow-up + +CI exposed an omitted type-level selector entry in the shared config reader: +`OFFLINE_CODEC_ORACLE` worked at runtime but was absent from the accepted prefix +union. The offline package type check reproduced TS2820 before the repair. +Adding that exact prefix to the union makes the type check pass, and the same +full-file codec replay at seed 20261022/path 0:0:0:0 still passes all 51 checks. +This follow-up changes only a test configuration type, no production code, +model judgment, generated history, runtime selector routing, or comparison. +The exact-head semantic review above continues to refer to its stated source +revision rather than claiming a fresh independent audit of this type edit. diff --git a/packages/offline-transactions/tests/oracle-config.ts b/packages/offline-transactions/tests/oracle-config.ts index 8384b6da64..c2ea829814 100644 --- a/packages/offline-transactions/tests/oracle-config.ts +++ b/packages/offline-transactions/tests/oracle-config.ts @@ -1,7 +1,8 @@ type OracleEnvironment = Record type OfflineOracleConfigOptions = { - prefix: `OFFLINE_ORACLE` | `TANSTACK_DB_OFFLINE_ORACLE` + prefix: + `OFFLINE_ORACLE` | `TANSTACK_DB_OFFLINE_ORACLE` | `OFFLINE_CODEC_ORACLE` defaultRuns: number defaultSeed?: number environment?: OracleEnvironment From 4223c39a6d5c2b2e2fedb1a7eee3c0052b5e5013 Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Sat, 24 Oct 2026 02:26:59 -0600 Subject: [PATCH 07/20] test: exercise Convex public apps and authenticated permissions --- docs/contributing/oracle-coverage.md | 30 ++++ packages/convex-db-collection/README.md | 2 +- .../e2e/auth-app-oracle.test.ts | 152 ++++++++++++++++ .../e2e/auth-app/.gitignore | 1 + .../e2e/auth-app/README.md | 7 + .../e2e/auth-app/configure.ts | 13 ++ .../e2e/auth-app/package.json | 11 ++ .../e2e/auth-app/prepare.ts | 48 +++++ .../e2e/auth-app/setup.ts | 76 ++++++++ .../convex-db-collection/e2e/local-backend.ts | 81 +++++++++ .../convex-db-collection/e2e/network-gate.ts | 57 ++++++ .../e2e/public-pagination-oracle.test.ts | 169 ++++++++++++++++++ .../e2e/public-pagination-setup.ts | 102 +++++++++++ packages/convex-db-collection/e2e/setup.ts | 81 +-------- packages/convex-db-collection/tsconfig.json | 1 + .../convex-db-collection/vitest.e2e.config.ts | 6 +- pnpm-lock.yaml | 144 ++++++++++++++- 17 files changed, 898 insertions(+), 83 deletions(-) create mode 100644 packages/convex-db-collection/e2e/auth-app-oracle.test.ts create mode 100644 packages/convex-db-collection/e2e/auth-app/.gitignore create mode 100644 packages/convex-db-collection/e2e/auth-app/README.md create mode 100644 packages/convex-db-collection/e2e/auth-app/configure.ts create mode 100644 packages/convex-db-collection/e2e/auth-app/package.json create mode 100644 packages/convex-db-collection/e2e/auth-app/prepare.ts create mode 100644 packages/convex-db-collection/e2e/auth-app/setup.ts create mode 100644 packages/convex-db-collection/e2e/local-backend.ts create mode 100644 packages/convex-db-collection/e2e/network-gate.ts create mode 100644 packages/convex-db-collection/e2e/public-pagination-oracle.test.ts create mode 100644 packages/convex-db-collection/e2e/public-pagination-setup.ts diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index 44d4495069..fcea97c278 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -75,6 +75,36 @@ CONVEX_BACKEND_INSERT_ORACLE_SEED/PATH, CONVEX_BACKEND_UPDATE_ORACLE_SEED/PATH, The offline engine's interruption, settlement, storage, and leadership owners remain primary for those laws; this is their Convex receiving witness. +`packages/convex-db-collection/e2e/public-pagination-oracle.test.ts` receives the +finite pagination contract through the actual public `get-convex/convex-demos` +functions at revision `2fac21e360bfcceae6065096f1b81480bd8c6fd5`. Its independent +append ledger predicts descending message order, indexed author membership, and +uppercase/author-initial projections. Both SDK clients exercise all three query +forms at page size three, live appends, real TCP connection loss, direct writes +while disconnected, and complete recovery after reconnect. Unique authored +bodies supply the stable key omitted by the transformed query. It observes +ordered input to select and public Collection rows after source acceptance. +This is six authored receiving histories, not a new primary pagination model or +an arbitrary transformed-key guarantee. Setup fetches the pinned source at run +time, with no upstream application source redistributed in this repository. + +`packages/convex-db-collection/e2e/auth-app-oracle.test.ts` receives the snapshot +contract through the official role-based Convex Auth app at revision +`45f08008e1257758560948db4280cc4e7d64e84a`. The app's schema, message functions, +permission checks, and role updates come from that pinned source. Setup changes +only the external sign-in providers to the official Anonymous provider, with +ephemeral signing keys and real issued JWTs. An independent set-valued permission +policy drives all six read/write/admin operation cells, including demotion and repeated equal-body sends, while a multiset of +authored message bodies predicts the public rows at each accepted source +checkpoint. Both SDKs must reject an unauthenticated Collection preload; an +invalid-token HTTP query is a separate authentication control. The app deliberately +allows self-service role selection, so this does not establish secure role +administration. The two authored receiving histories do not cover OAuth redirects, +email delivery, token refresh, browser auth components, or production hosting. +Both public-app fixtures deploy with this package's installed Convex 1.47 SDK. +The pagination app's upstream package declares 1.48, its business functions are +unchanged here but its original dependency installation is not being tested. + `packages/offline-transactions/tests/transaction-serializer-oracle.property.test.ts` remains the primary wire-format owner. Its opt-in codec grammar crosses insert/update/delete and arrays of signed 64-bit integers, special floats and diff --git a/packages/convex-db-collection/README.md b/packages/convex-db-collection/README.md index 40c34808ed..b423be00f0 100644 --- a/packages/convex-db-collection/README.md +++ b/packages/convex-db-collection/README.md @@ -4,4 +4,4 @@ Subscribe to Convex query results with TanStack DB. Supports `ConvexClient`, `Co See the [Convex collection guide](https://tanstack.com/db/latest/docs/collections/convex-collection) for setup, mutation handling, generated IDs, and supported behavior. -Run `pnpm --filter @tanstack/convex-db-collection test:e2e` to start an isolated real Convex backend and test both clients, server page splits, and durable replay after process death. +Run `pnpm --filter @tanstack/convex-db-collection test:e2e` to start isolated local Convex backends and test both clients, server page splits, and durable replay after process death. The suite also fetches pinned official pagination and authenticated chat examples to check integration with application code maintained outside this adapter. It needs network access to GitHub and the Convex backend download, but no cloud project or production credentials. Authentication uses a real Anonymous-provider sign-in with temporary signing keys. These checks do not exercise production cloud infrastructure or third-party OAuth services. diff --git a/packages/convex-db-collection/e2e/auth-app-oracle.test.ts b/packages/convex-db-collection/e2e/auth-app-oracle.test.ts new file mode 100644 index 0000000000..d193e14b36 --- /dev/null +++ b/packages/convex-db-collection/e2e/auth-app-oracle.test.ts @@ -0,0 +1,152 @@ +/** + * Receiving witness for the official role-based Convex Auth application pinned + * in auth-app/README.md. Its server functions define read/write/admin access. + * The independent role table predicts which operations may change the messages: + * read permits observation, write permits send, admin also permits deletion. + * A genuine Anonymous signIn issues the JWT used by both SDK WebSocket clients. + * Rejected operations must leave the public Collection unchanged; successful + * sends and deletions must reach its rows after awaitSync accepts the source. + * + * These are bounded authored role histories through the upstream application, + * not a model of OAuth, token refresh, role administration security, or browsers. + * The upstream demo deliberately allows self-service role changes. A separate + * unauthenticated client and an invalid token distinguish server authentication + * from trusting an asserted client identity. No setAdminAuth is used. + */ +import { ConvexClient, ConvexHttpClient } from 'convex/browser' +import { ConvexReactClient } from 'convex/react' +import { makeFunctionReference } from 'convex/server' +import { createCollection } from '@tanstack/db' +import { expect, inject, it } from 'vitest' +import { convexCollectionOptions } from '../src/index' +import { withTestCleanup } from '../tests/helpers' + +declare module 'vitest' { + interface ProvidedContext { + authAppUrl: string + } +} +type Message = { _id: string; body: string; userId: string; author?: string } +const list = makeFunctionReference<`query`, {}, Array>(`messages:list`) +const send = makeFunctionReference<`mutation`, { body: string }, null>( + `messages:send`, +) +const remove = makeFunctionReference<`mutation`, { messageId: string }, null>( + `messages:deleteMessage`, +) +const role = makeFunctionReference< + `mutation`, + { role: `read` | `write` | `admin` }, + null +>(`auth:updateRole`) +const signIn = makeFunctionReference< + `action`, + { provider: string }, + { tokens: { token: string; refreshToken: string } | null } +>(`auth:signIn`) +const url = inject(`authAppUrl`) +type Role = `read` | `write` | `admin` +type Operation = `send` | `delete` +// This set-valued policy is independent of upstream's numeric role hierarchy. +// The message multiset contains authored bodies, never server-assigned IDs. +const permitted: Record> = { + read: [], + write: [`send`], + admin: [`send`, `delete`], +} +const history: ReadonlyArray<{ role: Role; operation: Operation }> = [ + { role: `read`, operation: `send` }, + { role: `write`, operation: `send` }, + { role: `write`, operation: `delete` }, + { role: `read`, operation: `send` }, + { role: `read`, operation: `delete` }, + { role: `admin`, operation: `send` }, + { role: `admin`, operation: `delete` }, + { role: `admin`, operation: `delete` }, +] + +it.each([`javascript`, `react`] as const)( + `receives genuine authenticated role transitions with the %s client`, + async (kind) => { + const http = new ConvexHttpClient(url) + await expect(http.query(list, {})).rejects.toThrow(`Not signed in`) + const result = await http.action(signIn, { provider: `anonymous` }) + expect(result.tokens?.token.split(`.`)).toHaveLength(3) + const token = result.tokens!.token + const invalid = new ConvexHttpClient(url) + invalid.setAuth(`${token.slice(0, -3)}bad`) + await expect(invalid.query(list, {})).rejects.toThrow() + const makeClient = () => + kind === `javascript` + ? new ConvexClient(url, { unsavedChangesWarning: false, logger: false }) + : new ConvexReactClient(url, { + unsavedChangesWarning: false, + logger: false, + }) + const unauthenticated = makeClient() + const denied = createCollection( + convexCollectionOptions({ + client: unauthenticated, + query: list, + queryArgs: {}, + getKey: (row) => row._id, + gcTime: 0, + }), + ) + await withTestCleanup( + async () => { + await expect(denied.preload()).rejects.toThrow(`Not signed in`) + expect([...denied.values()]).toEqual([]) + }, + () => denied.cleanup(), + () => unauthenticated.close(), + ) + const client = makeClient() + client.setAuth(() => Promise.resolve(token)) + const collection = createCollection( + convexCollectionOptions({ + client, + query: list, + queryArgs: {}, + getKey: (row) => row._id, + gcTime: 0, + }), + ) + const marker = `auth-${kind}-${crypto.randomUUID()}` + await withTestCleanup( + async () => { + await collection.preload() + const model: Array = [] + for (const step of history) { + // This is the upstream demo's public role selector, not admin auth. + await client.mutation(role, { role: step.role }) + const allowed = permitted[step.role].includes(step.operation) + const inserted = [...collection.values()].find( + (row) => row.body === marker, + ) + const attempt = + step.operation === `send` + ? client.mutation(send, { body: marker }) + : client.mutation(remove, { messageId: inserted!._id }) + if (allowed) { + await attempt + if (step.operation === `send`) model.push(marker) + else model.splice(model.indexOf(marker), 1) + } else + await expect(attempt).rejects.toThrow(`Insufficient permissions`) + await collection.utils.awaitSync() + // Compare at every accepted source checkpoint, including denials and + // demotion. Two successful equal-body sends remain two model rows. + expect( + [...collection.values()] + .filter((row) => row.body === marker) + .map((row) => row.body) + .sort(), + ).toEqual([...model].sort()) + } + }, + () => collection.cleanup(), + () => client.close(), + ) + }, +) diff --git a/packages/convex-db-collection/e2e/auth-app/.gitignore b/packages/convex-db-collection/e2e/auth-app/.gitignore new file mode 100644 index 0000000000..712f264ddd --- /dev/null +++ b/packages/convex-db-collection/e2e/auth-app/.gitignore @@ -0,0 +1 @@ +runtime/ diff --git a/packages/convex-db-collection/e2e/auth-app/README.md b/packages/convex-db-collection/e2e/auth-app/README.md new file mode 100644 index 0000000000..56b5563dd6 --- /dev/null +++ b/packages/convex-db-collection/e2e/auth-app/README.md @@ -0,0 +1,7 @@ +# Authenticated official app fixture + +Adapted from [get-convex/convex-auth-with-role-based-permissions](https://github.com/get-convex/convex-auth-with-role-based-permissions/tree/45f08008e1257758560948db4280cc4e7d64e84a), pinned at `45f08008e1257758560948db4280cc4e7d64e84a`. + +Test setup loads the schema, message functions, permission checks, role update, and HTTP authentication routes from that pinned public checkout into an ignored runtime directory, no upstream source is vendored here. Its GitHub/Resend providers are replaced with Convex Auth's official Anonymous provider so a real sign-in can issue a JWT without external credentials. No admin authentication shortcut supplies the tested client identity. Setup generates fresh signing keys for an isolated local backend. + +The example's public `updateRole` allows signed-in users to choose their own role, as its source comment explains. This fixture checks that message access follows those roles, it does not claim that role administration is secure. OAuth redirects, email delivery, browser auth-provider components, refresh-token rotation, and production infrastructure are outside this witness. diff --git a/packages/convex-db-collection/e2e/auth-app/configure.ts b/packages/convex-db-collection/e2e/auth-app/configure.ts new file mode 100644 index 0000000000..21b43d397a --- /dev/null +++ b/packages/convex-db-collection/e2e/auth-app/configure.ts @@ -0,0 +1,13 @@ +import { exportJWK, exportPKCS8, generateKeyPair } from 'jose' + +/** Ephemeral keys only, passed to the isolated backend's environment setup. */ +export async function authEnvironment(): Promise> { + const { privateKey, publicKey } = await generateKeyPair(`RS256`) + return { + JWT_PRIVATE_KEY: (await exportPKCS8(privateKey)).trimEnd(), + JWKS: JSON.stringify({ + keys: [{ use: `sig`, ...(await exportJWK(publicKey)) }], + }), + SITE_URL: `http://127.0.0.1:3000`, + } +} diff --git a/packages/convex-db-collection/e2e/auth-app/package.json b/packages/convex-db-collection/e2e/auth-app/package.json new file mode 100644 index 0000000000..6c5dcac7b7 --- /dev/null +++ b/packages/convex-db-collection/e2e/auth-app/package.json @@ -0,0 +1,11 @@ +{ + "name": "convex-auth-app-test-backend", + "private": true, + "type": "module", + "dependencies": { + "@convex-dev/auth": "0.0.90", + "@auth/core": "0.37.4", + "convex": "^1.47.0", + "jose": "5.10.0" + } +} diff --git a/packages/convex-db-collection/e2e/auth-app/prepare.ts b/packages/convex-db-collection/e2e/auth-app/prepare.ts new file mode 100644 index 0000000000..3a3a2a1308 --- /dev/null +++ b/packages/convex-db-collection/e2e/auth-app/prepare.ts @@ -0,0 +1,48 @@ +import { cp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' + +/** Fetching and verifying the pinned public checkout belongs to test setup. */ +export async function prepareAuthApp(checkout: string): Promise { + const runtime = fileURLToPath(new URL(`./runtime`, import.meta.url)) + await rm(runtime, { recursive: true, force: true }) + const destination = join(runtime, `convex`) + await mkdir(destination, { recursive: true }) + for (const source of [ + `messages.ts`, + `schema.ts`, + `auth.config.ts`, + `http.ts`, + `tsconfig.json`, + `lib`, + ]) + await cp(join(checkout, `convex`, source), join(destination, source), { + recursive: true, + }) + const upstream = await readFile(join(checkout, `convex`, `auth.ts`), `utf8`) + if ( + !upstream.includes( + `import GitHub from "@auth/core/providers/github";\nimport Resend from "@auth/core/providers/resend";`, + ) || + !upstream.includes(`providers: [GitHub, Resend],`) + ) + throw new Error(`Pinned upstream auth provider configuration changed`) + const auth = upstream + .replace( + `import GitHub from "@auth/core/providers/github";\nimport Resend from "@auth/core/providers/resend";`, + `import { Anonymous } from "@convex-dev/auth/providers/Anonymous";`, + ) + .replace(`providers: [GitHub, Resend],`, `providers: [Anonymous],`) + if (auth === upstream || !auth.includes(`providers: [Anonymous],`)) + throw new Error(`Pinned upstream auth provider configuration changed`) + await writeFile(join(destination, `auth.ts`), auth) + await writeFile( + join(runtime, `package.json`), + JSON.stringify({ + private: true, + type: `module`, + dependencies: { convex: `^1.47.0` }, + }), + ) + return runtime +} diff --git a/packages/convex-db-collection/e2e/auth-app/setup.ts b/packages/convex-db-collection/e2e/auth-app/setup.ts new file mode 100644 index 0000000000..3865552584 --- /dev/null +++ b/packages/convex-db-collection/e2e/auth-app/setup.ts @@ -0,0 +1,76 @@ +import { execFile } from 'node:child_process' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { promisify } from 'node:util' +import { startLocalBackend } from '../local-backend' +import { withTestCleanup } from '../../tests/helpers' +import { prepareAuthApp } from './prepare' +import { authEnvironment } from './configure' +import type { TestProject } from 'vitest/node' + +const run = promisify(execFile) +const upstream = `https://github.com/get-convex/convex-auth-with-role-based-permissions.git` +const revision = `45f08008e1257758560948db4280cc4e7d64e84a` + +/** The provider issues genuine JWTs, no client is given deployment admin auth. */ +export default async function setup(project: TestProject) { + const checkout = await mkdtemp(join(tmpdir(), `convex-auth-upstream-`)) + let stop: (() => Promise) | undefined + const runtime = fileURLToPath(new URL(`./runtime/`, import.meta.url)) + try { + await run(`git`, [`init`, checkout]) + await run(`git`, [`-C`, checkout, `remote`, `add`, `origin`, upstream]) + await run(`git`, [`-C`, checkout, `fetch`, `--depth=1`, `origin`, revision]) + await run(`git`, [`-C`, checkout, `checkout`, `--detach`, `FETCH_HEAD`]) + const { stdout } = await run(`git`, [`-C`, checkout, `rev-parse`, `HEAD`]) + if (stdout.trim() !== revision) + throw new Error(`Wrong upstream auth revision`) + await prepareAuthApp(checkout) + const backend = await startLocalBackend(pathToFileURL(`${runtime}/`), 43330) + stop = backend.stop + if (!backend.url.startsWith(`http://127.0.0.1:`)) + throw new Error(`Authentication setup requires an isolated local backend`) + const { + CONVEX_DEPLOY_KEY: _key, + CONVEX_SELF_HOSTED_URL: _url, + CONVEX_SELF_HOSTED_ADMIN_KEY: _admin, + CONVEX_DEPLOYMENT: _deployment, + ...environment + } = process.env + const cli = fileURLToPath( + new URL(`./node_modules/convex/bin/main.js`, import.meta.url), + ) + for (const [name, value] of Object.entries(await authEnvironment())) { + try { + await run(process.execPath, [cli, `env`, `set`, name, `--`, value], { + cwd: runtime, + env: environment, + }) + } catch { + // execFile errors contain argv, which contains the ephemeral private key. + throw new Error( + `Failed to configure local authentication variable ${name}`, + ) + } + } + project.provide(`authAppUrl`, backend.url) + } catch (cause) { + await withTestCleanup( + () => { + throw cause + }, + () => stop?.(), + () => rm(checkout, { recursive: true, force: true }), + () => rm(runtime, { recursive: true, force: true }), + ) + } + return async () => + withTestCleanup( + () => {}, + () => stop?.(), + () => rm(checkout, { recursive: true, force: true }), + () => rm(runtime, { recursive: true, force: true }), + ) +} diff --git a/packages/convex-db-collection/e2e/local-backend.ts b/packages/convex-db-collection/e2e/local-backend.ts new file mode 100644 index 0000000000..7b480b7dcb --- /dev/null +++ b/packages/convex-db-collection/e2e/local-backend.ts @@ -0,0 +1,81 @@ +import { spawn } from 'node:child_process' +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import { withTestCleanup } from '../tests/helpers' + +/** Own a dedicated local backend for the test run. Never select a cloud project. */ +export async function startLocalBackend(directory: URL, port: number) { + const { + CONVEX_DEPLOY_KEY: _deployKey, + CONVEX_SELF_HOSTED_URL: _selfHostedUrl, + CONVEX_SELF_HOSTED_ADMIN_KEY: _adminKey, + CONVEX_DEPLOYMENT: _deployment, + ...environment + } = process.env + const backend = spawn( + process.execPath, + [ + fileURLToPath( + new URL(`../node_modules/convex/bin/main.js`, import.meta.url), + ), + `dev`, + `--typecheck`, + `disable`, + `--tail-logs`, + `disable`, + `--local-cloud-port`, + String(port), + `--local-site-port`, + String(port + 1), + ], + { + cwd: fileURLToPath(directory), + env: { ...environment, CONVEX_AGENT_MODE: `anonymous`, FORCE_COLOR: `0` }, + stdio: [`ignore`, `pipe`, `pipe`], + }, + ) + let output = `` + try { + await new Promise((resolve, reject) => { + const timeout = setTimeout( + () => reject(new Error(`Local Convex startup timed out:\n${output}`)), + 120000, + ) + const receive = (chunk: Buffer) => { + output = (output + String(chunk)).slice(-8000) + if (output.includes(`Convex functions ready!`)) { + clearTimeout(timeout) + resolve() + } + } + backend.stdout.on(`data`, receive) + backend.stderr.on(`data`, receive) + backend.once(`error`, (cause) => { + clearTimeout(timeout) + reject(cause) + }) + backend.once(`exit`, (code) => { + clearTimeout(timeout) + reject(new Error(`Local Convex exited (${code}):\n${output}`)) + }) + }) + const env = await readFile(new URL(`.env.local`, directory), `utf8`) + const url = /^CONVEX_URL=(.+)$/m.exec(env)?.[1] + if (url !== `http://127.0.0.1:${port}`) + throw new Error(`Expected an isolated loopback Convex backend`) + return { url, stop } + } catch (cause) { + await withTestCleanup(() => { + throw cause + }, stop) + throw cause + } + async function stop() { + if (backend.exitCode !== null || backend.signalCode !== null) return + const exited = new Promise((resolve) => + backend.once(`exit`, () => resolve()), + ) + backend.kill(`SIGINT`) + await exited + } +} diff --git a/packages/convex-db-collection/e2e/network-gate.ts b/packages/convex-db-collection/e2e/network-gate.ts new file mode 100644 index 0000000000..caba0d077d --- /dev/null +++ b/packages/convex-db-collection/e2e/network-gate.ts @@ -0,0 +1,57 @@ +import { createConnection, createServer } from 'node:net' +import type { Socket } from 'node:net' + +/** + * Forward real HTTP and WebSocket bytes without interpreting Convex messages. + * Closing the gate destroys existing TCP connections and refuses new ones, so + * SDK recovery must use its public reconnect path against the actual backend. + */ +export async function createNetworkGate(backendUrl: string) { + const target = new URL(backendUrl) + if (target.hostname !== `127.0.0.1`) + throw new Error(`Network tests require an isolated loopback backend`) + const connections = new Set() + let online = true + const server = createServer((incoming) => { + if (!online) { + incoming.destroy() + return + } + const outgoing = createConnection(Number(target.port), target.hostname) + connections.add(incoming) + connections.add(outgoing) + const close = () => { + connections.delete(incoming) + connections.delete(outgoing) + incoming.destroy() + outgoing.destroy() + } + incoming.on(`error`, close).on(`close`, close) + outgoing.on(`error`, close).on(`close`, close) + incoming.pipe(outgoing).pipe(incoming) + }) + await new Promise((resolve, reject) => { + server.once(`error`, reject) + server.listen(0, `127.0.0.1`, resolve) + }) + const address = server.address() + if (!address || typeof address === `string`) + throw new Error(`Expected a TCP listening address`) + return { + url: `http://127.0.0.1:${address.port}`, + disconnect() { + online = false + for (const socket of connections) socket.destroy() + }, + reconnect() { + online = true + }, + async close() { + online = false + for (const socket of connections) socket.destroy() + await new Promise((resolve, reject) => + server.close((cause) => (cause ? reject(cause) : resolve())), + ) + }, + } +} diff --git a/packages/convex-db-collection/e2e/public-pagination-oracle.test.ts b/packages/convex-db-collection/e2e/public-pagination-oracle.test.ts new file mode 100644 index 0000000000..693b17c59d --- /dev/null +++ b/packages/convex-db-collection/e2e/public-pagination-oracle.test.ts @@ -0,0 +1,169 @@ +/** + * Receiving oracle for the actual public get-convex/convex-demos pagination app + * at 2fac21e360bfcceae6065096f1b81480bd8c6fd5, deployed unchanged at runtime by + * public-pagination-setup.ts. Its messages:list orders newest first; + * listWithExtraArg restricts that order to an indexed author; and + * listWithTransformation returns author initials and uppercase body text. + * Source: https://github.com/get-convex/convex-demos/blob/2fac21e360bfcceae6065096f1b81480bd8c6fd5/pagination/convex/messages.ts + * + * The independent model is an append-only ledger of successful sends. Reverse + * insertion order gives the complete source, with an optional author predicate + * and the declared text transformation. It never asks the adapter or a paged + * server query for expected rows. Every body is unique, including after case + * conversion, to provide the stable key omitted by the transformed upstream + * query. These tests do not claim that arbitrary transformed rows have keys. + * + * Both actual SDK clients run all three public queries at pageSize 3. Each + * history adds enough messages for several ranges and includes another author, + * then appends while subscribed. A transparent TCP gate next destroys the + * adapter client's real connections; a separate direct writer commits while + * disconnected. The old public snapshot must remain until reconnect, then the + * complete Collection and select-boundary order must equal the ledger. + * + * This is finite public-app conformance, not a browser-render test, a generated + * history campaign, a total network-work bound, or a test of cloud hosting. + * Calibration: disabling adapter continuation is rejected immediately after + * initial preload at the select-order comparison, three received rows versus + * seventeen expected for JavaScript (and three versus thirty-four for React). + * The fixed replay selector is `-t 'mirrors list through'`; no timeout or setup + * failure is counted as that rejection. The repaired full six-case run passes. + * + * The upstream source declares Convex 1.48.0; this receiving fixture deliberately + * deploys its functions with this package's installed Convex 1.47 SDK/backend. + */ +import { createCollection } from '@tanstack/db' +import { ConvexClient } from 'convex/browser' +import { ConvexReactClient } from 'convex/react' +import { makeFunctionReference } from 'convex/server' +import { describe, expect, inject, it } from 'vitest' +import { convexPaginatedCollectionOptions } from '../src/index' +import { withTestCleanup } from '../tests/helpers' +import { createNetworkGate } from './network-gate' +import type { PaginationOptions, PaginationResult } from 'convex/server' + +type Message = { author: string; body: string } +const ledger: Array = [] +const send = makeFunctionReference<`mutation`, Message, null>(`messages:send`) +const backend = inject(`publicPaginationUrl`) +const queries = [`list`, `listWithExtraArg`, `listWithTransformation`] as const +type QueryName = (typeof queries)[number] + +function expectedRows(name: QueryName, author: string): Array { + return [...ledger] + .reverse() + .filter( + (message) => name !== `listWithExtraArg` || message.author === author, + ) + .map((message) => + name === `listWithTransformation` + ? { author: message.author.charAt(0), body: message.body.toUpperCase() } + : { ...message }, + ) +} + +// The backend starts with a new runtime directory and empty database. File and +// test execution are sequential, so this ledger includes all successful writes +// across the six histories, including rows outside the requested author scope. +describe.each([`javascript`, `react`] as const)( + `public pagination app with %s client`, + (kind) => { + it.each(queries)( + `mirrors %s through live writes and a real reconnect`, + async (name) => { + const gate = await createNetworkGate(backend) + const writer = new ConvexClient(backend, { + unsavedChangesWarning: false, + logger: false, + }) + const client = + kind === `javascript` + ? new ConvexClient(gate.url, { + unsavedChangesWarning: false, + logger: false, + }) + : new ConvexReactClient(gate.url, { + unsavedChangesWarning: false, + logger: false, + }) + const author = `author:${kind}:${name}` + const query = makeFunctionReference< + `query`, + { paginationOpts: PaginationOptions; author?: string }, + PaginationResult + >(`messages:${name}`) + let selected: ReadonlyArray = [] + const collection = createCollection( + convexPaginatedCollectionOptions({ + client, + query, + queryArgs: name === `listWithExtraArg` ? { author } : {}, + pageSize: 3, + select: (rows) => { + selected = rows.map(({ author: from, body }) => ({ + author: from, + body, + })) + return rows + }, + getKey: (row) => row.body, + gcTime: 0, + }), + ) + const publish = async (from: string) => { + const message = { + author: from, + body: `message ${ledger.length} from ${from}`, + } + await writer.mutation(send, message) + ledger.push(message) + } + const check = async (waitForUpdate = true) => { + const expected = expectedRows(name, author) + if (waitForUpdate) + await expect + .poll(() => selected, { timeout: 15000 }) + .toEqual(expected) + else expect(selected).toEqual(expected) + await collection.utils.awaitSync() + expect( + [...collection.values()] + .map(({ author: from, body }) => ({ author: from, body })) + .sort((a, b) => a.body.localeCompare(b.body)), + ).toEqual([...expected].sort((a, b) => a.body.localeCompare(b.body))) + } + await withTestCleanup( + async () => { + for (let index = 0; index < 17; index++) + await publish(index % 2 === 0 ? author : `other:${author}`) + await collection.preload() + await check(false) + await publish(author) + await check() + await publish(`other:${author}`) + await check() + const before = [...selected] + gate.disconnect() + await expect + .poll(() => client.connectionState().isWebSocketConnected) + .toBe(false) + await publish(author) + await publish(`other:${author}`) + expect(selected).toEqual(before) + expect( + [...collection.values()] + .map(({ author: from, body }) => ({ author: from, body })) + .sort((a, b) => a.body.localeCompare(b.body)), + ).toEqual([...before].sort((a, b) => a.body.localeCompare(b.body))) + gate.reconnect() + await check() + }, + () => collection.cleanup(), + () => client.close(), + () => writer.close(), + () => gate.close(), + ) + }, + 45000, + ) + }, +) diff --git a/packages/convex-db-collection/e2e/public-pagination-setup.ts b/packages/convex-db-collection/e2e/public-pagination-setup.ts new file mode 100644 index 0000000000..c561b43207 --- /dev/null +++ b/packages/convex-db-collection/e2e/public-pagination-setup.ts @@ -0,0 +1,102 @@ +import { execFile } from 'node:child_process' +import { mkdtemp, rm, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { promisify } from 'node:util' +import { withTestCleanup } from '../tests/helpers' +import { startLocalBackend } from './local-backend' +import type { TestProject } from 'vitest/node' + +const run = promisify(execFile) +const revision = `2fac21e360bfcceae6065096f1b81480bd8c6fd5` +const repository = `https://github.com/get-convex/convex-demos.git` + +declare module 'vitest' { + interface ProvidedContext { + publicPaginationUrl: string + } +} + +/** + * Run the actual pinned public demo, without redistributing its unlicensed + * source. git archive reads the pinned tree, so local edits and untracked + * files cannot alter the deployed messages/schema functions. The app shell is + * independently supplied test configuration using this package's Convex SDK. + */ +export default async function setup(project: TestProject) { + const directory = await mkdtemp(join(tmpdir(), `convex-public-pagination-`)) + let stop: (() => Promise) | undefined + try { + let source = process.env.CONVEX_PUBLIC_DEMOS_SOURCE + if (source) { + const head = ( + await run(`git`, [`-C`, source, `rev-parse`, `HEAD`]) + ).stdout.trim() + if (head !== revision) + throw new Error( + `Public pagination source must be pinned to ${revision}`, + ) + } else { + source = join(directory, `repository`) + await run(`git`, [`init`, source]) + await run(`git`, [ + `-C`, + source, + `fetch`, + `--depth=1`, + repository, + revision, + ]) + await run(`git`, [`-C`, source, `checkout`, `--detach`, `FETCH_HEAD`]) + } + const archive = join(directory, `source.tar`) + await run(`git`, [ + `-C`, + source, + `archive`, + `--format=tar`, + `--output=${archive}`, + revision, + `pagination/convex`, + ]) + await run(`tar`, [`-xf`, archive, `-C`, directory]) + const app = join(directory, `pagination`) + await writeFile( + join(app, `package.json`), + JSON.stringify({ + name: `convex-public-pagination-integration`, + private: true, + type: `module`, + dependencies: { convex: `1.47.0` }, + }), + ) + await symlink( + fileURLToPath(new URL(`../node_modules`, import.meta.url)), + join(app, `node_modules`), + `dir`, + ) + const backend = await startLocalBackend(pathToFileURL(`${app}/`), 43320) + stop = backend.stop + project.provide(`publicPaginationUrl`, backend.url) + } catch (error) { + await withTestCleanup( + () => { + throw error + }, + async () => { + await stop?.() + }, + () => rm(directory, { recursive: true, force: true }), + ) + throw error + } + return () => + withTestCleanup( + () => {}, + async () => { + await stop() + }, + () => rm(directory, { recursive: true, force: true }), + ) +} diff --git a/packages/convex-db-collection/e2e/setup.ts b/packages/convex-db-collection/e2e/setup.ts index 442b602415..67b884cc8b 100644 --- a/packages/convex-db-collection/e2e/setup.ts +++ b/packages/convex-db-collection/e2e/setup.ts @@ -1,6 +1,4 @@ -import { spawn } from 'node:child_process' -import { readFile } from 'node:fs/promises' -import { fileURLToPath } from 'node:url' +import { startLocalBackend } from './local-backend' import type { TestProject } from 'vitest/node' declare module 'vitest' { @@ -11,77 +9,10 @@ declare module 'vitest' { /** Own a dedicated local backend for the test run. Never select a cloud project. */ export default async function setup(project: TestProject) { - const { - CONVEX_DEPLOY_KEY: _deployKey, - CONVEX_SELF_HOSTED_URL: _selfHostedUrl, - CONVEX_SELF_HOSTED_ADMIN_KEY: _adminKey, - CONVEX_DEPLOYMENT: _deployment, - ...environment - } = process.env - const backend = spawn( - process.execPath, - [ - fileURLToPath( - new URL(`../node_modules/convex/bin/main.js`, import.meta.url), - ), - `dev`, - `--typecheck`, - `disable`, - `--tail-logs`, - `disable`, - `--local-cloud-port`, - process.env.CONVEX_TEST_PORT ?? `43310`, - `--local-site-port`, - String(Number(process.env.CONVEX_TEST_PORT ?? `43310`) + 1), - ], - { - cwd: fileURLToPath(new URL(`./backend`, import.meta.url)), - env: { ...environment, CONVEX_AGENT_MODE: `anonymous`, FORCE_COLOR: `0` }, - stdio: [`ignore`, `pipe`, `pipe`], - }, + const backend = await startLocalBackend( + new URL(`./backend/`, import.meta.url), + Number(process.env.CONVEX_TEST_PORT ?? `43310`), ) - let output = `` - try { - await new Promise((resolve, reject) => { - const timeout = setTimeout( - () => reject(new Error(`Local Convex startup timed out:\n${output}`)), - 120000, - ) - const receive = (chunk: Buffer) => { - output = (output + String(chunk)).slice(-8000) - if (output.includes(`Convex functions ready!`)) { - clearTimeout(timeout) - resolve() - } - } - backend.stdout.on(`data`, receive) - backend.stderr.on(`data`, receive) - backend.once(`error`, (cause) => { - clearTimeout(timeout) - reject(cause) - }) - backend.once(`exit`, (code) => { - clearTimeout(timeout) - reject(new Error(`Local Convex exited (${code}):\n${output}`)) - }) - }) - const env = await readFile( - new URL(`./backend/.env.local`, import.meta.url), - `utf8`, - ) - const url = /^CONVEX_URL=(.+)$/m.exec(env)?.[1] - if (!url?.startsWith(`http://127.0.0.1:`)) - throw new Error(`Expected an isolated loopback Convex backend`) - project.provide(`convexUrl`, url) - } catch (cause) { - backend.kill(`SIGINT`) - throw cause - } - return async () => { - const exited = new Promise((resolve) => - backend.once(`exit`, () => resolve()), - ) - backend.kill(`SIGINT`) - await exited - } + project.provide(`convexUrl`, backend.url) + return backend.stop } diff --git a/packages/convex-db-collection/tsconfig.json b/packages/convex-db-collection/tsconfig.json index 27b9c12a32..02471eaff8 100644 --- a/packages/convex-db-collection/tsconfig.json +++ b/packages/convex-db-collection/tsconfig.json @@ -21,6 +21,7 @@ "src", "tests", "e2e/*.ts", + "e2e/auth-app/*.ts", "vite.config.ts", "vitest.e2e.config.ts" ], diff --git a/packages/convex-db-collection/vitest.e2e.config.ts b/packages/convex-db-collection/vitest.e2e.config.ts index c998b8ee20..79b27b1e1d 100644 --- a/packages/convex-db-collection/vitest.e2e.config.ts +++ b/packages/convex-db-collection/vitest.e2e.config.ts @@ -2,7 +2,11 @@ import { defineConfig } from 'vitest/config' export default defineConfig({ test: { - globalSetup: [`./e2e/setup.ts`], + globalSetup: [ + `./e2e/setup.ts`, + `./e2e/auth-app/setup.ts`, + `./e2e/public-pagination-setup.ts`, + ], include: [`e2e/**/*.test.ts`], environment: `node`, testTimeout: 30000, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 30961f1c77..3e0ba4a45d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1227,6 +1227,21 @@ importers: specifier: ^1.47.0 version: 1.47.0(react@19.2.4) + packages/convex-db-collection/e2e/auth-app: + dependencies: + '@auth/core': + specifier: 0.37.4 + version: 0.37.4 + '@convex-dev/auth': + specifier: 0.0.90 + version: 0.0.90(@auth/core@0.37.4)(convex@1.47.0(react@19.2.4))(react@19.2.4) + convex: + specifier: ^1.47.0 + version: 1.47.0(react@19.2.4) + jose: + specifier: 5.10.0 + version: 5.10.0 + packages/convex-db-collection/e2e/backend: dependencies: convex: @@ -2070,6 +2085,20 @@ packages: '@asamuzakjp/nwsapi@2.3.9': resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + '@auth/core@0.37.4': + resolution: {integrity: sha512-HOXJwXWXQRhbBDHlMU0K/6FT1v+wjtzdKhsNg0ZN7/gne6XPsIrjZ4daMcFnbq0Z/vsAbYBinQhhua0d77v7qw==} + peerDependencies: + '@simplewebauthn/browser': ^9.0.1 + '@simplewebauthn/server': ^9.0.2 + nodemailer: ^6.8.0 + peerDependenciesMeta: + '@simplewebauthn/browser': + optional: true + '@simplewebauthn/server': + optional: true + nodemailer: + optional: true + '@babel/code-frame@7.10.4': resolution: {integrity: sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg==} @@ -2795,6 +2824,17 @@ packages: resolution: {integrity: sha512-KTy0OqRDLR5y/zZMnizyx09z/rPlPC/zKhYgH8o/q6PuAjoQAKlRfY4zzv0M64yybQ//6//4H1n14pxaLZfUnA==} engines: {node: '>=v18'} + '@convex-dev/auth@0.0.90': + resolution: {integrity: sha512-aqw88EB042HvnaF4wcf/f/wTocmT2Bus2VDQRuV79cM0+8kORM0ICK/ByZ6XsHgQ9qr6TmidNbXm6QAgndrdpQ==} + hasBin: true + peerDependencies: + '@auth/core': ^0.37.0 + convex: ^1.17.0 + react: ^18.2.0 || ^19.0.0-0 + peerDependenciesMeta: + react: + optional: true + '@cspotcode/source-map-support@0.8.1': resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} @@ -5029,6 +5069,21 @@ packages: resolution: {integrity: sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==} engines: {node: '>=14'} + '@oslojs/asn1@1.0.0': + resolution: {integrity: sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA==} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + + '@oslojs/binary@1.0.0': + resolution: {integrity: sha512-9RCU6OwXU6p67H4NODbuxv2S3eenuQ4/WFLrsq+K/k682xrznH5EVWA7N4VFk9VYVcbFtKqur5YQQZc0ySGhsQ==} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + + '@oslojs/crypto@1.0.1': + resolution: {integrity: sha512-7n08G8nWjAr/Yu3vu9zzrd0L9XnrJfpMioQcvCMxBIiF5orECHe5/3J0jmXRVvgfqMm/+4oxlQ+Sq39COYLcNQ==} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + + '@oslojs/encoding@1.1.0': + resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==} + '@oxc-resolver/binding-android-arm-eabi@11.16.2': resolution: {integrity: sha512-lVJbvydLQIDZHKUb6Zs9Rq80QVTQ9xdCQE30eC9/cjg4wsMoEOg65QZPymUAIVJotpUAWJD0XYcwE7ugfxx5kQ==} cpu: [arm] @@ -5137,6 +5192,9 @@ packages: cpu: [x64] os: [win32] + '@panva/hkdf@1.2.1': + resolution: {integrity: sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==} + '@parcel/watcher-android-arm64@2.5.1': resolution: {integrity: sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==} engines: {node: '>= 10.0.0'} @@ -9353,6 +9411,10 @@ packages: resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==} engines: {node: '>= 0.4'} + is-network-error@1.3.2: + resolution: {integrity: sha512-PhBY86zaxNZUuWP6h13Vu5oFe0XY6/UlKzQnYFELzGVHygP3MxmvTfYSG7GN3aIab/iWudSMgjSnG9Dq+nHrgA==} + engines: {node: '>=16'} + is-number-object@1.1.1: resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==} engines: {node: '>= 0.4'} @@ -9554,8 +9616,8 @@ packages: resolution: {integrity: sha512-RuCOQMIt78LWnktPoeBL0GErkNaJPTBGcYuyaBvUOQSpcpcLfWrHPPihYdOGbV5pam9VTWbeoF7TsGiHugcjGA==} engines: {node: '>= 20'} - jose@6.1.3: - resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==} + jose@5.10.0: + resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==} jose@6.2.12: resolution: {integrity: sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==} @@ -10021,6 +10083,10 @@ packages: resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} engines: {node: '>=10'} + lucia@3.2.2: + resolution: {integrity: sha512-P1FlFBGCMPMXu+EGdVD9W4Mjm0DqsusmKgO7Xc33mI5X1bklmsQb0hfzPhXomQr9waWIBDsiOjvr1e6BTaUqpA==} + deprecated: This package has been deprecated. Please see https://lucia-auth.com/lucia-v3/migrate. + lunr@2.3.9: resolution: {integrity: sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==} @@ -10471,6 +10537,9 @@ packages: nullthrows@1.1.1: resolution: {integrity: sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw==} + oauth4webapi@3.8.8: + resolution: {integrity: sha512-8N28E+a/oxfXWBgOMt+ZP/JUf/XR+IFbvkAEPP3gznXOMv9BpAAwiIj0TFNz3tGTPc0ZQ8zmWBNgN1nAys0gng==} + ob1@0.82.5: resolution: {integrity: sha512-QyQQ6e66f+Ut/qUVjEce0E/wux5nAGLXYZDn1jr15JWstHsCH3l6VVrg8NKDptW9NEiBXKOJeGF/ydxeSDF3IQ==} engines: {node: '>=18.18'} @@ -10856,6 +10925,14 @@ packages: resolution: {integrity: sha512-d+JFcLM17njZaOLkv6SCev7uoLaBtfK86vMUXhW1Z4glPWh4jozno9APvW/XKFJ3CCxVoC7OL38BqRydtu5nGg==} engines: {node: '>=12'} + preact-render-to-string@6.5.11: + resolution: {integrity: sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw==} + peerDependencies: + preact: '>=10' + + preact@10.24.3: + resolution: {integrity: sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==} + prebuild-install@7.1.3: resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} engines: {node: '>=10'} @@ -13084,6 +13161,14 @@ snapshots: '@asamuzakjp/nwsapi@2.3.9': {} + '@auth/core@0.37.4': + dependencies: + '@panva/hkdf': 1.2.1 + jose: 5.10.0 + oauth4webapi: 3.8.8 + preact: 10.24.3 + preact-render-to-string: 6.5.11(preact@10.24.3) + '@babel/code-frame@7.10.4': dependencies: '@babel/highlight': 7.25.9 @@ -13978,6 +14063,23 @@ snapshots: '@types/conventional-commits-parser': 5.0.2 chalk: 5.6.2 + '@convex-dev/auth@0.0.90(@auth/core@0.37.4)(convex@1.47.0(react@19.2.4))(react@19.2.4)': + dependencies: + '@auth/core': 0.37.4 + '@oslojs/crypto': 1.0.1 + '@oslojs/encoding': 1.1.0 + convex: 1.47.0(react@19.2.4) + cookie: 1.1.1 + is-network-error: 1.3.2 + jose: 5.10.0 + jwt-decode: 4.0.0 + lucia: 3.2.2 + oauth4webapi: 3.8.8 + path-to-regexp: 6.3.0 + server-only: 0.0.1 + optionalDependencies: + react: 19.2.4 + '@cspotcode/source-map-support@0.8.1': dependencies: '@jridgewell/trace-mapping': 0.3.9 @@ -14702,7 +14804,7 @@ snapshots: terminal-link: 2.1.1 toqr: 0.1.1 wrap-ansi: 7.0.0 - ws: 8.20.0 + ws: 8.21.0 zod: 3.25.76 optionalDependencies: expo-router: 5.1.11(@types/react@19.2.13)(expo-constants@55.0.9)(expo-linking@7.1.7)(expo@55.0.8)(react-native-safe-area-context@5.4.0(react-native@0.83.2(@babel/core@7.29.7)(@types/react@19.2.13)(react@19.2.0))(react@19.2.0))(react-native-screens@4.11.1(react-native@0.83.2(@babel/core@7.29.7)(@types/react@19.2.13)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.7)(@types/react@19.2.13)(react@19.2.0))(react@19.2.0) @@ -14778,7 +14880,7 @@ snapshots: terminal-link: 2.1.1 toqr: 0.1.1 wrap-ansi: 7.0.0 - ws: 8.20.0 + ws: 8.21.0 zod: 3.25.76 optionalDependencies: expo-router: 5.1.11(@types/react@19.2.13)(expo-constants@55.0.9)(expo-linking@7.1.7)(expo@55.0.8)(react-native-safe-area-context@5.4.0(react-native@0.83.2(@babel/core@7.29.7)(@types/react@19.2.13)(react@19.2.4))(react@19.2.4))(react-native-screens@4.11.1(react-native@0.83.2(@babel/core@7.29.7)(@types/react@19.2.13)(react@19.2.4))(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.7)(@types/react@19.2.13)(react@19.2.4))(react@19.2.4) @@ -15925,7 +16027,7 @@ snapshots: express: 5.2.1 express-rate-limit: 8.2.1(express@5.2.1) hono: 4.11.9 - jose: 6.1.3 + jose: 6.2.12 json-schema-typed: 8.0.2 pkce-challenge: 5.0.1 raw-body: 3.0.2 @@ -16171,6 +16273,19 @@ snapshots: '@opentelemetry/semantic-conventions@1.43.0': {} + '@oslojs/asn1@1.0.0': + dependencies: + '@oslojs/binary': 1.0.0 + + '@oslojs/binary@1.0.0': {} + + '@oslojs/crypto@1.0.1': + dependencies: + '@oslojs/asn1': 1.0.0 + '@oslojs/binary': 1.0.0 + + '@oslojs/encoding@1.1.0': {} + '@oxc-resolver/binding-android-arm-eabi@11.16.2': optional: true @@ -16233,6 +16348,8 @@ snapshots: '@oxc-resolver/binding-win32-x64-msvc@11.16.2': optional: true + '@panva/hkdf@1.2.1': {} + '@parcel/watcher-android-arm64@2.5.1': optional: true @@ -21474,6 +21591,8 @@ snapshots: is-negative-zero@2.0.3: {} + is-network-error@1.3.2: {} + is-number-object@1.1.1: dependencies: call-bound: 1.0.4 @@ -21720,7 +21839,7 @@ snapshots: '@hapi/topo': 6.0.2 '@standard-schema/spec': 1.1.0 - jose@6.1.3: {} + jose@5.10.0: {} jose@6.2.12: {} @@ -22149,6 +22268,11 @@ snapshots: dependencies: yallist: 4.0.0 + lucia@3.2.2: + dependencies: + '@oslojs/crypto': 1.0.1 + '@oslojs/encoding': 1.1.0 + lunr@2.3.9: {} lz-string@1.5.0: {} @@ -22738,6 +22862,8 @@ snapshots: nullthrows@1.1.1: {} + oauth4webapi@3.8.8: {} + ob1@0.82.5: dependencies: flow-enums-runtime: 0.0.6 @@ -23148,6 +23274,12 @@ snapshots: postgres@3.4.8: {} + preact-render-to-string@6.5.11(preact@10.24.3): + dependencies: + preact: 10.24.3 + + preact@10.24.3: {} + prebuild-install@7.1.3: dependencies: detect-libc: 2.1.2 From fc38247616534eccb518ae64ba2f15b8234e1cc4 Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Sat, 24 Oct 2026 02:31:05 -0600 Subject: [PATCH 08/20] docs: record independent Convex public app review --- docs/contributing/oracle-coverage.md | 2 + .../2026-10-24-convex-public-apps.md | 125 ++++++++++++++++++ 2 files changed, 127 insertions(+) create mode 100644 docs/contributing/oracle-reviews/2026-10-24-convex-public-apps.md diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index fcea97c278..560e845b12 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -15,6 +15,8 @@ distinguishes canonical laws from their receiving witnesses across packages. Exact-head follow-up records: [source and backend](oracle-reviews/2026-10-23-convex-source-and-backend.md) and [pagination and codecs](oracle-reviews/2026-10-23-convex-pagination-and-codecs.md), both bound to source revision `6511ee4ed0940cd54ee58ceeba7202ca859344b3`. +The [public app receiving review](oracle-reviews/2026-10-24-convex-public-apps.md) is bound to test-source revision `4223c39a6d5c2b2e2fedb1a7eee3c0052b5e5013`. + The Convex package has these independent judgments: - `packages/convex-db-collection/tests/snapshot-oracle.property.test.ts` compares diff --git a/docs/contributing/oracle-reviews/2026-10-24-convex-public-apps.md b/docs/contributing/oracle-reviews/2026-10-24-convex-public-apps.md new file mode 100644 index 0000000000..615a36aaad --- /dev/null +++ b/docs/contributing/oracle-reviews/2026-10-24-convex-public-apps.md @@ -0,0 +1,125 @@ +# Public Convex application receiving review + +Reviewed source and test commit: `4223c39a6d5c2b2e2fedb1a7eee3c0052b5e5013`. +This independent review covers the new public pagination and authenticated app +receiving tests, their pinned-source setup, `local-backend.ts`, and +`network-gate.ts`. It found no adapter defect. This commit changes test and +integration setup, dependencies, and documentation, with no shipped production +code change. + +The reviewer inspected the committed test and setup files and fetched the pinned +upstream message functions, schema, auth functions, and permission implementation +through GitHub's API. The execution results below are attributed to the authors +and root agent. The reviewer did not rerun the backend suites or the production +mutants for this review. + +## Sources and observed evidence + +The pagination source is [get-convex/convex-demos at +2fac21e360bfcceae6065096f1b81480bd8c6fd5](https://github.com/get-convex/convex-demos/tree/2fac21e360bfcceae6065096f1b81480bd8c6fd5/pagination). +The actual functions are deployed unchanged from `git archive` of that commit. +The optional local checkout must have that HEAD, and archive reads the pinned +object rather than dirty worktree files. The local app shell and SDK dependency +are supplied by the test. Upstream declares Convex 1.48, while this fixture +intentionally deploys with the package's installed Convex 1.47 SDK/CLI and the +CLI-selected local Rust backend release. The binary version was not recorded, +so this record does not assign it the SDK's version. + +The auth source is [get-convex/convex-auth-with-role-based-permissions at +45f08008e1257758560948db4280cc4e7d64e84a](https://github.com/get-convex/convex-auth-with-role-based-permissions/tree/45f08008e1257758560948db4280cc4e7d64e84a). +Setup verifies the fetched HEAD and copies the message functions, schema, auth +routes, and permission library into an ignored runtime. The one deliberate +application adaptation replaces GitHub/Resend providers with Convex Auth's +Anonymous provider. The replacement checks the expected pinned source text. +Server permission checks and role updates retain upstream behavior. The fixture +uses `@convex-dev/auth` 0.0.90 and `@auth/core` 0.37.4, rather than the upstream +app's older declared 0.0.71 and ^0.34.1. It does not install the upstream lockfile. +Fresh signing keys allow a genuine sign-in and JWT without third-party +credentials. + +Reported execution evidence: + +- All 17 combined backend tests passed before the final auth history extension. + That run included the existing nine backend tests and all eight new public-app + histories, six pagination and two authentication. +- After the last extension, both auth client histories passed with every + role/operation pair, two successful equal-body sends, and two deletions. This + is targeted evidence for the final auth changes, not a claim that the reviewer + reran the full combined suite at the reviewed commit. +- The root agent reported 62 adapter checks, package typecheck, and lint passing + for the final source. The author reported all six public pagination histories + passing after the continuation control was restored, using default fresh + GitHub fetch/archive setup rather than the local-source override. +- A stale runtime sentinel was removed by actual auth preparation. This checks + the fresh-runtime prerequisite rather than assuming a previous teardown ran. + +Calibration evidence is distinguished from successful execution: + +| Control | Reported result and checkpoint | +| --- | --- | +| Disable automatic continuation | With the proper selector `mirrors list through`, both actual SDK histories failed the immediate ordered `select` comparison after preload. JavaScript exposed three rows instead of the ledger's 17, React three instead of 34, because successful writes from the earlier selected history remained in the shared backend. These were assertion failures at the named checkpoint, not timeouts or setup errors. | +| Initial selector containing a literal `>` | It matched no tests. This was an unreached path and is not counted as a mutant kill. | +| Bypass upstream role checks in a temporary prototype | The author changed the pinned permission function's final numeric comparison to `return true`, preserving user/role existence checks, and let the real backend redeploy. The first read/send denial assertion received a resolved `null` instead of rejection, one failed/one skipped test, with no timeout or setup failure. The original source was restored and both SDK histories passed through their own pinned-source setup. This control used the earlier five-step history; the final expanded history retains that first read/send cell. It was not rerun as a mutant at the frozen commit. | + +## Requirement outcomes + +| Requirement | Outcome and evidence | +| --- | --- | +| ORC-001, authority and limits | Satisfied for these receiving claims. Upstream pagination functions establish newest-first order, indexed author membership, and uppercase body/author-initial transformation. The upstream permission function and message endpoints establish read/write/admin access. The adapter's existing source snapshot and complete eager pagination contracts establish the Collection observations. The tests do not authorize a new provider or adapter behavior. Pins and adaptations are explicit. | +| ORC-002, independent judgment | Satisfied. Pagination's expected rows come from an authored ledger of successful sends, reversed and optionally filtered/transformed according to the upstream query contract. Expected rows do not come from the adapter, a paged query, or SDK cache. Auth uses a set-valued role policy rather than copying upstream's numeric role comparison, and a body multiset rather than server-generated IDs. Actual IDs are used only to target deletion, not to compute the expected message count. | +| ORC-003, literate responsibilities | Satisfied. Both files explain the upstream authority, reference computation, authored histories, real SDK/Collection path, public observations, and limitations before mechanics. Comments identify the shared-backend ledger prerequisite, stable keys for transformed rows, role-update adaptation, denied operations, and reconnect cut. Setup and README describe which upstream code is deployed and which authentication provider is substituted. | +| ORC-004, generated grammar controls | Not applicable, these are fixed authored receiving histories rather than generated properties. Their finite scope is still explicit: both SDKs cross all three pagination query forms at page size three; the auth history crosses all six role/operation cells, demotion, two equal-body successful sends, and deletion of one occurrence at a time. Unique pagination bodies remain unique after uppercase conversion, excluding the transformed-key ambiguity that would make those histories invalid. Arbitrary operation sequences, page sizes, role values, token lifetimes, and malformed protocols are not generated or claimed. | +| ORC-005, production path and observation | Satisfied. Both drivers use actual SDK clients, actual deployed upstream functions, and real Collections. Pagination compares ordered input to `select` immediately after initial preload, then compares keyed public rows after source acceptance. Later checkpoints poll for the expected source revision before checking the public Collection. Auth waits for each real mutation outcome, then source acceptance, and compares marker multiplicity after both permitted and denied operations. Unauthenticated Collection preload and invalid-token HTTP query are distinct controls. No concrete missing-input hypothesis remains for the stated cuts. | +| ORC-006, calibration | Satisfied with the attributed controls above. Disabled continuation fails immediate source-order comparison on both clients. Bypassed server permission checks fail denial observations. The skipped initial selector is excluded from calibration. These controls target two specific wrong designs, they are not an exhaustive mutation campaign. | +| ORC-007, generated campaigns and replay | Not applicable, there is no generated property or `fc.commands` history in these new receiving files. Each named query/client history is directly selectable. Pinned source commits and fresh local deployments support reconstruction, but they are not seed/path replay or random campaigns. Existing primary pagination and offline owners retain those generated campaigns. | +| ORC-008, state minimality | Satisfied for the reference histories. Pagination retains successful messages in insertion order because later queries need their values, author membership, and reverse order, including writes from earlier histories against the shared backend. Counts alone cannot predict those observations. Auth's multiset must retain multiplicity because two identical sends create two rows and deleting one leaves one. Collapsing them to a set predicts the wrong intermediate public snapshot. Server IDs and timestamps are deliberately omitted from the model because the compared projection and legal deletion targets do not require predicting them. | +| ORC-009, vocabulary and abstraction | Satisfied. Collection rows, source acceptance, public snapshot, and cleanup retain the glossary meanings. The pagination ledger models successful application sends, not provider subscriptions or core sync transactions. Auth bodies abstract away server IDs while preserving the multiplicity that the observations need. TCP gate state is transport availability, not Collection readiness. The invalid-token HTTP control does not establish invalid-token WebSocket behavior. | +| ORC-010, failure fidelity and cleanup | Satisfied for the exercised harness. Registered Collection, SDK, TCP gate, backend, checkout, and runtime cleanup uses the existing calibrated withTestCleanup instrument. It preserves a primary comparison or setup error while reporting secondary release failures and attempts subsequent releases. The local backend startup catch now preserves the startup cause if stopping fails. Auth records its runtime path before preparation, clears stale contents before copying, and can clean a partial preparation. The runtime is scoped and ignored. This review inspected those paths; it does not claim fault injection at every construction instruction or process-kill cleanup. | +| ORC-011, independent second formulation | Satisfied for the concrete shared-fault concern motivating these witnesses. Adapter-authored backend functions could reproduce the same assumptions as the adapter and primary numeric range fixture. These tests instead deploy independently maintained public application functions at verified pins and compare them with an authored operation ledger or permission table. Auth's permission table differs structurally from upstream numeric hierarchy. No additional concrete shared semantic fault was identified that requires another model. This is not an exhaustive absence claim. | +| ORC-012, review evidence | Satisfied by this versioned record tied to the exact source/test commit. It distinguishes independent inspection from author/root execution, records the unreached control, reports the harness/provenance repairs, and retains scope limits. No adapter bug-class closure is claimed by these test-only changes. They receive existing laws at additional application and transport boundaries. | +| ORC-013, distinguishing boundary witnesses | Satisfied within the fixed cases. Authentication distinguishes no identity, an invalid token, and a genuine signed token accepted by server functions. The role history distinguishes read from write for send, write from admin for delete, and demotion after a successful write; every role/operation cell is exercised. Two equal-body sends followed by single deletions distinguish multiplicity from set membership. Pagination crosses one page into several with 17 initial appends at page size three, and includes another author to distinguish indexed membership from an unfiltered mirror. These fixed witnesses do not establish arbitrary thresholds or workloads. | +| ORC-014, controlled-premise handoff | Satisfied for the named receiving premises. Both actual SDK clients and a real local backend now supply cursor pagination, query transformation, successful/denied authenticated mutations, and subscribed source updates. A real auth action issues the tested JWT, neither setAdminAuth nor a fabricated identity supplies the tested user. The TCP gate forwards uninterpreted bytes, destroys live connections, and refuses new ones while closed. The test waits for the adapter client's public connection state to become disconnected before an independent direct writer commits. Old rows remain public; later ledger rows must arrive after reopening the gate. This receives real SDK reconnection, without protocol-message simulation. These app-specific ledgers and histories do not literally reuse the primary numeric fixture grammar, they require different expected application fields and auth policy. Their relationship is source snapshot refinement and complete finite acquisition; shared-law drift remains a reason to keep the primary owners and pinned-source review. | + +## Repairs and remaining limits + +Review identified setup and evidence gaps, with no adapter defect: + +- Auth runtime contents needed an ignore rule and a fresh-copy prerequisite. + Both are present at the reviewed commit, and the known runtime path is + registered before copying so partial setup can be removed. +- Local backend startup could lose its original error if stopping also failed. + It now uses the existing failure-preserving cleanup instrument. +- The first auth model was a set and the history admitted only one successful + send. The final history covers the full role/operation table and uses a + multiset, so repeated equal-body sends and one-at-a-time deletion are explicit. + +The upstream auth demo deliberately lets an authenticated user choose a role. +This is its documented demo behavior, not evidence of secure role administration. +The test does not establish tenant isolation, sign-out or token revocation, +refresh-token rotation, browser auth components, OAuth redirects, email delivery, +or production identity infrastructure. Token corruption is tested through HTTP; +unauthenticated preload and genuine token use are tested through both WebSocket +SDKs. Authenticated reconnect is not exercised. The reconnect witness uses the +separate unauthenticated public pagination app. + +The pagination observations project `author` and `body`; they deliberately omit +upstream `_id` and `_creationTime`, which the transformed query does not return. +Auth compares marked body multiplicity, not complete raw message records or +user/author fields. These are business-ledger observations, not full raw-row +fidelity. The existing backend owner checks IDs and application fields separately. + +Pagination observes complete finite results and live appends, not arbitrary +updates/deletes, continuous growth, transport work bounds, bounded lifetime +subscriptions, or browser rendering. Transformed rows have stable keys only +because the authored bodies are unique, including after case conversion. The +ledger assumes a fresh deployment and sequential execution, both supplied by +setup and test configuration. Auth compares test-marked messages within the +upstream query's latest-100 window, not every application row in an arbitrary +large database. + +The public applications are deployed locally with the package's installed SDK +and backend, with an independently supplied app shell. They are not unchanged +full frontend deployments or evidence for their originally declared dependency +matrix. GitHub availability, local backend download, and operating-system TCP +behavior remain test prerequisites. No production cloud service, third-party +OAuth system, power-loss atomicity, or arbitrary network scheduling is proved. From bae01f788134aa421a9018278ec6d2867519dae3 Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Sat, 24 Oct 2026 02:35:41 -0600 Subject: [PATCH 09/20] test: use patched supported Convex Auth dependencies --- .../e2e/auth-app/package.json | 4 +-- pnpm-lock.yaml | 28 +++++++++---------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/packages/convex-db-collection/e2e/auth-app/package.json b/packages/convex-db-collection/e2e/auth-app/package.json index 6c5dcac7b7..4e10d8985f 100644 --- a/packages/convex-db-collection/e2e/auth-app/package.json +++ b/packages/convex-db-collection/e2e/auth-app/package.json @@ -3,8 +3,8 @@ "private": true, "type": "module", "dependencies": { - "@convex-dev/auth": "0.0.90", - "@auth/core": "0.37.4", + "@convex-dev/auth": "0.0.96", + "@auth/core": "0.41.3", "convex": "^1.47.0", "jose": "5.10.0" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3e0ba4a45d..9ccf4531b3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1230,11 +1230,11 @@ importers: packages/convex-db-collection/e2e/auth-app: dependencies: '@auth/core': - specifier: 0.37.4 - version: 0.37.4 + specifier: 0.41.3 + version: 0.41.3 '@convex-dev/auth': - specifier: 0.0.90 - version: 0.0.90(@auth/core@0.37.4)(convex@1.47.0(react@19.2.4))(react@19.2.4) + specifier: 0.0.96 + version: 0.0.96(@auth/core@0.41.3)(convex@1.47.0(react@19.2.4))(react@19.2.4) convex: specifier: ^1.47.0 version: 1.47.0(react@19.2.4) @@ -2085,12 +2085,12 @@ packages: '@asamuzakjp/nwsapi@2.3.9': resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} - '@auth/core@0.37.4': - resolution: {integrity: sha512-HOXJwXWXQRhbBDHlMU0K/6FT1v+wjtzdKhsNg0ZN7/gne6XPsIrjZ4daMcFnbq0Z/vsAbYBinQhhua0d77v7qw==} + '@auth/core@0.41.3': + resolution: {integrity: sha512-sJ3JMHHkXMD3aOjopv7mOBTO1Ocw4b0fAEXJBz6k7YHLpYQI6C40jCUPc5fNvUKxXRXNE1/sRISA15UrwWJBTw==} peerDependencies: '@simplewebauthn/browser': ^9.0.1 '@simplewebauthn/server': ^9.0.2 - nodemailer: ^6.8.0 + nodemailer: ^7.0.7 || ^8.0.5 peerDependenciesMeta: '@simplewebauthn/browser': optional: true @@ -2824,11 +2824,11 @@ packages: resolution: {integrity: sha512-KTy0OqRDLR5y/zZMnizyx09z/rPlPC/zKhYgH8o/q6PuAjoQAKlRfY4zzv0M64yybQ//6//4H1n14pxaLZfUnA==} engines: {node: '>=v18'} - '@convex-dev/auth@0.0.90': - resolution: {integrity: sha512-aqw88EB042HvnaF4wcf/f/wTocmT2Bus2VDQRuV79cM0+8kORM0ICK/ByZ6XsHgQ9qr6TmidNbXm6QAgndrdpQ==} + '@convex-dev/auth@0.0.96': + resolution: {integrity: sha512-4vcoQmmlbInLpR6BK43iIH21JwVPG5U7kRIjLaUWkeEr+hVaS/DuYsQRrDsDLu9rbHpq9PT2ZkUrtPDLkxYx0A==} hasBin: true peerDependencies: - '@auth/core': ^0.37.0 + '@auth/core': ^0.41.1 convex: ^1.17.0 react: ^18.2.0 || ^19.0.0-0 peerDependenciesMeta: @@ -13161,10 +13161,10 @@ snapshots: '@asamuzakjp/nwsapi@2.3.9': {} - '@auth/core@0.37.4': + '@auth/core@0.41.3': dependencies: '@panva/hkdf': 1.2.1 - jose: 5.10.0 + jose: 6.2.12 oauth4webapi: 3.8.8 preact: 10.24.3 preact-render-to-string: 6.5.11(preact@10.24.3) @@ -14063,9 +14063,9 @@ snapshots: '@types/conventional-commits-parser': 5.0.2 chalk: 5.6.2 - '@convex-dev/auth@0.0.90(@auth/core@0.37.4)(convex@1.47.0(react@19.2.4))(react@19.2.4)': + '@convex-dev/auth@0.0.96(@auth/core@0.41.3)(convex@1.47.0(react@19.2.4))(react@19.2.4)': dependencies: - '@auth/core': 0.37.4 + '@auth/core': 0.41.3 '@oslojs/crypto': 1.0.1 '@oslojs/encoding': 1.1.0 convex: 1.47.0(react@19.2.4) From dd349a4c7c8e1d545d646bca18136767025f1d91 Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Sat, 24 Oct 2026 02:37:16 -0600 Subject: [PATCH 10/20] docs: record patched auth fixture validation --- docs/contributing/oracle-coverage.md | 2 +- ...026-10-24-convex-auth-dependency-update.md | 75 +++++++++++++++++++ 2 files changed, 76 insertions(+), 1 deletion(-) create mode 100644 docs/contributing/oracle-reviews/2026-10-24-convex-auth-dependency-update.md diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index 560e845b12..d601e83c99 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -15,7 +15,7 @@ distinguishes canonical laws from their receiving witnesses across packages. Exact-head follow-up records: [source and backend](oracle-reviews/2026-10-23-convex-source-and-backend.md) and [pagination and codecs](oracle-reviews/2026-10-23-convex-pagination-and-codecs.md), both bound to source revision `6511ee4ed0940cd54ee58ceeba7202ca859344b3`. -The [public app receiving review](oracle-reviews/2026-10-24-convex-public-apps.md) is bound to test-source revision `4223c39a6d5c2b2e2fedb1a7eee3c0052b5e5013`. +The [public app receiving review](oracle-reviews/2026-10-24-convex-public-apps.md) is bound to test-source revision `4223c39a6d5c2b2e2fedb1a7eee3c0052b5e5013`. The [auth dependency follow-up](oracle-reviews/2026-10-24-convex-auth-dependency-update.md) records patched Auth.js 0.41.3 and Convex Auth 0.0.96 at revision `bae01f788134aa421a9018278ec6d2867519dae3`, with the full final 17-test backend suite passing. The Convex package has these independent judgments: diff --git a/docs/contributing/oracle-reviews/2026-10-24-convex-auth-dependency-update.md b/docs/contributing/oracle-reviews/2026-10-24-convex-auth-dependency-update.md new file mode 100644 index 0000000000..95b2b847b5 --- /dev/null +++ b/docs/contributing/oracle-reviews/2026-10-24-convex-auth-dependency-update.md @@ -0,0 +1,75 @@ +# Auth fixture dependency update + +Reviewed dependency commit: `bae01f788134aa421a9018278ec6d2867519dae3`. +This is an addendum to the [public application receiving review](2026-10-24-convex-public-apps.md). +That earlier record remains an accurate account of its historical source and +runtime versions. This update reviews the new dependency resolution and receiving +evidence, it does not repeat the full oracle audit or claim new oracle behavior. + +The private auth fixture now uses `@convex-dev/auth` 0.0.96 and `@auth/core` +0.41.3, replacing 0.0.90 and 0.37.4. Only its package manifest and corresponding +lockfile entries changed in the reviewed commit. The pinned upstream business +functions, provider substitution, models, assertions, and setup code are unchanged. +No dependency override, security exemption, or shipped production code change was +introduced. + +## Independent checks + +The reviewer inspected the complete dependency diff and queried published npm +metadata for `@convex-dev/auth` 0.0.96. Its declared peers are: + +| Peer | Declared range | Resolved fixture version | +| --- | --- | --- | +| `@auth/core` | `^0.41.1` | `0.41.3` | +| `convex` | `^1.17.0` | `1.47.0` | +| `react` | `^18.2.0 || ^19.0.0-0` | `19.2.4` | + +All three resolutions satisfy the published peer ranges. The old Auth.js and +Convex Auth resolutions are absent from the lockfile. Auth.js now resolves its +own `jose` 6.2.12 dependency, while Convex Auth and the fixture's signing-key +setup retain their separate `jose` 5.10.0 resolution. These are ordinary package +resolutions, not a forced shared version. CI runs Node 22.13. + +The reviewer also queried both official GitHub advisory APIs: + +- [GHSA-7rqj-j65f-68wh](https://github.com/advisories/GHSA-7rqj-j65f-68wh) + identifies the Auth.js email-normalization issue and lists 0.41.3 as the first + patched `@auth/core` version. +- [GHSA-xmf8-cvqr-rfgj](https://github.com/advisories/GHSA-xmf8-cvqr-rfgj) + identifies malformed Bearer-header handling and likewise lists 0.41.3 as the + first patched version. + +The selected Auth.js version is the published patched version for both reported +advisories, and its companion Convex Auth version explicitly supports that peer +range. This establishes the dependency remediation for those alerts, not an +independent reproduction of either security exploit or an absence of other +vulnerabilities. + +## Receiving evidence and scope + +The root agent reports the complete 17-test backend suite passing after the +upgrade, including the final full auth permission matrix and equal-body message +multiplicity checks, with 34.02 seconds of test execution. Package typecheck also +passed. The reviewer inspected the dependency resolution independently, but did +not rerun this suite or the prior mutants. + +This fresh run receives the existing application laws with the updated auth +runtime: genuine Anonymous sign-in and JWT use, unauthenticated rejection, +invalid-token rejection, read/write/admin permission transitions, demotion, and +public Collection rows after source acceptance. The six public pagination +histories and existing nine backend tests also passed in that combined run. +The earlier record's mutant calibrations remain attributed to their original +runs, they are not presented as new controls executed against these versions. + +The upgrade does not change the models or their legal histories, so their +independence, bounded observations, and cleanup judgments remain as described in +the earlier record. The changed receiving premise is the installed auth runtime; +the new complete backend run supplies compatibility evidence for that premise. +This does not test email-provider normalization or every malformed Bearer-header +case, the patched-version claim comes from the official advisories. + +The fixture still adapts a pinned public app rather than installing its original +lockfile. The local Rust backend remains selected by the installed Convex CLI, +its binary version was not recorded. Cloud infrastructure, third-party OAuth, +email delivery, browser components, token refresh/revocation, authenticated +reconnect, and arbitrary network schedules remain outside these receiving tests. From ebe6dc5147cf3160a3f62d10b35e226f40d3b3c5 Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Sat, 24 Oct 2026 02:51:34 -0600 Subject: [PATCH 11/20] docs: escape auth peer range in review table --- .../oracle-reviews/2026-10-24-convex-auth-dependency-update.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/contributing/oracle-reviews/2026-10-24-convex-auth-dependency-update.md b/docs/contributing/oracle-reviews/2026-10-24-convex-auth-dependency-update.md index 95b2b847b5..11f080a818 100644 --- a/docs/contributing/oracle-reviews/2026-10-24-convex-auth-dependency-update.md +++ b/docs/contributing/oracle-reviews/2026-10-24-convex-auth-dependency-update.md @@ -22,7 +22,7 @@ metadata for `@convex-dev/auth` 0.0.96. Its declared peers are: | --- | --- | --- | | `@auth/core` | `^0.41.1` | `0.41.3` | | `convex` | `^1.17.0` | `1.47.0` | -| `react` | `^18.2.0 || ^19.0.0-0` | `19.2.4` | +| `react` | `^18.2.0 \|\| ^19.0.0-0` | `19.2.4` | All three resolutions satisfy the published peer ranges. The old Auth.js and Convex Auth resolutions are absent from the lockfile. Auth.js now resolves its From 4b37df090402469cafed0cf9f5e16b9528dae60f Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Sat, 24 Oct 2026 03:08:31 -0600 Subject: [PATCH 12/20] test: let Convex allocate available local backend ports --- docs/contributing/oracle-coverage.md | 12 +++ .../e2e/auth-app/setup.ts | 2 +- .../e2e/backend-startup-oracle.test.ts | 92 +++++++++++++++++++ .../convex-db-collection/e2e/local-backend.ts | 20 ++-- .../e2e/public-pagination-setup.ts | 4 +- packages/convex-db-collection/e2e/setup.ts | 4 +- pnpm-workspace.yaml | 1 + 7 files changed, 125 insertions(+), 10 deletions(-) create mode 100644 packages/convex-db-collection/e2e/backend-startup-oracle.test.ts diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index d601e83c99..a442aa44e7 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -107,6 +107,18 @@ Both public-app fixtures deploy with this package's installed Convex 1.47 SDK. The pagination app's upstream package declares 1.48, its business functions are unchanged here but its original dependency installation is not being tested. +`packages/convex-db-collection/e2e/backend-startup-oracle.test.ts` owns the +receiving fixture's occupied-port startup judgment. It holds the former fixed +pagination port, invokes the actual pinned public-app setup, and requires a +different loopback URL serving a fresh empty query. Expected availability comes +from an independently held listener, not a copy of CLI selection logic. An +already occupied port is also a valid precondition, and its external owner is +never closed. All three fixture setups delegate default port selection to the +supported Convex CLI, an explicit user `CONVEX_TEST_PORT` still requires that +port. This is a fixed native startup witness, not a concurrent-allocation proof +or a promise to start when no free ports exist. A CI run reproduced the original +mandatory-port failure at auth port 43330 before any tests ran. + `packages/offline-transactions/tests/transaction-serializer-oracle.property.test.ts` remains the primary wire-format owner. Its opt-in codec grammar crosses insert/update/delete and arrays of signed 64-bit integers, special floats and diff --git a/packages/convex-db-collection/e2e/auth-app/setup.ts b/packages/convex-db-collection/e2e/auth-app/setup.ts index 3865552584..0f51fa756e 100644 --- a/packages/convex-db-collection/e2e/auth-app/setup.ts +++ b/packages/convex-db-collection/e2e/auth-app/setup.ts @@ -28,7 +28,7 @@ export default async function setup(project: TestProject) { if (stdout.trim() !== revision) throw new Error(`Wrong upstream auth revision`) await prepareAuthApp(checkout) - const backend = await startLocalBackend(pathToFileURL(`${runtime}/`), 43330) + const backend = await startLocalBackend(pathToFileURL(`${runtime}/`)) stop = backend.stop if (!backend.url.startsWith(`http://127.0.0.1:`)) throw new Error(`Authentication setup requires an isolated local backend`) diff --git a/packages/convex-db-collection/e2e/backend-startup-oracle.test.ts b/packages/convex-db-collection/e2e/backend-startup-oracle.test.ts new file mode 100644 index 0000000000..3d4810e1c0 --- /dev/null +++ b/packages/convex-db-collection/e2e/backend-startup-oracle.test.ts @@ -0,0 +1,92 @@ +/** + * The real public-app setup must start on a free loopback port when no port + * override was requested, even if the fixture's former fixed port is occupied. + * Authority: Convex CLI's supported default local-backend port allocation and + * this suite's promise to own an isolated backend instead of requiring a + * particular machine-wide port. Explicit user port overrides remain fail-fast. + * + * The independent judgment is resource based: a listener already owns 43320, + * so the newly provided URL must name another port and serve an empty instance + * of the pinned upstream messages query. The test never calculates which free + * port the CLI should choose or reads the launcher's selection logic. + * + * This fixed history holds the old port, calls the actual public-pagination + * global setup, observes its provided URL, then executes the public query. + * EADDRINUSE is also a valid occupied-port precondition, and its external owner + * is never closed. All resources acquired here are released independently while + * preserving a primary mismatch. This is native startup conformance, not a + * generated port-allocation or concurrent-startup proof. External occupancy is + * observed at bind time, not held by a lease owned by this test. + * + * Calibration: restoring the former fixed 43320 setup argument rejects this + * startup-fulfillment assertion with the actual CLI's "Requested port 43320 is + * not available" failure. The public query checkpoint is not reached in that + * control. Removing the fixed argument permits startup and the empty query. + */ +import { createServer } from 'node:net' +import { ConvexHttpClient } from 'convex/browser' +import { makeFunctionReference } from 'convex/server' +import { expect, it, vi } from 'vitest' +import { withTestCleanup } from '../tests/helpers' +import setupPublicPagination from './public-pagination-setup' +import type { PaginationOptions, PaginationResult } from 'convex/server' +import type { TestProject } from 'vitest/node' + +it(`starts the actual public app when its former fixed port is occupied`, async () => { + const blocker = createServer((socket) => socket.destroy()) + let ownsBlocker = false + let stopBackend: (() => Promise) | undefined + let providedUrl: string | undefined + const project: Pick = { + provide: (key, value) => { + expect(key).toBe(`publicPaginationUrl`) + if (typeof value !== `string`) + throw new TypeError(`Expected the public app's loopback URL`) + providedUrl = value + }, + } + await withTestCleanup( + async () => { + vi.stubEnv(`CONVEX_TEST_PORT`, undefined) + ownsBlocker = await new Promise((resolve, reject) => { + blocker.once(`error`, (error: NodeJS.ErrnoException) => { + if (error.code === `EADDRINUSE`) resolve(false) + else reject(error) + }) + blocker.listen(43320, `127.0.0.1`, () => resolve(true)) + }) + const startup = setupPublicPagination(project).then((cleanup) => { + stopBackend = cleanup + return cleanup + }) + await expect(startup).resolves.toBeTypeOf(`function`) + if (!providedUrl) + throw new Error(`Public app setup did not provide a URL`) + const url = new URL(providedUrl) + expect(url.hostname).toBe(`127.0.0.1`) + expect(Number(url.port)).not.toBe(43320) + const query = makeFunctionReference< + `query`, + { paginationOpts: PaginationOptions }, + PaginationResult<{ author: string; body: string }> + >(`messages:list`) + const response = await new ConvexHttpClient(providedUrl).query(query, { + paginationOpts: { cursor: null, numItems: 1 }, + }) + expect(response.page).toEqual([]) + expect(response.isDone).toBe(true) + }, + async () => { + await stopBackend?.() + }, + async () => { + if (ownsBlocker) + await new Promise((resolve, reject) => + blocker.close((error) => (error ? reject(error) : resolve())), + ) + }, + () => { + vi.unstubAllEnvs() + }, + ) +}, 180000) diff --git a/packages/convex-db-collection/e2e/local-backend.ts b/packages/convex-db-collection/e2e/local-backend.ts index 7b480b7dcb..99946edf57 100644 --- a/packages/convex-db-collection/e2e/local-backend.ts +++ b/packages/convex-db-collection/e2e/local-backend.ts @@ -4,7 +4,7 @@ import { fileURLToPath } from 'node:url' import { withTestCleanup } from '../tests/helpers' /** Own a dedicated local backend for the test run. Never select a cloud project. */ -export async function startLocalBackend(directory: URL, port: number) { +export async function startLocalBackend(directory: URL, port?: number) { const { CONVEX_DEPLOY_KEY: _deployKey, CONVEX_SELF_HOSTED_URL: _selfHostedUrl, @@ -23,10 +23,14 @@ export async function startLocalBackend(directory: URL, port: number) { `disable`, `--tail-logs`, `disable`, - `--local-cloud-port`, - String(port), - `--local-site-port`, - String(port + 1), + ...(port === undefined + ? [] + : [ + `--local-cloud-port`, + String(port), + `--local-site-port`, + String(port + 1), + ]), ], { cwd: fileURLToPath(directory), @@ -61,7 +65,11 @@ export async function startLocalBackend(directory: URL, port: number) { }) const env = await readFile(new URL(`.env.local`, directory), `utf8`) const url = /^CONVEX_URL=(.+)$/m.exec(env)?.[1] - if (url !== `http://127.0.0.1:${port}`) + if ( + !url || + !/^http:\/\/127\.0\.0\.1:\d+$/.test(url) || + (port !== undefined && url !== `http://127.0.0.1:${port}`) + ) throw new Error(`Expected an isolated loopback Convex backend`) return { url, stop } } catch (cause) { diff --git a/packages/convex-db-collection/e2e/public-pagination-setup.ts b/packages/convex-db-collection/e2e/public-pagination-setup.ts index c561b43207..dc4a24fb20 100644 --- a/packages/convex-db-collection/e2e/public-pagination-setup.ts +++ b/packages/convex-db-collection/e2e/public-pagination-setup.ts @@ -24,7 +24,7 @@ declare module 'vitest' { * files cannot alter the deployed messages/schema functions. The app shell is * independently supplied test configuration using this package's Convex SDK. */ -export default async function setup(project: TestProject) { +export default async function setup(project: Pick) { const directory = await mkdtemp(join(tmpdir(), `convex-public-pagination-`)) let stop: (() => Promise) | undefined try { @@ -76,7 +76,7 @@ export default async function setup(project: TestProject) { join(app, `node_modules`), `dir`, ) - const backend = await startLocalBackend(pathToFileURL(`${app}/`), 43320) + const backend = await startLocalBackend(pathToFileURL(`${app}/`)) stop = backend.stop project.provide(`publicPaginationUrl`, backend.url) } catch (error) { diff --git a/packages/convex-db-collection/e2e/setup.ts b/packages/convex-db-collection/e2e/setup.ts index 67b884cc8b..acfded356f 100644 --- a/packages/convex-db-collection/e2e/setup.ts +++ b/packages/convex-db-collection/e2e/setup.ts @@ -11,7 +11,9 @@ declare module 'vitest' { export default async function setup(project: TestProject) { const backend = await startLocalBackend( new URL(`./backend/`, import.meta.url), - Number(process.env.CONVEX_TEST_PORT ?? `43310`), + process.env.CONVEX_TEST_PORT === undefined + ? undefined + : Number(process.env.CONVEX_TEST_PORT), ) project.provide(`convexUrl`, backend.url) return backend.stop diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 3f51a10356..dfb706b17c 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -9,6 +9,7 @@ trustPolicyExclude: packages: - packages/** + - '!packages/convex-db-collection/e2e/auth-app/runtime' - examples/**/* overrides: From 204af34344b698a48161522f0501c673af6197de Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Sat, 24 Oct 2026 03:10:39 -0600 Subject: [PATCH 13/20] docs: record Convex startup collision regression evidence --- docs/contributing/oracle-coverage.md | 2 +- .../2026-10-24-convex-backend-startup.md | 87 +++++++++++++++++++ 2 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 docs/contributing/oracle-reviews/2026-10-24-convex-backend-startup.md diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index a442aa44e7..b8746eecd9 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -117,7 +117,7 @@ never closed. All three fixture setups delegate default port selection to the supported Convex CLI, an explicit user `CONVEX_TEST_PORT` still requires that port. This is a fixed native startup witness, not a concurrent-allocation proof or a promise to start when no free ports exist. A CI run reproduced the original -mandatory-port failure at auth port 43330 before any tests ran. +mandatory-port failure at auth port 43330 before any tests ran. The [startup follow-up](oracle-reviews/2026-10-24-convex-backend-startup.md) records exact source revision `4b37df090402469cafed0cf9f5e16b9528dae60f`, the rejected fixed-port control, and the final 18-test run with the former auth port occupied. `packages/offline-transactions/tests/transaction-serializer-oracle.property.test.ts` remains the primary wire-format owner. Its opt-in codec grammar crosses diff --git a/docs/contributing/oracle-reviews/2026-10-24-convex-backend-startup.md b/docs/contributing/oracle-reviews/2026-10-24-convex-backend-startup.md new file mode 100644 index 0000000000..c5c702e7ca --- /dev/null +++ b/docs/contributing/oracle-reviews/2026-10-24-convex-backend-startup.md @@ -0,0 +1,87 @@ +# Local backend startup review + +Reviewed source and test commit: `4b37df090402469cafed0cf9f5e16b9528dae60f`. +This follows the [public-app receiving review](2026-10-24-convex-public-apps.md) +and [auth dependency update](2026-10-24-convex-auth-dependency-update.md). +It concerns test setup and workspace discovery, not an adapter production defect. +There is no shipped adapter production code change. + +The root agent reported that the browser CI job exited before test collection +with the official Convex CLI error `Requested port 43330 is not available`. +That establishes an unavailable requested port. It does not establish which +process occupied it or why. The fixture had imposed that machine-wide port +requirement itself. + +## Contract and repair + +Without a user port override, the fixture should start an isolated local backend +on available ports rather than require its former fixed numbers. The reviewer +inspected all three setup call sites and the installed Convex 1.47 CLI source. +Its anonymous deployment path calls `chooseLocalBackendPorts`, which delegates +to `detect-port`: explicit requested ports are mandatory, while defaults and +suggestions can select other available ports. The default search starts at +3210. The test does not calculate or promise the allocator's exact result. + +All three setups now omit mandatory port arguments by default. The existing +`CONVEX_TEST_PORT` override still supplies an explicit port to the main fixture. +The shared launcher retains strict loopback URL validation and verifies the +exact requested port when one was supplied. No custom free-port allocator, +retry loop, or silent fallback for an explicit override was added. + +A separate workspace discovery issue was also repaired. Auth setup creates an +ignored `auth-app/runtime/package.json`, which the broad `packages/**` workspace +glob could discover during concurrent pnpm checks. An exact exclusion now keeps +that generated runtime out of the workspace project set. The real auth fixture +remains a workspace package. This changes configuration, not dependency versions +or lockfile resolutions. + +## Witness and execution evidence + +`e2e/backend-startup-oracle.test.ts` supplies an independent resource judgment: +while the old public fixture port 43320 is occupied, setup must fulfill and +provide another loopback port serving a fresh empty upstream messages query. +It invokes the actual pinned public-app setup and queries the real backend. +Expected startup success and empty data do not come from copying CLI selection +logic. An explicit startup-fulfillment assertion precedes the URL and query +observations. + +The reviewer inspected that test, the setup changes, cleanup paths, workspace +exclusion, and installed CLI implementation. The following execution evidence +comes from the author and root agent, it is not presented as an independent +rerun by this reviewer: + +- Restoring the former fixed 43320 setup argument failed the startup-fulfillment + assertion in 292 ms with `Requested port 43320 is not available`. This is the + intended startup outcome comparison. The later query checkpoint was not + reached, so this is not a failure at a data comparison. +- Restoring default allocation passed in 1.05 seconds and reached the actual + upstream empty-page query, including `isDone: true`. +- The first combined 18-test run passed with normal allocation. A second run + held port 43330 from before pnpm and global setup began, matching the occupied + auth-port condition from CI, and all 18 tests passed in 33.69 seconds. The root + agent verified and terminated its own blocker afterward. Only this second + run establishes the specifically occupied auth-port condition. +- Final package typecheck and ESLint passed. With a synthetic generated runtime + package present, `pnpm list -r` succeeded and excluded it. The synthetic runtime + was removed afterward. + +The fixed startup witness runs in the normal backend suite. It releases its own +listener and backend independently through the existing failure-preserving +cleanup instrument. `EADDRINUSE` is accepted as an observation of an existing +external owner at bind time, that owner's lifetime is not leased by the test, +and the test never closes it. + +## Scope + +This closes the fixture's mandatory-default-port assumption for the reviewed +serial setup paths. The public fixture regression observes the shared startup +boundary directly; the complete suite also receives the auth setup under its +formerly required occupied port. The source inspection confirms that all three +call sites delegate their default choice to the same supported CLI path. + +This is a fixed native startup witness, not a generated port-allocation model +or a proof against every concurrent bind race. It does not promise startup when +no ports are available, override an occupied port explicitly requested by a user, +or identify the owner of the original CI port. Network access and backend +availability remain setup prerequisites. Existing public-app, auth, pagination, +and offline contracts and their recorded limits are unchanged. From 5aebb71f90919697b459b38673a0aa5fef00a0b2 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Mon, 26 Oct 2026 17:05:31 -0600 Subject: [PATCH 14/20] test(convex-db-collection): close oracle observation and reach gaps Retain typed keys and every pagination observation, exercise later source acceptance rejection, and preserve independently captured codec operands. Enforce separate fixed/random reach and direct replay, including backend acknowledgement cuts. Clarify that the adapter currently uses eager sync. Law: complete keyed snapshots, terminal source rejection until restart, and durable operand preservation follow the existing public collection and offline transaction guides. No production implementation changes. Reach: snapshot 150/state/lane; ownership, pagination and offline 60/state/lane; codec 60 and Convex values 100 histories/lane; 36 actual backend crash histories. RED: the repaired owners reject five previously surviving wrong designs, adjacent threshold/stale/delete-key variants, and in-place original corruption at intended comparisons. Unchanged production has no baseline RED to repair. GREEN: 64 adapter, 302 offline, 7 helper and 18 real-backend tests pass; the final captured-original follow-up passes 51 serializer tests and Convex values. Offline typecheck/build, configured ESLint and required oracle lint pass. Mutant: private stringification, prefix/stale publication, adjacent default caps, conditional terminality and operand-routing/corruption edits rejected; restored direct replays pass. Omitted uncertain histories fail declared reach. Limits: bounded eager queries and controlled source revisions; receiving completeness does not establish all scheduling, native fault or cloud laws. Exact-source evidence follows in the versioned October 26 review record. --- docs/collections/convex-collection.md | 2 +- docs/contributing/oracle-coverage.md | 46 +- .../e2e/backend-oracle.test.ts | 144 +++- .../e2e/public-pagination-oracle.test.ts | 95 ++- .../tests/offline-oracle.property.test.ts | 640 ++++++++++-------- .../tests/ownership-oracle.property.test.ts | 192 +++--- .../tests/pagination-oracle.property.test.ts | 580 ++++++++++------ .../tests/snapshot-oracle.property.test.ts | 334 ++++++--- .../tests/values-oracle.property.test.ts | 309 ++++++--- packages/db/tests/oracle-campaign.test.ts | 51 ++ packages/db/tests/oracle-campaign.ts | 63 ++ packages/db/tests/oracle-reach.ts | 15 +- packages/offline-transactions/package.json | 2 +- ...saction-serializer-oracle.property.test.ts | 410 +++++++---- .../offline-transactions/tsconfig.test.json | 6 + packages/offline-transactions/vite.config.ts | 2 +- 16 files changed, 1958 insertions(+), 933 deletions(-) create mode 100644 packages/db/tests/oracle-campaign.test.ts create mode 100644 packages/db/tests/oracle-campaign.ts create mode 100644 packages/offline-transactions/tsconfig.test.json diff --git a/docs/collections/convex-collection.md b/docs/collections/convex-collection.md index 8d6e63c7f7..3bd613ddb6 100644 --- a/docs/collections/convex-collection.md +++ b/docs/collections/convex-collection.md @@ -171,7 +171,7 @@ The adapter keeps each loaded cursor range subscribed. Fixed `endCursor` boundar The cap does not limit the bytes in a server response or guarantee peak memory use. A single reactive page can already exceed it. `pageSize` does not bound total memory or initial load time. Initial acquisition can inspect earlier cached pages repeatedly as it extends the cursor chain. -This API uses eager sync and does not translate DB's `loadSubset` requests. Demand-driven loading remains a separate API decision because it needs an application-supplied filter and ordering translation, ownership of overlapping windows, and rejection of unsupported requests. Until that API exists, narrow the server query or use another adapter when fetching only a requested window is required. +This factory uses eager sync; `syncMode: 'on-demand'` and DB's `loadSubset` protocol are not currently implemented. Convex cursor pagination can support lazy acquisition for a fixed query, but each load must establish the requested subset before reporting success. DB filters and ordering are not automatically translated into your server function arguments. An incomplete source page cannot stand in for a complete filtered or differently ordered result. Scope the server query when using this eager mirror. For paginated collections, `select` receives the complete row array. DB still applies local filters, joins, ordering, and limits. Pagination loads a finite query completely into memory, it does not push DB predicates to your server. diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index 6e3f887a23..151fe4ff66 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -13,6 +13,12 @@ distinguishes canonical laws from their receiving witnesses across packages. ## Convex collection integration +The [October 26 oracle repair record](oracle-reviews/2026-10-26-convex-oracle-repairs.md) +records rejection of five previously surviving wrong designs, enforced per-campaign +reach, and the current replay coordinates. Earlier shrink paths below remain +historical evidence for their named source revisions; changed grammars need the +new coordinates in that record. + Exact-head follow-up records: [source and backend](oracle-reviews/2026-10-23-convex-source-and-backend.md) and [pagination and codecs](oracle-reviews/2026-10-23-convex-pagination-and-codecs.md), both bound to source revision `6511ee4ed0940cd54ee58ceeba7202ca859344b3`. The [public app receiving review](oracle-reviews/2026-10-24-convex-public-apps.md) is bound to test-source revision `4223c39a6d5c2b2e2fedb1a7eee3c0052b5e5013`. The [auth dependency follow-up](oracle-reviews/2026-10-24-convex-auth-dependency-update.md) records patched Auth.js 0.41.3 and Convex Auth 0.0.96 at revision `bae01f788134aa421a9018278ec6d2867519dae3`, with the full final 17-test backend suite passing. @@ -23,6 +29,9 @@ The Convex package has these independent judgments: public rows with the latest valid provider array. It covers missing-row deletion, full-row replacement, reintroduction, empty results, and atomic duplicate-key rejection, through both raw results and successful select projections. + Constructive prefixes and generated suffixes cross string, finite-number and + mixed keys, including distinct `1` and `'1'`, initial duplicate rejection, + and projections that change key representation. Comparisons retain typed keys. - `packages/convex-db-collection/tests/ownership-oracle.property.test.ts` owns concrete options admission separately from snapshot refinement. Its identity model crosses direct and shallow-copy reuse with idle, ready, and cleaned-up @@ -40,7 +49,12 @@ The Convex package has these independent judgments: 1-4, forward/reverse callback delivery, row insertion/deletion/movement, and required/recommended/no split hints. The reference is the latest input array; the provider fixture independently answers numeric rank intervals. Comparison - checks provider row order after exhaustion and preserves the previous public snapshot while replacement ranges are pending. Split cursors stay inside their returned page ranges. The accepted-row bound rejects over-cap results without partial publication. + retains every select and public Collection publication, permits repeated complete + snapshots, and rejects partial snapshots or a return to the prior revision after + the new revision appears. Pending ranges retain the prior complete snapshot. + Split cursors stay inside their returned page ranges. The accepted-row bound + rejects over-cap results without partial publication; the omitted-option + default accepts exactly 10,000 rows and rejects 10,001. Its initial-materialization work law also varies 3-40 pages and 1-8 rows per page. The original implementation copied incomplete prefixes and failed at seed 20261022/path 0:0:0:0. The repaired fixed/random campaigns copy each @@ -52,13 +66,22 @@ The Convex package has these independent judgments: reject with NonRetriableError; post-confirmation sync failures can retry. The original argument-error path failed this oracle at seed 20261022, path 5:1:1. The repair includes argument construction in terminal classification. + Rejected source proposals now follow both initial and already accepted + empty/nonempty bases, including later insert/update/delete/replacement, + unchanged/changed cache retries and cleanup/restart recovery. - `packages/convex-db-collection/tests/values-oracle.property.test.ts` owns the Convex receiving check for native scalar types, bytes, nested containers, and - removed fields across durable storage and fresh readers. + removed fields across durable storage and fresh readers. Real Collection CRUD + mutations supply distinct original/modified operands, equal-payload and changed + updates, and removed-field changes; each operand is compared independently. Each owner runs matching fixed and random campaigns and accepts explicit seed/path replay through their CONVEX_*_ORACLE_SEED/PATH environment variables. +Each normal lane has a fresh reach ledger, fails if any declared state has fewer +than one reached history, and prints its counts as `ORACLE_REACH` JSON. Explicit +path replays run directly with one selected fast-check run and bypass campaign +minima; shrink-path reconstruction can evaluate the callback more than once. Snapshot replay uses CONVEX_SNAPSHOT_ORACLE; pagination has separate CONVEX_PAGINATION_ROWS, CONVEX_PAGINATION_WORK, and CONVEX_PAGINATION_BOUND selectors; offline bridge replay uses CONVEX_OFFLINE_ORACLE; native value replay uses CONVEX_VALUES_ORACLE; ownership uses CONVEX_OWNERSHIP_ORACLE. @@ -76,6 +99,10 @@ remain outside this backend witness. Replay uses CONVEX_BACKEND_INSERT_ORACLE_SEED/PATH, CONVEX_BACKEND_UPDATE_ORACLE_SEED/PATH, or CONVEX_BACKEND_DELETE_ORACLE_SEED/PATH, with matching operation test selection. The command owns backend startup and shutdown. The offline engine's interruption, settlement, storage, and leadership owners remain primary for those laws; this is their Convex receiving witness. +Generated bundles constructively cover known and uncertain acknowledgement for +each native payload class while retaining six actual process histories per +operation per lane (36 total). Reach is credited at observed disk admission, +server-acceptance and full replay comparisons, not from requested fixture inputs. `packages/convex-db-collection/e2e/public-pagination-oracle.test.ts` receives the finite pagination contract through the actual public `get-convex/convex-demos` @@ -85,7 +112,11 @@ uppercase/author-initial projections. Both SDK clients exercise all three query forms at page size three, live appends, real TCP connection loss, direct writes while disconnected, and complete recovery after reconnect. Unique authored bodies supply the stable key omitted by the transformed query. It observes -ordered input to select and public Collection rows after source acceptance. +every ordered input to select and public Collection publication against complete +authored ledger revisions allowed in that phase, plus final source-acceptance rows. +Future ledger entries cannot retroactively justify an earlier observation. +This receiving relation checks complete available revisions; it does not check +monotonicity within one phase. The primary pagination owner checks that law. This is six authored receiving histories, not a new primary pagination model or an arbitrary transformed-key guarantee. Setup fetches the pinned source at run time, with no upstream application source redistributed in this repository. @@ -124,8 +155,11 @@ remains the primary wire-format owner. Its opt-in codec grammar crosses insert/update/delete and arrays of signed 64-bit integers, special floats and binary buffers in modified/original/changes. It uses an independently supplied V8 codec, recreates the reader, and checks missing/mismatched/throwing codecs -through both get and getAll while preserving the exact stored bytes. Default -unversioned/v2/v3 histories retain their prior expectations. A version-4 record +through both get and getAll while preserving the exact stored bytes. +The codec grammar uses real CRUD with independently authored before/after +payloads, changed and equal-payload updates, and removed fields. Native class +and operand distinctions have enforced measured reach in each campaign. +Default unversioned/v2/v3 histories retain their prior expectations. A version-4 record requires its codec, and configured readers continue to accept old formats. The original serializer failed at durable admission for seed 20261022/path 0:0:0:0, a single BigInt insert. The repaired codec path preserves the values. @@ -146,7 +180,7 @@ storage are requirements of each application's server mutation. The October 23 review adds real-Collection source-acceptance histories to the offline bridge owner: idle, pending first result, failed startup, provider failure, and cleanup. Successful empty or nonempty snapshots must actually be accepted. Original idle acceptance fails seed 20261023/path 0:0:0, the repaired fixed/random campaigns and direct replay pass. The same file keeps its ordered-stage model separate from the source-settlement and rejected-acceptance judgments. A rejected durable acceptance stays terminal for that sync run, same or changed cached rows cannot falsely certify a retry, and cleanup/restart with a fresh accepted snapshot recovers. The nonterminal mutant fails seed 20261023/path 0:0 under CONVEX_OFFLINE_ACCEPTANCE_ORACLE_SEED/PATH. Ordinary provider errors retain same-run valid-result recovery. Dedicated codec replay uses OFFLINE_CODEC_ORACLE_SEED/PATH/RUNS; legacy serializer properties keep OFFLINE_ORACLE selectors. -The bounded eager API defaults maxRows to 10,000. It is not an on-demand window, a network-byte bound, a strict peak-memory bound, or a lifetime subscription-count bound. Cursor ranges remain subscribed, and cache scans still grow quadratically during acquisition. A separate on-demand API needs application-owned filter/order translation and demand-scoped publication/release witnesses before making those promises. +The bounded eager API defaults maxRows to 10,000. It is not an on-demand window, a network-byte bound, a strict peak-memory bound, or a lifetime subscription-count bound. Cursor ranges remain subscribed, and cache scans still grow quadratically during acquisition. Lazy cursor acquisition for a fixed Convex query could use DB's on-demand protocol; no separate factory is inherently required. Such support is not implemented here, and each load would still need to establish its requested subset rather than treating one partial source page as complete. Validation at the initial review: 43 adapter checks, 302 offline-engine tests, and seven real-backend tests passed. The October 23 strengthened backend suite passes nine tests with 36 generated crash histories. Final local validation passes 62 adapter checks and 302 offline-engine tests. Exact-head review records accompany this follow-up. The native-value receiving oracle rejects a scalar-as-JSON decoder at the typed-value comparison (seed 20261022/path 0:0); diff --git a/packages/convex-db-collection/e2e/backend-oracle.test.ts b/packages/convex-db-collection/e2e/backend-oracle.test.ts index df9efada1e..f1e06b4e10 100644 --- a/packages/convex-db-collection/e2e/backend-oracle.test.ts +++ b/packages/convex-db-collection/e2e/backend-oracle.test.ts @@ -17,6 +17,9 @@ * rows, receipt keys, application count, and complete replayed Collection rows * are checked after replay. Equal task rows alone cannot distinguish a duplicate * server application, while the independently observed counter can. + * Each fixed/random campaign declares and reports reach in individual process + * histories. Counts follow observed disk admission, the uncertain child cut, + * and successful replay comparisons; generated intentions do not earn reach. * * This is a receiving witness for the offline engine's interruption/settlement * owners, not another model of leadership. It covers one pending mutation in one @@ -33,6 +36,7 @@ import { makeFunctionReference } from 'convex/server' import { describe, expect, inject, it } from 'vitest' import { convexPaginatedCollectionOptions } from '../src/index' import { withTestCleanup } from '../tests/helpers' +import { runOracleCampaign } from '../../db/tests/oracle-campaign' import type { Value } from 'convex/values' import type { ConvexCollectionClient } from '../src/index' import type { PaginationOptions, PaginationResult } from 'convex/server' @@ -217,6 +221,7 @@ async function durableHistory( draft: { clientId: string; text: string; rank: number; payload?: Value }, uncertain: boolean, operation: `insert` | `update` | `delete`, + reached: (state: DurableReachState) => void, ) { const { spawn } = await import('node:child_process') const { mkdtemp, rm, readdir, readFile } = await import('node:fs/promises') @@ -312,6 +317,7 @@ async function durableHistory( }), ), ).toEqual(before) + reached(`disk-admission`) if (uncertain) { expect(await run(`uncertain`, `server-accepted`)).toContain( `provider-call`, @@ -319,6 +325,9 @@ async function durableHistory( expect( (await client.query(all, {})).map((row) => row.clientId), ).toEqual(expected.map((row) => row.clientId)) + // Count the observed child checkpoint, not the generated intention to + // lose a durable acknowledgement. run() waits for this exact stdout cut. + reached(`uncertain-acknowledgement`) } expect(await run(`replay`, `replayed`)).toContain(`provider-call`) const receipts = makeFunctionReference< @@ -343,16 +352,38 @@ async function durableHistory( }), ), ).toEqual(expected) + reached(`replay`) + if (!uncertain) reached(`known-acknowledgement`) + // Native payload reach is credited only after the child has compared its + // complete public row and the parent has compared the final server row. + if (operation !== `delete`) { + if (typeof draft.payload === `bigint`) reached(`payload-int64`) + else if (draft.payload instanceof ArrayBuffer) reached(`payload-bytes`) + else if ( + typeof draft.payload === `number` && + (!Number.isFinite(draft.payload) || Object.is(draft.payload, -0)) + ) + reached(`payload-special-float`) + } }, () => client.close(), () => rm(directory, { recursive: true, force: true }), ) } +type DurableReachState = + | `disk-admission` + | `replay` + | `known-acknowledgement` + | `uncertain-acknowledgement` + | `payload-int64` + | `payload-special-float` + | `payload-bytes` + it.each([`insert`, `update`, `delete`] as const)( `restores durable %s and deduplicates uncertain acknowledgements across generated process histories`, async (operation) => { - const property = fc.asyncProperty( + const draft = (payload: fc.Arbitrary) => fc.record({ text: operation === `delete` @@ -362,44 +393,83 @@ it.each([`insert`, `update`, `delete`] as const)( operation === `delete` ? fc.constant(0) : fc.integer({ min: -50, max: 50 }), - uncertain: fc.boolean(), - payload: - operation !== `delete` - ? fc.oneof( - fc.bigInt({ min: -(2n ** 63n), max: 2n ** 63n - 1n }), - fc.constantFrom(NaN, Infinity, -Infinity, -0), - fc - .uint8Array({ maxLength: 12 }) - .map((bytes) => bytes.buffer as ArrayBuffer), - ) - : fc.constant(undefined), - }), - async ({ text, rank, uncertain, payload }) => - durableHistory( - { - clientId: operation === `insert` ? `offline-created` : `0`, - text, - rank, - ...(operation !== `delete` ? { payload } : {}), + payload, + }) + // A generated bundle constructively includes both acknowledgement cuts and + // every claimed native payload class. Values remain generated independently + // in each history. Six unconstrained choices could omit a required cut or + // payload class; increasing their random budget would only reduce that risk. + const payloads = + operation === `delete` + ? fc.tuple(draft(fc.constant(undefined))) + : fc.tuple( + draft(fc.bigInt({ min: -(2n ** 63n), max: 2n ** 63n - 1n })), + draft(fc.constantFrom(NaN, Infinity, -Infinity, -0)), + draft( + fc + .uint8Array({ maxLength: 12 }) + .map((bytes) => bytes.buffer as ArrayBuffer), + ), + ) + const histories = fc.record({ + known: payloads, + uncertain: payloads, + uncertainFirst: fc.boolean(), + }) + const states: Array = [ + `disk-admission`, + `replay`, + `known-acknowledgement`, + `uncertain-acknowledgement`, + ...(operation === `delete` + ? [] + : ([ + `payload-int64`, + `payload-special-float`, + `payload-bytes`, + ] as const)), + ] + // Each operation owns its replay grammar. One insert/update bundle contains + // six process histories; three delete bundles contain six too. Fixed and + // random lanes therefore retain the same six-history budget per operation. + const replayPrefix = `CONVEX_BACKEND_${operation.toUpperCase()}_ORACLE` + await runOracleCampaign( + replayPrefix, + states, + async (reach, parameters) => { + const property = fc.asyncProperty( + histories, + async ({ known, uncertain, uncertainFirst }) => { + for (const lostAcknowledgement of [ + uncertainFirst, + !uncertainFirst, + ]) { + for (const { text, rank, payload } of lostAcknowledgement + ? uncertain + : known) { + reach.beginHistory() + await durableHistory( + { + clientId: operation === `insert` ? `offline-created` : `0`, + text, + rank, + ...(operation !== `delete` ? { payload } : {}), + }, + lostAcknowledgement, + operation, + (state) => reach.reach(state), + ) + } + } }, - uncertain, - operation, - ), + ) + await fc.assert(property, { + numRuns: operation === `delete` ? 3 : 1, + ...parameters, + }) + }, + { fixedSeed: 20261022 }, ) - // Each grammar owns its seed/path pair. An insert shrink path is not a - // delete path, whose text/rank/payload dimensions are intentionally fixed. - const replayPrefix = `CONVEX_BACKEND_${operation.toUpperCase()}_ORACLE` - const replaySeed = process.env[`${replayPrefix}_SEED`] - if (replaySeed) - await fc.assert(property, { - numRuns: 6, - seed: Number(replaySeed), - path: process.env[`${replayPrefix}_PATH`], - }) - else { - await fc.assert(property, { numRuns: 6, seed: 20261022 }) - await fc.assert(property, { numRuns: 6 }) - } }, 120000, ) diff --git a/packages/convex-db-collection/e2e/public-pagination-oracle.test.ts b/packages/convex-db-collection/e2e/public-pagination-oracle.test.ts index 693b17c59d..07ca2345e1 100644 --- a/packages/convex-db-collection/e2e/public-pagination-oracle.test.ts +++ b/packages/convex-db-collection/e2e/public-pagination-oracle.test.ts @@ -19,6 +19,13 @@ * adapter client's real connections; a separate direct writer commits while * disconnected. The old public snapshot must remain until reconnect, then the * complete Collection and select-boundary order must equal the ledger. + * Every select and public change callback is retained, including replacements + * followed immediately by another replacement. Each must represent a complete + * ledger revision available at that observation. A direct writer's update may + * arrive before its mutation promise fulfills, so its one pending authored + * message is a possible committed revision. Later messages cannot justify an + * earlier observation. During disconnection only the previous complete result + * is allowed; reopening admits the complete revisions committed while offline. * * This is finite public-app conformance, not a browser-render test, a generated * history campaign, a total network-work bound, or a test of cloud hosting. @@ -48,8 +55,12 @@ const backend = inject(`publicPaginationUrl`) const queries = [`list`, `listWithExtraArg`, `listWithTransformation`] as const type QueryName = (typeof queries)[number] -function expectedRows(name: QueryName, author: string): Array { - return [...ledger] +function expectedRows( + name: QueryName, + author: string, + messages: ReadonlyArray = ledger, +): Array { + return [...messages] .reverse() .filter( (message) => name !== `listWithExtraArg` || message.author === author, @@ -92,6 +103,46 @@ describe.each([`javascript`, `react`] as const)( PaginationResult >(`messages:${name}`) let selected: ReadonlyArray = [] + let recording = false + let disconnected = false + let allowedSnapshots: Array> = [] + const disconnectedSnapshots: Array> = [] + const observations: Array<{ + boundary: `select` | `publication` + rows: Array + allowed: Array> + }> = [] + const byBody = (a: Message, b: Message) => a.body.localeCompare(b.body) + // Snapshot the allowed revisions now. Extending the ledger later must + // not retroactively excuse an impossible earlier observation. Public + // Collection iteration order is not provider order; select preserves it. + const record = ( + boundary: `select` | `publication`, + rows: ReadonlyArray, + ) => { + if (!recording) return + const project = (snapshot: ReadonlyArray) => { + const projected = snapshot.map(({ author: from, body }) => ({ + author: from, + body, + })) + return boundary === `publication` + ? projected.sort(byBody) + : projected + } + observations.push({ + boundary, + rows: project(rows), + allowed: allowedSnapshots.map(project), + }) + } + const checkObservations = () => { + for (const observation of observations) + expect( + observation.allowed, + `${observation.boundary} must expose a complete ledger revision`, + ).toContainEqual(observation.rows) + } const collection = createCollection( convexPaginatedCollectionOptions({ client, @@ -103,6 +154,7 @@ describe.each([`javascript`, `react`] as const)( author: from, body, })) + record(`select`, selected) return rows }, getKey: (row) => row.body, @@ -114,6 +166,11 @@ describe.each([`javascript`, `react`] as const)( author: from, body: `message ${ledger.length} from ${from}`, } + if (recording) { + const next = expectedRows(name, author, [...ledger, message]) + if (disconnected) disconnectedSnapshots.push(next) + else allowedSnapshots.push(next) + } await writer.mutation(send, message) ledger.push(message) } @@ -128,13 +185,24 @@ describe.each([`javascript`, `react`] as const)( expect( [...collection.values()] .map(({ author: from, body }) => ({ author: from, body })) - .sort((a, b) => a.body.localeCompare(b.body)), - ).toEqual([...expected].sort((a, b) => a.body.localeCompare(b.body))) + .sort(byBody), + ).toEqual([...expected].sort(byBody)) + checkObservations() + // Both cuts have reached this complete source revision. A later + // source phase may repeat it or advance through new complete revisions. + allowedSnapshots = [expected] } + let subscription: + ReturnType | undefined await withTestCleanup( async () => { for (let index = 0; index < 17; index++) await publish(index % 2 === 0 ? author : `other:${author}`) + allowedSnapshots = [expectedRows(name, author)] + recording = true + subscription = collection.subscribeChanges(() => { + record(`publication`, [...collection.values()]) + }) await collection.preload() await check(false) await publish(author) @@ -142,6 +210,7 @@ describe.each([`javascript`, `react`] as const)( await publish(`other:${author}`) await check() const before = [...selected] + disconnected = true gate.disconnect() await expect .poll(() => client.connectionState().isWebSocketConnected) @@ -152,11 +221,25 @@ describe.each([`javascript`, `react`] as const)( expect( [...collection.values()] .map(({ author: from, body }) => ({ author: from, body })) - .sort((a, b) => a.body.localeCompare(b.body)), - ).toEqual([...before].sort((a, b) => a.body.localeCompare(b.body))) + .sort(byBody), + ).toEqual([...before].sort(byBody)) + checkObservations() + allowedSnapshots.push(...disconnectedSnapshots) + disconnected = false gate.reconnect() await check() + expect( + observations.some( + (observation) => observation.boundary === `select`, + ), + ).toBe(true) + expect( + observations.some( + (observation) => observation.boundary === `publication`, + ), + ).toBe(true) }, + () => subscription?.unsubscribe(), () => collection.cleanup(), () => client.close(), () => writer.close(), diff --git a/packages/convex-db-collection/tests/offline-oracle.property.test.ts b/packages/convex-db-collection/tests/offline-oracle.property.test.ts index 51f2e1119e..c51a7c0fee 100644 --- a/packages/convex-db-collection/tests/offline-oracle.property.test.ts +++ b/packages/convex-db-collection/tests/offline-oracle.property.test.ts @@ -6,20 +6,21 @@ * received by the existing outbox engine's NonRetriableError boundary. * * The model is an ordered dependency list: args, mutation, sync. A failure - * admits only its prefix. Generated histories choose the failing stage or no - * failure, plus arbitrary text and durable keys. Each stage records its call; - * the comparison checks the prefix and caller error after settlement. It does - * not model outbox storage or leadership, whose owners remain in that package. - * The real process-crash witness uses actual Convex mutations in e2e. + * admits only its prefix. Each generated history constructively crosses every + * failure stage with arbitrary text and durable keys; this prevents a random + * campaign from missing a classification boundary. The comparison checks calls + * and caller error after settlement. Storage and leadership have other owners. */ import { createCollection, withAcceptedReceipt } from '@tanstack/db' import fc from 'fast-check' import { NonRetriableError } from '@tanstack/offline-transactions' import { makeFunctionReference } from 'convex/server' import { expect, it } from 'vitest' +import { runOracleCampaign } from '../../db/tests/oracle-campaign' import { convexCollectionOptions } from '../src/index' import { convexOfflineMutation } from '../src/offline' import { controlledClient, query, withTestCleanup } from './helpers' +import type { Row } from './helpers' import type { ConvexClient } from 'convex/browser' import type { OfflineConfig } from '@tanstack/offline-transactions' @@ -29,289 +30,382 @@ const mutation = makeFunctionReference< null >(`tasks:save`) const stages = [`args`, `mutation`, `sync`] as const -const property = fc.asyncProperty( - fc.record({ - failure: fc.constantFrom(`none`, ...stages), - key: fc.string({ maxLength: 15 }), - text: fc.string({ maxLength: 15 }), - }), - async ({ failure, key, text }) => { - const trace: Array = [] - const cause = new Error(`injected ${failure}`) - const enter = (stage: string) => { - trace.push(stage) - if (failure === stage) throw cause - } - const send = ( - _ref: unknown, - args: { idempotencyKey: string; text: string }, - ) => { - enter(`mutation`) - expect(args).toEqual({ idempotencyKey: key, text }) - return Promise.resolve(null) - } - const persist = convexOfflineMutation({ - client: { mutation: send } as unknown as Pick, - mutation, - getArgs: () => { - enter(`args`) - return { text } - }, - collections: [ - { - utils: { - awaitSync: () => { - enter(`sync`) - return Promise.resolve() - }, - }, - }, - ], - }) - const params = { - transaction: { mutations: [] }, - idempotencyKey: key, - } as unknown as Parameters[0] - const observed = await persist(params).then( - () => ({ status: `fulfilled` as const }), - (error: unknown) => ({ status: `rejected` as const, error }), - ) - const boundary = stages.indexOf(failure as (typeof stages)[number]) - expect(trace).toEqual( - boundary === -1 ? stages : stages.slice(0, boundary + 1), - ) - expect(observed.status).toBe(boundary === -1 ? `fulfilled` : `rejected`) - if (observed.status === `rejected`) { - if (boundary < 2) expect(observed.error).toBeInstanceOf(NonRetriableError) - else expect(observed.error).toBe(cause) - } - }, -) +const failures = [`none`, ...stages] as const it(`preserves ordered replay and retry classification across bridge failures`, async () => { - const seed = process.env.CONVEX_OFFLINE_ORACLE_SEED - if (seed) - await fc.assert(property, { - seed: Number(seed), - path: process.env.CONVEX_OFFLINE_ORACLE_PATH, - numRuns: 60, - }) - else { - await fc.assert(property, { seed: 20261022, numRuns: 60 }) - await fc.assert(property, { numRuns: 60 }) - } + await runOracleCampaign( + `CONVEX_OFFLINE_ORACLE`, + failures, + async (reach, parameters) => { + const property = fc.asyncProperty( + fc.record({ + key: fc.string({ maxLength: 15 }), + text: fc.string({ maxLength: 15 }), + }), + async ({ key, text }) => { + reach.beginHistory() + for (const failure of failures) { + const trace: Array = [] + const cause = new Error(`injected ${failure}`) + const enter = (stage: string) => { + trace.push(stage) + if (failure === stage) throw cause + } + const send = ( + _ref: unknown, + args: { idempotencyKey: string; text: string }, + ) => { + enter(`mutation`) + expect(args).toEqual({ idempotencyKey: key, text }) + return Promise.resolve(null) + } + const persist = convexOfflineMutation({ + client: { mutation: send } as unknown as Pick< + ConvexClient, + `mutation` + >, + mutation, + getArgs: () => { + enter(`args`) + return { text } + }, + collections: [ + { + utils: { + awaitSync: () => { + enter(`sync`) + return Promise.resolve() + }, + }, + }, + ], + }) + const params = { + transaction: { mutations: [] }, + idempotencyKey: key, + } as unknown as Parameters[0] + const observed = await persist(params).then( + () => ({ status: `fulfilled` as const }), + (error: unknown) => ({ status: `rejected` as const, error }), + ) + reach.reach(failure) + const boundary = stages.indexOf(failure as (typeof stages)[number]) + expect(trace).toEqual( + boundary === -1 ? stages : stages.slice(0, boundary + 1), + ) + expect(observed.status).toBe( + boundary === -1 ? `fulfilled` : `rejected`, + ) + if (observed.status === `rejected`) { + if (boundary < 2) + expect(observed.error).toBeInstanceOf(NonRetriableError) + else expect(observed.error).toBe(cause) + } + } + }, + ) + await fc.assert(property, { numRuns: 60, ...parameters }) + }, + { fixedSeed: 20261022 }, + ) }) /** - * Source acceptance is owed by the bridge's documented Collection boundary, - * even on cold replay. This second driver uses real Collections, rather than - * treating an awaitSync stub as evidence of source acceptance. The independent - * model has one fact, whether a valid source snapshot has arrived. Before that - * fact, a successful mutation cannot fulfill replay. A provider failure rejects - * replay and preserves its cause. A valid empty snapshot counts as acceptance. + * Source acceptance is owed by the documented Collection boundary even on cold + * replay. Real Collections receive held provider callbacks. The independent + * judgment is whether a valid source snapshot arrived: pending data cannot + * fulfill replay, a provider failure rejects with its cause, and a valid empty + * snapshot counts as acceptance. Provider errors can recover on a valid result. * - * Histories start idle, awaiting a result, or failed during startup, then cross - * empty/nonempty snapshots and failures before the first result. The provider - * holds callbacks at an explicit cut. We observe bridge settlement and public - * rows before and after that delivery. This does not model durable outbox I/O. + * Every generated history crosses idle/pending/failed startup with each first + * delivery, using generated text. Startup failure has no live provider and is + * terminal at this cut. This bounded matrix ensures that the declared cuts are + * reached in both normal campaigns; it does not model durable outbox I/O. */ -const sourceProperty = fc.asyncProperty( - fc.record({ - state: fc.constantFrom(`idle`, `pending`, `startup-error`), - delivery: fc.constantFrom(`empty`, `rows`, `error`, `cleanup`), - text: fc.string({ maxLength: 15 }), - }), - async ({ state, delivery, text }) => { - const transport = controlledClient() - const cause = new Error(`source unavailable`) - const collection = createCollection( - convexCollectionOptions({ - client: - state === `startup-error` - ? { - onUpdate: () => { - throw cause - }, - } - : transport.client, - query, - queryArgs: { owner: `alice` }, - getKey: (row) => row._id, - gcTime: 0, - }), - ) - if (state !== `idle`) collection.startSyncImmediate() - const persist = convexOfflineMutation({ - client: { mutation: () => Promise.resolve(null) } as unknown as Pick< - ConvexClient, - `mutation` - >, - mutation, - getArgs: () => ({ text }), - collections: [collection], - }) - let outcome: { status: string; error?: unknown } = { status: `pending` } - const pending = persist({ - transaction: { mutations: [] }, - idempotencyKey: `source-key`, - } as unknown as Parameters[0]).then( - () => { - outcome = { status: `fulfilled` } - }, - (error) => { - outcome = { status: `rejected`, error } - }, - ) - await withTestCleanup( - async () => { - await new Promise((resolve) => setTimeout(resolve, 0)) - if (state === `startup-error`) { - expect(outcome).toEqual({ status: `rejected`, error: cause }) - } else { - expect(outcome.status).toBe(`pending`) - expect(transport.counts().subscriptions).toBe(1) - if (delivery === `error`) transport.error(cause) - else if (delivery === `cleanup`) await collection.cleanup() - else transport.emit(delivery === `empty` ? [] : [{ _id: `a`, text }]) - await pending - expect(outcome.status).toBe( - delivery === `error` || delivery === `cleanup` - ? `rejected` - : `fulfilled`, - ) - if (delivery === `error`) { - expect(outcome.error).toBe(cause) - // Provider errors can recover in this run when a valid result arrives. - transport.emit([{ _id: `a`, text }]) - await persist({ - transaction: { mutations: [] }, - idempotencyKey: `source-key`, - } as unknown as Parameters[0]) - expect(collection.get(`a`)?.text).toBe(text) - } - if (delivery === `rows`) expect(collection.get(`a`)?.text).toBe(text) - if (delivery === `empty`) expect(collection.size).toBe(0) - } - }, - async () => { - await collection.cleanup() - }, - ) - }, -) +const sourceStarts = [`idle`, `pending`, `startup-error`] as const +const sourceDeliveries = [`empty`, `rows`, `error`, `cleanup`] as const +const sourceReachStates = [ + ...sourceStarts, + ...sourceDeliveries, + `provider-recovery`, +] as const it(`requires real source acceptance through cold replay histories`, async () => { - const seed = process.env.CONVEX_OFFLINE_SOURCE_ORACLE_SEED - if (seed) - await fc.assert(sourceProperty, { - seed: Number(seed), - path: process.env.CONVEX_OFFLINE_SOURCE_ORACLE_PATH, - numRuns: 60, - }) - else { - await fc.assert(sourceProperty, { seed: 20261023, numRuns: 60 }) - await fc.assert(sourceProperty, { numRuns: 60 }) - } + await runOracleCampaign( + `CONVEX_OFFLINE_SOURCE_ORACLE`, + sourceReachStates, + async (reach, parameters) => { + const property = fc.asyncProperty( + fc.string({ maxLength: 15 }), + async (text) => { + reach.beginHistory() + for (const state of sourceStarts) { + for (const delivery of sourceDeliveries) { + const transport = controlledClient() + const cause = new Error(`source unavailable`) + const collection = createCollection( + convexCollectionOptions({ + client: + state === `startup-error` + ? { + onUpdate: () => { + throw cause + }, + } + : transport.client, + query, + queryArgs: { owner: `alice` }, + getKey: (row) => row._id, + gcTime: 0, + }), + ) + if (state !== `idle`) collection.startSyncImmediate() + const persist = convexOfflineMutation({ + client: { + mutation: () => Promise.resolve(null), + } as unknown as Pick, + mutation, + getArgs: () => ({ text }), + collections: [collection], + }) + let outcome: { status: string; error?: unknown } = { + status: `pending`, + } + const pending = persist({ + transaction: { mutations: [] }, + idempotencyKey: `source-key`, + } as unknown as Parameters[0]).then( + () => { + outcome = { status: `fulfilled` } + }, + (error) => { + outcome = { status: `rejected`, error } + }, + ) + await withTestCleanup( + async () => { + await new Promise((resolve) => setTimeout(resolve, 0)) + reach.reach(state) + if (state === `startup-error`) { + expect(outcome).toEqual({ + status: `rejected`, + error: cause, + }) + } else { + expect(outcome.status).toBe(`pending`) + expect(transport.counts().subscriptions).toBe(1) + if (delivery === `error`) transport.error(cause) + else if (delivery === `cleanup`) await collection.cleanup() + else + transport.emit( + delivery === `empty` ? [] : [{ _id: `a`, text }], + ) + await pending + reach.reach(delivery) + expect(outcome.status).toBe( + delivery === `error` || delivery === `cleanup` + ? `rejected` + : `fulfilled`, + ) + if (delivery === `error`) { + expect(outcome.error).toBe(cause) + transport.emit([{ _id: `a`, text }]) + await persist({ + transaction: { mutations: [] }, + idempotencyKey: `source-key`, + } as unknown as Parameters[0]) + reach.reach(`provider-recovery`) + expect(collection.get(`a`)?.text).toBe(text) + } + if (delivery === `rows`) + expect(collection.get(`a`)?.text).toBe(text) + if (delivery === `empty`) expect(collection.size).toBe(0) + } + }, + () => collection.cleanup(), + ) + } + } + }, + ) + await fc.assert(property, { numRuns: 60, ...parameters }) + }, + { fixedSeed: 20261023 }, + ) }) /** * A durable wrapper can reject before handing a committed source proposal to - * core, as specified by whenSyncAccepted. Once that happens, later diffs cannot - * assume that proposal became the source base. This run stays failed until an - * explicit cleanup/restart establishes a fresh authoritative snapshot. + * core, as specified by whenSyncAccepted. The public guide requires that run to + * stay failed until cleanup/restart, including when an earlier snapshot was + * accepted. Same-cache reads and changed callbacks cannot restore that authority. * - * The reference has two states, failed run and restarted run. Same-cache reads - * and new provider callbacks cannot distinguish failed states, all reject with - * the durable cause. Restart is the one action that changes that judgment. - * Histories vary empty/nonempty initial snapshots and one to four retries with - * unchanged or changed cached rows. Actual commit is deferred behind a rejected - * durability promise, matching the persistence adapter's acceptance boundary. + * The reference retains only the previously accepted rows and failed/restarted + * judgment. Each generated history crosses first empty/nonempty proposals with + * later insert/update/delete/replacement proposals over accepted empty/nonempty + * bases. The wrapper defers actual core commit behind durability. At rejection + * and every retry, the bridge must reject with the exact durable cause and public + * rows must equal the accepted base. Both unchanged and changed cache retries + * occur in every history, followed by generated extra retries and fresh-run + * recovery. These are receipt-interface histories, not native SQLite failures. */ -const acceptanceProperty = fc.asyncProperty( - fc.boolean(), - fc.array(fc.boolean(), { minLength: 1, maxLength: 4 }), - fc.string({ maxLength: 12 }), - async (empty, retries, text) => { - const transport = controlledClient() - const cause = new Error(`durable acceptance failed`) - let rejectDurable!: (error: unknown) => void - let durable = new Promise((_resolve, reject) => { - rejectDurable = reject - }) - const options = convexCollectionOptions({ - client: transport.client, - query, - queryArgs: { owner: `alice` }, - getKey: (row) => row._id, - gcTime: 0, - }) - const collection = createCollection({ - ...options, - sync: { - ...options.sync, - sync: (syncParams) => - options.sync.sync({ - ...syncParams, - commit: () => { - const visible = durable - .then(async () => { - await syncParams.commit() - }) - .then(() => undefined) - return withAcceptedReceipt(visible, durable) - }, - }), - }, - }) - const persist = convexOfflineMutation({ - client: { mutation: () => Promise.resolve(null) } as unknown as Pick< - ConvexClient, - `mutation` - >, - mutation, - getArgs: () => ({ text }), - collections: [collection], - }) - const params = { - transaction: { mutations: [] }, - idempotencyKey: `same-durable-key`, - } as unknown as Parameters[0] - await withTestCleanup( - async () => { - const pending = persist(params) - const rejected = expect(pending).rejects.toBe(cause) - await new Promise((resolve) => setTimeout(resolve, 0)) - const initial = empty ? [] : [{ _id: `a`, text }] - transport.emit(initial) - rejectDurable(cause) - await rejected - for (const changed of retries) { - if (changed) transport.emit([{ _id: `a`, text: `new:${text}` }]) - await expect(persist(params)).rejects.toBe(cause) - expect(collection.size).toBe(0) - } - await collection.cleanup() - durable = Promise.resolve() - transport.cache([{ _id: `a`, text: `restarted:${text}` }]) - collection.startSyncImmediate() - await persist(params) - expect(collection.get(`a`)?.text).toBe(`restarted:${text}`) - }, - () => collection.cleanup(), - ) - }, -) +const acceptanceScenes = [ + `first-empty`, + `first-rows`, + `accepted-empty-insert`, + `accepted-row-insert`, + `accepted-row-update`, + `accepted-row-delete`, + `accepted-row-replacement`, +] as const +const acceptanceReachStates = [ + ...acceptanceScenes, + `unchanged-cache-retry`, + `changed-cache-retry`, + `restart-recovery`, +] as const it(`keeps rejected source acceptance terminal until a fresh sync run`, async () => { - const seed = process.env.CONVEX_OFFLINE_ACCEPTANCE_ORACLE_SEED - if (seed) - await fc.assert(acceptanceProperty, { - seed: Number(seed), - path: process.env.CONVEX_OFFLINE_ACCEPTANCE_ORACLE_PATH, - numRuns: 60, - }) - else { - await fc.assert(acceptanceProperty, { seed: 20261023, numRuns: 60 }) - await fc.assert(acceptanceProperty, { numRuns: 60 }) - } + await runOracleCampaign( + `CONVEX_OFFLINE_ACCEPTANCE_ORACLE`, + acceptanceReachStates, + async (reach, parameters) => { + const property = fc.asyncProperty( + fc.record({ + text: fc.string({ maxLength: 12 }), + extraRetries: fc.array(fc.boolean(), { maxLength: 2 }), + changedFirst: fc.boolean(), + }), + async ({ text, extraRetries, changedFirst }) => { + reach.beginHistory() + for (const scene of acceptanceScenes) { + const transport = controlledClient() + const cause = new Error(`durable acceptance failed`) + let rejectDurable!: (error: unknown) => void + let durable = Promise.resolve() + let acceptImmediately = true + const prior: Array = scene.startsWith(`accepted-row`) + ? [{ _id: `a`, text }] + : [] + const proposed: Array = + scene === `first-empty` || scene === `accepted-row-delete` + ? [] + : scene === `accepted-row-insert` + ? [...prior, { _id: `b`, text: `inserted:${text}` }] + : scene === `accepted-row-replacement` + ? [{ _id: `b`, text: `replacement:${text}` }] + : [{ _id: `a`, text: `proposed:${text}` }] + const options = convexCollectionOptions({ + client: transport.client, + query, + queryArgs: { owner: `alice` }, + getKey: (row) => row._id, + gcTime: 0, + }) + const collection = createCollection({ + ...options, + sync: { + ...options.sync, + sync: (syncParams) => + options.sync.sync({ + ...syncParams, + commit: () => { + if (acceptImmediately) return syncParams.commit() + const visible = durable.then(async () => { + await syncParams.commit() + }) + return withAcceptedReceipt(visible, durable) + }, + }), + }, + }) + const persist = convexOfflineMutation({ + client: { + mutation: () => Promise.resolve(null), + } as unknown as Pick, + mutation, + getArgs: () => ({ text }), + collections: [collection], + }) + const params = { + transaction: { mutations: [] }, + idempotencyKey: `same-durable-key`, + } as unknown as Parameters[0] + await withTestCleanup( + async () => { + if (scene.startsWith(`accepted`)) { + const accepted = collection.utils.awaitSync() + transport.emit(prior) + await accepted + expect( + [...collection.values()].map( + ({ _id, text: observedText }) => ({ + _id, + text: observedText, + }), + ), + ).toEqual(prior) + } else collection.startSyncImmediate() + acceptImmediately = false + durable = new Promise((_resolve, reject) => { + rejectDurable = reject + }) + transport.emit(proposed) + const pending = persist(params) + const rejected = expect(pending).rejects.toBe(cause) + await new Promise((resolve) => setTimeout(resolve, 0)) + rejectDurable(cause) + await rejected + reach.reach(scene) + expect( + [...collection.values()].map( + ({ _id, text: observedText }) => ({ + _id, + text: observedText, + }), + ), + ).toEqual(prior) + const retries = changedFirst + ? [true, false, ...extraRetries] + : [false, true, ...extraRetries] + for (const changed of retries) { + if (changed) + transport.emit([{ _id: `a`, text: `new:${text}` }]) + await expect(persist(params)).rejects.toBe(cause) + reach.reach( + changed ? `changed-cache-retry` : `unchanged-cache-retry`, + ) + expect( + [...collection.values()].map( + ({ _id, text: observedText }) => ({ + _id, + text: observedText, + }), + ), + ).toEqual(prior) + } + await collection.cleanup() + acceptImmediately = true + transport.cache([{ _id: `a`, text: `restarted:${text}` }]) + collection.startSyncImmediate() + await persist(params) + reach.reach(`restart-recovery`) + expect( + [...collection.values()].map( + ({ _id, text: observedText }) => ({ + _id, + text: observedText, + }), + ), + ).toEqual([{ _id: `a`, text: `restarted:${text}` }]) + }, + () => collection.cleanup(), + ) + } + }, + ) + await fc.assert(property, { numRuns: 60, ...parameters }) + }, + { fixedSeed: 20261023 }, + ) }) 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 9deae66edf..3b1ddbc7c5 100644 --- a/packages/convex-db-collection/tests/ownership-oracle.property.test.ts +++ b/packages/convex-db-collection/tests/ownership-oracle.property.test.ts @@ -7,9 +7,10 @@ * * The model judges identity alone: concrete and shallow-copy attempts reuse * one owner, while fresh options and separate DbClients have separate owners. - * Generated histories cross reuse form with before-sync, ready, and cleanup - * checkpoints. At admission, a reused owner must reject. Accepted independent - * owners must each settle a mutation against the latest cached provider rows. + * Each generated history permutes all reuse forms and all before-sync, ready, + * and cleanup checkpoints, so every admission cell has enforced reach. Reused + * owners must reject at admission. Accepted independent owners must each settle + * a mutation against the latest cached provider rows. * The controlled provider holds callbacks after changing its cache, which is * the SDK's documented cached-listener window. Real SDK timing is separately * received in sdk.test.ts. This owner does not model arbitrary DB lifecycle. @@ -17,93 +18,118 @@ import { DbClient, collectionOptions, createCollection } from '@tanstack/db' import { fc } from '@fast-check/vitest' import { expect, it } from 'vitest' +import { runOracleCampaign } from '../../db/tests/oracle-campaign' import { convexCollectionOptions } from '../src/index' import { controlledClient, query, withTestCleanup } from './helpers' +const forms = [`direct`, `shallow`, `fresh`, `descriptor`] as const +const checkpoints = [`idle`, `ready`, `cleaned-up`] as const +const states = [ + ...forms.flatMap((form) => + checkpoints.map((checkpoint) => `${form}:${checkpoint}` as const), + ), + `independent-confirmation`, + `rightful-after-rejected-reuse`, + `rightful-after-sibling-cleanup`, +] as const + it(`keeps concrete options ownership across generated lifecycle checkpoints`, async () => { - const property = fc.asyncProperty( - fc.constantFrom(`direct`, `shallow`, `fresh`, `descriptor`), - fc.constantFrom(`idle`, `ready`, `cleaned-up`), - fc.string({ maxLength: 16 }), - async (form, checkpoint, text) => { - const transport = controlledClient() - transport.cache([{ _id: `a`, text: `old` }]) - const makeOptions = () => - convexCollectionOptions({ - id: `review-owned-tasks`, - client: transport.client, - query, - queryArgs: { owner: `alice` }, - getKey: (row) => row._id, - gcTime: 0, - onUpdate: () => Promise.resolve(), - }) - const options = makeOptions() - const descriptor = collectionOptions(options) - const first = - form === `descriptor` - ? new DbClient().collection(descriptor) - : createCollection(options) - const collections = [first] - await withTestCleanup( - async () => { - if (checkpoint !== `idle`) await first.preload() - if (checkpoint === `cleaned-up`) await first.cleanup() - const reusedOwner = form === `direct` || form === `shallow` - const construct = () => - form === `descriptor` - ? new DbClient().collection(descriptor) - : createCollection( - form === `fresh` - ? makeOptions() - : form === `shallow` - ? { ...options } - : options, - ) - if (reusedOwner) { - expect(construct).toThrow(`only one Collection`) - } else { - const second = construct() - collections.push(second) - await second.preload() - transport.cache([{ _id: `a`, text }]) - const transaction = second.update(`a`, (row) => { - row.text = `optimistic` - }) - await transaction.isPersisted.promise - expect(second.get(`a`)?.text).toBe(text) + await runOracleCampaign( + `CONVEX_OWNERSHIP_ORACLE`, + states, + async (reach, parameters) => { + const property = fc.asyncProperty( + fc.shuffledSubarray([...forms], { minLength: 4, maxLength: 4 }), + fc.shuffledSubarray([...checkpoints], { minLength: 3, maxLength: 3 }), + fc.string({ maxLength: 16 }), + async (orderedForms, orderedCheckpoints, text) => { + reach.beginHistory() + // Exhaust the finite identity/checkpoint matrix in generated order. + // Text still varies cached results; no rare cell depends on sampling. + for (const form of orderedForms) { + for (const checkpoint of orderedCheckpoints) { + const transport = controlledClient() + transport.cache([{ _id: `a`, text: `old` }]) + const makeOptions = () => + convexCollectionOptions({ + id: `review-owned-tasks`, + client: transport.client, + query, + queryArgs: { owner: `alice` }, + getKey: (row) => row._id, + gcTime: 0, + onUpdate: () => Promise.resolve(), + }) + const options = makeOptions() + const descriptor = collectionOptions(options) + const first = + form === `descriptor` + ? new DbClient().collection(descriptor) + : createCollection(options) + const collections = [first] + await withTestCleanup( + async () => { + if (checkpoint !== `idle`) await first.preload() + if (checkpoint === `cleaned-up`) await first.cleanup() + const reusedOwner = form === `direct` || form === `shallow` + const construct = () => + form === `descriptor` + ? new DbClient().collection(descriptor) + : createCollection( + form === `fresh` + ? makeOptions() + : form === `shallow` + ? { ...options } + : options, + ) + if (reusedOwner) { + expect(construct).toThrow(`only one Collection`) + } else { + const second = construct() + collections.push(second) + await second.preload() + transport.cache([{ _id: `a`, text }]) + const transaction = second.update(`a`, (row) => { + row.text = `optimistic` + }) + await transaction.isPersisted.promise + expect(second.get(`a`)?.text).toBe(text) + reach.reach(`independent-confirmation`) + } + // Failed reuse and sibling cleanup cannot revoke the rightful owner. + for (const collection of collections.slice(1)) + await collection.cleanup() + await first.preload() + transport.cache([{ _id: `a`, text: `owner:${text}` }]) + const transaction = first.update(`a`, (row) => { + row.text = `optimistic` + }) + await transaction.isPersisted.promise + expect(first.get(`a`)?.text).toBe(`owner:${text}`) + reach.reach(`${form}:${checkpoint}`) + reach.reach( + reusedOwner + ? `rightful-after-rejected-reuse` + : `rightful-after-sibling-cleanup`, + ) + }, + async () => { + // The second owner can be admitted inside the body. Enumerate all + // resources at cleanup time, then attempt every release. + await withTestCleanup( + () => {}, + ...collections.map( + (collection) => () => collection.cleanup(), + ), + ) + }, + ) + } } - // Failed reuse and sibling cleanup cannot revoke the rightful owner. - for (const collection of collections.slice(1)) - await collection.cleanup() - await first.preload() - transport.cache([{ _id: `a`, text: `owner:${text}` }]) - const transaction = first.update(`a`, (row) => { - row.text = `optimistic` - }) - await transaction.isPersisted.promise - expect(first.get(`a`)?.text).toBe(`owner:${text}`) - }, - async () => { - // The second owner can be admitted inside the body. Enumerate all - // resources at cleanup time, then attempt every release. - await withTestCleanup( - () => {}, - ...collections.map((collection) => () => collection.cleanup()), - ) }, ) + await fc.assert(property, { numRuns: 60, ...parameters }) }, + { fixedSeed: 20261022 }, ) - const seed = process.env.CONVEX_OWNERSHIP_ORACLE_SEED - if (seed) - await fc.assert(property, { - seed: Number(seed), - path: process.env.CONVEX_OWNERSHIP_ORACLE_PATH, - numRuns: 60, - }) - else { - await fc.assert(property, { seed: 20261022, numRuns: 60 }) - await fc.assert(property, { numRuns: 60 }) - } }) diff --git a/packages/convex-db-collection/tests/pagination-oracle.property.test.ts b/packages/convex-db-collection/tests/pagination-oracle.property.test.ts index 7792e69948..40bc93a215 100644 --- a/packages/convex-db-collection/tests/pagination-oracle.property.test.ts +++ b/packages/convex-db-collection/tests/pagination-oracle.property.test.ts @@ -9,8 +9,10 @@ * * The provider fixture below answers mathematical rank intervals, rather than * following the adapter's page state. Histories replace up to eight distinct - * keyed rows, moving their ranks and adding/removing keys. Page sizes 1-4, - * forward/reverse delivery, and required/recommended/no splits vary separately. + * keyed rows, moving their ranks and adding/removing keys. A three-row then + * eight-row prefix and an empty final result force pending replacement and + * empty readiness. Each row history crosses page sizes 1-2 and 3-4 with all + * three split policies; callback direction and intervening rows vary freely. * A pump delivers every active subscription once per source revision, including * newly acquired ranges, before the exhaustion checkpoint. It never supplies * an endCursor unless production requested it. The real-backend receiving @@ -24,6 +26,7 @@ import { createCollection } from '@tanstack/db' import { makeFunctionReference } from 'convex/server' import { expect, it } from 'vitest' import { convexPaginatedCollectionOptions } from '../src/index' +import { runOracleCampaign } from '../../db/tests/oracle-campaign' import { withTestCleanup } from './helpers' import type { PaginationOptions, PaginationResult } from 'convex/server' import type { ConvexCollectionClient } from '../src/index' @@ -166,86 +169,217 @@ const snapshot = fc.shuffledSubarray([0, 1, 2, 3, 4, 5, 6, 7]).chain((ids) => })), ), ) +// A three-row accepted snapshot followed by eight rows forces acquisition +// while a nonempty prior snapshot is visible. Each generated history uses a +// small page and a split-capable page with every split policy. This preserves +// page sizes 1-4 and makes both split premises constructive, not lucky samples. const history = fc.record({ snapshots: fc.array(snapshot, { minLength: 1, maxLength: 10 }), - pageSize: fc.integer({ min: 1, max: 4 }), + initialTexts: fc.array(fc.string({ maxLength: 8 }), { + minLength: 8, + maxLength: 8, + }), + smallPageSize: fc.integer({ min: 1, max: 2 }), + splitPageSize: fc.integer({ min: 3, max: 4 }), reverse: fc.boolean(), - split: fc.constantFrom( - `none` as const, - `required` as const, - `recommended` as const, - ), }) -// Calibration on the legal provider grammar: reversing page order fails the -// select-boundary comparison at seed 20261022/path 0:1:0:2:3:1:1:1. Publishing -// an available prefix before a missing range fails the intermediate checkpoint -// at seed 20261022/path 0:1:3:1:2:5:7:3:3:0:1:1. Both are assertion failures -// after reaching the named observation, not setup errors or timeouts. -const property = fc.asyncProperty( - history, - async ({ snapshots, pageSize, reverse, split }) => { - const source = provider(reverse, split) - let selected: ReadonlyArray = [] - let accepted: ReadonlyArray = [] - let pendingCheckpoints = 0 - const collection = createCollection( - convexPaginatedCollectionOptions({ - client: source.client, - query, - queryArgs: {}, - pageSize, - select: (rows) => { - selected = rows - return rows - }, - getKey: (row) => row.id, - gcTime: 0, - }), - ) - collection.startSyncImmediate() - await withTestCleanup( - async () => { - for (const rows of snapshots) { - await source.publish(rows, () => { - pendingCheckpoints++ - // A newly acquired range has no result yet. The last complete - // snapshot, including its order at select, must remain authoritative. - expect(selected).toEqual(accepted) - expect( - [...collection.values()] - .map(({ id, rank, text }) => ({ id, rank, text })) - .sort((a, b) => a.id.localeCompare(b.id)), - ).toEqual([...accepted].sort((a, b) => a.id.localeCompare(b.id))) - }) - await collection.utils.awaitSync() - expect(selected).toEqual([...rows].sort((a, b) => a.rank - b.rank)) - accepted = [...rows].sort((a, b) => a.rank - b.rank) - const actual = [...collection.values()] - .map(({ id, rank, text }) => ({ id, rank, text })) - .sort((a, b) => a.id.localeCompare(b.id)) - const expected = [...rows].sort((a, b) => a.id.localeCompare(b.id)) - expect(actual).toEqual(expected) - expect(collection.status).toBe(`ready`) - } - if (snapshots[0]!.length > pageSize) - expect(pendingCheckpoints).toBeGreaterThan(0) - }, - async () => { - await collection.cleanup() - expect(source.slots.every((slot) => !slot.active)).toBe(true) - }, +const rowStates = [ + `complete-nonempty-snapshot`, + `complete-empty-snapshot`, + `required-split`, + `recommended-split`, + `pending-range-with-prior-snapshot`, + `public-snapshot-publication`, +] as const + +function copyRows(rows: ReadonlyArray): Array { + return rows.map(({ id, rank, text }) => ({ id, rank, text })) +} +function keyedRows(rows: ReadonlyArray): Array { + return copyRows(rows).sort((a, b) => a.id.localeCompare(b.id)) +} +function sameRows( + left: ReadonlyArray, + right: ReadonlyArray, +): boolean { + return ( + left.length === right.length && + left.every( + (row, index) => + row.id === right[index]!.id && + row.rank === right[index]!.rank && + row.text === right[index]!.text, ) + ) +} + +// During one coherent source revision, observations may retain the prior +// complete snapshot and then expose the new complete snapshot. Repeated +// observations are permitted. Once the new snapshot appears, stale prior rows +// cannot reappear without another source revision. Keep every observation: +// overwriting one variable would hide a prefix immediately repaired in-turn. +function expectRevisionTrace( + boundary: string, + observations: ReadonlyArray>, + prior: ReadonlyArray, + current: ReadonlyArray, +): void { + let currentSeen = false + for (const observation of observations) { + if (sameRows(observation, current)) currentSeen = true + else + expect( + observation, + `${boundary}: complete monotone source snapshots`, + ).toEqual(currentSeen ? current : prior) + } +} + +// Calibrate both recorders' shared trace relation without imposing one callback +// per revision. Repeated old/new snapshots are legal; prefixes and rollback +// after the new snapshot are not, even when a final callback repairs the result. +it.each([`select`, `Collection publication`])( + `calibrates complete monotone snapshots at %s`, + (boundary) => { + const current = Array.from({ length: 3 }, (_, rank) => ({ + id: String(rank), + rank, + text: String(rank), + })) + expect(() => + expectRevisionTrace(boundary, [[], [], current, current], [], current), + ).not.toThrow() + expect(() => + expectRevisionTrace( + boundary, + [current.slice(0, 1), current], + [], + current, + ), + ).toThrow() + expect(() => + expectRevisionTrace(boundary, [current, [], current], [], current), + ).toThrow() }, ) -it(`refines exhausted snapshots across reactive ranges and page splits`, async () => { - const seed = process.env.CONVEX_PAGINATION_ROWS_SEED - const path = process.env.CONVEX_PAGINATION_ROWS_PATH - if (seed) await fc.assert(property, { seed: Number(seed), path, numRuns: 60 }) - else { - await fc.assert(property, { seed: 20261022, numRuns: 60 }) - await fc.assert(property, { numRuns: 60 }) - } +// Calibration: publishing a one-row prefix before the full result, or +// republishing the empty prior snapshot after the full result, fails the select +// trace comparison at seed 20261022/path 0:0:0:0:0:0:0:0:0:0. The unchanged +// implementation passes that direct replay; setup errors are not counted. +it(`refines every published snapshot across reactive ranges and page splits`, async () => { + await runOracleCampaign( + `CONVEX_PAGINATION_ROWS`, + rowStates, + async (reach, parameters) => { + const property = fc.asyncProperty( + history, + async ({ + snapshots, + initialTexts, + smallPageSize, + splitPageSize, + reverse, + }) => { + reach.beginHistory() + const initial = initialTexts.map((text, rank): Row => ({ + id: String(rank), + rank, + text, + })) + const revisions = [initial.slice(0, 3), initial, ...snapshots, []] + for (const pageSize of [smallPageSize, splitPageSize]) { + for (const split of [`none`, `required`, `recommended`] as const) { + const source = provider(reverse, split) + let selected: ReadonlyArray = [] + let accepted: ReadonlyArray = [] + const selectTrace: Array> = [] + const publicTrace: Array> = [] + const collection = createCollection( + convexPaginatedCollectionOptions({ + client: source.client, + query, + queryArgs: {}, + pageSize, + select: (rows) => { + selected = copyRows(rows) + selectTrace.push(copyRows(rows)) + return rows + }, + getKey: (row) => row.id, + gcTime: 0, + }), + ) + collection.startSyncImmediate() + const subscription = collection.subscribeChanges(() => { + publicTrace.push(keyedRows([...collection.values()])) + }) + await withTestCleanup( + async () => { + for (const rows of revisions) { + // Copy the model before passing source objects to production. + const expected = copyRows(rows).sort( + (a, b) => a.rank - b.rank, + ) + const selectStart = selectTrace.length + const publicStart = publicTrace.length + await source.publish(rows, () => { + if (accepted.length > 0) + reach.reach(`pending-range-with-prior-snapshot`) + // No new complete result is available at this cut. + expect(selected).toEqual(accepted) + expect(keyedRows([...collection.values()])).toEqual( + keyedRows(accepted), + ) + }) + await collection.utils.awaitSync() + expectRevisionTrace( + `select`, + selectTrace.slice(selectStart), + accepted, + expected, + ) + expectRevisionTrace( + `Collection publication`, + publicTrace.slice(publicStart), + keyedRows(accepted), + keyedRows(expected), + ) + expect(selected).toEqual(expected) + expect(keyedRows([...collection.values()])).toEqual( + keyedRows(expected), + ) + expect(collection.status).toBe(`ready`) + reach.reach( + expected.length === 0 + ? `complete-empty-snapshot` + : `complete-nonempty-snapshot`, + ) + if (publicTrace.length > publicStart) + reach.reach(`public-snapshot-publication`) + for (const slot of source.slots) { + if (slot.current?.pageStatus === `SplitRequired`) + reach.reach(`required-split`) + if (slot.current?.pageStatus === `SplitRecommended`) + reach.reach(`recommended-split`) + } + accepted = expected + } + }, + () => subscription.unsubscribe(), + async () => { + await collection.cleanup() + expect(source.slots.every((slot) => !slot.active)).toBe(true) + }, + ) + } + } + }, + ) + await fc.assert(property, { numRuns: 60, ...parameters }) + }, + { fixedSeed: 20261022 }, + ) }) // A finite initial load must materialize its rows only after all ranges are @@ -258,51 +392,59 @@ it(`refines exhausted snapshots across reactive ranges and page splits`, async ( // Calibration: copying incomplete prefixes failed the row-visit comparison at // seed 20261022/path 0:0:0:0, three rows at pageSize 1 read four elements. // The same direct replay passes after deferring materialization until exhaustion. -const initialWork = fc.asyncProperty( - fc.integer({ min: 3, max: 40 }), - fc.integer({ min: 1, max: 8 }), - fc.boolean(), - async (pageCount, pageSize, reverse) => { - const rows = Array.from({ length: pageCount * pageSize }, (_, rank) => ({ - id: String(rank), - rank, - text: String(rank), - })) - const source = provider(reverse, `none`) - const collection = createCollection( - convexPaginatedCollectionOptions({ - client: source.client, - query, - queryArgs: {}, - pageSize, - getKey: (row) => row.id, - gcTime: 0, - }), - ) - collection.startSyncImmediate() - await withTestCleanup( - async () => { - await source.publish(rows) - // Check before awaitSync's explicit cache refreshes. At initial exhaustion - // there is exactly one useful materialization, the complete public result. - expect(collection.size).toBe(rows.length) - expect(source.rowVisits).toBe(rows.length) - }, - async () => { - await collection.cleanup() - }, - ) - }, -) it(`materializes complete rows once during finite initial pagination`, async () => { - const seed = process.env.CONVEX_PAGINATION_WORK_SEED - const path = process.env.CONVEX_PAGINATION_WORK_PATH - if (seed) - await fc.assert(initialWork, { seed: Number(seed), path, numRuns: 60 }) - else { - await fc.assert(initialWork, { seed: 20261022, numRuns: 60 }) - await fc.assert(initialWork, { numRuns: 60 }) - } + await runOracleCampaign( + `CONVEX_PAGINATION_WORK`, + [`initial-multi-range-exhaustion`] as const, + async (reach, parameters) => { + const initialWork = fc.asyncProperty( + fc.integer({ min: 3, max: 40 }), + fc.integer({ min: 1, max: 8 }), + fc.boolean(), + async (pageCount, pageSize, reverse) => { + reach.beginHistory() + const rows = Array.from( + { length: pageCount * pageSize }, + (_, rank) => ({ + id: String(rank), + rank, + text: String(rank), + }), + ) + const source = provider(reverse, `none`) + const collection = createCollection( + convexPaginatedCollectionOptions({ + client: source.client, + query, + queryArgs: {}, + pageSize, + getKey: (row) => row.id, + gcTime: 0, + }), + ) + collection.startSyncImmediate() + await withTestCleanup( + async () => { + await source.publish(rows) + // Check before awaitSync's explicit cache refreshes. At initial exhaustion + // there is exactly one useful materialization, the complete public result. + expect(collection.size).toBe(rows.length) + expect(source.rowVisits).toBe(rows.length) + expect( + source.slots.some((slot) => slot.options.cursor !== null), + ).toBe(true) + reach.reach(`initial-multi-range-exhaustion`) + }, + async () => { + await collection.cleanup() + }, + ) + }, + ) + await fc.assert(initialWork, { numRuns: 60, ...parameters }) + }, + { fixedSeed: 20261022 }, + ) }) // A configured maxRows bounds the complete eager mirror's accepted row count. @@ -312,85 +454,94 @@ it(`materializes complete rows once during finite initial pagination`, async () // exhausted source must reject before opening any continuation beyond that cap. // Calibration: omitting the row cap fails at the public error observation, // seed 20261023/path 0:0, maxRows 1 followed by a two-row source snapshot. -const rowBound = fc.asyncProperty( - fc.integer({ min: 1, max: 20 }), - fc.integer({ min: 1, max: 6 }), - fc.integer({ min: 1, max: 6 }), - async (maxRows, pageSize, excess) => { - const rows = Array.from({ length: maxRows + excess }, (_, rank) => ({ - id: String(rank), - rank, - text: String(rank), - })) - const source = provider(false, `none`) - const collection = createCollection( - convexPaginatedCollectionOptions({ - client: source.client, - query, - queryArgs: {}, - pageSize, - maxRows, - getKey: (row) => row.id, - gcTime: 0, - }), - ) - collection.startSyncImmediate() - await withTestCleanup( - async () => { - const accepted = rows.slice(0, maxRows) - await source.publish(accepted) - await collection.utils.awaitSync() - expect( - [...collection.values()] - .map(({ id, rank, text }) => ({ - id, - rank, - text, - })) - .sort((a, b) => a.rank - b.rank), - ).toEqual(accepted) - await source.publish(rows) - expect(collection.utils.getSyncError()).toBeInstanceOf(RangeError) - expect( - [...collection.values()] - .map(({ id, rank, text }) => ({ - id, - rank, - text, - })) - .sort((a, b) => a.rank - b.rank), - ).toEqual(accepted) - // One continuation at the cap may be necessary to distinguish an empty - // remainder from an extra row. No cursor may advance past that first - // excess row once its response establishes that the mirror is too large. - for (const slot of source.slots) { - if (slot.options.cursor !== null) - expect(Number(slot.options.cursor)).toBeLessThan(maxRows) - } - await expect(collection.utils.awaitSync()).rejects.toBeInstanceOf( - RangeError, - ) - // The row cap is a valid source error, not a terminal run failure. - // Removing the excess rows permits this same subscription run to recover. - await source.publish(accepted) - await collection.utils.awaitSync() - expect(collection.utils.getSyncError()).toBeUndefined() - expect(collection.size).toBe(maxRows) - }, - async () => { - await collection.cleanup() - }, - ) - }, -) it(`rejects an oversized eager mirror before continuing and preserves accepted rows`, async () => { - const seed = process.env.CONVEX_PAGINATION_BOUND_SEED - const path = process.env.CONVEX_PAGINATION_BOUND_PATH - if (seed) await fc.assert(rowBound, { seed: Number(seed), path, numRuns: 60 }) - else { - await fc.assert(rowBound, { seed: 20261023, numRuns: 60 }) - await fc.assert(rowBound, { numRuns: 60 }) - } + await runOracleCampaign( + `CONVEX_PAGINATION_BOUND`, + [ + `accepted-at-cap`, + `overflow-preserves-prior`, + `recovered-after-shrink`, + ] as const, + async (reach, parameters) => { + const rowBound = fc.asyncProperty( + fc.integer({ min: 1, max: 20 }), + fc.integer({ min: 1, max: 6 }), + fc.integer({ min: 1, max: 6 }), + async (maxRows, pageSize, excess) => { + reach.beginHistory() + const rows = Array.from({ length: maxRows + excess }, (_, rank) => ({ + id: String(rank), + rank, + text: String(rank), + })) + const source = provider(false, `none`) + const collection = createCollection( + convexPaginatedCollectionOptions({ + client: source.client, + query, + queryArgs: {}, + pageSize, + maxRows, + getKey: (row) => row.id, + gcTime: 0, + }), + ) + collection.startSyncImmediate() + await withTestCleanup( + async () => { + const accepted = rows.slice(0, maxRows) + await source.publish(accepted) + await collection.utils.awaitSync() + expect( + [...collection.values()] + .map(({ id, rank, text }) => ({ + id, + rank, + text, + })) + .sort((a, b) => a.rank - b.rank), + ).toEqual(accepted) + reach.reach(`accepted-at-cap`) + await source.publish(rows) + expect(collection.utils.getSyncError()).toBeInstanceOf(RangeError) + expect( + [...collection.values()] + .map(({ id, rank, text }) => ({ + id, + rank, + text, + })) + .sort((a, b) => a.rank - b.rank), + ).toEqual(accepted) + reach.reach(`overflow-preserves-prior`) + // One continuation at the cap may be necessary to distinguish an empty + // remainder from an extra row. No cursor may advance past that first + // excess row once its response establishes that the mirror is too large. + for (const slot of source.slots) { + if (slot.options.cursor !== null) + expect(Number(slot.options.cursor)).toBeLessThan(maxRows) + } + await expect(collection.utils.awaitSync()).rejects.toBeInstanceOf( + RangeError, + ) + // The row cap is a valid source error, not a terminal run failure. + // Removing the excess rows permits this same subscription run to recover. + await source.publish(accepted) + await collection.utils.awaitSync() + expect(collection.utils.getSyncError()).toBeUndefined() + expect(collection.size).toBe(maxRows) + reach.reach(`recovered-after-shrink`) + }, + async () => { + await collection.cleanup() + }, + ) + }, + ) + await fc.assert(rowBound, { numRuns: 60, ...parameters }) + }, + { fixedSeed: 20261023 }, + ) }) it.each([0, -1, 1.5, Infinity, NaN])( @@ -415,7 +566,11 @@ it.each([0, -1, 1.5, Infinity, NaN])( }, ) -it(`defaults the eager mirror cap to ten thousand rows`, async () => { +// The omitted default needs both adjacent observations. Rejection at 10,001 +// alone also accepts a wrong default of 9,999; acceptance at 10,000 rejects it. +// Calibration: defaults of 9,999 and 10,001 fail the accepted-error and +// over-cap-error comparisons respectively; the documented default passes both. +it(`accepts ten thousand rows and rejects the next row under the default cap`, async () => { const source = provider(false, `none`) const collection = createCollection( convexPaginatedCollectionOptions({ @@ -427,18 +582,27 @@ it(`defaults the eager mirror cap to ten thousand rows`, async () => { }), ) collection.startSyncImmediate() - try { - await source.publish( - Array.from({ length: 10_001 }, (_, rank) => ({ + await withTestCleanup( + async () => { + const rows = Array.from({ length: 10_001 }, (_, rank) => ({ id: String(rank), rank, text: String(rank), - })), - ) - expect(collection.utils.getSyncError()).toBeInstanceOf(RangeError) - expect(collection.size).toBe(0) - expect(source.slots).toHaveLength(1) - } finally { - await collection.cleanup() - } + })) + const accepted = rows.slice(0, 10_000) + await source.publish(accepted) + expect(collection.utils.getSyncError()).toBeUndefined() + await collection.utils.awaitSync() + expect(collection.status).toBe(`ready`) + expect(keyedRows([...collection.values()])).toEqual(keyedRows(accepted)) + await source.publish(rows) + expect(collection.utils.getSyncError()).toBeInstanceOf(RangeError) + expect(keyedRows([...collection.values()])).toEqual(keyedRows(accepted)) + expect(source.slots).toHaveLength(1) + await expect(collection.utils.awaitSync()).rejects.toBeInstanceOf( + RangeError, + ) + }, + () => collection.cleanup(), + ) }) diff --git a/packages/convex-db-collection/tests/snapshot-oracle.property.test.ts b/packages/convex-db-collection/tests/snapshot-oracle.property.test.ts index dd6d92d1c7..a0f5ecdc4d 100644 --- a/packages/convex-db-collection/tests/snapshot-oracle.property.test.ts +++ b/packages/convex-db-collection/tests/snapshot-oracle.property.test.ts @@ -1,113 +1,259 @@ /** * A Collection mirrors the complete keyed rows of its latest valid Convex - * query result, as promised by convexCollectionOptions. An omitted key is a - * deletion, and an omitted field is absent, rather than retained from an older - * row. Duplicate keys reject the whole result and leave the last valid result. + * query result, as promised by docs/collections/convex-collection.md. Missing + * keys delete rows, missing fields disappear, and duplicate keys reject the + * whole result while preserving the last valid snapshot. Keys retain their + * string or finite-number identity: numeric 1 and string '1' are distinct. * - * The reference is simply the last valid input array. It never computes sync - * operations or uses the adapter's key map. Generated histories contain empty, - * reordered, changed, removed, reintroduced, and duplicate-key snapshots. - * Every history runs both raw rows and a successful text projection. The - * reference labels selected text with an independently specified uppercase - * operation, so omitting select cannot pass merely by preserving row keys. - * The driver invokes the real Collection through a controlled Convex callback. - * After each synchronous delivery, public rows and the error flag are compared. - * This owner covers snapshot refinement only. SDK transport, mutation timing, - * errors, and cleanup have separate focused receiving tests in this package. + * The independent reference is the last valid selected input array. Pairwise + * key equality judges duplicates without the adapter's map or sync operations. + * Every generated history covers valid and duplicate starts, then constructs + * omission, field removal, empty results, + * reintroduction, duplicates and recovery, then appends arbitrary snapshots. + * String, numeric and mixed key lanes share that history. The mixed lane + * includes 1/'1'; finite numeric parameters include fractions and negatives. + * Raw and selected runs compare the same law. Selection changes key shape and + * uppercases text; the reference states that transformation independently. + * + * The real Collection receives controlled Convex callbacks. After each + * synchronous delivery, typed public entries and the error flag are compared. + * Reach counts only histories that pass those checks. This owner does not + * establish provider ordering, intermediate publication, SDK transport, + * mutation timing or cleanup scheduling; receiving tests own those boundaries. */ import { isDeepStrictEqual } from 'node:util' import { createCollection } from '@tanstack/db' import { fc } from '@fast-check/vitest' +import { makeFunctionReference } from 'convex/server' import { expect, it } from 'vitest' +import { runOracleCampaign } from '../../db/tests/oracle-campaign' import { convexCollectionOptions } from '../src/index' -import { controlledClient, query, withTestCleanup } from './helpers' +import { controlledClient, withTestCleanup } from './helpers' import type { Row } from './helpers' -const row = fc - .record({ - _id: fc.constantFrom(`a`, `b`, `c`), - text: fc.string({ maxLength: 12 }), - optional: fc.option(fc.string({ maxLength: 6 }), { nil: undefined }), - }) - .map((value): Row => - value.optional === undefined ? { _id: value._id, text: value.text } : value, - ) +type KeyedRow = Row & { key: string | number } +const query = makeFunctionReference< + `query`, + { owner: string }, + Array +>(`tasks:keyed`) + +const row = fc.record({ + slot: fc.integer({ min: 0, max: 2 }), + text: fc.string({ maxLength: 12 }), + optional: fc.option(fc.string({ maxLength: 6 }), { nil: undefined }), +}) +const states = [ + `string-keys`, + `numeric-keys`, + `mixed-key-types`, + `equal-looking-typed-keys`, + `selected-key-representation`, + `omitted-key`, + `omitted-field`, + `empty-result`, + `reintroduced-key`, + `duplicate-rejected`, + `valid-after-error`, + `initial-valid`, + `initial-duplicate-rejected`, +] as const it(`refines complete snapshots throughout generated histories`, async () => { - const property = fc.asyncProperty( - fc.array(fc.array(row, { maxLength: 5 }), { - minLength: 1, - maxLength: 30, - }), - async (history) => { - for (const projected of [false, true]) { - const transport = controlledClient() - const collection = createCollection( - convexCollectionOptions({ - client: transport.client, - query, - queryArgs: { owner: `alice` }, - getKey: (value) => value._id, - ...(projected - ? { - select: (rows: Array) => - rows.map((value) => ({ - ...value, - text: value.text.toUpperCase(), - })), - } - : {}), - gcTime: 0, - }), - ) - collection.startSyncImmediate() - let expected: Array = [] - await withTestCleanup( - () => { - for (const snapshot of history) { - // Independent validity judgment: compare every pair of input keys. - const valid = snapshot.every((value, index) => - snapshot - .slice(index + 1) - .every((other) => other._id !== value._id), + await runOracleCampaign( + `CONVEX_SNAPSHOT_ORACLE`, + states, + async (reach, parameters) => { + const property = fc.asyncProperty( + fc.double({ min: -100, max: 100, noNaN: true }), + fc.string({ maxLength: 12 }), + fc.array(fc.array(row, { maxLength: 5 }), { + minLength: 1, + maxLength: 30, + }), + async (numberKey, text, suffix) => { + reach.beginHistory() + const lanes = [ + { name: `string-keys`, keys: [`a`, `b`, `c`] }, + { name: `numeric-keys`, keys: [numberKey, numberKey + 0.5, 1] }, + { name: `mixed-key-types`, keys: [1, `1`, numberKey] }, + ] as const + for (const lane of lanes) { + const first: KeyedRow = { + _id: `first`, + key: lane.keys[0], + text: `lower:${text}`, + optional: `remove`, + } + const second: KeyedRow = { + _id: `second`, + key: lane.keys[1], + text, + } + // The first two lane keys are always distinct. This constructive + // prefix forces adjacent laws even when the random suffix is empty. + const history: Array> = [ + [first, second], + [{ _id: first._id, key: first.key, text: `updated:${text}` }], + [], + [first, second], + [second, first], + [first, { ...first, text: `duplicate:${text}` }], + [{ ...second, text: `recovered:${text}` }], + ...suffix.map((snapshot) => + snapshot.map((value): KeyedRow => ({ + _id: `source:${value.slot}`, + key: lane.keys[value.slot]!, + text: value.text, + ...(value.optional === undefined + ? {} + : { optional: value.optional }), + })), + ), + ] + for (const [projected, initiallyInvalid] of [ + [false, false], + [false, true], + [true, false], + [true, true], + ] as const) { + const transport = controlledClient() + const collection = createCollection( + convexCollectionOptions({ + client: transport.client, + query, + queryArgs: { owner: `alice` }, + getKey: (value) => value.key, + ...(projected + ? { + select: (rows: Array): Array => + rows.map((value) => ({ + ...value, + key: + typeof value.key === `number` + ? String(value.key) + : `selected:${value.key}`, + text: value.text.toUpperCase(), + })), + } + : {}), + gcTime: 0, + }), ) - if (valid) - expected = snapshot.map((value) => ({ - ...value, - text: projected ? value.text.toUpperCase() : value.text, - })) - transport.emit(snapshot) - expect(collection.utils.getSyncError() !== undefined).toBe(!valid) - const actual = [...collection.values()] - .map((value): Row => { - const result: Row = { _id: value._id, text: value.text } - if (Object.hasOwn(value, `optional`)) - result.optional = value.optional - return result - }) - .sort((a, b) => a._id.localeCompare(b._id)) - const wanted = [...expected].sort((a, b) => - a._id.localeCompare(b._id), + collection.startSyncImmediate() + let expected: Array = [] + const seen: Array = [] + let previousInvalid = false + await withTestCleanup( + () => { + // Both startup forms remain reachable: prepending a + // duplicate must reject without publishing even one row. + const deliveries = initiallyInvalid + ? [[first, { ...first }], ...history] + : history + for (const [ + deliveryIndex, + snapshot, + ] of deliveries.entries()) { + // The public projection contract supplies these expected + // values; no adapter helper or key map computes them. + const selected = snapshot.map((value): KeyedRow => ({ + ...value, + key: projected + ? typeof value.key === `number` + ? `${value.key}` + : `selected:${value.key}` + : value.key, + text: projected ? value.text.toUpperCase() : value.text, + })) + const valid = selected.every((value, index) => + selected + .slice(index + 1) + .every((other) => other.key !== value.key), + ) + const prior = expected + if (valid) expected = selected + transport.emit(snapshot) + expect(collection.utils.getSyncError() !== undefined).toBe( + !valid, + ) + // Keep public key types and cardinality. String sorting or + // a string-keyed object would erase the 1/'1' distinction. + const actual = [...collection.entries()].map( + ([key, value]) => { + const observed: KeyedRow = { + _id: value._id, + key: value.key, + text: value.text, + } + if (Object.hasOwn(value, `optional`)) + observed.optional = value.optional + return { key, row: observed } + }, + ) + expect(actual).toHaveLength(expected.length) + for (const wanted of expected) { + const entry = actual.find(({ key }) => key === wanted.key) + expect(isDeepStrictEqual(entry?.row, wanted)).toBe(true) + } + if (deliveryIndex === 0) + reach.reach( + valid ? `initial-valid` : `initial-duplicate-rejected`, + ) + if (!valid) reach.reach(`duplicate-rejected`) + else { + if (projected && selected.length > 0) + reach.reach(`selected-key-representation`) + if (!projected && selected.length > 0) + reach.reach(lane.name) + if ( + selected.some((value) => value.key === 1) && + selected.some((value) => value.key === `1`) + ) + reach.reach(`equal-looking-typed-keys`) + if (expected.length === 0) reach.reach(`empty-result`) + if (previousInvalid) reach.reach(`valid-after-error`) + if ( + prior.some( + (old) => + !selected.some((value) => value.key === old.key), + ) + ) + reach.reach(`omitted-key`) + if ( + selected.some((value) => + prior.some( + (old) => + old.key === value.key && + Object.hasOwn(old, `optional`) && + !Object.hasOwn(value, `optional`), + ), + ) + ) + reach.reach(`omitted-field`) + if ( + selected.some( + (value) => + seen.includes(value.key) && + !prior.some((old) => old.key === value.key), + ) + ) + reach.reach(`reintroduced-key`) + for (const value of selected) { + if (!seen.includes(value.key)) seen.push(value.key) + } + } + previousInvalid = !valid + } + }, + () => collection.cleanup(), ) - expect(isDeepStrictEqual(actual, wanted)).toBe(true) } - }, - async () => { - await collection.cleanup() - }, - ) - } + } + }, + ) + await fc.assert(property, { numRuns: 150, ...parameters }) }, + { fixedSeed: 20261022 }, ) - const seed = process.env.CONVEX_SNAPSHOT_ORACLE_SEED - if (seed) - await fc.assert(property, { - seed: Number(seed), - path: process.env.CONVEX_SNAPSHOT_ORACLE_PATH, - numRuns: 150, - }) - else { - await fc.assert(property, { numRuns: 150, seed: 20261022 }) - await fc.assert(property, { numRuns: 150 }) - } }) 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 08f9ab4885..1516a913f7 100644 --- a/packages/convex-db-collection/tests/values-oracle.property.test.ts +++ b/packages/convex-db-collection/tests/values-oracle.property.test.ts @@ -5,20 +5,23 @@ * scalar types and bytes, so JSON normalization cannot pass as equality. * * Bounded trees combine native leaves, arrays, ordinary fields, and marker-like - * user data. A real OutboxManager writes each transaction, then a fresh manager + * user data. Real Collection insert/update/delete calls create legal mutation + * shapes. Independent before/after trees distinguish their operands; equal + * payload updates still change a revision, and field deletion produces undefined + * changes. A real OutboxManager writes each transaction, then a fresh manager * and Collection restore it with convexMutationCodec. Comparison happens on all * three restored mutation payloads, before any provider call. The core serializer * owner covers wire dispatch and unsupported codecs; this receives that contract * with Convex's actual public scalar encoding. The backend process oracle then - * receives these values on disk and across killed writer/replay processes. + * receives a bounded native subset on disk and across killed writer/replay processes. */ import fc from 'fast-check' -import { createCollection } from '@tanstack/db' +import { createCollection, createTransaction } from '@tanstack/db' import { OutboxManager } from '@tanstack/offline-transactions' import { expect, it } from 'vitest' import { convexMutationCodec } from '../src/offline' import { cleanupOfflineOracle } from '../../offline-transactions/tests/oracle-lifecycle' -import type { PendingMutation } from '@tanstack/db' +import { runOracleCampaign } from '../../db/tests/oracle-campaign' import type { OfflineTransaction, StorageAdapter, @@ -39,7 +42,8 @@ const leaf: fc.Arbitrary = fc.oneof( fc.constant(null), fc.boolean(), fc.string({ maxLength: 8 }), - fc.double(), + fc.double({ noNaN: true, noDefaultInfinity: true }), + fc.constantFrom(NaN, Infinity, -Infinity, -0), fc.bigInt({ min: -(2n ** 63n), max: 2n ** 63n - 1n }), fc.uint8Array({ maxLength: 8 }).map((value) => value.buffer as ArrayBuffer), ) @@ -77,89 +81,216 @@ function observe(value: unknown): unknown { return [`number`, Object.is(value, -0) ? `-0` : String(value)] return [typeof value, String(value)] } -const property = fc.asyncProperty(tree(2), async (payload) => { - type Row = { id: string; payload: Tree } - const make = (id: string) => - createCollection({ - id, - getKey: (row) => row.id, - sync: { sync: ({ markReady }) => markReady() }, - }) - const writer = make(`writer`) - const reader = make(`reader`) - const data = new Map() - const storage: StorageAdapter = { - get: (key) => Promise.resolve(data.get(key) ?? null), - set: (key, value) => { - data.set(key, value) - return Promise.resolve() - }, - delete: (key) => { - data.delete(key) - return Promise.resolve() - }, - keys: () => Promise.resolve([...data.keys()]), - clear: () => { - data.clear() - return Promise.resolve() - }, - } - const row: Row = { id: `one`, payload } - const transaction: OfflineTransaction = { - id: `durable`, - mutationFnName: `save`, - idempotencyKey: `same`, - createdAt: new Date(0), - retryCount: 0, - nextAttemptAt: 0, - version: 1, - keys: [`one`], - mutations: [ - { - globalKey: `one`, - type: `update`, - modified: row, - original: row, - changes: { payload }, - collection: writer, - } as unknown as PendingMutation, - ], - } - let hasPrimaryFailure = false - try { - await new OutboxManager(storage, { rows: writer }, convexMutationCodec).add( - transaction, - ) - const restored = await new OutboxManager( - storage, - { rows: reader }, - convexMutationCodec, - ).get(`durable`) - expect(observe(restored!.mutations[0]!.modified)).toEqual(observe(row)) - expect(observe(restored!.mutations[0]!.original)).toEqual(observe(row)) - expect(observe(restored!.mutations[0]!.changes)).toEqual( - observe({ payload }), - ) - } catch (error) { - hasPrimaryFailure = true - throw error - } finally { - await cleanupOfflineOracle( - [() => writer.cleanup(), () => reader.cleanup()], - hasPrimaryFailure, - ) - } -}) it(`preserves the Convex mutation value domain across durable serialization`, async () => { - const seed = process.env.CONVEX_VALUES_ORACLE_SEED - if (seed) - await fc.assert(property, { - seed: Number(seed), - path: process.env.CONVEX_VALUES_ORACLE_PATH, - numRuns: 100, - }) - else { - await fc.assert(property, { seed: 20261022, numRuns: 100 }) - await fc.assert(property, { numRuns: 100 }) - } + // Every campaign must actually reach these semantic distinctions. Replay + // checks its requested history directly rather than requiring all categories. + await runOracleCampaign( + `CONVEX_VALUES_ORACLE`, + [ + `insert`, + `update`, + `delete`, + `different-operands`, + `equal-payload-update`, + `changed-payload-update`, + `removed-field-update`, + `int64`, + `special-float`, + `binary`, + `nested-container`, + `undefined`, + ], + async (reach, parameters) => { + const property = fc.asyncProperty( + fc.record({ + kind: fc.constantFrom( + `insert` as const, + `update` as const, + `delete` as const, + ), + before: tree(2), + after: tree(2), + samePayload: fc.boolean(), + removeField: fc.boolean(), + }), + async ({ kind, before, after, samePayload, removeField }) => { + reach.beginHistory() + type Row = { + id: string + revision: number + payload: Tree + removable?: Tree + } + const original: Row = { + id: `one`, + revision: 0, + payload: before, + removable: before, + } + const modified: Row = { + id: `one`, + revision: 1, + payload: samePayload ? before : after, + ...(removeField ? {} : { removable: before }), + } + const writer = createCollection({ + id: `writer`, + getKey: (row) => row.id, + startSync: true, + sync: { + sync: ({ begin, write, commit, markReady }) => { + begin() + if (kind !== `insert`) + write({ type: `insert`, value: original }) + commit() + markReady() + }, + }, + }) + const reader = createCollection({ + id: `reader`, + getKey: (row) => row.id, + sync: { sync: ({ markReady }) => markReady() }, + }) + const data = new Map() + const storage: StorageAdapter = { + get: (key) => Promise.resolve(data.get(key) ?? null), + set: (key, value) => { + data.set(key, value) + return Promise.resolve() + }, + delete: (key) => { + data.delete(key) + return Promise.resolve() + }, + keys: () => Promise.resolve([...data.keys()]), + clear: () => { + data.clear() + return Promise.resolve() + }, + } + const local = createTransaction({ + autoCommit: false, + mutationFn: async () => {}, + }) + const rollback = local.isPersisted.promise.catch(() => undefined) + let hasPrimaryFailure = false + try { + local.mutate(() => { + if (kind === `insert`) writer.insert(modified) + else if (kind === `delete`) writer.delete(`one`) + else + writer.update(`one`, (draft) => { + draft.revision = modified.revision + draft.payload = modified.payload + if (removeField) delete draft.removable + }) + }) + expect(local.mutations).toHaveLength(1) + const mutation = local.mutations[0]! + const expectedOriginal = kind === `insert` ? {} : original + // Snapshot before the writer: its input aliases the seeded row. + // In-place corruption must not rewrite the expected original too. + const expectedOriginalObservation = observe(expectedOriginal) + // Core owns full-row/changes composition, including its undefined + // removal markers. Preserve those supplied operands exactly rather + // than specifying core's representation again in this codec owner. + const expectedModified = observe(mutation.modified) + const expectedChanges = observe(mutation.changes) + expect(mutation.modified.revision).toBe(kind === `delete` ? 0 : 1) + expect(observe(mutation.modified.payload)).toEqual( + observe(kind === `delete` ? original.payload : modified.payload), + ) + expect(observe(mutation.original)).toEqual( + observe(expectedOriginal), + ) + reach.reach(kind) + if (kind !== `delete`) reach.reach(`different-operands`) + if (kind === `update` && samePayload) + reach.reach(`equal-payload-update`) + if ( + kind === `update` && + JSON.stringify(observe(before)) !== + JSON.stringify(observe(modified.payload)) + ) + reach.reach(`changed-payload-update`) + if (kind === `update` && removeField) { + expect(Object.hasOwn(mutation.changes, `removable`)).toBe(true) + expect(mutation.changes.removable).toBeUndefined() + reach.reach(`removed-field-update`) + } + const recordNative = (value: unknown, nested = false): void => { + if (typeof value === `bigint`) reach.reach(`int64`) + else if (value === undefined) reach.reach(`undefined`) + else if ( + typeof value === `number` && + (!Number.isFinite(value) || Object.is(value, -0)) + ) + reach.reach(`special-float`) + else if (value instanceof ArrayBuffer) reach.reach(`binary`) + else if (value !== null && typeof value === `object`) { + if (nested) reach.reach(`nested-container`) + Object.values(value).forEach((field) => + recordNative(field, true), + ) + } + } + recordNative(mutation.original) + recordNative(mutation.modified) + recordNative(mutation.changes) + const transaction: OfflineTransaction = { + id: `durable`, + mutationFnName: `save`, + idempotencyKey: `same`, + createdAt: new Date(0), + retryCount: 0, + nextAttemptAt: 0, + version: 1, + keys: [mutation.globalKey], + mutations: local.mutations, + } + await new OutboxManager( + storage, + { rows: writer }, + convexMutationCodec, + ).add(transaction) + const restored = await new OutboxManager( + storage, + { rows: reader }, + convexMutationCodec, + ).get(`durable`) + expect( + observe(restored!.mutations[0]!.modified), + `modified operand`, + ).toEqual(expectedModified) + expect( + observe(restored!.mutations[0]!.original), + `original operand`, + ).toEqual(expectedOriginalObservation) + expect( + observe(restored!.mutations[0]!.changes), + `changes operand`, + ).toEqual(expectedChanges) + } catch (error) { + hasPrimaryFailure = true + throw error + } finally { + await cleanupOfflineOracle( + [ + () => { + local.rollback() + }, + () => rollback, + () => writer.cleanup(), + () => reader.cleanup(), + ], + hasPrimaryFailure, + ) + } + }, + ) + await fc.assert(property, { numRuns: 100, ...parameters }) + }, + { fixedSeed: 20261022 }, + ) }) diff --git a/packages/db/tests/oracle-campaign.test.ts b/packages/db/tests/oracle-campaign.test.ts new file mode 100644 index 0000000000..1a3aa01dfa --- /dev/null +++ b/packages/db/tests/oracle-campaign.test.ts @@ -0,0 +1,51 @@ +import { afterEach, expect, it, vi } from 'vitest' +import { runOracleCampaign } from './oracle-campaign' + +afterEach(() => { + vi.unstubAllEnvs() + vi.restoreAllMocks() +}) + +it(`does not let fixed campaign reach conceal an unreached random state`, async () => { + vi.spyOn(console, `info`).mockImplementation(() => {}) + let campaigns = 0 + await expect( + runOracleCampaign(`TEST_ORACLE`, [`required`], (reach, parameters) => { + campaigns++ + reach.beginHistory() + if (parameters.seed !== undefined) reach.reach(`required`) + }), + ).rejects.toThrow(/required \(0\)/) + expect(campaigns).toBe(2) +}) + +it(`replays the requested seed and path without demanding unrelated states`, async () => { + vi.spyOn(console, `info`).mockImplementation(() => {}) + vi.stubEnv(`TEST_ORACLE_SEED`, `0`) + vi.stubEnv(`TEST_ORACLE_PATH`, `2:0`) + const parameters: Array<{ seed?: number; path?: string; numRuns?: number }> = + [] + await runOracleCampaign(`TEST_ORACLE`, [`unrelated`], (reach, value) => { + parameters.push(value) + reach.beginHistory() + }) + expect(parameters).toEqual([{ seed: 0, path: `2:0`, numRuns: 1 }]) +}) + +it(`keeps package reach enforcement independent of a DB replay selector`, async () => { + vi.stubEnv(`TANSTACK_DB_ORACLE_SEED`, `1`) + await expect( + runOracleCampaign(`TEST_ORACLE`, [`required`], (reach) => { + reach.beginHistory() + }), + ).rejects.toThrow(/required \(0\)/) +}) + +it(`rejects a shrink path without a seed before starting a campaign`, async () => { + vi.stubEnv(`TEST_ORACLE_PATH`, `0`) + const run = vi.fn() + await expect( + runOracleCampaign(`TEST_ORACLE`, [`state`], run), + ).rejects.toThrow(/requires a seed/) + expect(run).not.toHaveBeenCalled() +}) diff --git a/packages/db/tests/oracle-campaign.ts b/packages/db/tests/oracle-campaign.ts new file mode 100644 index 0000000000..a6a4eaba05 --- /dev/null +++ b/packages/db/tests/oracle-campaign.ts @@ -0,0 +1,63 @@ +import { createOracleReach } from './oracle-reach' + +/** + * Run the same oracle with separate fixed/random reach ledgers. The callback + * owns its property, grammar, recorder, comparison, and run budget; only seed + * selection changes between the two calls. Counts are histories, not events: + * call beginHistory once and reach only after observing the named checkpoint. + * A direct prefix SEED/PATH replay runs immediately without campaign minima. + * This is runner support, not an independent product judgment. + */ +export async function runOracleCampaign( + prefix: string, + states: ReadonlyArray, + run: ( + reach: ReturnType>, + parameters: { seed?: number; path?: string; numRuns?: number }, + ) => void | Promise, + { fixedSeed = 20261022 }: { fixedSeed?: number } = {}, +): Promise { + const seedText = process.env[`${prefix}_SEED`] + const path = process.env[`${prefix}_PATH`] + const seed = seedText === undefined ? undefined : Number(seedText) + if ( + seedText !== undefined && + (seedText.trim() === `` || !Number.isSafeInteger(seed)) + ) { + throw new Error(`${prefix}_SEED must be an integer`) + } + if ( + path !== undefined && + (seed === undefined || !/^\d+(?::\d+)*$/.test(path)) + ) + throw new Error(`${prefix}_PATH requires a seed and numeric shrink path`) + + const campaigns = + seed === undefined + ? [ + { name: `fixed`, parameters: { seed: fixedSeed } }, + { name: `random`, parameters: {} }, + ] + : [ + { + name: `replay`, + parameters: + path === undefined ? { seed } : { seed, path, numRuns: 1 }, + }, + ] + for (const campaign of campaigns) { + const reach = createOracleReach(prefix, states, { + replaying: seed !== undefined, + }) + await run(reach, campaign.parameters) + reach.assertReached() + console.info( + `ORACLE_REACH ${JSON.stringify({ + property: prefix, + campaign: campaign.name, + minimum: seed === undefined ? 1 : null, + counts: Object.fromEntries(reach.counts()), + })}`, + ) + } +} diff --git a/packages/db/tests/oracle-reach.ts b/packages/db/tests/oracle-reach.ts index 9fa10c0cf3..b6ec08295a 100644 --- a/packages/db/tests/oracle-reach.ts +++ b/packages/db/tests/oracle-reach.ts @@ -20,12 +20,21 @@ import { readOracleRunConfig } from './oracle-config.js' export function createOracleReach( property: string, states: ReadonlyArray, - { minimum = 1 }: { minimum?: number } = {}, + { + minimum = 1, + replaying: replayOverride, + }: { minimum?: number; replaying?: boolean } = {}, ) { const counts = new Map(states.map((state) => [state, 0])) let current = new Set() - const { replaySeed, replayPath } = readOracleRunConfig() - const replaying = replaySeed !== undefined || replayPath !== undefined + // Other packages use their own replay prefixes. An explicit campaign choice + // must not inherit an unrelated DB replay selector from the environment. + const replaying = + replayOverride ?? + (() => { + const { replaySeed, replayPath } = readOracleRunConfig() + return replaySeed !== undefined || replayPath !== undefined + })() return { /** Starts one generated history. */ beginHistory(): void { diff --git a/packages/offline-transactions/package.json b/packages/offline-transactions/package.json index 4261160411..cbbed40509 100644 --- a/packages/offline-transactions/package.json +++ b/packages/offline-transactions/package.json @@ -23,7 +23,7 @@ "dev": "vite build --watch", "test": "vitest", "test:watch": "vitest --watch", - "typecheck": "tsc --noEmit", + "typecheck": "tsc --noEmit --project tsconfig.test.json", "lint": "eslint src" }, "type": "module", 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 26655204cb..213746bdd9 100644 --- a/packages/offline-transactions/tests/transaction-serializer-oracle.property.test.ts +++ b/packages/offline-transactions/tests/transaction-serializer-oracle.property.test.ts @@ -1,3 +1,4 @@ +import { types as nativeTypes } from 'node:util' import { createCollection, createTransaction } from '@tanstack/db' import fc from 'fast-check' import { expect, it, vi } from 'vitest' @@ -6,6 +7,7 @@ import { MissingTemporalConstructorError, TransactionSerializer, } from '../src/outbox/TransactionSerializer' +import { runOracleCampaign } from '../../db/tests/oracle-campaign' import { cleanupOfflineOracle } from './oracle-lifecycle' import { FakeStorageAdapter } from './harness' import { readOfflineOracleConfig } from './oracle-config' @@ -1601,9 +1603,11 @@ it.each( * Missing, mismatched, or failing codecs reject reads without changing storage. * * The supplied fixture codec uses Node's V8 structured serialization. Expected - * runtime values come directly from generated native leaves, not a reimplementation - * of production dispatch or codec output. Histories cross insert/update/delete, - * native values nested in all three mutation payloads, fresh readers, and + * runtime values come directly from independently generated before/after native + * leaves, not production dispatch or codec output. Real Collection operations + * construct legal insert/update/delete operands, including equal-payload updates + * and removed fields. Declared reach checks these distinctions and each native + * scalar class per fixed/random campaign. Histories also cross fresh readers and * unsupported reader configurations. Existing v3 histories remain unchanged. * The Convex receiving witness supplies its real public value codec and tests * disk/process replay against the actual backend. @@ -1621,136 +1625,280 @@ it(`preserves opt-in mutation values and rejects unreadable codec records withou fc.constantFrom(NaN, Infinity, -Infinity, -0), fc.uint8Array({ maxLength: 12 }).map((bytes) => bytes.buffer), ) - const property = fc.asyncProperty( - fc.array(native, { minLength: 1, maxLength: 5 }), - fc.constantFrom(`insert`, `update`, `delete`), - async (values, kind) => { - type CodecRow = { id: string; values: Array } - const makeCollection = (id: string) => - createCollection({ - id, - getKey: (row) => row.id, - sync: { sync: ({ markReady }) => markReady() }, - }) - const writer = makeCollection(`codec-writer`) - const reader = makeCollection(`codec-reader`) - const row: CodecRow = { id: `one`, values } - const transaction: OfflineTransaction = { - id: `codec-transaction`, - mutationFnName: `save`, - keys: [`one`], - idempotencyKey: `stable`, - createdAt: new Date(0), - retryCount: 0, - nextAttemptAt: 0, - version: 1, - metadata: { when: new Date(0) }, - mutations: [ - { - globalKey: `one`, - type: kind, - modified: row, - original: row, - changes: { values }, - collection: writer, - } as unknown as PendingMutation, - ], - } - const storage = new FakeStorageAdapter() - let hasPrimaryFailure = false - try { - await new OutboxManager(storage, { rows: writer }, codec).add( - transaction, - ) - const before = storage.snapshot() - const restored = await new OutboxManager( - storage, - { rows: reader }, - codec, - ).get(`codec-transaction`) - expect(restored?.mutations[0]?.modified).toEqual(row) - expect(restored?.mutations[0]?.original).toEqual(row) - expect(restored?.mutations[0]?.changes).toEqual({ values }) - expect(restored?.mutations[0]?.collection).toBe(reader) - expect(restored?.metadata).toEqual({ when: new Date(0) }) - const invalidEncoder = { - ...codec, - encode: () => null, - } as unknown as typeof codec - await expect( - new OutboxManager(storage, { rows: writer }, invalidEncoder).add( - transaction, - ), - ).rejects.toThrow(`codec`) - expect(storage.snapshot()).toEqual(before) - const wire = JSON.parse(Object.values(before)[0]!) - expect(wire.valueEncoding).toBe(4) - expect(wire.mutationEncoding).toBe(codec.id) - for (const unsupported of [ - undefined, - { ...codec, id: `other` }, - { - ...codec, - decode: () => { - throw new Error(`broken codec`) - }, - }, - ]) { - const outbox = new OutboxManager( - storage, - { rows: reader }, - unsupported, - ) - await expect(outbox.get(`codec-transaction`)).rejects.toThrow(`codec`) - await expect(outbox.getAll()).rejects.toThrow(`codec`) - expect(storage.snapshot()).toEqual(before) - } - const plain = { id: `one`, values: [] } - const legacy = { - ...transaction, - id: `legacy-transaction`, - mutations: [ - { - ...transaction.mutations[0]!, - modified: plain, - original: plain, - changes: { values: [] }, - }, - ], - } - await new OutboxManager(storage, { rows: writer }).add(legacy) - const mixed = await new OutboxManager( - storage, - { rows: reader }, - codec, - ).getAll() - expect(mixed).toHaveLength(2) - expect( - mixed.find((entry) => entry.id === legacy.id)?.mutations[0]?.modified, - ).toEqual(plain) - } catch (error) { - hasPrimaryFailure = true - throw error - } finally { - await cleanupOfflineOracle( - [() => writer.cleanup(), () => reader.cleanup()], - hasPrimaryFailure, - ) - } - }, - ) + // An explicit type/field projection preserves own undefined fields and bytes + // without depending on V8's object prototypes or serializer wire tags. Node's + // native ArrayBuffer brand check also recognizes V8's restored realm. + const observe = (value: unknown): unknown => { + if (nativeTypes.isArrayBuffer(value)) + return [`bytes`, [...new Uint8Array(value)]] + if (Array.isArray(value)) return [`array`, value.map(observe)] + if (value !== null && typeof value === `object`) + return [ + `object`, + Object.entries(value) + .map(([key, field]) => [key, observe(field)]) + .sort((a, b) => String(a[0]).localeCompare(String(b[0]))), + ] + if (typeof value === `number`) + return [`number`, Object.is(value, -0) ? `-0` : String(value)] + return [typeof value, String(value)] + } const config = readOfflineOracleConfig({ prefix: `OFFLINE_CODEC_ORACLE`, defaultRuns: 60, }) - if (config.seed !== undefined) - await fc.assert(property, { - numRuns: config.runs, - seed: config.seed, - path: config.path, - }) - else { - await fc.assert(property, { numRuns: config.runs, seed: 20261022 }) - await fc.assert(property, { numRuns: config.runs }) - } + await runOracleCampaign( + `OFFLINE_CODEC_ORACLE`, + [ + `insert`, + `update`, + `delete`, + `different-operands`, + `equal-payload-update`, + `changed-payload-update`, + `removed-field-update`, + `int64`, + `special-float`, + `binary`, + ], + async (reach, parameters) => { + const values = fc.array(native, { minLength: 1, maxLength: 5 }) + const property = fc.asyncProperty( + fc.record({ + kind: fc.constantFrom( + `insert` as const, + `update` as const, + `delete` as const, + ), + beforeValues: values, + afterValues: values, + samePayload: fc.boolean(), + removeField: fc.boolean(), + }), + async ({ + kind, + beforeValues, + afterValues, + samePayload, + removeField, + }) => { + reach.beginHistory() + type CodecRow = { + id: string + revision: number + values: Array + removable?: unknown + } + const original: CodecRow = { + id: `one`, + revision: 0, + values: beforeValues, + removable: beforeValues[0], + } + const modified: CodecRow = { + id: `one`, + revision: 1, + values: samePayload ? beforeValues : afterValues, + ...(removeField ? {} : { removable: beforeValues[0] }), + } + const writer = createCollection({ + id: `codec-writer`, + getKey: (row) => row.id, + startSync: true, + sync: { + sync: ({ begin, write, commit, markReady }) => { + begin() + if (kind !== `insert`) + write({ type: `insert`, value: original }) + commit() + markReady() + }, + }, + }) + const reader = createCollection({ + id: `codec-reader`, + getKey: (row) => row.id, + sync: { sync: ({ markReady }) => markReady() }, + }) + const local = createTransaction({ + autoCommit: false, + mutationFn: async () => {}, + }) + const rollback = local.isPersisted.promise.catch(() => undefined) + const storage = new FakeStorageAdapter() + let hasPrimaryFailure = false + try { + // Core constructs legal CRUD operands. Different authored trees + // prevent one operand from masquerading as another; revision also + // keeps equal-payload updates observable. The serializer owns + // preservation, while core owns changes composition. + local.mutate(() => { + if (kind === `insert`) writer.insert(modified) + else if (kind === `delete`) writer.delete(`one`) + else + writer.update(`one`, (draft) => { + draft.revision = modified.revision + draft.values = modified.values + if (removeField) delete draft.removable + }) + }) + expect(local.mutations).toHaveLength(1) + const mutation = local.mutations[0]! + const expectedOriginal = kind === `insert` ? {} : original + // Snapshot before the writer: its input aliases the seeded row. + // In-place corruption must not rewrite the expected original too. + const expectedOriginalObservation = observe(expectedOriginal) + // Core owns its full-row and removed-field representation; this + // owner preserves that complete supplied operand at the codec cut. + const expectedModified = observe(mutation.modified) + const expectedChanges = observe(mutation.changes) + expect(mutation.original).toEqual(expectedOriginal) + expect(mutation.modified.revision).toBe(kind === `delete` ? 0 : 1) + expect(observe(mutation.modified.values)).toEqual( + observe(kind === `delete` ? original.values : modified.values), + ) + reach.reach(kind) + if (kind !== `delete`) reach.reach(`different-operands`) + if (kind === `update` && samePayload) + reach.reach(`equal-payload-update`) + if ( + kind === `update` && + JSON.stringify(observe(beforeValues)) !== + JSON.stringify(observe(modified.values)) + ) + reach.reach(`changed-payload-update`) + if (kind === `update` && removeField) { + expect(Object.hasOwn(mutation.changes, `removable`)).toBe(true) + expect(mutation.changes.removable).toBeUndefined() + reach.reach(`removed-field-update`) + } + const recordNative = (value: unknown): void => { + if (typeof value === `bigint`) reach.reach(`int64`) + else if ( + typeof value === `number` && + (!Number.isFinite(value) || Object.is(value, -0)) + ) + reach.reach(`special-float`) + else if (nativeTypes.isArrayBuffer(value)) reach.reach(`binary`) + else if (value !== null && typeof value === `object`) + Object.values(value).forEach(recordNative) + } + recordNative(mutation.original) + recordNative(mutation.modified) + recordNative(mutation.changes) + const transaction: OfflineTransaction = { + id: `codec-transaction`, + mutationFnName: `save`, + keys: [mutation.globalKey], + idempotencyKey: `stable`, + createdAt: new Date(0), + retryCount: 0, + nextAttemptAt: 0, + version: 1, + metadata: { when: new Date(0) }, + mutations: local.mutations, + } + await new OutboxManager(storage, { rows: writer }, codec).add( + transaction, + ) + const before = storage.snapshot() + const restored = await new OutboxManager( + storage, + { rows: reader }, + codec, + ).get(`codec-transaction`) + expect( + observe(restored?.mutations[0]?.modified), + `modified operand`, + ).toEqual(expectedModified) + expect( + observe(restored?.mutations[0]?.original), + `original operand`, + ).toEqual(expectedOriginalObservation) + expect( + observe(restored?.mutations[0]?.changes), + `changes operand`, + ).toEqual(expectedChanges) + expect(restored?.mutations[0]?.collection).toBe(reader) + expect(restored?.metadata).toEqual({ when: new Date(0) }) + const invalidEncoder = { + ...codec, + encode: () => null, + } as unknown as typeof codec + await expect( + new OutboxManager(storage, { rows: writer }, invalidEncoder).add( + transaction, + ), + ).rejects.toThrow(`codec`) + expect(storage.snapshot()).toEqual(before) + const wire = JSON.parse(Object.values(before)[0]!) + expect(wire.valueEncoding).toBe(4) + expect(wire.mutationEncoding).toBe(codec.id) + for (const unsupported of [ + undefined, + { ...codec, id: `other` }, + { + ...codec, + decode: () => { + throw new Error(`broken codec`) + }, + }, + ]) { + const outbox = new OutboxManager( + storage, + { rows: reader }, + unsupported, + ) + await expect(outbox.get(`codec-transaction`)).rejects.toThrow( + `codec`, + ) + await expect(outbox.getAll()).rejects.toThrow(`codec`) + expect(storage.snapshot()).toEqual(before) + } + const plain = { id: `one`, revision: 0, values: [] } + const legacy = { + ...transaction, + id: `legacy-transaction`, + mutations: [ + { + ...transaction.mutations[0]!, + modified: plain, + original: plain, + changes: { values: [] }, + }, + ], + } + await new OutboxManager(storage, { rows: writer }).add(legacy) + const mixed = await new OutboxManager( + storage, + { rows: reader }, + codec, + ).getAll() + expect(mixed).toHaveLength(2) + expect( + mixed.find((entry) => entry.id === legacy.id)?.mutations[0] + ?.modified, + ).toEqual(plain) + } catch (error) { + hasPrimaryFailure = true + throw error + } finally { + await cleanupOfflineOracle( + [ + () => { + local.rollback() + }, + () => rollback, + () => writer.cleanup(), + () => reader.cleanup(), + ], + hasPrimaryFailure, + ) + } + }, + ) + await fc.assert(property, { numRuns: config.runs, ...parameters }) + }, + { fixedSeed: 20261022 }, + ) }) diff --git a/packages/offline-transactions/tsconfig.test.json b/packages/offline-transactions/tsconfig.test.json new file mode 100644 index 0000000000..320fefa0eb --- /dev/null +++ b/packages/offline-transactions/tsconfig.test.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": "../.." + } +} diff --git a/packages/offline-transactions/vite.config.ts b/packages/offline-transactions/vite.config.ts index 87883e4ced..3dc3529122 100644 --- a/packages/offline-transactions/vite.config.ts +++ b/packages/offline-transactions/vite.config.ts @@ -8,7 +8,7 @@ const config = defineConfig({ dir: `./tests`, environment: `jsdom`, coverage: { enabled: true, provider: `istanbul`, include: [`src/**/*`] }, - typecheck: { enabled: true }, + typecheck: { enabled: true, tsconfig: `./tsconfig.test.json` }, }, }) From 8c24922026fb4a714cb54b992ac85faa2e876742 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Mon, 26 Oct 2026 17:06:30 -0600 Subject: [PATCH 15/20] docs: record exact-source Convex oracle repair evidence --- .../2026-10-26-convex-oracle-reach.json | 495 ++++++++++++++++++ .../2026-10-26-convex-oracle-repairs.md | 156 ++++++ .../2026-10-26-convex-oracle-source.json | 26 + 3 files changed, 677 insertions(+) create mode 100644 docs/contributing/oracle-reviews/2026-10-26-convex-oracle-reach.json create mode 100644 docs/contributing/oracle-reviews/2026-10-26-convex-oracle-repairs.md create mode 100644 docs/contributing/oracle-reviews/2026-10-26-convex-oracle-source.json diff --git a/docs/contributing/oracle-reviews/2026-10-26-convex-oracle-reach.json b/docs/contributing/oracle-reviews/2026-10-26-convex-oracle-reach.json new file mode 100644 index 0000000000..364288b02a --- /dev/null +++ b/docs/contributing/oracle-reviews/2026-10-26-convex-oracle-reach.json @@ -0,0 +1,495 @@ +{ + "reviewParent": "de7cf8de13f14dfa0d4ce6676452eb231ef215e2", + "minimumPerNormalLane": 1, + "reports": [ + { + "log": "adapter-final.log", + "property": "CONVEX_VALUES_ORACLE", + "campaign": "fixed", + "minimum": 1, + "counts": { + "insert": 35, + "update": 31, + "delete": 34, + "different-operands": 66, + "equal-payload-update": 21, + "changed-payload-update": 10, + "removed-field-update": 14, + "int64": 14, + "special-float": 19, + "binary": 19, + "nested-container": 70, + "undefined": 39 + } + }, + { + "log": "adapter-final.log", + "property": "CONVEX_OFFLINE_ORACLE", + "campaign": "fixed", + "minimum": 1, + "counts": { + "none": 60, + "args": 60, + "mutation": 60, + "sync": 60 + } + }, + { + "log": "adapter-final.log", + "property": "CONVEX_OFFLINE_ORACLE", + "campaign": "random", + "minimum": 1, + "counts": { + "none": 60, + "args": 60, + "mutation": 60, + "sync": 60 + } + }, + { + "log": "adapter-final.log", + "property": "CONVEX_VALUES_ORACLE", + "campaign": "random", + "minimum": 1, + "counts": { + "insert": 39, + "update": 26, + "delete": 35, + "different-operands": 65, + "equal-payload-update": 13, + "changed-payload-update": 12, + "removed-field-update": 15, + "int64": 22, + "special-float": 22, + "binary": 21, + "nested-container": 68, + "undefined": 29 + } + }, + { + "log": "adapter-final.log", + "property": "CONVEX_OFFLINE_SOURCE_ORACLE", + "campaign": "fixed", + "minimum": 1, + "counts": { + "idle": 60, + "pending": 60, + "startup-error": 60, + "empty": 60, + "rows": 60, + "error": 60, + "cleanup": 60, + "provider-recovery": 60 + } + }, + { + "log": "adapter-final.log", + "property": "CONVEX_PAGINATION_ROWS", + "campaign": "fixed", + "minimum": 1, + "counts": { + "complete-nonempty-snapshot": 60, + "complete-empty-snapshot": 60, + "required-split": 60, + "recommended-split": 60, + "pending-range-with-prior-snapshot": 60, + "public-snapshot-publication": 60 + } + }, + { + "log": "adapter-final.log", + "property": "CONVEX_OFFLINE_SOURCE_ORACLE", + "campaign": "random", + "minimum": 1, + "counts": { + "idle": 60, + "pending": 60, + "startup-error": 60, + "empty": 60, + "rows": 60, + "error": 60, + "cleanup": 60, + "provider-recovery": 60 + } + }, + { + "log": "adapter-final.log", + "property": "CONVEX_PAGINATION_ROWS", + "campaign": "random", + "minimum": 1, + "counts": { + "complete-nonempty-snapshot": 60, + "complete-empty-snapshot": 60, + "required-split": 60, + "recommended-split": 60, + "pending-range-with-prior-snapshot": 60, + "public-snapshot-publication": 60 + } + }, + { + "log": "adapter-final.log", + "property": "CONVEX_PAGINATION_WORK", + "campaign": "fixed", + "minimum": 1, + "counts": { + "initial-multi-range-exhaustion": 60 + } + }, + { + "log": "adapter-final.log", + "property": "CONVEX_PAGINATION_WORK", + "campaign": "random", + "minimum": 1, + "counts": { + "initial-multi-range-exhaustion": 60 + } + }, + { + "log": "adapter-final.log", + "property": "CONVEX_PAGINATION_BOUND", + "campaign": "fixed", + "minimum": 1, + "counts": { + "accepted-at-cap": 60, + "overflow-preserves-prior": 60, + "recovered-after-shrink": 60 + } + }, + { + "log": "adapter-final.log", + "property": "CONVEX_PAGINATION_BOUND", + "campaign": "random", + "minimum": 1, + "counts": { + "accepted-at-cap": 60, + "overflow-preserves-prior": 60, + "recovered-after-shrink": 60 + } + }, + { + "log": "adapter-final.log", + "property": "CONVEX_OFFLINE_ACCEPTANCE_ORACLE", + "campaign": "fixed", + "minimum": 1, + "counts": { + "first-empty": 60, + "first-rows": 60, + "accepted-empty-insert": 60, + "accepted-row-insert": 60, + "accepted-row-update": 60, + "accepted-row-delete": 60, + "accepted-row-replacement": 60, + "unchanged-cache-retry": 60, + "changed-cache-retry": 60, + "restart-recovery": 60 + } + }, + { + "log": "adapter-final.log", + "property": "CONVEX_OWNERSHIP_ORACLE", + "campaign": "fixed", + "minimum": 1, + "counts": { + "direct:idle": 60, + "direct:ready": 60, + "direct:cleaned-up": 60, + "shallow:idle": 60, + "shallow:ready": 60, + "shallow:cleaned-up": 60, + "fresh:idle": 60, + "fresh:ready": 60, + "fresh:cleaned-up": 60, + "descriptor:idle": 60, + "descriptor:ready": 60, + "descriptor:cleaned-up": 60, + "independent-confirmation": 60, + "rightful-after-rejected-reuse": 60, + "rightful-after-sibling-cleanup": 60 + } + }, + { + "log": "adapter-final.log", + "property": "CONVEX_OWNERSHIP_ORACLE", + "campaign": "random", + "minimum": 1, + "counts": { + "direct:idle": 60, + "direct:ready": 60, + "direct:cleaned-up": 60, + "shallow:idle": 60, + "shallow:ready": 60, + "shallow:cleaned-up": 60, + "fresh:idle": 60, + "fresh:ready": 60, + "fresh:cleaned-up": 60, + "descriptor:idle": 60, + "descriptor:ready": 60, + "descriptor:cleaned-up": 60, + "independent-confirmation": 60, + "rightful-after-rejected-reuse": 60, + "rightful-after-sibling-cleanup": 60 + } + }, + { + "log": "adapter-final.log", + "property": "CONVEX_OFFLINE_ACCEPTANCE_ORACLE", + "campaign": "random", + "minimum": 1, + "counts": { + "first-empty": 60, + "first-rows": 60, + "accepted-empty-insert": 60, + "accepted-row-insert": 60, + "accepted-row-update": 60, + "accepted-row-delete": 60, + "accepted-row-replacement": 60, + "unchanged-cache-retry": 60, + "changed-cache-retry": 60, + "restart-recovery": 60 + } + }, + { + "log": "adapter-final.log", + "property": "CONVEX_SNAPSHOT_ORACLE", + "campaign": "fixed", + "minimum": 1, + "counts": { + "string-keys": 150, + "numeric-keys": 150, + "mixed-key-types": 150, + "equal-looking-typed-keys": 150, + "selected-key-representation": 150, + "omitted-key": 150, + "omitted-field": 150, + "empty-result": 150, + "reintroduced-key": 150, + "duplicate-rejected": 150, + "valid-after-error": 150, + "initial-valid": 150, + "initial-duplicate-rejected": 150 + } + }, + { + "log": "adapter-final.log", + "property": "CONVEX_SNAPSHOT_ORACLE", + "campaign": "random", + "minimum": 1, + "counts": { + "string-keys": 150, + "numeric-keys": 150, + "mixed-key-types": 150, + "equal-looking-typed-keys": 150, + "selected-key-representation": 150, + "omitted-key": 150, + "omitted-field": 150, + "empty-result": 150, + "reintroduced-key": 150, + "duplicate-rejected": 150, + "valid-after-error": 150, + "initial-valid": 150, + "initial-duplicate-rejected": 150 + } + }, + { + "log": "offline-final.log", + "property": "OFFLINE_CODEC_ORACLE", + "campaign": "fixed", + "minimum": 1, + "counts": { + "insert": 19, + "update": 19, + "delete": 22, + "different-operands": 38, + "equal-payload-update": 6, + "changed-payload-update": 13, + "removed-field-update": 9, + "int64": 35, + "special-float": 46, + "binary": 41 + } + }, + { + "log": "offline-final.log", + "property": "OFFLINE_CODEC_ORACLE", + "campaign": "random", + "minimum": 1, + "counts": { + "insert": 17, + "update": 19, + "delete": 24, + "different-operands": 36, + "equal-payload-update": 9, + "changed-payload-update": 10, + "removed-field-update": 9, + "int64": 44, + "special-float": 35, + "binary": 45 + } + }, + { + "log": "backend-e2e-repaired.log", + "property": "CONVEX_BACKEND_INSERT_ORACLE", + "campaign": "fixed", + "minimum": 1, + "counts": { + "disk-admission": 6, + "replay": 6, + "known-acknowledgement": 3, + "uncertain-acknowledgement": 3, + "payload-int64": 2, + "payload-special-float": 2, + "payload-bytes": 2 + } + }, + { + "log": "backend-e2e-repaired.log", + "property": "CONVEX_BACKEND_INSERT_ORACLE", + "campaign": "random", + "minimum": 1, + "counts": { + "disk-admission": 6, + "replay": 6, + "known-acknowledgement": 3, + "uncertain-acknowledgement": 3, + "payload-int64": 2, + "payload-special-float": 2, + "payload-bytes": 2 + } + }, + { + "log": "backend-e2e-repaired.log", + "property": "CONVEX_BACKEND_UPDATE_ORACLE", + "campaign": "fixed", + "minimum": 1, + "counts": { + "disk-admission": 6, + "replay": 6, + "known-acknowledgement": 3, + "uncertain-acknowledgement": 3, + "payload-int64": 2, + "payload-special-float": 2, + "payload-bytes": 2 + } + }, + { + "log": "backend-e2e-repaired.log", + "property": "CONVEX_BACKEND_UPDATE_ORACLE", + "campaign": "random", + "minimum": 1, + "counts": { + "disk-admission": 6, + "replay": 6, + "known-acknowledgement": 3, + "uncertain-acknowledgement": 3, + "payload-int64": 2, + "payload-special-float": 2, + "payload-bytes": 2 + } + }, + { + "log": "backend-e2e-repaired.log", + "property": "CONVEX_BACKEND_DELETE_ORACLE", + "campaign": "fixed", + "minimum": 1, + "counts": { + "disk-admission": 6, + "replay": 6, + "known-acknowledgement": 3, + "uncertain-acknowledgement": 3 + } + }, + { + "log": "backend-e2e-repaired.log", + "property": "CONVEX_BACKEND_DELETE_ORACLE", + "campaign": "random", + "minimum": 1, + "counts": { + "disk-admission": 6, + "replay": 6, + "known-acknowledgement": 3, + "uncertain-acknowledgement": 3 + } + }, + { + "log": "values-followup-final.log", + "property": "CONVEX_VALUES_ORACLE", + "campaign": "fixed", + "minimum": 1, + "counts": { + "insert": 35, + "update": 31, + "delete": 34, + "different-operands": 66, + "equal-payload-update": 21, + "changed-payload-update": 10, + "removed-field-update": 14, + "int64": 14, + "special-float": 19, + "binary": 19, + "nested-container": 70, + "undefined": 39 + } + }, + { + "log": "values-followup-final.log", + "property": "CONVEX_VALUES_ORACLE", + "campaign": "random", + "minimum": 1, + "counts": { + "insert": 40, + "update": 41, + "delete": 19, + "different-operands": 81, + "equal-payload-update": 25, + "changed-payload-update": 15, + "removed-field-update": 22, + "int64": 19, + "special-float": 25, + "binary": 24, + "nested-container": 75, + "undefined": 39 + } + }, + { + "log": "serializer-followup-final.log", + "property": "OFFLINE_CODEC_ORACLE", + "campaign": "fixed", + "minimum": 1, + "counts": { + "insert": 19, + "update": 19, + "delete": 22, + "different-operands": 38, + "equal-payload-update": 6, + "changed-payload-update": 13, + "removed-field-update": 9, + "int64": 35, + "special-float": 46, + "binary": 41 + } + }, + { + "log": "serializer-followup-final.log", + "property": "OFFLINE_CODEC_ORACLE", + "campaign": "random", + "minimum": 1, + "counts": { + "insert": 22, + "update": 16, + "delete": 22, + "different-operands": 38, + "equal-payload-update": 9, + "changed-payload-update": 7, + "removed-field-update": 11, + "int64": 44, + "special-float": 39, + "binary": 40 + } + } + ], + "validationStages": { + "full-suites": "Before final expected-original capture; normal grammar and comparisons otherwise unchanged.", + "codec-followup": "Final captured-original sources, complete serializer file and Convex values owner." + }, + "reviewedImplementationRevision": "5aebb71f90919697b459b38673a0aa5fef00a0b2" +} diff --git a/docs/contributing/oracle-reviews/2026-10-26-convex-oracle-repairs.md b/docs/contributing/oracle-reviews/2026-10-26-convex-oracle-repairs.md new file mode 100644 index 0000000000..4446774534 --- /dev/null +++ b/docs/contributing/oracle-reviews/2026-10-26-convex-oracle-repairs.md @@ -0,0 +1,156 @@ +# Convex oracle repairs, October 26 + +PR: . +Review parent: `de7cf8de13f14dfa0d4ce6676452eb231ef215e2`. +Reviewed implementation revision: `5aebb71f90919697b459b38673a0aa5fef00a0b2`. +The [source manifest](2026-10-26-convex-oracle-source.json) identifies the exact tested files. Older +records and shrink paths remain evidence for their named revisions. + +These are oracle and test-runner repairs. Production sources are unchanged. +The parent implementation passes the stronger comparisons. RED below means a +hostile implementation rejected by the repaired owner, not a baseline product +failure. Private production mutants were restored after each experiment. + +## Law and repaired distinctions + +The authority is the public [Convex collection guide](../../collections/convex-collection.md), +the [offline transaction guide](../../guides/offline-transactions.md), and the +Collection lifecycle and optimistic-history contracts named in AGENTS.md. + +| Owner | Promised observation and missing distinction repaired | +| --- | --- | +| Snapshot | Latest valid selected rows replace the complete keyed public result. Numeric and string keys retain their types, including `1` versus `'1'`. Constructive prefixes require omission, field removal, empty/reintroduction, duplicate rejection and recovery. | +| Ownership | One concrete options object has one Collection owner. Direct/shallow reuse, fresh factory calls and descriptors are crossed with idle, ready and cleaned-up cuts; confirmation and sibling cleanup remain independent. | +| Primary pagination | Every ordered `select` array and keyed public publication is complete. During each coherent source revision the trace permits prior snapshots followed by current snapshots, including repeats, and forbids prefixes or stale rollback. Pending replacement ranges retain only the prior complete result. The omitted default accepts 10,000 and rejects 10,001 rows. | +| Offline bridge | Rejected durable source acceptance is terminal until cleanup/restart, including rejection after accepted empty or nonempty rows. Exact error identity, prior application rows, changed/unchanged cached retries and fresh-run recovery are checked. Ordered bridge stages and cold acceptance retain separate judgments. | +| Shared codec and Convex receiver | Fresh durable readers preserve each supplied original, modified and changes operand independently, including native values and removed fields. Real CRUD supplies independently authored before/after values. Expected observations are captured before the writer; an in-place mutation cannot rewrite the expected original. | +| Backend crashes | Durable admission precedes provider calls; restart keeps the key and produces one application after known or uncertain completion acknowledgement. Reach is credited only after actual disk/server/replay comparisons. | +| Public pagination receiver | Every observed select/publication must equal a complete authored ledger revision available at that moment, including live writes and TCP reconnect through both SDKs and three query forms. Future writes cannot excuse an earlier observation. | + +## RED and hostile controls + +The original review executed five false-green designs: numeric-key +stringification, prefix-before-full pagination, default cap 9,999, terminal +acceptance only over empty Collections, and v4 original-from-modified routing. +The repaired owners reject all five at their intended comparisons. An +independent cross-review also found and executed an in-place original-corruption +survivor; capturing the expected original before the writer closes that gap. + +| Isolated wrong design | Intended rejection and adjacent witness | +| --- | --- | +| Stringify snapshot map keys | Typed public entries differ. The adjacent stringified delete-key design also fails when a numeric row is omitted. Seed `20261022`, path `0:0:0:0:0`; restored replay passes. | +| Publish one-row prefix, then full rows | Retained select trace rejects the prefix. Primary seed `20261022`, path `0:0:0:0:0:0:0:0:0:0`; direct RED and restored GREEN. Adjacent current→empty→current fails monotonicity. The real public receiver rejects the same prefix in all six histories; publication-only diagnostic also fails both SDK histories. | +| Default cap 9,999 or 10,001 | Exact 10,000 acceptance rejects the former; 10,001 overflow rejects the latter. Both fail assertions rather than timing out. Configured-cap replay uses seed `20261023`, path `0:0`. | +| Latch acceptance failure only when Collection is empty | Accepted-nonempty prefix followed by rejected insert resolves a retry that must reject. Seed `20261023`, path `0:0`; intended promise assertion RED and restored direct GREEN. | +| Encode v4 original from modified | Both codec owners reject at `original operand`. The narrower update-only routing mutant also fails: shared seed `20261022`, path `2:1:1:1:1:1`; Convex path `2:1:1:1`. Direct restored replay passes. | +| Update-only `Object.assign(mutation.original, mutation.modified)` before encoding | Both previous repaired owners passed because expected original aliased the writer input. Capturing its observation before writer entry makes both fail at `original operand`, with the same update replay coordinates above; restored production passes. | +| Omit uncertain backend acknowledgement histories | Grammar/runner negative control fails `uncertain-acknowledgement (0)` for each operation. This calibrates reach enforcement, not a provider mutation kill. Normal real-backend campaigns reach both acknowledgement cuts. | + +These results are intended assertion failures. Setup failures, absent runtime +downloads, discovery warnings and timeouts are not counted as rejected mutants. +Fixed default-cap cases and six public receiving histories are deliberate fixed +witnesses, alongside the generated primary owners. + +## Reach and GREEN + +Normal generated properties run identical fixed and unseeded campaigns with +fresh ledgers and a minimum of one history for every declared state. Counts are +histories, not callbacks or events. Every normal report is retained in +[the reach record](2026-10-26-convex-oracle-reach.json). Explicit seed/path replay runs directly, +sets fast-check's run budget to one and bypasses campaign minima. Reconstructing +a shrink path can still evaluate its callback more than once. + +| Owner | Actual normal reach in each lane | +| --- | --- | +| Snapshot | All 13 declared states reach 150 histories: string/numeric/mixed/equal-looking typed keys, selected-key representation, omitted key/field, empty, reintroduction, duplicate rejection, valid recovery, valid start and invalid start. | +| Ownership | All 15 states reach 60: four construction/reuse forms × three lifecycle cuts, independent confirmation, rightful owner after rejected reuse and after sibling cleanup. | +| Pagination rows/work/bound | All ten states reach 60: complete nonempty/empty, required/recommended split, pending with prior snapshot, public publication, initial multi-range exhaustion, acceptance at cap, overflow retention and shrink recovery. | +| Offline bridge/source/acceptance | All 22 states reach 60: four stage outcomes; idle/pending/startup-error/empty/rows/error/cleanup/provider-recovery; first-empty/first-rows/accepted-empty-insert/accepted-row-insert/update/delete/replacement/unchanged-cache-retry/changed-cache-retry/restart-recovery. | +| Shared codec | 60 histories per lane; all ten CRUD, operand-separation, equal/changed/removed-field update and native-class states exceed the minimum. Exact fixed/random counts are in the JSON record. | +| Convex values | 100 histories per lane; all 12 CRUD, operand, native-class, nested-container and undefined states exceed the minimum. Exact counts are in the JSON record. | +| Backend insert/update | Six actual process histories per operation per lane: admission/replay 6, known/uncertain acknowledgement 3 each, int64/special-float/bytes 2 each. | +| Backend delete | Six actual process histories per lane: admission/replay 6, known/uncertain 3 each. Total backend crash histories: 36. | + +The complete validation run passed 64 adapter tests, 302 offline-engine tests, +seven campaign/reach helper tests, and all 18 real-backend tests. The final +two-file codec follow-up passes after capturing expected original observations +before writer entry. Offline typecheck and build pass with a test-only tsconfig +that permits the shared helper import; production build configuration is +unchanged. Configured ESLint, oracle lint and diff checks pass. + +Commands from the repository root (the environment setting suppresses pnpm's +automatic dependency revalidation for this already installed local workspace): + +```sh +pnpm_config_verify_deps_before_run='' pnpm --filter @tanstack/convex-db-collection test +pnpm_config_verify_deps_before_run='' pnpm --filter @tanstack/offline-transactions exec vitest run +pnpm_config_verify_deps_before_run='' pnpm --filter @tanstack/offline-transactions typecheck +pnpm_config_verify_deps_before_run='' pnpm --filter @tanstack/offline-transactions build +pnpm_config_verify_deps_before_run='' pnpm --filter @tanstack/db exec vitest run tests/oracle-campaign.test.ts tests/oracle-reach.test.ts +CI=1 pnpm_config_verify_deps_before_run='' pnpm --filter @tanstack/convex-db-collection test:e2e +pnpm_config_verify_deps_before_run='' pnpm lint:changed +``` + +Replay selectors remain those in the coverage map. For example, set +`CONVEX_SNAPSHOT_ORACLE_SEED=20261022` and +`CONVEX_SNAPSHOT_ORACLE_PATH=0:0:0:0:0` when selecting the snapshot owner. +Shared codec uses `OFFLINE_CODEC_ORACLE`; Convex codec uses +`CONVEX_VALUES_ORACLE`. No commands arbitrary is used, so there is no command +`replayPath` interface. Old backend paths identify the old grammar; the new +constructive bundles change those coordinates. + +## Grammar controls and review outcomes + +Reconstruction includes every original false-green witness, the adjacent +mutants above, and their direct restored replays. Constructive prefixes retain +arbitrary suffixes and generated values. Ablating numeric keys loses typed-key +discrimination; omitting earlier accepted rows loses later-rejection evidence; +merging codec operands loses update routing; retaining only the final callback +loses partial/stale trace evidence; omitting uncertain acknowledgement loses +the named crash cut and fails reach. + +Domains remain bounded: snapshot uses three key slots, 0–5 rows and 1–30 suffix +snapshots; numeric key parameters lie in -100..100. Pagination crosses page +sizes 1–2 and 3–4 with all split policies, a 3→8 prefix, generated replacements +and a final empty result. Shared codec uses arrays of 1–5 native leaves; +Convex trees have depth two and arrays up to three. Binary sizes are 0–12 and +0–8 respectively. Acceptance scenes vary values and retry order/counts. +Backend bundles vary payloads and acknowledgement order within six actual +histories per operation/lane. Duplicate-key proposals are invalid but remain +explicit rejection inputs; malformed cursor tokens, out-of-range native +integers, update/delete of absent fixture rows and simultaneous uncontrolled +source revisions are outside these legal grammars. + +| Requirement | Outcome | +| --- | --- | +| ORC-001 | Authority and bounded omissions are stated beside each owner and above. No product promise was changed to match output. | +| ORC-002 | Expected arrays/typed entries, dependency prefixes, authored operand observations and append ledger are independent of production transitions. The codec cross-review found and removed a mutable expected-input alias. Core's supplied mutation composition, native brand checks and test storage are explicit trusted boundaries. | +| ORC-003 | Each changed owner retains opening contract prose and distinguishes grammar, model, real entry point, recorder and comparison. Campaign/reach utilities supply execution support, not another product oracle. | +| ORC-004 | Reconstruction, ablation, bounded ranges and nearby invalid inputs are accounted for above; constructive prefixes add reach without removing suffix variation. | +| ORC-005 | Real Collections, OutboxManager/fresh readers and actual backend processes reach their named public comparisons. Retained callbacks represent intermediate violations. No concrete missing-information hypothesis remains for these controlled checkpoints; arbitrary Convex query translation is outside scope. | +| ORC-006 | Every repaired false-green distinction has an executed hostile comparison failure. Backend enforcement has a separately labeled omitted-state control. | +| ORC-007 | Fresh fixed/random lanes use one callback and budget; direct captured RED/restored GREEN replay is exercised. Seven helper tests include lane separation, seed zero/path forwarding, run budget and independence from unrelated DB replay selectors. | +| ORC-008 | Retained prior/current pagination phase distinguishes a later stale callback. Accepted rows plus terminal failure distinguish same-cache retry from restarted acceptance. Allowed receiving revisions are frozen at observation time so future ledger writes cannot rewrite prior judgments. No production cursor state is copied into the value model. | +| ORC-009 | Source acceptance versus visibility follows the Collection contract. Scene names and reach states map to concrete fixture cuts in owner prose. Backend counts distinguish bundles from actual process histories. | +| ORC-010 | Cleanup harnesses preserve original comparison failures; direct shrinking reproduces their named assertions. Assertions in the public receiver run outside provider callbacks so they cannot turn into projection errors. Broader infrastructure failure handling is not newly proved. | +| ORC-011 | No unresolved named shared semantic fault requires another model for these repairs. Independent reviewers challenged pagination, codec and acceptance/backend/helper changes; the expected-original alias challenge was executed and fixed. Arbitrary query semantics and native persistence-fault refinement remain outside scope. | +| ORC-012 | This exact-source record gives the applicable outcomes, original/adjacent witnesses and remaining limits. Closure is confined to the tested contract × grammar × path × observation cells. | +| ORC-013 | Exact 10,000/10,001 cases reject either adjacent default. Accepted empty/nonempty scenes distinguish the terminality boundary. Distinct and equal-payload real updates distinguish operand routing. Prior/current/prefix traces distinguish trace consequences. Other untested reusable boundaries retain their existing owners. | +| ORC-014 | Real public queries supply actual cursor continuation, ordered pages and reconnect through both SDKs. Their append ledger checks complete available revisions; it cannot reuse the synchronous revision-step checker because stream delivery can precede the writer acknowledgement and does not label revision boundaries. Drift risk is limited explicitly: within-phase monotonicity, arbitrary update/delete and split scheduling remain primary controlled-owner evidence. Backend native payloads receive disk/process/server transport for a bounded subset; native storage failures remain unresolved in their existing owners. | +| ORC-015 | Every changed important generated property declares states and enforces per-lane minimum one. Actual counts are versioned in the companion JSON. Fixed public histories/default-cap examples are not generated campaigns, so this trigger does not apply to those cases. | + +## Limits + +No on-demand functionality was added. The adapter remains eager and drains the +fixed server query. Lazy cursor acquisition could use the existing on-demand +protocol; a separate factory is not inherently required. Arbitrary DB filters +and ordering cannot automatically become arguments to an application-defined +Convex function. Every successful load would still owe its requested subset. + +The public receiving relation proves completeness and availability, not +within-phase monotonicity; the primary pagination owner checks that relation. +These repairs do not prove arbitrary concurrent source revisions, cloud +hosting, atomic cross-Collection publication, cold backend replay with native +disk failure, multi-tab/atomic batches, power-loss behavior, arbitrary custom +objects/cycles or stricter byte/peak-memory/lifetime-subscription bounds. +The coverage map retains the existing owners for those laws. diff --git a/docs/contributing/oracle-reviews/2026-10-26-convex-oracle-source.json b/docs/contributing/oracle-reviews/2026-10-26-convex-oracle-source.json new file mode 100644 index 0000000000..4073a8e401 --- /dev/null +++ b/docs/contributing/oracle-reviews/2026-10-26-convex-oracle-source.json @@ -0,0 +1,26 @@ +{ + "reviewParent": "de7cf8de13f14dfa0d4ce6676452eb231ef215e2", + "reviewedImplementationRevision": "5aebb71f90919697b459b38673a0aa5fef00a0b2", + "sha256": { + "docs/collections/convex-collection.md": "c432cd6a6b2cc926c382b75220407dae53ea76e4a914393e3a0b516ff6e85f70", + "docs/contributing/oracle-coverage.md": "3b040397b513301a247aa8bf202c5efd18f543a23745a8c889ccda84a64d1c80", + "packages/convex-db-collection/e2e/backend-oracle.test.ts": "e30455f6066988c6a6bf9594fce5fac834ce0a4e132da3c67db7c5b65986f363", + "packages/convex-db-collection/e2e/public-pagination-oracle.test.ts": "bc2315e2e493762b9b11785876d67de19d76a4f541663f9e22ce71897f337711", + "packages/convex-db-collection/tests/offline-oracle.property.test.ts": "02a86f41c6efc350442435156cd4e645652a538a0aaded47a0d9399a7e6ceeb1", + "packages/convex-db-collection/tests/ownership-oracle.property.test.ts": "65571b825260f5cba81910bd2c09d4c059381718a22092594732ea97bf39a4b1", + "packages/convex-db-collection/tests/pagination-oracle.property.test.ts": "edee7b6bdb287523e54bc4d2acbae9d9239376f67ec89b1e41ec4247e98ef1b8", + "packages/convex-db-collection/tests/snapshot-oracle.property.test.ts": "014848222ce1390918edd96eb87ac8f12639d805334e8ce1254942fbf90e06b7", + "packages/convex-db-collection/tests/values-oracle.property.test.ts": "656a180d80f1e5dede569487b7ebf4a64a2f463c4c9180fa7539479eba8c0130", + "packages/db/tests/oracle-campaign.test.ts": "9bd9847227c02ea294247d29fb263103e24f354b02c56762da53fb0b0bbf734a", + "packages/db/tests/oracle-campaign.ts": "eb62aba6b24c596be97a7fe607c4c39808f7bef12ae5e0e2add547a45f8849d5", + "packages/db/tests/oracle-reach.ts": "f32b74e0adfaf8d869edaa8aeb2d1d8ac0a413ddd61b91ff706bde1767959283", + "packages/offline-transactions/package.json": "2f729365bcdfefc77a948954a1b4bbccfb46683612fa3f46fab24942493a6c7a", + "packages/offline-transactions/tests/transaction-serializer-oracle.property.test.ts": "94d3d1aa21f52c3e88b17530653644258f2f44c8283ca70f7aae4ca6199b3278", + "packages/offline-transactions/tsconfig.test.json": "5168aca44188a9be3b0bee215bfc2f222010dedb8f4e1fc08029c6873dcb6cf1", + "packages/offline-transactions/vite.config.ts": "1314f58bd503c40e633342fb6ed05a28a4d03641280159d65b34e6913226bb63" + }, + "excludedLocalInstallationWorkaround": [ + "pnpm-workspace.yaml", + "pnpm-lock.yaml" + ] +} From 8be0438c3982fccf5b32a2f1945a359a9bc87c31 Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Mon, 26 Oct 2026 17:26:03 -0600 Subject: [PATCH 16/20] fix: validate Convex docs and budget expanded oracle campaigns Fix relative app imports in the doc checker using import-specifier rewriting, retain real dependency types, and remove three baseline entries that now pass. Complete the Convex insert example and retain generated query row types. Give the expanded fixed/random campaigns 30 seconds under CI coverage without reducing their histories or comparisons. Validation: 767 doc examples, zero new failures; 64 adapter checks; 302 offline tests; 18 real-backend tests; adapter typecheck and build; lint:changed and configured ESLint. Published runtime sources are unchanged. --- docs/collections/convex-collection.md | 60 ++++++++++++-------- packages/convex-db-collection/vite.config.ts | 2 + scripts/check-doc-snippets.mjs | 6 +- scripts/doc-snippet-syntax.mjs | 46 +++++++++++++++ scripts/doc-snippets-baseline.json | 3 - 5 files changed, 88 insertions(+), 29 deletions(-) diff --git a/docs/collections/convex-collection.md b/docs/collections/convex-collection.md index 3bd613ddb6..bca036529b 100644 --- a/docs/collections/convex-collection.md +++ b/docs/collections/convex-collection.md @@ -19,8 +19,11 @@ import { createCollection } from '@tanstack/db' import { convexCollectionOptions } from '@tanstack/convex-db-collection' import { ConvexClient } from 'convex/browser' import { api } from './convex/_generated/api' +import type { FunctionReturnType } from 'convex/server' -const client = new ConvexClient(import.meta.env.VITE_CONVEX_URL) +type Task = FunctionReturnType[number] + +const client = new ConvexClient('https://your-deployment.convex.cloud') export const tasks = createCollection( convexCollectionOptions({ @@ -28,7 +31,7 @@ export const tasks = createCollection( client, query: api.tasks.list, queryArgs: { projectId }, - getKey: (task) => task._id, + getKey: (task: Task) => task._id, }), ) @@ -48,13 +51,16 @@ The adapter uses eager sync. DB filters, ordering, and limits apply locally to t Supply handlers for the operations your application supports. Await mutations on the same client used for the subscription. ```ts +import { createCollection } from '@tanstack/db' +import { convexCollectionOptions } from '@tanstack/convex-db-collection' + const tasks = createCollection( convexCollectionOptions({ id: 'tasks', client, query: api.tasks.list, queryArgs: { projectId }, - getKey: (task) => task._id, + getKey: (task: Task) => task._id, onUpdate: async ({ transaction }) => { await Promise.all( transaction.mutations.map(({ original, changes }) => @@ -92,23 +98,28 @@ A DB transaction does not make several Convex calls atomic. For atomic batches, Convex assigns `_id` on the server. DB needs a stable key before an optimistic insert. For optimistic inserts, add an application key such as `clientId` to your schema and documents. Use that key in `getKey` and send it through your insert mutation. Use `select` to omit server-only fields from the local row type. ```ts -select: (tasks) => tasks.map((task) => ({ - clientId: task.clientId, - projectId: task.projectId, - title: task.title, -})), -getKey: (task) => task.clientId, -onInsert: async ({ transaction }) => { - await Promise.all( - transaction.mutations.map(({ modified }) => - client.mutation(api.tasks.create, { - clientId: modified.clientId, - projectId: modified.projectId, - title: modified.title, - }), - ), - ) -}, +convexCollectionOptions({ + client, + query: api.tasks.list, + queryArgs: { projectId }, + select: (tasks) => tasks.map((task) => ({ + clientId: task.clientId, + projectId: task.projectId, + title: task.title, + })), + getKey: (task: { clientId: string; projectId: string; title: string }) => task.clientId, + onInsert: async ({ transaction }) => { + await Promise.all( + transaction.mutations.map(({ modified }) => + client.mutation(api.tasks.create, { + clientId: modified.clientId, + projectId: modified.projectId, + title: modified.title, + }), + ), + ) + }, +}) ``` Insert a local row with `tasks.insert({ clientId: crypto.randomUUID(), projectId, title })`. The server query must return the same `clientId`. @@ -125,7 +136,7 @@ convexCollectionOptions({ query: api.tasks.listWithMetadata, queryArgs: { projectId }, select: (result) => result.tasks, - getKey: (task) => task._id, + getKey: (task: Task) => task._id, }) convexCollectionOptions({ @@ -133,7 +144,7 @@ convexCollectionOptions({ query: api.tasks.get, queryArgs: { id: taskId }, select: (task) => (task === null ? [] : [task]), - getKey: (task) => task._id, + getKey: (task: Task) => task._id, }) ``` @@ -144,6 +155,7 @@ Keep projections synchronous and treat query results as immutable. A projection Use `convexPaginatedCollectionOptions` for a complete eager mirror of a finite query that returns Convex's `PaginationResult`. The adapter supplies `paginationOpts`, loads every page automatically, and keeps the full result and its page subscriptions in memory. `preload()` waits for the whole result. Set a server-side scope such as a project or workspace before using it with a large table. ```ts +import { createCollection } from '@tanstack/db' import { convexPaginatedCollectionOptions } from '@tanstack/convex-db-collection' const tasks = createCollection( @@ -154,7 +166,7 @@ const tasks = createCollection( queryArgs: { projectId }, pageSize: 100, maxRows: 5_000, - getKey: (task) => task._id, + getKey: (task: Task) => task._id, }), ) @@ -256,7 +268,7 @@ convexCollectionOptions({ client, query: api.tasks.list, queryArgs: { projectId }, - getKey: (task) => task._id, + getKey: (task: Task) => task._id, onError: (error) => reportError(error), }) ``` diff --git a/packages/convex-db-collection/vite.config.ts b/packages/convex-db-collection/vite.config.ts index abd0709e33..7a3c308092 100644 --- a/packages/convex-db-collection/vite.config.ts +++ b/packages/convex-db-collection/vite.config.ts @@ -8,6 +8,8 @@ const config = defineConfig({ name: packageJson.name, include: [`tests/**/*.test.ts`], environment: `jsdom`, + // Generated fixed/random campaigns include real Collection lifecycles. + testTimeout: 30_000, coverage: { enabled: true, provider: `istanbul`, include: [`src/**/*`] }, typecheck: { enabled: true, include: [`tests/**/*.test-d.ts`] }, }, diff --git a/scripts/check-doc-snippets.mjs b/scripts/check-doc-snippets.mjs index 4bcebaf0a1..250b17c9d3 100644 --- a/scripts/check-doc-snippets.mjs +++ b/scripts/check-doc-snippets.mjs @@ -27,6 +27,8 @@ import prettier from 'prettier' import ts from 'typescript' import { componentScript, + externalImports, + externalName, fingerprint, frameworkOf, misspelling, @@ -158,7 +160,7 @@ const ambient = join(root, `.snippet-check`, `ambient.d.ts`) const missingModules = new Set() const ambientSource = () => [...missingModules] - .map((name) => `declare module ${JSON.stringify(name)}\n`) + .map((name) => `declare module ${JSON.stringify(externalName(name))}\n`) .join(``) || `export {}\n` function check(sources) { @@ -224,7 +226,7 @@ function sourceOf(snippet, declared, autoImported) { `declare const ${name}: ${/collection$/i.test(name) ? `SnippetCollection, string | number>` : `any`}\ntype ${name} = any\n`, ) .join(``) + - snippet.code + + externalImports(snippet.code, missingModules) + `\nexport {}\n` ) } diff --git a/scripts/doc-snippet-syntax.mjs b/scripts/doc-snippet-syntax.mjs index f271fe222f..a19adbd9e7 100644 --- a/scripts/doc-snippet-syntax.mjs +++ b/scripts/doc-snippet-syntax.mjs @@ -170,7 +170,53 @@ export function frameworkOf(file) { return name.find((candidate) => frameworks.has(candidate)) ?? `react` } +// Relative ambient module declarations are invalid TypeScript. Only rewrite +// unresolved app import specifiers, leaving source strings and real modules alone. +export const externalName = (name) => + name.startsWith(`.`) ? `snippet-app:${name}` : name + +export function externalImports(code, missingModules) { + const source = parse(code, true) + const replacements = [] + for (const statement of source.statements) { + if ( + !ts.isImportDeclaration(statement) && + !ts.isExportDeclaration(statement) + ) + continue + const specifier = statement.moduleSpecifier + if (!specifier || !ts.isStringLiteral(specifier)) continue + if (!missingModules.has(specifier.text)) continue + replacements.push({ + start: specifier.getStart(source) + 1, + end: specifier.getEnd() - 1, + text: externalName(specifier.text), + }) + } + for (const replacement of replacements.reverse()) { + code = + code.slice(0, replacement.start) + + replacement.text + + code.slice(replacement.end) + } + return code +} + export function selfTest() { + const appImport = `import { api } from './convex/_generated/api'` + const literal = `const text = "from './convex/_generated/api'"` + const rewritten = externalImports( + `${appImport}\n${literal}`, + new Set([`./convex/_generated/api`]), + ) + if ( + rewritten !== + `import { api } from 'snippet-app:./convex/_generated/api'\n${literal}` + ) + throw new Error(`App import rewriting changed source outside an import`) + if (externalImports(appImport, new Set()) !== appImport) + throw new Error(`App import rewriting changed a resolved module`) + const imports = [ `include`, `toArray`, diff --git a/scripts/doc-snippets-baseline.json b/scripts/doc-snippets-baseline.json index 69187088c2..59387a062d 100644 --- a/scripts/doc-snippets-baseline.json +++ b/scripts/doc-snippets-baseline.json @@ -160,7 +160,6 @@ "fefabaccf3eabdf4" ], "docs/guides/offline-transactions.md": [ - "1147ca316b1bbc4f", "66ebb56ae8a44f0a", "e5ce654a151d436f" ], @@ -306,7 +305,6 @@ "packages/db/skills/meta-framework/SKILL.md": [ "230d26818828a8b0", "a529c96af815a04e", - "d1243ee3f23b2c9b", "e08f750ae875cf11" ], "packages/offline-transactions/skills/offline/SKILL.md": [ @@ -321,7 +319,6 @@ "95f54055d2f8f35c", "985618b925e3379e", "c3c382de80c45bb9", - "d1c8f0182841f3e2", "d2dce4009d5fa25e", "d5e14ff028e79b54", "e8cea9396752d82a" From 929e3533e358ba70d79941fa8c195b4bb6cf1748 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Tue, 27 Oct 2026 13:20:07 -0600 Subject: [PATCH 17/20] feat(convex): add on-demand paging and scientific oracle coverage Implement fixed-query lazy paging through syncMode on-demand, retaining exact coverage, applied-receipt settlement and obsolete-callback fencing. Add independent models and generated receivers for ownership, optimistic app writes, backend truth, failure equivalence and replay noninterference. Law: fixed-query coverage and publication, LoadSubsetFn/SyncAppliedReceipt, optimistic settlement, and the maintainer decision in issue #2269. Versioned law, authority and source bindings are in docs/contributing/oracle-reviews/. Reach: fixed/random campaigns enforce all declared states; final app deep campaigns reach all 14 states in 12 histories each, and outcome/abort deep campaigns reach every state in 90 histories each. RED: the recorded pre-repair ownership history rejects a retired native error callback at seed 20261027, path 0:0:0. Later coverage additions pass unchanged production and do not claim a new product-defect RED. GREEN: 112 package cases, 23 actual-backend cases, deeper campaigns, types, ESLint, formatting, oracle lint and diff checks pass. Mutant: final calibration rejects early handler settlement, swallowed sync throw, active retired subscription and a misdirected backend write; shrunk failures replay and pass after exact restoration. Limits: bounded fixed-query histories; zero-acceptance cancellable abort is covered, while prospective immediate core abort for uncooperative adapters remains #2168 integration. Final review is self-review. No new TLA+ run. Keep the local baseline-browser-mapping override and lockfile workaround out of this commit. Preserve earlier frozen evidence and versioned review records. --- .changeset/convex-collection.md | 2 +- docs/collections/convex-collection.md | 42 +- docs/contributing/oracle-coverage.md | 244 ++++- .../2026-10-26-convex-lazy-paging-source.json | 29 + .../2026-10-26-convex-lazy-paging.md | 255 +++++ ...26-10-27-convex-lazy-app-abort-source.json | 433 ++++++++ .../2026-10-27-convex-lazy-app-abort.md | 176 ++++ ...onvex-lazy-backend-concurrency-source.json | 386 +++++++ ...6-10-27-convex-lazy-backend-concurrency.md | 166 +++ ...6-10-27-convex-lazy-boundaries-source.json | 406 ++++++++ .../2026-10-27-convex-lazy-boundaries.md | 223 +++++ ...0-27-convex-lazy-interleavings-source.json | 50 + .../2026-10-27-convex-lazy-interleavings.md | 146 +++ .../2026-10-27-convex-lazy-paging-source.json | 39 + .../2026-10-27-convex-lazy-paging.md | 159 +++ packages/convex-db-collection/README.md | 12 + .../e2e/backend/convex/schema.ts | 1 + .../e2e/backend/convex/tasks.ts | 76 ++ .../e2e/lazy-app-write-oracle.test.ts | 495 +++++++++ .../e2e/lazy-boundary-oracle.test.ts | 610 +++++++++++ .../e2e/lazy-concurrency-oracle.test.ts | 536 ++++++++++ .../e2e/lazy-paging-oracle.test.ts | 268 +++++ .../convex-db-collection/e2e/local-backend.ts | 3 + packages/convex-db-collection/package.json | 5 +- packages/convex-db-collection/src/index.ts | 1 + packages/convex-db-collection/src/options.ts | 48 +- .../src/paginated-options.ts | 62 +- .../src/pagination-demand.ts | 90 ++ .../convex-db-collection/src/pagination.ts | 168 +++- .../convex-db-collection/src/subscription.ts | 27 +- ...aging-abort-replay-oracle.property.test.ts | 236 +++++ .../tests/lazy-paging-campaign.ts | 44 + .../tests/lazy-paging-driver.ts | 516 ++++++++++ ...ng-failure-history-oracle.property.test.ts | 443 ++++++++ ...azy-paging-history-oracle.property.test.ts | 471 +++++++++ ...aging-interleaving-oracle.property.test.ts | 426 ++++++++ ...aging-load-outcome-oracle.property.test.ts | 253 +++++ .../tests/lazy-paging-model-oracle.ts | 162 +++ ...ing-native-receipt-oracle.property.test.ts | 312 ++++++ .../tests/lazy-paging-oracle.property.test.ts | 945 ++++++++++++++++++ .../tests/lazy-paging-receipt-model.ts | 162 +++ ...azy-paging-receipt-oracle.property.test.ts | 426 ++++++++ ...aging-revalidation-oracle.property.test.ts | 392 ++++++++ .../tests/lazy-paging-version-model-oracle.ts | 55 + ...azy-paging-version-oracle.property.test.ts | 238 +++++ .../lazy-paging-write-model-oracle.test.ts | 67 ++ .../tests/lazy-paging-write-model-oracle.ts | 135 +++ .../tests/sdk-cache-oracle.test.ts | 147 +++ .../tests/types.test-d.ts | 37 + packages/convex-db-collection/tsconfig.json | 3 +- .../convex-db-collection/tsconfig.test.json | 6 + packages/convex-db-collection/vite.config.ts | 6 +- .../vitest.lazy.e2e.config.ts | 16 + packages/db/package.json | 2 +- ...ubset-joint-replay-oracle.property.test.ts | 468 +++++++++ .../tests/convex-lazy-paging-oracle.test.tsx | 112 +++ 56 files changed, 11193 insertions(+), 45 deletions(-) create mode 100644 docs/contributing/oracle-reviews/2026-10-26-convex-lazy-paging-source.json create mode 100644 docs/contributing/oracle-reviews/2026-10-26-convex-lazy-paging.md create mode 100644 docs/contributing/oracle-reviews/2026-10-27-convex-lazy-app-abort-source.json create mode 100644 docs/contributing/oracle-reviews/2026-10-27-convex-lazy-app-abort.md create mode 100644 docs/contributing/oracle-reviews/2026-10-27-convex-lazy-backend-concurrency-source.json create mode 100644 docs/contributing/oracle-reviews/2026-10-27-convex-lazy-backend-concurrency.md create mode 100644 docs/contributing/oracle-reviews/2026-10-27-convex-lazy-boundaries-source.json create mode 100644 docs/contributing/oracle-reviews/2026-10-27-convex-lazy-boundaries.md create mode 100644 docs/contributing/oracle-reviews/2026-10-27-convex-lazy-interleavings-source.json create mode 100644 docs/contributing/oracle-reviews/2026-10-27-convex-lazy-interleavings.md create mode 100644 docs/contributing/oracle-reviews/2026-10-27-convex-lazy-paging-source.json create mode 100644 docs/contributing/oracle-reviews/2026-10-27-convex-lazy-paging.md create mode 100644 packages/convex-db-collection/e2e/lazy-app-write-oracle.test.ts create mode 100644 packages/convex-db-collection/e2e/lazy-boundary-oracle.test.ts create mode 100644 packages/convex-db-collection/e2e/lazy-concurrency-oracle.test.ts create mode 100644 packages/convex-db-collection/e2e/lazy-paging-oracle.test.ts create mode 100644 packages/convex-db-collection/src/pagination-demand.ts create mode 100644 packages/convex-db-collection/tests/lazy-paging-abort-replay-oracle.property.test.ts create mode 100644 packages/convex-db-collection/tests/lazy-paging-campaign.ts create mode 100644 packages/convex-db-collection/tests/lazy-paging-driver.ts create mode 100644 packages/convex-db-collection/tests/lazy-paging-failure-history-oracle.property.test.ts create mode 100644 packages/convex-db-collection/tests/lazy-paging-history-oracle.property.test.ts create mode 100644 packages/convex-db-collection/tests/lazy-paging-interleaving-oracle.property.test.ts create mode 100644 packages/convex-db-collection/tests/lazy-paging-load-outcome-oracle.property.test.ts create mode 100644 packages/convex-db-collection/tests/lazy-paging-model-oracle.ts create mode 100644 packages/convex-db-collection/tests/lazy-paging-native-receipt-oracle.property.test.ts create mode 100644 packages/convex-db-collection/tests/lazy-paging-oracle.property.test.ts create mode 100644 packages/convex-db-collection/tests/lazy-paging-receipt-model.ts create mode 100644 packages/convex-db-collection/tests/lazy-paging-receipt-oracle.property.test.ts create mode 100644 packages/convex-db-collection/tests/lazy-paging-revalidation-oracle.property.test.ts create mode 100644 packages/convex-db-collection/tests/lazy-paging-version-model-oracle.ts create mode 100644 packages/convex-db-collection/tests/lazy-paging-version-oracle.property.test.ts create mode 100644 packages/convex-db-collection/tests/lazy-paging-write-model-oracle.test.ts create mode 100644 packages/convex-db-collection/tests/lazy-paging-write-model-oracle.ts create mode 100644 packages/convex-db-collection/tests/sdk-cache-oracle.test.ts create mode 100644 packages/convex-db-collection/tsconfig.test.json create mode 100644 packages/convex-db-collection/vitest.lazy.e2e.config.ts create mode 100644 packages/db/tests/query/load-subset-joint-replay-oracle.property.test.ts create mode 100644 packages/react-db/tests/convex-lazy-paging-oracle.test.tsx diff --git a/.changeset/convex-collection.md b/.changeset/convex-collection.md index b85a855f9a..96323503c2 100644 --- a/.changeset/convex-collection.md +++ b/.changeset/convex-collection.md @@ -3,6 +3,6 @@ '@tanstack/offline-transactions': minor --- -Add a Convex collection adapter with typed query subscriptions, full-row sync, automatic reactive pagination, durable offline mutation replay, optimistic mutation handlers, and support for both Convex JavaScript and React clients. +Add a Convex collection adapter with typed query subscriptions, full-row sync, automatic reactive pagination with on-demand windows for a fixed ordered query, durable offline mutation replay, optimistic mutation handlers, and support for both Convex JavaScript and React clients. Add opt-in mutation codecs to the durable outbox while preserving the default wire format and retaining unreadable codec records for recovery. diff --git a/docs/collections/convex-collection.md b/docs/collections/convex-collection.md index 3bd613ddb6..b8180677e7 100644 --- a/docs/collections/convex-collection.md +++ b/docs/collections/convex-collection.md @@ -41,7 +41,7 @@ Create fresh adapter options for each direct `createCollection` call. Reusing th Each result replaces the subscribed query's rows. Missing documents are deleted, and missing fields are removed. An empty array is a ready, empty collection. Keys must be unique strings or finite numbers. Duplicate keys reject the complete result and preserve the last valid snapshot. -The adapter uses eager sync. DB filters, ordering, and limits apply locally to the subscribed result. They do not change your Convex query or fetch documents outside its result. To change the query arguments, create a new collection with a distinct ID. +`convexCollectionOptions` uses eager sync. DB filters, ordering, and limits apply locally to the subscribed result. They do not change your Convex query or fetch documents outside its result. To change the query arguments, create a new collection with a distinct ID. ## Write through Convex mutations @@ -171,9 +171,45 @@ The adapter keeps each loaded cursor range subscribed. Fixed `endCursor` boundar The cap does not limit the bytes in a server response or guarantee peak memory use. A single reactive page can already exceed it. `pageSize` does not bound total memory or initial load time. Initial acquisition can inspect earlier cached pages repeatedly as it extends the cursor chain. -This factory uses eager sync; `syncMode: 'on-demand'` and DB's `loadSubset` protocol are not currently implemented. Convex cursor pagination can support lazy acquisition for a fixed query, but each load must establish the requested subset before reporting success. DB filters and ordering are not automatically translated into your server function arguments. An incomplete source page cannot stand in for a complete filtered or differently ordered result. Scope the server query when using this eager mirror. +In eager mode, `select` receives the complete row array. DB applies local filters, joins, ordering, and limits to that mirror. -For paginated collections, `select` receives the complete row array. DB still applies local filters, joins, ordering, and limits. Pagination loads a finite query completely into memory, it does not push DB predicates to your server. +## Lazy pagination for a fixed query + +Set `syncMode: 'on-demand'` on the same paginated factory to acquire pages as an ordered DB query needs them. Declare the server query's leading numeric order with `queryOrder`. The server function must always paginate that same query in that direction. + +```ts +const tasks = createCollection( + convexPaginatedCollectionOptions({ + client, + query: api.tasks.paginatedByRank, + queryArgs: { projectId }, + syncMode: 'on-demand', + queryOrder: { field: 'rank', direction: 'asc' }, + autoIndex: 'eager', + defaultIndexType: BTreeIndex, + pageSize: 100, + getKey: (task) => task._id, + }), +) + +const window = createLiveQueryCollection((q) => + q.from({ task: tasks }) + .orderBy(({ task }) => task.rank) + .orderBy(({ task }) => task._id) + .select(({ task }) => task) + .limit(25), +) +await window.preload() +await window.utils.setWindow({ offset: 25, limit: 25 }) +``` + +Import `createLiveQueryCollection` and `BTreeIndex` from `@tanstack/db`. The leading DB order must match `queryOrder`; trailing order terms run locally. Each load acquires a contiguous prefix with every row tied at its leading boundary. This may load more rows than the displayed window. Offset windows acquire the preceding prefix too. An unlimited source request, including `tasks.preload()`, loads the whole query. With no demand or a zero limit, no page is started. + +The initial profile supports finite numeric leading fields, aligned ordered windows, core boundary-tie requests, and forward continuations. It rejects other source filters and differently ordered requests. Put application filters in the fixed server query's arguments. `select` on the collection options is unavailable in on-demand mode; use DB query projections as shown above. This API does not translate general DB queries into Convex functions. + +Loaded ranges stay subscribed until collection cleanup. Later inserts, deletes, moves, splits, and reconnects repair the active windows. While more coverage is pending, a public live query retains its last complete window. A successful subset load waits for its accepted writes to become visible, including when optimistic writes hold them. `utils.awaitSync()` waits for source acceptance and remains safe inside mutation handlers. + +`maxRows` also caps the retained lazy prefix. It does not bound requests, network bytes, scans, or peak memory. A boundary tie or a whole-source request can still require the complete query. Use server-side scoping and a suitable finite cap for your application. ## Durable offline writes diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index 151fe4ff66..6bb0ec26b6 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -180,7 +180,249 @@ storage are requirements of each application's server mutation. The October 23 review adds real-Collection source-acceptance histories to the offline bridge owner: idle, pending first result, failed startup, provider failure, and cleanup. Successful empty or nonempty snapshots must actually be accepted. Original idle acceptance fails seed 20261023/path 0:0:0, the repaired fixed/random campaigns and direct replay pass. The same file keeps its ordered-stage model separate from the source-settlement and rejected-acceptance judgments. A rejected durable acceptance stays terminal for that sync run, same or changed cached rows cannot falsely certify a retry, and cleanup/restart with a fresh accepted snapshot recovers. The nonterminal mutant fails seed 20261023/path 0:0 under CONVEX_OFFLINE_ACCEPTANCE_ORACLE_SEED/PATH. Ordinary provider errors retain same-run valid-result recovery. Dedicated codec replay uses OFFLINE_CODEC_ORACLE_SEED/PATH/RUNS; legacy serializer properties keep OFFLINE_ORACLE selectors. -The bounded eager API defaults maxRows to 10,000. It is not an on-demand window, a network-byte bound, a strict peak-memory bound, or a lifetime subscription-count bound. Cursor ranges remain subscribed, and cache scans still grow quadratically during acquisition. Lazy cursor acquisition for a fixed Convex query could use DB's on-demand protocol; no separate factory is inherently required. Such support is not implemented here, and each load would still need to establish its requested subset rather than treating one partial source page as complete. +The paginated factory now also supports `syncMode: 'on-demand'` for one fixed +query with an explicitly aligned finite numeric leading order. The +[lazy paging implementation record](oracle-reviews/2026-10-26-convex-lazy-paging.md) +records the approved law, semantic RED/GREEN, review repairs, checked mutant +replays, source hashes and limits. `tests/lazy-paging-model-oracle.ts` owns the +independent exact-window, complete-leading-tie, delivered-evidence successor-work +and public-publication comparisons. `tests/lazy-paging-oracle.property.test.ts` +runs five fixed/random 60-history families through real Collection controls and +ordered live queries. Each current reach state was observed in all 60 histories +per campaign. It covers finite/offset/whole/tie/continuation/empty scopes, +short nonterminal pages, both splits, no-demand/zero/growth/cached-peer work, +rejected acquisition/unload, held initial/moved/repaired windows, cache-before- +callback refetch, abort before/after acceptance, held cleanup/restart, and two +real applied receipts across publication reentry. Duplicate pending request +objects retain both waiters. An invalid lazy startup never publishes readiness. + +The [October 27 strengthening record](oracle-reviews/2026-10-27-convex-lazy-paging.md) +supersedes those historical source bindings for the current files. The native +`lazy-paging-history-oracle.property.test.ts` adds generated acquisition, +retirement, abort, delivery, source-change, application-hold, cleanup and restart +actions. Its constructive prefix retires or aborts the wider authorizer while +work remains pending, then cleans up with a real accepted receipt held. Both +SDK subscription shapes receive stale success/error callbacks. Every source +publication is checked against the provider facts available at that publication; +optimistic overlays are excluded. Paired hidden suffixes compare actual discovery +traces before revealing the suffix. Retain/reacquire alternatives are positive +scientific-comparator calibrations, not claims about native retention strategy. + +`lazy-paging-receipt-model.ts` independently owns the exact many-to-many +attempt/establishing-receipt relation. Its controlled real-Collection receiver +varies response groups and commit partitions independently, checks cached zero, +one/shared/multiple receipts, held idempotent writes, true with zero/one/multiple +synchronous receipts, Promise rejection and synchronous throw. Six faulty +adapters distinguish borrowed receipts, first-only waits, skipped provider +completion, content-only application, premature canceled settlement and a +quiescent stranded caller. Native lifecycle checks trace every source/dependent +publication and status transition during stale injection at cleanup start, +held cleanup and restart; a fresh dependent succeeds while the old one remains +terminal. A stale provider-error RED preceded the subscription fencing repair. + +The [interleaving follow-up](oracle-reviews/2026-10-27-convex-lazy-interleavings.md) +adds four receiving families and supplies the current source binding for the +shared receipt model/driver. `lazy-paging-interleaving-oracle.property.test.ts` +varies legal response delivery, actual acceptance/application, and cancellation +orders independently. An authored uncancelable in-flight obligation distinguishes +abort before acceptance from withdrawal of already accepted work. Previously +initiated work may establish a receipt after abort. The generic core model +permits fulfillment after abort once facts, obligations and receipts apply; +native/controlled receivers separately check their cancellation outcome policy. +Borrowed/first-only receipts and premature in-flight cancellation fail promised +observations. Extra waiting on a future peer receipt is contract-permitted; +the earlier injected-edge control is not claimed as a rejected waiting strategy. + +`lazy-paging-native-receipt-oracle.property.test.ts` feeds actual native batches +and core receipts into that same scientific model. Independently authored +required versions absent at acquisition have unique supplying batches, which +can establish two callers. It covers cached zero new edges, a shallow cached +caller behind a deeper held peer, canceled/successful shared callers, and explicit +cache revalidation to a new version. Early or delayed cached settlement is +permitted; responsive post-drain completion is required. Its hostile native +receiver returns the already applied startup receipt instead of the actual +held receipt. Native attribution here excludes idempotent/superseding facts, +reentrant commit observations and cleanup abandonment; the older controlled +receiver retains idempotent receipt coverage and its all-responses-first profile. + +`lazy-paging-version-model-oracle.ts` owns coherent authored revisions and their +monotone public frontier. `lazy-paging-version-oracle.property.test.ts` checks +every source publication after full lazy acquisition, with multiple accepted +revisions held behind real optimistic transactions, both SDK shapes and skipped +intermediate revisions. Old complete values may remain while held; rollback +and mixed versions fail even when final values are correct. The two hostile +controls inject wrong public observations; they calibrate the recorder/model, +not a production pager mutation. This profile keeps keys/order stable. General +membership changes and partial-prefix retention retain their existing owners. + +`lazy-paging-failure-history-oracle.property.test.ts` composes preacceptance +failure, two owners' shared held batch, cancellation, peer retirement, provider +error, held cleanup, stale callbacks and restart. Every old dependent value and +status event is checked for terminality; a fresh graph succeeds. Ordinary +native errors may recover on a valid coherent result. It does not require an +ordinary rejected attempt to wait its receipts. Its terminal controls calibrate +the model; production stale-callback mutants remain in the prior repair record. + +Core `tests/query/load-subset-joint-replay-oracle.property.test.ts` receives the +authoritative truncate law through real source commits and two live owners. +It composes older uncancelable participants, held receipts, failure followed by +ordinary writes, owner retirement, retry and held cleanup/restart. Newest-only +publication and ordinary-write reopening production mutants fail the public +snapshot comparison. Native Convex does not issue same-run authoritative +truncates; these are distinct receiving profiles. Scientific models hold facts, +obligations and allowed publications; transport/pager mechanics stay in drivers. +No bounded campaign establishes arbitrary nonquiescent temporal progress or +every formal schedule product. + +`e2e/lazy-concurrency-oracle.test.ts` now receives the same receipt and version +models at the actual isolated Convex backend through both real SDK clients. +Each generated history runs both SDKs, with independently authored stable +rows/ranks, varied page sizes/text, shared full acquisitions, real optimistic +application holds, post-acceptance cancellation and a cached shallow peer. +Before cancellation, unique required facts establish the actual shared held +receipt and both callers' missing-fact sets are empty. Cached extra waiting is +permitted. After full acquisition, atomic test-server full-relation mutations +offer revisions before writing; two distinct accepted revision witnesses are +held before application, and the independent writer advances during reader TCP +loss. Every active source publication feeds the unchanged version/provenance +comparisons. Actual callback snapshots must contain the drained and reconnected +revisions; manual checkpoints cannot supply that reach evidence. Actual receipt +fulfillment supplies application, even when a newer FIFO revision is already +public before the Promise callback runs. The normal family has matching fixed +and random four-history campaigns, with both SDK receivers in every history; +`CONVEX_BACKEND_CONCURRENCY_ORACLE_SEED/PATH` replays directly. Removing applied +waiting and dropping the first updated row are production mutants rejected by +the receipt and version laws respectively through both SDKs, with shrink/replay +and byte-for-byte restoration. The [backend concurrency record](oracle-reviews/2026-10-27-convex-lazy-backend-concurrency.md) +binds sources, commands and results. + +This backend receiver preserves the stable-key, fully acquired revision profile. +It does not claim arbitrary acceptance/application orders, idempotent receipt +attribution, accepted cleanup abandonment or native application/provider errors. +TCP loss tests transport recovery; it does not promise a provider-error callback. +The broader combined failure grammar retains its controlled receiving profile; +the boundary receiver below adds the after-acceptance query-error/cleanup subset. +Other real backend source/window mutation and framework boundaries retain their +existing owners. + +The [boundary-strengthening record](oracle-reviews/2026-10-27-convex-lazy-boundaries.md) +extends these receiving profiles without importing pager mechanics into the +scientific models. `lazy-paging-revalidation-oracle.property.test.ts` attributes +actual native commits made inside isolated explicit revalidation calls, including +equal-valued accepted writes and later supersession. Actual receipt fulfillment +proves application; a historical value need not remain visible at its Promise +callback. Cached reuse may accept another receipt or owe zero new receipts. +Every history exercises both controlled SDK shapes, then an unheld reactive +revision, with callback-only publication witnesses and retained semantic errors. +This is not evidence that a real Convex backend emits equal-valued query results, +nor general attribution of asynchronous/reentrant native receipt edges. + +`e2e/lazy-boundary-oracle.test.ts` receives ordered windows computed by +`expectedLazyRows` and the sequence-observation mode of `LazyVersionModel`. +It never compares the whole retained partial source to a full server revision. +Every base publication separately receives the real SDK provenance judgment. +Generated histories keep an actually unread suffix while atomic server edits +insert, delete and move rows across the window. Window growth remains pending +on a real held application receipt while two or three later revisions arrive. +The same episode then composes two full callers' shared held acceptance, caller +abort, a genuine reactive server query error, cleanup-abandoned actual receipts, +and a fresh graph while the old graph stays terminal. `LazyReceiptModel` now +distinguishes pending, applied and cleanup-abandoned receipts; cleanup authority alone +does not discharge an unobserved receipt. The shared `LazyTerminalGraphModel` +supplies the old graph judgment to both controlled and backend receivers. + +Both new families run matching fixed/random campaigns, direct seed/path replay, +and both SDKs in every history. Selectors are `CONVEX_REVALIDATION_ORACLE_SEED/PATH` +and `CONVEX_BOUNDARY_ORACLE_SEED/PATH`; `CONVEX_LAZY_ORACLE_RUNS_MULTIPLIER` +expands both campaign budgets equally. Standard package oracle scripts discover +them; the existing browser CI lane runs the backend receiver via `test:e2e`. +Calibration distinguishes unchanged-content application shortcuts, lost native +deletions, and a separate stale-publication perturbation that ends with correct +rows. Exact commands, source hashes and outcome classifications are in the +record. This does not close arbitrary scheduling, rank ties in the new backend +family, provider errors before acceptance, adversarial stale real-SDK callbacks, +same-run authoritative truncate, or React rendering boundaries. Those retain +their existing owners; the controlled failure family remains broader than its +new backend receiving subset. + +The [application/cancellation follow-up](oracle-reviews/2026-10-27-convex-lazy-app-abort.md) +adds three receiving families motivated by issue #2269. `LazyWriteModel` in +`tests/lazy-paging-write-model-oracle.ts` independently owns the authored backend +ledger and the composition of source revisions, requested windows and one +optimistic operation. Backend acknowledgment and optimistic settlement are +different boundaries. The publication relation retains every compatible +monotone source/window explanation; rejecting an optimistic operation may reveal +an older applied source without permitting source rollback after a newer view. +Its comparator controls accept that legal history and reject atomic mixing, +source rollback, lost confirmed writes and remote effects of atomic rejection. + +`e2e/lazy-app-write-oracle.test.ts` receives that model through both actual SDKs +and the isolated backend. Each history includes confirmed/rejected Collection +insert, update and delete handlers, independent visible/unread remote edits, +demand growth behind a real held receipt, and an unread suffix. Successful +handlers must expose the correct source key at application settlement while +real SDK caches are current and notifications are withheld. Every graph +publication is judged; the backend is independently checked against the ledger, +and drained windows against its exact projection. Rejected server transactions +throw after attempting their writes, exercising real atomic rollback. This is +not a promise to undo arbitrary handler side effects after a remote commit. +Limits: unique ascending numeric ranks, one application operation at a time, +bounded strings, no rich-value/reload/health API coverage. + +`tests/lazy-paging-load-outcome-oracle.property.test.ts` compares the same +generated failed-replay/ordinary-write/retry history under synchronous throw and +Promise rejection, through direct subscriptions and live queries. It checks +original error identity, every public value, failed-publication retention and +successful recovery. The comparison permits their different acquisition lease +lifetimes. Initial-load equivalence and arbitrary reentry retain the core +lifecycle owners; this new family claims the replay boundary only. + +`tests/lazy-paging-abort-replay-oracle.property.test.ts` receives preacceptance +cancellation through both controlled native SDK shapes, followed by two real +same-run core truncate replays with disjoint source keys. Abort settles before +withheld delivery; no-demand delivery accepts no rows. Retired success/error +callbacks after each successful replay cannot resurrect rows, poison current +sync or read a retired React watch. Native Convex does not initiate these +truncates; the driver supplies that core input. This is no claim about actual +SDKs emitting adversarial stale callbacks or about bounded work during replay. +The existing receipt model still owns accepted writes. This checkout's older +core waits for uncancelable baselines; #2269's immediate-core-abort decision +belongs to integration with unmerged #2168. The new family exercises their +shared cancellable-adapter obligation without silently changing either law. + +All three families run fixed/random campaigns with direct seed/path replay and +declared reach. The follow-up records three rejected production decisions +(early handler confirmation, swallowed throws, missing retirement fencing), +a misdirected-write driver calibration, shrunk replays and restored GREENs. + +`tests/sdk-cache-oracle.test.ts` supplies both real SDKs' coherent cache-before- +callback premise through legal controlled multi-query wire transitions. +`e2e/lazy-paging-oracle.test.ts` receives shared value/coverage/work judgments +through both SDKs and the isolated local backend: unread finite tails, window +movement, live delete/move, real required/recommended splits, native leading ties +with a local trailing order, and same-run TCP reconnect. Native work checking is +limited to stable-source acquisitions; mutation/reconnect phases compare values +and coverage at settled cuts. `packages/react-db/tests/convex-lazy-paging-oracle.test.tsx` +receives exact data, every page, page parameters and lookahead through +`useLiveInfiniteQuery`, checking every committed ready tuple, across provider +page sizes 1–4. Other framework receiving paths are untested under these owners. + +The eager mirror and retained lazy prefix default to maxRows 10,000. Retained +ranges stay subscribed until cleanup; this is no network-byte, peak-memory, +total-call, scan or lifetime subscription-count bound. Whole-source requests +may exhaust the query. Core can issue such a request after source restart even +for a terminal dependent: the graph remains terminal but its source subscription +can survive. The cleanup driver accounts for that actual demand. Terminal graph +publication is covered; zero post-error acquisition is not promised here. +General query translation, arbitrary application filters/orders, nullable/string +leading orders and array select in lazy mode are excluded by the public profile. +An incompatible external truncate wrapper is not a Convex reset mechanism. +The existing core load-subset replay, transaction, ordered lifecycle and cleanup +owners remain primary for replay/failure gates and delayed cancellation. Their +458-test delegated run is distinct from native Convex coverage. Responsive- +provider quiescent stalls fail a named progress comparison; continuously +nonquiescent work remains a diagnostic limit. The frozen TLA+ design projections +are analytical evidence, not proof of this production implementation. Validation at the initial review: 43 adapter checks, 302 offline-engine tests, and seven real-backend tests passed. The October 23 strengthened backend suite passes nine tests with 36 generated crash histories. Final local validation passes 62 adapter checks and 302 offline-engine tests. Exact-head review records accompany this follow-up. The native-value receiving oracle rejects a scalar-as-JSON decoder at the typed-value comparison (seed 20261022/path 0:0); diff --git a/docs/contributing/oracle-reviews/2026-10-26-convex-lazy-paging-source.json b/docs/contributing/oracle-reviews/2026-10-26-convex-lazy-paging-source.json new file mode 100644 index 0000000000..4eb777369c --- /dev/null +++ b/docs/contributing/oracle-reviews/2026-10-26-convex-lazy-paging-source.json @@ -0,0 +1,29 @@ +{ + "starting_head": "8c24922026fb4a714cb54b992ac85faa2e876742", + "binding": "reviewed working tree; SHA-256 file contents; excludes local install override and this manifest/record", + "files_sha256": { + ".changeset/convex-collection.md": "c7ad56ad983fe653f0054f0a82fcd47cad46c1d59087d62d79a21ffb077eb324", + "docs/collections/convex-collection.md": "3901d5d040f8e101369f1a16a160d4e5d0df391ec48408f99d995191ad013105", + "docs/contributing/oracle-coverage.md": "ea22bb4760150ccc90bfc461c39aa6a435c5b74953a08b378052e6d0523569d7", + "packages/convex-db-collection/README.md": "53b984ef9156a52cea7cb92a81d34d6543508d2dbf048d8f2aeb361ae996d259", + "packages/convex-db-collection/e2e/lazy-paging-oracle.test.ts": "7a5c1390879e4e01757d020e22bfa24f5852a2cf5bb4e18faa4174ce47a02e48", + "packages/convex-db-collection/e2e/local-backend.ts": "e38be0c546a885f55b004f23bc894ab86d3c3b5880bfd9dde8208866a42aea5d", + "packages/convex-db-collection/package.json": "1ca78ea8f3805fc6839852025d63d852b9f756ce1dd35742a527f74ab6c71300", + "packages/convex-db-collection/src/index.ts": "4247e2bbd168a80791861f4553c10d0d878504a7cdc6a65d4bc648e15b1e51ba", + "packages/convex-db-collection/src/options.ts": "ea2c2efc4d574080c5e03da8e121b79406640c667a1e260ead9eff22f6253d33", + "packages/convex-db-collection/src/paginated-options.ts": "de0e92849d363f2545823d96829964084b1a115b890f0f521f9bdc0958ea2b76", + "packages/convex-db-collection/src/pagination-demand.ts": "d2be74c1f3610c153c2b7c3c9411aa6ea216db7e76c9b0a7f32d63ef2bde57ce", + "packages/convex-db-collection/src/pagination.ts": "0308668d07db701d1f7f891d001a4aca2798973ea29bf1d4b00931eaa0e05f1d", + "packages/convex-db-collection/src/subscription.ts": "5e9c72f271ff97721d25bb6a00b190633e79166254ca17173df7ffb5d4c19f57", + "packages/convex-db-collection/tests/lazy-paging-driver.ts": "2c0c8f928845daaaa54605273d01c86e2f9b576137466ed7867e262f39aaa7f7", + "packages/convex-db-collection/tests/lazy-paging-model-oracle.ts": "7410e80bd8ae376a7d7960db3bbc0dee7aecf9ece16038e2cdf90279266cb40d", + "packages/convex-db-collection/tests/lazy-paging-oracle.property.test.ts": "53e1c496514225ef153529e3d3ce361d67d4e01ec3a90ac5becc1c19ae5b8867", + "packages/convex-db-collection/tests/sdk-cache-oracle.test.ts": "0e011a20ce991195742bdfa0cd570d831105604ad8e3e2cc4b8fab4b62daad58", + "packages/convex-db-collection/tests/types.test-d.ts": "3ff1a42d6caf95d14e5270b632340524a9f796b978ccd031835bd1f2d0dab6dd", + "packages/convex-db-collection/tsconfig.json": "623bc70a72fd718e7d209c90a08df12cced0e9045a3565058623e34e946a5710", + "packages/convex-db-collection/vitest.lazy.e2e.config.ts": "e377ee3a835edd1215c48654db863d9ed27406ca67ae3b34c846e4ed253a030c", + "packages/react-db/tests/convex-lazy-paging-oracle.test.tsx": "0a33955d45816d766100b89e45dd63ce32125864cf3e2f0ea46533a582b0333b" + }, + "analytical_freeze_verified": true, + "local_install_files_unchanged": true +} diff --git a/docs/contributing/oracle-reviews/2026-10-26-convex-lazy-paging.md b/docs/contributing/oracle-reviews/2026-10-26-convex-lazy-paging.md new file mode 100644 index 0000000000..43faf31f19 --- /dev/null +++ b/docs/contributing/oracle-reviews/2026-10-26-convex-lazy-paging.md @@ -0,0 +1,255 @@ +# Convex lazy paging implementation evidence — 2026-10-26 + +This implements the maintainer-approved fixed-query lazy-paging design on PR +2230, starting at `8c24922026fb4a714cb54b992ac85faa2e876742`. The companion +[source manifest](2026-10-26-convex-lazy-paging-source.json) binds this record to +the reviewed working-tree files; it excludes the record itself and the preserved +local installation override. Earlier analytical design documents and their +63 TLA+ configurations remain frozen. Their original statements about work +not yet executed are historical; this record supplies implementation evidence. + +## Law and boundary + +`convexPaginatedCollectionOptions` supports `syncMode: 'on-demand'` using the +existing pager and subset protocol. One fixed server query declares its finite +numeric leading order. DB can order trailing terms locally because an acquired +window establishes every row tied at its leading boundary. Offset requests +establish the preceding prefix; whole-source requests may exhaust the query. +General query translation and arbitrary application filters/orders are outside +the initial public profile, as is array `select` in lazy mode. + +Authority is the approved October 26 design, now stated in the +[collection guide](../../collections/convex-collection.md), the public +`LoadSubsetOptions`/`LoadSubsetFn` and applied-receipt contracts in +`packages/db/src/types.ts`, and ordered window publication in +`packages/db/src/query/live/ARCHITECTURE.md`. + +A successful acquisition establishes its exact scope and waits for its actual +establishing writes to apply. Abort cannot withdraw accepted writes. No demand +or a zero limit starts no page. A new successor range needs delivered evidence +that an active scope remains incomplete. This is not a total call, memory, +network or scan budget. Cached completion, one batch and multiple actual batches +are legal. A live query retains its previous complete window while acquisition, +movement or repair is pending. Cleanup fences obsolete callbacks, including +after restart, and the old dependent graph stays terminal. + +The independent model orders authored rows for value/coverage checks. Its +separate work relation sees only delivered rows and whole-source exhaustion; +it cannot consult the hidden suffix. Opaque cursor intervals belong to the +transport driver. Production cursor parsing, expression evaluation, pager +state and source Collection rows do not compute expected results. + +## Grammar, observation and measured reach + +Five properties each run 60 fixed-seed histories (`20261026`) and 60 histories +without a seed, using the same arbitrary, recorder, comparison and budget. +Every declared state below was reached by **60 histories in each campaign**; +`createOracleReach` enforces a per-campaign minimum of one. These are 600 +property histories, not a universal proof. + +| Family / replay prefix | Declared states | +| --- | --- | +| `CONVEX_LAZY_VALUES_ORACLE` | offset-crosses-page; scoped-unlimited-tie; continuation-scope; whole-source-exhausted; authoritative-empty; required-split; recommended-split; short-nonterminal; unsupported-shape-rejected; rejected-unload-preserves-peer | +| `CONVEX_LAZY_WORK_ORACLE` | no-demand; zero-limit; finite-with-unread-tail; gap-free-growth-across-page; shallow-read-during-growth; peer-survives-release; same-object-pending-attempts | +| `CONVEX_LAZY_PUBLICATION_ORACLE` | pending-initial-window; pending-window-move; pending-boundary-repair; completed-boundary-refill; coherent-cache-before-callback; refetch-cache-before-callback | +| `CONVEX_LAZY_LIFECYCLE_ORACLE` | actual-establishing-set-checked; abort-before-acceptance; abort-after-acceptance; stale-after-restart; same-run-reconnect-recovery; cleanup-start-fences-callback; held-cleanup-fences-callback; old-dependent-stays-terminal | +| `CONVEX_LAZY_RECEIPT_ORACLE` | reentrant-second-held-receipt; cached-load-waits-for-latest | + +The arbitrary bounds source size to 16–32, provider page size to 1–5, base +offset/limit to 1–5, both directions and callback orders, 32 independently +authored texts of length 0–8, and a 0–8-step limit suffix with values 1–12. +Values use adjacent two-row ties, explicit scoped ties/continuations, a separate +empty source, and short/split scenes at page size four. The offset scene adds +the page size and observes successor work before crediting a crossed page. +Work uses unique ranks, page size four, top-three then top-six, a held deeper +tail and a healthy cached peer; its generated suffix moves finite limits. +Publication uses page size two and unique ranks to force held initial/moved +windows, deletion of all acquired rows and refill, then cache-only refetch. +Lifecycle uses generated page partitions and direction with two abort cuts, +reconnect, held cleanup and restart. The receipt family varies rows, text, +direction and page size, opening the second optimistic hold synchronously from +the first batch's actual public publication. + +Not every shared arbitrary field contributes to every family: offset/limit and +reverse delivery belong to values; reverse also belongs to publication; the +suffix belongs only to work. No semantic credit is claimed for ignored fields. +Removing ties loses coverage-vs-display discrimination; removing holds loses +early settlement/publication; removing growth loses gap-free continuation; +removing reentry loses the older-receipt overwrite; removing cleanup/restart +loses run authority. Constructive prefixes preserve those witnesses under +shrinking. Invalid directions, counts, filters and cursor predicates are nearby +negative controls. Invalid numeric caps and malformed order declarations have +separate bounded configuration checks, including every startup status event. + +The actual factory, Collection `_sync` normalization and ordered live-query +entry points run production. Every public live publication and checkpoint read +is retained. Completion checks reject a responsive-provider quiescent stall by +name. The fixture's nonquiescent turn guard is diagnostic, not a product cap. +`withTestCleanup` preserves primary failures and secondary cleanup diagnostics; +held gates and dependent resources are released on failing histories too. + +## RED, repairs and hostile controls + +The original eager runtime failed the new work oracle at the no-demand +comparison. A second calibration enabled only the proposed public type shape, +leaving runtime unchanged, and reproduced that semantic failure. This is RED +for a newly proposed capability, not a claim that the original public API +already advertised lazy mode. The log is `red-public-enabled.log`. + +Further extended scenes produced semantic RED before their repairs: + +- The split scene rejected an unbounded right split child as an unrelated new + successor (`split-witness1.log`). Children now stay within the parent's + returned interval. The existing eager oracle then caught missing continuation + after bounded split children (`green-campaigns2.log`); eager exhaustion was + restored without changing that oracle's expectations. +- Submitting the same immutable request object twice stranded its first waiter + at the named progress comparison (`duplicate-red.log`). Pending duplicate + attempts now share completion. +- A public callback opened a second optimistic hold and installed a newer + source revision before the older receipt continuation ran. A cached third + load incorrectly settled with the old row (`receipt-red.log`). The older + callback now clears `visible` only if it still identifies that receipt. +- Invalid lazy startup emitted loading → ready → error before validating its + cap (`invalid-cap-red4.log`). Subscription construction now precedes the + empty initial acceptance. The first acquired page still does not replay an + initial truncate. Earlier missing-import attempts were setup failures; + assuming `preload` must preserve the adapter's RangeError class was an + overly specific expectation. The final check preserves the adapter error and + checks the actual readiness law. +- Malformed runtime order declarations failed rejection before validation + (`order-runtime-red.log`). The type-level numeric-field check also first + failed as an unused `@ts-expect-error` (`types-order-red.log`). + +Seven temporary production mutants were each run, shrunk, replayed directly, +restored byte-for-byte, and checked with a repaired direct replay: + +| Mutant | Intended failure | +| --- | --- | +| eager startup | no row range without demand | +| unbounded right split child | unrelated successor at the work comparison | +| settle at boundary without its full tie | missing/current row at coverage | +| skip applied receipt | load settled before accepted rows applied | +| older receipt overwrites newer held receipt | cached load skipped newer establishing receipt | +| overwrite pending same-object attempt | quiescent responsive provider left load pending | +| stop after first sealed page | same named progress comparison | + +All seven were semantic comparison failures, not timeouts, setup failures or +unreached paths. All mutant direct replays failed and all repaired replays +passed. Each used seed `20261026` and this 34-component shrink path: + +```text +0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0 +``` + +For example, from `packages/convex-db-collection`: + +```sh +CONVEX_LAZY_RECEIPT_ORACLE_SEED=20261026 \ +CONVEX_LAZY_RECEIPT_ORACLE_PATH=0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0 \ +../../node_modules/.bin/vitest run tests/lazy-paging-oracle.property.test.ts \ + -t 'keeps the newer establishing' --coverage.enabled=false --typecheck.enabled=false +``` + +The audit's original findings and closure were checked independently by the +Query and lifecycle reviewers. Review also repaired timeout-only progress, +nonconstructive offset reach and a vacuous/final-only React publication check. +The current React recorder requires an actual committed ready target tuple. + +## Receiving witnesses and validation + +The SDK receiver feeds legal multi-query wire transitions through both real +SDKs and checks every callback against the shared publication relation. It +positively observes the callback comparison. This closes the coherent-cache +premise at the SDK boundary; it does not stand in for server cursor evidence. + +The backend receiver uses Convex 1.47.0 and cached local backend +`precompiled-2026-10-10-72ce13c`. Both clients passed no-demand, finite unread +prefix, offset growth, live delete/move, required/recommended splits, a native +four-row leading tie with DB's independent trailing client-ID order, and real +TCP reconnect. Stable-source work checking observed three permitted successor +starts per client. The strengthened run observed eight/ten required-split +responses and four native tie rows per client. Response counts can repeat; +they are not a call or split-count guarantee. + +React's actual `useLiveInfiniteQuery` receives the shared authored values for +provider page sizes 1–4, seven rows with ties and four displayed page counts. +Every committed ready data/page/parameter/lookahead tuple must be the complete +old tuple until the new one appears, then cannot revert; the target tuple must +actually occur. Settled fetching flags are also checked. + +Executed validation: + +- Complete adapter runtime: **10 files / 77 tests**, including all five + fixed/random lazy campaigns and existing eager, snapshot, ownership, offline + and native-value owners (`runtime-final1.log`). +- Full isolated backend suite: **5 files / 20 tests**, including 36 durable + crash/replay histories, pinned public apps and real auth + (`backend-full1.log`). The strengthened native-tie receiver subsequently + passed both clients (`backend-native-ties.log`). +- Core replay, transaction, ordered lifecycle and cleanup owners: + **5 files / 458 tests** (`core-owners.log`). This is delegated core evidence, + not native Convex replay coverage. +- React receiver and existing infinite-query tests: **17 tests** + (`react4.log`); the strengthened every-ready-tuple receiver passed its four + cases (`react-final.log`). +- Package TypeScript including public type assertions: + `tsc --noEmit --rootDir ../..` passed (`types-final.log`). Stock Vitest + typechecking had the same workspace rootDir errors on baseline; runtime + campaigns disable that runner and use this separate successful check. +- Changed-file ESLint, `pnpm lint:changed`, formatting and `git diff --check` + passed. The oracle lint script prints a harmless missing local `main` + diagnostic while successfully using `origin/main`. +- Both required minified size comparisons built successfully. Combined exports + are **4,096 gzip / 3,700 brotli bytes**, versus no package at the origin/main + merge base. Against the starting PR revision they grow **1,228 gzip / + 1,106 brotli bytes**. Existing app scenarios change by zero bytes. The exact + baseline uses archived source and the same installed dependency versions. + +The existing page chain and snapshot writer supply both modes; there is no +second pager, factory, mutation queue or recovery state machine. New bytes pay +for explicit capability parsing and coverage/waiter/receipt obligations. The +split, tie, progress and receipt mutants show why removing those distinctions +would violate the feature. Test/driver/documentation growth is outside the +shipped measurement. + +Local install state was preserved byte-for-byte: the pre-existing +`baseline-browser-mapping` override selects **2.11.30 instead of 2.11.32**. +Neither the override nor lockfile changes belong in this implementation. +Missing test/build dependencies were reused at their declared installed +versions through ignored local links; no new dependency downgrade was made. + +## ORC audit and limits + +| Requirement | Outcome | +| --- | --- | +| ORC-001 | Authority and excluded profile are beside the model and in the public guide. | +| ORC-002 | Authored array/window recomputation and delivered-only work knowledge import no pager semantics. | +| ORC-003 | Contract, model, grammar, driver and comparison prose remain in the directly named executable modules. | +| ORC-004 | Constructive witnesses, bounds, per-family axis use, ablations and invalid neighbors are documented above. | +| ORC-005 | Real Collection/live-query path, every publication/read, named pending cuts and positive receiving observations. Installed source facts are read before comparison; no concrete unresolved missing-input hypothesis remains within these cuts. | +| ORC-006 | Seven semantic mutants, direct replay and restored GREEN; wrong-result controls reject missing ties and transient windows. | +| ORC-007 | Same fixed/random property and budget; checked seed/path replay without a command replayPath because no fc.commands grammar is used. | +| ORC-008 | Value model is stateless recomputation. The work recorder retains delivered knowledge, exhaustion and exact active scopes because each changes next successor permission; public trace keeps whether the new window appeared because it changes allowed observations. | +| ORC-009 | Delivered evidence is provider-visible knowledge; applied receipts are actual core promises; transport cursor tables are authored intervals, not DB cursor state. Scope/coverage/publication remain separate. | +| ORC-010 | Shared cleanup helper preserves primary/secondary failures; gates, requests, dependents, sockets and backends release during shrinking. | +| ORC-011 | Shared hidden-suffix fault is challenged by paired suffix controls and delivered-only work judgment. Native cursors and local trailing ties use real server queries as receiving evidence. The frozen formal projections are distinct analytical formulations with their own limits, not a production equivalence claim. | +| ORC-012 | This versioned record, source manifest, findings/repairs, validation and explicit remaining limits bind the claim. | +| ORC-013 | Adjacent boundary ties, zero/nonzero, page-crossing growth, before/after acceptance, cached newer-held receipt and unchanged-source refetch distinguish the plausible wrong rules; mutants fail there. | +| ORC-014 | Real SDK cache scheduling, native splits/ties/reconnect and actual React ready tuples supply the named controlled-premise handoffs. Host replay/delayed cancellation remain delegated, not silently claimed. | +| ORC-015 | All 33 declared states reach 60 histories per fixed and random campaign, with enforced minima. | + +No combined unbounded proof follows from these finite tests or the TLA+ +projections. Continuous nonquiescent resealing has only a diagnostic guard. +Independent range loading, other framework receivers, general query +translation and broader provider/host timing remain outside this claim. + +An arbitrary external truncate wrapper is incompatible with the snapshot +writer's private previous/initialized state and is not a native Convex reset +mechanism. It earns no replay coverage here. The primary core replay owners +cover failed replay gates, authoritative recovery and delayed participation. +The adapter supplies real batches and prompt cancellation; it does not invent +multiple progressive commits for every acquisition. Source restart can cause +an errored dependent's retained subscription to request the whole source; +its graph remains terminal. The cleanup driver explicitly accounts for that +actual demand and makes no zero-post-error-acquisition promise. diff --git a/docs/contributing/oracle-reviews/2026-10-27-convex-lazy-app-abort-source.json b/docs/contributing/oracle-reviews/2026-10-27-convex-lazy-app-abort-source.json new file mode 100644 index 0000000000..3dc3f052d9 --- /dev/null +++ b/docs/contributing/oracle-reviews/2026-10-27-convex-lazy-app-abort-source.json @@ -0,0 +1,433 @@ +{ + "starting_head": "8c24922026fb4a714cb54b992ac85faa2e876742", + "binding": "SHA-256 of final dirty local sources; this manifest and the unchanged local pnpm override/lockfile workaround excluded", + "files_sha256": { + ".agents/skills/oracle-authoring/SKILL.md": "bdaf4cc887a9e89ade347eb938cb37a0de480de617e6bc8128af953dac90420d", + ".agents/skills/oracle-review/SKILL.md": "cf1f034838b5d04b6f95c4123bf32c18623a791b3bc5a02888bd43709f9308fb", + ".changeset/convex-collection.md": "c7ad56ad983fe653f0054f0a82fcd47cad46c1d59087d62d79a21ffb077eb324", + ".github/workflows/e2e-tests.yml": "62f257aab4d6629d4227edbf29e659d3d9a4149ca103add59f5d218924ccb50f", + "AGENTS.md": "dc10902d2eebee9d8e452d8e4b934e4764647b7f9eeedf8a1bd9b5a4bf570e64", + "docs/collections/convex-collection.md": "3901d5d040f8e101369f1a16a160d4e5d0df391ec48408f99d995191ad013105", + "docs/contributing/glossary.md": "0cf3403016fa085079c72b301ef0e1c7533b4f625be1edbf66a78019a1c1e542", + "docs/contributing/oracle-coverage.md": "1d3ca3e76908285cb28fcce9bfb699b45d54857a7f4f23b32c0f23555519f05f", + "docs/contributing/oracle-reviews/2026-10-26-convex-lazy-paging-source.json": "a4af1a586c058d08644fead26679a2894209aa6cbe3803dcf1c8cbefab97bcb1", + "docs/contributing/oracle-reviews/2026-10-26-convex-lazy-paging.md": "1de8506081336953f4b1c53933375960c84af277c04cb3dc07b91b79df391810", + "docs/contributing/oracle-reviews/2026-10-27-convex-lazy-app-abort.md": "12f0d22c630c1461b32b91fb56092421a58776c45b72fec4de6f0a7ca7d2296a", + "docs/contributing/oracle-reviews/2026-10-27-convex-lazy-backend-concurrency-source.json": "b859cc163b55b7b9076a0d2c94bad1b524fd0405de39a0689c503c142975ba62", + "docs/contributing/oracle-reviews/2026-10-27-convex-lazy-backend-concurrency.md": "adea3ed30c6f5df32e5af261497afd84c5c5519ca1ef71130681e899e7eba450", + "docs/contributing/oracle-reviews/2026-10-27-convex-lazy-boundaries-source.json": "8b6780ecc7cfa6bab39524f20cba9999425507a9860ded3ed20b47e7a2485760", + "docs/contributing/oracle-reviews/2026-10-27-convex-lazy-boundaries.md": "dae5fd558a6fe1e7cc2adaff2f977496edfed349be1494994bd3f890c00c4845", + "docs/contributing/oracle-reviews/2026-10-27-convex-lazy-interleavings-source.json": "0fa1e828f4fee657e9011770bef73da2ac02cb4ca068d386d3a662c81a3e7c8b", + "docs/contributing/oracle-reviews/2026-10-27-convex-lazy-interleavings.md": "f105899875a4129b7d816cacc1d6f094c68ad6048cf70350e8d105bd0acd89e6", + "docs/contributing/oracle-reviews/2026-10-27-convex-lazy-paging-source.json": "d25abd9b66a277ef20186bc507ecbcd082f8562a7acbfd2b6d691704434a110d", + "docs/contributing/oracle-reviews/2026-10-27-convex-lazy-paging.md": "d057323e0dee9bab5cf8fbba85fa6d6c525ffc0be7843f1e351d7e48a6e42fff", + "docs/contributing/oracle-rubric.md": "0ced21586bcddb8959d380575103cc3b66e7045a3b22736a173df923847ee166", + "packages/convex-db-collection/README.md": "2b92d0ce849e926acdc2d204e09fd9af3a662c8c8314ad8db5d223e74b77d12c", + "packages/convex-db-collection/e2e/backend/convex.json": "e7106add2ff21d61a430927bb9b1ba96b036b93fcecc31ff786149a6070ecd67", + "packages/convex-db-collection/e2e/backend/convex/schema.ts": "319611bbb3886d4b5c7bdbfbccab1f42792769304e2be8db009b73baf90aa92f", + "packages/convex-db-collection/e2e/backend/convex/tasks.ts": "b2e1d75b0c225d950df2070840955bf1b26b6a827d21a88a2b1e08108ada680e", + "packages/convex-db-collection/e2e/backend/package.json": "8e2e33603aa201acb7c8d88a55130bc07275d8fb53693e0965539ae4bc4e3585", + "packages/convex-db-collection/e2e/lazy-app-write-oracle.test.ts": "ad265bbbf78b617e1adf1c5df77991c4a2ce789ce8e78423ff56249dc353700b", + "packages/convex-db-collection/e2e/lazy-boundary-oracle.test.ts": "c14b42dcfbd28e1c875204593c10d7f98ebc6323cdcba32736be8315147ddccb", + "packages/convex-db-collection/e2e/lazy-concurrency-oracle.test.ts": "69a02d34d0efd0d1881cfbd321f22aac2e820597efd2cd32fc3fa1dec745b625", + "packages/convex-db-collection/e2e/lazy-paging-oracle.test.ts": "7a5c1390879e4e01757d020e22bfa24f5852a2cf5bb4e18faa4174ce47a02e48", + "packages/convex-db-collection/e2e/local-backend.ts": "e38be0c546a885f55b004f23bc894ab86d3c3b5880bfd9dde8208866a42aea5d", + "packages/convex-db-collection/e2e/network-gate.ts": "73851db1ae46e83b7ee1bdb88faf01aaec27178644a73e1f7835f94681806dd6", + "packages/convex-db-collection/e2e/setup.ts": "d448bab619ac7243957578b2565cdb647a5e71a70df3c65af3c3dc378b5df6bb", + "packages/convex-db-collection/package.json": "0d1420d6d318ab205560bb09b8be2fac7398998c963ce670f612c73611107851", + "packages/convex-db-collection/src/index.ts": "4247e2bbd168a80791861f4553c10d0d878504a7cdc6a65d4bc648e15b1e51ba", + "packages/convex-db-collection/src/offline.ts": "55e70bdbbc310226a9f0175d0c0912218413ba67433b5b47ccc78d6e7dadd5c3", + "packages/convex-db-collection/src/options.ts": "ea2c2efc4d574080c5e03da8e121b79406640c667a1e260ead9eff22f6253d33", + "packages/convex-db-collection/src/paginated-options.ts": "de0e92849d363f2545823d96829964084b1a115b890f0f521f9bdc0958ea2b76", + "packages/convex-db-collection/src/pagination-demand.ts": "d2be74c1f3610c153c2b7c3c9411aa6ea216db7e76c9b0a7f32d63ef2bde57ce", + "packages/convex-db-collection/src/pagination.ts": "0308668d07db701d1f7f891d001a4aca2798973ea29bf1d4b00931eaa0e05f1d", + "packages/convex-db-collection/src/subscription.ts": "a0c312caf2af3667c18e96c565ceb159c20d65550426cdd44aca69a43a3543e5", + "packages/convex-db-collection/tests/helpers.ts": "11f2c79c6872373e8f77efae62d2fabfead0e5af6673c86920ae2ef4abdb7274", + "packages/convex-db-collection/tests/lazy-paging-abort-replay-oracle.property.test.ts": "ba40916ea9c3d813d5f9df2c86ee1c6b849fd1decede97f3477f25cddeb432c3", + "packages/convex-db-collection/tests/lazy-paging-campaign.ts": "bf5dc54dd05db268c88ac1b8b9b32aa5a2765245658608e52163089d4601d63c", + "packages/convex-db-collection/tests/lazy-paging-driver.ts": "f8d04cdd895a06fedcae907b2416ca550191ad5fadf8215ad29953cd1a739a6f", + "packages/convex-db-collection/tests/lazy-paging-failure-history-oracle.property.test.ts": "65ff6c00d61014e841684ec7783f3175b7a013183f4bf671bcbeedb45212f272", + "packages/convex-db-collection/tests/lazy-paging-history-oracle.property.test.ts": "5bc8808d6f71648f48e40acb05943132448f3e8f5bba31d97e261f1741674384", + "packages/convex-db-collection/tests/lazy-paging-interleaving-oracle.property.test.ts": "f3a0238452746233224bc0b21adf790b594cacef82d786a0ba419d19980bb773", + "packages/convex-db-collection/tests/lazy-paging-load-outcome-oracle.property.test.ts": "bdccff66dcc336a2ebd687e31d5bddac0f0eedb096b15e503a40b53ed714c521", + "packages/convex-db-collection/tests/lazy-paging-model-oracle.ts": "7a0ce99a3442b15d516f03f0494b5a5cb39cc7106452ce50e257af79a014fc7b", + "packages/convex-db-collection/tests/lazy-paging-native-receipt-oracle.property.test.ts": "5714e2c25507f1ea4aef5bb6eb9bed69bc9a9b079540667570227aa1014a0822", + "packages/convex-db-collection/tests/lazy-paging-oracle.property.test.ts": "3a7b5b6284076bc8dbdb59ac1d735f509c6bc19201ad355a0382e7a797a2d569", + "packages/convex-db-collection/tests/lazy-paging-receipt-model.ts": "b3714d83e0a90ceb9184a1701059f027907ff663ce829fa58895007147cf1453", + "packages/convex-db-collection/tests/lazy-paging-receipt-oracle.property.test.ts": "06b60b0a86926070dd57dd068f4abe572d48924e2cea29acb77f417bf8115d6a", + "packages/convex-db-collection/tests/lazy-paging-revalidation-oracle.property.test.ts": "130b4e11498b9983cf123d03dd116957f089ac14c18ed7cbbd4ed6a165bdc10a", + "packages/convex-db-collection/tests/lazy-paging-version-model-oracle.ts": "5aee0ba04f556d312e25f03c6077949d47daf6d0e4eae6cd3dad0a9fe9ce86a0", + "packages/convex-db-collection/tests/lazy-paging-version-oracle.property.test.ts": "8f4f115806a65dc71b3382dade7a89e326decc2b3c25505369783f30163a210e", + "packages/convex-db-collection/tests/lazy-paging-write-model-oracle.test.ts": "134d1a0cad9e8397657df4fba055ecbaf1587244dfd0b68c3c481025cd56b163", + "packages/convex-db-collection/tests/lazy-paging-write-model-oracle.ts": "7220c5df1f39b1ee12159d63c7272afec157221e91bec8d7b22e51be7e09cc93", + "packages/convex-db-collection/tests/sdk-cache-oracle.test.ts": "0e011a20ce991195742bdfa0cd570d831105604ad8e3e2cc4b8fab4b62daad58", + "packages/convex-db-collection/tests/types.test-d.ts": "3ff1a42d6caf95d14e5270b632340524a9f796b978ccd031835bd1f2d0dab6dd", + "packages/convex-db-collection/tsconfig.json": "623bc70a72fd718e7d209c90a08df12cced0e9045a3565058623e34e946a5710", + "packages/convex-db-collection/tsconfig.test.json": "5168aca44188a9be3b0bee215bfc2f222010dedb8f4e1fc08029c6873dcb6cf1", + "packages/convex-db-collection/vite.config.ts": "3f4d5abb1fb86d41ee205bf6364e6f38ffb8de98f5d42cb3d233e52e674902bf", + "packages/convex-db-collection/vitest.e2e.config.ts": "b43b7b797860455efa827774e9847975bd9850f81b9b284f43665c422e45b440", + "packages/convex-db-collection/vitest.lazy.e2e.config.ts": "f70fe4252fa4814f8a48c0a264de8384868c9f47abe7166bd2e3363c211f4176", + "packages/db/package.json": "bb6df01ff9589c8b75437683c935b8457f65e9216d37173a4730e1a169e0ab43", + "packages/db/src/collection/subscription.ts": "c446932a8a1f8b2efd79d00846f1a110e65a6c6df75d2b24658053e6964b530b", + "packages/db/src/query/live/ARCHITECTURE.md": "aa6a41b651809819d414d364421140bab02f9b992e9f5dbe73cf634b43c46478", + "packages/db/src/types.ts": "2b4c5926922db049e7ccd58c3e3fc22503f6d8a9400f3fb01900e82da85c7a1e", + "packages/db/tests/collection-subscription-lifecycle-grammar-oracle.ts": "39d6bcdbeeab1d4121618b23d392b1bf38eade5361b32ece9ee6d8f5e804ea96", + "packages/db/tests/optimistic-history-oracle.ts": "ccfbfd1e62f7692a49cc9fbe4bf7c8440e9631b4fe4d1ed369f60c2ee4a9da10", + "packages/db/tests/query/load-subset-joint-replay-oracle.property.test.ts": "975585ef1d1bc921afcab2e5384318c5f661dd0a2577e642351b2aafed0ee98d", + "packages/react-db/tests/convex-lazy-paging-oracle.test.tsx": "0a33955d45816d766100b89e45dd63ce32125864cf3e2f0ea46533a582b0333b", + "scripts/agent-lint-feedback.mjs": "ec3d4783f5f3d7d71ea3e3f93ec57e300c272b219448407982a1f4247d5d82cf", + "scripts/commonjs-declarations.mjs": "3dae7c394287bd5d74467947267ab35207a6290b2fb5b12dcf7b2664115b9d66", + "tsconfig.json": "79e4282995ed94ce7e73583a6ef896db6dd8968b276f2a900de8d63493ace99b" + }, + "runtime_binding": "Prior verified DB runtime, source maps and embedded-source bindings rechecked unchanged", + "runtime_js_sha256": { + "packages/db/dist/esm/SortedMap.js": "ecbe71a4b4387932ed64817362c31eeb58c151d1fda5ba6824a491e4d6eeaf84", + "packages/db/dist/esm/client.js": "4191f1ff58a2b3eae418d2e8dc56b0fe16a290ff233129535b0945f41a34f0da", + "packages/db/dist/esm/collection/change-events.js": "cb5740b95d7c403e0ed7fd8b2aa831a921492782fc66c47bf1c91647070de0ac", + "packages/db/dist/esm/collection/changes.js": "084445baf434ffbfce6ceba34108c32dea5a58b5db68d6c068af6a8b762fff34", + "packages/db/dist/esm/collection/cleanup-queue.js": "3378b66f70261b2ce7eb14fdd982114e838ba6ce6d61ec18b0de976605cb9ba2", + "packages/db/dist/esm/collection/config-errors.js": "d6beb8dddab089d6dfb2516b5850b4b2a709b2bb2a9843bda854ad47723c35d1", + "packages/db/dist/esm/collection/events.js": "0cb3eadd7a9ce2e454e0c4a1a1b0023d2fb96724ad5a4dc806684f9ed4e47d17", + "packages/db/dist/esm/collection/index.js": "01e254fc0d024e7e716b4b09a10e7656130663ae587ad72314873f3b87f5fc2f", + "packages/db/dist/esm/collection/indexes.js": "9d1dbc166c3a44cd9348a62d96c7724335ef38c6f466575c0bbc2419e5647130", + "packages/db/dist/esm/collection/lifecycle.js": "2a26fe829e0183e77a33777f3003e1ab8699a4a55ec6fc309336b35c581ea03b", + "packages/db/dist/esm/collection/mutations.js": "42ed12785749b4098b422a538155ade6d002eaef3ad9cae227c0218f44c95e42", + "packages/db/dist/esm/collection/state.js": "d03955c898fc1672a1a708723d6e4506064665f8a24c57aea6be956f2c4dd0b4", + "packages/db/dist/esm/collection/subscription.js": "ac04fdd5bb56894afa45ecd9bef37abd4f96418c7912cd329f40e397370d27aa", + "packages/db/dist/esm/collection/sync.js": "82be78b67610f59fe60bad6e9f9776884287d38d6bc5641d1478875a559c6961", + "packages/db/dist/esm/collection/validate-config.js": "16a2b254fde0bc0d37c376107a5f394b17dab89165d99ff56a1897af9c234138", + "packages/db/dist/esm/collection-options-brand.js": "ae4fa09d0ac1ff1a9be961efa924d7dc769aad56a7e67cd1552c08b48fee3880", + "packages/db/dist/esm/collection-options.js": "1c6d225b2e8fd061f7e7616d67de43288583660e99a8a596b86d3f8c58c3a0de", + "packages/db/dist/esm/deferred.js": "d8ce1c72bd8f76a4e49aa17042e46a5432284730d1b2ceb4b5afbf927f881e2b", + "packages/db/dist/esm/error-message.js": "176a340e97f58cf9fbb21edc92cf43c6703ad2110310965b3da0f983e1cfab9f", + "packages/db/dist/esm/errors.js": "8c22d726d3878b029e2d9bf89c158ae2c25f915fdc9833711cf63fab3e48cd26", + "packages/db/dist/esm/event-emitter.js": "07add965808d5bd93b29d97e0e6d7db1c060856a1ea20cb7601a81785f2c67dd", + "packages/db/dist/esm/index.js": "fa5714b1da1264029dc75dd2f9e18edf145f128232feca5690392ff94dac6b8f", + "packages/db/dist/esm/indexed-db-wrapper.js": "5ca4ed531df467c317a8b302f218664a13ab579cc472eb4b9dda3b70854ec667", + "packages/db/dist/esm/indexed-db.js": "f7a7b78e057570562aa7eec0279e37a58ac7c4cfc05e820401f89728654dfbf7", + "packages/db/dist/esm/indexes/auto-index.js": "663f98c52c018571e87f398cfd4efe51bd27f48e2a4ac76b8e31327a725da8de", + "packages/db/dist/esm/indexes/base-index.js": "59652757da474e7119d759f784e954b56d9daecf586e385b2bda4ecd9219f465", + "packages/db/dist/esm/indexes/basic-index.js": "1ac365be033cb255d3b4005de57b7466b9ed4cd21daa08a77d8dfcfef0584626", + "packages/db/dist/esm/indexes/btree-index.js": "9ae7bf699fd9d44f9cc7dab823df85638efb2f43114eb33aaa7120efc367269b", + "packages/db/dist/esm/indexes/index-registry.js": "cd9448a61e290d32e23090cc57c8282aadb2d1736611a33509dbbe1f328d080b", + "packages/db/dist/esm/indexes/reverse-index.js": "94f0751672696624240f6e214a9b16517c9145269968e07ec7b2b8f439e3f25b", + "packages/db/dist/esm/live-query-adapter.js": "e787323960ffd3e848ad669ccfea1df3d21d80e084e75b6048740dfe23756e3c", + "packages/db/dist/esm/live-query-observer.js": "53eb064feaded0b54fb64e5a4741b2ed5cf94de00638a2bac0a95e573aab75d8", + "packages/db/dist/esm/live-query-options.js": "51ddfc4eb7ae745a75db197d67e23cd0b9b15f527b1fa706a759d90fa7920f2c", + "packages/db/dist/esm/live-query-primitives.js": "67f6abfb58ecba0abfa2e3c8094c98a86eadad39bd96afeb48912a77475bd76e", + "packages/db/dist/esm/live-query-window-binding.js": "3f239006fa71213f924008cccdb29b9ad57d97beff2947a30f0e0130370bac02", + "packages/db/dist/esm/live-query-window-controller.js": "bdb129aa7772774fe9fddb598d911bd7ae06cb8d581c05c41f47152b478f6805", + "packages/db/dist/esm/local-only.js": "0a4a5d855e4e3db629d20898eca261fa2dc02c3cb2d0c6feec32a052796930b4", + "packages/db/dist/esm/local-storage.js": "5a01856bf1eb5f1cb91aeb6536f3d685e8129aac6e9ea0c6c5feb62159aeb8ef", + "packages/db/dist/esm/optimistic-action.js": "ca9f92ae29a9710cd18e3e71c46a2cc0f204334525aab42174fdb4f53ec67b89", + "packages/db/dist/esm/paced-mutations.js": "01135f1c21fd5deefa89c92ecf7598bbe42d98aa284e646ac21c470c0d346369", + "packages/db/dist/esm/persisted-readiness.js": "160a5bdb6868f603e1bf1358ccabadd72cbe70c05d87f807972873bb03f9c850", + "packages/db/dist/esm/proxy.js": "33a224882c8dc0db103d6d0b92e50bb71021030e608937e7a800411ee9eb3fb6", + "packages/db/dist/esm/query/builder/clone-query.js": "9fdd9595f744fd286b55de26cd552bd630a358e4114dd605ad4d75a474ba70ec", + "packages/db/dist/esm/query/builder/functions.js": "d43c221d002d703965be1587f725255ceb58dd11258670b2882c3451340efe55", + "packages/db/dist/esm/query/builder/index.js": "c42986783a038350a694a9d8352f559017f62fc4b51197ca99f6ea1ce386a8fc", + "packages/db/dist/esm/query/builder/query-ir.js": "8d535e9be5fea36bbea302665f1d30a1b4a029fe4fdc95e6f674b6b0a3c5ad34", + "packages/db/dist/esm/query/builder/ref-proxy-identity.js": "fc756419ecb6b451640f729a44aa2e9cef4385d20ef1c68c049d36fde27d2b10", + "packages/db/dist/esm/query/builder/ref-proxy.js": "b71f544f804438a3c4a7ccec380fa0f4614ec7c859a983de4520d19390707993", + "packages/db/dist/esm/query/builder/wrapper-identity.js": "00c10f26051967ff8f284e550ab207552ac2632cbc08a7623617dad1c593bb06", + "packages/db/dist/esm/query/compiler/evaluators.js": "0ccf7503f360331210ffabf4a1003c96f904f831400ca2687f553f509a52f21b", + "packages/db/dist/esm/query/compiler/expressions.js": "eeb0c6dfca9871b4f7a1dd893c265e51e1b23d20400ac28579875446ac37ca87", + "packages/db/dist/esm/query/compiler/group-by.js": "ca935c6886fc9cb1574fc71e22efe688beceb36bd524a25a6aa29ae0bcf44dda", + "packages/db/dist/esm/query/compiler/index.js": "19667be77fa84da780beb102509c28d98abd365a330e8e7da4e9ac5a51154959", + "packages/db/dist/esm/query/compiler/joins.js": "af1bd496e41902c7e5b9759b4231d0487db2f67f8aa060b75fd326c7791c88b3", + "packages/db/dist/esm/query/compiler/lazy-targets.js": "04ed7aec7600a3ccf86ac28f06f8dd171770e56ca01a11b74d0b59fba6e083ea", + "packages/db/dist/esm/query/compiler/order-by.js": "e9844a5178d22908bf1ee6a723fe903d05110f6c333eefd8851a83165b39180d", + "packages/db/dist/esm/query/compiler/parent-routes.js": "bd0cf9a9781b321181ed3e2b6571d94dcbcd7bc784c53399b98051364c0fce80", + "packages/db/dist/esm/query/compiler/query-equivalence.js": "139c14fa078e5ba4c4a3d7490854c336870eea4c30f5960cd8f15980ceb00b57", + "packages/db/dist/esm/query/compiler/route-metadata.js": "6889f6b3dec56ef8fe5e6ee05e892dab1c22c0c0a13cbe09afa76774dae8a614", + "packages/db/dist/esm/query/compiler/select.js": "3f7d19d780395dd84c1b2b2c506e2b4f847a5a8a9442016bc5f72597f454af18", + "packages/db/dist/esm/query/effect.js": "59f37b190830c138368260801b09e7bc7a30e347fe36681b4796f6902c7e7c3f", + "packages/db/dist/esm/query/equality-conjunct.js": "ca0bee4e908fd4dbe52dc78feb172dca90492edca49cabe509c3cb4e4fd67fd7", + "packages/db/dist/esm/query/equality-value-identity.js": "5091c8c6fbb1e06127ef19db34258fd0d765b3c1b96c4a4f55b6fb553e37fcca", + "packages/db/dist/esm/query/expression-helpers.js": "5c957e7d0f23fb781aae8ead705404cf7bec3b1cd68db3fb46b356a1d25002ab", + "packages/db/dist/esm/query/ir-stable-identity.js": "a9cdba9efedc50caecaf7f8724f4146ab8d5f16d916f25707112a43cc36a0a8b", + "packages/db/dist/esm/query/ir.js": "9f2a67c11ecf08e1af74375eac7a603455e12ce56a75317376fd90b55193261a", + "packages/db/dist/esm/query/join-conditions.js": "0ede70428fc77b12d38c333b22767d629d50e88dca26af0c495c83c3308daa85", + "packages/db/dist/esm/query/live/bucket-facade-adapter.js": "daee317ea51844f450c893f7ce57e3383a0bf96c52e2bc4930d1aff951e5c7b3", + "packages/db/dist/esm/query/live/collection-config-builder.js": "93228d57a26e0e6dd5d51553c5fe10c9ed49f7803979ad21a76aa6805edec3b4", + "packages/db/dist/esm/query/live/collection-registry.js": "05e27e3d041d58aefc8820e523163b2e21ee53a3940680a0b468bb8e81e6d46d", + "packages/db/dist/esm/query/live/collection-subscriber.js": "11dc6621b866033008c2d6c9c2ba90ce36eb94d5f0f0ae3435ea55ecee736f3f", + "packages/db/dist/esm/query/live/graph-scheduler.js": "d3888f67b6e09ac29a3e45a120e3cbbceda49be7415eacaf0856cecf2cbc9e9a", + "packages/db/dist/esm/query/live/internal.js": "244a7f35268790408879ed8b1774fdd767b78829dc1a5ef6a0df4c2c372c0258", + "packages/db/dist/esm/query/live/materialized-pipeline.js": "6d98c2d4ee9412f5816c4151c14f73224b449738df183d0c82c37c08056c64c2", + "packages/db/dist/esm/query/live/ordered-source-loader.js": "63481aa7985a8e9de91f7e3499deb0e5d6f53d6abec6dd6614609ac1f1b5efda", + "packages/db/dist/esm/query/live/subset-demand-controller.js": "1163906179d8c68fbaf36507411c258ce664e8d4141f0a8c4b01f905f1cd1c27", + "packages/db/dist/esm/query/live/utils.js": "3df2bfd90ae95fda8c421e3131e0aaf90875e2cb1af514a09da42ec67e78f899", + "packages/db/dist/esm/query/live-query-collection.js": "f6024029a340a675df1e36ed180ab0d800ac0e697399a5b0ffdab392fdb87181", + "packages/db/dist/esm/query/optimizer.js": "2992bfaaf19a94828538613dcfffd71e4908952fd7c401d4fa01509c53776093", + "packages/db/dist/esm/query/pooled-live-query.js": "0c83e1139162d0314a0a6f1076911d4cf635877d6c9dbc89b8983d27cc90ac46", + "packages/db/dist/esm/query/query-once.js": "efa18058ba8d8abf2f201cf0a6418ab3f1c133d4d7464748f55c0ff8839150f8", + "packages/db/dist/esm/query/runtime-reference-identity.js": "3a5de8435888ede6b0edf799e5f0c192192aa1e0fad922450e9774ebceb4de18", + "packages/db/dist/esm/query/subset-dedupe.js": "06203c388ec4f1e525de8fe770775736222b62e273007e9a31bc37ba5fbab15a", + "packages/db/dist/esm/scheduler.js": "d9b4d34d6a4c656c0f38859426d39830ff62053ab47d22a14acc8b34bb6a8136", + "packages/db/dist/esm/strategies/commit-completion.js": "bd177de52002dceb9d28e02223da81b05c00ca5f72f5c06aa70699142904b263", + "packages/db/dist/esm/strategies/debounceStrategy.js": "88c73e9970201acbe24c3ea11632f2747f174cfb2840a53fdc4ba25224feffff", + "packages/db/dist/esm/strategies/queueStrategy.js": "abf15dc18ce9f481ef876478b0e2190188b960d76d7cedcf4f0c8b2a6818f9b8", + "packages/db/dist/esm/strategies/serial-pacer.js": "84c01c2f15ac57689b7f80a8ca0b152035cc2dedd8573e7e816b1a2bd0dfb08f", + "packages/db/dist/esm/strategies/throttleStrategy.js": "f646adf4f396ceddebca7580b398dc57edf1a22dfc2dd2e6c1eb607b412c0de0", + "packages/db/dist/esm/sync-persistence.js": "16292216a685c02ce1f3e1b2945a2409600e42d951c6acd19090dae99a38dacc", + "packages/db/dist/esm/sync-receipt.js": "c4365d1f36cff69c10194edef4903526fa85078cdf135172978075b5a3d56652", + "packages/db/dist/esm/transaction-commit-work.js": "c6838b1423ba8b0a1bb2ebf848fa37394416d52369d0d7ee5e8508d67f87066b", + "packages/db/dist/esm/transactions.js": "901116611280b5cbd71c3d9c49b6bad061174d90fdd0d2ab29f8cb66d5fb12d1", + "packages/db/dist/esm/utils/array-utils.js": "e8b8006009a956a5520822c9bb1ac8a9b13750613b0be937c2c2cb8d14c5d4c0", + "packages/db/dist/esm/utils/browser-polyfills.js": "620828c9fcf0df2181fed5d866a5ad20f1ac01ad5a296dcb04fd8be7012f6d75", + "packages/db/dist/esm/utils/btree.js": "2db68fc77ef20eb576502fd284668da1c9e04b18befc88dbf3d6842b722e75a6", + "packages/db/dist/esm/utils/callbacks.js": "0e5c79079dcbd00609aaeea1d769ae56fd881f4245740dfce408419773c599da", + "packages/db/dist/esm/utils/comparison.js": "19d37b5b97b84c1a19d331e277f47042e87ac01696d90a74fd3f76030d45e7ae", + "packages/db/dist/esm/utils/cursor.js": "1051977f9f8acbb6fb6884faf467c5c379cb54fdf0b02d22c9c1eb14cc52ca86", + "packages/db/dist/esm/utils/error.js": "a19d18998688b8a2ebd796bf668c694fe5f0c18fd4a4f8434f91589a7e436e40", + "packages/db/dist/esm/utils/get-or-create.js": "b1b71fa6f0e123e44f4908ea606ad6002515afbe204dd5841f72789f8fdaf3d3", + "packages/db/dist/esm/utils/index-optimization.js": "24ffd884fb0c24a49f614c5cc7fa9b37d9942bf59ccb12ccba07c7a0a328ea1c", + "packages/db/dist/esm/utils/source-record.js": "dcb726a12fb21ca74fafa5eb78ab7b83f1d18c2176fee2af11f430322ada6f48", + "packages/db/dist/esm/utils/type-guards.js": "a0327ad9ec010fa5f75a73b01b302c19433258db3ae23ce563a97eff8d981fa6", + "packages/db/dist/esm/utils/uuid.js": "fae77bf0bc610f2fe2e2b7b3ac289f48b572b223973780c6b895bb878c68e8f4", + "packages/db/dist/esm/utils.js": "ab6496511234ca87fd1c59c51c7b8f2f8b8601ea5d7ed15057f3068a88210f4f", + "packages/db/dist/esm/virtual-props.js": "76a0a0e70ee0f959dc7f4c2dac49bdcd24d8a7427b1cd59fb708d7bf47a7fde0" + }, + "runtime_source_map_sha256": { + "packages/db/dist/esm/SortedMap.js.map": "9fa31bea314653a026871313192f228eb75af82e8cec748e8041d1d7bc3d6978", + "packages/db/dist/esm/client.js.map": "33a90696c3b077207b8b1ccd57b1e9299f505a9d468466746066dfe3936c9a32", + "packages/db/dist/esm/collection/change-events.js.map": "c53651fdb6c5b229a2280d45af00a4ec969d5edd48d11608e96c8e813e109ceb", + "packages/db/dist/esm/collection/changes.js.map": "66ba53f09b63f0e668f7522297736919261a64518ec2c43b16eec770e413dbe1", + "packages/db/dist/esm/collection/cleanup-queue.js.map": "84777c1baefeb9e15bfad6f2144decb14ad0236895f8eb59d6721221aacf25e4", + "packages/db/dist/esm/collection/config-errors.js.map": "6bb878aebe57ff145f43dd2afb85c80041cff4b39546ae5e6eb3c7c1b7ec57ad", + "packages/db/dist/esm/collection/events.js.map": "4c035f44d2a25221e333af7799d8dbb06d8b69a12584fe6668f517c71ea87b4c", + "packages/db/dist/esm/collection/index.js.map": "2e50904cadd394695e45c5e82bbdaf1f22f29d6787e7ece5e225250aa721f438", + "packages/db/dist/esm/collection/indexes.js.map": "415a842ba3570ad1a6f341edc761c80b7c37a41c1dcc568121a854d95fcd91f5", + "packages/db/dist/esm/collection/lifecycle.js.map": "12486a4c6d0a7dcaca4489596e2e6da0deb1284c63538c3547773cfd1a1af3c4", + "packages/db/dist/esm/collection/mutations.js.map": "57b450f3fe34ff7c279e349338b525140fe287754763aa4e335cbae08f4374b7", + "packages/db/dist/esm/collection/state.js.map": "f11a3431edc53bcffebcd0bbd6c27af38c2a8c38e5b36e2d2b66b934031b835b", + "packages/db/dist/esm/collection/subscription.js.map": "73c1f1ca5d23cae10c45175164f5e2c59aa10138f13c86ed6ee775f1655765bd", + "packages/db/dist/esm/collection/sync.js.map": "cccc3eeceefdfebde3c092cc3944e23a2aae67eb0c57ff7f3a0694fec98d1d76", + "packages/db/dist/esm/collection/validate-config.js.map": "fd8cea162de4fe8ef094df6522aa8705b83c12fbfe8f9433ee556b3aa76d069e", + "packages/db/dist/esm/collection-options-brand.js.map": "006f1ec5a86b56a67c81e1450d129a8c18c2ff40aeedbac730ff344cac539248", + "packages/db/dist/esm/collection-options.js.map": "5e0b50e274db3185ed9b0c7192920a562c2d9669e57bdb4317ff27a0c7bc0bf8", + "packages/db/dist/esm/deferred.js.map": "c87f06ed0c6b0f229700d59008329205ff3fe7fd5466306982bd932ae273bfcd", + "packages/db/dist/esm/error-message.js.map": "1ddef42c2961d5d65ffd8bafd3a639431a539cf90da41cd5ee794710f3f2884c", + "packages/db/dist/esm/errors.js.map": "6d4e905f2f6c38ea74cef7cbb5fa6f46ccef861488bb57eb2096d3fe4e8fdd9d", + "packages/db/dist/esm/event-emitter.js.map": "b0b3a8588c21ee62b7d3f23d2dffe07ae67f3a451f5151b6f7dbc528c7d009f4", + "packages/db/dist/esm/index.js.map": "f8108774caf02d99844c09563d578fbc3b23ff1a41815163eb053569fa2d1544", + "packages/db/dist/esm/indexed-db-wrapper.js.map": "0203fa669e4b0329df4575a721ca2a3a170180209aa1e4ef6083223f9339c6e8", + "packages/db/dist/esm/indexed-db.js.map": "c29f3c555810dfc2a4b0c75c9ea1f7ba76ebae042d9380cf1f8c69a5471a20c1", + "packages/db/dist/esm/indexes/auto-index.js.map": "511e7a724df721c7decc03223fc765a289f06274cdd379b7de89383a2673d2e6", + "packages/db/dist/esm/indexes/base-index.js.map": "fd5416f04da3991df1fdca5605cec332b7eda9e2517962bbfa437ad10028b286", + "packages/db/dist/esm/indexes/basic-index.js.map": "8a1d3483240e52f21e82f22424b4168b4e17d7f0127e74d6cd2fb1abcc491645", + "packages/db/dist/esm/indexes/btree-index.js.map": "24afa9523a33e8c7ae06c0543991f83b454c9ca67e667a125e363bd3665b4c87", + "packages/db/dist/esm/indexes/index-registry.js.map": "5c0bbfbf82671306e89a15c7ef3bbb8a4813b268b4f317d75be63f88eb41a75e", + "packages/db/dist/esm/indexes/reverse-index.js.map": "633cce9cc9774fbe8854d1d9a338e26b3158107d1edbd0f2161fe952eb4700fc", + "packages/db/dist/esm/live-query-adapter.js.map": "26c5f830609eff6e54e4520eb0ec269a3aad375c2fc857a3ae77140e0179e7f2", + "packages/db/dist/esm/live-query-observer.js.map": "5475b3532cd994257fd71961200597b079078521b81440265ce455fbba901986", + "packages/db/dist/esm/live-query-options.js.map": "ad90acb33887c695fdfa862cd8d5d49e68cd4db37198db903b56c6382e83a7b3", + "packages/db/dist/esm/live-query-primitives.js.map": "41afad71afdcd23aada590c459cd4533d2737a7c28c21ebb5eb23e7fcf7e6386", + "packages/db/dist/esm/live-query-window-binding.js.map": "6c1bd8174569741a154f5bba07fd3a09f18391e599e42d4b9b697089aa60154e", + "packages/db/dist/esm/live-query-window-controller.js.map": "298367ea2b702476de2c969a4226821c6ccfa033d3b90947b330c21060d146ce", + "packages/db/dist/esm/local-only.js.map": "c8eace5d10617bb0c5e178db897b6b5905a51b3b1e6c10e3b1018b14aa08c373", + "packages/db/dist/esm/local-storage.js.map": "95e266c329925a1a2ce6c984f4509f6366d40fb3ef02f50998532eca55538611", + "packages/db/dist/esm/optimistic-action.js.map": "1ff3eaf6f6ae7e2a08f1988eac3f9f80b307cfeb61b7031579f19cee774d2207", + "packages/db/dist/esm/paced-mutations.js.map": "209978b76decd4e48ccd0eca467a8fe1878a0b24145f4903df2a30aa66fea200", + "packages/db/dist/esm/persisted-readiness.js.map": "b36b855729c7fe5e9cf49a0eaebaf66f48ec69e87acf762e21079907062d7c78", + "packages/db/dist/esm/proxy.js.map": "7e93b8d55ec84bfa6435b535abf642a4ea817750ad0af18bfb4b0841af5052ab", + "packages/db/dist/esm/query/builder/clone-query.js.map": "a458b5f869bccb52d83f5ccc2a022e617198b07d71161599925d2b179b22ce8a", + "packages/db/dist/esm/query/builder/functions.js.map": "7209aeb6b70bc74fc1631dee124bdba42525c9de6c8e63257a9cb75c67225703", + "packages/db/dist/esm/query/builder/index.js.map": "57f18c1d77caee866a05de3343ab14417dce54b5b3d84e484a90c64f5b305212", + "packages/db/dist/esm/query/builder/query-ir.js.map": "404592f23d86e39fed95862bcd6c3a40ea9899bf64ce2b3e454b7a246d799096", + "packages/db/dist/esm/query/builder/ref-proxy-identity.js.map": "b0a54507e2de7678cc902067c315216eddde48cb9de7bee7ecebe9ca89c88e22", + "packages/db/dist/esm/query/builder/ref-proxy.js.map": "c5c6ae0637ae9be5ff1a2e3cf54d8788f57baaa8c1ff6629c962444dfe7851c2", + "packages/db/dist/esm/query/builder/wrapper-identity.js.map": "b7b11e683600fd94d16e869cb38dfa250ff56eb7b34b4e95d20ac35b497004b2", + "packages/db/dist/esm/query/compiler/evaluators.js.map": "d68150a8ca3406a2960168688fed45f5e5fe07573802691e4bb400499977e6b1", + "packages/db/dist/esm/query/compiler/expressions.js.map": "f41f8b46979dce48936373f86cc990357a82699f51585aa7ff34fb284430a704", + "packages/db/dist/esm/query/compiler/group-by.js.map": "c677452c235bbb7b08ed8c380953304c2fe462c5ac5a9de7a22098f00b65f11b", + "packages/db/dist/esm/query/compiler/index.js.map": "619130f951479e75e3a261b7cfff523dadd61d491ff5f4b66d0919a4b0509f1f", + "packages/db/dist/esm/query/compiler/joins.js.map": "c2c321244483a65edb382c6f2c55cd7f395bd383671f1d6b63e07b9b8df65104", + "packages/db/dist/esm/query/compiler/lazy-targets.js.map": "a95673b8a853f10dbd239bf3612f4f644e582bc5dc0a7f12873898a7ce33d7b6", + "packages/db/dist/esm/query/compiler/order-by.js.map": "898ad1b7bd1bdbd1c2c0a8edbb78e7ce14811bb4a9fed3e148befc1127a9509d", + "packages/db/dist/esm/query/compiler/parent-routes.js.map": "e18ec4a4c12e4c4f2e176e07c47eb3d58e36cc82eb5fa6327c92106552c1d523", + "packages/db/dist/esm/query/compiler/query-equivalence.js.map": "0e569092d8666ed94fb82c83ec29c5e9663f5d1ae1afadb45856fc4278b5b19a", + "packages/db/dist/esm/query/compiler/route-metadata.js.map": "3bf70196b5be0b9dcadfaee25e7c4764874d76a50ae88c15486f64fa6aef65a9", + "packages/db/dist/esm/query/compiler/select.js.map": "b400ce63f5ce3a505045241de9b5d2b9cb1dccb3e2fbf04475c7d1204717b779", + "packages/db/dist/esm/query/effect.js.map": "0d8f6b6535196a2bc2038830616750b99efbb47505ffab786c7a7c018c296835", + "packages/db/dist/esm/query/equality-conjunct.js.map": "1bc30e55bad05f535c5dde6ff2e427ed80dc73139563652e9169ff78ed6e52cc", + "packages/db/dist/esm/query/equality-value-identity.js.map": "53e543a540f24c9bf28b549388e7b47e4d0dc2fe0a32d2b4c2fd42496f016d09", + "packages/db/dist/esm/query/expression-helpers.js.map": "552f3f643aaf98d1fa50328892bbff6c3cf166a788f1a9bed22450ea835a3e3b", + "packages/db/dist/esm/query/ir-stable-identity.js.map": "54629136d3760ce211cf87b7795dbe629795a59789d26b6b4b4c33f00c50da6e", + "packages/db/dist/esm/query/ir.js.map": "ccb4d95ae5caa7bfc320e2bc66ef54f685dbe867301c463e4793c80397c581a3", + "packages/db/dist/esm/query/join-conditions.js.map": "39d95b4fd518dce35318bbdf38cf3b9f105a4e3b5db15d4ff4048ce261a8caeb", + "packages/db/dist/esm/query/live/bucket-facade-adapter.js.map": "5346a9f6853231e91b63e7303be2ff234560ec0653c748f257fd103ea0b5fd41", + "packages/db/dist/esm/query/live/collection-config-builder.js.map": "7fc086bb59ab0dd80007cc16b7164a7a4c4d1306fc29ba2357e8ac2df67f2b09", + "packages/db/dist/esm/query/live/collection-registry.js.map": "4f4be3b283e3daa91838fa9ce44dbc11d29b599328d7db419319ed6fd7d9c8f4", + "packages/db/dist/esm/query/live/collection-subscriber.js.map": "1b1bec3ab4af5b8f568be4f298d5984ca74170929574b41ac7490ddf47b0eb32", + "packages/db/dist/esm/query/live/graph-scheduler.js.map": "f9d01ec493d292f27a6136816a61082939d7ff9b2915e73b9f04a58f573d48ee", + "packages/db/dist/esm/query/live/internal.js.map": "8f3093c6b396f3b3b0ebdaf9d330f23f71cc6ff9ad6813c598b590c497a56382", + "packages/db/dist/esm/query/live/materialized-pipeline.js.map": "9924a8437663a9ff914fc8e733903449f322b9fb9786a873b72f63e10aefd5b0", + "packages/db/dist/esm/query/live/ordered-source-loader.js.map": "eaa8b13f6b4d995d35579816e23e53041c925dc1f027271ce6bb99e89b3f67fe", + "packages/db/dist/esm/query/live/subset-demand-controller.js.map": "3c0ef841c2f6ce3f6c8c4e1f5ef6fe2b885b52140977f198479440b62dadb065", + "packages/db/dist/esm/query/live/utils.js.map": "0d1b870b1c6b91edcbb7d553fb4bf12122eb1331a56ffeaafac7312581c1443c", + "packages/db/dist/esm/query/live-query-collection.js.map": "f1ce1849148cd27103b5f0dcf55cb97a738d26f17706fe7685b7262cfc86e5ff", + "packages/db/dist/esm/query/optimizer.js.map": "fe4f220fd03842d9d518b44df4ffac8a03d9dc4c40fe3b5a1456ca05cb7c5a47", + "packages/db/dist/esm/query/pooled-live-query.js.map": "82bf9a5f6131a287cc09c05cd35d0f12910189f86ce2009529ecdb91cebf6bb7", + "packages/db/dist/esm/query/query-once.js.map": "cea3bbe5c87666103f83c8d02a8feaaf9f718f318b821f76ff5c2a5d77dbfea9", + "packages/db/dist/esm/query/runtime-reference-identity.js.map": "d06fece2e78c5ea3ae21e74ce22fb24a7422cd57632bc28d5cbd7292dd049dab", + "packages/db/dist/esm/query/subset-dedupe.js.map": "d86e0085d0097a37f35375a94d52063829177fa6ec2aefce3cdce46c42133e82", + "packages/db/dist/esm/scheduler.js.map": "d0ca54ee8e793d30ba55632a0f59d8c141724698b4d1f82236c70c7ef36307b1", + "packages/db/dist/esm/strategies/commit-completion.js.map": "0ccb2df9d5ed0561721f8459a6f029a031f774250aaaaadb7f68a5556bd4729c", + "packages/db/dist/esm/strategies/debounceStrategy.js.map": "8dcdddd38e78f32c844279e92845359b7a95f4b91cb6add26474b680e32e5421", + "packages/db/dist/esm/strategies/queueStrategy.js.map": "e61023e3380ae9594484c9e673b078454955b85ef8d37fd57b9ecdef1bdf6da2", + "packages/db/dist/esm/strategies/serial-pacer.js.map": "4e86b6c2203e220ab7096e2e624463c92c2bf6c8cb1098eaebad2df56f5e4a69", + "packages/db/dist/esm/strategies/throttleStrategy.js.map": "8abc6c79e822c3f6de0496b6c42bd730680f8b48989016e3f4257e25ab01a25d", + "packages/db/dist/esm/sync-persistence.js.map": "a6e395d89daebfc769642fa8bd00a0b4f3c9bfb4090a200d97f6637363d89df2", + "packages/db/dist/esm/sync-receipt.js.map": "037f7d6167e90947a5468a36757d4bd32dfe0c937d8b5f1ab7a83bd28c23f756", + "packages/db/dist/esm/transaction-commit-work.js.map": "10ae9167e5a3a0b030a5a9cb34b347d4bb6f719e023a84db6a0a9da85f39accf", + "packages/db/dist/esm/transactions.js.map": "d071193784fd02d4365ba1db134d53c13932fdfbeceab2e300211335e6582873", + "packages/db/dist/esm/utils/array-utils.js.map": "543f01857219d2625c3dab6f7c8da41e45ffc29f34643681f8be3d42b61099b6", + "packages/db/dist/esm/utils/browser-polyfills.js.map": "259ca9a616f32a59ae240606903456aae505856a03b01d7f0c5133fe724316c5", + "packages/db/dist/esm/utils/btree.js.map": "3d96225772c3794f2e65962fbe29cad56738f45a382b7a4e40f1370dcf780830", + "packages/db/dist/esm/utils/callbacks.js.map": "61b12884fd3fb07d404d53734577a254af387dde4d178862901ed43bf154d713", + "packages/db/dist/esm/utils/comparison.js.map": "4c3232f26a3487b36546b34c0af682b0880282c63fa021da36b070ba0506ec19", + "packages/db/dist/esm/utils/cursor.js.map": "50e78e47699a68d3ac3828eb37760f3d8331e25ac2bd833b74796c2f121056f3", + "packages/db/dist/esm/utils/error.js.map": "302fbea3014e7d353c8e19456d5c7b930876a738e868945fd0eba88498886626", + "packages/db/dist/esm/utils/get-or-create.js.map": "049135923c3a435c8a6435c2330978b7272b1f954481a71ede0450453b3c7d1f", + "packages/db/dist/esm/utils/index-optimization.js.map": "36d3be1f0e57af2e910d9d61f01f4757fe68637e84e0f59a360718b197505d97", + "packages/db/dist/esm/utils/source-record.js.map": "e52ab96575ec8e4fb99084e788ceb8e2f06451369503a5d3792a32b27f7311fe", + "packages/db/dist/esm/utils/type-guards.js.map": "0b96a21f6d742d02da8e62350804eb0e494dc2c10d90f03f29750988c843befc", + "packages/db/dist/esm/utils/uuid.js.map": "439824094f7d0ab0ce52e065cc80e717bac86ba96717435f0befc616495b83aa", + "packages/db/dist/esm/utils.js.map": "a635c14883c73c14c69a03637c408429e951b99fc3055062915245a2359f9c04", + "packages/db/dist/esm/virtual-props.js.map": "73b6876085e95170f2a932adddcf3c333175b3ff80ba00340ebcc150afc6358a" + }, + "verified_runtime_source_sha256": { + "packages/db/src/SortedMap.ts": "dbbef7d2842d265cf5268f3ca8c512b0b79ea927135116d3fc4e08ea2799856a", + "packages/db/src/client.ts": "fdac0f151242b349c53e3a8bb0e9788cc432b35f306d2ae91d74bf832fc97f38", + "packages/db/src/collection/change-events.ts": "754f897ba843d004eaf5bfe3f047fc53aa24eb49a16ae1eae4073e32ec17ba49", + "packages/db/src/collection/changes.ts": "c6deb4b04891feec0f2c4368ffa9ea652d12593f4e7ddf4edbe392a706f314df", + "packages/db/src/collection/cleanup-queue.ts": "d6d24ba9216d9dfacf57623d14e61861b044a23cac1b8e475dbfe71795a0c84d", + "packages/db/src/collection/config-errors.ts": "ffe046ad75542c863e1781c192191479cdcb68e7dcfaf92c9ce180ee48522cc0", + "packages/db/src/collection/events.ts": "fd95280be3bdd9a1177d4d57b425f484b9daf06b4b257ed28d6b17e3ef1bd132", + "packages/db/src/collection/index.ts": "458e71b1478a6150ef9c1854fb7df483cb32466217cec050993f21befcb1dba0", + "packages/db/src/collection/indexes.ts": "1a0b6633e5f5f24c1b29fe8e544ee3c52df6e886d2a61c8fe88750f3f811520e", + "packages/db/src/collection/lifecycle.ts": "5bb9027353ff30b9c467c1c3c749e739aef741ce1762c770ccd0ac90502763b5", + "packages/db/src/collection/mutations.ts": "735bdd4872c97afbe6d7333d42402635bdb54c914e6b5cacf703444fbf933f31", + "packages/db/src/collection/state.ts": "3c6caf3829455db5e6c037bf56e06c7a3b0e6abebc35368da0dbf886b4729e76", + "packages/db/src/collection/subscription.ts": "c446932a8a1f8b2efd79d00846f1a110e65a6c6df75d2b24658053e6964b530b", + "packages/db/src/collection/sync.ts": "4ff2e29f8fb27af6e71a46f959ad764a0d9dbd390bfba66604ba1c1836676add", + "packages/db/src/collection/validate-config.ts": "10bc3f83c357cf8b835694c73df9adf413c6190f924256786f17e123a688489a", + "packages/db/src/collection-options-brand.ts": "93df3fc8d90531ca926c6e9ca48653dbda9259cfaaade9c23cbbf35131b2e1ed", + "packages/db/src/collection-options.ts": "0b0f9713312f9554f62c020dda3bae8291303dbeadb85adfee76515a28442feb", + "packages/db/src/deferred.ts": "9c99553d7ab92ac89d377cb3382a9dfbdf592635c87b86eac8aa1f18efc4da68", + "packages/db/src/error-message.ts": "9ab4a64436c3db3a61f3a84b5ccff11359ccdf5676c81b81dc3be9db9c1dff13", + "packages/db/src/errors.ts": "ee54c0fb19ef95630e833b62562a4a2310ebd79337746355b8f67676cce0e416", + "packages/db/src/event-emitter.ts": "1d8775f3c2dbc9c833f03d2b9bb0d55d1606419e43d661064cc95cd91eb187e8", + "packages/db/src/indexed-db-wrapper.ts": "216fcac76c01b1af8363cda8a93678f53cd2cd58c9df918cd3d56d2a1f25fd11", + "packages/db/src/indexed-db.ts": "4c9960e7020389ab784f3ea6ce3fa1684b36ad1f6aef28a100cc15517374832b", + "packages/db/src/indexes/auto-index.ts": "d226516a7514c40e83010ab211020adba17965af96bd6d3b96e88989c6f31b51", + "packages/db/src/indexes/base-index.ts": "63e35c6bf011ad361882509a521407acf29b9404ce4e810a8f4d54b50c0e6685", + "packages/db/src/indexes/basic-index.ts": "9a128909d6df2c6d5b9ef07295a55f9f587170721cb7c6a652dd9c43eb674633", + "packages/db/src/indexes/btree-index.ts": "b006f65cf1c2ab14004e7311f4ac4b89958e919fb8439cd9ca25090789e37186", + "packages/db/src/indexes/index-registry.ts": "d44e34c9e5d90db56dee7f91e402f2547d91647ae100cc4752bc13bcd49e2719", + "packages/db/src/indexes/reverse-index.ts": "ebf2d22eb0eec10acb4df154488862243ec20be117bc069fe307fc991853dd0b", + "packages/db/src/live-query-adapter.ts": "cc6e68e05afb876a46989357304a2e9315cac918d2d7623e4272c3a4ce3b470c", + "packages/db/src/live-query-observer.ts": "37f3bdd904af1ba51d6d8821d4711b4e29789e307363590972e52a50e77a4d0f", + "packages/db/src/live-query-options.ts": "8ba7980c64de78500e56b3c822e19e8e451d77ee50352beaaf89644f47c3f411", + "packages/db/src/live-query-primitives.ts": "16fa17f9cefbb2dad98fda23651d792f8c8cd5bb4b812bd1197de7ae8f1f99c1", + "packages/db/src/live-query-window-binding.ts": "7564bac73ec3aab26dbc4c4ed44bc972f108fc6241c158eb63c8b7ae22b67747", + "packages/db/src/live-query-window-controller.ts": "ea439e6b6f0ce1bdd03f259a39d6d912024845e8343d013b67a7b2251740c8fc", + "packages/db/src/local-only.ts": "06d516a29e6f72b87f08c83f1fad2454dbc1cd3a3d296562f74fa5eb5aaec408", + "packages/db/src/local-storage.ts": "62331d402b5a7b958617e45ddb9de90a358f2fb1645ac589c5d412deb079cb5f", + "packages/db/src/optimistic-action.ts": "3b3d98ec0561927c301c6979256bbcd98faf0ee41b2b6690abe1c189eeaf4fec", + "packages/db/src/paced-mutations.ts": "ea9239b269c2622d23cb2fee6df2895534d64b43fb245f8160e054587b9b5458", + "packages/db/src/persisted-readiness.ts": "3fdff536570ed25347928bfac3043482d87a1506ea8bfd4d2c4e7f4c532fb598", + "packages/db/src/proxy.ts": "97fd4841cd8532a8bd2f08197d0d755cf348a50e5df0b2f4705079895f2a42c1", + "packages/db/src/query/builder/clone-query.ts": "7265db09e09e441d598d5b5774759f4ece6dc1cf5d5723d49d1c342a36e3798d", + "packages/db/src/query/builder/functions.ts": "71ff875be3b94602be517fd03d2ffd0fc1866a189a4849403623d9865e35227e", + "packages/db/src/query/builder/index.ts": "c658662a2ff50d85a7a1d662b728873e16a568d58e38248bbc57d03f884b463e", + "packages/db/src/query/builder/query-ir.ts": "6aa35045e8a83c63a68e3af2de951879aaa2b91c132606db6d0bfd32247af108", + "packages/db/src/query/builder/ref-proxy-identity.ts": "909d40e4425647ae58850decd6891fa7f6aa4690cd76e82c3ced13b70dc93b10", + "packages/db/src/query/builder/ref-proxy.ts": "da99a9d725676d7c29b1bc2c10913465389c6eb442ca651d06ab658a2561d0c1", + "packages/db/src/query/builder/wrapper-identity.ts": "3053d30505c57f0eb16cc573e196291e617aff7479151508bbaa397fa3757f57", + "packages/db/src/query/compiler/evaluators.ts": "499cb252c6e2c166cf626bf88c6c08ced102020558bdccab46a6d12acd52ec92", + "packages/db/src/query/compiler/expressions.ts": "70925f2d29f57aec189d0e7f485d0531534e3482f0696a5e9a1141d8cf36c42d", + "packages/db/src/query/compiler/group-by.ts": "f30cb4ebd6940dd4a3888fde633e16f952225e4f9d35be4e450d6ad912a22bc7", + "packages/db/src/query/compiler/index.ts": "0d53aa7d27fba0e0f472af55404adbc437e69a537aa2f529710e81d08b16944e", + "packages/db/src/query/compiler/joins.ts": "8f0aacfc3e09bbc2672cc21af75ac8b27e1f957a47ee8e36ce3b451137beff08", + "packages/db/src/query/compiler/lazy-targets.ts": "6949ebfac1f0d76b64dc9a3c1cfee87490702a21de235c320fdd7aaa02b77587", + "packages/db/src/query/compiler/order-by.ts": "271b3195a4d24cc0faf9c19261a1e18cd91eea8794ab9e611257929c4e3a880b", + "packages/db/src/query/compiler/parent-routes.ts": "d59e9d64534c53bb58349f07c121158adc9b37cac7a0b5150493e6368442c18b", + "packages/db/src/query/compiler/query-equivalence.ts": "7171fa0f1aa960297ba1269193ed3527967d78ebf1c19ef03544604280aa4480", + "packages/db/src/query/compiler/route-metadata.ts": "53283291ce25656339492bff678b8742a7fdc7b60c49d3b7f7015569e5c06788", + "packages/db/src/query/compiler/select.ts": "a4b60959e37ea674bfac8f3cc4076c35facb9378891f72603c309e92c2882760", + "packages/db/src/query/effect.ts": "013d653074e139caf2e2089574c3a985c7f408c9434d2d70d7c3b2dd9cc82258", + "packages/db/src/query/equality-conjunct.ts": "02041d880376b46814ab072747a9b0493e24cd4bd72df8389726780e535cc584", + "packages/db/src/query/equality-value-identity.ts": "a7b65759c20ebec4d83c47ec744bbd382da9a86ffa4c9e59028827127081d016", + "packages/db/src/query/expression-helpers.ts": "15d6635f8b28dafee0ca0afe2e8598545f09995a5b992798916d789167383019", + "packages/db/src/query/ir-stable-identity.ts": "2369d6ad584c2546f4fdf2051055a3bfa9bedb0c53cd8ff3d86b2b2a86cfd80e", + "packages/db/src/query/ir.ts": "9b319bd3728640d2ab941b167ea16b09d721ccff112647f597c9761dd43b5ada", + "packages/db/src/query/join-conditions.ts": "c2659d14f7a15203343de19b66cb83195c7ab5c26f953b52bbf6f40bbe012802", + "packages/db/src/query/live/bucket-facade-adapter.ts": "cbd6ea77a9d103fea6253e20656e31ff41ac353d134cb0c49710c56ffdd4b3c3", + "packages/db/src/query/live/collection-config-builder.ts": "aa55faec9bc8db4597357989d9466e4ab030e743ee6a197691807b775f0f8440", + "packages/db/src/query/live/collection-registry.ts": "c24da0f80292e4fa22370711cdf66e52ec44989f8fda50430025aa5b25510e65", + "packages/db/src/query/live/collection-subscriber.ts": "74e97fe498b44446d5ee21912175afab6a3e58d2787e04e890a26771d5c8e1a6", + "packages/db/src/query/live/graph-scheduler.ts": "bdd37b3814814a2bf32fa55422a2fb039107d04cbf4fbd79df67056c5bc4654b", + "packages/db/src/query/live/internal.ts": "3aa788cfb3647e2758ff9befe18e4503bc68797fd97d55dc5c3905db9a7145fc", + "packages/db/src/query/live/materialized-pipeline.ts": "9e0ad27c197c9571e242c7a7ccb286739ecdd748b25f00f4f4f8aae4f0894407", + "packages/db/src/query/live/ordered-source-loader.ts": "95ea59a251cd540406d3dd2dac1ba85f41d1f6422c4ffd44f973d9ea4ca01e18", + "packages/db/src/query/live/subset-demand-controller.ts": "28e49884847544356975afd781bb2d485844c2f43f9f33097aef28d06ba2a1d0", + "packages/db/src/query/live/utils.ts": "00d2a0462ea2a997f50d104f641e9837cdf1341fc763116a0df773f6888f1646", + "packages/db/src/query/live-query-collection.ts": "d9fa71c30a1dbb69d58e2e11c2ba4acbeaa727f8d1ee70ce3d225be08ce7224f", + "packages/db/src/query/optimizer.ts": "384be668674194cb69a3b6165ffc97bb3b7edf066e107c444f36f6bbfa43571c", + "packages/db/src/query/pooled-live-query.ts": "d2b9d1c77cb7b2f9fa0c0ed6c546e0738390d20207ebde466636d0c95f459554", + "packages/db/src/query/query-once.ts": "1399a24809ce83173f0d952ab35620bc4430874cb96ab14229495fad3ef57478", + "packages/db/src/query/runtime-reference-identity.ts": "c1c695e80771ab545e58254381333bd24469813d69f448106df2dc5a5de1db48", + "packages/db/src/query/subset-dedupe.ts": "c535bb2a38db491d1c11024c88c71dc1664a59bae3c19f1f5bec35d37d5b99de", + "packages/db/src/scheduler.ts": "b21cbbdc088b3c1dc4840b7db857ee7f262e6958dc8bc300398ad8e93da4f01a", + "packages/db/src/strategies/commit-completion.ts": "242b52fc7a041f5f9d17172c356dc4e1ec06f2cbeaa85ba5995294356fba9b15", + "packages/db/src/strategies/debounceStrategy.ts": "8ab894803575f1cfab85939adbf67637342bf2e230e34d0db9bb642f205cdce5", + "packages/db/src/strategies/queueStrategy.ts": "c21794ea5707e75de082b87c80ee5a19fd8b1752fd7725adf7435b377a5e4196", + "packages/db/src/strategies/serial-pacer.ts": "63f4aa8f7aacd4bbe5cc3d56173fdb9231fedea68bb721bb2d420cf2303ec679", + "packages/db/src/strategies/throttleStrategy.ts": "028b5a000cc5a49a0176a42f0bb0c5cbb0a71849e543b523b080942947f40cb7", + "packages/db/src/sync-persistence.ts": "eb64401db52d9d2f269e5b6d92f85ebb5d4cfe6479dd1d4832918cd7b9dbad6b", + "packages/db/src/sync-receipt.ts": "5ab4074603d96c23decd4fae499ed55f3a6ee5077e379f370d9b76fd90565399", + "packages/db/src/transaction-commit-work.ts": "b3c341c01f7a8b1207683fb3388b66b1173481f43ea59160e1922778e64872fe", + "packages/db/src/transactions.ts": "60acaa21a7c158e2617bb382778bec1aa2b5fcaff7d527ab902e47b364d140d7", + "packages/db/src/utils/array-utils.ts": "a151c6b1779cf975e85e53a7b2328865889738df1f0d0fc3b8a8e466d15911f2", + "packages/db/src/utils/browser-polyfills.ts": "b3212d22af7254eb8c286d89393eb043324abd0b5a7ddbc8eaaa505e292b437b", + "packages/db/src/utils/btree.ts": "e2172d757bfdaf3e528fe94864876fac9de24a66302ed1930d108daa1f525b1e", + "packages/db/src/utils/callbacks.ts": "da4d6fc8d94bf93d07442eefbebd1ec0c183ef559fa7eebceb51b3652816708a", + "packages/db/src/utils/comparison.ts": "ed8c22f7315f0119025d47a0ea6432bab974c618665b167012c6c12da2287449", + "packages/db/src/utils/cursor.ts": "b728649d395a8e4e61cd6a63479be92ce2d4d4a08dc73beb549b92dafec5181f", + "packages/db/src/utils/error.ts": "133924e3809af4a69826c08774455f12f10aba675cbc92a4298525868bba4ece", + "packages/db/src/utils/get-or-create.ts": "eb56ba9e1a6eddecc8241062cb9a4ea0afca7437fbb50f0404de1276a611ee82", + "packages/db/src/utils/index-optimization.ts": "034310477e43f29bfe3493a56ee3b3524378ae3f941f4a452e6a15b9aec24425", + "packages/db/src/utils/source-record.ts": "48023571bd2e850170b78a34a3a9d14940d97742f510f96cad4baaf4a75694e3", + "packages/db/src/utils/type-guards.ts": "b7a63accd5abd4dd5c93bb451650fc962a2fdcc3981e70f0b7b373ebeb5f07db", + "packages/db/src/utils/uuid.ts": "0365c5c02b46eabac53562dac87b342656e2774b36385a094c16b3a9db3a5ca1", + "packages/db/src/utils.ts": "67e7143cfb324dc8f97c310679730682ecfb8088c2dc0d9e0efde6bc57ff9c76", + "packages/db/src/virtual-props.ts": "6add3fb9226251bf4dddadc7c5227469d4183a4df7799047f2fdc6b51ef4d9c0" + }, + "unchanged_this_round_sha256": { + "packages/convex-db-collection/src/paginated-options.ts": "de0e92849d363f2545823d96829964084b1a115b890f0f521f9bdc0958ea2b76", + "packages/convex-db-collection/src/pagination.ts": "0308668d07db701d1f7f891d001a4aca2798973ea29bf1d4b00931eaa0e05f1d", + "packages/convex-db-collection/src/pagination-demand.ts": "d2be74c1f3610c153c2b7c3c9411aa6ea216db7e76c9b0a7f32d63ef2bde57ce", + "packages/convex-db-collection/src/offline.ts": "55e70bdbbc310226a9f0175d0c0912218413ba67433b5b47ccc78d6e7dadd5c3", + "packages/convex-db-collection/src/index.ts": "4247e2bbd168a80791861f4553c10d0d878504a7cdc6a65d4bc648e15b1e51ba", + "packages/convex-db-collection/src/subscription.ts": "a0c312caf2af3667c18e96c565ceb159c20d65550426cdd44aca69a43a3543e5", + "packages/convex-db-collection/src/options.ts": "ea2c2efc4d574080c5e03da8e121b79406640c667a1e260ead9eff22f6253d33", + "packages/db/src/collection/subscription.ts": "c446932a8a1f8b2efd79d00846f1a110e65a6c6df75d2b24658053e6964b530b", + "packages/db/src/types.ts": "2b4c5926922db049e7ccd58c3e3fc22503f6d8a9400f3fb01900e82da85c7a1e" + }, + "production_files_changed_this_round": [], + "scientific_models_added_this_round": [ + "packages/convex-db-collection/tests/lazy-paging-write-model-oracle.ts" + ], + "existing_scientific_models_unchanged_this_round": [ + "packages/convex-db-collection/tests/lazy-paging-model-oracle.ts", + "packages/convex-db-collection/tests/lazy-paging-receipt-model.ts", + "packages/convex-db-collection/tests/lazy-paging-version-model-oracle.ts" + ], + "prior_frozen_artifacts_verified": { + "convex-lazy-boundary-strengthening-evidence": 507 + }, + "review": "Self-review only; no new delegated review or TLA+ execution" +} diff --git a/docs/contributing/oracle-reviews/2026-10-27-convex-lazy-app-abort.md b/docs/contributing/oracle-reviews/2026-10-27-convex-lazy-app-abort.md new file mode 100644 index 0000000000..258eebe9a7 --- /dev/null +++ b/docs/contributing/oracle-reviews/2026-10-27-convex-lazy-app-abort.md @@ -0,0 +1,176 @@ +# Convex: application writes, load outcome equivalence and obsolete replay work + +Starting head: `8c24922026fb4a714cb54b992ac85faa2e876742`. +The changes remain local. This record supplies the current source bindings for +the files in its [manifest](2026-10-27-convex-lazy-app-abort-source.json). +Earlier versioned records and frozen evidence remain unchanged. No new TLA+ +execution or independent delegated review is claimed; the final review is a +self-review under the oracle-review skill. + +## Law and authority + +The [#2269 maintainer decision](https://github.com/TanStack/db/issues/2269#issuecomment-6448490334) +motivates these three additions. Collection mutation handlers in +`packages/db/src/types.ts`, `optimistic-history-oracle.ts`, LoadSubsetFn, +SyncAppliedReceipt and live-query architecture laws 7, 9 and 14 supply this +checkout's receiving contracts. The task-local `laws-before-edits.md` records +the rival predictions before implementation and before the composition repair. + +1. A confirmed application operation reaches the backend and the source key is + correct when its application promise settles. A rejected atomic backend + operation changes neither source truth nor the final promised window. + Independently authored remote operations remain visible on both sides. +2. A synchronous throw and a rejected load promise give equivalent failed-load + public observations and subsequent recovery. Their acquisition leases have + different lifetimes; raw unload/status-event schedules need not agree. +3. Without accepted writes, native cancellation cannot wait for an absent + provider response. Late transport success/error cannot corrupt a later + successful replay or resurrect rows it excludes. Accepted writes retain + their existing application/cleanup obligations. + +Version reconciliation is explicit. #2168 remained open at +`2bc79fc7c416ac0e2d691c0b622f1364e4eb5c12`; its types and this checkout still +describe waiting for uncancelable baselines. The later maintainer comment ends +core request bookkeeping on abort. This change exercises cancellable native +preacceptance work, which satisfies both accounts. It does not relax the older +receipt/replay models or claim the prospective immediate-core-publication law +for an arbitrary adapter that ignores abort. That remains #2168 integration. + +## Scientific model and receiving boundaries + +`LazyWriteModel` holds authored rows and one optimistic operation. The ledger +predicts backend truth independently of every backend read. Its publication +relation combines a monotone source revision, a monotone requested window and +the current optimistic operation. It keeps all compatible explanations when an +operation hides source changes. Acknowledging backend work and dropping a local +optimistic operation are distinguishable: a rejected delete can restore an +older applied source while a newer remote edit still waits. Once the newer +source is public, the older source cannot return. Pages, caches, queues and +commit partitions never enter this model. + +An initial flat list of optimistic/source windows rejected that legal restored +view under held SDK notifications. The old/new predictions and authority were +recorded before replacing that formulation. This was a model-scope gap, not a +production-defect RED. The existing source-only version model remains unchanged. +Comparator controls preserve lawful optimistic rollback and reject source +rollback, atomic mixing, lost confirmations and leaked atomic rejections. + +The real-backend receiver uses actual Collection insert/update/delete handlers, +both SDKs and an independently queried backend. Every history has all six +operation/outcome cells. Each handler is held before the server call and after +its response; independent visible and unread rows change during its optimistic +operation. The first insert overlaps larger demand and an actual held receipt. +Later, real SDK caches advance while their notifications are withheld. The +source-key comparison runs at application settlement before notifications are +released. Every graph callback reaches the scientific publication relation; +quiet checkpoints additionally require the exact latest window and backend. +The server rejection throws after attempted edits, so remote rollback is real. + +The outcome receiver uses the same generated replay history for throw/rejection +and for direct/live-query observation. Failed replacement and ordinary later +writes retain the old publication; retry succeeds with the original failure +identity preserved. Adapter-owned partial resources are released before a +throw; accepted acquisitions release on unload or cleanup. + +The abort receiver uses both controlled native SDK shapes. It proves a response +is still withheld, aborts before any native acceptance, observes rejection, +then delivers with no demand and checks that no rows/receipts were accepted. +A fresh graph then receives two same-run core truncate replays with disjoint +source keys. Retired callbacks deliver both success and error after each replay. +Every graph callback and every retained sync error is checked. The test driver +supplies truncate through real controls; production Convex does not initiate it. +Core may lawfully request a full-source repair, so this profile makes no bounded +work claim. An early fixture permission was too narrow and was corrected; its +rejection was a harness error, not product evidence. + +## Grammar, reach and validation + +The app grammar varies 12–16 initial rows, page sizes 2–3, growth from width two +to 5–6, short text and all permutations of the five-cell suffix after confirmed +insert. Each normal fixed/random campaign has four histories; both SDKs receive +every history: sixteen backend episodes, 96 application operations. Every one +of the fourteen declared reach states reaches four histories per campaign. +The multiplier-three campaign reaches twelve histories per state per campaign: +48 SDK/backend episodes, 288 application operations. + +The outcome grammar varies 1–4 rows, short text, partial/no failed writes and +1–4 rejection turns. Its normal campaigns each have thirty histories and four +receivers per history: 240 direct/query/outcome episodes. The abort grammar +varies 12–18 rows, page sizes 2–3, windows 2–4, short text and both stale callback +and delivery orders; thirty histories per campaign, both SDKs: 120 episodes. +Every declared state reaches thirty histories in each normal campaign. Their +multiplier-three campaigns use ninety histories per campaign. Campaign helpers +enforce nonzero reach and retain both SDK failures. + +Reconstruction: minimal row/page/window forms reconstruct each captured shrink. +The fixed constructive sequences guarantee failure/retry, cancellation/replay +and all write outcomes; the generators vary the relevant neighboring values and +orders. Ablations lose independent distinctions: no ledger permits shared lost +writes, no rejection loses rollback, no notification hold loses exact settlement, +no growth loses held acquisition, no partial failed write loses unauthorized +publication, and no disjoint replay loses stale-key resurrection. Nearby invalid +histories include overlapping app operations, local settlement before backend +acknowledgment and publication without any authored source/optimistic explanation. + +Final commands/results are preserved in the frozen evidence directory. The +package suite, full backend regression suite, deeper new campaigns, TypeScript, +ESLint, oracle lint and diff checks are the validation boundaries. Individual +runner cases are not counted as independent oracle owners. + +## Calibration and direct replay + +All correct production sources passed before calibration; no product-defect +RED or production repair is claimed. The calibration script saves the current +bytes, applies one semantic mutant, shrinks/replays the failure, restores the +exact bytes in `finally`, verifies the hash, and reruns the direct replay GREEN. +Backend calibrations run serially against isolated local deployments. + +| Calibration | Boundary rejected | Seed/path | +| --- | --- | --- | +| Remove handler source-acceptance wait | Actual source key is stale at application settlement, both SDKs | `20261030` / `0:0:0` | +| Swallow a synchronous load throw as success | Failed replay fulfills instead of rejecting with its original error | `20261030` / `0:0:0:0` | +| Leave a retired SDK subscription active | Late error poisons current sync / retired React cache is read, both SDKs | `20261030` / `0:0:0:0` | +| Misdirect the handler's backend key | Actual backend differs from independently authored ledger, both SDKs | `20261030` / `0:0:0` | + +The first three are production-decision mutants. The fourth is a hostile driver +behavior at the actual write boundary. Each failure is a semantic assertion, +not a timeout/setup failure. Permanent model controls separately challenge the +publication and two-sided comparisons. Calibration JSON records commands, +source hashes, exit codes and checked replay paths. + +Run from `packages/convex-db-collection` using the installed Vitest binary or +`pnpm --config.verify-deps-before-run=warn exec vitest`. That command option +avoids an automatic reinstall of the existing local dependency workaround. +Normal selectors are `CONVEX_LAZY_APP_ORACLE_SEED/PATH`, +`CONVEX_LOAD_OUTCOME_ORACLE_SEED/PATH` and `CONVEX_ABORT_REPLAY_ORACLE_SEED/PATH`. +Backend commands additionally set +`CONVEX_TEST_BACKEND_VERSION=precompiled-2026-10-10-72ce13c`. + +## Self-review against the oracle guide + +| Requirement | Evidence / limit | +| --- | --- | +| ORC-001 | Authorities and version difference above; scoped executable prose. | +| ORC-002 | Authored ledger, independent projection and explanation relation; no production semantic imports. | +| ORC-003 | Laws, models, grammars, drivers and public cuts remain distinguishable beside executable code. | +| ORC-004 | Reconstruction, ablation, bounded ranges and nearby invalid histories above. | +| ORC-005 | Real Collection APIs, original error identity, callback observations, source at settlement and independent backend reads. SDK cache/notification premise is explicitly witnessed. | +| ORC-006 | Four semantic calibration failures and positive/negative model controls. | +| ORC-007 | Equal fixed/random budgets, three direct selectors, shrunk and checked replay; no commands arbitrary/replayPath is used. | +| ORC-008 | Source truth, optimistic operation, source/window explanations and settlement boundary each have a distinguishing next observation. | +| ORC-009 | Put combines insert/update; backend acknowledgment differs from optimistic settlement; no transport state in the model. | +| ORC-010 | Subscriber and handler failures are retained; every cleanup is attempted; both SDK failures survive aggregation. | +| ORC-011 | Throw/rejection metamorphism, independently queried backend versus ledger, and atomic-mixing controls challenge shared-fault hypotheses. | +| ORC-012 | This record, source manifest, calibration classifications and explicit limits. Self-review only. | +| ORC-013 | Zero accepted work versus existing receipt owners' accepted work; held receipt versus application; failed replay versus successful retry; optimistic rollback versus forbidden source rollback. | +| ORC-014 | App mutations/rollback/cache advancement are received through both real SDKs/backend. Adversarial stale callbacks stay a controlled-boundary claim. | +| ORC-015 | Every named state is declared and its fixed/random/deep counts are captured. | + +Limits: one app operation at a time, unique ascending finite ranks, bounded +strings, one source, finite responsive schedules. Rich values/schema variation, +reload/snapshot/resume and new health APIs retain their existing/deferred owners. +Initial-load outcome equivalence and arbitrary callback reentry remain with the +core lifecycle owners. No arbitrary nonquiescent temporal progress, real SDK +adversarial-callback emission or immediate core cancellation of uncooperative +work is established. Generalized field/backend normalization and the public +cross-adapter kit remain outside this change. diff --git a/docs/contributing/oracle-reviews/2026-10-27-convex-lazy-backend-concurrency-source.json b/docs/contributing/oracle-reviews/2026-10-27-convex-lazy-backend-concurrency-source.json new file mode 100644 index 0000000000..958bb2fc5b --- /dev/null +++ b/docs/contributing/oracle-reviews/2026-10-27-convex-lazy-backend-concurrency-source.json @@ -0,0 +1,386 @@ +{ + "starting_head": "8c24922026fb4a714cb54b992ac85faa2e876742", + "binding": "Current working-tree source SHA-256; self and unchanged local installation workaround excluded", + "files_sha256": { + ".changeset/convex-collection.md": "c7ad56ad983fe653f0054f0a82fcd47cad46c1d59087d62d79a21ffb077eb324", + "docs/collections/convex-collection.md": "3901d5d040f8e101369f1a16a160d4e5d0df391ec48408f99d995191ad013105", + "docs/contributing/oracle-coverage.md": "007218a899ed4ca0d2860533a7dcaa258f10c3ff74e8aa0beda95ba90cc8115e", + "docs/contributing/oracle-reviews/2026-10-27-convex-lazy-backend-concurrency.md": "adea3ed30c6f5df32e5af261497afd84c5c5519ca1ef71130681e899e7eba450", + "docs/contributing/oracle-reviews/2026-10-27-convex-lazy-interleavings.md": "f105899875a4129b7d816cacc1d6f094c68ad6048cf70350e8d105bd0acd89e6", + "packages/convex-db-collection/README.md": "53b984ef9156a52cea7cb92a81d34d6543508d2dbf048d8f2aeb361ae996d259", + "packages/convex-db-collection/e2e/backend/convex.json": "e7106add2ff21d61a430927bb9b1ba96b036b93fcecc31ff786149a6070ecd67", + "packages/convex-db-collection/e2e/backend/convex/schema.ts": "722077ed1d0b7a3b5172ac867d95c573fd38e7a45ada747016cdd2b4311a913c", + "packages/convex-db-collection/e2e/backend/convex/tasks.ts": "09066afb10452e8c19dd8cc7dc129d8a95aaecec6d5144f92654f5c40f73ffee", + "packages/convex-db-collection/e2e/backend/package.json": "8e2e33603aa201acb7c8d88a55130bc07275d8fb53693e0965539ae4bc4e3585", + "packages/convex-db-collection/e2e/lazy-concurrency-oracle.test.ts": "69a02d34d0efd0d1881cfbd321f22aac2e820597efd2cd32fc3fa1dec745b625", + "packages/convex-db-collection/e2e/lazy-paging-oracle.test.ts": "7a5c1390879e4e01757d020e22bfa24f5852a2cf5bb4e18faa4174ce47a02e48", + "packages/convex-db-collection/e2e/local-backend.ts": "e38be0c546a885f55b004f23bc894ab86d3c3b5880bfd9dde8208866a42aea5d", + "packages/convex-db-collection/e2e/network-gate.ts": "73851db1ae46e83b7ee1bdb88faf01aaec27178644a73e1f7835f94681806dd6", + "packages/convex-db-collection/e2e/setup.ts": "d448bab619ac7243957578b2565cdb647a5e71a70df3c65af3c3dc378b5df6bb", + "packages/convex-db-collection/package.json": "2f7708d14a108253b257b1771f85060642817c6896ba240c7a0d1b853428b799", + "packages/convex-db-collection/src/index.ts": "4247e2bbd168a80791861f4553c10d0d878504a7cdc6a65d4bc648e15b1e51ba", + "packages/convex-db-collection/src/offline.ts": "55e70bdbbc310226a9f0175d0c0912218413ba67433b5b47ccc78d6e7dadd5c3", + "packages/convex-db-collection/src/options.ts": "ea2c2efc4d574080c5e03da8e121b79406640c667a1e260ead9eff22f6253d33", + "packages/convex-db-collection/src/paginated-options.ts": "de0e92849d363f2545823d96829964084b1a115b890f0f521f9bdc0958ea2b76", + "packages/convex-db-collection/src/pagination-demand.ts": "d2be74c1f3610c153c2b7c3c9411aa6ea216db7e76c9b0a7f32d63ef2bde57ce", + "packages/convex-db-collection/src/pagination.ts": "0308668d07db701d1f7f891d001a4aca2798973ea29bf1d4b00931eaa0e05f1d", + "packages/convex-db-collection/src/subscription.ts": "a0c312caf2af3667c18e96c565ceb159c20d65550426cdd44aca69a43a3543e5", + "packages/convex-db-collection/tests/helpers.ts": "11f2c79c6872373e8f77efae62d2fabfead0e5af6673c86920ae2ef4abdb7274", + "packages/convex-db-collection/tests/lazy-paging-driver.ts": "9819960aa222777f9de3b4bbc3728d492fd9304865701dacbbe381591e88cea6", + "packages/convex-db-collection/tests/lazy-paging-failure-history-oracle.property.test.ts": "b96de9cf49b58cdbc07e364312692e677363c90a83a9c1d94b84cb2b6c4dbdbb", + "packages/convex-db-collection/tests/lazy-paging-history-oracle.property.test.ts": "5bc8808d6f71648f48e40acb05943132448f3e8f5bba31d97e261f1741674384", + "packages/convex-db-collection/tests/lazy-paging-interleaving-oracle.property.test.ts": "f3a0238452746233224bc0b21adf790b594cacef82d786a0ba419d19980bb773", + "packages/convex-db-collection/tests/lazy-paging-model-oracle.ts": "5c25e5765413eb6ad3d7e26bd10e7cd20c75a9c094cdca0e142b87da56d5dfc5", + "packages/convex-db-collection/tests/lazy-paging-native-receipt-oracle.property.test.ts": "5714e2c25507f1ea4aef5bb6eb9bed69bc9a9b079540667570227aa1014a0822", + "packages/convex-db-collection/tests/lazy-paging-oracle.property.test.ts": "3a7b5b6284076bc8dbdb59ac1d735f509c6bc19201ad355a0382e7a797a2d569", + "packages/convex-db-collection/tests/lazy-paging-receipt-model.ts": "ce9d4e5bcf6265536498462c218deb5d8c42a4f1c2f792dd98042ed7b077902d", + "packages/convex-db-collection/tests/lazy-paging-receipt-oracle.property.test.ts": "06b60b0a86926070dd57dd068f4abe572d48924e2cea29acb77f417bf8115d6a", + "packages/convex-db-collection/tests/lazy-paging-version-model-oracle.ts": "23f2b3624ecf0e086f2d9b04eb3f940ead4f9a6fe320dc7b100fc04ef82099e1", + "packages/convex-db-collection/tests/lazy-paging-version-oracle.property.test.ts": "8f4f115806a65dc71b3382dade7a89e326decc2b3c25505369783f30163a210e", + "packages/convex-db-collection/tests/sdk-cache-oracle.test.ts": "0e011a20ce991195742bdfa0cd570d831105604ad8e3e2cc4b8fab4b62daad58", + "packages/convex-db-collection/tests/types.test-d.ts": "3ff1a42d6caf95d14e5270b632340524a9f796b978ccd031835bd1f2d0dab6dd", + "packages/convex-db-collection/tsconfig.json": "623bc70a72fd718e7d209c90a08df12cced0e9045a3565058623e34e946a5710", + "packages/convex-db-collection/vitest.e2e.config.ts": "b43b7b797860455efa827774e9847975bd9850f81b9b284f43665c422e45b440", + "packages/convex-db-collection/vitest.lazy.e2e.config.ts": "cffb0cba6d38edd898e0f7b519d970ae28608351aacb66b5ef54f73e7c2c1905", + "packages/db/package.json": "bb6df01ff9589c8b75437683c935b8457f65e9216d37173a4730e1a169e0ab43", + "packages/db/src/collection/subscription.ts": "c446932a8a1f8b2efd79d00846f1a110e65a6c6df75d2b24658053e6964b530b", + "packages/db/src/query/live/ARCHITECTURE.md": "aa6a41b651809819d414d364421140bab02f9b992e9f5dbe73cf634b43c46478", + "packages/db/src/types.ts": "2b4c5926922db049e7ccd58c3e3fc22503f6d8a9400f3fb01900e82da85c7a1e", + "packages/db/tests/query/load-subset-joint-replay-oracle.property.test.ts": "975585ef1d1bc921afcab2e5384318c5f661dd0a2577e642351b2aafed0ee98d", + "packages/react-db/tests/convex-lazy-paging-oracle.test.tsx": "0a33955d45816d766100b89e45dd63ce32125864cf3e2f0ea46533a582b0333b" + }, + "runtime_js_sha256": { + "packages/db/dist/esm/SortedMap.js": "ecbe71a4b4387932ed64817362c31eeb58c151d1fda5ba6824a491e4d6eeaf84", + "packages/db/dist/esm/client.js": "4191f1ff58a2b3eae418d2e8dc56b0fe16a290ff233129535b0945f41a34f0da", + "packages/db/dist/esm/collection/change-events.js": "cb5740b95d7c403e0ed7fd8b2aa831a921492782fc66c47bf1c91647070de0ac", + "packages/db/dist/esm/collection/changes.js": "084445baf434ffbfce6ceba34108c32dea5a58b5db68d6c068af6a8b762fff34", + "packages/db/dist/esm/collection/cleanup-queue.js": "3378b66f70261b2ce7eb14fdd982114e838ba6ce6d61ec18b0de976605cb9ba2", + "packages/db/dist/esm/collection/config-errors.js": "d6beb8dddab089d6dfb2516b5850b4b2a709b2bb2a9843bda854ad47723c35d1", + "packages/db/dist/esm/collection/events.js": "0cb3eadd7a9ce2e454e0c4a1a1b0023d2fb96724ad5a4dc806684f9ed4e47d17", + "packages/db/dist/esm/collection/index.js": "01e254fc0d024e7e716b4b09a10e7656130663ae587ad72314873f3b87f5fc2f", + "packages/db/dist/esm/collection/indexes.js": "9d1dbc166c3a44cd9348a62d96c7724335ef38c6f466575c0bbc2419e5647130", + "packages/db/dist/esm/collection/lifecycle.js": "2a26fe829e0183e77a33777f3003e1ab8699a4a55ec6fc309336b35c581ea03b", + "packages/db/dist/esm/collection/mutations.js": "42ed12785749b4098b422a538155ade6d002eaef3ad9cae227c0218f44c95e42", + "packages/db/dist/esm/collection/state.js": "d03955c898fc1672a1a708723d6e4506064665f8a24c57aea6be956f2c4dd0b4", + "packages/db/dist/esm/collection/subscription.js": "ac04fdd5bb56894afa45ecd9bef37abd4f96418c7912cd329f40e397370d27aa", + "packages/db/dist/esm/collection/sync.js": "82be78b67610f59fe60bad6e9f9776884287d38d6bc5641d1478875a559c6961", + "packages/db/dist/esm/collection/validate-config.js": "16a2b254fde0bc0d37c376107a5f394b17dab89165d99ff56a1897af9c234138", + "packages/db/dist/esm/collection-options-brand.js": "ae4fa09d0ac1ff1a9be961efa924d7dc769aad56a7e67cd1552c08b48fee3880", + "packages/db/dist/esm/collection-options.js": "1c6d225b2e8fd061f7e7616d67de43288583660e99a8a596b86d3f8c58c3a0de", + "packages/db/dist/esm/deferred.js": "d8ce1c72bd8f76a4e49aa17042e46a5432284730d1b2ceb4b5afbf927f881e2b", + "packages/db/dist/esm/error-message.js": "176a340e97f58cf9fbb21edc92cf43c6703ad2110310965b3da0f983e1cfab9f", + "packages/db/dist/esm/errors.js": "8c22d726d3878b029e2d9bf89c158ae2c25f915fdc9833711cf63fab3e48cd26", + "packages/db/dist/esm/event-emitter.js": "07add965808d5bd93b29d97e0e6d7db1c060856a1ea20cb7601a81785f2c67dd", + "packages/db/dist/esm/index.js": "fa5714b1da1264029dc75dd2f9e18edf145f128232feca5690392ff94dac6b8f", + "packages/db/dist/esm/indexed-db-wrapper.js": "5ca4ed531df467c317a8b302f218664a13ab579cc472eb4b9dda3b70854ec667", + "packages/db/dist/esm/indexed-db.js": "f7a7b78e057570562aa7eec0279e37a58ac7c4cfc05e820401f89728654dfbf7", + "packages/db/dist/esm/indexes/auto-index.js": "663f98c52c018571e87f398cfd4efe51bd27f48e2a4ac76b8e31327a725da8de", + "packages/db/dist/esm/indexes/base-index.js": "59652757da474e7119d759f784e954b56d9daecf586e385b2bda4ecd9219f465", + "packages/db/dist/esm/indexes/basic-index.js": "1ac365be033cb255d3b4005de57b7466b9ed4cd21daa08a77d8dfcfef0584626", + "packages/db/dist/esm/indexes/btree-index.js": "9ae7bf699fd9d44f9cc7dab823df85638efb2f43114eb33aaa7120efc367269b", + "packages/db/dist/esm/indexes/index-registry.js": "cd9448a61e290d32e23090cc57c8282aadb2d1736611a33509dbbe1f328d080b", + "packages/db/dist/esm/indexes/reverse-index.js": "94f0751672696624240f6e214a9b16517c9145269968e07ec7b2b8f439e3f25b", + "packages/db/dist/esm/live-query-adapter.js": "e787323960ffd3e848ad669ccfea1df3d21d80e084e75b6048740dfe23756e3c", + "packages/db/dist/esm/live-query-observer.js": "53eb064feaded0b54fb64e5a4741b2ed5cf94de00638a2bac0a95e573aab75d8", + "packages/db/dist/esm/live-query-options.js": "51ddfc4eb7ae745a75db197d67e23cd0b9b15f527b1fa706a759d90fa7920f2c", + "packages/db/dist/esm/live-query-primitives.js": "67f6abfb58ecba0abfa2e3c8094c98a86eadad39bd96afeb48912a77475bd76e", + "packages/db/dist/esm/live-query-window-binding.js": "3f239006fa71213f924008cccdb29b9ad57d97beff2947a30f0e0130370bac02", + "packages/db/dist/esm/live-query-window-controller.js": "bdb129aa7772774fe9fddb598d911bd7ae06cb8d581c05c41f47152b478f6805", + "packages/db/dist/esm/local-only.js": "0a4a5d855e4e3db629d20898eca261fa2dc02c3cb2d0c6feec32a052796930b4", + "packages/db/dist/esm/local-storage.js": "5a01856bf1eb5f1cb91aeb6536f3d685e8129aac6e9ea0c6c5feb62159aeb8ef", + "packages/db/dist/esm/optimistic-action.js": "ca9f92ae29a9710cd18e3e71c46a2cc0f204334525aab42174fdb4f53ec67b89", + "packages/db/dist/esm/paced-mutations.js": "01135f1c21fd5deefa89c92ecf7598bbe42d98aa284e646ac21c470c0d346369", + "packages/db/dist/esm/persisted-readiness.js": "160a5bdb6868f603e1bf1358ccabadd72cbe70c05d87f807972873bb03f9c850", + "packages/db/dist/esm/proxy.js": "33a224882c8dc0db103d6d0b92e50bb71021030e608937e7a800411ee9eb3fb6", + "packages/db/dist/esm/query/builder/clone-query.js": "9fdd9595f744fd286b55de26cd552bd630a358e4114dd605ad4d75a474ba70ec", + "packages/db/dist/esm/query/builder/functions.js": "d43c221d002d703965be1587f725255ceb58dd11258670b2882c3451340efe55", + "packages/db/dist/esm/query/builder/index.js": "c42986783a038350a694a9d8352f559017f62fc4b51197ca99f6ea1ce386a8fc", + "packages/db/dist/esm/query/builder/query-ir.js": "8d535e9be5fea36bbea302665f1d30a1b4a029fe4fdc95e6f674b6b0a3c5ad34", + "packages/db/dist/esm/query/builder/ref-proxy-identity.js": "fc756419ecb6b451640f729a44aa2e9cef4385d20ef1c68c049d36fde27d2b10", + "packages/db/dist/esm/query/builder/ref-proxy.js": "b71f544f804438a3c4a7ccec380fa0f4614ec7c859a983de4520d19390707993", + "packages/db/dist/esm/query/builder/wrapper-identity.js": "00c10f26051967ff8f284e550ab207552ac2632cbc08a7623617dad1c593bb06", + "packages/db/dist/esm/query/compiler/evaluators.js": "0ccf7503f360331210ffabf4a1003c96f904f831400ca2687f553f509a52f21b", + "packages/db/dist/esm/query/compiler/expressions.js": "eeb0c6dfca9871b4f7a1dd893c265e51e1b23d20400ac28579875446ac37ca87", + "packages/db/dist/esm/query/compiler/group-by.js": "ca935c6886fc9cb1574fc71e22efe688beceb36bd524a25a6aa29ae0bcf44dda", + "packages/db/dist/esm/query/compiler/index.js": "19667be77fa84da780beb102509c28d98abd365a330e8e7da4e9ac5a51154959", + "packages/db/dist/esm/query/compiler/joins.js": "af1bd496e41902c7e5b9759b4231d0487db2f67f8aa060b75fd326c7791c88b3", + "packages/db/dist/esm/query/compiler/lazy-targets.js": "04ed7aec7600a3ccf86ac28f06f8dd171770e56ca01a11b74d0b59fba6e083ea", + "packages/db/dist/esm/query/compiler/order-by.js": "e9844a5178d22908bf1ee6a723fe903d05110f6c333eefd8851a83165b39180d", + "packages/db/dist/esm/query/compiler/parent-routes.js": "bd0cf9a9781b321181ed3e2b6571d94dcbcd7bc784c53399b98051364c0fce80", + "packages/db/dist/esm/query/compiler/query-equivalence.js": "139c14fa078e5ba4c4a3d7490854c336870eea4c30f5960cd8f15980ceb00b57", + "packages/db/dist/esm/query/compiler/route-metadata.js": "6889f6b3dec56ef8fe5e6ee05e892dab1c22c0c0a13cbe09afa76774dae8a614", + "packages/db/dist/esm/query/compiler/select.js": "3f7d19d780395dd84c1b2b2c506e2b4f847a5a8a9442016bc5f72597f454af18", + "packages/db/dist/esm/query/effect.js": "59f37b190830c138368260801b09e7bc7a30e347fe36681b4796f6902c7e7c3f", + "packages/db/dist/esm/query/equality-conjunct.js": "ca0bee4e908fd4dbe52dc78feb172dca90492edca49cabe509c3cb4e4fd67fd7", + "packages/db/dist/esm/query/equality-value-identity.js": "5091c8c6fbb1e06127ef19db34258fd0d765b3c1b96c4a4f55b6fb553e37fcca", + "packages/db/dist/esm/query/expression-helpers.js": "5c957e7d0f23fb781aae8ead705404cf7bec3b1cd68db3fb46b356a1d25002ab", + "packages/db/dist/esm/query/ir-stable-identity.js": "a9cdba9efedc50caecaf7f8724f4146ab8d5f16d916f25707112a43cc36a0a8b", + "packages/db/dist/esm/query/ir.js": "9f2a67c11ecf08e1af74375eac7a603455e12ce56a75317376fd90b55193261a", + "packages/db/dist/esm/query/join-conditions.js": "0ede70428fc77b12d38c333b22767d629d50e88dca26af0c495c83c3308daa85", + "packages/db/dist/esm/query/live/bucket-facade-adapter.js": "daee317ea51844f450c893f7ce57e3383a0bf96c52e2bc4930d1aff951e5c7b3", + "packages/db/dist/esm/query/live/collection-config-builder.js": "93228d57a26e0e6dd5d51553c5fe10c9ed49f7803979ad21a76aa6805edec3b4", + "packages/db/dist/esm/query/live/collection-registry.js": "05e27e3d041d58aefc8820e523163b2e21ee53a3940680a0b468bb8e81e6d46d", + "packages/db/dist/esm/query/live/collection-subscriber.js": "11dc6621b866033008c2d6c9c2ba90ce36eb94d5f0f0ae3435ea55ecee736f3f", + "packages/db/dist/esm/query/live/graph-scheduler.js": "d3888f67b6e09ac29a3e45a120e3cbbceda49be7415eacaf0856cecf2cbc9e9a", + "packages/db/dist/esm/query/live/internal.js": "244a7f35268790408879ed8b1774fdd767b78829dc1a5ef6a0df4c2c372c0258", + "packages/db/dist/esm/query/live/materialized-pipeline.js": "6d98c2d4ee9412f5816c4151c14f73224b449738df183d0c82c37c08056c64c2", + "packages/db/dist/esm/query/live/ordered-source-loader.js": "63481aa7985a8e9de91f7e3499deb0e5d6f53d6abec6dd6614609ac1f1b5efda", + "packages/db/dist/esm/query/live/subset-demand-controller.js": "1163906179d8c68fbaf36507411c258ce664e8d4141f0a8c4b01f905f1cd1c27", + "packages/db/dist/esm/query/live/utils.js": "3df2bfd90ae95fda8c421e3131e0aaf90875e2cb1af514a09da42ec67e78f899", + "packages/db/dist/esm/query/live-query-collection.js": "f6024029a340a675df1e36ed180ab0d800ac0e697399a5b0ffdab392fdb87181", + "packages/db/dist/esm/query/optimizer.js": "2992bfaaf19a94828538613dcfffd71e4908952fd7c401d4fa01509c53776093", + "packages/db/dist/esm/query/pooled-live-query.js": "0c83e1139162d0314a0a6f1076911d4cf635877d6c9dbc89b8983d27cc90ac46", + "packages/db/dist/esm/query/query-once.js": "efa18058ba8d8abf2f201cf0a6418ab3f1c133d4d7464748f55c0ff8839150f8", + "packages/db/dist/esm/query/runtime-reference-identity.js": "3a5de8435888ede6b0edf799e5f0c192192aa1e0fad922450e9774ebceb4de18", + "packages/db/dist/esm/query/subset-dedupe.js": "06203c388ec4f1e525de8fe770775736222b62e273007e9a31bc37ba5fbab15a", + "packages/db/dist/esm/scheduler.js": "d9b4d34d6a4c656c0f38859426d39830ff62053ab47d22a14acc8b34bb6a8136", + "packages/db/dist/esm/strategies/commit-completion.js": "bd177de52002dceb9d28e02223da81b05c00ca5f72f5c06aa70699142904b263", + "packages/db/dist/esm/strategies/debounceStrategy.js": "88c73e9970201acbe24c3ea11632f2747f174cfb2840a53fdc4ba25224feffff", + "packages/db/dist/esm/strategies/queueStrategy.js": "abf15dc18ce9f481ef876478b0e2190188b960d76d7cedcf4f0c8b2a6818f9b8", + "packages/db/dist/esm/strategies/serial-pacer.js": "84c01c2f15ac57689b7f80a8ca0b152035cc2dedd8573e7e816b1a2bd0dfb08f", + "packages/db/dist/esm/strategies/throttleStrategy.js": "f646adf4f396ceddebca7580b398dc57edf1a22dfc2dd2e6c1eb607b412c0de0", + "packages/db/dist/esm/sync-persistence.js": "16292216a685c02ce1f3e1b2945a2409600e42d951c6acd19090dae99a38dacc", + "packages/db/dist/esm/sync-receipt.js": "c4365d1f36cff69c10194edef4903526fa85078cdf135172978075b5a3d56652", + "packages/db/dist/esm/transaction-commit-work.js": "c6838b1423ba8b0a1bb2ebf848fa37394416d52369d0d7ee5e8508d67f87066b", + "packages/db/dist/esm/transactions.js": "901116611280b5cbd71c3d9c49b6bad061174d90fdd0d2ab29f8cb66d5fb12d1", + "packages/db/dist/esm/utils/array-utils.js": "e8b8006009a956a5520822c9bb1ac8a9b13750613b0be937c2c2cb8d14c5d4c0", + "packages/db/dist/esm/utils/browser-polyfills.js": "620828c9fcf0df2181fed5d866a5ad20f1ac01ad5a296dcb04fd8be7012f6d75", + "packages/db/dist/esm/utils/btree.js": "2db68fc77ef20eb576502fd284668da1c9e04b18befc88dbf3d6842b722e75a6", + "packages/db/dist/esm/utils/callbacks.js": "0e5c79079dcbd00609aaeea1d769ae56fd881f4245740dfce408419773c599da", + "packages/db/dist/esm/utils/comparison.js": "19d37b5b97b84c1a19d331e277f47042e87ac01696d90a74fd3f76030d45e7ae", + "packages/db/dist/esm/utils/cursor.js": "1051977f9f8acbb6fb6884faf467c5c379cb54fdf0b02d22c9c1eb14cc52ca86", + "packages/db/dist/esm/utils/error.js": "a19d18998688b8a2ebd796bf668c694fe5f0c18fd4a4f8434f91589a7e436e40", + "packages/db/dist/esm/utils/get-or-create.js": "b1b71fa6f0e123e44f4908ea606ad6002515afbe204dd5841f72789f8fdaf3d3", + "packages/db/dist/esm/utils/index-optimization.js": "24ffd884fb0c24a49f614c5cc7fa9b37d9942bf59ccb12ccba07c7a0a328ea1c", + "packages/db/dist/esm/utils/source-record.js": "dcb726a12fb21ca74fafa5eb78ab7b83f1d18c2176fee2af11f430322ada6f48", + "packages/db/dist/esm/utils/type-guards.js": "a0327ad9ec010fa5f75a73b01b302c19433258db3ae23ce563a97eff8d981fa6", + "packages/db/dist/esm/utils/uuid.js": "fae77bf0bc610f2fe2e2b7b3ac289f48b572b223973780c6b895bb878c68e8f4", + "packages/db/dist/esm/utils.js": "ab6496511234ca87fd1c59c51c7b8f2f8b8601ea5d7ed15057f3068a88210f4f", + "packages/db/dist/esm/virtual-props.js": "76a0a0e70ee0f959dc7f4c2dac49bdcd24d8a7427b1cd59fb708d7bf47a7fde0" + }, + "runtime_source_map_inputs_verified": 106, + "production_files_changed_this_round": [], + "scientific_models_changed_this_round": [], + "production_files_unchanged": 7, + "prior_interleaving_artifacts_verified": 143, + "prior_formal_artifacts_verified": 250, + "prior_loss_audit_artifacts_verified": 9, + "local_install_files_unchanged": 2, + "runtime_source_map_sha256": { + "packages/db/dist/esm/SortedMap.js.map": "9fa31bea314653a026871313192f228eb75af82e8cec748e8041d1d7bc3d6978", + "packages/db/dist/esm/client.js.map": "33a90696c3b077207b8b1ccd57b1e9299f505a9d468466746066dfe3936c9a32", + "packages/db/dist/esm/collection/change-events.js.map": "c53651fdb6c5b229a2280d45af00a4ec969d5edd48d11608e96c8e813e109ceb", + "packages/db/dist/esm/collection/changes.js.map": "66ba53f09b63f0e668f7522297736919261a64518ec2c43b16eec770e413dbe1", + "packages/db/dist/esm/collection/cleanup-queue.js.map": "84777c1baefeb9e15bfad6f2144decb14ad0236895f8eb59d6721221aacf25e4", + "packages/db/dist/esm/collection/config-errors.js.map": "6bb878aebe57ff145f43dd2afb85c80041cff4b39546ae5e6eb3c7c1b7ec57ad", + "packages/db/dist/esm/collection/events.js.map": "4c035f44d2a25221e333af7799d8dbb06d8b69a12584fe6668f517c71ea87b4c", + "packages/db/dist/esm/collection/index.js.map": "2e50904cadd394695e45c5e82bbdaf1f22f29d6787e7ece5e225250aa721f438", + "packages/db/dist/esm/collection/indexes.js.map": "415a842ba3570ad1a6f341edc761c80b7c37a41c1dcc568121a854d95fcd91f5", + "packages/db/dist/esm/collection/lifecycle.js.map": "12486a4c6d0a7dcaca4489596e2e6da0deb1284c63538c3547773cfd1a1af3c4", + "packages/db/dist/esm/collection/mutations.js.map": "57b450f3fe34ff7c279e349338b525140fe287754763aa4e335cbae08f4374b7", + "packages/db/dist/esm/collection/state.js.map": "f11a3431edc53bcffebcd0bbd6c27af38c2a8c38e5b36e2d2b66b934031b835b", + "packages/db/dist/esm/collection/subscription.js.map": "73c1f1ca5d23cae10c45175164f5e2c59aa10138f13c86ed6ee775f1655765bd", + "packages/db/dist/esm/collection/sync.js.map": "cccc3eeceefdfebde3c092cc3944e23a2aae67eb0c57ff7f3a0694fec98d1d76", + "packages/db/dist/esm/collection/validate-config.js.map": "fd8cea162de4fe8ef094df6522aa8705b83c12fbfe8f9433ee556b3aa76d069e", + "packages/db/dist/esm/collection-options-brand.js.map": "006f1ec5a86b56a67c81e1450d129a8c18c2ff40aeedbac730ff344cac539248", + "packages/db/dist/esm/collection-options.js.map": "5e0b50e274db3185ed9b0c7192920a562c2d9669e57bdb4317ff27a0c7bc0bf8", + "packages/db/dist/esm/deferred.js.map": "c87f06ed0c6b0f229700d59008329205ff3fe7fd5466306982bd932ae273bfcd", + "packages/db/dist/esm/error-message.js.map": "1ddef42c2961d5d65ffd8bafd3a639431a539cf90da41cd5ee794710f3f2884c", + "packages/db/dist/esm/errors.js.map": "6d4e905f2f6c38ea74cef7cbb5fa6f46ccef861488bb57eb2096d3fe4e8fdd9d", + "packages/db/dist/esm/event-emitter.js.map": "b0b3a8588c21ee62b7d3f23d2dffe07ae67f3a451f5151b6f7dbc528c7d009f4", + "packages/db/dist/esm/index.js.map": "f8108774caf02d99844c09563d578fbc3b23ff1a41815163eb053569fa2d1544", + "packages/db/dist/esm/indexed-db-wrapper.js.map": "0203fa669e4b0329df4575a721ca2a3a170180209aa1e4ef6083223f9339c6e8", + "packages/db/dist/esm/indexed-db.js.map": "c29f3c555810dfc2a4b0c75c9ea1f7ba76ebae042d9380cf1f8c69a5471a20c1", + "packages/db/dist/esm/indexes/auto-index.js.map": "511e7a724df721c7decc03223fc765a289f06274cdd379b7de89383a2673d2e6", + "packages/db/dist/esm/indexes/base-index.js.map": "fd5416f04da3991df1fdca5605cec332b7eda9e2517962bbfa437ad10028b286", + "packages/db/dist/esm/indexes/basic-index.js.map": "8a1d3483240e52f21e82f22424b4168b4e17d7f0127e74d6cd2fb1abcc491645", + "packages/db/dist/esm/indexes/btree-index.js.map": "24afa9523a33e8c7ae06c0543991f83b454c9ca67e667a125e363bd3665b4c87", + "packages/db/dist/esm/indexes/index-registry.js.map": "5c0bbfbf82671306e89a15c7ef3bbb8a4813b268b4f317d75be63f88eb41a75e", + "packages/db/dist/esm/indexes/reverse-index.js.map": "633cce9cc9774fbe8854d1d9a338e26b3158107d1edbd0f2161fe952eb4700fc", + "packages/db/dist/esm/live-query-adapter.js.map": "26c5f830609eff6e54e4520eb0ec269a3aad375c2fc857a3ae77140e0179e7f2", + "packages/db/dist/esm/live-query-observer.js.map": "5475b3532cd994257fd71961200597b079078521b81440265ce455fbba901986", + "packages/db/dist/esm/live-query-options.js.map": "ad90acb33887c695fdfa862cd8d5d49e68cd4db37198db903b56c6382e83a7b3", + "packages/db/dist/esm/live-query-primitives.js.map": "41afad71afdcd23aada590c459cd4533d2737a7c28c21ebb5eb23e7fcf7e6386", + "packages/db/dist/esm/live-query-window-binding.js.map": "6c1bd8174569741a154f5bba07fd3a09f18391e599e42d4b9b697089aa60154e", + "packages/db/dist/esm/live-query-window-controller.js.map": "298367ea2b702476de2c969a4226821c6ccfa033d3b90947b330c21060d146ce", + "packages/db/dist/esm/local-only.js.map": "c8eace5d10617bb0c5e178db897b6b5905a51b3b1e6c10e3b1018b14aa08c373", + "packages/db/dist/esm/local-storage.js.map": "95e266c329925a1a2ce6c984f4509f6366d40fb3ef02f50998532eca55538611", + "packages/db/dist/esm/optimistic-action.js.map": "1ff3eaf6f6ae7e2a08f1988eac3f9f80b307cfeb61b7031579f19cee774d2207", + "packages/db/dist/esm/paced-mutations.js.map": "209978b76decd4e48ccd0eca467a8fe1878a0b24145f4903df2a30aa66fea200", + "packages/db/dist/esm/persisted-readiness.js.map": "b36b855729c7fe5e9cf49a0eaebaf66f48ec69e87acf762e21079907062d7c78", + "packages/db/dist/esm/proxy.js.map": "7e93b8d55ec84bfa6435b535abf642a4ea817750ad0af18bfb4b0841af5052ab", + "packages/db/dist/esm/query/builder/clone-query.js.map": "a458b5f869bccb52d83f5ccc2a022e617198b07d71161599925d2b179b22ce8a", + "packages/db/dist/esm/query/builder/functions.js.map": "7209aeb6b70bc74fc1631dee124bdba42525c9de6c8e63257a9cb75c67225703", + "packages/db/dist/esm/query/builder/index.js.map": "57f18c1d77caee866a05de3343ab14417dce54b5b3d84e484a90c64f5b305212", + "packages/db/dist/esm/query/builder/query-ir.js.map": "404592f23d86e39fed95862bcd6c3a40ea9899bf64ce2b3e454b7a246d799096", + "packages/db/dist/esm/query/builder/ref-proxy-identity.js.map": "b0a54507e2de7678cc902067c315216eddde48cb9de7bee7ecebe9ca89c88e22", + "packages/db/dist/esm/query/builder/ref-proxy.js.map": "c5c6ae0637ae9be5ff1a2e3cf54d8788f57baaa8c1ff6629c962444dfe7851c2", + "packages/db/dist/esm/query/builder/wrapper-identity.js.map": "b7b11e683600fd94d16e869cb38dfa250ff56eb7b34b4e95d20ac35b497004b2", + "packages/db/dist/esm/query/compiler/evaluators.js.map": "d68150a8ca3406a2960168688fed45f5e5fe07573802691e4bb400499977e6b1", + "packages/db/dist/esm/query/compiler/expressions.js.map": "f41f8b46979dce48936373f86cc990357a82699f51585aa7ff34fb284430a704", + "packages/db/dist/esm/query/compiler/group-by.js.map": "c677452c235bbb7b08ed8c380953304c2fe462c5ac5a9de7a22098f00b65f11b", + "packages/db/dist/esm/query/compiler/index.js.map": "619130f951479e75e3a261b7cfff523dadd61d491ff5f4b66d0919a4b0509f1f", + "packages/db/dist/esm/query/compiler/joins.js.map": "c2c321244483a65edb382c6f2c55cd7f395bd383671f1d6b63e07b9b8df65104", + "packages/db/dist/esm/query/compiler/lazy-targets.js.map": "a95673b8a853f10dbd239bf3612f4f644e582bc5dc0a7f12873898a7ce33d7b6", + "packages/db/dist/esm/query/compiler/order-by.js.map": "898ad1b7bd1bdbd1c2c0a8edbb78e7ce14811bb4a9fed3e148befc1127a9509d", + "packages/db/dist/esm/query/compiler/parent-routes.js.map": "e18ec4a4c12e4c4f2e176e07c47eb3d58e36cc82eb5fa6327c92106552c1d523", + "packages/db/dist/esm/query/compiler/query-equivalence.js.map": "0e569092d8666ed94fb82c83ec29c5e9663f5d1ae1afadb45856fc4278b5b19a", + "packages/db/dist/esm/query/compiler/route-metadata.js.map": "3bf70196b5be0b9dcadfaee25e7c4764874d76a50ae88c15486f64fa6aef65a9", + "packages/db/dist/esm/query/compiler/select.js.map": "b400ce63f5ce3a505045241de9b5d2b9cb1dccb3e2fbf04475c7d1204717b779", + "packages/db/dist/esm/query/effect.js.map": "0d8f6b6535196a2bc2038830616750b99efbb47505ffab786c7a7c018c296835", + "packages/db/dist/esm/query/equality-conjunct.js.map": "1bc30e55bad05f535c5dde6ff2e427ed80dc73139563652e9169ff78ed6e52cc", + "packages/db/dist/esm/query/equality-value-identity.js.map": "53e543a540f24c9bf28b549388e7b47e4d0dc2fe0a32d2b4c2fd42496f016d09", + "packages/db/dist/esm/query/expression-helpers.js.map": "552f3f643aaf98d1fa50328892bbff6c3cf166a788f1a9bed22450ea835a3e3b", + "packages/db/dist/esm/query/ir-stable-identity.js.map": "54629136d3760ce211cf87b7795dbe629795a59789d26b6b4b4c33f00c50da6e", + "packages/db/dist/esm/query/ir.js.map": "ccb4d95ae5caa7bfc320e2bc66ef54f685dbe867301c463e4793c80397c581a3", + "packages/db/dist/esm/query/join-conditions.js.map": "39d95b4fd518dce35318bbdf38cf3b9f105a4e3b5db15d4ff4048ce261a8caeb", + "packages/db/dist/esm/query/live/bucket-facade-adapter.js.map": "5346a9f6853231e91b63e7303be2ff234560ec0653c748f257fd103ea0b5fd41", + "packages/db/dist/esm/query/live/collection-config-builder.js.map": "7fc086bb59ab0dd80007cc16b7164a7a4c4d1306fc29ba2357e8ac2df67f2b09", + "packages/db/dist/esm/query/live/collection-registry.js.map": "4f4be3b283e3daa91838fa9ce44dbc11d29b599328d7db419319ed6fd7d9c8f4", + "packages/db/dist/esm/query/live/collection-subscriber.js.map": "1b1bec3ab4af5b8f568be4f298d5984ca74170929574b41ac7490ddf47b0eb32", + "packages/db/dist/esm/query/live/graph-scheduler.js.map": "f9d01ec493d292f27a6136816a61082939d7ff9b2915e73b9f04a58f573d48ee", + "packages/db/dist/esm/query/live/internal.js.map": "8f3093c6b396f3b3b0ebdaf9d330f23f71cc6ff9ad6813c598b590c497a56382", + "packages/db/dist/esm/query/live/materialized-pipeline.js.map": "9924a8437663a9ff914fc8e733903449f322b9fb9786a873b72f63e10aefd5b0", + "packages/db/dist/esm/query/live/ordered-source-loader.js.map": "eaa8b13f6b4d995d35579816e23e53041c925dc1f027271ce6bb99e89b3f67fe", + "packages/db/dist/esm/query/live/subset-demand-controller.js.map": "3c0ef841c2f6ce3f6c8c4e1f5ef6fe2b885b52140977f198479440b62dadb065", + "packages/db/dist/esm/query/live/utils.js.map": "0d1b870b1c6b91edcbb7d553fb4bf12122eb1331a56ffeaafac7312581c1443c", + "packages/db/dist/esm/query/live-query-collection.js.map": "f1ce1849148cd27103b5f0dcf55cb97a738d26f17706fe7685b7262cfc86e5ff", + "packages/db/dist/esm/query/optimizer.js.map": "fe4f220fd03842d9d518b44df4ffac8a03d9dc4c40fe3b5a1456ca05cb7c5a47", + "packages/db/dist/esm/query/pooled-live-query.js.map": "82bf9a5f6131a287cc09c05cd35d0f12910189f86ce2009529ecdb91cebf6bb7", + "packages/db/dist/esm/query/query-once.js.map": "cea3bbe5c87666103f83c8d02a8feaaf9f718f318b821f76ff5c2a5d77dbfea9", + "packages/db/dist/esm/query/runtime-reference-identity.js.map": "d06fece2e78c5ea3ae21e74ce22fb24a7422cd57632bc28d5cbd7292dd049dab", + "packages/db/dist/esm/query/subset-dedupe.js.map": "d86e0085d0097a37f35375a94d52063829177fa6ec2aefce3cdce46c42133e82", + "packages/db/dist/esm/scheduler.js.map": "d0ca54ee8e793d30ba55632a0f59d8c141724698b4d1f82236c70c7ef36307b1", + "packages/db/dist/esm/strategies/commit-completion.js.map": "0ccb2df9d5ed0561721f8459a6f029a031f774250aaaaadb7f68a5556bd4729c", + "packages/db/dist/esm/strategies/debounceStrategy.js.map": "8dcdddd38e78f32c844279e92845359b7a95f4b91cb6add26474b680e32e5421", + "packages/db/dist/esm/strategies/queueStrategy.js.map": "e61023e3380ae9594484c9e673b078454955b85ef8d37fd57b9ecdef1bdf6da2", + "packages/db/dist/esm/strategies/serial-pacer.js.map": "4e86b6c2203e220ab7096e2e624463c92c2bf6c8cb1098eaebad2df56f5e4a69", + "packages/db/dist/esm/strategies/throttleStrategy.js.map": "8abc6c79e822c3f6de0496b6c42bd730680f8b48989016e3f4257e25ab01a25d", + "packages/db/dist/esm/sync-persistence.js.map": "a6e395d89daebfc769642fa8bd00a0b4f3c9bfb4090a200d97f6637363d89df2", + "packages/db/dist/esm/sync-receipt.js.map": "037f7d6167e90947a5468a36757d4bd32dfe0c937d8b5f1ab7a83bd28c23f756", + "packages/db/dist/esm/transaction-commit-work.js.map": "10ae9167e5a3a0b030a5a9cb34b347d4bb6f719e023a84db6a0a9da85f39accf", + "packages/db/dist/esm/transactions.js.map": "d071193784fd02d4365ba1db134d53c13932fdfbeceab2e300211335e6582873", + "packages/db/dist/esm/utils/array-utils.js.map": "543f01857219d2625c3dab6f7c8da41e45ffc29f34643681f8be3d42b61099b6", + "packages/db/dist/esm/utils/browser-polyfills.js.map": "259ca9a616f32a59ae240606903456aae505856a03b01d7f0c5133fe724316c5", + "packages/db/dist/esm/utils/btree.js.map": "3d96225772c3794f2e65962fbe29cad56738f45a382b7a4e40f1370dcf780830", + "packages/db/dist/esm/utils/callbacks.js.map": "61b12884fd3fb07d404d53734577a254af387dde4d178862901ed43bf154d713", + "packages/db/dist/esm/utils/comparison.js.map": "4c3232f26a3487b36546b34c0af682b0880282c63fa021da36b070ba0506ec19", + "packages/db/dist/esm/utils/cursor.js.map": "50e78e47699a68d3ac3828eb37760f3d8331e25ac2bd833b74796c2f121056f3", + "packages/db/dist/esm/utils/error.js.map": "302fbea3014e7d353c8e19456d5c7b930876a738e868945fd0eba88498886626", + "packages/db/dist/esm/utils/get-or-create.js.map": "049135923c3a435c8a6435c2330978b7272b1f954481a71ede0450453b3c7d1f", + "packages/db/dist/esm/utils/index-optimization.js.map": "36d3be1f0e57af2e910d9d61f01f4757fe68637e84e0f59a360718b197505d97", + "packages/db/dist/esm/utils/source-record.js.map": "e52ab96575ec8e4fb99084e788ceb8e2f06451369503a5d3792a32b27f7311fe", + "packages/db/dist/esm/utils/type-guards.js.map": "0b96a21f6d742d02da8e62350804eb0e494dc2c10d90f03f29750988c843befc", + "packages/db/dist/esm/utils/uuid.js.map": "439824094f7d0ab0ce52e065cc80e717bac86ba96717435f0befc616495b83aa", + "packages/db/dist/esm/utils.js.map": "a635c14883c73c14c69a03637c408429e951b99fc3055062915245a2359f9c04", + "packages/db/dist/esm/virtual-props.js.map": "73b6876085e95170f2a932adddcf3c333175b3ff80ba00340ebcc150afc6358a" + }, + "verified_runtime_source_sha256": { + "packages/db/src/SortedMap.ts": "dbbef7d2842d265cf5268f3ca8c512b0b79ea927135116d3fc4e08ea2799856a", + "packages/db/src/client.ts": "fdac0f151242b349c53e3a8bb0e9788cc432b35f306d2ae91d74bf832fc97f38", + "packages/db/src/collection/change-events.ts": "754f897ba843d004eaf5bfe3f047fc53aa24eb49a16ae1eae4073e32ec17ba49", + "packages/db/src/collection/changes.ts": "c6deb4b04891feec0f2c4368ffa9ea652d12593f4e7ddf4edbe392a706f314df", + "packages/db/src/collection/cleanup-queue.ts": "d6d24ba9216d9dfacf57623d14e61861b044a23cac1b8e475dbfe71795a0c84d", + "packages/db/src/collection/config-errors.ts": "ffe046ad75542c863e1781c192191479cdcb68e7dcfaf92c9ce180ee48522cc0", + "packages/db/src/collection/events.ts": "fd95280be3bdd9a1177d4d57b425f484b9daf06b4b257ed28d6b17e3ef1bd132", + "packages/db/src/collection/index.ts": "458e71b1478a6150ef9c1854fb7df483cb32466217cec050993f21befcb1dba0", + "packages/db/src/collection/indexes.ts": "1a0b6633e5f5f24c1b29fe8e544ee3c52df6e886d2a61c8fe88750f3f811520e", + "packages/db/src/collection/lifecycle.ts": "5bb9027353ff30b9c467c1c3c749e739aef741ce1762c770ccd0ac90502763b5", + "packages/db/src/collection/mutations.ts": "735bdd4872c97afbe6d7333d42402635bdb54c914e6b5cacf703444fbf933f31", + "packages/db/src/collection/state.ts": "3c6caf3829455db5e6c037bf56e06c7a3b0e6abebc35368da0dbf886b4729e76", + "packages/db/src/collection/subscription.ts": "c446932a8a1f8b2efd79d00846f1a110e65a6c6df75d2b24658053e6964b530b", + "packages/db/src/collection/sync.ts": "4ff2e29f8fb27af6e71a46f959ad764a0d9dbd390bfba66604ba1c1836676add", + "packages/db/src/collection/validate-config.ts": "10bc3f83c357cf8b835694c73df9adf413c6190f924256786f17e123a688489a", + "packages/db/src/collection-options-brand.ts": "93df3fc8d90531ca926c6e9ca48653dbda9259cfaaade9c23cbbf35131b2e1ed", + "packages/db/src/collection-options.ts": "0b0f9713312f9554f62c020dda3bae8291303dbeadb85adfee76515a28442feb", + "packages/db/src/deferred.ts": "9c99553d7ab92ac89d377cb3382a9dfbdf592635c87b86eac8aa1f18efc4da68", + "packages/db/src/error-message.ts": "9ab4a64436c3db3a61f3a84b5ccff11359ccdf5676c81b81dc3be9db9c1dff13", + "packages/db/src/errors.ts": "ee54c0fb19ef95630e833b62562a4a2310ebd79337746355b8f67676cce0e416", + "packages/db/src/event-emitter.ts": "1d8775f3c2dbc9c833f03d2b9bb0d55d1606419e43d661064cc95cd91eb187e8", + "packages/db/src/indexed-db-wrapper.ts": "216fcac76c01b1af8363cda8a93678f53cd2cd58c9df918cd3d56d2a1f25fd11", + "packages/db/src/indexed-db.ts": "4c9960e7020389ab784f3ea6ce3fa1684b36ad1f6aef28a100cc15517374832b", + "packages/db/src/indexes/auto-index.ts": "d226516a7514c40e83010ab211020adba17965af96bd6d3b96e88989c6f31b51", + "packages/db/src/indexes/base-index.ts": "63e35c6bf011ad361882509a521407acf29b9404ce4e810a8f4d54b50c0e6685", + "packages/db/src/indexes/basic-index.ts": "9a128909d6df2c6d5b9ef07295a55f9f587170721cb7c6a652dd9c43eb674633", + "packages/db/src/indexes/btree-index.ts": "b006f65cf1c2ab14004e7311f4ac4b89958e919fb8439cd9ca25090789e37186", + "packages/db/src/indexes/index-registry.ts": "d44e34c9e5d90db56dee7f91e402f2547d91647ae100cc4752bc13bcd49e2719", + "packages/db/src/indexes/reverse-index.ts": "ebf2d22eb0eec10acb4df154488862243ec20be117bc069fe307fc991853dd0b", + "packages/db/src/live-query-adapter.ts": "cc6e68e05afb876a46989357304a2e9315cac918d2d7623e4272c3a4ce3b470c", + "packages/db/src/live-query-observer.ts": "37f3bdd904af1ba51d6d8821d4711b4e29789e307363590972e52a50e77a4d0f", + "packages/db/src/live-query-options.ts": "8ba7980c64de78500e56b3c822e19e8e451d77ee50352beaaf89644f47c3f411", + "packages/db/src/live-query-primitives.ts": "16fa17f9cefbb2dad98fda23651d792f8c8cd5bb4b812bd1197de7ae8f1f99c1", + "packages/db/src/live-query-window-binding.ts": "7564bac73ec3aab26dbc4c4ed44bc972f108fc6241c158eb63c8b7ae22b67747", + "packages/db/src/live-query-window-controller.ts": "ea439e6b6f0ce1bdd03f259a39d6d912024845e8343d013b67a7b2251740c8fc", + "packages/db/src/local-only.ts": "06d516a29e6f72b87f08c83f1fad2454dbc1cd3a3d296562f74fa5eb5aaec408", + "packages/db/src/local-storage.ts": "62331d402b5a7b958617e45ddb9de90a358f2fb1645ac589c5d412deb079cb5f", + "packages/db/src/optimistic-action.ts": "3b3d98ec0561927c301c6979256bbcd98faf0ee41b2b6690abe1c189eeaf4fec", + "packages/db/src/paced-mutations.ts": "ea9239b269c2622d23cb2fee6df2895534d64b43fb245f8160e054587b9b5458", + "packages/db/src/persisted-readiness.ts": "3fdff536570ed25347928bfac3043482d87a1506ea8bfd4d2c4e7f4c532fb598", + "packages/db/src/proxy.ts": "97fd4841cd8532a8bd2f08197d0d755cf348a50e5df0b2f4705079895f2a42c1", + "packages/db/src/query/builder/clone-query.ts": "7265db09e09e441d598d5b5774759f4ece6dc1cf5d5723d49d1c342a36e3798d", + "packages/db/src/query/builder/functions.ts": "71ff875be3b94602be517fd03d2ffd0fc1866a189a4849403623d9865e35227e", + "packages/db/src/query/builder/index.ts": "c658662a2ff50d85a7a1d662b728873e16a568d58e38248bbc57d03f884b463e", + "packages/db/src/query/builder/query-ir.ts": "6aa35045e8a83c63a68e3af2de951879aaa2b91c132606db6d0bfd32247af108", + "packages/db/src/query/builder/ref-proxy-identity.ts": "909d40e4425647ae58850decd6891fa7f6aa4690cd76e82c3ced13b70dc93b10", + "packages/db/src/query/builder/ref-proxy.ts": "da99a9d725676d7c29b1bc2c10913465389c6eb442ca651d06ab658a2561d0c1", + "packages/db/src/query/builder/wrapper-identity.ts": "3053d30505c57f0eb16cc573e196291e617aff7479151508bbaa397fa3757f57", + "packages/db/src/query/compiler/evaluators.ts": "499cb252c6e2c166cf626bf88c6c08ced102020558bdccab46a6d12acd52ec92", + "packages/db/src/query/compiler/expressions.ts": "70925f2d29f57aec189d0e7f485d0531534e3482f0696a5e9a1141d8cf36c42d", + "packages/db/src/query/compiler/group-by.ts": "f30cb4ebd6940dd4a3888fde633e16f952225e4f9d35be4e450d6ad912a22bc7", + "packages/db/src/query/compiler/index.ts": "0d53aa7d27fba0e0f472af55404adbc437e69a537aa2f529710e81d08b16944e", + "packages/db/src/query/compiler/joins.ts": "8f0aacfc3e09bbc2672cc21af75ac8b27e1f957a47ee8e36ce3b451137beff08", + "packages/db/src/query/compiler/lazy-targets.ts": "6949ebfac1f0d76b64dc9a3c1cfee87490702a21de235c320fdd7aaa02b77587", + "packages/db/src/query/compiler/order-by.ts": "271b3195a4d24cc0faf9c19261a1e18cd91eea8794ab9e611257929c4e3a880b", + "packages/db/src/query/compiler/parent-routes.ts": "d59e9d64534c53bb58349f07c121158adc9b37cac7a0b5150493e6368442c18b", + "packages/db/src/query/compiler/query-equivalence.ts": "7171fa0f1aa960297ba1269193ed3527967d78ebf1c19ef03544604280aa4480", + "packages/db/src/query/compiler/route-metadata.ts": "53283291ce25656339492bff678b8742a7fdc7b60c49d3b7f7015569e5c06788", + "packages/db/src/query/compiler/select.ts": "a4b60959e37ea674bfac8f3cc4076c35facb9378891f72603c309e92c2882760", + "packages/db/src/query/effect.ts": "013d653074e139caf2e2089574c3a985c7f408c9434d2d70d7c3b2dd9cc82258", + "packages/db/src/query/equality-conjunct.ts": "02041d880376b46814ab072747a9b0493e24cd4bd72df8389726780e535cc584", + "packages/db/src/query/equality-value-identity.ts": "a7b65759c20ebec4d83c47ec744bbd382da9a86ffa4c9e59028827127081d016", + "packages/db/src/query/expression-helpers.ts": "15d6635f8b28dafee0ca0afe2e8598545f09995a5b992798916d789167383019", + "packages/db/src/query/ir-stable-identity.ts": "2369d6ad584c2546f4fdf2051055a3bfa9bedb0c53cd8ff3d86b2b2a86cfd80e", + "packages/db/src/query/ir.ts": "9b319bd3728640d2ab941b167ea16b09d721ccff112647f597c9761dd43b5ada", + "packages/db/src/query/join-conditions.ts": "c2659d14f7a15203343de19b66cb83195c7ab5c26f953b52bbf6f40bbe012802", + "packages/db/src/query/live/bucket-facade-adapter.ts": "cbd6ea77a9d103fea6253e20656e31ff41ac353d134cb0c49710c56ffdd4b3c3", + "packages/db/src/query/live/collection-config-builder.ts": "aa55faec9bc8db4597357989d9466e4ab030e743ee6a197691807b775f0f8440", + "packages/db/src/query/live/collection-registry.ts": "c24da0f80292e4fa22370711cdf66e52ec44989f8fda50430025aa5b25510e65", + "packages/db/src/query/live/collection-subscriber.ts": "74e97fe498b44446d5ee21912175afab6a3e58d2787e04e890a26771d5c8e1a6", + "packages/db/src/query/live/graph-scheduler.ts": "bdd37b3814814a2bf32fa55422a2fb039107d04cbf4fbd79df67056c5bc4654b", + "packages/db/src/query/live/internal.ts": "3aa788cfb3647e2758ff9befe18e4503bc68797fd97d55dc5c3905db9a7145fc", + "packages/db/src/query/live/materialized-pipeline.ts": "9e0ad27c197c9571e242c7a7ccb286739ecdd748b25f00f4f4f8aae4f0894407", + "packages/db/src/query/live/ordered-source-loader.ts": "95ea59a251cd540406d3dd2dac1ba85f41d1f6422c4ffd44f973d9ea4ca01e18", + "packages/db/src/query/live/subset-demand-controller.ts": "28e49884847544356975afd781bb2d485844c2f43f9f33097aef28d06ba2a1d0", + "packages/db/src/query/live/utils.ts": "00d2a0462ea2a997f50d104f641e9837cdf1341fc763116a0df773f6888f1646", + "packages/db/src/query/live-query-collection.ts": "d9fa71c30a1dbb69d58e2e11c2ba4acbeaa727f8d1ee70ce3d225be08ce7224f", + "packages/db/src/query/optimizer.ts": "384be668674194cb69a3b6165ffc97bb3b7edf066e107c444f36f6bbfa43571c", + "packages/db/src/query/pooled-live-query.ts": "d2b9d1c77cb7b2f9fa0c0ed6c546e0738390d20207ebde466636d0c95f459554", + "packages/db/src/query/query-once.ts": "1399a24809ce83173f0d952ab35620bc4430874cb96ab14229495fad3ef57478", + "packages/db/src/query/runtime-reference-identity.ts": "c1c695e80771ab545e58254381333bd24469813d69f448106df2dc5a5de1db48", + "packages/db/src/query/subset-dedupe.ts": "c535bb2a38db491d1c11024c88c71dc1664a59bae3c19f1f5bec35d37d5b99de", + "packages/db/src/scheduler.ts": "b21cbbdc088b3c1dc4840b7db857ee7f262e6958dc8bc300398ad8e93da4f01a", + "packages/db/src/strategies/commit-completion.ts": "242b52fc7a041f5f9d17172c356dc4e1ec06f2cbeaa85ba5995294356fba9b15", + "packages/db/src/strategies/debounceStrategy.ts": "8ab894803575f1cfab85939adbf67637342bf2e230e34d0db9bb642f205cdce5", + "packages/db/src/strategies/queueStrategy.ts": "c21794ea5707e75de082b87c80ee5a19fd8b1752fd7725adf7435b377a5e4196", + "packages/db/src/strategies/serial-pacer.ts": "63f4aa8f7aacd4bbe5cc3d56173fdb9231fedea68bb721bb2d420cf2303ec679", + "packages/db/src/strategies/throttleStrategy.ts": "028b5a000cc5a49a0176a42f0bb0c5cbb0a71849e543b523b080942947f40cb7", + "packages/db/src/sync-persistence.ts": "eb64401db52d9d2f269e5b6d92f85ebb5d4cfe6479dd1d4832918cd7b9dbad6b", + "packages/db/src/sync-receipt.ts": "5ab4074603d96c23decd4fae499ed55f3a6ee5077e379f370d9b76fd90565399", + "packages/db/src/transaction-commit-work.ts": "b3c341c01f7a8b1207683fb3388b66b1173481f43ea59160e1922778e64872fe", + "packages/db/src/transactions.ts": "60acaa21a7c158e2617bb382778bec1aa2b5fcaff7d527ab902e47b364d140d7", + "packages/db/src/utils/array-utils.ts": "a151c6b1779cf975e85e53a7b2328865889738df1f0d0fc3b8a8e466d15911f2", + "packages/db/src/utils/browser-polyfills.ts": "b3212d22af7254eb8c286d89393eb043324abd0b5a7ddbc8eaaa505e292b437b", + "packages/db/src/utils/btree.ts": "e2172d757bfdaf3e528fe94864876fac9de24a66302ed1930d108daa1f525b1e", + "packages/db/src/utils/callbacks.ts": "da4d6fc8d94bf93d07442eefbebd1ec0c183ef559fa7eebceb51b3652816708a", + "packages/db/src/utils/comparison.ts": "ed8c22f7315f0119025d47a0ea6432bab974c618665b167012c6c12da2287449", + "packages/db/src/utils/cursor.ts": "b728649d395a8e4e61cd6a63479be92ce2d4d4a08dc73beb549b92dafec5181f", + "packages/db/src/utils/error.ts": "133924e3809af4a69826c08774455f12f10aba675cbc92a4298525868bba4ece", + "packages/db/src/utils/get-or-create.ts": "eb56ba9e1a6eddecc8241062cb9a4ea0afca7437fbb50f0404de1276a611ee82", + "packages/db/src/utils/index-optimization.ts": "034310477e43f29bfe3493a56ee3b3524378ae3f941f4a452e6a15b9aec24425", + "packages/db/src/utils/source-record.ts": "48023571bd2e850170b78a34a3a9d14940d97742f510f96cad4baaf4a75694e3", + "packages/db/src/utils/type-guards.ts": "b7a63accd5abd4dd5c93bb451650fc962a2fdcc3981e70f0b7b373ebeb5f07db", + "packages/db/src/utils/uuid.ts": "0365c5c02b46eabac53562dac87b342656e2774b36385a094c16b3a9db3a5ca1", + "packages/db/src/utils.ts": "67e7143cfb324dc8f97c310679730682ecfb8088c2dc0d9e0efde6bc57ff9c76", + "packages/db/src/virtual-props.ts": "6add3fb9226251bf4dddadc7c5227469d4183a4df7799047f2fdc6b51ef4d9c0" + } +} diff --git a/docs/contributing/oracle-reviews/2026-10-27-convex-lazy-backend-concurrency.md b/docs/contributing/oracle-reviews/2026-10-27-convex-lazy-backend-concurrency.md new file mode 100644 index 0000000000..62a1e8c6b2 --- /dev/null +++ b/docs/contributing/oracle-reviews/2026-10-27-convex-lazy-backend-concurrency.md @@ -0,0 +1,166 @@ +# Convex lazy concurrency at the real backend + +This record closes the real-backend receipt/version receiving boundary left in +the [interleaving follow-up](2026-10-27-convex-lazy-interleavings.md). +The scientific models are unchanged. A new receiver uses the actual isolated +Convex backend, both real SDK clients, real opaque server cursors, actual core +commit receipts and real optimistic transactions. Its source binding is the +[manifest](2026-10-27-convex-lazy-backend-concurrency-source.json). + +## Law and receiving path + +`LoadSubsetFn`, `LoadSubsetOptions.signal` and `SyncAppliedReceipt` in +`packages/db/src/types.ts` require successful loads and canceled uncancelable +accepted work to wait for their establishing writes to apply. A cached caller +can owe zero new receipts, and can still wait behind unrelated held work. The +fixed-query coherent SDK premise and atomic publication require a fully acquired +source to publish coherent authored relations in monotone revision order. +Stuttering and skipped intermediate revisions remain permitted. + +`e2e/lazy-concurrency-oracle.test.ts` receives these laws through the public +paginated factory and real Collection. `LazyReceiptModel` owns facts and exact +establishing obligations; `LazyVersionModel` owns authored relations and the +compatible monotone revision frontier. The new driver has no independent +expectation derived from production's page chain, sealing state or queue. + +The server fixture adds `tasks:revise`, an atomic full-table text update with +stable keys/ranks. The independent ledger authors the complete expected relation +before each mutation starts; the separate unpaginated query verifies the fixture. +Updating rows in separate transactions would permit intermediate server +relations and would not test this law. SDK wrappers only record genuine results +seen by the adapter. The source-provenance comparison receives that evidence +separately from the offered revision ledger. + +Each history first establishes a shallow source prefix, then starts two full +acquisitions. Required facts absent at acquisition have unique supplying native +batches, allowing one actual receipt to establish both callers. A real optimistic +transaction holds application. The shared held receipt is observed and both +callers' missing-fact sets must be empty before cancellation. The canceled +caller and successful peer remain pending until release. A cached shallow +caller owes no invented receipt; its early or delayed settlement is permitted, +and it must complete after the responsive drain. + +After full acquisition applies, every source change callback checks base rows +against the unchanged version/provenance models. Two actual version-bearing +batches are accepted during another optimistic hold before application is +released. A version witness proves some unique new fact reached acceptance; +it does not impose a commit partition or claim every authored fact was written +in that batch. Application comes from actual receipt fulfillment. FIFO work may +already have advanced to a newer revision before an earlier Promise callback +runs, so the driver does not require historical rows to remain present then. + +The reader then loses its real TCP connections while an independent writer +advances the server. Reconnect must eventually establish the latest authored +relation, allowing skipped intermediate revisions. The network gate forwards +uninterpreted TCP bytes and never synthesizes page data. TCP loss is a transport +fault; it does not promise a native provider-error callback. + +## Generated reach + +The matching fixed/random family varies 7–12 rows, page sizes 2–4, text, and +whether another revision supersedes one during disconnect. Every history runs +both real SDKs. Each lane has four top-level histories, giving sixteen complete +SDK/backend episodes across the two lanes. + +Final full-suite evidence observed all eight states in all four histories in +each lane: + +- `real-shared-held-receipt` +- `real-canceled-held` +- `real-cached-peer-drain` +- `real-two-revisions-held` +- `real-monotone-publications` +- `real-reconnect-latest` +- `javascript` +- `react` + +Actual callback snapshots must contain both the held-drain revision and the +reconnected latest relation. Manual comparison checkpoints cannot populate that +callback witness set. Callback violations are retained and surfaced at explicit +checkpoints without throwing through production. Environmental polling also +stops when a retained semantic violation makes its target unreachable. A timeout +alone is not classified as a semantic mutant rejection. Both SDK receivers run +even after one fails, preserving each independent failure. + +## RED, GREEN and hostile calibration + +Unchanged production passed the extended receiver. No production defect RED or +production repair is claimed in this round. Changes are the backend receiver, +atomic test-server mutation, lazy test configuration and coverage documentation. + +Two production edits were applied in isolation and restored in `finally`: + +| Mutant | Semantic rejection through both SDKs | +| --- | --- | +| Remove `if (visible !== true) await visible` from the native subset wrapper | `receipt law: a settled before batch-2 applied`. Provider completion cannot substitute for the actual application receipt. | +| Drop the first changed row's update from native keyed reconciliation | `lazy version: mixed revision or public rollback`. The first row retains `task 0` while the remaining rows expose revision 2. Provenance alone would permit those individually delivered facts; coherence rejects their combination. | + +Both shrink twice to seven rows, page size two, empty text and no supersession. +Both directly replay at seed `20261028`, path `0:0:0`, with exit 1 at the intended +comparison through each SDK. Restored production passes the same direct replay. +`calibration-v2` contains exact patches, commands, logs, before/mutant/restored +hashes and the final receiver hash. The final source was calibrated after the +review repairs. Earlier calibration logs remain preserved as stage-specific +evidence. The restored `options.ts` hash is +`ea2c2efc4d574080c5e03da8e121b79406640c667a1e260ead9eff22f6253d33`. + +Normal final command, from `packages/convex-db-collection`: + +```sh +CONVEX_TEST_BACKEND_VERSION=precompiled-2026-10-10-72ce13c \ + ../../node_modules/.bin/vitest run --config vitest.e2e.config.ts +``` + +Result: **21 tests pass across six files**, including both complete concurrency +campaigns. Targeted entry: `vitest.lazy.e2e.config.ts`; direct history replay adds +`CONVEX_BACKEND_CONCURRENCY_ORACLE_SEED=20261028` and +`CONVEX_BACKEND_CONCURRENCY_ORACLE_PATH=0:0:0` and selects +`e2e/lazy-concurrency-oracle.test.ts`. Correct production gives GREEN; the patches +in the evidence package are required to reproduce each hostile RED. + +TypeScript, changed-file ESLint, oracle lint and `git diff --check` pass. Oracle +lint's known missing-local-`main` diagnostic accompanies successful use of +`origin/main` and exit 0. The runner resolves `@tanstack/db` to its local ESM +build. All 107 JavaScript modules are fingerprinted; all 106 embedded source-map +inputs match current source bytes. This binds the actual receiving runtime +without rebuilding it. The scientific models and seven Convex production files +retain their pre-round bytes. + +## Review and preservation + +An independent authority/design review and a fresh receiver review inspected +the sources and evidence. Review repaired a real reach gap: manual observations +could have satisfied the publication count without proving callbacks reached +the monitor. The callback-only exact snapshot witnesses now enforce that +boundary. The source review also required the explicit all-missing-facts-accepted +cut before abort. Final rechecks have no unresolved supported findings within +the stated profile; each report binds its final source hash. + +A separate receiving-control calibration disconnects the actual change callback +while leaving manual observations intact and production correct. Both SDKs fail +`held revision never reached the publication callback`; the control shrinks and +directly replays at seed `20261028`, path `0:0:0`. The receiver is restored byte +for byte and the same replay passes. This is a test-recorder control, not a +production mutant or native provider failure. It confirms that the repaired +reach guard rejects the exact blind observation path identified in review. + +The task's `outputs/convex-lazy-backend-concurrency-evidence` preserves raw logs, +patches, reviews, runtime fingerprints and source snapshots. Prior frozen +formal/audit/interleaving artifacts and versioned records remain unchanged. +The existing local install override is unchanged and uncommitted: +`baseline-browser-mapping` 2.11.32 → 2.11.30. No install, commit or push occurred. + +## Limits + +The backend monitor covers stable keys/ranks and complete acquisition before +version monitoring. It does not add general query translation, arbitrary +partial-prefix retention, row membership movement, idempotent/superseding receipt +attribution, accepted cleanup abandonment, native application/provider failures, +or same-run authoritative truncate replay. The composed failure/terminal-graph +monitor retains its controlled receiver, and existing backend/window/framework +owners retain their independent boundaries. Both SDK client classes are used; +this is not a new React rendering or hook-concurrency claim. The new backend +production mutation calibrates mixed revisions, while transient rollback/final- +correct recorder calibrations remain in the earlier version owner. Four fixed +and four random histories are bounded receiving evidence, not exhaustive +scheduling or a temporal proof. No new TLC run is claimed. diff --git a/docs/contributing/oracle-reviews/2026-10-27-convex-lazy-boundaries-source.json b/docs/contributing/oracle-reviews/2026-10-27-convex-lazy-boundaries-source.json new file mode 100644 index 0000000000..163daf5e12 --- /dev/null +++ b/docs/contributing/oracle-reviews/2026-10-27-convex-lazy-boundaries-source.json @@ -0,0 +1,406 @@ +{ + "starting_head": "8c24922026fb4a714cb54b992ac85faa2e876742", + "binding": "Current source SHA-256; this manifest and the unchanged local installation workaround excluded", + "files_sha256": { + ".changeset/convex-collection.md": "c7ad56ad983fe653f0054f0a82fcd47cad46c1d59087d62d79a21ffb077eb324", + ".github/workflows/e2e-tests.yml": "62f257aab4d6629d4227edbf29e659d3d9a4149ca103add59f5d218924ccb50f", + "docs/collections/convex-collection.md": "3901d5d040f8e101369f1a16a160d4e5d0df391ec48408f99d995191ad013105", + "docs/contributing/oracle-coverage.md": "0e2f93f152dbf074d4c91720f2d70969f50beeb8c8ae5571831c963f09c0ab2d", + "docs/contributing/oracle-reviews/2026-10-26-convex-lazy-paging-source.json": "a4af1a586c058d08644fead26679a2894209aa6cbe3803dcf1c8cbefab97bcb1", + "docs/contributing/oracle-reviews/2026-10-26-convex-lazy-paging.md": "1de8506081336953f4b1c53933375960c84af277c04cb3dc07b91b79df391810", + "docs/contributing/oracle-reviews/2026-10-27-convex-lazy-backend-concurrency-source.json": "b859cc163b55b7b9076a0d2c94bad1b524fd0405de39a0689c503c142975ba62", + "docs/contributing/oracle-reviews/2026-10-27-convex-lazy-backend-concurrency.md": "adea3ed30c6f5df32e5af261497afd84c5c5519ca1ef71130681e899e7eba450", + "docs/contributing/oracle-reviews/2026-10-27-convex-lazy-boundaries.md": "dae5fd558a6fe1e7cc2adaff2f977496edfed349be1494994bd3f890c00c4845", + "docs/contributing/oracle-reviews/2026-10-27-convex-lazy-interleavings-source.json": "0fa1e828f4fee657e9011770bef73da2ac02cb4ca068d386d3a662c81a3e7c8b", + "docs/contributing/oracle-reviews/2026-10-27-convex-lazy-interleavings.md": "f105899875a4129b7d816cacc1d6f094c68ad6048cf70350e8d105bd0acd89e6", + "docs/contributing/oracle-reviews/2026-10-27-convex-lazy-paging-source.json": "d25abd9b66a277ef20186bc507ecbcd082f8562a7acbfd2b6d691704434a110d", + "docs/contributing/oracle-reviews/2026-10-27-convex-lazy-paging.md": "d057323e0dee9bab5cf8fbba85fa6d6c525ffc0be7843f1e351d7e48a6e42fff", + "packages/convex-db-collection/README.md": "2b92d0ce849e926acdc2d204e09fd9af3a662c8c8314ad8db5d223e74b77d12c", + "packages/convex-db-collection/e2e/backend/convex.json": "e7106add2ff21d61a430927bb9b1ba96b036b93fcecc31ff786149a6070ecd67", + "packages/convex-db-collection/e2e/backend/convex/schema.ts": "319611bbb3886d4b5c7bdbfbccab1f42792769304e2be8db009b73baf90aa92f", + "packages/convex-db-collection/e2e/backend/convex/tasks.ts": "9663005b0c35ec3b1a89fdc5644b2c16e16fa97ff0f77dbb641a3e05f8b6f64d", + "packages/convex-db-collection/e2e/backend/package.json": "8e2e33603aa201acb7c8d88a55130bc07275d8fb53693e0965539ae4bc4e3585", + "packages/convex-db-collection/e2e/lazy-boundary-oracle.test.ts": "c14b42dcfbd28e1c875204593c10d7f98ebc6323cdcba32736be8315147ddccb", + "packages/convex-db-collection/e2e/lazy-concurrency-oracle.test.ts": "69a02d34d0efd0d1881cfbd321f22aac2e820597efd2cd32fc3fa1dec745b625", + "packages/convex-db-collection/e2e/lazy-paging-oracle.test.ts": "7a5c1390879e4e01757d020e22bfa24f5852a2cf5bb4e18faa4174ce47a02e48", + "packages/convex-db-collection/e2e/local-backend.ts": "e38be0c546a885f55b004f23bc894ab86d3c3b5880bfd9dde8208866a42aea5d", + "packages/convex-db-collection/e2e/network-gate.ts": "73851db1ae46e83b7ee1bdb88faf01aaec27178644a73e1f7835f94681806dd6", + "packages/convex-db-collection/e2e/setup.ts": "d448bab619ac7243957578b2565cdb647a5e71a70df3c65af3c3dc378b5df6bb", + "packages/convex-db-collection/package.json": "0b1ec7960948a8d0f51b418881db6f962d7189a1d8421eb5f8f7e4131f6dc5cd", + "packages/convex-db-collection/src/index.ts": "4247e2bbd168a80791861f4553c10d0d878504a7cdc6a65d4bc648e15b1e51ba", + "packages/convex-db-collection/src/offline.ts": "55e70bdbbc310226a9f0175d0c0912218413ba67433b5b47ccc78d6e7dadd5c3", + "packages/convex-db-collection/src/options.ts": "ea2c2efc4d574080c5e03da8e121b79406640c667a1e260ead9eff22f6253d33", + "packages/convex-db-collection/src/paginated-options.ts": "de0e92849d363f2545823d96829964084b1a115b890f0f521f9bdc0958ea2b76", + "packages/convex-db-collection/src/pagination-demand.ts": "d2be74c1f3610c153c2b7c3c9411aa6ea216db7e76c9b0a7f32d63ef2bde57ce", + "packages/convex-db-collection/src/pagination.ts": "0308668d07db701d1f7f891d001a4aca2798973ea29bf1d4b00931eaa0e05f1d", + "packages/convex-db-collection/src/subscription.ts": "a0c312caf2af3667c18e96c565ceb159c20d65550426cdd44aca69a43a3543e5", + "packages/convex-db-collection/tests/helpers.ts": "11f2c79c6872373e8f77efae62d2fabfead0e5af6673c86920ae2ef4abdb7274", + "packages/convex-db-collection/tests/lazy-paging-campaign.ts": "bf5dc54dd05db268c88ac1b8b9b32aa5a2765245658608e52163089d4601d63c", + "packages/convex-db-collection/tests/lazy-paging-driver.ts": "9819960aa222777f9de3b4bbc3728d492fd9304865701dacbbe381591e88cea6", + "packages/convex-db-collection/tests/lazy-paging-failure-history-oracle.property.test.ts": "65ff6c00d61014e841684ec7783f3175b7a013183f4bf671bcbeedb45212f272", + "packages/convex-db-collection/tests/lazy-paging-history-oracle.property.test.ts": "5bc8808d6f71648f48e40acb05943132448f3e8f5bba31d97e261f1741674384", + "packages/convex-db-collection/tests/lazy-paging-interleaving-oracle.property.test.ts": "f3a0238452746233224bc0b21adf790b594cacef82d786a0ba419d19980bb773", + "packages/convex-db-collection/tests/lazy-paging-model-oracle.ts": "7a0ce99a3442b15d516f03f0494b5a5cb39cc7106452ce50e257af79a014fc7b", + "packages/convex-db-collection/tests/lazy-paging-native-receipt-oracle.property.test.ts": "5714e2c25507f1ea4aef5bb6eb9bed69bc9a9b079540667570227aa1014a0822", + "packages/convex-db-collection/tests/lazy-paging-oracle.property.test.ts": "3a7b5b6284076bc8dbdb59ac1d735f509c6bc19201ad355a0382e7a797a2d569", + "packages/convex-db-collection/tests/lazy-paging-receipt-model.ts": "b3714d83e0a90ceb9184a1701059f027907ff663ce829fa58895007147cf1453", + "packages/convex-db-collection/tests/lazy-paging-receipt-oracle.property.test.ts": "06b60b0a86926070dd57dd068f4abe572d48924e2cea29acb77f417bf8115d6a", + "packages/convex-db-collection/tests/lazy-paging-revalidation-oracle.property.test.ts": "130b4e11498b9983cf123d03dd116957f089ac14c18ed7cbbd4ed6a165bdc10a", + "packages/convex-db-collection/tests/lazy-paging-version-model-oracle.ts": "5aee0ba04f556d312e25f03c6077949d47daf6d0e4eae6cd3dad0a9fe9ce86a0", + "packages/convex-db-collection/tests/lazy-paging-version-oracle.property.test.ts": "8f4f115806a65dc71b3382dade7a89e326decc2b3c25505369783f30163a210e", + "packages/convex-db-collection/tests/sdk-cache-oracle.test.ts": "0e011a20ce991195742bdfa0cd570d831105604ad8e3e2cc4b8fab4b62daad58", + "packages/convex-db-collection/tests/types.test-d.ts": "3ff1a42d6caf95d14e5270b632340524a9f796b978ccd031835bd1f2d0dab6dd", + "packages/convex-db-collection/tsconfig.json": "623bc70a72fd718e7d209c90a08df12cced0e9045a3565058623e34e946a5710", + "packages/convex-db-collection/tsconfig.test.json": "5168aca44188a9be3b0bee215bfc2f222010dedb8f4e1fc08029c6873dcb6cf1", + "packages/convex-db-collection/vite.config.ts": "3f4d5abb1fb86d41ee205bf6364e6f38ffb8de98f5d42cb3d233e52e674902bf", + "packages/convex-db-collection/vitest.e2e.config.ts": "b43b7b797860455efa827774e9847975bd9850f81b9b284f43665c422e45b440", + "packages/convex-db-collection/vitest.lazy.e2e.config.ts": "17ccc7abc23cbe325433eae0487d36087994a15fade3b0c62a0ec2dc003b09e4", + "packages/db/package.json": "bb6df01ff9589c8b75437683c935b8457f65e9216d37173a4730e1a169e0ab43", + "packages/db/src/collection/subscription.ts": "c446932a8a1f8b2efd79d00846f1a110e65a6c6df75d2b24658053e6964b530b", + "packages/db/src/query/live/ARCHITECTURE.md": "aa6a41b651809819d414d364421140bab02f9b992e9f5dbe73cf634b43c46478", + "packages/db/src/types.ts": "2b4c5926922db049e7ccd58c3e3fc22503f6d8a9400f3fb01900e82da85c7a1e", + "packages/db/tests/query/load-subset-joint-replay-oracle.property.test.ts": "975585ef1d1bc921afcab2e5384318c5f661dd0a2577e642351b2aafed0ee98d", + "packages/react-db/tests/convex-lazy-paging-oracle.test.tsx": "0a33955d45816d766100b89e45dd63ce32125864cf3e2f0ea46533a582b0333b", + "scripts/commonjs-declarations.mjs": "3dae7c394287bd5d74467947267ab35207a6290b2fb5b12dcf7b2664115b9d66", + "tsconfig.json": "79e4282995ed94ce7e73583a6ef896db6dd8968b276f2a900de8d63493ace99b" + }, + "runtime_js_sha256": { + "packages/db/dist/esm/SortedMap.js": "ecbe71a4b4387932ed64817362c31eeb58c151d1fda5ba6824a491e4d6eeaf84", + "packages/db/dist/esm/client.js": "4191f1ff58a2b3eae418d2e8dc56b0fe16a290ff233129535b0945f41a34f0da", + "packages/db/dist/esm/collection/change-events.js": "cb5740b95d7c403e0ed7fd8b2aa831a921492782fc66c47bf1c91647070de0ac", + "packages/db/dist/esm/collection/changes.js": "084445baf434ffbfce6ceba34108c32dea5a58b5db68d6c068af6a8b762fff34", + "packages/db/dist/esm/collection/cleanup-queue.js": "3378b66f70261b2ce7eb14fdd982114e838ba6ce6d61ec18b0de976605cb9ba2", + "packages/db/dist/esm/collection/config-errors.js": "d6beb8dddab089d6dfb2516b5850b4b2a709b2bb2a9843bda854ad47723c35d1", + "packages/db/dist/esm/collection/events.js": "0cb3eadd7a9ce2e454e0c4a1a1b0023d2fb96724ad5a4dc806684f9ed4e47d17", + "packages/db/dist/esm/collection/index.js": "01e254fc0d024e7e716b4b09a10e7656130663ae587ad72314873f3b87f5fc2f", + "packages/db/dist/esm/collection/indexes.js": "9d1dbc166c3a44cd9348a62d96c7724335ef38c6f466575c0bbc2419e5647130", + "packages/db/dist/esm/collection/lifecycle.js": "2a26fe829e0183e77a33777f3003e1ab8699a4a55ec6fc309336b35c581ea03b", + "packages/db/dist/esm/collection/mutations.js": "42ed12785749b4098b422a538155ade6d002eaef3ad9cae227c0218f44c95e42", + "packages/db/dist/esm/collection/state.js": "d03955c898fc1672a1a708723d6e4506064665f8a24c57aea6be956f2c4dd0b4", + "packages/db/dist/esm/collection/subscription.js": "ac04fdd5bb56894afa45ecd9bef37abd4f96418c7912cd329f40e397370d27aa", + "packages/db/dist/esm/collection/sync.js": "82be78b67610f59fe60bad6e9f9776884287d38d6bc5641d1478875a559c6961", + "packages/db/dist/esm/collection/validate-config.js": "16a2b254fde0bc0d37c376107a5f394b17dab89165d99ff56a1897af9c234138", + "packages/db/dist/esm/collection-options-brand.js": "ae4fa09d0ac1ff1a9be961efa924d7dc769aad56a7e67cd1552c08b48fee3880", + "packages/db/dist/esm/collection-options.js": "1c6d225b2e8fd061f7e7616d67de43288583660e99a8a596b86d3f8c58c3a0de", + "packages/db/dist/esm/deferred.js": "d8ce1c72bd8f76a4e49aa17042e46a5432284730d1b2ceb4b5afbf927f881e2b", + "packages/db/dist/esm/error-message.js": "176a340e97f58cf9fbb21edc92cf43c6703ad2110310965b3da0f983e1cfab9f", + "packages/db/dist/esm/errors.js": "8c22d726d3878b029e2d9bf89c158ae2c25f915fdc9833711cf63fab3e48cd26", + "packages/db/dist/esm/event-emitter.js": "07add965808d5bd93b29d97e0e6d7db1c060856a1ea20cb7601a81785f2c67dd", + "packages/db/dist/esm/index.js": "fa5714b1da1264029dc75dd2f9e18edf145f128232feca5690392ff94dac6b8f", + "packages/db/dist/esm/indexed-db-wrapper.js": "5ca4ed531df467c317a8b302f218664a13ab579cc472eb4b9dda3b70854ec667", + "packages/db/dist/esm/indexed-db.js": "f7a7b78e057570562aa7eec0279e37a58ac7c4cfc05e820401f89728654dfbf7", + "packages/db/dist/esm/indexes/auto-index.js": "663f98c52c018571e87f398cfd4efe51bd27f48e2a4ac76b8e31327a725da8de", + "packages/db/dist/esm/indexes/base-index.js": "59652757da474e7119d759f784e954b56d9daecf586e385b2bda4ecd9219f465", + "packages/db/dist/esm/indexes/basic-index.js": "1ac365be033cb255d3b4005de57b7466b9ed4cd21daa08a77d8dfcfef0584626", + "packages/db/dist/esm/indexes/btree-index.js": "9ae7bf699fd9d44f9cc7dab823df85638efb2f43114eb33aaa7120efc367269b", + "packages/db/dist/esm/indexes/index-registry.js": "cd9448a61e290d32e23090cc57c8282aadb2d1736611a33509dbbe1f328d080b", + "packages/db/dist/esm/indexes/reverse-index.js": "94f0751672696624240f6e214a9b16517c9145269968e07ec7b2b8f439e3f25b", + "packages/db/dist/esm/live-query-adapter.js": "e787323960ffd3e848ad669ccfea1df3d21d80e084e75b6048740dfe23756e3c", + "packages/db/dist/esm/live-query-observer.js": "53eb064feaded0b54fb64e5a4741b2ed5cf94de00638a2bac0a95e573aab75d8", + "packages/db/dist/esm/live-query-options.js": "51ddfc4eb7ae745a75db197d67e23cd0b9b15f527b1fa706a759d90fa7920f2c", + "packages/db/dist/esm/live-query-primitives.js": "67f6abfb58ecba0abfa2e3c8094c98a86eadad39bd96afeb48912a77475bd76e", + "packages/db/dist/esm/live-query-window-binding.js": "3f239006fa71213f924008cccdb29b9ad57d97beff2947a30f0e0130370bac02", + "packages/db/dist/esm/live-query-window-controller.js": "bdb129aa7772774fe9fddb598d911bd7ae06cb8d581c05c41f47152b478f6805", + "packages/db/dist/esm/local-only.js": "0a4a5d855e4e3db629d20898eca261fa2dc02c3cb2d0c6feec32a052796930b4", + "packages/db/dist/esm/local-storage.js": "5a01856bf1eb5f1cb91aeb6536f3d685e8129aac6e9ea0c6c5feb62159aeb8ef", + "packages/db/dist/esm/optimistic-action.js": "ca9f92ae29a9710cd18e3e71c46a2cc0f204334525aab42174fdb4f53ec67b89", + "packages/db/dist/esm/paced-mutations.js": "01135f1c21fd5deefa89c92ecf7598bbe42d98aa284e646ac21c470c0d346369", + "packages/db/dist/esm/persisted-readiness.js": "160a5bdb6868f603e1bf1358ccabadd72cbe70c05d87f807972873bb03f9c850", + "packages/db/dist/esm/proxy.js": "33a224882c8dc0db103d6d0b92e50bb71021030e608937e7a800411ee9eb3fb6", + "packages/db/dist/esm/query/builder/clone-query.js": "9fdd9595f744fd286b55de26cd552bd630a358e4114dd605ad4d75a474ba70ec", + "packages/db/dist/esm/query/builder/functions.js": "d43c221d002d703965be1587f725255ceb58dd11258670b2882c3451340efe55", + "packages/db/dist/esm/query/builder/index.js": "c42986783a038350a694a9d8352f559017f62fc4b51197ca99f6ea1ce386a8fc", + "packages/db/dist/esm/query/builder/query-ir.js": "8d535e9be5fea36bbea302665f1d30a1b4a029fe4fdc95e6f674b6b0a3c5ad34", + "packages/db/dist/esm/query/builder/ref-proxy-identity.js": "fc756419ecb6b451640f729a44aa2e9cef4385d20ef1c68c049d36fde27d2b10", + "packages/db/dist/esm/query/builder/ref-proxy.js": "b71f544f804438a3c4a7ccec380fa0f4614ec7c859a983de4520d19390707993", + "packages/db/dist/esm/query/builder/wrapper-identity.js": "00c10f26051967ff8f284e550ab207552ac2632cbc08a7623617dad1c593bb06", + "packages/db/dist/esm/query/compiler/evaluators.js": "0ccf7503f360331210ffabf4a1003c96f904f831400ca2687f553f509a52f21b", + "packages/db/dist/esm/query/compiler/expressions.js": "eeb0c6dfca9871b4f7a1dd893c265e51e1b23d20400ac28579875446ac37ca87", + "packages/db/dist/esm/query/compiler/group-by.js": "ca935c6886fc9cb1574fc71e22efe688beceb36bd524a25a6aa29ae0bcf44dda", + "packages/db/dist/esm/query/compiler/index.js": "19667be77fa84da780beb102509c28d98abd365a330e8e7da4e9ac5a51154959", + "packages/db/dist/esm/query/compiler/joins.js": "af1bd496e41902c7e5b9759b4231d0487db2f67f8aa060b75fd326c7791c88b3", + "packages/db/dist/esm/query/compiler/lazy-targets.js": "04ed7aec7600a3ccf86ac28f06f8dd171770e56ca01a11b74d0b59fba6e083ea", + "packages/db/dist/esm/query/compiler/order-by.js": "e9844a5178d22908bf1ee6a723fe903d05110f6c333eefd8851a83165b39180d", + "packages/db/dist/esm/query/compiler/parent-routes.js": "bd0cf9a9781b321181ed3e2b6571d94dcbcd7bc784c53399b98051364c0fce80", + "packages/db/dist/esm/query/compiler/query-equivalence.js": "139c14fa078e5ba4c4a3d7490854c336870eea4c30f5960cd8f15980ceb00b57", + "packages/db/dist/esm/query/compiler/route-metadata.js": "6889f6b3dec56ef8fe5e6ee05e892dab1c22c0c0a13cbe09afa76774dae8a614", + "packages/db/dist/esm/query/compiler/select.js": "3f7d19d780395dd84c1b2b2c506e2b4f847a5a8a9442016bc5f72597f454af18", + "packages/db/dist/esm/query/effect.js": "59f37b190830c138368260801b09e7bc7a30e347fe36681b4796f6902c7e7c3f", + "packages/db/dist/esm/query/equality-conjunct.js": "ca0bee4e908fd4dbe52dc78feb172dca90492edca49cabe509c3cb4e4fd67fd7", + "packages/db/dist/esm/query/equality-value-identity.js": "5091c8c6fbb1e06127ef19db34258fd0d765b3c1b96c4a4f55b6fb553e37fcca", + "packages/db/dist/esm/query/expression-helpers.js": "5c957e7d0f23fb781aae8ead705404cf7bec3b1cd68db3fb46b356a1d25002ab", + "packages/db/dist/esm/query/ir-stable-identity.js": "a9cdba9efedc50caecaf7f8724f4146ab8d5f16d916f25707112a43cc36a0a8b", + "packages/db/dist/esm/query/ir.js": "9f2a67c11ecf08e1af74375eac7a603455e12ce56a75317376fd90b55193261a", + "packages/db/dist/esm/query/join-conditions.js": "0ede70428fc77b12d38c333b22767d629d50e88dca26af0c495c83c3308daa85", + "packages/db/dist/esm/query/live/bucket-facade-adapter.js": "daee317ea51844f450c893f7ce57e3383a0bf96c52e2bc4930d1aff951e5c7b3", + "packages/db/dist/esm/query/live/collection-config-builder.js": "93228d57a26e0e6dd5d51553c5fe10c9ed49f7803979ad21a76aa6805edec3b4", + "packages/db/dist/esm/query/live/collection-registry.js": "05e27e3d041d58aefc8820e523163b2e21ee53a3940680a0b468bb8e81e6d46d", + "packages/db/dist/esm/query/live/collection-subscriber.js": "11dc6621b866033008c2d6c9c2ba90ce36eb94d5f0f0ae3435ea55ecee736f3f", + "packages/db/dist/esm/query/live/graph-scheduler.js": "d3888f67b6e09ac29a3e45a120e3cbbceda49be7415eacaf0856cecf2cbc9e9a", + "packages/db/dist/esm/query/live/internal.js": "244a7f35268790408879ed8b1774fdd767b78829dc1a5ef6a0df4c2c372c0258", + "packages/db/dist/esm/query/live/materialized-pipeline.js": "6d98c2d4ee9412f5816c4151c14f73224b449738df183d0c82c37c08056c64c2", + "packages/db/dist/esm/query/live/ordered-source-loader.js": "63481aa7985a8e9de91f7e3499deb0e5d6f53d6abec6dd6614609ac1f1b5efda", + "packages/db/dist/esm/query/live/subset-demand-controller.js": "1163906179d8c68fbaf36507411c258ce664e8d4141f0a8c4b01f905f1cd1c27", + "packages/db/dist/esm/query/live/utils.js": "3df2bfd90ae95fda8c421e3131e0aaf90875e2cb1af514a09da42ec67e78f899", + "packages/db/dist/esm/query/live-query-collection.js": "f6024029a340a675df1e36ed180ab0d800ac0e697399a5b0ffdab392fdb87181", + "packages/db/dist/esm/query/optimizer.js": "2992bfaaf19a94828538613dcfffd71e4908952fd7c401d4fa01509c53776093", + "packages/db/dist/esm/query/pooled-live-query.js": "0c83e1139162d0314a0a6f1076911d4cf635877d6c9dbc89b8983d27cc90ac46", + "packages/db/dist/esm/query/query-once.js": "efa18058ba8d8abf2f201cf0a6418ab3f1c133d4d7464748f55c0ff8839150f8", + "packages/db/dist/esm/query/runtime-reference-identity.js": "3a5de8435888ede6b0edf799e5f0c192192aa1e0fad922450e9774ebceb4de18", + "packages/db/dist/esm/query/subset-dedupe.js": "06203c388ec4f1e525de8fe770775736222b62e273007e9a31bc37ba5fbab15a", + "packages/db/dist/esm/scheduler.js": "d9b4d34d6a4c656c0f38859426d39830ff62053ab47d22a14acc8b34bb6a8136", + "packages/db/dist/esm/strategies/commit-completion.js": "bd177de52002dceb9d28e02223da81b05c00ca5f72f5c06aa70699142904b263", + "packages/db/dist/esm/strategies/debounceStrategy.js": "88c73e9970201acbe24c3ea11632f2747f174cfb2840a53fdc4ba25224feffff", + "packages/db/dist/esm/strategies/queueStrategy.js": "abf15dc18ce9f481ef876478b0e2190188b960d76d7cedcf4f0c8b2a6818f9b8", + "packages/db/dist/esm/strategies/serial-pacer.js": "84c01c2f15ac57689b7f80a8ca0b152035cc2dedd8573e7e816b1a2bd0dfb08f", + "packages/db/dist/esm/strategies/throttleStrategy.js": "f646adf4f396ceddebca7580b398dc57edf1a22dfc2dd2e6c1eb607b412c0de0", + "packages/db/dist/esm/sync-persistence.js": "16292216a685c02ce1f3e1b2945a2409600e42d951c6acd19090dae99a38dacc", + "packages/db/dist/esm/sync-receipt.js": "c4365d1f36cff69c10194edef4903526fa85078cdf135172978075b5a3d56652", + "packages/db/dist/esm/transaction-commit-work.js": "c6838b1423ba8b0a1bb2ebf848fa37394416d52369d0d7ee5e8508d67f87066b", + "packages/db/dist/esm/transactions.js": "901116611280b5cbd71c3d9c49b6bad061174d90fdd0d2ab29f8cb66d5fb12d1", + "packages/db/dist/esm/utils/array-utils.js": "e8b8006009a956a5520822c9bb1ac8a9b13750613b0be937c2c2cb8d14c5d4c0", + "packages/db/dist/esm/utils/browser-polyfills.js": "620828c9fcf0df2181fed5d866a5ad20f1ac01ad5a296dcb04fd8be7012f6d75", + "packages/db/dist/esm/utils/btree.js": "2db68fc77ef20eb576502fd284668da1c9e04b18befc88dbf3d6842b722e75a6", + "packages/db/dist/esm/utils/callbacks.js": "0e5c79079dcbd00609aaeea1d769ae56fd881f4245740dfce408419773c599da", + "packages/db/dist/esm/utils/comparison.js": "19d37b5b97b84c1a19d331e277f47042e87ac01696d90a74fd3f76030d45e7ae", + "packages/db/dist/esm/utils/cursor.js": "1051977f9f8acbb6fb6884faf467c5c379cb54fdf0b02d22c9c1eb14cc52ca86", + "packages/db/dist/esm/utils/error.js": "a19d18998688b8a2ebd796bf668c694fe5f0c18fd4a4f8434f91589a7e436e40", + "packages/db/dist/esm/utils/get-or-create.js": "b1b71fa6f0e123e44f4908ea606ad6002515afbe204dd5841f72789f8fdaf3d3", + "packages/db/dist/esm/utils/index-optimization.js": "24ffd884fb0c24a49f614c5cc7fa9b37d9942bf59ccb12ccba07c7a0a328ea1c", + "packages/db/dist/esm/utils/source-record.js": "dcb726a12fb21ca74fafa5eb78ab7b83f1d18c2176fee2af11f430322ada6f48", + "packages/db/dist/esm/utils/type-guards.js": "a0327ad9ec010fa5f75a73b01b302c19433258db3ae23ce563a97eff8d981fa6", + "packages/db/dist/esm/utils/uuid.js": "fae77bf0bc610f2fe2e2b7b3ac289f48b572b223973780c6b895bb878c68e8f4", + "packages/db/dist/esm/utils.js": "ab6496511234ca87fd1c59c51c7b8f2f8b8601ea5d7ed15057f3068a88210f4f", + "packages/db/dist/esm/virtual-props.js": "76a0a0e70ee0f959dc7f4c2dac49bdcd24d8a7427b1cd59fb708d7bf47a7fde0" + }, + "runtime_source_map_sha256": { + "packages/db/dist/esm/SortedMap.js.map": "9fa31bea314653a026871313192f228eb75af82e8cec748e8041d1d7bc3d6978", + "packages/db/dist/esm/client.js.map": "33a90696c3b077207b8b1ccd57b1e9299f505a9d468466746066dfe3936c9a32", + "packages/db/dist/esm/collection/change-events.js.map": "c53651fdb6c5b229a2280d45af00a4ec969d5edd48d11608e96c8e813e109ceb", + "packages/db/dist/esm/collection/changes.js.map": "66ba53f09b63f0e668f7522297736919261a64518ec2c43b16eec770e413dbe1", + "packages/db/dist/esm/collection/cleanup-queue.js.map": "84777c1baefeb9e15bfad6f2144decb14ad0236895f8eb59d6721221aacf25e4", + "packages/db/dist/esm/collection/config-errors.js.map": "6bb878aebe57ff145f43dd2afb85c80041cff4b39546ae5e6eb3c7c1b7ec57ad", + "packages/db/dist/esm/collection/events.js.map": "4c035f44d2a25221e333af7799d8dbb06d8b69a12584fe6668f517c71ea87b4c", + "packages/db/dist/esm/collection/index.js.map": "2e50904cadd394695e45c5e82bbdaf1f22f29d6787e7ece5e225250aa721f438", + "packages/db/dist/esm/collection/indexes.js.map": "415a842ba3570ad1a6f341edc761c80b7c37a41c1dcc568121a854d95fcd91f5", + "packages/db/dist/esm/collection/lifecycle.js.map": "12486a4c6d0a7dcaca4489596e2e6da0deb1284c63538c3547773cfd1a1af3c4", + "packages/db/dist/esm/collection/mutations.js.map": "57b450f3fe34ff7c279e349338b525140fe287754763aa4e335cbae08f4374b7", + "packages/db/dist/esm/collection/state.js.map": "f11a3431edc53bcffebcd0bbd6c27af38c2a8c38e5b36e2d2b66b934031b835b", + "packages/db/dist/esm/collection/subscription.js.map": "73c1f1ca5d23cae10c45175164f5e2c59aa10138f13c86ed6ee775f1655765bd", + "packages/db/dist/esm/collection/sync.js.map": "cccc3eeceefdfebde3c092cc3944e23a2aae67eb0c57ff7f3a0694fec98d1d76", + "packages/db/dist/esm/collection/validate-config.js.map": "fd8cea162de4fe8ef094df6522aa8705b83c12fbfe8f9433ee556b3aa76d069e", + "packages/db/dist/esm/collection-options-brand.js.map": "006f1ec5a86b56a67c81e1450d129a8c18c2ff40aeedbac730ff344cac539248", + "packages/db/dist/esm/collection-options.js.map": "5e0b50e274db3185ed9b0c7192920a562c2d9669e57bdb4317ff27a0c7bc0bf8", + "packages/db/dist/esm/deferred.js.map": "c87f06ed0c6b0f229700d59008329205ff3fe7fd5466306982bd932ae273bfcd", + "packages/db/dist/esm/error-message.js.map": "1ddef42c2961d5d65ffd8bafd3a639431a539cf90da41cd5ee794710f3f2884c", + "packages/db/dist/esm/errors.js.map": "6d4e905f2f6c38ea74cef7cbb5fa6f46ccef861488bb57eb2096d3fe4e8fdd9d", + "packages/db/dist/esm/event-emitter.js.map": "b0b3a8588c21ee62b7d3f23d2dffe07ae67f3a451f5151b6f7dbc528c7d009f4", + "packages/db/dist/esm/index.js.map": "f8108774caf02d99844c09563d578fbc3b23ff1a41815163eb053569fa2d1544", + "packages/db/dist/esm/indexed-db-wrapper.js.map": "0203fa669e4b0329df4575a721ca2a3a170180209aa1e4ef6083223f9339c6e8", + "packages/db/dist/esm/indexed-db.js.map": "c29f3c555810dfc2a4b0c75c9ea1f7ba76ebae042d9380cf1f8c69a5471a20c1", + "packages/db/dist/esm/indexes/auto-index.js.map": "511e7a724df721c7decc03223fc765a289f06274cdd379b7de89383a2673d2e6", + "packages/db/dist/esm/indexes/base-index.js.map": "fd5416f04da3991df1fdca5605cec332b7eda9e2517962bbfa437ad10028b286", + "packages/db/dist/esm/indexes/basic-index.js.map": "8a1d3483240e52f21e82f22424b4168b4e17d7f0127e74d6cd2fb1abcc491645", + "packages/db/dist/esm/indexes/btree-index.js.map": "24afa9523a33e8c7ae06c0543991f83b454c9ca67e667a125e363bd3665b4c87", + "packages/db/dist/esm/indexes/index-registry.js.map": "5c0bbfbf82671306e89a15c7ef3bbb8a4813b268b4f317d75be63f88eb41a75e", + "packages/db/dist/esm/indexes/reverse-index.js.map": "633cce9cc9774fbe8854d1d9a338e26b3158107d1edbd0f2161fe952eb4700fc", + "packages/db/dist/esm/live-query-adapter.js.map": "26c5f830609eff6e54e4520eb0ec269a3aad375c2fc857a3ae77140e0179e7f2", + "packages/db/dist/esm/live-query-observer.js.map": "5475b3532cd994257fd71961200597b079078521b81440265ce455fbba901986", + "packages/db/dist/esm/live-query-options.js.map": "ad90acb33887c695fdfa862cd8d5d49e68cd4db37198db903b56c6382e83a7b3", + "packages/db/dist/esm/live-query-primitives.js.map": "41afad71afdcd23aada590c459cd4533d2737a7c28c21ebb5eb23e7fcf7e6386", + "packages/db/dist/esm/live-query-window-binding.js.map": "6c1bd8174569741a154f5bba07fd3a09f18391e599e42d4b9b697089aa60154e", + "packages/db/dist/esm/live-query-window-controller.js.map": "298367ea2b702476de2c969a4226821c6ccfa033d3b90947b330c21060d146ce", + "packages/db/dist/esm/local-only.js.map": "c8eace5d10617bb0c5e178db897b6b5905a51b3b1e6c10e3b1018b14aa08c373", + "packages/db/dist/esm/local-storage.js.map": "95e266c329925a1a2ce6c984f4509f6366d40fb3ef02f50998532eca55538611", + "packages/db/dist/esm/optimistic-action.js.map": "1ff3eaf6f6ae7e2a08f1988eac3f9f80b307cfeb61b7031579f19cee774d2207", + "packages/db/dist/esm/paced-mutations.js.map": "209978b76decd4e48ccd0eca467a8fe1878a0b24145f4903df2a30aa66fea200", + "packages/db/dist/esm/persisted-readiness.js.map": "b36b855729c7fe5e9cf49a0eaebaf66f48ec69e87acf762e21079907062d7c78", + "packages/db/dist/esm/proxy.js.map": "7e93b8d55ec84bfa6435b535abf642a4ea817750ad0af18bfb4b0841af5052ab", + "packages/db/dist/esm/query/builder/clone-query.js.map": "a458b5f869bccb52d83f5ccc2a022e617198b07d71161599925d2b179b22ce8a", + "packages/db/dist/esm/query/builder/functions.js.map": "7209aeb6b70bc74fc1631dee124bdba42525c9de6c8e63257a9cb75c67225703", + "packages/db/dist/esm/query/builder/index.js.map": "57f18c1d77caee866a05de3343ab14417dce54b5b3d84e484a90c64f5b305212", + "packages/db/dist/esm/query/builder/query-ir.js.map": "404592f23d86e39fed95862bcd6c3a40ea9899bf64ce2b3e454b7a246d799096", + "packages/db/dist/esm/query/builder/ref-proxy-identity.js.map": "b0a54507e2de7678cc902067c315216eddde48cb9de7bee7ecebe9ca89c88e22", + "packages/db/dist/esm/query/builder/ref-proxy.js.map": "c5c6ae0637ae9be5ff1a2e3cf54d8788f57baaa8c1ff6629c962444dfe7851c2", + "packages/db/dist/esm/query/builder/wrapper-identity.js.map": "b7b11e683600fd94d16e869cb38dfa250ff56eb7b34b4e95d20ac35b497004b2", + "packages/db/dist/esm/query/compiler/evaluators.js.map": "d68150a8ca3406a2960168688fed45f5e5fe07573802691e4bb400499977e6b1", + "packages/db/dist/esm/query/compiler/expressions.js.map": "f41f8b46979dce48936373f86cc990357a82699f51585aa7ff34fb284430a704", + "packages/db/dist/esm/query/compiler/group-by.js.map": "c677452c235bbb7b08ed8c380953304c2fe462c5ac5a9de7a22098f00b65f11b", + "packages/db/dist/esm/query/compiler/index.js.map": "619130f951479e75e3a261b7cfff523dadd61d491ff5f4b66d0919a4b0509f1f", + "packages/db/dist/esm/query/compiler/joins.js.map": "c2c321244483a65edb382c6f2c55cd7f395bd383671f1d6b63e07b9b8df65104", + "packages/db/dist/esm/query/compiler/lazy-targets.js.map": "a95673b8a853f10dbd239bf3612f4f644e582bc5dc0a7f12873898a7ce33d7b6", + "packages/db/dist/esm/query/compiler/order-by.js.map": "898ad1b7bd1bdbd1c2c0a8edbb78e7ce14811bb4a9fed3e148befc1127a9509d", + "packages/db/dist/esm/query/compiler/parent-routes.js.map": "e18ec4a4c12e4c4f2e176e07c47eb3d58e36cc82eb5fa6327c92106552c1d523", + "packages/db/dist/esm/query/compiler/query-equivalence.js.map": "0e569092d8666ed94fb82c83ec29c5e9663f5d1ae1afadb45856fc4278b5b19a", + "packages/db/dist/esm/query/compiler/route-metadata.js.map": "3bf70196b5be0b9dcadfaee25e7c4764874d76a50ae88c15486f64fa6aef65a9", + "packages/db/dist/esm/query/compiler/select.js.map": "b400ce63f5ce3a505045241de9b5d2b9cb1dccb3e2fbf04475c7d1204717b779", + "packages/db/dist/esm/query/effect.js.map": "0d8f6b6535196a2bc2038830616750b99efbb47505ffab786c7a7c018c296835", + "packages/db/dist/esm/query/equality-conjunct.js.map": "1bc30e55bad05f535c5dde6ff2e427ed80dc73139563652e9169ff78ed6e52cc", + "packages/db/dist/esm/query/equality-value-identity.js.map": "53e543a540f24c9bf28b549388e7b47e4d0dc2fe0a32d2b4c2fd42496f016d09", + "packages/db/dist/esm/query/expression-helpers.js.map": "552f3f643aaf98d1fa50328892bbff6c3cf166a788f1a9bed22450ea835a3e3b", + "packages/db/dist/esm/query/ir-stable-identity.js.map": "54629136d3760ce211cf87b7795dbe629795a59789d26b6b4b4c33f00c50da6e", + "packages/db/dist/esm/query/ir.js.map": "ccb4d95ae5caa7bfc320e2bc66ef54f685dbe867301c463e4793c80397c581a3", + "packages/db/dist/esm/query/join-conditions.js.map": "39d95b4fd518dce35318bbdf38cf3b9f105a4e3b5db15d4ff4048ce261a8caeb", + "packages/db/dist/esm/query/live/bucket-facade-adapter.js.map": "5346a9f6853231e91b63e7303be2ff234560ec0653c748f257fd103ea0b5fd41", + "packages/db/dist/esm/query/live/collection-config-builder.js.map": "7fc086bb59ab0dd80007cc16b7164a7a4c4d1306fc29ba2357e8ac2df67f2b09", + "packages/db/dist/esm/query/live/collection-registry.js.map": "4f4be3b283e3daa91838fa9ce44dbc11d29b599328d7db419319ed6fd7d9c8f4", + "packages/db/dist/esm/query/live/collection-subscriber.js.map": "1b1bec3ab4af5b8f568be4f298d5984ca74170929574b41ac7490ddf47b0eb32", + "packages/db/dist/esm/query/live/graph-scheduler.js.map": "f9d01ec493d292f27a6136816a61082939d7ff9b2915e73b9f04a58f573d48ee", + "packages/db/dist/esm/query/live/internal.js.map": "8f3093c6b396f3b3b0ebdaf9d330f23f71cc6ff9ad6813c598b590c497a56382", + "packages/db/dist/esm/query/live/materialized-pipeline.js.map": "9924a8437663a9ff914fc8e733903449f322b9fb9786a873b72f63e10aefd5b0", + "packages/db/dist/esm/query/live/ordered-source-loader.js.map": "eaa8b13f6b4d995d35579816e23e53041c925dc1f027271ce6bb99e89b3f67fe", + "packages/db/dist/esm/query/live/subset-demand-controller.js.map": "3c0ef841c2f6ce3f6c8c4e1f5ef6fe2b885b52140977f198479440b62dadb065", + "packages/db/dist/esm/query/live/utils.js.map": "0d1b870b1c6b91edcbb7d553fb4bf12122eb1331a56ffeaafac7312581c1443c", + "packages/db/dist/esm/query/live-query-collection.js.map": "f1ce1849148cd27103b5f0dcf55cb97a738d26f17706fe7685b7262cfc86e5ff", + "packages/db/dist/esm/query/optimizer.js.map": "fe4f220fd03842d9d518b44df4ffac8a03d9dc4c40fe3b5a1456ca05cb7c5a47", + "packages/db/dist/esm/query/pooled-live-query.js.map": "82bf9a5f6131a287cc09c05cd35d0f12910189f86ce2009529ecdb91cebf6bb7", + "packages/db/dist/esm/query/query-once.js.map": "cea3bbe5c87666103f83c8d02a8feaaf9f718f318b821f76ff5c2a5d77dbfea9", + "packages/db/dist/esm/query/runtime-reference-identity.js.map": "d06fece2e78c5ea3ae21e74ce22fb24a7422cd57632bc28d5cbd7292dd049dab", + "packages/db/dist/esm/query/subset-dedupe.js.map": "d86e0085d0097a37f35375a94d52063829177fa6ec2aefce3cdce46c42133e82", + "packages/db/dist/esm/scheduler.js.map": "d0ca54ee8e793d30ba55632a0f59d8c141724698b4d1f82236c70c7ef36307b1", + "packages/db/dist/esm/strategies/commit-completion.js.map": "0ccb2df9d5ed0561721f8459a6f029a031f774250aaaaadb7f68a5556bd4729c", + "packages/db/dist/esm/strategies/debounceStrategy.js.map": "8dcdddd38e78f32c844279e92845359b7a95f4b91cb6add26474b680e32e5421", + "packages/db/dist/esm/strategies/queueStrategy.js.map": "e61023e3380ae9594484c9e673b078454955b85ef8d37fd57b9ecdef1bdf6da2", + "packages/db/dist/esm/strategies/serial-pacer.js.map": "4e86b6c2203e220ab7096e2e624463c92c2bf6c8cb1098eaebad2df56f5e4a69", + "packages/db/dist/esm/strategies/throttleStrategy.js.map": "8abc6c79e822c3f6de0496b6c42bd730680f8b48989016e3f4257e25ab01a25d", + "packages/db/dist/esm/sync-persistence.js.map": "a6e395d89daebfc769642fa8bd00a0b4f3c9bfb4090a200d97f6637363d89df2", + "packages/db/dist/esm/sync-receipt.js.map": "037f7d6167e90947a5468a36757d4bd32dfe0c937d8b5f1ab7a83bd28c23f756", + "packages/db/dist/esm/transaction-commit-work.js.map": "10ae9167e5a3a0b030a5a9cb34b347d4bb6f719e023a84db6a0a9da85f39accf", + "packages/db/dist/esm/transactions.js.map": "d071193784fd02d4365ba1db134d53c13932fdfbeceab2e300211335e6582873", + "packages/db/dist/esm/utils/array-utils.js.map": "543f01857219d2625c3dab6f7c8da41e45ffc29f34643681f8be3d42b61099b6", + "packages/db/dist/esm/utils/browser-polyfills.js.map": "259ca9a616f32a59ae240606903456aae505856a03b01d7f0c5133fe724316c5", + "packages/db/dist/esm/utils/btree.js.map": "3d96225772c3794f2e65962fbe29cad56738f45a382b7a4e40f1370dcf780830", + "packages/db/dist/esm/utils/callbacks.js.map": "61b12884fd3fb07d404d53734577a254af387dde4d178862901ed43bf154d713", + "packages/db/dist/esm/utils/comparison.js.map": "4c3232f26a3487b36546b34c0af682b0880282c63fa021da36b070ba0506ec19", + "packages/db/dist/esm/utils/cursor.js.map": "50e78e47699a68d3ac3828eb37760f3d8331e25ac2bd833b74796c2f121056f3", + "packages/db/dist/esm/utils/error.js.map": "302fbea3014e7d353c8e19456d5c7b930876a738e868945fd0eba88498886626", + "packages/db/dist/esm/utils/get-or-create.js.map": "049135923c3a435c8a6435c2330978b7272b1f954481a71ede0450453b3c7d1f", + "packages/db/dist/esm/utils/index-optimization.js.map": "36d3be1f0e57af2e910d9d61f01f4757fe68637e84e0f59a360718b197505d97", + "packages/db/dist/esm/utils/source-record.js.map": "e52ab96575ec8e4fb99084e788ceb8e2f06451369503a5d3792a32b27f7311fe", + "packages/db/dist/esm/utils/type-guards.js.map": "0b96a21f6d742d02da8e62350804eb0e494dc2c10d90f03f29750988c843befc", + "packages/db/dist/esm/utils/uuid.js.map": "439824094f7d0ab0ce52e065cc80e717bac86ba96717435f0befc616495b83aa", + "packages/db/dist/esm/utils.js.map": "a635c14883c73c14c69a03637c408429e951b99fc3055062915245a2359f9c04", + "packages/db/dist/esm/virtual-props.js.map": "73b6876085e95170f2a932adddcf3c333175b3ff80ba00340ebcc150afc6358a" + }, + "verified_runtime_source_sha256": { + "packages/db/src/SortedMap.ts": "dbbef7d2842d265cf5268f3ca8c512b0b79ea927135116d3fc4e08ea2799856a", + "packages/db/src/client.ts": "fdac0f151242b349c53e3a8bb0e9788cc432b35f306d2ae91d74bf832fc97f38", + "packages/db/src/collection/change-events.ts": "754f897ba843d004eaf5bfe3f047fc53aa24eb49a16ae1eae4073e32ec17ba49", + "packages/db/src/collection/changes.ts": "c6deb4b04891feec0f2c4368ffa9ea652d12593f4e7ddf4edbe392a706f314df", + "packages/db/src/collection/cleanup-queue.ts": "d6d24ba9216d9dfacf57623d14e61861b044a23cac1b8e475dbfe71795a0c84d", + "packages/db/src/collection/config-errors.ts": "ffe046ad75542c863e1781c192191479cdcb68e7dcfaf92c9ce180ee48522cc0", + "packages/db/src/collection/events.ts": "fd95280be3bdd9a1177d4d57b425f484b9daf06b4b257ed28d6b17e3ef1bd132", + "packages/db/src/collection/index.ts": "458e71b1478a6150ef9c1854fb7df483cb32466217cec050993f21befcb1dba0", + "packages/db/src/collection/indexes.ts": "1a0b6633e5f5f24c1b29fe8e544ee3c52df6e886d2a61c8fe88750f3f811520e", + "packages/db/src/collection/lifecycle.ts": "5bb9027353ff30b9c467c1c3c749e739aef741ce1762c770ccd0ac90502763b5", + "packages/db/src/collection/mutations.ts": "735bdd4872c97afbe6d7333d42402635bdb54c914e6b5cacf703444fbf933f31", + "packages/db/src/collection/state.ts": "3c6caf3829455db5e6c037bf56e06c7a3b0e6abebc35368da0dbf886b4729e76", + "packages/db/src/collection/subscription.ts": "c446932a8a1f8b2efd79d00846f1a110e65a6c6df75d2b24658053e6964b530b", + "packages/db/src/collection/sync.ts": "4ff2e29f8fb27af6e71a46f959ad764a0d9dbd390bfba66604ba1c1836676add", + "packages/db/src/collection/validate-config.ts": "10bc3f83c357cf8b835694c73df9adf413c6190f924256786f17e123a688489a", + "packages/db/src/collection-options-brand.ts": "93df3fc8d90531ca926c6e9ca48653dbda9259cfaaade9c23cbbf35131b2e1ed", + "packages/db/src/collection-options.ts": "0b0f9713312f9554f62c020dda3bae8291303dbeadb85adfee76515a28442feb", + "packages/db/src/deferred.ts": "9c99553d7ab92ac89d377cb3382a9dfbdf592635c87b86eac8aa1f18efc4da68", + "packages/db/src/error-message.ts": "9ab4a64436c3db3a61f3a84b5ccff11359ccdf5676c81b81dc3be9db9c1dff13", + "packages/db/src/errors.ts": "ee54c0fb19ef95630e833b62562a4a2310ebd79337746355b8f67676cce0e416", + "packages/db/src/event-emitter.ts": "1d8775f3c2dbc9c833f03d2b9bb0d55d1606419e43d661064cc95cd91eb187e8", + "packages/db/src/indexed-db-wrapper.ts": "216fcac76c01b1af8363cda8a93678f53cd2cd58c9df918cd3d56d2a1f25fd11", + "packages/db/src/indexed-db.ts": "4c9960e7020389ab784f3ea6ce3fa1684b36ad1f6aef28a100cc15517374832b", + "packages/db/src/indexes/auto-index.ts": "d226516a7514c40e83010ab211020adba17965af96bd6d3b96e88989c6f31b51", + "packages/db/src/indexes/base-index.ts": "63e35c6bf011ad361882509a521407acf29b9404ce4e810a8f4d54b50c0e6685", + "packages/db/src/indexes/basic-index.ts": "9a128909d6df2c6d5b9ef07295a55f9f587170721cb7c6a652dd9c43eb674633", + "packages/db/src/indexes/btree-index.ts": "b006f65cf1c2ab14004e7311f4ac4b89958e919fb8439cd9ca25090789e37186", + "packages/db/src/indexes/index-registry.ts": "d44e34c9e5d90db56dee7f91e402f2547d91647ae100cc4752bc13bcd49e2719", + "packages/db/src/indexes/reverse-index.ts": "ebf2d22eb0eec10acb4df154488862243ec20be117bc069fe307fc991853dd0b", + "packages/db/src/live-query-adapter.ts": "cc6e68e05afb876a46989357304a2e9315cac918d2d7623e4272c3a4ce3b470c", + "packages/db/src/live-query-observer.ts": "37f3bdd904af1ba51d6d8821d4711b4e29789e307363590972e52a50e77a4d0f", + "packages/db/src/live-query-options.ts": "8ba7980c64de78500e56b3c822e19e8e451d77ee50352beaaf89644f47c3f411", + "packages/db/src/live-query-primitives.ts": "16fa17f9cefbb2dad98fda23651d792f8c8cd5bb4b812bd1197de7ae8f1f99c1", + "packages/db/src/live-query-window-binding.ts": "7564bac73ec3aab26dbc4c4ed44bc972f108fc6241c158eb63c8b7ae22b67747", + "packages/db/src/live-query-window-controller.ts": "ea439e6b6f0ce1bdd03f259a39d6d912024845e8343d013b67a7b2251740c8fc", + "packages/db/src/local-only.ts": "06d516a29e6f72b87f08c83f1fad2454dbc1cd3a3d296562f74fa5eb5aaec408", + "packages/db/src/local-storage.ts": "62331d402b5a7b958617e45ddb9de90a358f2fb1645ac589c5d412deb079cb5f", + "packages/db/src/optimistic-action.ts": "3b3d98ec0561927c301c6979256bbcd98faf0ee41b2b6690abe1c189eeaf4fec", + "packages/db/src/paced-mutations.ts": "ea9239b269c2622d23cb2fee6df2895534d64b43fb245f8160e054587b9b5458", + "packages/db/src/persisted-readiness.ts": "3fdff536570ed25347928bfac3043482d87a1506ea8bfd4d2c4e7f4c532fb598", + "packages/db/src/proxy.ts": "97fd4841cd8532a8bd2f08197d0d755cf348a50e5df0b2f4705079895f2a42c1", + "packages/db/src/query/builder/clone-query.ts": "7265db09e09e441d598d5b5774759f4ece6dc1cf5d5723d49d1c342a36e3798d", + "packages/db/src/query/builder/functions.ts": "71ff875be3b94602be517fd03d2ffd0fc1866a189a4849403623d9865e35227e", + "packages/db/src/query/builder/index.ts": "c658662a2ff50d85a7a1d662b728873e16a568d58e38248bbc57d03f884b463e", + "packages/db/src/query/builder/query-ir.ts": "6aa35045e8a83c63a68e3af2de951879aaa2b91c132606db6d0bfd32247af108", + "packages/db/src/query/builder/ref-proxy-identity.ts": "909d40e4425647ae58850decd6891fa7f6aa4690cd76e82c3ced13b70dc93b10", + "packages/db/src/query/builder/ref-proxy.ts": "da99a9d725676d7c29b1bc2c10913465389c6eb442ca651d06ab658a2561d0c1", + "packages/db/src/query/builder/wrapper-identity.ts": "3053d30505c57f0eb16cc573e196291e617aff7479151508bbaa397fa3757f57", + "packages/db/src/query/compiler/evaluators.ts": "499cb252c6e2c166cf626bf88c6c08ced102020558bdccab46a6d12acd52ec92", + "packages/db/src/query/compiler/expressions.ts": "70925f2d29f57aec189d0e7f485d0531534e3482f0696a5e9a1141d8cf36c42d", + "packages/db/src/query/compiler/group-by.ts": "f30cb4ebd6940dd4a3888fde633e16f952225e4f9d35be4e450d6ad912a22bc7", + "packages/db/src/query/compiler/index.ts": "0d53aa7d27fba0e0f472af55404adbc437e69a537aa2f529710e81d08b16944e", + "packages/db/src/query/compiler/joins.ts": "8f0aacfc3e09bbc2672cc21af75ac8b27e1f957a47ee8e36ce3b451137beff08", + "packages/db/src/query/compiler/lazy-targets.ts": "6949ebfac1f0d76b64dc9a3c1cfee87490702a21de235c320fdd7aaa02b77587", + "packages/db/src/query/compiler/order-by.ts": "271b3195a4d24cc0faf9c19261a1e18cd91eea8794ab9e611257929c4e3a880b", + "packages/db/src/query/compiler/parent-routes.ts": "d59e9d64534c53bb58349f07c121158adc9b37cac7a0b5150493e6368442c18b", + "packages/db/src/query/compiler/query-equivalence.ts": "7171fa0f1aa960297ba1269193ed3527967d78ebf1c19ef03544604280aa4480", + "packages/db/src/query/compiler/route-metadata.ts": "53283291ce25656339492bff678b8742a7fdc7b60c49d3b7f7015569e5c06788", + "packages/db/src/query/compiler/select.ts": "a4b60959e37ea674bfac8f3cc4076c35facb9378891f72603c309e92c2882760", + "packages/db/src/query/effect.ts": "013d653074e139caf2e2089574c3a985c7f408c9434d2d70d7c3b2dd9cc82258", + "packages/db/src/query/equality-conjunct.ts": "02041d880376b46814ab072747a9b0493e24cd4bd72df8389726780e535cc584", + "packages/db/src/query/equality-value-identity.ts": "a7b65759c20ebec4d83c47ec744bbd382da9a86ffa4c9e59028827127081d016", + "packages/db/src/query/expression-helpers.ts": "15d6635f8b28dafee0ca0afe2e8598545f09995a5b992798916d789167383019", + "packages/db/src/query/ir-stable-identity.ts": "2369d6ad584c2546f4fdf2051055a3bfa9bedb0c53cd8ff3d86b2b2a86cfd80e", + "packages/db/src/query/ir.ts": "9b319bd3728640d2ab941b167ea16b09d721ccff112647f597c9761dd43b5ada", + "packages/db/src/query/join-conditions.ts": "c2659d14f7a15203343de19b66cb83195c7ab5c26f953b52bbf6f40bbe012802", + "packages/db/src/query/live/bucket-facade-adapter.ts": "cbd6ea77a9d103fea6253e20656e31ff41ac353d134cb0c49710c56ffdd4b3c3", + "packages/db/src/query/live/collection-config-builder.ts": "aa55faec9bc8db4597357989d9466e4ab030e743ee6a197691807b775f0f8440", + "packages/db/src/query/live/collection-registry.ts": "c24da0f80292e4fa22370711cdf66e52ec44989f8fda50430025aa5b25510e65", + "packages/db/src/query/live/collection-subscriber.ts": "74e97fe498b44446d5ee21912175afab6a3e58d2787e04e890a26771d5c8e1a6", + "packages/db/src/query/live/graph-scheduler.ts": "bdd37b3814814a2bf32fa55422a2fb039107d04cbf4fbd79df67056c5bc4654b", + "packages/db/src/query/live/internal.ts": "3aa788cfb3647e2758ff9befe18e4503bc68797fd97d55dc5c3905db9a7145fc", + "packages/db/src/query/live/materialized-pipeline.ts": "9e0ad27c197c9571e242c7a7ccb286739ecdd748b25f00f4f4f8aae4f0894407", + "packages/db/src/query/live/ordered-source-loader.ts": "95ea59a251cd540406d3dd2dac1ba85f41d1f6422c4ffd44f973d9ea4ca01e18", + "packages/db/src/query/live/subset-demand-controller.ts": "28e49884847544356975afd781bb2d485844c2f43f9f33097aef28d06ba2a1d0", + "packages/db/src/query/live/utils.ts": "00d2a0462ea2a997f50d104f641e9837cdf1341fc763116a0df773f6888f1646", + "packages/db/src/query/live-query-collection.ts": "d9fa71c30a1dbb69d58e2e11c2ba4acbeaa727f8d1ee70ce3d225be08ce7224f", + "packages/db/src/query/optimizer.ts": "384be668674194cb69a3b6165ffc97bb3b7edf066e107c444f36f6bbfa43571c", + "packages/db/src/query/pooled-live-query.ts": "d2b9d1c77cb7b2f9fa0c0ed6c546e0738390d20207ebde466636d0c95f459554", + "packages/db/src/query/query-once.ts": "1399a24809ce83173f0d952ab35620bc4430874cb96ab14229495fad3ef57478", + "packages/db/src/query/runtime-reference-identity.ts": "c1c695e80771ab545e58254381333bd24469813d69f448106df2dc5a5de1db48", + "packages/db/src/query/subset-dedupe.ts": "c535bb2a38db491d1c11024c88c71dc1664a59bae3c19f1f5bec35d37d5b99de", + "packages/db/src/scheduler.ts": "b21cbbdc088b3c1dc4840b7db857ee7f262e6958dc8bc300398ad8e93da4f01a", + "packages/db/src/strategies/commit-completion.ts": "242b52fc7a041f5f9d17172c356dc4e1ec06f2cbeaa85ba5995294356fba9b15", + "packages/db/src/strategies/debounceStrategy.ts": "8ab894803575f1cfab85939adbf67637342bf2e230e34d0db9bb642f205cdce5", + "packages/db/src/strategies/queueStrategy.ts": "c21794ea5707e75de082b87c80ee5a19fd8b1752fd7725adf7435b377a5e4196", + "packages/db/src/strategies/serial-pacer.ts": "63f4aa8f7aacd4bbe5cc3d56173fdb9231fedea68bb721bb2d420cf2303ec679", + "packages/db/src/strategies/throttleStrategy.ts": "028b5a000cc5a49a0176a42f0bb0c5cbb0a71849e543b523b080942947f40cb7", + "packages/db/src/sync-persistence.ts": "eb64401db52d9d2f269e5b6d92f85ebb5d4cfe6479dd1d4832918cd7b9dbad6b", + "packages/db/src/sync-receipt.ts": "5ab4074603d96c23decd4fae499ed55f3a6ee5077e379f370d9b76fd90565399", + "packages/db/src/transaction-commit-work.ts": "b3c341c01f7a8b1207683fb3388b66b1173481f43ea59160e1922778e64872fe", + "packages/db/src/transactions.ts": "60acaa21a7c158e2617bb382778bec1aa2b5fcaff7d527ab902e47b364d140d7", + "packages/db/src/utils/array-utils.ts": "a151c6b1779cf975e85e53a7b2328865889738df1f0d0fc3b8a8e466d15911f2", + "packages/db/src/utils/browser-polyfills.ts": "b3212d22af7254eb8c286d89393eb043324abd0b5a7ddbc8eaaa505e292b437b", + "packages/db/src/utils/btree.ts": "e2172d757bfdaf3e528fe94864876fac9de24a66302ed1930d108daa1f525b1e", + "packages/db/src/utils/callbacks.ts": "da4d6fc8d94bf93d07442eefbebd1ec0c183ef559fa7eebceb51b3652816708a", + "packages/db/src/utils/comparison.ts": "ed8c22f7315f0119025d47a0ea6432bab974c618665b167012c6c12da2287449", + "packages/db/src/utils/cursor.ts": "b728649d395a8e4e61cd6a63479be92ce2d4d4a08dc73beb549b92dafec5181f", + "packages/db/src/utils/error.ts": "133924e3809af4a69826c08774455f12f10aba675cbc92a4298525868bba4ece", + "packages/db/src/utils/get-or-create.ts": "eb56ba9e1a6eddecc8241062cb9a4ea0afca7437fbb50f0404de1276a611ee82", + "packages/db/src/utils/index-optimization.ts": "034310477e43f29bfe3493a56ee3b3524378ae3f941f4a452e6a15b9aec24425", + "packages/db/src/utils/source-record.ts": "48023571bd2e850170b78a34a3a9d14940d97742f510f96cad4baaf4a75694e3", + "packages/db/src/utils/type-guards.ts": "b7a63accd5abd4dd5c93bb451650fc962a2fdcc3981e70f0b7b373ebeb5f07db", + "packages/db/src/utils/uuid.ts": "0365c5c02b46eabac53562dac87b342656e2774b36385a094c16b3a9db3a5ca1", + "packages/db/src/utils.ts": "67e7143cfb324dc8f97c310679730682ecfb8088c2dc0d9e0efde6bc57ff9c76", + "packages/db/src/virtual-props.ts": "6add3fb9226251bf4dddadc7c5227469d4183a4df7799047f2fdc6b51ef4d9c0" + }, + "production_files_changed_this_round": [], + "scientific_models_changed_this_round": [ + "packages/convex-db-collection/tests/lazy-paging-model-oracle.ts", + "packages/convex-db-collection/tests/lazy-paging-receipt-model.ts", + "packages/convex-db-collection/tests/lazy-paging-version-model-oracle.ts" + ], + "prior_frozen_artifacts_verified": { + "convex-lazy-backend-concurrency-evidence": 416, + "convex-lazy-interleavings-evidence": 143, + "convex-lazy-paging-strengthening-evidence": 74, + "convex-lazy-paging-loss-audit": 9, + "formal-design": 250 + } +} diff --git a/docs/contributing/oracle-reviews/2026-10-27-convex-lazy-boundaries.md b/docs/contributing/oracle-reviews/2026-10-27-convex-lazy-boundaries.md new file mode 100644 index 0000000000..2ca5d2034d --- /dev/null +++ b/docs/contributing/oracle-reviews/2026-10-27-convex-lazy-boundaries.md @@ -0,0 +1,223 @@ +# Convex lazy paging: receipt ambiguity and partial-window boundaries + +Starting head: `8c24922026fb4a714cb54b992ac85faa2e876742`. +This record supersedes the current-source bindings of the earlier receipt and +version records for the files in its [source manifest](2026-10-27-convex-lazy-boundaries-source.json). +Older records and frozen evidence are preserved. Changes remain local; no +commit, push or new TLC run is claimed. + +## Law and scientific account + +Authority is `LoadSubsetOptions.refetch/signal`, `LoadSubsetFn` and +`SyncAppliedReceipt` in `packages/db/src/types.ts`, the ordered-prefix contract, +and publication/cleanup laws 7, 9 and 14 in `query/live/ARCHITECTURE.md`. + +Successful or canceled accepted work owes its actual establishing application +receipts even when its row values equal an already applied relation. A later +revision may already be visible when an earlier receipt observer runs: the +actual receipt proves application, not historical equality at that later cut. +A conforming unchanged revalidation may accept another receipt or need no new +receipt. The model does not prescribe either batching choice. + +A partial source may retain delivered rows. Its entire retained base need not +match any complete server revision. The promised live ordered window must match +an independently authored scoped relation and advance monotonically through +compatible offered revisions. Old complete observations may stutter; revisions +may be skipped. Every applied base fact separately needs provider provenance. + +Cleanup permits pending actual receipts to reject as abandoned. Abort and +ordinary provider error do not grant that authority. Calling cleanup alone does +not discharge a pending receipt; the driver must observe actual rejection. +Cleanup makes an old dependent graph terminal with its last complete value. A +fresh sync run may serve a fresh graph without reviving the old one. + +The task-local `laws.md` records these predictions before model edits. In the old +receipt model, cleanup abandonment was unrepresentable. The extension retains +pending/applied/abandoned receipt state and cleanup authority. These distinguish +cancellation while work is pending, lawful cancellation after actual cleanup +abandonment, and success after application. Abandonment cannot provide applied +facts for success. This is an authority-based extension, not an expectation +changed to fit an observed implementation. + +`LazyVersionModel` now distinguishes relation observations from ordered sequence +observations. `expectedLazyRows` independently projects authored relations into +the promised window before offering those sequences. `LazyTerminalGraphModel` +is the existing last-value/cleanup judgment moved into the shared model owner; +both controlled and backend receivers use it. No cursor, page chain, queue, +sealing state, SDK identity or transport phase enters these scientific models. + +## Receiving paths and grammar + +`tests/lazy-paging-revalidation-oracle.property.test.ts` uses the native public +paginated factory, real Collection/core receipts, coherent controlled SDK caches +and real optimistic holds. Each isolated explicit revalidation attributes only +commits actually made inside its synchronous invocation. Equal-valued fresh SDK +objects witness an accepted idempotent batch even though every fact already +applied. A newer accepted revision supersedes it while held. One caller aborts; +its peer and a cached revalidation must also obey their actual obligations. +An additional unheld reactive revision exercises the ordinary callback path. +Actual callback snapshots must witness the held-drain and reactive latest +relations; manual checkpoints cannot supply those witnesses. + +The bounded grammar has 5–11 rows, page sizes 2–4, both order directions, short +text and either canceled caller. Every history runs both SDK shapes, even if one +fails. Its fixed/random campaigns each run 30 histories: 120 SDK episodes total. +Each of the eight declared states reaches 30 histories in each lane. With the +run multiplier at three, each lane reaches 90 histories, totaling 360 episodes. +The current implementation exhibits zero-new-receipt cached reuse in every +successful episode; this is measured separately, without requiring that choice. + +`e2e/lazy-boundary-oracle.test.ts` supplies real SDKs, opaque server cursors, +atomic server edits, actual query errors, actual application/abandonment receipts +and a real dependent graph. The ledger authors expected relations before server +mutations; an independent unpaginated query verifies the final server fixture. +Only genuine SDK reads enter the separate provenance recorder. + +Its grammar has 18–24 rows, page sizes 2–3, initial limits 2–3, a growth window +at offset one with limit 7–8, short text, either canceled caller, and two or three +accepted revisions during the growth hold. Each history: + +1. Proves an unread tail and a partial acquired source. +2. Holds application while one atomic transaction deletes, inserts and moves an + unread row into the promised window, then drains to its exact result. +3. Starts larger-window acquisition, proves its promise still waits on an actual + held receipt, and accepts later membership/order revisions before release. + The grown result must be exact while a separately witnessed tail stays unread. +4. Acquires full coverage for two callers, proves shared held establishment and + no missing required facts, aborts one caller, then causes an actual reactive + server query error via the test-only control document. +5. Requires the canceled caller to keep waiting until application or cleanup. + Cleanup authorizes actual receipt abandonment; the old graph stays terminal + while a fresh run and fresh graph recover. + +Every original-graph change/status event and every base publication reaches its relevant +monitor. Subscriber exceptions are retained and surfaced at explicit checkpoints. +Polling stops on a retained semantic failure before it can become a misleading +timeout. Four matching fixed/random histories per lane give sixteen SDK/backend +episodes; each of ten declared states reaches four histories in each lane. The +deeper campaign has twelve histories per lane, giving forty-eight episodes. + +Reconstruction: minimum-row/page/window cases are the captured shrinks below; +order reversal, caller choice and revision count reconstruct adjacent schedules. +Ablating fresh equal-valued acceptance loses content-independent receipt debt; +removing the unheld callback loses the transient-publication challenge; removing +held demand growth loses mutation-during-acquisition reach; removing query error +or cleanup loses their authority distinction. Larger source bounds retain a +truly unread suffix after growth. Nearby excluded histories include abandonment +under ordinary abort, source acceptance after cleanup in the ended model, and +success using an abandoned receipt. The executable model controls reject them. +The backend family excludes ties and edits during an unresolved prior edit; +other owners retain those independent profiles. + +## Calibration and replay + +All production sources remain unchanged from the start of this round. Correct +production passes the extended receivers; no product-defect RED or repair is +claimed. In isolated local Git clones overlaid with the current source, two +production decisions and one separate publication perturbation are challenged: + +| Calibration | Intended comparison | Seed/path | +| --- | --- | --- | +| Clear pending visibility when an unchanged cached result arrives | `receipt law: equal settled before receipt-2 applied` | `20261029` / `0:0` | +| Iterate the new keys instead of the previous keys when emitting deletions | `lazy version: mixed revision or public rollback`; deleted rows contaminate the grown public window | `20261029` / `0:0:0:0:0:0` | +| Deliberately preserve and replay the previous native publication between new and corrected publications | Same version law; both SDKs report `finalMatchesLatest:true` while retaining the earlier violation | `20261029` / `0:0` | + +All three fail semantically through both SDKs, shrink, directly replay with exit +one, and give GREEN after byte-for-byte restoration. The third is a native +publication perturbation, not a minimal production decision mutant or provider +failure. It exercises actual core writes/publication rather than injecting a +wrong answer into the scientific model. `calibration-final-v5` contains the final +patches, commands, hashes, source bindings, REDs, replays and restored GREENs. + +An earlier candidate replayed the current result cache. It survived on the +exercised paths because explicit refetch and published-range callbacks invalidate +that cache before a changed relation publishes. No global equivalence is claimed. +The retained-publication perturbation supplies the distinguishing wrong output +without weakening that invariant. Intermediate calibration directories are not +final-source kill evidence. Port contention, a wrong command working directory, +and shared coverage output races are preserved as setup/harness failures, never +semantic rejections. + +Comparator controls additionally reject abandonment without cleanup, canceled +settlement while a receipt is still pending, success using an abandoned receipt, +wrong window order and rollback after a newer observation. Positive controls +accept zero new receipts, additional actual receipts, coherent skipped revisions, +old complete stuttering and lawful retained base facts. Existing independently +varied response/commit partitions continue through the same receipt model. + +Commands below run from `packages/convex-db-collection`: + +```sh +pnpm test:oracles +CONVEX_TEST_BACKEND_VERSION=precompiled-2026-10-10-72ce13c pnpm test:e2e +CONVEX_LAZY_ORACLE_RUNS_MULTIPLIER=3 pnpm exec vitest run tests/lazy-paging-revalidation-oracle.property.test.ts +CONVEX_TEST_BACKEND_VERSION=precompiled-2026-10-10-72ce13c CONVEX_LAZY_ORACLE_RUNS_MULTIPLIER=3 pnpm exec vitest run --config vitest.lazy.e2e.config.ts e2e/lazy-boundary-oracle.test.ts +``` + +Direct replay selects the native file with `CONVEX_REVALIDATION_ORACLE_SEED=20261029` +and `CONVEX_REVALIDATION_ORACLE_PATH=0:0`, or the backend file with +`CONVEX_BOUNDARY_ORACLE_SEED=20261029` and +`CONVEX_BOUNDARY_ORACLE_PATH=0:0:0:0:0:0`. Correct production is GREEN; apply the +matching saved perturbation/patch to reproduce RED. The scripts discover the +native family, and the existing browser CI job already runs the backend family +via `test:e2e`; no new scheduler or background automation is introduced. Run +backend commands serially because the local CLI checks its default port across +checkouts. Concurrent native runs need separate coverage directories. + +The full package command passes 108 runner cases across eighteen files, including +four type assertions; the complete backend suite passes 22 cases across seven +files. These are runner counts, not counts of independent oracles. The new oracle +reach and receiving episode counts above are the semantic evidence. TypeScript, +changed-file ESLint, oracle lint and diff checks pass. The dedicated test tsconfig +fixes Vitest incremental source-root errors without disabling typechecking or +changing production build configuration. + +## Oracle-review self-audit + +This round uses the oracle-review skill for self-review; no fresh independent +review is claimed. The raw evidence and this record bind the reviewed source. + +| Requirement | Outcome | +| --- | --- | +| ORC-001 | API/architecture authority and bounded receiving limits stated above. | +| ORC-002 | Scientific facts, receipt debt, cleanup authority and authored windows are independent of pager mechanics. | +| ORC-003 | Opening contract, shared models, grammar, real drivers and event comparisons are directly named in each executable owner. | +| ORC-004 | Bounds, reconstruction, ablations and excluded authority states accounted for above. | +| ORC-005 | Actual factory/core/SDK paths, held-growth promise, genuine query error, actual abandonment and callback witnesses are reached. | +| ORC-006 | Two decision mutants, one publication perturbation and separate model controls reject at intended comparisons. | +| ORC-007 | Matching fixed/random budgets and checked shrunk seed/path replays. | +| ORC-008 | Cleanup authority and three receipt states change legal abandonment/settlement; sequence versus relation equality changes allowed order. | +| ORC-009 | Shared glossary vocabulary; isolated-call attribution and projected application fields are driver abstractions, not pager states. | +| ORC-010 | Retained callback failures, original nested causes, both SDKs after a failure, and cleanup aggregation preserve the law failure. | +| ORC-011 | Row equality as a proposed substitute for receipt application is independently challenged with actual core receipts; scoped array recomputation and real server order challenge stale/deleted windows. No new second pager is used. | +| ORC-012 | This versioned record, source manifest and frozen evidence distinguish final calibration, intermediates and open cells. No broad bug-class closure is claimed. | +| ORC-013 | Equal already-applied facts versus a newly pending actual receipt, pending versus observed-abandoned cleanup, and partial versus whole-source judgments have distinguishing witnesses. | +| ORC-014 | Actual server supplies partial mutation, query-error and cleanup premises; equal-valued SDK emission remains explicitly fixture-scoped. | +| ORC-015 | Required eight native and ten backend reach states fail on underreach and are recorded per campaign. | + +Self-review repaired two overconstraints before final validation: requiring +cached reuse to create zero receipts, and requiring a graph to stay `ready` +during ordinary provider failure. Neither is promised by the contract. It also +strengthened growth from a potentially cached window to an actually pending held +acquisition, and added unheld reactive receiving. None of these repairs weaken +receipt application, coherent publication or cleanup terminality. + +## Limits and preservation + +Native idempotent attribution is limited to isolated synchronous explicit +revalidation against coherent controlled SDK caches. It does not establish a +real backend must emit equal-valued query updates, general asynchronous receipt +attribution, or reentrant commit listener ordering. The backend family uses +ascending unique ranks and sequenced atomic edits, with mutation overlapping +held growth. Arbitrary nonquiescent schedules, error before acceptance, stale +real-SDK callback injection, authoritative truncate replay, rank-tie changes and +React hook/render concurrency retain their existing owners. The controlled +failure grammar remains broader than the backend receiving subset. No finite +campaign or deeper run is an exhaustive temporal proof. + +Seven Convex production source files, the two existing install-workaround files, +and all prior frozen formal/audit/interleaving/backend evidence remain unchanged. +The pre-existing uncommitted local override remains +`baseline-browser-mapping` 2.11.32 → 2.11.30. No dependency install occurred. +Task-local raw evidence and source/runtime snapshots are frozen separately in +`outputs/convex-lazy-boundary-strengthening-evidence`. diff --git a/docs/contributing/oracle-reviews/2026-10-27-convex-lazy-interleavings-source.json b/docs/contributing/oracle-reviews/2026-10-27-convex-lazy-interleavings-source.json new file mode 100644 index 0000000000..be89fdcbb9 --- /dev/null +++ b/docs/contributing/oracle-reviews/2026-10-27-convex-lazy-interleavings-source.json @@ -0,0 +1,50 @@ +{ + "starting_head": "8c24922026fb4a714cb54b992ac85faa2e876742", + "binding": "Current working-tree SHA-256 contents; excludes this manifest and unchanged local install workaround", + "files_sha256": { + ".changeset/convex-collection.md": "c7ad56ad983fe653f0054f0a82fcd47cad46c1d59087d62d79a21ffb077eb324", + "docs/collections/convex-collection.md": "3901d5d040f8e101369f1a16a160d4e5d0df391ec48408f99d995191ad013105", + "docs/contributing/oracle-coverage.md": "4a6b2059732a7d25aa7c3e585e7ff4ee9c93b54f33ccaa4f5a08964f3c6c6151", + "docs/contributing/oracle-reviews/2026-10-27-convex-lazy-interleavings.md": "f105899875a4129b7d816cacc1d6f094c68ad6048cf70350e8d105bd0acd89e6", + "packages/convex-db-collection/README.md": "53b984ef9156a52cea7cb92a81d34d6543508d2dbf048d8f2aeb361ae996d259", + "packages/convex-db-collection/e2e/lazy-paging-oracle.test.ts": "7a5c1390879e4e01757d020e22bfa24f5852a2cf5bb4e18faa4174ce47a02e48", + "packages/convex-db-collection/e2e/local-backend.ts": "e38be0c546a885f55b004f23bc894ab86d3c3b5880bfd9dde8208866a42aea5d", + "packages/convex-db-collection/package.json": "2f7708d14a108253b257b1771f85060642817c6896ba240c7a0d1b853428b799", + "packages/convex-db-collection/src/index.ts": "4247e2bbd168a80791861f4553c10d0d878504a7cdc6a65d4bc648e15b1e51ba", + "packages/convex-db-collection/src/offline.ts": "55e70bdbbc310226a9f0175d0c0912218413ba67433b5b47ccc78d6e7dadd5c3", + "packages/convex-db-collection/src/options.ts": "ea2c2efc4d574080c5e03da8e121b79406640c667a1e260ead9eff22f6253d33", + "packages/convex-db-collection/src/paginated-options.ts": "de0e92849d363f2545823d96829964084b1a115b890f0f521f9bdc0958ea2b76", + "packages/convex-db-collection/src/pagination-demand.ts": "d2be74c1f3610c153c2b7c3c9411aa6ea216db7e76c9b0a7f32d63ef2bde57ce", + "packages/convex-db-collection/src/pagination.ts": "0308668d07db701d1f7f891d001a4aca2798973ea29bf1d4b00931eaa0e05f1d", + "packages/convex-db-collection/src/subscription.ts": "a0c312caf2af3667c18e96c565ceb159c20d65550426cdd44aca69a43a3543e5", + "packages/convex-db-collection/tests/helpers.ts": "11f2c79c6872373e8f77efae62d2fabfead0e5af6673c86920ae2ef4abdb7274", + "packages/convex-db-collection/tests/lazy-paging-driver.ts": "9819960aa222777f9de3b4bbc3728d492fd9304865701dacbbe381591e88cea6", + "packages/convex-db-collection/tests/lazy-paging-failure-history-oracle.property.test.ts": "b96de9cf49b58cdbc07e364312692e677363c90a83a9c1d94b84cb2b6c4dbdbb", + "packages/convex-db-collection/tests/lazy-paging-history-oracle.property.test.ts": "5bc8808d6f71648f48e40acb05943132448f3e8f5bba31d97e261f1741674384", + "packages/convex-db-collection/tests/lazy-paging-interleaving-oracle.property.test.ts": "f3a0238452746233224bc0b21adf790b594cacef82d786a0ba419d19980bb773", + "packages/convex-db-collection/tests/lazy-paging-model-oracle.ts": "5c25e5765413eb6ad3d7e26bd10e7cd20c75a9c094cdca0e142b87da56d5dfc5", + "packages/convex-db-collection/tests/lazy-paging-native-receipt-oracle.property.test.ts": "5714e2c25507f1ea4aef5bb6eb9bed69bc9a9b079540667570227aa1014a0822", + "packages/convex-db-collection/tests/lazy-paging-oracle.property.test.ts": "3a7b5b6284076bc8dbdb59ac1d735f509c6bc19201ad355a0382e7a797a2d569", + "packages/convex-db-collection/tests/lazy-paging-receipt-model.ts": "ce9d4e5bcf6265536498462c218deb5d8c42a4f1c2f792dd98042ed7b077902d", + "packages/convex-db-collection/tests/lazy-paging-receipt-oracle.property.test.ts": "06b60b0a86926070dd57dd068f4abe572d48924e2cea29acb77f417bf8115d6a", + "packages/convex-db-collection/tests/lazy-paging-version-model-oracle.ts": "23f2b3624ecf0e086f2d9b04eb3f940ead4f9a6fe320dc7b100fc04ef82099e1", + "packages/convex-db-collection/tests/lazy-paging-version-oracle.property.test.ts": "8f4f115806a65dc71b3382dade7a89e326decc2b3c25505369783f30163a210e", + "packages/convex-db-collection/tests/sdk-cache-oracle.test.ts": "0e011a20ce991195742bdfa0cd570d831105604ad8e3e2cc4b8fab4b62daad58", + "packages/convex-db-collection/tests/types.test-d.ts": "3ff1a42d6caf95d14e5270b632340524a9f796b978ccd031835bd1f2d0dab6dd", + "packages/convex-db-collection/tsconfig.json": "623bc70a72fd718e7d209c90a08df12cced0e9045a3565058623e34e946a5710", + "packages/convex-db-collection/vitest.lazy.e2e.config.ts": "e377ee3a835edd1215c48654db863d9ed27406ca67ae3b34c846e4ed253a030c", + "packages/db/package.json": "bb6df01ff9589c8b75437683c935b8457f65e9216d37173a4730e1a169e0ab43", + "packages/db/src/collection/subscription.ts": "c446932a8a1f8b2efd79d00846f1a110e65a6c6df75d2b24658053e6964b530b", + "packages/db/src/query/live/ARCHITECTURE.md": "aa6a41b651809819d414d364421140bab02f9b992e9f5dbe73cf634b43c46478", + "packages/db/src/types.ts": "2b4c5926922db049e7ccd58c3e3fc22503f6d8a9400f3fb01900e82da85c7a1e", + "packages/db/tests/query/load-subset-joint-replay-oracle.property.test.ts": "975585ef1d1bc921afcab2e5384318c5f661dd0a2577e642351b2aafed0ee98d", + "packages/react-db/tests/convex-lazy-paging-oracle.test.tsx": "0a33955d45816d766100b89e45dd63ce32125864cf3e2f0ea46533a582b0333b" + }, + "production_files_changed_this_round": [], + "production_files_unchanged": 7, + "core_subscription_restored_sha256": "c446932a8a1f8b2efd79d00846f1a110e65a6c6df75d2b24658053e6964b530b", + "prior_analytical_freeze_verified": 250, + "loss_audit_artifacts_unchanged": 9, + "prior_original_records_unchanged": 2, + "local_install_files_unchanged": 2 +} diff --git a/docs/contributing/oracle-reviews/2026-10-27-convex-lazy-interleavings.md b/docs/contributing/oracle-reviews/2026-10-27-convex-lazy-interleavings.md new file mode 100644 index 0000000000..bf3cd5f2d4 --- /dev/null +++ b/docs/contributing/oracle-reviews/2026-10-27-convex-lazy-interleavings.md @@ -0,0 +1,146 @@ +# Convex lazy paging: receipt interleavings and composed publication + +This follow-up strengthens the four open products from the prior +[loss-audit/strengthening record](2026-10-27-convex-lazy-paging.md). +The scientific models own authored facts, in-flight obligations, receipt debt, +coherent versions and permitted public snapshots. Provider ranges, cursor +encodings, page chains, sealing and transport callbacks remain driver facts. + +Source binding: [manifest](2026-10-27-convex-lazy-interleavings-source.json). +The source began at branch commit `8c24922026fb4a714cb54b992ac85faa2e876742` +with the previous round's uncommitted implementation. No production fix was +needed in this round. All seven Convex production files match its recorded +pre-round hashes; core `collection/subscription.ts` was restored byte for byte +after calibration. Earlier records and frozen evidence remain historical. + +## Laws and receiving boundaries + +- **Exact applied settlement:** `LoadSubsetFn`, `LoadSubsetOptions.signal` and + `SyncAppliedReceipt` in `packages/db/src/types.ts` require establishing + receipts and permit an uncancelable in-flight baseline. The existing + `LazyReceiptModel` now records that independently authored obligation with + `initiate`/`finish`. Abort can precede acceptance of already initiated work. + Cancellation cannot settle before that work concludes and its assigned + receipts apply. Core can fulfill after abort once all required facts and + receipts apply; native/controlled receivers also assert their cancellation + outcome policy. Ordinary rejection has no new universal receipt-wait rule. +- **Coherent version publication:** after full lazy acquisition, each observed + source base must be one offered authored relation. Its compatible revision + frontier cannot move backward. Stuttering and skipped intermediate revisions + remain legal. Source provenance is checked separately, so offering facts + does not stand in for delivering them. +- **Replay authority:** architecture sections on replay participants/failure + and normative laws 7/9 require each graph's participating uncancelable work + to finish before a replacement publishes. Failed authoritative replacements + stay private through ordinary writes until a successful authoritative retry. +- **Cleanup authority:** architecture law 14 and `CleanupFn` terminate old + dependent graphs. Every later value/status event must preserve their terminal + state; a fresh dependent can receive a restarted source. + +Native Convex emits coherent keyed diffs during ordinary reactive repair. It +does not issue authoritative same-run truncate replacement. Native and core +receivers therefore compose the same relevant laws through distinct boundaries; +an incompatible external truncate wrapper is not introduced as native behavior. + +## Generated reach and comparison + +All families run fixed seed `20261027` and a fresh random campaign with separate +reach ledgers. Explicit `*_SEED`/`*_PATH` replays bypass campaign minima. + +| Family | Histories per campaign | Observed distinguishing states | +| --- | ---: | --- | +| `CONVEX_LAZY_INTERLEAVING_ORACLE` | 60 | Acceptance before final response; partial application before another acceptance; abort with held receipt; later peer acceptance; uncancelable acceptance after abort; abort with only unfinished in-flight work. All six: 60 in each lane. | +| `CONVEX_LAZY_NATIVE_RECEIPT_ORACLE` | 45 | Shared held native receipt, cached zero debt, cached shallow caller behind deeper held peer, new-version revalidation, canceled and successful held callers. All six: 45 in each lane; both SDK shapes observed. | +| `CONVEX_LAZY_VERSION_ORACLE` | 50 | Fully acquired multi-revision source, two accepted held revisions, later/skipped revision delivery, both SDK shapes. See raw reach log for each count. | +| `CONVEX_LAZY_FAILURE_HISTORY_ORACLE` | 40 | Failure before acceptance, shared held batch, error after acceptance with surviving owner, accepted writes surviving retirement/error, held cleanup after recovery, fresh graph with old graphs terminal. All six: 40 in each lane. | +| `JOINT_REPLAY_ORACLE` | 35 | Overlap with held receipt, newest done while older remains pending, failed replacement plus ordinary writes, retiring peer/retry, held cleanup/restart. All five: 35 in each lane. | + +Response and accepted-batch counts vary independently in the interleaving +receiver. A legal-action scheduler consumes authored delivery, acceptance, +application and cancellation actions. It has no production call budget. +Constructive prefixes preserve the distinguishing cuts; generated dimensions +vary cardinalities and legal suffix orders. + +The native receipt receiver observes actual sync writes and actual core +application receipts. Each required row/version absent at acquisition has a +unique supplying batch in its stable episode; that causal witness can assign +one receipt to two callers. Cached unchanged facts acquire no invented edge. +A cached caller may complete early or wait behind unrelated held work; only +post-drain completion is required. New-version revalidation installs the +authored coherent SDK caches before acquisition while callbacks remain queued. +Merely changing an unobserved remote source is not a promise of fresh load data. + +Version comparisons run on every source publication after full acquisition. +Terminal graph comparisons retain every value/status event after their cleanup +monitor is armed, plus explicit drained checkpoints. They preserve transient +violations even when later state becomes correct. Core replay compares every +public snapshot event and each drained action, with explicit newest-settled and +older-pending predicates before crediting that reach. + +## Calibration and production status + +Unchanged production passed the extended families. There is no production-bug +RED or repair claimed in this round. Initial failed test drafts exposed fixture +assumptions (Map iteration order, an uninstalled source revision, and an +overstrong ordinary-failure expectation), not product defects. + +Hostile checks distinguish the following wrong behavior: + +| Control | Evidence and classification | +| --- | --- | +| Borrow a peer's applied receipt / wait only the first receipt | Faulty controlled adapters fail the actual caller settlement comparison. | +| Settle canceled while an uncancelable baseline is still in flight | Faulty controlled adapter fails `settled before in-flight work finished`; shrinks and directly replays at seed `20261027`, path `0:0:0:0:0`. All earlier accepted receipts have applied at this cut. | +| Return startup's applied receipt for a native held commit | Faulty native receiver preserves actual core receipts for observation but lies to the adapter. It fails `a settled before receipt-2 applied`, shrinks/replays at seed `20261027`, path `0:0:0:0:0`. Correct receiver passes. | +| Transient rollback / mixed versions followed by correct final data | Recorder/model controls fail the version comparison after retaining the bad event; both shrink/replay at seed `20261027`, path `0:0:0:0:0:0:0`. These are not production-pager mutation results. | +| Publish when only newest replay finished | Core production mutant fails the exact public-row comparison during held-receipt release; older A still participates. Shrink/direct replay: seed `20261027`, path `0:0:0:0:0`. | +| Ordinary writes reopen failed replacement | Core production mutant exposes revision 5 after failure and fails the public-row comparison. Same shrink/direct replay coordinates. | +| Revive/change an old terminal graph | Scientific comparator controls reject the observations. Native event recorders are independently exercised in generated cleanup/restart histories. | + +The former future-peer receipt edge injection was removed. Waiting longer +behind a peer is permitted; an invalid model attribution is not evidence that a +production waiting strategy violates the contract. A positive model calibration +also accepts fully applied fulfillment after abort, while negative cuts reject +unfinished in-flight work and unapplied receipts. + +## Independent review and validation + +Three fresh `oracle-review` passes reviewed receipts/interleavings, core replay, +and version/failure histories; the latter also compared Query/Electric receiving +boundaries. Review found two repaired issues: a promise wait that could hide a +stranded caller behind test timeout, and the old model's blanket post-abort +acceptance prohibition. Native post-drain outcomes now fail before any unbounded +result wait. A suggested core reach predicate was also made explicit. Final +rechecks report no unresolved supported findings in their bounded profiles. + +Fresh final validation: + +- Full Convex package: **100 tests / 16 files** pass. +- Core replay/transaction/lifecycle receiving suite: **459 tests / 6 files** pass. +- Convex and core TypeScript checks, changed-file ESLint and oracle lint pass. + Oracle lint emits the known missing-local-`main` diagnostic, finds `origin/main`, + and exits 0. `git diff --check` passes. +- Semantic hostile controls shrink and directly replay as described above; + correct receivers and restored core production pass. + +Raw commands, logs, mutation restoration hashes and source-bound reviews are +packaged in `outputs/convex-lazy-interleavings-evidence` in the task workspace. +The existing local installation workaround is unchanged: +`baseline-browser-mapping` 2.11.32 → 2.11.30, confined to the preexisting +uncommitted workspace/lockfile override. No install, commit or push was performed. + +## Limits + +These finite histories do not prove arbitrary nonquiescent temporal progress +or every TLA+ interleaving. The native causal receipt bridge covers unique new +facts and nonreentrant commits; idempotent/superseding attribution and cleanup +abandonment retain their separate controlled/receiving owners. Receipt batch +listeners fire when commit returns and do not impose FIFO listener order on +nested commits. Version histories keep source keys/order stable after full +acquisition; arbitrary partial-prefix retention and membership edits retain +their earlier owners. Core joint replay uses one source and two stable-key +filtered owners; joined/includes graphs and exact event-payload replica equality +retain existing coverage. Ordinary native errors may recover and are distinct +from core authoritative replacement failure. New concurrency monitors use +controlled SDKs; receiving them at a real backend remains open under the Convex +integration owner. Backend and React tests from the prior round were not rerun +or relabeled as fresh results here; production bytes are unchanged this round. diff --git a/docs/contributing/oracle-reviews/2026-10-27-convex-lazy-paging-source.json b/docs/contributing/oracle-reviews/2026-10-27-convex-lazy-paging-source.json new file mode 100644 index 0000000000..76a97cd08a --- /dev/null +++ b/docs/contributing/oracle-reviews/2026-10-27-convex-lazy-paging-source.json @@ -0,0 +1,39 @@ +{ + "starting_head": "8c24922026fb4a714cb54b992ac85faa2e876742", + "binding": "current working-tree SHA-256 contents; excludes local install workaround and self/record", + "files_sha256": { + ".changeset/convex-collection.md": "c7ad56ad983fe653f0054f0a82fcd47cad46c1d59087d62d79a21ffb077eb324", + "docs/collections/convex-collection.md": "3901d5d040f8e101369f1a16a160d4e5d0df391ec48408f99d995191ad013105", + "docs/contributing/oracle-coverage.md": "a1f250ff60d734264475f69d293773d01259d7c9ded1ceb5e624f99d7bd06258", + "packages/convex-db-collection/README.md": "53b984ef9156a52cea7cb92a81d34d6543508d2dbf048d8f2aeb361ae996d259", + "packages/convex-db-collection/e2e/lazy-paging-oracle.test.ts": "7a5c1390879e4e01757d020e22bfa24f5852a2cf5bb4e18faa4174ce47a02e48", + "packages/convex-db-collection/e2e/local-backend.ts": "e38be0c546a885f55b004f23bc894ab86d3c3b5880bfd9dde8208866a42aea5d", + "packages/convex-db-collection/package.json": "8f6f781a3f005920033d44d237e86598b0e81157df73ff8955b4c44d36abb63b", + "packages/convex-db-collection/src/index.ts": "4247e2bbd168a80791861f4553c10d0d878504a7cdc6a65d4bc648e15b1e51ba", + "packages/convex-db-collection/src/offline.ts": "55e70bdbbc310226a9f0175d0c0912218413ba67433b5b47ccc78d6e7dadd5c3", + "packages/convex-db-collection/src/options.ts": "ea2c2efc4d574080c5e03da8e121b79406640c667a1e260ead9eff22f6253d33", + "packages/convex-db-collection/src/paginated-options.ts": "de0e92849d363f2545823d96829964084b1a115b890f0f521f9bdc0958ea2b76", + "packages/convex-db-collection/src/pagination-demand.ts": "d2be74c1f3610c153c2b7c3c9411aa6ea216db7e76c9b0a7f32d63ef2bde57ce", + "packages/convex-db-collection/src/pagination.ts": "0308668d07db701d1f7f891d001a4aca2798973ea29bf1d4b00931eaa0e05f1d", + "packages/convex-db-collection/src/subscription.ts": "a0c312caf2af3667c18e96c565ceb159c20d65550426cdd44aca69a43a3543e5", + "packages/convex-db-collection/tests/helpers.ts": "11f2c79c6872373e8f77efae62d2fabfead0e5af6673c86920ae2ef4abdb7274", + "packages/convex-db-collection/tests/lazy-paging-driver.ts": "b6cdef3d9f929be6cd0104d531d4177a0dff72c0d2e8ff61260659acffa3dffe", + "packages/convex-db-collection/tests/lazy-paging-history-oracle.property.test.ts": "5bc8808d6f71648f48e40acb05943132448f3e8f5bba31d97e261f1741674384", + "packages/convex-db-collection/tests/lazy-paging-model-oracle.ts": "5c25e5765413eb6ad3d7e26bd10e7cd20c75a9c094cdca0e142b87da56d5dfc5", + "packages/convex-db-collection/tests/lazy-paging-oracle.property.test.ts": "3a7b5b6284076bc8dbdb59ac1d735f509c6bc19201ad355a0382e7a797a2d569", + "packages/convex-db-collection/tests/lazy-paging-receipt-model.ts": "4f0387612c6e18caae83a24af1183586c426ddad65a6b3ad8d7cfb5b3de1067a", + "packages/convex-db-collection/tests/lazy-paging-receipt-oracle.property.test.ts": "06b60b0a86926070dd57dd068f4abe572d48924e2cea29acb77f417bf8115d6a", + "packages/convex-db-collection/tests/sdk-cache-oracle.test.ts": "0e011a20ce991195742bdfa0cd570d831105604ad8e3e2cc4b8fab4b62daad58", + "packages/convex-db-collection/tests/types.test-d.ts": "3ff1a42d6caf95d14e5270b632340524a9f796b978ccd031835bd1f2d0dab6dd", + "packages/convex-db-collection/tsconfig.json": "623bc70a72fd718e7d209c90a08df12cced0e9045a3565058623e34e946a5710", + "packages/convex-db-collection/vitest.lazy.e2e.config.ts": "e377ee3a835edd1215c48654db863d9ed27406ca67ae3b34c846e4ed253a030c", + "packages/react-db/tests/convex-lazy-paging-oracle.test.tsx": "0a33955d45816d766100b89e45dd63ce32125864cf3e2f0ea46533a582b0333b" + }, + "production_files_changed_this_round": [ + "packages/convex-db-collection/src/subscription.ts" + ], + "prior_analytical_freeze_verified": 250, + "loss_audit_artifacts_unchanged": 9, + "prior_original_records_unchanged": 2, + "local_install_files_unchanged": true +} diff --git a/docs/contributing/oracle-reviews/2026-10-27-convex-lazy-paging.md b/docs/contributing/oracle-reviews/2026-10-27-convex-lazy-paging.md new file mode 100644 index 0000000000..bfd15aa930 --- /dev/null +++ b/docs/contributing/oracle-reviews/2026-10-27-convex-lazy-paging.md @@ -0,0 +1,159 @@ +# Convex lazy paging: scientific oracle strengthening — 2026-10-27 + +This follow-up implements the maintainer's direction that the oracle owns the +scientific model and pager details remain in its driver. It starts from the +local implementation recorded on October 26, at repository HEAD +`8c24922026fb4a714cb54b992ac85faa2e876742`. All changes remain local and +uncommitted. The [source manifest](2026-10-27-convex-lazy-paging-source.json) +binds the current working tree. The prior implementation record, TLA+ package +and loss audit remain historical and unchanged. + +## Law + +Authority: `LoadSubsetOptions.signal`, `LoadSubsetFn`, `SyncAppliedReceipt` and +`UnloadSubsetFn` in `packages/db/src/types.ts`, the approved fixed-query profile +in the [collection guide](../../collections/convex-collection.md), and ordered +publication in `packages/db/src/query/live/ARCHITECTURE.md`. + +Success requires exact scoped coverage and every actual establishing receipt. +Receipt establishment is many-to-many: one batch may establish several attempts; +one attempt may owe zero, one or multiple batches. Already-visible row content +cannot discharge a newly accepted receipt. Abort removes permission for new work +but cannot withdraw accepted writes; uncancelable baseline writes become visible +before the canceled caller settles. An unaccepted cancellation may settle promptly. +Retirement does not retroactively forbid an already-started response. Retired +subscriptions have no row, readiness or error authority over their replacement. + +The source model sees authored row/version facts, scopes, owners, acceptance, +application and caller outcomes. It imports no production parser, comparator, +page chain, cursor encoding, sealing phase or commit partition. The native driver +alone translates opaque provider ranges. The applied-base provenance comparison +excludes optimistic overlays. Historical delivered facts remain allowed while a +new revision is waiting for application; settled coverage separately requires +current authored facts. + +## Reach and observations + +All campaigns run 60 fixed and 60 random histories, with distinct seed/path +prefixes and enforced reach minima. New campaigns use fixed seed `20261027`: + +- `CONVEX_LAZY_HISTORY_ORACLE`: one ownership/action history composes acquisition, + retirement, abort, chosen callback delivery, source changes, real optimistic + application holds, cleanup and restart. Its constructive prefix reaches + wider-authorizer retirement/abort while a successor is pending, delivery after + retirement, stale success/error fencing, accepted-held-receipt cleanup/restart, + and a changed source after restart. A 1–32 action suffix explores legal orders. + Source size is 16–24, page size 2–5, both directions and both SDK shapes. + Every source publication is checked using its contemporaneous provider-fact + evidence. Branch reaches are separate; other declared states reach all 60 + histories per campaign. +- `CONVEX_LAZY_SUFFIX_ORACLE`: three six-row profiles, page size two, both + directions and generated text. Two profiles have identical first-delivery + evidence and actual discovery trace but different hidden suffixes. A third + places three rows in the leading tie. Pending discovery and final exact + coverage are checked through production. All three reaches occur 60 times per + campaign. Separate comparator controls accept retained and reacquired + overshoot while rejecting a lost row; they do not claim native strategy choice. +- `CONVEX_LAZY_EXACT_RECEIPT_ORACLE`: an independent receipt model receives actual + core commit receipts, with independently varied 1–3 provider-response groups + and 2–3 establishing partitions, both partition orders, text and two abort + choices per generated case. Actual held response gates affect completion. + Shared attempts owe the same batches; a peer with applied coverage succeeds + while another waits. A held idempotent batch proves that row equality cannot + stand in for receipt application. Cached success, true with zero/one/multiple + synchronous receipts, ordinary Promise rejection and synchronous throw are + checked. Quiescent comparison rejects stranding. Each declared reach occurs + in all 60 histories per campaign. + +The existing native lifecycle campaign now records every source and dependent +row publication and status transition during stale injections at cleanup start, +held cleanup and after restart, together with source error observations. It +checks a fresh dependent's ordered result after restart while the old dependent +remains terminal. Its ten declared reaches occur in all 60 fixed/random histories. +The SDK cache receiver, local backend receiver and React public receiver retain +separate roles. + +## RED and production repair + +Before production edits, all seven production-source hashes were saved in +`work/lazy-strengthening/production-before.json`, with raw byte copies. The +extended ownership property failed on unchanged production: + +```sh +cd packages/convex-db-collection +../../node_modules/.bin/vitest run tests/lazy-paging-history-oracle.property.test.ts \ + -t 'generates ownership' --coverage.enabled=false --typecheck.enabled=false +``` + +`native-red.log`: seed `20261027`, path `0:0:0`, smallest source size 16/page size +2, at `retired callback reported an error`. The retained error callback of a +retired/replaced page subscription reached the current pager's error path. + +The only production edit in this strengthening round is `src/subscription.ts`: +unsubscribe revokes callback authority before invoking the SDK unsubscribe; +JavaScript success/error callbacks check that authority, and React watch callbacks +check it before reading a possibly throwing obsolete cache. The pager, public +API and scientific expectations did not change to make this witness pass. + +Early paired-suffix, cancellation-classification and fresh-dependent failures +were driver/observation mistakes: descending tie IDs changed the supposed equal +prefix; a DOMException failed an Error realm check; Collection values were used +instead of the ordered live result. They are recorded in intermediate logs and +are not production RED evidence. Review also repaired inert response grouping, +content-inferred receipt application, an ambiguous replay prefix, and aborted +requests retaining work permission. + +## GREEN and hostile controls + +Fresh logs are packaged under `outputs/convex-lazy-paging-strengthening-evidence`. +They include package/runtime, core receiving owners, React, real local backend, +TypeScript, ESLint and oracle lint checks. `package-final.log` is the final full +package run (87 tests); the core receiving run passes 465 checks and the React +receiver passes four. Direct minified public-index bundling with package +dependencies external measures +142 gzip / +113 brotli bytes for this round +(+420 minified bytes); this is a same-method comparison against the saved +pre-strengthening source, not a replacement for the prior packaged-size record. +The full existing backend suite passes 20 tests, including both lazy SDK +receivers; the targeted lazy run also passes separately. The local backend uses cached build +`precompiled-2026-10-10-72ce13c` and both installed SDKs. + +Seven existing production mutants were rerun, shrunk, directly replayed, +restored byte-for-byte and given repaired GREEN replays: eager startup, +unbounded split child, missing leading tie, skipped applied receipt, older +receipt clearing a newer receipt, stranded duplicate request, and quiescent +stall. New callback controls restore the original unfenced subscription and +separately remove only React's before-read guard. Both fail semantically and +have repaired GREEN direct replays. JSON results retain seed/path/exit/hash data. + +Six faulty controlled adapters fail the independent receipt comparison: +borrowing a peer's receipts, awaiting only the first receipt, skipping provider +completion, equating visible facts with an applied idempotent receipt, promptly +rejecting an accepted-held canceled caller, and stranding a quiescent cached +caller. These are actual faulty adapters through real Collection controls, not +TLA+ negative controls credited as implementation execution. + +Two independent reviewers used the repository oracle-review skill, one comparing +Electric/Query receiving practice. Their findings drove the repairs above. +Reviewed source bindings and later narrow re-review are retained with the new +evidence. No review claim is transferred silently across source edits. + +## Limits + +This strengthens the runtime oracle without claiming equivalence to all four +formal projections. Exact receipt-edge calibration uses a controlled core +adapter; native Convex exercises its actual batching separately. The controlled +receiver supplies all provider responses before establishing commits, so +arbitrary response/commit interleavings are not covered. Its fact domain is +stable within a history; native histories separately vary source revisions. + +Generated native cleanup retires direct owners first. The constructive native +lifecycle witness covers a surviving terminal dependent and held cleanup, but +not every surviving-owner/receipt/replay/failure product. Core replay/failure +owners remain primary and were rerun. No test here invents a total-call cap or +uses pager sealing state as scientific progress. Finite responsive quiescent +stalls fail semantically; the continuously nonquiescent TLA+ lasso remains +analytical evidence, and the fixture's turn guard remains diagnostic. + +The existing local-only `baseline-browser-mapping` override (2.11.32 → 2.11.30) +and lockfile are preserved unchanged and excluded from review/commit content. +No install, commit, push or PR mutation was performed in this round. diff --git a/packages/convex-db-collection/README.md b/packages/convex-db-collection/README.md index b423be00f0..4805650373 100644 --- a/packages/convex-db-collection/README.md +++ b/packages/convex-db-collection/README.md @@ -5,3 +5,15 @@ Subscribe to Convex query results with TanStack DB. Supports `ConvexClient`, `Co See the [Convex collection guide](https://tanstack.com/db/latest/docs/collections/convex-collection) for setup, mutation handling, generated IDs, and supported behavior. Run `pnpm --filter @tanstack/convex-db-collection test:e2e` to start isolated local Convex backends and test both clients, server page splits, and durable replay after process death. The suite also fetches pinned official pagination and authenticated chat examples to check integration with application code maintained outside this adapter. It needs network access to GitHub and the Convex backend download, but no cloud project or production credentials. Authentication uses a real Anonymous-provider sign-in with temporary signing keys. These checks do not exercise production cloud infrastructure or third-party OAuth services. + +The paginated factory supports `syncMode: 'on-demand'` for one fixed query with an explicit numeric `queryOrder`. Ordered DB windows acquire the required prefix and complete boundary ties. General filters and orders are not translated into Convex queries; see the guide for the supported profile. + +Run `pnpm --filter @tanstack/convex-db-collection test:e2e:lazy` for the local lazy-paging receiving checks. `CONVEX_TEST_BACKEND_VERSION` can select an already downloaded backend version when the version service is unavailable. + +The lazy receipt and window oracles run matching fixed and random campaigns. +Set `CONVEX_LAZY_ORACLE_RUNS_MULTIPLIER=3` to deepen the revalidation and backend +boundary families. Replay a captured failure with the reported seed and shrink +path in `CONVEX_REVALIDATION_ORACLE_SEED/PATH` or +`CONVEX_BOUNDARY_ORACLE_SEED/PATH`. Run backend commands serially: the local +Convex CLI checks its default port across checkouts. Concurrent unit campaigns +also need separate coverage report directories. diff --git a/packages/convex-db-collection/e2e/backend/convex/schema.ts b/packages/convex-db-collection/e2e/backend/convex/schema.ts index 3129c58112..154e736186 100644 --- a/packages/convex-db-collection/e2e/backend/convex/schema.ts +++ b/packages/convex-db-collection/e2e/backend/convex/schema.ts @@ -11,5 +11,6 @@ export default defineSchema({ .index('by_rank', ['rank']) .index('by_clientId', ['clientId']), applications: defineTable({ count: v.number() }), + oracleControl: defineTable({ failQuery: v.boolean() }), receipts: defineTable({ key: v.string() }).index('by_key', ['key']), }) diff --git a/packages/convex-db-collection/e2e/backend/convex/tasks.ts b/packages/convex-db-collection/e2e/backend/convex/tasks.ts index 10045a4e5d..4d97a9e8b0 100644 --- a/packages/convex-db-collection/e2e/backend/convex/tasks.ts +++ b/packages/convex-db-collection/e2e/backend/convex/tasks.ts @@ -10,13 +10,89 @@ export const list = queryGeneric({ handler: (ctx, args) => ctx.db.query('tasks').withIndex('by_rank').paginate(args.paginationOpts), }) + +// Test-server query errors are actual reactive Convex errors. TCP loss alone +// cannot supply that premise to a receiving oracle. +export const guardedList = queryGeneric({ + args: { paginationOpts: paginationOptsValidator }, + handler: async (ctx, args) => { + if ((await ctx.db.query('oracleControl').unique())?.failQuery) + throw new Error('oracle query failure') + return ctx.db + .query('tasks') + .withIndex('by_rank') + .paginate(args.paginationOpts) + }, +}) +export const setQueryFailure = mutationGeneric({ + args: { enabled: v.boolean() }, + handler: async (ctx, { enabled }) => { + const previous = await ctx.db.query('oracleControl').unique() + if (previous) await ctx.db.patch(previous._id, { failQuery: enabled }) + else await ctx.db.insert('oracleControl', { failQuery: enabled }) + }, +}) + +// Membership/order edits form one authored server revision, rather than +// multiple independently observable client mutation transactions. +export const reshape = mutationGeneric({ + args: { + reject: v.optional(v.boolean()), + changes: v.array( + v.object({ + clientId: v.string(), + rank: v.number(), + text: v.string(), + remove: v.optional(v.boolean()), + }), + ), + }, + handler: async (ctx, { changes, reject }) => { + for (const change of changes) { + const previous = await ctx.db + .query('tasks') + .withIndex('by_clientId', (q) => q.eq('clientId', change.clientId)) + .unique() + if (change.remove) { + if (previous) await ctx.db.delete(previous._id) + } else if (previous) { + await ctx.db.patch(previous._id, { + rank: change.rank, + text: change.text, + }) + } else { + await ctx.db.insert('tasks', { + clientId: change.clientId, + rank: change.rank, + text: change.text, + }) + } + } + // Throw after the attempted edits: Convex must roll back the entire remote + // transaction, including inserts and deletes. The app oracle checks both + // the backend and the disappearance of the local optimistic operation. + if (reject) throw new Error('oracle rejected app write') + }, +}) export const all = queryGeneric({ args: {}, handler: (ctx) => ctx.db.query('tasks').withIndex('by_rank').collect(), }) +// One backend transaction authors a distinguishable complete revision. Several +// client-side mutations would permit intermediate server relations instead. +export const revise = mutationGeneric({ + args: { text: v.string() }, + handler: async (ctx, { text }) => { + const rows = await ctx.db.query('tasks').collect() + for (const row of rows) await ctx.db.patch(row._id, { text }) + return rows.length + }, +}) export const seed = mutationGeneric({ args: { count: v.number() }, handler: async (ctx, { count }) => { + for (const row of await ctx.db.query('oracleControl').collect()) + await ctx.db.delete(row._id) for (const row of await ctx.db.query('tasks').collect()) await ctx.db.delete(row._id) for (const row of await ctx.db.query('receipts').collect()) 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 new file mode 100644 index 0000000000..f951971721 --- /dev/null +++ b/packages/convex-db-collection/e2e/lazy-app-write-oracle.test.ts @@ -0,0 +1,495 @@ +/** + * Application handlers, remote truth and promised windows must agree after a + * confirmed write. A rejected atomic backend write changes neither source + * truth nor the final window. LazyWriteModel owns the ledger and the coherent + * source/window/optimistic publication relation, using expectedLazyRows. No backend + * read supplies an expected row. Both real SDKs receive each generated history. + * + * The constructive prefix grows demand while an actual app insert persists, + * witnesses a held core receipt, then confirms the write. A shuffled suffix + * receives rejected insert and successful/rejected update/delete. Each handler + * is held before the server call and after its response. Independent remote + * edits occur during the optimistic operation. Immediate reads, every graph + * publication, backend rows and drained windows are separate observations. + * + * Grammar: 12–16 rows, page sizes 2–3, windows 2 then 5–6, short Unicode text, + * six operation/outcome cells and their suffix order. Removing rejection loses + * atomic rollback; removing growth loses application/acquisition overlap; + * removing the independent ledger lets backend and adapter share lost writes. + * Limits: ascending unique ranks, one app operation at a time, finite responsive + * schedules, no reload/new health API, rich values or arbitrary reentry. + */ +import { + BTreeIndex, + createCollection, + createLiveQueryCollection, +} from '@tanstack/db' +import { ConvexClient } from 'convex/browser' +import { ConvexReactClient } from 'convex/react' +import { makeFunctionReference } from 'convex/server' +import fc from 'fast-check' +import { expect, inject, it } from 'vitest' +import { convexPaginatedCollectionOptions } from '../src/index' +import { runOracleCampaign } from '../../db/tests/oracle-campaign' +import { withTestCleanup } from '../tests/helpers' +import { lazyOracleRuns, receiveBothSdks } from '../tests/lazy-paging-campaign' +import { LazyWriteModel } from '../tests/lazy-paging-write-model-oracle' +import type { LazyRow } from '../tests/lazy-paging-model-oracle' +import type { ConvexCollectionClient } from '../src/index' +import type { PaginationOptions, PaginationResult } from 'convex/server' + +type Task = { _id: string; clientId: string; rank: number; text: string } +type Kind = `insert` | `update` | `delete` +type Cell = { kind: Kind; rejected: boolean } +const seed = makeFunctionReference<`mutation`, { count: number }, null>( + `tasks:seed`, +) +const all = makeFunctionReference<`query`, {}, Array>(`tasks:all`) +const reshape = makeFunctionReference< + `mutation`, + { + changes: Array<{ + clientId: string + rank: number + text: string + remove?: boolean + }> + reject?: boolean + }, + null +>(`tasks:reshape`) +const query = makeFunctionReference< + `query`, + { paginationOpts: PaginationOptions }, + PaginationResult +>(`tasks:list`) +const project = (row: Task): LazyRow => ({ + id: row.clientId, + rank: row.rank, + text: row.text, +}) +const signature = (rows: ReadonlyArray) => + JSON.stringify(rows.map(({ id, rank, text }) => ({ id, rank, text }))) +const gate = () => { + let resolve!: () => void + const promise = new Promise((yes) => { + resolve = yes + }) + return { promise, resolve } +} +const cells: Array = [ + { kind: `insert`, rejected: true }, + { kind: `update`, rejected: false }, + { kind: `update`, rejected: true }, + { kind: `delete`, rejected: false }, + { kind: `delete`, rejected: true }, +] +const grammar = fc.record({ + size: fc.integer({ min: 12, max: 16 }), + pageSize: fc.integer({ min: 2, max: 3 }), + width: fc.integer({ min: 5, max: 6 }), + text: fc.string({ maxLength: 10 }), + suffix: fc.shuffledSubarray(cells, { + minLength: cells.length, + maxLength: cells.length, + }), +}) +type History = typeof grammar extends fc.Arbitrary ? T : never + +async function receive(h: History, sdk: `javascript` | `react`) { + const writer = new ConvexClient(inject(`convexUrl`), { + unsavedChangesWarning: false, + }) + const reader = + sdk === `javascript` + ? new ConvexClient(inject(`convexUrl`), { unsavedChangesWarning: false }) + : new ConvexReactClient(inject(`convexUrl`), { + unsavedChangesWarning: false, + }) + // Real SDK caches advance normally. Delay only their notifications so a + // successful handler must read current caches before acknowledging the app. + let notificationsHeld = false + const notifications: Array<() => void> = [] + const notify = (callback: () => void) => { + if (notificationsHeld) notifications.push(callback) + else callback() + } + const releaseNotifications = () => { + notificationsHeld = false + for (const callback of notifications.splice(0)) callback() + } + const transport: ConvexCollectionClient = + `onUpdate` in reader + ? { + onUpdate: (fn, args, callback, onError) => { + const subscription = reader.onUpdate( + fn, + args, + (value) => notify(() => callback(value)), + onError, + ) + return Object.assign(() => subscription(), { + unsubscribe: () => subscription(), + getCurrentValue: () => subscription.getCurrentValue(), + }) + }, + } + : { + watchQuery: (fn, args, options) => { + const watch = reader.watchQuery(fn, args, options) + return { + ...watch, + onUpdate: (callback) => watch.onUpdate(() => notify(callback)), + } + }, + } + const model = new LazyWriteModel( + Array.from({ length: h.size }, (_, rank) => ({ + id: String(rank), + rank, + text: `task ${rank}`, + })), + { limit: 2 }, + ) + const failures: Array = [] + const reaches = new Set() + let scope = { limit: 2 } + let monitor = false + let heldReceipts = 0 + let plan: + | { + cell: Cell + row: LazyRow + before: ReturnType + after: ReturnType + entered: ReturnType + server: ReturnType + error?: unknown + } + | undefined + const handler = + (kind: Kind) => + async ({ + transaction, + }: { + transaction: { mutations: ReadonlyArray<{ modified: Task }> } + }) => { + const active = plan! + try { + expect(kind).toBe(active.cell.kind) + expect(transaction.mutations).toHaveLength(1) + expect(project(transaction.mutations[0]!.modified)).toEqual(active.row) + } catch (error) { + failures.push(error) + active.entered.resolve() + throw error + } + active.entered.resolve() + await active.before.promise + try { + await reader.mutation(reshape, { + changes: [ + { + clientId: active.row.id, + rank: active.row.rank, + text: active.row.text, + ...(kind === `delete` ? { remove: true } : {}), + }, + ], + reject: active.cell.rejected, + }) + } catch (error) { + active.error = error + } + active.server.resolve() + await active.after.promise + if (active.error) throw active.error + } + const options = convexPaginatedCollectionOptions({ + client: transport, + query, + queryArgs: {}, + syncMode: `on-demand`, + queryOrder: { field: `rank`, direction: `asc` }, + pageSize: h.pageSize, + getKey: (row) => row.clientId, + autoIndex: `eager`, + defaultIndexType: BTreeIndex, + gcTime: 0, + onInsert: handler(`insert`), + onUpdate: handler(`update`), + onDelete: handler(`delete`), + }) + const sync = options.sync.sync + options.sync = { + ...options.sync, + sync: (controls) => + sync({ + ...controls, + commit: (signal) => { + const receipt = controls.commit(signal) + if (receipt !== true) { + heldReceipts++ + void receipt.catch((error: unknown) => failures.push(error)) + } + return receipt + }, + }), + } + const source = createCollection(options) + const graph = createLiveQueryCollection((q) => + q + .from({ row: source }) + .orderBy(({ row }) => row.rank, `asc`) + .limit(scope.limit) + .select(({ row }) => ({ + id: row.clientId, + rank: row.rank, + text: row.text, + })), + ) + const observe = () => { + if (!monitor) return + try { + model.observeWindow(graph.toArray) + } catch (error) { + failures.push(error) + } + } + let observer: { unsubscribe: () => void } | undefined + const stopStatus = graph.on(`status:change`, observe) + const check = () => { + if (failures.length) throw failures[0] + } + const waitFor = async (predicate: () => boolean) => { + const deadline = Date.now() + 10000 + while (!predicate()) { + check() + if (Date.now() > deadline) + throw new Error(`app write receiving checkpoint not reached`) + await new Promise((resolve) => setTimeout(resolve, 5)) + } + check() + } + const backend = async () => { + const actual = (await writer.query(all, {})).map(project) + check() + model.assertBackend(actual) + } + const quiet = async () => { + await waitFor( + () => signature(graph.toArray) === signature(model.window(scope)), + ) + model.observeWindow(graph.toArray) + await backend() + expect(source.utils.getSyncError()).toBeUndefined() + expect(graph.status).toBe(`ready`) + check() + } + const persisted: Array> = [] + let growth: Promise | undefined + await withTestCleanup( + async () => { + await writer.mutation(seed, { count: h.size }) + observer = graph.subscribeChanges(observe, { includeInitialState: false }) + await graph.preload() + monitor = true + await quiet() + expect(source.base.size).toBeLessThan(h.size) + reaches.add(`partial-source`) + const sequence = [ + { kind: `insert` as const, rejected: false }, + ...h.suffix, + ] + for (const [index, cell] of sequence.entries()) { + const previous = model.rows() + const target = model.window(scope)[0]! + const row = + cell.kind === `insert` + ? { + id: `app-${index}`, + rank: -20 - index, + text: `${h.text}:app-${index}`, + } + : cell.kind === `update` + ? { ...target, rank: -20 - index, text: `${h.text}:app-${index}` } + : target + const change = { + id: row.id, + ...(cell.kind === `delete` ? {} : { row }), + } + model.begin(change) + plan = { + cell, + row, + before: gate(), + after: gate(), + entered: gate(), + server: gate(), + } + const transaction = + cell.kind === `insert` + ? source.insert({ + _id: `optimistic-${index}`, + clientId: row.id, + rank: row.rank, + text: row.text, + }) + : cell.kind === `delete` + ? source.delete(row.id) + : source.update(row.id, (draft) => { + draft.rank = row.rank + draft.text = row.text + }) + const result = transaction.isPersisted.promise + void result.catch(() => undefined) + persisted.push(result) + // The direct Collection read is synchronous; graph propagation has its + // own publication boundary and is checked by the version monitor. + expect( + source.get(row.id) ? project(source.get(row.id)!) : undefined, + ).toEqual(cell.kind === `delete` ? undefined : row) + await plan.entered.promise + check() + model.assertBackend((await writer.query(all, {})).map(project)) + expect(model.rows()).toEqual(previous) + const remote = { + id: String(h.size - 1), + rank: h.size - 1, + text: `${h.text}:remote-${index}`, + } + const visibleRemote = { + ...model.window(scope).at(-1)!, + text: `${h.text}:visible-remote-${index}`, + } + expect(visibleRemote.id).not.toBe(row.id) + model.remote([ + { id: remote.id, row: remote }, + { id: visibleRemote.id, row: visibleRemote }, + ]) + await writer.mutation(reshape, { + changes: [ + { clientId: remote.id, rank: remote.rank, text: remote.text }, + { + clientId: visibleRemote.id, + rank: visibleRemote.rank, + text: visibleRemote.text, + }, + ], + }) + await backend() + reaches.add(`remote-during-app`) + reaches.add(`visible-remote-during-app`) + if (index === 0) { + const before = heldReceipts + scope = { limit: h.width } + model.moveWindow(scope) + let settled = false + growth = Promise.resolve(graph.utils.setWindow(scope)) + void growth.then( + () => { + settled = true + }, + () => { + settled = true + }, + ) + await waitFor(() => heldReceipts > before) + expect( + settled, + `demand growth escaped a persisting app handler`, + ).toBe(false) + reaches.add(`growth-behind-app-receipt`) + } + if (!cell.rejected) model.offerConfirmation() + notificationsHeld = true + plan.before.resolve() + await plan.server.promise + expect(Boolean(plan.error)).toBe(cell.rejected) + model.acknowledge(!cell.rejected) + await backend() + model.startSettlement() + plan.after.resolve() + if (cell.rejected) + await expect(result).rejects.toThrow(`oracle rejected app write`) + else await result + model.finishSettlement() + const settledRow = model.rows().find((value) => value.id === row.id) + expect( + source.get(row.id) ? project(source.get(row.id)!) : undefined, + `app write law: source differs at application settlement`, + ).toEqual(settledRow) + if (!cell.rejected) { + expect( + notifications.length, + `SDK notification-delay premise absent`, + ).toBeGreaterThan(0) + reaches.add(`cached-confirmation-before-notification`) + } + releaseNotifications() + if (growth) { + await growth + growth = undefined + } + await quiet() + reaches.add(`${cell.kind}:${cell.rejected ? `rejected` : `confirmed`}`) + } + expect(source.base.size).toBeLessThan(model.rows().length) + reaches.add(`unread-tail-after-writes`) + }, + () => { + plan?.before.resolve() + plan?.after.resolve() + releaseNotifications() + }, + () => Promise.allSettled(persisted), + () => { + monitor = false + observer?.unsubscribe() + stopStatus() + }, + () => graph.cleanup(), + () => source.cleanup(), + () => reader.close(), + () => writer.close(), + ) + return reaches +} + +const states = [ + `partial-source`, + `remote-during-app`, + `visible-remote-during-app`, + `cached-confirmation-before-notification`, + `growth-behind-app-receipt`, + `unread-tail-after-writes`, + `insert:confirmed`, + `insert:rejected`, + `update:confirmed`, + `update:rejected`, + `delete:confirmed`, + `delete:rejected`, + `javascript`, + `react`, +] as const +it(`receives app confirmations and atomic rejection on both sides during lazy acquisition`, async () => { + await runOracleCampaign( + `CONVEX_LAZY_APP_ORACLE`, + states, + (reach, parameters) => + fc.assert( + fc.asyncProperty(grammar, async (h) => { + reach.beginHistory() + for (const [sdk, observed] of await receiveBothSdks((kind) => + receive(h, kind), + )) { + for (const state of states) + if (observed.has(state)) reach.reach(state) + reach.reach(sdk) + } + }), + { numRuns: lazyOracleRuns(4), ...parameters }, + ), + { fixedSeed: 20261030 }, + ) +}, 120000) diff --git a/packages/convex-db-collection/e2e/lazy-boundary-oracle.test.ts b/packages/convex-db-collection/e2e/lazy-boundary-oracle.test.ts new file mode 100644 index 0000000000..ee609aeb52 --- /dev/null +++ b/packages/convex-db-collection/e2e/lazy-boundary-oracle.test.ts @@ -0,0 +1,610 @@ +/** + * A partial source may retain delivered rows without representing the complete + * server relation. Its promised ordered window must still be one coherent + * authored window, monotonically advancing through offered revisions. Cleanup + * can abandon accepted receipts, but caller abort/provider errors cannot. Old + * dependent graphs remain terminal; recovery requires a fresh graph. + * Authority: LoadSubsetFn/SyncAppliedReceipt/CleanupFn in db/src/types.ts and + * live/ARCHITECTURE.md laws 7, 9, 14. The scientific judgments are reused from + * lazy-paging-model-oracle, LazyVersionModel and LazyReceiptModel. + * + * Real SDK caches, opaque cursors, atomic backend edits, actual query errors, + * optimistic holds, and cleanup supply the receiving premises. Expected rows + * are authored before mutations. The independent all query verifies the server + * fixture; provider-result recording proves provenance without defining truth. + * Every graph change/status event reaches the monitors; checkpoints retain + * semantic failures instead of throwing through production subscribers. + * + * Grammar varies unread source size, page/window sizes, text, the canceled + * caller, and two or three revisions while application is held. Every history + * receives both SDKs. Limits: ascending unique numeric ranks, isolated atomic + * server edits, finitely drained schedules; no arbitrary reentry, idempotent + * receipt attribution, authoritative truncate replay or React render claim. + */ +import { + BTreeIndex, + createCollection, + createLiveQueryCollection, + createTransaction, +} from '@tanstack/db' +import { ConvexClient } from 'convex/browser' +import { ConvexReactClient } from 'convex/react' +import { makeFunctionReference } from 'convex/server' +import fc from 'fast-check' +import { expect, inject, it } from 'vitest' +import { convexPaginatedCollectionOptions } from '../src/index' +import { runOracleCampaign } from '../../db/tests/oracle-campaign' +import { withTestCleanup } from '../tests/helpers' +import { lazyRequest } from '../tests/lazy-paging-driver' +import { lazyOracleRuns, receiveBothSdks } from '../tests/lazy-paging-campaign' +import { + LazyTerminalGraphModel, + assertLazyCoverage, + assertLazySourceFacts, + copyLazyRows, + expectedLazyRows, +} from '../tests/lazy-paging-model-oracle' +import { LazyReceiptModel } from '../tests/lazy-paging-receipt-model' +import { LazyVersionModel } from '../tests/lazy-paging-version-model-oracle' +import type { LazyRow, LazyScope } from '../tests/lazy-paging-model-oracle' +import type { ConvexCollectionClient } from '../src/index' +import type { LoadSubsetOptions } from '@tanstack/db' +import type { PaginationOptions, PaginationResult } from 'convex/server' + +type Task = { _id: string; clientId: string; rank: number; text: string } +type Change = { clientId: string; rank: number; text: string; remove?: boolean } +const seed = makeFunctionReference<`mutation`, { count: number }, null>( + `tasks:seed`, +) +const all = makeFunctionReference<`query`, {}, Array>(`tasks:all`) +const reshape = makeFunctionReference< + `mutation`, + { changes: Array }, + null +>(`tasks:reshape`) +const setFailure = makeFunctionReference< + `mutation`, + { enabled: boolean }, + null +>(`tasks:setQueryFailure`) +const query = makeFunctionReference< + `query`, + { paginationOpts: PaginationOptions }, + PaginationResult +>(`tasks:guardedList`) +const project = (row: Task): LazyRow => ({ + id: row.clientId, + rank: row.rank, + text: row.text, +}) +const fact = (row: LazyRow) => JSON.stringify([row.id, row.rank, row.text]) +const signature = (rows: ReadonlyArray) => + JSON.stringify(copyLazyRows(rows)) +const turns = async () => { + for (let i = 0; i < 30; i++) await Promise.resolve() +} +const grammar = fc.record({ + size: fc.integer({ min: 18, max: 24 }), + pageSize: fc.integer({ min: 2, max: 3 }), + limit: fc.integer({ min: 2, max: 3 }), + text: fc.string({ maxLength: 8 }), + cancelFirst: fc.boolean(), + revisions: fc.integer({ min: 1, max: 2 }), +}) +type History = typeof grammar extends fc.Arbitrary ? T : never + +async function receive(h: History, kind: `javascript` | `react`) { + const writer = new ConvexClient(inject(`convexUrl`), { + unsavedChangesWarning: false, + }) + const reader = + kind === `javascript` + ? new ConvexClient(inject(`convexUrl`), { unsavedChangesWarning: false }) + : new ConvexReactClient(inject(`convexUrl`), { + unsavedChangesWarning: false, + }) + const receipts = new LazyReceiptModel() + const terminal = new LazyTerminalGraphModel() + const failures: Array = [] + const providerErrors: Array = [] + const evidence = new Map() + const applied = new Set() + const attempts = new Map; outcome?: string }>() + const batches: Array<{ + id: string + facts: Array + owners: Array + held: boolean + state: `pending` | `applied` | `abandoned` + }> = [] + const requests: Array = [] + const reaches = new Set() + const inspect = (value: unknown) => { + const result = value as PaginationResult + if (result.pageStatus !== `SplitRequired`) + for (const row of result.page) { + const observed = project(row) + evidence.set(fact(observed), observed) + } + } + const transport: ConvexCollectionClient = + `onUpdate` in reader + ? { + onUpdate: (fn, args, callback, onError) => { + const subscription = reader.onUpdate( + fn, + args, + (value) => { + inspect(value) + callback(value) + }, + onError, + ) + return Object.assign(() => subscription(), { + unsubscribe: () => subscription(), + getCurrentValue: () => { + const value = subscription.getCurrentValue() + if (value !== undefined) inspect(value) + return value + }, + }) + }, + } + : { + watchQuery: (fn, args, options) => { + const watch = reader.watchQuery(fn, args, options) + return { + ...watch, + localQueryResult: () => { + const value = watch.localQueryResult() + if (value !== undefined) inspect(value) + return value + }, + } + }, + } + const options = convexPaginatedCollectionOptions({ + client: transport, + query, + queryArgs: {}, + syncMode: `on-demand`, + queryOrder: { field: `rank`, direction: `asc` }, + pageSize: h.pageSize, + getKey: (row) => row.clientId, + autoIndex: `eager`, + defaultIndexType: BTreeIndex, + gcTime: 0, + onError: (error) => providerErrors.push(error), + }) + let recordReceipts = true + const sync = options.sync.sync + options.sync = { + ...options.sync, + sync: (controls) => { + let values: Array = [] + return sync({ + ...controls, + begin: (...args) => { + values = [] + controls.begin(...args) + }, + write: (message) => { + if (message.type !== `delete`) values.push(project(message.value)) + controls.write(message) + }, + commit: (signal) => { + const supplied = values.map(fact) + const owners = [...attempts].filter( + ([, attempt]) => + attempt.outcome === undefined && + supplied.some((value) => attempt.missing.has(value)), + ) + for (const [, attempt] of owners) + for (const value of supplied) attempt.missing.delete(value) + const actual = controls.commit(signal) + if (!recordReceipts) return actual + const batch = { + id: `batch-${batches.length}`, + facts: supplied, + owners: owners.map(([id]) => id), + held: actual !== true, + state: `pending` as `pending` | `applied` | `abandoned`, + } + batches.push(batch) + try { + receipts.accept(batch.id, supplied, batch.owners) + } catch (error) { + failures.push(error) + } + const apply = () => { + try { + receipts.apply(batch.id) + batch.state = `applied` + for (const value of supplied) applied.add(value) + } catch (error) { + failures.push(error) + } + } + if (actual === true) apply() + else + void actual.then(apply, (error: unknown) => { + try { + if (!(error instanceof Error) || error.name !== `AbortError`) + throw error + receipts.abandon(batch.id) + batch.state = `abandoned` + } catch (failure) { + failures.push(failure) + } + }) + return actual + }, + }) + }, + } + const source = createCollection(options) + const dependent = (limit: number) => + createLiveQueryCollection((q) => + q + .from({ row: source }) + .orderBy(({ row }) => row.rank) + .orderBy(({ row }) => row.clientId) + .select(({ row }) => row) + .limit(limit), + ) + const graph = dependent(h.limit) + let fresh: ReturnType | undefined + let versions: LazyVersionModel | undefined + const callbackSnapshots: Array = [] + const observeGraph = () => { + try { + const rows = graph.toArray.map(project) + terminal.observe(graph.status, rows) + versions?.observe(rows) + } catch (error) { + failures.push(error) + } + } + let graphObserver: ReturnType | undefined + let stopStatus = () => {} + const sourceObserver = source.subscribeChanges( + () => { + try { + assertLazySourceFacts([...source.base.values()].map(project), [ + ...evidence.values(), + ]) + } catch (error) { + failures.push(error) + } + }, + { includeInitialState: false }, + ) + const checkpoint = () => { + if (failures.length) throw failures[0] + } + const waitFor = async (predicate: () => boolean) => { + await expect + .poll(() => failures.length > 0 || predicate(), { + timeout: 10000, + interval: 10, + }) + .toBe(true) + await turns() + checkpoint() + } + let release = () => {} + let persisted: Promise | undefined + let holds = 0 + const hold = () => { + const pending = new Promise((resolve) => { + release = resolve + }) + const transaction = createTransaction({ mutationFn: () => pending }) + transaction.mutate(() => + source.insert({ + _id: `overlay-${holds}`, + clientId: `overlay-${holds++}`, + rank: 10000, + text: `hold`, + }), + ) + persisted = transaction.isPersisted.promise + } + const drain = async () => { + release() + await persisted + await turns() + checkpoint() + } + let truth: Array = Array.from({ length: h.size }, (_, rank) => ({ + id: String(rank), + rank, + text: `task ${rank}`, + })) + let scope: LazyScope = { limit: h.limit } + const monitor = () => { + versions = new LazyVersionModel(`sequence`) + versions.offer(expectedLazyRows(truth, scope)) + versions.observe(graph.toArray.map(project)) + } + const edit = async (changes: Array) => { + // The independent ledger authors truth before touching the server. + const next = truth.filter( + (row) => !changes.some((change) => change.clientId === row.id), + ) + next.push( + ...changes + .filter((change) => !change.remove) + .map(({ clientId, rank, text }) => ({ id: clientId, rank, text })), + ) + truth = next + versions!.offer(expectedLazyRows(truth, scope)) + const before = batches.length + await writer.mutation(reshape, { changes }) + const changed = new Set( + changes + .filter((change) => !change.remove) + .map(({ clientId, rank, text }) => fact({ id: clientId, rank, text })), + ) + await waitFor(() => + batches + .slice(before) + .some( + (batch) => + batch.held && batch.facts.some((value) => changed.has(value)), + ), + ) + expect( + source.base.size, + `partial prefix accidentally exhausted source`, + ).toBeLessThan(truth.length) + } + const reachLatest = async () => { + const expected = signature(expectedLazyRows(truth, scope)) + await waitFor(() => signature(graph.toArray.map(project)) === expected) + expect( + callbackSnapshots, + `latest window never reached actual change callback`, + ).toContain(expected) + assertLazyCoverage([...source.base.values()].map(project), truth, scope) + checkpoint() + } + const acquire = (id: string, signal: AbortSignal) => { + const required = truth.map(fact) + receipts.acquire(id, required) + const attempt = { + missing: new Set(required.filter((value) => !applied.has(value))), + outcome: undefined as string | undefined, + } + attempts.set(id, attempt) + const request = { ...lazyRequest({}), signal } + requests.push(request) + const result = source._sync.loadSubset(request) + const settle = (outcome: `success` | `canceled` | `rejected`) => { + attempt.outcome = outcome + try { + receipts.observe(id, outcome) + } catch (error) { + failures.push(error) + } + } + void Promise.resolve(result).then( + () => settle(`success`), + (error: unknown) => + settle( + error instanceof Error && error.name === `AbortError` + ? `canceled` + : `rejected`, + ), + ) + return attempt + } + let cleaning: Promise | undefined + await withTestCleanup( + async () => { + await writer.mutation(seed, { count: h.size }) + graphObserver = graph.subscribeChanges( + () => { + callbackSnapshots.push(signature(graph.toArray.map(project))) + observeGraph() + }, + { includeInitialState: false }, + ) + stopStatus = graph.on(`status:change`, observeGraph) + await graph.preload() + monitor() + expect(source.base.size).toBeLessThan(truth.length) + expect(source.base.has(String(h.size - 1))).toBe(false) + reaches.add(`unread-tail`) + hold() + await edit([ + { clientId: `0`, rank: 0, text: `removed`, remove: true }, + { clientId: String(h.size - 1), rank: -2, text: `${h.text}:moved-1` }, + { clientId: `insert-1`, rank: -1, text: `${h.text}:insert-1` }, + ]) + checkpoint() + reaches.add(`partial-membership-order-held`) + await drain() + await reachLatest() + + const prior = graph.toArray.map(project) + scope = { offset: 1, limit: h.limit + 5 } + versions = new LazyVersionModel(`sequence`) + versions.offer(prior) + versions.offer(expectedLazyRows(truth, scope)) + hold() + const beforeGrowth = batches.length + let growthSettled = false + const growth = Promise.resolve(graph.utils.setWindow(scope)) + void growth.then( + () => { + growthSettled = true + }, + () => { + growthSettled = true + }, + ) + await waitFor(() => + batches.slice(beforeGrowth).some((batch) => batch.held), + ) + expect(growthSettled, `growth escaped held application`).toBe(false) + reaches.add(`demand-growth-held`) + await edit([ + { clientId: `1`, rank: 1, text: `removed`, remove: true }, + { clientId: String(h.size - 2), rank: -3, text: `${h.text}:moved-2` }, + { clientId: `insert-2`, rank: -2.5, text: `${h.text}:insert-2` }, + ]) + for (let revision = 1; revision <= h.revisions; revision++) + await edit([ + { + clientId: `insert-1`, + rank: -1, + text: `${h.text}:superseding-${revision}`, + }, + ]) + reaches.add(`two-partial-revisions-held`) + await drain() + await waitFor(() => growthSettled) + await growth + await reachLatest() + expect(source.base.size).toBeLessThan(truth.length) + const unread = truth.find((row) => row.id === String(h.size - 3))! + expect( + evidence.has(fact(unread)), + `growth must retain a genuinely unread tail`, + ).toBe(false) + reaches.add(`demand-growth-with-unread-tail`) + expect(providerErrors).toEqual([]) + + // Only now acquire the whole relation, to compose shared acceptance with + // a genuine query error and cleanup-abandoned actual receipts. + hold() + const controllers = [new AbortController(), new AbortController()] + const first = acquire(`a`, controllers[0]!.signal) + const second = acquire(`b`, controllers[1]!.signal) + await waitFor( + () => + batches.some( + (batch) => + batch.held && + batch.owners.includes(`a`) && + batch.owners.includes(`b`), + ) && + first.missing.size === 0 && + second.missing.size === 0, + ) + const canceled = h.cancelFirst ? `a` : `b` + receipts.abort(canceled) + controllers[h.cancelFirst ? 0 : 1]!.abort() + await writer.mutation(setFailure, { enabled: true }) + await waitFor(() => + providerErrors.some((error) => + String(error).includes(`oracle query failure`), + ), + ) + expect( + attempts.get(canceled)!.outcome, + `canceled caller escaped accepted work before cleanup`, + ).toBeUndefined() + reaches.add(`real-query-error-after-shared-acceptance`) + // A provider error does not grant abandonment authority. Graph status + // before cleanup is unconstrained here; only cleanup activates terminality. + versions = undefined + terminal.cleanup(graph.toArray.map(project)) + receipts.cleanup() + const pending = batches.filter((batch) => batch.state === `pending`) + expect(pending.length).toBeGreaterThan(0) + cleaning = source.cleanup() + terminal.observe(graph.status, graph.toArray.map(project)) + await waitFor( + () => + pending.every((batch) => batch.state === `abandoned`) && + [...attempts.values()].every( + (attempt) => attempt.outcome !== undefined, + ), + ) + await cleaning + receipts.complete() + reaches.add(`actual-cleanup-abandonment`) + await drain() + await writer.mutation(setFailure, { enabled: false }) + // A fresh sync run needs its own receipt authority; the ended model remains + // complete. Stop the old receipt recorder before creating the fresh source. + recordReceipts = false + source.startSyncImmediate() + fresh = dependent(h.limit) + await fresh.preload() + await waitFor( + () => + signature(fresh!.toArray.map(project)) === + signature(expectedLazyRows(truth, { limit: h.limit })), + ) + terminal.observe(graph.status, graph.toArray.map(project)) + checkpoint() + const server = (await writer.query(all, {})).map(project) + expect([...server].sort((a, b) => a.id.localeCompare(b.id))).toEqual( + [...truth].sort((a, b) => a.id.localeCompare(b.id)), + ) + reaches.add(`fresh-graph-old-terminal`) + }, + () => release(), + () => Promise.allSettled(persisted ? [persisted] : []), + () => { + for (const request of requests) source._sync.unloadSubset(request) + }, + () => fresh?.cleanup(), + () => graph.cleanup(), + () => source.cleanup(), + () => { + graphObserver?.unsubscribe() + sourceObserver.unsubscribe() + stopStatus() + }, + () => writer.mutation(setFailure, { enabled: false }), + () => reader.close(), + () => writer.close(), + ) + return reaches +} + +it(`receives partial windows, query failure and abandoned receipts through both SDKs`, async () => { + await runOracleCampaign( + `CONVEX_BOUNDARY_ORACLE`, + [ + `unread-tail`, + `partial-membership-order-held`, + `demand-growth-with-unread-tail`, + `demand-growth-held`, + `two-partial-revisions-held`, + `real-query-error-after-shared-acceptance`, + `actual-cleanup-abandonment`, + `fresh-graph-old-terminal`, + `javascript`, + `react`, + ] as const, + (reach, parameters) => + fc.assert( + fc.asyncProperty(grammar, async (h) => { + reach.beginHistory() + for (const [kind, reached] of await receiveBothSdks((sdk) => + receive(h, sdk), + )) { + for (const state of [ + `unread-tail`, + `partial-membership-order-held`, + `demand-growth-with-unread-tail`, + `demand-growth-held`, + `two-partial-revisions-held`, + `real-query-error-after-shared-acceptance`, + `actual-cleanup-abandonment`, + `fresh-graph-old-terminal`, + ] as const) + if (reached.has(state)) reach.reach(state) + reach.reach(kind) + } + }), + { numRuns: lazyOracleRuns(4), ...parameters }, + ), + { fixedSeed: 20261029 }, + ) +}, 120000) diff --git a/packages/convex-db-collection/e2e/lazy-concurrency-oracle.test.ts b/packages/convex-db-collection/e2e/lazy-concurrency-oracle.test.ts new file mode 100644 index 0000000000..127a09a13c --- /dev/null +++ b/packages/convex-db-collection/e2e/lazy-concurrency-oracle.test.ts @@ -0,0 +1,536 @@ +/** + * Real-backend receiving witness for the SAME LazyReceiptModel and + * LazyVersionModel used by the controlled receivers. Authority: LoadSubsetFn, + * signal and SyncAppliedReceipt in db/src/types.ts; fixed-query coherent SDK + * snapshots and atomic publication. Facts/versions are authored here before + * server mutations; real opaque cursors and SDK caches remain driver facts. + * + * Stable new facts uniquely identify the native batch establishing two callers. + * A real optimistic transaction holds actual core application. Cancellation + * cannot settle its accepted work early; a cached shallow caller may wait for + * the deeper peer. Once the full finite relation applies, every subsequent base + * publication must match a monotone authored revision, including two revisions + * accepted while held and reader TCP loss while the writer advances. + * + * Grammar: 7..12 rows, page sizes 2..4, text, and reconnect supersession; every + * history receives both real SDKs. Server updates change the complete relation + * atomically, with stable keys/ranks. There are no controlled page responses or + * invented receipts. Transport wrappers only observe genuine SDK reads. + * Limits: no attribution of idempotent/superseding receipts, no accepted cleanup + * abandonment, no same-run authoritative truncate or application failure. TCP + * loss is a recoverable transport fault, not a promised provider-error callback. + */ +import { createCollection, createTransaction } from '@tanstack/db' +import { ConvexClient } from 'convex/browser' +import { ConvexReactClient } from 'convex/react' +import { makeFunctionReference } from 'convex/server' +import fc from 'fast-check' +import { expect, inject, it } from 'vitest' +import { convexPaginatedCollectionOptions } from '../src/index' +import { runOracleCampaign } from '../../db/tests/oracle-campaign' +import { withTestCleanup } from '../tests/helpers' +import { lazyRequest } from '../tests/lazy-paging-driver' +import { assertLazySourceFacts } from '../tests/lazy-paging-model-oracle' +import { LazyReceiptModel } from '../tests/lazy-paging-receipt-model' +import { LazyVersionModel } from '../tests/lazy-paging-version-model-oracle' +import { createNetworkGate } from './network-gate' +import type { LazyRow } from '../tests/lazy-paging-model-oracle' +import type { ConvexCollectionClient } from '../src/index' +import type { LoadSubsetOptions } from '@tanstack/db' +import type { PaginationOptions, PaginationResult } from 'convex/server' + +type Task = { _id: string; clientId: string; rank: number; text: string } +const seed = makeFunctionReference<`mutation`, { count: number }, null>( + `tasks:seed`, +) +const revise = makeFunctionReference<`mutation`, { text: string }, number>( + `tasks:revise`, +) +const all = makeFunctionReference<`query`, {}, Array>(`tasks:all`) +const query = makeFunctionReference< + `query`, + { paginationOpts: PaginationOptions }, + PaginationResult +>(`tasks:list`) +const project = (row: Task): LazyRow => ({ + id: row.clientId, + rank: row.rank, + text: row.text, +}) +const fact = (row: LazyRow) => JSON.stringify([row.id, row.rank, row.text]) +const normalize = (rows: ReadonlyArray) => + [...rows].sort((a, b) => a.rank - b.rank) +const turns = async () => { + for (let i = 0; i < 30; i++) await Promise.resolve() +} +type History = { + size: number + pageSize: number + text: string + supersede: boolean +} +type Fault = `none` | `borrow-startup` + +async function receive( + h: History, + kind: `javascript` | `react`, + fault: Fault = `none`, +) { + const gate = await createNetworkGate(inject(`convexUrl`)) + const writer = new ConvexClient(inject(`convexUrl`), { + unsavedChangesWarning: false, + }) + const reader = + kind === `javascript` + ? new ConvexClient(gate.url, { unsavedChangesWarning: false }) + : new ConvexReactClient(gate.url, { unsavedChangesWarning: false }) + const receipts = new LazyReceiptModel() + const versions = new LazyVersionModel() + const failures: Array = [] + const evidence = new Map() + const appliedFacts = new Set() + const attempts = new Map< + string, + { + missing: Set + active: boolean + outcome?: `success` | `canceled` | `rejected` + } + >() + const batches: Array<{ + id: string + facts: Array + owners: Array + held: boolean + applied: boolean + }> = [] + const requests: Array = [] + const authored = (revision: number): Array => + Array.from({ length: h.size }, (_, rank) => ({ + id: String(rank), + rank, + text: revision === 0 ? `task ${rank}` : `${h.text}:revision-${revision}`, + })) + const inspect = (value: unknown) => { + const result = value as PaginationResult + if (result.pageStatus !== `SplitRequired`) + for (const row of result.page) { + const observed = project(row) + evidence.set(fact(observed), observed) + } + } + const transport: ConvexCollectionClient = + `onUpdate` in reader + ? { + onUpdate: (fn, args, callback, onError) => { + const subscription = reader.onUpdate( + fn, + args, + (value) => { + inspect(value) + callback(value) + }, + onError, + ) + return Object.assign(() => subscription(), { + unsubscribe: () => subscription(), + getCurrentValue: () => { + const value = subscription.getCurrentValue() + if (value !== undefined) inspect(value) + return value + }, + }) + }, + } + : { + watchQuery: (fn, args, options) => { + const watch = reader.watchQuery(fn, args, options) + return { + ...watch, + localQueryResult: () => { + const value = watch.localQueryResult() + if (value !== undefined) inspect(value) + return value + }, + } + }, + } + const options = convexPaginatedCollectionOptions({ + client: transport, + query, + queryArgs: {}, + syncMode: `on-demand`, + queryOrder: { field: `rank`, direction: `asc` }, + pageSize: h.pageSize, + getKey: (row) => row.clientId, + gcTime: 0, + }) + let nextBatch = 0 + const sync = options.sync.sync + options.sync = { + ...options.sync, + sync: (controls) => { + let pending: Array = [] + return sync({ + ...controls, + begin: (...args) => { + pending = [] + controls.begin(...args) + }, + write: (message) => { + if (message.type !== `delete`) pending.push(project(message.value)) + controls.write(message) + }, + commit: (signal) => { + const values = pending + const id = `batch-${nextBatch++}` + const actual = controls.commit(signal) + const owners = [...attempts].filter( + ([, attempt]) => + attempt.active && + values.some((row) => attempt.missing.has(fact(row))), + ) + for (const [, attempt] of owners) + for (const row of values) attempt.missing.delete(fact(row)) + const batch = { + id, + facts: values.map(fact), + owners: owners.map(([owner]) => owner), + held: actual !== true, + applied: false, + } + batches.push(batch) + try { + receipts.accept(id, batch.facts, batch.owners) + } catch (error) { + failures.push(error) + } + const apply = () => { + try { + // Application comes from the actual receipt. A newer FIFO batch can + // already be public before this .then executes; event monitoring + // judges version visibility rather than rereading an old row here. + receipts.apply(id) + batch.applied = true + for (const value of batch.facts) appliedFacts.add(value) + } catch (error) { + failures.push(error) + } + } + if (actual === true) apply() + else void actual.then(apply, (error: unknown) => failures.push(error)) + return fault === `borrow-startup` ? true : actual + }, + }) + }, + } + const source = createCollection(options) + let versionActive = false + const publicationRevisions = new Set() + const callbackSnapshots = new Set() + const observe = () => { + if (!versionActive) return + const rows = [...source.base.values()].map(project) + try { + assertLazySourceFacts(rows, [...evidence.values()]) + versions.observe(rows) + if (rows.length) publicationRevisions.add(rows[0]!.text) + } catch (error) { + failures.push(error) + } + } + const observer = source.subscribeChanges( + () => { + if (versionActive) + callbackSnapshots.add( + JSON.stringify(normalize([...source.base.values()].map(project))), + ) + observe() + }, + { + includeInitialState: false, + }, + ) + const checkpoint = () => { + if (failures.length) throw failures[0] + } + const load = ( + id: string, + required: Array, + limit: number, + signal?: AbortSignal, + ) => { + const facts = required.map(fact) + receipts.acquire(id, facts) + const attempt = { + missing: new Set(facts.filter((value) => !appliedFacts.has(value))), + active: true, + outcome: undefined as `success` | `canceled` | `rejected` | undefined, + } + attempts.set(id, attempt) + const request = { ...lazyRequest({ limit }), signal } + requests.push(request) + const result = source._sync.loadSubset(request) + const outcome = (value: `success` | `canceled` | `rejected`) => { + attempt.outcome = value + attempt.active = false + try { + receipts.observe(id, value) + } catch (error) { + failures.push(error) + } + } + void Promise.resolve(result).then( + () => outcome(`success`), + (error: unknown) => + outcome( + error instanceof Error && error.name === `AbortError` + ? `canceled` + : `rejected`, + ), + ) + return attempt + } + const waitFor = async (predicate: () => boolean) => { + // A retained semantic violation must win over an environmental predicate + // that can no longer become true under the faulty implementation. + await expect + .poll(() => failures.length > 0 || predicate(), { + timeout: 10000, + interval: 10, + }) + .toBe(true) + await turns() + checkpoint() + } + let release = () => {} + let persisted: Promise | undefined + let holdNumber = 0 + const hold = () => { + const promise = new Promise((resolve) => { + release = resolve + }) + const transaction = createTransaction({ mutationFn: () => promise }) + const clientId = `overlay-${holdNumber++}` + transaction.mutate(() => + source.insert({ + _id: clientId, + clientId, + rank: 10000, + text: `optimistic`, + }), + ) + persisted = transaction.isPersisted.promise + } + const drain = async () => { + release() + await persisted + await turns() + checkpoint() + } + const reaches = new Set() + await withTestCleanup( + async () => { + await writer.mutation(seed, { count: h.size }) + expect(normalize((await writer.query(all, {})).map(project))).toEqual( + authored(0), + ) + source.startSyncImmediate() + const warm = load(`warm`, authored(0).slice(0, 1), 1) + await waitFor(() => warm.outcome !== undefined) + expect(warm.outcome).toBe(`success`) + expect(source.base.size).toBeLessThan(h.size) + hold() + const controller = new AbortController() + const a = load(`a`, authored(0), h.size, controller.signal) + const peer = load(`peer`, authored(0), h.size) + await waitFor( + () => + batches.some( + (batch) => + batch.held && + batch.owners.includes(`a`) && + batch.owners.includes(`peer`), + ) && + a.missing.size === 0 && + peer.missing.size === 0, + ) + reaches.add(`real-shared-held-receipt`) + const cached = load(`cached`, authored(0).slice(0, 1), 1) + receipts.abort(`a`) + a.active = false + controller.abort() + await turns() + checkpoint() + expect( + a.outcome, + `backend canceled caller escaped held receipt`, + ).toBeUndefined() + expect(peer.outcome, `backend peer escaped held receipt`).toBeUndefined() + expect(batches.flatMap((batch) => batch.owners)).not.toContain(`cached`) + reaches.add(`real-canceled-held`) + await drain() + await waitFor( + () => + a.outcome !== undefined && + peer.outcome !== undefined && + cached.outcome !== undefined, + ) + expect(a.outcome).toBe(`canceled`) + expect(peer.outcome).toBe(`success`) + expect(cached.outcome).toBe(`success`) + receipts.complete() + reaches.add(`real-cached-peer-drain`) + versions.offer(authored(0)) + versionActive = true + observe() + checkpoint() + hold() + const acceptRevision = async (revision: number) => { + const rows = authored(revision) + versions.offer(rows) + expect(await writer.mutation(revise, { text: rows[0]!.text })).toBe( + h.size, + ) + await waitFor(() => + rows.some((row) => + batches.some((batch) => batch.facts.includes(fact(row))), + ), + ) + } + const before = batches.length + await acceptRevision(1) + await acceptRevision(2) + expect(batches.length - before).toBeGreaterThanOrEqual(2) + expect(batches.slice(before).every((batch) => batch.held)).toBe(true) + expect(normalize([...source.base.values()].map(project))).toEqual( + authored(0), + ) + reaches.add(`real-two-revisions-held`) + await drain() + await waitFor( + () => + JSON.stringify(normalize([...source.base.values()].map(project))) === + JSON.stringify(authored(2)), + ) + observe() + checkpoint() + expect( + callbackSnapshots.has(JSON.stringify(authored(2))), + `backend version: held revision never reached the publication callback`, + ).toBe(true) + gate.disconnect() + const latest = h.supersede ? 4 : 3 + for (let revision = 3; revision <= latest; revision++) { + const rows = authored(revision) + versions.offer(rows) + expect(await writer.mutation(revise, { text: rows[0]!.text })).toBe( + h.size, + ) + } + expect(normalize([...source.base.values()].map(project))).toEqual( + authored(2), + ) + gate.reconnect() + await waitFor( + () => + JSON.stringify(normalize([...source.base.values()].map(project))) === + JSON.stringify(authored(latest)), + ) + observe() + checkpoint() + receipts.complete() + expect( + callbackSnapshots.has(JSON.stringify(authored(latest))), + `backend version: reconnected revision never reached the publication callback`, + ).toBe(true) + expect(normalize((await writer.query(all, {})).map(project))).toEqual( + authored(latest), + ) + expect(publicationRevisions.size).toBeGreaterThan(1) + reaches.add(`real-monotone-publications`) + reaches.add(`real-reconnect-latest`) + console.info( + `CONVEX_BACKEND_CONCURRENCY ${JSON.stringify({ kind, size: h.size, pageSize: h.pageSize, batches: batches.length, publications: [...publicationRevisions], reaches: [...reaches] })}`, + ) + }, + () => { + versionActive = false + observer.unsubscribe() + for (const request of requests) source._sync.unloadSubset(request) + }, + async () => { + release() + await persisted + }, + () => source.cleanup(), + () => reader.close(), + () => writer.close(), + () => gate.close(), + ) + return reaches +} + +it(`receives receipt and version laws through both real SDKs and backend`, async () => { + await runOracleCampaign( + `CONVEX_BACKEND_CONCURRENCY_ORACLE`, + [ + `real-shared-held-receipt`, + `real-canceled-held`, + `real-cached-peer-drain`, + `real-two-revisions-held`, + `real-monotone-publications`, + `real-reconnect-latest`, + `javascript`, + `react`, + ] as const, + (reach, parameters) => + fc.assert( + fc.asyncProperty( + fc.record({ + size: fc.integer({ min: 7, max: 12 }), + pageSize: fc.integer({ min: 2, max: 4 }), + text: fc.string({ maxLength: 6 }), + supersede: fc.boolean(), + }), + async (h) => { + reach.beginHistory() + const fault = process.env.CONVEX_BACKEND_CONCURRENCY_FAULT + if (fault !== undefined && fault !== `borrow-startup`) + throw new Error(`invalid backend concurrency fault`) + const errors: Array = [] + for (const kind of [`javascript`, `react`] as const) { + try { + const observed = await receive(h, kind, fault) + for (const state of [ + `real-shared-held-receipt`, + `real-canceled-held`, + `real-cached-peer-drain`, + `real-two-revisions-held`, + `real-monotone-publications`, + `real-reconnect-latest`, + ] as const) { + expect(observed.has(state)).toBe(true) + reach.reach(state) + } + reach.reach(kind) + } catch (error) { + errors.push(error) + console.info( + `CONVEX_BACKEND_RECEIVER_FAILURE ${JSON.stringify({ kind, error: String(error) })}`, + ) + } + } + if (errors.length) + throw new AggregateError( + errors, + `backend receiver comparison failures`, + { cause: errors[0] }, + ) + }, + ), + { numRuns: 4, ...parameters }, + ), + { fixedSeed: 20261028 }, + ) +}, 120000) diff --git a/packages/convex-db-collection/e2e/lazy-paging-oracle.test.ts b/packages/convex-db-collection/e2e/lazy-paging-oracle.test.ts new file mode 100644 index 0000000000..4e37a54b47 --- /dev/null +++ b/packages/convex-db-collection/e2e/lazy-paging-oracle.test.ts @@ -0,0 +1,268 @@ +/** Receiving driver for lazy-paging-model-oracle.ts. Real server cursors, + * both SDK caches, fixed rank-index order, live repair and TCP loss/reconnect + * supply the premises. The value reference is the separate unpaginated query; + * work compares only observed responses during stable-source acquisitions. + * Synthetic ties, held receipts and host replay are not backend claims. */ +import { + BTreeIndex, + createCollection, + createLiveQueryCollection, +} from '@tanstack/db' +import { ConvexClient } from 'convex/browser' +import { ConvexReactClient } from 'convex/react' +import { makeFunctionReference } from 'convex/server' +import { expect, inject, it } from 'vitest' +import { convexPaginatedCollectionOptions } from '../src/index' +import { + assertLazyCoverage, + deliveredScopeComplete, + expectedLazyRows, +} from '../tests/lazy-paging-model-oracle' +import { withTestCleanup } from '../tests/helpers' +import { createNetworkGate } from './network-gate' +import type { LazyRow, LazyScope } from '../tests/lazy-paging-model-oracle' +import type { ConvexCollectionClient } from '../src/index' +import type { PaginationOptions, PaginationResult } from 'convex/server' + +type Task = { _id: string; clientId: string; rank: number; text: string } +const seed = makeFunctionReference<`mutation`, { count: number }, null>( + `tasks:seed`, +) +const write = makeFunctionReference< + `mutation`, + { + idempotencyKey: string + clientId: string + rank: number + text: string + remove?: boolean + }, + null +>(`tasks:write`) +const all = makeFunctionReference<`query`, {}, Array>(`tasks:all`) +const modelRows = (rows: ReadonlyArray): Array => + rows.map(({ clientId, rank, text }) => ({ id: clientId, rank, text })) + +it.each([`javascript`, `react`] as const)( + `receives lazy scopes, repair and reconnect with the %s SDK`, + async (kind) => { + const gate = await createNetworkGate(inject(`convexUrl`)) + const writer = new ConvexClient(inject(`convexUrl`), { + unsavedChangesWarning: false, + }) + const client = + kind === `javascript` + ? new ConvexClient(gate.url, { unsavedChangesWarning: false }) + : new ConvexReactClient(gate.url, { unsavedChangesWarning: false }) + const delivered = new Map() + let exhausted = false + let checkWork = true + let scope: LazyScope = { limit: 3 } + let required = 0 + let recommended = 0 + const started = new Set() + let successorStarts = 0 + const inspect = ( + value: PaginationResult, + opts: PaginationOptions, + ) => { + if (value.pageStatus === `SplitRequired`) required++ + else { + for (const row of modelRows(value.page)) delivered.set(row.id, row) + } + if (value.pageStatus === `SplitRecommended`) recommended++ + if (opts.endCursor == null && value.isDone) exhausted = true + } + const start = (args: { paginationOpts: PaginationOptions }) => { + const opts = args.paginationOpts + const key = JSON.stringify(opts) + if ( + checkWork && + opts.cursor !== null && + opts.endCursor == null && + !started.has(key) + ) { + expect( + deliveredScopeComplete([...delivered.values()], exhausted, scope), + `native lazy work: unrelated successor`, + ).toBe(false) + successorStarts++ + } + started.add(key) + } + // These wrappers record request tuples and responses without producing rows, + // changing cancellation, or decoding the server's opaque cursor strings. + const transport: ConvexCollectionClient = + `onUpdate` in client + ? { + onUpdate: (query, args, callback, onError) => { + start(args as { paginationOpts: PaginationOptions }) + const subscription = client.onUpdate( + query, + args, + (value) => { + inspect( + value as PaginationResult, + (args as { paginationOpts: PaginationOptions }) + .paginationOpts, + ) + callback(value) + }, + onError, + ) + return Object.assign(() => subscription(), { + unsubscribe: () => subscription(), + getCurrentValue: () => { + const value = subscription.getCurrentValue() + if (value !== undefined) + inspect( + value as PaginationResult, + (args as { paginationOpts: PaginationOptions }) + .paginationOpts, + ) + return value + }, + }) + }, + } + : { + watchQuery: (query, args, options) => { + start(args as { paginationOpts: PaginationOptions }) + const watch = client.watchQuery(query, args, options) + return { + ...watch, + localQueryResult: () => { + const value = watch.localQueryResult() + if (value !== undefined) + inspect( + value as PaginationResult, + (args as { paginationOpts: PaginationOptions }) + .paginationOpts, + ) + return value + }, + } + }, + } + const source = createCollection( + convexPaginatedCollectionOptions({ + client: transport, + query: makeFunctionReference< + `query`, + { paginationOpts: PaginationOptions }, + PaginationResult + >(`tasks:split`), + queryArgs: {}, + syncMode: `on-demand`, + queryOrder: { field: `rank`, direction: `asc` }, + pageSize: 4, + getKey: (row) => row.clientId, + autoIndex: `eager`, + defaultIndexType: BTreeIndex, + gcTime: 0, + }), + ) + const live = createLiveQueryCollection((q) => + q + .from({ row: source }) + .orderBy(({ row }) => row.rank) + .orderBy(({ row }) => row.clientId) + .select(({ row }) => row) + .limit(3), + ) + await withTestCleanup( + async () => { + await writer.mutation(seed, { count: 23 }) + source.startSyncImmediate() + expect(started.size).toBe(0) + await live.preload() + let truth = modelRows(await writer.query(all, {})) + expect(modelRows(live.toArray)).toEqual(expectedLazyRows(truth, scope)) + assertLazyCoverage(modelRows([...source.values()]), truth, scope) + expect(source.size).toBeLessThan(truth.length) + scope = { offset: 2, limit: 7 } + await live.utils.setWindow(scope) + expect(modelRows(live.toArray)).toEqual(expectedLazyRows(truth, scope)) + assertLazyCoverage(modelRows([...source.values()]), truth, scope) + expect(source.size).toBeLessThan(truth.length) + expect(successorStarts).toBeGreaterThan(0) + // Work comparison is declared only for stable acquisitions. These named + // mutation/reconnect scenes compare source coverage and actual live values. + checkWork = false + const check = async () => { + truth = modelRows(await writer.query(all, {})) + await source.utils.awaitSync() + await expect + .poll(() => modelRows(live.toArray), { timeout: 10000 }) + .toEqual(expectedLazyRows(truth, scope)) + assertLazyCoverage(modelRows([...source.values()]), truth, scope) + } + await writer.mutation(write, { + idempotencyKey: crypto.randomUUID(), + clientId: `2`, + rank: 2, + text: `deleted`, + remove: true, + }) + await check() + await writer.mutation(write, { + idempotencyKey: crypto.randomUUID(), + clientId: `20`, + rank: -10, + text: `moved into unread prefix`, + }) + await check() + // A bounded interval that formerly contained a few rows now contains many. + // The real read budget forces SplitRequired, distinct from a hint. + for (let i = 0; i < 14; i++) + await writer.mutation(write, { + idempotencyKey: crypto.randomUUID(), + clientId: `dense${i}`, + rank: 0.01 + i / 100, + text: `dense`, + }) + await check() + expect( + required, + `native SplitRequired receiving premise`, + ).toBeGreaterThan(0) + // The leading boundary now crosses a native four-row rank tie. The + // trailing clientId order belongs to DB, independent of Convex's tie order. + for (const clientId of [`10`, `11`, `12`]) { + await writer.mutation(write, { + idempotencyKey: crypto.randomUUID(), + clientId, + rank: 0.05, + text: `native tie`, + }) + } + await check() + const nativeTieRows = truth.filter((row) => row.rank === 0.05).length + expect(nativeTieRows).toBe(4) + expect( + [...source.values()].filter((row) => row.rank === 0.05), + ).toHaveLength(nativeTieRows) + const beforeRun = source.status + gate.disconnect() + await writer.mutation(write, { + idempotencyKey: crypto.randomUUID(), + clientId: `22`, + rank: -20, + text: `offline arrival`, + }) + gate.reconnect() + await check() + expect(source.status).toBe(beforeRun) + console.info( + `CONVEX_LAZY_BACKEND ${JSON.stringify({ kind, successorStarts, required, recommended, nativeTieRows, requestTuples: started.size, scope })}`, + ) + }, + () => live.cleanup(), + () => source.cleanup(), + () => client.close(), + () => writer.close(), + () => gate.close(), + ) + }, + 60000, +) diff --git a/packages/convex-db-collection/e2e/local-backend.ts b/packages/convex-db-collection/e2e/local-backend.ts index 99946edf57..b92da7923d 100644 --- a/packages/convex-db-collection/e2e/local-backend.ts +++ b/packages/convex-db-collection/e2e/local-backend.ts @@ -19,6 +19,9 @@ export async function startLocalBackend(directory: URL, port?: number) { new URL(`../node_modules/convex/bin/main.js`, import.meta.url), ), `dev`, + ...(process.env.CONVEX_TEST_BACKEND_VERSION + ? [`--local-backend-version`, process.env.CONVEX_TEST_BACKEND_VERSION] + : []), `--typecheck`, `disable`, `--tail-logs`, diff --git a/packages/convex-db-collection/package.json b/packages/convex-db-collection/package.json index fd9ca5e232..df44dfdcb4 100644 --- a/packages/convex-db-collection/package.json +++ b/packages/convex-db-collection/package.json @@ -21,9 +21,10 @@ "dev": "vite build --watch", "lint": "eslint . --fix", "test": "vitest run", - "test:oracles": "vitest run tests/snapshot-oracle.property.test.ts tests/ownership-oracle.property.test.ts tests/pagination-oracle.property.test.ts tests/offline-oracle.property.test.ts tests/values-oracle.property.test.ts", + "test:oracles": "vitest run tests/snapshot-oracle.property.test.ts tests/ownership-oracle.property.test.ts tests/pagination-oracle.property.test.ts tests/offline-oracle.property.test.ts tests/values-oracle.property.test.ts tests/lazy-paging-oracle.property.test.ts tests/lazy-paging-history-oracle.property.test.ts tests/lazy-paging-receipt-oracle.property.test.ts tests/sdk-cache-oracle.test.ts tests/lazy-paging-interleaving-oracle.property.test.ts tests/lazy-paging-native-receipt-oracle.property.test.ts tests/lazy-paging-version-oracle.property.test.ts tests/lazy-paging-failure-history-oracle.property.test.ts tests/lazy-paging-revalidation-oracle.property.test.ts tests/lazy-paging-load-outcome-oracle.property.test.ts tests/lazy-paging-abort-replay-oracle.property.test.ts tests/lazy-paging-write-model-oracle.test.ts", "typecheck": "tsc --noEmit", - "test:e2e": "vitest run --config vitest.e2e.config.ts" + "test:e2e": "vitest run --config vitest.e2e.config.ts", + "test:e2e:lazy": "vitest run --config vitest.lazy.e2e.config.ts" }, "type": "module", "main": "dist/cjs/index.cjs", diff --git a/packages/convex-db-collection/src/index.ts b/packages/convex-db-collection/src/index.ts index 67aa8ea84f..85ab6cb29b 100644 --- a/packages/convex-db-collection/src/index.ts +++ b/packages/convex-db-collection/src/index.ts @@ -64,4 +64,5 @@ export function convexCollectionOptions< export { convexPaginatedCollectionOptions, type ConvexPaginatedCollectionConfig, + type ConvexQueryOrder, } from './paginated-options' diff --git a/packages/convex-db-collection/src/options.ts b/packages/convex-db-collection/src/options.ts index d8e4c4edab..71fbab7421 100644 --- a/packages/convex-db-collection/src/options.ts +++ b/packages/convex-db-collection/src/options.ts @@ -26,7 +26,7 @@ export function createConvexOptions< >( config: Omit< BaseCollectionConfig, - `syncMode` | `utils` + `utils` > & { select?: (result: TResult) => ReadonlyArray onError?: (error: unknown) => void @@ -38,7 +38,7 @@ export function createConvexOptions< ): CollectionConfig & { utils: ConvexCollectionUtils } & ([TSchema] extends [never] ? unknown : { schema: TSchema }) { - const { select, onError, ...baseConfig } = config + const { select, onError, syncMode = `eager`, ...baseConfig } = config let syncError: unknown let readCurrent: (() => Promise) | undefined @@ -159,7 +159,7 @@ export function createConvexOptions< if (receipt === true) markReady() else void receipt.then(() => { - if (active) { + if (active && visible === receipt) { visible = true markReady() } @@ -198,16 +198,46 @@ export function createConvexOptions< readCurrent = refresh try { subscription = subscribe(receive, fail) + // Validate subscription construction before readiness. The empty lazy + // run still precedes subset activation, avoiding first-page replay. + if (syncMode === `on-demand`) receive([] as TResult) const value = subscription.getCurrentValue() if (value !== undefined) receive(value) } catch (error) { fail(error) } - return () => { - active = false - rejectFirst(new Error(`Convex sync ended before source acceptance.`)) - if (readCurrent === refresh) readCurrent = undefined - subscription?.unsubscribe() + const loadSubset = subscription?.loadSubset + return { + ...(loadSubset + ? { + loadSubset: async (request) => { + let failure: { error: unknown } | undefined + try { + await loadSubset(request) + } catch (error) { + failure = { error } + } + // The pager publishes coherent batches. Its latest establishing + // receipt follows every earlier accepted batch in core's FIFO. + // Cancellation after acceptance cannot withdraw those writes. + if (visible !== true) await visible + if (failure) throw failure.error + if (!active || request.signal?.aborted) { + const error = new Error(`Convex subset was canceled.`) + error.name = `AbortError` + throw error + } + if (syncError !== undefined) throw syncError + }, + unloadSubset: subscription?.unloadSubset, + } + : {}), + cleanup: () => { + active = false + rejectFirst(new Error(`Convex sync ended before source acceptance.`)) + if (readCurrent === refresh) readCurrent = undefined + subscription?.unsubscribe() + }, } }, } @@ -236,7 +266,7 @@ export function createConvexOptions< ConvexCollectionUtils > & { utils: ConvexCollectionUtils } = { ...baseConfig, - syncMode: `eager` as const, + syncMode, sync: withCollectionSyncConfigFactory( sync, (source, utils, startSyncIfIdle) => { diff --git a/packages/convex-db-collection/src/paginated-options.ts b/packages/convex-db-collection/src/paginated-options.ts index 1fd8b07291..43f0377f29 100644 --- a/packages/convex-db-collection/src/paginated-options.ts +++ b/packages/convex-db-collection/src/paginated-options.ts @@ -17,6 +17,16 @@ type PaginatedRow> = ? TRow : never +/** Leading numeric order of the fixed server query. Trailing DB order terms + * are local: each acquisition establishes the complete leading-value tie. */ +export type ConvexQueryOrder = { + field: { + [K in keyof TRow]-?: TRow[K] extends number ? K : never + }[keyof TRow] & + string + direction: `asc` | `desc` +} + export type ConvexPaginatedCollectionConfig< TQuery extends FunctionReference<`query`>, TItem extends object = PaginatedRow, @@ -30,7 +40,7 @@ export type ConvexPaginatedCollectionConfig< query: TQuery queryArgs: Omit, `paginationOpts`> pageSize?: number - /** Maximum accepted rows in the complete eager mirror. Defaults to 10,000. */ + /** Maximum accepted rows in the retained query prefix. Defaults to 10,000. */ maxRows?: number onError?: (error: unknown) => void } & ([TSchema] extends [never] ? { schema?: never } : { schema: TSchema }) & @@ -41,15 +51,22 @@ export type ConvexPaginatedCollectionConfig< ? unknown : never) & ( - | { - select: ( - rows: ReadonlyArray>, - ) => ReadonlyArray - } - | (PaginatedRow extends TItem ? { select?: never } : never) + | ({ syncMode?: `eager`; queryOrder?: never } & ( + | { + select: ( + rows: ReadonlyArray>, + ) => ReadonlyArray + } + | (PaginatedRow extends TItem ? { select?: never } : never) + )) + | ({ + syncMode: `on-demand` + queryOrder: ConvexQueryOrder> + select?: never + } & (PaginatedRow extends TItem ? unknown : never)) ) -/** Automatically subscribe to every page, retaining fixed reactive cursor ranges. */ +/** Retain reactive cursor ranges; on-demand mode acquires only the prefix needed by active subsets. */ export function convexPaginatedCollectionOptions< TQuery extends FunctionReference<`query`>, TItem extends object = PaginatedRow, @@ -62,14 +79,41 @@ export function convexPaginatedCollectionOptions< queryArgs, pageSize = 100, maxRows = 10_000, + queryOrder, ...options } = config + if (options.syncMode === `on-demand` && (options.select || !queryOrder)) { + throw new TypeError( + `Convex on-demand paging requires queryOrder and does not support select.`, + ) + } + const declaredOrder: { field: unknown; direction: unknown } | undefined = + queryOrder + if ( + declaredOrder && + (typeof declaredOrder.field !== `string` || + !declaredOrder.field || + (declaredOrder.direction !== `asc` && declaredOrder.direction !== `desc`)) + ) { + throw new TypeError( + `Convex queryOrder requires a numeric field and asc or desc direction.`, + ) + } return createConvexOptions< ReadonlyArray>, TItem, TKey, TSchema >(options, (receive, fail) => - subscribePages(client, query, queryArgs, pageSize, maxRows, receive, fail), + subscribePages( + client, + query, + queryArgs, + pageSize, + maxRows, + receive, + fail, + options.syncMode === `on-demand` ? queryOrder : undefined, + ), ) } diff --git a/packages/convex-db-collection/src/pagination-demand.ts b/packages/convex-db-collection/src/pagination-demand.ts new file mode 100644 index 0000000000..e279a3f328 --- /dev/null +++ b/packages/convex-db-collection/src/pagination-demand.ts @@ -0,0 +1,90 @@ +import type { IR, LoadSubsetOptions } from '@tanstack/db' +import type { ConvexQueryOrder } from './paginated-options' + +/** Whether a contiguous, complete provider prefix establishes this exact + * request. No Convex cursor is decoded and no unseen suffix is consulted. */ +export function paginationDemand( + request: LoadSubsetOptions, + order: ConvexQueryOrder, +): (rows: ReadonlyArray, exhausted: boolean) => boolean { + const unsupported = (): never => { + throw new TypeError( + `Convex on-demand paging requires the declared numeric query order and supports only its prefix, boundary tie and continuation requests.`, + ) + } + const { limit, offset = 0, where, cursor, orderBy } = request + for (const count of [limit, offset]) { + if (count !== undefined && (!Number.isSafeInteger(count) || count < 0)) + throw new RangeError( + `Convex subset offset and limit must be nonnegative safe integers.`, + ) + } + const leading = orderBy?.[0] + if ( + leading && + (leading.expression.type !== `ref` || + leading.expression.path.length !== 1 || + leading.expression.path[0] !== order.field || + leading.compareOptions.direction !== order.direction) + ) + unsupported() + const comparison = ( + expression: IR.BasicExpression, + name: string, + ): number => { + if ( + expression.type !== `func` || + expression.name !== name || + expression.args.length !== 2 + ) + return unsupported() + const [field, value] = expression.args + if ( + field?.type !== `ref` || + field.path.length !== 1 || + field.path[0] !== order.field || + value?.type !== `val` || + typeof value.value !== `number` || + !Number.isFinite(value.value) + ) + return unsupported() + return value.value + } + const direction = order.direction === `asc` ? 1 : -1 + // Main where is a scoped tie (emitted by core separately), never a general + // filter translated into a Convex query. Cursor expressions stay separate. + const equal = where === undefined ? undefined : comparison(where, `eq`) + let after: number | undefined + if (cursor) { + const boundary = comparison(cursor.whereFrom, direction === 1 ? `gt` : `lt`) + if (comparison(cursor.whereCurrent, `eq`) !== boundary) unsupported() + // Core permits offset encoding instead of cursor encoding. A provided + // absolute offset takes precedence; cursor-only callers keep their scope. + if (request.offset === undefined) after = boundary + } + return (rows, exhausted) => { + if (limit === 0) return true + let count = 0 + let boundary: number | undefined + let last: number | undefined + for (const row of rows) { + const value = row[order.field] + if (typeof value !== `number` || !Number.isFinite(value)) + return unsupported() + if (last !== undefined && direction * (value - last) < 0) + return unsupported() + last = value + if (equal !== undefined && direction * (value - equal) > 0) return true + if ( + (equal === undefined || value === equal) && + (after === undefined || direction * (value - after) > 0) + ) { + count++ + if (limit !== undefined && count === offset + limit) boundary = value + } + if (boundary !== undefined && direction * (value - boundary) > 0) + return true + } + return exhausted + } +} diff --git a/packages/convex-db-collection/src/pagination.ts b/packages/convex-db-collection/src/pagination.ts index 255f7988de..c25e3d12bb 100644 --- a/packages/convex-db-collection/src/pagination.ts +++ b/packages/convex-db-collection/src/pagination.ts @@ -1,4 +1,7 @@ +import { paginationDemand } from './pagination-demand' import { subscribeQuery } from './subscription' +import type { LoadSubsetOptions } from '@tanstack/db' +import type { ConvexQueryOrder } from './paginated-options' import type { ConvexCollectionClient, ConvexSubscription } from './subscription' import type { FunctionArgs, @@ -16,7 +19,8 @@ type Page = { * Each bounded subscription owns (cursor, endCursor]. A changing page cannot * move its neighbour's boundary. Unbounded tails are sealed before continuing; * split hints replace a range with its two disjoint halves. Only a complete - * chain ending in isDone can replace the Collection's authoritative snapshot. + * chain can replace the Collection's authoritative snapshot: eager mode needs + * exhaustion; on-demand mode needs every active scope and its complete tie. */ export function subscribePages, TRow>( client: ConvexCollectionClient, @@ -26,6 +30,7 @@ export function subscribePages, TRow>( maxRows: number, receive: (rows: ReadonlyArray) => void, fail: (error: unknown) => void, + queryOrder?: ConvexQueryOrder, ): ConvexSubscription> { if (!Number.isSafeInteger(pageSize) || pageSize <= 0) { throw new RangeError(`Convex pageSize must be a positive safe integer.`) @@ -42,19 +47,35 @@ export function subscribePages, TRow>( resolve: () => void reject: (reason: unknown) => void }>() + const demands = new Map< + LoadSubsetOptions, + { + complete: ReturnType> + promise: Promise + resolve: () => void + reject: (reason: unknown) => void + cancel: () => void + pending: boolean + } + >() + let publishedPages = new Set>() const pages: Array> = [] const boundaries = new Set() const reject = (reason: unknown): void => { if (!active) return error = reason result = undefined + for (const demand of demands.values()) + if (demand.pending) { + demand.pending = false + demand.reject(reason) + } for (const waiter of waiters) waiter.reject(reason) waiters.clear() fail(reason) } const schedule = (): void => { if (!active || scheduled) return - result = undefined scheduled = true queueMicrotask(() => { scheduled = false @@ -75,12 +96,24 @@ export function subscribePages, TRow>( client, query, { ...args, paginationOpts } as FunctionArgs, - schedule, + () => { + // Pending tail acquisition leaves prior applied shallow coverage usable. + // An update to a published range invalidates that cache until a new + // coherent prefix establishes every still-active served scope. + if (publishedPages.has(page)) result = undefined + schedule() + }, reject, ) as ConvexSubscription> return page } - const replace = (index: number, page: Page, boundary: string): void => { + const replace = ( + index: number, + page: Page, + boundary: string, + continueTail = true, + endCursor = page.endCursor, + ): void => { if ( boundary === page.cursor || boundary === page.endCursor || @@ -91,22 +124,21 @@ export function subscribePages, TRow>( ) } const left = createPage(page.cursor, boundary) - let right: Page + let right: Page | undefined try { - right = createPage(boundary, page.endCursor) + if (continueTail) right = createPage(boundary, endCursor) } catch (cause) { left.subscription?.unsubscribe() throw cause } boundaries.add(boundary) - pages.splice(index, 1, left, right) + pages.splice(index, 1, left, ...(right ? [right] : [])) page.subscription?.unsubscribe() schedule() } function flush(): void { - if (!active || flushing) return + if (!active || flushing || (queryOrder && demands.size === 0)) return flushing = true - result = undefined try { const completePages: Array> = [] let rowCount = 0 @@ -137,21 +169,59 @@ export function subscribePages, TRow>( throw new Error( `Convex requested a page split without a splitCursor.`, ) - replace(index, page, value.splitCursor) + // Convex defines split children inside (cursor, continueCursor]. + // An unbounded right child would also start an unrelated successor. + replace( + index, + page, + value.splitCursor, + true, + page.endCursor ?? value.continueCursor, + ) return } + completePages.push(value.page) if (page.endCursor === undefined && !value.isDone) { - replace(index, page, value.continueCursor) + const needTail = + !queryOrder || + [...demands.values()].some( + (demand) => !demand.complete(completePages.flat(), false), + ) + // Sealing keeps the returned range stable without speculatively + // opening its unrelated successor. Future growth resumes from here. + replace(index, page, value.continueCursor, needTail) return } - completePages.push(value.page) } // Acquisition can add many ranges. Materialize rows only once every // range is available, rather than copying each incomplete prefix. const rows = completePages.flat() + const tail = pages.at(-1) + const exhausted = + tail?.endCursor === undefined && + tail?.subscription?.getCurrentValue()?.isDone === true + if ( + queryOrder + ? [...demands.values()].some( + (demand) => !demand.complete(rows, exhausted), + ) + : !exhausted + ) { + if (tail?.endCursor !== undefined) { + pages.push(createPage(tail.endCursor)) + schedule() + } + return + } error = undefined result = rows + publishedPages = new Set(pages) receive(rows) + for (const demand of demands.values()) + if (demand.pending) { + demand.pending = false + demand.resolve() + } for (const waiter of waiters) waiter.resolve() waiters.clear() } catch (cause) { @@ -161,13 +231,81 @@ export function subscribePages, TRow>( } } try { - pages.push(createPage(null)) + if (!queryOrder) pages.push(createPage(null)) schedule() } catch (cause) { for (const page of pages) page.subscription?.unsubscribe() throw cause } + const abortError = (): Error => + Object.assign(new Error(`Convex subset was canceled.`), { + name: `AbortError`, + }) return { + ...(queryOrder + ? { + loadSubset: (request: LoadSubsetOptions): true | Promise => { + if (!active || request.signal?.aborted) + return Promise.reject(abortError()) + const existing = demands.get(request) + if (existing?.pending) return existing.promise + existing?.cancel() + const complete = paginationDemand(request, queryOrder) + let resolve!: () => void + let rejectWaiter!: (error: unknown) => void + const pending = new Promise((yes, no) => { + resolve = yes + rejectWaiter = no + }) + const cancel = (): void => { + const demand = demands.get(request) + if (!demand) return + request.signal?.removeEventListener(`abort`, cancel) + demands.delete(request) + if (demand.pending) { + demand.pending = false + demand.reject(abortError()) + } + } + const demand = { + complete, + promise: pending, + resolve, + reject: rejectWaiter, + cancel, + pending: true, + } + demands.set(request, demand) + request.signal?.addEventListener(`abort`, cancel, { once: true }) + if (complete([], false)) { + demand.pending = false + resolve() + return pending + } + if (request.refetch) result = undefined + flush() + if ( + result !== undefined && + complete( + result, + pages.at(-1)?.endCursor === undefined && + pages.at(-1)?.subscription?.getCurrentValue()?.isDone === + true, + ) + ) { + demand.pending = false + resolve() + return pending + } + if (!pages.length) pages.push(createPage(null)) + schedule() + return pending + }, + unloadSubset: (request: LoadSubsetOptions): void => { + demands.get(request)?.cancel() + }, + } + : {}), getCurrentValue: () => { flush() return result @@ -175,13 +313,15 @@ export function subscribePages, TRow>( waitForCurrent: () => { flush() if (error !== undefined) return Promise.reject(error) - if (result !== undefined) return Promise.resolve() + if (result !== undefined || (queryOrder && demands.size === 0)) + return Promise.resolve() return new Promise((resolve, rejectWaiter) => { waiters.add({ resolve, reject: rejectWaiter }) }) }, unsubscribe: () => { active = false + for (const demand of [...demands.values()]) demand.cancel() for (const page of pages) page.subscription?.unsubscribe() const cause = new Error(`Convex pagination was cleaned up.`) cause.name = `AbortError` diff --git a/packages/convex-db-collection/src/subscription.ts b/packages/convex-db-collection/src/subscription.ts index 210dc06dab..ff50036ca4 100644 --- a/packages/convex-db-collection/src/subscription.ts +++ b/packages/convex-db-collection/src/subscription.ts @@ -1,3 +1,4 @@ +import type { LoadSubsetFn, UnloadSubsetFn } from '@tanstack/db' import type { ConvexClient } from 'convex/browser' import type { ConvexReactClient } from 'convex/react' import type { @@ -10,6 +11,8 @@ export type ConvexCollectionClient = Pick | Pick export interface ConvexSubscription { + loadSubset?: LoadSubsetFn + unloadSubset?: UnloadSubsetFn getCurrentValue: () => T | undefined unsubscribe: () => void /** Wait for a complete authoritative snapshot, without waiting for DB visibility. */ @@ -23,6 +26,11 @@ export function subscribeQuery>( receive: (result: FunctionReturnType) => void, fail: (error: unknown) => void, ): ConvexSubscription> { + let active = true + const stop = (unsubscribe: () => void) => () => { + active = false + unsubscribe() + } if (`disabled` in client && client.disabled === true) { throw new Error(`A disabled Convex client cannot sync a collection.`) } @@ -30,14 +38,24 @@ export function subscribeQuery>( if (`closed` in client && client.closed === true) { throw new Error(`A closed Convex client cannot sync a collection.`) } - const subscription = client.onUpdate(query, args, receive, fail) + const subscription = client.onUpdate( + query, + args, + (value) => { + if (active) receive(value) + }, + (error) => { + if (active) fail(error) + }, + ) return { getCurrentValue: () => subscription.getCurrentValue(), - unsubscribe: subscription, + unsubscribe: stop(subscription), } } const watch = client.watchQuery(query, args) const unsubscribe = watch.onUpdate(() => { + if (!active) return try { const result = watch.localQueryResult() if (result !== undefined) receive(result) @@ -45,5 +63,8 @@ export function subscribeQuery>( fail(error) } }) - return { getCurrentValue: () => watch.localQueryResult(), unsubscribe } + return { + getCurrentValue: () => watch.localQueryResult(), + unsubscribe: stop(unsubscribe), + } } diff --git a/packages/convex-db-collection/tests/lazy-paging-abort-replay-oracle.property.test.ts b/packages/convex-db-collection/tests/lazy-paging-abort-replay-oracle.property.test.ts new file mode 100644 index 0000000000..ad1de20732 --- /dev/null +++ b/packages/convex-db-collection/tests/lazy-paging-abort-replay-oracle.property.test.ts @@ -0,0 +1,236 @@ +/** + * Abort ends cancellable request work. Without accepted writes, the adapter's + * load must reject without a provider response. Old success/error callbacks + * cannot alter a later successful replay or resurrect its excluded rows. + * Authority: #2269 comment 6448490334, LoadSubsetOptions.signal, LoadSubsetFn, + * and publication laws 7/9. LazyVersionModel owns the public-history judgment; + * expectedLazyRows owns the promised window, never the pager's retained base. + * + * Contract reconciliation: this branch still waits for uncancelable baselines + * during core replay. This family supplies cancellable native work with zero + * accepted receipts. It therefore checks the common adapter obligation without + * changing that older core law or withdrawing already accepted writes. The + * receipt owners retain accepted-work and cleanup-abandonment coverage. + * + * Both controlled SDK shapes receive abort before any response, a withheld + * response delivered with no demand, fresh acquisition, then two real same-run + * core truncate replays with disjoint source keys. After each replay, retired + * transport callbacks deliver both success and error in generated order. Every + * graph callback is checked, and all stale errors are retained. The fixture + * exposes the real truncate control; Convex does not initiate that input itself. + * + * Grammar: 12–18 rows, page sizes 2–3, windows 2–4, short text, either stale + * callback order and either drain order. Removing no-demand delivery loses + * cancellation's write boundary; removing replay loses subsequent authority; + * disjoint keys distinguish resurrection from harmless repeated equal rows. + * Limits: controlled SDK caches, no real-provider emission claim, no arbitrary + * adapter ignoring abort, accepted-write cancellation or prospective #2168 + * immediate-core-publication law. + */ +import { createLiveQueryCollection } from '@tanstack/db' +import fc from 'fast-check' +import { expect, it } from 'vitest' +import { runOracleCampaign } from '../../db/tests/oracle-campaign' +import { withTestCleanup } from './helpers' +import { lazyFixture } from './lazy-paging-driver' +import { lazyOracleRuns, receiveBothSdks } from './lazy-paging-campaign' +import { expectedLazyRows } from './lazy-paging-model-oracle' +import { LazyVersionModel } from './lazy-paging-version-model-oracle' +import type { LazyRow } from './lazy-paging-model-oracle' + +const grammar = fc.record({ + size: fc.integer({ min: 12, max: 18 }), + pageSize: fc.integer({ min: 2, max: 3 }), + limit: fc.integer({ min: 2, max: 4 }), + text: fc.string({ maxLength: 10 }), + errorFirst: fc.boolean(), + reverse: fc.boolean(), +}) +type History = typeof grammar extends fc.Arbitrary ? T : never +const drain = async () => { + for (let turn = 0; turn < 60; turn++) await Promise.resolve() +} + +async function receive(h: History, sdk: `javascript` | `react`) { + const rows = (revision: number): Array => + Array.from({ length: h.size }, (_, rank) => ({ + id: `revision-${revision}-${rank}`, + rank, + text: `${h.text}:${revision}`, + })) + const f = lazyFixture( + rows(0), + h.pageSize, + false, + `none`, + false, + undefined, + sdk, + ) + const versions = new LazyVersionModel(`sequence`) + const failures: Array = [] + const reaches = new Set() + let outcome: string | undefined + const controller = new AbortController() + const canceled = f.load({ limit: h.limit }, controller.signal) + const cancellation = Promise.resolve(canceled.result).then( + () => { + outcome = `success` + }, + (error: unknown) => { + outcome = error instanceof Error ? error.name : `unknown` + }, + ) + let graph: ReturnType | undefined + let graphObserver: { unsubscribe: () => void } | undefined + let monitor = false + const read = () => + graph!.toArray.map(({ id, rank, text }) => ({ id, rank, text })) + function createGraph() { + return createLiveQueryCollection((q) => + q + .from({ row: f.source }) + .orderBy(({ row }) => row.rank, `asc`) + .limit(h.limit) + .select(({ row }) => ({ id: row.id, rank: row.rank, text: row.text })), + ) + } + const observe = () => { + if (!monitor) return + try { + versions.observe(read()) + } catch (error) { + failures.push(error) + } + } + const checkpoint = () => { + if (failures.length) throw failures[0] + expect(outcome, `aborted request outcome changed`).toBe(`AbortError`) + expect( + f.errors, + `obsolete error reached current sync: ${f.errors.map(String).join(`; `)}`, + ).toEqual([]) + expect(f.source.utils.getSyncError()).toBeUndefined() + expect(f.remote.obsoleteReads, `retired watch read its old cache`).toBe(0) + } + const scopeOwner = {} + await withTestCleanup( + async () => { + await drain() + expect(f.remote.slots.some((slot) => slot.active && !slot.current)).toBe( + true, + ) + const before = f.batches.length + controller.abort() + await drain() + expect(outcome, `abort waited for absent provider delivery`).toBe( + `AbortError`, + ) + expect(f.batches).toHaveLength(before) + await cancellation + reaches.add(`abort-before-response-without-acceptance`) + await f.remote.pump(() => false, h.reverse) + expect(f.source.base.size, `canceled load installed late rows`).toBe(0) + expect(f.batches).toHaveLength(before) + reaches.add(`late-response-with-no-demand`) + // Explicit window retry may lawfully request an authoritative full-source + // repair. This receiver judges cancellation/publication, not bounded work. + f.remote.scopes.set(scopeOwner, {}) + graph = createGraph() + graphObserver = graph.subscribeChanges(observe, { + includeInitialState: false, + }) + const preload = graph.preload() + await f.remote.pump(() => false, h.reverse) + await preload + versions.offer(expectedLazyRows(rows(0), { limit: h.limit })) + monitor = true + observe() + checkpoint() + for (const revision of [1, 2]) { + versions.offer(expectedLazyRows(rows(revision), { limit: h.limit })) + f.remote.change(rows(revision)) + // SDK caches advance coherently before callbacks. Replay itself must + // re-read them; the driver does not install any Collection rows. + f.remote.installCaches() + await f.truncateSource() + const replay = Promise.resolve( + graph.utils.setWindow({ limit: h.limit }), + ) + void replay.catch(() => undefined) + await f.remote.pump(() => false, h.reverse, checkpoint) + await replay + expect(graph.status).toBe(`ready`) + expect(read(), `successful replay window`).toEqual( + expectedLazyRows(rows(revision), { limit: h.limit }), + ) + expect( + [...f.source.base.keys()].every((key) => + String(key).startsWith(`revision-${revision}-`), + ), + ).toBe(true) + expect( + f.remote.slots.filter((slot) => !slot.active).length, + ).toBeGreaterThan(0) + for (const late of h.errorFirst + ? ([`error`, `success`] as const) + : ([`success`, `error`] as const)) { + f.remote.late(late) + await drain() + observe() + checkpoint() + expect(graph.status).toBe(`ready`) + expect(read(), `late ${late} changed replay`).toEqual( + expectedLazyRows(rows(revision), { limit: h.limit }), + ) + reaches.add(`late-${late}-after-replay`) + } + reaches.add(`disjoint-replay-${revision}`) + } + }, + () => { + controller.abort() + canceled.release() + }, + () => cancellation, + () => { + monitor = false + graphObserver?.unsubscribe() + }, + () => graph?.cleanup(), + () => f.source.cleanup(), + () => f.stopObserving(), + ) + return reaches +} +const states = [ + `abort-before-response-without-acceptance`, + `late-response-with-no-demand`, + `late-success-after-replay`, + `late-error-after-replay`, + `disjoint-replay-1`, + `disjoint-replay-2`, + `javascript`, + `react`, +] as const +it(`fences aborted work from later successful same-run replay`, async () => { + await runOracleCampaign( + `CONVEX_ABORT_REPLAY_ORACLE`, + states, + (reach, parameters) => + fc.assert( + fc.asyncProperty(grammar, async (h) => { + reach.beginHistory() + for (const [sdk, observed] of await receiveBothSdks((kind) => + receive(h, kind), + )) { + for (const state of states) + if (observed.has(state)) reach.reach(state) + reach.reach(sdk) + } + }), + { numRuns: lazyOracleRuns(30), ...parameters }, + ), + { fixedSeed: 20261030 }, + ) +}) diff --git a/packages/convex-db-collection/tests/lazy-paging-campaign.ts b/packages/convex-db-collection/tests/lazy-paging-campaign.ts new file mode 100644 index 0000000000..6620ca185d --- /dev/null +++ b/packages/convex-db-collection/tests/lazy-paging-campaign.ts @@ -0,0 +1,44 @@ +/** Runner support only: both fixed/random campaigns receive the same budget. + * Explicit seed/path replay still overrides it to the selected history. */ +export function lazyOracleRuns(normal: number): number { + const value = process.env.CONVEX_LAZY_ORACLE_RUNS_MULTIPLIER ?? `1` + const multiplier = Number(value) + if ( + !Number.isSafeInteger(multiplier) || + multiplier < 1 || + value.trim() === `` + ) + throw new Error( + `CONVEX_LAZY_ORACLE_RUNS_MULTIPLIER must be a positive integer`, + ) + return normal * multiplier +} + +/** Execute both receiving paths even after one fails, preserving nested law + * failures in the runner's top-level message as well as their original causes. */ +export async function receiveBothSdks( + receive: (kind: `javascript` | `react`) => Promise, +): Promise> { + const results: Array = [] + const errors: Array = [] + const describe = (value: unknown): string => + value instanceof AggregateError + ? `${value.message}\n${value.errors.map(describe).join(`\n`)}` + : value instanceof Error + ? (value.stack ?? value.message) + : String(value) + for (const kind of [`javascript`, `react`] as const) { + try { + results.push([kind, await receive(kind)]) + } catch (error) { + errors.push( + new Error(`${kind} receiver failed: ${describe(error)}`, { + cause: error, + }), + ) + } + } + if (errors.length) + throw new AggregateError(errors, errors.map(String).join(`\n`)) + return results +} diff --git a/packages/convex-db-collection/tests/lazy-paging-driver.ts b/packages/convex-db-collection/tests/lazy-paging-driver.ts new file mode 100644 index 0000000000..e2a3d817a4 --- /dev/null +++ b/packages/convex-db-collection/tests/lazy-paging-driver.ts @@ -0,0 +1,516 @@ +import { BTreeIndex, IR, createCollection } from '@tanstack/db' +import { makeFunctionReference } from 'convex/server' +import { convexPaginatedCollectionOptions } from '../src/index' +import { deliveredScopeComplete, ordered } from './lazy-paging-model-oracle' +import type { LoadSubsetOptions, SyncAppliedReceipt } from '@tanstack/db' +import type { PaginationOptions, PaginationResult } from 'convex/server' +import type { ConvexCollectionClient } from '../src/index' +import type { LazyRow, LazyScope } from './lazy-paging-model-oracle' + +export const lazyQuery = makeFunctionReference< + `query`, + { paginationOpts: PaginationOptions }, + PaginationResult +>(`rows:lazy`) +export const lazyOrder = ( + descending = false, +): NonNullable => [ + { + expression: new IR.PropRef([`rank`]), + compareOptions: { direction: descending ? `desc` : `asc`, nulls: `first` }, + }, +] +export function lazyRequest( + scope: LazyScope, + descending = false, +): LoadSubsetOptions { + const field = new IR.PropRef([`rank`]) + return { + orderBy: lazyOrder(descending), + offset: scope.offset, + limit: scope.limit, + ...(scope.equal === undefined + ? {} + : { where: new IR.Func(`eq`, [field, new IR.Value(scope.equal)]) }), + ...(scope.after === undefined + ? {} + : { + cursor: { + whereFrom: new IR.Func(descending ? `lt` : `gt`, [ + field, + new IR.Value(scope.after), + ]), + whereCurrent: new IR.Func(`eq`, [field, new IR.Value(scope.after)]), + }, + }), + } +} + +type Slot = { + opts: PaginationOptions + callback: (rows: PaginationResult) => void + fail?: (error: unknown) => void + current?: PaginationResult + active: boolean + revision: number +} +/** Only transport is controlled. Opaque tokens denote authored (rank,id) + * intervals; rows never come from the production Collection or its cursors. + * Each transition installs all existing caches before any callback dispatch, + * matching the SDK premise. New subscriptions remain independently pending. */ +export function lazyProvider( + initial: Array, + descending = false, + split: `none` | `required` | `recommended` = `none`, + short = false, + kind: `javascript` | `react` = `javascript`, +) { + let source = initial.map((row) => ({ ...row })) + let revision = 0 + let online = true + const slots: Array = [] + const cursors = new Map() + const scopes = new Map() + const delivered = new Map() + const knownFacts = new Map() + let exhausted = false + let successorStarts = 0 + let obsoleteReads = 0 + const compare = (a: LazyRow, b: LazyRow) => + descending + ? b.rank - a.rank || b.id.localeCompare(a.id) + : a.rank - b.rank || a.id.localeCompare(b.id) + const token = (row: LazyRow) => { + const value = `opaque:${row.rank}:${row.id}` + cursors.set(value, { ...row }) + return value + } + const client = { + onUpdate: ( + _query: unknown, + args: { paginationOpts: PaginationOptions }, + callback: Slot[`callback`], + fail: Slot[`fail`], + ) => { + const opts = { ...args.paginationOpts } + if ( + opts.cursor === null && + opts.endCursor == null && + !slots.some((slot) => slot.active) + ) { + delivered.clear() + knownFacts.clear() + exhausted = false + } + // Bounded reacquisition and split children have their own permission. A + // previously requested identical range is not new successor work. + if ( + opts.cursor !== null && + opts.endCursor == null && + !slots.some( + (slot) => JSON.stringify(slot.opts) === JSON.stringify(opts), + ) + ) { + if ( + [...scopes.values()].every((scope) => + deliveredScopeComplete( + [...delivered.values()], + exhausted, + scope, + descending, + ), + ) + ) + throw new Error(`lazy work: unrelated successor ${opts.cursor}`) + successorStarts++ + } + const slot: Slot = { opts, callback, fail, active: true, revision: -1 } + slots.push(slot) + return Object.assign( + () => { + slot.active = false + }, + { getCurrentValue: () => slot.current }, + ) + }, + } as ConvexCollectionClient + // Keep retired watch callbacks callable. A retired error changes their old + // cache to a throwing read; the React subscription must fence before reading. + const reactClient = { + watchQuery: ( + query: unknown, + args: { paginationOpts: PaginationOptions }, + ) => { + let listener: (() => void) | undefined + let readError: unknown + const subscription = ( + `onUpdate` in client ? client.onUpdate : undefined + )!( + query as typeof lazyQuery, + args, + () => listener?.(), + (error: unknown) => { + readError = error + listener?.() + }, + ) + const slot = slots.at(-1)! + return { + journal: () => undefined, + onUpdate: (callback: () => void) => { + listener = callback + return subscription + }, + localQueryResult: () => { + if (!slot.active) obsoleteReads++ + if (readError) throw readError + return subscription.getCurrentValue() + }, + } + }, + } as ConvexCollectionClient + function result(slot: Slot): PaginationResult { + const from = + slot.opts.cursor === null ? undefined : cursors.get(slot.opts.cursor) + const to = + slot.opts.endCursor == null ? undefined : cursors.get(slot.opts.endCursor) + if (slot.opts.cursor !== null && !from) + throw new Error(`unknown source cursor`) + const interval = ordered(source, descending).filter( + (row) => + (!from || compare(row, from) > 0) && (!to || compare(row, to) <= 0), + ) + const page = to + ? interval + : interval.slice( + 0, + short ? Math.max(1, slot.opts.numItems - 1) : slot.opts.numItems, + ) + const boundary = + slot.opts.endCursor ?? + (page.length ? token(page.at(-1)!) : (slot.opts.cursor ?? `empty`)) + const splitting = split !== `none` && page.length > 2 + return { + page: splitting && split === `required` ? [] : page, + continueCursor: boundary, + isDone: !to && page.length === interval.length, + ...(splitting + ? { + pageStatus: + split === `required` + ? (`SplitRequired` as const) + : (`SplitRecommended` as const), + splitCursor: token(page[Math.floor((page.length - 1) / 2)]!), + } + : {}), + } + } + return { + client: kind === `react` ? reactClient : client, + slots, + get obsoleteReads() { + return obsoleteReads + }, + scopes, + get source() { + return source + }, + get successorStarts() { + return successorStarts + }, + get deliveredFacts() { + return [...knownFacts.values()] + }, + change(rows: Array) { + source = rows.map((row) => ({ ...row })) + revision++ + delivered.clear() + exhausted = false + }, + disconnect() { + online = false + }, + reconnect() { + online = true + }, + // Install a coherent SDK cache revision while its callbacks remain queued. + installCaches() { + for (const slot of slots.filter((entry) => entry.active)) { + slot.current = result(slot) + if (slot.current.pageStatus !== `SplitRequired`) + for (const row of slot.current.page) { + delivered.set(row.id, row) + knownFacts.set(JSON.stringify(row), { ...row }) + } + if (slot.opts.endCursor == null && slot.current.isDone) exhausted = true + } + }, + error(error: unknown) { + slots.find((slot) => slot.active)?.fail?.(error) + }, + /** One selected callback, with every existing cache installed first. The + * choice is an environment schedule, not a pager transition. */ + async deliver(choice = 0) { + if (!online) return false + this.installCaches() + const pending = slots.filter( + (slot) => slot.active && slot.revision !== revision, + ) + const slot = pending[choice % pending.length] + if (!slot) return false + slot.revision = revision + slot.callback(slot.current!) + for (let i = 0; i < 10; i++) await Promise.resolve() + return true + }, + async pump( + hold: (slot: Slot) => boolean = () => false, + reverse = false, + checkpoint?: () => void, + ) { + // This is a finite fixture guard, not a semantic progress or call cap. + for (let turn = 0; turn < 200; turn++) { + await Promise.resolve() + await Promise.resolve() + const pending = slots.filter( + (slot) => + online && slot.active && slot.revision !== revision && !hold(slot), + ) + if (!pending.length) { + for (let i = 0; i < 10; i++) await Promise.resolve() + checkpoint?.() + if ( + !slots.some( + (slot) => + online && + slot.active && + slot.revision !== revision && + !hold(slot), + ) + ) + return + continue + } + if (reverse) pending.reverse() + for (const slot of pending) { + slot.current = result(slot) + slot.revision = revision + } + // Knowledge for work is only actual delivered complete results. + for (const slot of pending) { + if (slot.current!.pageStatus !== `SplitRequired`) + for (const row of slot.current!.page) { + delivered.set(row.id, row) + knownFacts.set(JSON.stringify(row), { ...row }) + } + if (slot.opts.endCursor == null && slot.current!.isDone) + exhausted = true + } + for (const slot of pending) slot.callback(slot.current!) + await Promise.resolve() + checkpoint?.() + } + throw new Error(`lazy fixture nonquiescence (diagnostic guard)`) + }, + late(outcome: `success` | `error` = `success`) { + for (const slot of slots.filter((entry) => !entry.active)) + if (outcome === `error`) + slot.fail?.(new Error(`obsolete provider error`)) + else + slot.callback({ + page: [{ id: `obsolete`, rank: -100, text: `stale` }], + isDone: true, + continueCursor: `stale`, + }) + }, + } +} + +/** Test-driver observation of the real sync boundary. No pager state enters + * the record: writes, replacement, run identity and actual application receipt. + * Listeners run when commit returns. Causal receipt receivers use nonreentrant + * commits; this record does not impose FIFO listener order on nested commits. */ +export type LazyBatch = { + id: string + run: number + rows: Array + deletes: Array + replacement: boolean + receipt: SyncAppliedReceipt +} + +export function lazyFixture( + rows: Array, + pageSize: number, + descending = false, + split: `none` | `required` | `recommended` = `none`, + short = false, + holdCleanup?: () => Promise, + kind: `javascript` | `react` = `javascript`, + adapterReceipt?: (actual: SyncAppliedReceipt) => SyncAppliedReceipt, +) { + const remote = lazyProvider(rows, descending, split, short, kind) + const mode = { + syncMode: `on-demand`, + queryOrder: { field: `rank`, direction: descending ? `desc` : `asc` }, + } as const + const errors: Array = [] + const statuses: Array = [] + const options = convexPaginatedCollectionOptions({ + client: remote.client, + query: lazyQuery, + queryArgs: {}, + pageSize, + getKey: (row) => row.id, + autoIndex: `eager`, + defaultIndexType: BTreeIndex, + gcTime: 0, + onError: (error) => errors.push(error), + ...mode, + }) + const receipts: Array = [] + const batches: Array = [] + let nextBatch = 0 + const batchListeners = new Set<(batch: LazyBatch) => void>() + let syncRun = 0 + let truncateSource!: () => SyncAppliedReceipt + const sync = options.sync.sync + options.sync = { + ...options.sync, + sync: (controls) => { + const run = ++syncRun + // Environment input for the core replay receiver. Native Convex does not + // initiate same-run truncates itself; this exposes the real core boundary. + truncateSource = () => { + controls.begin() + controls.truncate() + return controls.commit() + } + let pendingRows: Array = [] + let deletes: Array = [] + let replacement = false + const running = sync({ + ...controls, + begin: (...args) => { + pendingRows = [] + deletes = [] + replacement = false + controls.begin(...args) + }, + write: (message) => { + if (message.type === `delete`) + deletes.push( + String(`key` in message ? message.key : message.value.id), + ) + else pendingRows.push({ ...message.value }) + controls.write(message) + }, + truncate: () => { + replacement = true + controls.truncate() + }, + commit: (signal) => { + const payload = { + id: `receipt-${nextBatch++}`, + run, + rows: pendingRows, + deletes, + replacement, + } + const receipt = controls.commit(signal) + receipts.push(receipt) + const batch = { + ...payload, + receipt, + } + batches.push(batch) + for (const listener of batchListeners) listener(batch) + // Hostile receiver controls may lie to the adapter while the observer + // retains the actual core receipt and its true application boundary. + return adapterReceipt ? adapterReceipt(receipt) : receipt + }, + }) + if (!holdCleanup) return running + return { + ...(typeof running === `object` ? running : {}), + cleanup: async () => { + if (typeof running === `function`) await running() + else await running?.cleanup?.() + await holdCleanup() + }, + } + }, + } + const source = createCollection(options) + const publications: Array<{ + rows: Array + evidence: Array + }> = [] + const observer = source.subscribeChanges( + () => + publications.push({ + rows: [...source.base.values()].map((row) => ({ ...row })), + evidence: remote.deliveredFacts, + }), + { includeInitialState: false }, + ) + source.on(`status:change`, ({ status }) => statuses.push(status)) + source.startSyncImmediate() + return { + remote, + source, + receipts, + batches, + truncateSource: () => truncateSource(), + observeBatches: (listener: (batch: LazyBatch) => void) => { + batchListeners.add(listener) + for (const batch of batches) listener(batch) + return () => { + batchListeners.delete(listener) + } + }, + errors, + statuses, + publications, + stopObserving: () => observer.unsubscribe(), + load(scope: LazyScope, signal?: AbortSignal, refetch = false) { + const request = { ...lazyRequest(scope, descending), signal, refetch } + remote.scopes.set(request, scope) + // Abort revokes new-work permission without withdrawing accepted writes. + if (signal?.aborted) remote.scopes.delete(request) + else + signal?.addEventListener(`abort`, () => remote.scopes.delete(request), { + once: true, + }) + const result = source._sync.loadSubset(request) + let settled = result === true + if (result !== true) + void result.then( + () => { + settled = true + }, + () => { + settled = true + }, + ) + return { + // A responsive provider that has become quiescent must not strand a + // request. Call only after draining it and releasing any receipt hold. + complete: async () => { + for (let i = 0; i < 10; i++) await Promise.resolve() + if (!settled) + throw new Error( + `lazy progress: quiescent responsive provider left load pending`, + ) + return result + }, + request, + result, + release: () => { + remote.scopes.delete(request) + source._sync.unloadSubset(request) + }, + } + }, + } +} 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 new file mode 100644 index 0000000000..53c9e0f413 --- /dev/null +++ b/packages/convex-db-collection/tests/lazy-paging-failure-history-oracle.property.test.ts @@ -0,0 +1,443 @@ +/** + * Native failure histories compose ownership, application, and sync-run + * authority. Authority: LoadSubsetOptions.signal, SyncAppliedReceipt and + * CleanupFn in db/src/types.ts; live/ARCHITECTURE.md laws 6, 7 and 14. Releasing + * one owner cannot cancel its peer's accepted writes. A canceled caller waits + * for its already accepted writes, unless cleanup abandons them. Cleanup puts + * every old dependent graph in terminal error; restart needs a fresh graph. + * Ordinary provider errors may reject acquisitions and valid later snapshots + * may recover the native source. They are not authoritative truncate replay. + * + * The scientific receipt model is LazyReceiptModel. Authored row/version facts + * identify a uniquely necessary batch for two deep callers. The small terminal + * monitor below keeps only the last complete graph value and cleanup authority; + * transport ranges, pager queues, cursor encodings and sealing are driver facts. + * Three episodes compose pre-acceptance failure, shared held acceptance with + * cancellation and a surviving owner, then held cleanup and restart. Generated + * rows, order, transport, retiring owner, delivery order, and stale-callback + * order reconstruct adjacent schedules. Removing either failure cut loses the + * distinction between unaccepted work and an irrevocable accepted batch. + * Limits: finite fixed-query source, one coherent accepted native batch per + * deep acquisition, no same-run authoritative truncate, and no claim that + * ordinary rejection must await application. Both SDK shapes are fixtures; + * real SDK/backend observations have separate receiving owners. + */ +import { createLiveQueryCollection, createTransaction } from '@tanstack/db' +import fc from 'fast-check' +import { expect, it } from 'vitest' +import { runOracleCampaign } from '../../db/tests/oracle-campaign' +import { withTestCleanup } from './helpers' +import { lazyFixture } from './lazy-paging-driver' +import { + LazyTerminalGraphModel, + assertLazyCoverage, + copyLazyRows, + expectedLazyRows, + ordered, +} from './lazy-paging-model-oracle' +import { LazyReceiptModel } from './lazy-paging-receipt-model' +import type { LazyRow } from './lazy-paging-model-oracle' + +const drain = async () => { + for (let i = 0; i < 30; i++) await Promise.resolve() +} +const gate = () => { + let resolve!: () => void + const promise = new Promise((done) => { + resolve = done + }) + return { promise, resolve } +} +const fact = (row: LazyRow) => JSON.stringify(row) + +const grammar = fc.record({ + size: fc.integer({ min: 7, max: 13 }), + pageSize: fc.integer({ min: 1, max: 3 }), + descending: fc.boolean(), + react: fc.boolean(), + reverse: fc.boolean(), + retiring: fc.integer({ min: 0, max: 1 }), + staleErrorFirst: fc.boolean(), + text: fc.string({ maxLength: 8 }), +}) + +type History = typeof grammar extends fc.Arbitrary ? T : never +async function runFailureHistory(h: History) { + const rows = Array.from({ length: h.size }, (_, rank) => ({ + id: `row-${rank}`, + rank, + text: h.text, + })) + let cleanupHold: ReturnType | undefined + const f = lazyFixture( + rows, + h.pageSize, + h.descending, + `none`, + false, + () => cleanupHold?.promise ?? Promise.resolve(), + h.react ? `react` : `javascript`, + ) + const releases: Array<() => void> = [] + const stopGraphObservers: Array<() => void> = [] + const graphCleanups: Array<() => Promise> = [] + const receiptFailures: Array = [] + const reaches = new Set() + let releaseApplication = async () => {} + let stopBatches = () => {} + const load = (limit: number, signal?: AbortSignal) => { + const result = f.load({ limit }, signal) + releases.push(result.release) + void Promise.resolve(result.result).catch(() => {}) + return result + } + const hold = () => { + const pending = gate() + const transaction = createTransaction({ mutationFn: () => pending.promise }) + transaction.mutate(() => + f.source.insert({ id: `optimistic-only`, rank: 10000, text: `hold` }), + ) + releaseApplication = async () => { + pending.resolve() + await transaction.isPersisted.promise + await drain() + } + } + const dependent = (limit: number) => { + const graph = createLiveQueryCollection((q) => + q + .from({ row: f.source }) + .orderBy(({ row }) => row.rank, h.descending ? `desc` : `asc`) + .orderBy(({ row }) => row.id, h.descending ? `desc` : `asc`) + .select(({ row }) => row) + .limit(limit), + ) + graphCleanups.push(() => graph.cleanup()) + return graph + } + const terminalMonitor = (graph: ReturnType) => { + const model = new LazyTerminalGraphModel() + const observe = () => { + try { + model.observe(graph.status, graph.toArray) + } catch (error) { + // Do not throw through a production subscriber. Retain every failure + // until the next explicit comparison cut, including transient events. + receiptFailures.push(error) + } + } + const changes = graph.subscribeChanges(observe, { + includeInitialState: false, + }) + stopGraphObservers.push(() => changes.unsubscribe()) + stopGraphObservers.push(graph.on(`status:change`, observe)) + return model + } + const preload = (graph: ReturnType) => { + const promise = graph.preload() + let settled = false + void promise.then( + () => { + settled = true + }, + () => { + settled = true + }, + ) + return async () => { + await drain() + if (!settled) + throw new Error(`native progress: quiescent preload waiter stranded`) + await promise + } + } + await withTestCleanup( + async () => { + // Before source acceptance, two owners can fail without any provider + // rows entering the applied base. This episode ends before recovery. + const failed = [load(h.size), load(h.size)] + const failure = new Error(`before acceptance`) + f.remote.error(failure) + await drain() + for (const attempt of failed) + await expect(attempt.complete()).rejects.toBe(failure) + expect(f.source.base.size).toBe(0) + failed.forEach((attempt) => attempt.release()) + reaches.add(`failure-before-acceptance`) + await f.source.cleanup() + f.source.startSyncImmediate() + + const shallow = load(1) + await f.remote.pump(() => false, h.reverse) + await shallow.complete() + const graph = dependent(1) + const permission = {} + f.remote.scopes.set(permission, { limit: 1 }) + const graphReady = preload(graph) + await f.remote.pump(() => false, h.reverse) + await drain() + expect(graph.status, `native progress: initial dependent stranded`).toBe( + `ready`, + ) + await graphReady() + + // One previously absent required fact is a unique causal witness. The + // same accepted native batch establishes both callers; no page/commit + // bijection or global-latest rule enters that attribution. + const model = new LazyReceiptModel() + let acquiring = false + const required = ordered(rows, h.descending).at(-1)! + expect(f.source.base.has(required.id)).toBe(false) + const ids = [`owner-0`, `owner-1`] + const controllers = [new AbortController(), new AbortController()] + const outcomes = new Map() + const accepted: Array = [] + stopBatches = f.observeBatches((batch) => { + try { + const establishes = + acquiring && batch.rows.some((row) => fact(row) === fact(required)) + ? ids + : [] + model.accept(batch.id, batch.rows.map(fact), establishes) + if (establishes.length) accepted.push(batch.id) + const apply = () => model.apply(batch.id) + if (batch.receipt === true) apply() + else void batch.receipt.then(apply, () => {}) + } catch (error) { + receiptFailures.push(error) + } + }) + ids.forEach((id) => model.acquire(id, rows.map(fact))) + hold() + acquiring = true + const owners = controllers.map((controller, index) => { + const attempt = load(h.size, controller.signal) + void Promise.resolve(attempt.result).then( + () => { + outcomes.set(ids[index]!, `success`) + try { + model.observe(ids[index]!, `success`) + } catch (error) { + receiptFailures.push(error) + } + }, + (error: unknown) => { + const outcome = + error instanceof Error && error.name === `AbortError` + ? `canceled` + : `rejected` + outcomes.set(ids[index]!, outcome) + try { + model.observe(ids[index]!, outcome) + } catch (modelFailure) { + receiptFailures.push(modelFailure) + } + }, + ) + return attempt + }) + await f.remote.pump(() => false, h.reverse) + acquiring = false + expect( + accepted.length, + `native batch lacks unique establishing fact`, + ).toBe(1) + expect(outcomes.size, `native load settled before held application`).toBe( + 0, + ) + reaches.add(`two-owners-share-held-native-receipt`) + const canceled = h.retiring + model.abort(ids[canceled]!) + controllers[canceled]!.abort() + owners[canceled]!.release() + const providerError = new Error(`after acceptance`) + f.remote.error(providerError) + await drain() + expect( + f.errors, + `native provider failure path was not observed`, + ).toContain(providerError) + expect( + outcomes.has(ids[canceled]!), + `native canceled owner settled before accepted application`, + ).toBe(false) + expect(f.source.base.has(required.id)).toBe(false) + reaches.add(`error-after-acceptance-with-surviving-owner`) + await releaseApplication() + await drain() + expect( + outcomes.size, + `native progress: callers stranded after accepted application`, + ).toBe(2) + // A subsequent valid coherent cache observation can recover the native + // source before the surviving caller settles. Its ordinary error is not + // terminal: either rejection or successful applied coverage is legal. + await Promise.allSettled(owners.map((owner) => owner.result)) + await expect(owners[canceled]!.result).rejects.toMatchObject({ + name: `AbortError`, + }) + assertLazyCoverage([...f.source.base.values()], rows, {}, h.descending) + expect(outcomes.get(ids[canceled]!)).toBe(`canceled`) + if (receiptFailures.length) throw receiptFailures[0] + model.complete() + stopBatches() + reaches.add(`accepted-writes-survive-peer-retirement-and-error`) + + // Cleanup terminates the still-owned graph. Its provider failure did not + // itself grant terminal authority. A new run can serve a fresh graph. + const terminal = terminalMonitor(graph) + terminal.cleanup(graph.toArray) + const cleaning = f.source.cleanup() + terminal.observe(graph.status, graph.toArray) + await cleaning + f.remote.scopes.set(permission, {}) + f.source.startSyncImmediate() + const recovered = load(1) + await f.remote.pump(() => false, h.reverse) + await recovered.complete() + const fresh = dependent(2) + const freshReady = preload(fresh) + await f.remote.pump(() => false, h.reverse) + await drain() + expect(fresh.status, `native progress: fresh dependent stranded`).toBe( + `ready`, + ) + await freshReady() + terminal.observe(graph.status, graph.toArray) + expect(copyLazyRows(fresh.toArray)).toEqual( + expectedLazyRows(rows, { limit: 2 }, h.descending), + ) + + // A subsequent native update is accepted but held. Cleanup may abandon + // it; neither its old receipt nor retired callbacks may revive graphs. + hold() + const changed = rows.map((row) => ({ ...row, text: `${h.text}:next` })) + f.remote.change(changed) + const before = f.receipts.length + await f.remote.pump(() => false, h.reverse) + expect(f.receipts.slice(before).some((receipt) => receipt !== true)).toBe( + true, + ) + const freshTerminal = terminalMonitor(fresh) + freshTerminal.cleanup(fresh.toArray) + cleanupHold = gate() + const heldCleaning = f.source.cleanup() + reaches.add(`held-cleanup-after-failure-recovery`) + const staleCheckpoint = async () => { + const baseline = { + base: copyLazyRows([...f.source.base.values()]), + status: f.source.status, + statuses: [...f.statuses], + errors: [...f.errors], + syncError: f.source.utils.getSyncError(), + publications: [...f.publications], + } + for (const outcome of h.staleErrorFirst + ? ([`error`, `success`] as const) + : ([`success`, `error`] as const)) + f.remote.late(outcome) + await drain() + expect( + { + base: copyLazyRows([...f.source.base.values()]), + status: f.source.status, + statuses: [...f.statuses], + errors: [...f.errors], + syncError: f.source.utils.getSyncError(), + publications: [...f.publications], + }, + `native lifecycle: retired callback retained authority`, + ).toEqual(baseline) + terminal.observe(graph.status, graph.toArray) + freshTerminal.observe(fresh.status, fresh.toArray) + if (receiptFailures.length) throw receiptFailures[0] + } + await staleCheckpoint() + expect(f.source.status).not.toBe(`cleaned-up`) + await releaseApplication() + await staleCheckpoint() + cleanupHold.resolve() + await heldCleaning + f.source.startSyncImmediate() + const restarted = load(2) + await f.remote.pump(() => false, h.reverse) + await restarted.complete() + await staleCheckpoint() + const newest = dependent(3) + const newestReady = preload(newest) + await f.remote.pump(() => false, h.reverse) + await drain() + expect( + newest.status, + `native progress: restarted dependent stranded`, + ).toBe(`ready`) + await newestReady() + expect(copyLazyRows(newest.toArray)).toEqual( + expectedLazyRows(changed, { limit: 3 }, h.descending), + ) + expect(newest.status).toBe(`ready`) + terminal.observe(graph.status, graph.toArray) + freshTerminal.observe(fresh.status, fresh.toArray) + if (receiptFailures.length) throw receiptFailures[0] + reaches.add(`fresh-graph-after-held-cleanup-old-graphs-terminal`) + f.remote.scopes.delete(permission) + }, + () => { + stopBatches() + stopGraphObservers.forEach((stop) => stop()) + cleanupHold?.resolve() + releases.forEach((release) => release()) + }, + () => releaseApplication(), + async () => { + for (const cleanup of graphCleanups) await cleanup() + }, + () => f.source.cleanup(), + ) + return reaches +} + +it(`composes native failures, shared holds, retirement and terminal restart`, async () => { + await runOracleCampaign( + `CONVEX_LAZY_FAILURE_HISTORY_ORACLE`, + [ + `failure-before-acceptance`, + `two-owners-share-held-native-receipt`, + `error-after-acceptance-with-surviving-owner`, + `accepted-writes-survive-peer-retirement-and-error`, + `held-cleanup-after-failure-recovery`, + `fresh-graph-after-held-cleanup-old-graphs-terminal`, + ] as const, + (reach, parameters) => + fc.assert( + fc.asyncProperty(grammar, async (h) => { + reach.beginHistory() + const states = await runFailureHistory(h) + for (const state of [ + `failure-before-acceptance`, + `two-owners-share-held-native-receipt`, + `error-after-acceptance-with-surviving-owner`, + `accepted-writes-survive-peer-retirement-and-error`, + `held-cleanup-after-failure-recovery`, + `fresh-graph-after-held-cleanup-old-graphs-terminal`, + ] as const) { + expect(states.has(state)).toBe(true) + reach.reach(state) + } + }), + { numRuns: 40, ...parameters }, + ), + { fixedSeed: 20261027 }, + ) +}, 60000) + +it(`rejects old graphs recovering or changing after cleanup`, () => { + const row = { id: `one`, rank: 1, text: `authored` } + const model = new LazyTerminalGraphModel() + model.cleanup([row]) + model.observe(`error`, [row]) + expect(() => model.observe(`ready`, [row])).toThrow(`old graph recovered`) + expect(() => model.observe(`error`, [{ ...row, text: `new run` }])).toThrow( + `dead graph published`, + ) +}) 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 new file mode 100644 index 0000000000..20104f02c4 --- /dev/null +++ b/packages/convex-db-collection/tests/lazy-paging-history-oracle.property.test.ts @@ -0,0 +1,471 @@ +/** + * The scientific law is in lazy-paging-model-oracle.ts: exact scoped coverage, + * evidence-authorized successor work and applied source provenance. The same + * source can support several logical owners. Retirement revokes future work + * permission, not permission for an already initiated response to arrive. + * Captured callbacks from a retired transport cannot affect its replacement. + * Authority: LoadSubsetFn/UnloadSubsetFn and cleanup in db/src/types.ts, and + * the approved fixed-query profile in docs/collections/convex-collection.md. + * + * The grammar authors acquire/retire/abort and environment delivery choices; + * it has no page-chain or sealing state. lazy-paging-driver.ts alone translates + * opaque provider intervals. A constructive ownership prefix is followed by + * arbitrary legal action orders, including callbacks from retired transports. + * Comparisons run after every action; success is also checked after an honest + * responsive drain. No future source suffix grants work permission. Scope + * profiles include unique, leading-three and interior ties. Removing retirement + * loses initiation-time versus current permission; removing stale errors loses + * authority effects invisible to a stale-row sentinel. + */ +import fc from 'fast-check' +import { createTransaction } from '@tanstack/db' +import { expect, it } from 'vitest' +import { runOracleCampaign } from '../../db/tests/oracle-campaign' +import { withTestCleanup } from './helpers' +import { lazyFixture } from './lazy-paging-driver' +import { + assertLazyCoverage, + assertLazySourceFacts, + expectedLazyRows, + ordered, +} from './lazy-paging-model-oracle' +import type { LazyRow } from './lazy-paging-model-oracle' + +const commands = fc.array( + fc.record({ + action: fc.constantFrom( + `acquire`, + `retire`, + `abort`, + `deliver`, + `drain`, + `stale-success`, + `stale-error`, + `hold-application`, + `release-application`, + `source-change`, + `cleanup`, + `restart`, + ), + choice: fc.nat({ max: 15 }), + }), + { minLength: 1, maxLength: 32 }, +) +const history = fc.record({ + size: fc.integer({ min: 16, max: 24 }), + pageSize: fc.integer({ min: 2, max: 5 }), + descending: fc.boolean(), + react: fc.boolean(), + text: fc.string({ maxLength: 8 }), + commands, +}) + +it(`generates ownership and callback schedules against one independent scope law`, async () => { + await runOracleCampaign( + `CONVEX_LAZY_HISTORY_ORACLE`, + [ + `javascript-transport`, + `react-transport`, + `held-receipt-cleanup-restart`, + `source-change-after-restart`, + `aborted-authorizer-pending`, + `retired-authorizer-pending`, + `prior-delivery-after-retirement`, + `retired-success-fenced`, + `retired-error-fenced`, + `generated-action-suffix`, + `applied-source-provenance`, + ] as const, + (reach, parameters) => + fc.assert( + fc.asyncProperty(history, async (h) => { + reach.beginHistory() + const rows: Array = Array.from( + { length: h.size }, + (_, rank) => ({ + id: `r${rank}`, + rank, + text: h.text, + }), + ) + const f = lazyFixture( + rows, + h.pageSize, + h.descending, + `none`, + false, + undefined, + h.react ? `react` : `javascript`, + ) + let running = true + let revision = 0 + let holdNumber = 0 + let applicationHold: + { release: () => void; persisted: Promise } | undefined + const releaseApplication = async () => { + if (!applicationHold) return + const held = applicationHold + applicationHold = undefined + held.release() + await held.persisted + } + const holdApplication = () => { + if (!running || applicationHold) return + let release!: () => void + const gate = new Promise((resolve) => { + release = resolve + }) + const transaction = createTransaction({ mutationFn: () => gate }) + transaction.mutate(() => + f.source.insert({ + id: `overlay-${holdNumber++}`, + rank: 10000, + text: `optimistic only`, + }), + ) + applicationHold = { + release, + persisted: transaction.isPersisted.promise, + } + } + const attempts: Array<{ + load: ReturnType + controller: AbortController + retired: boolean + }> = [] + const acquire = (limit: number) => { + const controller = new AbortController() + const load = f.load({ limit }, controller.signal) + void Promise.resolve(load.result).catch(() => {}) + const attempt = { load, controller, retired: false } + attempts.push(attempt) + return attempt + } + const retire = (attempt: (typeof attempts)[number]) => { + if (attempt.retired) return + attempt.retired = true + attempt.load.release() + } + const cleanup = async () => { + if (!running) return + for (const attempt of attempts) retire(attempt) + const cleaning = f.source.cleanup() + running = false + await releaseApplication() + await cleaning + } + const restart = () => { + if (running) return + f.source.startSyncImmediate() + running = true + } + const change = (choice: number) => { + revision++ + f.remote.change( + f.remote.source.map((row, i) => + i === choice % h.size + ? { + ...row, + rank: h.descending ? h.size + revision : -revision, + text: `${h.text}:${revision}`, + } + : row, + ), + ) + } + const checkpoint = () => { + for (const publication of f.publications) + assertLazySourceFacts(publication.rows, publication.evidence) + assertLazySourceFacts( + [...f.source.base.values()], + f.remote.deliveredFacts, + ) + expect( + f.source.has(`obsolete`), + `stale callback wrote into current run`, + ).toBe(false) + expect(f.remote.obsoleteReads, `retired watch read its cache`).toBe( + 0, + ) + expect(f.errors, `retired callback reported an error`).toEqual([]) + expect( + f.source.utils.getSyncError(), + `retired callback poisoned current sync`, + ).toBeUndefined() + } + await withTestCleanup( + async () => { + const shallow = acquire(1) + await f.remote.pump() + await shallow.load.complete() + const deep = acquire(9) + await f.remote.pump( + (slot) => + slot.opts.cursor !== null && slot.opts.endCursor == null, + ) + expect( + f.remote.slots.some( + (slot) => + slot.active && + slot.opts.cursor !== null && + slot.opts.endCursor == null && + !slot.current, + ), + ).toBe(true) + const before = f.remote.successorStarts + retire(deep) + reach.reach(`retired-authorizer-pending`) + await f.remote.pump() + expect( + f.remote.successorStarts, + `new successor after its authorizer retired`, + ).toBe(before) + assertLazyCoverage( + [...f.source.values()], + rows, + { limit: 1 }, + h.descending, + ) + reach.reach(`prior-delivery-after-retirement`) + f.remote.late(`success`) + await f.remote.pump() + checkpoint() + reach.reach(`retired-success-fenced`) + f.remote.late(`error`) + await f.remote.pump() + checkpoint() + reach.reach(`retired-error-fenced`) + reach.reach(h.react ? `react-transport` : `javascript-transport`) + const canceling = acquire(h.size - 1) + await f.remote.pump( + (slot) => + slot.opts.cursor !== null && slot.opts.endCursor == null, + ) + expect( + f.remote.slots.some( + (slot) => + slot.active && + slot.opts.cursor !== null && + slot.opts.endCursor == null && + !slot.current, + ), + ).toBe(true) + const beforeAbort = f.remote.successorStarts + canceling.controller.abort() + await f.remote.pump() + expect( + f.remote.successorStarts, + `new successor after authorizing abort`, + ).toBe(beforeAbort) + reach.reach(`aborted-authorizer-pending`) + holdApplication() + const acceptedBefore = f.receipts.length + acquire(h.size) + await f.remote.pump() + expect( + f.receipts + .slice(acceptedBefore) + .some((receipt) => receipt !== true), + ).toBe(true) + await cleanup() + f.remote.late(`success`) + f.remote.late(`error`) + checkpoint() + restart() + const fresh = acquire(1) + await f.remote.pump() + await fresh.load.complete() + reach.reach(`held-receipt-cleanup-restart`) + change(0) + await f.remote.pump() + assertLazyCoverage( + [...f.source.base.values()], + f.remote.source, + { limit: 1 }, + h.descending, + ) + reach.reach(`source-change-after-restart`) + for (const command of h.commands) { + const attempt = attempts[command.choice % attempts.length]! + switch (command.action) { + case `acquire`: + if (running) acquire(command.choice) + break + case `retire`: + retire(attempt) + break + case `abort`: + attempt.controller.abort() + break + case `deliver`: + await f.remote.deliver(command.choice) + break + case `drain`: + await f.remote.pump() + break + case `stale-success`: + f.remote.late(`success`) + break + case `stale-error`: + f.remote.late(`error`) + break + case `hold-application`: + holdApplication() + break + case `release-application`: + await releaseApplication() + break + case `source-change`: + change(command.choice) + break + case `cleanup`: + await cleanup() + break + case `restart`: + restart() + break + } + for (let turn = 0; turn < 10; turn++) await Promise.resolve() + checkpoint() + } + reach.reach(`generated-action-suffix`) + await releaseApplication() + await f.remote.pump() + for (const attempt of attempts) + if (!attempt.retired && !attempt.controller.signal.aborted) { + await attempt.load.complete() + assertLazyCoverage( + [...f.source.values()], + f.remote.source, + attempt.load.request, + h.descending, + ) + } + checkpoint() + reach.reach(`applied-source-provenance`) + }, + () => { + for (const attempt of attempts) retire(attempt) + }, + () => releaseApplication(), + () => f.source.cleanup(), + ) + }), + { numRuns: 60, ...parameters }, + ), + { fixedSeed: 20261027 }, + ) +}, 60000) + +it(`compares paired hidden suffixes at the production discovery boundary`, async () => { + await runOracleCampaign( + `CONVEX_LAZY_SUFFIX_ORACLE`, + [ + `same-prefix-discovery`, + `different-suffix-coverage`, + `leading-three-tie`, + ] as const, + (reach, parameters) => + fc.assert( + fc.asyncProperty( + fc.boolean(), + fc.string({ maxLength: 8 }), + async (descending, text) => { + reach.beginHistory() + const traces: Array> = [] + let firstEvidence: Array | undefined + for (const ranks of [ + [1, 2, 3, 4, 5, 6], + [1, 2, 2, 3, 4, 5], + [1, 1, 1, 2, 3, 4], + ]) { + const rows = ranks.map((rank, i) => ({ + id: `r${String(descending ? 10 - i : i).padStart(2, `0`)}`, + rank: descending ? -rank : rank, + text, + })) + if (traces.length < 2) { + const evidence = ordered(rows, descending).slice(0, 2) + if (firstEvidence) expect(evidence).toEqual(firstEvidence) + else firstEvidence = evidence + } + const f = lazyFixture(rows, 2, descending) + await withTestCleanup( + async () => { + const load = f.load({ limit: 2 }) + let settled = false + void Promise.resolve(load.result).then(() => { + settled = true + }) + await f.remote.pump((slot) => slot.opts.cursor !== null) + expect( + settled, + `a full nonterminal page cannot close its leading tie`, + ).toBe(false) + if (traces.length < 2) + traces.push( + f.remote.slots.map((slot) => JSON.stringify(slot.opts)), + ) + await f.remote.pump() + await load.complete() + assertLazyCoverage( + [...f.source.values()], + rows, + { limit: 2 }, + descending, + ) + expect( + expectedLazyRows( + [...f.source.values()], + { limit: 2 }, + descending, + ), + ).toEqual(expectedLazyRows(rows, { limit: 2 }, descending)) + load.release() + }, + () => f.source.cleanup(), + ) + } + expect(traces[0]).toEqual(traces[1]) + reach.reach(`same-prefix-discovery`) + reach.reach(`different-suffix-coverage`) + reach.reach(`leading-three-tie`) + }, + ), + { numRuns: 60, ...parameters }, + ), + { fixedSeed: 20261027 }, + ) +}, 60000) + +/** These are positive and negative calibrations of the scientific coverage + * comparison, not claims that the native adapter selects both retention + * strategies. An overshot row may be kept, or dropped and reacquired before a + * later deeper success. Assuming it was retained after dropping it is illegal. */ +it(`accepts retained and reacquired overshoot while rejecting a lost row`, () => { + fc.assert( + fc.property( + fc.integer({ min: 2, max: 8 }), + fc.boolean(), + (size, descending) => { + const source = Array.from({ length: size + 2 }, (_, rank) => ({ + id: `r${rank}`, + rank, + text: `authored`, + })) + const orderedSource = ordered(source, descending) + const shallow = orderedSource.slice(0, 1) + const retained = orderedSource.slice(0, size) + assertLazyCoverage(retained, source, { limit: 1 }, descending) + assertLazyCoverage(shallow, source, { limit: 1 }, descending) + assertLazyCoverage(retained, source, { limit: size }, descending) + expect(() => + assertLazyCoverage(shallow, source, { limit: size }, descending), + ).toThrow(`lazy coverage`) + const reacquired = [...shallow, ...orderedSource.slice(1, size)] + assertLazyCoverage(reacquired, source, { limit: size }, descending) + }, + ), + { seed: 20261027, numRuns: 120 }, + ) +}) 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 new file mode 100644 index 0000000000..a7ea5e606b --- /dev/null +++ b/packages/convex-db-collection/tests/lazy-paging-interleaving-oracle.property.test.ts @@ -0,0 +1,426 @@ +/** + * Provider response, acceptance and application are independent boundaries. + * LoadSubsetFn and LoadSubsetOptions.signal in db/src/types.ts require success + * to await every establishing receipt; cancellation preserves earlier accepted + * writes without acquiring the surviving peer's future receipt obligations. + * + * The grammar has deliver, accept, apply and abort actions. Enabled actions + * depend only on authored facts/establishment edges, not pages or a sync queue. + * Choices select legal topological orders. Actual Collection commit receipts + * and real optimistic transactions implement acceptance/application separation. + * The same LazyReceiptModel used by the native receiver judges caller outcomes. + * A constructive prefix guarantees acceptance before provider completion, + * partial application before another acceptance, and cancellation before a + * peer-only batch. Random legal suffixes explore neighboring orders. + * Limits: six stable disjoint facts, two shared callers, one independent peer, + * finite responses/batches, and no cleanup abandonment in this receiver. + */ +import { createCollection, createTransaction } from '@tanstack/db' +import fc from 'fast-check' +import { expect, it } from 'vitest' +import { runOracleCampaign } from '../../db/tests/oracle-campaign' +import { withTestCleanup } from './helpers' +import { LazyReceiptModel } from './lazy-paging-receipt-model' +import type { SyncAppliedReceipt, SyncConfig } from '@tanstack/db' + +type Row = { id: string } +const gate = () => { + let resolve!: () => void + const promise = new Promise((done) => { + resolve = done + }) + return { promise, resolve } +} +const drain = async () => { + for (let i = 0; i < 20; i++) await Promise.resolve() +} +type Action = + { type: `deliver` | `accept`; index: number } | { type: `apply` | `abort` } +type Fault = `none` | `borrow-peer` | `first-only` | `premature-inflight-cancel` + +async function runInterleaving( + h: { + responses: number + batches: number + choices: Array + cancelAt: number + constructive: boolean + uncancelable: boolean + }, + fault: Fault = process.env.CONVEX_LAZY_INTERLEAVING_FAULT === + `premature-inflight-cancel` + ? `premature-inflight-cancel` + : `none`, +) { + const model = new LazyReceiptModel() + const facts = Array.from({ length: 6 }, (_, i) => `fact${i}`) + const responses = Array.from({ length: h.responses }, (_response, i) => + facts.filter((_fact, j) => j % h.responses === i), + ) + const partitions = Array.from({ length: h.batches }, (_batch, i) => + facts.filter((_fact, j) => j % h.batches === i), + ) + const available = new Set() + const delivered = new Set() + const accepted = new Set() + const applied = new Set() + const allAccepted = gate() + const canceled = gate() + const receiptSets: Record> = { + a: [], + c: [], + peer: [], + } + const outcomes = new Map() + const failures: Array = [] + let operations!: Parameters[`sync`]>[0] + let aborted = false + let baselineFinished = false + let actionNumber = 0 + const trace: Array = [] + const reaches = new Set() + const controller = new AbortController() + const source = createCollection({ + getKey: (row) => row.id, + syncMode: `on-demand`, + sync: { + sync: (controls) => { + operations = controls + controls.markReady() + return { + loadSubset: (request) => { + const id = + request.limit === 1 ? `peer` : request.limit === 2 ? `a` : `c` + return ( + id === `peer` + ? Promise.resolve() + : id === `a` && + (!h.uncancelable || fault === `premature-inflight-cancel`) + ? Promise.race([allAccepted.promise, canceled.promise]) + : allAccepted.promise + ).then(async () => { + const owed = + fault === `borrow-peer` && id === `a` + ? receiptSets.peer! + : fault === `first-only` && id === `a` + ? receiptSets.a!.slice(0, 1) + : receiptSets[id]! + await Promise.all(owed) + if (request.signal?.aborted) + throw Object.assign(new Error(`canceled`), { + name: `AbortError`, + }) + }) + }, + } + }, + }, + }) + source.startSyncImmediate() + const load = ( + id: string, + limit: number, + required: Array, + signal?: AbortSignal, + ) => { + model.acquire(id, required) + if (id === `a` && h.uncancelable) model.initiate(id, `baseline`) + const result = source._sync.loadSubset({ limit, signal }) + void Promise.resolve(result).then( + () => { + outcomes.set(id, `success`) + try { + model.observe(id, `success`) + } catch (error) { + failures.push(error) + } + }, + (error: unknown) => { + const outcome = + error instanceof Error && error.name === `AbortError` + ? `canceled` + : `rejected` + if (outcome !== `rejected`) outcomes.set(id, outcome) + try { + model.observe(id, outcome) + } catch (failure) { + failures.push(failure) + } + }, + ) + } + const commit = ( + id: string, + values: Array, + owners: Array, + index?: number, + ) => { + operations.begin() + for (const fact of values) + operations.write({ type: `insert`, value: { id: fact } }) + const receipt = operations.commit() + model.accept(id, values, owners) + for (const owner of owners) receiptSets[owner]!.push(receipt) + const application = () => { + try { + expect( + values.every((fact) => source.base.has(fact)), + `receipt before base visibility`, + ).toBe(true) + model.apply(id) + if (index !== undefined) applied.add(index) + } catch (error) { + failures.push(error) + } + } + if (receipt === true) application() + else + void receipt.then(application, (error: unknown) => failures.push(error)) + } + let held: { release: () => void; persisted: Promise } | undefined + const hold = () => { + const pending = gate() + const transaction = createTransaction({ mutationFn: () => pending.promise }) + transaction.mutate(() => source.insert({ id: `overlay-${actionNumber}` })) + held = { + release: pending.resolve, + persisted: transaction.isPersisted.promise, + } + } + const release = async () => { + if (!held) return + const pending = held + held = undefined + pending.release() + await pending.persisted + await drain() + } + const checkpoint = () => { + if (failures.length) throw failures[0] + // Independent provider facts also constrain the applied source base. + for (const row of source.base.values()) + expect( + row.id === `peer` || available.has(row.id), + `undelivered source fact`, + ).toBe(true) + } + const act = async (action: Action) => { + actionNumber++ + trace.push(`${action.type}${`index` in action ? action.index : ``}`) + switch (action.type) { + case `deliver`: + delivered.add(action.index) + for (const fact of responses[action.index]!) available.add(fact) + break + case `accept`: { + const owners = aborted && !h.uncancelable ? [`c`] : [`a`, `c`] + commit( + `batch${action.index}`, + partitions[action.index]!, + owners, + action.index, + ) + accepted.add(action.index) + if (delivered.size < responses.length) + reaches.add(`accept-before-last-response`) + if (aborted) reaches.add(`peer-accept-after-abort`) + if (aborted && h.uncancelable) + reaches.add(`uncancelable-accept-after-abort`) + break + } + case `apply`: + await release() + if (accepted.size < partitions.length) { + reaches.add(`apply-before-next-accept`) + hold() + } + break + case `abort`: + model.abort(`a`) + aborted = true + controller.abort() + canceled.resolve() + if (applied.size < accepted.size) reaches.add(`abort-with-held-receipt`) + if ( + h.uncancelable && + applied.size === accepted.size && + accepted.size < partitions.length + ) + reaches.add(`abort-with-only-inflight-work`) + break + } + if (accepted.size === partitions.length && aborted) { + if (h.uncancelable && !baselineFinished) { + model.finish(`a`, `baseline`) + baselineFinished = true + } + allAccepted.resolve() + } + await drain() + checkpoint() + } + await withTestCleanup( + async () => { + load(`peer`, 1, [`peer`]) + commit(`peer-batch`, [`peer`], [`peer`]) + load(`a`, 2, facts, controller.signal) + load(`c`, 3, facts) + hold() + if (h.constructive) { + // Same fact partition for this prefix; the generated suffix and the + // second profile vary response/batch cardinalities independently. + await act({ type: `deliver`, index: 0 }) + await act({ type: `accept`, index: 0 }) + await act({ type: `apply` }) + if (h.cancelAt === 1) await act({ type: `abort` }) + await act({ type: `deliver`, index: 1 }) + await act({ type: `accept`, index: 1 }) + if (!aborted) await act({ type: `abort` }) + } + // Every action consumes one previously outstanding delivery/acceptance, + // cancellation, or actual application. This finite scheduler needs no + // artificial bound on provider calls or knowledge of a production queue. + while ( + delivered.size < responses.length || + accepted.size < partitions.length || + applied.size < accepted.size || + !aborted + ) { + const enabled: Array = [] + for (let i = 0; i < responses.length; i++) + if (!delivered.has(i)) enabled.push({ type: `deliver`, index: i }) + for (let i = 0; i < partitions.length; i++) + if ( + !accepted.has(i) && + partitions[i]!.every((fact) => available.has(fact)) + ) + enabled.push({ type: `accept`, index: i }) + if (applied.size < accepted.size) enabled.push({ type: `apply` }) + if ( + !aborted && + accepted.size >= Math.min(h.cancelAt, partitions.length) + ) + enabled.push({ type: `abort` }) + if (!enabled.length) + throw new Error( + `illegal authored receipt schedule ${trace.join(`,`)}`, + ) + const choice = h.choices[actionNumber % h.choices.length]! + await act(enabled[choice % enabled.length]!) + } + await release() + await drain() + checkpoint() + expect(outcomes.get(`a`)).toBe(`canceled`) + expect(outcomes.get(`c`)).toBe(`success`) + expect(outcomes.get(`peer`)).toBe(`success`) + model.complete() + }, + () => release(), + () => source.cleanup(), + ) + return reaches +} + +it(`generates legal response/receipt/application/cancellation interleavings`, async () => { + await runOracleCampaign( + `CONVEX_LAZY_INTERLEAVING_ORACLE`, + [ + `accept-before-last-response`, + `apply-before-next-accept`, + `abort-with-held-receipt`, + `peer-accept-after-abort`, + `uncancelable-accept-after-abort`, + `abort-with-only-inflight-work`, + ] as const, + (reach, parameters) => + fc.assert( + fc.asyncProperty( + fc.record({ + responses: fc.integer({ min: 1, max: 3 }), + batches: fc.integer({ min: 2, max: 3 }), + choices: fc.array(fc.nat({ max: 100 }), { + minLength: 1, + maxLength: 24, + }), + cancelAt: fc.integer({ min: 1, max: 3 }), + uncancelable: fc.boolean(), + }), + async (h) => { + reach.beginHistory() + await runInterleaving({ + ...h, + responses: 3, + batches: 3, + constructive: true, + uncancelable: false, + cancelAt: 2, + }) + const observed = await runInterleaving({ + ...h, + responses: 3, + batches: 3, + constructive: true, + uncancelable: true, + cancelAt: 2, + }) + const early = await runInterleaving({ + ...h, + responses: 3, + batches: 3, + constructive: true, + uncancelable: true, + cancelAt: 1, + }) + await runInterleaving({ ...h, constructive: false }) + for (const state of [ + `accept-before-last-response`, + `apply-before-next-accept`, + `abort-with-held-receipt`, + `peer-accept-after-abort`, + `uncancelable-accept-after-abort`, + `abort-with-only-inflight-work`, + ] as const) { + expect(observed.has(state) || early.has(state)).toBe(true) + reach.reach(state) + } + }, + ), + { numRuns: 60, ...parameters }, + ), + { fixedSeed: 20261027 }, + ) +}, 60000) + +it.each([`borrow-peer`, `first-only`, `premature-inflight-cancel`] as const)( + `rejects %s under interleaving`, + async (fault) => { + await expect( + runInterleaving( + { + responses: 3, + batches: 3, + choices: [0], + cancelAt: fault === `premature-inflight-cancel` ? 1 : 2, + constructive: true, + uncancelable: true, + }, + fault, + ), + ).rejects.toThrow(/receipt (law|model)/) + }, +) + +it(`permits core fulfillment after abort once an uncancelable baseline applies`, () => { + const model = new LazyReceiptModel() + model.acquire(`a`, [`v1`]) + model.initiate(`a`, `baseline`) + model.abort(`a`) + model.accept(`receipt`, [`v1`], [`a`]) + expect(() => model.observe(`a`, `success`)).toThrow(`in-flight work`) + model.finish(`a`, `baseline`) + expect(() => model.observe(`a`, `success`)).toThrow(`receipt`) + model.apply(`receipt`) + model.observe(`a`, `success`) + model.complete() +}) diff --git a/packages/convex-db-collection/tests/lazy-paging-load-outcome-oracle.property.test.ts b/packages/convex-db-collection/tests/lazy-paging-load-outcome-oracle.property.test.ts new file mode 100644 index 0000000000..c730be9bc8 --- /dev/null +++ b/packages/convex-db-collection/tests/lazy-paging-load-outcome-oracle.property.test.ts @@ -0,0 +1,253 @@ +/** + * A throw and a rejected promise are equivalent failed-load observations. + * Authority: #2269 comment 6448490334, LoadSubsetFn, and publication laws 7/9 + * in live/ARCHITECTURE.md. This metamorphic judgment compares the same authored + * history through both outcomes, independently checking exact public values, + * error identity and recovery. A failed replacement never gains publication + * authority from ordinary later writes. LazyVersionModel judges every callback. + * + * Acquisition lifetimes differ legitimately: a throw creates no lease; an + * accepted promise does. The adapter releases partial resources before a + * throw, and releases accepted resources on unload/cleanup. We compare drained + * public cuts and final resource release, not raw scheduling or unload counts. + * + * Grammar: 1–4 rows, short text, optional partial writes before failure, + * 1–4 rejection turns, direct and live-query receivers in every history. + * The constructive initial/failure/ordinary-write/retry sequence reconstructs + * replay-failure-boundary.test.ts without its includes/peer profile. Partial + * writes distinguish failed publication from empty-source handling; later + * writes distinguish failed authority from a temporary delay. Limits: one + * source/consumer, no initial-load equivalence, reentry or real backend claim. + */ +import { IR, createCollection, createLiveQueryCollection } from '@tanstack/db' +import fc from 'fast-check' +import { expect, it } from 'vitest' +import { runOracleCampaign } from '../../db/tests/oracle-campaign' +import { withTestCleanup } from './helpers' +import { lazyOracleRuns } from './lazy-paging-campaign' +import { expectedLazyRows } from './lazy-paging-model-oracle' +import { LazyVersionModel } from './lazy-paging-version-model-oracle' +import type { LazyRow } from './lazy-paging-model-oracle' +import type { LoadSubsetOptions, SyncConfig } from '@tanstack/db' + +const grammar = fc.record({ + count: fc.integer({ min: 1, max: 4 }), + text: fc.string({ maxLength: 10 }), + partial: fc.boolean(), + turns: fc.integer({ min: 1, max: 4 }), +}) +type History = typeof grammar extends fc.Arbitrary ? T : never +const drain = async () => { + for (let turn = 0; turn < 60; turn++) await Promise.resolve() +} + +async function receive( + h: History, + failureMode: `throw` | `reject`, + consumer: `direct` | `query`, +) { + const rows = (version: number): Array => + Array.from({ length: h.count }, (_, rank) => ({ + id: `r${rank}`, + rank, + text: `${h.text}:${version}`, + })) + const versions = new LazyVersionModel(`sequence`) + versions.offer(rows(1)) + const failure = new Error(`authored failed load: ${h.text}`) + let phase: `initial` | `failed` | `recovered` = `initial` + let operations!: Parameters[`sync`]>[0] + const resources = new Set() + let failedCalls = 0 + const source = createCollection({ + getKey: (row) => row.id, + syncMode: `on-demand`, + sync: { + sync: (controls) => { + operations = controls + controls.begin() + for (const row of rows(1)) + controls.write({ type: `insert`, value: row }) + controls.commit() + controls.markReady() + return { + loadSubset: (request) => { + resources.add(request) + if (phase === `initial`) return true + if (phase === `recovered` || h.partial) { + controls.begin() + for (const row of rows(phase === `recovered` ? 4 : 2)) + controls.write({ + type: source.base.has(row.id) ? `update` : `insert`, + value: row, + }) + controls.commit() + } + if (phase === `recovered`) return true + failedCalls++ + if (failureMode === `throw`) { + resources.delete(request) + throw failure + } + return (async () => { + for (let turn = 0; turn < h.turns; turn++) await Promise.resolve() + throw failure + })() + }, + unloadSubset: (request) => { + resources.delete(request) + }, + cleanup: () => { + resources.clear() + }, + } + }, + }, + }) + const failures: Array = [] + const retained = new Map() + const graph = + consumer === `query` + ? createLiveQueryCollection((q) => + q + .from({ row: source }) + .orderBy(({ row }) => row.rank, `asc`) + .select(({ row }) => ({ + id: row.id, + rank: row.rank, + text: row.text, + })), + ) + : undefined + const read = () => + graph + ? graph.toArray.map(({ id, rank, text }) => ({ id, rank, text })) + : expectedLazyRows([...retained.values()], {}) + const observe = () => { + try { + versions.observe(read()) + } catch (error) { + failures.push(error) + } + } + const direct = source.subscribeChanges( + (changes) => { + if (consumer !== `direct`) return + for (const change of changes) { + if (change.type === `delete`) retained.delete(String(change.key)) + else retained.set(String(change.key), { ...change.value }) + } + observe() + }, + { includeInitialState: false }, + ) + const graphObserver = graph?.subscribeChanges(observe, { + includeInitialState: false, + }) + const errors: Array = [] + direct.on(`loadSubset:error`, ({ error }) => errors.push(error)) + const trace: Array<{ + checkpoint: string + rows: Array + status: string + failed: boolean + }> = [] + const checkpoint = async ( + name: string, + expected: Array, + failed: boolean, + ) => { + await drain() + if (failures.length) throw failures[0] + expect(read(), `load outcome law at ${name}`).toEqual(expected) + trace.push({ + checkpoint: name, + rows: read().map((row) => ({ ...row })), + status: source.status, + failed, + }) + } + const truncate = () => { + operations.begin() + operations.truncate() + operations.commit() + } + await withTestCleanup( + async () => { + if (graph) await graph.preload() + else + direct.requestSnapshot({ + where: new IR.Func(`gte`, [ + new IR.PropRef([`rank`]), + new IR.Value(0), + ]), + }) + await checkpoint(`initial`, rows(1), false) + phase = `failed` + truncate() + const waiter = graph + ? graph.utils.setWindow({ limit: h.count + 1 }) + : direct.pendingTruncateReplacement + expect(waiter).toBeInstanceOf(Promise) + const result = Promise.allSettled([waiter]) + expect( + await result, + `load outcome law: failed replay must reject with original error`, + ).toEqual([{ status: `rejected`, reason: failure }]) + expect(failedCalls).toBeGreaterThan(0) + if (!graph) { + expect(errors).toEqual([failure]) + expect(direct.lastError).toBe(failure) + } + await checkpoint(`failed`, rows(1), true) + operations.begin() + for (const row of rows(3)) + operations.write({ + type: source.base.has(row.id) ? `update` : `insert`, + value: row, + }) + operations.commit() + await checkpoint(`ordinary write cannot repair`, rows(1), true) + phase = `recovered` + versions.offer(rows(4)) + truncate() + await drain() + if (graph) await graph.utils.setWindow({ limit: h.count + 1 }) + await checkpoint(`recovered`, rows(4), false) + }, + () => graphObserver?.unsubscribe(), + () => direct.unsubscribe(), + () => graph?.cleanup(), + () => source.cleanup(), + ) + expect(resources.size, `load outcome law: leaked adapter resources`).toBe(0) + return trace +} + +it(`gives throw and rejection the same public replay failure and recovery`, async () => { + await runOracleCampaign( + `CONVEX_LOAD_OUTCOME_ORACLE`, + [ + `direct-equivalence`, + `query-equivalence`, + `failed-publication-held`, + `retry-recovers`, + ] as const, + (reach, parameters) => + fc.assert( + fc.asyncProperty(grammar, async (h) => { + reach.beginHistory() + for (const consumer of [`direct`, `query`] as const) { + const thrown = await receive(h, `throw`, consumer) + const rejected = await receive(h, `reject`, consumer) + expect(rejected, `throw/rejection metamorphic law`).toEqual(thrown) + reach.reach(`${consumer}-equivalence`) + } + reach.reach(`failed-publication-held`) + reach.reach(`retry-recovers`) + }), + { numRuns: lazyOracleRuns(30), ...parameters }, + ), + { fixedSeed: 20261030 }, + ) +}) diff --git a/packages/convex-db-collection/tests/lazy-paging-model-oracle.ts b/packages/convex-db-collection/tests/lazy-paging-model-oracle.ts new file mode 100644 index 0000000000..ef15965ba5 --- /dev/null +++ b/packages/convex-db-collection/tests/lazy-paging-model-oracle.ts @@ -0,0 +1,162 @@ +/** + * Fixed-query lazy paging: exact subset coverage is separate from the public + * window and from permission to start another provider range. Authority: + * LoadSubsetOptions/LoadSubsetFn in db/src/types.ts, atomic window publication + * in query/live/ARCHITECTURE.md, and the maintainer-approved lazy-paging design + * (2026-10-26). The work promise is new: no unrelated successor after delivered + * evidence closes every active scope. Reacquiring an existing interval is legal. + * + * This model orders the full authored source to predict values. The separate + * work relation below receives only delivered rows/exhaustion; hidden suffixes + * cannot tell the adapter that a tie is already closed. It imports no adapter + * parser, cursor implementation, comparator, or Collection snapshot. + * Limits: finite numeric rank, unique string IDs, one aligned query; provider + * cache coherence, native cursor correctness and timing need receiving drivers. + */ +export type LazyRow = { id: string; rank: number; text: string } +export type LazyScope = { + offset?: number + limit?: number + equal?: number + after?: number +} +export const copyLazyRows = (rows: ReadonlyArray): Array => + rows.map(({ id, rank, text }) => ({ id, rank, text })) + +/** Cleanup law 14 in live/ARCHITECTURE.md: the old graph keeps its last + * complete value and terminal error, including after a fresh run recovers. + * Only cleanup authority and the last public value have scientific meaning. */ +export class LazyTerminalGraphModel { + private snapshot: Array | undefined + + cleanup(rows: ReadonlyArray): void { + this.snapshot = copyLazyRows(rows) + } + + observe(status: string, rows: ReadonlyArray): void { + if (!this.snapshot) return + if (status !== `error`) + throw new Error(`native lifecycle: old graph recovered`) + if (JSON.stringify(copyLazyRows(rows)) !== JSON.stringify(this.snapshot)) + throw new Error(`native lifecycle: dead graph published`) + } +} +export const ordered = (rows: ReadonlyArray, descending = false) => + [...rows].sort((a, b) => + descending + ? b.rank - a.rank || b.id.localeCompare(a.id) + : a.rank - b.rank || a.id.localeCompare(b.id), + ) + +export function expectedLazyRows( + rows: ReadonlyArray, + scope: LazyScope, + descending = false, +): Array { + const matching = ordered(rows, descending).filter( + (row) => + (scope.equal === undefined || row.rank === scope.equal) && + (scope.after === undefined || + (descending ? row.rank < scope.after : row.rank > scope.after)), + ) + const offset = scope.offset ?? 0 + return matching + .slice(offset, scope.limit === undefined ? undefined : offset + scope.limit) + .map(({ id, rank, text }) => ({ id, rank, text })) +} + +export function expectedLazyCoverage( + source: ReadonlyArray, + scope: LazyScope, + descending = false, +): Array { + const display = expectedLazyRows(source, scope, descending) + const last = display.at(-1) + if (!last) return [] + // A served ordered prefix includes the complete leading-value tie, even + // when only one of its IDs appears in the displayed window. + return ordered(source, descending).filter( + (row) => + (scope.equal === undefined || row.rank === scope.equal) && + (scope.after === undefined || + (descending ? row.rank < scope.after : row.rank > scope.after)) && + (descending ? row.rank >= last.rank : row.rank <= last.rank), + ) +} + +export function deliveredScopeComplete( + delivered: ReadonlyArray, + exhausted: boolean, + scope: LazyScope, + descending = false, +): boolean { + if (scope.limit === 0 || exhausted) return true + const matching = expectedLazyRows( + delivered, + { ...scope, offset: 0, limit: undefined }, + descending, + ) + if ( + scope.equal !== undefined && + delivered.some((row) => + descending ? row.rank < scope.equal! : row.rank > scope.equal!, + ) + ) + return true + if ( + scope.limit === undefined || + matching.length < (scope.offset ?? 0) + scope.limit + ) + return false + const boundary = matching[(scope.offset ?? 0) + scope.limit - 1]!.rank + return delivered.some((row) => + descending ? row.rank < boundary : row.rank > boundary, + ) +} + +export function assertLazyCoverage( + actual: ReadonlyArray, + source: ReadonlyArray, + scope: LazyScope, + descending = false, +): void { + for (const row of expectedLazyCoverage(source, scope, descending)) { + const found = actual.find((candidate) => candidate.id === row.id) + if (!found || found.rank !== row.rank || found.text !== row.text) + throw new Error(`lazy coverage: missing/current row ${row.id}`) + } +} + +/** Compare the applied provider base, never the optimistic overlay. Every + * applied row/version must have been supplied as observable provider evidence + * in this run. Historical delivered versions remain lawful while newer writes + * wait for application. This detects invented rows without imposing retention + * or requiring a future revision to be visible early. */ +export function assertLazySourceFacts( + base: ReadonlyArray, + evidence: ReadonlyArray, +): void { + const fact = ({ id, rank, text }: LazyRow) => JSON.stringify([id, rank, text]) + const delivered = new Set(evidence.map(fact)) + for (const row of base) + if (!delivered.has(fact(row))) + throw new Error(`lazy source: undelivered applied fact ${fact(row)}`) +} + +/** Every observed public snapshot is the old complete window until the new one + * appears; it may then repeat, but cannot fall back or expose a repaired prefix. */ +export function assertLazyPublications( + trace: ReadonlyArray>, + prior: ReadonlyArray, + next: ReadonlyArray, +): void { + let advanced = false + const key = (rows: ReadonlyArray) => JSON.stringify(rows) + for (const rows of trace) { + if (key(rows) === key(next)) advanced = true + else if (key(rows) !== key(advanced ? next : prior)) + throw new Error( + `lazy publication: partial or stale public window ${key(rows)}`, + ) + } +} 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 new file mode 100644 index 0000000000..670537e7a6 --- /dev/null +++ b/packages/convex-db-collection/tests/lazy-paging-native-receipt-oracle.property.test.ts @@ -0,0 +1,312 @@ +/** + * Native receipt refinement of LazyReceiptModel (LoadSubsetFn, signal and + * SyncAppliedReceipt in db/src/types.ts). Each required authored row/version + * absent at acquisition has exactly one supplying batch in these stable source + * episodes. That unique causal witness assigns an establishing receipt, shared + * by both callers when appropriate. No queue, page or cursor determines debt. + * + * Native writes, actual core receipts, and caller outcomes enter the SAME model + * as the controlled interleaving receiver. Cached facts can owe zero new + * receipts. Waiting behind unrelated work remains legal; only quiescent drain + * requires completion. The driver alone chooses provider page/callback order. + * Limits: stable keys/order, disjoint new version facts, no idempotent refresh + * attribution, cleanup abandonment or adversarial acceptance rejection here. + */ +import { createTransaction } from '@tanstack/db' +import fc from 'fast-check' +import { expect, it } from 'vitest' +import { runOracleCampaign } from '../../db/tests/oracle-campaign' +import { withTestCleanup } from './helpers' +import { lazyFixture } from './lazy-paging-driver' +import { LazyReceiptModel } from './lazy-paging-receipt-model' +import { ordered } from './lazy-paging-model-oracle' +import type { LazyRow } from './lazy-paging-model-oracle' + +const turns = async () => { + for (let i = 0; i < 25; i++) await Promise.resolve() +} +const fact = (row: LazyRow) => JSON.stringify([row.id, row.rank, row.text]) +type History = { + size: number + pageSize: number + descending: boolean + react: boolean + cancel: boolean + reverse: boolean + text: string +} + +async function nativeHistory( + h: History, + borrowStartup = process.env.CONVEX_LAZY_NATIVE_RECEIPT_FAULT === + `borrow-startup`, +) { + const rows = Array.from({ length: h.size }, (_, rank) => ({ + id: `r${rank}`, + rank, + text: `${h.text}:v1`, + })) + const model = new LazyReceiptModel() + const failures: Array = [] + const attempts = new Map< + string, + { + missing: Set + active: boolean + outcome?: `success` | `canceled` | `rejected` + } + >() + const f = lazyFixture( + rows, + h.pageSize, + h.descending, + `none`, + false, + undefined, + h.react ? `react` : `javascript`, + borrowStartup ? () => true : undefined, + ) + const applied = new Set() + const edges = new Map>() + const stop = f.observeBatches((batch) => { + try { + const facts = batch.rows.map(fact) + const owners = [...attempts].filter( + ([, attempt]) => + attempt.active && facts.some((value) => attempt.missing.has(value)), + ) + for (const [, attempt] of owners) + for (const value of facts) attempt.missing.delete(value) + const ids = owners.map(([id]) => id) + model.accept(batch.id, facts, ids) + edges.set(batch.id, ids) + const observeApplied = () => { + try { + for (const row of batch.rows) + expect( + f.source.base.get(row.id), + `receipt before authored fact visibility`, + ).toEqual(row) + model.apply(batch.id) + for (const value of facts) applied.add(value) + } catch (error) { + failures.push(error) + } + } + if (batch.receipt === true) observeApplied() + else + void batch.receipt.then(observeApplied, (error: unknown) => + failures.push(error), + ) + } catch (error) { + failures.push(error) + } + }) + const loads: Array> = [] + const acquire = ( + id: string, + required: Array, + limit: number, + signal?: AbortSignal, + refetch = false, + ) => { + const facts = required.map(fact) + model.acquire(id, facts) + const attempt = { + missing: new Set(facts.filter((value) => !applied.has(value))), + active: true, + outcome: undefined as `success` | `canceled` | `rejected` | undefined, + } + attempts.set(id, attempt) + const load = f.load({ limit }, signal, refetch) + loads.push(load) + const observe = (outcome: `success` | `canceled` | `rejected`) => { + attempt.outcome = outcome + attempt.active = false + try { + model.observe(id, outcome) + } catch (error) { + failures.push(error) + } + } + void Promise.resolve(load.result).then( + () => observe(`success`), + (error: unknown) => + observe( + error instanceof Error && error.name === `AbortError` + ? `canceled` + : `rejected`, + ), + ) + return { load, attempt } + } + let release = () => {} + const hold = (id: string) => { + let resolve!: () => void + const promise = new Promise((done) => { + resolve = done + }) + release = resolve + const tx = createTransaction({ mutationFn: () => promise }) + tx.mutate(() => f.source.insert({ id, rank: 10000, text: `overlay` })) + return async () => { + resolve() + await tx.isPersisted.promise + await turns() + } + } + const checkpoint = () => { + if (failures.length) throw failures[0] + } + await withTestCleanup( + async () => { + // Empty native startup is an actual zero-fact receipt, not a substitute + // for a later caller's establishing receipt. + const warmRows = ordered(rows, h.descending).slice(0, 1) + const initial = acquire(`initial`, warmRows, 1) + await f.remote.pump() + await initial.load.complete() + await turns() + checkpoint() + const controller = new AbortController() + const a = acquire(`a`, rows, h.size, controller.signal) + const c = acquire(`c`, rows, h.size) + const drain = hold(`first-overlay`) + await f.remote.pump(() => false, h.reverse) + expect( + [...edges.values()].some( + (ids) => ids.includes(`a`) && ids.includes(`c`), + ), + `unique new facts must witness shared establishment`, + ).toBe(true) + expect(f.batches.some((batch) => batch.receipt !== true)).toBe(true) + const cachedHeld = acquire(`cached-held`, warmRows, 1) + // These facts were applied before the peer's deeper acquisition. The + // cached caller owes no new receipt, but may lawfully wait behind it. + expect([...edges.values()].flat()).not.toContain(`cached-held`) + if (h.cancel) { + model.abort(`a`) + a.attempt.active = false + controller.abort() + } + await turns() + checkpoint() + expect( + a.attempt.outcome, + `native caller escaped a held receipt`, + ).toBeUndefined() + expect( + c.attempt.outcome, + `native shared caller escaped a held receipt`, + ).toBeUndefined() + await drain() + await turns() + checkpoint() + expect( + a.attempt.outcome, + `receipt progress: quiescent native a stranded`, + ).toBe(h.cancel ? `canceled` : `success`) + expect( + c.attempt.outcome, + `receipt progress: quiescent native c stranded`, + ).toBe(`success`) + expect( + cachedHeld.attempt.outcome, + `receipt progress: cached native caller stranded after peer drain`, + ).toBe(`success`) + const cached = acquire(`cached`, rows, h.size) + await f.remote.pump() + await cached.load.complete() + await turns() + checkpoint() + expect([...edges.values()].flat()).not.toContain(`cached`) + const revised = rows.map((row) => ({ ...row, text: `${h.text}:v2` })) + f.remote.change(revised) + // The SDK has atomically installed this authored revision while its + // callbacks remain queued. Explicit revalidation can now require these + // facts; a load against an unchanged cache could lawfully serve v1. + f.remote.installCaches() + const secondDrain = hold(`second-overlay`) + const changed = acquire(`changed`, revised, h.size, undefined, true) + await f.remote.pump(() => false, !h.reverse) + expect([...edges.values()].flat()).toContain(`changed`) + await turns() + checkpoint() + expect(changed.attempt.outcome).toBeUndefined() + await secondDrain() + await changed.load.complete() + await turns() + checkpoint() + model.complete() + }, + () => release(), + () => { + for (const load of loads) load.release() + }, + () => f.source.cleanup(), + () => stop(), + ) +} + +it(`refines the same receipt model through native uniquely establishing batches`, async () => { + await runOracleCampaign( + `CONVEX_LAZY_NATIVE_RECEIPT_ORACLE`, + [ + `native-shared-held`, + `native-cached-zero`, + `native-cached-behind-peer`, + `native-new-version`, + `native-canceled-held`, + `native-success-held`, + `javascript`, + `react`, + ] as const, + (reach, parameters) => + fc.assert( + fc.asyncProperty( + fc.record({ + size: fc.integer({ min: 7, max: 12 }), + pageSize: fc.integer({ min: 2, max: 4 }), + descending: fc.boolean(), + react: fc.boolean(), + reverse: fc.boolean(), + text: fc.string({ maxLength: 8 }), + }), + async (h) => { + reach.beginHistory() + for (const cancel of [false, true]) + await nativeHistory({ ...h, cancel }) + for (const state of [ + `native-shared-held`, + `native-cached-zero`, + `native-cached-behind-peer`, + `native-new-version`, + `native-canceled-held`, + `native-success-held`, + ] as const) + reach.reach(state) + reach.reach(h.react ? `react` : `javascript`) + }, + ), + { numRuns: 45, ...parameters }, + ), + { fixedSeed: 20261027 }, + ) +}, 60000) + +it(`rejects a native receiver borrowing the applied startup receipt`, async () => { + await expect( + nativeHistory( + { + size: 8, + pageSize: 2, + descending: false, + react: false, + cancel: false, + reverse: false, + text: ``, + }, + true, + ), + ).rejects.toThrow(/receipt law/) +}) 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 new file mode 100644 index 0000000000..def6495fa8 --- /dev/null +++ b/packages/convex-db-collection/tests/lazy-paging-oracle.property.test.ts @@ -0,0 +1,945 @@ +/** + * Lazy page acquisitions refine the laws in lazy-paging-model-oracle.ts. + * Five families use the same generated history and 60-run fixed/random budget: + * values/scopes; successor work; publication/repair; acquisition/lifecycle; + * publication-reentrant receipts. + * Each has its own predeclared reach ledger and direct SEED/PATH replay. + * Drivers are real Collection/ordered live-query boundaries plus the controlled + * transport in lazy-paging-driver.ts. Expectations never use pager internals. + * + * Grammar: finite ranks (ties included), stable IDs, page partitions, offset, + * forward/reverse transitions and generated finite limit-move suffixes (work + * family). Shared arbitrary fields not used by a family earn it no coverage. The + * constructive scenes below reconstruct the named witnesses before suffixes. + * Removing ties loses scope-vs-display; removing holds loses early settlement; + * removing cleanup/restart loses run authority. Unsupported orders/filters are + * a nearby negative. The receipt family supplies two actual batches through + * publication reentry. Arbitrary progressive multi-commit acquisitions and + * delayed cancellation remain with core's receipt/replay owners. + * Quiescent provider drains detect finite stalls; the fixture turn guard is + * diagnostic and does not claim arbitrary nonquiescent termination coverage. + */ +import fc from 'fast-check' +import { + IR, + createCollection, + createLiveQueryCollection, + createTransaction, +} from '@tanstack/db' +import { expect, it } from 'vitest' +import { runOracleCampaign } from '../../db/tests/oracle-campaign' +import { convexPaginatedCollectionOptions } from '../src/index' +import { withTestCleanup } from './helpers' +import { + lazyFixture, + lazyOrder, + lazyQuery, + lazyRequest, +} from './lazy-paging-driver' +import { + assertLazyCoverage, + assertLazyPublications, + copyLazyRows, + deliveredScopeComplete, + expectedLazyRows, + ordered, +} from './lazy-paging-model-oracle' +import type { LazyRow } from './lazy-paging-model-oracle' +import type { ConvexPaginatedCollectionConfig } from '../src/index' + +const history = fc.record({ + size: fc.integer({ min: 16, max: 32 }), + pageSize: fc.integer({ min: 1, max: 5 }), + offset: fc.integer({ min: 1, max: 5 }), + limit: fc.integer({ min: 1, max: 5 }), + descending: fc.boolean(), + reverse: fc.boolean(), + texts: fc.array(fc.string({ maxLength: 8 }), { + minLength: 32, + maxLength: 32, + }), + suffix: fc.array(fc.integer({ min: 1, max: 12 }), { maxLength: 8 }), +}) +type History = typeof history extends fc.Arbitrary ? T : never +function rowsFor(h: History, ties = true): Array { + return Array.from({ length: h.size }, (_, i) => ({ + id: `r${String(i).padStart(2, `0`)}`, + rank: ties ? Math.floor(i / 2) : i, + text: h.texts[i]!, + })) +} +const runBudget = 60 +const fixedSeed = 20261026 + +it(`matches exact finite, offset, tie, continuation, whole and empty scopes`, async () => { + await runOracleCampaign( + `CONVEX_LAZY_VALUES_ORACLE`, + [ + `offset-crosses-page`, + `scoped-unlimited-tie`, + `continuation-scope`, + `whole-source-exhausted`, + `authoritative-empty`, + `required-split`, + `recommended-split`, + `short-nonterminal`, + `unsupported-shape-rejected`, + `rejected-unload-preserves-peer`, + ] as const, + (reach, parameters) => + fc.assert( + fc.asyncProperty(history, async (h) => { + reach.beginHistory() + const rows = rowsFor(h) + const order = ordered(rows, h.descending) + const scopes = [ + { offset: h.offset + h.pageSize, limit: h.limit }, + { equal: order[3]!.rank }, + { after: order[3]!.rank, limit: h.limit }, + {}, + ] + for (const [i, scope] of scopes.entries()) { + const f = lazyFixture(rows, h.pageSize, h.descending) + await withTestCleanup( + async () => { + const load = f.load(scope) + await f.remote.pump(() => false, h.reverse) + await load.complete() + const actual = [...f.source.values()] + expect( + expectedLazyRows(actual, scope, h.descending), + `lazy exact scope ${i}`, + ).toEqual(expectedLazyRows(rows, scope, h.descending)) + assertLazyCoverage(actual, rows, scope, h.descending) + if (i === 0) expect(f.remote.successorStarts).toBeGreaterThan(0) + reach.reach( + ( + [ + `offset-crosses-page`, + `scoped-unlimited-tie`, + `continuation-scope`, + `whole-source-exhausted`, + ] as const + )[i]!, + ) + load.release() + }, + () => f.source.cleanup(), + ) + } + for (const split of [`required`, `recommended`] as const) { + const f = lazyFixture(rows, 4, h.descending, split, true) + await withTestCleanup( + async () => { + const load = f.load({ limit: 6 }) + await f.remote.pump(() => false, h.reverse) + await load.complete() + assertLazyCoverage( + [...f.source.values()], + rows, + { limit: 6 }, + h.descending, + ) + expect( + f.remote.slots.some( + (slot) => + slot.current?.pageStatus === + (split === `required` + ? `SplitRequired` + : `SplitRecommended`), + ), + ).toBe(true) + reach.reach( + split === `required` ? `required-split` : `recommended-split`, + ) + expect( + f.remote.slots.some( + (slot) => + slot.current && + !slot.current.isDone && + slot.current.page.length < slot.opts.numItems, + ), + ).toBe(true) + reach.reach(`short-nonterminal`) + load.release() + }, + () => f.source.cleanup(), + ) + } + const capability = lazyFixture(rows, h.pageSize, h.descending) + await withTestCleanup( + async () => { + const valid = lazyRequest({ limit: h.limit }, h.descending) + const rank = new IR.PropRef([`rank`]) + const invalid = [ + { ...valid, orderBy: lazyOrder(!h.descending) }, + { + ...valid, + where: new IR.Func(`gt`, [rank, new IR.Value(h.offset)]), + }, + { ...valid, offset: -h.offset }, + { + ...valid, + cursor: { + whereFrom: new IR.Func(`eq`, [ + rank, + new IR.Value(h.offset), + ]), + whereCurrent: new IR.Func(`eq`, [ + rank, + new IR.Value(h.offset), + ]), + }, + }, + ] + for (const request of invalid) { + await expect( + capability.source._sync.loadSubset(request), + ).rejects.toBeInstanceOf(Error) + capability.source._sync.unloadSubset(request) + } + expect(capability.remote.slots).toHaveLength(0) + reach.reach(`unsupported-shape-rejected`) + const peer = capability.load({ limit: h.limit }) + await capability.remote.pump() + await peer.complete() + for (const request of invalid) + capability.source._sync.unloadSubset(request) + assertLazyCoverage( + [...capability.source.values()], + rows, + { limit: h.limit }, + h.descending, + ) + reach.reach(`rejected-unload-preserves-peer`) + peer.release() + }, + () => capability.source.cleanup(), + ) + const empty = lazyFixture([], h.pageSize, h.descending) + await withTestCleanup( + async () => { + const load = empty.load({ limit: h.limit }) + await empty.remote.pump() + await load.complete() + expect([...empty.source.values()]).toEqual([]) + reach.reach(`authoritative-empty`) + load.release() + }, + () => empty.source.cleanup(), + ) + }), + { numRuns: runBudget, ...parameters }, + ), + { fixedSeed }, + ) +}, 60000) + +it(`starts no unneeded ranges and preserves gap-free growth with healthy peers`, async () => { + await runOracleCampaign( + `CONVEX_LAZY_WORK_ORACLE`, + [ + `no-demand`, + `zero-limit`, + `finite-with-unread-tail`, + `gap-free-growth-across-page`, + `shallow-read-during-growth`, + `peer-survives-release`, + `same-object-pending-attempts`, + ] as const, + (reach, parameters) => + fc.assert( + fc.asyncProperty(history, async (h) => { + reach.beginHistory() + const rows = rowsFor(h, false) + const f = lazyFixture(rows, 4, h.descending) + await withTestCleanup( + async () => { + await f.remote.pump() + expect( + f.remote.slots, + `lazy work: no row-range without demand`, + ).toHaveLength(0) + reach.reach(`no-demand`) + const zero = f.load({ limit: 0 }) + await zero.complete() + expect(f.remote.slots).toHaveLength(0) + reach.reach(`zero-limit`) + zero.release() + const a = f.load({ limit: 3 }) + let duplicateSettled = false + const duplicate = f.source._sync.loadSubset(a.request) + void Promise.resolve(duplicate).then(() => { + duplicateSettled = true + }) + await f.remote.pump() + await a.complete() + expect( + duplicateSettled, + `lazy progress: duplicate pending attempt stranded`, + ).toBe(true) + await duplicate + reach.reach(`same-object-pending-attempts`) + assertLazyCoverage( + [...f.source.values()], + rows, + { limit: 3 }, + h.descending, + ) + expect(f.source.size).toBeLessThan(rows.length) + reach.reach(`finite-with-unread-tail`) + const b = f.load({ limit: 6 }) + await f.remote.pump( + (slot) => + slot.opts.cursor !== null && slot.opts.endCursor == null, + ) + // Newly held deeper work must not make already applied shallow coverage + // unavailable. This cut supplies no delayed cancellation premise. + let shallow = false + const peer = f.load({ limit: 3 }) + void Promise.resolve(peer.result).then(() => { + shallow = true + }) + await f.remote.pump(() => true) + expect(shallow, `lazy shallow read blocked by deeper work`).toBe( + true, + ) + reach.reach(`shallow-read-during-growth`) + a.release() + peer.release() + await f.remote.pump() + await b.complete() + assertLazyCoverage( + [...f.source.values()], + rows, + { limit: 6 }, + h.descending, + ) + reach.reach(`gap-free-growth-across-page`) + reach.reach(`peer-survives-release`) + for (const limit of h.suffix) { + const next = f.load({ limit }) + await f.remote.pump() + await next.complete() + assertLazyCoverage( + [...f.source.values()], + rows, + { limit }, + h.descending, + ) + next.release() + } + b.release() + }, + () => f.source.cleanup(), + ) + }), + { numRuns: runBudget, ...parameters }, + ), + { fixedSeed }, + ) +}, 60000) + +it(`publishes complete initial, moved and repaired live windows`, async () => { + await runOracleCampaign( + `CONVEX_LAZY_PUBLICATION_ORACLE`, + [ + `pending-initial-window`, + `pending-window-move`, + `pending-boundary-repair`, + `completed-boundary-refill`, + `coherent-cache-before-callback`, + `refetch-cache-before-callback`, + ] as const, + (reach, parameters) => + fc.assert( + fc.asyncProperty(history, async (h) => { + reach.beginHistory() + let rows = rowsFor(h, false) + const f = lazyFixture(rows, 2, h.descending) + const permission = {} + f.remote.scopes.set(permission, { limit: 3 }) + const live = createLiveQueryCollection((q) => + q + .from({ row: f.source }) + .orderBy(({ row }) => row.rank, h.descending ? `desc` : `asc`) + .orderBy(({ row }) => row.id, h.descending ? `desc` : `asc`) + .select(({ row }) => row) + .limit(3), + ) + const trace: Array> = [] + const subscription = live.subscribeChanges(() => + trace.push(copyLazyRows(live.toArray)), + ) + const read = () => { + trace.push(copyLazyRows(live.toArray)) + } + await withTestCleanup( + async () => { + const ready = live.preload() + void ready.catch(() => {}) + await f.remote.pump( + (slot) => + slot.opts.cursor !== null && slot.opts.endCursor == null, + h.reverse, + read, + ) + expect(copyLazyRows(live.toArray)).toEqual([]) + reach.reach(`pending-initial-window`) + await f.remote.pump(() => false, h.reverse, read) + await ready + const initial = expectedLazyRows(rows, { limit: 3 }, h.descending) + expect(copyLazyRows(live.toArray)).toEqual(initial) + assertLazyPublications(trace, [], initial) + trace.length = 0 + f.remote.scopes.set(permission, { offset: 3, limit: 4 }) + const moved = Promise.resolve( + live.utils.setWindow({ offset: 3, limit: 4 }), + ) + void moved.catch(() => {}) + await f.remote.pump( + (slot) => + slot.opts.cursor !== null && slot.opts.endCursor == null, + h.reverse, + read, + ) + expect(copyLazyRows(live.toArray)).toEqual(initial) + reach.reach(`pending-window-move`) + await f.remote.pump(() => false, h.reverse, read) + await moved + const next = expectedLazyRows( + rows, + { offset: 3, limit: 4 }, + h.descending, + ) + expect(copyLazyRows(live.toArray)).toEqual(next) + assertLazyPublications(trace, initial, next) + trace.length = 0 + // Remove every currently acquired row. Existing bounded intervals become + // empty; only a successor can supply the required replacement window. + const retained = new Set( + [...f.source.values()].map((row) => row.id), + ) + rows = rows.filter((row) => !retained.has(row.id)) + f.remote.change(rows) + await f.remote.pump( + (slot) => + slot.opts.cursor !== null && slot.opts.endCursor == null, + h.reverse, + read, + ) + expect(copyLazyRows(live.toArray)).toEqual(next) + reach.reach(`pending-boundary-repair`) + await f.remote.pump(() => false, h.reverse, read) + expect(copyLazyRows(live.toArray)).toEqual( + expectedLazyRows(rows, { offset: 3, limit: 4 }, h.descending), + ) + assertLazyPublications( + trace, + next, + expectedLazyRows(rows, { offset: 3, limit: 4 }, h.descending), + ) + reach.reach(`completed-boundary-refill`) + reach.reach(`coherent-cache-before-callback`) + trace.length = 0 + const prior = copyLazyRows(live.toArray) + rows = rows.map((row) => ({ + ...row, + text: `${row.text} refreshed`, + })) + f.remote.change(rows) + f.remote.installCaches() + let refreshed = false + const refetch = f.load({ offset: 3, limit: 4 }, undefined, true) + void Promise.resolve(refetch.result).then(() => { + refreshed = true + }) + // No callback runs: explicit refetch must read installed caches. + await f.remote.pump(() => true, h.reverse, read) + expect( + refreshed, + `lazy refetch left a coherent cache unread`, + ).toBe(true) + await refetch.complete() + const fresh = expectedLazyRows( + rows, + { offset: 3, limit: 4 }, + h.descending, + ) + expect(copyLazyRows(live.toArray)).toEqual(fresh) + assertLazyPublications(trace, prior, fresh) + reach.reach(`refetch-cache-before-callback`) + refetch.release() + }, + () => subscription.unsubscribe(), + () => live.cleanup(), + () => f.source.cleanup(), + ) + }), + { numRuns: runBudget, ...parameters }, + ), + { fixedSeed }, + ) +}, 60000) + +it(`waits for applied receipts, respects abort and fences callbacks across restart`, async () => { + await runOracleCampaign( + `CONVEX_LAZY_LIFECYCLE_ORACLE`, + [ + `actual-establishing-set-checked`, + `abort-before-acceptance`, + `abort-after-acceptance`, + `stale-after-restart`, + `same-run-reconnect-recovery`, + `cleanup-start-fences-callback`, + `held-cleanup-fences-callback`, + `old-dependent-stays-terminal`, + `retired-error-and-status-fenced`, + `fresh-dependent-after-restart`, + ] as const, + (reach, parameters) => + fc.assert( + fc.asyncProperty(history, async (h) => { + reach.beginHistory() + const rows = rowsFor(h, false) + let releaseBlock = () => {} + let cleanDependent = async () => {} + let cleanFresh = async () => {} + let finishCleanup!: () => void + const cleanupGate = new Promise((resolve) => { + finishCleanup = resolve + }) + const f = lazyFixture( + rows, + h.pageSize, + h.descending, + `none`, + false, + () => cleanupGate, + ) + await withTestCleanup( + async () => { + const controller = new AbortController() + const canceled = f.load({ limit: 3 }, controller.signal) + let rejected = false + void Promise.resolve(canceled.result).catch(() => { + rejected = true + }) + controller.abort() + await f.remote.pump() + expect(rejected).toBe(true) + expect(f.source.size).toBe(0) + reach.reach(`abort-before-acceptance`) + canceled.release() + // An optimistic write outside the query parks every following ordinary + // sync commit. This is a real core receipt, not a substituted promise. + let release!: () => void + const transaction = createTransaction({ + mutationFn: () => + new Promise((resolve) => { + release = resolve + releaseBlock = resolve + }), + }) + transaction.mutate(() => + f.source.insert({ id: `blocker`, rank: 1000, text: `held` }), + ) + const after = new AbortController() + let settled = false + const loading = f.load({ limit: 3 }, after.signal) + void Promise.resolve(loading.result).then( + () => { + settled = true + }, + () => { + settled = true + }, + ) + await f.remote.pump() + expect( + settled, + `lazy receipt: successful load before application`, + ).toBe(false) + expect(f.receipts.some((receipt) => receipt !== true)).toBe(true) + after.abort() + await f.remote.pump() + expect( + settled, + `lazy abort: accepted rows must apply first`, + ).toBe(false) + release() + await transaction.isPersisted.promise + await f.remote.pump() + await expect(loading.result).rejects.toMatchObject({ + name: `AbortError`, + }) + assertLazyCoverage( + [...f.source.values()], + rows, + { limit: 3 }, + h.descending, + ) + reach.reach(`actual-establishing-set-checked`) + reach.reach(`abort-after-acceptance`) + loading.release() + const current = f.load({ limit: 3 }) + await f.remote.pump() + await current.complete() + f.remote.disconnect() + const changed = rows.map((row, i) => + i === rows.length - 1 + ? { ...row, rank: h.descending ? 100 : -100 } + : row, + ) + f.remote.change(changed) + await f.remote.pump() + f.remote.reconnect() + await f.remote.pump() + assertLazyCoverage( + [...f.source.values()], + changed, + { limit: 3 }, + h.descending, + ) + reach.reach(`same-run-reconnect-recovery`) + current.release() + const dependent = createLiveQueryCollection((q) => + q + .from({ row: f.source }) + .orderBy(({ row }) => row.rank, h.descending ? `desc` : `asc`) + .select(({ row }) => row) + .limit(3), + ) + cleanDependent = () => dependent.cleanup() + const permission = {} + f.remote.scopes.set(permission, { limit: 3 }) + const ready = dependent.preload() + await f.remote.pump() + await ready + const dependentPublications: Array> = [] + const dependentStatuses: Array = [] + const stopDependentStatuses = dependent.on( + `status:change`, + ({ status }) => dependentStatuses.push(status), + ) + const dependentObserver = dependent.subscribeChanges( + () => + dependentPublications.push(copyLazyRows(dependent.toArray)), + { includeInitialState: false }, + ) + const cleaning = f.source.cleanup() + const staleCheckpoint = async () => { + const before = { + base: [...f.source.base.values()], + status: f.source.status, + statuses: [...f.statuses], + errors: [...f.errors], + syncError: f.source.utils.getSyncError(), + publications: [...f.publications], + dependentPublications: [...dependentPublications], + dependentStatuses: [...dependentStatuses], + dependent: [...dependent.values()], + dependentStatus: dependent.status, + } + f.remote.late(`success`) + f.remote.late(`error`) + for (let turn = 0; turn < 10; turn++) await Promise.resolve() + expect( + { + base: [...f.source.base.values()], + status: f.source.status, + statuses: [...f.statuses], + errors: [...f.errors], + syncError: f.source.utils.getSyncError(), + publications: [...f.publications], + dependentPublications: [...dependentPublications], + dependentStatuses: [...dependentStatuses], + dependent: [...dependent.values()], + dependentStatus: dependent.status, + }, + `retired callback retained authority across cleanup`, + ).toEqual(before) + } + await staleCheckpoint() + expect(f.source.has(`obsolete`)).toBe(false) + expect(dependent.status).toBe(`error`) + expect(() => f.source.startSyncImmediate()).toThrow() + reach.reach(`cleanup-start-fences-callback`) + await f.remote.pump() + await staleCheckpoint() + expect(f.source.has(`obsolete`)).toBe(false) + expect(f.source.status).not.toBe(`cleaned-up`) + reach.reach(`held-cleanup-fences-callback`) + finishCleanup() + await cleaning + // Core retains the terminal query's source subscription. Its new-run + // truncate requests a whole-source replay even though the old graph + // remains terminal. Account for that real demand; do not call it + // speculative work for the fresh bounded request. + f.remote.scopes.set(permission, {}) + f.source.startSyncImmediate() + const restarted = f.load({ limit: 3 }) + await f.remote.pump() + await restarted.complete() + await staleCheckpoint() + await f.remote.pump() + expect(f.source.has(`obsolete`)).toBe(false) + assertLazyCoverage( + [...f.source.values()], + changed, + { limit: 3 }, + h.descending, + ) + reach.reach(`stale-after-restart`) + expect(dependent.status).toBe(`error`) + reach.reach(`old-dependent-stays-terminal`) + reach.reach(`retired-error-and-status-fenced`) + const fresh = createLiveQueryCollection((q) => + q + .from({ row: f.source }) + .orderBy(({ row }) => row.rank, h.descending ? `desc` : `asc`) + .orderBy(({ row }) => row.id, h.descending ? `desc` : `asc`) + .select(({ row }) => row) + .limit(3), + ) + cleanFresh = () => fresh.cleanup() + const freshReady = fresh.preload() + await f.remote.pump() + await freshReady + expect(copyLazyRows(fresh.toArray)).toEqual( + expectedLazyRows(changed, { limit: 3 }, h.descending), + ) + expect(fresh.status).toBe(`ready`) + expect(dependent.status).toBe(`error`) + reach.reach(`fresh-dependent-after-restart`) + await fresh.cleanup() + stopDependentStatuses() + dependentObserver.unsubscribe() + await dependent.cleanup() + f.remote.scopes.delete(permission) + restarted.release() + }, + () => { + releaseBlock() + finishCleanup() + return f.source.cleanup() + }, + () => cleanDependent(), + () => cleanFresh(), + ) + }), + { numRuns: runBudget, ...parameters }, + ), + { fixedSeed }, + ) +}, 60000) + +/** A publication callback may open a second optimistic hold and install the + * next source revision before the older applied receipt's continuation runs. + * Each cached acquisition still owes the actual latest establishing receipt. + * The two receipts here come from core; no promise is substituted. */ +it(`keeps the newer establishing receipt through publication reentry`, async () => { + await runOracleCampaign( + `CONVEX_LAZY_RECEIPT_ORACLE`, + [`reentrant-second-held-receipt`, `cached-load-waits-for-latest`] as const, + (reach, parameters) => + fc.assert( + fc.asyncProperty(history, async (h) => { + reach.beginHistory() + const rows = rowsFor(h, false) + const target = ordered(rows, h.descending)[0]! + const fresh = rows.map((row) => + row.id === target.id ? { ...row, text: `${row.text} new` } : row, + ) + const f = lazyFixture(rows, Math.max(2, h.pageSize), h.descending) + let open1 = () => {} + let open2 = () => {} + const firstBlock = createTransaction({ + mutationFn: () => + new Promise((resolve) => { + open1 = resolve + }), + }) + firstBlock.mutate(() => + f.source.insert({ + id: `block1`, + rank: h.descending ? -1000 : 1000, + text: `held`, + }), + ) + const first = f.load({ limit: 1 }) + await f.remote.pump() + let second: ReturnType | undefined + let cached: ReturnType | undefined + let secondBlock: ReturnType | undefined + let started = false + const subscription = f.source.subscribeChanges( + (changes) => { + if ( + started || + !changes.some((change) => change.key === target.id) + ) + return + started = true + secondBlock = createTransaction({ + mutationFn: () => + new Promise((resolve) => { + open2 = resolve + }), + }) + secondBlock.mutate(() => + f.source.insert({ + id: `block2`, + rank: h.descending ? -1001 : 1001, + text: `held`, + }), + ) + f.remote.change(fresh) + f.remote.installCaches() + second = f.load({ limit: 1 }) + }, + { includeInitialState: false }, + ) + await withTestCleanup( + async () => { + open1() + await firstBlock.isPersisted.promise + await f.remote.pump() + expect(started).toBe(true) + expect( + f.receipts.filter((receipt) => receipt !== true).length, + ).toBeGreaterThanOrEqual(2) + expect(f.source.get(target.id)!.text).toBe(target.text) + reach.reach(`reentrant-second-held-receipt`) + let settled = false + cached = f.load({ limit: 1 }) + void Promise.resolve(cached.result).then( + () => { + settled = true + }, + () => { + settled = true + }, + ) + await f.remote.pump() + expect( + settled, + `lazy receipt: cached load skipped newer establishing receipt`, + ).toBe(false) + reach.reach(`cached-load-waits-for-latest`) + open2() + await secondBlock!.isPersisted.promise + await f.remote.pump() + await first.complete() + await second!.complete() + await cached.complete() + assertLazyCoverage( + [...f.source.values()], + fresh, + { limit: 1 }, + h.descending, + ) + }, + () => { + open1() + open2() + }, + () => subscription.unsubscribe(), + () => { + first.release() + second?.release() + cached?.release() + }, + () => f.source.cleanup(), + ) + }), + { numRuns: runBudget, ...parameters }, + ), + { fixedSeed }, + ) +}, 60000) + +// Wrong-answer controls distinguish missing same-rank coverage, skipped +// overshoot, transient publication and hidden-suffix knowledge independently +// of the production driver. Legal batch/cached and reacquisition behavior +// carries no mandatory second-receipt or retained-tail assertion. +it(`rejects wrong coverage and transient windows while preserving discovery freedom`, () => { + const rows = [ + { id: `a`, rank: 1, text: `` }, + { id: `b`, rank: 1, text: `` }, + { id: `c`, rank: 2, text: `` }, + ] + expect(() => assertLazyCoverage([rows[0]!], rows, { limit: 1 })).toThrow( + `lazy coverage`, + ) + expect(() => assertLazyPublications([[rows[0]!]], [], rows)).toThrow( + `lazy publication`, + ) + for (const suffix of [ + [{ id: `c`, rank: 3, text: `` }], + [{ id: `d`, rank: 2, text: `` }], + ]) { + const delivered = [ + { id: `a`, rank: 1, text: `` }, + { id: `b`, rank: 2, text: `` }, + ] + expect(deliveredScopeComplete(delivered, false, { limit: 2 })).toBe(false) + expect(expectedLazyRows([...delivered, ...suffix], { limit: 2 })).toEqual( + delivered, + ) + } +}) + +// Configuration contradictions fail before a subscription can acquire state. +it.each([ + { field: `rank`, direction: `sideways` }, + { field: 1, direction: `asc` }, + { field: ``, direction: `asc` }, +])(`rejects malformed order declarations %j`, (queryOrder) => { + expect(() => + convexPaginatedCollectionOptions({ + client: {}, + query: lazyQuery, + queryArgs: {}, + syncMode: `on-demand`, + queryOrder, + getKey: (row: LazyRow) => row.id, + } as unknown as ConvexPaginatedCollectionConfig), + ).toThrow(TypeError) +}) + +it.each([0, -1, 1.5, Infinity, NaN])( + `rejects invalid lazy row caps before readiness: %s`, + async (maxRows) => { + const f = lazyFixture([], 1) + await f.source.cleanup() + const source = createCollection( + convexPaginatedCollectionOptions({ + client: f.remote.client, + query: lazyQuery, + queryArgs: {}, + syncMode: `on-demand`, + queryOrder: { field: `rank`, direction: `asc` }, + maxRows, + getKey: (row) => row.id, + }), + ) + const statuses: Array = [] + const off = source.on(`status:change`, ({ status }) => { + statuses.push(status) + }) + await withTestCleanup( + async () => { + await expect(source.preload()).rejects.toBeInstanceOf(Error) + expect(source.utils.getSyncError()).toBeInstanceOf(RangeError) + expect( + statuses, + `lazy startup: invalid provider config reported ready`, + ).not.toContain(`ready`) + expect(f.remote.slots).toHaveLength(0) + }, + off, + () => source.cleanup(), + ) + }, +) diff --git a/packages/convex-db-collection/tests/lazy-paging-receipt-model.ts b/packages/convex-db-collection/tests/lazy-paging-receipt-model.ts new file mode 100644 index 0000000000..4fd7a1f4b5 --- /dev/null +++ b/packages/convex-db-collection/tests/lazy-paging-receipt-model.ts @@ -0,0 +1,162 @@ +/** + * Scientific settlement model. Authority: LoadSubsetFn, SyncAppliedReceipt and + * LoadSubsetOptions.signal in db/src/types.ts. A successful attempt owes its + * exact establishing receipts; cancellation cannot withdraw accepted writes. + * + * Facts are independently authored row/version tokens. The driver declares + * which actual batches establish which attempts, then records their real + * applied receipts and caller outcomes. The relation is many-to-many: one + * accepted batch can establish several attempts. Pages, cursor encodings, + * queues and the adapter's chosen commit partition have no model meaning. + * Zero new receipts is legal only when the required facts already applied. + * Cancellation with no accepted work may settle promptly. Accepted work + * cannot be withdrawn and its caller waits for application even on cancellation + * (LoadSubsetOptions.signal). Ordinary rejection discharges no receipt. + * An uncancelable baseline initiated before abort may still be accepted later. + * The driver authors that in-flight obligation explicitly; it is neither a + * provider response count nor a pager phase. Core permits fulfillment after + * abort once coverage and receipts apply. Receivers separately check their + * own cancellation outcome policy. + * Cleanup is separate authority: it cancels remaining attempts and permits + * actual pending receipts to reject as abandoned (SyncAppliedReceipt). Merely + * calling cleanup does not discharge a receipt; its rejection must be observed. + */ +export class LazyReceiptModel { + readonly events: Array = [] + private readonly attempts = new Map< + string, + { + facts: Set + receipts: Set + aborted: boolean + settled: boolean + inFlight: Set + } + >() + private readonly receipts = new Map< + string, + { facts: Set; state: `pending` | `applied` | `abandoned` } + >() + private readonly appliedFacts = new Set() + private cleanedUp = false + + acquire(id: string, facts: ReadonlyArray): void { + if (this.cleanedUp) + throw new Error(`receipt model: acquisition after cleanup`) + if (this.attempts.has(id)) + throw new Error(`receipt model: duplicate attempt`) + this.attempts.set(id, { + facts: new Set(facts), + receipts: new Set(), + aborted: false, + settled: false, + inFlight: new Set(), + }) + this.events.push(`acquire ${id}`) + } + + initiate(id: string, work: string): void { + const attempt = this.attempts.get(id)! + if (attempt.aborted || attempt.settled || attempt.inFlight.has(work)) + throw new Error(`receipt model: invalid in-flight initiation`) + attempt.inFlight.add(work) + this.events.push(`initiate ${id} ${work}`) + } + + finish(id: string, work: string): void { + if (!this.attempts.get(id)!.inFlight.delete(work)) + throw new Error(`receipt model: unknown in-flight completion`) + this.events.push(`finish ${id} ${work}`) + } + + accept( + id: string, + facts: ReadonlyArray, + establishes: ReadonlyArray, + ): void { + if (this.cleanedUp) + throw new Error(`receipt model: acceptance after cleanup`) + if (this.receipts.has(id)) + throw new Error(`receipt model: duplicate receipt`) + this.receipts.set(id, { facts: new Set(facts), state: `pending` }) + for (const attempt of establishes) { + const demand = this.attempts.get(attempt)! + if (demand.settled) + throw new Error(`receipt model: establishment after attempt ended`) + demand.receipts.add(id) + } + this.events.push(`accept ${id} -> ${establishes.join(`,`)}`) + } + + apply(id: string): void { + const receipt = this.receipts.get(id)! + if (receipt.state !== `pending`) + throw new Error(`receipt model: duplicate or abandoned application`) + receipt.state = `applied` + for (const fact of receipt.facts) this.appliedFacts.add(fact) + this.events.push(`apply ${id}`) + } + + cleanup(): void { + this.cleanedUp = true + for (const attempt of this.attempts.values()) attempt.aborted = true + this.events.push(`cleanup`) + } + + abandon(id: string): void { + const receipt = this.receipts.get(id)! + if (!this.cleanedUp || receipt.state !== `pending`) + throw new Error(`receipt law: abandonment without pending cleanup work`) + receipt.state = `abandoned` + this.events.push(`abandon ${id}`) + } + + abort(id: string): void { + this.attempts.get(id)!.aborted = true + this.events.push(`abort ${id}`) + } + + observe(id: string, outcome: `success` | `canceled` | `rejected`): void { + const attempt = this.attempts.get(id)! + if (attempt.settled) + throw new Error(`receipt law: duplicate settlement ${id}`) + if (outcome !== `rejected` && attempt.inFlight.size) + throw new Error( + `receipt law: ${id} settled before in-flight work finished`, + ) + if (outcome !== `rejected`) + for (const receipt of attempt.receipts) + if ( + this.receipts.get(receipt)!.state === `pending` || + (outcome === `success` && + this.receipts.get(receipt)!.state === `abandoned`) + ) + throw new Error( + `receipt law: ${id} settled before ${receipt} applied`, + ) + if (outcome === `success`) { + for (const fact of attempt.facts) + if (!this.appliedFacts.has(fact)) + throw new Error(`receipt law: ${id} lacks applied fact ${fact}`) + } + if (outcome === `canceled` && !attempt.aborted) + throw new Error(`receipt law: cancellation without abort ${id}`) + attempt.settled = true + this.events.push(`${outcome} ${id}`) + } + + /** Driver has released every authored establishing hold and drained the + * responsive environment. This is a semantic quiescent checkpoint, never a + * page/call budget or a claim about an arbitrary infinite execution. */ + complete(): void { + for (const [id, attempt] of this.attempts) { + if (!attempt.settled) + throw new Error(`receipt progress: quiescent attempt ${id} stranded`) + if (attempt.inFlight.size) + throw new Error(`receipt progress: in-flight work for ${id} stranded`) + } + for (const [id, receipt] of this.receipts) + if (receipt.state === `pending`) + throw new Error(`receipt law: accepted work ${id} abandoned`) + } +} 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 new file mode 100644 index 0000000000..4821290dbd --- /dev/null +++ b/packages/convex-db-collection/tests/lazy-paging-receipt-oracle.property.test.ts @@ -0,0 +1,426 @@ +/** + * Exact establishment is a relation between attempts and accepted batches, + * independently of provider response partition. LoadSubsetFn and + * SyncAppliedReceipt in db/src/types.ts require every establishing receipt on + * success and retain accepted writes after cancellation. A shared batch may + * establish two callers; a cached caller may need no new batch. + * + * This controlled adapter exercises the real Collection boundary with actual + * commit receipts and an optimistic transaction that holds application. It is + * a receiving check of the core contract used by Convex, not a second Convex + * pager. Authored facts and establishment edges feed the scientific model; + * the driver's response grouping and commit partition never enter that model. + * Native Convex histories separately test its actual batching strategy. + * + * Each generated history crosses cached zero, one and several establishing + * receipts, a shared receipt, cancellation between receipts, a successful peer + * while another receipt waits, synchronous true and throw. Ordinary rejection + * is distinct from cancellation. After all environment holds are released, a + * named quiescent comparison detects a stranded attempt. No finite test claims + * termination of an arbitrary nonquiescent execution. + */ +import { createCollection, createTransaction } from '@tanstack/db' +import fc from 'fast-check' +import { expect, it } from 'vitest' +import { runOracleCampaign } from '../../db/tests/oracle-campaign' +import { withTestCleanup } from './helpers' +import { LazyReceiptModel } from './lazy-paging-receipt-model' +import type { + LoadSubsetOptions, + SyncAppliedReceipt, + SyncConfig, +} from '@tanstack/db' + +type Row = { id: string; text: string } +const gate = () => { + let resolve!: () => void + const promise = new Promise((done) => { + resolve = done + }) + return { promise, resolve } +} +const turns = async () => { + for (let i = 0; i < 20; i++) await Promise.resolve() +} +type Fault = + | `none` + | `borrow-peer` + | `first-only` + | `skip-response` + | `content-only` + | `prompt-cancel` + | `stranded` + +async function receiptHistory( + h: { + responses: number + batches: number + cancel: boolean + reverse: boolean + text: string + }, + fault: Fault = `none`, +) { + const model = new LazyReceiptModel() + const providerDone = gate() + const attempts = new Map< + number, + { + id: string + done: ReturnType + controller: AbortController + receipts: Array + outcome?: `success` | `canceled` | `rejected` + } + >() + let controls!: Parameters[`sync`]>[0] + const source = createCollection({ + getKey: (row) => row.id, + syncMode: `on-demand`, + sync: { + sync: (sync) => { + controls = sync + sync.markReady() + return { + loadSubset: (request) => { + if (request.limit === 99) throw new Error(`authored throw`) + if (request.limit === 98) return true + if (request.limit === 95) + return Promise.reject(new Error(`authored rejection`)) + if (request.limit === 96 || request.limit === 97) { + const count = request.limit === 96 ? 1 : h.batches + for (let i = 0; i < count; i++) { + const fact = `synchronous-${request.limit}-${i}` + available.add(fact) + if (accept(fact, [fact], [request.limit]) !== true) + throw new Error(`synchronous fixture unexpectedly deferred`) + } + return true + } + const attempt = attempts.get(request.limit!)! + if (fault === `stranded` && attempt.id === `cached`) + return new Promise(() => {}) + return attempt.done.promise.then(async () => { + if ( + (attempt.id === `a` || attempt.id === `shared`) && + fault !== `skip-response` + ) + await providerDone.promise + if (fault === `prompt-cancel` && request.signal?.aborted) + throw Object.assign(new Error(`canceled`), { + name: `AbortError`, + }) + const receipts = + fault === `content-only` && attempt.id === `idempotent` + ? [] + : fault === `borrow-peer` && attempt.id === `a` + ? attempts.get(1)!.receipts + : fault === `first-only` + ? attempt.receipts.slice(0, 1) + : attempt.receipts + await Promise.all(receipts) + if (request.signal?.aborted) + throw Object.assign(new Error(`canceled`), { + name: `AbortError`, + }) + }) + }, + } + }, + }, + }) + source.startSyncImmediate() + const requests: Array = [] + const accepted: Array<{ + id: string + facts: Array + applied: boolean + receipt: SyncAppliedReceipt + }> = [] + const errors: Array = [] + const facts = Array.from({ length: 6 }, (_, i) => `a${i}`) + // Provider grouping and actual batch grouping vary independently. The + // driver holds every response boundary before supplying the next group. + // Provider completion gates the real adapter result; no cursor is modeled. + const responses = Array.from({ length: h.responses }, (_, i) => + facts.filter((_fact, j) => j % h.responses === i), + ) + const available = new Set() + const batches = Array.from({ length: h.batches }, (_, i) => + facts.filter((_fact, j) => j % h.batches === i), + ) + if (h.reverse) batches.reverse() + const acquire = (key: number, id: string, required: Array) => { + model.acquire(id, required) + const attempt = { + id, + done: gate(), + controller: new AbortController(), + receipts: [] as Array, + outcome: undefined as `success` | `canceled` | `rejected` | undefined, + } + attempts.set(key, attempt) + const request = { limit: key, signal: attempt.controller.signal } + requests.push(request) + const result = source._sync.loadSubset(request) + void Promise.resolve(result).then( + () => { + attempt.outcome = `success` + try { + model.observe(id, `success`) + } catch (error) { + errors.push(error) + } + }, + (error: unknown) => { + const canceled = error instanceof Error && error.name === `AbortError` + attempt.outcome = canceled ? `canceled` : `rejected` + try { + model.observe(id, attempt.outcome) + } catch (failure) { + errors.push(failure) + } + }, + ) + return attempt + } + const accept = (id: string, values: Array, owners: Array) => { + expect(values.every((fact) => available.has(fact) || fact === `peer`)).toBe( + true, + ) + controls.begin() + for (const fact of values) + controls.write({ + type: source.base.has(fact) ? `update` : `insert`, + value: { id: fact, text: h.text }, + }) + const receipt = controls.commit() + model.accept( + id, + values, + owners.map((key) => attempts.get(key)!.id), + ) + const batch = { id, facts: values, applied: false, receipt } + accepted.push(batch) + for (const key of owners) attempts.get(key)!.receipts.push(receipt) + const applied = () => { + try { + for (const fact of values) + expect( + source.base.get(fact)?.text, + `receipt fulfilled before its base facts applied`, + ).toBe(h.text) + batch.applied = true + model.apply(id) + } catch (error) { + errors.push(error) + } + } + if (receipt === true) applied() + else void receipt.then(applied, (error: unknown) => errors.push(error)) + return receipt + } + let release = () => {} + await withTestCleanup( + async () => { + const peer = acquire(1, `peer`, [`peer`]) + accept(`peer-batch`, [`peer`], [1]) + const a = acquire(2, `a`, facts) + const shared = acquire(3, `shared`, facts) + a.done.resolve() + shared.done.resolve() + // Completing the caller-side work before provider delivery cannot produce + // success. Each response group really crosses this adapter's input gate. + await turns() + if (errors.length) throw errors[0] + expect(a.outcome, `success without source response`).toBeUndefined() + for (let i = 0; i < responses.length; i++) { + for (const fact of responses[i]!) available.add(fact) + if (i < responses.length - 1) { + await turns() + if (errors.length) throw errors[0] + expect( + a.outcome, + `success with an undelivered response`, + ).toBeUndefined() + } + } + providerDone.resolve() + // The first receipt applies before the unrelated optimistic hold. Every + // remaining partition is accepted while application waits. + accept(`a0`, batches[0]!, [2, 3]) + const held = gate() + release = held.resolve + const blocker = createTransaction({ mutationFn: () => held.promise }) + blocker.mutate(() => + source.insert({ id: `optimistic-only`, text: `overlay` }), + ) + for (let i = 1; i < batches.length; i++) + accept(`a${i}`, batches[i]!, [2, 3]) + if (h.cancel) { + a.controller.abort() + model.abort(`a`) + } + peer.done.resolve() + a.done.resolve() + shared.done.resolve() + await turns() + if (errors.length) throw errors[0] + expect( + peer.outcome, + `unrelated held work stranded the established peer`, + ).toBe(`success`) + if (h.batches > 1) { + expect( + shared.outcome, + `shared caller skipped an establishing receipt`, + ).toBeUndefined() + if (!h.cancel) + expect( + a.outcome, + `caller skipped its exact receipt set`, + ).toBeUndefined() + expect(accepted.slice(2).every((batch) => batch.receipt !== true)).toBe( + true, + ) + } + // 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 turns() + if (errors.length) throw errors[0] + expect(shared.outcome).toBe(`success`) + expect(a.outcome).toBe(h.cancel ? `canceled` : `success`) + // Identical facts are already visible, but a newly establishing receipt + // still waits. Content coincidence must not stand in for that receipt. + const same = acquire(5, `idempotent`, [facts[0]!]) + const secondHold = gate() + release = secondHold.resolve + const secondBlocker = createTransaction({ + mutationFn: () => secondHold.promise, + }) + secondBlocker.mutate(() => + source.insert({ id: `second-overlay`, text: `overlay` }), + ) + expect(accept(`same-facts`, [facts[0]!], [5])).not.toBe(true) + same.done.resolve() + await turns() + if (errors.length) throw errors[0] + expect( + same.outcome, + `visible facts hid a held establishing receipt`, + ).toBeUndefined() + release() + await secondBlocker.isPersisted.promise + await turns() + if (errors.length) throw errors[0] + expect(same.outcome).toBe(`success`) + // Cached success owes no new receipt: the same authored facts already apply. + const cached = acquire(4, `cached`, facts) + cached.done.resolve() + await turns() + if (errors.length) throw errors[0] + model.complete() + expect(cached.outcome).toBe(`success`) + for (const key of [96, 97]) { + const count = key === 96 ? 1 : h.batches + const required = Array.from( + { length: count }, + (_, i) => `synchronous-${key}-${i}`, + ) + const synchronous = acquire(key, `true-${key}`, required) + await turns() + if (errors.length) throw errors[0] + expect(synchronous.outcome).toBe(`success`) + } + const rejected = acquire(95, `rejected`, []) + await turns() + if (errors.length) throw errors[0] + expect(rejected.outcome).toBe(`rejected`) + model.acquire(`true`, facts) + expect(source._sync.loadSubset({ limit: 98 })).toBe(true) + model.observe(`true`, `success`) + model.acquire(`throw`, []) + const throwing = { limit: 99 } + expect(() => source._sync.loadSubset(throwing)).toThrow(`authored throw`) + model.observe(`throw`, `rejected`) + model.complete() + }, + () => release(), + () => { + for (const request of requests) source._sync.unloadSubset(request) + }, + () => source.cleanup(), + ) +} + +it(`checks exact shared receipt obligations independently of response partition`, async () => { + await runOracleCampaign( + `CONVEX_LAZY_EXACT_RECEIPT_ORACLE`, + [ + `response-held-before-acceptance`, + `idempotent-establishing-receipt`, + `zero-one-many-receipts`, + `shared-establishment`, + `cancellation-between-receipts`, + `unrelated-peer-progress`, + `true-and-throw`, + `quiescent-settlement`, + ] as const, + (reach, parameters) => + fc.assert( + fc.asyncProperty( + fc.record({ + responses: fc.integer({ min: 1, max: 3 }), + batches: fc.integer({ min: 2, max: 3 }), + reverse: fc.boolean(), + text: fc.string({ maxLength: 8 }), + }), + async (h) => { + reach.beginHistory() + for (const cancel of [false, true]) + await receiptHistory({ ...h, cancel }) + for (const state of [ + `response-held-before-acceptance`, + `idempotent-establishing-receipt`, + `zero-one-many-receipts`, + `shared-establishment`, + `cancellation-between-receipts`, + `unrelated-peer-progress`, + `true-and-throw`, + `quiescent-settlement`, + ] as const) + reach.reach(state) + }, + ), + { numRuns: 60, ...parameters }, + ), + { fixedSeed: 20261027 }, + ) +}, 60000) + +it.each([ + `borrow-peer`, + `first-only`, + `skip-response`, + `content-only`, + `prompt-cancel`, + `stranded`, +] as const)( + `rejects the %s faulty adapter at the real Collection boundary`, + async (fault) => { + await expect( + receiptHistory( + { + responses: 3, + batches: 2, + cancel: fault === `prompt-cancel`, + reverse: false, + text: ``, + }, + fault, + ), + ).rejects.toThrow(/receipt (law|progress)/) + }, +) 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 new file mode 100644 index 0000000000..c65eb059c2 --- /dev/null +++ b/packages/convex-db-collection/tests/lazy-paging-revalidation-oracle.property.test.ts @@ -0,0 +1,392 @@ +/** + * Revalidation owes actual establishing application even when its values equal + * the old base. A newer accepted revision can supersede those values before + * receipt observers run. Authority: LoadSubsetOptions.refetch/signal, + * LoadSubsetFn and SyncAppliedReceipt in db/src/types.ts. LazyReceiptModel + * judges settlement; LazyVersionModel and source provenance judge publication. + * + * This receiver invokes the native public paginated adapter and real core. + * The SDK fixture installs a coherent cache before explicit revalidation. + * Only synchronous commits inside that isolated invocation are attributed to + * it: no row inequality, page, queue or global-latest guess establishes debt. + * Fresh equal-valued SDK objects permit a genuinely accepted idempotent batch. + * An identical-cache revalidation may create zero new receipts. Both choices + * are legal; a receipt is owed only when core actually accepted it. + * + * Generated histories vary rows, page size, order, text and which caller aborts; + * every history receives both SDK shapes. Each forces equal-valued acceptance, + * later supersession, cancellation while held, cached revalidation and an + * unheld reactive revision. Reuse + * may accept another receipt or owe no new one; both refinements are permitted. + * Limits: coherent controlled SDK caches, synchronous isolated revalidation, + * complete stable-key source and nonreentrant commits. This does not claim a + * real server emits equal-valued query updates or general causal attribution. + */ +import { createTransaction } from '@tanstack/db' +import fc from 'fast-check' +import { expect, it } from 'vitest' +import { runOracleCampaign } from '../../db/tests/oracle-campaign' +import { withTestCleanup } from './helpers' +import { lazyFixture } from './lazy-paging-driver' +import { lazyOracleRuns, receiveBothSdks } from './lazy-paging-campaign' +import { assertLazySourceFacts, copyLazyRows } from './lazy-paging-model-oracle' +import { LazyReceiptModel } from './lazy-paging-receipt-model' +import { LazyVersionModel } from './lazy-paging-version-model-oracle' +import type { LazyRow } from './lazy-paging-model-oracle' + +const turns = async () => { + for (let i = 0; i < 30; i++) await Promise.resolve() +} +const fact = (row: LazyRow) => JSON.stringify([row.id, row.rank, row.text]) +const grammar = fc.record({ + size: fc.integer({ min: 5, max: 11 }), + pageSize: fc.integer({ min: 2, max: 4 }), + descending: fc.boolean(), + cancelFirst: fc.boolean(), + text: fc.string({ maxLength: 8 }), +}) +type History = typeof grammar extends fc.Arbitrary ? T : never + +async function receive( + h: History, + kind: `javascript` | `react`, + borrowApplied = process.env.CONVEX_REVALIDATION_FAULT === `borrow-applied`, +) { + const rows = Array.from({ length: h.size }, (_, rank) => ({ + id: String(rank), + rank, + text: h.text, + })) + const revised = rows.map((row) => ({ ...row, text: `${row.text}:new` })) + let latest = revised + const f = lazyFixture( + rows, + h.pageSize, + h.descending, + `none`, + false, + undefined, + kind, + borrowApplied ? () => true : undefined, + ) + const receipts = new LazyReceiptModel() + const versions = new LazyVersionModel() + const failures: Array = [] + const outcomes = new Map() + const edges = new Map>() + const loads: Array> = [] + const callbacks: Array> = [] + let call: string | undefined + let stopBatches = () => {} + let stopChanges = () => {} + let release = () => {} + let zeroReceiptReuse = false + const checkpoint = () => { + if (failures.length) { + const signature = (values: ReadonlyArray) => + JSON.stringify( + [...values].sort((a, b) => a.id.localeCompare(b.id)).map(fact), + ) + console.info( + `ORACLE_REVALIDATION_FAILURE ${JSON.stringify({ + kind, + finalMatchesLatest: + signature([...f.source.base.values()]) === signature(latest), + })}`, + ) + throw failures[0] + } + } + const acquire = ( + id: string, + required: Array, + refetch: boolean, + controller?: AbortController, + ) => { + receipts.acquire(id, required.map(fact)) + call = id + let load: ReturnType + try { + load = f.load({}, controller?.signal, refetch) + } finally { + call = undefined + } + loads.push(load) + void Promise.resolve(load.result).then( + () => { + outcomes.set(id, `success`) + try { + receipts.observe(id, `success`) + } catch (error) { + failures.push(error) + } + }, + (error: unknown) => { + const outcome = + error instanceof Error && error.name === `AbortError` + ? `canceled` + : `rejected` + outcomes.set(id, outcome) + try { + receipts.observe(id, outcome) + } catch (failure) { + failures.push(failure) + } + }, + ) + return load + } + await withTestCleanup( + async () => { + const warm = f.load({}) + loads.push(warm) + await f.remote.pump() + await warm.complete() + versions.offer(rows) + versions.observe([...f.source.base.values()]) + stopBatches = f.observeBatches((batch) => { + try { + const owners = call === undefined ? [] : [call] + edges.set(batch.id, owners) + receipts.accept(batch.id, batch.rows.map(fact), owners) + const apply = () => { + try { + receipts.apply(batch.id) + } catch (error) { + failures.push(error) + } + } + // A later FIFO revision can already be public at this callback. The + // real receipt proves application; public values are checked separately. + if (batch.receipt === true) apply() + else + void batch.receipt.then(apply, (error: unknown) => + failures.push(error), + ) + } catch (error) { + failures.push(error) + } + }) + const observing = f.source.subscribeChanges( + () => { + try { + const actual = copyLazyRows([...f.source.base.values()]) + callbacks.push(actual) + assertLazySourceFacts(actual, f.remote.deliveredFacts) + versions.observe(actual) + } catch (error) { + failures.push(error) + } + }, + { includeInitialState: false }, + ) + stopChanges = () => observing.unsubscribe() + let resolve!: () => void + const held = new Promise((done) => { + resolve = done + }) + release = resolve + const transaction = createTransaction({ mutationFn: () => held }) + transaction.mutate(() => + f.source.insert({ id: `overlay`, rank: 10000, text: `hold` }), + ) + + // Equal values, fresh SDK objects: explicit revalidation actually commits + // while the old equal facts are already applied. No changed fact can name it. + f.remote.change(rows) + f.remote.installCaches() + const controllers = [new AbortController(), new AbortController()] + const beforeEqual = f.batches.length + const equal = acquire(`equal`, rows, true, controllers[0]) + expect( + f.batches + .slice(beforeEqual) + .some( + (batch) => + edges.get(batch.id)?.includes(`equal`) && batch.receipt !== true, + ), + `idempotent revalidation needs actual held acceptance`, + ).toBe(true) + expect( + copyLazyRows([...f.source.base.values()]).sort((a, b) => + a.id.localeCompare(b.id), + ), + ).toEqual(copyLazyRows(rows).sort((a, b) => a.id.localeCompare(b.id))) + + versions.offer(revised) + f.remote.change(revised) + f.remote.installCaches() + const beforeNew = f.batches.length + const newer = acquire(`newer`, revised, true, controllers[1]) + expect( + f.batches + .slice(beforeNew) + .some( + (batch) => + edges.get(batch.id)?.includes(`newer`) && batch.receipt !== true, + ), + `superseding revalidation needs actual held acceptance`, + ).toBe(true) + const beforeReuse = f.batches.length + const reuse = acquire(`reuse`, revised, true) + zeroReceiptReuse = f.batches.length === beforeReuse + const canceled = h.cancelFirst ? `equal` : `newer` + receipts.abort(canceled) + controllers[h.cancelFirst ? 0 : 1]!.abort() + await turns() + checkpoint() + expect( + outcomes.size, + `revalidation settled before accepted application`, + ).toBe(0) + release() + await transaction.isPersisted.promise + await turns() + checkpoint() + await Promise.allSettled([equal.result, newer.result, reuse.result]) + expect(outcomes.get(canceled)).toBe(`canceled`) + expect(outcomes.get(h.cancelFirst ? `newer` : `equal`)).toBe(`success`) + expect(outcomes.get(`reuse`)).toBe(`success`) + expect( + callbacks.some( + (snapshot) => + JSON.stringify( + [...snapshot].sort((a, b) => a.id.localeCompare(b.id)), + ) === + JSON.stringify( + [...revised].sort((a, b) => a.id.localeCompare(b.id)), + ), + ), + `superseding revision never reached publication callback`, + ).toBe(true) + // Explicit refetch is not the ordinary reactive callback path. Exercise + // that path without a hold, so a transient rollback followed by correct + // final rows must remain observable and fail the retained comparison. + latest = rows.map((row) => ({ ...row, text: `${row.text}:reactive` })) + versions.offer(latest) + f.remote.change(latest) + await f.remote.pump() + await turns() + checkpoint() + expect( + callbacks.some( + (snapshot) => + JSON.stringify( + [...snapshot].sort((a, b) => a.id.localeCompare(b.id)), + ) === + JSON.stringify( + [...latest].sort((a, b) => a.id.localeCompare(b.id)), + ), + ), + `reactive revision never reached publication callback`, + ).toBe(true) + receipts.complete() + }, + () => release(), + () => loads.forEach((load) => load.release()), + () => f.source.cleanup(), + () => { + stopBatches() + stopChanges() + }, + ) + return { zeroReceiptReuse } +} + +it(`receives equal-valued and superseding revalidation through actual receipts`, async () => { + await runOracleCampaign( + `CONVEX_REVALIDATION_ORACLE`, + [ + `equal-held`, + `superseding-held`, + `cached-revalidation`, + `canceled-held`, + `coherent-callback`, + `unheld-reactive-publication`, + `javascript`, + `react`, + ] as const, + async (reach, parameters) => { + let zeroReceiptReuses = 0 + await fc.assert( + fc.asyncProperty(grammar, async (h) => { + reach.beginHistory() + for (const [kind, observed] of await receiveBothSdks((sdk) => + receive(h, sdk), + )) { + reach.reach(kind) + if (observed.zeroReceiptReuse) zeroReceiptReuses++ + } + for (const state of [ + `equal-held`, + `superseding-held`, + `cached-revalidation`, + `canceled-held`, + `coherent-callback`, + `unheld-reactive-publication`, + ] as const) + reach.reach(state) + }), + { numRuns: lazyOracleRuns(30), ...parameters }, + ) + console.info( + `ORACLE_REVALIDATION_ALTERNATIVES ${JSON.stringify({ zeroReceiptReuses })}`, + ) + }, + { fixedSeed: 20261029 }, + ) +}, 60000) + +it(`rejects applied-content substitution for an equal-valued held receipt`, async () => { + await expect( + receive( + { size: 5, pageSize: 2, descending: false, cancelFirst: true, text: `` }, + `javascript`, + true, + ), + ).rejects.toThrow(/receipt law/) +}) + +it(`calibrates cleanup authority without treating cleanup as receipt application`, () => { + const model = new LazyReceiptModel() + model.acquire(`a`, [`equal`]) + model.accept(`actual`, [`equal`], [`a`]) + model.abort(`a`) + expect(() => model.abandon(`actual`)).toThrow( + /abandonment without pending cleanup work/, + ) + model.cleanup() + expect(() => model.observe(`a`, `canceled`)).toThrow( + /settled before actual applied/, + ) + model.abandon(`actual`) + expect(() => model.observe(`a`, `success`)).toThrow( + /settled before actual applied/, + ) + model.observe(`a`, `canceled`) + model.complete() +}) + +it(`calibrates exact window order while accepting coherent skipped revisions`, () => { + const a = { id: `a`, rank: 0, text: `old` } + const b = { id: `b`, rank: 1, text: `old` } + const latest = [ + { ...a, text: `latest` }, + { ...b, text: `latest` }, + ] + const model = new LazyVersionModel(`sequence`) + model.offer([a, b]) + model.offer([ + { ...a, text: `middle` }, + { ...b, text: `middle` }, + ]) + model.offer(latest) + model.observe([a, b]) + expect(() => model.observe([b, a])).toThrow( + /mixed revision or public rollback/, + ) + model.observe(latest) + expect(() => model.observe([a, b])).toThrow( + /mixed revision or public rollback/, + ) +}) diff --git a/packages/convex-db-collection/tests/lazy-paging-version-model-oracle.ts b/packages/convex-db-collection/tests/lazy-paging-version-model-oracle.ts new file mode 100644 index 0000000000..8ce2e6a87d --- /dev/null +++ b/packages/convex-db-collection/tests/lazy-paging-version-model-oracle.ts @@ -0,0 +1,55 @@ +import type { LazyRow } from './lazy-paging-model-oracle' + +/** + * A coherent source observation belongs to one authored revision. After a newer + * revision becomes public, an older revision cannot become public again. + * Authority: the fixed-query coherent-snapshot premise in the approved Convex + * lazy-paging design, and Collection's atomic sync transaction publication. + * + * Revisions are full authored relations, not provider pages. The receiver below + * observes a fully acquired stable-key source, so each allowed relation is exact. + * Retention choices for partially acquired sources and optimistic values are + * outside this model. Old complete values may stutter, and intermediate offered + * revisions may be skipped. Offering a revision permits it; it does not require + * immediate visibility or prove that its facts have actually been delivered. + * The separate source-provenance comparison supplies that latter obligation. + * Sequence observations use the same monotone revision law for exact ordered + * windows independently projected by expectedLazyRows. Relation observations + * ignore Collection iteration order; sequence observations retain public order. + */ +export class LazyVersionModel { + private readonly revisions: Array = [] + private minimumRevision = 0 + + constructor( + private readonly observation: `relation` | `sequence` = `relation`, + ) {} + + offer(rows: ReadonlyArray): void { + this.revisions.push(this.signature(rows)) + } + + observe(rows: ReadonlyArray): void { + const value = this.signature(rows) + // If unchanged rows make two revisions observationally indistinguishable, + // keep the earliest compatible one. This preserves every monotone history + // that the public observations could represent, without guessing a version. + const next = this.revisions.findIndex( + (revision, index) => index >= this.minimumRevision && revision === value, + ) + if (next < 0) + throw new Error( + `lazy version: mixed revision or public rollback ${value}`, + ) + this.minimumRevision = next + } + + private signature(rows: ReadonlyArray): string { + return JSON.stringify( + (this.observation === `relation` + ? [...rows].sort((a, b) => a.id.localeCompare(b.id)) + : [...rows] + ).map(({ id, rank, text }) => [id, rank, text]), + ) + } +} 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 new file mode 100644 index 0000000000..a25e5ee7c1 --- /dev/null +++ b/packages/convex-db-collection/tests/lazy-paging-version-oracle.property.test.ts @@ -0,0 +1,238 @@ +/** + * Every public base snapshot of a fully acquired fixed-query source is one + * coherent authored revision, and publication never moves backwards. This + * extends source-fact provenance: individually delivered old facts still cannot + * form a mixed or rolled-back public relation. The companion + * lazy-paging-version-model-oracle.ts states the law + * without pager, queue, receipt, or cursor machinery. + * + * The native Convex fixture loads the full finite relation through several lazy + * pages. After initial acquisition, subscribeChanges records every publication, + * including synchronous callbacks inside receipt application. Two complete + * revisions are accepted while a real optimistic transaction holds application; + * old values remain legal until the hold drains. Later authored revisions vary + * callback order and can supersede a revision before its callbacks run. + * + * Grammar controls: size 8..14, page size 2..4, both SDK shapes and directions, + * 3..6 later revisions, callback choice 0..15, and optional supersession. Dropping + * the hold loses accepted-versus-applied observations; one revision cannot expose + * rollback; updating one row alone cannot expose mixed source versions. A source + * with independent incoherent page caches is excluded by the SDK premise. + * Limits: stable keys/order, nonempty fully acquired relation, no cleanup or + * provider failure in this receiver, and no claim of eventual progress before + * provider/receipt holds drain. Initial partial acquisitions have other owners. + */ +import { createTransaction } from '@tanstack/db' +import fc from 'fast-check' +import { expect, it } from 'vitest' +import { runOracleCampaign } from '../../db/tests/oracle-campaign' +import { withTestCleanup } from './helpers' +import { lazyFixture } from './lazy-paging-driver' +import { assertLazySourceFacts } from './lazy-paging-model-oracle' +import { LazyVersionModel } from './lazy-paging-version-model-oracle' +import type { LazyRow } from './lazy-paging-model-oracle' + +const history = fc.record({ + size: fc.integer({ min: 8, max: 14 }), + pageSize: fc.integer({ min: 2, max: 4 }), + descending: fc.boolean(), + react: fc.boolean(), + revisions: fc.integer({ min: 3, max: 6 }), + text: fc.string({ maxLength: 8 }), + choices: fc.array(fc.nat({ max: 15 }), { minLength: 1, maxLength: 8 }), + skip: fc.boolean(), +}) +type History = typeof history extends fc.Arbitrary ? T : never +type Fault = `none` | `rollback` | `mixed` + +async function runVersions(h: History, fault: Fault = `none`) { + // Every row's fact changes, making revisions independently distinguishable. + const authored = Array.from({ length: h.revisions + 1 }, (_, revision) => + Array.from({ length: h.size }, (_row, rank) => ({ + id: `row-${rank}`, + rank, + text: `${h.text}:revision-${revision}`, + })), + ) + const f = lazyFixture( + authored[0]!, + h.pageSize, + h.descending, + `none`, + false, + undefined, + h.react ? `react` : `javascript`, + ) + const model = new LazyVersionModel() + const failures: Array = [] + const observed: Array> = [] + let revision = 0 + let injected = false + let stop: (() => void) | undefined + let release: () => void = () => undefined + let persisted: Promise | undefined + const compare = (rows: Array) => { + observed.push(rows) + try { + assertLazySourceFacts(rows, f.remote.deliveredFacts) + model.observe(rows) + } catch (error) { + failures.push(error) + } + } + const publish = () => { + const rows = [...f.source.base.values()].map((row) => ({ ...row })) + compare(rows) + // Hostile publication controls run at the same observation boundary, then + // restore the correct final value. They test trace comparison, not a claim + // that production itself emitted these deliberately injected observations. + if (!injected && fault !== `none` && rows[0]?.text.endsWith(`revision-2`)) { + injected = true + compare( + fault === `rollback` + ? authored[0]! + : [authored[0]![0]!, ...authored[2]!.slice(1)], + ) + compare(rows) + } + } + const change = () => { + revision++ + model.offer(authored[revision]!) + f.remote.change(authored[revision]!) + } + const drain = async () => { + for (let turn = 0; turn < 20; turn++) await Promise.resolve() + } + await withTestCleanup( + async () => { + const load = f.load({ limit: h.size }) + await f.remote.pump() + await load.complete() + expect(f.remote.slots.length).toBeGreaterThan(1) + model.offer(authored[0]!) + publish() + const observer = f.source.subscribeChanges(publish, { + includeInitialState: false, + }) + stop = () => observer.unsubscribe() + const gate = new Promise((resolve) => { + release = resolve + }) + const transaction = createTransaction({ mutationFn: () => gate }) + transaction.mutate(() => + f.source.insert({ id: `overlay`, rank: -1, text: `optimistic only` }), + ) + persisted = transaction.isPersisted.promise + const accepted = f.receipts.length + change() + await f.remote.deliver(h.choices[0]) + await f.remote.pump() + const first = f.receipts.length + expect(first).toBeGreaterThan(accepted) + change() + await f.remote.deliver(h.choices[1 % h.choices.length]) + await f.remote.pump() + expect(f.receipts.length).toBeGreaterThan(first) + expect( + f.receipts.slice(accepted).every((receipt) => receipt !== true), + ).toBe(true) + expect([...f.source.base.values()]).toEqual( + expect.arrayContaining(authored[0]!), + ) + expect(f.source.base.size).toBe(h.size) + release() + await persisted + await drain() + publish() + while (revision < h.revisions) { + change() + if (h.skip && revision < h.revisions) change() + await f.remote.deliver(h.choices[revision % h.choices.length]) + await f.remote.pump( + () => false, + h.choices[revision % h.choices.length]! % 2 === 0, + ) + } + await drain() + publish() + expect([...f.source.base.values()]).toEqual( + expect.arrayContaining(authored[h.revisions]!), + ) + expect(f.source.base.size).toBe(h.size) + expect(observed.length).toBeGreaterThan(2) + if (fault !== `none`) expect(injected).toBe(true) + if (failures.length) throw failures[0] + load.release() + }, + () => stop?.(), + async () => { + release() + await persisted + }, + () => f.source.cleanup(), + ) +} + +it(`checks every native publication against coherent monotone authored revisions`, async () => { + await runOracleCampaign( + `CONVEX_LAZY_VERSION_ORACLE`, + [ + `javascript-versions`, + `react-versions`, + `two-revisions-held`, + `post-hold-publication`, + `three-or-more-revisions`, + `superseded-before-delivery`, + ] as const, + (reach, parameters) => + fc.assert( + fc.asyncProperty(history, async (h) => { + reach.beginHistory() + const fault = process.env.CONVEX_LAZY_VERSION_FAULT + if (fault !== undefined && fault !== `rollback` && fault !== `mixed`) + throw new Error(`invalid CONVEX_LAZY_VERSION_FAULT`) + await runVersions(h, fault) + reach.reach(h.react ? `react-versions` : `javascript-versions`) + reach.reach(`two-revisions-held`) + reach.reach(`post-hold-publication`) + reach.reach(`three-or-more-revisions`) + if (h.skip && h.revisions >= 4) + reach.reach(`superseded-before-delivery`) + }), + { numRuns: 50, ...parameters }, + ), + { fixedSeed: 20261027 }, + ) +}, 60000) + +it.each([`rollback`, `mixed`] as const)( + `rejects a transient %s publication even with the correct final revision`, + async (fault) => { + await expect( + runVersions( + { + size: 8, + pageSize: 2, + descending: false, + react: false, + revisions: 4, + text: `authored`, + choices: [0, 1], + skip: true, + }, + fault, + ), + ).rejects.toThrow(`lazy version`) + }, +) + +it(`allows stuttering, skipped and observationally identical revisions`, () => { + const a = [{ id: `a`, rank: 0, text: `first` }] + const b = [{ id: `a`, rank: 0, text: `middle` }] + const c = [{ id: `a`, rank: 0, text: `last` }] + const model = new LazyVersionModel() + for (const rows of [a, a, b, c]) model.offer(rows) + for (const rows of [a, a, c, c]) model.observe(rows) + expect(() => model.observe(a)).toThrow(`lazy version`) +}) diff --git a/packages/convex-db-collection/tests/lazy-paging-write-model-oracle.test.ts b/packages/convex-db-collection/tests/lazy-paging-write-model-oracle.test.ts new file mode 100644 index 0000000000..4df2dd7984 --- /dev/null +++ b/packages/convex-db-collection/tests/lazy-paging-write-model-oracle.test.ts @@ -0,0 +1,67 @@ +import { expect, it } from 'vitest' +import { LazyWriteModel } from './lazy-paging-write-model-oracle' + +/** Calibration of the two-sided law, independently of a backend driver. The + * same source row distinguishes a confirmed lost write from a rejected write + * that leaked remotely. These controls preserve both permitted counterparts. */ +it(`rejects lost confirmations and remote effects of atomic rejection`, () => { + const original = { id: `a`, rank: 1, text: `before` } + const changed = { ...original, rank: 0, text: `after` } + for (const confirmed of [false, true]) { + const model = new LazyWriteModel([original]) + model.begin({ id: `a`, row: changed }) + expect(model.rows(true)).toEqual([changed]) + expect(model.rows()).toEqual([original]) + model.acknowledge(confirmed) + model.startSettlement() + model.finishSettlement() + expect(() => + model.assertBackend([confirmed ? changed : original]), + ).not.toThrow() + expect(() => model.assertBackend([confirmed ? original : changed])).toThrow( + `backend differs from authored ledger`, + ) + } + const model = new LazyWriteModel([original]) + model.begin({ id: `a` }) + expect(model.rows(true)).toEqual([]) + model.acknowledge(false) + model.startSettlement() + model.finishSettlement() + expect(() => model.assertBackend([])).toThrow( + `backend differs from authored ledger`, + ) + model.assertBackend([original]) +}) + +/** A rejected overlay can reveal an older applied source while a newer remote + * revision waits. After the newer source is public, the old one cannot return. */ +it(`separates optimistic rollback from source rollback and atomic source mixing`, () => { + const a = { id: `a`, rank: 0, text: `a0` } + const b = { id: `b`, rank: 1, text: `b0` } + const nextA = { ...a, text: `a1` } + const nextB = { ...b, text: `b1` } + const model = new LazyWriteModel([a, b]) + model.observeWindow([a, b]) + model.begin({ id: `a` }) + model.observeWindow([b]) + model.remote([{ id: `b`, row: nextB }]) + model.acknowledge(false) + model.startSettlement() + model.observeWindow([a, b]) + model.finishSettlement() + model.observeWindow([a, nextB]) + expect(() => model.observeWindow([a, b])).toThrow( + `no coherent source/optimistic explanation`, + ) + const atomic = new LazyWriteModel([a, b]) + atomic.observeWindow([a, b]) + atomic.remote([ + { id: `a`, row: nextA }, + { id: `b`, row: nextB }, + ]) + expect(() => atomic.observeWindow([nextA, b])).toThrow( + `no coherent source/optimistic explanation`, + ) + atomic.observeWindow([nextA, nextB]) +}) diff --git a/packages/convex-db-collection/tests/lazy-paging-write-model-oracle.ts b/packages/convex-db-collection/tests/lazy-paging-write-model-oracle.ts new file mode 100644 index 0000000000..abd1bb1eaa --- /dev/null +++ b/packages/convex-db-collection/tests/lazy-paging-write-model-oracle.ts @@ -0,0 +1,135 @@ +import { copyLazyRows, expectedLazyRows } from './lazy-paging-model-oracle' +import type { LazyRow, LazyScope } from './lazy-paging-model-oracle' + +/** + * Confirmed application writes change source truth; rejected atomic writes do + * not. Until settlement, one optimistic operation overlays that truth. + * Authority: Collection mutation handlers in db/src/types.ts and + * db/tests/optimistic-history-oracle.ts; two-sided observation in #2269. + * + * This small ledger holds authored rows, never a provider read. A put combines + * insert and update in this model; the driver exercises both public APIs. + * Backend acknowledgment and optimistic settlement are separate boundaries. + * A failed put restores the applied source row, which can precede a newer + * remote revision's application. One public window needs one explanation: + * a monotone authored source revision, a monotone window request, and the + * current optimistic operation. Keep every compatible explanation when an + * optimistic operation hides a source change. Identical stuttering is legal. + * Backend transactions, SDK caches, pages and commit partitions have no model + * meaning. Limits: one pending atomic application operation, finite LazyRows. + */ +export type LazyWrite = { id: string; row?: LazyRow } +export class LazyWriteModel { + private readonly source = new Map() + private pending: LazyWrite | undefined + private releasing = false + private acknowledged = false + private readonly revisions: Array> = [] + private readonly windows: Array + private explanations = [{ revision: 0, window: 0 }] + private lastPublication: string | undefined + + constructor(rows: ReadonlyArray, scope: LazyScope = {}) { + for (const row of rows) this.source.set(row.id, { ...row }) + this.revisions.push(this.rows()) + this.windows = [{ ...scope }] + } + private apply(change: LazyWrite, target: Map): void { + if (change.row) target.set(change.id, { ...change.row }) + else target.delete(change.id) + } + remote(changes: ReadonlyArray): void { + for (const change of changes) this.apply(change, this.source) + this.revisions.push(this.rows()) + } + begin(change: LazyWrite): void { + if (this.pending) throw new Error(`write model: overlapping app operations`) + this.pending = { + id: change.id, + ...(change.row ? { row: { ...change.row } } : {}), + } + this.acknowledged = false + } + /** Author the successful source proposal before the backend can emit it. */ + offerConfirmation(): void { + if (!this.pending) throw new Error(`write model: proposal without app work`) + const proposed = new Map(this.source) + this.apply(this.pending, proposed) + this.revisions.push(copyLazyRows([...proposed.values()])) + } + acknowledge(confirmed: boolean): void { + if (!this.pending) + throw new Error(`write model: settlement without app work`) + if (this.acknowledged) + throw new Error(`write model: duplicate acknowledgment`) + if (confirmed) this.apply(this.pending, this.source) + this.acknowledged = true + } + /** The driver is about to release the handler. This boundary can publish + * with the optimistic operation still present or with it dropped. */ + startSettlement(): void { + if (!this.pending || !this.acknowledged) + throw new Error(`write model: local settlement before acknowledgment`) + this.releasing = true + } + finishSettlement(): void { + if (!this.releasing) + throw new Error(`write model: settlement was not started`) + this.pending = undefined + this.releasing = false + } + rows(optimistic = false): Array { + const rows = new Map(this.source) + if (optimistic && this.pending) this.apply(this.pending, rows) + return copyLazyRows([...rows.values()]) + } + window(scope: LazyScope, optimistic = false): Array { + return expectedLazyRows(this.rows(optimistic), scope) + } + moveWindow(scope: LazyScope): void { + this.windows.push({ ...scope }) + } + observeWindow(actual: ReadonlyArray): void { + const signature = (rows: ReadonlyArray) => + JSON.stringify(copyLazyRows(rows)) + const observed = signature(actual) + if (observed === this.lastPublication) return + const next: Array<{ revision: number; window: number }> = [] + for (const [revision, rows] of this.revisions.entries()) { + for (const [window, scope] of this.windows.entries()) { + if ( + !this.explanations.some( + (prior) => prior.revision <= revision && prior.window <= window, + ) + ) + continue + const source = new Map(rows.map((row) => [row.id, row])) + const candidates = [expectedLazyRows(rows, scope)] + if (this.pending) { + this.apply(this.pending, source) + const optimistic = expectedLazyRows([...source.values()], scope) + if (this.releasing) candidates.push(optimistic) + else candidates.splice(0, 1, optimistic) + } + if (candidates.some((candidate) => signature(candidate) === observed)) + next.push({ revision, window }) + } + } + if (!next.length) + throw new Error( + `app publication law: no coherent source/optimistic explanation ${observed}`, + ) + this.explanations = next + this.lastPublication = observed + } + assertBackend(actual: ReadonlyArray): void { + const relation = (rows: ReadonlyArray) => + JSON.stringify( + copyLazyRows(rows).sort((a, b) => a.id.localeCompare(b.id)), + ) + if (relation(actual) !== relation(this.rows())) + throw new Error( + `app write law: backend differs from authored ledger\nexpected ${relation(this.rows())}\nactual ${relation(actual)}`, + ) + } +} diff --git a/packages/convex-db-collection/tests/sdk-cache-oracle.test.ts b/packages/convex-db-collection/tests/sdk-cache-oracle.test.ts new file mode 100644 index 0000000000..b5661e29e3 --- /dev/null +++ b/packages/convex-db-collection/tests/sdk-cache-oracle.test.ts @@ -0,0 +1,147 @@ +/** Receiving premise for lazy-paging-model-oracle.ts: a Convex query transition + * installs all changed query caches before dispatching any listener. The pager + * reads those caches to publish a coherent prefix. Real SDKs receive two legal + * multi-query wire transitions; the shared row/publication model rejects a mix + * of their authored revisions at every callback. This checks SDK scheduling, + * not server cursor correctness, which the local-backend receiver supplies. */ +import { Buffer } from 'node:buffer' +import { ConvexClient } from 'convex/browser' +import { ConvexReactClient } from 'convex/react' +import { makeFunctionReference } from 'convex/server' +import { expect, it } from 'vitest' +import { + assertLazyPublications, + expectedLazyRows, +} from './lazy-paging-model-oracle' +import { withTestCleanup } from './helpers' +import type { LazyRow } from './lazy-paging-model-oracle' +import type { PaginationOptions, PaginationResult } from 'convex/server' + +type Outgoing = { + type: string + newVersion: number + modifications: Array<{ type: string; queryId: number }> +} +class Socket { + static instances: Array = [] + sent: Array = [] + onopen?: () => void + onclose?: (event: { code: number; reason: string }) => void + onmessage?: (event: { data: string }) => void + constructor() { + Socket.instances.push(this) + } + send(data: string) { + this.sent.push(JSON.parse(data) as Outgoing) + } + close() { + queueMicrotask(() => this.onclose?.({ code: 1000, reason: `` })) + } + open() { + this.onopen?.() + } + receive(message: unknown) { + this.onmessage?.({ data: JSON.stringify(message) }) + } +} +const timestamp = (value: number) => { + const bytes = Buffer.alloc(8) + bytes.writeBigUInt64LE(BigInt(value)) + return bytes.toString(`base64`) +} + +it.each([`javascript`, `react`] as const)( + `reads coherent range caches before every %s SDK callback`, + async (kind) => { + const options = { + webSocketConstructor: Socket as unknown as typeof WebSocket, + unsavedChangesWarning: false, + logger: false as const, + } + const client = + kind === `javascript` + ? new ConvexClient(`https://test.convex.cloud`, options) + : new ConvexReactClient(`https://test.convex.cloud`, options) + const query = makeFunctionReference< + `query`, + { paginationOpts: PaginationOptions }, + PaginationResult + >(`tasks:list`) + const handles: Array<{ + read: () => PaginationResult | undefined + release: () => void + }> = [] + const trace: Array> = [] + const record = () => + trace.push(handles.flatMap((handle) => handle.read()?.page ?? [])) + await withTestCleanup( + () => { + for (const cursor of [null, `boundary`]) { + const args = { paginationOpts: { cursor, numItems: 1 } } + if (`onUpdate` in client) { + const sub = client.onUpdate(query, args, record) + handles.push({ read: () => sub.getCurrentValue(), release: sub }) + } else { + const watch = client.watchQuery(query, args) + handles.push({ + read: () => watch.localQueryResult(), + release: watch.onUpdate(record), + }) + } + } + const socket = Socket.instances.at(-1)! + socket.open() + const messages = socket.sent.filter( + (message) => message.type === `ModifyQuerySet`, + ) + const version = messages.at(-1)!.newVersion + const adds = messages + .flatMap((message) => message.modifications) + .filter((item) => item.type === `Add`) + expect(adds).toHaveLength(2) + let prior: Array = [] + for (let revision = 1; revision <= 2; revision++) { + const authored = [0, 1].map((rank) => ({ + id: String(rank), + rank, + text: `revision ${revision}`, + })) + const start = trace.length + socket.receive({ + type: `Transition`, + startVersion: { + querySet: revision === 1 ? 0 : version, + ts: timestamp(revision - 1), + identity: 0, + }, + endVersion: { + querySet: version, + ts: timestamp(revision), + identity: 0, + }, + modifications: adds.map((item, index) => ({ + type: `QueryUpdated`, + queryId: item.queryId, + value: { + page: [authored[index]], + isDone: index === 1, + continueCursor: `end-${index}`, + }, + logLines: [], + journal: null, + })), + }) + expect(trace.length).toBeGreaterThan(start) + const expected = expectedLazyRows(authored, {}) + assertLazyPublications(trace.slice(start), prior, expected) + expect(trace.at(-1)).toEqual(expected) + prior = expected + } + }, + () => { + for (const handle of handles) handle.release() + }, + () => client.close(), + ) + }, +) diff --git a/packages/convex-db-collection/tests/types.test-d.ts b/packages/convex-db-collection/tests/types.test-d.ts index 98c7258abd..d18b2292aa 100644 --- a/packages/convex-db-collection/tests/types.test-d.ts +++ b/packages/convex-db-collection/tests/types.test-d.ts @@ -152,3 +152,40 @@ it(`requires a projection for changed paginated row types and server pagination getKey: (row: Row) => row._id, }) }) + +it(`requires a declared numeric query order for lazy paging`, () => { + const client = {} as ConvexClient + const paginated = makeFunctionReference< + `query`, + { paginationOpts: PaginationOptions }, + PaginationResult<{ id: string; rank: number; text: string }> + >(`tasks:lazy`) + const base = { + client, + query: paginated, + queryArgs: {}, + getKey: (row: { id: string; rank: number; text: string }) => row.id, + } + const options = convexPaginatedCollectionOptions({ + ...base, + syncMode: `on-demand`, + queryOrder: { field: `rank`, direction: `asc` }, + }) + expectTypeOf(createCollection(options).get(`a`)!.rank).toEqualTypeOf() + // @ts-expect-error On-demand pagination needs the fixed server query's order. + convexPaginatedCollectionOptions({ ...base, syncMode: `on-demand` }) + convexPaginatedCollectionOptions({ + ...base, + syncMode: `on-demand`, + // @ts-expect-error String fields are outside the numeric-order profile. + queryOrder: { field: `text`, direction: `asc` }, + }) + // @ts-expect-error Array select cannot preserve arbitrary lazy coverage. + convexPaginatedCollectionOptions({ + ...base, + syncMode: `on-demand`, + queryOrder: { field: `rank`, direction: `asc` }, + select: (rows: ReadonlyArray<{ id: string; rank: number; text: string }>) => + rows, + }) +}) diff --git a/packages/convex-db-collection/tsconfig.json b/packages/convex-db-collection/tsconfig.json index 02471eaff8..5585054175 100644 --- a/packages/convex-db-collection/tsconfig.json +++ b/packages/convex-db-collection/tsconfig.json @@ -23,7 +23,8 @@ "e2e/*.ts", "e2e/auth-app/*.ts", "vite.config.ts", - "vitest.e2e.config.ts" + "vitest.e2e.config.ts", + "vitest.lazy.e2e.config.ts" ], "exclude": ["node_modules", "dist"] } diff --git a/packages/convex-db-collection/tsconfig.test.json b/packages/convex-db-collection/tsconfig.test.json new file mode 100644 index 0000000000..320fefa0eb --- /dev/null +++ b/packages/convex-db-collection/tsconfig.test.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": "../.." + } +} diff --git a/packages/convex-db-collection/vite.config.ts b/packages/convex-db-collection/vite.config.ts index abd0709e33..9b7c2083e9 100644 --- a/packages/convex-db-collection/vite.config.ts +++ b/packages/convex-db-collection/vite.config.ts @@ -9,7 +9,11 @@ const config = defineConfig({ include: [`tests/**/*.test.ts`], environment: `jsdom`, coverage: { enabled: true, provider: `istanbul`, include: [`src/**/*`] }, - typecheck: { enabled: true, include: [`tests/**/*.test-d.ts`] }, + typecheck: { + enabled: true, + include: [`tests/**/*.test-d.ts`], + tsconfig: `./tsconfig.test.json`, + }, }, }) diff --git a/packages/convex-db-collection/vitest.lazy.e2e.config.ts b/packages/convex-db-collection/vitest.lazy.e2e.config.ts new file mode 100644 index 0000000000..0216943e7c --- /dev/null +++ b/packages/convex-db-collection/vitest.lazy.e2e.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + globalSetup: [`./e2e/setup.ts`], + include: [ + `e2e/lazy-paging-oracle.test.ts`, + `e2e/lazy-concurrency-oracle.test.ts`, + `e2e/lazy-boundary-oracle.test.ts`, + `e2e/lazy-app-write-oracle.test.ts`, + ], + environment: `node`, + testTimeout: 60000, + fileParallelism: false, + }, +}) diff --git a/packages/db/package.json b/packages/db/package.json index f09af0c658..fcc85e7286 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/temporal-index-order-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-peer-oracle.test.ts tests/local-storage-value-fidelity-oracle.property.test.ts tests/collection-mutation-startup-oracle.test.ts tests/collection-cleanup-restart-oracle.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-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", + "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/temporal-index-order-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-peer-oracle.test.ts tests/local-storage-value-fidelity-oracle.property.test.ts tests/collection-mutation-startup-oracle.test.ts tests/collection-cleanup-restart-oracle.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-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", "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:indexed-db:browser": "playwright test --config playwright.indexed-db.config.ts", 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 new file mode 100644 index 0000000000..5d670cb6a7 --- /dev/null +++ b/packages/db/tests/query/load-subset-joint-replay-oracle.property.test.ts @@ -0,0 +1,468 @@ +/** + * A truncate replacement owes every uncancelable participant, including older + * overlapping work. Failure leaves the last complete publication visible; + * ordinary later writes cannot supply the missing authority. Each live-query + * owner has its own publication barrier; a peer holds no authority over it. Retiring one + * logical owner removes only its obligations. Cleanup terminates old graphs. + * Authority: live/ARCHITECTURE.md, Replay participants and failure, normative + * laws 7, 9 and 14; types.ts LoadSubsetFn and SyncAppliedReceipt. + * + * The scientific model below retains authored snapshots, active logical + * owners, outstanding participant obligations, receipt application and failure. + * It knows no replay queues, graph counters, source cursors or page chains. + * Its attempt number names authored work, not a production generation token. + * The driver maps these facts onto real sync commits, two live-query owners, + * actual optimistic holds and adapter promises. Every publication callback and + * every drained semantic action compares public rows with the last complete + * model snapshot. Cleanup abandons accepted work and closes the old model. + * + * Grammar controls: 1–4 rows, signed row values, independently permuted current + * participant settlement, obsolete success/AbortError, owner retirement before + * or after retry acceptance, 1–3 ordinary revisions after failure, and cleanup + * before/after releasing its held receipt. Each history reconstructs the known + * overlap and failed-write witnesses; removing overlap, failure, receipt holds + * or retirement removes its corresponding distinguishing checkpoint. Starting + * a request-scoped write after observing abort is excluded. Limits: one source, + * two full filtered owners, no joined/includes graph or throwing callbacks. + */ +import fc from 'fast-check' +import { expect, it } from 'vitest' +import { createCollection } from '../../src/collection/index.js' +import { createDeferred } from '../../src/deferred.js' +import { createLiveQueryCollection, eq } from '../../src/query/index.js' +import { createTransaction } from '../../src/transactions.js' +import { runOracleCampaign } from '../oracle-campaign.js' +import { flushPromises, withOracleCleanup } from '../utils.js' +import type { + LoadSubsetOptions, + SyncAppliedReceipt, + SyncConfig, +} from '../../src/types.js' + +type Row = { id: string; version: number; value: number; kind: `data` | `hold` } +type PublicRow = Pick +type Owner = `main` | `peer` +const normalize = (rows: ReadonlyArray) => + rows + .map(({ id, version, value }) => ({ id, version, value })) + .sort((a, b) => a.id.localeCompare(b.id)) + +// Last complete publication and current obligations are distinguishable: the +// same new rows remain private with an older participant or a failed owner, +// but become public once all still-owned work and receipts establish success. +class ReplayPublicationModel { + readonly owners = new Set([`main`, `peer`]) + readonly visible = new Map>() + private replacement: Array + private readonly participants = new Map< + string, + { owner: Owner; attempt: number } + >() + private readonly receipts = new Set() + private readonly failed = new Set() + private latest = 0 + private readonly replaying = new Set() + private closed = false + + constructor(initial: Array) { + this.replacement = normalize(initial) + for (const owner of this.owners) this.visible.set(owner, normalize(initial)) + } + start(): number { + if (this.closed) throw new Error(`model: replay after cleanup`) + for (const owner of this.owners) this.replaying.add(owner) + this.failed.clear() + const attempt = ++this.latest + for (const owner of this.owners) + this.participants.set(`${attempt}:${owner}`, { owner, attempt }) + return attempt + } + accept(id: string, rows: Array): void { + this.replacement = normalize(rows) + this.receipts.add(id) + } + apply(id: string): void { + this.receipts.delete(id) + this.publishIfComplete() + } + settle(attempt: number, owner: Owner, failed: boolean): void { + this.participants.delete(`${attempt}:${owner}`) + if ( + !this.closed && + this.owners.has(owner) && + attempt === this.latest && + failed + ) + this.failed.add(owner) + this.publishIfComplete() + } + retire(owner: Owner): void { + this.owners.delete(owner) + this.failed.delete(owner) + for (const [id, participant] of this.participants) + if (participant.owner === owner) this.participants.delete(id) + this.publishIfComplete() + } + cleanup(): void { + this.closed = true + } + private publishIfComplete(): void { + if (this.closed || this.receipts.size) return + for (const owner of this.owners) { + if (!this.replaying.has(owner) || this.failed.has(owner)) continue + if ( + [...this.participants.values()].some( + (participant) => participant.owner === owner, + ) + ) + continue + this.visible.set(owner, normalize(this.replacement)) + this.replaying.delete(owner) + } + } +} + +type Scenario = { + count: number + value: number + mainFirst: boolean + obsoleteRejects: boolean + retireBeforeAccept: boolean + ordinaryWrites: number + releaseBeforeCleanup: boolean +} +const scenario = fc.record({ + count: fc.integer({ min: 1, max: 4 }), + value: fc.integer({ min: -20, max: 20 }), + mainFirst: fc.boolean(), + obsoleteRejects: fc.boolean(), + retireBeforeAccept: fc.boolean(), + ordinaryWrites: fc.integer({ min: 1, max: 3 }), + releaseBeforeCleanup: fc.boolean(), +}) + +async function runHistory(h: Scenario) { + const rows = (version: number): Array => + Array.from({ length: h.count }, (_, i) => ({ + id: `row${i}`, + version, + value: h.value + i + version, + kind: `data`, + })) + const model = new ReplayPublicationModel(rows(1)) + const failures: Array = [] + let action = `initial` + const reaches = new Set() + let operations!: Parameters[`sync`]>[0] + let run = 0 + let firstLoad = true + let currentAttempt = 0 + const loads: Array<{ + owner: Owner + attempt: number + options: LoadSubsetOptions + outcome: ReturnType> + done: Promise + settled: boolean + receipts: Array + }> = [] + const subscriptions = new Map() + const activeQueries: Array<{ cleanup: () => Promise }> = [] + const source = createCollection({ + getKey: (row) => row.id, + syncMode: `on-demand`, + sync: { + sync: (controls) => { + operations = controls + run++ + firstLoad = true + controls.markReady() + return { + loadSubset: (options) => { + let owner = subscriptions.get(options.subscription) + if (!owner) { + owner = subscriptions.size === 0 ? `main` : `peer` + subscriptions.set(options.subscription, owner) + } + if (currentAttempt === 0 || run > 1) { + if (firstLoad) { + firstLoad = false + controls.begin() + for (const value of rows(run > 1 ? 20 : 1)) + controls.write({ type: `insert`, value }) + return controls.commit() + } + return true + } + const outcome = createDeferred() + const load = { + owner, + attempt: currentAttempt, + options, + outcome, + done: Promise.resolve(), + settled: false, + receipts: [] as Array, + } + // A conforming adapter cannot report success before its actual + // establishing receipt. Obsolete work has no later request writes. + load.done = outcome.promise.then(async (failed) => { + await Promise.all(load.receipts) + load.settled = true + model.settle(load.attempt, load.owner, failed) + if (failed) throw new Error(`authored replacement failed`) + if (options.signal?.aborted && h.obsoleteRejects) + throw new DOMException(`obsolete`, `AbortError`) + }) + void load.done.catch(() => undefined) + loads.push(load) + return load.done + }, + unloadSubset: () => {}, + } + }, + }, + }) + const createQuery = () => + createLiveQueryCollection((q) => + q + .from({ row: source }) + .where(({ row }) => eq(row.kind, `data`)) + .select(({ row }) => ({ + id: row.id, + version: row.version, + value: row.value, + })), + ) + const main = createQuery() + const peer = createQuery() + activeQueries.push(main, peer) + const publications = new Map>>([ + [`main`, []], + [`peer`, []], + ]) + const record = (owner: Owner, query: typeof main) => + query.subscribeChanges( + () => { + const observed = normalize(query.toArray) + publications.get(owner)!.push(observed) + if (currentAttempt > 0) { + try { + expect( + observed, + `joint replay publication ${owner} during ${action}`, + ).toEqual(model.visible.get(owner)) + } catch (error) { + failures.push(error) + } + } + }, + { includeInitialState: true }, + ) + const mainSubscription = record(`main`, main) + const peerSubscription = record(`peer`, peer) + let held: { release: () => void; done: Promise } | undefined + const hold = () => { + const pending = createDeferred() + const transaction = createTransaction({ mutationFn: () => pending.promise }) + transaction.mutate(() => + source.insert({ id: `hold`, version: 0, value: 0, kind: `hold` }), + ) + held = { release: pending.resolve, done: transaction.isPersisted.promise } + } + const release = async () => { + if (!held) return + const pending = held + held = undefined + action = `apply held receipt` + pending.release() + await pending.done + await flushPromises() + } + const checkpoint = async (label: string) => { + await flushPromises() + if (failures.length) throw failures[0] + expect(normalize(main.toArray), `joint replay ${label}: main`).toEqual( + model.visible.get(`main`), + ) + if (model.owners.has(`peer`)) + expect(normalize(peer.toArray), `joint replay ${label}: peer`).toEqual( + model.visible.get(`peer`), + ) + } + const start = async () => { + currentAttempt = model.start() + action = `start replay ${currentAttempt}` + operations.begin() + operations.truncate() + operations.commit() + await checkpoint(`start ${currentAttempt}`) + } + const accept = (version: number) => { + const id = `receipt${version}` + action = `accept revision ${version}` + model.accept(id, rows(version)) + operations.begin() + for (const value of rows(version)) + operations.write({ + type: source.base.has(value.id) ? `update` : `insert`, + value, + }) + const receipt = operations.commit() + for (const load of loads) + if ( + load.attempt === currentAttempt && + !load.settled && + model.owners.has(load.owner) + ) + load.receipts.push(receipt) + if (receipt === true) model.apply(id) + else + void receipt.then( + () => model.apply(id), + () => { + /* cleanup abandons accepted work */ + }, + ) + return receipt + } + const settle = async (attempt: number, failed: boolean) => { + const owned = loads.filter((load) => load.attempt === attempt) + const ordered = h.mainFirst ? owned : [...owned].reverse() + for (const load of ordered) { + action = `settle ${attempt}:${load.owner}` + load.outcome.resolve(failed) + await checkpoint(`settle ${attempt}:${load.owner}`) + } + } + await withOracleCleanup(async () => { + await Promise.all([main.preload(), peer.preload()]) + expect(normalize(main.toArray)).toEqual(normalize(rows(1))) + expect(normalize(peer.toArray)).toEqual(normalize(rows(1))) + await start() // A remains uncancelably pending. + await start() // B is authoritative, but A still participates. + expect(loads.filter((load) => load.attempt === 1)).toHaveLength(2) + expect(loads.filter((load) => load.attempt === 2)).toHaveLength(2) + expect( + loads + .filter((load) => load.attempt === 1) + .every((load) => load.options.signal?.aborted), + ).toBe(true) + hold() + const heldReceipt = accept(3) + expect(heldReceipt).toBeInstanceOf(Promise) + for (const load of loads.filter((candidate) => candidate.attempt === 2)) + load.outcome.resolve(false) + await checkpoint(`accepted held replacement`) + expect( + loads.filter((load) => load.attempt === 2).every((load) => !load.settled), + ).toBe(true) + reaches.add(`overlap-with-held-receipt`) + await release() + await checkpoint(`newest settles before older`) + expect( + loads.filter((load) => load.attempt === 2).every((load) => load.settled), + ).toBe(true) + expect( + loads.filter((load) => load.attempt === 1).every((load) => !load.settled), + ).toBe(true) + expect(normalize(main.toArray)).toEqual(normalize(rows(1))) + reaches.add(`newest-done-older-pending`) + await settle(1, false) + await checkpoint(`all overlapping work settled`) + expect(normalize(main.toArray)).toEqual(normalize(rows(3))) + + await start() + accept(4) + await settle(3, true) + const failurePublications = publications.get(`main`)!.length + for (let i = 0; i < h.ordinaryWrites; i++) { + accept(5 + i) + await checkpoint(`ordinary write after failure ${i}`) + expect(publications.get(`main`)!).toHaveLength(failurePublications) + } + reaches.add(`failure-keeps-ordinary-writes-private`) + await start() + const retirePeer = async () => { + model.retire(`peer`) + peerSubscription.unsubscribe() + await peer.cleanup() + await checkpoint(`peer retired`) + } + if (h.retireBeforeAccept) await retirePeer() + accept(10) + if (!h.retireBeforeAccept) await retirePeer() + const survivor = loads.find( + (load) => load.attempt === 4 && load.owner === `main`, + )! + survivor.outcome.resolve(false) + await checkpoint(`surviving owner succeeds`) + expect(normalize(main.toArray)).toEqual(normalize(rows(10))) + reaches.add(`retired-peer-does-not-block-retry`) + + await start() + hold() + accept(11) + if (h.releaseBeforeCleanup) await release() + model.cleanup() + await source.cleanup() + expect(main.status).toBe(`error`) + await release() + // Old completions and their errors may release resources but cannot revive + // the terminal graph. A fresh dependent belongs to the restarted sync run. + for (const load of loads) load.outcome.resolve(false) + source.startSyncImmediate() + const fresh = createQuery() + activeQueries.push(fresh) + await fresh.preload() + await checkpoint(`cleanup and restarted source`) + expect(normalize(main.toArray)).toEqual(normalize(rows(10))) + expect(main.status).toBe(`error`) + expect(normalize(fresh.toArray)).toEqual(normalize(rows(20))) + reaches.add(`cleanup-holds-old-graph-terminal`) + }, [ + async () => { + for (const load of loads) load.outcome.resolve(false) + await release() + }, + () => mainSubscription.unsubscribe(), + () => peerSubscription.unsubscribe(), + async () => { + for (const query of activeQueries) await query.cleanup() + }, + () => source.cleanup(), + ]) + return reaches +} + +it(`composes overlapping replay receipts, failure, surviving ownership and restart`, async () => { + await runOracleCampaign( + `JOINT_REPLAY_ORACLE`, + [ + `overlap-with-held-receipt`, + `newest-done-older-pending`, + `failure-keeps-ordinary-writes-private`, + `retired-peer-does-not-block-retry`, + `cleanup-holds-old-graph-terminal`, + ] as const, + (reach, parameters) => + fc.assert( + fc.asyncProperty(scenario, async (h) => { + reach.beginHistory() + const observed = await runHistory(h) + for (const state of [ + `overlap-with-held-receipt`, + `newest-done-older-pending`, + `failure-keeps-ordinary-writes-private`, + `retired-peer-does-not-block-retry`, + `cleanup-holds-old-graph-terminal`, + ] as const) { + expect(observed.has(state)).toBe(true) + reach.reach(state) + } + }), + { numRuns: 35, ...parameters }, + ), + { fixedSeed: 20261027 }, + ) +}, 60000) diff --git a/packages/react-db/tests/convex-lazy-paging-oracle.test.tsx b/packages/react-db/tests/convex-lazy-paging-oracle.test.tsx new file mode 100644 index 0000000000..02aee78c34 --- /dev/null +++ b/packages/react-db/tests/convex-lazy-paging-oracle.test.tsx @@ -0,0 +1,112 @@ +/** Receiving witness for the shared Convex lazy window law through the actual + * React infinite-query binding. Page labels, every page, data and lookahead + * are public observations. This supplies React scheduling, not another + * pagination algorithm. Other framework bindings remain separate owners. */ +import { act, renderHook } from '@testing-library/react' +import { useLayoutEffect } from 'react' +import { expect, it } from 'vitest' +import { useLiveInfiniteQuery } from '../src/useLiveInfiniteQuery' +import { lazyFixture } from '../../convex-db-collection/tests/lazy-paging-driver' +import { + copyLazyRows, + expectedLazyRows, +} from '../../convex-db-collection/tests/lazy-paging-model-oracle' +import { withTestCleanup } from '../../convex-db-collection/tests/helpers' + +it.each([1, 2, 3, 4])( + `receives exact infinite-query pages with provider page size %s`, + async (pageSize) => { + const rows = Array.from({ length: 7 }, (_, i) => ({ + id: `r${i}`, + rank: Math.floor(i / 2), + text: `row ${i}`, + })) + const f = lazyFixture(rows, pageSize) + const permission = {} + f.remote.scopes.set(permission, { limit: 3 }) + const trace: Array = [] + const expectedLedger = (pages: number) => { + const data = expectedLazyRows(rows, { limit: pages * 2 }) + return JSON.stringify({ + data, + pages: Array.from({ length: pages }, (_, i) => + data.slice(i * 2, i * 2 + 2), + ), + pageParams: Array.from({ length: pages }, (_, i) => i), + hasNextPage: rows.length > pages * 2, + }) + } + const hook = renderHook(() => { + const result = useLiveInfiniteQuery( + (q) => + q + .from({ row: f.source }) + .orderBy(({ row }) => row.rank) + .orderBy(({ row }) => row.id) + .select(({ row }) => row), + { pageSize: 2 }, + ) + // Capture each committed ready tuple, including intermediate fetch commits. + // Discarded concurrent renders are not public publications. + useLayoutEffect(() => { + if (result.isReady) + trace.push( + JSON.stringify({ + data: copyLazyRows(result.data), + pages: result.pages.map(copyLazyRows), + pageParams: result.pageParams, + hasNextPage: result.hasNextPage, + }), + ) + }) + return result + }) + await withTestCleanup( + async () => { + await act(async () => { + await f.remote.pump() + }) + for (let pages = 1; pages <= 4; pages++) { + if (pages > 1) { + f.remote.scopes.set(permission, { limit: pages * 2 + 1 }) + await act(async () => { + const fetching = hook.result.current.fetchNextPage() + await f.remote.pump() + await fetching + }) + } + const publications = trace.splice(0) + expect( + publications, + `React lazy publication: no ready target tuple`, + ).toContain(expectedLedger(pages)) + expect(hook.result.current.isReady).toBe(true) + let advanced = false + for (const snapshot of publications) { + if (snapshot === expectedLedger(pages)) advanced = true + else + expect( + snapshot, + `React lazy publication: torn or stale page ledger`, + ).toBe(expectedLedger(advanced ? pages : Math.max(1, pages - 1))) + } + const expected = expectedLazyRows(rows, { limit: pages * 2 }) + expect(copyLazyRows(hook.result.current.data)).toEqual(expected) + expect(hook.result.current.pages.map(copyLazyRows)).toEqual( + Array.from({ length: pages }, (_, i) => + expected.slice(i * 2, i * 2 + 2), + ), + ) + expect(hook.result.current.pageParams).toEqual( + Array.from({ length: pages }, (_, i) => i), + ) + expect(hook.result.current.hasNextPage).toBe(rows.length > pages * 2) + expect(hook.result.current.isFetchingNextPage).toBe(false) + } + }, + () => hook.unmount(), + () => hook.result.current.collection.cleanup(), + () => f.source.cleanup(), + ) + }, +) From 160b5ce2486e7d12ce53cde0518eb761dc5eace9 Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Tue, 27 Oct 2026 13:49:54 -0600 Subject: [PATCH 18/20] fix: type React paging cleanup and Electric guide rows --- docs/collections/electric-collection.md | 4 ++-- .../oracle-reviews/2026-10-27-convex-ci-types.md | 11 +++++++++++ .../collection-setup/references/electric-adapter.md | 6 ++++-- packages/db/skills/db-core/persistence/SKILL.md | 2 +- .../react-db/tests/convex-lazy-paging-oracle.test.tsx | 7 ++++++- 5 files changed, 24 insertions(+), 6 deletions(-) create mode 100644 docs/contributing/oracle-reviews/2026-10-27-convex-ci-types.md diff --git a/docs/collections/electric-collection.md b/docs/collections/electric-collection.md index 806d3d8546..04aeb7fd26 100644 --- a/docs/collections/electric-collection.md +++ b/docs/collections/electric-collection.md @@ -28,7 +28,7 @@ const todosCollection = createCollection( shapeOptions: { url: '/api/todos', }, - getKey: (item) => item.id, + getKey: (item: { id: string }) => item.id, }) ) ``` @@ -143,7 +143,7 @@ For quick prototyping or when you're confident about timing, you can use a simpl const todosCollection = createCollection( electricCollectionOptions({ id: 'todos', - getKey: (item) => item.id, + getKey: (item: { id: string }) => item.id, shapeOptions: { url: '/api/todos', params: { table: 'todos' }, diff --git a/docs/contributing/oracle-reviews/2026-10-27-convex-ci-types.md b/docs/contributing/oracle-reviews/2026-10-27-convex-ci-types.md new file mode 100644 index 0000000000..116583c0b2 --- /dev/null +++ b/docs/contributing/oracle-reviews/2026-10-27-convex-ci-types.md @@ -0,0 +1,11 @@ +# CI type integration follow-up + +Parent: `23a097ca6716384c4e8b29bc68b032b9152dded3`. + +PR workflow 40569615912 failed at two type boundaries. The React receiving witness accessed an optional Collection during cleanup without narrowing it. The documentation checker rejected five Electric examples whose untyped key callbacks returned `unknown`. + +The React cleanup callback now checks for a missing Collection, throws if absent, and otherwise performs the same cleanup. Its independent model, page histories, publication recorder, and comparisons remain unchanged. This is a test typing repair, not a product behavior change or new oracle closure claim. Earlier law, reach and mutant evidence retains its original source binding. + +The Electric examples declare the string ID expected by their key callbacks. The schema/parser example imports Zod so its output type includes Date and Electric can infer the matching parser extension. No casts, suppression, baseline additions, or runtime changes were introduced. + +Validation: rebuilt DB; all four React receiving cases passed with Vitest typechecking reporting no errors. The full documentation check passed all 769 examples against the existing baseline: zero new failures, 276 known failures, including 61 parse failures. Changed-file oracle lint and targeted React ESLint passed. The RED evidence is the parent CI log, including React TS18048 at cleanup and five Electric TS2769 diagnostics. These checks establish type integration and the existing four React page-size histories, not a new product law or cloud-provider guarantee. 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 5886791c35..98927cf408 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 @@ -15,7 +15,7 @@ import { electricCollectionOptions } from '@tanstack/electric-db-collection' const collection = createCollection( electricCollectionOptions({ shapeOptions: { url: '/api/todos' }, - getKey: (item) => item.id, + getKey: (item: { id: string }) => item.id, }), ) ``` @@ -140,6 +140,8 @@ When using Electric with a schema, data enters the collection via **two independ For types that need transformation (e.g., `timestamptz`), you need BOTH configured: ```typescript +import { z } from 'zod' + const todosCollection = createCollection( electricCollectionOptions({ schema: z.object({ @@ -154,7 +156,7 @@ const todosCollection = createCollection( timestamptz: (value: string) => new Date(value), // sync path: parse incoming strings }, }, - getKey: (item) => item.id, + getKey: (item: { id: string }) => item.id, }), ) ``` diff --git a/packages/db/skills/db-core/persistence/SKILL.md b/packages/db/skills/db-core/persistence/SKILL.md index 66330ffcc5..30d813d8a0 100644 --- a/packages/db/skills/db-core/persistence/SKILL.md +++ b/packages/db/skills/db-core/persistence/SKILL.md @@ -96,7 +96,7 @@ const todosCollection = createCollection( ...electricCollectionOptions({ id: 'todos', shapeOptions: { url: '/api/electric/todos' }, - getKey: (item) => item.id, + getKey: (item: { id: string }) => item.id, }), persistence, schemaVersion: 1, diff --git a/packages/react-db/tests/convex-lazy-paging-oracle.test.tsx b/packages/react-db/tests/convex-lazy-paging-oracle.test.tsx index 02aee78c34..214abb38d6 100644 --- a/packages/react-db/tests/convex-lazy-paging-oracle.test.tsx +++ b/packages/react-db/tests/convex-lazy-paging-oracle.test.tsx @@ -105,7 +105,12 @@ it.each([1, 2, 3, 4])( } }, () => hook.unmount(), - () => hook.result.current.collection.cleanup(), + () => { + const collection = hook.result.current.collection + if (collection === undefined) + throw new Error(`React lazy paging Collection missing at cleanup`) + return collection.cleanup() + }, () => f.source.cleanup(), ) }, From 969078c19d4a4e7a888d4d4466e3d0d99a1372cc Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Tue, 27 Oct 2026 14:08:38 -0600 Subject: [PATCH 19/20] test: honor unindexed joined refetch fallback in work oracle --- docs/contributing/oracle-coverage.md | 15 ++++++++ .../2026-10-27-ordered-refetch-scope.md | 23 ++++++++++++ .../ordered-work-oracle.property.test.ts | 36 ++++++++++++++++++- 3 files changed, 73 insertions(+), 1 deletion(-) create mode 100644 docs/contributing/oracle-reviews/2026-10-27-ordered-refetch-scope.md diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index 254f94d157..8110678e0c 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -4690,3 +4690,18 @@ campaign was not run on `main` at that time. Owner: the includes oracle owner. N path from a later run. Until then, this entry cannot tell an operator regression from an older defect. A later failure of that campaign must record its seed here before anyone calls it pre-existing. + +### Ordered signalling-refetch work scope, October 27 + +Owner: `packages/db/tests/query/ordered-work-oracle.property.test.ts`. The +architecture's "Ordered requests, continuation, and recovery" law permits +full-source fallback for an underfilled joined prefix without an order index. +The signalling-refetch assertion therefore forbids extra full-source requests +only when finite continuation is available, indexed joined windows and direct +source-filter windows. Unindexed joined histories retain all public row, order, +publication and other applicable work comparisons. They do not claim a bounded +refetch transfer. The authority is the existing architecture contract, not a +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. diff --git a/docs/contributing/oracle-reviews/2026-10-27-ordered-refetch-scope.md b/docs/contributing/oracle-reviews/2026-10-27-ordered-refetch-scope.md new file mode 100644 index 0000000000..3406a2e372 --- /dev/null +++ b/docs/contributing/oracle-reviews/2026-10-27-ordered-refetch-scope.md @@ -0,0 +1,23 @@ +# Ordered signalling-refetch oracle scope + +Parent: `160b5ce2486e7d12ce53cde0518eb761dc5eace9`. + +## Law and authority + +The live-query architecture's "Ordered requests, continuation, and recovery" section states: finite continuation requires an order index, and an underfilled unindexed prefix retains the established full-source fallback. Its work law requires an applicable index. A no-additional-full-source assertion for every joined refetch exceeded that contract. + +CI workflow 40572667149 failed the generated `ordered-work.eager-indexed-window` property at seed `-931507916`, path `21:3:3:3:4:3:4:4:4:5:4:4:4:4`. Local replay reproduced the same work assertion, two full-source requests versus zero, after an empty source receives an ineligible row and a marker change. These were two consumers' permitted fallback reads. No production defect is claimed. + +## Repair and evidence + +The signalling-refetch transfer assertion now requires bounded continuation to be available: an indexed joined window or a direct source-filter window. Unindexed joined histories still execute every row, order, publication, and other applicable work comparison. The coverage map records the bounded-transfer limit and owner. Existing generated dimensions already include both index choices and these actions; they remain unchanged. + +The exact replay passes. All 270 existing tests in the owner pass with no type errors. Two fixed inputs replay the reduced trace through the same `checkEagerWindow` model, driver and comparisons, once without an index and once with it; both pass. They carry no independent expected answers. These fixed inputs are distinguishing witnesses, not a new generated reach claim. + +A temporary wrong-observation control added one full-source request only on indexed histories immediately before the work comparison. The indexed neighboring witness failed at that comparison, one request versus zero; the unindexed witness passed. This demonstrates retained comparator sensitivity, not a production-mutant result. The control was removed. Changed oracle lint and targeted ESLint passed; ESLint reports four unchanged marker-shadow warnings outside this edit. + +Replay command: `TANSTACK_DB_ORACLE_PROPERTY=ordered-work.eager-indexed-window TANSTACK_DB_ORACLE_SEED=-931507916 TANSTACK_DB_ORACLE_PATH=21:3:3:3:4:3:4:4:4:5:4:4:4:4 pnpm --filter @tanstack/db exec vitest run tests/query/ordered-work-oracle.property.test.ts -t 'keeps rows and per-change work bounded for a random' --coverage.enabled=false`. + +## Limits + +This corrects an oracle assertion under an existing architectural contract. It does not revise a product law or establish a new behavior-class closure. Unindexed joined refetch transfer is not bounded by this assertion. Existing independent row checks remain mandatory, and indexed histories continue to reject full-source work at the tested checkpoint. No shipped implementation changed. Fresh CI must pass before readiness is reported. 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 1c73f7bb73..498eb90f29 100644 --- a/packages/db/tests/query/ordered-work-oracle.property.test.ts +++ b/packages/db/tests/query/ordered-work-oracle.property.test.ts @@ -5892,7 +5892,15 @@ async function runEagerWindow(testCase: EagerCase): Promise<{ // tie, and refill; a whole-source read would be the cost the report // exists to avoid. A page that ends on a null or NaN rank takes the // documented full-source fallback instead. - if (signalsRefetch && unexpressiblePageEnds === unexpressibleBefore) { + // A joined predicate can leave the root prefix underfilled. Without + // an order index, finite continuation is unavailable, so the + // architecture permits full-source fallback. Row checks still apply. + const boundedContinuation = testCase.indexed || !joined + if ( + signalsRefetch && + boundedContinuation && + unexpressiblePageEnds === unexpressibleBefore + ) { expect( fullSourceRequests, `full-source requests after a signalling refetch at step ${step}`, @@ -7908,6 +7916,32 @@ describe(`eager indexed ordered windows`, () => { }, ) + it.each([false, true])( + `revalidates an empty joined prefix after an ineligible insert (indexed: %s)`, + async (indexed) => { + await checkEagerWindow({ + path: `on-demand`, + provider: `refetch-signal`, + latency: `immediate`, + cancellation: `cooperative`, + peer: false, + filter: `joined`, + via: `marker`, + indexed, + direction: `asc`, + nulls: `first`, + terms: 1, + limit: 1, + offset: 0, + initial: [], + history: [ + { type: `upsert`, id: 0, rank: 0, eligible: false }, + { type: `flip`, id: 1 }, + ], + }) + }, + ) + fcTest.prop([eagerCase], { numRuns: runs, seed: 2044 })( `keeps rows and per-change work bounded for a fixed seed`, checkEagerWindow, From 73ddc8bacf9498a5c89269e15fadce1657ba15f3 Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Tue, 27 Oct 2026 20:49:36 -0600 Subject: [PATCH 20/20] fix: regenerate minifier cache for Convex fixture names --- .../2026-10-28-convex-mangle-cache.md | 28 +++++++++++++++++++ packages/db/mangle-cache.json | 1 - 2 files changed, 28 insertions(+), 1 deletion(-) create mode 100644 docs/contributing/oracle-reviews/2026-10-28-convex-mangle-cache.md diff --git a/docs/contributing/oracle-reviews/2026-10-28-convex-mangle-cache.md b/docs/contributing/oracle-reviews/2026-10-28-convex-mangle-cache.md new file mode 100644 index 0000000000..508b548132 --- /dev/null +++ b/docs/contributing/oracle-reviews/2026-10-28-convex-mangle-cache.md @@ -0,0 +1,28 @@ +# Convex integration minifier cache, October 28 + +Source parent: `48a6c492a6e00439467ee8b0ec9dfafe8ea95aa6`. +PR workflow 40622732106, job 122600360701, failed `pnpm check:mangle`: +`failQuery` was also used by the Convex backend fixture's public control field. +Main's new private method was therefore unsafe under the existing cache policy. + +The authority is `scripts/mangle-private-members.mjs`: every cache key must be +private-only, with no matching property use in another package. Its supported +`--write` generator preserves safe assignments and removes unsafe names. +Running it removed exactly `failQuery: gZ`, added no names, and changed no +source behavior, model, grammar, comparison, fixture schema or public API. +Renaming the fixture or excluding it from the checker would hide the collision. + +RED: `pnpm check:mangle` reproduced the same unsafe-entry failure locally. +GREEN: the regenerated 439-name cache passes that command. A fresh minified DB +build passes `pnpm test:minified-db`, checking 114 exported error names, index +metadata, query rows and live updates. The rest of that CI job's checks also +pass locally: client-only query bundle checks, production error text checks +(263 literals, including 22 shared), and all 318 built-dist cases in five files. +Changed lint passes. Existing minified and dist oracles supply unchanged public +behavior checks; this cache repair adds no new law or generated-history claim. +Fresh remote CI is required before declaring the new head ready. + +`pnpm size:compare --base HEAD --exact-base --packages db --no-scenarios` +measures DB at 108498 to 108501 gzip bytes (+3) and +26 brotli bytes. The +private method keeps its full name to meet the existing public-property safety +policy; no extra recovery machinery or runtime branch was added. diff --git a/packages/db/mangle-cache.json b/packages/db/mangle-cache.json index 275273c756..d332dc950a 100644 --- a/packages/db/mangle-cache.json +++ b/packages/db/mangle-cache.json @@ -422,7 +422,6 @@ "reportedTree": "fD", "rootLoadsStarted": "gX", "enterErrorState": "gY", - "failQuery": "gZ", "callLoadSubset": "g_", "releaseDemands": "g$", "crashQuery": "g0",