From a6445a7c2e63968f14362622aff3a6c545b4fe7e Mon Sep 17 00:00:00 2001 From: Isaac Date: Thu, 1 Oct 2026 08:08:15 -0600 Subject: [PATCH 01/28] test(db): cover draft reverts, clone key classes, and deepEquals order rules Mutants on unchanged code showed that eight plausible draft-proxy and deepEquals refactor mistakes passed the whole db suite, including a partial revert that drops the remaining change and a symbol-only nested change treated as no change. - proxy-revert-oracle: generated write, revert, delete, nested, and for...of histories checked against an independent draft-equality model. - proxy-detachment-contract: pin which key classes a draft copies. - utils.property: pin that deepEquals ignores Map and Set order, RegExp lastIndex, and array holes. All land on unchanged production code before the proxy refactor. Co-authored-by: Isaac --- .../tests/proxy-detachment-contract.test.ts | 43 ++ .../proxy-revert-oracle.property.test.ts | 657 ++++++++++++++++++ packages/db/tests/utils.property.test.ts | 75 ++ 3 files changed, 775 insertions(+) create mode 100644 packages/db/tests/proxy-revert-oracle.property.test.ts diff --git a/packages/db/tests/proxy-detachment-contract.test.ts b/packages/db/tests/proxy-detachment-contract.test.ts index eb27dc951..cece3ee63 100644 --- a/packages/db/tests/proxy-detachment-contract.test.ts +++ b/packages/db/tests/proxy-detachment-contract.test.ts @@ -760,4 +760,47 @@ describe(`Mutation result detachment`, () => { expect(saved.child).toBe(saved.alias) expect(child.name).toBe(`before`) }) + + // Current behavior, not a promise: a draft copies a row's own enumerable + // string keys and every own symbol key, enumerable or not. Non-enumerable + // string keys are not copied. The change record keeps only the written key. + it.each([`unchanged`, `nested`] as const)( + `copies enumerable string keys and every symbol key into a %s draft`, + (shape) => { + const tag = Symbol(`tag`) + const hidden = Symbol(`hidden`) + const make = () => { + const value: Record = { id: 1, title: `a` } + Object.defineProperty(value, `secret`, { + value: `s`, + enumerable: false, + writable: true, + configurable: true, + }) + value[tag] = `t` + Object.defineProperty(value, hidden, { + value: `h`, + enumerable: false, + writable: true, + configurable: true, + }) + return value + } + const row = shape === `nested` ? { child: make() } : make() + const { proxy, getChanges } = createChangeProxy(row) + const draft = ( + shape === `nested` ? (proxy as { child: object }).child : proxy + ) as Record + expect(Reflect.ownKeys(draft)).toEqual([`id`, `title`, tag, hidden]) + expect(draft.secret).toBeUndefined() + expect(draft[tag]).toBe(`t`) + expect(draft[hidden]).toBe(`h`) + draft.title = `b` + expect(Reflect.ownKeys(draft)).toEqual([`id`, `title`, tag, hidden]) + const changes = getChanges() as Record + expect(Object.keys(changes)).toEqual([ + shape === `nested` ? `child` : `title`, + ]) + }, + ) }) diff --git a/packages/db/tests/proxy-revert-oracle.property.test.ts b/packages/db/tests/proxy-revert-oracle.property.test.ts new file mode 100644 index 000000000..02dbe9a2d --- /dev/null +++ b/packages/db/tests/proxy-revert-oracle.property.test.ts @@ -0,0 +1,657 @@ +import { describe, expect, it } from 'vitest' +import { fc } from '@fast-check/vitest' +import { createChangeProxy } from '../src/proxy' + +/** + * # Which draft writes count as changes, and which count as reverts? + * + * A draft records the changes a callback makes to a row. A write that makes a + * value structurally equal to its original again is a revert, and a fully + * reverted draft reports no change. `getChanges()` returns: + * + * - `{}` when every value equals its original; + * - otherwise each own enumerable string key whose final value differs from + * its original, with that final value, and each deleted key as `undefined`. + * + * "Equal" here is draft equality, which is stricter than `deepEquals`: + * + * 1. Primitives compare by value. `-0` equals `0`, and `NaN` equals `NaN`. + * 2. Dates compare by timestamp. Invalid dates are equal. + * 3. Regular expressions compare by source, flags, and `lastIndex`. + * 4. Maps and Sets compare by entries or values in insertion order. + * 5. Arrays compare by length and present indexes, so a hole differs from + * `undefined`. + * 6. Plain objects compare by enumerable own string and symbol keys, in any + * order. + * + * Laws checked after every generated history: + * + * - `getChanges()` equals the model's changes. + * - Reading the draft gives the model's final value. + * - The original row is unchanged. + * + * Authority: the `createChangeProxy` and `getChanges` implementation comments + * in `src/proxy.ts` and the revert examples in `tests/proxy.test.ts`, as of + * `18abceee`. + * + * Limits: + * - Writes are assignments, deletes, nested property writes, and nested writes + * through `for...of` on an array. Map, Set, and array mutator methods + * (`set`, `add`, `push`) mark a value changed without a revert check; the + * native-operation tests in `proxy.test.ts` own them. + * - Top-level symbol keys are not written. `getChanges()` does not report + * them; the coverage map lists symbol writes as unsupported. Symbol keys + * inside nested objects are written. + * - Keys are non-index strings, so property order follows insertion order. + */ + +// --------------------------------------------------------------------------- +// Value specs. The model works on plain-data specs and never reads a draft. + +const S = Symbol(`s`) +const PRIMITIVES = [0, -0, 1, NaN, `a`, `b`, null, undefined] as const +type Primitive = (typeof PRIMITIVES)[number] + +type Spec = + | { k: `prim`; v: Primitive } + | { k: `date`; t: number } + | { k: `regex`; flags: string; lastIndex: number } + | { k: `map`; entries: Array<[string, Primitive]> } + | { k: `set`; values: Array } + | { k: `array`; items: Array } + | { k: `obj`; a?: Primitive; b?: Primitive; sym?: Primitive } + | { k: `rows`; rows: Array<{ a: Primitive }> } + +const HOLE = Symbol(`hole`) +const FIELDS = [`f`, `g`, `h`] as const +type Field = (typeof FIELDS)[number] +type Root = Partial> + +// Draft equality as an encoding. Equal encodings mean equal values. +function encode(spec: Spec | undefined): string { + return JSON.stringify(canon(spec)) +} +function prim(v: Primitive | undefined): unknown { + return typeof v === `number` ? [`num`, String(v)] : [typeof v, v ?? null] +} +function canon(spec: Spec | undefined): unknown { + if (spec === undefined) return [`absent`] + switch (spec.k) { + case `prim`: + return prim(spec.v) + case `date`: + return [`date`, String(spec.t)] + case `regex`: + return [`regex`, spec.flags, spec.lastIndex] + case `map`: + return [`map`, spec.entries.map(([key, v]) => [key, prim(v)])] + case `set`: + return [`set`, spec.values] + case `array`: + return [ + `array`, + spec.items.length, + spec.items.flatMap((v, i) => (v === HOLE ? [] : [[i, prim(v)]])), + ] + case `obj`: + return [ + `obj`, + `a` in spec ? prim(spec.a) : `-`, + `b` in spec ? prim(spec.b) : `-`, + `sym` in spec ? prim(spec.sym) : `-`, + ] + case `rows`: + return [`rows`, spec.rows.map((row) => prim(row.a))] + } +} + +function realize(spec: Spec): unknown { + switch (spec.k) { + case `prim`: + return spec.v + case `date`: + return new Date(spec.t) + case `regex`: { + const value = new RegExp(`x`, spec.flags) + value.lastIndex = spec.lastIndex + return value + } + case `map`: + return new Map(spec.entries) + case `set`: + return new Set(spec.values) + case `array`: { + const value: Array = [] + value.length = spec.items.length + spec.items.forEach((v, i) => { + if (v !== HOLE) value[i] = v + }) + return value + } + case `obj`: { + const value: Record = {} + if (`a` in spec) value.a = spec.a + if (`b` in spec) value.b = spec.b + if (`sym` in spec) value[S] = spec.sym + return value + } + case `rows`: + return spec.rows.map((row) => ({ a: row.a })) + } +} + +function realizeRoot(root: Root): Record { + const value: Record = {} + for (const field of FIELDS) { + const spec = root[field] + if (spec) value[field] = realize(spec) + } + return value +} + +// --------------------------------------------------------------------------- +// Grammar. + +const primArb = fc.constantFrom(...PRIMITIVES) +const specArb: fc.Arbitrary = fc.oneof( + { weight: 2, arbitrary: primArb.map((v) => ({ k: `prim` as const, v })) }, + fc.constantFrom(0, 1, NaN).map((t) => ({ k: `date` as const, t })), + fc.record({ + k: fc.constant(`regex` as const), + flags: fc.constantFrom(``, `g`), + lastIndex: fc.nat(1), + }), + fc + .uniqueArray(fc.tuple(fc.constantFrom(`x`, `y`), primArb), { + maxLength: 2, + selector: ([key]) => key, + }) + .map((entries) => ({ k: `map` as const, entries })), + fc + .uniqueArray(fc.constantFrom(`x`, `y`), { maxLength: 2 }) + .map((values) => ({ k: `set` as const, values })), + fc + .array(fc.oneof(primArb, fc.constant(HOLE)), { maxLength: 3 }) + .map((items) => ({ k: `array` as const, items })), + fc.record( + { k: fc.constant(`obj` as const), a: primArb, b: primArb, sym: primArb }, + { requiredKeys: [`k`] }, + ), + fc + .array(fc.record({ a: primArb }), { minLength: 1, maxLength: 3 }) + .map((rows) => ({ k: `rows` as const, rows })), +) + +type Op = + | { op: `set`; field: Field; value: Spec } + | { op: `revert`; field: Field } + | { op: `delete`; field: Field } + | { op: `nested`; field: Field; key: `a` | `b` | `sym`; value: Primitive } + | { op: `index`; field: Field; index: number; value: Primitive } + | { op: `forOf`; field: Field; index: number; value: Primitive } + +const opArb: fc.Arbitrary = fc.oneof( + fc.record({ + op: fc.constant(`set` as const), + field: fc.constantFrom(...FIELDS), + value: specArb, + }), + { + weight: 3, + arbitrary: fc.record({ + op: fc.constant(`revert` as const), + field: fc.constantFrom(...FIELDS), + }), + }, + fc.record({ + op: fc.constant(`delete` as const), + field: fc.constantFrom(...FIELDS), + }), + { + weight: 2, + arbitrary: fc.record({ + op: fc.constant(`nested` as const), + field: fc.constantFrom(...FIELDS), + key: fc.constantFrom(`a` as const, `b` as const, `sym` as const), + value: primArb, + }), + }, + fc.record({ + op: fc.constant(`index` as const), + field: fc.constantFrom(...FIELDS), + index: fc.nat(2), + value: primArb, + }), + { + weight: 2, + arbitrary: fc.record({ + op: fc.constant(`forOf` as const), + field: fc.constantFrom(...FIELDS), + index: fc.nat(2), + value: primArb, + }), + }, +) + +type History = { original: Root; ops: Array } +const historyArb: fc.Arbitrary = fc.record({ + original: fc.record( + { f: specArb, g: specArb, h: specArb }, + { requiredKeys: [] }, + ), + ops: fc.array(opArb, { minLength: 1, maxLength: 6 }), +}) + +// --------------------------------------------------------------------------- +// Model: apply each op to the spec state. An op whose target has the wrong +// shape does nothing, and the driver skips it the same way. + +function applicable(state: Root, op: Op): boolean { + const current = state[op.field] + switch (op.op) { + case `set`: + return true + case `revert`: + return op.field in state || current !== undefined + case `delete`: + return current !== undefined + case `nested`: + return current?.k === `obj` + case `index`: + return current?.k === `array` && op.index < current.items.length + case `forOf`: + return current?.k === `rows` && op.index < current.rows.length + } +} + +function step(state: Root, original: Root, op: Op): Root { + const next: Root = { ...state } + const current = state[op.field] + switch (op.op) { + case `set`: + next[op.field] = op.value + break + case `revert`: + if (original[op.field] === undefined) delete next[op.field] + else next[op.field] = original[op.field] + break + case `delete`: + delete next[op.field] + break + case `nested`: + next[op.field] = { + ...(current as Extract), + [op.key]: op.value, + } + break + case `index`: { + const items = [...(current as Extract).items] + items[op.index] = op.value + next[op.field] = { k: `array`, items } + break + } + case `forOf`: { + const rows = (current as Extract).rows.map( + (row) => ({ ...row }), + ) + rows[op.index] = { a: op.value } + next[op.field] = { k: `rows`, rows } + break + } + } + return next +} + +function expectedChanges( + original: Root, + final: Root, +): Map { + const changes = new Map() + for (const field of FIELDS) { + if (encode(final[field]) === encode(original[field])) continue + changes.set(field, final[field]) + } + return changes +} + +// --------------------------------------------------------------------------- +// Driver. + +function drive(draft: Record, op: Op): void { + switch (op.op) { + case `set`: + draft[op.field] = realize(op.value) + return + case `revert`: + return + case `delete`: + delete draft[op.field] + return + case `nested`: + if (op.key === `sym`) draft[op.field][S] = op.value + else draft[op.field][op.key] = op.value + return + case `index`: + draft[op.field][op.index] = op.value + return + case `forOf`: { + let index = 0 + for (const row of draft[op.field] as Array<{ a: unknown }>) { + if (index++ === op.index) row.a = op.value + } + return + } + } +} + +function readSpec(value: unknown, like: Spec | undefined): string { + // Encode the draft's value with the same rules as the model. + if (like === undefined) + return value === undefined ? encode(undefined) : `present` + switch (like.k) { + case `prim`: + return JSON.stringify(prim(value as Primitive)) + case `date`: + return value instanceof Date + ? encode({ k: `date`, t: value.getTime() }) + : `not a Date` + case `regex`: + return value instanceof RegExp + ? encode({ k: `regex`, flags: value.flags, lastIndex: value.lastIndex }) + : `not a RegExp` + case `map`: + return value instanceof Map + ? encode({ k: `map`, entries: [...value] }) + : `not a Map` + case `set`: + return value instanceof Set + ? encode({ k: `set`, values: [...value] }) + : `not a Set` + case `array`: { + if (!Array.isArray(value)) return `not an array` + const items: Array = [] + for (let i = 0; i < value.length; i++) + items.push(i in value ? (value[i] as Primitive) : HOLE) + return encode({ k: `array`, items }) + } + case `obj`: { + if (value === null || typeof value !== `object`) return `not an object` + const o = value as Record + const keys = Reflect.ownKeys(o).filter((key) => + Object.prototype.propertyIsEnumerable.call(o, key), + ) + if (keys.some((key) => key !== `a` && key !== `b` && key !== S)) + return `extra keys` + const spec: Spec = { k: `obj` } + if (`a` in o) spec.a = o.a + if (`b` in o) spec.b = o.b + if (S in o) spec.sym = o[S] + return encode(spec) + } + case `rows`: + return Array.isArray(value) + ? encode({ + k: `rows`, + rows: value.map((row: { a: Primitive }) => ({ a: row.a })), + }) + : `not rows` + } +} + +function expectHistory({ original, ops }: History): void { + const row = realizeRoot(original) + const before = realizeRoot(original) + const { proxy, getChanges } = createChangeProxy(row) + let state: Root = { ...original } + for (const op of ops) { + if (!applicable(state, op)) continue + if (op.op === `revert`) { + if (original[op.field] === undefined) + delete (proxy)[op.field] + else + (proxy)[op.field] = realize( + original[op.field]!, + ) + } else drive(proxy as Record, op) + state = step(state, original, op) + } + + const expected = expectedChanges(original, state) + const changes = getChanges() as Record + const context = JSON.stringify({ + original: Object.fromEntries(FIELDS.map((f) => [f, canon(original[f])])), + ops, + }) + expect(Object.keys(changes).sort(), `changed keys, ${context}`).toEqual( + [...expected.keys()].sort(), + ) + for (const [field, spec] of expected) { + if (spec === undefined) + expect(changes[field], `deleted ${field}, ${context}`).toBeUndefined() + else + expect( + readSpec(changes[field], spec), + `change of ${field}, ${context}`, + ).toBe(encode(spec)) + } + for (const field of FIELDS) + expect( + readSpec((proxy)[field], state[field]), + `draft ${field}, ${context}`, + ).toBe(encode(state[field])) + for (const field of FIELDS) + expect( + readSpec(row[field], original[field]), + `original ${field} unchanged, ${context}`, + ).toBe(readSpec(before[field], original[field])) +} + +// --------------------------------------------------------------------------- +// Campaigns. `TANSTACK_DB_PROXY_REVERT_SEED` and +// `TANSTACK_DB_PROXY_REVERT_PATH` select a direct replay. + +const replaySeed = process.env.TANSTACK_DB_PROXY_REVERT_SEED +const replayPath = process.env.TANSTACK_DB_PROXY_REVERT_PATH +const FIXED_SEED = 2026101 +const campaigns = + replaySeed === undefined && replayPath === undefined + ? [ + { name: String(FIXED_SEED), seed: FIXED_SEED as number | undefined }, + { name: `random`, seed: undefined }, + ] + : [ + { + name: `replay`, + seed: replaySeed === undefined ? undefined : Number(replaySeed), + }, + ] + +describe(`draft revert oracle`, () => { + for (const { name, seed } of campaigns) { + it(`matches the model across generated write histories (${name})`, () => { + if (replayPath !== undefined && replaySeed === undefined) + throw new Error(`TANSTACK_DB_PROXY_REVERT_PATH requires a seed`) + if ( + replaySeed !== undefined && + (replaySeed.trim() === `` || + typeof seed !== `number` || + !Number.isSafeInteger(seed)) + ) + throw new Error(`TANSTACK_DB_PROXY_REVERT_SEED must be an integer`) + fc.assert(fc.property(historyArb, expectHistory), { + numRuns: 400, + ...(seed === undefined ? {} : { seed }), + ...(replayPath === undefined ? {} : { path: replayPath }), + }) + }) + } + + // Positive execution witness: the fixed campaign reaches each operation, a + // partial revert (some change survives one revert), a full revert, and a + // change made only under a nested symbol key. + it(`reaches every operation and both revert outcomes in the fixed campaign`, () => { + const sample = fc.sample(historyArb, { seed: FIXED_SEED, numRuns: 400 }) + const reached = new Set() + let partial = 0 + let full = 0 + let symbolOnly = 0 + for (const { original, ops } of sample) { + let state: Root = { ...original } + const applied: Array = [] + for (const op of ops) { + if (!applicable(state, op)) continue + reached.add(op.op) + applied.push(op) + state = step(state, original, op) + } + const changed = expectedChanges(original, state).size + const reverts = applied.filter((op) => op.op === `revert`).length + const touched = new Set(applied.map((op) => op.field)).size + if (reverts > 0 && changed > 0 && touched > changed) partial++ + if (applied.length > 1 && reverts > 0 && changed === 0) full++ + for (const field of FIELDS) { + const a = original[field] + const b = state[field] + if ( + a?.k === `obj` && + b?.k === `obj` && + a.sym !== b.sym && + encode({ ...a, sym: undefined }) === + encode({ ...b, sym: undefined }) && + !Object.is(a.sym, b.sym) && + encode(a) !== encode(b) + ) + symbolOnly++ + } + } + expect([...reached].sort()).toEqual([ + `delete`, + `forOf`, + `index`, + `nested`, + `revert`, + `set`, + ]) + expect(partial).toBeGreaterThan(20) + expect(full).toBeGreaterThan(20) + expect(symbolOnly).toBeGreaterThan(0) + }) + + // Pinned witnesses. + it.each<[string, History]>([ + [ + `reverting one of two changed fields keeps the other change`, + { + original: { f: { k: `prim`, v: `a` }, g: { k: `prim`, v: `a` } }, + ops: [ + { op: `set`, field: `f`, value: { k: `prim`, v: `b` } }, + { op: `set`, field: `g`, value: { k: `prim`, v: `b` } }, + { op: `revert`, field: `f` }, + ], + }, + ], + [ + `a nested write through for...of records the row change`, + { + original: { f: { k: `rows`, rows: [{ a: 0 }, { a: 1 }] } }, + ops: [{ op: `forOf`, field: `f`, index: 1, value: `a` }], + }, + ], + [ + `a nested write through for...of that restores the value is a revert`, + { + original: { f: { k: `rows`, rows: [{ a: 0 }, { a: 1 }] } }, + ops: [ + { op: `forOf`, field: `f`, index: 1, value: `a` }, + { op: `forOf`, field: `f`, index: 1, value: 1 }, + ], + }, + ], + [ + `a change made only under a nested symbol key is recorded`, + { + original: { f: { k: `obj`, a: 1, sym: `a` } }, + ops: [{ op: `nested`, field: `f`, key: `sym`, value: `b` }], + }, + ], + [ + `replacing a nested object with one that differs only under a symbol key is recorded`, + { + original: { f: { k: `obj`, a: 1, sym: `a` } }, + ops: [{ op: `set`, field: `f`, value: { k: `obj`, a: 1, sym: `b` } }], + }, + ], + [ + `Map entry order is a change`, + { + original: { + f: { + k: `map`, + entries: [ + [`x`, 1], + [`y`, 0], + ], + }, + }, + ops: [ + { + op: `set`, + field: `f`, + value: { + k: `map`, + entries: [ + [`y`, 0], + [`x`, 1], + ], + }, + }, + ], + }, + ], + [ + `Set value order is a change`, + { + original: { f: { k: `set`, values: [`x`, `y`] } }, + ops: [ + { op: `set`, field: `f`, value: { k: `set`, values: [`y`, `x`] } }, + ], + }, + ], + [ + `RegExp lastIndex is a change`, + { + original: { f: { k: `regex`, flags: `g`, lastIndex: 0 } }, + ops: [ + { + op: `set`, + field: `f`, + value: { k: `regex`, flags: `g`, lastIndex: 1 }, + }, + ], + }, + ], + [ + `a hole replaced by undefined is a change`, + { + original: { f: { k: `array`, items: [HOLE, 1] } }, + ops: [ + { + op: `set`, + field: `f`, + value: { k: `array`, items: [undefined, 1] }, + }, + ], + }, + ], + [ + `-0 and NaN rewrites are not changes`, + { + original: { f: { k: `prim`, v: 0 }, g: { k: `prim`, v: NaN } }, + ops: [ + { op: `set`, field: `f`, value: { k: `prim`, v: -0 } }, + { op: `set`, field: `g`, value: { k: `prim`, v: NaN } }, + ], + }, + ], + ])(`%s`, (_name, history) => expectHistory(history)) +}) diff --git a/packages/db/tests/utils.property.test.ts b/packages/db/tests/utils.property.test.ts index 723ec21c3..fbf859975 100644 --- a/packages/db/tests/utils.property.test.ts +++ b/packages/db/tests/utils.property.test.ts @@ -939,6 +939,81 @@ describe(`deepEquals property-based tests`, () => { ) }) + // `deepEquals` drives change-event suppression, so it deliberately ignores + // state that draft revert detection keeps (see proxy-revert-oracle): Map and + // Set insertion order, RegExp `lastIndex`, and array holes. These laws pin + // that current relation so a shared walker cannot leak draft rules into it. + describe(`order and state the general relation ignores`, () => { + utilsProperty([ + fc.uniqueArray(fc.tuple(fc.string(), fc.integer()), { + minLength: 2, + maxLength: 5, + selector: ([key]) => key, + }), + ])( + `Maps with the same entries in another insertion order are equal`, + (entries) => { + const reordered = new Map([...entries].reverse()) + expect([...reordered.keys()]).not.toEqual(entries.map(([key]) => key)) + expectEqualityPair(new Map(entries), reordered, true) + expectEqualityPair( + new Map(entries.map(([key, value]) => [key, { value }])), + new Map( + [...entries].reverse().map(([key, value]) => [key, { value }]), + ), + true, + ) + }, + ) + + utilsProperty([ + fc.uniqueArray(fc.integer(), { minLength: 2, maxLength: 5 }), + ])( + `Sets with the same values in another insertion order are equal`, + (values) => { + expectEqualityPair( + new Set(values), + new Set([...values].reverse()), + true, + ) + expectEqualityPair( + new Set(values.map((value) => ({ value }))), + new Set([...values].reverse().map((value) => ({ value }))), + true, + ) + }, + ) + + utilsProperty([fc.constantFrom(``, `g`, `y`), fc.nat(5), fc.nat(5)])( + `RegExps with the same source and flags are equal at any lastIndex`, + (flags, left, right) => { + const a = new RegExp(`x`, flags) + const b = new RegExp(`x`, flags) + a.lastIndex = left + b.lastIndex = right + expectEqualityPair(a, b, true) + expectEqualityPair({ pattern: a }, { pattern: b }, true) + }, + ) + + utilsProperty([ + fc.array(fc.oneof(fc.integer(), fc.constant(`hole`)), { + minLength: 1, + maxLength: 5, + }), + ])(`an array hole equals undefined at the same index`, (cells) => { + const sparse: Array = [] + sparse.length = cells.length + const dense: Array = [] + cells.forEach((cell, index) => { + if (cell !== `hole`) sparse[index] = cell + dense[index] = cell === `hole` ? undefined : cell + }) + expectEqualityPair(sparse, dense, true) + expectEqualityPair({ items: sparse }, { items: dense }, true) + }) + }) + describe(`nested structure consistency`, () => { utilsProperty([ fc.array(fc.array(fc.integer(), { maxLength: 3 }), { maxLength: 3 }), From 639ed5a1e78c7d538bf44789f515e55892fa8a34 Mon Sep 17 00:00:00 2001 From: Isaac Date: Thu, 1 Oct 2026 08:17:00 -0600 Subject: [PATCH 02/28] test(db): let Map values in the draft revert oracle be nested Sets A refactor mutant that compared Map values with general deepEquals rules passed the oracle, because generated Map values were only primitives. Map values can now be nested Sets, and a pinned case checks that a reordered Set inside a Map value is a change. Passes on unchanged code. Co-authored-by: Isaac --- .../proxy-revert-oracle.property.test.ts | 66 ++++++++++++++----- 1 file changed, 51 insertions(+), 15 deletions(-) diff --git a/packages/db/tests/proxy-revert-oracle.property.test.ts b/packages/db/tests/proxy-revert-oracle.property.test.ts index 02dbe9a2d..467529941 100644 --- a/packages/db/tests/proxy-revert-oracle.property.test.ts +++ b/packages/db/tests/proxy-revert-oracle.property.test.ts @@ -56,12 +56,20 @@ type Spec = | { k: `prim`; v: Primitive } | { k: `date`; t: number } | { k: `regex`; flags: string; lastIndex: number } - | { k: `map`; entries: Array<[string, Primitive]> } + | { k: `map`; entries: Array<[string, MapValue]> } | { k: `set`; values: Array } | { k: `array`; items: Array } | { k: `obj`; a?: Primitive; b?: Primitive; sym?: Primitive } | { k: `rows`; rows: Array<{ a: Primitive }> } +// A Map value is a primitive or a nested Set, so draft rules must also hold +// inside Map values. +type MapValue = Primitive | { set: Array } +const isSetValue = (v: MapValue): v is { set: Array } => + typeof v === `object` && v !== null +const mapValue = (v: MapValue): unknown => + isSetValue(v) ? [`set`, v.set] : prim(v) + const HOLE = Symbol(`hole`) const FIELDS = [`f`, `g`, `h`] as const type Field = (typeof FIELDS)[number] @@ -84,7 +92,7 @@ function canon(spec: Spec | undefined): unknown { case `regex`: return [`regex`, spec.flags, spec.lastIndex] case `map`: - return [`map`, spec.entries.map(([key, v]) => [key, prim(v)])] + return [`map`, spec.entries.map(([key, v]) => [key, mapValue(v)])] case `set`: return [`set`, spec.values] case `array`: @@ -117,7 +125,12 @@ function realize(spec: Spec): unknown { return value } case `map`: - return new Map(spec.entries) + return new Map( + spec.entries.map(([key, v]) => [ + key, + isSetValue(v) ? new Set(v.set) : v, + ]), + ) case `set`: return new Set(spec.values) case `array`: { @@ -162,10 +175,18 @@ const specArb: fc.Arbitrary = fc.oneof( lastIndex: fc.nat(1), }), fc - .uniqueArray(fc.tuple(fc.constantFrom(`x`, `y`), primArb), { - maxLength: 2, - selector: ([key]) => key, - }) + .uniqueArray( + fc.tuple( + fc.constantFrom(`x`, `y`), + fc.oneof( + primArb, + fc + .uniqueArray(fc.constantFrom(`x`, `y`), { maxLength: 2 }) + .map((set): MapValue => ({ set })), + ), + ), + { maxLength: 2, selector: ([key]) => key }, + ) .map((entries) => ({ k: `map` as const, entries })), fc .uniqueArray(fc.constantFrom(`x`, `y`), { maxLength: 2 }) @@ -361,7 +382,13 @@ function readSpec(value: unknown, like: Spec | undefined): string { : `not a RegExp` case `map`: return value instanceof Map - ? encode({ k: `map`, entries: [...value] }) + ? encode({ + k: `map`, + entries: [...value].map(([key, v]): [string, MapValue] => [ + key, + v instanceof Set ? { set: [...v] } : v, + ]), + }) : `not a Map` case `set`: return value instanceof Set @@ -406,12 +433,8 @@ function expectHistory({ original, ops }: History): void { for (const op of ops) { if (!applicable(state, op)) continue if (op.op === `revert`) { - if (original[op.field] === undefined) - delete (proxy)[op.field] - else - (proxy)[op.field] = realize( - original[op.field]!, - ) + if (original[op.field] === undefined) delete proxy[op.field] + else proxy[op.field] = realize(original[op.field]!) } else drive(proxy as Record, op) state = step(state, original, op) } @@ -436,7 +459,7 @@ function expectHistory({ original, ops }: History): void { } for (const field of FIELDS) expect( - readSpec((proxy)[field], state[field]), + readSpec(proxy[field], state[field]), `draft ${field}, ${context}`, ).toBe(encode(state[field])) for (const field of FIELDS) @@ -608,6 +631,19 @@ describe(`draft revert oracle`, () => { ], }, ], + [ + `a reordered Set inside a Map value is a change`, + { + original: { f: { k: `map`, entries: [[`x`, { set: [`x`, `y`] }]] } }, + ops: [ + { + op: `set`, + field: `f`, + value: { k: `map`, entries: [[`x`, { set: [`y`, `x`] }]] }, + }, + ], + }, + ], [ `Set value order is a change`, { From 6b68b8dc380307dacf8fb80cb90e25b22907f190 Mon Sep 17 00:00:00 2001 From: Isaac Date: Thu, 1 Oct 2026 08:28:05 -0600 Subject: [PATCH 03/28] refactor(db): simplify the draft proxy and share its equality walker - proxy.ts: remove the write-only proxyCache, the symbol-key branches over assigned_ (it only ever holds string keys), the one-use createObjectProxy wrapper, and the custom array iterator. Arrays now bind the native iterator to the draft, so element reads take the get trap and its parent edge. The set-trap revert path calls checkParentStatus on itself instead of repeating it. - utils.ts: draftValuesEqual becomes a draft mode of deepEqualsInternal. Draft mode keeps Map and Set order, RegExp lastIndex, and array holes; the general mode is unchanged. deepClone keeps its two-loop form: a single Reflect.ownKeys loop made every draft 8 to 17 percent slower. Co-authored-by: Isaac --- packages/db/src/proxy.ts | 633 +++++++++++++-------------------------- packages/db/src/utils.ts | 40 ++- 2 files changed, 234 insertions(+), 439 deletions(-) diff --git a/packages/db/src/proxy.ts b/packages/db/src/proxy.ts index 48bd0c84b..96b331abe 100644 --- a/packages/db/src/proxy.ts +++ b/packages/db/src/proxy.ts @@ -3,7 +3,7 @@ * and provides a way to retrieve those changes. */ -import { deepEquals, isTemporal } from './utils' +import { deepEqualsInternal, isTemporal } from './utils' // Resolve draft handles before calling native Map/Set membership methods. const draftCopies = new WeakMap() @@ -89,56 +89,6 @@ function isProxiableObject( ) } -/** - * Creates a Symbol.iterator handler for arrays that yields proxied elements. - */ -function createArrayIteratorHandler( - changeTracker: ChangeTracker, - memoizedCreateChangeProxy: ( - obj: Record, - parent?: { - tracker: ChangeTracker> - prop: string | symbol - }, - ) => { proxy: Record }, -): () => Iterator { - return function () { - const array = changeTracker.copy_ as unknown as Array - let index = 0 - - return { - next() { - if (index >= array.length) { - return { done: true, value: undefined } - } - - const element = array[index] - let proxiedElement = element - - if (isProxiableObject(element)) { - const nestedParent = { - tracker: changeTracker as unknown as ChangeTracker< - Record - >, - prop: String(index), - } - const { proxy: elementProxy } = memoizedCreateChangeProxy( - element, - nestedParent, - ) - proxiedElement = elementProxy - } - - index++ - return { done: false, value: proxiedElement } - }, - [Symbol.iterator]() { - return this - }, - } - } -} - /** * Creates a wrapper for methods that modify a collection (array, Map, Set). * The wrapper calls the method and marks the change tracker as modified. @@ -220,12 +170,6 @@ function createMapSetIteratorHandler( } } -// Add TypedArray interface with proper type -interface TypedArray { - length: number - [index: number]: number -} - // Update type for ChangeTracker interface ChangeParent { tracker: ChangeTracker> @@ -301,18 +245,10 @@ function deepClone( // Handle TypedArrays if (ArrayBuffer.isView(obj) && !(obj instanceof DataView)) { // Get the constructor to create a new instance of the same type - const TypedArrayConstructor = Object.getPrototypeOf(obj).constructor - const clone = new TypedArrayConstructor( - (obj as unknown as TypedArray).length, - ) as unknown as TypedArray + // and copy its elements. + const clone = new (Object.getPrototypeOf(obj).constructor)(obj) visited.set(obj as object, clone) - - // Copy the values - for (let i = 0; i < (obj as unknown as TypedArray).length; i++) { - clone[i] = (obj as unknown as TypedArray)[i]! - } - - return clone as unknown as T + return clone } if (obj instanceof Map) { @@ -350,6 +286,9 @@ function deepClone( const clone = {} as Record visited.set(obj as object, clone) + // Own enumerable string keys, then every own symbol key, in native order. + // Own enumerable string keys, then every own symbol key. A Reflect.ownKeys + // loop with an enumerability check is shorter but slower on this hot path. for (const key in obj) { if (Object.prototype.hasOwnProperty.call(obj, key)) { // Copy data properties without invoking Object.prototype.__proto__. @@ -365,8 +304,7 @@ function deepClone( } } - const symbolProps = Object.getOwnPropertySymbols(obj) - for (const sym of symbolProps) { + for (const sym of Object.getOwnPropertySymbols(obj)) { clone[sym] = deepClone( (obj as Record)[sym], visited, @@ -384,82 +322,7 @@ function draftValuesEqual( right: unknown, paired = new Map(), ): boolean { - if (left === right) return true - if ( - left === null || - right === null || - typeof left !== `object` || - typeof right !== `object` - ) - return deepEquals(left, right) - if (paired.has(left)) return paired.get(left) === right - paired.set(left, right) - try { - if (left instanceof Set || right instanceof Set) { - if ( - !(left instanceof Set) || - !(right instanceof Set) || - left.size !== right.size - ) - return false - const values = [...right] - return [...left].every((value, index) => - draftValuesEqual(value, values[index], paired), - ) - } - if (left instanceof RegExp || right instanceof RegExp) - return ( - left instanceof RegExp && - right instanceof RegExp && - deepEquals(left, right) && - left.lastIndex === right.lastIndex - ) - if (left instanceof Map || right instanceof Map) { - if ( - !(left instanceof Map) || - !(right instanceof Map) || - left.size !== right.size - ) - return false - const entries = [...right] - return [...left].every( - ([key, value], index) => - (key === entries[index]![0] || Object.is(key, entries[index]![0])) && - draftValuesEqual(value, entries[index]![1], paired), - ) - } - // Native leaf values retain their existing equality contract. Traverse - // ordinary containers so a nested Set or RegExp cannot bypass refinement. - if ( - left instanceof Date || - right instanceof Date || - ArrayBuffer.isView(left) || - ArrayBuffer.isView(right) || - isTemporal(left) || - isTemporal(right) - ) - return deepEquals(left, right) - if (Array.isArray(left) !== Array.isArray(right)) return false - if (Array.isArray(left) && left.length !== (right as Array).length) - return false - const keys = (value: object) => - Reflect.ownKeys(value).filter((key) => - Object.prototype.propertyIsEnumerable.call(value, key), - ) - const leftKeys = keys(left) - if (leftKeys.length !== keys(right).length) return false - return leftKeys.every( - (key) => - Object.prototype.propertyIsEnumerable.call(right, key) && - draftValuesEqual( - (left as Record)[key], - (right as Record)[key], - paired, - ), - ) - } finally { - paired.delete(left) - } + return deepEqualsInternal(left, right, paired, true) } /** @@ -501,11 +364,6 @@ export function createChangeProxy< return changeProxy } } - // Create a WeakMap to cache proxies for nested objects - // This prevents creating multiple proxies for the same object - // and handles circular references - const proxyCache = new Map() - // Existing values share one private copy per row. Newly inserted objects // retain normal references during the callback; the result is detached below. const valueCopies = @@ -545,7 +403,8 @@ export function createChangeProxy< } } - // Check if all properties in the current state have reverted to original values + // Check if all properties in the current state have reverted to original values. + // assigned_ keys are always strings: traps record `prop.toString()`. function checkIfReverted( state: ChangeTracker>, ): boolean { @@ -560,49 +419,18 @@ export function createChangeProxy< ), ) } - // If there are no assigned properties, object is unchanged - if ( - Object.keys(state.assigned_).length === 0 && - Object.getOwnPropertySymbols(state.assigned_).length === 0 - ) { - return true - } - - // Check each assigned regular property for (const prop in state.assigned_) { - // If this property is marked as assigned - if (state.assigned_[prop] === true) { - const currentValue = state.copy_[prop] - const originalValue = (state.originalObject as any)[prop] - - // If the value is not equal to original, something is still changed - if (!draftValuesEqual(currentValue, originalValue)) { - return false - } - } else if (state.assigned_[prop] === false) { - // Property was deleted, so it's different from original - return false - } - } - - // Check each assigned symbol property - const symbolProps = Object.getOwnPropertySymbols(state.assigned_) - for (const sym of symbolProps) { - if (state.assigned_[sym] === true) { - const currentValue = (state.copy_ as any)[sym] - const originalValue = (state.originalObject as any)[sym] - - // If the value is not equal to original, something is still changed - if (!draftValuesEqual(currentValue, originalValue)) { - return false - } - } else if (state.assigned_[sym] === false) { - // Property was deleted, so it's different from original + // false marks a deletion, which always differs from the original + if ( + !state.assigned_[prop] || + !draftValuesEqual( + state.copy_[prop], + (state.originalObject as any)[prop], + ) + ) { return false } } - - // All assigned properties match their original values return true } @@ -625,279 +453,228 @@ export function createChangeProxy< } } - // Create a proxy for the target object - function createObjectProxy(obj: TObj): TObj { - // If we've already created a proxy for this object, return it - if (proxyCache.has(obj)) { - return proxyCache.get(obj) as TObj - } - - // Create a proxy for the object - const proxy = new Proxy(obj, { - get(ptarget, prop, receiver) { - const value = changeTracker.copy_[prop as keyof T] - - // If it's a getter, return the value directly - const desc = Object.getOwnPropertyDescriptor(ptarget, prop) - if (desc?.get) { - return value - } + // Create a proxy for the target object. + // Use the unfrozen copy_ as the proxy target to avoid Proxy invariant violations + // when the original target is frozen (e.g., from Immer) + const proxy = new Proxy(changeTracker.copy_, { + get(ptarget, prop, receiver) { + const value = changeTracker.copy_[prop as keyof T] - // If the value is a function, bind it to the ptarget - if (typeof value === `function`) { - // For Array methods that modify the array - if (Array.isArray(ptarget)) { - const methodName = prop.toString() - - if (ARRAY_MODIFYING_METHODS.has(methodName)) { - return createModifyingMethodHandler( - value, - changeTracker, - markChanged, - ) - } + // If it's a getter, return the value directly + const desc = Object.getOwnPropertyDescriptor(ptarget, prop) + if (desc?.get) { + return value + } - // Native callbacks and iterators read through the draft itself. - // This also tracks the callback array and implicit reduce seed. - if ( - CALLBACK_ITERATION_METHODS.has(methodName) || - methodName === `values` || - methodName === `entries` - ) { - return value.bind(receiver) - } + // If the value is a function, bind it to the ptarget + if (typeof value === `function`) { + // For Array methods that modify the array + if (Array.isArray(ptarget)) { + const methodName = prop.toString() - // Handle array Symbol.iterator for for...of loops - if (prop === Symbol.iterator) { - return createArrayIteratorHandler( - changeTracker, - memoizedCreateChangeProxy, - ) - } + if (ARRAY_MODIFYING_METHODS.has(methodName)) { + return createModifyingMethodHandler( + value, + changeTracker, + markChanged, + ) } - // For Map and Set methods that modify the collection - if (ptarget instanceof Map || ptarget instanceof Set) { - const methodName = prop.toString() + // Native callbacks and iterators read through the draft itself. + // This also tracks the callback array and implicit reduce seed. + if ( + CALLBACK_ITERATION_METHODS.has(methodName) || + methodName === `values` || + methodName === `entries` || + prop === Symbol.iterator + ) { + return value.bind(receiver) + } + } - const resolveValue = (entry: unknown) => { - const raw = unwrapDraft(entry) - return raw !== null && typeof raw === `object` - ? (valueCopies.get(raw) ?? raw) - : raw - } + // For Map and Set methods that modify the collection + if (ptarget instanceof Map || ptarget instanceof Set) { + const methodName = prop.toString() - if ( - methodName === `has` || - methodName === `delete` || - methodName === `add` || - methodName === `set` - ) { - return (...args: Array) => { - if (ptarget instanceof Set) args[0] = resolveValue(args[0]) - else if (methodName === `set`) args[1] = resolveValue(args[1]) - const result = value.apply(ptarget, args) - if (methodName !== `has`) markChanged(changeTracker) - return result === ptarget ? receiver : result - } - } + const resolveValue = (entry: unknown) => { + const raw = unwrapDraft(entry) + return raw !== null && typeof raw === `object` + ? (valueCopies.get(raw) ?? raw) + : raw + } - if (ptarget instanceof Map && methodName === `get`) { - return (key: unknown) => { - const entry = ptarget.get(key) - return isProxiableObject(entry) - ? memoizedCreateChangeProxy(entry, { - tracker: changeTracker, - prop: ``, - retainIdentity: true, - }).proxy - : entry - } + if ( + methodName === `has` || + methodName === `delete` || + methodName === `add` || + methodName === `set` + ) { + return (...args: Array) => { + if (ptarget instanceof Set) args[0] = resolveValue(args[0]) + else if (methodName === `set`) args[1] = resolveValue(args[1]) + const result = value.apply(ptarget, args) + if (methodName !== `has`) markChanged(changeTracker) + return result === ptarget ? receiver : result } + } - if (methodName === `clear`) { - return createModifyingMethodHandler( - value, - changeTracker, - markChanged, - ) + if (ptarget instanceof Map && methodName === `get`) { + return (key: unknown) => { + const entry = ptarget.get(key) + return isProxiableObject(entry) + ? memoizedCreateChangeProxy(entry, { + tracker: changeTracker, + prop: ``, + retainIdentity: true, + }).proxy + : entry } + } - // Handle iterator methods for Map and Set - const iteratorHandler = createMapSetIteratorHandler( - methodName, - prop, + if (methodName === `clear`) { + return createModifyingMethodHandler( + value, changeTracker, - receiver, - memoizedCreateChangeProxy, + markChanged, ) - if (iteratorHandler) { - return iteratorHandler - } - } - return value.bind(ptarget) - } - - // If the value is an object (but not Date, RegExp, or Temporal), create a proxy for it - if (isProxiableObject(value)) { - // Create a parent reference for the nested object - const nestedParent = { - tracker: changeTracker, - prop: String(prop), } - // Create a proxy for the nested object - const { proxy: nestedProxy } = memoizedCreateChangeProxy( - value, - nestedParent, + // Handle iterator methods for Map and Set + const iteratorHandler = createMapSetIteratorHandler( + methodName, + prop, + changeTracker, + receiver, + memoizedCreateChangeProxy, ) - - // Cache the proxy - proxyCache.set(value, nestedProxy) - - return nestedProxy + if (iteratorHandler) { + return iteratorHandler + } } + return value.bind(ptarget) + } - return value - }, - - set(_sobj, prop, value) { - const currentValue = changeTracker.copy_[prop as keyof T] + // If the value is an object (but not Date, RegExp, or Temporal), create a proxy for it + if (isProxiableObject(value)) { + // Create a parent reference for the nested object + const nestedParent = { + tracker: changeTracker, + prop: String(prop), + } - // Only track the change if the value is actually different - if ( - !Object.hasOwn(changeTracker.copy_, prop) || - !draftValuesEqual(currentValue, value) - ) { - // Check if the new value is equal to the original value - // Important: Use the originalObject to get the true original value - const originalValue = changeTracker.originalObject[prop as keyof T] - const isRevertToOriginal = - Object.hasOwn(changeTracker.originalObject, prop) && - draftValuesEqual(value, originalValue) - - if (isRevertToOriginal) { - // If the value is reverted to its original state, remove it from changes - delete changeTracker.assigned_[prop.toString()] - - // Make sure the copy is updated with the original value - changeTracker.copy_[prop as keyof T] = deepClone(originalValue) - - // Check if all properties in this object have been reverted - const allReverted = checkIfReverted(changeTracker) - - if (allReverted) { - // If all have been reverted, clear tracking - changeTracker.modified = false - changeTracker.assigned_ = Object.create(null) - - // If we're a nested object, check if the parent needs updating - if (parent) { - checkParentStatus(parent.tracker) - } - } else { - // Some properties are still changed - changeTracker.modified = true - } - } else { - // Set the value on the copy - changeTracker.copy_[prop as keyof T] = value + // Create (or reuse) the proxy for the nested object + return memoizedCreateChangeProxy(value, nestedParent).proxy + } - // Track that this property was assigned - store using the actual property (symbol or string) - changeTracker.assigned_[prop.toString()] = true + return value + }, - // Mark this object and its ancestors as modified - markChanged(changeTracker) - } - } + set(_sobj, prop, value) { + const currentValue = changeTracker.copy_[prop as keyof T] - return true - }, - - defineProperty(ptarget, prop, descriptor) { - // Forward the defineProperty to the target to maintain Proxy invariants - // This allows Object.seal() and Object.freeze() to work on the proxy - const result = Reflect.defineProperty(ptarget, prop, descriptor) - if (result && `value` in descriptor) { - changeTracker.copy_[prop as keyof T] = deepClone(descriptor.value) + // Only track the change if the value is actually different + if ( + !Object.hasOwn(changeTracker.copy_, prop) || + !draftValuesEqual(currentValue, value) + ) { + // Check if the new value is equal to the original value + // Important: Use the originalObject to get the true original value + const originalValue = changeTracker.originalObject[prop as keyof T] + const isRevertToOriginal = + Object.hasOwn(changeTracker.originalObject, prop) && + draftValuesEqual(value, originalValue) + + if (isRevertToOriginal) { + // If the value is reverted to its original state, remove it from changes + delete changeTracker.assigned_[prop.toString()] + + // Make sure the copy is updated with the original value + changeTracker.copy_[prop as keyof T] = deepClone(originalValue) + + // Some properties may still be changed; checkParentStatus clears + // tracking here and up the chain once everything is reverted. + changeTracker.modified = true + checkParentStatus(changeTracker) + } else { + // Set the value on the copy + changeTracker.copy_[prop as keyof T] = value + + // Track that this property was assigned - store using the actual property (symbol or string) changeTracker.assigned_[prop.toString()] = true + + // Mark this object and its ancestors as modified markChanged(changeTracker) } - return result - }, + } - getOwnPropertyDescriptor(ptarget, prop) { - // Forward to target to maintain Proxy invariants for seal/freeze - return Reflect.getOwnPropertyDescriptor(ptarget, prop) - }, + return true + }, - preventExtensions(ptarget) { - // Forward to target to allow Object.seal() and Object.preventExtensions() - return Reflect.preventExtensions(ptarget) - }, + defineProperty(ptarget, prop, descriptor) { + // Forward the defineProperty to the target to maintain Proxy invariants + // This allows Object.seal() and Object.freeze() to work on the proxy + const result = Reflect.defineProperty(ptarget, prop, descriptor) + if (result && `value` in descriptor) { + changeTracker.copy_[prop as keyof T] = deepClone(descriptor.value) + changeTracker.assigned_[prop.toString()] = true + markChanged(changeTracker) + } + return result + }, - isExtensible(ptarget) { - // Forward to target to maintain consistency - return Reflect.isExtensible(ptarget) - }, + getOwnPropertyDescriptor(ptarget, prop) { + // Forward to target to maintain Proxy invariants for seal/freeze + return Reflect.getOwnPropertyDescriptor(ptarget, prop) + }, - deleteProperty(dobj, prop) { - const stringProp = typeof prop === `symbol` ? prop.toString() : prop + preventExtensions(ptarget) { + // Forward to target to allow Object.seal() and Object.preventExtensions() + return Reflect.preventExtensions(ptarget) + }, - if (Object.hasOwn(dobj, prop)) { - // Check if the property exists in the original object - const hadPropertyInOriginal = Object.hasOwn( - changeTracker.originalObject, - prop, - ) + isExtensible(ptarget) { + // Forward to target to maintain consistency + return Reflect.isExtensible(ptarget) + }, - // Forward the delete to the target using Reflect - // This respects Object.seal/preventExtensions constraints - const result = Reflect.deleteProperty(dobj, prop) - - if (result) { - // If the property didn't exist in the original object, removing it - // should revert to the original state - if (!hadPropertyInOriginal) { - delete changeTracker.assigned_[stringProp] - - // If this is the last change and we're not a nested object, - // mark the object as unmodified - if ( - Object.keys(changeTracker.assigned_).length === 0 && - Object.getOwnPropertySymbols(changeTracker.assigned_).length === - 0 - ) { - changeTracker.modified = false - } else { - // We still have changes, keep as modified - changeTracker.modified = true - } - } else { - // Mark this property as deleted - changeTracker.assigned_[stringProp] = false - markChanged(changeTracker) - } + deleteProperty(dobj, prop) { + const stringProp = typeof prop === `symbol` ? prop.toString() : prop + + if (Object.hasOwn(dobj, prop)) { + // Check if the property exists in the original object + const hadPropertyInOriginal = Object.hasOwn( + changeTracker.originalObject, + prop, + ) + + // Forward the delete to the target using Reflect + // This respects Object.seal/preventExtensions constraints + const result = Reflect.deleteProperty(dobj, prop) + + if (result) { + // If the property didn't exist in the original object, removing it + // should revert to the original state + if (!hadPropertyInOriginal) { + delete changeTracker.assigned_[stringProp] + + // If this is the last change and we're not a nested object, + // mark the object as unmodified + changeTracker.modified = + Object.keys(changeTracker.assigned_).length > 0 + } else { + // Mark this property as deleted + changeTracker.assigned_[stringProp] = false + markChanged(changeTracker) } - - return result } - return true - }, - }) - - // Cache the proxy - proxyCache.set(obj, proxy) - draftCopies.set(proxy, changeTracker.copy_) - - return proxy - } + return result + } - // Create a proxy for the target object - // Use the unfrozen copy_ as the proxy target to avoid Proxy invariant violations - // when the original target is frozen (e.g., from Immer) - const proxy = createObjectProxy(changeTracker.copy_ as unknown as T) + return true + }, + }) + draftCopies.set(proxy, changeTracker.copy_) // Return the proxy and a function to get the changes return { diff --git a/packages/db/src/utils.ts b/packages/db/src/utils.ts index 1bca8e9a9..7fd600852 100644 --- a/packages/db/src/utils.ts +++ b/packages/db/src/utils.ts @@ -41,11 +41,16 @@ function enumerableOwnKeys(value: object): Array { /** * Internal implementation with cycle detection to prevent infinite recursion. * Internal callers can seed already-paired roots when comparing their children. + * + * `draft` selects the stricter equality used by change-tracking drafts: Map + * and Set contents must match in order, RegExp match position must match, and + * arrays compare as keyed objects (holes and extra enumerable keys count). */ export function deepEqualsInternal( a: any, b: any, visited: Map, + draft = false, ): boolean { // Handle strict equality (primitives, same reference) if (a === b || Object.is(a, b)) return true @@ -67,7 +72,11 @@ export function deepEqualsInternal( // Handle RegExp objects if (a instanceof RegExp) { if (!(b instanceof RegExp)) return false - return a.source === b.source && a.flags === b.flags + return ( + a.source === b.source && + a.flags === b.flags && + (!draft || a.lastIndex === b.lastIndex) + ) } // Symmetric check: if b is RegExp but a is not, they're not equal if (b instanceof RegExp) return false @@ -84,9 +93,13 @@ export function deepEqualsInternal( visited.set(a, b) const entries = Array.from(a.entries()) - const result = entries.every(([key, val]) => { - return b.has(key) && deepEqualsInternal(val, b.get(key), visited) - }) + const bEntries = draft ? Array.from(b.entries()) : [] + const result = entries.every(([key, val], index) => + draft + ? Object.is(key, bEntries[index]![0]) && + deepEqualsInternal(val, bEntries[index]![1], visited, draft) + : b.has(key) && deepEqualsInternal(val, b.get(key), visited), + ) visited.delete(a) return result @@ -109,6 +122,14 @@ export function deepEqualsInternal( const aValues = Array.from(a) const bValues = Array.from(b) + if (draft) { + const result = aValues.every((val, index) => + deepEqualsInternal(val, bValues[index], visited, draft), + ) + visited.delete(a) + return result + } + // Simple comparison for primitive values if (aValues.every((val) => typeof val !== `object`)) { visited.delete(a) @@ -201,9 +222,9 @@ export function deepEqualsInternal( if (isTemporal(b)) return false // Handle arrays - if (Array.isArray(a)) { - if (!Array.isArray(b) || a.length !== b.length) return false - + if (Array.isArray(a) !== Array.isArray(b)) return false + if (Array.isArray(a) && a.length !== b.length) return false + if (Array.isArray(a) && !draft) { // Check for circular references if (visited.has(a)) { return visited.get(a) === b @@ -216,9 +237,6 @@ export 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`) { // Check for circular references @@ -242,7 +260,7 @@ export function deepEqualsInternal( const result = keysA.every( (key) => Object.prototype.propertyIsEnumerable.call(b, key) && - deepEqualsInternal(a[key], b[key], visited), + deepEqualsInternal(a[key], b[key], visited, draft), ) visited.delete(a) From affd9f3e8e72f4f40d15a5905741aac513a3fdc0 Mon Sep 17 00:00:00 2001 From: Isaac Date: Thu, 1 Oct 2026 08:32:14 -0600 Subject: [PATCH 04/28] docs: record the draft proxy review Record the sixteen base mutants (eight passed the whole db suite before the new tests), P4's equivalence, the fifteen refactor mutants, the deepClone loop regression and its bisection, bytes, and verification for 6b68b8dc3. Add the revert owner and the deepEquals order rules to the coverage map, and a changeset. Co-authored-by: Isaac --- .changeset/simplify-draft-proxy.md | 5 + docs/contributing/oracle-coverage.md | 4 +- .../oracle-reviews/code-weight-draft-proxy.md | 220 ++++++++++++++++++ 3 files changed, 227 insertions(+), 2 deletions(-) create mode 100644 .changeset/simplify-draft-proxy.md create mode 100644 docs/contributing/oracle-reviews/code-weight-draft-proxy.md diff --git a/.changeset/simplify-draft-proxy.md b/.changeset/simplify-draft-proxy.md new file mode 100644 index 000000000..33c5b1338 --- /dev/null +++ b/.changeset/simplify-draft-proxy.md @@ -0,0 +1,5 @@ +--- +'@tanstack/db': patch +--- + +Simplify the mutation draft proxy and share one equality walker with `deepEquals`. Change tracking, revert detection, and `deepEquals` results do not change, and draft writes are faster. Iterating a draft array with `for...of` now uses the native Array Iterator. A typical app bundle is about 460 B smaller with gzip. diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index d66b3d3d7..bdd6778eb 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -241,7 +241,7 @@ comment and the current API/architecture contract before extending its model. | Collection lifecycle | [mutation startup](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-mutation-startup-oracle.test.ts), [change-event history](https://github.com/TanStack/db/blob/main/packages/db/tests/change-event-history-oracle.test.ts), [history](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-subscription-lifecycle-history.property.test.ts), [publication](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-subscription-lifecycle-publication.property.test.ts), [replay](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-subscription-replay-oracle.property.test.ts), [effect disposal](https://github.com/TanStack/db/blob/main/packages/db/tests/effect-disposal-oracle.test.ts), [PR #1902 review](oracle-reviews/pr-1902-change-event-history.md), [batch-order decision review](oracle-reviews/change-event-batch-order.md) | Core Collection `insert`/`update`/`delete` admission while `startSync:false` is idle; complete public-row/change-message agreement across bounded one- and two-key histories plus replayable four-key campaigns; a two-key deferred sync history checks each key's causal insert/update/delete trace while allowing any cross-key interleaving. Reversing all deferred messages fails that check; reversing each sync transaction's distinct-key messages passes. Queued duplicate admission and cancellation, ownership and phase histories, exact caller/error/publication evidence, and late completion and restart have separate witnesses. Generated deferred histories with cancellation or reentrant callbacks need a witness in the change-event history owner before claiming general batch-shape coverage. Query write utilities and effect self-dependent disposal remain separate contracts. | | Paced mutations | [virtual-clock oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/paced-mutations-oracle.test.ts) | `createPacedMutations` with queue front/back options, bounded waiting capacity at zero and one, cleanup draining in FIFO/LIFO order after a held write, cleanup timing at the configured wait for immediate writes, admitted writes after separate Collection cleanup, debounce and throttle schedules with explicit and omitted options, and first-leading throttle execution at epoch zero. Finite histories compare optimistic rows, returned transaction identity and receipt outcome, execution order and virtual-clock time. Held writes verify queue serialization. Leading-only throttle and debounce witnesses reject skipped calls immediately with their named dropped-call errors, including omitted edges, both edges disabled, and same-row rollback while a prior write is held. Capacity witnesses cover overflow rejection, distinct-key optimistic rollback, same-key admitted-write survival, and in-flight versus waiting admission. Cleanup witnesses check repeated queue cleanup, eventual admitted receipt settlement, direct queue strategy rejection of new callbacks, public post-cleanup rollback with `QueueDisposedError`, pending debounce transactions that wait until the last call's quiet edge after separate Collection cleanup, and a trailing throttle timer that runs at its regular edge after separate Collection cleanup. Deferred custom queue and batch callbacks check compatibility with `void` and `false` execute results, respectively. Frozen options verify factory non-mutation; a mutable option witness checks that debounce uses its construction-time trailing setting. New debounce/throttle admission after cleanup, failed persistence, broader capacities and schedules remain outside this owner. | | Optimistic state | [history model](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-history-oracle.ts), [generated histories](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-transaction-oracle.property.test.ts), [truncate capture ownership](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-truncate-ownership-oracle.property.test.ts), [outcomes](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-history-outcomes.test.ts), [publication](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-history-publication.test.ts) | Independent whole-row snapshots, rollback dependencies, captured truncate ownership, metadata and prior-value events. Never rebase a pending snapshot merely to simplify the model. | -| Drafts and native values | [proxy](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy.test.ts), [detachment](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-detachment-contract.test.ts), [iteration](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-iteration-contract.test.ts) | Native-operation controls, exact patches and actual stored rows; alias/cycle/adversarial-key histories. Set and Map reorder histories check draft patches and stored iteration order after replacement and clear-and-readd; RegExp replacement/reversion histories check `lastIndex`. General native-mutator and symbol-write support is not established by a plain-object oracle. | +| Drafts and native values | [proxy](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy.test.ts), [detachment](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-detachment-contract.test.ts), [iteration](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-iteration-contract.test.ts), [revert](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-revert-oracle.property.test.ts) | Native-operation controls, exact patches and actual stored rows; alias/cycle/adversarial-key histories. Set and Map reorder histories check draft patches and stored iteration order after replacement and clear-and-readd; RegExp replacement/reversion histories check `lastIndex`. General native-mutator and symbol-write support is not established by a plain-object oracle. The revert owner generates assignment, revert, delete, nested, symbol-key, and `for...of` histories and checks `getChanges()` and the draft against an independent draft-equality model: ordered Map and Set contents (including Sets inside Map values), RegExp `lastIndex`, array holes, and symbol keys inside nested values. Mutator methods (`push`, `set`, `add`) and top-level symbol writes are outside its grammar. The detachment owner pins which key classes a draft copies: enumerable string keys and every symbol key ([draft proxy review](oracle-reviews/code-weight-draft-proxy.md)). | | Query DB and observer | [ownership](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/ownership-lifecycle.oracle.test.ts), [load lifecycle](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/load-subset-lifecycle-oracle.test.ts), [observer histories](https://github.com/TanStack/db/blob/main/packages/db/tests/live-query-observer-history.property.test.ts) | Real QueryClient boundary, applied-settlement barriers for existing and cached demand, mutation-handler Collection identity, terminal fetch-record lifecycle, and a per-listener eligibility ledger, not a duplicate dispatch queue. The bounded handler grammar crosses insert, update, delete, parameter and mutation-alias access, refetch, and clearError. A held clearError retry retains the prior public error through initial, background, and Collection application failures; success clears it, failure advances the count, including a repeated error at clock time zero. An intermediate Query retry attempt does not recount the previous background error; a held final attempt can succeed or fail. A held application after successful Query fetch keeps the error visible until the applied result. A mocked persisted-baseline scan holds an older success while a newer terminal Query failure arrives; the newer public error and count survive application, including when the Error object and clock tick repeat. Native SQLite persistence, concurrent retries across multiple tracked Queries, mutation-handler fetch-boundary settlement, and deferred result application remain outside this witness. The mutation overlap grammar crosses both start orders. Check reentry, peer survival, FIFO and disposal independently of final rows. | | Query DB SSR dehydration | `packages/query-db-collection/tests/ssr-dehydration-oracle.test.ts`, [review record](oracle-reviews/issue-1950-ssr-dehydration.md) | A plain Query cache control, a live on-demand compound predicate, a signal-only request, and an ordered cursor with a custom comparator all retain their row through QueryClient dehydration, reconstruction of Seroval's emitted stream payload, and Query Core hydration from that payload. The on-demand query functions still receive the original IR, comparator, and signal; serialized metadata retains enumerable user metadata but excludes request options. The original implementation fails the compound and signal cases at the stream checkpoint. A serializable stream-only request-options leak passes the former chunk-count check but fails the current payload assertion. This owner covers successful Query cache entries at the installed Query Core 5.90.20 and Seroval 1.5.0 versions. A TanStack Start integration owner still needs the reporter's Router 1.171.33 and Seroval 1.6.8 path, browser Collection resume/refetch, and request cancellation across SSR. Query subset and pagination oracles remain owners of predicate/order/cursor meaning and supported value types. The issue's `shouldDehydrateQuery` workaround replaces Query Core's success-only default and admits pending/error queries; any documented workaround must preserve that default filter. | | Ordered acquisition | [pagination](https://github.com/TanStack/db/blob/main/packages/db/tests/query/pagination-oracle.property.test.ts), [ordered work](https://github.com/TanStack/db/blob/main/packages/db/tests/query/ordered-work-oracle.property.test.ts), [ordered lifecycle](https://github.com/TanStack/db/blob/main/packages/db/tests/query/ordered-lifecycle-oracle.property.test.ts), [ordered loader state](https://github.com/TanStack/db/blob/main/packages/db/tests/query/ordered-source-loader-state.test.ts), [graph scheduler](https://github.com/TanStack/db/blob/main/packages/db/tests/query/scheduler.test.ts), [issue #1880 ordered-repair review](oracle-reviews/issue-1880-ordered-repair.md), [issue #1882 custom local collation review](oracle-reviews/issue-1882-custom-local-collation.md), [issue #1898 relation-filter review](oracle-reviews/issue-1898-relation-filter.md), [PR #1909 external review](oracle-reviews/pr-1909-external-review.md) | Complete finite provider results, inherited locale and bounded/unbounded custom collation with exact request options, query-level inheritance across source aliases, real lexical/numeric disagreement, custom comparator ties, scan/index paths, pending windows, ties/nulls, lease ownership, Effect callback gates, including an indexed source update during Effect startup, and documented repair timing. Direct LEFT-joined filters cover local finite prefixes, pending child demand, child removal, and anti-join publication with custom full-source and unindexed fallback loading; unrelated include demand cannot stall the root continuation, and an empty joined replay wakes held Effect callbacks; remote relation hints remain outside this owner. Graph scheduler checks coalescing, dependency order, synchronous loader input, and repeated-alias failure ownership. The ordered-work oracle checks that a synchronous root refill reaches D2 before joined publication; a mutant that omitted the post-loader graph step failed at the second child demand. Custom collation proves local full-source acquisition only; it does not establish provider cursor capability or backend collation fidelity. Sibling-source input during a synchronous ordered continuation returns to graph work before another ordered acquisition. Request completion is not proof of unrequested source extent. A window move during existing joined demand waits for its root continuation, including an independently held root request; current demand failure rejects the move, while retirement and an obsolete rejection do not. A bounded ordered repair resumes its refill after joined demand settles. The ordered-source-loader state test checks that an explicit failed-request retry blocked by joined demand retains its generation until it can issue a request. Multiple simultaneously pending joined plans during a window move and a public-query failed-retry overlap still need dedicated witnesses. | @@ -259,7 +259,7 @@ comment and the current API/architecture contract before extending its model. | Offline execution | [scheduler](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/KeyScheduler.property.test.ts), [leadership](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/leadership-replay.property.test.ts), [settlement](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/transaction-settlement.property.test.ts), [serialization](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/transaction-serializer.property.test.ts), [web connectivity replay](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/connectivity-replay-oracle.test.ts), [review record](oracle-reviews/pr-1879-visible-replay.md) | Declarative FIFO eligibility, per-transaction outcomes, durable state and typed wire trees. The web connectivity owner checks a persisted write under a false browser online hint, then visible replay by event or explicit retry, at provider-call, outbox, and caller-settlement cuts. It uses controlled browser globals and fake storage; it does not prove actual browser network detection, genuine offline retry timing, multi-tab leadership transfer, or React Native behavior. Issued work may finish after ownership loss, but new work must not start. Exactly-once network execution is not promised. | | Frameworks | [React conformance](https://github.com/TanStack/db/blob/main/packages/react-db/tests/conformance.test.tsx), [React pagination](https://github.com/TanStack/db/blob/main/packages/react-db/tests/infinite-query-conformance.test.tsx), [shared suites](https://github.com/TanStack/db/tree/main/packages/db-collection-e2e/src/suites), [Vue synchronous publication](https://github.com/TanStack/db/blob/main/packages/vue-db/tests/useLiveQuery-publication-oracle.test.ts) | Exact exposed rows/pages and each framework's own lifecycle cuts. Vue's synchronous watcher checks insert, update, and nonterminal delete through supplied Collection and identity query inputs; it does not cover an empty final result, multiple changes in one transaction, or query recompilation. A React witness does not prove Vue/Solid/Angular/Svelte scheduling. Preserve their receiving registrations. | | Window-controller overlap | [shared infinite-query conformance](https://github.com/TanStack/db/blob/main/packages/db/tests/conformance/infinite-suite.ts), [React driver](https://github.com/TanStack/db/blob/main/packages/react-db/tests/infinite-query-conformance.test.tsx), [Vue driver](https://github.com/TanStack/db/blob/main/packages/vue-db/tests/infinite-query-conformance.test.ts), [Svelte driver](https://github.com/TanStack/db/blob/main/packages/svelte-db/tests/infinite-query-conformance.svelte.test.ts), [review record](oracle-reviews/dec-03-window-overlap.md) | Four or five ordered source rows, two window-settlement orders, and public snapshots after each settlement, a subscribed observer update, a detached controller read, and explicit recovery. Insertion and removal of the fifth row distinguish both continuation directions while an earlier error remains visible. The detached read follows a source change with no controller subscriber; its preloaded Collection retains the five-row window. The exact overlap uses the exported DB controller in each package realm. Framework hooks expose no controller preload, so this cell does not establish a hook scheduling path; direct hook paging remains in the adjacent shared scenarios. An unsubscribed controller has no notification claim. | -| Structural values and ordered primitives | [hash values](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/hash.property.test.ts), [hash identity](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/hash-identity-oracle.property.test.ts), [MultiSet consolidation](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/multiset-consolidate-oracle.property.test.ts), [hash graphs](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/hash-graph.property.test.ts), [mixed hash graphs](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/hash-mixed-graph.property.test.ts), [hash retry](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/hash-failure-retry.property.test.ts), [comparison](https://github.com/TanStack/db/blob/main/packages/db/tests/comparison.property.test.ts), [deep equality](https://github.com/TanStack/db/blob/main/packages/db/tests/utils.property.test.ts), [cursor](https://github.com/TanStack/db/blob/main/packages/db/tests/cursor.property.test.ts), [indexes](https://github.com/TanStack/db/blob/main/packages/db/tests/index-update.property.test.ts), [BTree Map](https://github.com/TanStack/db/blob/main/packages/db/tests/btree-map-oracle.test.ts), [query identity](https://github.com/TanStack/db/blob/main/packages/db/tests/query/identity-output-shape-oracle.test.ts), [LIKE semantics](https://github.com/TanStack/db/blob/main/packages/db/tests/query/compiler/evaluators.test.ts) | Independent flat values, graph topology, algebraic laws, Map/group/sort recomputation, expression denotation, custom comparator dispatch/reference identity/index compatibility, LIKE wildcard refinement, compiled output bags, declared value-pair agreement between D2 equality keys and IR equality operands, and Collection collation with exact public scan/index order and one-index reuse across repeated reads. The auto-index cells cross BasicIndex and BTreeIndex; scan cells omit indexes. Both paths cover lexical and numeric `en-US` locale defaults plus an explicit ordinary locale override of a numeric default. An unindexed six-row work cell bounds Collection collation resolution to at most two reads per ordered scan: one index-path check and one scan clause. Other locales and ordered histories are outside this owner. The identity pair grammar covers primitives, signed zero, NaN/invalid Date, valid Date/timestamp, binary/Buffer, Temporal, nested references, symbols, and functions; fixed and sampled pairs do not prove every JavaScript value or hash collision freedom. Ref proxies are expressions rather than literal values in this lane. The hash-values owner samples Set/object and Map/object type-marker distinctions in fixed and random campaigns with seed-and-path replay; collision freedom is not promised. The hash-identity owner checks `hash` and `equalHashValues` against one spec-level identity model: primitives with signed zero, NaN, and bigint; reference leaves (functions, registered handles, `File`, binary over 128 bytes); Date, binary, Temporal, and RegExp headers; array length and holes; Map and Set insertion order; and object keys in any order, for acyclic values and for cycles through arrays, Map values, Sets, and plain objects. Map/Set order sensitivity is pinned current behavior, not a promise. Arrays from another realm, RegExps with a `NaN` `lastIndex`, getters, proxies, other cycle carriers, and pairs that differ only in a back-edge target are outside its grammar; pinned cases keep equality's cross-realm length check and strict RegExp field comparison. `hash-work.test.ts` pins how visited values and array/RegExp headers count toward the structural work cap, and that equality copies a shared Map's entries once per distinct pair ([code-weight hash dispatch review](oracle-reviews/code-weight-hash-dispatch.md)). Custom string indexes do not optimize ordinary ranges, and custom cursors remain unsupported. The LIKE owner covers boolean string matching and bounded work, not nullish three-valued logic or a general Unicode collation contract. Unsupported composite cursors reject. The MultiSet consolidation owner checks keyed, single-type, and structural identity, signed zero and NaN in keyed and single-number data, the retained first record, zero-sum removal, and input record contents. It does not assert output order. Its keyed grammar includes cross-type key and value collisions, non-finite numeric keys, the `\|` delimiter, symbol and function identity, and a direct-object/object-tuple delimiter control from #1948. Eight named collision witnesses and both generated campaigns are RED on `c879ba6d` and GREEN with the repair ([#1948 review record](oracle-reviews/issue-1948-keyed-consolidation.md)); the [code-weight consolidation review](oracle-reviews/code-weight-multiset-consolidation.md) records the earlier exclusion. The keyed reference uses direct value/reference equality, including SameValueZero for numeric keys. The unkeyed fallback keeps its original key and value domains. The direct `MultiSet` owner does not prove which `@tanstack/db` query shapes produce primitive keyed values or mixed-type source keys; that needs a live-query production witness. The index owner enforces one invalid-comparator law across both BasicIndex and BTreeIndex: for each comparator, accepted numeric prefix (including the empty prefix), and probe operation (add, update, eq/range lookup, take, build), the operation throws exactly when it receives a `NaN` or non-number result and never when every comparison is valid. A `signed infinity` comparator is the valid control, and a custom collation `compare` supplied through `compareOptions` is checked the same way. Two BTree Map split histories check inserted-key reads, exact whole-range payloads/order, size, and extrema, proving split placement follows the insertion index without a post-mutation comparison. A rejected index add or remove leaves the index refining its accepted rows; update and build are not atomic. At the Collection boundary, both index types and both write paths (optimistic insert, sync commit) crash the collection: status becomes `error` and the next mutation throws, so a usable collection never holds rows its subscribers were not told about. Open: a sync source can still commit writes on a collection in `error` state, which stores unpublished rows; this is existing `markError` behavior and needs a lifecycle-owner witness. Comparator transitivity is outside this evidence. | +| Structural values and ordered primitives | [hash values](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/hash.property.test.ts), [hash identity](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/hash-identity-oracle.property.test.ts), [MultiSet consolidation](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/multiset-consolidate-oracle.property.test.ts), [hash graphs](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/hash-graph.property.test.ts), [mixed hash graphs](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/hash-mixed-graph.property.test.ts), [hash retry](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/hash-failure-retry.property.test.ts), [comparison](https://github.com/TanStack/db/blob/main/packages/db/tests/comparison.property.test.ts), [deep equality](https://github.com/TanStack/db/blob/main/packages/db/tests/utils.property.test.ts), [cursor](https://github.com/TanStack/db/blob/main/packages/db/tests/cursor.property.test.ts), [indexes](https://github.com/TanStack/db/blob/main/packages/db/tests/index-update.property.test.ts), [BTree Map](https://github.com/TanStack/db/blob/main/packages/db/tests/btree-map-oracle.test.ts), [query identity](https://github.com/TanStack/db/blob/main/packages/db/tests/query/identity-output-shape-oracle.test.ts), [LIKE semantics](https://github.com/TanStack/db/blob/main/packages/db/tests/query/compiler/evaluators.test.ts) | Independent flat values, graph topology, algebraic laws, Map/group/sort recomputation, expression denotation, custom comparator dispatch/reference identity/index compatibility, LIKE wildcard refinement, compiled output bags, declared value-pair agreement between D2 equality keys and IR equality operands, and Collection collation with exact public scan/index order and one-index reuse across repeated reads. The auto-index cells cross BasicIndex and BTreeIndex; scan cells omit indexes. Both paths cover lexical and numeric `en-US` locale defaults plus an explicit ordinary locale override of a numeric default. An unindexed six-row work cell bounds Collection collation resolution to at most two reads per ordered scan: one index-path check and one scan clause. Other locales and ordered histories are outside this owner. The identity pair grammar covers primitives, signed zero, NaN/invalid Date, valid Date/timestamp, binary/Buffer, Temporal, nested references, symbols, and functions; fixed and sampled pairs do not prove every JavaScript value or hash collision freedom. Ref proxies are expressions rather than literal values in this lane. The deep-equality owner also pins that `deepEquals` ignores Map and Set insertion order, RegExp `lastIndex`, and array holes, the rules draft equality keeps. The hash-values owner samples Set/object and Map/object type-marker distinctions in fixed and random campaigns with seed-and-path replay; collision freedom is not promised. The hash-identity owner checks `hash` and `equalHashValues` against one spec-level identity model: primitives with signed zero, NaN, and bigint; reference leaves (functions, registered handles, `File`, binary over 128 bytes); Date, binary, Temporal, and RegExp headers; array length and holes; Map and Set insertion order; and object keys in any order, for acyclic values and for cycles through arrays, Map values, Sets, and plain objects. Map/Set order sensitivity is pinned current behavior, not a promise. Arrays from another realm, RegExps with a `NaN` `lastIndex`, getters, proxies, other cycle carriers, and pairs that differ only in a back-edge target are outside its grammar; pinned cases keep equality's cross-realm length check and strict RegExp field comparison. `hash-work.test.ts` pins how visited values and array/RegExp headers count toward the structural work cap, and that equality copies a shared Map's entries once per distinct pair ([code-weight hash dispatch review](oracle-reviews/code-weight-hash-dispatch.md)). Custom string indexes do not optimize ordinary ranges, and custom cursors remain unsupported. The LIKE owner covers boolean string matching and bounded work, not nullish three-valued logic or a general Unicode collation contract. Unsupported composite cursors reject. The MultiSet consolidation owner checks keyed, single-type, and structural identity, signed zero and NaN in keyed and single-number data, the retained first record, zero-sum removal, and input record contents. It does not assert output order. Its keyed grammar includes cross-type key and value collisions, non-finite numeric keys, the `\|` delimiter, symbol and function identity, and a direct-object/object-tuple delimiter control from #1948. Eight named collision witnesses and both generated campaigns are RED on `c879ba6d` and GREEN with the repair ([#1948 review record](oracle-reviews/issue-1948-keyed-consolidation.md)); the [code-weight consolidation review](oracle-reviews/code-weight-multiset-consolidation.md) records the earlier exclusion. The keyed reference uses direct value/reference equality, including SameValueZero for numeric keys. The unkeyed fallback keeps its original key and value domains. The direct `MultiSet` owner does not prove which `@tanstack/db` query shapes produce primitive keyed values or mixed-type source keys; that needs a live-query production witness. The index owner enforces one invalid-comparator law across both BasicIndex and BTreeIndex: for each comparator, accepted numeric prefix (including the empty prefix), and probe operation (add, update, eq/range lookup, take, build), the operation throws exactly when it receives a `NaN` or non-number result and never when every comparison is valid. A `signed infinity` comparator is the valid control, and a custom collation `compare` supplied through `compareOptions` is checked the same way. Two BTree Map split histories check inserted-key reads, exact whole-range payloads/order, size, and extrema, proving split placement follows the insertion index without a post-mutation comparison. A rejected index add or remove leaves the index refining its accepted rows; update and build are not atomic. At the Collection boundary, both index types and both write paths (optimistic insert, sync commit) crash the collection: status becomes `error` and the next mutation throws, so a usable collection never holds rows its subscribers were not told about. Open: a sync source can still commit writes on a collection in `error` state, which stores unpublished rows; this is existing `markError` behavior and needs a lifecycle-owner witness. Comparator transitivity is outside this evidence. | | WHERE predicate publication | [WHERE predicate publication oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/query/where-predicate-publication-oracle.property.test.ts) | An independent Kleene evaluator judges `eq`/`not`/`and`/`or` predicates over strings, a normalization-prefixed string, booleans, numbers, `NaN`, a valid Date, `null`, a missing field, and virtual fields. Snapshot histories with pending optimistic inserts compare a live query, direct subscribers with and without initial state, and `currentStateAsChanges`; change histories apply multi-key sync transactions, including reinsertion of a key deleted earlier, and compare every consumer's key set after each commit, on scan and `BasicIndex` paths. Peer subscribers share one field with different literals, plus one on a virtual field, so every published change must reach exactly the subscribers whose predicate it can satisfy; routing mutants that ignored the previous value, ignored the field, delivered a change twice, or routed while stale published rows awaited reconciliation fail here. Fixed witnesses cover a layout-only publication reaching a filtered subscriber as one empty batch, a filtered subscriber's empty Collection-readiness batch, retraction of a vanished row after eager cleanup and restart, and the new row plus exact subscriber update payload when a same-key row stays TRUE. A stale-live-value mutant survives the generated membership checks but fails the payload witness. A focused [property-visibility test](https://github.com/TanStack/db/blob/main/packages/db/tests/query/where-prefilter-property-visibility.test.ts) compares the public unindexed snapshot with the enriched-row contract for inherited, non-enumerable, enumerable-own, and nested getter paths; the pre-fix stored-row shortcut failed three of its four controls. Other property-descriptor and stateful-getter histories remain outside those fixed controls. Hostile mutants for FALSE-for-UNKNOWN `eq`, `or` or number-literal subscription prefilters, a prefilter that ignores the previous value, a skipped readiness batch, a skip while stale published rows await reconciliation, and an unindexed snapshot scan that reads only synced rows while an optimistic insert or delete changes visibility passed the prior `@tanstack/db` suite and fail here. Change routing withholds a dropped row from an `eq` subscription before its sent key could be recorded, so a mutant that records dropped rows as sent is equivalent for routed predicates in this oracle. A focused `collection-subscription.test.ts` witness checks that a dropped insert or update, alone or beside a matching change, cannot advance a limited subscription's page offset under a routed `eq` and an unrouted `or` predicate; the unrouted cases beside a matching change kill that mutant, and the unrouted `alone` case does not. A loose-equality prefilter is an equivalent mutant: it only skips less. Skipping during truncate replay also survives; the replay's baseline diff re-derives the same retraction, so the guard keeps the prior dataflow without its own witness. Generated cleanup and restart histories for filtered subscribers remain open for the lifecycle publication owner. No-op updates, other comparison operators, Temporal and binary operands, joins, ordering, optimistic updates, and truncate are outside this owner. It runs in `@tanstack/db`'s `test:oracles` campaign. The [2026-09-30 review](https://github.com/TanStack/db/blob/main/docs/contributing/oracle-reviews/2026-09-30-where-predicate-and-join-keys.md) records each ORC outcome. | | Joined result keys | [Joined result key oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/query/join-result-key-oracle.property.test.ts) | A nested-loop model recomputes the pair set of an inner, left, or full join over source keys drawn from plain, comma-bearing, bracket-bearing, and quoted strings, numbers beside the strings that print the same, both infinities, and `NaN`. After preload and each synced group change, the published rows must equal the model's pairs and the result key count must equal the row count. The comma-joined key encoding fails its two pinned histories and both campaigns; plain `JSON.stringify`, which prints infinities as the missing-side `null`, fails the pinned infinity history and both campaigns. A pinned history in which a `NaN`-keyed pair leaves and re-forms fails when the join index compares source-key prefixes with `===`. Joins over subqueries, more than two sources, custom `getKey`, and optimistic mutations are outside this owner. It runs in `@tanstack/db`'s `test:oracles` campaign. The [2026-09-30 review](https://github.com/TanStack/db/blob/main/docs/contributing/oracle-reviews/2026-09-30-where-predicate-and-join-keys.md) records each ORC outcome. | | D2 Index storage | [Index refinement oracle](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/index-refinement-oracle.property.test.ts) | A plain-`Map` model sums multiplicities under a type-and-value identity in which `NaN` equals `NaN` and `-0` equals `0`. Histories of up to twelve additions to two keys mix prefixed arrays with `NaN`, `0`, `-0`, `1`, `'1'`, `1n`, and `'a'` prefixes and unprefixed values, with cancellation and reappearance. After each addition, `get` and `has` must equal the model. Comparing prefixes with `===`, which disagrees with the `Map` that holds them, fails three pinned histories and both campaigns; the `-0` control passes under both. Compaction, presence tracking, and structural payloads are outside this owner; the join operator tests and incrementalization law own operator behavior. | diff --git a/docs/contributing/oracle-reviews/code-weight-draft-proxy.md b/docs/contributing/oracle-reviews/code-weight-draft-proxy.md new file mode 100644 index 000000000..b73d4e1b2 --- /dev/null +++ b/docs/contributing/oracle-reviews/code-weight-draft-proxy.md @@ -0,0 +1,220 @@ +# Code weight: simplify the draft proxy and share its equality walker + +Reviewed executable revision: `6b68b8dc3` (base `18abceee4`, the fetched +`origin/main` at review time). This record follows in a documentation-only +commit. + +- `a6445a7c2` adds the draft revert oracle and the clone and `deepEquals` + laws on unchanged production code. +- `639ed5a1e` extends the revert oracle after a refactor mutant survived. The + extension also passes on unchanged code. +- `6b68b8dc3` is the refactor. + +## Change + +`packages/db/src/proxy.ts` builds the drafts that `collection.update` and +`withChangeTracking` pass to callbacks. + +- The write-only `proxyCache`, the symbol-key branches over `assigned_`, and + the one-use `createObjectProxy` wrapper are deleted. Every writer of + `assigned_` stores a string key (`String(prop)`, `String(index)`, or `''`), + so the symbol branches could not run. +- The custom array iterator is deleted. Arrays bind the native + `Array.prototype[Symbol.iterator]` to the draft, so each element read takes + the `get` trap and its parent edge. `draft[Symbol.iterator]()` now returns a + native Array Iterator. +- The `set` trap's revert path sets `modified` and calls `checkParentStatus` on + its own tracker. That function already clears tracking and continues up the + chain when every value is reverted. +- `draftValuesEqual` becomes a `draft` mode of `deepEqualsInternal` in + `packages/db/src/utils.ts`. Draft mode keeps Map and Set order, RegExp + `lastIndex`, and array holes. The general mode does not change. + +The audit prototype also rewrote `deepClone` as one `Reflect.ownKeys` loop. That +loop made every draft 8% to 17% slower, so this change keeps the original +two-loop form. See Performance. + +| Consumer bundle (esbuild, `es2020`) | min | gzip | brotli | +| --- | ---: | ---: | ---: | +| full public API | −1,756 (−0.50%) | −453 (−0.43%) | −197 (−0.22%) | +| collection with a filtered live query | −1,756 (−0.66%) | −461 (−0.58%) | −278 (−0.41%) | +| local-only collection with an insert, an update, and a delete | −1,753 (−1.43%) | −444 (−1.27%) | −251 (−0.81%) | +| local-only collection with one insert | −1,753 (−1.43%) | −442 (−1.26%) | −330 (−1.07%) | + +Each row bundles the built `dist`, with private members renamed, for base and +refactor under the same `package.json`. + +## Why the oracle work came first + +Sixteen source mutants on unchanged code model plausible mistakes in this +refactor. Before this work, eight of them passed the whole db suite (7,596 +tests): + +- a partial revert treated as a full revert, which drops the remaining change. +- a nested write under a symbol key treated as no change. +- `deepClone` copying non-enumerable string keys, or dropping enumerable or + non-enumerable symbol keys. +- `deepEquals` comparing RegExp `lastIndex`, or treating array holes as + differences. + +A ninth (`deepEquals` comparing Maps in order) failed one unrelated test only. + +### The draft revert oracle + +`packages/db/tests/proxy-revert-oracle.property.test.ts` generates write +histories on a draft of a three-field row. An operation sets a field to a new +value, reverts it to its original, deletes it, writes a nested property (a +string or symbol key), writes an array index, or writes a row property through +`for...of`. Values are primitives (including `-0` and `NaN`), Dates, RegExps, +Maps (with primitive or Set values), Sets, arrays with holes, objects with a +symbol key, and arrays of rows. + +The model applies the same operations to plain-data specs and compares values +with its own encoding of draft equality. It never reads a draft. After each +history, the check asserts: + +- `getChanges()` holds exactly the fields whose final value differs from the + original, each with that value, and deleted fields as `undefined`. +- The draft reads back the model's final value. +- The original row is unchanged. + +Each run executes a fixed campaign (seed `2026101`) and a random campaign of +400 histories. `TANSTACK_DB_PROXY_REVERT_SEED` and +`TANSTACK_DB_PROXY_REVERT_PATH` select a replay. A positive witness requires +the fixed campaign to reach every operation, more than 20 partial reverts, +more than 20 full reverts, and a change made only under a nested symbol key. +Eleven pinned histories hold one rule each. + +Limits: + +- Map, Set, and array mutator methods (`set`, `add`, `push`) mark a value + changed without a revert check. The native-operation tests in + `proxy.test.ts` own them. +- Top-level symbol writes are outside the grammar. `getChanges()` does not + report them, and the coverage map lists symbol writes as unsupported. + +### Clone key classes and `deepEquals` order rules + +- `proxy-detachment-contract.test.ts` pins that a draft copies a row's own + enumerable string keys and every own symbol key, enumerable or not, and + does not copy non-enumerable string keys. This is current behavior, not a + promise. +- `utils.property.test.ts` adds four generated laws: `deepEquals` ignores Map + and Set insertion order, RegExp `lastIndex`, and array holes. These are the + rules draft equality keeps, so a shared walker must not leak one mode into + the other. + +## Mutant calibration + +"Owners" are the revert oracle and the four existing owners +(`proxy.test.ts`, `proxy-detachment-contract.test.ts`, +`proxy-iteration-contract.test.ts`, `utils.property.test.ts`). "Before" is the +owners without this change's tests. The full-suite column shows whether any +other db test caught the mutant. + +### On unchanged production (`18abceee4`) + +| Mutant | Before | Full suite before | Owners now | +| --- | --- | --- | --- | +| P1: a nested revert does not reach the parent | 20/417 | — | assertion failure (21) | +| P2: a partial revert is treated as full | 0/417 | 0/7,596 | assertion failure (3) | +| P3: the array iterator yields raw elements | 1/417 | — | assertion failure (4) | +| P4: the array iterator records a wrong parent edge | 0/417 | 0/7,596 | survives (equivalent) | +| C1: `deepClone` copies non-enumerable strings | 0/417 | 0/7,596 | assertion failure (2) | +| C2: `deepClone` drops symbol keys | 0/417 | 0/7,596 | assertion failure (4) | +| C3: `deepClone` drops non-enumerable symbols | 0/417 | 0/7,596 | assertion failure (2) | +| E1: draft Set comparison ignores order | 2/417 | — | assertion failure (3) | +| E2: draft Map comparison ignores order | 2/417 | — | assertion failure (3) | +| E3: draft RegExp ignores `lastIndex` | 2/417 | — | assertion failure (3) | +| E4: a draft array hole equals `undefined` | 1/417 (crash) | — | assertion failure (3) | +| E5: draft comparison ignores symbol keys | 0/417 | 0/7,596 | assertion failure (2) | +| D1: `deepEquals` compares Maps in order | 0/417 | 1/7,596 | assertion failure (2) | +| D2: `deepEquals` compares RegExp `lastIndex` | 0/417 | 0/7,596 | assertion failure (2) | +| D3: `deepEquals` compares Sets in order | 3/417 | — | assertion failure (5) | +| D4: `deepEquals` treats array holes as differences | 0/417 | 0/7,596 | assertion failure (2) | + +P4 is equivalent under every public observation. An array element's parent +edge feeds only the array's own revert check, and `getChanges()` works at the +row level, where it compares each field with draft equality. A wrong edge can +make the array mark itself reverted early, but the row then compares the field +by value and still reports the change. The refactor's native iterator records +the right edge through the `get` trap. + +### On the refactor (`6b68b8dc3`) + +| Mutant | Owners | +| --- | --- | +| N1: a nested revert does not reach the parent | assertion failure (25/441) | +| N2: a partial revert in the `set` trap clears all tracking | assertion failure (26/441) | +| N3: the array iterator binds the raw copy | assertion failure (4/441) | +| N4: `deepClone` drops non-enumerable symbols | assertion failure (2/441) | +| N4b: `deepClone` drops every symbol | assertion failure (4/441) | +| N5: `deepClone` copies non-enumerable strings | assertion failure (2/441) | +| N6: draft Map comparison ignores order | assertion failure (3/441) | +| N7: draft Set comparison ignores order | assertion failure (3/441) | +| N8: draft RegExp ignores `lastIndex` | assertion failure (3/441) | +| N9: draft arrays use the element walk | assertion failure (2/441) | +| N10: general mode compares `lastIndex` | assertion failure (2/441) | +| N11: general mode compares Maps in order | assertion failure (2/441) | +| N12: general arrays use the key walk | assertion failure (4/441) | +| N13: draft mode is not passed into object values | assertion failure (1/441) | +| N14: draft mode is not passed into Map values | assertion failure (1/441) | + +N14 first survived, because generated Map values were only primitives. +`639ed5a1e` lets Map values be nested Sets and adds a pinned case for a +reordered Set inside a Map value. + +## Performance + +Each timing process loads one implementation and runs each workload five +times after two warm-up passes. Processes alternate, and each ratio is the +median of 11 processes of each. An A/A control of two copies of the base +stayed within ±1%. + +| Workload | A/A | Prototype loop | Kept loop | +| --- | ---: | ---: | ---: | +| draft: two writes, then `getChanges` (20,000 rows) | 1.010 | 1.012 | 0.827 | +| draft: a write, then a revert (20,000 rows) | 1.006 | 1.163 | 0.900 | +| draft: Map, Set, and array writes (10,000 rows) | 1.005 | 0.910 | 0.772 | +| `deepEquals`: equal rows (20,000) | 0.993 | 0.953 | 0.965 | +| `deepEquals`: equal rows with Map, Set, RegExp (10,000) | 1.010 | 1.019 | 1.023 | +| `deepEquals`: unequal rows (20,000) | 0.999 | 1.024 | 1.009 | + +Each ratio is new time over base time. "Prototype loop" is the audit prototype. +Bisection showed that its `deepClone` loop caused the regression: the +equality fold alone was faster (0.92, 1.00, 0.81 on the draft rows), and the +other proxy changes with the original loop were faster too (0.92, 0.93, 1.03). +`deepClone` runs twice for each draft, and the `Reflect.ownKeys` loop calls +`propertyIsEnumerable` for each key. Keeping the two-loop form costs about 18 +minified bytes. The harness is not checked in. + +## ORC-001 through ORC-012 + +| Requirement | Result | +| --- | --- | +| ORC-001 | The revert oracle's opening comment states the `getChanges()` contract, the six draft-equality rules, the three laws, the authority, and the limits. | +| ORC-002 | The model is an encoding of plain-data specs. It does not import `draftValuesEqual`, `deepEquals`, or `deepClone`, and it never reads a draft. | +| ORC-003 | The contract, model (`canon`, `step`, `expectedChanges`), grammar (`specArb`, `opArb`, `historyArb`), driver (`drive`, `realize`), and check (`expectHistory`) are separate in one file. | +| ORC-004 | Pinned histories reconstruct each rule. The mutants above are the ablations. The positive witness is the range check. The grammar excludes mutator methods and top-level symbol writes, as declared. | +| ORC-005 | The driver uses `createChangeProxy`. The positive witness shows that the fixed campaign reaches every operation and both revert outcomes. | +| ORC-006 | Every mutant has an outcome class. P4 is recorded as equivalent with its reason. | +| ORC-007 | Fixed and random campaigns share the property, grammar, check, and budget (400 runs). The replay variables select a direct replay. | +| ORC-008 | The model is a stateless recomputation over specs, so this requirement does not apply. | +| ORC-009 | Terms match `proxy.ts` and `utils.ts`: draft, revert, changes, draft equality. Spec kinds such as `rows` are model-only and named in the oracle. | +| ORC-010 | fast-check reports the seed, path, and shrunk history. Assertion messages give the field and the full history. | +| ORC-011 | No shared semantic fault calls for a second formulation. | +| ORC-012 | This record ties the reviewed revisions, outcome classes, limits, and performance evidence to the coverage map. | + +## Verification + +On `6b68b8dc3`, with the built `dist`: + +- `packages/db` Vitest, typecheck off: 198 files, 7,620 tests. +- `packages/db` `tsc --noEmit`: only the pre-existing errors in + `tests/conformance` that `main` also has. +- `pnpm check:mangle`: 368 names. +- `pnpm test:minified-db`: error names, index metadata, query rows, and live + updates. +- `pnpm --filter @tanstack/db test:dist`: 5 files, 290 tests. + +The environment was Node `v24.19.0` and Vitest `3.2.4` on Darwin arm64. From cccaf1b9e8024d745677785b86de81fd70734022 Mon Sep 17 00:00:00 2001 From: Isaac Date: Thu, 1 Oct 2026 09:33:16 -0600 Subject: [PATCH 05/28] fix(db): return functions stored in a draft as stored The draft get trap bound every function it returned to the private copy. A function stored as data (a field, an array element, a nested object member) then lost its identity, so draft.handler === handler was false. A stored method saw the private copy as `this`, so its writes were not tracked and getChanges() dropped them. On main, array iteration was the one read path that kept identity. Own data functions are now returned as stored. Inherited Array, Map, and Set methods keep their draft handling. A native-differential law in proxy.test.ts checks 13 probes and the write-through-this case. It fails on main (8 of 14 cases) and on the refactor before this fix (10 of 14 cases). Co-authored-by: Isaac --- packages/db/src/proxy.ts | 5 ++ packages/db/tests/proxy.test.ts | 93 +++++++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+) diff --git a/packages/db/src/proxy.ts b/packages/db/src/proxy.ts index 96b331abe..764a2273f 100644 --- a/packages/db/src/proxy.ts +++ b/packages/db/src/proxy.ts @@ -468,6 +468,11 @@ export function createChangeProxy< // If the value is a function, bind it to the ptarget if (typeof value === `function`) { + // A function stored as data is returned as stored, like any value. A + // call then sees the draft as `this`, so its writes are tracked. Only + // inherited methods (Array, Map, Set) need the handling below. + if (Object.hasOwn(ptarget, prop)) return value + // For Array methods that modify the array if (Array.isArray(ptarget)) { const methodName = prop.toString() diff --git a/packages/db/tests/proxy.test.ts b/packages/db/tests/proxy.test.ts index 2678cad54..ef9e5d214 100644 --- a/packages/db/tests/proxy.test.ts +++ b/packages/db/tests/proxy.test.ts @@ -2430,3 +2430,96 @@ describe(`Proxy Library`, () => { }) }) }) + +// A function stored as data is a value like any other. Reading it from a draft +// must give back the stored function, by any read path, as the native row +// does. Calling a stored method must see the draft as `this`, so its writes are +// tracked. Inherited methods (Array, Map, and Set methods) are not data and +// keep their own draft handling. +describe(`stored functions behave like native values`, () => { + type Row = { + handler: () => number + fns: Array<() => number> + obj: { g: () => number; count: number; bump: () => unknown } + m: Map number> + s: Set<() => number> + } + const make = (f: () => number, f2: () => number): Row => ({ + handler: f, + fns: [f, f2], + obj: { + g: f, + count: 0, + bump() { + this.count++ + return this + }, + }, + m: new Map([[`k`, f]]), + s: new Set([f]), + }) + + // Each probe returns an observation that must be the same for a native row + // and for a draft of an equal row. + const probes: Array<[string, (row: Row, f: () => number) => unknown]> = [ + [`field access`, (row, f) => row.handler === f], + [`array index`, (row, f) => row.fns[0] === f], + [ + `for...of`, + (row, f) => { + for (const fn of row.fns) return fn === f + return undefined + }, + ], + [`spread`, (row, f) => [...row.fns][0] === f], + [ + `includes and indexOf`, + (row, f) => [row.fns.includes(f), row.fns.indexOf(f)], + ], + [`array callback`, (row, f) => row.fns.map((fn) => fn === f)], + [`nested field`, (row, f) => row.obj.g === f], + [`Object.values`, (row, f) => Object.values(row.obj).includes(f)], + [`Map value`, (row, f) => row.m.get(`k`) === f], + [`Set member`, (row, f) => [...row.s][0] === f && row.s.has(f)], + [`calling a stored function`, (row) => row.handler()], + [ + `a function assigned during the callback`, + (row, f) => { + const assigned = row as Row & { added?: () => number } + assigned.added = f + return assigned.added === f + }, + ], + [ + `a stored method sees its object as this`, + (row) => row.obj.bump() === row.obj, + ], + ] + + it.each(probes)(`%s gives the native result`, (_name, probe) => { + const f = () => 1 + const f2 = () => 2 + const native = probe(make(f, f2), f) + const { proxy } = createChangeProxy(make(f, f2)) + expect(probe(proxy, f)).toEqual(native) + }) + + it(`tracks writes a stored method makes through this`, () => { + const native = make( + () => 1, + () => 2, + ) + native.obj.bump() + const { proxy, getChanges } = createChangeProxy( + make( + () => 1, + () => 2, + ), + ) + proxy.obj.bump() + expect(proxy.obj.count).toBe(native.obj.count) + const changes = getChanges() as Partial + expect(Object.keys(changes)).toEqual([`obj`]) + expect(changes.obj?.count).toBe(1) + }) +}) From 721a5091b94ec46f5a39852cdc27f03a08be9529 Mon Sep 17 00:00:00 2001 From: Isaac Date: Thu, 1 Oct 2026 09:35:46 -0600 Subject: [PATCH 06/28] docs: record the stored-function fix in the draft proxy review Add the stored-function behavior change and lost-write fix, its law and F1 to F3 mutants, the array-method timing, final bytes, and verification for cccaf1b9e. Update the changeset and the coverage map. Co-authored-by: Isaac --- .changeset/simplify-draft-proxy.md | 4 +- docs/contributing/oracle-coverage.md | 2 +- .../oracle-reviews/code-weight-draft-proxy.md | 65 +++++++++++++++++-- 3 files changed, 62 insertions(+), 9 deletions(-) diff --git a/.changeset/simplify-draft-proxy.md b/.changeset/simplify-draft-proxy.md index 33c5b1338..ccc00b77e 100644 --- a/.changeset/simplify-draft-proxy.md +++ b/.changeset/simplify-draft-proxy.md @@ -2,4 +2,6 @@ '@tanstack/db': patch --- -Simplify the mutation draft proxy and share one equality walker with `deepEquals`. Change tracking, revert detection, and `deepEquals` results do not change, and draft writes are faster. Iterating a draft array with `for...of` now uses the native Array Iterator. A typical app bundle is about 460 B smaller with gzip. +Simplify the mutation draft proxy and share one equality walker with `deepEquals`. Revert detection and `deepEquals` results do not change, and draft writes are faster. A typical app bundle is about 460 B smaller with gzip. + +A function stored in a row is now returned as stored when read from a draft, by any read path. Before, the draft returned a bound copy, so `draft.handler === handler` was false. A stored method also saw a private copy as `this`, so writes it made through `this` were not tracked and `collection.update` dropped them. Those writes are now tracked. diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index bdd6778eb..fb6420ee7 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -241,7 +241,7 @@ comment and the current API/architecture contract before extending its model. | Collection lifecycle | [mutation startup](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-mutation-startup-oracle.test.ts), [change-event history](https://github.com/TanStack/db/blob/main/packages/db/tests/change-event-history-oracle.test.ts), [history](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-subscription-lifecycle-history.property.test.ts), [publication](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-subscription-lifecycle-publication.property.test.ts), [replay](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-subscription-replay-oracle.property.test.ts), [effect disposal](https://github.com/TanStack/db/blob/main/packages/db/tests/effect-disposal-oracle.test.ts), [PR #1902 review](oracle-reviews/pr-1902-change-event-history.md), [batch-order decision review](oracle-reviews/change-event-batch-order.md) | Core Collection `insert`/`update`/`delete` admission while `startSync:false` is idle; complete public-row/change-message agreement across bounded one- and two-key histories plus replayable four-key campaigns; a two-key deferred sync history checks each key's causal insert/update/delete trace while allowing any cross-key interleaving. Reversing all deferred messages fails that check; reversing each sync transaction's distinct-key messages passes. Queued duplicate admission and cancellation, ownership and phase histories, exact caller/error/publication evidence, and late completion and restart have separate witnesses. Generated deferred histories with cancellation or reentrant callbacks need a witness in the change-event history owner before claiming general batch-shape coverage. Query write utilities and effect self-dependent disposal remain separate contracts. | | Paced mutations | [virtual-clock oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/paced-mutations-oracle.test.ts) | `createPacedMutations` with queue front/back options, bounded waiting capacity at zero and one, cleanup draining in FIFO/LIFO order after a held write, cleanup timing at the configured wait for immediate writes, admitted writes after separate Collection cleanup, debounce and throttle schedules with explicit and omitted options, and first-leading throttle execution at epoch zero. Finite histories compare optimistic rows, returned transaction identity and receipt outcome, execution order and virtual-clock time. Held writes verify queue serialization. Leading-only throttle and debounce witnesses reject skipped calls immediately with their named dropped-call errors, including omitted edges, both edges disabled, and same-row rollback while a prior write is held. Capacity witnesses cover overflow rejection, distinct-key optimistic rollback, same-key admitted-write survival, and in-flight versus waiting admission. Cleanup witnesses check repeated queue cleanup, eventual admitted receipt settlement, direct queue strategy rejection of new callbacks, public post-cleanup rollback with `QueueDisposedError`, pending debounce transactions that wait until the last call's quiet edge after separate Collection cleanup, and a trailing throttle timer that runs at its regular edge after separate Collection cleanup. Deferred custom queue and batch callbacks check compatibility with `void` and `false` execute results, respectively. Frozen options verify factory non-mutation; a mutable option witness checks that debounce uses its construction-time trailing setting. New debounce/throttle admission after cleanup, failed persistence, broader capacities and schedules remain outside this owner. | | Optimistic state | [history model](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-history-oracle.ts), [generated histories](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-transaction-oracle.property.test.ts), [truncate capture ownership](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-truncate-ownership-oracle.property.test.ts), [outcomes](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-history-outcomes.test.ts), [publication](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-history-publication.test.ts) | Independent whole-row snapshots, rollback dependencies, captured truncate ownership, metadata and prior-value events. Never rebase a pending snapshot merely to simplify the model. | -| Drafts and native values | [proxy](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy.test.ts), [detachment](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-detachment-contract.test.ts), [iteration](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-iteration-contract.test.ts), [revert](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-revert-oracle.property.test.ts) | Native-operation controls, exact patches and actual stored rows; alias/cycle/adversarial-key histories. Set and Map reorder histories check draft patches and stored iteration order after replacement and clear-and-readd; RegExp replacement/reversion histories check `lastIndex`. General native-mutator and symbol-write support is not established by a plain-object oracle. The revert owner generates assignment, revert, delete, nested, symbol-key, and `for...of` histories and checks `getChanges()` and the draft against an independent draft-equality model: ordered Map and Set contents (including Sets inside Map values), RegExp `lastIndex`, array holes, and symbol keys inside nested values. Mutator methods (`push`, `set`, `add`) and top-level symbol writes are outside its grammar. The detachment owner pins which key classes a draft copies: enumerable string keys and every symbol key ([draft proxy review](oracle-reviews/code-weight-draft-proxy.md)). | +| Drafts and native values | [proxy](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy.test.ts), [detachment](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-detachment-contract.test.ts), [iteration](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-iteration-contract.test.ts), [revert](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-revert-oracle.property.test.ts) | Native-operation controls, exact patches and actual stored rows; alias/cycle/adversarial-key histories. Set and Map reorder histories check draft patches and stored iteration order after replacement and clear-and-readd; RegExp replacement/reversion histories check `lastIndex`. General native-mutator and symbol-write support is not established by a plain-object oracle. The revert owner generates assignment, revert, delete, nested, symbol-key, and `for...of` histories and checks `getChanges()` and the draft against an independent draft-equality model: ordered Map and Set contents (including Sets inside Map values), RegExp `lastIndex`, array holes, and symbol keys inside nested values. Mutator methods (`push`, `set`, `add`) and top-level symbol writes are outside its grammar. The proxy owner's stored-function law checks, against a native row, that a function stored as data reads back as stored by every read path and that a stored method sees the draft as `this`, so its writes are tracked. The detachment owner pins which key classes a draft copies: enumerable string keys and every symbol key ([draft proxy review](oracle-reviews/code-weight-draft-proxy.md)). | | Query DB and observer | [ownership](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/ownership-lifecycle.oracle.test.ts), [load lifecycle](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/load-subset-lifecycle-oracle.test.ts), [observer histories](https://github.com/TanStack/db/blob/main/packages/db/tests/live-query-observer-history.property.test.ts) | Real QueryClient boundary, applied-settlement barriers for existing and cached demand, mutation-handler Collection identity, terminal fetch-record lifecycle, and a per-listener eligibility ledger, not a duplicate dispatch queue. The bounded handler grammar crosses insert, update, delete, parameter and mutation-alias access, refetch, and clearError. A held clearError retry retains the prior public error through initial, background, and Collection application failures; success clears it, failure advances the count, including a repeated error at clock time zero. An intermediate Query retry attempt does not recount the previous background error; a held final attempt can succeed or fail. A held application after successful Query fetch keeps the error visible until the applied result. A mocked persisted-baseline scan holds an older success while a newer terminal Query failure arrives; the newer public error and count survive application, including when the Error object and clock tick repeat. Native SQLite persistence, concurrent retries across multiple tracked Queries, mutation-handler fetch-boundary settlement, and deferred result application remain outside this witness. The mutation overlap grammar crosses both start orders. Check reentry, peer survival, FIFO and disposal independently of final rows. | | Query DB SSR dehydration | `packages/query-db-collection/tests/ssr-dehydration-oracle.test.ts`, [review record](oracle-reviews/issue-1950-ssr-dehydration.md) | A plain Query cache control, a live on-demand compound predicate, a signal-only request, and an ordered cursor with a custom comparator all retain their row through QueryClient dehydration, reconstruction of Seroval's emitted stream payload, and Query Core hydration from that payload. The on-demand query functions still receive the original IR, comparator, and signal; serialized metadata retains enumerable user metadata but excludes request options. The original implementation fails the compound and signal cases at the stream checkpoint. A serializable stream-only request-options leak passes the former chunk-count check but fails the current payload assertion. This owner covers successful Query cache entries at the installed Query Core 5.90.20 and Seroval 1.5.0 versions. A TanStack Start integration owner still needs the reporter's Router 1.171.33 and Seroval 1.6.8 path, browser Collection resume/refetch, and request cancellation across SSR. Query subset and pagination oracles remain owners of predicate/order/cursor meaning and supported value types. The issue's `shouldDehydrateQuery` workaround replaces Query Core's success-only default and admits pending/error queries; any documented workaround must preserve that default filter. | | Ordered acquisition | [pagination](https://github.com/TanStack/db/blob/main/packages/db/tests/query/pagination-oracle.property.test.ts), [ordered work](https://github.com/TanStack/db/blob/main/packages/db/tests/query/ordered-work-oracle.property.test.ts), [ordered lifecycle](https://github.com/TanStack/db/blob/main/packages/db/tests/query/ordered-lifecycle-oracle.property.test.ts), [ordered loader state](https://github.com/TanStack/db/blob/main/packages/db/tests/query/ordered-source-loader-state.test.ts), [graph scheduler](https://github.com/TanStack/db/blob/main/packages/db/tests/query/scheduler.test.ts), [issue #1880 ordered-repair review](oracle-reviews/issue-1880-ordered-repair.md), [issue #1882 custom local collation review](oracle-reviews/issue-1882-custom-local-collation.md), [issue #1898 relation-filter review](oracle-reviews/issue-1898-relation-filter.md), [PR #1909 external review](oracle-reviews/pr-1909-external-review.md) | Complete finite provider results, inherited locale and bounded/unbounded custom collation with exact request options, query-level inheritance across source aliases, real lexical/numeric disagreement, custom comparator ties, scan/index paths, pending windows, ties/nulls, lease ownership, Effect callback gates, including an indexed source update during Effect startup, and documented repair timing. Direct LEFT-joined filters cover local finite prefixes, pending child demand, child removal, and anti-join publication with custom full-source and unindexed fallback loading; unrelated include demand cannot stall the root continuation, and an empty joined replay wakes held Effect callbacks; remote relation hints remain outside this owner. Graph scheduler checks coalescing, dependency order, synchronous loader input, and repeated-alias failure ownership. The ordered-work oracle checks that a synchronous root refill reaches D2 before joined publication; a mutant that omitted the post-loader graph step failed at the second child demand. Custom collation proves local full-source acquisition only; it does not establish provider cursor capability or backend collation fidelity. Sibling-source input during a synchronous ordered continuation returns to graph work before another ordered acquisition. Request completion is not proof of unrequested source extent. A window move during existing joined demand waits for its root continuation, including an independently held root request; current demand failure rejects the move, while retirement and an obsolete rejection do not. A bounded ordered repair resumes its refill after joined demand settles. The ordered-source-loader state test checks that an explicit failed-request retry blocked by joined demand retains its generation until it can issue a request. Multiple simultaneously pending joined plans during a window move and a public-query failed-retry overlap still need dedicated witnesses. | diff --git a/docs/contributing/oracle-reviews/code-weight-draft-proxy.md b/docs/contributing/oracle-reviews/code-weight-draft-proxy.md index b73d4e1b2..ee66cae2a 100644 --- a/docs/contributing/oracle-reviews/code-weight-draft-proxy.md +++ b/docs/contributing/oracle-reviews/code-weight-draft-proxy.md @@ -1,6 +1,6 @@ # Code weight: simplify the draft proxy and share its equality walker -Reviewed executable revision: `6b68b8dc3` (base `18abceee4`, the fetched +Reviewed executable revision: `cccaf1b9e` (base `18abceee4`, the fetched `origin/main` at review time). This record follows in a documentation-only commit. @@ -9,6 +9,8 @@ commit. - `639ed5a1e` extends the revert oracle after a refactor mutant survived. The extension also passes on unchanged code. - `6b68b8dc3` is the refactor. +- `cccaf1b9e` returns functions stored in a draft as stored, with a new + native-differential law. This is a behavior change and a lost-write fix. ## Change @@ -30,16 +32,19 @@ commit. `packages/db/src/utils.ts`. Draft mode keeps Map and Set order, RegExp `lastIndex`, and array holes. The general mode does not change. +- A function stored as data (a field, an array element, a nested object + member) is now returned as stored. See Stored functions. + The audit prototype also rewrote `deepClone` as one `Reflect.ownKeys` loop. That loop made every draft 8% to 17% slower, so this change keeps the original two-loop form. See Performance. | Consumer bundle (esbuild, `es2020`) | min | gzip | brotli | | --- | ---: | ---: | ---: | -| full public API | −1,756 (−0.50%) | −453 (−0.43%) | −197 (−0.22%) | -| collection with a filtered live query | −1,756 (−0.66%) | −461 (−0.58%) | −278 (−0.41%) | -| local-only collection with an insert, an update, and a delete | −1,753 (−1.43%) | −444 (−1.27%) | −251 (−0.81%) | -| local-only collection with one insert | −1,753 (−1.43%) | −442 (−1.26%) | −330 (−1.07%) | +| full public API | −1,725 (−0.49%) | −451 (−0.43%) | −246 (−0.28%) | +| collection with a filtered live query | −1,725 (−0.65%) | −457 (−0.58%) | −302 (−0.44%) | +| local-only collection with an insert, an update, and a delete | −1,722 (−1.41%) | −437 (−1.25%) | −270 (−0.87%) | +| local-only collection with one insert | −1,722 (−1.41%) | −436 (−1.24%) | −276 (−0.89%) | Each row bundles the built `dist`, with private members renamed, for base and refactor under the same `package.json`. @@ -104,6 +109,47 @@ Limits: rules draft equality keeps, so a shared walker must not leak one mode into the other. +## Stored functions + +The `get` trap bound every function it returned to the private copy, so it +could call built-in methods such as `Map.prototype.get`, which reject a Proxy +receiver. That binding also applied to functions stored as data: + +| Read on a draft of `{ handler: f, fns: [f], obj: { g: f } }` | `main` | refactor before the fix | now | +| --- | --- | --- | --- | +| `draft.handler === f` | false | false | true | +| `draft.fns[0] === f` | false | false | true | +| `[...draft.fns][0] === f`, `for...of` | true | false | true | +| `draft.obj.g === f` | false | false | true | + +On `main`, array iteration was the only read path that kept identity, because +the custom array iterator did not take the `get` trap. The native iterator +does, so the refactor first lost identity there too. + +The binding had a worse effect: a stored method saw the private copy as `this`. +A method such as `bump() { this.count++ }` then changed the copy without +tracking, and `getChanges()` reported no change. Inside `collection.update`, +that write was lost. + +`cccaf1b9e` returns an own data function as stored. A call then sees the draft +as `this`, so its writes take the `set` trap. Inherited Array, Map, and Set +methods keep their draft handling. + +`proxy.test.ts` adds a native-differential law. Each probe runs on a native row +and on a draft of an equal row, and the results must be equal. The probes are +field access, array index, `for...of`, spread, `includes` and `indexOf`, an +array callback, a nested field, `Object.values`, a Map value, a Set member, a +call, a function assigned during the callback, and `this` inside a stored +method. One more case checks that a write through `this` appears in +`getChanges()`. The law fails 8 of 14 cases on `main` and 10 of 14 on the +refactor before the fix. + +| Mutant on `cccaf1b9e` | Owners | +| --- | --- | +| F1: every function is bound again | assertion failure (10/455) | +| F2: inherited methods are returned unbound too | assertion failure (103/455) | +| F3: the own-property check reads the original row | assertion failure (1/455) | + ## Mutant calibration "Owners" are the revert oracle and the four existing owners @@ -179,8 +225,13 @@ stayed within ±1%. | `deepEquals`: equal rows (20,000) | 0.993 | 0.953 | 0.965 | | `deepEquals`: equal rows with Map, Set, RegExp (10,000) | 1.010 | 1.019 | 1.023 | | `deepEquals`: unequal rows (20,000) | 0.999 | 1.024 | 1.009 | +| draft: 20 Array method calls (10,000 rows) | 1.021 | — | 0.961 | Each ratio is new time over base time. "Prototype loop" is the audit prototype. +"Kept loop" is the final code. The array-method row was measured with the +stored-function fix, which adds an own-property check before each inherited +method is handled. A second run with the fix gave 0.830, 0.876, and 0.766 for +the three draft rows above. Bisection showed that its `deepClone` loop caused the regression: the equality fold alone was faster (0.92, 1.00, 0.81 on the draft rows), and the other proxy changes with the original loop were faster too (0.92, 0.93, 1.03). @@ -207,9 +258,9 @@ minified bytes. The harness is not checked in. ## Verification -On `6b68b8dc3`, with the built `dist`: +On `cccaf1b9e`, with the built `dist`: -- `packages/db` Vitest, typecheck off: 198 files, 7,620 tests. +- `packages/db` Vitest, typecheck off: 198 files, 7,634 tests. - `packages/db` `tsc --noEmit`: only the pre-existing errors in `tests/conformance` that `main` also has. - `pnpm check:mangle`: 368 names. From 1a07cd3ce80cac32e0e2d6ebbe08db3460c49620 Mon Sep 17 00:00:00 2001 From: Isaac Date: Thu, 1 Oct 2026 09:51:02 -0600 Subject: [PATCH 07/28] Apply code simplification and review fixes - proxy-revert-oracle: count only reverts of a changed field in the witness, generate reverts of currently changed fields, and add a partial-revert property. The first witness counted no-op reverts. - proxy.ts: merge a duplicated comment and drop a key-in check in getChanges that for...in already guarantees. Co-authored-by: Isaac --- packages/db/src/proxy.ts | 19 ++-- .../proxy-revert-oracle.property.test.ts | 90 ++++++++++++++++--- 2 files changed, 85 insertions(+), 24 deletions(-) diff --git a/packages/db/src/proxy.ts b/packages/db/src/proxy.ts index 764a2273f..17302ebf4 100644 --- a/packages/db/src/proxy.ts +++ b/packages/db/src/proxy.ts @@ -287,8 +287,8 @@ function deepClone( visited.set(obj as object, clone) // Own enumerable string keys, then every own symbol key, in native order. - // Own enumerable string keys, then every own symbol key. A Reflect.ownKeys - // loop with an enumerability check is shorter but slower on this hot path. + // A Reflect.ownKeys loop with an enumerability check is shorter but slower + // on this hot path. for (const key in obj) { if (Object.prototype.hasOwnProperty.call(obj, key)) { // Copy data properties without invoking Object.prototype.__proto__. @@ -720,14 +720,13 @@ export function createChangeProxy< // Compare child contents, stopping only at paired root backedges. A // child's own changes still count even when it also points to this row. if ( - (changeTracker.assigned_[key] === true || - (mayHaveChangedAliases && - !draftValuesEqual( - value instanceof Set ? Array.from(value) : value, - original instanceof Set ? Array.from(original) : original, - pairedRoots, - ))) && - key in changeTracker.copy_ + changeTracker.assigned_[key] === true || + (mayHaveChangedAliases && + !draftValuesEqual( + value instanceof Set ? Array.from(value) : value, + original instanceof Set ? Array.from(original) : original, + pairedRoots, + )) ) { defineDataProperty(result, key, changeTracker.copy_[key]) } diff --git a/packages/db/tests/proxy-revert-oracle.property.test.ts b/packages/db/tests/proxy-revert-oracle.property.test.ts index 467529941..284902bab 100644 --- a/packages/db/tests/proxy-revert-oracle.property.test.ts +++ b/packages/db/tests/proxy-revert-oracle.property.test.ts @@ -254,15 +254,64 @@ const opArb: fc.Arbitrary = fc.oneof( }, ) -type History = { original: Root; ops: Array } +// Generated ops also include a revert of a field that is currently changed, +// so most reverts run the set trap's revert branch. `resolve` turns it into a +// concrete revert of one changed field, or nothing. +type GeneratedOp = Op | { op: `revertChanged`; pick: number } +type History = { original: Root; ops: Array } + +function resolve(state: Root, original: Root, op: GeneratedOp): Op | undefined { + if (op.op !== `revertChanged`) return op + const changed = FIELDS.filter( + (field) => encode(state[field]) !== encode(original[field]), + ) + return changed.length > 0 + ? { op: `revert`, field: changed[op.pick % changed.length]! } + : undefined +} const historyArb: fc.Arbitrary = fc.record({ original: fc.record( { f: specArb, g: specArb, h: specArb }, { requiredKeys: [] }, ), - ops: fc.array(opArb, { minLength: 1, maxLength: 6 }), + ops: fc.array( + fc.oneof( + { weight: 3, arbitrary: opArb as fc.Arbitrary }, + { + weight: 2, + arbitrary: fc.record({ + op: fc.constant(`revertChanged` as const), + pick: fc.nat(2), + }), + }, + ), + { minLength: 1, maxLength: 8 }, + ), }) +// Partial reverts by construction: change two or three fields in some order, +// then revert every changed field but one. The model decides whether a change +// survives (a new value can equal the original). +const partialRevertArb: fc.Arbitrary = fc + .record({ + original: fc.record({ f: specArb, g: specArb, h: specArb }), + values: fc.tuple(specArb, specArb, specArb), + order: fc.shuffledSubarray([...FIELDS], { minLength: 2 }), + keep: fc.nat(2), + }) + .map(({ original, values, order, keep }) => { + const kept = order[keep % order.length]! + const changes: Array = order.map((field) => ({ + op: `set`, + field, + value: values[FIELDS.indexOf(field)]!, + })) + const reverts: Array = order + .filter((field) => field !== kept) + .map((field) => ({ op: `revert`, field })) + return { original, ops: [...changes, ...reverts] } + }) + // --------------------------------------------------------------------------- // Model: apply each op to the spec state. An op whose target has the wrong // shape does nothing, and the driver skips it the same way. @@ -430,8 +479,9 @@ function expectHistory({ original, ops }: History): void { const before = realizeRoot(original) const { proxy, getChanges } = createChangeProxy(row) let state: Root = { ...original } - for (const op of ops) { - if (!applicable(state, op)) continue + for (const generated of ops) { + const op = resolve(state, original, generated) + if (op === undefined || !applicable(state, op)) continue if (op.op === `revert`) { if (original[op.field] === undefined) delete proxy[op.field] else proxy[op.field] = realize(original[op.field]!) @@ -507,6 +557,13 @@ describe(`draft revert oracle`, () => { ...(replayPath === undefined ? {} : { path: replayPath }), }) }) + it(`matches the model across generated partial reverts (${name})`, () => { + fc.assert(fc.property(partialRevertArb, expectHistory), { + numRuns: 200, + ...(seed === undefined ? {} : { seed }), + ...(replayPath === undefined ? {} : { path: replayPath }), + }) + }) } // Positive execution witness: the fixed campaign reaches each operation, a @@ -520,18 +577,23 @@ describe(`draft revert oracle`, () => { let symbolOnly = 0 for (const { original, ops } of sample) { let state: Root = { ...original } - const applied: Array = [] - for (const op of ops) { - if (!applicable(state, op)) continue + // A revert counts only when the field differs from its original, so + // the set trap's revert branch runs. Other reverts write an equal value. + let effectiveReverts = 0 + for (const generated of ops) { + const op = resolve(state, original, generated) + if (op === undefined || !applicable(state, op)) continue reached.add(op.op) - applied.push(op) + if ( + op.op === `revert` && + encode(state[op.field]) !== encode(original[op.field]) + ) + effectiveReverts++ state = step(state, original, op) } const changed = expectedChanges(original, state).size - const reverts = applied.filter((op) => op.op === `revert`).length - const touched = new Set(applied.map((op) => op.field)).size - if (reverts > 0 && changed > 0 && touched > changed) partial++ - if (applied.length > 1 && reverts > 0 && changed === 0) full++ + if (effectiveReverts > 0 && changed > 0) partial++ + if (effectiveReverts > 0 && changed === 0) full++ for (const field of FIELDS) { const a = original[field] const b = state[field] @@ -555,8 +617,8 @@ describe(`draft revert oracle`, () => { `revert`, `set`, ]) - expect(partial).toBeGreaterThan(20) - expect(full).toBeGreaterThan(20) + expect(partial).toBeGreaterThanOrEqual(10) + expect(full).toBeGreaterThanOrEqual(30) expect(symbolOnly).toBeGreaterThan(0) }) From 0be15b3ded4cef9bfc0cb5333eba6926d28d9d39 Mon Sep 17 00:00:00 2001 From: Isaac Date: Thu, 1 Oct 2026 09:51:05 -0600 Subject: [PATCH 08/28] docs: tie the draft proxy review to the review fixes Record the effective-revert witness, the partial-revert property, the corrected survivor list, final bytes, and verification. Co-authored-by: Isaac --- .../oracle-reviews/code-weight-draft-proxy.md | 58 +++++++++++++------ 1 file changed, 40 insertions(+), 18 deletions(-) diff --git a/docs/contributing/oracle-reviews/code-weight-draft-proxy.md b/docs/contributing/oracle-reviews/code-weight-draft-proxy.md index ee66cae2a..addd55094 100644 --- a/docs/contributing/oracle-reviews/code-weight-draft-proxy.md +++ b/docs/contributing/oracle-reviews/code-weight-draft-proxy.md @@ -1,6 +1,6 @@ # Code weight: simplify the draft proxy and share its equality walker -Reviewed executable revision: `cccaf1b9e` (base `18abceee4`, the fetched +Reviewed executable revision: `1a07cd3ce` (base `18abceee4`, the fetched `origin/main` at review time). This record follows in a documentation-only commit. @@ -11,6 +11,9 @@ commit. - `6b68b8dc3` is the refactor. - `cccaf1b9e` returns functions stored in a draft as stored, with a new native-differential law. This is a behavior change and a lost-write fix. +- `1a07cd3ce` applies review fixes: a stronger revert grammar and witness, a + duplicate comment, and a redundant `key in` check in `getChanges` (about 14 + minified bytes). ## Change @@ -41,10 +44,10 @@ two-loop form. See Performance. | Consumer bundle (esbuild, `es2020`) | min | gzip | brotli | | --- | ---: | ---: | ---: | -| full public API | −1,725 (−0.49%) | −451 (−0.43%) | −246 (−0.28%) | -| collection with a filtered live query | −1,725 (−0.65%) | −457 (−0.58%) | −302 (−0.44%) | -| local-only collection with an insert, an update, and a delete | −1,722 (−1.41%) | −437 (−1.25%) | −270 (−0.87%) | -| local-only collection with one insert | −1,722 (−1.41%) | −436 (−1.24%) | −276 (−0.89%) | +| full public API | −1,739 (−0.50%) | −453 (−0.43%) | −145 (−0.16%) | +| collection with a filtered live query | −1,739 (−0.65%) | −461 (−0.58%) | −356 (−0.52%) | +| local-only collection with an insert, an update, and a delete | −1,736 (−1.42%) | −441 (−1.26%) | −256 (−0.83%) | +| local-only collection with one insert | −1,736 (−1.42%) | −439 (−1.25%) | −295 (−0.95%) | Each row bundles the built `dist`, with private members renamed, for base and refactor under the same `package.json`. @@ -53,14 +56,18 @@ refactor under the same `package.json`. Sixteen source mutants on unchanged code model plausible mistakes in this refactor. Before this work, eight of them passed the whole db suite (7,596 -tests): +tests). Seven are real gaps: -- a partial revert treated as a full revert, which drops the remaining change. -- a nested write under a symbol key treated as no change. +- A partial revert treated as a full revert, which drops the remaining change + (P2). +- A nested write under a symbol key treated as no change (E5). - `deepClone` copying non-enumerable string keys, or dropping enumerable or - non-enumerable symbol keys. + non-enumerable symbol keys (C1, C2, C3). - `deepEquals` comparing RegExp `lastIndex`, or treating array holes as - differences. + differences (D2, D4). + +The eighth, a wrong parent edge for array iterator elements (P4), is +equivalent under every public observation. See Mutant calibration. A ninth (`deepEquals` comparing Maps in order) failed one unrelated test only. @@ -83,12 +90,27 @@ history, the check asserts: - The draft reads back the model's final value. - The original row is unchanged. -Each run executes a fixed campaign (seed `2026101`) and a random campaign of -400 histories. `TANSTACK_DB_PROXY_REVERT_SEED` and -`TANSTACK_DB_PROXY_REVERT_PATH` select a replay. A positive witness requires -the fixed campaign to reach every operation, more than 20 partial reverts, -more than 20 full reverts, and a change made only under a nested symbol key. -Eleven pinned histories hold one rule each. +The generator also emits a revert of a field that is currently changed, so +most reverts run the `set` trap's revert branch. A second property builds +partial reverts directly: it changes two or three fields in any order, then +reverts all of them but one. + +Each property runs a fixed campaign (seed `2026101`) and a random campaign: +400 general histories and 200 partial-revert histories. +`TANSTACK_DB_PROXY_REVERT_SEED` and `TANSTACK_DB_PROXY_REVERT_PATH` select a +replay. A positive witness requires the fixed general campaign to reach every +operation, a change made only under a nested symbol key, and effective +reverts: at least 10 histories where a change survives a revert, and at least +30 histories that end fully reverted. A revert counts only when the field +differs from its original before the revert. Eleven pinned histories hold one +rule each. + +Review of the first version found that its witness also counted reverts of +unchanged fields, which write an equal value and do nothing. With those +excluded, the first grammar reached 10 partial and 11 full reverts in 400 +histories. The changed-field revert and the partial-revert property raise +that coverage. Mutant N2 now fails 6 of the oracle's 16 cases, including both +partial-revert campaigns. Limits: @@ -258,9 +280,9 @@ minified bytes. The harness is not checked in. ## Verification -On `cccaf1b9e`, with the built `dist`: +On `1a07cd3ce`, with the built `dist`: -- `packages/db` Vitest, typecheck off: 198 files, 7,634 tests. +- `packages/db` Vitest, typecheck off: 198 files, 7,636 tests. - `packages/db` `tsc --noEmit`: only the pre-existing errors in `tests/conformance` that `main` also has. - `pnpm check:mangle`: 368 names. From fcc353d5d09f9c0bede36ae16d3c53d4bc63087b Mon Sep 17 00:00:00 2001 From: Isaac Date: Thu, 1 Oct 2026 10:16:20 -0600 Subject: [PATCH 09/28] fix(db): clear reverted nested changes from the parent draft Three pre-existing revert bugs, found from review of the revert oracle: - Deleting a nested key that the callback added left the parent marked changed, so getChanges reported an unchanged object. deleteProperty now clears tracking up the chain, like the set trap. - When a nested object fully reverted while a sibling field stayed changed, the parent kept its stale entry for that object. The parent edge is now cleared when its value equals the original; a replaced object keeps it. - The revert check treated a key added with the value undefined as equal to an absent key, so a later sibling revert dropped it. It now compares key presence too. The oracle's revert guard no longer skips restoring a deleted field, and the grammar gains a nested delete op and a nested round-trip property. The extended oracle fails 6 of 24 cases on main. Co-authored-by: Isaac --- packages/db/src/proxy.ts | 29 ++-- .../proxy-revert-oracle.property.test.ts | 131 +++++++++++++++++- 2 files changed, 147 insertions(+), 13 deletions(-) diff --git a/packages/db/src/proxy.ts b/packages/db/src/proxy.ts index 17302ebf4..b17d7c6d5 100644 --- a/packages/db/src/proxy.ts +++ b/packages/db/src/proxy.ts @@ -420,9 +420,12 @@ export function createChangeProxy< ) } for (const prop in state.assigned_) { - // false marks a deletion, which always differs from the original + // false marks a deletion, which always differs from the original. A key + // added with the value undefined differs from an absent key. if ( !state.assigned_[prop] || + Object.hasOwn(state.copy_, prop) !== + Object.hasOwn(state.originalObject, prop) || !draftValuesEqual( state.copy_[prop], (state.originalObject as any)[prop], @@ -446,9 +449,19 @@ export function createChangeProxy< parentState.modified = false parentState.assigned_ = Object.create(null) - // Continue up the chain - if (parentState.parent) { - checkParentStatus(parentState.parent.tracker) + // Continue up the chain. The parent's edge to this object no longer + // counts as a change when the parent's value equals its original; a + // replaced object can revert to its own snapshot and still differ. + const edge = parentState.parent + if (edge) { + if ( + draftValuesEqual( + edge.tracker.copy_[edge.prop], + edge.tracker.originalObject[edge.prop], + ) + ) + delete edge.tracker.assigned_[edge.prop] + checkParentStatus(edge.tracker) } } } @@ -662,10 +675,10 @@ export function createChangeProxy< if (!hadPropertyInOriginal) { delete changeTracker.assigned_[stringProp] - // If this is the last change and we're not a nested object, - // mark the object as unmodified - changeTracker.modified = - Object.keys(changeTracker.assigned_).length > 0 + // Deleting an added key is a revert. Like the set trap, clear + // tracking here and up the chain once everything is reverted. + changeTracker.modified = true + checkParentStatus(changeTracker) } else { // Mark this property as deleted changeTracker.assigned_[stringProp] = false diff --git a/packages/db/tests/proxy-revert-oracle.property.test.ts b/packages/db/tests/proxy-revert-oracle.property.test.ts index 284902bab..d100db4a3 100644 --- a/packages/db/tests/proxy-revert-oracle.property.test.ts +++ b/packages/db/tests/proxy-revert-oracle.property.test.ts @@ -208,6 +208,7 @@ type Op = | { op: `revert`; field: Field } | { op: `delete`; field: Field } | { op: `nested`; field: Field; key: `a` | `b` | `sym`; value: Primitive } + | { op: `nestedDelete`; field: Field; key: `a` | `b` | `sym` } | { op: `index`; field: Field; index: number; value: Primitive } | { op: `forOf`; field: Field; index: number; value: Primitive } @@ -237,6 +238,14 @@ const opArb: fc.Arbitrary = fc.oneof( value: primArb, }), }, + { + weight: 2, + arbitrary: fc.record({ + op: fc.constant(`nestedDelete` as const), + field: fc.constantFrom(...FIELDS), + key: fc.constantFrom(`a` as const, `b` as const, `sym` as const), + }), + }, fc.record({ op: fc.constant(`index` as const), field: fc.constantFrom(...FIELDS), @@ -312,21 +321,51 @@ const partialRevertArb: fc.Arbitrary = fc return { original, ops: [...changes, ...reverts] } }) +// Nested round trips. Either add a key the original object lacks and later +// delete it, or change an existing key and later write its original value +// back, with up to two other ops in between. Other ops may leave other fields +// changed, so the nested object must stop counting as a change on its own. +const nestedRoundTripArb: fc.Arbitrary = fc + .record({ + a: primArb, + sym: fc.option(primArb, { nil: undefined }), + g: specArb, + mode: fc.constantFrom(`delete` as const, `restore` as const), + value: primArb, + between: fc.array(opArb, { maxLength: 2 }), + }) + .map(({ a, sym, g, mode, value, between }): History => { + const f: Spec = sym === undefined ? { k: `obj`, a } : { k: `obj`, a, sym } + const first: GeneratedOp = + mode === `delete` + ? { op: `nested`, field: `f`, key: `b`, value } + : { op: `nested`, field: `f`, key: `a`, value } + const last: GeneratedOp = + mode === `delete` + ? { op: `nestedDelete`, field: `f`, key: `b` } + : { op: `nested`, field: `f`, key: `a`, value: a } + return { original: { f, g }, ops: [first, ...between, last] } + }) + // --------------------------------------------------------------------------- // Model: apply each op to the spec state. An op whose target has the wrong // shape does nothing, and the driver skips it the same way. -function applicable(state: Root, op: Op): boolean { +function applicable(state: Root, original: Root, op: Op): boolean { const current = state[op.field] switch (op.op) { case `set`: return true case `revert`: - return op.field in state || current !== undefined + // Restoring a deleted field is a revert too. Skip only when the field + // is absent both originally and now. + return original[op.field] !== undefined || current !== undefined case `delete`: return current !== undefined case `nested`: return current?.k === `obj` + case `nestedDelete`: + return current?.k === `obj` && op.key in current case `index`: return current?.k === `array` && op.index < current.items.length case `forOf`: @@ -354,6 +393,12 @@ function step(state: Root, original: Root, op: Op): Root { [op.key]: op.value, } break + case `nestedDelete`: { + const obj = { ...(current as Extract) } + delete obj[op.key] + next[op.field] = obj + break + } case `index`: { const items = [...(current as Extract).items] items[op.index] = op.value @@ -401,6 +446,10 @@ function drive(draft: Record, op: Op): void { if (op.key === `sym`) draft[op.field][S] = op.value else draft[op.field][op.key] = op.value return + case `nestedDelete`: + if (op.key === `sym`) delete draft[op.field][S] + else delete draft[op.field][op.key] + return case `index`: draft[op.field][op.index] = op.value return @@ -481,7 +530,7 @@ function expectHistory({ original, ops }: History): void { let state: Root = { ...original } for (const generated of ops) { const op = resolve(state, original, generated) - if (op === undefined || !applicable(state, op)) continue + if (op === undefined || !applicable(state, original, op)) continue if (op.op === `revert`) { if (original[op.field] === undefined) delete proxy[op.field] else proxy[op.field] = realize(original[op.field]!) @@ -557,6 +606,13 @@ describe(`draft revert oracle`, () => { ...(replayPath === undefined ? {} : { path: replayPath }), }) }) + it(`matches the model across generated nested round trips (${name})`, () => { + fc.assert(fc.property(nestedRoundTripArb, expectHistory), { + numRuns: 200, + ...(seed === undefined ? {} : { seed }), + ...(replayPath === undefined ? {} : { path: replayPath }), + }) + }) it(`matches the model across generated partial reverts (${name})`, () => { fc.assert(fc.property(partialRevertArb, expectHistory), { numRuns: 200, @@ -582,7 +638,7 @@ describe(`draft revert oracle`, () => { let effectiveReverts = 0 for (const generated of ops) { const op = resolve(state, original, generated) - if (op === undefined || !applicable(state, op)) continue + if (op === undefined || !applicable(state, original, op)) continue reached.add(op.op) if ( op.op === `revert` && @@ -614,10 +670,11 @@ describe(`draft revert oracle`, () => { `forOf`, `index`, `nested`, + `nestedDelete`, `revert`, `set`, ]) - expect(partial).toBeGreaterThanOrEqual(10) + expect(partial).toBeGreaterThanOrEqual(5) expect(full).toBeGreaterThanOrEqual(30) expect(symbolOnly).toBeGreaterThan(0) }) @@ -635,6 +692,70 @@ describe(`draft revert oracle`, () => { ], }, ], + [ + `adding a nested key and deleting it again is not a change`, + { + original: { f: { k: `obj`, a: 1 } }, + ops: [ + { op: `nested`, field: `f`, key: `b`, value: 2 }, + { op: `nestedDelete`, field: `f`, key: `b` }, + ], + }, + ], + [ + `restoring a nested value is not a change while a sibling stays changed`, + { + original: { f: { k: `obj`, a: 1 }, g: { k: `prim`, v: `a` } }, + ops: [ + { op: `nested`, field: `f`, key: `a`, value: 2 }, + { op: `set`, field: `g`, value: { k: `prim`, v: `b` } }, + { op: `nested`, field: `f`, key: `a`, value: 1 }, + ], + }, + ], + [ + `deleting an added nested key is not a change while a sibling stays changed`, + { + original: { f: { k: `obj`, a: 1 }, g: { k: `prim`, v: `a` } }, + ops: [ + { op: `nested`, field: `f`, key: `b`, value: 2 }, + { op: `set`, field: `g`, value: { k: `prim`, v: `b` } }, + { op: `nestedDelete`, field: `f`, key: `b` }, + ], + }, + ], + [ + `a replaced object that returns to its new value is still a change`, + { + original: { f: { k: `obj`, a: 1 } }, + ops: [ + { op: `set`, field: `f`, value: { k: `obj`, a: 2 } }, + { op: `nested`, field: `f`, key: `a`, value: 3 }, + { op: `nested`, field: `f`, key: `a`, value: 2 }, + ], + }, + ], + [ + `a key added with the value undefined stays a change after a sibling revert`, + { + original: { f: { k: `obj`, a: 0 } }, + ops: [ + { op: `nested`, field: `f`, key: `b`, value: 0 }, + { op: `set`, field: `h`, value: { k: `prim`, v: undefined } }, + { op: `nestedDelete`, field: `f`, key: `b` }, + ], + }, + ], + [ + `deleting a field and writing its original back is a revert`, + { + original: { f: { k: `prim`, v: `a` }, g: { k: `prim`, v: `a` } }, + ops: [ + { op: `delete`, field: `f` }, + { op: `revert`, field: `f` }, + ], + }, + ], [ `a nested write through for...of records the row change`, { From 36030ef8f9239b4a4a0441753bed400898edb13b Mon Sep 17 00:00:00 2001 From: Isaac Date: Thu, 1 Oct 2026 10:30:20 -0600 Subject: [PATCH 10/28] fix(db): clone typed-array subclasses and compare typed elements like numbers - deepClone builds a typed array by length and copies its elements again. The audit prototype's `new Ctor(source)` gave an empty clone for a subclass whose constructor does not forward its argument, so a draft read zeros and could publish them. - Typed-array elements follow the number rule (NaN equals NaN). Before, a Float64Array of NaN never equaled itself, which caused false changes and missed reverts. - Draft equality also treats a change of typed-array class as a change. General deepEquals still ignores the class, as utils.test.ts pins. The revert oracle generates Float64Array, Uint8Array, and a typed-array subclass, with index writes; utils.property adds a typed-array law. Co-authored-by: Isaac --- packages/db/src/proxy.ts | 19 +++- packages/db/src/utils.ts | 12 ++- .../proxy-revert-oracle.property.test.ts | 97 +++++++++++++++++++ packages/db/tests/utils.property.test.ts | 25 +++++ 4 files changed, 147 insertions(+), 6 deletions(-) diff --git a/packages/db/src/proxy.ts b/packages/db/src/proxy.ts index b17d7c6d5..3910900e4 100644 --- a/packages/db/src/proxy.ts +++ b/packages/db/src/proxy.ts @@ -192,6 +192,11 @@ interface ChangeTracker { * Deep clones an object while preserving special types like Date and RegExp */ +interface TypedArray { + length: number + [index: number]: number +} + function deepClone( obj: T, visited = new WeakMap(), @@ -244,11 +249,17 @@ function deepClone( // Handle TypedArrays if (ArrayBuffer.isView(obj) && !(obj instanceof DataView)) { - // Get the constructor to create a new instance of the same type - // and copy its elements. - const clone = new (Object.getPrototypeOf(obj).constructor)(obj) + // Create an instance of the same type by length, then copy the values. + // A subclass constructor need not forward a source array to super. + const TypedArrayConstructor = Object.getPrototypeOf(obj).constructor + const clone = new TypedArrayConstructor( + (obj as unknown as TypedArray).length, + ) as unknown as TypedArray visited.set(obj as object, clone) - return clone + for (let i = 0; i < (obj as unknown as TypedArray).length; i++) { + clone[i] = (obj as unknown as TypedArray)[i]! + } + return clone as unknown as T } if (obj instanceof Map) { diff --git a/packages/db/src/utils.ts b/packages/db/src/utils.ts index 7fd600852..16cb9e061 100644 --- a/packages/db/src/utils.ts +++ b/packages/db/src/utils.ts @@ -184,10 +184,18 @@ export function deepEqualsInternal( ) { const typedA = a as unknown as TypedArray const typedB = b as unknown as TypedArray - if (typedA.length !== typedB.length) return false + // Elements compare like numbers: -0 equals 0, NaN equals NaN. Only a + // draft treats a change of typed-array class as a change. + if ( + (draft && Object.getPrototypeOf(a) !== Object.getPrototypeOf(b)) || + typedA.length !== typedB.length + ) + return false for (let i = 0; i < typedA.length; i++) { - if (typedA[i] !== typedB[i]) return false + const x = typedA[i]! + const y = typedB[i]! + if (x !== y && !(x !== x && y !== y)) return false } return true diff --git a/packages/db/tests/proxy-revert-oracle.property.test.ts b/packages/db/tests/proxy-revert-oracle.property.test.ts index d100db4a3..e346492ec 100644 --- a/packages/db/tests/proxy-revert-oracle.property.test.ts +++ b/packages/db/tests/proxy-revert-oracle.property.test.ts @@ -61,6 +61,7 @@ type Spec = | { k: `array`; items: Array } | { k: `obj`; a?: Primitive; b?: Primitive; sym?: Primitive } | { k: `rows`; rows: Array<{ a: Primitive }> } + | { k: `typed`; ctor: TypedKind; values: Array } // A Map value is a primitive or a nested Set, so draft rules must also hold // inside Map values. @@ -70,6 +71,23 @@ const isSetValue = (v: MapValue): v is { set: Array } => const mapValue = (v: MapValue): unknown => isSetValue(v) ? [`set`, v.set] : prim(v) +// Typed arrays, including a subclass whose constructor ignores its argument, +// so a clone must not rely on constructor arguments to copy elements. +type TypedKind = `f64` | `u8` | `vec3` +class Vec3 extends Float64Array { + constructor() { + super(3) + } +} +const typedKind = (value: unknown): TypedKind | undefined => + value instanceof Vec3 + ? `vec3` + : value instanceof Float64Array + ? `f64` + : value instanceof Uint8Array + ? `u8` + : undefined + const HOLE = Symbol(`hole`) const FIELDS = [`f`, `g`, `h`] as const type Field = (typeof FIELDS)[number] @@ -110,6 +128,10 @@ function canon(spec: Spec | undefined): unknown { ] case `rows`: return [`rows`, spec.rows.map((row) => prim(row.a))] + case `typed`: + // Elements follow rule 1: -0 equals 0 and NaN equals NaN. The class is + // part of the value. + return [`typed`, spec.ctor, spec.values.map(String)] } } @@ -150,6 +172,13 @@ function realize(spec: Spec): unknown { } case `rows`: return spec.rows.map((row) => ({ a: row.a })) + case `typed`: { + if (spec.ctor === `u8`) return Uint8Array.from(spec.values) + const value = + spec.ctor === `vec3` ? new Vec3() : new Float64Array(spec.values.length) + spec.values.forEach((v, i) => (value[i] = v)) + return value + } } } @@ -201,6 +230,17 @@ const specArb: fc.Arbitrary = fc.oneof( fc .array(fc.record({ a: primArb }), { minLength: 1, maxLength: 3 }) .map((rows) => ({ k: `rows` as const, rows })), + fc.oneof( + fc + .array(fc.constantFrom(0, -0, 1, NaN, 1.5), { maxLength: 3 }) + .map((values): Spec => ({ k: `typed`, ctor: `f64`, values })), + fc + .array(fc.constantFrom(0, 1, 255), { maxLength: 3 }) + .map((values): Spec => ({ k: `typed`, ctor: `u8`, values })), + fc + .tuple(...[0, 1, 2].map(() => fc.constantFrom(0, -0, 1, NaN))) + .map((values): Spec => ({ k: `typed`, ctor: `vec3`, values })), + ), ) type Op = @@ -367,6 +407,13 @@ function applicable(state: Root, original: Root, op: Op): boolean { case `nestedDelete`: return current?.k === `obj` && op.key in current case `index`: + // Float typed arrays store any number exactly; Uint8Array would coerce. + if (current?.k === `typed`) + return ( + current.ctor !== `u8` && + typeof op.value === `number` && + op.index < current.values.length + ) return current?.k === `array` && op.index < current.items.length case `forOf`: return current?.k === `rows` && op.index < current.rows.length @@ -400,6 +447,12 @@ function step(state: Root, original: Root, op: Op): Root { break } case `index`: { + if (current?.k === `typed`) { + const values = [...current.values] + values[op.index] = op.value as number + next[op.field] = { ...current, values } + break + } const items = [...(current as Extract).items] items[op.index] = op.value next[op.field] = { k: `array`, items } @@ -520,6 +573,16 @@ function readSpec(value: unknown, like: Spec | undefined): string { rows: value.map((row: { a: Primitive }) => ({ a: row.a })), }) : `not rows` + case `typed`: { + const ctor = typedKind(value) + return ctor === undefined + ? `not a typed array` + : encode({ + k: `typed`, + ctor, + values: Array.from(value as Float64Array), + }) + } } } @@ -746,6 +809,40 @@ describe(`draft revert oracle`, () => { ], }, ], + [ + `a typed-array subclass keeps its elements in the draft`, + { + original: { f: { k: `typed`, ctor: `vec3`, values: [1, 2, 3] } }, + ops: [{ op: `index`, field: `f`, index: 0, value: 9 }], + }, + ], + [ + `rewriting NaN into a Float64Array is not a change`, + { + original: { f: { k: `typed`, ctor: `f64`, values: [NaN, 1] } }, + ops: [ + { + op: `set`, + field: `f`, + value: { k: `typed`, ctor: `f64`, values: [NaN, 1] }, + }, + { op: `index`, field: `f`, index: 0, value: NaN }, + ], + }, + ], + [ + `a typed array of another class is a change`, + { + original: { f: { k: `typed`, ctor: `f64`, values: [1, 0] } }, + ops: [ + { + op: `set`, + field: `f`, + value: { k: `typed`, ctor: `u8`, values: [1, 0] }, + }, + ], + }, + ], [ `deleting a field and writing its original back is a revert`, { diff --git a/packages/db/tests/utils.property.test.ts b/packages/db/tests/utils.property.test.ts index fbf859975..af2e92b7b 100644 --- a/packages/db/tests/utils.property.test.ts +++ b/packages/db/tests/utils.property.test.ts @@ -996,6 +996,31 @@ describe(`deepEquals property-based tests`, () => { }, ) + utilsProperty([ + fc.array(fc.oneof(fc.double(), fc.constant(NaN), fc.constant(-0)), { + minLength: 1, + maxLength: 5, + }), + fc.array(fc.integer({ min: 0, max: 127 }), { minLength: 1, maxLength: 5 }), + ])( + `typed arrays compare elements like numbers and ignore their class`, + (values, bytes) => { + const a = Float64Array.from(values) + const b = Float64Array.from( + values.map((v) => (Object.is(v, -0) ? 0 : v)), + ) + expectEqualityPair(a, b, true) + expectEqualityPair({ v: a }, { v: b }, true) + // Draft equality keeps the class; the draft revert oracle owns that. + expectEqualityPair(Uint8Array.from(bytes), Int8Array.from(bytes), true) + expectEqualityPair( + Uint8Array.from(bytes), + Int8Array.from([...bytes.slice(1), 128]), + false, + ) + }, + ) + utilsProperty([ fc.array(fc.oneof(fc.integer(), fc.constant(`hole`)), { minLength: 1, From 967a28c1d213edeacccafe235e8853d8d2ca64ac Mon Sep 17 00:00:00 2001 From: Isaac Date: Thu, 1 Oct 2026 11:10:57 -0600 Subject: [PATCH 11/28] perf(db): iterate draft arrays without a Proxy read per element The native Array iterator bound to the draft read each element and the length through the Proxy, which made for...of over 200 numbers 4x slower than main. A small iterator now reads the copy and calls the get trap function directly for object and function elements, so each element still reads like an index read. Primitives skip the trap, which returns them as read. The iteration contract adds a native-differential law for arrays: for...of, values(), and entries() with a push, pop, shift, index write, length cut, or element write made while the iterator is open. A cached-length mutant survived the owners before this law. Co-authored-by: Isaac --- packages/db/src/proxy.ts | 54 +++++++++++++-- .../db/tests/proxy-iteration-contract.test.ts | 65 +++++++++++++++++++ 2 files changed, 113 insertions(+), 6 deletions(-) diff --git a/packages/db/src/proxy.ts b/packages/db/src/proxy.ts index 3910900e4..e96488401 100644 --- a/packages/db/src/proxy.ts +++ b/packages/db/src/proxy.ts @@ -89,6 +89,38 @@ function isProxiableObject( ) } +/** + * Iterates a draft array. Each element is read like an index read; `read` is + * the draft's `get` trap, called directly because a read through the Proxy is + * several times slower. Primitives skip it: the trap returns them as read. + */ +function iterateArray( + read: (array: A, prop: string, receiver: unknown) => unknown, + array: A & Array, + receiver: unknown, + withIndex: boolean, +): IterableIterator { + let index = 0 + return { + next() { + if (index >= array.length) return { done: true, value: undefined } + let element = array[index] + if ( + element !== null && + (typeof element === `object` || typeof element === `function`) + ) + element = read(array, String(index), receiver) + return { + done: false, + value: withIndex ? [index++, element] : (index++, element), + } + }, + [Symbol.iterator]() { + return this + }, + } +} + /** * Creates a wrapper for methods that modify a collection (array, Map, Set). * The wrapper calls the method and marks the change tracker as modified. @@ -480,7 +512,7 @@ export function createChangeProxy< // Create a proxy for the target object. // Use the unfrozen copy_ as the proxy target to avoid Proxy invariant violations // when the original target is frozen (e.g., from Immer) - const proxy = new Proxy(changeTracker.copy_, { + const handler: ProxyHandler = { get(ptarget, prop, receiver) { const value = changeTracker.copy_[prop as keyof T] @@ -509,15 +541,24 @@ export function createChangeProxy< ) } - // Native callbacks and iterators read through the draft itself. - // This also tracks the callback array and implicit reduce seed. + // Native callbacks read through the draft itself. This also tracks + // the callback array and implicit reduce seed. + if (CALLBACK_ITERATION_METHODS.has(methodName)) { + return value.bind(receiver) + } + if ( - CALLBACK_ITERATION_METHODS.has(methodName) || methodName === `values` || methodName === `entries` || prop === Symbol.iterator ) { - return value.bind(receiver) + return () => + iterateArray( + handler.get!, + ptarget, + receiver, + methodName === `entries`, + ) } } @@ -702,7 +743,8 @@ export function createChangeProxy< return true }, - }) + } + const proxy = new Proxy(changeTracker.copy_, handler) draftCopies.set(proxy, changeTracker.copy_) // Return the proxy and a function to get the changes diff --git a/packages/db/tests/proxy-iteration-contract.test.ts b/packages/db/tests/proxy-iteration-contract.test.ts index 974c3e60b..a8cf2f1cf 100644 --- a/packages/db/tests/proxy-iteration-contract.test.ts +++ b/packages/db/tests/proxy-iteration-contract.test.ts @@ -20,6 +20,10 @@ import { * oracle compares membership, key order, callback arguments, nested writes, * aliases, cycles, rollback after throw, and the final detached result. Whole- * object detachment rules live in the companion contract, not this model. + * + * Array iterators follow the same live rule: an edit made while an iterator + * is open (a push, a removal, an index write, a length cut) changes what it + * visits next, as on a native array, and an object element is a draft. */ describe.each([`Map`, `Set`] as const)(`%s draft iteration`, (kind) => { it(`calls a read-only forEach callback once per entry without reporting changes`, () => { @@ -99,6 +103,67 @@ it.each([`Map`, `Set`] as const)( }, ) +describe(`Array draft iteration`, () => { + type Element = { x: number } | number + const iterations: Array< + [string, (array: Array) => Iterable] + > = [ + [`for...of`, (array) => array], + [`values()`, (array) => array.values()], + [`entries()`, (array) => array.entries()], + ] + // Each edit runs while the iterator holds its first element. + const edits: Array< + [string, (array: Array, first: unknown) => void] + > = [ + [`push`, (array) => array.push({ x: 9 }, 8)], + [`pop`, (array) => array.pop()], + [`shift`, (array) => array.shift()], + [`index write`, (array) => (array[1] = 7)], + [`length cut`, (array) => (array.length = 1)], + [ + `write through the element`, + (_array, first) => { + const element = (Array.isArray(first) ? first[1] : first) as { + x: number + } + element.x = 10 + }, + ], + ] + const run = ( + array: Array, + iterate: (array: Array) => Iterable, + edit: (array: Array, first: unknown) => void, + ) => { + const visited: Array = [] + for (const value of iterate(array)) { + if (visited.length === 0) edit(array, value) + visited.push(JSON.parse(JSON.stringify(value))) + } + return visited + } + const make = (): { items: Array } => ({ + items: [{ x: 1 }, 2, { x: 3 }], + }) + + describe.each(iterations)(`%s`, (_name, iterate) => { + it.each(edits)( + `visits and publishes what a native array does after %s`, + (_edit, edit) => { + const native = make() + const expectedVisits = run(native.items, iterate, edit) + let visits: Array = [] + const changes = withChangeTracking(make(), (draft) => { + visits = run(draft.items, iterate, edit) + }) + expect(visits).toEqual(expectedVisits) + expect(changes).toEqual({ items: native.items }) + }, + ) + }) +}) + describe.each([`Map`, `Set`] as const)( `%s caller-owned insertion values`, (kind) => { From 5b4049cdfbdd23e448276b30109cfcb950e3477b Mon Sep 17 00:00:00 2001 From: Isaac Date: Thu, 1 Oct 2026 11:14:35 -0600 Subject: [PATCH 12/28] perf(db): skip the draft entry list when deepEquals compares Maps General mode allocated an empty entry list for every Map and tested the draft flag on each entry. The draft entry list is now built only in draft mode, and its presence selects the ordered walk. Co-authored-by: Isaac --- packages/db/src/utils.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/db/src/utils.ts b/packages/db/src/utils.ts index 16cb9e061..f617f0a65 100644 --- a/packages/db/src/utils.ts +++ b/packages/db/src/utils.ts @@ -92,12 +92,12 @@ export function deepEqualsInternal( } visited.set(a, b) - const entries = Array.from(a.entries()) - const bEntries = draft ? Array.from(b.entries()) : [] - const result = entries.every(([key, val], index) => - draft + // A draft compares entries in order; general equality looks keys up. + const bEntries = draft && Array.from(b.entries()) + const result = Array.from(a.entries()).every(([key, val], index) => + bEntries ? Object.is(key, bEntries[index]![0]) && - deepEqualsInternal(val, bEntries[index]![1], visited, draft) + deepEqualsInternal(val, bEntries[index]![1], visited, true) : b.has(key) && deepEqualsInternal(val, b.get(key), visited), ) From b57d600226f5e3ed072b27aafb8d3fad898589b5 Mon Sep 17 00:00:00 2001 From: Isaac Date: Thu, 1 Oct 2026 12:08:46 -0600 Subject: [PATCH 13/28] fix(db): return drafts from array read methods on a draft Array methods outside the callback set, such as at, slice, concat, flat, toReversed, toSpliced, and with, ran on the private copy. They returned raw elements, so a write through a result was lost, and a search for an element the draft returned (indexOf, includes) did not find it. Every non-mutating method now reads through the draft, except the methods whose results hold no elements (searches, join, keys, toString), which run on the copy with a draft argument unwrapped. An inherited constructor is returned as stored, so draft.items.constructor === Array again. A native-differential law in proxy.test.ts takes its method list from Array.prototype, so a new built-in method fails until it has arguments. It observes each result, the identity of each object in it, and the row after a write through it. It fails 15 of 39 cases on main. Co-authored-by: Isaac --- packages/db/src/proxy.ts | 55 ++++++++------ packages/db/tests/proxy.test.ts | 127 ++++++++++++++++++++++++++++++++ 2 files changed, 158 insertions(+), 24 deletions(-) diff --git a/packages/db/src/proxy.ts b/packages/db/src/proxy.ts index e96488401..c4916186a 100644 --- a/packages/db/src/proxy.ts +++ b/packages/db/src/proxy.ts @@ -31,22 +31,16 @@ function unwrapDraft(value: unknown): unknown { } /** - * Set of array methods that iterate with callbacks and may return elements. - * Hoisted to module scope to avoid creating a new Set on every property access. + * Array methods whose results hold no elements of the array. */ -const CALLBACK_ITERATION_METHODS = new Set([ - `find`, - `findLast`, - `findIndex`, - `findLastIndex`, - `filter`, - `map`, - `flatMap`, - `forEach`, - `some`, - `every`, - `reduce`, - `reduceRight`, +const ARRAY_VALUE_METHODS = new Set([ + `includes`, + `indexOf`, + `lastIndexOf`, + `join`, + `keys`, + `toLocaleString`, + `toString`, ]) /** @@ -515,6 +509,12 @@ export function createChangeProxy< const handler: ProxyHandler = { get(ptarget, prop, receiver) { const value = changeTracker.copy_[prop as keyof T] + // Primitives return as read, from a getter or not. + if ( + value === null || + (typeof value !== `object` && typeof value !== `function`) + ) + return value // If it's a getter, return the value directly const desc = Object.getOwnPropertyDescriptor(ptarget, prop) @@ -525,9 +525,10 @@ export function createChangeProxy< // If the value is a function, bind it to the ptarget if (typeof value === `function`) { // A function stored as data is returned as stored, like any value. A - // call then sees the draft as `this`, so its writes are tracked. Only - // inherited methods (Array, Map, Set) need the handling below. - if (Object.hasOwn(ptarget, prop)) return value + // call then sees the draft as `this`, so its writes are tracked. A + // constructor is not a method. Only inherited methods (Array, Map, + // Set) need the handling below. + if (Object.hasOwn(ptarget, prop) || prop === `constructor`) return value // For Array methods that modify the array if (Array.isArray(ptarget)) { @@ -541,12 +542,6 @@ export function createChangeProxy< ) } - // Native callbacks read through the draft itself. This also tracks - // the callback array and implicit reduce seed. - if (CALLBACK_ITERATION_METHODS.has(methodName)) { - return value.bind(receiver) - } - if ( methodName === `values` || methodName === `entries` || @@ -560,6 +555,18 @@ export function createChangeProxy< methodName === `entries`, ) } + + // These results hold no elements, so they run on the copy, where a + // draft element argument is its copy. + if (ARRAY_VALUE_METHODS.has(methodName)) { + return (search: unknown, ...rest: Array) => + value.call(ptarget, unwrapDraft(search), ...rest) + } + + // Other methods read through the draft itself, so returned and + // callback elements are drafts. This also tracks the callback array + // and implicit reduce seed. + return value.bind(receiver) } // For Map and Set methods that modify the collection diff --git a/packages/db/tests/proxy.test.ts b/packages/db/tests/proxy.test.ts index ef9e5d214..8c69d32d2 100644 --- a/packages/db/tests/proxy.test.ts +++ b/packages/db/tests/proxy.test.ts @@ -2494,6 +2494,15 @@ describe(`stored functions behave like native values`, () => { `a stored method sees its object as this`, (row) => row.obj.bump() === row.obj, ], + [ + `an inherited constructor`, + (row) => [ + row.constructor === Object, + row.fns.constructor === Array, + row.m.constructor === Map, + row.s.constructor === Set, + ], + ], ] it.each(probes)(`%s gives the native result`, (_name, probe) => { @@ -2523,3 +2532,121 @@ describe(`stored functions behave like native values`, () => { expect(changes.obj?.count).toBe(1) }) }) + +/** + * Every array method that does not mutate the array must behave on a draft as + * on a native array. The method list comes from `Array.prototype`, so a new + * built-in method fails here until it has arguments below. Each call is + * observed by its result, the identity of each object in the result, and the + * row after a write through every object the result holds. + */ +describe(`array read methods behave like native arrays`, () => { + type Element = { x: number } | number | Array<{ x: number }> + type Row = { items: Array } + const make = (): Row => ({ items: [{ x: 1 }, 2, [{ x: 3 }], { x: 4 }] }) + const mutators = new Set([ + `copyWithin`, + `fill`, + `pop`, + `push`, + `reverse`, + `shift`, + `sort`, + `splice`, + `unshift`, + ]) + // Arguments for each method. A draft element as the argument checks that + // searches find the element the draft returned. + const calls: Record Array>> = { + at: [() => [0], () => [-1]], + concat: [() => [[{ x: 5 }]], (row) => [row.items]], + entries: [() => []], + every: [() => [(v: unknown) => v !== 0]], + filter: [() => [(v: unknown) => typeof v === `object`]], + find: [() => [(v: unknown) => typeof v === `object`]], + findIndex: [() => [(v: unknown) => v === 2]], + findLast: [() => [(v: unknown) => typeof v === `object`]], + findLastIndex: [() => [(v: unknown) => v === 2]], + flat: [() => []], + flatMap: [() => [(v: unknown) => v]], + forEach: [() => [() => undefined]], + includes: [() => [2], (row) => [row.items[0]], (row) => [row.items.at(-1)]], + indexOf: [() => [2], (row) => [row.items[0]], (row) => [row.items.at(-1)]], + join: [() => [`,`]], + keys: [() => []], + lastIndexOf: [() => [2], (row) => [row.items[3]]], + map: [() => [(v: unknown) => v]], + reduce: [() => [(_acc: unknown, v: unknown) => v]], + reduceRight: [() => [(_acc: unknown, v: unknown) => v]], + slice: [() => [0, 2], () => [-1]], + some: [() => [(v: unknown) => v === 2]], + toLocaleString: [() => []], + toReversed: [() => []], + toSorted: [ + () => [(a: unknown, b: unknown) => (a === 2 ? -1 : b === 2 ? 1 : 0)], + ], + toSpliced: [() => [0, 1]], + toString: [() => []], + values: [() => []], + with: [() => [1, { x: 7 }]], + } + const methods = Object.getOwnPropertyNames(Array.prototype).filter( + (name) => + name !== `constructor` && name !== `length` && !mutators.has(name), + ) + + // Objects reachable from a result, in visit order, without repeats. + const objectsIn = (value: unknown, seen: Array = []) => { + if (value === null || typeof value !== `object` || seen.includes(value)) + return seen + seen.push(value) + for (const child of Array.isArray(value) ? value : Object.values(value)) + objectsIn(child, seen) + return seen + } + const observe = (row: Row, method: string, args: Array) => { + const call = ( + row.items as unknown as Record) => unknown> + )[method]! + const raw = call.apply(row.items, args) + const result = + raw !== null && typeof raw === `object` && Symbol.iterator in raw + ? [...(raw as Iterable)] + : raw + const snapshot = JSON.parse(JSON.stringify(result ?? null)) + const objects = objectsIn(result).filter((o) => !Array.isArray(o)) + // Where each object in the result sits in the row, by identity. + const identity = objects.map((o) => + row.items.flatMap((element, i) => + element === o + ? [i] + : Array.isArray(element) && element[0] === o + ? [i + 0.5] + : [], + ), + ) + for (const o of objects) (o as { x: number }).x += 100 + return { snapshot, identity } + } + + it(`has arguments for every non-mutating method`, () => { + expect(methods.filter((name) => !(name in calls))).toEqual([]) + }) + + const cases = methods.flatMap((method) => + (calls[method] ?? []).map( + (makeArgs, i) => [`${method} #${i + 1}`, method, makeArgs] as const, + ), + ) + it.each(cases)(`%s gives the native result`, (_name, method, makeArgs) => { + const native = make() + const expected = observe(native, method, makeArgs(native)) + let actual: unknown + const changes = withChangeTracking(make(), (draft) => { + actual = observe(draft, method, makeArgs(draft)) + }) + expect(actual).toEqual(expected) + const changed = JSON.stringify(native) !== JSON.stringify(make()) + expect(changes).toEqual(changed ? { items: native.items } : {}) + }) +}) From 38d8ba78fec259d1425d8b304b9158b0ae1eab08 Mon Sep 17 00:00:00 2001 From: Isaac Date: Thu, 1 Oct 2026 12:13:11 -0600 Subject: [PATCH 14/28] fix(db): define a draft property with its value in one step The defineProperty trap defined the property, then assigned the cloned value. A descriptor without `writable: true` made the property read-only first, so Object.defineProperty(draft, key, { value }) threw. The trap now defines the clone directly. A native-differential law in proxy.test.ts compares the result, value, descriptor, and changes for six definitions. It fails 3 of 6 on main. Co-authored-by: Isaac --- packages/db/src/proxy.ts | 14 +++++++-- packages/db/tests/proxy.test.ts | 56 +++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/packages/db/src/proxy.ts b/packages/db/src/proxy.ts index c4916186a..a3e46148e 100644 --- a/packages/db/src/proxy.ts +++ b/packages/db/src/proxy.ts @@ -690,9 +690,17 @@ export function createChangeProxy< defineProperty(ptarget, prop, descriptor) { // Forward the defineProperty to the target to maintain Proxy invariants // This allows Object.seal() and Object.freeze() to work on the proxy - const result = Reflect.defineProperty(ptarget, prop, descriptor) - if (result && `value` in descriptor) { - changeTracker.copy_[prop as keyof T] = deepClone(descriptor.value) + // A defined value is stored as a clone, in the definition itself: a + // later assignment would fail on a read-only property. + const hasValue = `value` in descriptor + const result = Reflect.defineProperty( + ptarget, + prop, + hasValue + ? { ...descriptor, value: deepClone(descriptor.value) } + : descriptor, + ) + if (result && hasValue) { changeTracker.assigned_[prop.toString()] = true markChanged(changeTracker) } diff --git a/packages/db/tests/proxy.test.ts b/packages/db/tests/proxy.test.ts index 8c69d32d2..71c1b5c78 100644 --- a/packages/db/tests/proxy.test.ts +++ b/packages/db/tests/proxy.test.ts @@ -2650,3 +2650,59 @@ describe(`array read methods behave like native arrays`, () => { expect(changes).toEqual(changed ? { items: native.items } : {}) }) }) + +/** + * `Object.defineProperty` on a draft defines the property as on a native row: + * the same result, value, and descriptor. A defined enumerable value is a + * change. + */ +describe(`defineProperty behaves like on a native row`, () => { + type Row = Record + const make = (): Row => ({ a: 1, nested: { b: 2 } }) + const definitions: Array< + [string, (row: Row) => PropertyDescriptor & { key: string }] + > = [ + [`a new key with only a value`, () => ({ key: `k`, value: 5 })], + [`an existing key with only a value`, () => ({ key: `a`, value: 5 })], + [ + `an existing key made read-only`, + () => ({ key: `a`, value: 6, writable: false }), + ], + [ + `a new enumerable writable key`, + () => ({ + key: `k`, + value: 7, + writable: true, + enumerable: true, + configurable: true, + }), + ], + [`an object value`, () => ({ key: `a`, value: { c: 3 }, writable: false })], + [`a nested key`, () => ({ key: `nested`, value: { b: 3 } })], + ] + const observe = ( + row: Row, + define: (row: Row) => PropertyDescriptor & { key: string }, + ) => { + const { key, ...descriptor } = define(row) + const defined = Reflect.defineProperty(row, key, descriptor) + const { value, ...rest } = Object.getOwnPropertyDescriptor(row, key) ?? {} + return { + defined, + value: JSON.stringify(value), + rest, + read: JSON.stringify(row[key]), + } + } + + it.each(definitions)(`%s`, (_name, define) => { + const expected = observe(make(), define) + const { proxy, getChanges } = createChangeProxy(make()) + expect(observe(proxy, define)).toEqual(expected) + // Like a clone, changes hold enumerable string keys only. + const { key, value } = define(proxy) + const enumerable = expected.rest.enumerable === true + expect(getChanges()).toEqual(enumerable ? { [key]: value } : {}) + }) +}) From 815432ebd08c1e47ef61d0f21ce698ad29543b67 Mon Sep 17 00:00:00 2001 From: Isaac Date: Thu, 1 Oct 2026 12:38:48 -0600 Subject: [PATCH 15/28] fix(db): compare objects within their class and keyless instances by identity deepEquals compared every object by its enumerable keys, so two URLs, or two instances whose state is in private fields, were equal. A draft then skipped a write of a different URL or instance as unchanged, and collection.update dropped it. Now, in both modes: - an object of another class differs; plain and null-prototype objects, from any realm, are one class; - a class instance without enumerable keys equals only itself; - URLs compare by href, because a draft copies a URL by its href. The class check runs after the keys match, so unequal rows exit early. Equal rows compare about 6% slower. The revert oracle adds URL values and a class with only a private field, with a property that writes two of them over one field. It fails both campaigns on the previous commit. utils.property adds a general-mode law. Co-authored-by: Isaac --- packages/db/src/utils.ts | 29 ++++- .../proxy-revert-oracle.property.test.ts | 117 +++++++++++++++++- packages/db/tests/utils.property.test.ts | 54 +++++++- 3 files changed, 197 insertions(+), 3 deletions(-) diff --git a/packages/db/src/utils.ts b/packages/db/src/utils.ts index f617f0a65..4d2ff52bc 100644 --- a/packages/db/src/utils.ts +++ b/packages/db/src/utils.ts @@ -30,6 +30,21 @@ export function deepEquals(a: any, b: any): boolean { return deepEqualsInternal(a, b, new Map()) } +function isPlainPrototype(prototype: object | null): boolean { + return prototype === null || Object.getPrototypeOf(prototype) === null +} + +// An object of another class differs. Plain and null-prototype objects, from +// any realm, are one class. +function isSameClass(a: object, b: object): boolean { + const prototype = Object.getPrototypeOf(a) + const prototypeB = Object.getPrototypeOf(b) + return ( + prototype === prototypeB || + (isPlainPrototype(prototype) && isPlainPrototype(prototypeB)) + ) +} + function enumerableOwnKeys(value: object): Array { const keys: Array = Object.keys(value) for (const key of Object.getOwnPropertySymbols(value)) { @@ -264,6 +279,18 @@ export function deepEqualsInternal( return false } + // A class instance without enumerable keys (a File, an object with + // private fields) keeps its state elsewhere, so it equals only itself. + // A draft copies a URL by its href, so URLs compare by href. + if ( + keysA.length === 0 && + !Array.isArray(a) && + !isPlainPrototype(Object.getPrototypeOf(a)) + ) { + visited.delete(a) + return a instanceof URL && b instanceof URL && a.href === b.href + } + // Check if all keys exist in both objects and their values are equal const result = keysA.every( (key) => @@ -272,7 +299,7 @@ export function deepEqualsInternal( ) visited.delete(a) - return result + return result && isSameClass(a, b) } // For primitives that aren't strictly equal diff --git a/packages/db/tests/proxy-revert-oracle.property.test.ts b/packages/db/tests/proxy-revert-oracle.property.test.ts index e346492ec..fd7914f68 100644 --- a/packages/db/tests/proxy-revert-oracle.property.test.ts +++ b/packages/db/tests/proxy-revert-oracle.property.test.ts @@ -23,6 +23,11 @@ import { createChangeProxy } from '../src/proxy' * `undefined`. * 6. Plain objects compare by enumerable own string and symbol keys, in any * order. + * 7. Typed arrays compare by class and by elements under rule 1. + * 8. URLs compare by `href`. + * 9. An object of another class differs, and a class instance without + * enumerable keys (here, one with only a private field) equals only + * itself. * * Laws checked after every generated history: * @@ -43,6 +48,9 @@ import { createChangeProxy } from '../src/proxy' * them; the coverage map lists symbol writes as unsupported. Symbol keys * inside nested objects are written. * - Keys are non-index strings, so property order follows insertion order. + * - Class instances appear only as written values. A draft reads a class + * instance of the original row as a plain object; the detachment contract + * owns that boundary. */ // --------------------------------------------------------------------------- @@ -62,6 +70,8 @@ type Spec = | { k: `obj`; a?: Primitive; b?: Primitive; sym?: Primitive } | { k: `rows`; rows: Array<{ a: Primitive }> } | { k: `typed`; ctor: TypedKind; values: Array } + | { k: `url`; path: `a` | `b` } + | { k: `secret`; v: number } // A Map value is a primitive or a nested Set, so draft rules must also hold // inside Map values. @@ -88,6 +98,17 @@ const typedKind = (value: unknown): TypedKind | undefined => ? `u8` : undefined +// A class whose only state is a private field, so it has no enumerable keys. +class Secret { + #v: number + constructor(v: number) { + this.#v = v + } + read(): number { + return this.#v + } +} + const HOLE = Symbol(`hole`) const FIELDS = [`f`, `g`, `h`] as const type Field = (typeof FIELDS)[number] @@ -132,6 +153,12 @@ function canon(spec: Spec | undefined): unknown { // Elements follow rule 1: -0 equals 0 and NaN equals NaN. The class is // part of the value. return [`typed`, spec.ctor, spec.values.map(String)] + case `url`: + return [`url`, spec.path] + case `secret`: + // Rule 9. Secrets appear only as written values, and the original is + // never a Secret, so the value is enough to compare written states. + return [`secret`, spec.v] } } @@ -179,6 +206,10 @@ function realize(spec: Spec): unknown { spec.values.forEach((v, i) => (value[i] = v)) return value } + case `url`: + return new URL(`https://example.com/${spec.path}`) + case `secret`: + return new Secret(spec.v) } } @@ -241,6 +272,14 @@ const specArb: fc.Arbitrary = fc.oneof( .tuple(...[0, 1, 2].map(() => fc.constantFrom(0, -0, 1, NaN))) .map((values): Spec => ({ k: `typed`, ctor: `vec3`, values })), ), + fc + .constantFrom(`a` as const, `b` as const) + .map((path): Spec => ({ k: `url`, path })), +) +// Written values may also be class instances with only private state. +const writtenArb: fc.Arbitrary = fc.oneof( + { weight: 9, arbitrary: specArb }, + fc.constantFrom(1, 2).map((v): Spec => ({ k: `secret`, v })), ) type Op = @@ -256,7 +295,7 @@ const opArb: fc.Arbitrary = fc.oneof( fc.record({ op: fc.constant(`set` as const), field: fc.constantFrom(...FIELDS), - value: specArb, + value: writtenArb, }), { weight: 3, @@ -361,6 +400,40 @@ const partialRevertArb: fc.Arbitrary = fc return { original, ops: [...changes, ...reverts] } }) +// Two writes of URLs or keyless Secrets to one field, with up to two other +// ops in between and an optional revert. The original field is a URL, an +// object without keys, or any value. +const urlOrSecretArb: fc.Arbitrary = fc.oneof( + fc + .constantFrom(`a` as const, `b` as const) + .map((path): Spec => ({ k: `url`, path })), + fc.constantFrom(1, 2).map((v): Spec => ({ k: `secret`, v })), +) +const classWriteArb: fc.Arbitrary = fc + .record({ + f: fc.oneof( + urlOrSecretArb.filter((spec) => spec.k === `url`), + fc.constant({ k: `obj` }), + specArb, + ), + g: specArb, + first: urlOrSecretArb, + second: urlOrSecretArb, + between: fc.array(opArb, { maxLength: 2 }), + revert: fc.boolean(), + }) + .map( + ({ f, g, first, second, between, revert }): History => ({ + original: { f, g }, + ops: [ + { op: `set`, field: `f`, value: first }, + ...between, + { op: `set`, field: `f`, value: second }, + ...(revert ? [{ op: `revert` as const, field: `f` as const }] : []), + ], + }), + ) + // Nested round trips. Either add a key the original object lacks and later // delete it, or change an existing key and later write its original value // back, with up to two other ops in between. Other ops may leave other fields @@ -583,6 +656,14 @@ function readSpec(value: unknown, like: Spec | undefined): string { values: Array.from(value as Float64Array), }) } + case `url`: + return value instanceof URL + ? encode({ k: `url`, path: value.pathname.slice(1) as `a` | `b` }) + : `not a URL` + case `secret`: + return value instanceof Secret + ? encode({ k: `secret`, v: value.read() }) + : `not a Secret` } } @@ -676,6 +757,13 @@ describe(`draft revert oracle`, () => { ...(replayPath === undefined ? {} : { path: replayPath }), }) }) + it(`matches the model across generated URL and keyless writes (${name})`, () => { + fc.assert(fc.property(classWriteArb, expectHistory), { + numRuns: 200, + ...(seed === undefined ? {} : { seed }), + ...(replayPath === undefined ? {} : { path: replayPath }), + }) + }) it(`matches the model across generated partial reverts (${name})`, () => { fc.assert(fc.property(partialRevertArb, expectHistory), { numRuns: 200, @@ -742,6 +830,33 @@ describe(`draft revert oracle`, () => { expect(symbolOnly).toBeGreaterThan(0) }) + // Writes that a keyless comparison would call equal: another URL, or a + // Secret over another Secret or over an object without keys. + it(`reaches writes over URLs and keyless objects in the fixed campaign`, () => { + let urlWrites = 0 + let keylessWrites = 0 + const sample = fc.sample(classWriteArb, { seed: FIXED_SEED, numRuns: 200 }) + for (const { original, ops } of sample) { + let state: Root = { ...original } + for (const generated of ops) { + const op = resolve(state, original, generated) + if (op === undefined || !applicable(state, original, op)) continue + const current = state[op.field] + if (op.op === `set` && op.value.k === `url` && current?.k === `url`) + if (op.value.path !== current.path) urlWrites++ + if (op.op === `set` && op.value.k === `secret`) + if ( + (current?.k === `secret` && current.v !== op.value.v) || + (current?.k === `obj` && encode(current) === encode({ k: `obj` })) + ) + keylessWrites++ + state = step(state, original, op) + } + } + expect(urlWrites).toBeGreaterThanOrEqual(10) + expect(keylessWrites).toBeGreaterThanOrEqual(10) + }) + // Pinned witnesses. it.each<[string, History]>([ [ diff --git a/packages/db/tests/utils.property.test.ts b/packages/db/tests/utils.property.test.ts index af2e92b7b..4744d2aab 100644 --- a/packages/db/tests/utils.property.test.ts +++ b/packages/db/tests/utils.property.test.ts @@ -996,12 +996,64 @@ describe(`deepEquals property-based tests`, () => { }, ) + utilsProperty([ + fc.constantFrom(`a`, `b`), + fc.constantFrom(`a`, `b`), + fc.constantFrom(1, 2), + fc.constantFrom(1, 2), + ])( + `objects compare within their class and keyless instances by identity`, + (pathA, pathB, v, w) => { + class Secret { + #v: number + constructor(value: number) { + this.#v = value + } + read(): number { + return this.#v + } + } + class Point { + constructor(public a: number) {} + } + const url = (path: string) => new URL(`https://example.com/${path}`) + expectEqualityPair(url(pathA), url(pathB), pathA === pathB) + expectEqualityPair( + { u: url(pathA) }, + { u: url(pathB) }, + pathA === pathB, + ) + // Another class with the same href is still another class. + expectEqualityPair( + url(pathA), + Object.create({ href: url(pathA).href }), + false, + ) + // State outside enumerable keys is unknown, so only identity is equal. + const secret = new Secret(v) + expectEqualityPair(secret, secret, true) + expectEqualityPair(secret, new Secret(w), false) + expectEqualityPair({ s: secret }, { s: new Secret(v) }, false) + expectEqualityPair(new Secret(v), {}, false) + // Class instances with keys compare by keys within their class. + expectEqualityPair(new Point(v), new Point(w), v === w) + expectEqualityPair(new Point(v), { a: v }, false) + // Plain and null-prototype objects are one class. + const bare = Object.assign(Object.create(null), { a: v }) + expectEqualityPair(bare, { a: w }, v === w) + expectEqualityPair(Object.create(null), {}, true) + }, + ) + utilsProperty([ fc.array(fc.oneof(fc.double(), fc.constant(NaN), fc.constant(-0)), { minLength: 1, maxLength: 5, }), - fc.array(fc.integer({ min: 0, max: 127 }), { minLength: 1, maxLength: 5 }), + fc.array(fc.integer({ min: 0, max: 127 }), { + minLength: 1, + maxLength: 5, + }), ])( `typed arrays compare elements like numbers and ignore their class`, (values, bytes) => { From a49ae90e30d2900c5e651708b944563dd0c6a058 Mon Sep 17 00:00:00 2001 From: Isaac Date: Thu, 1 Oct 2026 12:39:20 -0600 Subject: [PATCH 16/28] test(db): pin that a detached stored method has no this A stored method called without its object throws on a native row, and now on a draft too, because the draft returns the function as stored. Co-authored-by: Isaac --- packages/db/tests/proxy.test.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/db/tests/proxy.test.ts b/packages/db/tests/proxy.test.ts index 71c1b5c78..665ff19d4 100644 --- a/packages/db/tests/proxy.test.ts +++ b/packages/db/tests/proxy.test.ts @@ -2494,6 +2494,18 @@ describe(`stored functions behave like native values`, () => { `a stored method sees its object as this`, (row) => row.obj.bump() === row.obj, ], + [ + `a detached stored method has no this`, + (row) => { + const { bump } = row.obj + try { + bump() + return `returned` + } catch (error) { + return (error as Error).constructor.name + } + }, + ], [ `an inherited constructor`, (row) => [ From 251f97c7c0c9273f054ea44cbccb51d8af08abb1 Mon Sep 17 00:00:00 2001 From: Isaac Date: Thu, 1 Oct 2026 12:47:03 -0600 Subject: [PATCH 17/28] docs: record the draft proxy fixes from the second review Ties the review record to a49ae90e3, with each fix's law, mutants, timings, and bytes, and updates the coverage map and changeset. Co-authored-by: Isaac --- .changeset/simplify-draft-proxy.md | 17 ++- docs/contributing/oracle-coverage.md | 4 +- .../oracle-reviews/code-weight-draft-proxy.md | 133 ++++++++++++++++-- .../proxy-revert-oracle.property.test.ts | 3 +- 4 files changed, 138 insertions(+), 19 deletions(-) diff --git a/.changeset/simplify-draft-proxy.md b/.changeset/simplify-draft-proxy.md index ccc00b77e..66e380a4d 100644 --- a/.changeset/simplify-draft-proxy.md +++ b/.changeset/simplify-draft-proxy.md @@ -2,6 +2,19 @@ '@tanstack/db': patch --- -Simplify the mutation draft proxy and share one equality walker with `deepEquals`. Revert detection and `deepEquals` results do not change, and draft writes are faster. A typical app bundle is about 460 B smaller with gzip. +Simplify the mutation draft proxy and share one equality walker with `deepEquals`. Draft writes are faster, and a typical app bundle is about 150 B smaller with gzip. -A function stored in a row is now returned as stored when read from a draft, by any read path. Before, the draft returned a bound copy, so `draft.handler === handler` was false. A stored method also saw a private copy as `this`, so writes it made through `this` were not tracked and `collection.update` dropped them. Those writes are now tracked. +This change also fixes draft writes that were lost or wrong: + +- A function stored in a row is now returned as stored when read from a draft. Before, the draft returned a bound copy, so `draft.handler === handler` was false. A stored method also saw a private copy as `this`, so `collection.update` dropped the writes it made through `this`. +- Array methods such as `at`, `slice`, `concat`, `flat`, `toReversed`, and `with` now return drafts, so a write through their result is saved. `indexOf` and `includes` find an element that the draft returned, and `draft.items.constructor === Array` is true. +- `Object.defineProperty(draft, key, { value })` no longer throws when the descriptor does not set `writable`. +- Deleting a nested key that the callback added, or writing a nested value back, no longer leaves the parent marked as changed. +- A typed-array subclass whose constructor does not forward its argument is now copied with its elements. Typed arrays that hold `NaN` now equal themselves. + +`deepEquals` and draft change detection also have new rules: + +- An object of another class is not equal. Plain and null-prototype objects are one class. +- A class instance without enumerable keys, such as a `File` or an object whose state is in private fields, equals only itself. Before, two such instances were always equal, so a draft dropped a write that replaced one. +- URLs compare by `href`. +- In draft change detection only, a typed array of another class is a change. diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index fb6420ee7..246443ed9 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -241,7 +241,7 @@ comment and the current API/architecture contract before extending its model. | Collection lifecycle | [mutation startup](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-mutation-startup-oracle.test.ts), [change-event history](https://github.com/TanStack/db/blob/main/packages/db/tests/change-event-history-oracle.test.ts), [history](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-subscription-lifecycle-history.property.test.ts), [publication](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-subscription-lifecycle-publication.property.test.ts), [replay](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-subscription-replay-oracle.property.test.ts), [effect disposal](https://github.com/TanStack/db/blob/main/packages/db/tests/effect-disposal-oracle.test.ts), [PR #1902 review](oracle-reviews/pr-1902-change-event-history.md), [batch-order decision review](oracle-reviews/change-event-batch-order.md) | Core Collection `insert`/`update`/`delete` admission while `startSync:false` is idle; complete public-row/change-message agreement across bounded one- and two-key histories plus replayable four-key campaigns; a two-key deferred sync history checks each key's causal insert/update/delete trace while allowing any cross-key interleaving. Reversing all deferred messages fails that check; reversing each sync transaction's distinct-key messages passes. Queued duplicate admission and cancellation, ownership and phase histories, exact caller/error/publication evidence, and late completion and restart have separate witnesses. Generated deferred histories with cancellation or reentrant callbacks need a witness in the change-event history owner before claiming general batch-shape coverage. Query write utilities and effect self-dependent disposal remain separate contracts. | | Paced mutations | [virtual-clock oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/paced-mutations-oracle.test.ts) | `createPacedMutations` with queue front/back options, bounded waiting capacity at zero and one, cleanup draining in FIFO/LIFO order after a held write, cleanup timing at the configured wait for immediate writes, admitted writes after separate Collection cleanup, debounce and throttle schedules with explicit and omitted options, and first-leading throttle execution at epoch zero. Finite histories compare optimistic rows, returned transaction identity and receipt outcome, execution order and virtual-clock time. Held writes verify queue serialization. Leading-only throttle and debounce witnesses reject skipped calls immediately with their named dropped-call errors, including omitted edges, both edges disabled, and same-row rollback while a prior write is held. Capacity witnesses cover overflow rejection, distinct-key optimistic rollback, same-key admitted-write survival, and in-flight versus waiting admission. Cleanup witnesses check repeated queue cleanup, eventual admitted receipt settlement, direct queue strategy rejection of new callbacks, public post-cleanup rollback with `QueueDisposedError`, pending debounce transactions that wait until the last call's quiet edge after separate Collection cleanup, and a trailing throttle timer that runs at its regular edge after separate Collection cleanup. Deferred custom queue and batch callbacks check compatibility with `void` and `false` execute results, respectively. Frozen options verify factory non-mutation; a mutable option witness checks that debounce uses its construction-time trailing setting. New debounce/throttle admission after cleanup, failed persistence, broader capacities and schedules remain outside this owner. | | Optimistic state | [history model](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-history-oracle.ts), [generated histories](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-transaction-oracle.property.test.ts), [truncate capture ownership](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-truncate-ownership-oracle.property.test.ts), [outcomes](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-history-outcomes.test.ts), [publication](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-history-publication.test.ts) | Independent whole-row snapshots, rollback dependencies, captured truncate ownership, metadata and prior-value events. Never rebase a pending snapshot merely to simplify the model. | -| Drafts and native values | [proxy](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy.test.ts), [detachment](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-detachment-contract.test.ts), [iteration](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-iteration-contract.test.ts), [revert](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-revert-oracle.property.test.ts) | Native-operation controls, exact patches and actual stored rows; alias/cycle/adversarial-key histories. Set and Map reorder histories check draft patches and stored iteration order after replacement and clear-and-readd; RegExp replacement/reversion histories check `lastIndex`. General native-mutator and symbol-write support is not established by a plain-object oracle. The revert owner generates assignment, revert, delete, nested, symbol-key, and `for...of` histories and checks `getChanges()` and the draft against an independent draft-equality model: ordered Map and Set contents (including Sets inside Map values), RegExp `lastIndex`, array holes, and symbol keys inside nested values. Mutator methods (`push`, `set`, `add`) and top-level symbol writes are outside its grammar. The proxy owner's stored-function law checks, against a native row, that a function stored as data reads back as stored by every read path and that a stored method sees the draft as `this`, so its writes are tracked. The detachment owner pins which key classes a draft copies: enumerable string keys and every symbol key ([draft proxy review](oracle-reviews/code-weight-draft-proxy.md)). | +| Drafts and native values | [proxy](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy.test.ts), [detachment](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-detachment-contract.test.ts), [iteration](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-iteration-contract.test.ts), [revert](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-revert-oracle.property.test.ts) | Native-operation controls, exact patches and actual stored rows; alias/cycle/adversarial-key histories. Set and Map reorder histories check draft patches and stored iteration order after replacement and clear-and-readd; RegExp replacement/reversion histories check `lastIndex`. General native-mutator and symbol-write support is not established by a plain-object oracle. The revert owner generates assignment, revert, delete, nested, symbol-key, and `for...of` histories and checks `getChanges()` and the draft against an independent draft-equality model: ordered Map and Set contents (including Sets inside Map values), RegExp `lastIndex`, array holes, and symbol keys inside nested values. Mutator methods (`push`, `set`, `add`) and top-level symbol writes are outside its grammar. The proxy owner's stored-function law checks, against a native row, that a function stored as data reads back as stored by every read path and that a stored method sees the draft as `this`, so its writes are tracked. The detachment owner pins which key classes a draft copies: enumerable string keys and every symbol key. The revert owner also generates typed arrays (including a subclass that does not forward its constructor argument), URLs, and a class with only private state as written values, with a property that writes two of them over one field. The proxy owner's array law takes every non-mutating method from `Array.prototype` and compares its result, the identity of each object in it, and the row after a write through it with a native array; a new built-in method fails until it has arguments. Its `defineProperty` law compares result, value, descriptor, and changes. The iteration owner checks that array iterators see a push, removal, index write, or length cut made while they are open. Open: a draft reads a class instance of the original row as a plain object, so private-field getters read `undefined`, and a built-in method called through `this` on a nested Map or Set draft rejects the Proxy receiver ([draft proxy review](oracle-reviews/code-weight-draft-proxy.md)). | | Query DB and observer | [ownership](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/ownership-lifecycle.oracle.test.ts), [load lifecycle](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/load-subset-lifecycle-oracle.test.ts), [observer histories](https://github.com/TanStack/db/blob/main/packages/db/tests/live-query-observer-history.property.test.ts) | Real QueryClient boundary, applied-settlement barriers for existing and cached demand, mutation-handler Collection identity, terminal fetch-record lifecycle, and a per-listener eligibility ledger, not a duplicate dispatch queue. The bounded handler grammar crosses insert, update, delete, parameter and mutation-alias access, refetch, and clearError. A held clearError retry retains the prior public error through initial, background, and Collection application failures; success clears it, failure advances the count, including a repeated error at clock time zero. An intermediate Query retry attempt does not recount the previous background error; a held final attempt can succeed or fail. A held application after successful Query fetch keeps the error visible until the applied result. A mocked persisted-baseline scan holds an older success while a newer terminal Query failure arrives; the newer public error and count survive application, including when the Error object and clock tick repeat. Native SQLite persistence, concurrent retries across multiple tracked Queries, mutation-handler fetch-boundary settlement, and deferred result application remain outside this witness. The mutation overlap grammar crosses both start orders. Check reentry, peer survival, FIFO and disposal independently of final rows. | | Query DB SSR dehydration | `packages/query-db-collection/tests/ssr-dehydration-oracle.test.ts`, [review record](oracle-reviews/issue-1950-ssr-dehydration.md) | A plain Query cache control, a live on-demand compound predicate, a signal-only request, and an ordered cursor with a custom comparator all retain their row through QueryClient dehydration, reconstruction of Seroval's emitted stream payload, and Query Core hydration from that payload. The on-demand query functions still receive the original IR, comparator, and signal; serialized metadata retains enumerable user metadata but excludes request options. The original implementation fails the compound and signal cases at the stream checkpoint. A serializable stream-only request-options leak passes the former chunk-count check but fails the current payload assertion. This owner covers successful Query cache entries at the installed Query Core 5.90.20 and Seroval 1.5.0 versions. A TanStack Start integration owner still needs the reporter's Router 1.171.33 and Seroval 1.6.8 path, browser Collection resume/refetch, and request cancellation across SSR. Query subset and pagination oracles remain owners of predicate/order/cursor meaning and supported value types. The issue's `shouldDehydrateQuery` workaround replaces Query Core's success-only default and admits pending/error queries; any documented workaround must preserve that default filter. | | Ordered acquisition | [pagination](https://github.com/TanStack/db/blob/main/packages/db/tests/query/pagination-oracle.property.test.ts), [ordered work](https://github.com/TanStack/db/blob/main/packages/db/tests/query/ordered-work-oracle.property.test.ts), [ordered lifecycle](https://github.com/TanStack/db/blob/main/packages/db/tests/query/ordered-lifecycle-oracle.property.test.ts), [ordered loader state](https://github.com/TanStack/db/blob/main/packages/db/tests/query/ordered-source-loader-state.test.ts), [graph scheduler](https://github.com/TanStack/db/blob/main/packages/db/tests/query/scheduler.test.ts), [issue #1880 ordered-repair review](oracle-reviews/issue-1880-ordered-repair.md), [issue #1882 custom local collation review](oracle-reviews/issue-1882-custom-local-collation.md), [issue #1898 relation-filter review](oracle-reviews/issue-1898-relation-filter.md), [PR #1909 external review](oracle-reviews/pr-1909-external-review.md) | Complete finite provider results, inherited locale and bounded/unbounded custom collation with exact request options, query-level inheritance across source aliases, real lexical/numeric disagreement, custom comparator ties, scan/index paths, pending windows, ties/nulls, lease ownership, Effect callback gates, including an indexed source update during Effect startup, and documented repair timing. Direct LEFT-joined filters cover local finite prefixes, pending child demand, child removal, and anti-join publication with custom full-source and unindexed fallback loading; unrelated include demand cannot stall the root continuation, and an empty joined replay wakes held Effect callbacks; remote relation hints remain outside this owner. Graph scheduler checks coalescing, dependency order, synchronous loader input, and repeated-alias failure ownership. The ordered-work oracle checks that a synchronous root refill reaches D2 before joined publication; a mutant that omitted the post-loader graph step failed at the second child demand. Custom collation proves local full-source acquisition only; it does not establish provider cursor capability or backend collation fidelity. Sibling-source input during a synchronous ordered continuation returns to graph work before another ordered acquisition. Request completion is not proof of unrequested source extent. A window move during existing joined demand waits for its root continuation, including an independently held root request; current demand failure rejects the move, while retirement and an obsolete rejection do not. A bounded ordered repair resumes its refill after joined demand settles. The ordered-source-loader state test checks that an explicit failed-request retry blocked by joined demand retains its generation until it can issue a request. Multiple simultaneously pending joined plans during a window move and a public-query failed-retry overlap still need dedicated witnesses. | @@ -259,7 +259,7 @@ comment and the current API/architecture contract before extending its model. | Offline execution | [scheduler](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/KeyScheduler.property.test.ts), [leadership](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/leadership-replay.property.test.ts), [settlement](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/transaction-settlement.property.test.ts), [serialization](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/transaction-serializer.property.test.ts), [web connectivity replay](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/connectivity-replay-oracle.test.ts), [review record](oracle-reviews/pr-1879-visible-replay.md) | Declarative FIFO eligibility, per-transaction outcomes, durable state and typed wire trees. The web connectivity owner checks a persisted write under a false browser online hint, then visible replay by event or explicit retry, at provider-call, outbox, and caller-settlement cuts. It uses controlled browser globals and fake storage; it does not prove actual browser network detection, genuine offline retry timing, multi-tab leadership transfer, or React Native behavior. Issued work may finish after ownership loss, but new work must not start. Exactly-once network execution is not promised. | | Frameworks | [React conformance](https://github.com/TanStack/db/blob/main/packages/react-db/tests/conformance.test.tsx), [React pagination](https://github.com/TanStack/db/blob/main/packages/react-db/tests/infinite-query-conformance.test.tsx), [shared suites](https://github.com/TanStack/db/tree/main/packages/db-collection-e2e/src/suites), [Vue synchronous publication](https://github.com/TanStack/db/blob/main/packages/vue-db/tests/useLiveQuery-publication-oracle.test.ts) | Exact exposed rows/pages and each framework's own lifecycle cuts. Vue's synchronous watcher checks insert, update, and nonterminal delete through supplied Collection and identity query inputs; it does not cover an empty final result, multiple changes in one transaction, or query recompilation. A React witness does not prove Vue/Solid/Angular/Svelte scheduling. Preserve their receiving registrations. | | Window-controller overlap | [shared infinite-query conformance](https://github.com/TanStack/db/blob/main/packages/db/tests/conformance/infinite-suite.ts), [React driver](https://github.com/TanStack/db/blob/main/packages/react-db/tests/infinite-query-conformance.test.tsx), [Vue driver](https://github.com/TanStack/db/blob/main/packages/vue-db/tests/infinite-query-conformance.test.ts), [Svelte driver](https://github.com/TanStack/db/blob/main/packages/svelte-db/tests/infinite-query-conformance.svelte.test.ts), [review record](oracle-reviews/dec-03-window-overlap.md) | Four or five ordered source rows, two window-settlement orders, and public snapshots after each settlement, a subscribed observer update, a detached controller read, and explicit recovery. Insertion and removal of the fifth row distinguish both continuation directions while an earlier error remains visible. The detached read follows a source change with no controller subscriber; its preloaded Collection retains the five-row window. The exact overlap uses the exported DB controller in each package realm. Framework hooks expose no controller preload, so this cell does not establish a hook scheduling path; direct hook paging remains in the adjacent shared scenarios. An unsubscribed controller has no notification claim. | -| Structural values and ordered primitives | [hash values](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/hash.property.test.ts), [hash identity](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/hash-identity-oracle.property.test.ts), [MultiSet consolidation](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/multiset-consolidate-oracle.property.test.ts), [hash graphs](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/hash-graph.property.test.ts), [mixed hash graphs](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/hash-mixed-graph.property.test.ts), [hash retry](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/hash-failure-retry.property.test.ts), [comparison](https://github.com/TanStack/db/blob/main/packages/db/tests/comparison.property.test.ts), [deep equality](https://github.com/TanStack/db/blob/main/packages/db/tests/utils.property.test.ts), [cursor](https://github.com/TanStack/db/blob/main/packages/db/tests/cursor.property.test.ts), [indexes](https://github.com/TanStack/db/blob/main/packages/db/tests/index-update.property.test.ts), [BTree Map](https://github.com/TanStack/db/blob/main/packages/db/tests/btree-map-oracle.test.ts), [query identity](https://github.com/TanStack/db/blob/main/packages/db/tests/query/identity-output-shape-oracle.test.ts), [LIKE semantics](https://github.com/TanStack/db/blob/main/packages/db/tests/query/compiler/evaluators.test.ts) | Independent flat values, graph topology, algebraic laws, Map/group/sort recomputation, expression denotation, custom comparator dispatch/reference identity/index compatibility, LIKE wildcard refinement, compiled output bags, declared value-pair agreement between D2 equality keys and IR equality operands, and Collection collation with exact public scan/index order and one-index reuse across repeated reads. The auto-index cells cross BasicIndex and BTreeIndex; scan cells omit indexes. Both paths cover lexical and numeric `en-US` locale defaults plus an explicit ordinary locale override of a numeric default. An unindexed six-row work cell bounds Collection collation resolution to at most two reads per ordered scan: one index-path check and one scan clause. Other locales and ordered histories are outside this owner. The identity pair grammar covers primitives, signed zero, NaN/invalid Date, valid Date/timestamp, binary/Buffer, Temporal, nested references, symbols, and functions; fixed and sampled pairs do not prove every JavaScript value or hash collision freedom. Ref proxies are expressions rather than literal values in this lane. The deep-equality owner also pins that `deepEquals` ignores Map and Set insertion order, RegExp `lastIndex`, and array holes, the rules draft equality keeps. The hash-values owner samples Set/object and Map/object type-marker distinctions in fixed and random campaigns with seed-and-path replay; collision freedom is not promised. The hash-identity owner checks `hash` and `equalHashValues` against one spec-level identity model: primitives with signed zero, NaN, and bigint; reference leaves (functions, registered handles, `File`, binary over 128 bytes); Date, binary, Temporal, and RegExp headers; array length and holes; Map and Set insertion order; and object keys in any order, for acyclic values and for cycles through arrays, Map values, Sets, and plain objects. Map/Set order sensitivity is pinned current behavior, not a promise. Arrays from another realm, RegExps with a `NaN` `lastIndex`, getters, proxies, other cycle carriers, and pairs that differ only in a back-edge target are outside its grammar; pinned cases keep equality's cross-realm length check and strict RegExp field comparison. `hash-work.test.ts` pins how visited values and array/RegExp headers count toward the structural work cap, and that equality copies a shared Map's entries once per distinct pair ([code-weight hash dispatch review](oracle-reviews/code-weight-hash-dispatch.md)). Custom string indexes do not optimize ordinary ranges, and custom cursors remain unsupported. The LIKE owner covers boolean string matching and bounded work, not nullish three-valued logic or a general Unicode collation contract. Unsupported composite cursors reject. The MultiSet consolidation owner checks keyed, single-type, and structural identity, signed zero and NaN in keyed and single-number data, the retained first record, zero-sum removal, and input record contents. It does not assert output order. Its keyed grammar includes cross-type key and value collisions, non-finite numeric keys, the `\|` delimiter, symbol and function identity, and a direct-object/object-tuple delimiter control from #1948. Eight named collision witnesses and both generated campaigns are RED on `c879ba6d` and GREEN with the repair ([#1948 review record](oracle-reviews/issue-1948-keyed-consolidation.md)); the [code-weight consolidation review](oracle-reviews/code-weight-multiset-consolidation.md) records the earlier exclusion. The keyed reference uses direct value/reference equality, including SameValueZero for numeric keys. The unkeyed fallback keeps its original key and value domains. The direct `MultiSet` owner does not prove which `@tanstack/db` query shapes produce primitive keyed values or mixed-type source keys; that needs a live-query production witness. The index owner enforces one invalid-comparator law across both BasicIndex and BTreeIndex: for each comparator, accepted numeric prefix (including the empty prefix), and probe operation (add, update, eq/range lookup, take, build), the operation throws exactly when it receives a `NaN` or non-number result and never when every comparison is valid. A `signed infinity` comparator is the valid control, and a custom collation `compare` supplied through `compareOptions` is checked the same way. Two BTree Map split histories check inserted-key reads, exact whole-range payloads/order, size, and extrema, proving split placement follows the insertion index without a post-mutation comparison. A rejected index add or remove leaves the index refining its accepted rows; update and build are not atomic. At the Collection boundary, both index types and both write paths (optimistic insert, sync commit) crash the collection: status becomes `error` and the next mutation throws, so a usable collection never holds rows its subscribers were not told about. Open: a sync source can still commit writes on a collection in `error` state, which stores unpublished rows; this is existing `markError` behavior and needs a lifecycle-owner witness. Comparator transitivity is outside this evidence. | +| Structural values and ordered primitives | [hash values](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/hash.property.test.ts), [hash identity](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/hash-identity-oracle.property.test.ts), [MultiSet consolidation](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/multiset-consolidate-oracle.property.test.ts), [hash graphs](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/hash-graph.property.test.ts), [mixed hash graphs](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/hash-mixed-graph.property.test.ts), [hash retry](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/hash-failure-retry.property.test.ts), [comparison](https://github.com/TanStack/db/blob/main/packages/db/tests/comparison.property.test.ts), [deep equality](https://github.com/TanStack/db/blob/main/packages/db/tests/utils.property.test.ts), [cursor](https://github.com/TanStack/db/blob/main/packages/db/tests/cursor.property.test.ts), [indexes](https://github.com/TanStack/db/blob/main/packages/db/tests/index-update.property.test.ts), [BTree Map](https://github.com/TanStack/db/blob/main/packages/db/tests/btree-map-oracle.test.ts), [query identity](https://github.com/TanStack/db/blob/main/packages/db/tests/query/identity-output-shape-oracle.test.ts), [LIKE semantics](https://github.com/TanStack/db/blob/main/packages/db/tests/query/compiler/evaluators.test.ts) | Independent flat values, graph topology, algebraic laws, Map/group/sort recomputation, expression denotation, custom comparator dispatch/reference identity/index compatibility, LIKE wildcard refinement, compiled output bags, declared value-pair agreement between D2 equality keys and IR equality operands, and Collection collation with exact public scan/index order and one-index reuse across repeated reads. The auto-index cells cross BasicIndex and BTreeIndex; scan cells omit indexes. Both paths cover lexical and numeric `en-US` locale defaults plus an explicit ordinary locale override of a numeric default. An unindexed six-row work cell bounds Collection collation resolution to at most two reads per ordered scan: one index-path check and one scan clause. Other locales and ordered histories are outside this owner. The identity pair grammar covers primitives, signed zero, NaN/invalid Date, valid Date/timestamp, binary/Buffer, Temporal, nested references, symbols, and functions; fixed and sampled pairs do not prove every JavaScript value or hash collision freedom. Ref proxies are expressions rather than literal values in this lane. The deep-equality owner also pins that `deepEquals` ignores Map and Set insertion order, RegExp `lastIndex`, array holes, and typed-array class, the rules draft equality keeps. It also pins that objects of another class differ, that plain and null-prototype objects are one class, that a class instance without enumerable keys equals only itself, that URLs compare by `href`, and that typed-array elements compare like numbers. The hash-values owner samples Set/object and Map/object type-marker distinctions in fixed and random campaigns with seed-and-path replay; collision freedom is not promised. The hash-identity owner checks `hash` and `equalHashValues` against one spec-level identity model: primitives with signed zero, NaN, and bigint; reference leaves (functions, registered handles, `File`, binary over 128 bytes); Date, binary, Temporal, and RegExp headers; array length and holes; Map and Set insertion order; and object keys in any order, for acyclic values and for cycles through arrays, Map values, Sets, and plain objects. Map/Set order sensitivity is pinned current behavior, not a promise. Arrays from another realm, RegExps with a `NaN` `lastIndex`, getters, proxies, other cycle carriers, and pairs that differ only in a back-edge target are outside its grammar; pinned cases keep equality's cross-realm length check and strict RegExp field comparison. `hash-work.test.ts` pins how visited values and array/RegExp headers count toward the structural work cap, and that equality copies a shared Map's entries once per distinct pair ([code-weight hash dispatch review](oracle-reviews/code-weight-hash-dispatch.md)). Custom string indexes do not optimize ordinary ranges, and custom cursors remain unsupported. The LIKE owner covers boolean string matching and bounded work, not nullish three-valued logic or a general Unicode collation contract. Unsupported composite cursors reject. The MultiSet consolidation owner checks keyed, single-type, and structural identity, signed zero and NaN in keyed and single-number data, the retained first record, zero-sum removal, and input record contents. It does not assert output order. Its keyed grammar includes cross-type key and value collisions, non-finite numeric keys, the `\|` delimiter, symbol and function identity, and a direct-object/object-tuple delimiter control from #1948. Eight named collision witnesses and both generated campaigns are RED on `c879ba6d` and GREEN with the repair ([#1948 review record](oracle-reviews/issue-1948-keyed-consolidation.md)); the [code-weight consolidation review](oracle-reviews/code-weight-multiset-consolidation.md) records the earlier exclusion. The keyed reference uses direct value/reference equality, including SameValueZero for numeric keys. The unkeyed fallback keeps its original key and value domains. The direct `MultiSet` owner does not prove which `@tanstack/db` query shapes produce primitive keyed values or mixed-type source keys; that needs a live-query production witness. The index owner enforces one invalid-comparator law across both BasicIndex and BTreeIndex: for each comparator, accepted numeric prefix (including the empty prefix), and probe operation (add, update, eq/range lookup, take, build), the operation throws exactly when it receives a `NaN` or non-number result and never when every comparison is valid. A `signed infinity` comparator is the valid control, and a custom collation `compare` supplied through `compareOptions` is checked the same way. Two BTree Map split histories check inserted-key reads, exact whole-range payloads/order, size, and extrema, proving split placement follows the insertion index without a post-mutation comparison. A rejected index add or remove leaves the index refining its accepted rows; update and build are not atomic. At the Collection boundary, both index types and both write paths (optimistic insert, sync commit) crash the collection: status becomes `error` and the next mutation throws, so a usable collection never holds rows its subscribers were not told about. Open: a sync source can still commit writes on a collection in `error` state, which stores unpublished rows; this is existing `markError` behavior and needs a lifecycle-owner witness. Comparator transitivity is outside this evidence. | | WHERE predicate publication | [WHERE predicate publication oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/query/where-predicate-publication-oracle.property.test.ts) | An independent Kleene evaluator judges `eq`/`not`/`and`/`or` predicates over strings, a normalization-prefixed string, booleans, numbers, `NaN`, a valid Date, `null`, a missing field, and virtual fields. Snapshot histories with pending optimistic inserts compare a live query, direct subscribers with and without initial state, and `currentStateAsChanges`; change histories apply multi-key sync transactions, including reinsertion of a key deleted earlier, and compare every consumer's key set after each commit, on scan and `BasicIndex` paths. Peer subscribers share one field with different literals, plus one on a virtual field, so every published change must reach exactly the subscribers whose predicate it can satisfy; routing mutants that ignored the previous value, ignored the field, delivered a change twice, or routed while stale published rows awaited reconciliation fail here. Fixed witnesses cover a layout-only publication reaching a filtered subscriber as one empty batch, a filtered subscriber's empty Collection-readiness batch, retraction of a vanished row after eager cleanup and restart, and the new row plus exact subscriber update payload when a same-key row stays TRUE. A stale-live-value mutant survives the generated membership checks but fails the payload witness. A focused [property-visibility test](https://github.com/TanStack/db/blob/main/packages/db/tests/query/where-prefilter-property-visibility.test.ts) compares the public unindexed snapshot with the enriched-row contract for inherited, non-enumerable, enumerable-own, and nested getter paths; the pre-fix stored-row shortcut failed three of its four controls. Other property-descriptor and stateful-getter histories remain outside those fixed controls. Hostile mutants for FALSE-for-UNKNOWN `eq`, `or` or number-literal subscription prefilters, a prefilter that ignores the previous value, a skipped readiness batch, a skip while stale published rows await reconciliation, and an unindexed snapshot scan that reads only synced rows while an optimistic insert or delete changes visibility passed the prior `@tanstack/db` suite and fail here. Change routing withholds a dropped row from an `eq` subscription before its sent key could be recorded, so a mutant that records dropped rows as sent is equivalent for routed predicates in this oracle. A focused `collection-subscription.test.ts` witness checks that a dropped insert or update, alone or beside a matching change, cannot advance a limited subscription's page offset under a routed `eq` and an unrouted `or` predicate; the unrouted cases beside a matching change kill that mutant, and the unrouted `alone` case does not. A loose-equality prefilter is an equivalent mutant: it only skips less. Skipping during truncate replay also survives; the replay's baseline diff re-derives the same retraction, so the guard keeps the prior dataflow without its own witness. Generated cleanup and restart histories for filtered subscribers remain open for the lifecycle publication owner. No-op updates, other comparison operators, Temporal and binary operands, joins, ordering, optimistic updates, and truncate are outside this owner. It runs in `@tanstack/db`'s `test:oracles` campaign. The [2026-09-30 review](https://github.com/TanStack/db/blob/main/docs/contributing/oracle-reviews/2026-09-30-where-predicate-and-join-keys.md) records each ORC outcome. | | Joined result keys | [Joined result key oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/query/join-result-key-oracle.property.test.ts) | A nested-loop model recomputes the pair set of an inner, left, or full join over source keys drawn from plain, comma-bearing, bracket-bearing, and quoted strings, numbers beside the strings that print the same, both infinities, and `NaN`. After preload and each synced group change, the published rows must equal the model's pairs and the result key count must equal the row count. The comma-joined key encoding fails its two pinned histories and both campaigns; plain `JSON.stringify`, which prints infinities as the missing-side `null`, fails the pinned infinity history and both campaigns. A pinned history in which a `NaN`-keyed pair leaves and re-forms fails when the join index compares source-key prefixes with `===`. Joins over subqueries, more than two sources, custom `getKey`, and optimistic mutations are outside this owner. It runs in `@tanstack/db`'s `test:oracles` campaign. The [2026-09-30 review](https://github.com/TanStack/db/blob/main/docs/contributing/oracle-reviews/2026-09-30-where-predicate-and-join-keys.md) records each ORC outcome. | | D2 Index storage | [Index refinement oracle](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/index-refinement-oracle.property.test.ts) | A plain-`Map` model sums multiplicities under a type-and-value identity in which `NaN` equals `NaN` and `-0` equals `0`. Histories of up to twelve additions to two keys mix prefixed arrays with `NaN`, `0`, `-0`, `1`, `'1'`, `1n`, and `'a'` prefixes and unprefixed values, with cancellation and reappearance. After each addition, `get` and `has` must equal the model. Comparing prefixes with `===`, which disagrees with the `Map` that holds them, fails three pinned histories and both campaigns; the `-0` control passes under both. Compaction, presence tracking, and structural payloads are outside this owner; the join operator tests and incrementalization law own operator behavior. | diff --git a/docs/contributing/oracle-reviews/code-weight-draft-proxy.md b/docs/contributing/oracle-reviews/code-weight-draft-proxy.md index addd55094..11edc3b98 100644 --- a/docs/contributing/oracle-reviews/code-weight-draft-proxy.md +++ b/docs/contributing/oracle-reviews/code-weight-draft-proxy.md @@ -1,6 +1,6 @@ # Code weight: simplify the draft proxy and share its equality walker -Reviewed executable revision: `1a07cd3ce` (base `18abceee4`, the fetched +Reviewed executable revision: `a49ae90e3` (base `18abceee4`, the fetched `origin/main` at review time). This record follows in a documentation-only commit. @@ -14,6 +14,8 @@ commit. - `1a07cd3ce` applies review fixes: a stronger revert grammar and witness, a duplicate comment, and a redundant `key in` check in `getChanges` (about 14 minified bytes). +- `fcc353d5d` through `a49ae90e3` fix the bugs that a second review found. Each + fix extends an oracle first. See Fixes from the second review. ## Change @@ -44,13 +46,16 @@ two-loop form. See Performance. | Consumer bundle (esbuild, `es2020`) | min | gzip | brotli | | --- | ---: | ---: | ---: | -| full public API | −1,739 (−0.50%) | −453 (−0.43%) | −145 (−0.16%) | -| collection with a filtered live query | −1,739 (−0.65%) | −461 (−0.58%) | −356 (−0.52%) | -| local-only collection with an insert, an update, and a delete | −1,736 (−1.42%) | −441 (−1.26%) | −256 (−0.83%) | -| local-only collection with one insert | −1,736 (−1.42%) | −439 (−1.25%) | −295 (−0.95%) | +| full public API | −822 (−0.23%) | −150 (−0.14%) | +1 (0.00%) | +| collection with a filtered live query | −822 (−0.31%) | −146 (−0.18%) | −12 (−0.02%) | +| local-only collection with an insert, an update, and a delete | −819 (−0.67%) | −155 (−0.44%) | −9 (−0.03%) | +| local-only collection with one insert | −819 (−0.67%) | −145 (−0.41%) | −64 (−0.21%) | Each row bundles the built `dist`, with private members renamed, for base and -refactor under the same `package.json`. +`a49ae90e3` under the same `package.json`. The refactor alone (`1a07cd3ce`) +saved 1,739 minified and 453 gzip bytes on the full public API. The fixes from +the second review add the rest back; Fixes from the second review lists the +cost of each. ## Why the oracle work came first @@ -172,6 +177,85 @@ refactor before the fix. | F2: inherited methods are returned unbound too | assertion failure (103/455) | | F3: the own-property check reads the original row | assertion failure (1/455) | +## Fixes from the second review + +A second review found one regression in the refactor and several bugs that +`main` also has. Each fix extends an owner first, so the new law fails before +the fix. Bytes are for the full public API, minified and gzip, against the +commit before. + +| Commit | Bug | Owner law | Fails on `main` | Bytes | +| --- | --- | --- | --- | ---: | +| `fcc353d5d` | A nested add-then-delete, a fully reverted nested object beside a changed sibling, and a key added as `undefined` left wrong changes. | Revert oracle: nested delete op and nested round-trip property. | 6 of 24 cases | +138 / +49 | +| `36030ef8f` | The refactor cloned a typed array with `new Ctor(source)`, so a subclass that does not forward its argument cloned empty. A typed array of `NaN` never equaled itself. | Revert oracle: `Float64Array`, `Uint8Array`, and a subclass, with index writes. `utils.property`: typed elements compare like numbers. | NaN cases only | +133 / +53 | +| `967a28c1d` | The native iterator bound to the draft made `for...of` over 200 numbers 4 times slower than `main`. | Iteration contract: arrays visit and publish what a native array does, with an edit while the iterator is open. | none (performance) | +293 / +93 | +| `5b4049cdf` | General mode allocated a draft entry list for every Map. | Existing Map mutants N6, N11, N14. | none (performance) | −5 / +5 | +| `b57d60022` | `at`, `slice`, `concat`, `flat`, `toReversed`, `toSpliced`, and `with` returned raw elements, so a write through them was lost. A search for a draft element failed, and `constructor` was a bound copy. | `proxy.test.ts`: every non-mutating method of `Array.prototype`, against a native array. | 15 of 39 cases | +38 / +16 | +| `38d8ba78f` | `Object.defineProperty(draft, key, { value })` threw unless the descriptor set `writable`. | `proxy.test.ts`: six definitions against a native row. | 3 of 6 cases | +8 / +8 | +| `815432ebd` | Two URLs, or two instances whose state is in private fields, were equal, so a draft dropped a write that replaced one. | Revert oracle: URL values and a private-field class, with a write-twice property. `utils.property`: class and identity law. | both campaigns | +312 / +79 | +| `a49ae90e3` | None. A detached stored method must throw, as on a native row. | `proxy.test.ts` stored-function law. | passes | 0 | + +Design decisions for these fixes: + +- **Typed-array class.** Draft equality treats a typed array of another class + as a change. `deepEquals` still ignores the class, as `utils.test.ts` has + pinned since #434. +- **Array read methods.** Every non-mutating method reads through the draft, + except searches, `join`, `keys`, `toString`, and `toLocaleString`. Their + results hold no elements, so they run on the copy, and a draft argument is + unwrapped. Reading through the draft makes `slice` slower (see + Performance). Running the element methods on the copy and then replacing + elements with drafts was faster but cost 167 more gzip bytes. +- **Classes and keyless objects.** In both modes, an object of another class + differs, and plain and null-prototype objects from any realm are one class. + A class instance without enumerable keys equals only itself. URLs compare + by `href`, because a draft copies a URL by its `href`; by identity, an + untouched URL would differ from its own snapshot. + +The law that takes its methods from `Array.prototype` closes a whole class: a +new built-in method fails the law until it has arguments. Its last case also +caught the bound `constructor`. + +Limits that remain: + +- A draft reads a class instance of the original row as a plain object, so a + private-field getter reads `undefined`. The detachment contract owns that + boundary. The revert oracle writes class instances but never starts with + one. +- A built-in method called through `this` on a nested Map or Set draft, such + as `Map.prototype.get.call(this.m, key)`, throws, because the receiver is a + Proxy. This is a Proxy limit. + +| Mutant | Owners | +| --- | --- | +| T1: typed clone by constructor argument | assertion failure (7/470) | +| T2: typed elements compare with `!==` | assertion failure (9/470) | +| T3: draft ignores typed-array class | assertion failure (1/470) | +| I1: iterator yields raw elements | assertion failure (9/488) | +| I2: `entries()` yields no index | assertion failure (7/488) | +| I3: iterator records a wrong parent edge | assertion failure (19/488) | +| I4: iterator caches the length | assertion failure (12/488) | +| I5: `values()` reads the raw copy | assertion failure (2/488) | +| I6: `entries()` reads the raw copy | assertion failure (2/488) | +| A1: element methods read the copy | assertion failure (42/527) | +| A2: searches do not unwrap a draft | assertion failure (5/527) | +| A3: `slice` runs on the copy | assertion failure (2/527) | +| A4: `constructor` is bound | assertion failure (1/527) | +| A5: searches read through the draft | survives (equivalent) | +| K1: no class check | assertion failure (4/538) | +| K2: keyless instances compare by keys | assertion failure (5/538) | +| K3: URLs compare by identity | assertion failure (10/538) | +| K4: a null prototype is another class | assertion failure (2/538) | +| K5: any two URLs are equal | assertion failure (4/538) | +| K6: empty arrays compare by identity | assertion failure (8/538) | +| K7: a URL equals a non-URL with the same `href` | assertion failure (2/538) | + +I4 first survived every owner, because no test edited an array while an +iterator was open. The iteration contract's array law closes that gap. K7 +first survived too, and `utils.property` now compares a URL with an object +that inherits the same `href`. A5 gives the same results and is only slower: +searches through the draft compare memoized drafts. + ## Mutant calibration "Owners" are the revert oracle and the four existing owners @@ -261,6 +345,28 @@ other proxy changes with the original loop were faster too (0.92, 0.93, 1.03). `propertyIsEnumerable` for each key. Keeping the two-loop form costs about 18 minified bytes. The harness is not checked in. +### Timings for the fixes + +These runs used 11 processes of each variant. The machine was loaded, and an +A/A control stayed within ±5%. + +| Workload | Ratio | Compared with | +| --- | ---: | --- | +| `for...of` and spread over 200 numbers | 0.98 (4.02 before `967a28c1d`) | `main` | +| `for...of` over 200 objects | 0.99 | `main` | +| `slice(0, 1)` on a 2-element draft array | 1.45 | the commit before `b57d60022` | +| `slice()` on a 50-number draft array | 5.6 | the commit before `b57d60022` | +| `join()` on a 50-number draft array | 1.03 | the commit before `b57d60022` | +| `deepEquals`: equal rows | 1.06 to 1.08 | the commit before `815432ebd` | +| `deepEquals`: unequal rows | 1.00 | the commit before `815432ebd` | + +The iterator first used a generator, which made object elements 1.4 times +slower. It also named the `get` trap as a function expression, which made +every draft write about 7% slower. The kept iterator is a module function. It +calls the trap through the handler object and skips primitives. The class +check in `deepEquals` runs after the keys match, so an unequal row exits +before it. + ## ORC-001 through ORC-012 | Requirement | Result | @@ -268,10 +374,10 @@ minified bytes. The harness is not checked in. | ORC-001 | The revert oracle's opening comment states the `getChanges()` contract, the six draft-equality rules, the three laws, the authority, and the limits. | | ORC-002 | The model is an encoding of plain-data specs. It does not import `draftValuesEqual`, `deepEquals`, or `deepClone`, and it never reads a draft. | | ORC-003 | The contract, model (`canon`, `step`, `expectedChanges`), grammar (`specArb`, `opArb`, `historyArb`), driver (`drive`, `realize`), and check (`expectHistory`) are separate in one file. | -| ORC-004 | Pinned histories reconstruct each rule. The mutants above are the ablations. The positive witness is the range check. The grammar excludes mutator methods and top-level symbol writes, as declared. | -| ORC-005 | The driver uses `createChangeProxy`. The positive witness shows that the fixed campaign reaches every operation and both revert outcomes. | -| ORC-006 | Every mutant has an outcome class. P4 is recorded as equivalent with its reason. | -| ORC-007 | Fixed and random campaigns share the property, grammar, check, and budget (400 runs). The replay variables select a direct replay. | +| ORC-004 | Pinned histories reconstruct each rule. The mutants above are the ablations. The positive witness is the range check. The grammar excludes mutator methods, top-level symbol writes, and class instances in the original row, as declared. | +| ORC-005 | The driver uses `createChangeProxy`. The positive witnesses show that the fixed campaigns reach every operation, both revert outcomes, and at least 10 writes each of another URL and of a keyless instance. | +| ORC-006 | Every mutant has an outcome class. P4 and A5 are recorded as equivalent with their reasons. | +| ORC-007 | Fixed and random campaigns share each property, grammar, check, and budget (400 general histories, 200 for each focused property). The replay variables select a direct replay. | | ORC-008 | The model is a stateless recomputation over specs, so this requirement does not apply. | | ORC-009 | Terms match `proxy.ts` and `utils.ts`: draft, revert, changes, draft equality. Spec kinds such as `rows` are model-only and named in the oracle. | | ORC-010 | fast-check reports the seed, path, and shrunk history. Assertion messages give the field and the full history. | @@ -280,11 +386,10 @@ minified bytes. The harness is not checked in. ## Verification -On `1a07cd3ce`, with the built `dist`: +On `a49ae90e3`, with the built `dist`: -- `packages/db` Vitest, typecheck off: 198 files, 7,636 tests. -- `packages/db` `tsc --noEmit`: only the pre-existing errors in - `tests/conformance` that `main` also has. +- `packages/db` Vitest, typecheck off: 198 files, 7,718 tests. +- `packages/db` `tsc --noEmit`: no errors. - `pnpm check:mangle`: 368 names. - `pnpm test:minified-db`: error names, index metadata, query rows, and live updates. diff --git a/packages/db/tests/proxy-revert-oracle.property.test.ts b/packages/db/tests/proxy-revert-oracle.property.test.ts index fd7914f68..7603376ce 100644 --- a/packages/db/tests/proxy-revert-oracle.property.test.ts +++ b/packages/db/tests/proxy-revert-oracle.property.test.ts @@ -37,7 +37,8 @@ import { createChangeProxy } from '../src/proxy' * * Authority: the `createChangeProxy` and `getChanges` implementation comments * in `src/proxy.ts` and the revert examples in `tests/proxy.test.ts`, as of - * `18abceee`. + * `18abceee`. Rules 7 to 9 are design decisions recorded in + * `docs/contributing/oracle-reviews/code-weight-draft-proxy.md`. * * Limits: * - Writes are assignments, deletes, nested property writes, and nested writes From 1e2044b7db8a360ebfbbe2b11628938f03190834 Mon Sep 17 00:00:00 2001 From: Isaac Date: Thu, 1 Oct 2026 13:00:57 -0600 Subject: [PATCH 18/28] refactor(db): drop draft code that only bought speed Drafts run inside update callbacks, rarely and on small values, so their speed does not matter and their bytes do. This removes: - the light array iterator: iterators bind to the draft like every other non-mutating array method; - the list of array methods that ran on the copy: searches, join, and keys also read through the draft, which finds draft elements without an unwrap; - the primitive fast path in the get trap; - the two-loop deepClone key walk: one Reflect.ownKeys loop copies enumerable string keys and every symbol key; - the typed-array element loop: TypedArray#set copies the elements; - the deferred class check in deepEquals: it now runs first, without a helper. The full public API is 623 minified and 193 gzip bytes smaller than the previous commit. The existing laws cover each change, and mutants of each new form fail them. Co-authored-by: Isaac --- packages/db/src/proxy.ts | 111 ++++++--------------------------------- packages/db/src/utils.ts | 27 +++++----- 2 files changed, 28 insertions(+), 110 deletions(-) diff --git a/packages/db/src/proxy.ts b/packages/db/src/proxy.ts index a3e46148e..f5af1d6af 100644 --- a/packages/db/src/proxy.ts +++ b/packages/db/src/proxy.ts @@ -30,19 +30,6 @@ function unwrapDraft(value: unknown): unknown { : value } -/** - * Array methods whose results hold no elements of the array. - */ -const ARRAY_VALUE_METHODS = new Set([ - `includes`, - `indexOf`, - `lastIndexOf`, - `join`, - `keys`, - `toLocaleString`, - `toString`, -]) - /** * Set of array methods that modify the array in place. */ @@ -83,38 +70,6 @@ function isProxiableObject( ) } -/** - * Iterates a draft array. Each element is read like an index read; `read` is - * the draft's `get` trap, called directly because a read through the Proxy is - * several times slower. Primitives skip it: the trap returns them as read. - */ -function iterateArray( - read: (array: A, prop: string, receiver: unknown) => unknown, - array: A & Array, - receiver: unknown, - withIndex: boolean, -): IterableIterator { - let index = 0 - return { - next() { - if (index >= array.length) return { done: true, value: undefined } - let element = array[index] - if ( - element !== null && - (typeof element === `object` || typeof element === `function`) - ) - element = read(array, String(index), receiver) - return { - done: false, - value: withIndex ? [index++, element] : (index++, element), - } - }, - [Symbol.iterator]() { - return this - }, - } -} - /** * Creates a wrapper for methods that modify a collection (array, Map, Set). * The wrapper calls the method and marks the change tracker as modified. @@ -220,7 +175,7 @@ interface ChangeTracker { interface TypedArray { length: number - [index: number]: number + set: (source: TypedArray) => void } function deepClone( @@ -280,11 +235,9 @@ function deepClone( const TypedArrayConstructor = Object.getPrototypeOf(obj).constructor const clone = new TypedArrayConstructor( (obj as unknown as TypedArray).length, - ) as unknown as TypedArray + ) as TypedArray visited.set(obj as object, clone) - for (let i = 0; i < (obj as unknown as TypedArray).length; i++) { - clone[i] = (obj as unknown as TypedArray)[i]! - } + clone.set(obj as unknown as TypedArray) return clone as unknown as T } @@ -323,11 +276,12 @@ function deepClone( const clone = {} as Record visited.set(obj as object, clone) - // Own enumerable string keys, then every own symbol key, in native order. - // A Reflect.ownKeys loop with an enumerability check is shorter but slower - // on this hot path. - for (const key in obj) { - if (Object.prototype.hasOwnProperty.call(obj, key)) { + // Own enumerable string keys and every own symbol key, in native order. + for (const key of Reflect.ownKeys(obj)) { + if ( + typeof key === `symbol` || + Object.prototype.propertyIsEnumerable.call(obj, key) + ) { // Copy data properties without invoking Object.prototype.__proto__. defineDataProperty( clone, @@ -341,14 +295,6 @@ function deepClone( } } - for (const sym of Object.getOwnPropertySymbols(obj)) { - clone[sym] = deepClone( - (obj as Record)[sym], - visited, - detach, - ) - } - return clone as T } @@ -506,15 +452,9 @@ export function createChangeProxy< // Create a proxy for the target object. // Use the unfrozen copy_ as the proxy target to avoid Proxy invariant violations // when the original target is frozen (e.g., from Immer) - const handler: ProxyHandler = { + const proxy = new Proxy(changeTracker.copy_, { get(ptarget, prop, receiver) { const value = changeTracker.copy_[prop as keyof T] - // Primitives return as read, from a getter or not. - if ( - value === null || - (typeof value !== `object` && typeof value !== `function`) - ) - return value // If it's a getter, return the value directly const desc = Object.getOwnPropertyDescriptor(ptarget, prop) @@ -542,30 +482,10 @@ export function createChangeProxy< ) } - if ( - methodName === `values` || - methodName === `entries` || - prop === Symbol.iterator - ) { - return () => - iterateArray( - handler.get!, - ptarget, - receiver, - methodName === `entries`, - ) - } - - // These results hold no elements, so they run on the copy, where a - // draft element argument is its copy. - if (ARRAY_VALUE_METHODS.has(methodName)) { - return (search: unknown, ...rest: Array) => - value.call(ptarget, unwrapDraft(search), ...rest) - } - - // Other methods read through the draft itself, so returned and - // callback elements are drafts. This also tracks the callback array - // and implicit reduce seed. + // Other methods, iterators included, read through the draft + // itself, so returned and callback elements are drafts and searches + // find them. This also tracks the callback array and implicit + // reduce seed. return value.bind(receiver) } @@ -758,8 +678,7 @@ export function createChangeProxy< return true }, - } - const proxy = new Proxy(changeTracker.copy_, handler) + }) draftCopies.set(proxy, changeTracker.copy_) // Return the proxy and a function to get the changes diff --git a/packages/db/src/utils.ts b/packages/db/src/utils.ts index 4d2ff52bc..a08bef751 100644 --- a/packages/db/src/utils.ts +++ b/packages/db/src/utils.ts @@ -34,17 +34,6 @@ function isPlainPrototype(prototype: object | null): boolean { return prototype === null || Object.getPrototypeOf(prototype) === null } -// An object of another class differs. Plain and null-prototype objects, from -// any realm, are one class. -function isSameClass(a: object, b: object): boolean { - const prototype = Object.getPrototypeOf(a) - const prototypeB = Object.getPrototypeOf(b) - return ( - prototype === prototypeB || - (isPlainPrototype(prototype) && isPlainPrototype(prototypeB)) - ) -} - function enumerableOwnKeys(value: object): Array { const keys: Array = Object.keys(value) for (const key of Object.getOwnPropertySymbols(value)) { @@ -262,6 +251,16 @@ export function deepEqualsInternal( } // Handle objects if (typeof a === `object`) { + // An object of another class differs. Plain and null-prototype objects, + // from any realm, are one class. + const prototype = Object.getPrototypeOf(a) + const prototypeB = Object.getPrototypeOf(b) + if ( + prototype !== prototypeB && + !(isPlainPrototype(prototype) && isPlainPrototype(prototypeB)) + ) + return false + // Check for circular references if (visited.has(a)) { return visited.get(a) === b @@ -285,10 +284,10 @@ export function deepEqualsInternal( if ( keysA.length === 0 && !Array.isArray(a) && - !isPlainPrototype(Object.getPrototypeOf(a)) + !isPlainPrototype(prototype) ) { visited.delete(a) - return a instanceof URL && b instanceof URL && a.href === b.href + return a instanceof URL && a.href === b.href } // Check if all keys exist in both objects and their values are equal @@ -299,7 +298,7 @@ export function deepEqualsInternal( ) visited.delete(a) - return result && isSameClass(a, b) + return result } // For primitives that aren't strictly equal From 674f87bee6361d158ad5f5daefb80d39b1df1c8e Mon Sep 17 00:00:00 2001 From: Isaac Date: Thu, 1 Oct 2026 13:02:31 -0600 Subject: [PATCH 19/28] docs: record the draft proxy code-weight pass Ties the review record to 31b2a6bd4 with the removed speed-only code, its mutants, and the new bytes, and updates the changeset. Co-authored-by: Isaac --- .changeset/simplify-draft-proxy.md | 2 +- .../oracle-reviews/code-weight-draft-proxy.md | 76 ++++++++++++++----- 2 files changed, 59 insertions(+), 19 deletions(-) diff --git a/.changeset/simplify-draft-proxy.md b/.changeset/simplify-draft-proxy.md index 66e380a4d..d33fe8760 100644 --- a/.changeset/simplify-draft-proxy.md +++ b/.changeset/simplify-draft-proxy.md @@ -2,7 +2,7 @@ '@tanstack/db': patch --- -Simplify the mutation draft proxy and share one equality walker with `deepEquals`. Draft writes are faster, and a typical app bundle is about 150 B smaller with gzip. +Simplify the mutation draft proxy and share one equality walker with `deepEquals`. A typical app bundle is about 350 B smaller with gzip. This change also fixes draft writes that were lost or wrong: diff --git a/docs/contributing/oracle-reviews/code-weight-draft-proxy.md b/docs/contributing/oracle-reviews/code-weight-draft-proxy.md index 11edc3b98..cc25f232e 100644 --- a/docs/contributing/oracle-reviews/code-weight-draft-proxy.md +++ b/docs/contributing/oracle-reviews/code-weight-draft-proxy.md @@ -1,6 +1,6 @@ # Code weight: simplify the draft proxy and share its equality walker -Reviewed executable revision: `a49ae90e3` (base `18abceee4`, the fetched +Reviewed executable revision: `31b2a6bd4` (base `18abceee4`, the fetched `origin/main` at review time). This record follows in a documentation-only commit. @@ -16,6 +16,8 @@ commit. minified bytes). - `fcc353d5d` through `a49ae90e3` fix the bugs that a second review found. Each fix extends an oracle first. See Fixes from the second review. +- `31b2a6bd4` removes draft code that only bought speed. See Code weight over + speed. ## Change @@ -41,21 +43,22 @@ commit. member) is now returned as stored. See Stored functions. The audit prototype also rewrote `deepClone` as one `Reflect.ownKeys` loop. That -loop made every draft 8% to 17% slower, so this change keeps the original -two-loop form. See Performance. +loop made every draft 8% to 17% slower, so the refactor first kept the +original two-loop form. `31b2a6bd4` adopts the shorter loop. See Code weight +over speed. | Consumer bundle (esbuild, `es2020`) | min | gzip | brotli | | --- | ---: | ---: | ---: | -| full public API | −822 (−0.23%) | −150 (−0.14%) | +1 (0.00%) | -| collection with a filtered live query | −822 (−0.31%) | −146 (−0.18%) | −12 (−0.02%) | -| local-only collection with an insert, an update, and a delete | −819 (−0.67%) | −155 (−0.44%) | −9 (−0.03%) | -| local-only collection with one insert | −819 (−0.67%) | −145 (−0.41%) | −64 (−0.21%) | +| full public API | −1,445 (−0.41%) | −343 (−0.33%) | −108 (−0.12%) | +| collection with a filtered live query | −1,445 (−0.54%) | −363 (−0.46%) | −279 (−0.41%) | +| local-only collection with an insert, an update, and a delete | −1,441 (−1.18%) | −350 (−1.00%) | −223 (−0.72%) | +| local-only collection with one insert | −1,441 (−1.18%) | −350 (−1.00%) | −227 (−0.73%) | Each row bundles the built `dist`, with private members renamed, for base and -`a49ae90e3` under the same `package.json`. The refactor alone (`1a07cd3ce`) +`31b2a6bd4` under the same `package.json`. The refactor alone (`1a07cd3ce`) saved 1,739 minified and 453 gzip bytes on the full public API. The fixes from -the second review add the rest back; Fixes from the second review lists the -cost of each. +the second review added 917 minified and 303 gzip bytes. `31b2a6bd4` then +removed 623 minified and 193 gzip bytes of speed-only code. ## Why the oracle work came first @@ -200,12 +203,11 @@ Design decisions for these fixes: - **Typed-array class.** Draft equality treats a typed array of another class as a change. `deepEquals` still ignores the class, as `utils.test.ts` has pinned since #434. -- **Array read methods.** Every non-mutating method reads through the draft, - except searches, `join`, `keys`, `toString`, and `toLocaleString`. Their - results hold no elements, so they run on the copy, and a draft argument is - unwrapped. Reading through the draft makes `slice` slower (see - Performance). Running the element methods on the copy and then replacing - elements with drafts was faster but cost 167 more gzip bytes. +- **Array read methods.** Every non-mutating method reads through the draft. + `b57d60022` first ran searches, `join`, `keys`, and `toString` on the copy + for speed. `31b2a6bd4` removed that list. Running the element methods on the + copy and then replacing elements with drafts was faster but cost 167 more + gzip bytes. - **Classes and keyless objects.** In both modes, an object of another class differs, and plain and null-prototype objects from any realm are one class. A class instance without enumerable keys equals only itself. URLs compare @@ -256,6 +258,42 @@ first survived too, and `utils.property` now compares a URL with an object that inherits the same `href`. A5 gives the same results and is only slower: searches through the draft compare memoized drafts. +## Code weight over speed + +Drafts run inside `collection.update` callbacks. These calls are rare and +almost never touch large values, so draft speed does not matter, and bytes +do. `31b2a6bd4` removes code that only bought speed: + +| Removed | Speed it bought | Law that still owns the behavior | +| --- | --- | --- | +| The light array iterator from `967a28c1d` | `for...of` over 200 numbers: 0.98 of `main` instead of 4.0 | Iteration contract, array law | +| The list of array methods that ran on the copy | `join()` on 50 numbers: 1.03 instead of 2.8 | `Array.prototype` law in `proxy.test.ts` | +| The primitive fast path in the `get` trap | `slice` on small arrays | Every proxy owner | +| The two-loop `deepClone` key walk | 8% to 17% for each draft | Detachment contract key-class law | +| The typed-array element loop (`TypedArray#set` copies instead) | none | Revert oracle typed values | +| The deferred class check in `deepEquals` | unequal rows exit before the class check | Class law in `utils.property` | + +Mutants of each new form fail the owners: + +| Mutant on `31b2a6bd4` | Owners | +| --- | --- | +| C1: clone copies non-enumerable strings | assertion failure (2/539) | +| C2: clone drops every symbol | assertion failure (10/539) | +| C3: clone drops non-enumerable symbols | assertion failure (2/539) | +| T1: typed clone stays empty | assertion failure (13/539) | +| N3: the iterator reads the raw copy | assertion failure (16/539) | +| A1: read methods run on the copy | assertion failure (56/539) | +| A2: searches run on the copy | assertion failure (4/539) | +| K1: no class check | assertion failure (4/539) | +| K2: keyless instances compare by keys | assertion failure (5/539) | +| K3: URLs compare by identity | assertion failure (10/539) | +| K4: a null prototype is another class | assertion failure (2/539) | +| K5: any two URLs are equal | assertion failure (5/539) | +| K6: empty arrays compare by identity | assertion failure (8/539) | + +The I and A3 to A5 mutants in the previous section apply to code that +`31b2a6bd4` removed. + ## Mutant calibration "Owners" are the revert oracle and the four existing owners @@ -347,7 +385,9 @@ minified bytes. The harness is not checked in. ### Timings for the fixes -These runs used 11 processes of each variant. The machine was loaded, and an +These timings describe `a49ae90e3`. `31b2a6bd4` removed the iterator and +the copy-method list for code weight, so the iteration and `join` rows no +longer hold. These runs used 11 processes of each variant. The machine was loaded, and an A/A control stayed within ±5%. | Workload | Ratio | Compared with | @@ -386,7 +426,7 @@ before it. ## Verification -On `a49ae90e3`, with the built `dist`: +On `31b2a6bd4`, with the built `dist`: - `packages/db` Vitest, typecheck off: 198 files, 7,718 tests. - `packages/db` `tsc --noEmit`: no errors. From b825e648314c4b11534a67d90eb7ef9cd4307443 Mon Sep 17 00:00:00 2001 From: Isaac Date: Thu, 1 Oct 2026 13:09:58 -0600 Subject: [PATCH 20/28] fix(db): keep a read-only non-configurable draft property as defined Defining a non-configurable property with an object value threw: defineProperty stored a clone, and the Proxy invariants require the target property to match the caller's descriptor. A read would also have thrown, because get wrapped the value in a draft. defineProperty now forwards the descriptor as given, like an assignment during the callback; publication still detaches the value. get returns a read-only non-configurable value as stored. The defineProperty law adds a new key with only an object value and checks identity where the invariants require it. Found by CodeRabbit review of 251f97c7c. Co-authored-by: Isaac --- packages/db/src/proxy.ts | 18 +++++------------- packages/db/tests/proxy.test.ts | 8 ++++++++ 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/packages/db/src/proxy.ts b/packages/db/src/proxy.ts index f5af1d6af..55c3f35f6 100644 --- a/packages/db/src/proxy.ts +++ b/packages/db/src/proxy.ts @@ -456,9 +456,10 @@ export function createChangeProxy< get(ptarget, prop, receiver) { const value = changeTracker.copy_[prop as keyof T] - // If it's a getter, return the value directly + // A getter's result, and a read-only non-configurable value, which + // the Proxy invariants require as stored, return directly. const desc = Object.getOwnPropertyDescriptor(ptarget, prop) - if (desc?.get) { + if (desc?.get || (desc?.configurable === false && !desc.writable)) { return value } @@ -610,17 +611,8 @@ export function createChangeProxy< defineProperty(ptarget, prop, descriptor) { // Forward the defineProperty to the target to maintain Proxy invariants // This allows Object.seal() and Object.freeze() to work on the proxy - // A defined value is stored as a clone, in the definition itself: a - // later assignment would fail on a read-only property. - const hasValue = `value` in descriptor - const result = Reflect.defineProperty( - ptarget, - prop, - hasValue - ? { ...descriptor, value: deepClone(descriptor.value) } - : descriptor, - ) - if (result && hasValue) { + const result = Reflect.defineProperty(ptarget, prop, descriptor) + if (result && `value` in descriptor) { changeTracker.assigned_[prop.toString()] = true markChanged(changeTracker) } diff --git a/packages/db/tests/proxy.test.ts b/packages/db/tests/proxy.test.ts index 665ff19d4..578ba1bf7 100644 --- a/packages/db/tests/proxy.test.ts +++ b/packages/db/tests/proxy.test.ts @@ -2692,6 +2692,10 @@ describe(`defineProperty behaves like on a native row`, () => { ], [`an object value`, () => ({ key: `a`, value: { c: 3 }, writable: false })], [`a nested key`, () => ({ key: `nested`, value: { b: 3 } })], + [ + `a new key with only an object value`, + () => ({ key: `k`, value: { c: 3 } }), + ], ] const observe = ( row: Row, @@ -2700,7 +2704,11 @@ describe(`defineProperty behaves like on a native row`, () => { const { key, ...descriptor } = define(row) const defined = Reflect.defineProperty(row, key, descriptor) const { value, ...rest } = Object.getOwnPropertyDescriptor(row, key) ?? {} + // A read-only, non-configurable property must read back as defined. + const fixed = rest.configurable === false && rest.writable === false + const same = fixed ? row[key] === descriptor.value : undefined return { + same, defined, value: JSON.stringify(value), rest, From 40262afe6477783704c33cd6c8706a65ebeb149d Mon Sep 17 00:00:00 2001 From: Isaac Date: Thu, 1 Oct 2026 13:18:15 -0600 Subject: [PATCH 21/28] docs: record the defineProperty follow-up fix Co-authored-by: Isaac --- .../oracle-reviews/code-weight-draft-proxy.md | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/docs/contributing/oracle-reviews/code-weight-draft-proxy.md b/docs/contributing/oracle-reviews/code-weight-draft-proxy.md index cc25f232e..ad7355b03 100644 --- a/docs/contributing/oracle-reviews/code-weight-draft-proxy.md +++ b/docs/contributing/oracle-reviews/code-weight-draft-proxy.md @@ -1,6 +1,6 @@ # Code weight: simplify the draft proxy and share its equality walker -Reviewed executable revision: `31b2a6bd4` (base `18abceee4`, the fetched +Reviewed executable revision: `b825e6483` (base `18abceee4`, the fetched `origin/main` at review time). This record follows in a documentation-only commit. @@ -18,6 +18,9 @@ commit. fix extends an oracle first. See Fixes from the second review. - `31b2a6bd4` removes draft code that only bought speed. See Code weight over speed. +- `b825e6483` fixes a CodeRabbit finding in the `defineProperty` fix: a + non-configurable property with an object value threw. See Fixes from the + second review. ## Change @@ -196,6 +199,7 @@ commit before. | `b57d60022` | `at`, `slice`, `concat`, `flat`, `toReversed`, `toSpliced`, and `with` returned raw elements, so a write through them was lost. A search for a draft element failed, and `constructor` was a bound copy. | `proxy.test.ts`: every non-mutating method of `Array.prototype`, against a native array. | 15 of 39 cases | +38 / +16 | | `38d8ba78f` | `Object.defineProperty(draft, key, { value })` threw unless the descriptor set `writable`. | `proxy.test.ts`: six definitions against a native row. | 3 of 6 cases | +8 / +8 | | `815432ebd` | Two URLs, or two instances whose state is in private fields, were equal, so a draft dropped a write that replaced one. | Revert oracle: URL values and a private-field class, with a write-twice property. `utils.property`: class and identity law. | both campaigns | +312 / +79 | +| `b825e6483` | `38d8ba78f` defined a clone, so a non-configurable object value broke the Proxy invariants and threw. `get` would also have wrapped it. | `defineProperty` law: a new key with only an object value, with identity where the invariants require it. | throws on `main` too | +9 / +5 (module only) | | `a49ae90e3` | None. A detached stored method must throw, as on a native row. | `proxy.test.ts` stored-function law. | passes | 0 | Design decisions for these fixes: @@ -290,6 +294,8 @@ Mutants of each new form fail the owners: | K4: a null prototype is another class | assertion failure (2/539) | | K5: any two URLs are equal | assertion failure (5/539) | | K6: empty arrays compare by identity | assertion failure (8/539) | +| D1 (on `b825e6483`): `get` wraps a read-only non-configurable value | crash, the reported TypeError (1/540) | +| D2 (on `b825e6483`): `defineProperty` stores a clone | crash, the reported TypeError (1/540) | The I and A3 to A5 mutants in the previous section apply to code that `31b2a6bd4` removed. @@ -426,9 +432,10 @@ before it. ## Verification -On `31b2a6bd4`, with the built `dist`: +On `bb48e2bc9` (`b825e6483` merged with `origin/main` at `3463cf8ad`), with +the built `dist`: -- `packages/db` Vitest, typecheck off: 198 files, 7,718 tests. +- `packages/db` Vitest, typecheck off: 198 files, 7,751 tests. - `packages/db` `tsc --noEmit`: no errors. - `pnpm check:mangle`: 368 names. - `pnpm test:minified-db`: error names, index metadata, query rows, and live From 0ce101acf99d274e656d29185083f1a1460f99c6 Mon Sep 17 00:00:00 2001 From: Isaac Date: Thu, 1 Oct 2026 13:40:46 -0600 Subject: [PATCH 22/28] fix(db): track accessor definitions and typed-array mutators in drafts Two lost-write classes that main also has, found by CodeRabbit review: - defineProperty counted a change only when the descriptor had a value, so defining a getter over a field reported no change. A value or an accessor now counts; a definition that only changes attributes, as Object.seal makes, still does not. - fill, set, sort, reverse, and copyWithin on a typed-array draft wrote to the private copy without marking a change. They now take the array mutator path. subarray does too, because it shares the buffer, so a subarray call counts as a change even without a later write. A typed-array law in proxy.test.ts takes every method from TypedArray.prototype and compares its result and the row after a write through it with a native typed array; a new built-in method fails until it has arguments. It fails 6 of 31 methods on the previous commit. The defineProperty law adds two getter definitions, which failed too. Co-authored-by: Isaac --- packages/db/src/proxy.ts | 32 ++++++----- packages/db/tests/proxy.test.ts | 99 +++++++++++++++++++++++++++++++-- 2 files changed, 113 insertions(+), 18 deletions(-) diff --git a/packages/db/src/proxy.ts b/packages/db/src/proxy.ts index 55c3f35f6..3e9a65897 100644 --- a/packages/db/src/proxy.ts +++ b/packages/db/src/proxy.ts @@ -31,7 +31,8 @@ function unwrapDraft(value: unknown): unknown { } /** - * Set of array methods that modify the array in place. + * Array and typed-array methods that modify the value in place. A typed + * array's `subarray` shares its buffer, so later writes through it do too. */ const ARRAY_MODIFYING_METHODS = new Set([ `pop`, @@ -43,6 +44,8 @@ const ARRAY_MODIFYING_METHODS = new Set([ `reverse`, `fill`, `copyWithin`, + `set`, + `subarray`, ]) /** @@ -471,18 +474,17 @@ export function createChangeProxy< // Set) need the handling below. if (Object.hasOwn(ptarget, prop) || prop === `constructor`) return value - // For Array methods that modify the array - if (Array.isArray(ptarget)) { - const methodName = prop.toString() + const methodName = prop.toString() - if (ARRAY_MODIFYING_METHODS.has(methodName)) { - return createModifyingMethodHandler( - value, - changeTracker, - markChanged, - ) - } + // Array and typed-array methods that modify the value in place + if ( + ARRAY_MODIFYING_METHODS.has(methodName) && + (Array.isArray(ptarget) || ArrayBuffer.isView(ptarget)) + ) { + return createModifyingMethodHandler(value, changeTracker, markChanged) + } + if (Array.isArray(ptarget)) { // Other methods, iterators included, read through the draft // itself, so returned and callback elements are drafts and searches // find them. This also tracks the callback array and implicit @@ -492,8 +494,6 @@ export function createChangeProxy< // For Map and Set methods that modify the collection if (ptarget instanceof Map || ptarget instanceof Set) { - const methodName = prop.toString() - const resolveValue = (entry: unknown) => { const raw = unwrapDraft(entry) return raw !== null && typeof raw === `object` @@ -612,7 +612,11 @@ export function createChangeProxy< // Forward the defineProperty to the target to maintain Proxy invariants // This allows Object.seal() and Object.freeze() to work on the proxy const result = Reflect.defineProperty(ptarget, prop, descriptor) - if (result && `value` in descriptor) { + // A value or an accessor changes what the key reads. Sealing does not. + if ( + result && + (`value` in descriptor || descriptor.get || descriptor.set) + ) { changeTracker.assigned_[prop.toString()] = true markChanged(changeTracker) } diff --git a/packages/db/tests/proxy.test.ts b/packages/db/tests/proxy.test.ts index 578ba1bf7..82d5e11fe 100644 --- a/packages/db/tests/proxy.test.ts +++ b/packages/db/tests/proxy.test.ts @@ -2671,6 +2671,8 @@ describe(`array read methods behave like native arrays`, () => { describe(`defineProperty behaves like on a native row`, () => { type Row = Record const make = (): Row => ({ a: 1, nested: { b: 2 } }) + // One getter for both rows, so their descriptors compare equal. + const getTwo = () => 2 const definitions: Array< [string, (row: Row) => PropertyDescriptor & { key: string }] > = [ @@ -2696,6 +2698,11 @@ describe(`defineProperty behaves like on a native row`, () => { `a new key with only an object value`, () => ({ key: `k`, value: { c: 3 } }), ], + [`a getter over an existing key`, () => ({ key: `a`, get: getTwo })], + [ + `a new enumerable getter`, + () => ({ key: `k`, get: getTwo, enumerable: true, configurable: true }), + ], ] const observe = ( row: Row, @@ -2717,12 +2724,96 @@ describe(`defineProperty behaves like on a native row`, () => { } it.each(definitions)(`%s`, (_name, define) => { - const expected = observe(make(), define) + const native = make() + const expected = observe(native, define) const { proxy, getChanges } = createChangeProxy(make()) expect(observe(proxy, define)).toEqual(expected) - // Like a clone, changes hold enumerable string keys only. - const { key, value } = define(proxy) + // Like a clone, changes hold enumerable string keys only, with the value + // the native row now reads. + const { key } = define(proxy) const enumerable = expected.rest.enumerable === true - expect(getChanges()).toEqual(enumerable ? { [key]: value } : {}) + expect(getChanges()).toEqual(enumerable ? { [key]: native[key] } : {}) + }) +}) + +/** + * Every typed-array method must behave on a draft as on a native typed array. + * The method list comes from the shared `TypedArray.prototype`, so a new + * built-in method fails here until it has arguments below. Each call is + * observed by its result and by the row after a write through a typed-array + * result, which on a native row changes the row when the result shares its + * buffer (`subarray`). + */ +describe(`typed-array methods behave like native typed arrays`, () => { + type Row = { t: Float64Array } + const make = (): Row => ({ t: Float64Array.of(3, 1, 2) }) + const positive = (v: number) => v > 1 + const calls: Record> = { + at: [0], + copyWithin: [0, 1], + entries: [], + every: [positive], + fill: [7], + filter: [positive], + find: [positive], + findIndex: [positive], + findLast: [positive], + findLastIndex: [positive], + forEach: [() => undefined], + includes: [2], + indexOf: [2], + join: [`,`], + keys: [], + lastIndexOf: [2], + map: [(v: number) => v * 2], + reduce: [(a: number, v: number) => a + v], + reduceRight: [(a: number, v: number) => a + v], + reverse: [], + set: [[9], 1], + slice: [0, 2], + some: [positive], + sort: [], + subarray: [0, 2], + toLocaleString: [], + toReversed: [], + toSorted: [], + toString: [], + values: [], + with: [0, 9], + } + const typedArrayPrototype = Object.getPrototypeOf(Float64Array.prototype) + const methods = Object.getOwnPropertyNames(typedArrayPrototype).filter( + (name) => + name !== `constructor` && + typeof Object.getOwnPropertyDescriptor(typedArrayPrototype, name) + ?.value === `function`, + ) + const observe = (row: Row, method: string) => { + const call = ( + row.t as unknown as Record) => unknown> + )[method]! + const raw = call.apply(row.t, calls[method]!) + const result = + raw !== null && typeof raw === `object` && Symbol.iterator in raw + ? Array.from(raw as Iterable) + : raw + if (ArrayBuffer.isView(raw)) (raw as Float64Array)[0] = 42 + return result + } + + it(`has arguments for every method`, () => { + expect(methods.filter((name) => !(name in calls))).toEqual([]) + }) + + it.each(methods)(`%s gives the native result`, (method) => { + const native = make() + const expected = observe(native, method) + let actual: unknown + const changes = withChangeTracking(make(), (draft) => { + actual = observe(draft, method) + }) + expect(actual).toEqual(expected) + const changed = native.t.some((v, i) => v !== make().t[i]) + expect(changes).toEqual(changed ? { t: native.t } : {}) }) }) From ca0a2170c944946c4cc6dc9214eeff5ca91dbb32 Mon Sep 17 00:00:00 2001 From: Isaac Date: Thu, 1 Oct 2026 13:41:56 -0600 Subject: [PATCH 23/28] docs: record the accessor and typed-array mutator fixes Co-authored-by: Isaac --- .changeset/simplify-draft-proxy.md | 3 ++- docs/contributing/oracle-coverage.md | 2 +- .../oracle-reviews/code-weight-draft-proxy.md | 21 +++++++++++++++---- 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/.changeset/simplify-draft-proxy.md b/.changeset/simplify-draft-proxy.md index d33fe8760..b65b9eaeb 100644 --- a/.changeset/simplify-draft-proxy.md +++ b/.changeset/simplify-draft-proxy.md @@ -8,7 +8,8 @@ This change also fixes draft writes that were lost or wrong: - A function stored in a row is now returned as stored when read from a draft. Before, the draft returned a bound copy, so `draft.handler === handler` was false. A stored method also saw a private copy as `this`, so `collection.update` dropped the writes it made through `this`. - Array methods such as `at`, `slice`, `concat`, `flat`, `toReversed`, and `with` now return drafts, so a write through their result is saved. `indexOf` and `includes` find an element that the draft returned, and `draft.items.constructor === Array` is true. -- `Object.defineProperty(draft, key, { value })` no longer throws when the descriptor does not set `writable`. +- `Object.defineProperty(draft, key, { value })` no longer throws when the descriptor does not set `writable` or `configurable`. Defining a getter on a draft is now a change. +- `fill`, `set`, `sort`, `reverse`, and `copyWithin` on a typed array in a draft, and writes through its `subarray`, are now saved. - Deleting a nested key that the callback added, or writing a nested value back, no longer leaves the parent marked as changed. - A typed-array subclass whose constructor does not forward its argument is now copied with its elements. Typed arrays that hold `NaN` now equal themselves. diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index 981959378..3c946abe0 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -242,7 +242,7 @@ comment and the current API/architecture contract before extending its model. | Collection lifecycle | [mutation startup](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-mutation-startup-oracle.test.ts), [change-event history](https://github.com/TanStack/db/blob/main/packages/db/tests/change-event-history-oracle.test.ts), [history](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-subscription-lifecycle-history.property.test.ts), [publication](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-subscription-lifecycle-publication.property.test.ts), [replay](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-subscription-replay-oracle.property.test.ts), [effect disposal](https://github.com/TanStack/db/blob/main/packages/db/tests/effect-disposal-oracle.test.ts), [PR #1902 review](oracle-reviews/pr-1902-change-event-history.md), [batch-order decision review](oracle-reviews/change-event-batch-order.md) | Core Collection `insert`/`update`/`delete` admission while `startSync:false` is idle; complete public-row/change-message agreement across bounded one- and two-key histories plus replayable four-key campaigns; a two-key deferred sync history checks each key's causal insert/update/delete trace while allowing any cross-key interleaving. Reversing all deferred messages fails that check; reversing each sync transaction's distinct-key messages passes. Queued duplicate admission and cancellation, ownership and phase histories, exact caller/error/publication evidence, and late completion and restart have separate witnesses. Generated deferred histories with cancellation or reentrant callbacks need a witness in the change-event history owner before claiming general batch-shape coverage. Query write utilities and effect self-dependent disposal remain separate contracts. | | Paced mutations | [virtual-clock oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/paced-mutations-oracle.test.ts) | `createPacedMutations` with queue front/back options, bounded waiting capacity at zero and one, cleanup draining in FIFO/LIFO order after a held write, cleanup timing at the configured wait for immediate writes, admitted writes after separate Collection cleanup, debounce and throttle schedules with explicit and omitted options, and first-leading throttle execution at epoch zero. Finite histories compare optimistic rows, returned transaction identity and receipt outcome, execution order and virtual-clock time. Held writes verify queue serialization. Leading-only throttle and debounce witnesses reject skipped calls immediately with their named dropped-call errors, including omitted edges, both edges disabled, and same-row rollback while a prior write is held. Capacity witnesses cover overflow rejection, distinct-key optimistic rollback, same-key admitted-write survival, and in-flight versus waiting admission. Cleanup witnesses check repeated queue cleanup, eventual admitted receipt settlement, direct queue strategy rejection of new callbacks, public post-cleanup rollback with `QueueDisposedError`, pending debounce transactions that wait until the last call's quiet edge after separate Collection cleanup, and a trailing throttle timer that runs at its regular edge after separate Collection cleanup. Deferred custom queue and batch callbacks check compatibility with `void` and `false` execute results, respectively. Frozen options verify factory non-mutation; a mutable option witness checks that debounce uses its construction-time trailing setting. New debounce/throttle admission after cleanup, failed persistence, broader capacities and schedules remain outside this owner. | | Optimistic state | [history model](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-history-oracle.ts), [generated histories](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-transaction-oracle.property.test.ts), [truncate capture ownership](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-truncate-ownership-oracle.property.test.ts), [outcomes](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-history-outcomes.test.ts), [publication](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-history-publication.test.ts) | Independent whole-row snapshots, rollback dependencies, captured truncate ownership, metadata and prior-value events. Never rebase a pending snapshot merely to simplify the model. | -| Drafts and native values | [proxy](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy.test.ts), [detachment](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-detachment-contract.test.ts), [iteration](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-iteration-contract.test.ts), [revert](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-revert-oracle.property.test.ts) | Native-operation controls, exact patches and actual stored rows; alias/cycle/adversarial-key histories. Set and Map reorder histories check draft patches and stored iteration order after replacement and clear-and-readd; RegExp replacement/reversion histories check `lastIndex`. General native-mutator and symbol-write support is not established by a plain-object oracle. The revert owner generates assignment, revert, delete, nested, symbol-key, and `for...of` histories and checks `getChanges()` and the draft against an independent draft-equality model: ordered Map and Set contents (including Sets inside Map values), RegExp `lastIndex`, array holes, and symbol keys inside nested values. Mutator methods (`push`, `set`, `add`) and top-level symbol writes are outside its grammar. The proxy owner's stored-function law checks, against a native row, that a function stored as data reads back as stored by every read path and that a stored method sees the draft as `this`, so its writes are tracked. The detachment owner pins which key classes a draft copies: enumerable string keys and every symbol key. The revert owner also generates typed arrays (including a subclass that does not forward its constructor argument), URLs, and a class with only private state as written values, with a property that writes two of them over one field. The proxy owner's array law takes every non-mutating method from `Array.prototype` and compares its result, the identity of each object in it, and the row after a write through it with a native array; a new built-in method fails until it has arguments. Its `defineProperty` law compares result, value, descriptor, and changes. The iteration owner checks that array iterators see a push, removal, index write, or length cut made while they are open. Open: a draft reads a class instance of the original row as a plain object, so private-field getters read `undefined`, and a built-in method called through `this` on a nested Map or Set draft rejects the Proxy receiver ([draft proxy review](oracle-reviews/code-weight-draft-proxy.md)). | +| Drafts and native values | [proxy](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy.test.ts), [detachment](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-detachment-contract.test.ts), [iteration](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-iteration-contract.test.ts), [revert](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-revert-oracle.property.test.ts) | Native-operation controls, exact patches and actual stored rows; alias/cycle/adversarial-key histories. Set and Map reorder histories check draft patches and stored iteration order after replacement and clear-and-readd; RegExp replacement/reversion histories check `lastIndex`. General native-mutator and symbol-write support is not established by a plain-object oracle. The revert owner generates assignment, revert, delete, nested, symbol-key, and `for...of` histories and checks `getChanges()` and the draft against an independent draft-equality model: ordered Map and Set contents (including Sets inside Map values), RegExp `lastIndex`, array holes, and symbol keys inside nested values. Mutator methods (`push`, `set`, `add`) and top-level symbol writes are outside its grammar. The proxy owner's stored-function law checks, against a native row, that a function stored as data reads back as stored by every read path and that a stored method sees the draft as `this`, so its writes are tracked. The detachment owner pins which key classes a draft copies: enumerable string keys and every symbol key. The revert owner also generates typed arrays (including a subclass that does not forward its constructor argument), URLs, and a class with only private state as written values, with a property that writes two of them over one field. The proxy owner's array law takes every non-mutating method from `Array.prototype` and compares its result, the identity of each object in it, and the row after a write through it with a native array; a new built-in method fails until it has arguments. Its `defineProperty` law compares result, value, descriptor, and changes, for values and accessors. Its typed-array law takes every method from `TypedArray.prototype` and compares result and row with a native typed array. The iteration owner checks that array iterators see a push, removal, index write, or length cut made while they are open. Open: a draft reads a class instance of the original row as a plain object, so private-field getters read `undefined`, and a built-in method called through `this` on a nested Map or Set draft rejects the Proxy receiver; a typed-array `subarray` call counts as a change without a write, and `DataView` setters are untracked ([draft proxy review](oracle-reviews/code-weight-draft-proxy.md)). | | Query DB and observer | [ownership](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/ownership-lifecycle.oracle.test.ts), [load lifecycle](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/load-subset-lifecycle-oracle.test.ts), [observer histories](https://github.com/TanStack/db/blob/main/packages/db/tests/live-query-observer-history.property.test.ts) | Real QueryClient boundary, applied-settlement barriers for existing and cached demand, mutation-handler Collection identity, terminal fetch-record lifecycle, and a per-listener eligibility ledger, not a duplicate dispatch queue. The bounded handler grammar crosses insert, update, delete, parameter and mutation-alias access, refetch, and clearError. A held clearError retry retains the prior public error through initial, background, and Collection application failures; success clears it, failure advances the count, including a repeated error at clock time zero. An intermediate Query retry attempt does not recount the previous background error; a held final attempt can succeed or fail. A held application after successful Query fetch keeps the error visible until the applied result. A mocked persisted-baseline scan holds an older success while a newer terminal Query failure arrives; the newer public error and count survive application, including when the Error object and clock tick repeat. Native SQLite persistence, concurrent retries across multiple tracked Queries, mutation-handler fetch-boundary settlement, and deferred result application remain outside this witness. The mutation overlap grammar crosses both start orders. Check reentry, peer survival, FIFO and disposal independently of final rows. | | Query DB SSR dehydration | `packages/query-db-collection/tests/ssr-dehydration-oracle.test.ts`, [review record](oracle-reviews/issue-1950-ssr-dehydration.md) | A plain Query cache control, a live on-demand compound predicate, a signal-only request, and an ordered cursor with a custom comparator all retain their row through QueryClient dehydration, reconstruction of Seroval's emitted stream payload, and Query Core hydration from that payload. The on-demand query functions still receive the original IR, comparator, and signal; serialized metadata retains enumerable user metadata but excludes request options. The original implementation fails the compound and signal cases at the stream checkpoint. A serializable stream-only request-options leak passes the former chunk-count check but fails the current payload assertion. This owner covers successful Query cache entries at the installed Query Core 5.90.20 and Seroval 1.5.0 versions. A TanStack Start integration owner still needs the reporter's Router 1.171.33 and Seroval 1.6.8 path, browser Collection resume/refetch, and request cancellation across SSR. Query subset and pagination oracles remain owners of predicate/order/cursor meaning and supported value types. The issue's `shouldDehydrateQuery` workaround replaces Query Core's success-only default and admits pending/error queries; any documented workaround must preserve that default filter. | | Ordered acquisition | [pagination](https://github.com/TanStack/db/blob/main/packages/db/tests/query/pagination-oracle.property.test.ts), [ordered work](https://github.com/TanStack/db/blob/main/packages/db/tests/query/ordered-work-oracle.property.test.ts), [ordered lifecycle](https://github.com/TanStack/db/blob/main/packages/db/tests/query/ordered-lifecycle-oracle.property.test.ts), [ordered loader state](https://github.com/TanStack/db/blob/main/packages/db/tests/query/ordered-source-loader-state.test.ts), [graph scheduler](https://github.com/TanStack/db/blob/main/packages/db/tests/query/scheduler.test.ts), [issue #1880 ordered-repair review](oracle-reviews/issue-1880-ordered-repair.md), [issue #1882 custom local collation review](oracle-reviews/issue-1882-custom-local-collation.md), [issue #1898 relation-filter review](oracle-reviews/issue-1898-relation-filter.md), [PR #1909 external review](oracle-reviews/pr-1909-external-review.md) | Complete finite provider results, inherited locale and bounded/unbounded custom collation with exact request options, query-level inheritance across source aliases, real lexical/numeric disagreement, custom comparator ties, scan/index paths, pending windows, ties/nulls, lease ownership, Effect callback gates, including an indexed source update during Effect startup, and documented repair timing. Direct LEFT-joined filters cover local finite prefixes, pending child demand, child removal, and anti-join publication with custom full-source and unindexed fallback loading; unrelated include demand cannot stall the root continuation, and an empty joined replay wakes held Effect callbacks; remote relation hints remain outside this owner. Graph scheduler checks coalescing, dependency order, synchronous loader input, and repeated-alias failure ownership. The ordered-work oracle checks that a synchronous root refill reaches D2 before joined publication; a mutant that omitted the post-loader graph step failed at the second child demand. Custom collation proves local full-source acquisition only; it does not establish provider cursor capability or backend collation fidelity. Sibling-source input during a synchronous ordered continuation returns to graph work before another ordered acquisition. Request completion is not proof of unrequested source extent. A window move during existing joined demand waits for its root continuation, including an independently held root request; current demand failure rejects the move, while retirement and an obsolete rejection do not. A bounded ordered repair resumes its refill after joined demand settles. The ordered-source-loader state test checks that an explicit failed-request retry blocked by joined demand retains its generation until it can issue a request. Multiple simultaneously pending joined plans during a window move and a public-query failed-retry overlap still need dedicated witnesses. | diff --git a/docs/contributing/oracle-reviews/code-weight-draft-proxy.md b/docs/contributing/oracle-reviews/code-weight-draft-proxy.md index ad7355b03..94c177681 100644 --- a/docs/contributing/oracle-reviews/code-weight-draft-proxy.md +++ b/docs/contributing/oracle-reviews/code-weight-draft-proxy.md @@ -1,6 +1,6 @@ # Code weight: simplify the draft proxy and share its equality walker -Reviewed executable revision: `b825e6483` (base `18abceee4`, the fetched +Reviewed executable revision: `0ce101acf` (base `18abceee4`, the fetched `origin/main` at review time). This record follows in a documentation-only commit. @@ -21,6 +21,8 @@ commit. - `b825e6483` fixes a CodeRabbit finding in the `defineProperty` fix: a non-configurable property with an object value threw. See Fixes from the second review. +- `0ce101acf` fixes two more lost-write classes from CodeRabbit review: + accessor definitions and typed-array mutator methods. ## Change @@ -200,6 +202,7 @@ commit before. | `38d8ba78f` | `Object.defineProperty(draft, key, { value })` threw unless the descriptor set `writable`. | `proxy.test.ts`: six definitions against a native row. | 3 of 6 cases | +8 / +8 | | `815432ebd` | Two URLs, or two instances whose state is in private fields, were equal, so a draft dropped a write that replaced one. | Revert oracle: URL values and a private-field class, with a write-twice property. `utils.property`: class and identity law. | both campaigns | +312 / +79 | | `b825e6483` | `38d8ba78f` defined a clone, so a non-configurable object value broke the Proxy invariants and threw. `get` would also have wrapped it. | `defineProperty` law: a new key with only an object value, with identity where the invariants require it. | throws on `main` too | +9 / +5 (module only) | +| `0ce101acf` | Defining a getter over a field reported no change. `fill`, `set`, `sort`, `reverse`, `copyWithin`, and writes through `subarray` on a typed-array draft changed the private copy without a change. | `proxy.test.ts`: every method of `TypedArray.prototype` against a native typed array, and two getter definitions. | 6 of 31 methods, 2 of 2 getters | +70 / +25 (module only) | | `a49ae90e3` | None. A detached stored method must throw, as on a native row. | `proxy.test.ts` stored-function law. | passes | 0 | Design decisions for these fixes: @@ -231,6 +234,11 @@ Limits that remain: - A built-in method called through `this` on a nested Map or Set draft, such as `Map.prototype.get.call(this.m, key)`, throws, because the receiver is a Proxy. This is a Proxy limit. +- A `subarray` call on a typed-array draft counts as a change even when no + write follows, so the row may publish an equal value. Tracking later writes + through the shared buffer would need a second proxy. +- `DataView` setters on a draft are not tracked. No owner generates a + `DataView`. | Mutant | Owners | | --- | --- | @@ -296,6 +304,11 @@ Mutants of each new form fail the owners: | K6: empty arrays compare by identity | assertion failure (8/539) | | D1 (on `b825e6483`): `get` wraps a read-only non-configurable value | crash, the reported TypeError (1/540) | | D2 (on `b825e6483`): `defineProperty` stores a clone | crash, the reported TypeError (1/540) | +| G1 (on `0ce101acf`): accessor definitions untracked | assertion failure (2/574) | +| G2 (on `0ce101acf`): every definition tracked, sealing too | assertion failure (1/574, the existing `Object.seal` test) | +| Y1 (on `0ce101acf`): typed-array mutators untracked | assertion failure (6/574) | +| Y2 (on `0ce101acf`): `subarray` untracked | assertion failure (1/574) | +| Y3 (on `0ce101acf`): typed-array `set` untracked | assertion failure (1/574) | The I and A3 to A5 mutants in the previous section apply to code that `31b2a6bd4` removed. @@ -432,10 +445,10 @@ before it. ## Verification -On `bb48e2bc9` (`b825e6483` merged with `origin/main` at `3463cf8ad`), with -the built `dist`: +On `0ce101acf`, which includes `origin/main` at `3463cf8ad`, with the built +`dist`: -- `packages/db` Vitest, typecheck off: 198 files, 7,751 tests. +- `packages/db` Vitest, typecheck off: 198 files, 7,785 tests. - `packages/db` `tsc --noEmit`: no errors. - `pnpm check:mangle`: 368 names. - `pnpm test:minified-db`: error names, index metadata, query rows, and live From 7e5dfee25e9931a7486c25dc3de379965b933eac Mon Sep 17 00:00:00 2001 From: Isaac Date: Thu, 1 Oct 2026 14:47:43 -0600 Subject: [PATCH 24/28] fix(db): keep draft write-backs, frozen-key writes, and subarray reads exact Three bugs from code review of the earlier fixes: - A draft snapshot holds class instances as plain objects, so the class rule made writing back the row's own instance a change and missed a revert to it. A class instance and a plain object now compare by keys, in both modes. Two different classes still differ, and a keyless instance still equals only itself. - After Object.freeze(draft), the get trap returned the raw copy for an object, as the Proxy invariants require, so a nested write was lost. It now counts that key as changed when it hands out a raw object. - subarray marked a change on every call. It now returns a draft of the view, like a Map value, so only a write marks the typed array changed. The revert oracle generates a keyed class instance in original rows and fails 7 cases on the previous commit. A new law compares rows after freeze, seal, or a fixed key with native rows. The typed-array law calls each method with and without a write. Co-authored-by: Isaac --- packages/db/src/proxy.ts | 31 +++++-- packages/db/src/utils.ts | 19 ++-- .../proxy-revert-oracle.property.test.ts | 30 +++++-- packages/db/tests/proxy.test.ts | 87 +++++++++++++++++-- packages/db/tests/utils.property.test.ts | 12 ++- 5 files changed, 147 insertions(+), 32 deletions(-) diff --git a/packages/db/src/proxy.ts b/packages/db/src/proxy.ts index 3e9a65897..e229d778d 100644 --- a/packages/db/src/proxy.ts +++ b/packages/db/src/proxy.ts @@ -31,8 +31,7 @@ function unwrapDraft(value: unknown): unknown { } /** - * Array and typed-array methods that modify the value in place. A typed - * array's `subarray` shares its buffer, so later writes through it do too. + * Array and typed-array methods that modify the value in place. */ const ARRAY_MODIFYING_METHODS = new Set([ `pop`, @@ -45,7 +44,6 @@ const ARRAY_MODIFYING_METHODS = new Set([ `fill`, `copyWithin`, `set`, - `subarray`, ]) /** @@ -459,10 +457,20 @@ export function createChangeProxy< get(ptarget, prop, receiver) { const value = changeTracker.copy_[prop as keyof T] - // A getter's result, and a read-only non-configurable value, which - // the Proxy invariants require as stored, return directly. + // If it's a getter, return the value directly const desc = Object.getOwnPropertyDescriptor(ptarget, prop) - if (desc?.get || (desc?.configurable === false && !desc.writable)) { + if (desc?.get) { + return value + } + + // The Proxy invariants require a read-only non-configurable value as + // stored, as after Object.freeze. A raw object can still be written + // through, so its key counts as changed. + if (desc?.configurable === false && !desc.writable) { + if (isProxiableObject(value)) { + changeTracker.assigned_[String(prop)] = true + markChanged(changeTracker) + } return value } @@ -476,6 +484,17 @@ export function createChangeProxy< const methodName = prop.toString() + // A subarray shares the buffer, so it is a draft whose writes mark + // this value changed, like a Map value. + if (methodName === `subarray` && ArrayBuffer.isView(ptarget)) { + return (...args: Array) => + memoizedCreateChangeProxy(value.apply(ptarget, args), { + tracker: changeTracker, + prop: ``, + retainIdentity: true, + }).proxy + } + // Array and typed-array methods that modify the value in place if ( ARRAY_MODIFYING_METHODS.has(methodName) && diff --git a/packages/db/src/utils.ts b/packages/db/src/utils.ts index a08bef751..967650ea9 100644 --- a/packages/db/src/utils.ts +++ b/packages/db/src/utils.ts @@ -251,15 +251,14 @@ export function deepEqualsInternal( } // Handle objects if (typeof a === `object`) { - // An object of another class differs. Plain and null-prototype objects, - // from any realm, are one class. + // Instances of two different classes differ. A plain or null-prototype + // object, from any realm, compares by keys with any class: a draft + // snapshot and plain JSON both hold class instances as plain objects. const prototype = Object.getPrototypeOf(a) const prototypeB = Object.getPrototypeOf(b) - if ( - prototype !== prototypeB && - !(isPlainPrototype(prototype) && isPlainPrototype(prototypeB)) - ) - return false + const plain = isPlainPrototype(prototype) + const plainB = isPlainPrototype(prototypeB) + if (prototype !== prototypeB && !plain && !plainB) return false // Check for circular references if (visited.has(a)) { @@ -281,11 +280,7 @@ export function deepEqualsInternal( // A class instance without enumerable keys (a File, an object with // private fields) keeps its state elsewhere, so it equals only itself. // A draft copies a URL by its href, so URLs compare by href. - if ( - keysA.length === 0 && - !Array.isArray(a) && - !isPlainPrototype(prototype) - ) { + if (keysA.length === 0 && !Array.isArray(a) && !(plain && plainB)) { visited.delete(a) return a instanceof URL && a.href === b.href } diff --git a/packages/db/tests/proxy-revert-oracle.property.test.ts b/packages/db/tests/proxy-revert-oracle.property.test.ts index 7603376ce..6d38e13a5 100644 --- a/packages/db/tests/proxy-revert-oracle.property.test.ts +++ b/packages/db/tests/proxy-revert-oracle.property.test.ts @@ -25,9 +25,10 @@ import { createChangeProxy } from '../src/proxy' * order. * 7. Typed arrays compare by class and by elements under rule 1. * 8. URLs compare by `href`. - * 9. An object of another class differs, and a class instance without - * enumerable keys (here, one with only a private field) equals only - * itself. + * 9. Instances of two different classes differ. A class instance and a + * plain object compare by keys, because a draft snapshot holds class + * instances as plain objects. A class instance without enumerable keys + * (here, one with only a private field) equals only itself. * * Laws checked after every generated history: * @@ -49,9 +50,10 @@ import { createChangeProxy } from '../src/proxy' * them; the coverage map lists symbol writes as unsupported. Symbol keys * inside nested objects are written. * - Keys are non-index strings, so property order follows insertion order. - * - Class instances appear only as written values. A draft reads a class - * instance of the original row as a plain object; the detachment contract - * owns that boundary. + * - A keyed class instance (`Point`) appears in original rows and written + * values. A keyless one (`Secret`) appears only as a written value. A + * draft reads a class instance of the original row as a plain object; the + * detachment contract owns that boundary. */ // --------------------------------------------------------------------------- @@ -73,6 +75,7 @@ type Spec = | { k: `typed`; ctor: TypedKind; values: Array } | { k: `url`; path: `a` | `b` } | { k: `secret`; v: number } + | { k: `point`; a: Primitive } // A Map value is a primitive or a nested Set, so draft rules must also hold // inside Map values. @@ -110,6 +113,11 @@ class Secret { } } +// A class instance with one enumerable key. +class Point { + constructor(public a: unknown) {} +} + const HOLE = Symbol(`hole`) const FIELDS = [`f`, `g`, `h`] as const type Field = (typeof FIELDS)[number] @@ -156,6 +164,10 @@ function canon(spec: Spec | undefined): unknown { return [`typed`, spec.ctor, spec.values.map(String)] case `url`: return [`url`, spec.path] + case `point`: + // Rule 9: a Point compares by keys with the plain object a draft + // snapshot holds, so it encodes as that object. + return canon({ k: `obj`, a: spec.a }) case `secret`: // Rule 9. Secrets appear only as written values, and the original is // never a Secret, so the value is enough to compare written states. @@ -211,6 +223,8 @@ function realize(spec: Spec): unknown { return new URL(`https://example.com/${spec.path}`) case `secret`: return new Secret(spec.v) + case `point`: + return new Point(spec.a) } } @@ -276,6 +290,7 @@ const specArb: fc.Arbitrary = fc.oneof( fc .constantFrom(`a` as const, `b` as const) .map((path): Spec => ({ k: `url`, path })), + primArb.map((a): Spec => ({ k: `point`, a })), ) // Written values may also be class instances with only private state. const writtenArb: fc.Arbitrary = fc.oneof( @@ -665,6 +680,9 @@ function readSpec(value: unknown, like: Spec | undefined): string { return value instanceof Secret ? encode({ k: `secret`, v: value.read() }) : `not a Secret` + case `point`: + // A Point, or the plain object a draft reads for one. + return readSpec(value, { k: `obj`, a: like.a }) } } diff --git a/packages/db/tests/proxy.test.ts b/packages/db/tests/proxy.test.ts index 82d5e11fe..153e71126 100644 --- a/packages/db/tests/proxy.test.ts +++ b/packages/db/tests/proxy.test.ts @@ -2788,7 +2788,7 @@ describe(`typed-array methods behave like native typed arrays`, () => { typeof Object.getOwnPropertyDescriptor(typedArrayPrototype, name) ?.value === `function`, ) - const observe = (row: Row, method: string) => { + const observe = (row: Row, method: string, write: boolean) => { const call = ( row.t as unknown as Record) => unknown> )[method]! @@ -2797,7 +2797,7 @@ describe(`typed-array methods behave like native typed arrays`, () => { raw !== null && typeof raw === `object` && Symbol.iterator in raw ? Array.from(raw as Iterable) : raw - if (ArrayBuffer.isView(raw)) (raw as Float64Array)[0] = 42 + if (write && raw instanceof Float64Array) raw[0] = 42 return result } @@ -2805,15 +2805,92 @@ describe(`typed-array methods behave like native typed arrays`, () => { expect(methods.filter((name) => !(name in calls))).toEqual([]) }) - it.each(methods)(`%s gives the native result`, (method) => { + // A call alone, and a call followed by a write through its result. A + // read must not count as a change. + const cases = methods.flatMap((method) => [ + [method, `read`] as const, + [method, `write`] as const, + ]) + it.each(cases)(`%s (%s) gives the native result`, (method, mode) => { const native = make() - const expected = observe(native, method) + const expected = observe(native, method, mode === `write`) let actual: unknown const changes = withChangeTracking(make(), (draft) => { - actual = observe(draft, method) + actual = observe(draft, method, mode === `write`) }) expect(actual).toEqual(expected) const changed = native.t.some((v, i) => v !== make().t[i]) expect(changes).toEqual(changed ? { t: native.t } : {}) }) }) + +/** + * Freezing, sealing, or fixing a key of a draft must not lose a later write + * through a nested value. The Proxy invariants make a frozen key return the + * raw copy, so the draft counts that key as changed when it reads it. The law + * therefore compares rows, not patches: applying `getChanges()` to the + * original must give the native row. + */ +describe(`frozen and sealed drafts keep nested writes`, () => { + type Row = { n: { x: number }; m: number } + const make = (): Row => ({ n: { x: 1 }, m: 1 }) + const histories: Array<[string, (row: Row) => void]> = [ + [ + `freeze, then a nested write`, + (row) => { + Object.freeze(row) + row.n.x = 2 + }, + ], + [`freeze, then a nested read`, (row) => void Object.freeze(row).n.x], + [ + `seal, then a nested write`, + (row) => { + Object.seal(row) + row.n.x = 2 + }, + ], + [ + `a fixed key, then a nested write`, + (row) => { + Object.defineProperty(row, `n`, { + writable: false, + configurable: false, + }) + row.n.x = 2 + }, + ], + ] + + it.each(histories)(`%s gives the native row`, (_name, run) => { + const native = make() + run(native) + const { proxy, getChanges } = createChangeProxy(make()) + run(proxy) + expect({ ...make(), ...getChanges() }).toEqual({ ...native }) + }) + + it(`does not count a primitive read under a frozen key`, () => { + const { proxy, getChanges } = createChangeProxy(make()) + void Object.freeze(proxy).m + expect(getChanges()).toEqual({}) + }) + + it(`does not count writing back the row's own class instance`, () => { + class Point { + constructor(public x: number) {} + } + const row = { p: new Point(1) } + expect( + withChangeTracking(row, (draft) => { + draft.p = row.p + }), + ).toEqual({}) + expect( + withChangeTracking(row, (draft) => { + draft.p = new Point(2) + draft.p = row.p + }), + ).toEqual({}) + }) +}) diff --git a/packages/db/tests/utils.property.test.ts b/packages/db/tests/utils.property.test.ts index 4744d2aab..4884e8ff7 100644 --- a/packages/db/tests/utils.property.test.ts +++ b/packages/db/tests/utils.property.test.ts @@ -1002,7 +1002,7 @@ describe(`deepEquals property-based tests`, () => { fc.constantFrom(1, 2), fc.constantFrom(1, 2), ])( - `objects compare within their class and keyless instances by identity`, + `objects compare by class and keys, and keyless instances by identity`, (pathA, pathB, v, w) => { class Secret { #v: number @@ -1016,6 +1016,9 @@ describe(`deepEquals property-based tests`, () => { class Point { constructor(public a: number) {} } + class Other { + constructor(public a: number) {} + } const url = (path: string) => new URL(`https://example.com/${path}`) expectEqualityPair(url(pathA), url(pathB), pathA === pathB) expectEqualityPair( @@ -1035,9 +1038,12 @@ describe(`deepEquals property-based tests`, () => { expectEqualityPair(secret, new Secret(w), false) expectEqualityPair({ s: secret }, { s: new Secret(v) }, false) expectEqualityPair(new Secret(v), {}, false) - // Class instances with keys compare by keys within their class. + // Class instances with keys compare by keys within their class, and + // with a plain object, which is how JSON and draft snapshots hold + // them. Two different classes differ. expectEqualityPair(new Point(v), new Point(w), v === w) - expectEqualityPair(new Point(v), { a: v }, false) + expectEqualityPair(new Point(v), { a: w }, v === w) + expectEqualityPair(new Point(v), new Other(v), false) // Plain and null-prototype objects are one class. const bare = Object.assign(Object.create(null), { a: v }) expectEqualityPair(bare, { a: w }, v === w) From fe67162b38479122867965589d1a82e80cdcd7fa Mon Sep 17 00:00:00 2001 From: Isaac Date: Thu, 1 Oct 2026 14:48:37 -0600 Subject: [PATCH 25/28] docs: record the fixes from code review of the earlier fixes Co-authored-by: Isaac --- .changeset/simplify-draft-proxy.md | 2 +- docs/contributing/oracle-coverage.md | 2 +- .../oracle-reviews/code-weight-draft-proxy.md | 32 ++++++++++++++----- 3 files changed, 26 insertions(+), 10 deletions(-) diff --git a/.changeset/simplify-draft-proxy.md b/.changeset/simplify-draft-proxy.md index b65b9eaeb..85c0927e0 100644 --- a/.changeset/simplify-draft-proxy.md +++ b/.changeset/simplify-draft-proxy.md @@ -15,7 +15,7 @@ This change also fixes draft writes that were lost or wrong: `deepEquals` and draft change detection also have new rules: -- An object of another class is not equal. Plain and null-prototype objects are one class. +- Instances of two different classes are not equal. A plain or null-prototype object compares by keys with any class. - A class instance without enumerable keys, such as a `File` or an object whose state is in private fields, equals only itself. Before, two such instances were always equal, so a draft dropped a write that replaced one. - URLs compare by `href`. - In draft change detection only, a typed array of another class is a change. diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index 3c946abe0..8615f0754 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -242,7 +242,7 @@ comment and the current API/architecture contract before extending its model. | Collection lifecycle | [mutation startup](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-mutation-startup-oracle.test.ts), [change-event history](https://github.com/TanStack/db/blob/main/packages/db/tests/change-event-history-oracle.test.ts), [history](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-subscription-lifecycle-history.property.test.ts), [publication](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-subscription-lifecycle-publication.property.test.ts), [replay](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-subscription-replay-oracle.property.test.ts), [effect disposal](https://github.com/TanStack/db/blob/main/packages/db/tests/effect-disposal-oracle.test.ts), [PR #1902 review](oracle-reviews/pr-1902-change-event-history.md), [batch-order decision review](oracle-reviews/change-event-batch-order.md) | Core Collection `insert`/`update`/`delete` admission while `startSync:false` is idle; complete public-row/change-message agreement across bounded one- and two-key histories plus replayable four-key campaigns; a two-key deferred sync history checks each key's causal insert/update/delete trace while allowing any cross-key interleaving. Reversing all deferred messages fails that check; reversing each sync transaction's distinct-key messages passes. Queued duplicate admission and cancellation, ownership and phase histories, exact caller/error/publication evidence, and late completion and restart have separate witnesses. Generated deferred histories with cancellation or reentrant callbacks need a witness in the change-event history owner before claiming general batch-shape coverage. Query write utilities and effect self-dependent disposal remain separate contracts. | | Paced mutations | [virtual-clock oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/paced-mutations-oracle.test.ts) | `createPacedMutations` with queue front/back options, bounded waiting capacity at zero and one, cleanup draining in FIFO/LIFO order after a held write, cleanup timing at the configured wait for immediate writes, admitted writes after separate Collection cleanup, debounce and throttle schedules with explicit and omitted options, and first-leading throttle execution at epoch zero. Finite histories compare optimistic rows, returned transaction identity and receipt outcome, execution order and virtual-clock time. Held writes verify queue serialization. Leading-only throttle and debounce witnesses reject skipped calls immediately with their named dropped-call errors, including omitted edges, both edges disabled, and same-row rollback while a prior write is held. Capacity witnesses cover overflow rejection, distinct-key optimistic rollback, same-key admitted-write survival, and in-flight versus waiting admission. Cleanup witnesses check repeated queue cleanup, eventual admitted receipt settlement, direct queue strategy rejection of new callbacks, public post-cleanup rollback with `QueueDisposedError`, pending debounce transactions that wait until the last call's quiet edge after separate Collection cleanup, and a trailing throttle timer that runs at its regular edge after separate Collection cleanup. Deferred custom queue and batch callbacks check compatibility with `void` and `false` execute results, respectively. Frozen options verify factory non-mutation; a mutable option witness checks that debounce uses its construction-time trailing setting. New debounce/throttle admission after cleanup, failed persistence, broader capacities and schedules remain outside this owner. | | Optimistic state | [history model](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-history-oracle.ts), [generated histories](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-transaction-oracle.property.test.ts), [truncate capture ownership](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-truncate-ownership-oracle.property.test.ts), [outcomes](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-history-outcomes.test.ts), [publication](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-history-publication.test.ts) | Independent whole-row snapshots, rollback dependencies, captured truncate ownership, metadata and prior-value events. Never rebase a pending snapshot merely to simplify the model. | -| Drafts and native values | [proxy](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy.test.ts), [detachment](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-detachment-contract.test.ts), [iteration](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-iteration-contract.test.ts), [revert](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-revert-oracle.property.test.ts) | Native-operation controls, exact patches and actual stored rows; alias/cycle/adversarial-key histories. Set and Map reorder histories check draft patches and stored iteration order after replacement and clear-and-readd; RegExp replacement/reversion histories check `lastIndex`. General native-mutator and symbol-write support is not established by a plain-object oracle. The revert owner generates assignment, revert, delete, nested, symbol-key, and `for...of` histories and checks `getChanges()` and the draft against an independent draft-equality model: ordered Map and Set contents (including Sets inside Map values), RegExp `lastIndex`, array holes, and symbol keys inside nested values. Mutator methods (`push`, `set`, `add`) and top-level symbol writes are outside its grammar. The proxy owner's stored-function law checks, against a native row, that a function stored as data reads back as stored by every read path and that a stored method sees the draft as `this`, so its writes are tracked. The detachment owner pins which key classes a draft copies: enumerable string keys and every symbol key. The revert owner also generates typed arrays (including a subclass that does not forward its constructor argument), URLs, and a class with only private state as written values, with a property that writes two of them over one field. The proxy owner's array law takes every non-mutating method from `Array.prototype` and compares its result, the identity of each object in it, and the row after a write through it with a native array; a new built-in method fails until it has arguments. Its `defineProperty` law compares result, value, descriptor, and changes, for values and accessors. Its typed-array law takes every method from `TypedArray.prototype` and compares result and row with a native typed array. The iteration owner checks that array iterators see a push, removal, index write, or length cut made while they are open. Open: a draft reads a class instance of the original row as a plain object, so private-field getters read `undefined`, and a built-in method called through `this` on a nested Map or Set draft rejects the Proxy receiver; a typed-array `subarray` call counts as a change without a write, and `DataView` setters are untracked ([draft proxy review](oracle-reviews/code-weight-draft-proxy.md)). | +| Drafts and native values | [proxy](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy.test.ts), [detachment](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-detachment-contract.test.ts), [iteration](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-iteration-contract.test.ts), [revert](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-revert-oracle.property.test.ts) | Native-operation controls, exact patches and actual stored rows; alias/cycle/adversarial-key histories. Set and Map reorder histories check draft patches and stored iteration order after replacement and clear-and-readd; RegExp replacement/reversion histories check `lastIndex`. General native-mutator and symbol-write support is not established by a plain-object oracle. The revert owner generates assignment, revert, delete, nested, symbol-key, and `for...of` histories and checks `getChanges()` and the draft against an independent draft-equality model: ordered Map and Set contents (including Sets inside Map values), RegExp `lastIndex`, array holes, and symbol keys inside nested values. Mutator methods (`push`, `set`, `add`) and top-level symbol writes are outside its grammar. The proxy owner's stored-function law checks, against a native row, that a function stored as data reads back as stored by every read path and that a stored method sees the draft as `this`, so its writes are tracked. The detachment owner pins which key classes a draft copies: enumerable string keys and every symbol key. The revert owner also generates typed arrays (including a subclass that does not forward its constructor argument), URLs, and a class with only private state as written values, with a property that writes two of them over one field. The proxy owner's array law takes every non-mutating method from `Array.prototype` and compares its result, the identity of each object in it, and the row after a write through it with a native array; a new built-in method fails until it has arguments. Its `defineProperty` law compares result, value, descriptor, and changes, for values and accessors. Its typed-array law takes every method from `TypedArray.prototype` and compares result and row with a native typed array. The iteration owner checks that array iterators see a push, removal, index write, or length cut made while they are open. Open: a draft reads a class instance of the original row as a plain object, so private-field getters read `undefined`, and a built-in method called through `this` on a nested Map or Set draft rejects the Proxy receiver; reading an object under a frozen draft key counts that key as changed, writing back a keyless class instance of the original row is a change, and `DataView` setters are untracked ([draft proxy review](oracle-reviews/code-weight-draft-proxy.md)). | | Query DB and observer | [ownership](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/ownership-lifecycle.oracle.test.ts), [load lifecycle](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/load-subset-lifecycle-oracle.test.ts), [observer histories](https://github.com/TanStack/db/blob/main/packages/db/tests/live-query-observer-history.property.test.ts) | Real QueryClient boundary, applied-settlement barriers for existing and cached demand, mutation-handler Collection identity, terminal fetch-record lifecycle, and a per-listener eligibility ledger, not a duplicate dispatch queue. The bounded handler grammar crosses insert, update, delete, parameter and mutation-alias access, refetch, and clearError. A held clearError retry retains the prior public error through initial, background, and Collection application failures; success clears it, failure advances the count, including a repeated error at clock time zero. An intermediate Query retry attempt does not recount the previous background error; a held final attempt can succeed or fail. A held application after successful Query fetch keeps the error visible until the applied result. A mocked persisted-baseline scan holds an older success while a newer terminal Query failure arrives; the newer public error and count survive application, including when the Error object and clock tick repeat. Native SQLite persistence, concurrent retries across multiple tracked Queries, mutation-handler fetch-boundary settlement, and deferred result application remain outside this witness. The mutation overlap grammar crosses both start orders. Check reentry, peer survival, FIFO and disposal independently of final rows. | | Query DB SSR dehydration | `packages/query-db-collection/tests/ssr-dehydration-oracle.test.ts`, [review record](oracle-reviews/issue-1950-ssr-dehydration.md) | A plain Query cache control, a live on-demand compound predicate, a signal-only request, and an ordered cursor with a custom comparator all retain their row through QueryClient dehydration, reconstruction of Seroval's emitted stream payload, and Query Core hydration from that payload. The on-demand query functions still receive the original IR, comparator, and signal; serialized metadata retains enumerable user metadata but excludes request options. The original implementation fails the compound and signal cases at the stream checkpoint. A serializable stream-only request-options leak passes the former chunk-count check but fails the current payload assertion. This owner covers successful Query cache entries at the installed Query Core 5.90.20 and Seroval 1.5.0 versions. A TanStack Start integration owner still needs the reporter's Router 1.171.33 and Seroval 1.6.8 path, browser Collection resume/refetch, and request cancellation across SSR. Query subset and pagination oracles remain owners of predicate/order/cursor meaning and supported value types. The issue's `shouldDehydrateQuery` workaround replaces Query Core's success-only default and admits pending/error queries; any documented workaround must preserve that default filter. | | Ordered acquisition | [pagination](https://github.com/TanStack/db/blob/main/packages/db/tests/query/pagination-oracle.property.test.ts), [ordered work](https://github.com/TanStack/db/blob/main/packages/db/tests/query/ordered-work-oracle.property.test.ts), [ordered lifecycle](https://github.com/TanStack/db/blob/main/packages/db/tests/query/ordered-lifecycle-oracle.property.test.ts), [ordered loader state](https://github.com/TanStack/db/blob/main/packages/db/tests/query/ordered-source-loader-state.test.ts), [graph scheduler](https://github.com/TanStack/db/blob/main/packages/db/tests/query/scheduler.test.ts), [issue #1880 ordered-repair review](oracle-reviews/issue-1880-ordered-repair.md), [issue #1882 custom local collation review](oracle-reviews/issue-1882-custom-local-collation.md), [issue #1898 relation-filter review](oracle-reviews/issue-1898-relation-filter.md), [PR #1909 external review](oracle-reviews/pr-1909-external-review.md) | Complete finite provider results, inherited locale and bounded/unbounded custom collation with exact request options, query-level inheritance across source aliases, real lexical/numeric disagreement, custom comparator ties, scan/index paths, pending windows, ties/nulls, lease ownership, Effect callback gates, including an indexed source update during Effect startup, and documented repair timing. Direct LEFT-joined filters cover local finite prefixes, pending child demand, child removal, and anti-join publication with custom full-source and unindexed fallback loading; unrelated include demand cannot stall the root continuation, and an empty joined replay wakes held Effect callbacks; remote relation hints remain outside this owner. Graph scheduler checks coalescing, dependency order, synchronous loader input, and repeated-alias failure ownership. The ordered-work oracle checks that a synchronous root refill reaches D2 before joined publication; a mutant that omitted the post-loader graph step failed at the second child demand. Custom collation proves local full-source acquisition only; it does not establish provider cursor capability or backend collation fidelity. Sibling-source input during a synchronous ordered continuation returns to graph work before another ordered acquisition. Request completion is not proof of unrequested source extent. A window move during existing joined demand waits for its root continuation, including an independently held root request; current demand failure rejects the move, while retirement and an obsolete rejection do not. A bounded ordered repair resumes its refill after joined demand settles. The ordered-source-loader state test checks that an explicit failed-request retry blocked by joined demand retains its generation until it can issue a request. Multiple simultaneously pending joined plans during a window move and a public-query failed-retry overlap still need dedicated witnesses. | diff --git a/docs/contributing/oracle-reviews/code-weight-draft-proxy.md b/docs/contributing/oracle-reviews/code-weight-draft-proxy.md index 94c177681..cf68946e1 100644 --- a/docs/contributing/oracle-reviews/code-weight-draft-proxy.md +++ b/docs/contributing/oracle-reviews/code-weight-draft-proxy.md @@ -1,6 +1,6 @@ # Code weight: simplify the draft proxy and share its equality walker -Reviewed executable revision: `0ce101acf` (base `18abceee4`, the fetched +Reviewed executable revision: `7e5dfee25` (base `18abceee4`, the fetched `origin/main` at review time). This record follows in a documentation-only commit. @@ -23,6 +23,8 @@ commit. second review. - `0ce101acf` fixes two more lost-write classes from CodeRabbit review: accessor definitions and typed-array mutator methods. +- `7e5dfee25` fixes three bugs that code review found in the earlier fixes: + class write-backs, writes after `Object.freeze`, and `subarray` reads. ## Change @@ -203,6 +205,7 @@ commit before. | `815432ebd` | Two URLs, or two instances whose state is in private fields, were equal, so a draft dropped a write that replaced one. | Revert oracle: URL values and a private-field class, with a write-twice property. `utils.property`: class and identity law. | both campaigns | +312 / +79 | | `b825e6483` | `38d8ba78f` defined a clone, so a non-configurable object value broke the Proxy invariants and threw. `get` would also have wrapped it. | `defineProperty` law: a new key with only an object value, with identity where the invariants require it. | throws on `main` too | +9 / +5 (module only) | | `0ce101acf` | Defining a getter over a field reported no change. `fill`, `set`, `sort`, `reverse`, `copyWithin`, and writes through `subarray` on a typed-array draft changed the private copy without a change. | `proxy.test.ts`: every method of `TypedArray.prototype` against a native typed array, and two getter definitions. | 6 of 31 methods, 2 of 2 getters | +70 / +25 (module only) | +| `7e5dfee25` | The class rule made writing back the row's own class instance a change, because draft snapshots hold class instances as plain objects. After `Object.freeze(draft)`, a nested write was lost. A `subarray` read counted as a change. | Revert oracle: a keyed class instance in original rows. `proxy.test.ts`: rows after freeze, seal, or a fixed key against native rows, and the typed-array law with and without a write. | 7 oracle cases and 3 pinned cases on `ca0a2170c` | about +160 / — (proxy and utils modules) | | `a49ae90e3` | None. A detached stored method must throw, as on a native row. | `proxy.test.ts` stored-function law. | passes | 0 | Design decisions for these fixes: @@ -215,8 +218,12 @@ Design decisions for these fixes: for speed. `31b2a6bd4` removed that list. Running the element methods on the copy and then replacing elements with drafts was faster but cost 167 more gzip bytes. -- **Classes and keyless objects.** In both modes, an object of another class - differs, and plain and null-prototype objects from any realm are one class. +- **Classes and keyless objects.** In both modes, instances of two different + classes differ. A plain or null-prototype object from any realm compares by + keys with any class, because draft snapshots and plain JSON hold class + instances as plain objects. (`815432ebd` first made a plain object differ + from a class instance; `7e5dfee25` reverted that after review found that + writing back the row's own instance became a change.) A class instance without enumerable keys equals only itself. URLs compare by `href`, because a draft copies a URL by its `href`; by identity, an untouched URL would differ from its own snapshot. @@ -234,9 +241,11 @@ Limits that remain: - A built-in method called through `this` on a nested Map or Set draft, such as `Map.prototype.get.call(this.m, key)`, throws, because the receiver is a Proxy. This is a Proxy limit. -- A `subarray` call on a typed-array draft counts as a change even when no - write follows, so the row may publish an equal value. Tracking later writes - through the shared buffer would need a second proxy. +- Reading an object under a frozen or fixed key of a draft counts that key as + changed, because the Proxy invariants require the raw copy. The row may + publish an equal value. +- Writing back a keyless class instance of the original row counts as a + change, because the snapshot holds it as an empty plain object. - `DataView` setters on a draft are not tracked. No owner generates a `DataView`. @@ -309,6 +318,13 @@ Mutants of each new form fail the owners: | Y1 (on `0ce101acf`): typed-array mutators untracked | assertion failure (6/574) | | Y2 (on `0ce101acf`): `subarray` untracked | assertion failure (1/574) | | Y3 (on `0ce101acf`): typed-array `set` untracked | assertion failure (1/574) | +| R1 (on `7e5dfee25`): a plain object differs from a class | assertion failure (10/611) | +| R2 (on `7e5dfee25`): no class check | assertion failure (2/611) | +| R3 (on `7e5dfee25`): the keyless rule needs `a` on the class side | assertion failure (4/611) | +| F1 (on `7e5dfee25`): a frozen-key object read is not a change | assertion failure (2/611) | +| F2 (on `7e5dfee25`): a frozen-key primitive read is a change | assertion failure (1/611) | +| S1 (on `7e5dfee25`): `subarray` returns a plain view | assertion failure (2/611) | +| S2 (on `7e5dfee25`): `subarray` marks a change on call | assertion failure (1/611) | The I and A3 to A5 mutants in the previous section apply to code that `31b2a6bd4` removed. @@ -445,10 +461,10 @@ before it. ## Verification -On `0ce101acf`, which includes `origin/main` at `3463cf8ad`, with the built +On `7e5dfee25`, which includes `origin/main` at `3463cf8ad`, with the built `dist`: -- `packages/db` Vitest, typecheck off: 198 files, 7,785 tests. +- `packages/db` Vitest, typecheck off: 198 files, 7,822 tests. - `packages/db` `tsc --noEmit`: no errors. - `pnpm check:mangle`: 368 names. - `pnpm test:minified-db`: error names, index metadata, query rows, and live From cbd2aff06ebabee532a6bf91b7290b25a4e985af Mon Sep 17 00:00:00 2001 From: Isaac Date: Thu, 1 Oct 2026 15:29:01 -0600 Subject: [PATCH 26/28] fix(db): return the draft from array mutators that return the array sort, reverse, fill, and copyWithin on a draft array or typed array returned the private copy, so `draft.items.sort() === draft.items` was false; main has the same bug. The mutator handler now returns the draft when the native method returns the value itself, like Map and Set methods already do. The array and typed-array native-differential laws move to proxy-native-methods.property.test.ts and become generated: rows with holes, undefined, duplicates, nested arrays, and empty arrays, or typed values with -0 and NaN, every built-in method, an index-shaped argument, and an optional write through the result. Each runs a fixed and a random campaign with seed-and-path replay, a witness that the fixed campaign reaches every method and row shape, and the old pinned rows. They record whether a method returned the array itself and a native throw. The law failed on exactly the four self-returning mutators before the fix. Found by a loss audit of the oracles against docs/contributing/oracle-tests.md. Co-authored-by: Isaac --- packages/db/src/proxy.ts | 12 +- .../proxy-native-methods.property.test.ts | 431 ++++++++++++++++++ packages/db/tests/proxy.test.ts | 206 --------- 3 files changed, 441 insertions(+), 208 deletions(-) create mode 100644 packages/db/tests/proxy-native-methods.property.test.ts diff --git a/packages/db/src/proxy.ts b/packages/db/src/proxy.ts index e229d778d..13e5188c4 100644 --- a/packages/db/src/proxy.ts +++ b/packages/db/src/proxy.ts @@ -79,11 +79,13 @@ function createModifyingMethodHandler( methodFn: (...args: Array) => unknown, changeTracker: ChangeTracker, markChanged: (tracker: ChangeTracker) => void, + receiver: unknown, ): (...args: Array) => unknown { return function (...args: Array) { const result = methodFn.apply(changeTracker.copy_, args) markChanged(changeTracker) - return result + // A method that returns the value itself returns the draft. + return result === changeTracker.copy_ ? receiver : result } } @@ -500,7 +502,12 @@ export function createChangeProxy< ARRAY_MODIFYING_METHODS.has(methodName) && (Array.isArray(ptarget) || ArrayBuffer.isView(ptarget)) ) { - return createModifyingMethodHandler(value, changeTracker, markChanged) + return createModifyingMethodHandler( + value, + changeTracker, + markChanged, + receiver, + ) } if (Array.isArray(ptarget)) { @@ -553,6 +560,7 @@ export function createChangeProxy< value, changeTracker, markChanged, + receiver, ) } diff --git a/packages/db/tests/proxy-native-methods.property.test.ts b/packages/db/tests/proxy-native-methods.property.test.ts new file mode 100644 index 000000000..40c4e3a6e --- /dev/null +++ b/packages/db/tests/proxy-native-methods.property.test.ts @@ -0,0 +1,431 @@ +import { describe, expect, it } from 'vitest' +import { fc } from '@fast-check/vitest' +import { withChangeTracking } from '../src/proxy' + +/** + * # Do built-in array and typed-array methods behave on a draft as natively? + * + * Contract: a draft behaves like the native value it represents. Every + * built-in method called on a draft array or typed array gives the native + * result, returns the draft where the native method returns the array itself, + * hands out drafts of the row's objects, and publishes what the native row + * now holds. + * + * Model: the same call on a native row of equal data. The expected result + * never reads a draft or production code. + * + * Grammar: a row value (an array of numbers, `undefined`, holes, objects, and + * nested arrays of objects; or a `Float64Array` with `-0` and `NaN`), a method + * from the built-in prototype, an index-shaped argument, and an optional write + * through the result. The method list comes from `Array.prototype` and the + * shared `TypedArray.prototype`, so a new built-in method fails the argument + * check until it has arguments. + * + * Driver: `withChangeTracking` runs the same call on a draft of an equal row. + * + * Check, at the end of the callback: the call's result or the class of the + * error it threw, whether it returned + * the array itself, the row index of each original object in the result, and + * the published row after the optional write. + * + * Authority: the draft contract in `src/proxy.ts` and the native-differential + * laws in `tests/proxy.test.ts`. + * + * Limits: + * - A mutator that changes nothing may publish an equal value. Mutators mark + * a change without a revert check (see the revert oracle's limits). + * - An object the callback inserts reads back as a draft, not as itself, so + * identity is checked only for objects of the original row. + * - Callback arguments are fixed per method. The callbacks read only. + */ + +// --------------------------------------------------------------------------- +// Campaigns. `TANSTACK_DB_PROXY_NATIVE_SEED` and +// `TANSTACK_DB_PROXY_NATIVE_PATH` select a direct replay. + +const replaySeed = process.env.TANSTACK_DB_PROXY_NATIVE_SEED +const replayPath = process.env.TANSTACK_DB_PROXY_NATIVE_PATH +const FIXED_SEED = 2026102 +const RUNS = 400 +const campaigns = + replaySeed === undefined && replayPath === undefined + ? [ + { name: String(FIXED_SEED), seed: FIXED_SEED as number | undefined }, + { name: `random`, seed: undefined }, + ] + : [ + { + name: `replay`, + seed: replaySeed === undefined ? undefined : Number(replaySeed), + }, + ] +function campaignOptions(seed: number | undefined): fc.Parameters { + if (replayPath !== undefined && replaySeed === undefined) + throw new Error(`TANSTACK_DB_PROXY_NATIVE_PATH requires a seed`) + if (seed !== undefined && !Number.isSafeInteger(seed)) + throw new Error(`TANSTACK_DB_PROXY_NATIVE_SEED must be an integer`) + return { + numRuns: RUNS, + ...(seed === undefined ? {} : { seed }), + ...(replayPath === undefined ? {} : { path: replayPath }), + } +} + +const prototypeMethods = (prototype: object, skip: Set) => + Object.getOwnPropertyNames(prototype).filter( + (name) => + !skip.has(name) && + typeof Object.getOwnPropertyDescriptor(prototype, name)?.value === + `function`, + ) + +// --------------------------------------------------------------------------- +// Arrays. + +// Original objects have `x` from 0 to 3; objects the callback inserts have +// `x` of 5 or more, so identity can be checked for original objects only. +type Item = { x: number } +type Element = number | undefined | Item | Array +type ElementSpec = Element | `hole` +type ArrayRow = { items: Array } + +const itemArb = fc.integer({ min: 0, max: 3 }).map((x): Item => ({ x })) +const elementArb: fc.Arbitrary = fc.oneof( + fc.integer({ min: 0, max: 3 }), + fc.constant(undefined), + fc.constant(`hole` as const), + itemArb, + fc.array(itemArb, { maxLength: 2 }), +) +const arraySpecArb = fc.array(elementArb, { maxLength: 4 }) + +function realizeArray(spec: Array): ArrayRow { + const items: Array = [] + items.length = spec.length + spec.forEach((element, i) => { + if (element === `hole`) return + items[i] = + typeof element === `object` + ? (JSON.parse(JSON.stringify(element)) as Item | Array) + : element + }) + return { items } +} + +const readsObject = (v: unknown) => typeof v === `object` +const arrayMethods = prototypeMethods(Array.prototype, new Set([`constructor`])) +// Arguments for each method, from the row and an index-shaped number `k`. +const arrayCalls: Record Array> = + { + at: (_row, k) => [k - 2], + concat: (row) => [[{ x: 9 }], row.items], + copyWithin: (_row, k) => [0, k], + entries: () => [], + every: () => [readsObject], + fill: (_row, k) => [{ x: 6 }, k], + filter: () => [readsObject], + find: () => [readsObject], + findIndex: () => [readsObject], + findLast: () => [readsObject], + findLastIndex: () => [readsObject], + flat: () => [], + flatMap: () => [(v: unknown) => v], + forEach: () => [() => undefined], + includes: (row, k) => [row.items[k % Math.max(row.items.length, 1)]], + indexOf: (row, k) => [row.items[k % Math.max(row.items.length, 1)]], + join: () => [`,`], + keys: () => [], + lastIndexOf: (row, k) => [row.items[k % Math.max(row.items.length, 1)]], + map: () => [(v: unknown) => v], + pop: () => [], + push: () => [{ x: 5 }], + reduce: () => [(_acc: unknown, v: unknown) => v, 0], + reduceRight: () => [(_acc: unknown, v: unknown) => v, 0], + reverse: () => [], + shift: () => [], + slice: (_row, k) => [k - 2, k], + some: () => [readsObject], + sort: () => [() => 0], + splice: (_row, k) => [k, 1, { x: 7 }], + toLocaleString: () => [], + toReversed: () => [], + toSorted: () => [() => 0], + toSpliced: (_row, k) => [k, 1], + toString: () => [], + unshift: () => [{ x: 5 }], + values: () => [], + with: () => [0, { x: 7 }], + } + +// Objects reachable from a result, in visit order, without repeats. +function objectsIn(value: unknown, seen: Array = []): Array { + if (value === null || typeof value !== `object` || seen.includes(value)) + return seen + seen.push(value) + for (const child of Array.isArray(value) ? value : Object.values(value)) + objectsIn(child, seen) + return seen +} + +function observeArray(row: ArrayRow, method: string, k: number) { + const call = ( + row.items as unknown as Record) => unknown> + )[method]! + let raw: unknown + try { + raw = call.apply(row.items, arrayCalls[method]!(row, k)) + } catch (error) { + // A native error, such as a RangeError from `with`, is an observation. + return { threw: (error as Error).constructor.name } + } + const returnsSelf = raw === row.items + const result = + raw !== null && typeof raw === `object` && Symbol.iterator in raw + ? [...(raw as Iterable)] + : raw + const snapshot = JSON.stringify(result ?? null) + const originals = objectsIn(result).filter( + (o): o is Item => !Array.isArray(o) && (o as Item).x <= 3, + ) + // Where each original object of the result sits in the row, by identity. + const identity = originals.map((o) => + row.items.flatMap((element, i) => + element === o + ? [i] + : Array.isArray(element) && element.includes(o) + ? [i + 0.5] + : [], + ), + ) + for (const o of originals) o.x += 100 + return { snapshot, returnsSelf, identity } +} + +// Holes count: a draft must publish a hole where the native row has one. +const encodeArray = (items: Array) => + JSON.stringify({ items, present: items.map((_, i) => i in items) }) + +function expectArrayCall( + spec: Array, + method: string, + k: number, +): void { + const native = realizeArray(spec) + const expected = observeArray(native, method, k) + let actual: unknown + const changes = withChangeTracking(realizeArray(spec), (draft) => { + actual = observeArray(draft, method, k) + }) + const context = JSON.stringify({ spec, method, k }) + expect(actual, `result, ${context}`).toEqual(expected) + const changed = + encodeArray(native.items) !== encodeArray(realizeArray(spec).items) + const published = (changes as Partial).items + if (changed || published !== undefined) + expect( + published && encodeArray(published), + `published row, ${context}`, + ).toBe(encodeArray(native.items)) + if (!changed && !MUTATORS.has(method)) + expect(changes, `no change, ${context}`).toEqual({}) +} +const MUTATORS = new Set([ + `copyWithin`, + `fill`, + `pop`, + `push`, + `reverse`, + `shift`, + `sort`, + `splice`, + `unshift`, +]) + +describe(`array methods behave like native arrays`, () => { + it(`has arguments for every method`, () => { + expect(arrayMethods.filter((name) => !(name in arrayCalls))).toEqual([]) + }) + + // The row of the original pinned table, for every method. + it.each(arrayMethods)(`%s on a fixed row gives the native result`, (m) => { + for (const k of [0, 1, 3]) + expectArrayCall([{ x: 1 }, 2, [{ x: 3 }], { x: 0 }], m, k) + }) + + for (const { name, seed } of campaigns) { + it(`matches native arrays across generated rows (${name})`, () => { + fc.assert( + fc.property( + arraySpecArb, + fc.constantFrom(...arrayMethods), + fc.nat(4), + expectArrayCall, + ), + campaignOptions(seed), + ) + }) + } + + it(`reaches every method, holes, empty rows, and nested objects in the fixed campaign`, () => { + const sample = fc.sample( + fc.tuple(arraySpecArb, fc.constantFrom(...arrayMethods), fc.nat(4)), + { seed: FIXED_SEED, numRuns: RUNS }, + ) + expect(new Set(sample.map(([, m]) => m)).size).toBe(arrayMethods.length) + expect(sample.filter(([spec]) => spec.length === 0).length).toBeGreaterThan( + 0, + ) + expect( + sample.filter(([spec]) => spec.includes(`hole`)).length, + ).toBeGreaterThan(0) + expect( + sample.filter(([spec]) => spec.some((e) => Array.isArray(e))).length, + ).toBeGreaterThan(0) + }) +}) + +// --------------------------------------------------------------------------- +// Typed arrays. + +type TypedRow = { t: Float64Array } +const typedSpecArb = fc.array(fc.constantFrom(0, -0, 1, 2, NaN, 3.5), { + maxLength: 4, +}) +const typedArrayPrototype = Object.getPrototypeOf(Float64Array.prototype) +const typedMethods = prototypeMethods( + typedArrayPrototype, + new Set([`constructor`]), +) +const positive = (v: number) => v > 1 +const sum = (a: number, v: number) => a + v +const typedCalls: Record Array> = { + at: (k) => [k - 2], + copyWithin: (k) => [0, k], + entries: () => [], + every: () => [positive], + fill: (k) => [7, k], + filter: () => [positive], + find: () => [positive], + findIndex: () => [positive], + findLast: () => [positive], + findLastIndex: () => [positive], + forEach: () => [() => undefined], + includes: () => [NaN], + indexOf: () => [1], + join: () => [`,`], + keys: () => [], + lastIndexOf: () => [1], + map: () => [(v: number) => v * 2], + reduce: () => [sum, 0], + reduceRight: () => [sum, 0], + reverse: () => [], + set: () => [[9]], + slice: (k) => [k - 2, k], + some: () => [positive], + sort: () => [], + subarray: (k) => [k - 2, k], + toLocaleString: () => [], + toReversed: () => [], + toSorted: () => [], + toString: () => [], + values: () => [], + with: () => [0, 9], +} +const TYPED_MUTATORS = new Set([`copyWithin`, `fill`, `reverse`, `set`, `sort`]) + +function observeTyped( + row: TypedRow, + method: string, + k: number, + write: boolean, +) { + const call = ( + row.t as unknown as Record) => unknown> + )[method]! + let raw: unknown + try { + raw = call.apply(row.t, typedCalls[method]!(k)) + } catch (error) { + return { threw: (error as Error).constructor.name } + } + const returnsSelf = raw === row.t + const result = + raw !== null && typeof raw === `object` && Symbol.iterator in raw + ? Array.from(raw as Iterable, String) + : String(raw) + // A write through a typed-array result changes the row when it shares the + // buffer (`subarray`). + if (write && raw instanceof Float64Array && raw.length > 0) raw[0] = 42 + return { result, returnsSelf } +} + +const encodeTyped = (t: Float64Array) => Array.from(t, (v) => String(v)) + +function expectTypedCall( + values: Array, + method: string, + k: number, + write: boolean, +): void { + const make = (): TypedRow => ({ t: Float64Array.from(values) }) + const native = make() + const expected = observeTyped(native, method, k, write) + let actual: unknown + const changes = withChangeTracking(make(), (draft) => { + actual = observeTyped(draft, method, k, write) + }) + const context = JSON.stringify({ + values: values.map(String), + method, + k, + write, + }) + expect(actual, `result, ${context}`).toEqual(expected) + // -0 differs from 0 here: a typed array stores the sign. + const changed = native.t.some((v, i) => !Object.is(v, values[i])) + const published = (changes as Partial).t + if (changed || published !== undefined) + expect( + published && encodeTyped(published), + `published row, ${context}`, + ).toEqual(encodeTyped(native.t)) + if (!changed && !TYPED_MUTATORS.has(method)) + expect(changes, `no change, ${context}`).toEqual({}) +} + +describe(`typed-array methods behave like native typed arrays`, () => { + it(`has arguments for every method`, () => { + expect(typedMethods.filter((name) => !(name in typedCalls))).toEqual([]) + }) + + // The row of the original pinned table, for every method. + it.each(typedMethods)(`%s on a fixed row gives the native result`, (m) => { + for (const write of [false, true]) expectTypedCall([3, 1, 2], m, 1, write) + }) + + for (const { name, seed } of campaigns) { + it(`matches native typed arrays across generated rows (${name})`, () => { + fc.assert( + fc.property( + typedSpecArb, + fc.constantFrom(...typedMethods), + fc.nat(4), + fc.boolean(), + expectTypedCall, + ), + campaignOptions(seed), + ) + }) + } + + it(`reaches every method, empty rows, NaN, and -0 in the fixed campaign`, () => { + const sample = fc.sample( + fc.tuple(typedSpecArb, fc.constantFrom(...typedMethods)), + { seed: FIXED_SEED, numRuns: RUNS }, + ) + expect(new Set(sample.map(([, m]) => m)).size).toBe(typedMethods.length) + expect(sample.some(([v]) => v.length === 0)).toBe(true) + expect(sample.some(([v]) => v.some((x) => Number.isNaN(x)))).toBe(true) + expect(sample.some(([v]) => v.some((x) => Object.is(x, -0)))).toBe(true) + }) +}) diff --git a/packages/db/tests/proxy.test.ts b/packages/db/tests/proxy.test.ts index 153e71126..d39bf4d94 100644 --- a/packages/db/tests/proxy.test.ts +++ b/packages/db/tests/proxy.test.ts @@ -2545,124 +2545,6 @@ describe(`stored functions behave like native values`, () => { }) }) -/** - * Every array method that does not mutate the array must behave on a draft as - * on a native array. The method list comes from `Array.prototype`, so a new - * built-in method fails here until it has arguments below. Each call is - * observed by its result, the identity of each object in the result, and the - * row after a write through every object the result holds. - */ -describe(`array read methods behave like native arrays`, () => { - type Element = { x: number } | number | Array<{ x: number }> - type Row = { items: Array } - const make = (): Row => ({ items: [{ x: 1 }, 2, [{ x: 3 }], { x: 4 }] }) - const mutators = new Set([ - `copyWithin`, - `fill`, - `pop`, - `push`, - `reverse`, - `shift`, - `sort`, - `splice`, - `unshift`, - ]) - // Arguments for each method. A draft element as the argument checks that - // searches find the element the draft returned. - const calls: Record Array>> = { - at: [() => [0], () => [-1]], - concat: [() => [[{ x: 5 }]], (row) => [row.items]], - entries: [() => []], - every: [() => [(v: unknown) => v !== 0]], - filter: [() => [(v: unknown) => typeof v === `object`]], - find: [() => [(v: unknown) => typeof v === `object`]], - findIndex: [() => [(v: unknown) => v === 2]], - findLast: [() => [(v: unknown) => typeof v === `object`]], - findLastIndex: [() => [(v: unknown) => v === 2]], - flat: [() => []], - flatMap: [() => [(v: unknown) => v]], - forEach: [() => [() => undefined]], - includes: [() => [2], (row) => [row.items[0]], (row) => [row.items.at(-1)]], - indexOf: [() => [2], (row) => [row.items[0]], (row) => [row.items.at(-1)]], - join: [() => [`,`]], - keys: [() => []], - lastIndexOf: [() => [2], (row) => [row.items[3]]], - map: [() => [(v: unknown) => v]], - reduce: [() => [(_acc: unknown, v: unknown) => v]], - reduceRight: [() => [(_acc: unknown, v: unknown) => v]], - slice: [() => [0, 2], () => [-1]], - some: [() => [(v: unknown) => v === 2]], - toLocaleString: [() => []], - toReversed: [() => []], - toSorted: [ - () => [(a: unknown, b: unknown) => (a === 2 ? -1 : b === 2 ? 1 : 0)], - ], - toSpliced: [() => [0, 1]], - toString: [() => []], - values: [() => []], - with: [() => [1, { x: 7 }]], - } - const methods = Object.getOwnPropertyNames(Array.prototype).filter( - (name) => - name !== `constructor` && name !== `length` && !mutators.has(name), - ) - - // Objects reachable from a result, in visit order, without repeats. - const objectsIn = (value: unknown, seen: Array = []) => { - if (value === null || typeof value !== `object` || seen.includes(value)) - return seen - seen.push(value) - for (const child of Array.isArray(value) ? value : Object.values(value)) - objectsIn(child, seen) - return seen - } - const observe = (row: Row, method: string, args: Array) => { - const call = ( - row.items as unknown as Record) => unknown> - )[method]! - const raw = call.apply(row.items, args) - const result = - raw !== null && typeof raw === `object` && Symbol.iterator in raw - ? [...(raw as Iterable)] - : raw - const snapshot = JSON.parse(JSON.stringify(result ?? null)) - const objects = objectsIn(result).filter((o) => !Array.isArray(o)) - // Where each object in the result sits in the row, by identity. - const identity = objects.map((o) => - row.items.flatMap((element, i) => - element === o - ? [i] - : Array.isArray(element) && element[0] === o - ? [i + 0.5] - : [], - ), - ) - for (const o of objects) (o as { x: number }).x += 100 - return { snapshot, identity } - } - - it(`has arguments for every non-mutating method`, () => { - expect(methods.filter((name) => !(name in calls))).toEqual([]) - }) - - const cases = methods.flatMap((method) => - (calls[method] ?? []).map( - (makeArgs, i) => [`${method} #${i + 1}`, method, makeArgs] as const, - ), - ) - it.each(cases)(`%s gives the native result`, (_name, method, makeArgs) => { - const native = make() - const expected = observe(native, method, makeArgs(native)) - let actual: unknown - const changes = withChangeTracking(make(), (draft) => { - actual = observe(draft, method, makeArgs(draft)) - }) - expect(actual).toEqual(expected) - const changed = JSON.stringify(native) !== JSON.stringify(make()) - expect(changes).toEqual(changed ? { items: native.items } : {}) - }) -}) - /** * `Object.defineProperty` on a draft defines the property as on a native row: * the same result, value, and descriptor. A defined enumerable value is a @@ -2736,94 +2618,6 @@ describe(`defineProperty behaves like on a native row`, () => { }) }) -/** - * Every typed-array method must behave on a draft as on a native typed array. - * The method list comes from the shared `TypedArray.prototype`, so a new - * built-in method fails here until it has arguments below. Each call is - * observed by its result and by the row after a write through a typed-array - * result, which on a native row changes the row when the result shares its - * buffer (`subarray`). - */ -describe(`typed-array methods behave like native typed arrays`, () => { - type Row = { t: Float64Array } - const make = (): Row => ({ t: Float64Array.of(3, 1, 2) }) - const positive = (v: number) => v > 1 - const calls: Record> = { - at: [0], - copyWithin: [0, 1], - entries: [], - every: [positive], - fill: [7], - filter: [positive], - find: [positive], - findIndex: [positive], - findLast: [positive], - findLastIndex: [positive], - forEach: [() => undefined], - includes: [2], - indexOf: [2], - join: [`,`], - keys: [], - lastIndexOf: [2], - map: [(v: number) => v * 2], - reduce: [(a: number, v: number) => a + v], - reduceRight: [(a: number, v: number) => a + v], - reverse: [], - set: [[9], 1], - slice: [0, 2], - some: [positive], - sort: [], - subarray: [0, 2], - toLocaleString: [], - toReversed: [], - toSorted: [], - toString: [], - values: [], - with: [0, 9], - } - const typedArrayPrototype = Object.getPrototypeOf(Float64Array.prototype) - const methods = Object.getOwnPropertyNames(typedArrayPrototype).filter( - (name) => - name !== `constructor` && - typeof Object.getOwnPropertyDescriptor(typedArrayPrototype, name) - ?.value === `function`, - ) - const observe = (row: Row, method: string, write: boolean) => { - const call = ( - row.t as unknown as Record) => unknown> - )[method]! - const raw = call.apply(row.t, calls[method]!) - const result = - raw !== null && typeof raw === `object` && Symbol.iterator in raw - ? Array.from(raw as Iterable) - : raw - if (write && raw instanceof Float64Array) raw[0] = 42 - return result - } - - it(`has arguments for every method`, () => { - expect(methods.filter((name) => !(name in calls))).toEqual([]) - }) - - // A call alone, and a call followed by a write through its result. A - // read must not count as a change. - const cases = methods.flatMap((method) => [ - [method, `read`] as const, - [method, `write`] as const, - ]) - it.each(cases)(`%s (%s) gives the native result`, (method, mode) => { - const native = make() - const expected = observe(native, method, mode === `write`) - let actual: unknown - const changes = withChangeTracking(make(), (draft) => { - actual = observe(draft, method, mode === `write`) - }) - expect(actual).toEqual(expected) - const changed = native.t.some((v, i) => v !== make().t[i]) - expect(changes).toEqual(changed ? { t: native.t } : {}) - }) -}) - /** * Freezing, sealing, or fixing a key of a draft must not lose a later write * through a nested value. The Proxy invariants make a frozen key return the From 556cbd8ff0e84d4351624c71375dfa0e70134eb0 Mon Sep 17 00:00:00 2001 From: Isaac Date: Thu, 1 Oct 2026 15:37:38 -0600 Subject: [PATCH 27/28] test(db): close oracle gaps found by the loss audit - The revert oracle's revert may write the row's own original value back instead of an equal fresh value. Its grammar can now rebuild the class-instance write-back witness, and the fixed-campaign witness requires same-object reverts, including class instances. - The revert oracle names its checkpoint, the traps its driver reaches, and the source of rules 1 to 6. - The frozen-draft law adds a sealed key and a read-only configurable key, where an object read must not be a change. These reject a frozen-key boundary that checks only one of the two attributes. - The two-step callback property in proxy.test.ts, which was on main, runs a fixed and a random campaign with seed-and-path replay. Co-authored-by: Isaac --- .../proxy-revert-oracle.property.test.ts | 34 +++++++-- packages/db/tests/proxy.test.ts | 74 ++++++++++++++++--- 2 files changed, 90 insertions(+), 18 deletions(-) diff --git a/packages/db/tests/proxy-revert-oracle.property.test.ts b/packages/db/tests/proxy-revert-oracle.property.test.ts index 6d38e13a5..87d209f5c 100644 --- a/packages/db/tests/proxy-revert-oracle.property.test.ts +++ b/packages/db/tests/proxy-revert-oracle.property.test.ts @@ -30,15 +30,19 @@ import { createChangeProxy } from '../src/proxy' * instances as plain objects. A class instance without enumerable keys * (here, one with only a private field) equals only itself. * - * Laws checked after every generated history: + * Laws checked at one checkpoint: after the last write of a history, before + * the draft publishes. The driver writes through `createChangeProxy`'s draft + * (its `set`, `deleteProperty`, and `get` traps, and the array iterator), and + * the check reads `getChanges()` and the draft. * * - `getChanges()` equals the model's changes. * - Reading the draft gives the model's final value. * - The original row is unchanged. * - * Authority: the `createChangeProxy` and `getChanges` implementation comments - * in `src/proxy.ts` and the revert examples in `tests/proxy.test.ts`, as of - * `18abceee`. Rules 7 to 9 are design decisions recorded in + * Authority: rules 1 to 6 and the laws come from the `createChangeProxy` and + * `getChanges` implementation comments in `src/proxy.ts` and the revert + * examples in `tests/proxy.test.ts`, as of `18abceee`. Rules 7 to 9 are design + * decisions recorded in * `docs/contributing/oracle-reviews/code-weight-draft-proxy.md`. * * Limits: @@ -300,7 +304,9 @@ const writtenArb: fc.Arbitrary = fc.oneof( type Op = | { op: `set`; field: Field; value: Spec } - | { op: `revert`; field: Field } + // `same` writes the original row's own value back instead of an equal + // fresh value, so identity-based paths are reached too. + | { op: `revert`; field: Field; same?: boolean } | { op: `delete`; field: Field } | { op: `nested`; field: Field; key: `a` | `b` | `sym`; value: Primitive } | { op: `nestedDelete`; field: Field; key: `a` | `b` | `sym` } @@ -318,6 +324,7 @@ const opArb: fc.Arbitrary = fc.oneof( arbitrary: fc.record({ op: fc.constant(`revert` as const), field: fc.constantFrom(...FIELDS), + same: fc.boolean(), }), }, fc.record({ @@ -696,7 +703,8 @@ function expectHistory({ original, ops }: History): void { if (op === undefined || !applicable(state, original, op)) continue if (op.op === `revert`) { if (original[op.field] === undefined) delete proxy[op.field] - else proxy[op.field] = realize(original[op.field]!) + else + proxy[op.field] = op.same ? row[op.field] : realize(original[op.field]!) } else drive(proxy as Record, op) state = step(state, original, op) } @@ -801,6 +809,9 @@ describe(`draft revert oracle`, () => { let partial = 0 let full = 0 let symbolOnly = 0 + // Reverts that write the row's own object back, a class instance included. + let sameObjectReverts = 0 + let samePointReverts = 0 for (const { original, ops } of sample) { let state: Root = { ...original } // A revert counts only when the field differs from its original, so @@ -810,6 +821,15 @@ describe(`draft revert oracle`, () => { const op = resolve(state, original, generated) if (op === undefined || !applicable(state, original, op)) continue reached.add(op.op) + const restored = original[op.field] + if ( + op.op === `revert` && + op.same && + restored !== undefined && + !(restored.k === `prim` || restored.k === `date`) + ) + sameObjectReverts += + restored.k === `point` ? (samePointReverts++, 1) : 1 if ( op.op === `revert` && encode(state[op.field]) !== encode(original[op.field]) @@ -847,6 +867,8 @@ describe(`draft revert oracle`, () => { expect(partial).toBeGreaterThanOrEqual(5) expect(full).toBeGreaterThanOrEqual(30) expect(symbolOnly).toBeGreaterThan(0) + expect(sameObjectReverts).toBeGreaterThanOrEqual(20) + expect(samePointReverts).toBeGreaterThan(0) }) // Writes that a keyless comparison would call equal: another URL, or a diff --git a/packages/db/tests/proxy.test.ts b/packages/db/tests/proxy.test.ts index d39bf4d94..430357e37 100644 --- a/packages/db/tests/proxy.test.ts +++ b/packages/db/tests/proxy.test.ts @@ -1,4 +1,4 @@ -import { fc, test as fcTest } from '@fast-check/vitest' +import { fc } from '@fast-check/vitest' import { describe, expect, it, vi } from 'vitest' import { Temporal } from 'temporal-polyfill' import { createCollection } from '../src/collection/index' @@ -238,18 +238,47 @@ describe(`native array callback oracle`, () => { target: fc.integer({ min: 0, max: 5 }), delta: fc.integer({ min: -5, max: 5 }), }) - fcTest.prop( - { - values: fc.array(fc.integer({ min: -10, max: 10 }), { - minLength: 1, - maxLength: 6, - }), - steps: fc.tuple(step, step), - }, - { numRuns: 200 }, - )(`matches native two-step callback histories`, ({ values, steps }) => { - assertArrayCallbacks(observeArrayCallbacks(values, steps)) + // A fixed and a random campaign. TANSTACK_DB_PROXY_CALLBACK_SEED and + // TANSTACK_DB_PROXY_CALLBACK_PATH select a direct replay instead. + const callbackHistory = fc.record({ + values: fc.array(fc.integer({ min: -10, max: 10 }), { + minLength: 1, + maxLength: 6, + }), + steps: fc.tuple(step, step), }) + const replaySeed = process.env.TANSTACK_DB_PROXY_CALLBACK_SEED + const replayPath = process.env.TANSTACK_DB_PROXY_CALLBACK_PATH + const callbackCampaigns = + replaySeed === undefined && replayPath === undefined + ? [ + { name: `2026103`, seed: 2026103 as number | undefined }, + { name: `random`, seed: undefined }, + ] + : [ + { + name: `replay`, + seed: replaySeed === undefined ? undefined : Number(replaySeed), + }, + ] + for (const { name, seed } of callbackCampaigns) { + it(`matches native two-step callback histories (${name})`, () => { + if (replayPath !== undefined && replaySeed === undefined) + throw new Error(`TANSTACK_DB_PROXY_CALLBACK_PATH requires a seed`) + if (seed !== undefined && !Number.isSafeInteger(seed)) + throw new Error(`TANSTACK_DB_PROXY_CALLBACK_SEED must be an integer`) + fc.assert( + fc.property(callbackHistory, ({ values, steps }) => { + assertArrayCallbacks(observeArrayCallbacks(values, steps)) + }), + { + numRuns: 200, + ...(seed === undefined ? {} : { seed }), + ...(replayPath === undefined ? {} : { path: replayPath }), + }, + ) + }) + } it.each([`lost-write`, `extra-visit`, `wrong-peer`] as const)( `rejects a captured %s independently of the native authority`, @@ -2670,6 +2699,27 @@ describe(`frozen and sealed drafts keep nested writes`, () => { expect(getChanges()).toEqual({}) }) + // The boundary is a read-only and non-configurable key. A sealed key is + // non-configurable but writable, and a read-only key may stay configurable. + // Either way the draft hands out a draft, so a read is no change. These + // cases reject a boundary that checks only one of the two attributes. + it.each([ + [`sealed`, (row: Row) => Object.seal(row)], + [ + `read-only but configurable`, + (row: Row) => + Object.defineProperty(row, `n`, { + writable: false, + configurable: true, + }), + ], + ])(`does not count an object read under a %s key`, (_name, fix) => { + const { proxy, getChanges } = createChangeProxy(make()) + fix(proxy) + void proxy.n.x + expect(getChanges()).toEqual({}) + }) + it(`does not count writing back the row's own class instance`, () => { class Point { constructor(public x: number) {} From c049bf76a33d0507370277370717dc368688b0a7 Mon Sep 17 00:00:00 2001 From: Isaac Date: Thu, 1 Oct 2026 15:42:44 -0600 Subject: [PATCH 28/28] docs: record the loss-audit fixes and all ORC requirements The review record now covers ORC-001 to ORC-014 for each owner, with the four ORC-004 controls per generated property, the reusable boundary laws and their nearby witnesses, bug-class closure boundaries with open cells, and the no-op mutator limit. The coverage map lists the native-methods owner. Co-authored-by: Isaac --- .changeset/simplify-draft-proxy.md | 2 +- docs/contributing/oracle-coverage.md | 2 +- .../oracle-reviews/code-weight-draft-proxy.md | 153 ++++++++++++++++-- 3 files changed, 139 insertions(+), 18 deletions(-) diff --git a/.changeset/simplify-draft-proxy.md b/.changeset/simplify-draft-proxy.md index 85c0927e0..4c5969b24 100644 --- a/.changeset/simplify-draft-proxy.md +++ b/.changeset/simplify-draft-proxy.md @@ -9,7 +9,7 @@ This change also fixes draft writes that were lost or wrong: - A function stored in a row is now returned as stored when read from a draft. Before, the draft returned a bound copy, so `draft.handler === handler` was false. A stored method also saw a private copy as `this`, so `collection.update` dropped the writes it made through `this`. - Array methods such as `at`, `slice`, `concat`, `flat`, `toReversed`, and `with` now return drafts, so a write through their result is saved. `indexOf` and `includes` find an element that the draft returned, and `draft.items.constructor === Array` is true. - `Object.defineProperty(draft, key, { value })` no longer throws when the descriptor does not set `writable` or `configurable`. Defining a getter on a draft is now a change. -- `fill`, `set`, `sort`, `reverse`, and `copyWithin` on a typed array in a draft, and writes through its `subarray`, are now saved. +- `fill`, `set`, `sort`, `reverse`, and `copyWithin` on a typed array in a draft, and writes through its `subarray`, are now saved. `sort`, `reverse`, `fill`, and `copyWithin` on a draft now return the draft, as the native methods return the array itself. - Deleting a nested key that the callback added, or writing a nested value back, no longer leaves the parent marked as changed. - A typed-array subclass whose constructor does not forward its argument is now copied with its elements. Typed arrays that hold `NaN` now equal themselves. diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index 8615f0754..e699a01b8 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -242,7 +242,7 @@ comment and the current API/architecture contract before extending its model. | Collection lifecycle | [mutation startup](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-mutation-startup-oracle.test.ts), [change-event history](https://github.com/TanStack/db/blob/main/packages/db/tests/change-event-history-oracle.test.ts), [history](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-subscription-lifecycle-history.property.test.ts), [publication](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-subscription-lifecycle-publication.property.test.ts), [replay](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-subscription-replay-oracle.property.test.ts), [effect disposal](https://github.com/TanStack/db/blob/main/packages/db/tests/effect-disposal-oracle.test.ts), [PR #1902 review](oracle-reviews/pr-1902-change-event-history.md), [batch-order decision review](oracle-reviews/change-event-batch-order.md) | Core Collection `insert`/`update`/`delete` admission while `startSync:false` is idle; complete public-row/change-message agreement across bounded one- and two-key histories plus replayable four-key campaigns; a two-key deferred sync history checks each key's causal insert/update/delete trace while allowing any cross-key interleaving. Reversing all deferred messages fails that check; reversing each sync transaction's distinct-key messages passes. Queued duplicate admission and cancellation, ownership and phase histories, exact caller/error/publication evidence, and late completion and restart have separate witnesses. Generated deferred histories with cancellation or reentrant callbacks need a witness in the change-event history owner before claiming general batch-shape coverage. Query write utilities and effect self-dependent disposal remain separate contracts. | | Paced mutations | [virtual-clock oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/paced-mutations-oracle.test.ts) | `createPacedMutations` with queue front/back options, bounded waiting capacity at zero and one, cleanup draining in FIFO/LIFO order after a held write, cleanup timing at the configured wait for immediate writes, admitted writes after separate Collection cleanup, debounce and throttle schedules with explicit and omitted options, and first-leading throttle execution at epoch zero. Finite histories compare optimistic rows, returned transaction identity and receipt outcome, execution order and virtual-clock time. Held writes verify queue serialization. Leading-only throttle and debounce witnesses reject skipped calls immediately with their named dropped-call errors, including omitted edges, both edges disabled, and same-row rollback while a prior write is held. Capacity witnesses cover overflow rejection, distinct-key optimistic rollback, same-key admitted-write survival, and in-flight versus waiting admission. Cleanup witnesses check repeated queue cleanup, eventual admitted receipt settlement, direct queue strategy rejection of new callbacks, public post-cleanup rollback with `QueueDisposedError`, pending debounce transactions that wait until the last call's quiet edge after separate Collection cleanup, and a trailing throttle timer that runs at its regular edge after separate Collection cleanup. Deferred custom queue and batch callbacks check compatibility with `void` and `false` execute results, respectively. Frozen options verify factory non-mutation; a mutable option witness checks that debounce uses its construction-time trailing setting. New debounce/throttle admission after cleanup, failed persistence, broader capacities and schedules remain outside this owner. | | Optimistic state | [history model](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-history-oracle.ts), [generated histories](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-transaction-oracle.property.test.ts), [truncate capture ownership](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-truncate-ownership-oracle.property.test.ts), [outcomes](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-history-outcomes.test.ts), [publication](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-history-publication.test.ts) | Independent whole-row snapshots, rollback dependencies, captured truncate ownership, metadata and prior-value events. Never rebase a pending snapshot merely to simplify the model. | -| Drafts and native values | [proxy](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy.test.ts), [detachment](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-detachment-contract.test.ts), [iteration](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-iteration-contract.test.ts), [revert](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-revert-oracle.property.test.ts) | Native-operation controls, exact patches and actual stored rows; alias/cycle/adversarial-key histories. Set and Map reorder histories check draft patches and stored iteration order after replacement and clear-and-readd; RegExp replacement/reversion histories check `lastIndex`. General native-mutator and symbol-write support is not established by a plain-object oracle. The revert owner generates assignment, revert, delete, nested, symbol-key, and `for...of` histories and checks `getChanges()` and the draft against an independent draft-equality model: ordered Map and Set contents (including Sets inside Map values), RegExp `lastIndex`, array holes, and symbol keys inside nested values. Mutator methods (`push`, `set`, `add`) and top-level symbol writes are outside its grammar. The proxy owner's stored-function law checks, against a native row, that a function stored as data reads back as stored by every read path and that a stored method sees the draft as `this`, so its writes are tracked. The detachment owner pins which key classes a draft copies: enumerable string keys and every symbol key. The revert owner also generates typed arrays (including a subclass that does not forward its constructor argument), URLs, and a class with only private state as written values, with a property that writes two of them over one field. The proxy owner's array law takes every non-mutating method from `Array.prototype` and compares its result, the identity of each object in it, and the row after a write through it with a native array; a new built-in method fails until it has arguments. Its `defineProperty` law compares result, value, descriptor, and changes, for values and accessors. Its typed-array law takes every method from `TypedArray.prototype` and compares result and row with a native typed array. The iteration owner checks that array iterators see a push, removal, index write, or length cut made while they are open. Open: a draft reads a class instance of the original row as a plain object, so private-field getters read `undefined`, and a built-in method called through `this` on a nested Map or Set draft rejects the Proxy receiver; reading an object under a frozen draft key counts that key as changed, writing back a keyless class instance of the original row is a change, and `DataView` setters are untracked ([draft proxy review](oracle-reviews/code-weight-draft-proxy.md)). | +| Drafts and native values | [proxy](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy.test.ts), [detachment](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-detachment-contract.test.ts), [iteration](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-iteration-contract.test.ts), [revert](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-revert-oracle.property.test.ts), [native methods](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-native-methods.property.test.ts) | Native-operation controls, exact patches and actual stored rows; alias/cycle/adversarial-key histories. Set and Map reorder histories check draft patches and stored iteration order after replacement and clear-and-readd; RegExp replacement/reversion histories check `lastIndex`. General native-mutator and symbol-write support is not established by a plain-object oracle. The revert owner generates assignment, revert, delete, nested, symbol-key, and `for...of` histories and checks `getChanges()` and the draft against an independent draft-equality model: ordered Map and Set contents (including Sets inside Map values), RegExp `lastIndex`, array holes, and symbol keys inside nested values. Mutator methods (`push`, `set`, `add`) and top-level symbol writes are outside its grammar. The proxy owner's stored-function law checks, against a native row, that a function stored as data reads back as stored by every read path and that a stored method sees the draft as `this`, so its writes are tracked. The detachment owner pins which key classes a draft copies: enumerable string keys and every symbol key. The revert owner also generates typed arrays (including a subclass that does not forward its constructor argument), URLs, and a class with only private state as written values, with a property that writes two of them over one field. The native-methods owner generates rows (holes, `undefined`, duplicates, nested arrays, empty rows, typed `-0` and `NaN`) and calls every method of `Array.prototype` and `TypedArray.prototype`, in fixed and random campaigns with replay; it compares the result, whether the method returned the array itself, the identity of each original object, and the published row with native values, and a new built-in method fails until it has arguments. The proxy owner's `defineProperty`, stored-function, and frozen-draft laws are bounded tables against native rows. Its `defineProperty` law compares result, value, descriptor, and changes, for values and accessors. The iteration owner checks that array iterators see a push, removal, index write, or length cut made while they are open. Open: a draft reads a class instance of the original row as a plain object, so private-field getters read `undefined`, and a built-in method called through `this` on a nested Map or Set draft rejects the Proxy receiver; a mutator that changes nothing publishes an equal value; reading an object under a frozen draft key counts that key as changed, writing back a keyless class instance of the original row is a change, and `DataView` setters are untracked ([draft proxy review](oracle-reviews/code-weight-draft-proxy.md)). | | Query DB and observer | [ownership](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/ownership-lifecycle.oracle.test.ts), [load lifecycle](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/load-subset-lifecycle-oracle.test.ts), [observer histories](https://github.com/TanStack/db/blob/main/packages/db/tests/live-query-observer-history.property.test.ts) | Real QueryClient boundary, applied-settlement barriers for existing and cached demand, mutation-handler Collection identity, terminal fetch-record lifecycle, and a per-listener eligibility ledger, not a duplicate dispatch queue. The bounded handler grammar crosses insert, update, delete, parameter and mutation-alias access, refetch, and clearError. A held clearError retry retains the prior public error through initial, background, and Collection application failures; success clears it, failure advances the count, including a repeated error at clock time zero. An intermediate Query retry attempt does not recount the previous background error; a held final attempt can succeed or fail. A held application after successful Query fetch keeps the error visible until the applied result. A mocked persisted-baseline scan holds an older success while a newer terminal Query failure arrives; the newer public error and count survive application, including when the Error object and clock tick repeat. Native SQLite persistence, concurrent retries across multiple tracked Queries, mutation-handler fetch-boundary settlement, and deferred result application remain outside this witness. The mutation overlap grammar crosses both start orders. Check reentry, peer survival, FIFO and disposal independently of final rows. | | Query DB SSR dehydration | `packages/query-db-collection/tests/ssr-dehydration-oracle.test.ts`, [review record](oracle-reviews/issue-1950-ssr-dehydration.md) | A plain Query cache control, a live on-demand compound predicate, a signal-only request, and an ordered cursor with a custom comparator all retain their row through QueryClient dehydration, reconstruction of Seroval's emitted stream payload, and Query Core hydration from that payload. The on-demand query functions still receive the original IR, comparator, and signal; serialized metadata retains enumerable user metadata but excludes request options. The original implementation fails the compound and signal cases at the stream checkpoint. A serializable stream-only request-options leak passes the former chunk-count check but fails the current payload assertion. This owner covers successful Query cache entries at the installed Query Core 5.90.20 and Seroval 1.5.0 versions. A TanStack Start integration owner still needs the reporter's Router 1.171.33 and Seroval 1.6.8 path, browser Collection resume/refetch, and request cancellation across SSR. Query subset and pagination oracles remain owners of predicate/order/cursor meaning and supported value types. The issue's `shouldDehydrateQuery` workaround replaces Query Core's success-only default and admits pending/error queries; any documented workaround must preserve that default filter. | | Ordered acquisition | [pagination](https://github.com/TanStack/db/blob/main/packages/db/tests/query/pagination-oracle.property.test.ts), [ordered work](https://github.com/TanStack/db/blob/main/packages/db/tests/query/ordered-work-oracle.property.test.ts), [ordered lifecycle](https://github.com/TanStack/db/blob/main/packages/db/tests/query/ordered-lifecycle-oracle.property.test.ts), [ordered loader state](https://github.com/TanStack/db/blob/main/packages/db/tests/query/ordered-source-loader-state.test.ts), [graph scheduler](https://github.com/TanStack/db/blob/main/packages/db/tests/query/scheduler.test.ts), [issue #1880 ordered-repair review](oracle-reviews/issue-1880-ordered-repair.md), [issue #1882 custom local collation review](oracle-reviews/issue-1882-custom-local-collation.md), [issue #1898 relation-filter review](oracle-reviews/issue-1898-relation-filter.md), [PR #1909 external review](oracle-reviews/pr-1909-external-review.md) | Complete finite provider results, inherited locale and bounded/unbounded custom collation with exact request options, query-level inheritance across source aliases, real lexical/numeric disagreement, custom comparator ties, scan/index paths, pending windows, ties/nulls, lease ownership, Effect callback gates, including an indexed source update during Effect startup, and documented repair timing. Direct LEFT-joined filters cover local finite prefixes, pending child demand, child removal, and anti-join publication with custom full-source and unindexed fallback loading; unrelated include demand cannot stall the root continuation, and an empty joined replay wakes held Effect callbacks; remote relation hints remain outside this owner. Graph scheduler checks coalescing, dependency order, synchronous loader input, and repeated-alias failure ownership. The ordered-work oracle checks that a synchronous root refill reaches D2 before joined publication; a mutant that omitted the post-loader graph step failed at the second child demand. Custom collation proves local full-source acquisition only; it does not establish provider cursor capability or backend collation fidelity. Sibling-source input during a synchronous ordered continuation returns to graph work before another ordered acquisition. Request completion is not proof of unrequested source extent. A window move during existing joined demand waits for its root continuation, including an independently held root request; current demand failure rejects the move, while retirement and an obsolete rejection do not. A bounded ordered repair resumes its refill after joined demand settles. The ordered-source-loader state test checks that an explicit failed-request retry blocked by joined demand retains its generation until it can issue a request. Multiple simultaneously pending joined plans during a window move and a public-query failed-retry overlap still need dedicated witnesses. | diff --git a/docs/contributing/oracle-reviews/code-weight-draft-proxy.md b/docs/contributing/oracle-reviews/code-weight-draft-proxy.md index cf68946e1..5365cdcc8 100644 --- a/docs/contributing/oracle-reviews/code-weight-draft-proxy.md +++ b/docs/contributing/oracle-reviews/code-weight-draft-proxy.md @@ -1,6 +1,6 @@ # Code weight: simplify the draft proxy and share its equality walker -Reviewed executable revision: `7e5dfee25` (base `18abceee4`, the fetched +Reviewed executable revision: `556cbd8ff` (base `18abceee4`, the fetched `origin/main` at review time). This record follows in a documentation-only commit. @@ -23,6 +23,10 @@ commit. second review. - `0ce101acf` fixes two more lost-write classes from CodeRabbit review: accessor definitions and typed-array mutator methods. +- `cbd2aff06` and `556cbd8ff` act on a loss audit of these oracles against + `docs/contributing/oracle-tests.md`. They fix one bug, make the array and + typed-array laws generated, and close the audit's grammar, checkpoint, and + replay gaps. - `7e5dfee25` fixes three bugs that code review found in the earlier fixes: class write-backs, writes after `Object.freeze`, and `subarray` reads. @@ -206,6 +210,8 @@ commit before. | `b825e6483` | `38d8ba78f` defined a clone, so a non-configurable object value broke the Proxy invariants and threw. `get` would also have wrapped it. | `defineProperty` law: a new key with only an object value, with identity where the invariants require it. | throws on `main` too | +9 / +5 (module only) | | `0ce101acf` | Defining a getter over a field reported no change. `fill`, `set`, `sort`, `reverse`, `copyWithin`, and writes through `subarray` on a typed-array draft changed the private copy without a change. | `proxy.test.ts`: every method of `TypedArray.prototype` against a native typed array, and two getter definitions. | 6 of 31 methods, 2 of 2 getters | +70 / +25 (module only) | | `7e5dfee25` | The class rule made writing back the row's own class instance a change, because draft snapshots hold class instances as plain objects. After `Object.freeze(draft)`, a nested write was lost. A `subarray` read counted as a change. | Revert oracle: a keyed class instance in original rows. `proxy.test.ts`: rows after freeze, seal, or a fixed key against native rows, and the typed-array law with and without a write. | 7 oracle cases and 3 pinned cases on `ca0a2170c` | about +160 / — (proxy and utils modules) | +| `cbd2aff06` | `sort`, `reverse`, `fill`, and `copyWithin` on a draft returned the private copy, not the draft. `main` has the same bug. | N: generated rows record whether a method returned the array itself. | the four self-returning mutators, on arrays and typed arrays | small (one comparison) | +| `556cbd8ff` | No product bug. R's grammar could not write back the row's own object, the frozen-key boundary had no nearby witness, and the two-step callback property had no fixed campaign or replay. | R: same-object reverts. T: sealed and read-only configurable keys. `proxy.test.ts`: campaigns and replay. | F4 survived before the read-only configurable case | 0 | | `a49ae90e3` | None. A detached stored method must throw, as on a native row. | `proxy.test.ts` stored-function law. | passes | 0 | Design decisions for these fixes: @@ -248,6 +254,11 @@ Limits that remain: change, because the snapshot holds it as an empty plain object. - `DataView` setters on a draft are not tracked. No owner generates a `DataView`. +- A mutator method that changes nothing, such as `sort` on a sorted array or + `add` of a present Set member, counts as a change, so the row publishes an + equal value. Arrays, Maps, and Sets did this on `main`. Typed arrays do too + since `0ce101acf`. This is a deliberate limit: a revert check after mutators + would cost bytes to avoid a publish of an equal value. | Mutant | Owners | | --- | --- | @@ -325,6 +336,11 @@ Mutants of each new form fail the owners: | F2 (on `7e5dfee25`): a frozen-key primitive read is a change | assertion failure (1/611) | | S1 (on `7e5dfee25`): `subarray` returns a plain view | assertion failure (2/611) | | S2 (on `7e5dfee25`): `subarray` marks a change on call | assertion failure (1/611) | +| M1 (on `cbd2aff06`): mutators return the raw copy | assertion failure (12/587) | +| M2 (on `cbd2aff06`): mutators always return the draft | assertion failure (10/587) | +| A1, A2, N3, Y1, Y3, S1, S2 (rerun on `cbd2aff06` against N) | assertion failure (55, 21, 23, 7, 3, 3, 3 of 587) | +| F3 (on `556cbd8ff`): the frozen-key boundary checks configurability alone | assertion failure (1/589) | +| F4 (on `556cbd8ff`): the frozen-key boundary checks writability alone | assertion failure (1/589); survived before the read-only configurable case | The I and A3 to A5 mutants in the previous section apply to code that `31b2a6bd4` removed. @@ -442,29 +458,134 @@ calls the trap through the handler object and skips primitives. The class check in `deepEquals` runs after the keys match, so an unequal row exits before it. -## ORC-001 through ORC-012 +## ORC-001 through ORC-014 + +The owners are: + +- **R**, the revert oracle `proxy-revert-oracle.property.test.ts`: four + generated properties over one grammar (general histories, partial reverts, + nested round trips, and URL and keyless writes). +- **N**, the native-methods oracle `proxy-native-methods.property.test.ts`: + generated array and typed-array calls compared with native values. +- **T**, the native-differential tables in `proxy.test.ts`: stored functions, + `defineProperty`, and frozen and sealed drafts. These are bounded + enumerations, not important generated properties. +- **U**, the laws this change adds to `utils.property.test.ts`. | Requirement | Result | | --- | --- | -| ORC-001 | The revert oracle's opening comment states the `getChanges()` contract, the six draft-equality rules, the three laws, the authority, and the limits. | -| ORC-002 | The model is an encoding of plain-data specs. It does not import `draftValuesEqual`, `deepEquals`, or `deepClone`, and it never reads a draft. | -| ORC-003 | The contract, model (`canon`, `step`, `expectedChanges`), grammar (`specArb`, `opArb`, `historyArb`), driver (`drive`, `realize`), and check (`expectHistory`) are separate in one file. | -| ORC-004 | Pinned histories reconstruct each rule. The mutants above are the ablations. The positive witness is the range check. The grammar excludes mutator methods, top-level symbol writes, and class instances in the original row, as declared. | -| ORC-005 | The driver uses `createChangeProxy`. The positive witnesses show that the fixed campaigns reach every operation, both revert outcomes, and at least 10 writes each of another URL and of a keyless instance. | -| ORC-006 | Every mutant has an outcome class. P4 and A5 are recorded as equivalent with their reasons. | -| ORC-007 | Fixed and random campaigns share each property, grammar, check, and budget (400 general histories, 200 for each focused property). The replay variables select a direct replay. | -| ORC-008 | The model is a stateless recomputation over specs, so this requirement does not apply. | -| ORC-009 | Terms match `proxy.ts` and `utils.ts`: draft, revert, changes, draft equality. Spec kinds such as `rows` are model-only and named in the oracle. | -| ORC-010 | fast-check reports the seed, path, and shrunk history. Assertion messages give the field and the full history. | -| ORC-011 | No shared semantic fault calls for a second formulation. | -| ORC-012 | This record ties the reviewed revisions, outcome classes, limits, and performance evidence to the coverage map. | +| ORC-001 | R states the `getChanges()` contract, nine draft-equality rules, the three laws, their authority (rules 1 to 6 from `src/proxy.ts` and earlier tests, rules 7 to 9 from this record), and the limits. N and each T table open with the contract they check, against a native value. | +| ORC-002 | R's model is an encoding of plain-data specs. It does not import `draftValuesEqual`, `deepEquals`, or `deepClone`, and it never reads a draft. N and T take the expected result from the same call on a native value. U builds each pair with a known expected relation. | +| ORC-003 | R keeps contract, model (`canon`, `step`, `expectedChanges`), grammar (the arbitraries), driver (`drive`, `realize`), and check (`expectHistory`) apart in one file. N's opening comment names the same five parts. | +| ORC-004 | See ORC-004 controls below. | +| ORC-005 | R and N drive the draft that `createChangeProxy` or `withChangeTracking` returns. The checkpoint is the end of the callback, before publication: R compares `getChanges()` and the draft, and N the call result and the published row. Positive witnesses: R's fixed general campaign reaches every operation, both revert outcomes, 20 or more same-object reverts, and a class-instance write-back. R's URL and keyless witness requires 10 or more of each write. N's witnesses reach every method, empty rows, holes, nested objects, `NaN`, and `-0`. The partial-revert and nested round-trip properties reach their histories by construction. | +| ORC-006 | Every mutant in this record has an outcome class. P4 and A5 are equivalent within the tested domain, with reasons. | +| ORC-007 | R, N, and the two-step callback property each run a fixed campaign (seeds `2026101`, `2026102`, `2026103`) and a random one with the same property, grammar, check, and budget. Replay: `TANSTACK_DB_PROXY_REVERT_*`, `TANSTACK_DB_PROXY_NATIVE_*`, and `TANSTACK_DB_PROXY_CALLBACK_*` accept a seed and path and run only the replay. Under mutant M1, a captured random failure (`[[], "sort", 0]`) replayed directly to the same counterexample. U uses the replay interface `utils.property.test.ts` already has. | +| ORC-008 | R's model is a stateless recomputation over specs, and N and T use native values, so this requirement does not apply. | +| ORC-009 | Terms match `proxy.ts` and `utils.ts`: draft, revert, changes, draft equality. Model-only spec kinds such as `rows` and `point` are named in R. | +| ORC-010 | fast-check reports seed, path, and shrunk input. R's messages give the field and the history. N's give the row, method, and argument. | +| ORC-011 | N and T are native-differential, a second formulation independent of R's model. No shared semantic fault was found that would call for another one. | +| ORC-012 | This record ties the revisions, outcome classes, limits, and performance evidence to the coverage map. See Bug-class closure below. | +| ORC-013 | See Reusable boundary laws below. | +| ORC-014 | No controlled provider or host supplies a premise. Drafts run in-process on the JavaScript engine, so this requirement does not apply. | + +### ORC-004 controls + +**R (all four properties share one grammar).** + +- **Reconstruction:** the pinned histories rebuild each rule and each reported + bug in R's own grammar and check. These include a nested add-then-delete, + a stale parent edge, a key added as `undefined`, a typed-array subclass, + typed `NaN`, a URL write, a keyless write, and a class-instance write-back. + The general grammar reaches each of their shapes. `556cbd8ff` added the + same-object revert so that a write-back of the row's own object is + generated, not only pinned. +- **Ablation:** each mutant in Mutant calibration and Fixes from the second + review removes one rule, edge, or clause. Every one fails an owner, except + P4, which is equivalent. +- **Range:** three fields. Values are primitives `0`, `-0`, `1`, `NaN`, + `"a"`, `"b"`, `null`, and `undefined`; Dates (including invalid); + RegExps with `lastIndex` 0 or 1; Maps of up to two entries, with values + that may be Sets; Sets of up to two members; arrays of up to three slots + with holes; objects with up to two string keys and a symbol key; arrays of + rows; typed arrays of up to three elements (three classes); URLs; a keyed + class instance; and, as written values only, a keyless class instance. The + marginal cases are empty containers, `-0` and `NaN`, holes, and an absent + key against a key holding `undefined`. Typed-array subclasses, URLs, + keyless instances, and the class write-back each exposed a missing rule when + first added. +- **Exclusion:** `applicable` rejects a nested write on a value that is not an + object, an index write beyond the length or into a `Uint8Array`, and a + revert of a field that is absent both originally and now. Model and driver + skip the same operations. Mutator methods and top-level symbol writes are + outside the grammar. + +**N (arrays and typed arrays).** + +- **Reconstruction:** the rows of the earlier pinned tables run for every + method as fixed cases. +- **Ablation:** mutants A1, A2, N3, M1, M2, Y1, Y3, S1, and S2 each fail an + owner. +- **Range:** array rows of up to four slots from `0` to `3`, `undefined`, + holes, objects, and nested arrays of up to two objects. Typed rows of up to + four values from `0`, `-0`, `1`, `2`, `NaN`, and `3.5`. An index-shaped + argument from 0 to 4, so negative and out-of-range indexes occur. Every + method of the built-in prototypes. The marginal cases are empty rows, holes, + and duplicate elements. +- **Exclusion:** every generated call is legal JavaScript, and a native throw + is an observation that the draft must reproduce. A callback that writes is + outside the grammar. + +**U.** The laws use small fixed value sets with the pinned pairs inside the +same property. Mutants K1 to K7 and R1 to R4 are the ablations. Every pair in +the domain is legal, so no exclusion applies. + +The two-step callback property was already on `main`. This change only adds +its campaigns and replay, and it makes no new grammar claim for it. + +### Reusable boundary laws (ORC-013) + +| Law | Premise witness | Nearby witness | Wrong boundary it rejects | +| --- | --- | --- | --- | +| A key that is both read-only and non-configurable returns the raw value and counts as changed | freeze, then a nested write | a sealed key, and a read-only configurable key, read without a change | F3 (configurability alone), F4 (writability alone) | +| Two different classes differ, and a plain object compares with any class by keys | `Point` against `Other` | `Point` against a plain object, and a draft write-back | R1 (a plain object differs from a class), R2 (no class check) | +| A keyless class instance equals only itself; a URL compares by `href` | `Secret` against `Secret` | `Secret` against `{}`, and a URL against an object that inherits the same `href` | K2, R3, K7 | +| Typed-array class counts only in draft mode | the draft `Float64Array` to `Uint8Array` write | `deepEquals` of `Uint8Array` and `Int8Array` | T3, and the #434 test against a class check in general mode | +| Every non-mutating array method reads through the draft | generated rows for every method | searches with draft elements and duplicates | A1 (all methods on the copy), A2 (searches on the copy) | + +### Bug-class closure (ORC-012) + +Each class below is bounded by contract, histories, production path, and +observation. Open cells name their owner. + +| Class | Boundary | Open in-scope cells | +| --- | --- | --- | +| Stored functions read back bound | Any draft read path (field, index, iterator, spread, Map value, Set member, `Object.values`) on the `get` trap; observed by identity and `getChanges()` | A built-in method called through `this` on a nested Map or Set draft rejects the Proxy (T, a Proxy limit) | +| Array and typed-array methods lose writes or identity | Every built-in method on generated rows (N); observed by result, identity, returned self, and the published row | A callback that writes (N, outside the grammar); a no-op mutator publishes an equal value (N and R, declared limit) | +| `defineProperty` throws or loses changes | Value, accessor, read-only, and fixed definitions (T); observed by result, descriptor, read, and changes | None known | +| Nested reverts leave stale changes | R's nested, delete, and round-trip histories; observed by `getChanges()` | Mutator methods have no revert check (R, declared) | +| Equality hides writes of URLs, keyless objects, and classes | R's URL, keyed, and keyless class values and U's pairs; observed by `getChanges()` and `deepEquals` | Writing back a keyless instance of the original row is a change; a draft reads a class instance of the original row as a plain object (detachment owner) | +| Frozen drafts lose nested writes | Freeze, seal, and fixed keys (T); observed by the published row | An object read under a frozen key publishes an equal value (T, declared) | + +None of these classes has a known reachable counterexample to its claimed law. +The open cells are listed in the coverage map's Drafts row. + +### Guide prompts that do not apply + +The guide's reusable boundary-law checklist targets adapter and lifecycle +oracles. Drafts have no provider, classifier, transport, `await` boundary, or +acquired resource, so real-provider conformance, minimal ambiguity, name +invariance, await-boundary transitions, local/transport refinement, and +partial-construction cleanup do not apply. Representation symmetry appears as +the class rule above. Value-and-work refinement does not apply, because no +work bound is promised. ## Verification -On `7e5dfee25`, which includes `origin/main` at `3463cf8ad`, with the built +On `556cbd8ff`, which includes `origin/main` at `3463cf8ad`, with the built `dist`: -- `packages/db` Vitest, typecheck off: 198 files, 7,822 tests. +- `packages/db` Vitest, typecheck off: 199 files, 7,801 tests. - `packages/db` `tsc --noEmit`: no errors. - `pnpm check:mangle`: 368 names. - `pnpm test:minified-db`: error names, index metadata, query rows, and live