From c4b9c635e373b1b1d22e61578cdeebb6a6f0fd99 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 14 Dec 2025 19:50:10 +0000 Subject: [PATCH 1/8] feat: add property-based testing with fast-check - Add @fast-check/vitest and fast-check dependencies - Add property-based tests for deepEquals (reflexivity, symmetry, transitivity) - Add property-based tests for comparators (antisymmetry, transitivity, consistency) - Add tests for normalizeValue and areValuesEqual utilities - Document known edge case: deepEquals asymmetry between Date and Temporal.Duration --- package.json | 2 + packages/db/tests/comparison.property.test.ts | 460 ++++++++++++++++++ packages/db/tests/utils.property.test.ts | 312 ++++++++++++ pnpm-lock.yaml | 29 ++ 4 files changed, 803 insertions(+) create mode 100644 packages/db/tests/comparison.property.test.ts create mode 100644 packages/db/tests/utils.property.test.ts diff --git a/package.json b/package.json index ea2f51369..290faef9a 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,8 @@ "generate-docs": "node scripts/generate-docs.ts" }, "devDependencies": { + "@fast-check/vitest": "^0.2.0", + "fast-check": "^3.23.0", "@changesets/cli": "^2.29.8", "@eslint/js": "^9.39.1", "@svitejs/changesets-changelog-github-compact": "^1.2.0", diff --git a/packages/db/tests/comparison.property.test.ts b/packages/db/tests/comparison.property.test.ts new file mode 100644 index 000000000..7f4646383 --- /dev/null +++ b/packages/db/tests/comparison.property.test.ts @@ -0,0 +1,460 @@ +import { describe, expect } from 'vitest' +import { fc, test as fcTest } from '@fast-check/vitest' +import { + areValuesEqual, + ascComparator, + descComparator, + makeComparator, + normalizeValue, +} from '../src/utils/comparison' +import type { CompareOptions } from '../src/query/builder/types' + +/** + * Property-based tests for comparison functions + * + * A valid comparator must satisfy: + * 1. Consistency: compare(a, b) always returns the same value + * 2. Antisymmetry: sign(compare(a, b)) === -sign(compare(b, a)) + * 3. Transitivity: if compare(a, b) <= 0 and compare(b, c) <= 0 then compare(a, c) <= 0 + * 4. Reflexivity: compare(a, a) === 0 + * + * Note: Object comparison uses stable IDs based on creation order, which means + * comparing two different object instances has order-dependent behavior. + * These tests focus on primitives, dates, and arrays where comparison is deterministic. + */ + +const defaultOpts: CompareOptions = { + direction: `asc`, + nulls: `first`, + stringSort: `locale`, +} + +const lexicalOpts: CompareOptions = { + direction: `asc`, + nulls: `first`, + stringSort: `lexical`, +} + +const nullsLastOpts: CompareOptions = { + direction: `asc`, + nulls: `last`, + stringSort: `locale`, +} + +// Comparator-compatible values (primitives that can be compared deterministically) +// Note: We exclude plain objects because they use stable IDs which are creation-order dependent +const arbitraryComparablePrimitive = fc.oneof( + fc.string(), + fc.integer(), + fc.double({ noNaN: true, noDefaultInfinity: true }), + fc.boolean(), +) + +const arbitraryDate = fc.date({ noInvalidDate: true }) + +const arbitraryComparableArray = fc.array(arbitraryComparablePrimitive, { maxLength: 5 }) + +// Helper to get sign of a number +const sign = (n: number): -1 | 0 | 1 => { + if (n < 0) return -1 + if (n > 0) return 1 + return 0 +} + +/** + * Check antisymmetry property: sign(compare(a, b)) === -sign(compare(b, a)) + * This handles the +0/-0 JavaScript edge case where -0 !== 0 in Object.is + */ +const checkAntisymmetry = (ab: number, ba: number): boolean => { + const signAB = sign(ab) + const signBA = sign(ba) + // Both zero, or opposite signs + return (signAB === 0 && signBA === 0) || signAB === -signBA +} + +// Same-type arbitraries for comparator tests (cross-type comparison is not guaranteed to be total ordering) +const arbitrarySameTypeString = fc.tuple(fc.string(), fc.string()) +const arbitrarySameTypeInt = fc.tuple(fc.integer(), fc.integer()) +const arbitrarySameTypeDouble = fc.tuple( + fc.double({ noNaN: true, noDefaultInfinity: true }), + fc.double({ noNaN: true, noDefaultInfinity: true }), +) +const arbitrarySameTypeBool = fc.tuple(fc.boolean(), fc.boolean()) +const arbitrarySameTypeDate = fc.tuple( + fc.date({ noInvalidDate: true }), + fc.date({ noInvalidDate: true }), +) +const arbitrarySameTypeArray = fc.tuple( + fc.array(fc.integer(), { maxLength: 5 }), + fc.array(fc.integer(), { maxLength: 5 }), +) + +// Pair of same-type comparable values +const arbitrarySameTypePair = fc.oneof( + arbitrarySameTypeString, + arbitrarySameTypeInt, + arbitrarySameTypeDouble, + arbitrarySameTypeBool, + arbitrarySameTypeDate, + arbitrarySameTypeArray, +) + +// Triple of same-type values for transitivity +const arbitrarySameTypeTriple = fc.oneof( + fc.tuple(fc.integer(), fc.integer(), fc.integer()), + fc.tuple(fc.string(), fc.string(), fc.string()), + fc.tuple( + fc.double({ noNaN: true, noDefaultInfinity: true }), + fc.double({ noNaN: true, noDefaultInfinity: true }), + fc.double({ noNaN: true, noDefaultInfinity: true }), + ), +) + +describe(`ascComparator property-based tests`, () => { + describe(`comparator laws`, () => { + fcTest.prop([arbitraryComparablePrimitive])( + `reflexivity: compare(a, a) === 0`, + (a) => { + expect(ascComparator(a, a, defaultOpts)).toBe(0) + }, + ) + + fcTest.prop([arbitrarySameTypePair])( + `antisymmetry: sign(compare(a, b)) === -sign(compare(b, a)) for same types`, + ([a, b]) => { + const ab = ascComparator(a, b, defaultOpts) + const ba = ascComparator(b, a, defaultOpts) + expect(checkAntisymmetry(ab, ba)).toBe(true) + }, + ) + + fcTest.prop([arbitrarySameTypeTriple])( + `transitivity: if a <= b and b <= c then a <= c (same types)`, + ([a, b, c]) => { + const ab = ascComparator(a, b, defaultOpts) + const bc = ascComparator(b, c, defaultOpts) + const ac = ascComparator(a, c, defaultOpts) + + if (ab <= 0 && bc <= 0) { + expect(ac).toBeLessThanOrEqual(0) + } + }, + ) + + fcTest.prop([arbitrarySameTypePair])( + `consistency: compare(a, b) always returns the same value`, + ([a, b]) => { + const result1 = ascComparator(a, b, defaultOpts) + const result2 = ascComparator(a, b, defaultOpts) + expect(result1).toBe(result2) + }, + ) + }) + + describe(`null handling`, () => { + fcTest.prop([arbitraryComparablePrimitive])( + `nulls first: null comes before any non-null value`, + (a) => { + expect(ascComparator(null, a, defaultOpts)).toBeLessThan(0) + expect(ascComparator(a, null, defaultOpts)).toBeGreaterThan(0) + }, + ) + + fcTest.prop([arbitraryComparablePrimitive])( + `nulls last: null comes after any non-null value`, + (a) => { + expect(ascComparator(null, a, nullsLastOpts)).toBeGreaterThan(0) + expect(ascComparator(a, null, nullsLastOpts)).toBeLessThan(0) + }, + ) + + fcTest.prop([fc.constant(null), fc.constant(null)])( + `null equals null`, + (a, b) => { + expect(ascComparator(a, b, defaultOpts)).toBe(0) + }, + ) + + fcTest.prop([fc.constant(undefined), fc.constant(undefined)])( + `undefined equals undefined`, + (a, b) => { + expect(ascComparator(a, b, defaultOpts)).toBe(0) + }, + ) + + fcTest.prop([fc.constantFrom(null, undefined), fc.constantFrom(null, undefined)])( + `null and undefined are treated the same for comparison`, + (a, b) => { + // Both null and undefined are treated as "null-ish" values + expect(ascComparator(a, b, defaultOpts)).toBe(0) + }, + ) + }) + + describe(`string comparison`, () => { + fcTest.prop([fc.string()])( + `locale sort: reflexivity holds for strings`, + (a) => { + expect(ascComparator(a, a, defaultOpts)).toBe(0) + }, + ) + + fcTest.prop([fc.string(), fc.string()])( + `lexical sort: antisymmetry holds for strings`, + (a, b) => { + const ab = ascComparator(a, b, lexicalOpts) + const ba = ascComparator(b, a, lexicalOpts) + expect(checkAntisymmetry(ab, ba)).toBe(true) + }, + ) + }) + + describe(`array comparison`, () => { + fcTest.prop([arbitraryComparableArray])( + `reflexivity: compare(arr, arr) === 0`, + (arr) => { + expect(ascComparator(arr, arr, defaultOpts)).toBe(0) + }, + ) + + fcTest.prop([arbitrarySameTypeArray])( + `antisymmetry for arrays of same element type`, + ([a, b]) => { + const ab = ascComparator(a, b, defaultOpts) + const ba = ascComparator(b, a, defaultOpts) + expect(checkAntisymmetry(ab, ba)).toBe(true) + }, + ) + + fcTest.prop([fc.array(fc.integer(), { maxLength: 5 })])( + `arrays with same elements compare equal`, + (arr) => { + const copy = [...arr] + expect(ascComparator(arr, copy, defaultOpts)).toBe(0) + }, + ) + + fcTest.prop([fc.array(fc.integer(), { minLength: 1, maxLength: 5 })])( + `shorter prefix array comes before longer array`, + (arr) => { + const prefix = arr.slice(0, -1) + if (prefix.length < arr.length) { + expect(ascComparator(prefix, arr, defaultOpts)).toBeLessThan(0) + expect(ascComparator(arr, prefix, defaultOpts)).toBeGreaterThan(0) + } + }, + ) + }) + + describe(`date comparison`, () => { + fcTest.prop([arbitraryDate])( + `reflexivity: compare(date, date) === 0`, + (date) => { + expect(ascComparator(date, date, defaultOpts)).toBe(0) + }, + ) + + fcTest.prop([arbitraryDate, arbitraryDate])( + `antisymmetry for dates`, + (a, b) => { + const ab = ascComparator(a, b, defaultOpts) + const ba = ascComparator(b, a, defaultOpts) + expect(checkAntisymmetry(ab, ba)).toBe(true) + }, + ) + + fcTest.prop([arbitraryDate])( + `dates with same time compare equal`, + (date) => { + const copy = new Date(date.getTime()) + expect(ascComparator(date, copy, defaultOpts)).toBe(0) + }, + ) + + fcTest.prop([ + // Use bounded dates to avoid overflow when adding offset + fc.date({ min: new Date(0), max: new Date(`2100-01-01`) }), + fc.integer({ min: 1, max: 1000000 }), + ])( + `earlier date comes before later date`, + (date, offset) => { + const later = new Date(date.getTime() + offset) + // Only test if the later date is valid + if (!isNaN(later.getTime())) { + expect(ascComparator(date, later, defaultOpts)).toBeLessThan(0) + expect(ascComparator(later, date, defaultOpts)).toBeGreaterThan(0) + } + }, + ) + }) + + describe(`number comparison`, () => { + fcTest.prop([fc.integer()])( + `reflexivity for integers`, + (n) => { + expect(ascComparator(n, n, defaultOpts)).toBe(0) + }, + ) + + fcTest.prop([fc.double({ noNaN: true, noDefaultInfinity: true })])( + `reflexivity for doubles`, + (n) => { + expect(ascComparator(n, n, defaultOpts)).toBe(0) + }, + ) + + fcTest.prop([fc.integer(), fc.integer()])( + `integer ordering is correct`, + (a, b) => { + const result = ascComparator(a, b, defaultOpts) + if (a < b) { + expect(result).toBeLessThan(0) + } else if (a > b) { + expect(result).toBeGreaterThan(0) + } else { + expect(result).toBe(0) + } + }, + ) + }) +}) + +describe(`descComparator property-based tests`, () => { + fcTest.prop([arbitrarySameTypePair])( + `descComparator reverses ascComparator ordering (same types)`, + ([a, b]) => { + const asc = ascComparator(a, b, defaultOpts) + const desc = descComparator(a, b, defaultOpts) + expect(checkAntisymmetry(asc, desc)).toBe(true) + }, + ) + + fcTest.prop([arbitraryComparablePrimitive])( + `reflexivity: descComparator(a, a) === 0`, + (a) => { + expect(descComparator(a, a, defaultOpts)).toBe(0) + }, + ) + + fcTest.prop([fc.integer({ min: 1 })])( + `nulls first in desc: null sorts before non-null values`, + (a) => { + // With nulls: 'first', null should come before non-null values in sorted output + // If compare(a, b) < 0, then a comes before b in the result + const result = descComparator(null, a, defaultOpts) + expect(result).toBeLessThan(0) + }, + ) +}) + +describe(`makeComparator property-based tests`, () => { + fcTest.prop([ + arbitrarySameTypePair, + fc.constantFrom(`asc`, `desc`), + fc.constantFrom(`first`, `last`), + ])( + `makeComparator produces valid comparator for any options (same types)`, + ([a, b], direction, nulls) => { + const opts: CompareOptions = { direction, nulls, stringSort: `locale` } + const comparator = makeComparator(opts) + + // Reflexivity + expect(comparator(a, a)).toBe(0) + + // Antisymmetry + const ab = comparator(a, b) + const ba = comparator(b, a) + expect(checkAntisymmetry(ab, ba)).toBe(true) + }, + ) +}) + +describe(`normalizeValue property-based tests`, () => { + fcTest.prop([arbitraryDate])( + `dates normalize to their timestamp`, + (date) => { + expect(normalizeValue(date)).toBe(date.getTime()) + }, + ) + + fcTest.prop([fc.uint8Array({ minLength: 0, maxLength: 128 })])( + `small Uint8Arrays normalize to string representation`, + (arr) => { + const normalized = normalizeValue(arr) + expect(typeof normalized).toBe(`string`) + expect(normalized).toMatch(/^__u8__/) + }, + ) + + fcTest.prop([fc.uint8Array({ minLength: 129, maxLength: 200 })])( + `large Uint8Arrays are not normalized`, + (arr) => { + const normalized = normalizeValue(arr) + expect(normalized).toBe(arr) + }, + ) + + fcTest.prop([fc.string()])( + `strings pass through unchanged`, + (str) => { + expect(normalizeValue(str)).toBe(str) + }, + ) + + fcTest.prop([fc.integer()])( + `integers pass through unchanged`, + (n) => { + expect(normalizeValue(n)).toBe(n) + }, + ) + + fcTest.prop([fc.uint8Array({ minLength: 0, maxLength: 128 })])( + `normalization is idempotent for Uint8Arrays`, + (arr) => { + const normalized1 = normalizeValue(arr) + // For strings (which small arrays become), normalizing again should be identity + expect(normalizeValue(normalized1)).toBe(normalized1) + }, + ) +}) + +describe(`areValuesEqual property-based tests`, () => { + fcTest.prop([fc.uint8Array({ minLength: 0, maxLength: 50 })])( + `Uint8Arrays with same content are equal`, + (arr) => { + const copy = new Uint8Array(arr) + expect(areValuesEqual(arr, copy)).toBe(true) + }, + ) + + fcTest.prop([ + fc.uint8Array({ minLength: 1, maxLength: 50 }), + fc.integer({ min: 0, max: 49 }), + fc.integer({ min: 0, max: 255 }), + ])( + `Uint8Arrays with different content are not equal`, + (arr, index, newValue) => { + if (index < arr.length && arr[index] !== newValue) { + const modified = new Uint8Array(arr) + modified[index] = newValue + expect(areValuesEqual(arr, modified)).toBe(false) + } + }, + ) + + fcTest.prop([fc.integer()])( + `reference equality for primitives`, + (n) => { + expect(areValuesEqual(n, n)).toBe(true) + }, + ) + + fcTest.prop([fc.integer(), fc.integer()])( + `different integers are not equal`, + (a, b) => { + if (a !== b) { + expect(areValuesEqual(a, b)).toBe(false) + } + }, + ) +}) diff --git a/packages/db/tests/utils.property.test.ts b/packages/db/tests/utils.property.test.ts new file mode 100644 index 000000000..1c1ec8d9f --- /dev/null +++ b/packages/db/tests/utils.property.test.ts @@ -0,0 +1,312 @@ +import { describe, expect, it } from 'vitest' +import { fc, test as fcTest } from '@fast-check/vitest' +import { Temporal } from 'temporal-polyfill' +import { deepEquals } from '../src/utils' + +/** + * Custom arbitraries for generating values that deepEquals handles. + * + * Note: We use same-type arbitraries for symmetry/transitivity tests because + * deepEquals has known asymmetric behavior when comparing certain cross-type + * values (e.g., Date vs Temporal.Duration). This is documented in the + * "known edge cases" section below. + */ +const arbitraryPrimitive = fc.oneof( + fc.string(), + fc.integer(), + fc.double({ noNaN: true }), // NaN !== NaN, which would break reflexivity + fc.boolean(), + fc.constant(null), + fc.constant(undefined), +) + +const arbitraryDate = fc.date().map((d) => new Date(d.getTime())) + +const arbitraryRegExp = fc.tuple(fc.string(), fc.constantFrom(``, `g`, `i`, `gi`, `m`, `gim`)).map( + ([source, flags]) => { + try { + // Escape special regex characters to avoid invalid patterns + const escapedSource = source.replace(/[.*+?^${}()|[\]\\]/g, `\\$&`) + return new RegExp(escapedSource, flags) + } catch { + return /test/ + } + }, +) + +const arbitraryUint8Array = fc.uint8Array({ minLength: 0, maxLength: 20 }) + +const arbitraryFloat32Array = fc + .array(fc.float({ noNaN: true }), { minLength: 0, maxLength: 10 }) + .map((arr) => new Float32Array(arr)) + +const arbitraryTemporalPlainDate = fc + .tuple( + fc.integer({ min: 1, max: 9999 }), + fc.integer({ min: 1, max: 12 }), + fc.integer({ min: 1, max: 28 }), // Safe day range + ) + .map(([year, month, day]) => new Temporal.PlainDate(year, month, day)) + +const arbitraryTemporalDuration = fc + .tuple( + fc.integer({ min: 0, max: 100 }), + fc.integer({ min: 0, max: 59 }), + fc.integer({ min: 0, max: 59 }), + ) + .map(([hours, minutes, seconds]) => Temporal.Duration.from({ hours, minutes, seconds })) + +// Same-type value arbitraries for testing equivalence properties +// These avoid cross-type comparisons that have known asymmetric behavior +const arbitrarySameTypePrimitive = fc.oneof( + fc.tuple(fc.string(), fc.string()), + fc.tuple(fc.integer(), fc.integer()), + fc.tuple(fc.double({ noNaN: true }), fc.double({ noNaN: true })), + fc.tuple(fc.boolean(), fc.boolean()), +) + +const arbitrarySameTypeDate = fc.tuple(arbitraryDate, arbitraryDate) +const arbitrarySameTypeRegExp = fc.tuple(arbitraryRegExp, arbitraryRegExp) +const arbitrarySameTypeUint8Array = fc.tuple(arbitraryUint8Array, arbitraryUint8Array) +const arbitrarySameTypeTemporalDate = fc.tuple(arbitraryTemporalPlainDate, arbitraryTemporalPlainDate) +const arbitrarySameTypeTemporalDuration = fc.tuple(arbitraryTemporalDuration, arbitraryTemporalDuration) + +// Pair of values of the same type +const arbitrarySameTypePair = fc.oneof( + arbitrarySameTypePrimitive, + arbitrarySameTypeDate, + arbitrarySameTypeRegExp, + arbitrarySameTypeUint8Array, + arbitrarySameTypeTemporalDate, + arbitrarySameTypeTemporalDuration, + fc.tuple(fc.array(fc.integer(), { maxLength: 5 }), fc.array(fc.integer(), { maxLength: 5 })), + fc.tuple( + fc.dictionary(fc.string(), fc.integer(), { maxKeys: 5 }), + fc.dictionary(fc.string(), fc.integer(), { maxKeys: 5 }), + ), +) + +// Single values for reflexivity tests +const arbitrarySingleValue = fc.oneof( + arbitraryPrimitive, + arbitraryDate, + arbitraryRegExp, + arbitraryUint8Array, + arbitraryFloat32Array, + arbitraryTemporalPlainDate, + arbitraryTemporalDuration, + fc.array(fc.integer(), { maxLength: 5 }), + fc.dictionary(fc.string(), fc.integer(), { maxKeys: 5 }), +) + +describe(`deepEquals property-based tests`, () => { + describe(`equivalence relation properties`, () => { + fcTest.prop([arbitrarySingleValue])( + `reflexivity: deepEquals(a, a) is always true`, + (a) => { + expect(deepEquals(a, a)).toBe(true) + }, + ) + + fcTest.prop([arbitrarySameTypePair])( + `symmetry: deepEquals(a, b) === deepEquals(b, a) for same-type values`, + ([a, b]) => { + expect(deepEquals(a, b)).toBe(deepEquals(b, a)) + }, + ) + + fcTest.prop([fc.array(fc.integer(), { maxLength: 5 })])( + `transitivity: if deepEquals(a, b) && deepEquals(b, c) then deepEquals(a, c)`, + (arr) => { + // Create three copies to test transitivity + const a = [...arr] + const b = [...arr] + const c = [...arr] + if (deepEquals(a, b) && deepEquals(b, c)) { + expect(deepEquals(a, c)).toBe(true) + } + }, + ) + }) + + describe(`known edge cases - cross-type comparisons`, () => { + /** + * This test documents a known asymmetry in deepEquals when comparing + * Date objects with Temporal objects. The Date check comes first and + * returns false, but when reversed, the Temporal check requires both + * to be Temporal types, so it falls through to generic object comparison. + * + * This is arguably a bug that could be fixed by ensuring symmetric + * type checking for all special types. + */ + it(`documents asymmetric behavior between Date and Temporal.Duration`, () => { + const date = new Date(`1970-01-01T00:00:00.000Z`) + const duration = Temporal.Duration.from({ hours: 0, minutes: 0, seconds: 0 }) + + // Both directions should return false for different types + const dateFirst = deepEquals(date, duration) + const durationFirst = deepEquals(duration, date) + + // Document the current behavior (this may be asymmetric) + // If this test fails after a fix, that's good - the fix should make them both false + expect(dateFirst).toBe(false) + // Note: durationFirst may be true due to object key comparison falling through + // This documents the asymmetry - ideally both should be false + if (durationFirst !== dateFirst) { + // Asymmetry detected - this is the known edge case + expect(durationFirst).toBe(true) // Documents current behavior + } + }) + }) + + describe(`structural equality`, () => { + fcTest.prop([fc.array(fc.integer(), { minLength: 0, maxLength: 10 })])( + `arrays with same elements are equal`, + (arr) => { + const copy = [...arr] + expect(deepEquals(arr, copy)).toBe(true) + }, + ) + + fcTest.prop([fc.dictionary(fc.string(), fc.integer(), { minKeys: 0, maxKeys: 10 })])( + `objects with same properties are equal`, + (obj) => { + const copy = { ...obj } + expect(deepEquals(obj, copy)).toBe(true) + }, + ) + + fcTest.prop([fc.array(fc.tuple(fc.string(), fc.integer()), { minLength: 0, maxLength: 5 })])( + `Maps with same entries are equal`, + (entries) => { + const map1 = new Map(entries) + const map2 = new Map(entries) + expect(deepEquals(map1, map2)).toBe(true) + }, + ) + + fcTest.prop([fc.array(fc.integer(), { minLength: 0, maxLength: 10 })])( + `Sets with same primitive values are equal`, + (arr) => { + const set1 = new Set(arr) + const set2 = new Set(arr) + expect(deepEquals(set1, set2)).toBe(true) + }, + ) + + fcTest.prop([fc.uint8Array({ minLength: 0, maxLength: 50 })])( + `Uint8Arrays with same content are equal`, + (arr) => { + const copy = new Uint8Array(arr) + expect(deepEquals(arr, copy)).toBe(true) + }, + ) + + fcTest.prop([arbitraryDate])( + `Dates with same time are equal`, + (date) => { + const copy = new Date(date.getTime()) + expect(deepEquals(date, copy)).toBe(true) + }, + ) + + fcTest.prop([arbitraryTemporalPlainDate])( + `Temporal.PlainDate with same values are equal`, + (date) => { + const copy = new Temporal.PlainDate(date.year, date.month, date.day) + expect(deepEquals(date, copy)).toBe(true) + }, + ) + }) + + describe(`inequality properties`, () => { + fcTest.prop([fc.array(fc.integer(), { minLength: 1, maxLength: 10 }), fc.integer()])( + `arrays with different elements are not equal`, + (arr, extraElement) => { + const modified = [...arr, extraElement] + expect(deepEquals(arr, modified)).toBe(false) + }, + ) + + fcTest.prop([ + fc.dictionary(fc.string(), fc.integer(), { minKeys: 1, maxKeys: 5 }), + fc.string(), + fc.integer(), + ])( + `objects with extra property are not equal`, + (obj, newKey, newValue) => { + // Only test if the key doesn't already exist + if (!(newKey in obj)) { + const modified = { ...obj, [newKey]: newValue } + expect(deepEquals(obj, modified)).toBe(false) + } + }, + ) + + fcTest.prop([fc.integer(), fc.string()])( + `different types are not equal`, + (num, str) => { + expect(deepEquals(num, str)).toBe(false) + }, + ) + + fcTest.prop([fc.date(), fc.date()])( + `dates with different times are not equal`, + (date1, date2) => { + if (date1.getTime() !== date2.getTime()) { + expect(deepEquals(date1, date2)).toBe(false) + } + }, + ) + }) + + describe(`edge cases`, () => { + fcTest.prop([arbitrarySingleValue])( + `null is never equal to a non-null value`, + (a) => { + if (a !== null) { + expect(deepEquals(null, a)).toBe(false) + expect(deepEquals(a, null)).toBe(false) + } + }, + ) + + fcTest.prop([arbitrarySingleValue])( + `undefined is never equal to a non-undefined value`, + (a) => { + if (a !== undefined) { + expect(deepEquals(undefined, a)).toBe(false) + expect(deepEquals(a, undefined)).toBe(false) + } + }, + ) + + fcTest.prop([fc.array(fc.integer(), { minLength: 0, maxLength: 5 })])( + `array is never equal to object with same values`, + (arr) => { + const obj = { ...arr } + expect(deepEquals(arr, obj)).toBe(false) + }, + ) + }) + + describe(`nested structure consistency`, () => { + fcTest.prop([fc.array(fc.array(fc.integer(), { maxLength: 3 }), { maxLength: 3 })])( + `nested arrays maintain equality through cloning`, + (nestedArr) => { + const clone = nestedArr.map((inner) => [...inner]) + expect(deepEquals(nestedArr, clone)).toBe(true) + }, + ) + + fcTest.prop([fc.dictionary(fc.string(), fc.dictionary(fc.string(), fc.integer(), { maxKeys: 3 }), { maxKeys: 3 })])( + `nested objects maintain equality through cloning`, + (nestedObj) => { + const clone = Object.fromEntries( + Object.entries(nestedObj).map(([k, v]) => [k, { ...v }]), + ) + expect(deepEquals(nestedObj, clone)).toBe(true) + }, + ) + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c6a532eb9..b96c22038 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@eslint/js': specifier: ^9.39.1 version: 9.39.1 + '@fast-check/vitest': + specifier: ^0.2.0 + version: 0.2.4(vitest@3.2.4) '@svitejs/changesets-changelog-github-compact': specifier: ^1.2.0 version: 1.2.0(encoding@0.1.13) @@ -59,6 +62,9 @@ importers: eslint-plugin-react: specifier: ^7.37.5 version: 7.37.5(eslint@9.39.1(jiti@2.6.1)) + fast-check: + specifier: ^3.23.0 + version: 3.23.2 husky: specifier: ^9.1.7 version: 9.1.7 @@ -2200,6 +2206,11 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@fast-check/vitest@0.2.4': + resolution: {integrity: sha512-Ilcr+JAIPhb1s6FRm4qoglQYSGXXrS+zAupZeNuWAA3qHVGDA1d1Gb84Hb/+otL3GzVZjFJESg5/1SfIvrgssA==} + peerDependencies: + vitest: ^1 || ^2 || ^3 || ^4 + '@firebase/ai@1.4.1': resolution: {integrity: sha512-bcusQfA/tHjUjBTnMx6jdoPMpDl3r8K15Z+snHz9wq0Foox0F/V+kNLXucEOHoTL2hTc9l+onZCyBJs2QoIC3g==} engines: {node: '>=18.0.0'} @@ -5676,6 +5687,10 @@ packages: extendable-error@0.1.7: resolution: {integrity: sha512-UOiS2in6/Q0FK0R0q6UY9vYpQ21mr/Qn1KOnte7vsACuNJf514WvCCUHSRCPcgjPT2bAhNIJdlE6bVap1GKmeg==} + fast-check@3.23.2: + resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==} + engines: {node: '>=8.0.0'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -7446,6 +7461,9 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + pure-rand@6.1.0: + resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} + pvtsutils@1.3.6: resolution: {integrity: sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==} @@ -9971,6 +9989,11 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 + '@fast-check/vitest@0.2.4(vitest@3.2.4)': + dependencies: + fast-check: 3.23.2 + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.7.0)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.2.0(postcss@8.5.6))(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1) + '@firebase/ai@1.4.1(@firebase/app-types@0.9.3)(@firebase/app@0.13.2)': dependencies: '@firebase/app': 0.13.2 @@ -14086,6 +14109,10 @@ snapshots: extendable-error@0.1.7: {} + fast-check@3.23.2: + dependencies: + pure-rand: 6.1.0 + fast-deep-equal@3.1.3: {} fast-diff@1.3.0: {} @@ -15946,6 +15973,8 @@ snapshots: punycode@2.3.1: {} + pure-rand@6.1.0: {} + pvtsutils@1.3.6: dependencies: tslib: 2.8.1 From 0a4dec21804414f094a80a3a4ba5b81cca3812c2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 14 Dec 2025 20:00:23 +0000 Subject: [PATCH 2/8] fix(db): ensure symmetric type checking in deepEquals Previously, comparing values like `deepEquals(Date, Temporal.Duration)` and `deepEquals(Temporal.Duration, Date)` could return different results because special type checks (Date, RegExp, Map, Set, TypedArray, Temporal) only checked if `a` was the special type, not if `b` was. This fix adds symmetric checks after each special type handler: - If `b` is Date but `a` is not, return false - If `b` is RegExp but `a` is not, return false - If `b` is Map but `a` is not, return false - If `b` is Set but `a` is not, return false - If `b` is TypedArray but `a` is not, return false - If `b` is Temporal but `a` is not, return false - If `b` is Array but `a` is not, return false This ensures deepEquals is a proper equivalence relation where `deepEquals(a, b) === deepEquals(b, a)` always holds. --- packages/db/src/utils.ts | 20 ++++++ packages/db/tests/comparison.property.test.ts | 6 +- packages/db/tests/utils.property.test.ts | 69 ++++++++++++------- 3 files changed, 70 insertions(+), 25 deletions(-) diff --git a/packages/db/src/utils.ts b/packages/db/src/utils.ts index 248b93d36..00292e37a 100644 --- a/packages/db/src/utils.ts +++ b/packages/db/src/utils.ts @@ -52,12 +52,16 @@ function deepEqualsInternal( if (!(b instanceof Date)) return false return a.getTime() === b.getTime() } + // Symmetric check: if b is Date but a is not, they're not equal + if (b instanceof Date) return false // Handle RegExp objects if (a instanceof RegExp) { if (!(b instanceof RegExp)) return false return a.source === b.source && a.flags === b.flags } + // Symmetric check: if b is RegExp but a is not, they're not equal + if (b instanceof RegExp) return false // Handle Map objects - only if both are Maps if (a instanceof Map) { @@ -78,6 +82,8 @@ function deepEqualsInternal( visited.delete(a) return result } + // Symmetric check: if b is Map but a is not, they're not equal + if (b instanceof Map) return false // Handle Set objects - only if both are Sets if (a instanceof Set) { @@ -106,6 +112,8 @@ function deepEqualsInternal( visited.delete(a) return result } + // Symmetric check: if b is Set but a is not, they're not equal + if (b instanceof Set) return false // Handle TypedArrays if ( @@ -124,6 +132,14 @@ function deepEqualsInternal( return true } + // Symmetric check: if b is TypedArray but a is not, they're not equal + if ( + ArrayBuffer.isView(b) && + !(b instanceof DataView) && + !ArrayBuffer.isView(a) + ) { + return false + } // Handle Temporal objects // Check if both are Temporal objects of the same type @@ -142,6 +158,8 @@ function deepEqualsInternal( // Fallback to toString comparison for other types return a.toString() === b.toString() } + // Symmetric check: if b is Temporal but a is not, they're not equal + if (isTemporal(b)) return false // Handle arrays if (Array.isArray(a)) { @@ -159,6 +177,8 @@ function deepEqualsInternal( visited.delete(a) return result } + // Symmetric check: if b is array but a is not, they're not equal + if (Array.isArray(b)) return false // Handle objects if (typeof a === `object`) { diff --git a/packages/db/tests/comparison.property.test.ts b/packages/db/tests/comparison.property.test.ts index 7f4646383..0c72533c1 100644 --- a/packages/db/tests/comparison.property.test.ts +++ b/packages/db/tests/comparison.property.test.ts @@ -355,7 +355,11 @@ describe(`makeComparator property-based tests`, () => { ])( `makeComparator produces valid comparator for any options (same types)`, ([a, b], direction, nulls) => { - const opts: CompareOptions = { direction, nulls, stringSort: `locale` } + const opts: CompareOptions = { + direction: direction as `asc` | `desc`, + nulls: nulls as `first` | `last`, + stringSort: `locale`, + } const comparator = makeComparator(opts) // Reflexivity diff --git a/packages/db/tests/utils.property.test.ts b/packages/db/tests/utils.property.test.ts index 1c1ec8d9f..2d0fec807 100644 --- a/packages/db/tests/utils.property.test.ts +++ b/packages/db/tests/utils.property.test.ts @@ -129,33 +129,54 @@ describe(`deepEquals property-based tests`, () => { ) }) - describe(`known edge cases - cross-type comparisons`, () => { - /** - * This test documents a known asymmetry in deepEquals when comparing - * Date objects with Temporal objects. The Date check comes first and - * returns false, but when reversed, the Temporal check requires both - * to be Temporal types, so it falls through to generic object comparison. - * - * This is arguably a bug that could be fixed by ensuring symmetric - * type checking for all special types. - */ - it(`documents asymmetric behavior between Date and Temporal.Duration`, () => { + describe(`cross-type comparisons`, () => { + it(`Date and Temporal.Duration are not equal in either direction`, () => { const date = new Date(`1970-01-01T00:00:00.000Z`) const duration = Temporal.Duration.from({ hours: 0, minutes: 0, seconds: 0 }) - // Both directions should return false for different types - const dateFirst = deepEquals(date, duration) - const durationFirst = deepEquals(duration, date) - - // Document the current behavior (this may be asymmetric) - // If this test fails after a fix, that's good - the fix should make them both false - expect(dateFirst).toBe(false) - // Note: durationFirst may be true due to object key comparison falling through - // This documents the asymmetry - ideally both should be false - if (durationFirst !== dateFirst) { - // Asymmetry detected - this is the known edge case - expect(durationFirst).toBe(true) // Documents current behavior - } + // Both directions should return false for different types (symmetric) + expect(deepEquals(date, duration)).toBe(false) + expect(deepEquals(duration, date)).toBe(false) + }) + + it(`Date and Temporal.PlainDate are not equal in either direction`, () => { + const date = new Date(`2023-01-01T00:00:00.000Z`) + const plainDate = new Temporal.PlainDate(2023, 1, 1) + + expect(deepEquals(date, plainDate)).toBe(false) + expect(deepEquals(plainDate, date)).toBe(false) + }) + + it(`RegExp and object are not equal in either direction`, () => { + const regex = /test/g + const obj = { source: `test`, flags: `g` } + + expect(deepEquals(regex, obj)).toBe(false) + expect(deepEquals(obj, regex)).toBe(false) + }) + + it(`Map and object are not equal in either direction`, () => { + const map = new Map([[`a`, 1]]) + const obj = { a: 1 } + + expect(deepEquals(map, obj)).toBe(false) + expect(deepEquals(obj, map)).toBe(false) + }) + + it(`Set and array are not equal in either direction`, () => { + const set = new Set([1, 2, 3]) + const arr = [1, 2, 3] + + expect(deepEquals(set, arr)).toBe(false) + expect(deepEquals(arr, set)).toBe(false) + }) + + it(`Uint8Array and array are not equal in either direction`, () => { + const typedArr = new Uint8Array([1, 2, 3]) + const arr = [1, 2, 3] + + expect(deepEquals(typedArr, arr)).toBe(false) + expect(deepEquals(arr, typedArr)).toBe(false) }) }) From 2a7cc4deb48a20a2ec2cf61244a10df4af6c4190 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sun, 14 Dec 2025 20:56:21 +0000 Subject: [PATCH 3/8] ci: apply automated fixes --- packages/db/tests/comparison.property.test.ts | 82 ++++------ packages/db/tests/utils.property.test.ts | 145 ++++++++++-------- 2 files changed, 113 insertions(+), 114 deletions(-) diff --git a/packages/db/tests/comparison.property.test.ts b/packages/db/tests/comparison.property.test.ts index 0c72533c1..dd6279001 100644 --- a/packages/db/tests/comparison.property.test.ts +++ b/packages/db/tests/comparison.property.test.ts @@ -52,7 +52,9 @@ const arbitraryComparablePrimitive = fc.oneof( const arbitraryDate = fc.date({ noInvalidDate: true }) -const arbitraryComparableArray = fc.array(arbitraryComparablePrimitive, { maxLength: 5 }) +const arbitraryComparableArray = fc.array(arbitraryComparablePrimitive, { + maxLength: 5, +}) // Helper to get sign of a number const sign = (n: number): -1 | 0 | 1 => { @@ -182,13 +184,13 @@ describe(`ascComparator property-based tests`, () => { }, ) - fcTest.prop([fc.constantFrom(null, undefined), fc.constantFrom(null, undefined)])( - `null and undefined are treated the same for comparison`, - (a, b) => { - // Both null and undefined are treated as "null-ish" values - expect(ascComparator(a, b, defaultOpts)).toBe(0) - }, - ) + fcTest.prop([ + fc.constantFrom(null, undefined), + fc.constantFrom(null, undefined), + ])(`null and undefined are treated the same for comparison`, (a, b) => { + // Both null and undefined are treated as "null-ish" values + expect(ascComparator(a, b, defaultOpts)).toBe(0) + }) }) describe(`string comparison`, () => { @@ -275,26 +277,20 @@ describe(`ascComparator property-based tests`, () => { // Use bounded dates to avoid overflow when adding offset fc.date({ min: new Date(0), max: new Date(`2100-01-01`) }), fc.integer({ min: 1, max: 1000000 }), - ])( - `earlier date comes before later date`, - (date, offset) => { - const later = new Date(date.getTime() + offset) - // Only test if the later date is valid - if (!isNaN(later.getTime())) { - expect(ascComparator(date, later, defaultOpts)).toBeLessThan(0) - expect(ascComparator(later, date, defaultOpts)).toBeGreaterThan(0) - } - }, - ) + ])(`earlier date comes before later date`, (date, offset) => { + const later = new Date(date.getTime() + offset) + // Only test if the later date is valid + if (!isNaN(later.getTime())) { + expect(ascComparator(date, later, defaultOpts)).toBeLessThan(0) + expect(ascComparator(later, date, defaultOpts)).toBeGreaterThan(0) + } + }) }) describe(`number comparison`, () => { - fcTest.prop([fc.integer()])( - `reflexivity for integers`, - (n) => { - expect(ascComparator(n, n, defaultOpts)).toBe(0) - }, - ) + fcTest.prop([fc.integer()])(`reflexivity for integers`, (n) => { + expect(ascComparator(n, n, defaultOpts)).toBe(0) + }) fcTest.prop([fc.double({ noNaN: true, noDefaultInfinity: true })])( `reflexivity for doubles`, @@ -374,12 +370,9 @@ describe(`makeComparator property-based tests`, () => { }) describe(`normalizeValue property-based tests`, () => { - fcTest.prop([arbitraryDate])( - `dates normalize to their timestamp`, - (date) => { - expect(normalizeValue(date)).toBe(date.getTime()) - }, - ) + fcTest.prop([arbitraryDate])(`dates normalize to their timestamp`, (date) => { + expect(normalizeValue(date)).toBe(date.getTime()) + }) fcTest.prop([fc.uint8Array({ minLength: 0, maxLength: 128 })])( `small Uint8Arrays normalize to string representation`, @@ -398,19 +391,13 @@ describe(`normalizeValue property-based tests`, () => { }, ) - fcTest.prop([fc.string()])( - `strings pass through unchanged`, - (str) => { - expect(normalizeValue(str)).toBe(str) - }, - ) + fcTest.prop([fc.string()])(`strings pass through unchanged`, (str) => { + expect(normalizeValue(str)).toBe(str) + }) - fcTest.prop([fc.integer()])( - `integers pass through unchanged`, - (n) => { - expect(normalizeValue(n)).toBe(n) - }, - ) + fcTest.prop([fc.integer()])(`integers pass through unchanged`, (n) => { + expect(normalizeValue(n)).toBe(n) + }) fcTest.prop([fc.uint8Array({ minLength: 0, maxLength: 128 })])( `normalization is idempotent for Uint8Arrays`, @@ -446,12 +433,9 @@ describe(`areValuesEqual property-based tests`, () => { }, ) - fcTest.prop([fc.integer()])( - `reference equality for primitives`, - (n) => { - expect(areValuesEqual(n, n)).toBe(true) - }, - ) + fcTest.prop([fc.integer()])(`reference equality for primitives`, (n) => { + expect(areValuesEqual(n, n)).toBe(true) + }) fcTest.prop([fc.integer(), fc.integer()])( `different integers are not equal`, diff --git a/packages/db/tests/utils.property.test.ts b/packages/db/tests/utils.property.test.ts index 2d0fec807..eb03cef64 100644 --- a/packages/db/tests/utils.property.test.ts +++ b/packages/db/tests/utils.property.test.ts @@ -22,8 +22,9 @@ const arbitraryPrimitive = fc.oneof( const arbitraryDate = fc.date().map((d) => new Date(d.getTime())) -const arbitraryRegExp = fc.tuple(fc.string(), fc.constantFrom(``, `g`, `i`, `gi`, `m`, `gim`)).map( - ([source, flags]) => { +const arbitraryRegExp = fc + .tuple(fc.string(), fc.constantFrom(``, `g`, `i`, `gi`, `m`, `gim`)) + .map(([source, flags]) => { try { // Escape special regex characters to avoid invalid patterns const escapedSource = source.replace(/[.*+?^${}()|[\]\\]/g, `\\$&`) @@ -31,8 +32,7 @@ const arbitraryRegExp = fc.tuple(fc.string(), fc.constantFrom(``, `g`, `i`, `gi` } catch { return /test/ } - }, -) + }) const arbitraryUint8Array = fc.uint8Array({ minLength: 0, maxLength: 20 }) @@ -54,7 +54,9 @@ const arbitraryTemporalDuration = fc fc.integer({ min: 0, max: 59 }), fc.integer({ min: 0, max: 59 }), ) - .map(([hours, minutes, seconds]) => Temporal.Duration.from({ hours, minutes, seconds })) + .map(([hours, minutes, seconds]) => + Temporal.Duration.from({ hours, minutes, seconds }), + ) // Same-type value arbitraries for testing equivalence properties // These avoid cross-type comparisons that have known asymmetric behavior @@ -67,9 +69,18 @@ const arbitrarySameTypePrimitive = fc.oneof( const arbitrarySameTypeDate = fc.tuple(arbitraryDate, arbitraryDate) const arbitrarySameTypeRegExp = fc.tuple(arbitraryRegExp, arbitraryRegExp) -const arbitrarySameTypeUint8Array = fc.tuple(arbitraryUint8Array, arbitraryUint8Array) -const arbitrarySameTypeTemporalDate = fc.tuple(arbitraryTemporalPlainDate, arbitraryTemporalPlainDate) -const arbitrarySameTypeTemporalDuration = fc.tuple(arbitraryTemporalDuration, arbitraryTemporalDuration) +const arbitrarySameTypeUint8Array = fc.tuple( + arbitraryUint8Array, + arbitraryUint8Array, +) +const arbitrarySameTypeTemporalDate = fc.tuple( + arbitraryTemporalPlainDate, + arbitraryTemporalPlainDate, +) +const arbitrarySameTypeTemporalDuration = fc.tuple( + arbitraryTemporalDuration, + arbitraryTemporalDuration, +) // Pair of values of the same type const arbitrarySameTypePair = fc.oneof( @@ -79,7 +90,10 @@ const arbitrarySameTypePair = fc.oneof( arbitrarySameTypeUint8Array, arbitrarySameTypeTemporalDate, arbitrarySameTypeTemporalDuration, - fc.tuple(fc.array(fc.integer(), { maxLength: 5 }), fc.array(fc.integer(), { maxLength: 5 })), + fc.tuple( + fc.array(fc.integer(), { maxLength: 5 }), + fc.array(fc.integer(), { maxLength: 5 }), + ), fc.tuple( fc.dictionary(fc.string(), fc.integer(), { maxKeys: 5 }), fc.dictionary(fc.string(), fc.integer(), { maxKeys: 5 }), @@ -132,7 +146,11 @@ describe(`deepEquals property-based tests`, () => { describe(`cross-type comparisons`, () => { it(`Date and Temporal.Duration are not equal in either direction`, () => { const date = new Date(`1970-01-01T00:00:00.000Z`) - const duration = Temporal.Duration.from({ hours: 0, minutes: 0, seconds: 0 }) + const duration = Temporal.Duration.from({ + hours: 0, + minutes: 0, + seconds: 0, + }) // Both directions should return false for different types (symmetric) expect(deepEquals(date, duration)).toBe(false) @@ -189,22 +207,23 @@ describe(`deepEquals property-based tests`, () => { }, ) - fcTest.prop([fc.dictionary(fc.string(), fc.integer(), { minKeys: 0, maxKeys: 10 })])( - `objects with same properties are equal`, - (obj) => { - const copy = { ...obj } - expect(deepEquals(obj, copy)).toBe(true) - }, - ) + fcTest.prop([ + fc.dictionary(fc.string(), fc.integer(), { minKeys: 0, maxKeys: 10 }), + ])(`objects with same properties are equal`, (obj) => { + const copy = { ...obj } + expect(deepEquals(obj, copy)).toBe(true) + }) - fcTest.prop([fc.array(fc.tuple(fc.string(), fc.integer()), { minLength: 0, maxLength: 5 })])( - `Maps with same entries are equal`, - (entries) => { - const map1 = new Map(entries) - const map2 = new Map(entries) - expect(deepEquals(map1, map2)).toBe(true) - }, - ) + fcTest.prop([ + fc.array(fc.tuple(fc.string(), fc.integer()), { + minLength: 0, + maxLength: 5, + }), + ])(`Maps with same entries are equal`, (entries) => { + const map1 = new Map(entries) + const map2 = new Map(entries) + expect(deepEquals(map1, map2)).toBe(true) + }) fcTest.prop([fc.array(fc.integer(), { minLength: 0, maxLength: 10 })])( `Sets with same primitive values are equal`, @@ -223,13 +242,10 @@ describe(`deepEquals property-based tests`, () => { }, ) - fcTest.prop([arbitraryDate])( - `Dates with same time are equal`, - (date) => { - const copy = new Date(date.getTime()) - expect(deepEquals(date, copy)).toBe(true) - }, - ) + fcTest.prop([arbitraryDate])(`Dates with same time are equal`, (date) => { + const copy = new Date(date.getTime()) + expect(deepEquals(date, copy)).toBe(true) + }) fcTest.prop([arbitraryTemporalPlainDate])( `Temporal.PlainDate with same values are equal`, @@ -241,28 +257,25 @@ describe(`deepEquals property-based tests`, () => { }) describe(`inequality properties`, () => { - fcTest.prop([fc.array(fc.integer(), { minLength: 1, maxLength: 10 }), fc.integer()])( - `arrays with different elements are not equal`, - (arr, extraElement) => { - const modified = [...arr, extraElement] - expect(deepEquals(arr, modified)).toBe(false) - }, - ) + fcTest.prop([ + fc.array(fc.integer(), { minLength: 1, maxLength: 10 }), + fc.integer(), + ])(`arrays with different elements are not equal`, (arr, extraElement) => { + const modified = [...arr, extraElement] + expect(deepEquals(arr, modified)).toBe(false) + }) fcTest.prop([ fc.dictionary(fc.string(), fc.integer(), { minKeys: 1, maxKeys: 5 }), fc.string(), fc.integer(), - ])( - `objects with extra property are not equal`, - (obj, newKey, newValue) => { - // Only test if the key doesn't already exist - if (!(newKey in obj)) { - const modified = { ...obj, [newKey]: newValue } - expect(deepEquals(obj, modified)).toBe(false) - } - }, - ) + ])(`objects with extra property are not equal`, (obj, newKey, newValue) => { + // Only test if the key doesn't already exist + if (!(newKey in obj)) { + const modified = { ...obj, [newKey]: newValue } + expect(deepEquals(obj, modified)).toBe(false) + } + }) fcTest.prop([fc.integer(), fc.string()])( `different types are not equal`, @@ -312,22 +325,24 @@ describe(`deepEquals property-based tests`, () => { }) describe(`nested structure consistency`, () => { - fcTest.prop([fc.array(fc.array(fc.integer(), { maxLength: 3 }), { maxLength: 3 })])( - `nested arrays maintain equality through cloning`, - (nestedArr) => { - const clone = nestedArr.map((inner) => [...inner]) - expect(deepEquals(nestedArr, clone)).toBe(true) - }, - ) + fcTest.prop([ + fc.array(fc.array(fc.integer(), { maxLength: 3 }), { maxLength: 3 }), + ])(`nested arrays maintain equality through cloning`, (nestedArr) => { + const clone = nestedArr.map((inner) => [...inner]) + expect(deepEquals(nestedArr, clone)).toBe(true) + }) - fcTest.prop([fc.dictionary(fc.string(), fc.dictionary(fc.string(), fc.integer(), { maxKeys: 3 }), { maxKeys: 3 })])( - `nested objects maintain equality through cloning`, - (nestedObj) => { - const clone = Object.fromEntries( - Object.entries(nestedObj).map(([k, v]) => [k, { ...v }]), - ) - expect(deepEquals(nestedObj, clone)).toBe(true) - }, - ) + fcTest.prop([ + fc.dictionary( + fc.string(), + fc.dictionary(fc.string(), fc.integer(), { maxKeys: 3 }), + { maxKeys: 3 }, + ), + ])(`nested objects maintain equality through cloning`, (nestedObj) => { + const clone = Object.fromEntries( + Object.entries(nestedObj).map(([k, v]) => [k, { ...v }]), + ) + expect(deepEquals(nestedObj, clone)).toBe(true) + }) }) }) From 7fead964862c1337a8883ac675bc3ce437bbcca5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 14 Dec 2025 20:56:42 +0000 Subject: [PATCH 4/8] chore: add changeset for deepEquals fix --- .changeset/swift-pens-glow.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/swift-pens-glow.md diff --git a/.changeset/swift-pens-glow.md b/.changeset/swift-pens-glow.md new file mode 100644 index 000000000..65a1655a3 --- /dev/null +++ b/.changeset/swift-pens-glow.md @@ -0,0 +1,5 @@ +--- +"@tanstack/db": patch +--- + +Fix asymmetric behavior in `deepEquals` when comparing different special types (Date, RegExp, Map, Set, TypedArray, Temporal, Array). Previously, comparing values like `deepEquals(Date, Temporal.Duration)` could return a different result than `deepEquals(Temporal.Duration, Date)`. Now both directions correctly return `false` for mismatched types, ensuring `deepEquals` is a proper equivalence relation. From 30d8d70d380d3596dade1a07394c0a04d030c75e Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sun, 14 Dec 2025 20:57:52 +0000 Subject: [PATCH 5/8] ci: apply automated fixes --- .changeset/swift-pens-glow.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/swift-pens-glow.md b/.changeset/swift-pens-glow.md index 65a1655a3..a01f11432 100644 --- a/.changeset/swift-pens-glow.md +++ b/.changeset/swift-pens-glow.md @@ -1,5 +1,5 @@ --- -"@tanstack/db": patch +'@tanstack/db': patch --- Fix asymmetric behavior in `deepEquals` when comparing different special types (Date, RegExp, Map, Set, TypedArray, Temporal, Array). Previously, comparing values like `deepEquals(Date, Temporal.Duration)` could return a different result than `deepEquals(Temporal.Duration, Date)`. Now both directions correctly return `false` for mismatched types, ensuring `deepEquals` is a proper equivalence relation. From 79c059889661a331dc78c3f81a25bb62d7d16a1b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 14 Dec 2025 21:48:03 +0000 Subject: [PATCH 6/8] feat: add property-based tests for hash, cursor, and pg-serializer Add fast-check property-based tests for additional modules: - hash.property.test.ts: Tests determinism, structural equality, property order independence, number normalization, and type distinction - cursor.property.test.ts: Tests empty input handling, single/multi-column cursor generation, operator consistency, and value preservation - pg-serializer.property.test.ts: Tests serialization round-trips for strings, numbers, bigints, booleans, dates, and arrays --- packages/db-ivm/tests/hash.property.test.ts | 313 ++++++++++++++++ packages/db/tests/cursor.property.test.ts | 353 ++++++++++++++++++ .../tests/pg-serializer.property.test.ts | 226 +++++++++++ 3 files changed, 892 insertions(+) create mode 100644 packages/db-ivm/tests/hash.property.test.ts create mode 100644 packages/db/tests/cursor.property.test.ts create mode 100644 packages/electric-db-collection/tests/pg-serializer.property.test.ts diff --git a/packages/db-ivm/tests/hash.property.test.ts b/packages/db-ivm/tests/hash.property.test.ts new file mode 100644 index 000000000..15d81bbb7 --- /dev/null +++ b/packages/db-ivm/tests/hash.property.test.ts @@ -0,0 +1,313 @@ +import { describe, expect } from 'vitest' +import { fc, test as fcTest } from '@fast-check/vitest' +import { hash } from '../src/hashing/hash' + +/** + * Property-based tests for hash function + * + * Key properties: + * 1. Determinism: hash(x) always returns the same value + * 2. Structural equality: equal structures should have the same hash + * 3. Property order independence: objects with same properties in different order have same hash + * 4. Number normalization: -0 and 0 have same hash, NaN has consistent hash + * 5. Type markers: different types should generally produce different hashes + */ + +// Arbitraries for generating test values +const arbitraryPrimitive = fc.oneof( + fc.string(), + fc.integer(), + fc.double({ noNaN: true }), + fc.boolean(), + fc.constant(null), + fc.constant(undefined), +) + +const arbitraryDate = fc.date({ noInvalidDate: true }) + +const arbitraryUint8Array = fc.uint8Array({ minLength: 0, maxLength: 128 }) + +const arbitrarySimpleObject = fc.dictionary(fc.string(), fc.integer(), { + maxKeys: 5, +}) + +const arbitrarySimpleArray = fc.array(fc.integer(), { maxLength: 10 }) + +describe(`hash property-based tests`, () => { + describe(`determinism`, () => { + fcTest.prop([arbitraryPrimitive])( + `hash is deterministic for primitives`, + (value) => { + const first = hash(value) + const second = hash(value) + expect(first).toBe(second) + }, + ) + + fcTest.prop([arbitrarySimpleObject])( + `hash is deterministic for objects`, + (obj) => { + const first = hash(obj) + const second = hash(obj) + expect(first).toBe(second) + }, + ) + + fcTest.prop([arbitrarySimpleArray])( + `hash is deterministic for arrays`, + (arr) => { + const first = hash(arr) + const second = hash(arr) + expect(first).toBe(second) + }, + ) + + fcTest.prop([arbitraryDate])(`hash is deterministic for dates`, (date) => { + const first = hash(date) + const second = hash(date) + expect(first).toBe(second) + }) + + fcTest.prop([arbitraryUint8Array])( + `hash is deterministic for Uint8Arrays`, + (arr) => { + const first = hash(arr) + const second = hash(arr) + expect(first).toBe(second) + }, + ) + }) + + describe(`structural equality`, () => { + fcTest.prop([arbitrarySimpleObject])( + `cloned objects have same hash`, + (obj) => { + const clone = { ...obj } + expect(hash(clone)).toBe(hash(obj)) + }, + ) + + fcTest.prop([arbitrarySimpleArray])(`cloned arrays have same hash`, (arr) => { + const clone = [...arr] + expect(hash(clone)).toBe(hash(arr)) + }) + + fcTest.prop([arbitraryDate])(`dates with same time have same hash`, (date) => { + const clone = new Date(date.getTime()) + expect(hash(clone)).toBe(hash(date)) + }) + + fcTest.prop([arbitraryUint8Array])( + `Uint8Arrays with same content have same hash`, + (arr) => { + const clone = new Uint8Array(arr) + expect(hash(clone)).toBe(hash(arr)) + }, + ) + + fcTest.prop([ + fc.array(fc.tuple(fc.string(), fc.integer()), { maxLength: 5 }), + ])(`Maps with same entries have same hash`, (entries) => { + const map1 = new Map(entries) + const map2 = new Map(entries) + expect(hash(map1)).toBe(hash(map2)) + }) + + fcTest.prop([fc.array(fc.integer(), { maxLength: 10 })])( + `Sets with same values have same hash`, + (arr) => { + const set1 = new Set(arr) + const set2 = new Set(arr) + expect(hash(set1)).toBe(hash(set2)) + }, + ) + }) + + describe(`property order independence`, () => { + fcTest.prop([ + fc.tuple( + fc.string().filter((s) => s !== ``), + fc.integer(), + fc.string().filter((s) => s !== ``), + fc.integer(), + ), + ])( + `objects with same properties in different order have same hash`, + ([key1, val1, key2, val2]) => { + // Skip if keys are the same + if (key1 === key2) return + + const obj1 = { [key1]: val1, [key2]: val2 } + const obj2 = { [key2]: val2, [key1]: val1 } + expect(hash(obj1)).toBe(hash(obj2)) + }, + ) + + fcTest.prop([fc.dictionary(fc.string(), fc.integer(), { minKeys: 2, maxKeys: 5 })])( + `object hash is independent of property insertion order`, + (obj) => { + const keys = Object.keys(obj) + const reversedKeys = [...keys].reverse() + + // Create new object with reversed key order + const reversed: Record = {} + for (const key of reversedKeys) { + reversed[key] = obj[key]! + } + + expect(hash(reversed)).toBe(hash(obj)) + }, + ) + }) + + describe(`number normalization`, () => { + fcTest.prop([fc.constant(0)])(`0 and -0 have the same hash`, () => { + expect(hash(0)).toBe(hash(-0)) + }) + + fcTest.prop([fc.constant(NaN)])(`NaN has consistent hash`, () => { + const first = hash(NaN) + const second = hash(NaN) + expect(first).toBe(second) + }) + + fcTest.prop([fc.integer()])(`integers hash consistently`, (n) => { + expect(hash(n)).toBe(hash(n)) + }) + + fcTest.prop([fc.double({ noNaN: true, noDefaultInfinity: true })])( + `doubles hash consistently`, + (n) => { + expect(hash(n)).toBe(hash(n)) + }, + ) + }) + + describe(`type distinction`, () => { + fcTest.prop([fc.array(fc.integer(), { minLength: 0, maxLength: 5 })])( + `array and object with same indices have different hashes`, + (arr) => { + // Create object with same numeric keys + const obj: Record = {} + arr.forEach((val, idx) => { + obj[String(idx)] = val + }) + + // Arrays and objects should generally have different hashes due to markers + // but for empty ones, both might hash the same + if (arr.length > 0) { + expect(hash(arr)).not.toBe(hash(obj)) + } + }, + ) + + fcTest.prop([fc.integer()])( + `number and string representation have different hashes`, + (n) => { + expect(hash(n)).not.toBe(hash(String(n))) + }, + ) + + fcTest.prop([fc.boolean()])( + `boolean and its string representation have different hashes`, + (b) => { + expect(hash(b)).not.toBe(hash(String(b))) + }, + ) + + fcTest.prop([fc.date({ noInvalidDate: true })])( + `date and its timestamp have different hashes`, + (date) => { + expect(hash(date)).not.toBe(hash(date.getTime())) + }, + ) + + fcTest.prop([fc.array(fc.integer(), { minLength: 1, maxLength: 5 })])( + `array and Set with same values have different hashes`, + (arr) => { + const set = new Set(arr) + expect(hash(arr)).not.toBe(hash(set)) + }, + ) + }) + + describe(`nested structures`, () => { + fcTest.prop([ + fc.array(fc.array(fc.integer(), { maxLength: 3 }), { maxLength: 3 }), + ])(`nested arrays hash consistently`, (nested) => { + const clone = nested.map((inner) => [...inner]) + expect(hash(clone)).toBe(hash(nested)) + }) + + fcTest.prop([ + fc.dictionary( + fc.string(), + fc.dictionary(fc.string(), fc.integer(), { maxKeys: 3 }), + { maxKeys: 3 }, + ), + ])(`nested objects hash consistently`, (nested) => { + const clone = Object.fromEntries( + Object.entries(nested).map(([k, v]) => [k, { ...v }]), + ) + expect(hash(clone)).toBe(hash(nested)) + }) + }) + + describe(`hash produces numbers`, () => { + fcTest.prop([arbitraryPrimitive])(`hash returns a number for primitives`, (value) => { + expect(typeof hash(value)).toBe(`number`) + expect(Number.isFinite(hash(value))).toBe(true) + }) + + fcTest.prop([arbitrarySimpleObject])(`hash returns a number for objects`, (obj) => { + expect(typeof hash(obj)).toBe(`number`) + expect(Number.isFinite(hash(obj))).toBe(true) + }) + + fcTest.prop([arbitrarySimpleArray])(`hash returns a number for arrays`, (arr) => { + expect(typeof hash(arr)).toBe(`number`) + expect(Number.isFinite(hash(arr))).toBe(true) + }) + }) + + describe(`inequality detection`, () => { + fcTest.prop([fc.integer(), fc.integer()])( + `different integers have different hashes (most of the time)`, + (a, b) => { + // Hash collisions are possible, so we only check that equal values have equal hashes + if (a === b) { + expect(hash(a)).toBe(hash(b)) + } + // We don't assert different values have different hashes due to collisions + }, + ) + + fcTest.prop([fc.string(), fc.string()])( + `equal strings have equal hashes`, + (a, b) => { + if (a === b) { + expect(hash(a)).toBe(hash(b)) + } + }, + ) + + fcTest.prop([ + fc.array(fc.integer(), { minLength: 1, maxLength: 10 }), + fc.integer(), + ])(`arrays with extra element have different hashes`, (arr, extra) => { + const extended = [...arr, extra] + expect(hash(arr)).not.toBe(hash(extended)) + }) + + fcTest.prop([ + fc.dictionary(fc.string(), fc.integer(), { minKeys: 1, maxKeys: 5 }), + fc.string(), + fc.integer(), + ])(`objects with extra property have different hashes`, (obj, newKey, newValue) => { + if (!(newKey in obj)) { + const extended = { ...obj, [newKey]: newValue } + expect(hash(obj)).not.toBe(hash(extended)) + } + }) + }) +}) diff --git a/packages/db/tests/cursor.property.test.ts b/packages/db/tests/cursor.property.test.ts new file mode 100644 index 000000000..af59a0d42 --- /dev/null +++ b/packages/db/tests/cursor.property.test.ts @@ -0,0 +1,353 @@ +import { describe, expect, it } from 'vitest' +import { fc, test as fcTest } from '@fast-check/vitest' +import { buildCursor } from '../src/utils/cursor' +import { Func, PropRef, Value } from '../src/query/ir' +import type { OrderBy, OrderByClause } from '../src/query/ir' +import type { CompareOptions } from '../src/query/builder/types' + +/** + * Property-based tests for cursor building + * + * Key properties: + * 1. Empty inputs return undefined + * 2. Single column produces simple gt/lt based on direction + * 3. Direction affects operator choice (asc = gt, desc = lt) + * 4. Determinism - same inputs always produce same output + * 5. Result structure is always valid + */ + +// Arbitraries for generating test data +const arbitraryDirection = fc.constantFrom(`asc`, `desc`) + +const arbitraryNulls = fc.constantFrom(`first`, `last`) + +const arbitraryStringSort = fc.constantFrom( + `locale`, + `lexical`, +) + +const arbitraryCompareOptions = fc.record({ + direction: arbitraryDirection, + nulls: arbitraryNulls, + stringSort: arbitraryStringSort, +}) as fc.Arbitrary + +const arbitraryPropRef = fc + .array(fc.string({ minLength: 1, maxLength: 10 }), { + minLength: 1, + maxLength: 3, + }) + .map((path) => new PropRef(path)) + +const arbitraryOrderByClause = fc + .tuple(arbitraryPropRef, arbitraryCompareOptions) + .map( + ([expr, compareOptions]): OrderByClause => ({ + expression: expr, + compareOptions, + }), + ) + +const arbitraryOrderBy = ( + minLength: number, + maxLength: number, +): fc.Arbitrary => + fc.array(arbitraryOrderByClause, { minLength, maxLength }) + +const arbitraryValue = fc.oneof( + fc.string(), + fc.integer(), + fc.double({ noNaN: true }), + fc.boolean(), + fc.constant(null), +) + +const arbitraryValues = ( + minLength: number, + maxLength: number, +): fc.Arbitrary> => + fc.array(arbitraryValue, { minLength, maxLength }) + +// Helper to check if result is a Func +function isFunc(expr: unknown): expr is Func { + return expr instanceof Func +} + +// Helper to get operator name from Func +function getFuncName(expr: Func): string { + return expr.name +} + +// Helper to recursively count operators in an expression +function countOperators(expr: unknown, name: string): number { + if (!isFunc(expr)) return 0 + const selfCount = expr.name === name ? 1 : 0 + return ( + selfCount + + expr.args.reduce((sum, arg) => sum + countOperators(arg, name), 0) + ) +} + +describe(`buildCursor property-based tests`, () => { + describe(`empty input handling`, () => { + fcTest.prop([arbitraryOrderBy(0, 5)])( + `returns undefined for empty values array`, + (orderBy) => { + const result = buildCursor(orderBy, []) + expect(result).toBeUndefined() + }, + ) + + fcTest.prop([arbitraryValues(0, 5)])( + `returns undefined for empty orderBy array`, + (values) => { + const result = buildCursor([], values) + expect(result).toBeUndefined() + }, + ) + + it(`returns undefined for both empty`, () => { + expect(buildCursor([], [])).toBeUndefined() + }) + }) + + describe(`single column cursor`, () => { + fcTest.prop([arbitraryOrderByClause, arbitraryValue])( + `produces a simple comparison for single column`, + (clause, value) => { + const result = buildCursor([clause], [value]) + + expect(result).toBeDefined() + expect(isFunc(result!)).toBe(true) + + // Should be either 'gt' or 'lt' based on direction + const func = result as Func + expect([`gt`, `lt`]).toContain(getFuncName(func)) + }, + ) + + fcTest.prop([arbitraryPropRef, arbitraryNulls, arbitraryStringSort, arbitraryValue])( + `ascending direction produces gt operator`, + (expr, nulls, stringSort, value) => { + const clause: OrderByClause = { + expression: expr, + compareOptions: { direction: `asc`, nulls, stringSort }, + } + const result = buildCursor([clause], [value]) + + expect(result).toBeDefined() + expect(getFuncName(result as Func)).toBe(`gt`) + }, + ) + + fcTest.prop([arbitraryPropRef, arbitraryNulls, arbitraryStringSort, arbitraryValue])( + `descending direction produces lt operator`, + (expr, nulls, stringSort, value) => { + const clause: OrderByClause = { + expression: expr, + compareOptions: { direction: `desc`, nulls, stringSort }, + } + const result = buildCursor([clause], [value]) + + expect(result).toBeDefined() + expect(getFuncName(result as Func)).toBe(`lt`) + }, + ) + }) + + describe(`multi-column cursor structure`, () => { + fcTest.prop([arbitraryOrderBy(2, 4), arbitraryValues(2, 4)])( + `multi-column produces or at top level when matching lengths`, + (orderBy, values) => { + // Ensure we have matching lengths for a valid multi-column cursor + const minLen = Math.min(orderBy.length, values.length) + if (minLen < 2) return // Skip if not enough for multi-column + + const trimmedOrderBy = orderBy.slice(0, minLen) + const trimmedValues = values.slice(0, minLen) + + const result = buildCursor(trimmedOrderBy, trimmedValues) + + expect(result).toBeDefined() + expect(isFunc(result!)).toBe(true) + + // For 2+ columns, top level should be 'or' + const func = result as Func + expect(getFuncName(func)).toBe(`or`) + }, + ) + + fcTest.prop([ + fc.tuple(arbitraryOrderByClause, arbitraryOrderByClause), + fc.tuple(arbitraryValue, arbitraryValue), + ])( + `two columns produces correct structure`, + ([clause1, clause2], [val1, val2]) => { + const result = buildCursor([clause1, clause2], [val1, val2]) + + expect(result).toBeDefined() + const func = result as Func + + // Top level should be 'or' + expect(getFuncName(func)).toBe(`or`) + + // Should have structure: or(comparison1, and(eq, comparison2)) + expect(func.args).toHaveLength(2) + + // First arg should be direct gt/lt + expect(isFunc(func.args[0])).toBe(true) + expect([`gt`, `lt`]).toContain(getFuncName(func.args[0] as Func)) + + // Second arg should be 'and' combining eq and comparison + expect(isFunc(func.args[1])).toBe(true) + expect(getFuncName(func.args[1] as Func)).toBe(`and`) + }, + ) + }) + + describe(`determinism`, () => { + fcTest.prop([arbitraryOrderBy(1, 3), arbitraryValues(1, 3)])( + `buildCursor is deterministic`, + (orderBy, values) => { + const result1 = buildCursor(orderBy, values) + const result2 = buildCursor(orderBy, values) + + // Both should be defined or both undefined + expect(result1 === undefined).toBe(result2 === undefined) + + if (result1 !== undefined && result2 !== undefined) { + // Compare structure by JSON representation + expect(JSON.stringify(result1)).toBe(JSON.stringify(result2)) + } + }, + ) + }) + + describe(`value preservation`, () => { + fcTest.prop([arbitraryOrderByClause, arbitraryValue])( + `cursor contains the provided value`, + (clause, value) => { + const result = buildCursor([clause], [value]) + + expect(result).toBeDefined() + const func = result as Func + + // Second argument should be a Value containing our value + expect(func.args[1]).toBeInstanceOf(Value) + expect((func.args[1] as Value).value).toBe(value) + }, + ) + + fcTest.prop([arbitraryOrderByClause, arbitraryValue])( + `cursor references the correct property`, + (clause, value) => { + const result = buildCursor([clause], [value]) + + expect(result).toBeDefined() + const func = result as Func + + // First argument should be the same PropRef + expect(func.args[0]).toBeInstanceOf(PropRef) + expect((func.args[0] as PropRef).path).toEqual( + (clause.expression as PropRef).path, + ) + }, + ) + }) + + describe(`length mismatch handling`, () => { + fcTest.prop([arbitraryOrderBy(3, 5), arbitraryValues(1, 2)])( + `handles more orderBy columns than values gracefully`, + (orderBy, values) => { + // Should use the minimum of the two lengths + const result = buildCursor(orderBy, values) + + if (values.length === 0) { + expect(result).toBeUndefined() + } else { + expect(result).toBeDefined() + expect(isFunc(result!)).toBe(true) + } + }, + ) + + fcTest.prop([arbitraryOrderBy(1, 2), arbitraryValues(3, 5)])( + `handles more values than orderBy columns gracefully`, + (orderBy, values) => { + // Should use the minimum of the two lengths + const result = buildCursor(orderBy, values) + + if (orderBy.length === 0) { + expect(result).toBeUndefined() + } else { + expect(result).toBeDefined() + expect(isFunc(result!)).toBe(true) + } + }, + ) + }) + + describe(`operator consistency`, () => { + fcTest.prop([ + fc.array( + fc.tuple(arbitraryPropRef, arbitraryNulls, arbitraryStringSort), + { minLength: 2, maxLength: 4 }, + ), + arbitraryValues(2, 4), + ])( + `all ascending columns use gt operators`, + (clauseParts, values) => { + const orderBy: OrderBy = clauseParts.map(([expr, nulls, stringSort]) => ({ + expression: expr, + compareOptions: { direction: `asc` as const, nulls, stringSort }, + })) + + const minLen = Math.min(orderBy.length, values.length) + const result = buildCursor( + orderBy.slice(0, minLen), + values.slice(0, minLen), + ) + + if (result) { + // Count gt operators - should equal number of columns + const gtCount = countOperators(result, `gt`) + expect(gtCount).toBe(minLen) + // Should have no lt operators + const ltCount = countOperators(result, `lt`) + expect(ltCount).toBe(0) + } + }, + ) + + fcTest.prop([ + fc.array( + fc.tuple(arbitraryPropRef, arbitraryNulls, arbitraryStringSort), + { minLength: 2, maxLength: 4 }, + ), + arbitraryValues(2, 4), + ])( + `all descending columns use lt operators`, + (clauseParts, values) => { + const orderBy: OrderBy = clauseParts.map(([expr, nulls, stringSort]) => ({ + expression: expr, + compareOptions: { direction: `desc` as const, nulls, stringSort }, + })) + + const minLen = Math.min(orderBy.length, values.length) + const result = buildCursor( + orderBy.slice(0, minLen), + values.slice(0, minLen), + ) + + if (result) { + // Count lt operators - should equal number of columns + const ltCount = countOperators(result, `lt`) + expect(ltCount).toBe(minLen) + // Should have no gt operators + const gtCount = countOperators(result, `gt`) + expect(gtCount).toBe(0) + } + }, + ) + }) +}) diff --git a/packages/electric-db-collection/tests/pg-serializer.property.test.ts b/packages/electric-db-collection/tests/pg-serializer.property.test.ts new file mode 100644 index 000000000..effde4fde --- /dev/null +++ b/packages/electric-db-collection/tests/pg-serializer.property.test.ts @@ -0,0 +1,226 @@ +import { describe, expect } from 'vitest' +import { fc, test as fcTest } from '@fast-check/vitest' +import { serialize } from '../src/pg-serializer' + +/** + * Property-based tests for pg-serializer + * + * Key properties: + * 1. Strings pass through unchanged + * 2. Numbers produce parseable numeric strings + * 3. Booleans serialize to 'true'/'false' + * 4. null and undefined both serialize to empty string + * 5. Dates produce valid ISO strings + * 6. Array escaping is consistent + */ + +describe(`pg-serializer property-based tests`, () => { + describe(`string serialization`, () => { + fcTest.prop([fc.string()])( + `strings pass through unchanged`, + (str) => { + expect(serialize(str)).toBe(str) + }, + ) + + fcTest.prop([fc.string()])(`strings are idempotent`, (str) => { + // serialize(serialize(str)) should equal serialize(str) for strings + const once = serialize(str) + const twice = serialize(once) + expect(twice).toBe(once) + }) + }) + + describe(`number serialization`, () => { + fcTest.prop([fc.integer()])( + `integers round-trip through parseFloat`, + (n) => { + const serialized = serialize(n) + expect(parseFloat(serialized)).toBe(n) + }, + ) + + fcTest.prop([fc.double({ noNaN: true, noDefaultInfinity: true })])( + `finite doubles round-trip through parseFloat`, + (n) => { + const serialized = serialize(n) + expect(parseFloat(serialized)).toBe(n) + }, + ) + + fcTest.prop([fc.integer()])(`integers produce numeric strings`, (n) => { + const serialized = serialize(n) + expect(serialized).toMatch(/^-?\d+$/) + }) + }) + + describe(`bigint serialization`, () => { + fcTest.prop([fc.bigInt()])( + `bigints round-trip through BigInt parsing`, + (n) => { + const serialized = serialize(n) + expect(BigInt(serialized)).toBe(n) + }, + ) + + fcTest.prop([fc.bigInt()])(`bigints produce integer strings`, (n) => { + const serialized = serialize(n) + expect(serialized).toMatch(/^-?\d+$/) + }) + }) + + describe(`boolean serialization`, () => { + fcTest.prop([fc.boolean()])(`booleans serialize to 'true' or 'false'`, (b) => { + const serialized = serialize(b) + expect(serialized).toBe(b ? `true` : `false`) + }) + + fcTest.prop([fc.boolean()])( + `booleans round-trip through string comparison`, + (b) => { + const serialized = serialize(b) + expect(serialized === `true`).toBe(b) + }, + ) + }) + + describe(`null/undefined serialization`, () => { + fcTest.prop([fc.constantFrom(null, undefined)])( + `null and undefined both serialize to empty string`, + (val) => { + expect(serialize(val)).toBe(``) + }, + ) + }) + + describe(`date serialization`, () => { + // Use bounded dates to avoid negative years which have different ISO format + const arbitraryBoundedDate = fc.date({ + noInvalidDate: true, + min: new Date(`0000-01-01T00:00:00.000Z`), + max: new Date(`9999-12-31T23:59:59.999Z`), + }) + + fcTest.prop([arbitraryBoundedDate])( + `dates produce valid ISO strings`, + (date) => { + const serialized = serialize(date) + expect(serialized).toMatch( + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/, + ) + }, + ) + + fcTest.prop([arbitraryBoundedDate])( + `dates round-trip through Date parsing`, + (date) => { + const serialized = serialize(date) + const parsed = new Date(serialized) + expect(parsed.getTime()).toBe(date.getTime()) + }, + ) + }) + + describe(`array serialization`, () => { + fcTest.prop([fc.array(fc.integer(), { maxLength: 10 })])( + `integer arrays produce Postgres array format`, + (arr) => { + const serialized = serialize(arr) + expect(serialized).toMatch(/^\{.*\}$/) + }, + ) + + fcTest.prop([fc.array(fc.integer(), { maxLength: 10 })])( + `integer arrays can be parsed back`, + (arr) => { + const serialized = serialize(arr) + // Remove braces and parse + const inner = serialized.slice(1, -1) + if (inner === ``) { + expect(arr).toEqual([]) + } else { + const parsed = inner.split(`,`).map(Number) + expect(parsed).toEqual(arr) + } + }, + ) + + fcTest.prop([fc.array(fc.boolean(), { maxLength: 10 })])( + `boolean arrays serialize correctly`, + (arr) => { + const serialized = serialize(arr) + expect(serialized).toMatch(/^\{.*\}$/) + // Each element should be 'true' or 'false' + const inner = serialized.slice(1, -1) + if (inner !== ``) { + const elements = inner.split(`,`) + elements.forEach((el) => { + expect([`true`, `false`]).toContain(el) + }) + } + }, + ) + + fcTest.prop([ + fc.array(fc.constantFrom(null, undefined), { maxLength: 5 }), + ])(`arrays with null/undefined serialize to NULL`, (arr) => { + const serialized = serialize(arr) + const inner = serialized.slice(1, -1) + if (inner !== ``) { + const elements = inner.split(`,`) + elements.forEach((el) => { + expect(el).toBe(`NULL`) + }) + } + }) + }) + + describe(`string array escaping`, () => { + fcTest.prop([fc.array(fc.string(), { maxLength: 5 })])( + `string arrays are properly quoted`, + (arr) => { + const serialized = serialize(arr) + expect(serialized).toMatch(/^\{.*\}$/) + // Each string element should be quoted + }, + ) + + fcTest.prop([ + fc.string().filter((s) => s.includes(`"`) || s.includes(`\\`)), + ])(`strings with special chars are escaped`, (str) => { + const serialized = serialize([str]) + // Should contain escaped quotes or backslashes + if (str.includes(`"`)) { + expect(serialized).toContain(`\\"`) + } + if (str.includes(`\\`)) { + expect(serialized).toContain(`\\\\`) + } + }) + }) + + describe(`consistency properties`, () => { + fcTest.prop([ + fc.oneof( + fc.string(), + fc.integer(), + fc.boolean(), + fc.constant(null), + fc.date({ noInvalidDate: true }), + ), + ])(`serialize is deterministic`, (val) => { + const first = serialize(val) + const second = serialize(val) + expect(first).toBe(second) + }) + + fcTest.prop([fc.array(fc.integer(), { maxLength: 10 })])( + `array serialization is deterministic`, + (arr) => { + const first = serialize(arr) + const second = serialize(arr) + expect(first).toBe(second) + }, + ) + }) +}) From 508b3130f69a82da8c0d40ca231f4f66cfe8ff5f Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sun, 14 Dec 2025 21:50:29 +0000 Subject: [PATCH 7/8] ci: apply automated fixes --- packages/db-ivm/tests/hash.property.test.ts | 97 +++++++++------- packages/db/tests/cursor.property.test.ts | 109 +++++++++--------- .../tests/pg-serializer.property.test.ts | 45 ++++---- 3 files changed, 135 insertions(+), 116 deletions(-) diff --git a/packages/db-ivm/tests/hash.property.test.ts b/packages/db-ivm/tests/hash.property.test.ts index 15d81bbb7..ac941c76f 100644 --- a/packages/db-ivm/tests/hash.property.test.ts +++ b/packages/db-ivm/tests/hash.property.test.ts @@ -87,15 +87,21 @@ describe(`hash property-based tests`, () => { }, ) - fcTest.prop([arbitrarySimpleArray])(`cloned arrays have same hash`, (arr) => { - const clone = [...arr] - expect(hash(clone)).toBe(hash(arr)) - }) + fcTest.prop([arbitrarySimpleArray])( + `cloned arrays have same hash`, + (arr) => { + const clone = [...arr] + expect(hash(clone)).toBe(hash(arr)) + }, + ) - fcTest.prop([arbitraryDate])(`dates with same time have same hash`, (date) => { - const clone = new Date(date.getTime()) - expect(hash(clone)).toBe(hash(date)) - }) + fcTest.prop([arbitraryDate])( + `dates with same time have same hash`, + (date) => { + const clone = new Date(date.getTime()) + expect(hash(clone)).toBe(hash(date)) + }, + ) fcTest.prop([arbitraryUint8Array])( `Uint8Arrays with same content have same hash`, @@ -143,21 +149,20 @@ describe(`hash property-based tests`, () => { }, ) - fcTest.prop([fc.dictionary(fc.string(), fc.integer(), { minKeys: 2, maxKeys: 5 })])( - `object hash is independent of property insertion order`, - (obj) => { - const keys = Object.keys(obj) - const reversedKeys = [...keys].reverse() - - // Create new object with reversed key order - const reversed: Record = {} - for (const key of reversedKeys) { - reversed[key] = obj[key]! - } + fcTest.prop([ + fc.dictionary(fc.string(), fc.integer(), { minKeys: 2, maxKeys: 5 }), + ])(`object hash is independent of property insertion order`, (obj) => { + const keys = Object.keys(obj) + const reversedKeys = [...keys].reverse() + + // Create new object with reversed key order + const reversed: Record = {} + for (const key of reversedKeys) { + reversed[key] = obj[key]! + } - expect(hash(reversed)).toBe(hash(obj)) - }, - ) + expect(hash(reversed)).toBe(hash(obj)) + }) }) describe(`number normalization`, () => { @@ -254,20 +259,29 @@ describe(`hash property-based tests`, () => { }) describe(`hash produces numbers`, () => { - fcTest.prop([arbitraryPrimitive])(`hash returns a number for primitives`, (value) => { - expect(typeof hash(value)).toBe(`number`) - expect(Number.isFinite(hash(value))).toBe(true) - }) + fcTest.prop([arbitraryPrimitive])( + `hash returns a number for primitives`, + (value) => { + expect(typeof hash(value)).toBe(`number`) + expect(Number.isFinite(hash(value))).toBe(true) + }, + ) - fcTest.prop([arbitrarySimpleObject])(`hash returns a number for objects`, (obj) => { - expect(typeof hash(obj)).toBe(`number`) - expect(Number.isFinite(hash(obj))).toBe(true) - }) + fcTest.prop([arbitrarySimpleObject])( + `hash returns a number for objects`, + (obj) => { + expect(typeof hash(obj)).toBe(`number`) + expect(Number.isFinite(hash(obj))).toBe(true) + }, + ) - fcTest.prop([arbitrarySimpleArray])(`hash returns a number for arrays`, (arr) => { - expect(typeof hash(arr)).toBe(`number`) - expect(Number.isFinite(hash(arr))).toBe(true) - }) + fcTest.prop([arbitrarySimpleArray])( + `hash returns a number for arrays`, + (arr) => { + expect(typeof hash(arr)).toBe(`number`) + expect(Number.isFinite(hash(arr))).toBe(true) + }, + ) }) describe(`inequality detection`, () => { @@ -303,11 +317,14 @@ describe(`hash property-based tests`, () => { fc.dictionary(fc.string(), fc.integer(), { minKeys: 1, maxKeys: 5 }), fc.string(), fc.integer(), - ])(`objects with extra property have different hashes`, (obj, newKey, newValue) => { - if (!(newKey in obj)) { - const extended = { ...obj, [newKey]: newValue } - expect(hash(obj)).not.toBe(hash(extended)) - } - }) + ])( + `objects with extra property have different hashes`, + (obj, newKey, newValue) => { + if (!(newKey in obj)) { + const extended = { ...obj, [newKey]: newValue } + expect(hash(obj)).not.toBe(hash(extended)) + } + }, + ) }) }) diff --git a/packages/db/tests/cursor.property.test.ts b/packages/db/tests/cursor.property.test.ts index af59a0d42..5967a8756 100644 --- a/packages/db/tests/cursor.property.test.ts +++ b/packages/db/tests/cursor.property.test.ts @@ -21,10 +21,7 @@ const arbitraryDirection = fc.constantFrom(`asc`, `desc`) const arbitraryNulls = fc.constantFrom(`first`, `last`) -const arbitraryStringSort = fc.constantFrom( - `locale`, - `lexical`, -) +const arbitraryStringSort = fc.constantFrom(`locale`, `lexical`) const arbitraryCompareOptions = fc.record({ direction: arbitraryDirection, @@ -126,7 +123,12 @@ describe(`buildCursor property-based tests`, () => { }, ) - fcTest.prop([arbitraryPropRef, arbitraryNulls, arbitraryStringSort, arbitraryValue])( + fcTest.prop([ + arbitraryPropRef, + arbitraryNulls, + arbitraryStringSort, + arbitraryValue, + ])( `ascending direction produces gt operator`, (expr, nulls, stringSort, value) => { const clause: OrderByClause = { @@ -140,7 +142,12 @@ describe(`buildCursor property-based tests`, () => { }, ) - fcTest.prop([arbitraryPropRef, arbitraryNulls, arbitraryStringSort, arbitraryValue])( + fcTest.prop([ + arbitraryPropRef, + arbitraryNulls, + arbitraryStringSort, + arbitraryValue, + ])( `descending direction produces lt operator`, (expr, nulls, stringSort, value) => { const clause: OrderByClause = { @@ -294,30 +301,27 @@ describe(`buildCursor property-based tests`, () => { { minLength: 2, maxLength: 4 }, ), arbitraryValues(2, 4), - ])( - `all ascending columns use gt operators`, - (clauseParts, values) => { - const orderBy: OrderBy = clauseParts.map(([expr, nulls, stringSort]) => ({ - expression: expr, - compareOptions: { direction: `asc` as const, nulls, stringSort }, - })) - - const minLen = Math.min(orderBy.length, values.length) - const result = buildCursor( - orderBy.slice(0, minLen), - values.slice(0, minLen), - ) - - if (result) { - // Count gt operators - should equal number of columns - const gtCount = countOperators(result, `gt`) - expect(gtCount).toBe(minLen) - // Should have no lt operators - const ltCount = countOperators(result, `lt`) - expect(ltCount).toBe(0) - } - }, - ) + ])(`all ascending columns use gt operators`, (clauseParts, values) => { + const orderBy: OrderBy = clauseParts.map(([expr, nulls, stringSort]) => ({ + expression: expr, + compareOptions: { direction: `asc` as const, nulls, stringSort }, + })) + + const minLen = Math.min(orderBy.length, values.length) + const result = buildCursor( + orderBy.slice(0, minLen), + values.slice(0, minLen), + ) + + if (result) { + // Count gt operators - should equal number of columns + const gtCount = countOperators(result, `gt`) + expect(gtCount).toBe(minLen) + // Should have no lt operators + const ltCount = countOperators(result, `lt`) + expect(ltCount).toBe(0) + } + }) fcTest.prop([ fc.array( @@ -325,29 +329,26 @@ describe(`buildCursor property-based tests`, () => { { minLength: 2, maxLength: 4 }, ), arbitraryValues(2, 4), - ])( - `all descending columns use lt operators`, - (clauseParts, values) => { - const orderBy: OrderBy = clauseParts.map(([expr, nulls, stringSort]) => ({ - expression: expr, - compareOptions: { direction: `desc` as const, nulls, stringSort }, - })) - - const minLen = Math.min(orderBy.length, values.length) - const result = buildCursor( - orderBy.slice(0, minLen), - values.slice(0, minLen), - ) - - if (result) { - // Count lt operators - should equal number of columns - const ltCount = countOperators(result, `lt`) - expect(ltCount).toBe(minLen) - // Should have no gt operators - const gtCount = countOperators(result, `gt`) - expect(gtCount).toBe(0) - } - }, - ) + ])(`all descending columns use lt operators`, (clauseParts, values) => { + const orderBy: OrderBy = clauseParts.map(([expr, nulls, stringSort]) => ({ + expression: expr, + compareOptions: { direction: `desc` as const, nulls, stringSort }, + })) + + const minLen = Math.min(orderBy.length, values.length) + const result = buildCursor( + orderBy.slice(0, minLen), + values.slice(0, minLen), + ) + + if (result) { + // Count lt operators - should equal number of columns + const ltCount = countOperators(result, `lt`) + expect(ltCount).toBe(minLen) + // Should have no gt operators + const gtCount = countOperators(result, `gt`) + expect(gtCount).toBe(0) + } + }) }) }) diff --git a/packages/electric-db-collection/tests/pg-serializer.property.test.ts b/packages/electric-db-collection/tests/pg-serializer.property.test.ts index effde4fde..2eabf4576 100644 --- a/packages/electric-db-collection/tests/pg-serializer.property.test.ts +++ b/packages/electric-db-collection/tests/pg-serializer.property.test.ts @@ -16,12 +16,9 @@ import { serialize } from '../src/pg-serializer' describe(`pg-serializer property-based tests`, () => { describe(`string serialization`, () => { - fcTest.prop([fc.string()])( - `strings pass through unchanged`, - (str) => { - expect(serialize(str)).toBe(str) - }, - ) + fcTest.prop([fc.string()])(`strings pass through unchanged`, (str) => { + expect(serialize(str)).toBe(str) + }) fcTest.prop([fc.string()])(`strings are idempotent`, (str) => { // serialize(serialize(str)) should equal serialize(str) for strings @@ -70,10 +67,13 @@ describe(`pg-serializer property-based tests`, () => { }) describe(`boolean serialization`, () => { - fcTest.prop([fc.boolean()])(`booleans serialize to 'true' or 'false'`, (b) => { - const serialized = serialize(b) - expect(serialized).toBe(b ? `true` : `false`) - }) + fcTest.prop([fc.boolean()])( + `booleans serialize to 'true' or 'false'`, + (b) => { + const serialized = serialize(b) + expect(serialized).toBe(b ? `true` : `false`) + }, + ) fcTest.prop([fc.boolean()])( `booleans round-trip through string comparison`, @@ -161,18 +161,19 @@ describe(`pg-serializer property-based tests`, () => { }, ) - fcTest.prop([ - fc.array(fc.constantFrom(null, undefined), { maxLength: 5 }), - ])(`arrays with null/undefined serialize to NULL`, (arr) => { - const serialized = serialize(arr) - const inner = serialized.slice(1, -1) - if (inner !== ``) { - const elements = inner.split(`,`) - elements.forEach((el) => { - expect(el).toBe(`NULL`) - }) - } - }) + fcTest.prop([fc.array(fc.constantFrom(null, undefined), { maxLength: 5 })])( + `arrays with null/undefined serialize to NULL`, + (arr) => { + const serialized = serialize(arr) + const inner = serialized.slice(1, -1) + if (inner !== ``) { + const elements = inner.split(`,`) + elements.forEach((el) => { + expect(el).toBe(`NULL`) + }) + } + }, + ) }) describe(`string array escaping`, () => { From 515a6c819293fcf9c4a5dfdb47326274b47e9a6a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 16 Dec 2025 14:12:38 +0000 Subject: [PATCH 8/8] fix: add type casts to cursor property tests Fix TypeScript type errors in cursor.property.test.ts by adding explicit type casts for nulls and stringSort parameters at usage sites. --- packages/db/tests/cursor.property.test.ts | 24 +++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/packages/db/tests/cursor.property.test.ts b/packages/db/tests/cursor.property.test.ts index 5967a8756..04c2fa536 100644 --- a/packages/db/tests/cursor.property.test.ts +++ b/packages/db/tests/cursor.property.test.ts @@ -133,7 +133,11 @@ describe(`buildCursor property-based tests`, () => { (expr, nulls, stringSort, value) => { const clause: OrderByClause = { expression: expr, - compareOptions: { direction: `asc`, nulls, stringSort }, + compareOptions: { + direction: `asc`, + nulls: nulls as `first` | `last`, + stringSort: stringSort as `locale` | `lexical`, + }, } const result = buildCursor([clause], [value]) @@ -152,7 +156,11 @@ describe(`buildCursor property-based tests`, () => { (expr, nulls, stringSort, value) => { const clause: OrderByClause = { expression: expr, - compareOptions: { direction: `desc`, nulls, stringSort }, + compareOptions: { + direction: `desc`, + nulls: nulls as `first` | `last`, + stringSort: stringSort as `locale` | `lexical`, + }, } const result = buildCursor([clause], [value]) @@ -304,7 +312,11 @@ describe(`buildCursor property-based tests`, () => { ])(`all ascending columns use gt operators`, (clauseParts, values) => { const orderBy: OrderBy = clauseParts.map(([expr, nulls, stringSort]) => ({ expression: expr, - compareOptions: { direction: `asc` as const, nulls, stringSort }, + compareOptions: { + direction: `asc` as const, + nulls: nulls as `first` | `last`, + stringSort: stringSort as `locale` | `lexical`, + }, })) const minLen = Math.min(orderBy.length, values.length) @@ -332,7 +344,11 @@ describe(`buildCursor property-based tests`, () => { ])(`all descending columns use lt operators`, (clauseParts, values) => { const orderBy: OrderBy = clauseParts.map(([expr, nulls, stringSort]) => ({ expression: expr, - compareOptions: { direction: `desc` as const, nulls, stringSort }, + compareOptions: { + direction: `desc` as const, + nulls: nulls as `first` | `last`, + stringSort: stringSort as `locale` | `lexical`, + }, })) const minLen = Math.min(orderBy.length, values.length)