diff --git a/.changeset/simplify-draft-proxy.md b/.changeset/simplify-draft-proxy.md new file mode 100644 index 0000000000..4c5969b24e --- /dev/null +++ b/.changeset/simplify-draft-proxy.md @@ -0,0 +1,21 @@ +--- +'@tanstack/db': patch +--- + +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: + +- 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. `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. + +`deepEquals` and draft change detection also have new rules: + +- 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 5e49a8cc6f..f7a05e5d54 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) | 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 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. | @@ -261,7 +261,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`, 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 new file mode 100644 index 0000000000..5365cdcc82 --- /dev/null +++ b/docs/contributing/oracle-reviews/code-weight-draft-proxy.md @@ -0,0 +1,595 @@ +# Code weight: simplify the draft proxy and share its equality walker + +Reviewed executable revision: `556cbd8ff` (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. +- `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). +- `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. +- `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. +- `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. + +## 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. + +- 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 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 | −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 +`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 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 + +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). Seven are real gaps: + +- 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 (C1, C2, C3). +- `deepEquals` comparing RegExp `lastIndex`, or treating array holes as + 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. + +### 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. + +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: + +- 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. + +## 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) | + +## 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 | +| `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: + +- **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. + `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, 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. + +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. +- 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`. +- 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 | +| --- | --- | +| 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. + +## 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) | +| 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) | +| 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) | +| 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. + +## 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 | +| 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). +`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. + +### Timings for the fixes + +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 | +| --- | ---: | --- | +| `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-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 | 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 `556cbd8ff`, which includes `origin/main` at `3463cf8ad`, with the built +`dist`: + +- `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 + 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. diff --git a/packages/db/src/proxy.ts b/packages/db/src/proxy.ts index 48bd0c84b1..13e5188c48 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() @@ -31,26 +31,7 @@ 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. - */ -const CALLBACK_ITERATION_METHODS = new Set([ - `find`, - `findLast`, - `findIndex`, - `findLastIndex`, - `filter`, - `map`, - `flatMap`, - `forEach`, - `some`, - `every`, - `reduce`, - `reduceRight`, -]) - -/** - * Set of array methods that modify the array in place. + * Array and typed-array methods that modify the value in place. */ const ARRAY_MODIFYING_METHODS = new Set([ `pop`, @@ -62,6 +43,7 @@ const ARRAY_MODIFYING_METHODS = new Set([ `reverse`, `fill`, `copyWithin`, + `set`, ]) /** @@ -89,56 +71,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. @@ -147,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 } } @@ -220,12 +154,6 @@ function createMapSetIteratorHandler( } } -// Add TypedArray interface with proper type -interface TypedArray { - length: number - [index: number]: number -} - // Update type for ChangeTracker interface ChangeParent { tracker: ChangeTracker> @@ -248,6 +176,11 @@ interface ChangeTracker { * Deep clones an object while preserving special types like Date and RegExp */ +interface TypedArray { + length: number + set: (source: TypedArray) => void +} + function deepClone( obj: T, visited = new WeakMap(), @@ -300,18 +233,14 @@ function deepClone( // Handle TypedArrays if (ArrayBuffer.isView(obj) && !(obj instanceof DataView)) { - // Get the constructor to create a new instance of the same type + // 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 + ) as TypedArray 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]! - } - + clone.set(obj as unknown as TypedArray) return clone as unknown as T } @@ -350,8 +279,12 @@ function deepClone( const clone = {} as Record visited.set(obj as object, clone) - 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, @@ -365,15 +298,6 @@ function deepClone( } } - const symbolProps = Object.getOwnPropertySymbols(obj) - for (const sym of symbolProps) { - clone[sym] = deepClone( - (obj as Record)[sym], - visited, - detach, - ) - } - return clone as T } @@ -384,82 +308,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 +350,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 +389,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 +405,21 @@ 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. 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], + ) + ) { return false } } - - // All assigned properties match their original values return true } @@ -618,286 +435,274 @@ 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) } } } - // 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 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] - // 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 + } - // If it's a getter, return the value directly - const desc = Object.getOwnPropertyDescriptor(ptarget, prop) - 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 + } - // 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, - ) - } - - // 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) - } - - // Handle array Symbol.iterator for for...of loops - if (prop === Symbol.iterator) { - return createArrayIteratorHandler( - changeTracker, - memoizedCreateChangeProxy, - ) - } - } + // 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. 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 + + 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 + } - // For Map and Set methods that modify the collection - if (ptarget instanceof Map || ptarget instanceof Set) { - const methodName = prop.toString() + // 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, + receiver, + ) + } - const resolveValue = (entry: unknown) => { - const raw = unwrapDraft(entry) - return raw !== null && typeof raw === `object` - ? (valueCopies.get(raw) ?? raw) - : raw - } + 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 + // reduce seed. + return value.bind(receiver) + } - 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 - } - } + // For Map and Set methods that modify the collection + if (ptarget instanceof Map || ptarget instanceof Set) { + 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, + markChanged, receiver, - memoizedCreateChangeProxy, ) - 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] - - // 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 + // 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), + } - // Track that this property was assigned - store using the actual property (symbol or string) - changeTracker.assigned_[prop.toString()] = true + // Create (or reuse) the proxy for the nested object + return memoizedCreateChangeProxy(value, nestedParent).proxy + } - // Mark this object and its ancestors as modified - markChanged(changeTracker) - } - } + return value + }, - return true - }, + set(_sobj, prop, value) { + const currentValue = changeTracker.copy_[prop as keyof T] - 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) + // 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) + } + 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] + + // 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 + 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 { @@ -938,14 +743,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/src/utils.ts b/packages/db/src/utils.ts index 1bca8e9a9c..967650ea9d 100644 --- a/packages/db/src/utils.ts +++ b/packages/db/src/utils.ts @@ -30,6 +30,10 @@ 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 +} + function enumerableOwnKeys(value: object): Array { const keys: Array = Object.keys(value) for (const key of Object.getOwnPropertySymbols(value)) { @@ -41,11 +45,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 +76,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 @@ -83,10 +96,14 @@ 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) - }) + // 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, true) + : b.has(key) && deepEqualsInternal(val, b.get(key), visited), + ) visited.delete(a) return result @@ -109,6 +126,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) @@ -163,10 +188,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 @@ -201,9 +234,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,11 +249,17 @@ 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`) { + // 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) + const plain = isPlainPrototype(prototype) + const plainB = isPlainPrototype(prototypeB) + if (prototype !== prototypeB && !plain && !plainB) return false + // Check for circular references if (visited.has(a)) { return visited.get(a) === b @@ -238,11 +277,19 @@ 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) && !(plain && plainB)) { + visited.delete(a) + return a instanceof URL && a.href === b.href + } + // Check if all keys exist in both objects and their values are equal 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) diff --git a/packages/db/tests/proxy-detachment-contract.test.ts b/packages/db/tests/proxy-detachment-contract.test.ts index eb27dc9518..cece3ee639 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-iteration-contract.test.ts b/packages/db/tests/proxy-iteration-contract.test.ts index 974c3e60b3..a8cf2f1cfc 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) => { 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 0000000000..40c4e3a6e3 --- /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-revert-oracle.property.test.ts b/packages/db/tests/proxy-revert-oracle.property.test.ts new file mode 100644 index 0000000000..87d209f5ce --- /dev/null +++ b/packages/db/tests/proxy-revert-oracle.property.test.ts @@ -0,0 +1,1129 @@ +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. + * 7. Typed arrays compare by class and by elements under rule 1. + * 8. URLs compare by `href`. + * 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 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: 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: + * - 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. + * - 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. + */ + +// --------------------------------------------------------------------------- +// 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, MapValue]> } + | { k: `set`; values: Array } + | { k: `array`; items: Array } + | { 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 } + | { k: `point`; 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) + +// 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 + +// 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 + } +} + +// 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] +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, mapValue(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))] + 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)] + 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. + return [`secret`, spec.v] + } +} + +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.map(([key, v]) => [ + key, + isSetValue(v) ? new Set(v.set) : v, + ]), + ) + 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 })) + 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 + } + case `url`: + return new URL(`https://example.com/${spec.path}`) + case `secret`: + return new Secret(spec.v) + case `point`: + return new Point(spec.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`), + 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 }) + .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 })), + 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 })), + ), + 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( + { weight: 9, arbitrary: specArb }, + fc.constantFrom(1, 2).map((v): Spec => ({ k: `secret`, v })), +) + +type Op = + | { op: `set`; field: Field; value: Spec } + // `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` } + | { 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: writtenArb, + }), + { + weight: 3, + arbitrary: fc.record({ + op: fc.constant(`revert` as const), + field: fc.constantFrom(...FIELDS), + same: fc.boolean(), + }), + }, + 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, + }), + }, + { + 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), + 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, + }), + }, +) + +// 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( + 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] } + }) + +// 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 +// 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, original: Root, op: Op): boolean { + const current = state[op.field] + switch (op.op) { + case `set`: + return true + case `revert`: + // 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`: + // 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 + } +} + +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 `nestedDelete`: { + const obj = { ...(current as Extract) } + delete obj[op.key] + next[op.field] = obj + 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 } + 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 `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 + 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].map(([key, v]): [string, MapValue] => [ + key, + v instanceof Set ? { set: [...v] } : v, + ]), + }) + : `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` + case `typed`: { + const ctor = typedKind(value) + return ctor === undefined + ? `not a typed array` + : encode({ + k: `typed`, + ctor, + 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` + case `point`: + // A Point, or the plain object a draft reads for one. + return readSpec(value, { k: `obj`, a: like.a }) + } +} + +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 generated of ops) { + const op = resolve(state, original, generated) + 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] = op.same ? row[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 }), + }) + }) + 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 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, + ...(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 + // 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 + // 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, 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]) + ) + effectiveReverts++ + state = step(state, original, op) + } + const changed = expectedChanges(original, state).size + 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] + 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`, + `nestedDelete`, + `revert`, + `set`, + ]) + 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 + // 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]>([ + [ + `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` }, + ], + }, + ], + [ + `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` }, + ], + }, + ], + [ + `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`, + { + 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`, + { + 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], + ], + }, + }, + ], + }, + ], + [ + `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`, + { + 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/proxy.test.ts b/packages/db/tests/proxy.test.ts index 2678cad546..430357e371 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`, @@ -2430,3 +2459,282 @@ 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, + ], + [ + `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) => [ + 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) => { + 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) + }) +}) + +/** + * `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 } }) + // One getter for both rows, so their descriptors compare equal. + const getTwo = () => 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 } })], + [ + `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, + 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) ?? {} + // 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, + read: JSON.stringify(row[key]), + } + } + + it.each(definitions)(`%s`, (_name, 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, with the value + // the native row now reads. + const { key } = define(proxy) + const enumerable = expected.rest.enumerable === true + expect(getChanges()).toEqual(enumerable ? { [key]: native[key] } : {}) + }) +}) + +/** + * 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({}) + }) + + // 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) {} + } + 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 723ec21c37..4884e8ff7c 100644 --- a/packages/db/tests/utils.property.test.ts +++ b/packages/db/tests/utils.property.test.ts @@ -939,6 +939,164 @@ 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.constantFrom(`a`, `b`), + fc.constantFrom(`a`, `b`), + fc.constantFrom(1, 2), + fc.constantFrom(1, 2), + ])( + `objects compare by class and keys, 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) {} + } + class Other { + 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, 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: 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) + 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, + }), + ])( + `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, + 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 }),