From bec0ba9a9791520a54ea88de60b0d4aa72993da9 Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Wed, 10 Jun 2026 11:30:48 +0200 Subject: [PATCH 1/7] test: add failing tests for index-optimized queries mixing indexed and non-indexed conditions These tests assert the expected results of currentStateAsChanges for AND/OR where clauses that combine conditions on indexed fields with conditions that cannot be served by an index. They currently fail. Co-Authored-By: Claude Fable 5 --- packages/db/tests/collection-indexes.test.ts | 55 ++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/packages/db/tests/collection-indexes.test.ts b/packages/db/tests/collection-indexes.test.ts index a441a5520d..87f8d1a705 100644 --- a/packages/db/tests/collection-indexes.test.ts +++ b/packages/db/tests/collection-indexes.test.ts @@ -1179,6 +1179,61 @@ describe(`Collection Indexes`, () => { }) }) }) + + it(`should include rows matched by any OR condition when conditions mix indexed and non-indexed expressions`, () => { + // An OR query must return the union of rows matching each condition: + // eq(age, 25) matches Alice (age 25) + // gt(length(name), 6) matches Charlie (name length 7) + // `age` has an index while `length(name)` is a computed expression + // without one, but the chosen execution strategy must not change the + // result: both Alice and Charlie satisfy the OR and must be returned. + const result = collection.currentStateAsChanges({ + where: or( + eq(new PropRef([`age`]), 25), + gt(length(new PropRef([`name`])), 6), + ), + })! + + const names = result.map((r) => r.value.name).sort() + expect(names).toEqual([`Alice`, `Charlie`]) + }) + + it(`should only return rows matching every AND condition when conditions mix indexed and non-indexed expressions`, () => { + // An AND query must return only the rows matching all conditions: + // eq(status, 'active') matches Alice, Charlie and Eve + // gt(length(name), 6) matches only Charlie (name length 7) + // `status` has an index while `length(name)` is a computed expression + // without one, but every condition must still be enforced: only + // Charlie satisfies both. + const result = collection.currentStateAsChanges({ + where: and( + eq(new PropRef([`status`]), `active`), + gt(length(new PropRef([`name`])), 6), + ), + })! + + const names = result.map((r) => r.value.name).sort() + expect(names).toEqual([`Charlie`]) + }) + + it(`should enforce every AND condition when a range on one field is combined with conditions on other fields`, () => { + // An AND query that contains a compound range on one field plus a + // condition on another field must enforce all of them: + // gt(age, 24) AND lt(age, 36) matches Alice (25), Bob (30), + // Charlie (35) and Diana (28) + // eq(status, 'active') matches Alice, Charlie and Eve + // Only Alice and Charlie satisfy the full conjunction. + const result = collection.currentStateAsChanges({ + where: and( + gt(new PropRef([`age`]), 24), + lt(new PropRef([`age`]), 36), + eq(new PropRef([`status`]), `active`), + ), + })! + + const names = result.map((r) => r.value.name).sort() + expect(names).toEqual([`Alice`, `Charlie`]) + }) }) describe(`Index Usage Verification`, () => { From ac262057f543dd19bb91bd5ed331b0bf8cf680c9 Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Wed, 10 Jun 2026 14:31:06 +0200 Subject: [PATCH 2/7] test: add failing tests for range query boundary handling Adds expected-behaviour tests for range conditions: - compound ranges sharing a boundary value must apply the strictest bound regardless of argument order, including for date values - one-sided compound ranges must return the matching rows - strict comparisons (gt) on date fields must exclude the boundary row Co-Authored-By: Claude Fable 5 --- packages/db/tests/collection-indexes.test.ts | 55 ++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/packages/db/tests/collection-indexes.test.ts b/packages/db/tests/collection-indexes.test.ts index 87f8d1a705..6faf07aa04 100644 --- a/packages/db/tests/collection-indexes.test.ts +++ b/packages/db/tests/collection-indexes.test.ts @@ -625,6 +625,19 @@ describe(`Collection Indexes`, () => { }) }) + it(`should exclude the boundary value from greater than queries on dates`, () => { + // gt must be strict for date fields: Bob was created exactly on + // 2023-01-02, so only rows created strictly later may be returned. + collection.createIndex((row) => row.createdAt) + + const result = collection.currentStateAsChanges({ + where: gt(new PropRef([`createdAt`]), new Date(`2023-01-02`)), + })! + + const names = result.map((r) => r.value.name).sort() + expect(names).toEqual([`Charlie`, `Diana`, `Eve`]) + }) + it(`should perform greater than or equal queries`, () => { withIndexTracking(collection, (tracker) => { const result = collection.currentStateAsChanges({ @@ -1216,6 +1229,48 @@ describe(`Collection Indexes`, () => { expect(names).toEqual([`Charlie`]) }) + it(`should apply the strictest lower bound when range conditions share the same value`, () => { + // gte(age, 25) AND gt(age, 25) reduces to age > 25: the strict + // comparison wins at the shared boundary, so Alice (age 25) must be + // excluded regardless of the order the conditions appear in. + const result = collection.currentStateAsChanges({ + where: and(gte(new PropRef([`age`]), 25), gt(new PropRef([`age`]), 25)), + })! + + const names = result.map((r) => r.value.name).sort() + expect(names).toEqual([`Bob`, `Charlie`, `Diana`]) + }) + + it(`should apply the strictest upper bound when range conditions share the same value`, () => { + // lte(age, 30) AND lt(age, 30) reduces to age < 30: the strict + // comparison wins at the shared boundary, so Bob (age 30) must be + // excluded. + const result = collection.currentStateAsChanges({ + where: and(lte(new PropRef([`age`]), 30), lt(new PropRef([`age`]), 30)), + })! + + const names = result.map((r) => r.value.name).sort() + expect(names).toEqual([`Alice`, `Diana`, `Eve`]) + }) + + it(`should apply the strictest bound for date ranges sharing the same value`, () => { + // Distinct Date instances representing the same point in time must be + // treated as equal values: gte(createdAt, jan2) AND gt(createdAt, jan2) + // reduces to createdAt > jan2, so Bob (created 2023-01-02) must be + // excluded. + collection.createIndex((row) => row.createdAt) + + const result = collection.currentStateAsChanges({ + where: and( + gte(new PropRef([`createdAt`]), new Date(`2023-01-02`)), + gt(new PropRef([`createdAt`]), new Date(`2023-01-02`)), + ), + })! + + const names = result.map((r) => r.value.name).sort() + expect(names).toEqual([`Charlie`, `Diana`, `Eve`]) + }) + it(`should enforce every AND condition when a range on one field is combined with conditions on other fields`, () => { // An AND query that contains a compound range on one field plus a // condition on another field must enforce all of them: From 0dac2f8a418f8e2bfc93c26af248dcbb66db81bb Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Thu, 18 Jun 2026 10:04:34 +0200 Subject: [PATCH 3/7] test: add failing test for compound range query with undefined bound A compound range condition where one bound is undefined (e.g. gt(score, undefined) AND lt(score, 90)) must match nothing, since a comparison against undefined is never true. The index-optimized path must agree with a full scan. This test currently fails. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/db/tests/collection-indexes.test.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/packages/db/tests/collection-indexes.test.ts b/packages/db/tests/collection-indexes.test.ts index 6faf07aa04..5505b54dd7 100644 --- a/packages/db/tests/collection-indexes.test.ts +++ b/packages/db/tests/collection-indexes.test.ts @@ -1289,6 +1289,23 @@ describe(`Collection Indexes`, () => { const names = result.map((r) => r.value.name).sort() expect(names).toEqual([`Alice`, `Charlie`]) }) + + it(`should match a full scan when a range condition uses an undefined bound`, () => { + // A comparison against `undefined` matches no rows (a comparison with + // null/undefined is never true), so `gt(score, undefined)` excludes + // every row and the whole AND must return nothing. The index-optimized + // path must agree with a plain full scan and not leak rows. + collection.createIndex((row) => row.score) + + const result = collection.currentStateAsChanges({ + where: and( + gt(new PropRef([`score`]), undefined), + lt(new PropRef([`score`]), 90), + ), + })! + + expect(result).toEqual([]) + }) }) describe(`Index Usage Verification`, () => { From 029e759139d1b2cd584d12868ed1d40f22b55190 Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Mon, 22 Jun 2026 10:11:21 +0200 Subject: [PATCH 4/7] test: add failing tests for nullish values in indexed eq/in/range queries A comparison against null/undefined is never true, but BTree indexes store and return rows with nullish indexed values (they sort as the smallest key). These tests assert that the index-optimized snapshot matches a full predicate scan for: - eq against undefined - IN with an undefined member - a range comparison over a field that has rows with undefined values - an upper-bounded compound range over such a field They currently fail. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/db/tests/collection-indexes.test.ts | 61 ++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/packages/db/tests/collection-indexes.test.ts b/packages/db/tests/collection-indexes.test.ts index 5505b54dd7..666e0a915c 100644 --- a/packages/db/tests/collection-indexes.test.ts +++ b/packages/db/tests/collection-indexes.test.ts @@ -1306,6 +1306,67 @@ describe(`Collection Indexes`, () => { expect(result).toEqual([]) }) + + it(`should not match rows with a missing value for an equality on undefined`, () => { + // An equality comparison against `undefined` is never true, so + // `eq(score, undefined)` must return no rows even though Eve has an + // undefined score. The index-optimized path must agree with a full + // predicate scan. + collection.createIndex((row) => row.score) + + const result = collection.currentStateAsChanges({ + where: eq(new PropRef([`score`]), undefined), + })! + + expect(result).toEqual([]) + }) + + it(`should ignore an undefined member when matching an IN list`, () => { + // A row only matches `IN` when its value equals one of the listed + // values; a comparison with `undefined` is never true. So + // `inArray(score, [undefined, 80])` must match only Bob (score 80) + // and must not match Eve (undefined score). + collection.createIndex((row) => row.score) + + const result = collection.currentStateAsChanges({ + where: inArray(new PropRef([`score`]), [undefined, 80]), + })! + + const names = result.map((r) => r.value.name).sort() + expect(names).toEqual([`Bob`]) + }) + + it(`should not match rows with a missing value for a range comparison`, () => { + // A range comparison against a row with an undefined value is never + // true, so `lt(score, 85)` must match only Bob (score 80) and must + // not match Eve (undefined score). + collection.createIndex((row) => row.score) + + const result = collection.currentStateAsChanges({ + where: lt(new PropRef([`score`]), 85), + })! + + const names = result.map((r) => r.value.name).sort() + expect(names).toEqual([`Bob`]) + }) + + it(`should not match rows with a missing value for an upper-bounded compound range`, () => { + // A compound range with only upper bounds (e.g. score <= 90) must not + // match a row with an undefined value, since a comparison against + // undefined is never true. Only Bob (80), Charlie (90) and Diana (85) + // satisfy `score <= 90`; Eve (undefined) must be excluded. + collection.createIndex((row) => row.score) + + const result = collection.currentStateAsChanges({ + where: and( + lte(new PropRef([`score`]), 90), + lte(new PropRef([`score`]), 95), + ), + })! + + const names = result.map((r) => r.value.name).sort() + expect(names).toEqual([`Bob`, `Charlie`, `Diana`]) + }) }) describe(`Index Usage Verification`, () => { From fc07196bb3fba30e9f95ce04d0c0c70838f55702 Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Wed, 24 Jun 2026 11:53:38 +0200 Subject: [PATCH 5/7] test: add failing tests for locale string range and NaN index queries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two more cases where an index-optimized snapshot must match a full predicate scan: - a string range predicate (e.g. name > 'z') must return a row whose value satisfies the JS relational comparison ('ö' > 'z'), even though a locale-collated index orders that value differently - eq and IN against NaN must not match a NaN-valued row, since NaN is never equal to itself They currently fail. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/db/tests/collection-indexes.test.ts | 99 ++++++++++++++++++++ 1 file changed, 99 insertions(+) diff --git a/packages/db/tests/collection-indexes.test.ts b/packages/db/tests/collection-indexes.test.ts index 666e0a915c..a63c3dfe86 100644 --- a/packages/db/tests/collection-indexes.test.ts +++ b/packages/db/tests/collection-indexes.test.ts @@ -1367,6 +1367,105 @@ describe(`Collection Indexes`, () => { const names = result.map((r) => r.value.name).sort() expect(names).toEqual([`Bob`, `Charlie`, `Diana`]) }) + + it(`should match a string range predicate using the same ordering as a full scan`, async () => { + // String comparisons in the WHERE evaluator use JS relational operators + // (code-point order), where `'ö' > 'z'` is true. A row named `ö` must + // therefore be returned by `name > 'z'`, even though a locale-collated + // index orders `ö` before `z`. The index-optimized result must agree + // with a full predicate scan. + const stringCollection = createCollection< + { id: string; name: string }, + string + >({ + getKey: (row) => row.id, + startSync: true, + autoIndex: `eager`, + defaultIndexType: BTreeIndex, + sync: { + sync: ({ begin, write, commit, markReady }) => { + begin() + write({ type: `insert`, value: { id: `1`, name: `apple` } }) + write({ type: `insert`, value: { id: `2`, name: `ö` } }) + commit() + markReady() + }, + }, + }) + await stringCollection.stateWhenReady() + stringCollection.createIndex((row) => row.name) + + const result = stringCollection.currentStateAsChanges({ + where: gt(new PropRef([`name`]), `z`), + })! + + const names = result.map((r) => r.value.name).sort() + expect(names).toEqual([`ö`]) + }) + + it(`should not match a row with a NaN value for an equality on NaN`, async () => { + // NaN is never equal to itself, so `eq(score, NaN)` must return no rows + // even though the index stores and can return the NaN-valued row. + const nanCollection = createCollection< + { id: string; score: number }, + string + >({ + getKey: (row) => row.id, + startSync: true, + autoIndex: `eager`, + defaultIndexType: BTreeIndex, + sync: { + sync: ({ begin, write, commit, markReady }) => { + begin() + write({ type: `insert`, value: { id: `1`, score: 5 } }) + write({ type: `insert`, value: { id: `2`, score: NaN } }) + commit() + markReady() + }, + }, + }) + await nanCollection.stateWhenReady() + nanCollection.createIndex((row) => row.score) + + const result = nanCollection.currentStateAsChanges({ + where: eq(new PropRef([`score`]), NaN), + })! + + expect(result).toEqual([]) + }) + + it(`should not match a row with a NaN value for an IN list containing NaN`, async () => { + // A row only matches `IN` when its value equals a listed value, and NaN + // is never equal to itself. So `inArray(score, [NaN, 5])` must match + // only the row with score 5 and never the NaN-valued row. + const nanCollection = createCollection< + { id: string; score: number }, + string + >({ + getKey: (row) => row.id, + startSync: true, + autoIndex: `eager`, + defaultIndexType: BTreeIndex, + sync: { + sync: ({ begin, write, commit, markReady }) => { + begin() + write({ type: `insert`, value: { id: `1`, score: 5 } }) + write({ type: `insert`, value: { id: `2`, score: NaN } }) + commit() + markReady() + }, + }, + }) + await nanCollection.stateWhenReady() + nanCollection.createIndex((row) => row.score) + + const result = nanCollection.currentStateAsChanges({ + where: inArray(new PropRef([`score`]), [NaN, 5]), + })! + + const ids = result.map((r) => r.value.id).sort() + expect(ids).toEqual([`1`]) + }) }) describe(`Index Usage Verification`, () => { From 9d5c125bf81eacd3906d01555f2444e9a7ce00d9 Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Thu, 25 Jun 2026 10:16:34 +0200 Subject: [PATCH 6/7] test: add failing tests for range predicates over non-orderable index domains Three more cases where an index-optimized range query must match a full predicate scan: - an array-valued field (the evaluator compares with standard relational operators, which differ from the index's recursive array ordering) - a field indexed with a custom comparator - a numeric field that also contains a NaN value They currently fail. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/db/tests/collection-indexes.test.ts | 104 +++++++++++++++++++ 1 file changed, 104 insertions(+) diff --git a/packages/db/tests/collection-indexes.test.ts b/packages/db/tests/collection-indexes.test.ts index a63c3dfe86..ae1b45e844 100644 --- a/packages/db/tests/collection-indexes.test.ts +++ b/packages/db/tests/collection-indexes.test.ts @@ -1466,6 +1466,110 @@ describe(`Collection Indexes`, () => { const ids = result.map((r) => r.value.id).sort() expect(ids).toEqual([`1`]) }) + + it(`should return array-valued rows for a range predicate consistently with a full scan`, async () => { + // Range predicates are evaluated with standard relational comparison, + // under which `[2] > [10]` is true (arrays compare as their string + // form). An index on an array-valued field must return the same rows as + // a full scan and must not drop this match. + const arrayCollection = createCollection< + { id: string; value: Array }, + string + >({ + getKey: (row) => row.id, + startSync: true, + autoIndex: `off`, + defaultIndexType: BTreeIndex, + sync: { + sync: ({ begin, write, commit, markReady }) => { + begin() + write({ type: `insert`, value: { id: `1`, value: [2] } }) + commit() + markReady() + }, + }, + }) + await arrayCollection.stateWhenReady() + arrayCollection.createIndex((row) => row.value) + + const result = arrayCollection.currentStateAsChanges({ + where: gt(new PropRef([`value`]), [10]), + })! + + const ids = result.map((r) => r.value.id).sort() + expect(ids).toEqual([`1`]) + }) + + it(`should return all matching rows for a range predicate on a custom-comparator index`, async () => { + // A range predicate must return every row that satisfies it regardless + // of the comparator the index was created with. With scores 5 and 20, + // `score > 10` matches only the row with score 20. + const customCollection = createCollection< + { id: string; score: number }, + string + >({ + getKey: (row) => row.id, + startSync: true, + autoIndex: `off`, + defaultIndexType: BTreeIndex, + sync: { + sync: ({ begin, write, commit, markReady }) => { + begin() + write({ type: `insert`, value: { id: `low`, score: 5 } }) + write({ type: `insert`, value: { id: `high`, score: 20 } }) + commit() + markReady() + }, + }, + }) + await customCollection.stateWhenReady() + customCollection.createIndex((row) => row.score, { + options: { compareFn: (a: number, b: number) => b - a }, + }) + + const result = customCollection.currentStateAsChanges({ + where: gt(new PropRef([`score`]), 10), + })! + + const ids = result.map((r) => r.value.id).sort() + expect(ids).toEqual([`high`]) + }) + + it(`should return all matching rows for a range predicate when the field also contains NaN`, async () => { + // A range predicate must return every matching row even when other rows + // hold a NaN value for the field. With scores NaN, 1, 3, 5 and 7, + // `score > 2` matches the rows with scores 3, 5 and 7. + const nanCollection = createCollection< + { id: string; score: number }, + string + >({ + getKey: (row) => row.id, + startSync: true, + autoIndex: `off`, + defaultIndexType: BTreeIndex, + sync: { + sync: ({ begin, write, commit, markReady }) => { + begin() + write({ type: `insert`, value: { id: `nan`, score: NaN } }) + write({ type: `insert`, value: { id: `one`, score: 1 } }) + write({ type: `insert`, value: { id: `three`, score: 3 } }) + write({ type: `insert`, value: { id: `five`, score: 5 } }) + write({ type: `insert`, value: { id: `seven`, score: 7 } }) + commit() + markReady() + }, + }, + }) + await nanCollection.stateWhenReady() + nanCollection.createIndex((row) => row.score) + + const result = nanCollection.currentStateAsChanges({ + where: gt(new PropRef([`score`]), 2), + })! + + const ids = result.map((r) => r.value.id).sort() + expect(ids).toEqual([`five`, `seven`, `three`]) + }) }) describe(`Index Usage Verification`, () => { From 13f7ead1e4eaeab5f3436dcd67a9daecc9ecb67f Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Thu, 25 Jun 2026 11:21:30 +0200 Subject: [PATCH 7/7] test: add failing tests for ordering values that have no natural order NaN and invalid Dates have no natural order. They should still get a consistent, well-defined position (alongside nulls) so that: - the comparator produces a stable total order; - ordering a collection by such a field is deterministic; - a range query on a field that contains such a value can still be served by the index rather than falling back to a full scan. These tests currently fail. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/db/tests/collection-indexes.test.ts | 43 +++++++++++++++++++ packages/db/tests/comparison.test.ts | 32 ++++++++++++++ .../db/tests/deterministic-ordering.test.ts | 33 ++++++++++++++ 3 files changed, 108 insertions(+) create mode 100644 packages/db/tests/comparison.test.ts diff --git a/packages/db/tests/collection-indexes.test.ts b/packages/db/tests/collection-indexes.test.ts index ae1b45e844..a9bb013efb 100644 --- a/packages/db/tests/collection-indexes.test.ts +++ b/packages/db/tests/collection-indexes.test.ts @@ -1570,6 +1570,49 @@ describe(`Collection Indexes`, () => { const ids = result.map((r) => r.value.id).sort() expect(ids).toEqual([`five`, `seven`, `three`]) }) + + it(`should use the index for a range query on a field that also contains NaN`, async () => { + // A NaN value has a well-defined sort position (with nulls), so a range + // query on the field can still be served by the index and does not need + // to fall back to a full scan. + const nanCollection = createCollection< + { id: string; score: number }, + string + >({ + getKey: (row) => row.id, + startSync: true, + autoIndex: `off`, + defaultIndexType: BTreeIndex, + sync: { + sync: ({ begin, write, commit, markReady }) => { + begin() + write({ type: `insert`, value: { id: `nan`, score: NaN } }) + write({ type: `insert`, value: { id: `one`, score: 1 } }) + write({ type: `insert`, value: { id: `three`, score: 3 } }) + write({ type: `insert`, value: { id: `five`, score: 5 } }) + write({ type: `insert`, value: { id: `seven`, score: 7 } }) + commit() + markReady() + }, + }, + }) + await nanCollection.stateWhenReady() + nanCollection.createIndex((row) => row.score) + + withIndexTracking(nanCollection, (tracker) => { + const result = nanCollection.currentStateAsChanges({ + where: gt(new PropRef([`score`]), 2), + })! + + const ids = result.map((r) => r.value.id).sort() + expect(ids).toEqual([`five`, `seven`, `three`]) + + expectIndexUsage(tracker.stats, { + shouldUseIndex: true, + shouldUseFullScan: false, + }) + }) + }) }) describe(`Index Usage Verification`, () => { diff --git a/packages/db/tests/comparison.test.ts b/packages/db/tests/comparison.test.ts new file mode 100644 index 0000000000..a70c38981f --- /dev/null +++ b/packages/db/tests/comparison.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest' +import { ascComparator, defaultComparator } from '../src/utils/comparison' +import { DEFAULT_COMPARE_OPTIONS } from '../src/utils' + +describe(`ascComparator with values that have no natural order`, () => { + const opts = DEFAULT_COMPARE_OPTIONS // nulls: `first` + + it(`should order NaN consistently relative to numbers`, () => { + // NaN has no natural order, but the comparator must still place it + // consistently (alongside nulls, which sort first by default) so the + // overall ordering stays well-defined. + expect(ascComparator(NaN, 5, opts)).toBeLessThan(0) + expect(ascComparator(5, NaN, opts)).toBeGreaterThan(0) + expect(ascComparator(NaN, NaN, opts)).toBe(0) + }) + + it(`should produce a stable total order when sorting numbers that include NaN`, () => { + const sorted = [3, NaN, 1, 5, NaN].sort((a, b) => defaultComparator(a, b)) + + // NaN values sort to the front (same end as nulls), the rest ascending + expect(sorted.slice(0, 2).every((v) => Number.isNaN(v))).toBe(true) + expect(sorted.slice(2)).toEqual([1, 3, 5]) + }) + + it(`should order an invalid Date consistently relative to valid Dates`, () => { + const invalid = new Date(`not a date`) + const valid = new Date(`2023-01-01`) + + expect(ascComparator(invalid, valid, opts)).toBeLessThan(0) + expect(ascComparator(valid, invalid, opts)).toBeGreaterThan(0) + }) +}) diff --git a/packages/db/tests/deterministic-ordering.test.ts b/packages/db/tests/deterministic-ordering.test.ts index 9ce9a326d6..2714688872 100644 --- a/packages/db/tests/deterministic-ordering.test.ts +++ b/packages/db/tests/deterministic-ordering.test.ts @@ -489,5 +489,38 @@ describe(`Deterministic Ordering`, () => { const keys = changes?.map((c) => c.key) expect(keys).toEqual([`a`, `b`, `c`]) }) + + it(`should place NaN values consistently when ordering`, () => { + type Item = { id: string; score: number } + + const options = mockSyncCollectionOptions({ + id: `test-collection-changes-nan`, + getKey: (item) => item.id, + initialData: [], + }) + + const collection = createCollection(options) + + options.utils.begin() + options.utils.write({ type: `insert`, value: { id: `a`, score: 5 } }) + options.utils.write({ type: `insert`, value: { id: `nan`, score: NaN } }) + options.utils.write({ type: `insert`, value: { id: `b`, score: 1 } }) + options.utils.write({ type: `insert`, value: { id: `c`, score: 3 } }) + options.utils.commit() + + const changes = collection.currentStateAsChanges({ + orderBy: [ + { + expression: new PropRef([`score`]), + compareOptions: { direction: `asc`, nulls: `first` }, + }, + ], + }) + + // NaN has no natural order; it sorts with nulls (first), then the + // numbers ascending. + const keys = changes?.map((c) => c.key) + expect(keys).toEqual([`nan`, `b`, `c`, `a`]) + }) }) })