From 873e3ec9e3a843d72abe379f22f17129ad691a7f Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Thu, 17 Sep 2026 09:12:27 -0600 Subject: [PATCH 1/2] test: harden fix-wave oracle ownership --- docs/contributing/oracle-coverage.md | 23 +- packages/db/package.json | 2 +- .../db/tests/collection-auto-index.test.ts | 42 -- .../cold-join-reconciliation-oracle.test.ts | 495 +++++++++++++++++- packages/db/tests/query/join.test.ts | 464 ---------------- .../query/pagination-oracle.property.test.ts | 100 +++- packages/powersync-db-collection/package.json | 3 +- 7 files changed, 608 insertions(+), 521 deletions(-) diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index 528cabbe63..40981ac365 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -13,6 +13,21 @@ received routing recommendations, not 197 full semantic reviews. Focused examples, type tests, and host-wiring tests remain useful; converting them all to generated tests is not a completion criterion. +### Recent fix-wave authority inventory + +This inventory records the permanent authority for the September 17 fix wave. It +distinguishes an executable oracle from a specialized real-provider authority and +does not award oracle credit for a filename alone. + +| PR | Classification | Permanent authority and campaign | +| --- | --- | --- | +| [#1831](https://github.com/TanStack/db/pull/1831) | Explicit oracle | PowerSync's `packages/powersync-db-collection/tests/correctness-oracle.test.ts` crosses real PowerSync receipts and native SQLite behavior. It runs in the package test campaign and the focused `test:oracles` campaign; portable declarations retain compiler authority. | +| [#1832](https://github.com/TanStack/db/pull/1832) | Equivalent specialized authority | `packages/electric-db-collection/e2e/sql-predicate-semantics.e2e.test.ts` and `packages/electric-db-collection/e2e/subset-sql-acceptance.e2e.test.ts` run through the package's real-provider `test:e2e` campaign. Compiler unit tests are collateral, not substitutes for either service boundary. | +| [#1833](https://github.com/TanStack/db/pull/1833) | Explicit oracle | `packages/db/tests/query/pagination-oracle.property.test.ts` owns inherited collection collation, actual `item2`/`item10` order, exact request options, hostile lexical/numeric controls, and both scan and auto-index paths. It runs in `@tanstack/db`'s `test:oracles` campaign. | +| [#1834](https://github.com/TanStack/db/pull/1834) | Explicit oracle | `packages/db/tests/query/cold-join-reconciliation-oracle.test.ts` owns join/predicate equality equivalence across the established value domains, binary/string and nullish controls, replacement histories, raw lazy demand, and scan/auto-index paths. It runs in `@tanstack/db`'s `test:oracles` campaign. | +| [#1835](https://github.com/TanStack/db/pull/1835) | Explicit oracle | The existing `packages/db/tests/collection-state-retention-oracle.property.test.ts` and `packages/db/tests/optimistic-transaction-oracle.property.test.ts` owners cover separate collection-state and transaction-history laws. Both were already registered in `@tanstack/db`'s `test:oracles` campaign; focused storage/local-only tests remain collateral. | +| [#1842](https://github.com/TanStack/db/pull/1842) | No shipped-law case | The PR changed only focused observer tests and introduced no production behavior. `packages/db/tests/live-query-observer.test.ts` remains the correct evidence; no synthetic oracle or campaign claim is added. | + [PR #1816](https://github.com/TanStack/db/pull/1816) preserves existing witnesses, repairs false-green assertions and drivers, adds missing histories, and includes narrow runtime fixes reproduced by the stronger tests. The later ten-area audit @@ -34,10 +49,11 @@ comment and the current API/architecture contract before extending its model. | Optimistic state | [history model](../../packages/db/tests/optimistic-history-oracle.ts), [generated histories](../../packages/db/tests/optimistic-transaction-oracle.property.test.ts), [outcomes](../../packages/db/tests/optimistic-history-outcomes.test.ts), [publication](../../packages/db/tests/optimistic-history-publication.test.ts) | Independent whole-row snapshots, rollback dependencies, metadata and prior-value events. Never rebase a pending snapshot merely to simplify the model. | | Drafts and native values | [proxy](../../packages/db/tests/proxy.test.ts), [detachment](../../packages/db/tests/proxy-detachment-contract.test.ts), [iteration](../../packages/db/tests/proxy-iteration-contract.test.ts) | Native-operation controls, exact patches and actual stored rows; alias/cycle/adversarial-key histories. General native-mutator and symbol-write support is not established by a plain-object oracle. | | Query DB and observer | [ownership](../../packages/query-db-collection/tests/ownership-lifecycle.oracle.test.ts), [load lifecycle](../../packages/query-db-collection/tests/load-subset-lifecycle-oracle.test.ts), [observer histories](../../packages/db/tests/live-query-observer-history.property.test.ts) | Real QueryClient boundary and a per-listener eligibility ledger, not a duplicate dispatch queue. Check reentry, peer survival, FIFO and disposal independently of final rows. | -| Ordered acquisition | [pagination](../../packages/db/tests/query/pagination-oracle.property.test.ts), [ordered work](../../packages/db/tests/query/ordered-work-oracle.property.test.ts), [ordered lifecycle](../../packages/db/tests/query/ordered-lifecycle-oracle.property.test.ts) | Complete finite provider results, pending windows, ties/nulls, ownership and documented repair timing. Request completion is not proof of unrequested source extent. | +| Ordered acquisition | [pagination](../../packages/db/tests/query/pagination-oracle.property.test.ts), [ordered work](../../packages/db/tests/query/ordered-work-oracle.property.test.ts), [ordered lifecycle](../../packages/db/tests/query/ordered-lifecycle-oracle.property.test.ts) | Complete finite provider results, inherited collation with exact own-key request options, real lexical/numeric disagreement, pending windows, ties/nulls, ownership and documented repair timing. Request completion is not proof of unrequested source extent. | +| Join equality and cold acquisition | `packages/db/tests/query/cold-join-reconciliation-oracle.test.ts` | Independent recomputation for cold acquisition plus direct join/predicate equivalence across established equality domains. Binary/string and nullish classes, replacement histories, raw on-demand values, and both scan/auto-index paths are explicit; compound join syntax is not claimed. | | Opaque backend pagination | [window oracle](../../packages/query-db-collection/tests/cursor-pagination.oracle.test.ts), [cache histories](../../packages/query-db-collection/tests/cursor-pagination.cache-oracle.test.ts), [cache publication](../../packages/query-db-collection/tests/cursor-pagination.publication-oracle.test.ts), [browser acquisition boundaries](../../packages/query-db-collection/tests/cursor-pagination.boundary-oracle.test.ts), [QueryCollection integration](../../packages/query-db-collection/tests/cursor-pagination.integration.test.ts) | Full filter/sort/slice reference, opaque token transport, actual Query cache expiry/invalidation/GC, forced refresh during growth, protocol failure publication/recovery, bounded slice work, nested cancellation/replacement, reader abort, browser retry defaults, manual-write cache isolation, and production window publications. Stable backend sequences; not snapshot guarantees for changing endpoints. Peek-ahead remains enabled. | | Electric and TrailBase | [Electric histories](../../packages/electric-db-collection/tests/electric-oracle.property.test.ts), [PostgreSQL semantics](../../packages/electric-db-collection/e2e/sql-predicate-semantics.e2e.test.ts), [TrailBase contract](../../packages/trailbase-db-collection/tests/ORACLE.md) | Installed SDK delivery/framing, independent predicates, exact subscription arguments and late errors. SDK fixtures and a real service test earn different credit. | -| PowerSync | [tests](../../packages/powersync-db-collection/tests) | Applied receipt positions crossed with held peers, native SQLite/SDK and cleanup evidence. A timeout mutant proves a progress failure, not every value assertion. | +| PowerSync | [tests](../../packages/powersync-db-collection/tests), `tests/correctness-oracle.test.ts` | Applied receipt positions crossed with held peers, native SQLite/SDK and cleanup evidence. Run the focused owner with the package's `test:oracles` command. A timeout mutant proves a progress failure, not every value assertion. | | SQLite persistence and native hosts | [persisted histories](../../packages/db-sqlite-persistence-core/tests/persisted.test.ts), [driver contracts](../../packages/db-sqlite-persistence-core/tests/contracts/sqlite-driver-contract.ts), [113-law manifest](../../packages/db-collection-e2e/src/fixtures/persisted-conformance-manifest.ts) | Cache/remote rejection/peer/reopen histories and exact driver results. The manifest excludes progressive and move suites; registration and shim runs are not device execution. | | Offline execution | [scheduler](../../packages/offline-transactions/tests/KeyScheduler.property.test.ts), [leadership](../../packages/offline-transactions/tests/leadership-replay.property.test.ts), [settlement](../../packages/offline-transactions/tests/transaction-settlement.property.test.ts), [serialization](../../packages/offline-transactions/tests/transaction-serializer.property.test.ts) | Declarative FIFO eligibility, per-transaction outcomes, durable state and typed wire trees. Issued work may finish after ownership loss, but new work must not start. Exactly-once network execution is not promised. | | Frameworks | [React conformance](../../packages/react-db/tests/conformance.test.tsx), [React pagination](../../packages/react-db/tests/infinite-query-conformance.test.tsx), [shared suites](../../packages/db-collection-e2e/src/suites) | Exact exposed rows/pages and each framework's own lifecycle cuts. A React witness does not prove Vue/Solid/Angular/Svelte scheduling. Preserve their receiving registrations. | @@ -79,6 +95,9 @@ when they replace that path. ```sh pnpm --filter @tanstack/db-ivm build pnpm --filter @tanstack/db build +pnpm --filter @tanstack/db test:oracles +pnpm --filter @tanstack/powersync-db-collection test:oracles +pnpm --filter @tanstack/electric-db-collection test:e2e pnpm run typecheck:tests pnpm exec tsc --noEmit -p packages/db/tsconfig.json pnpm --dir packages/db exec vitest run --coverage.enabled=false --maxWorkers=2 diff --git a/packages/db/package.json b/packages/db/package.json index 18857f854b..af34a1b2ee 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -22,7 +22,7 @@ "lint": "eslint . --fix", "test": "vitest --run", "test:facade-retention": "node --expose-gc --import tsx tests/facade-retention.probe.ts", - "test:oracles": "vitest --run tests/collection-cleanup-restart-oracle.test.ts tests/effect-disposal-oracle.test.ts tests/optimistic-transaction-oracle.property.test.ts tests/optimistic-settlement-boundaries.test.ts tests/optimistic-history-publication.test.ts tests/optimistic-history-outcomes.test.ts tests/collection-metadata-publication-oracle.property.test.ts tests/collection-state-retention-oracle.property.test.ts tests/collection-subscription-lifecycle-history.property.test.ts tests/collection-subscription-lifecycle-oracle.test.ts tests/collection-subscription-lifecycle-publication.property.test.ts tests/collection-subscription-replay-oracle.property.test.ts tests/d2-source-reconciliation-oracle.property.test.ts tests/live-query-observer-history.property.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-publication-oracle.test.ts tests/query/includes-query-shape-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.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", + "test:oracles": "vitest --run tests/collection-cleanup-restart-oracle.test.ts tests/effect-disposal-oracle.test.ts tests/optimistic-transaction-oracle.property.test.ts tests/optimistic-settlement-boundaries.test.ts tests/optimistic-history-publication.test.ts tests/optimistic-history-outcomes.test.ts tests/collection-metadata-publication-oracle.property.test.ts tests/collection-state-retention-oracle.property.test.ts tests/collection-subscription-lifecycle-history.property.test.ts tests/collection-subscription-lifecycle-oracle.test.ts tests/collection-subscription-lifecycle-publication.property.test.ts tests/collection-subscription-replay-oracle.property.test.ts tests/d2-source-reconciliation-oracle.property.test.ts tests/live-query-observer-history.property.test.ts tests/query/cold-join-reconciliation-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-publication-oracle.test.ts tests/query/includes-query-shape-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.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", "bench:nested-includes": "vitest bench tests/query/includes-performance.bench.ts --run" }, "type": "module", diff --git a/packages/db/tests/collection-auto-index.test.ts b/packages/db/tests/collection-auto-index.test.ts index 633ea9f947..7d004d4946 100644 --- a/packages/db/tests/collection-auto-index.test.ts +++ b/packages/db/tests/collection-auto-index.test.ts @@ -347,48 +347,6 @@ describe(`Collection Auto-Indexing`, () => { subscription3.unsubscribe() }) - it(`omits unset locale fields from collection compare options`, async () => { - const collection = createCollection<{ id: string }, string>({ - getKey: (item) => item.id, - defaultStringCollation: { stringSort: `locale` }, - sync: { - sync: () => {}, - }, - }) - - try { - expect(collection.compareOptions.stringSort).toBe(`locale`) - expect(Object.keys(collection.compareOptions).sort()).toEqual([ - `stringSort`, - ]) - } finally { - await collection.cleanup() - } - }) - - it(`preserves explicit locale settings when locale mode is selected by default`, async () => { - const collection = createCollection<{ id: string }, string>({ - getKey: (item) => item.id, - defaultStringCollation: { - locale: `en-US`, - localeOptions: { sensitivity: `base` }, - }, - sync: { - sync: () => {}, - }, - }) - - try { - expect(collection.compareOptions).toEqual({ - stringSort: `locale`, - locale: `en-US`, - localeOptions: { sensitivity: `base` }, - }) - } finally { - await collection.cleanup() - } - }) - it(`should create auto-indexes for different supported operations`, async () => { const autoIndexCollection = createCollection({ getKey: (item) => item.id, diff --git a/packages/db/tests/query/cold-join-reconciliation-oracle.test.ts b/packages/db/tests/query/cold-join-reconciliation-oracle.test.ts index 0d93900801..7b506b8ccf 100644 --- a/packages/db/tests/query/cold-join-reconciliation-oracle.test.ts +++ b/packages/db/tests/query/cold-join-reconciliation-oracle.test.ts @@ -1,10 +1,16 @@ -import { expect, it } from 'vitest' import fc from 'fast-check' +import { Temporal } from 'temporal-polyfill' +import { describe, expect, it } from 'vitest' import { createCollection } from '../../src/collection/index.js' import { BTreeIndex } from '../../src/indexes/btree-index.js' import { createLiveQueryCollection, eq } from '../../src/query/index.js' import { runTrace } from '../trace-runner.js' -import type { ChangeMessage, SyncConfig } from '../../src/types.js' +import type { Collection } from '../../src/collection/index.js' +import type { + ChangeMessage, + LoadSubsetOptions, + SyncConfig, +} from '../../src/types.js' type Parent = { id: number; name: string } type Child = { id: number; parentId: number; amount: number } @@ -209,6 +215,491 @@ async function runColdJoin(steps: ReadonlyArray, fault?: Fault) { return batches } +/** + * Join-equality contract: a compiled equality join must agree with the + * predicate evaluator over the established ValueIdentity domains. The + * independent predicate source prevents shared indexes from satisfying path + * reach. Binary/string and nullish controls, replacement histories, raw lazy + * demand, and off/eager index modes guard the historical key-normalization + * defect without claiming compound join syntax. + */ +type EqualityRow = { id: number; value: unknown } +type JoinPair = readonly [number | undefined, number | undefined] +type EqualityMode = `off` | `eager` +type MutableEqualitySource = Collection & { + oracleReplace: (row: T) => Promise +} + +const equalityJoinCases = [ + { + label: `scalar strings`, + createValues: () => ({ left: `match`, right: `match`, other: `other` }), + }, + { + label: `small binary values`, + createValues: () => ({ + left: new Uint8Array(16).fill(7), + right: new Uint8Array(16).fill(7), + other: new Uint8Array(16).fill(8), + }), + }, + { + label: `large binary values`, + createValues: () => ({ + left: new Uint8Array(200).fill(7), + right: new Uint8Array(200).fill(7), + other: new Uint8Array(200).fill(8), + }), + }, + { + label: `Uint8Array and Buffer values`, + createValues: () => ({ + left: new Uint8Array(16).fill(7), + right: Buffer.alloc(16, 7), + other: new Uint8Array(16).fill(8), + }), + }, + { + label: `Date timestamps`, + createValues: () => ({ + left: new Date(1), + right: new Date(1), + other: new Date(2), + }), + }, + { + label: `Temporal values`, + createValues: () => ({ + left: Temporal.PlainDate.from(`2024-04-05`), + right: Temporal.PlainDate.from(`2024-04-05`), + other: Temporal.PlainDate.from(`2024-04-06`), + }), + }, + { + label: `BigInt values`, + createValues: () => ({ + left: 9007199254740993n, + right: 9007199254740993n, + other: 1n, + }), + }, + { + label: `NaN values`, + createValues: () => ({ left: Number.NaN, right: Number.NaN, other: 0 }), + }, + { + label: `non-finite numbers`, + createValues: () => ({ + left: Number.POSITIVE_INFINITY, + right: Number.POSITIVE_INFINITY, + other: Number.NEGATIVE_INFINITY, + }), + }, + { + label: `opaque shared references`, + createValues: () => { + const shared = { code: 1 } + return { left: shared, right: shared, other: { code: 1 } } + }, + }, +] as const + +let equalitySourceId = 0 + +function createEqualitySource( + label: string, + rows: ReadonlyArray, + autoIndex: EqualityMode, +): MutableEqualitySource { + let actions!: Parameters[`sync`]>[0] + const collection = createCollection({ + id: `${label}-${equalitySourceId++}`, + getKey: (row) => row.id, + autoIndex, + defaultIndexType: autoIndex === `eager` ? BTreeIndex : undefined, + startSync: true, + sync: { + sync: (nextActions) => { + actions = nextActions + const { begin, write, commit, markReady } = nextActions + begin() + for (const row of rows) write({ type: `insert`, value: { ...row } }) + commit() + markReady() + }, + }, + }) + return Object.assign(collection, { + oracleReplace: async (row: T) => { + actions.begin() + actions.write({ type: `update`, value: row }) + await actions.commit() + }, + }) +} + +function sortJoinPairs(pairs: Array): Array { + return pairs.sort( + ([leftA, rightA], [leftB, rightB]) => + (leftA ?? Number.POSITIVE_INFINITY) - + (leftB ?? Number.POSITIVE_INFINITY) || + (rightA ?? Number.POSITIVE_INFINITY) - + (rightB ?? Number.POSITIVE_INFINITY), + ) +} + +async function cleanupAll( + ...resources: Array<{ cleanup: () => Promise }> +) { + const results = await Promise.allSettled( + resources.map((resource) => resource.cleanup()), + ) + const errors = results.flatMap((result) => + result.status === `rejected` ? [result.reason] : [], + ) + if (errors.length) + throw new AggregateError(errors, `join oracle cleanup failed`) +} + +function bytesEqual(left: Uint8Array, right: Uint8Array): boolean { + return ( + left.length === right.length && + left.every((byte, index) => byte === right[index]) + ) +} + +describe.each([`off`, `eager`] as const)( + `join equality oracle with auto-index %s`, + (autoIndex) => { + it.each(equalityJoinCases)( + `$label joins agree with equality predicates`, + async ({ label, createValues }) => { + const { left: leftValue, right: rightValue, other } = createValues() + const left = createEqualitySource( + `equality-left-${label}`, + [{ id: 1, value: leftValue }], + autoIndex, + ) + const rightRows = [ + { id: 10, value: rightValue }, + { id: 20, value: other }, + ] + const right = createEqualitySource( + `equality-right-${label}`, + rightRows, + autoIndex, + ) + const predicateRight = createEqualitySource( + `equality-predicate-${label}`, + rightRows, + autoIndex, + ) + const joined = createLiveQueryCollection({ + startSync: true, + query: (q) => + q + .from({ left }) + .innerJoin({ right }, ({ left: l, right: r }) => + eq(l.value, r.value), + ) + .select(({ right: row }) => ({ id: row.id })), + }) + const filtered = createLiveQueryCollection({ + startSync: true, + query: (q) => + q + .from({ right: predicateRight }) + .where(({ right: row }) => eq(row.value, leftValue)) + .select(({ right: row }) => ({ id: row.id })), + }) + + try { + await Promise.all([joined.preload(), filtered.preload()]) + const expected = [{ id: 10 }] + expect(joined.toArray, `${label} join result`).toMatchObject(expected) + expect(filtered.toArray, `${label} predicate result`).toMatchObject( + expected, + ) + expect(joined.toArray.map(({ id }) => ({ id }))).toEqual( + filtered.toArray.map(({ id }) => ({ id })), + ) + if (autoIndex === `eager`) + expect( + right.indexes.size, + `${label} join auto-index reach`, + ).toBeGreaterThan(0) + else expect(right.indexes.size, `${label} join scan path`).toBe(0) + } finally { + await cleanupAll(joined, filtered, predicateRight, right, left) + } + }, + ) + + it(`keeps binary encodings and nullish operands in disjoint join classes`, async () => { + const bytes = new Uint8Array([65]) + const text = `\u0000tanstack-db:binary:A` + const binaryLeft = createEqualitySource( + `binary-disjoint-left`, + [{ id: 1, value: bytes }], + autoIndex, + ) + const binaryRight = createEqualitySource( + `binary-disjoint-right`, + [ + { id: 10, value: new Uint8Array(bytes) }, + { id: 20, value: text }, + ], + autoIndex, + ) + const binaryJoin = createLiveQueryCollection({ + startSync: true, + query: (q) => + q + .from({ left: binaryLeft }) + .innerJoin({ right: binaryRight }, ({ left, right }) => + eq(left.value, right.value), + ) + .select(({ right }) => ({ id: right.id })), + }) + + type NullishRow = { id: number; value: null | undefined | string } + const nullishRows: Array = [ + { id: 1, value: null }, + { id: 2, value: undefined }, + { id: 3, value: `\0m` }, + { id: 4, value: `\0j` }, + ] + const nullishLeft = createEqualitySource( + `nullish-left`, + nullishRows, + autoIndex, + ) + const nullishRight = createEqualitySource( + `nullish-right`, + nullishRows, + autoIndex, + ) + const fullJoin = createLiveQueryCollection({ + startSync: true, + query: (q) => + q + .from({ left: nullishLeft }) + .fullJoin({ right: nullishRight }, ({ left, right }) => + eq(left.value, right.value), + ) + .select(({ left, right }) => ({ + leftId: left.id, + rightId: right.id, + })), + }) + + try { + await Promise.all([binaryJoin.preload(), fullJoin.preload()]) + expect(binaryJoin.toArray).toMatchObject([{ id: 10 }]) + expect( + sortJoinPairs( + fullJoin.toArray.map( + ({ leftId, rightId }) => [leftId, rightId] as const, + ), + ), + ).toEqual([ + [1, undefined], + [2, undefined], + [3, 3], + [4, 4], + [undefined, 1], + [undefined, 2], + ]) + } finally { + await cleanupAll( + binaryJoin, + fullJoin, + binaryRight, + binaryLeft, + nullishRight, + nullishLeft, + ) + } + }) + + it(`preserves equality classes through equal, unequal, and nullish replacements`, async () => { + type LifecycleRow = { id: number; value: Uint8Array | number | null } + const left = createEqualitySource( + `lifecycle-left`, + [ + { id: 1, value: new Uint8Array([1, 2, 3]) }, + { id: 2, value: null }, + ], + autoIndex, + ) + const right = createEqualitySource( + `lifecycle-right`, + [ + { id: 10, value: new Uint8Array([1, 2, 3]) }, + { id: 20, value: null }, + ], + autoIndex, + ) + const joined = createLiveQueryCollection({ + startSync: true, + query: (q) => + q + .from({ left }) + .fullJoin({ right }, ({ left: l, right: r }) => + eq(l.value, r.value), + ) + .select(({ left: l, right: r }) => ({ + leftId: l.id, + rightId: r.id, + })), + }) + const pairs = () => + sortJoinPairs( + joined.toArray.map( + ({ leftId, rightId }) => [leftId, rightId] as const, + ), + ) + try { + await joined.preload() + expect(pairs()).toEqual([ + [1, 10], + [2, undefined], + [undefined, 20], + ]) + await right.oracleReplace({ + id: 10, + value: new Uint8Array([1, 2, 3]), + }) + expect(pairs()).toEqual([ + [1, 10], + [2, undefined], + [undefined, 20], + ]) + await right.oracleReplace({ + id: 10, + value: new Uint8Array([1, 2, 4]), + }) + expect(pairs()).toEqual([ + [1, undefined], + [2, undefined], + [undefined, 10], + [undefined, 20], + ]) + await right.oracleReplace({ + id: 10, + value: new Uint8Array([1, 2, 3]), + }) + expect(pairs()).toEqual([ + [1, 10], + [2, undefined], + [undefined, 20], + ]) + await right.oracleReplace({ id: 20, value: 1 }) + expect(pairs()).toEqual([ + [1, 10], + [2, undefined], + [undefined, 20], + ]) + await left.oracleReplace({ id: 2, value: 1 }) + expect(pairs()).toEqual([ + [1, 10], + [2, 20], + ]) + await left.oracleReplace({ id: 2, value: null }) + expect(pairs()).toEqual([ + [1, 10], + [2, undefined], + [undefined, 20], + ]) + } finally { + await cleanupAll(joined, right, left) + } + }) + + it(`passes raw binary equality demand through the lazy join path`, async () => { + type BinaryRow = { id: number; binaryId: Uint8Array } + const activeKey = new Uint8Array([1, 2, 3]) + const active = createEqualitySource( + `binary-demand-active`, + [{ id: 1, binaryId: activeKey }], + autoIndex, + ) + const backend: Array = [ + { id: 10, binaryId: new Uint8Array(activeKey) }, + { id: 20, binaryId: new Uint8Array([1, 2, 4]) }, + ] + let loadCalls = 0 + let candidateChecks = 0 + const requestedValues: Array = [] + const lazy = createCollection({ + id: `binary-demand-lazy-${autoIndex}-${equalitySourceId++}`, + getKey: (row) => row.id, + autoIndex, + defaultIndexType: autoIndex === `eager` ? BTreeIndex : undefined, + syncMode: `on-demand`, + sync: { + sync: (actions) => { + actions.markReady() + return { + loadSubset: (options: LoadSubsetOptions) => { + loadCalls++ + const where = options.where + if (where?.type !== `func` || where.name !== `in`) + throw new Error(`expected binary demand to use IN`) + const candidates = where.args[1] + if ( + candidates?.type !== `val` || + !Array.isArray(candidates.value) + ) + throw new Error(`expected binary demand candidates`) + requestedValues.push(...candidates.value) + const requested = candidates.value[0] + if (!(requested instanceof Uint8Array)) + throw new Error(`expected raw binary demand value`) + actions.begin() + for (const row of backend) { + candidateChecks++ + if (bytesEqual(row.binaryId, requested)) + actions.write({ type: `insert`, value: row }) + } + const receipt = actions.commit() + return receipt === true ? true : receipt + }, + unloadSubset: () => {}, + } + }, + }, + }) + const joined = createLiveQueryCollection({ + query: (q) => + q + .from({ left: active }) + .leftJoin({ right: lazy }, ({ left, right }) => + eq(left.binaryId, right.binaryId), + ) + .select(({ left, right }) => ({ + leftId: left.id, + rightId: right.id, + })), + }) + + try { + await joined.preload() + expect(loadCalls).toBe(1) + expect(candidateChecks).toBe(2) + expect(requestedValues).toHaveLength(1) + expect(requestedValues[0]).toBeInstanceOf(Uint8Array) + expect(Array.from(requestedValues[0] as Uint8Array)).toEqual( + Array.from(activeKey), + ) + expect(joined.toArray).toMatchObject([{ leftId: 1, rightId: 10 }]) + } finally { + await cleanupAll(joined, lazy, active) + } + }) + }, +) + const history: ReadonlyArray = [ { type: `delete`, id: 10 }, { type: `put`, row: { id: 10, parentId: 2, amount: 7 } }, diff --git a/packages/db/tests/query/join.test.ts b/packages/db/tests/query/join.test.ts index caa7124d47..9917dca4cd 100644 --- a/packages/db/tests/query/join.test.ts +++ b/packages/db/tests/query/join.test.ts @@ -13,7 +13,6 @@ import { or, } from '../../src/query/index.js' import { createCollection } from '../../src/collection/index.js' -import { createFilterFunctionFromExpression } from '../../src/collection/change-events.js' import { flushPromises, mockSyncCollectionOptions, @@ -50,92 +49,6 @@ const sampleDepartments: Array = [ { id: 3, name: `Marketing`, budget: 60000 }, ] -const equalityJoinCases = [ - { - label: `scalar strings`, - createValues: () => ({ left: `match`, right: `match`, other: `other` }), - }, - { - label: `small binary values`, - createValues: () => ({ - left: new Uint8Array(16).fill(7), - right: new Uint8Array(16).fill(7), - other: new Uint8Array(16).fill(8), - }), - }, - { - label: `large binary values`, - createValues: () => ({ - left: new Uint8Array(200).fill(7), - right: new Uint8Array(200).fill(7), - other: new Uint8Array(200).fill(8), - }), - }, - { - label: `Uint8Array and Buffer values`, - createValues: () => ({ - left: new Uint8Array(16).fill(7), - right: Buffer.alloc(16, 7), - other: new Uint8Array(16).fill(8), - }), - }, - { - label: `Date timestamps`, - createValues: () => ({ - left: new Date(1), - right: new Date(1), - other: new Date(2), - }), - }, - { - label: `Temporal values`, - createValues: () => ({ - left: Temporal.PlainDate.from(`2024-04-05`), - right: Temporal.PlainDate.from(`2024-04-05`), - other: Temporal.PlainDate.from(`2024-04-06`), - }), - }, - { - label: `BigInt values`, - createValues: () => ({ - left: 9007199254740993n, - right: 9007199254740993n, - other: 1n, - }), - }, - { - label: `NaN values`, - createValues: () => ({ left: Number.NaN, right: Number.NaN, other: 0 }), - }, - { - label: `non-finite numbers`, - createValues: () => ({ - left: Number.POSITIVE_INFINITY, - right: Number.POSITIVE_INFINITY, - other: Number.NEGATIVE_INFINITY, - }), - }, - { - label: `opaque shared references`, - createValues: () => { - const shared = { code: 1 } - return { left: shared, right: shared, other: { code: 1 } } - }, - }, -] - -type JoinPair = readonly [number | undefined, number | undefined] - -function sortJoinPairs(pairs: Array): Array { - return pairs.sort( - ([leftA, rightA], [leftB, rightB]) => - (leftA ?? Number.POSITIVE_INFINITY) - - (leftB ?? Number.POSITIVE_INFINITY) || - (rightA ?? Number.POSITIVE_INFINITY) - - (rightB ?? Number.POSITIVE_INFINITY), - ) -} - function createUsersCollection(autoIndex: `off` | `eager` = `eager`) { return createCollection( mockSyncCollectionOptions({ @@ -1134,287 +1047,6 @@ function createJoinTests(autoIndex: `off` | `eager`): void { }) }) - test.each(equalityJoinCases)( - `$label joins agree with equality predicates`, - ({ label, createValues }) => { - type EqualityRow = { id: number; value: unknown } - const { left: leftValue, right: rightValue, other } = createValues() - const leftCollection = createCollection( - mockSyncCollectionOptions({ - id: `equality-left-${autoIndex}-${label}`, - getKey: (row) => row.id, - initialData: [{ id: 1, value: leftValue }], - autoIndex, - }), - ) - const rightCollection = createCollection( - mockSyncCollectionOptions({ - id: `equality-right-${autoIndex}-${label}`, - getKey: (row) => row.id, - initialData: [ - { id: 10, value: rightValue }, - { id: 20, value: other }, - ], - autoIndex, - }), - ) - const joined = createLiveQueryCollection({ - startSync: true, - query: (q) => - q - .from({ left: leftCollection }) - .innerJoin({ right: rightCollection }, ({ left, right }) => - eq(left.value, right.value), - ) - .select(({ left, right }) => ({ - leftId: left.id, - rightId: right.id, - })), - }) - const filtered = createLiveQueryCollection({ - startSync: true, - query: (q) => - q - .from({ right: rightCollection }) - .where(({ right: row }) => eq(row.value, leftValue)) - .select(({ right: row }) => ({ rightId: row.id })), - }) - - expect(joined.toArray.map(stripVirtualProps)).toEqual([ - { leftId: 1, rightId: 10 }, - ]) - expect(filtered.toArray.map(stripVirtualProps)).toEqual([ - { rightId: 10 }, - ]) - }, - ) - - test(`binary values stay disjoint from normalization-like strings`, () => { - type EqualityRow = { id: number; value: unknown } - const bytes = new Uint8Array([65]) - const text = `\u0000tanstack-db:binary:A` - const leftCollection = createCollection( - mockSyncCollectionOptions({ - id: `binary-disjoint-left-${autoIndex}`, - getKey: (row) => row.id, - initialData: [{ id: 1, value: bytes }], - autoIndex, - }), - ) - const rightCollection = createCollection( - mockSyncCollectionOptions({ - id: `binary-disjoint-right-${autoIndex}`, - getKey: (row) => row.id, - initialData: [ - { id: 10, value: new Uint8Array(bytes) }, - { id: 20, value: text }, - ], - autoIndex, - }), - ) - const query = createLiveQueryCollection({ - startSync: true, - query: (q) => - q - .from({ left: leftCollection }) - .innerJoin({ right: rightCollection }, ({ left, right }) => - eq(left.value, right.value), - ) - .select(({ right }) => ({ rightId: right.id })), - }) - - expect(query.toArray.map(stripVirtualProps)).toEqual([{ rightId: 10 }]) - }) - - test(`full joins leave nullish equality operands unmatched`, () => { - type NullishRow = { id: number; value: null | undefined | string } - const leftCollection = createCollection( - mockSyncCollectionOptions({ - id: `nullish-full-left-${autoIndex}`, - getKey: (row) => row.id, - initialData: [ - { id: 1, value: null }, - { id: 2, value: undefined }, - { id: 3, value: `\0m` }, - { id: 4, value: `\0j` }, - ], - autoIndex, - }), - ) - const rightCollection = createCollection( - mockSyncCollectionOptions({ - id: `nullish-full-right-${autoIndex}`, - getKey: (row) => row.id, - initialData: [ - { id: 1, value: null }, - { id: 2, value: undefined }, - { id: 3, value: `\0m` }, - { id: 4, value: `\0j` }, - ], - autoIndex, - }), - ) - const query = createLiveQueryCollection({ - startSync: true, - query: (q) => - q - .from({ left: leftCollection }) - .fullJoin({ right: rightCollection }, ({ left, right }) => - eq(left.value, right.value), - ) - .select(({ left, right }) => ({ - leftId: left.id, - rightId: right.id, - })), - }) - - const pairs = sortJoinPairs( - query.toArray.map( - ({ leftId, rightId }) => [leftId, rightId] as const, - ), - ) - expect(pairs).toEqual([ - [1, undefined], - [2, undefined], - [3, 3], - [4, 4], - [undefined, 1], - [undefined, 2], - ]) - }) - - test(`binary join identity survives equal and unequal replacements`, () => { - type BinaryRow = { id: number; value: Uint8Array } - const leftCollection = createCollection( - mockSyncCollectionOptions({ - id: `binary-lifecycle-left-${autoIndex}`, - getKey: (row) => row.id, - initialData: [{ id: 1, value: new Uint8Array([1, 2, 3]) }], - autoIndex, - }), - ) - const rightCollection = createCollection( - mockSyncCollectionOptions({ - id: `binary-lifecycle-right-${autoIndex}`, - getKey: (row) => row.id, - initialData: [{ id: 10, value: new Uint8Array([1, 2, 3]) }], - autoIndex, - }), - ) - const query = createLiveQueryCollection({ - startSync: true, - query: (q) => - q - .from({ left: leftCollection }) - .fullJoin({ right: rightCollection }, ({ left, right }) => - eq(left.value, right.value), - ) - .select(({ left, right }) => ({ - leftId: left.id, - rightId: right.id, - })), - }) - const pairs = () => - sortJoinPairs( - query.toArray.map( - ({ leftId, rightId }) => [leftId, rightId] as const, - ), - ) - const replaceRight = (value: Uint8Array) => { - rightCollection.utils.begin() - rightCollection.utils.write({ - type: `update`, - value: { id: 10, value }, - }) - rightCollection.utils.commit() - } - - expect(pairs()).toEqual([[1, 10]]) - replaceRight(new Uint8Array([1, 2, 3])) - expect(pairs()).toEqual([[1, 10]]) - replaceRight(new Uint8Array([1, 2, 4])) - expect(pairs()).toEqual([ - [1, undefined], - [undefined, 10], - ]) - replaceRight(new Uint8Array([1, 2, 3])) - expect(pairs()).toEqual([[1, 10]]) - }) - - test(`nullish outer rows transition through a finite join key`, () => { - type NullableRow = { id: number; value: number | null } - const leftCollection = createCollection( - mockSyncCollectionOptions({ - id: `nullish-lifecycle-left-${autoIndex}`, - getKey: (row) => row.id, - initialData: [{ id: 1, value: null }], - autoIndex, - }), - ) - const rightCollection = createCollection( - mockSyncCollectionOptions({ - id: `nullish-lifecycle-right-${autoIndex}`, - getKey: (row) => row.id, - initialData: [ - { id: 2, value: null }, - { id: 1, value: null }, - ], - autoIndex, - }), - ) - const query = createLiveQueryCollection({ - startSync: true, - query: (q) => - q - .from({ left: leftCollection }) - .fullJoin({ right: rightCollection }, ({ left, right }) => - eq(left.value, right.value), - ) - .select(({ left, right }) => ({ - leftId: left.id, - rightId: right.id, - })), - }) - const pairs = () => - sortJoinPairs( - query.toArray - .map(({ leftId, rightId }) => [leftId, rightId] as const) - .reverse(), - ) - const update = ( - collection: typeof leftCollection, - id: number, - value: number | null, - ) => { - collection.utils.begin() - collection.utils.write({ type: `update`, value: { id, value } }) - collection.utils.commit() - } - - expect(pairs()).toEqual([ - [1, undefined], - [undefined, 1], - [undefined, 2], - ]) - update(rightCollection, 1, 1) - expect(pairs()).toEqual([ - [1, undefined], - [undefined, 1], - [undefined, 2], - ]) - update(leftCollection, 1, 1) - expect(pairs()).toEqual([ - [1, 1], - [undefined, 2], - ]) - update(leftCollection, 1, null) - expect(pairs()).toEqual([ - [1, undefined], - [undefined, 1], - [undefined, 2], - ]) - }) - test(`should update Date join matches when timestamp changes`, () => { type DateLeft = { id: number; joinedAt: Date; name: string } type DateRight = { id: number; joinedAt: Date; label: string } @@ -2483,99 +2115,3 @@ describe(`Query JOIN Operations`, () => { createJoinTests(`off`) createJoinTests(`eager`) }) - -test.each([`off`, `eager`] as const)( - `lazy binary join demand uses predicate equality with autoIndex %s`, - async (autoIndex) => { - type BinaryRow = { id: number; binaryId: Uint8Array } - const activeKey = new Uint8Array([1, 2, 3]) - const active = createCollection( - mockSyncCollectionOptions({ - id: `binary-demand-active-${autoIndex}`, - getKey: (row) => row.id, - initialData: [{ id: 1, binaryId: activeKey }], - autoIndex, - }), - ) - const backend: Array = [ - { id: 10, binaryId: new Uint8Array(activeKey) }, - { id: 20, binaryId: new Uint8Array([1, 2, 4]) }, - ] - let loadCalls = 0 - let candidateChecks = 0 - const requestedValues: Array = [] - const lazy = createCollection( - mockSyncCollectionOptions({ - id: `binary-demand-lazy-${autoIndex}`, - getKey: (row) => row.id, - initialData: [], - autoIndex, - syncMode: `on-demand`, - sync: { - sync: (actions) => { - actions.markReady() - return { - loadSubset: (options) => { - loadCalls++ - expect(options.where).toBeDefined() - const where = options.where! - expect(where.type).toBe(`func`) - if (where.type !== `func` || where.name !== `in`) { - throw new Error(`expected binary demand to use IN`) - } - const candidates = where.args[1] - if ( - candidates?.type !== `val` || - !Array.isArray(candidates.value) - ) { - throw new Error(`expected binary demand candidates`) - } - requestedValues.push(...candidates.value) - const matches = - createFilterFunctionFromExpression(where) - actions.begin() - for (const row of backend) { - candidateChecks++ - if (matches(row)) { - actions.write({ type: `insert`, value: row }) - } - } - const applied = actions.commit() - return applied === true ? true : applied - }, - unloadSubset: () => {}, - } - }, - }, - }), - ) - const query = createLiveQueryCollection({ - query: (q) => - q - .from({ left: active }) - .leftJoin({ right: lazy }, ({ left, right }) => - eq(left.binaryId, right.binaryId), - ) - .select(({ left, right }) => ({ - leftId: left.id, - rightId: right.id, - })), - }) - - try { - await query.preload() - expect(loadCalls).toBe(1) - expect(candidateChecks).toBe(2) - expect(requestedValues).toHaveLength(1) - expect(requestedValues[0]).toBeInstanceOf(Uint8Array) - expect(Array.from(requestedValues[0] as Uint8Array)).toEqual( - Array.from(activeKey), - ) - const rows = query.toArray.map(stripVirtualProps) - const expected = [{ leftId: 1, rightId: 10 }] - expect(rows).toEqual(expected) - } finally { - await Promise.all([query.cleanup(), lazy.cleanup(), active.cleanup()]) - } - }, -) diff --git a/packages/db/tests/query/pagination-oracle.property.test.ts b/packages/db/tests/query/pagination-oracle.property.test.ts index 727ee4b29f..91ffdd45ab 100644 --- a/packages/db/tests/query/pagination-oracle.property.test.ts +++ b/packages/db/tests/query/pagination-oracle.property.test.ts @@ -1257,10 +1257,13 @@ async function runAdversarialOrderedProviderScenario( limit: number expectedIds: ReadonlyArray useOffsetWhenAvailable?: boolean + inheritSourceLocale?: boolean + autoIndex?: `off` | `eager` }, fault?: `post-cleanup-request`, ): Promise> { const loads: Array = [] + const autoIndex = options.autoIndex ?? `eager` let recordedProvider!: (options: LoadSubsetOptions) => Promise const delivered = new Set(options.initialRows?.map(({ id }) => id) ?? []) const source = createCollection({ @@ -1268,8 +1271,14 @@ async function runAdversarialOrderedProviderScenario( getKey: (row) => row.id, syncMode: `on-demand`, startSync: true, - autoIndex: `eager`, - defaultIndexType: BTreeIndex, + autoIndex, + defaultIndexType: autoIndex === `eager` ? BTreeIndex : undefined, + defaultStringCollation: options.inheritSourceLocale + ? { + locale: `en-US`, + localeOptions: { numeric: true }, + } + : undefined, sync: { sync: ({ begin, write, commit, markReady }) => { if (options.initialRows?.length) { @@ -1327,13 +1336,15 @@ async function runAdversarialOrderedProviderScenario( const from = query.from({ row: source }) const ordered = options.order.kind === `locale` - ? from.orderBy(({ row }) => row.label, { - direction: `asc`, - nulls: `first`, - stringSort: `locale`, - locale: `en-US`, - localeOptions: { numeric: true }, - }) + ? options.inheritSourceLocale + ? from.orderBy(({ row }) => row.label) + : from.orderBy(({ row }) => row.label, { + direction: `asc`, + nulls: `first`, + stringSort: `locale`, + locale: `en-US`, + localeOptions: { numeric: true }, + }) : from.orderBy( ({ row }) => row.rank, options.order.kind === `reference` @@ -1355,6 +1366,24 @@ async function runAdversarialOrderedProviderScenario( expect(Array.from(live.values(), ({ id }) => id)).toEqual( options.expectedIds, ) + if (options.inheritSourceLocale) { + expect(Reflect.ownKeys(source.compareOptions).sort()).toEqual([ + `locale`, + `localeOptions`, + `stringSort`, + ]) + expect(source.compareOptions).toStrictEqual({ + stringSort: `locale`, + locale: `en-US`, + localeOptions: { numeric: true }, + }) + if (autoIndex === `eager`) + expect( + source.indexes.size, + `auto-index path reached`, + ).toBeGreaterThan(0) + else expect(source.indexes.size, `scan path reached`).toBe(0) + } // Keep the pre-cleanup snapshot, but judge disposal against the live recorder. return [...loads] }, @@ -5340,6 +5369,59 @@ describe(`pagination recomputation oracle`, () => { expect(loads[1]?.cursor).toBeUndefined() }) + it(`inherits collection locale options through scan and auto-index ordering`, async () => { + const defaultOnly = createCollection<{ id: number }>({ + getKey: (row) => row.id, + defaultStringCollation: { stringSort: `locale` }, + sync: { sync: () => {} }, + }) + try { + expect(defaultOnly.compareOptions).toStrictEqual({ + stringSort: `locale`, + }) + } finally { + await defaultOnly.cleanup() + } + + const labels = [`item10`, `item2`] + expect( + [...labels].sort(new Intl.Collator(`en-US`, { numeric: true }).compare), + `numeric locale control`, + ).toEqual([`item2`, `item10`]) + expect([...labels].sort(), `lexical hostile control`).toEqual([ + `item10`, + `item2`, + ]) + + for (const autoIndex of [`off`, `eager`] as const) { + const loads = await runAdversarialOrderedProviderScenario({ + // The provider's lexical prefix disagrees with inherited locale order. + providerRows: [ + { id: 2, rank: 0, label: `item10` }, + { id: 1, rank: 0, label: `item2` }, + ], + order: { kind: `locale` }, + limit: 1, + expectedIds: [1], + useOffsetWhenAvailable: true, + inheritSourceLocale: true, + autoIndex, + }) + + expect(loads).toHaveLength(2) + expect(loads[0]?.orderBy?.[0]?.compareOptions).toStrictEqual({ + direction: `asc`, + nulls: `first`, + stringSort: `locale`, + locale: `en-US`, + localeOptions: { numeric: true }, + }) + expect(loads[1]?.limit).toBeUndefined() + expect(loads[1]?.offset).toBeUndefined() + expect(loads[1]?.cursor).toBeUndefined() + } + }) + it(`refines an initial reference-ordered window locally`, async () => { const first = { value: `first` } const second = { value: `second` } diff --git a/packages/powersync-db-collection/package.json b/packages/powersync-db-collection/package.json index 5399526c78..e74c1a95a0 100644 --- a/packages/powersync-db-collection/package.json +++ b/packages/powersync-db-collection/package.json @@ -27,7 +27,8 @@ "build": "vite build", "dev": "vite build --watch", "lint": "eslint . --fix", - "test": "vitest --run" + "test": "vitest --run", + "test:oracles": "vitest --run tests/correctness-oracle.test.ts" }, "type": "module", "main": "dist/cjs/index.cjs", From f6d2ac7568ae6ea200b8c7ef557bfa85406f2245 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Thu, 17 Sep 2026 10:07:35 -0600 Subject: [PATCH 2/2] test: preserve duplicate null join coverage --- docs/contributing/oracle-coverage.md | 1 + .../cold-join-reconciliation-oracle.test.ts | 22 +++++++++++++------ 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index 40981ac365..13a46cd6f2 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -97,6 +97,7 @@ pnpm --filter @tanstack/db-ivm build pnpm --filter @tanstack/db build pnpm --filter @tanstack/db test:oracles pnpm --filter @tanstack/powersync-db-collection test:oracles +# Service-dependent: requires Electric and PostgreSQL to be running. pnpm --filter @tanstack/electric-db-collection test:e2e pnpm run typecheck:tests pnpm exec tsc --noEmit -p packages/db/tsconfig.json diff --git a/packages/db/tests/query/cold-join-reconciliation-oracle.test.ts b/packages/db/tests/query/cold-join-reconciliation-oracle.test.ts index 7b506b8ccf..8c657f4d22 100644 --- a/packages/db/tests/query/cold-join-reconciliation-oracle.test.ts +++ b/packages/db/tests/query/cold-join-reconciliation-oracle.test.ts @@ -537,6 +537,7 @@ describe.each([`off`, `eager`] as const)( [ { id: 10, value: new Uint8Array([1, 2, 3]) }, { id: 20, value: null }, + { id: 30, value: null }, ], autoIndex, ) @@ -559,9 +560,16 @@ describe.each([`off`, `eager`] as const)( ({ leftId, rightId }) => [leftId, rightId] as const, ), ) + const duplicateNullPair: JoinPair = [undefined, 30] + const expectPairs = (expected: Array) => { + expect( + pairs(), + `duplicate same-side null outer row must remain observable`, + ).toEqual([...expected, duplicateNullPair]) + } try { await joined.preload() - expect(pairs()).toEqual([ + expectPairs([ [1, 10], [2, undefined], [undefined, 20], @@ -570,7 +578,7 @@ describe.each([`off`, `eager`] as const)( id: 10, value: new Uint8Array([1, 2, 3]), }) - expect(pairs()).toEqual([ + expectPairs([ [1, 10], [2, undefined], [undefined, 20], @@ -579,7 +587,7 @@ describe.each([`off`, `eager`] as const)( id: 10, value: new Uint8Array([1, 2, 4]), }) - expect(pairs()).toEqual([ + expectPairs([ [1, undefined], [2, undefined], [undefined, 10], @@ -589,24 +597,24 @@ describe.each([`off`, `eager`] as const)( id: 10, value: new Uint8Array([1, 2, 3]), }) - expect(pairs()).toEqual([ + expectPairs([ [1, 10], [2, undefined], [undefined, 20], ]) await right.oracleReplace({ id: 20, value: 1 }) - expect(pairs()).toEqual([ + expectPairs([ [1, 10], [2, undefined], [undefined, 20], ]) await left.oracleReplace({ id: 2, value: 1 }) - expect(pairs()).toEqual([ + expectPairs([ [1, 10], [2, 20], ]) await left.oracleReplace({ id: 2, value: null }) - expect(pairs()).toEqual([ + expectPairs([ [1, 10], [2, undefined], [undefined, 20],