foottrace 0.0.0-stage → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +73 -2
- package/dist/esm/index.d.ts +27 -0
- package/dist/esm/index.js +12 -0
- package/dist/esm/lib/capture/freeze.d.ts +91 -0
- package/dist/esm/lib/capture/freeze.js +313 -0
- package/dist/esm/lib/capture/ownData.d.ts +18 -0
- package/dist/esm/lib/capture/ownData.js +35 -0
- package/dist/esm/lib/capture/valueKinds.d.ts +57 -0
- package/dist/esm/lib/capture/valueKinds.js +141 -0
- package/dist/esm/lib/ids/runtimeStageId.d.ts +173 -0
- package/dist/esm/lib/ids/runtimeStageId.js +193 -0
- package/dist/esm/lib/memory/EventLog.d.ts +105 -0
- package/dist/esm/lib/memory/EventLog.js +147 -0
- package/dist/esm/lib/memory/RecordFrame.d.ts +232 -0
- package/dist/esm/lib/memory/RecordFrame.js +295 -0
- package/dist/esm/lib/memory/SharedMemory.d.ts +46 -0
- package/dist/esm/lib/memory/SharedMemory.js +87 -0
- package/dist/esm/lib/memory/TransactionBuffer.d.ts +521 -0
- package/dist/esm/lib/memory/TransactionBuffer.js +946 -0
- package/dist/esm/lib/memory/admission.d.ts +81 -0
- package/dist/esm/lib/memory/admission.js +152 -0
- package/dist/esm/lib/memory/backtrack.d.ts +286 -0
- package/dist/esm/lib/memory/backtrack.js +456 -0
- package/dist/esm/lib/memory/commitLogUtils.d.ts +217 -0
- package/dist/esm/lib/memory/commitLogUtils.js +383 -0
- package/dist/esm/lib/memory/deltaEncoding.d.ts +66 -0
- package/dist/esm/lib/memory/deltaEncoding.js +121 -0
- package/dist/esm/lib/memory/equality.d.ts +93 -0
- package/dist/esm/lib/memory/equality.js +261 -0
- package/dist/esm/lib/memory/eventPosition.d.ts +18 -0
- package/dist/esm/lib/memory/eventPosition.js +1 -0
- package/dist/esm/lib/memory/honesty.d.ts +93 -0
- package/dist/esm/lib/memory/honesty.js +91 -0
- package/dist/esm/lib/memory/keyPaths.d.ts +125 -0
- package/dist/esm/lib/memory/keyPaths.js +226 -0
- package/dist/esm/lib/memory/logModel.d.ts +76 -0
- package/dist/esm/lib/memory/logModel.js +363 -0
- package/dist/esm/lib/memory/merge.d.ts +26 -0
- package/dist/esm/lib/memory/merge.js +51 -0
- package/dist/esm/lib/memory/pathOps.d.ts +118 -0
- package/dist/esm/lib/memory/pathOps.js +292 -0
- package/dist/esm/lib/memory/paths.d.ts +56 -0
- package/dist/esm/lib/memory/paths.js +60 -0
- package/dist/esm/lib/memory/placeholders.d.ts +39 -0
- package/dist/esm/lib/memory/placeholders.js +39 -0
- package/dist/esm/lib/memory/recordCommit.d.ts +45 -0
- package/dist/esm/lib/memory/recordCommit.js +60 -0
- package/dist/esm/lib/memory/scrub.d.ts +52 -0
- package/dist/esm/lib/memory/scrub.js +72 -0
- package/dist/esm/lib/memory/types.d.ts +209 -0
- package/dist/esm/lib/memory/types.js +9 -0
- package/dist/esm/lib/memory/utils.d.ts +73 -0
- package/dist/esm/lib/memory/utils.js +150 -0
- package/dist/esm/lib/memory/verbs.d.ts +333 -0
- package/dist/esm/lib/memory/verbs.js +426 -0
- package/dist/esm/lib/recorder/CommitRangeIndex.d.ts +147 -0
- package/dist/esm/lib/recorder/CommitRangeIndex.js +206 -0
- package/dist/esm/lib/slice/elementProvenance.d.ts +81 -0
- package/dist/esm/lib/slice/elementProvenance.js +189 -0
- package/dist/esm/lib/slice/forwardSliceForKey.d.ts +88 -0
- package/dist/esm/lib/slice/forwardSliceForKey.js +242 -0
- package/dist/esm/lib/slice/index.d.ts +26 -0
- package/dist/esm/lib/slice/index.js +25 -0
- package/dist/esm/lib/slice/keyIndex.d.ts +137 -0
- package/dist/esm/lib/slice/keyIndex.js +292 -0
- package/dist/esm/lib/slice/keyTimeline.d.ts +49 -0
- package/dist/esm/lib/slice/keyTimeline.js +112 -0
- package/dist/esm/lib/slice/keysReadSources.d.ts +51 -0
- package/dist/esm/lib/slice/keysReadSources.js +94 -0
- package/dist/esm/lib/slice/serialize.d.ts +78 -0
- package/dist/esm/lib/slice/serialize.js +277 -0
- package/dist/esm/lib/slice/sliceForKey.d.ts +40 -0
- package/dist/esm/lib/slice/sliceForKey.js +78 -0
- package/dist/esm/lib/slice/types.d.ts +567 -0
- package/dist/esm/lib/slice/types.js +24 -0
- package/dist/esm/lib/time-travel/axis.d.ts +119 -0
- package/dist/esm/lib/time-travel/axis.js +178 -0
- package/dist/esm/lib/time-travel/bundles.d.ts +77 -0
- package/dist/esm/lib/time-travel/bundles.js +135 -0
- package/dist/esm/lib/time-travel/chain.d.ts +93 -0
- package/dist/esm/lib/time-travel/chain.js +187 -0
- package/dist/esm/lib/time-travel/commitStops.d.ts +55 -0
- package/dist/esm/lib/time-travel/commitStops.js +201 -0
- package/dist/esm/lib/time-travel/index.d.ts +22 -0
- package/dist/esm/lib/time-travel/index.js +20 -0
- package/dist/esm/lib/time-travel/stateAt.d.ts +102 -0
- package/dist/esm/lib/time-travel/stateAt.js +152 -0
- package/dist/esm/lib/time-travel/tagStops.d.ts +72 -0
- package/dist/esm/lib/time-travel/tagStops.js +101 -0
- package/dist/esm/lib/time-travel/timeTravel.d.ts +70 -0
- package/dist/esm/lib/time-travel/timeTravel.js +359 -0
- package/dist/esm/lib/time-travel/types.d.ts +470 -0
- package/dist/esm/lib/time-travel/types.js +22 -0
- package/dist/esm/package.json +1 -0
- package/dist/esm/paths.d.ts +4 -0
- package/dist/esm/paths.js +4 -0
- package/dist/esm/write.d.ts +49 -0
- package/dist/esm/write.js +47 -0
- package/dist/index.js +67 -0
- package/dist/lib/capture/freeze.js +319 -0
- package/dist/lib/capture/ownData.js +40 -0
- package/dist/lib/capture/valueKinds.js +145 -0
- package/dist/lib/ids/runtimeStageId.js +209 -0
- package/dist/lib/memory/EventLog.js +151 -0
- package/dist/lib/memory/RecordFrame.js +299 -0
- package/dist/lib/memory/SharedMemory.js +91 -0
- package/dist/lib/memory/TransactionBuffer.js +950 -0
- package/dist/lib/memory/admission.js +158 -0
- package/dist/lib/memory/backtrack.js +462 -0
- package/dist/lib/memory/commitLogUtils.js +398 -0
- package/dist/lib/memory/deltaEncoding.js +127 -0
- package/dist/lib/memory/equality.js +265 -0
- package/dist/lib/memory/eventPosition.js +2 -0
- package/dist/lib/memory/honesty.js +94 -0
- package/dist/lib/memory/keyPaths.js +242 -0
- package/dist/lib/memory/logModel.js +371 -0
- package/dist/lib/memory/merge.js +55 -0
- package/dist/lib/memory/pathOps.js +308 -0
- package/dist/lib/memory/paths.js +66 -0
- package/dist/lib/memory/placeholders.js +42 -0
- package/dist/lib/memory/recordCommit.js +64 -0
- package/dist/lib/memory/scrub.js +77 -0
- package/dist/lib/memory/types.js +10 -0
- package/dist/lib/memory/utils.js +169 -0
- package/dist/lib/memory/verbs.js +443 -0
- package/dist/lib/recorder/CommitRangeIndex.js +210 -0
- package/dist/lib/slice/elementProvenance.js +194 -0
- package/dist/lib/slice/forwardSliceForKey.js +246 -0
- package/dist/lib/slice/index.js +42 -0
- package/dist/lib/slice/keyIndex.js +312 -0
- package/dist/lib/slice/keyTimeline.js +116 -0
- package/dist/lib/slice/keysReadSources.js +100 -0
- package/dist/lib/slice/serialize.js +285 -0
- package/dist/lib/slice/sliceForKey.js +82 -0
- package/dist/lib/slice/types.js +25 -0
- package/dist/lib/time-travel/axis.js +183 -0
- package/dist/lib/time-travel/bundles.js +143 -0
- package/dist/lib/time-travel/chain.js +192 -0
- package/dist/lib/time-travel/commitStops.js +205 -0
- package/dist/lib/time-travel/index.js +31 -0
- package/dist/lib/time-travel/stateAt.js +159 -0
- package/dist/lib/time-travel/tagStops.js +105 -0
- package/dist/lib/time-travel/timeTravel.js +363 -0
- package/dist/lib/time-travel/types.js +23 -0
- package/dist/paths.js +14 -0
- package/dist/types/index.d.ts +27 -0
- package/dist/types/lib/capture/freeze.d.ts +91 -0
- package/dist/types/lib/capture/ownData.d.ts +18 -0
- package/dist/types/lib/capture/valueKinds.d.ts +57 -0
- package/dist/types/lib/ids/runtimeStageId.d.ts +173 -0
- package/dist/types/lib/memory/EventLog.d.ts +105 -0
- package/dist/types/lib/memory/RecordFrame.d.ts +232 -0
- package/dist/types/lib/memory/SharedMemory.d.ts +46 -0
- package/dist/types/lib/memory/TransactionBuffer.d.ts +521 -0
- package/dist/types/lib/memory/admission.d.ts +81 -0
- package/dist/types/lib/memory/backtrack.d.ts +286 -0
- package/dist/types/lib/memory/commitLogUtils.d.ts +217 -0
- package/dist/types/lib/memory/deltaEncoding.d.ts +66 -0
- package/dist/types/lib/memory/equality.d.ts +93 -0
- package/dist/types/lib/memory/eventPosition.d.ts +18 -0
- package/dist/types/lib/memory/honesty.d.ts +93 -0
- package/dist/types/lib/memory/keyPaths.d.ts +125 -0
- package/dist/types/lib/memory/logModel.d.ts +76 -0
- package/dist/types/lib/memory/merge.d.ts +26 -0
- package/dist/types/lib/memory/pathOps.d.ts +118 -0
- package/dist/types/lib/memory/paths.d.ts +56 -0
- package/dist/types/lib/memory/placeholders.d.ts +39 -0
- package/dist/types/lib/memory/recordCommit.d.ts +45 -0
- package/dist/types/lib/memory/scrub.d.ts +52 -0
- package/dist/types/lib/memory/types.d.ts +209 -0
- package/dist/types/lib/memory/utils.d.ts +73 -0
- package/dist/types/lib/memory/verbs.d.ts +333 -0
- package/dist/types/lib/recorder/CommitRangeIndex.d.ts +147 -0
- package/dist/types/lib/slice/elementProvenance.d.ts +81 -0
- package/dist/types/lib/slice/forwardSliceForKey.d.ts +88 -0
- package/dist/types/lib/slice/index.d.ts +26 -0
- package/dist/types/lib/slice/keyIndex.d.ts +137 -0
- package/dist/types/lib/slice/keyTimeline.d.ts +49 -0
- package/dist/types/lib/slice/keysReadSources.d.ts +51 -0
- package/dist/types/lib/slice/serialize.d.ts +78 -0
- package/dist/types/lib/slice/sliceForKey.d.ts +40 -0
- package/dist/types/lib/slice/types.d.ts +567 -0
- package/dist/types/lib/time-travel/axis.d.ts +119 -0
- package/dist/types/lib/time-travel/bundles.d.ts +77 -0
- package/dist/types/lib/time-travel/chain.d.ts +93 -0
- package/dist/types/lib/time-travel/commitStops.d.ts +55 -0
- package/dist/types/lib/time-travel/index.d.ts +22 -0
- package/dist/types/lib/time-travel/stateAt.d.ts +102 -0
- package/dist/types/lib/time-travel/tagStops.d.ts +72 -0
- package/dist/types/lib/time-travel/timeTravel.d.ts +70 -0
- package/dist/types/lib/time-travel/types.d.ts +470 -0
- package/dist/types/paths.d.ts +4 -0
- package/dist/types/write.d.ts +49 -0
- package/dist/write.js +56 -0
- package/package.json +48 -4
|
@@ -0,0 +1,946 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TransactionBuffer — Per-stage STAGING buffer for state mutations
|
|
3
|
+
*
|
|
4
|
+
* What it IS: a staging buffer with read-your-writes and net-change commits.
|
|
5
|
+
* - Changes are staged here during stage execution and flushed to
|
|
6
|
+
* SharedMemory in ONE batch per stage (`commit()`) — other stages and
|
|
7
|
+
* parallel siblings never observe a stage's half-finished writes.
|
|
8
|
+
* - Read-after-write consistency within a stage — a stage sees its own
|
|
9
|
+
* staged writes immediately.
|
|
10
|
+
* - `commit()` records the stage's NET change (see {@link commit}), plus an
|
|
11
|
+
* operation trace for deterministic replay.
|
|
12
|
+
*
|
|
13
|
+
* What it is NOT: a rollback mechanism. Despite the name, there is no
|
|
14
|
+
* abort/rollback path — when a stage THROWS, the engine still commits
|
|
15
|
+
* everything staged so far before re-throwing (commit-on-error in
|
|
16
|
+
* `FlowchartTraverser`). That is deliberate: the audit trail must record
|
|
17
|
+
* what the failing stage changed. Do not rely on "stage failed → its
|
|
18
|
+
* writes vanished".
|
|
19
|
+
*
|
|
20
|
+
* The patch trees hold REFERENCES until commit (9.23.0). A `set` stores the
|
|
21
|
+
* caller's own value in `overwritePatch` exactly as it always did in
|
|
22
|
+
* `workingCopy`; the copy the record needs is taken ONCE per surviving path
|
|
23
|
+
* when the payload leaves the buffer (`toChangeOnlyPayload` /
|
|
24
|
+
* `toDeltaPayload` — every value they emit passes through `structuredClone`
|
|
25
|
+
* there). The law "the record never aliases a caller's object" is kept at
|
|
26
|
+
* the commit boundary instead of at every write, which is what makes N
|
|
27
|
+
* writes to one path O(N) instead of O(N × size). Consequence, by design: a
|
|
28
|
+
* caller who mutates its own object AFTER the write and BEFORE the stage
|
|
29
|
+
* ends commits the value as the stage read it back — the record agrees with
|
|
30
|
+
* the stage (CLAUDE.md landmine 3's first bite, closed). The one place the
|
|
31
|
+
* engine itself edits THROUGH a staged value — a later nested op under a
|
|
32
|
+
* `set` path — detaches that value first ({@link detachHeldAncestors}), so
|
|
33
|
+
* `workingCopy` and `overwritePatch` never share a container the engine
|
|
34
|
+
* mutates on one side only.
|
|
35
|
+
*
|
|
36
|
+
* COPY-ON-WRITE (9.29.0 — docs/design/2026-10-copy-on-write-commit.md). The
|
|
37
|
+
* buffer no longer clones the committed state it starts from. `baseSnapshot`
|
|
38
|
+
* IS the committed generation the stage first touched (held by reference —
|
|
39
|
+
* committed state is immutable-after-swap), and `workingCopy` starts as a
|
|
40
|
+
* copy of its ROOT only: each write copies the containers on its own path
|
|
41
|
+
* ({@link ownSpine}), and the first READ after the stage's first write of a
|
|
42
|
+
* container still shared with committed state takes a private deep copy of
|
|
43
|
+
* it ({@link privatise}) — what the whole-state clone used to give every
|
|
44
|
+
* read, so an in-place edit of a read value stays private exactly as it did.
|
|
45
|
+
* A read the working copy cannot answer is served from live state by the
|
|
46
|
+
* caller, as before; the buffer then replaces that path of its diff base
|
|
47
|
+
* with a private copy ({@link detachBase}), as the clone had it.
|
|
48
|
+
*
|
|
49
|
+
* THE ADMITTED RECORD (9.30.0 — docs/design/2026-10-admitted-record.md). A
|
|
50
|
+
* commit is admitted only if its bundle folds back to the stage's
|
|
51
|
+
* read-your-writes view at every path the stage touched (and at every
|
|
52
|
+
* container its writes created on the way there, below the stage's address).
|
|
53
|
+
* Both encoders commit the value the stage read: a compact row — a `merge`
|
|
54
|
+
* delta, an `append` tail — is kept where it provably folds back; a family of
|
|
55
|
+
* rows that does not is recorded as `set` rows of the values the stage read
|
|
56
|
+
* ({@link admit}). Before 9.30.0 the one accumulated merge delta per path was
|
|
57
|
+
* replayed at every `merge` row of that path, which lied across a hard write
|
|
58
|
+
* (`$update(k,{x}); k = {y}; $update(k,{z})` committed `{y, x, z}` for a
|
|
59
|
+
* read-back of `{y, z}`), an `[]` clear, a kind change or an array union
|
|
60
|
+
* deduplicated by reference. Verified when the stage staged a `merge` or a
|
|
61
|
+
* nested op; a stage that staged only `set` / `delete` of keys directly under
|
|
62
|
+
* its address is coherent by construction — both trees receive the same
|
|
63
|
+
* write — and is admitted without the fold.
|
|
64
|
+
*/
|
|
65
|
+
import { putOwn } from '../capture/ownData.js';
|
|
66
|
+
import { addressDepthOf, lossyFamilies } from './admission.js';
|
|
67
|
+
import { emitInFamilyOrder, groupIntoFamilies, pushValueRow } from './deltaEncoding.js';
|
|
68
|
+
import { relation } from './keyPaths.js';
|
|
69
|
+
import { adopt, isContainer, nativeGet as _get, nativeSet as _set, own, ownChild, ownedRootOf, ownSpine, shallowCopy, } from './pathOps.js';
|
|
70
|
+
import { deepEqual, deepSmartMerge, DELIM, dryFold, normalisePath } from './utils.js';
|
|
71
|
+
export class TransactionBuffer {
|
|
72
|
+
/**
|
|
73
|
+
* The net-change diff base: the committed generation the stage first
|
|
74
|
+
* touched, by reference — except where {@link detachBase} replaced a path
|
|
75
|
+
* of it with a private copy (its root and the containers on the way are
|
|
76
|
+
* then copies too, owned by {@link baseOwned}).
|
|
77
|
+
*/
|
|
78
|
+
baseSnapshot;
|
|
79
|
+
workingCopy;
|
|
80
|
+
overwritePatch = {};
|
|
81
|
+
updatePatch = {};
|
|
82
|
+
opTrace = [];
|
|
83
|
+
redactedPaths = new Set();
|
|
84
|
+
/**
|
|
85
|
+
* Paths whose `overwritePatch` value is the caller's own object (9.23.0):
|
|
86
|
+
* a `set` of a container stores the reference, and this is the ledger of
|
|
87
|
+
* which ones are still shared with the caller (and with `workingCopy`).
|
|
88
|
+
* Consulted only by the engine's nested ops — see
|
|
89
|
+
* {@link detachHeldAncestors}; the commit payload clones every surviving
|
|
90
|
+
* value anyway and never asks. Cleared with the rest at `commit()`.
|
|
91
|
+
*/
|
|
92
|
+
heldRefs = new Set();
|
|
93
|
+
/** Commit-value encoding policy (#13c-B). `'full'` = historical bytes. */
|
|
94
|
+
commitValues;
|
|
95
|
+
/** Per-write read-provenance source (#P1). When set (the
|
|
96
|
+
* `writeProvenance: 'reads-prefix'` dial), every staged op snapshots the
|
|
97
|
+
* keys tracked-read so far — the temporal-prefix attribution consumed by
|
|
98
|
+
* causal slicing. Undefined (default) = zero cost, byte-identical ops. */
|
|
99
|
+
readKeysProvider;
|
|
100
|
+
/**
|
|
101
|
+
* The containers in `workingCopy` this buffer CREATED: its root copy, the
|
|
102
|
+
* path copies its writes made, the merge results it built, and every
|
|
103
|
+
* container of the private copies its reads took. A write edits these in
|
|
104
|
+
* place. Everything else in `workingCopy` is SHARED — with the committed
|
|
105
|
+
* generation the stage first touched, or with a value the stage staged —
|
|
106
|
+
* and is copied before a write passes through it ({@link ownSpine}).
|
|
107
|
+
*/
|
|
108
|
+
owned = new WeakSet();
|
|
109
|
+
/**
|
|
110
|
+
* Container values the stage staged with {@link set} — its own objects,
|
|
111
|
+
* held by reference (9.23.0). A read hands them back as they are, exactly
|
|
112
|
+
* as the private working copy always did, and never privatises them: a
|
|
113
|
+
* private copy of a staged value would leave `workingCopy` and
|
|
114
|
+
* `overwritePatch` disagreeing about a value the stage wrote.
|
|
115
|
+
*/
|
|
116
|
+
staged = new WeakSet();
|
|
117
|
+
/**
|
|
118
|
+
* Owned containers with no container still shared with committed state
|
|
119
|
+
* anywhere below them (a private deep copy, a merge result built on a
|
|
120
|
+
* private base, a path copy whose shared children were privatised). A read
|
|
121
|
+
* that lands on one needs no walk — see {@link privatiseBelow}.
|
|
122
|
+
*/
|
|
123
|
+
privateTrees = new WeakSet();
|
|
124
|
+
/**
|
|
125
|
+
* The containers of {@link baseSnapshot} that {@link detachBase} created:
|
|
126
|
+
* its root copy and the copies on the way to a detached path. Allocated
|
|
127
|
+
* only by the first detach — the in-contract stage never pays for it.
|
|
128
|
+
*/
|
|
129
|
+
baseOwned;
|
|
130
|
+
/**
|
|
131
|
+
* The private deep copies {@link detachBase} put into the diff base: nothing
|
|
132
|
+
* at or below one is committed state, so a later detach inside it is a no-op.
|
|
133
|
+
*/
|
|
134
|
+
detachedBase;
|
|
135
|
+
/**
|
|
136
|
+
* Where the stage writes (9.30.0): a path prefix its frame computed and
|
|
137
|
+
* hands over as data (C2 — for the engine, the frame's run namespace, or
|
|
138
|
+
* `[]` for the root). Its containers are the stage's ADDRESS, not a value
|
|
139
|
+
* the stage reads (every read it makes is a key below it), so a shell a
|
|
140
|
+
* write leaves there is no part of the read-back the record must hold.
|
|
141
|
+
*/
|
|
142
|
+
address;
|
|
143
|
+
/** `merge` ops staged since the last commit — see {@link admit}. */
|
|
144
|
+
stagedMerges = 0;
|
|
145
|
+
/** Ops staged deeper than one key below the stage's address — see {@link admit}. */
|
|
146
|
+
nestedOps = 0;
|
|
147
|
+
constructor(base, commitValues = 'full', readKeysProvider, address = []) {
|
|
148
|
+
// `base` is the committed generation the stage first touched —
|
|
149
|
+
// immutable-after-swap, the invariant the first-touch view already rests
|
|
150
|
+
// on — so the diff base is held by REFERENCE, and the working copy starts
|
|
151
|
+
// as a copy of its ROOT only. Before 9.29.0 these two lines were two
|
|
152
|
+
// `structuredClone`s of the whole state, paid by every stage that wrote.
|
|
153
|
+
this.baseSnapshot = base;
|
|
154
|
+
this.workingCopy = ownedRootOf(base, this.owned);
|
|
155
|
+
this.commitValues = commitValues;
|
|
156
|
+
this.readKeysProvider = readKeysProvider;
|
|
157
|
+
this.address = address;
|
|
158
|
+
}
|
|
159
|
+
/** Stamp the current read prefix onto a staged op — only when the
|
|
160
|
+
* provenance dial is on (provider present), so the default path allocates
|
|
161
|
+
* nothing and commit bundles stay byte-identical. */
|
|
162
|
+
stampReadKeys(op) {
|
|
163
|
+
if (this.readKeysProvider)
|
|
164
|
+
op.readKeys = this.readKeysProvider();
|
|
165
|
+
return op;
|
|
166
|
+
}
|
|
167
|
+
/** Stage an op into `opTrace`, counting what decides whether {@link admit} must fold. */
|
|
168
|
+
stage(path, key, verb) {
|
|
169
|
+
if (verb === 'merge')
|
|
170
|
+
this.stagedMerges++;
|
|
171
|
+
if (path.length - addressDepthOf(path, this.address) > 1)
|
|
172
|
+
this.nestedOps++;
|
|
173
|
+
this.opTrace.push(this.stampReadKeys({ path: key, verb }));
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Hard overwrite at the specified path. Stores the REFERENCE in both trees
|
|
177
|
+
* (9.23.0) — the record's copy is taken once, at commit; see the header.
|
|
178
|
+
*/
|
|
179
|
+
set(path, value, shouldRedact = false) {
|
|
180
|
+
const key = normalisePath(path);
|
|
181
|
+
this.detachHeldAncestors(path);
|
|
182
|
+
ownSpine(this.workingCopy, path, this.owned);
|
|
183
|
+
_set(this.workingCopy, path, value);
|
|
184
|
+
_set(this.overwritePatch, path, value);
|
|
185
|
+
if (isContainer(value)) {
|
|
186
|
+
this.heldRefs.add(key);
|
|
187
|
+
this.staged.add(value);
|
|
188
|
+
}
|
|
189
|
+
else {
|
|
190
|
+
this.heldRefs.delete(key);
|
|
191
|
+
}
|
|
192
|
+
if (shouldRedact) {
|
|
193
|
+
this.redactedPaths.add(key);
|
|
194
|
+
}
|
|
195
|
+
this.stage(path, key, 'set');
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* A nested op is about to write THROUGH a path whose `overwritePatch`
|
|
199
|
+
* value is a held reference (9.23.0). Today's law is that the two trees
|
|
200
|
+
* receive the same nested `set`/`delete` mutations but ONLY `workingCopy`
|
|
201
|
+
* receives a nested `merge`'s result (`updatePatch` gets the delta) — so a
|
|
202
|
+
* container shared between the trees would leak the merged value into the
|
|
203
|
+
* overwrite payload, and a shared container is also the caller's own
|
|
204
|
+
* object, which the engine must not edit on the record's behalf. Replacing
|
|
205
|
+
* the held ancestor with its clone in `overwritePatch` restores exactly
|
|
206
|
+
* the private tree the per-write clone used to give it, paid once per
|
|
207
|
+
* ancestor and only on the nested-op path the public surface never takes
|
|
208
|
+
* (the scope proxy writes ROOT keys). `workingCopy` keeps the reference,
|
|
209
|
+
* as it always did.
|
|
210
|
+
*/
|
|
211
|
+
detachHeldAncestors(path) {
|
|
212
|
+
if (this.heldRefs.size === 0)
|
|
213
|
+
return;
|
|
214
|
+
for (let i = 1; i < path.length; i++) {
|
|
215
|
+
const ancestor = path.slice(0, i);
|
|
216
|
+
const key = normalisePath(ancestor);
|
|
217
|
+
if (!this.heldRefs.has(key))
|
|
218
|
+
continue;
|
|
219
|
+
const held = _get(this.overwritePatch, ancestor);
|
|
220
|
+
if (held !== null && typeof held === 'object')
|
|
221
|
+
_set(this.overwritePatch, ancestor, structuredClone(held));
|
|
222
|
+
this.heldRefs.delete(key);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Explicit key deletion at the specified path (#13c-B; absorbs backlog B8).
|
|
227
|
+
*
|
|
228
|
+
* Stages EXACTLY the same buffer mutations as `set(path, undefined)` —
|
|
229
|
+
* `workingCopy`/`overwritePatch` get an own `undefined` at the path (the
|
|
230
|
+
* historical flattening, preserving read behavior and the dedup diff base
|
|
231
|
+
* across modes) — but records the op verb as `'delete'`. At commit:
|
|
232
|
+
* `'full'` mode maps it back to a `'set'` trace entry (byte-identical to
|
|
233
|
+
* today); `'delta'` mode emits a real `'delete'` entry whose replay
|
|
234
|
+
* REMOVES the key instead of leaving `key: undefined` behind.
|
|
235
|
+
*
|
|
236
|
+
* The net-change filter reads that staged own `undefined` as ABSENT
|
|
237
|
+
* (`deepEqual`, 9.19.1) — so under `'delta'`, whose committed state really
|
|
238
|
+
* lacks the key, deleting an absent key inside a container the stage also
|
|
239
|
+
* re-wrote is the no-op it is, not a `set` of the whole container.
|
|
240
|
+
*/
|
|
241
|
+
delete(path, shouldRedact = false) {
|
|
242
|
+
const key = normalisePath(path);
|
|
243
|
+
this.detachHeldAncestors(path);
|
|
244
|
+
ownSpine(this.workingCopy, path, this.owned);
|
|
245
|
+
_set(this.workingCopy, path, undefined);
|
|
246
|
+
_set(this.overwritePatch, path, undefined);
|
|
247
|
+
this.heldRefs.delete(key);
|
|
248
|
+
if (shouldRedact) {
|
|
249
|
+
this.redactedPaths.add(key);
|
|
250
|
+
}
|
|
251
|
+
this.stage(path, key, 'delete');
|
|
252
|
+
}
|
|
253
|
+
/** Deep union merge at the specified path. `deepSmartMerge` builds fresh
|
|
254
|
+
* containers, so neither tree ever holds the merge INPUT by reference;
|
|
255
|
+
* the nested-op detach guards the ancestor it writes INTO.
|
|
256
|
+
*
|
|
257
|
+
* The value merged INTO is made private first (`privatise`), as the
|
|
258
|
+
* whole-state clone made it before 9.29.0. Not only for the reads that
|
|
259
|
+
* follow: `deepSmartMerge` unions arrays BY REFERENCE, so whether an
|
|
260
|
+
* element the stage passes in is "already there" depends on which objects
|
|
261
|
+
* the base holds — a committed array would dedup an element the stage
|
|
262
|
+
* read from it before its first write, where the private copy (and
|
|
263
|
+
* 9.28.0) appends it. */
|
|
264
|
+
merge(path, value, shouldRedact = false) {
|
|
265
|
+
const key = normalisePath(path);
|
|
266
|
+
this.detachHeldAncestors(path);
|
|
267
|
+
this.privatise(path);
|
|
268
|
+
const existing = _get(this.workingCopy, path) ?? {};
|
|
269
|
+
const merged = deepSmartMerge(existing, value);
|
|
270
|
+
ownSpine(this.workingCopy, path, this.owned);
|
|
271
|
+
own(merged, this.owned);
|
|
272
|
+
if (isContainer(merged))
|
|
273
|
+
this.privateTrees.add(merged);
|
|
274
|
+
_set(this.workingCopy, path, merged);
|
|
275
|
+
_set(this.updatePatch, path, deepSmartMerge(_get(this.updatePatch, path) ?? {}, value));
|
|
276
|
+
if (shouldRedact) {
|
|
277
|
+
this.redactedPaths.add(key);
|
|
278
|
+
}
|
|
279
|
+
this.stage(path, key, 'merge');
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* Field-level redaction (9.19.0): mark dot-paths INSIDE the value staged at
|
|
283
|
+
* `path` as secret, so `scrubPatch` scrubs them in the commit log and the
|
|
284
|
+
* mirror the same way a whole-key redaction is scrubbed. A field is
|
|
285
|
+
* registered as a literal key AND, when dotted, as the nested path it
|
|
286
|
+
* names — whichever exists in the patch is the one `scrubPatch` finds.
|
|
287
|
+
* Marked paths survive commit only while the op path they sit under does
|
|
288
|
+
* (see {@link survivingRedactedPaths}).
|
|
289
|
+
*/
|
|
290
|
+
markRedactedFields(path, fields) {
|
|
291
|
+
for (const field of fields) {
|
|
292
|
+
this.redactedPaths.add(normalisePath([...path, field]));
|
|
293
|
+
if (field.includes('.'))
|
|
294
|
+
this.redactedPaths.add(normalisePath([...path, ...field.split('.')]));
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Does a redaction mark address an array ELEMENT right below `path`
|
|
299
|
+
* (`list.1.token`)? An `append` row re-bases the indices onto the tail, so
|
|
300
|
+
* such a path takes the whole-value `set` (`deltaEncoding · pushValueRow`).
|
|
301
|
+
* A mark by NAME below an array (`obj.y`) means the same thing on the tail
|
|
302
|
+
* as on the whole value, so the compact row stays (byte-identical).
|
|
303
|
+
*/
|
|
304
|
+
markedBelow(path) {
|
|
305
|
+
const prefix = path + DELIM;
|
|
306
|
+
for (const marked of this.redactedPaths) {
|
|
307
|
+
if (!marked.startsWith(prefix))
|
|
308
|
+
continue;
|
|
309
|
+
const next = marked.slice(prefix.length).split(DELIM)[0];
|
|
310
|
+
if (/^(0|[1-9]\d*)$/.test(next))
|
|
311
|
+
return true;
|
|
312
|
+
}
|
|
313
|
+
return false;
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* The redacted paths that survive the net-change filter: a path survives
|
|
317
|
+
* when it IS a surviving op path (a whole-key redaction) or sits UNDER one
|
|
318
|
+
* (a field inside a surviving write). A dropped op takes its fields with it.
|
|
319
|
+
*/
|
|
320
|
+
survivingRedactedPaths(survivingPaths) {
|
|
321
|
+
const out = new Set();
|
|
322
|
+
for (const path of this.redactedPaths) {
|
|
323
|
+
if (survivingPaths.has(path)) {
|
|
324
|
+
out.add(path);
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
for (const op of survivingPaths) {
|
|
328
|
+
if (path.startsWith(op + DELIM)) {
|
|
329
|
+
out.add(path);
|
|
330
|
+
break;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
return out;
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* Read current value at path (includes uncommitted changes). The value is
|
|
338
|
+
* the stage's own — private to it, as the whole-state clone made every read
|
|
339
|
+
* after the first write before 9.29.0 (`privatise`).
|
|
340
|
+
*/
|
|
341
|
+
get(path, defaultValue) {
|
|
342
|
+
this.privatise(path);
|
|
343
|
+
return _get(this.workingCopy, path, defaultValue);
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* Read the working copy at `path` WITHOUT taking a private copy — for a
|
|
347
|
+
* reader that only compares (the dev-mode borrowed-mutation report in
|
|
348
|
+
* `StageContext.commit`), never for a value handed to a stage.
|
|
349
|
+
*/
|
|
350
|
+
peek(path) {
|
|
351
|
+
return _get(this.workingCopy, path);
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* Keep the diff base exact under a read the working copy cannot answer.
|
|
355
|
+
*
|
|
356
|
+
* When `workingCopy` holds nothing at `path` — the stage deleted or unset
|
|
357
|
+
* it, or a write replaced a container above it — `RecordFrame · read`
|
|
358
|
+
* serves the read from LIVE committed state, exactly as 9.28.0
|
|
359
|
+
* did, so the value it hands out is committed state itself. On 9.28.0 the
|
|
360
|
+
* diff base was a whole-state clone taken at the stage's first write, and
|
|
361
|
+
* an in-place edit of that value (out of contract — reads are borrowed)
|
|
362
|
+
* could not move it: written back, the edit was recorded. Here the diff
|
|
363
|
+
* base IS a committed generation, and it very often holds that same
|
|
364
|
+
* container at `path` — the edit would move the base with it, and the
|
|
365
|
+
* write-back would commit no change. So before the value goes out, the
|
|
366
|
+
* base's value at `path` is replaced by a private deep copy (the base's
|
|
367
|
+
* root and the containers on the way copied shallowly) — what 9.28.0's
|
|
368
|
+
* base held there. The commit's net-change test compares against it and
|
|
369
|
+
* the delta encoder replays from it; the value handed out stays the live
|
|
370
|
+
* one, so whatever else an edit of it reaches (live state, the next
|
|
371
|
+
* generation) behaves as on 9.28.0 too.
|
|
372
|
+
*
|
|
373
|
+
* Not `privatise`: a private copy handed to the stage would also keep the
|
|
374
|
+
* edit out of live state, where 9.28.0 let it in — a run-namespaced read
|
|
375
|
+
* that falls back to a global key would then end differently (the
|
|
376
|
+
* differential's NESTED family fails within 100 programs that way).
|
|
377
|
+
*
|
|
378
|
+
* Copies nothing when the base holds no container at `path`, and nothing
|
|
379
|
+
* twice: a path at or below an earlier detach is already private. O(path)
|
|
380
|
+
* otherwise, plus O(value) once per detached path.
|
|
381
|
+
*/
|
|
382
|
+
detachBase(path) {
|
|
383
|
+
if (path.length === 0 || !isContainer(this.baseSnapshot))
|
|
384
|
+
return;
|
|
385
|
+
let at = this.baseSnapshot;
|
|
386
|
+
for (const k of path) {
|
|
387
|
+
if (this.detachedBase?.has(at))
|
|
388
|
+
return;
|
|
389
|
+
const child = ownChild(at, k);
|
|
390
|
+
if (!isContainer(child))
|
|
391
|
+
return;
|
|
392
|
+
at = child;
|
|
393
|
+
}
|
|
394
|
+
if (this.detachedBase?.has(at))
|
|
395
|
+
return;
|
|
396
|
+
const owned = (this.baseOwned ??= new WeakSet());
|
|
397
|
+
if (!owned.has(this.baseSnapshot))
|
|
398
|
+
this.baseSnapshot = ownedRootOf(this.baseSnapshot, owned);
|
|
399
|
+
ownSpine(this.baseSnapshot, path, owned);
|
|
400
|
+
const copy = structuredClone(at);
|
|
401
|
+
(this.detachedBase ??= new WeakSet()).add(copy);
|
|
402
|
+
_set(this.baseSnapshot, path, copy);
|
|
403
|
+
}
|
|
404
|
+
/**
|
|
405
|
+
* Private reads (9.29.0): make the value at `path` the stage's own before
|
|
406
|
+
* a read hands it out — so a read after the stage's first write behaves
|
|
407
|
+
* exactly as it did when the buffer began with a deep clone of the whole
|
|
408
|
+
* state. An in-place edit of what the read returned (out of contract —
|
|
409
|
+
* reads are borrowed) stays inside the buffer, and committed state is never
|
|
410
|
+
* touched. Whether the RECORD keeps it is the admitted record's law
|
|
411
|
+
* (9.30.0, owner ruling R1 — the record keeps what the stage read back): it
|
|
412
|
+
* is kept when the edited value lies at or below a path the stage wrote in
|
|
413
|
+
* this stage — set, merged or written back alike — and lost otherwise.
|
|
414
|
+
*
|
|
415
|
+
* Walking the path from the (owned) root:
|
|
416
|
+
* - a container still SHARED with committed state at the same position is
|
|
417
|
+
* replaced by a private deep copy when it is the value read, or by a
|
|
418
|
+
* shallow copy when it is a container on the way to it (so a read of
|
|
419
|
+
* `k` under a fork child's address copies `k`, not every child's namespace);
|
|
420
|
+
* - a container the stage STAGED (or anything else the buffer did not
|
|
421
|
+
* take from committed state) is handed back by reference, as before;
|
|
422
|
+
* - a container the buffer owns is walked on — and when it is the value
|
|
423
|
+
* read, its still-shared descendants are privatised
|
|
424
|
+
* ({@link privatiseBelow}).
|
|
425
|
+
*
|
|
426
|
+
* Cost: O(path) per read, plus O(value) ONCE per shared container a stage
|
|
427
|
+
* reads after its first write. A stage that never reads after writing pays
|
|
428
|
+
* nothing; a read before the first write never reaches the buffer.
|
|
429
|
+
*/
|
|
430
|
+
privatise(path) {
|
|
431
|
+
if (path.length === 0)
|
|
432
|
+
return;
|
|
433
|
+
// Resolve first: a read that lands on nothing (a missing key, a refused
|
|
434
|
+
// segment, a primitive) returns nothing and must copy nothing.
|
|
435
|
+
const chain = [];
|
|
436
|
+
let at = this.workingCopy;
|
|
437
|
+
for (const k of path) {
|
|
438
|
+
at = ownChild(at, k);
|
|
439
|
+
if (!isContainer(at))
|
|
440
|
+
return;
|
|
441
|
+
chain.push(at);
|
|
442
|
+
}
|
|
443
|
+
let parent = this.workingCopy;
|
|
444
|
+
let base = this.baseSnapshot;
|
|
445
|
+
for (let i = 0; i < path.length; i++) {
|
|
446
|
+
const k = path[i];
|
|
447
|
+
const atBase = ownChild(base, k);
|
|
448
|
+
let value = chain[i];
|
|
449
|
+
if (!this.owned.has(value)) {
|
|
450
|
+
if (value !== atBase || this.staged.has(value))
|
|
451
|
+
return;
|
|
452
|
+
if (i === path.length - 1) {
|
|
453
|
+
putOwn(parent, k, this.privateCopyOf(value));
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
value = shallowCopy(value);
|
|
457
|
+
this.owned.add(value);
|
|
458
|
+
putOwn(parent, k, value);
|
|
459
|
+
}
|
|
460
|
+
parent = value;
|
|
461
|
+
base = atBase;
|
|
462
|
+
}
|
|
463
|
+
this.privatiseBelow(parent, base);
|
|
464
|
+
}
|
|
465
|
+
/**
|
|
466
|
+
* An OWNED container is about to be read whole (a path copy a nested write
|
|
467
|
+
* made, say): its descendants still shared with committed state are
|
|
468
|
+
* replaced by private deep copies, so nothing the read hands out is
|
|
469
|
+
* committed state. Walks owned containers only; marks each one finished
|
|
470
|
+
* ({@link privateTrees}), so a container is scanned once per stage.
|
|
471
|
+
*/
|
|
472
|
+
privatiseBelow(container, base) {
|
|
473
|
+
const work = [[container, base]];
|
|
474
|
+
while (work.length > 0) {
|
|
475
|
+
const [node, nodeBase] = work.pop();
|
|
476
|
+
if (this.privateTrees.has(node))
|
|
477
|
+
continue;
|
|
478
|
+
for (const key of Object.keys(node)) {
|
|
479
|
+
const child = ownChild(node, key);
|
|
480
|
+
if (!isContainer(child))
|
|
481
|
+
continue;
|
|
482
|
+
const atBase = ownChild(nodeBase, key);
|
|
483
|
+
if (this.owned.has(child))
|
|
484
|
+
work.push([child, atBase]);
|
|
485
|
+
else if (child === atBase && !this.staged.has(child)) {
|
|
486
|
+
putOwn(node, key, this.privateCopyOf(child));
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
this.privateTrees.add(node);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
/** A private deep copy of a committed container — owned through and through. */
|
|
493
|
+
privateCopyOf(value) {
|
|
494
|
+
const copy = structuredClone(value);
|
|
495
|
+
adopt(copy, this.owned);
|
|
496
|
+
this.privateTrees.add(copy);
|
|
497
|
+
return copy;
|
|
498
|
+
}
|
|
499
|
+
/**
|
|
500
|
+
* Did any staged op touch `path`, or a path above or below it?
|
|
501
|
+
*
|
|
502
|
+
* The dev-mode borrowed-read check (`StageContext.commit`) asks this before
|
|
503
|
+
* accusing a stage of mutating a value in place: a key the stage legitimately
|
|
504
|
+
* WROTE is expected to differ from what it read, and writes reach the buffer
|
|
505
|
+
* from paths that bypass user-level write tracking too (the subflow seed,
|
|
506
|
+
* `outputMapper` merge-back, the resume re-seed). Dev-mode only — nothing on
|
|
507
|
+
* the hot path calls it.
|
|
508
|
+
*/
|
|
509
|
+
wasStaged(path) {
|
|
510
|
+
const target = normalisePath(path);
|
|
511
|
+
for (const op of this.opTrace) {
|
|
512
|
+
// On the path, below it or above it — the one path relation every key query asks.
|
|
513
|
+
if (relation(op.path, target) !== undefined)
|
|
514
|
+
return true;
|
|
515
|
+
}
|
|
516
|
+
return false;
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* Flush all staged mutations and return the commit bundle — recording the
|
|
520
|
+
* stage's NET CHANGE, not its raw write log.
|
|
521
|
+
*
|
|
522
|
+
* ── WHY (the defect this fixes) ─────────────────────────────────────────
|
|
523
|
+
* Previously every `set`/`merge` was recorded verbatim, so the commit bundle
|
|
524
|
+
* was a log of *operations* rather than *changes*. Two operations produce no
|
|
525
|
+
* net change yet were still committed as "mutations":
|
|
526
|
+
*
|
|
527
|
+
* 1. No-op write — writing a key the value it already holds (e.g. an
|
|
528
|
+
* agent context slot re-emitting identical content every
|
|
529
|
+
* turn). base K=1, stage writes K=1.
|
|
530
|
+
* 2. Write-revert — changing then restoring a key within one stage.
|
|
531
|
+
* base K=1, stage writes K=2 then K=1.
|
|
532
|
+
*
|
|
533
|
+
* Recording these as mutations (a) bloated causal slicing / backtracking with
|
|
534
|
+
* spurious dependencies on intermediate values that never reach final state,
|
|
535
|
+
* and (b) made downstream "what changed here?" consumers light up stages that
|
|
536
|
+
* changed nothing — most visibly the lens highlight flagging every slot.
|
|
537
|
+
*
|
|
538
|
+
* ── HOW ─────────────────────────────────────────────────────────────────
|
|
539
|
+
* At commit we hold BOTH `baseSnapshot` (state when the stage began) and
|
|
540
|
+
* `workingCopy` (state after all its writes). For each path the stage touched
|
|
541
|
+
* we keep it in the bundle ONLY if its final value differs from the base
|
|
542
|
+
* value ({@link deepEqual}). No-op AND write-revert paths drop out, because
|
|
543
|
+
* both compare equal to base. This is a single net-delta diff at commit time
|
|
544
|
+
* — one deep compare per touched path, O(changed state), paid once per stage
|
|
545
|
+
* (NOT per write). A naive per-write deep-equal skip would be more expensive
|
|
546
|
+
* and would still miss write-revert (the intermediate write differs from the
|
|
547
|
+
* value present at the moment of writing).
|
|
548
|
+
*
|
|
549
|
+
* ── TWO HONEST TIERS (by design — do not "unify" them) ──────────────────
|
|
550
|
+
* • commit (here) = CHANGE-level — truthful net delta. Feeds the commit
|
|
551
|
+
* log, causal chain, narrative, and the lens highlight.
|
|
552
|
+
* • `onWrite` event = OP-level — fires on EVERY write attempt regardless of
|
|
553
|
+
* net change. Feeds metrics / behavioural observability
|
|
554
|
+
* (a debugger wants to see "wrote 2, then reverted").
|
|
555
|
+
* `onWrite` is unchanged by this method; only the COMMIT becomes change-only.
|
|
556
|
+
*
|
|
557
|
+
* ── EMPTY COMMITS ARE INTENTIONAL ───────────────────────────────────────
|
|
558
|
+
* A stage that nets no change commits an EMPTY patch — NOT nothing.
|
|
559
|
+
* {@link StageContext.commit} still records the bundle unconditionally, so
|
|
560
|
+
* every executed stage remains a time-travel cursor stop (its `runtimeStageId`
|
|
561
|
+
* marker is preserved); only its PATCH is empty. This is what keeps the
|
|
562
|
+
* commit-indexed slider stable while making the highlight truthful.
|
|
563
|
+
*
|
|
564
|
+
* ── KNOWN LIMITATIONS / FUTURE ──────────────────────────────────────────
|
|
565
|
+
* • Explicit key DELETION under the default 'full' mode is still
|
|
566
|
+
* flattened to set-of-`undefined` (a removed key cannot be expressed
|
|
567
|
+
* in MemoryPatch alone). CLOSED under `commitValues: 'delta'` (#13c-B):
|
|
568
|
+
* {@link delete} stages a distinct op and the bundle carries a real
|
|
569
|
+
* `delete` trace verb whose replay removes the key.
|
|
570
|
+
* • Array-merge dedup in {@link deepSmartMerge} still uses reference equality
|
|
571
|
+
* (`new Set`), so deep-equal *objects* in a merged array are not deduped.
|
|
572
|
+
* Orthogonal to this change; tracked separately.
|
|
573
|
+
*
|
|
574
|
+
* Resets the buffer to empty state after commit.
|
|
575
|
+
*/
|
|
576
|
+
commit() {
|
|
577
|
+
const rows = this.commitValues === 'delta' ? this.toDeltaPayload() : this.toChangeOnlyPayload();
|
|
578
|
+
const redactedPaths = this.survivingRedactedPaths(new Set(rows.trace.map((t) => t.path)));
|
|
579
|
+
// The bundle's key order is `recordCommit`'s; a direct caller keeps this same order.
|
|
580
|
+
const payload = { overwrite: rows.overwrite, updates: rows.updates, redactedPaths, trace: rows.trace };
|
|
581
|
+
this.overwritePatch = {};
|
|
582
|
+
this.updatePatch = {};
|
|
583
|
+
this.opTrace.length = 0;
|
|
584
|
+
this.redactedPaths.clear();
|
|
585
|
+
this.heldRefs.clear();
|
|
586
|
+
this.workingCopy = {};
|
|
587
|
+
this.owned = new WeakSet();
|
|
588
|
+
this.owned.add(this.workingCopy);
|
|
589
|
+
this.staged = new WeakSet();
|
|
590
|
+
this.privateTrees = new WeakSet();
|
|
591
|
+
this.stagedMerges = 0;
|
|
592
|
+
this.nestedOps = 0;
|
|
593
|
+
return payload;
|
|
594
|
+
}
|
|
595
|
+
/**
|
|
596
|
+
* Rebuild overwrite / updates / trace keeping ONLY paths whose final value
|
|
597
|
+
* differs from the base value — i.e. the stage's net change. See
|
|
598
|
+
* {@link TransactionBuffer.commit} for the rationale.
|
|
599
|
+
*
|
|
600
|
+
* Paths are compared at the exact granularity they were written (each trace
|
|
601
|
+
* entry's path), against `workingCopy` (final) vs `baseSnapshot` (start).
|
|
602
|
+
* Surviving `set` paths copy their final value from `overwritePatch`;
|
|
603
|
+
* surviving `merge` paths copy their accumulated delta from `updatePatch`,
|
|
604
|
+
* keeping the set-vs-merge verb — and the result is ADMITTED ({@link admit}):
|
|
605
|
+
* a family of rows whose replay does not give back what the stage read is
|
|
606
|
+
* recorded as `set` rows of the read-back instead (9.30.0).
|
|
607
|
+
*
|
|
608
|
+
* This is the DEFAULT (`commitValues: 'full'`) payload, including the
|
|
609
|
+
* historical flattening of staged `delete` ops into `set`-of-`undefined`
|
|
610
|
+
* trace entries. The delta encoding lives in {@link toDeltaPayload}.
|
|
611
|
+
*
|
|
612
|
+
* Work, not bytes (9.22.1): the net-change verdict is paid once per PATH
|
|
613
|
+
* and the clone once per CONSECUTIVE run of ops on a path, not once per op
|
|
614
|
+
* — see the two memos inside. Pinned by
|
|
615
|
+
* test/lib/memory/scenario/repeated-path-byte-identity.test.ts against the
|
|
616
|
+
* 9.22.0 bytes. Since 9.23.0 that clone is THE clone: the patch trees hold
|
|
617
|
+
* the caller's references (see the class header), so this loop is the one
|
|
618
|
+
* place the record is detached from them.
|
|
619
|
+
*/
|
|
620
|
+
toChangeOnlyPayload() {
|
|
621
|
+
return this.admit((lossy) => this.changeOnlyRows(lossy));
|
|
622
|
+
}
|
|
623
|
+
/** The 'full' rows; a family in `lossy` is written as its read-back, at its last touch ({@link emitReadBack}). */
|
|
624
|
+
changeOnlyRows(lossy) {
|
|
625
|
+
const rows = { overwrite: {}, updates: {}, trace: [] };
|
|
626
|
+
const { overwrite, updates, trace } = rows;
|
|
627
|
+
// Per PATH, decided once (9.22.1): base and final value are fixed at
|
|
628
|
+
// commit, so every later op on a path gets the verdict its first op got.
|
|
629
|
+
// `undefined` = not yet decided; the surviving paths are the `true` keys.
|
|
630
|
+
const survives = new Map();
|
|
631
|
+
// The path the LAST surviving op copied into each patch tree (9.22.1).
|
|
632
|
+
// The value at a path is fixed at commit, so an op whose predecessor on
|
|
633
|
+
// the SAME tree already copied that path would write the same bytes over
|
|
634
|
+
// the same key — it keeps its trace row (the log is byte-identical) and
|
|
635
|
+
// pays no second clone. The 9.22.0 element-write funnel stages N
|
|
636
|
+
// whole-array sets on ONE path; this is what makes that commit O(N)
|
|
637
|
+
// instead of O(N × ops). CONSECUTIVE only — an op on another path in
|
|
638
|
+
// between may have coerced or grown this one's container (a descendant
|
|
639
|
+
// `nativeSet` through a primitive, a `key: undefined` shell) and the
|
|
640
|
+
// re-copy is what repairs it; a `merge` between two sets touches the
|
|
641
|
+
// other tree and does not count. Same law as `supersededByNextSet`.
|
|
642
|
+
let lastSetPath;
|
|
643
|
+
let lastMergePath;
|
|
644
|
+
for (let i = 0; i < this.opTrace.length; i++) {
|
|
645
|
+
const op = this.opTrace[i];
|
|
646
|
+
const family = lossy?.families.get(lossy.rootOf.get(op.path));
|
|
647
|
+
if (family !== undefined) {
|
|
648
|
+
if (i === family.slot) {
|
|
649
|
+
this.emitReadBack(family, rows, 'full');
|
|
650
|
+
lastSetPath = lastMergePath = undefined;
|
|
651
|
+
}
|
|
652
|
+
continue;
|
|
653
|
+
}
|
|
654
|
+
let keep = survives.get(op.path);
|
|
655
|
+
if (keep === undefined) {
|
|
656
|
+
keep = this.changedSinceBase(op.path.split(DELIM));
|
|
657
|
+
survives.set(op.path, keep);
|
|
658
|
+
}
|
|
659
|
+
if (!keep)
|
|
660
|
+
continue; // no-op or write-then-revert → no net change
|
|
661
|
+
trace.push(flattenedTraceRow(op));
|
|
662
|
+
if (op.verb === 'merge') {
|
|
663
|
+
if (lastMergePath === op.path)
|
|
664
|
+
continue;
|
|
665
|
+
lastMergePath = op.path;
|
|
666
|
+
const segments = op.path.split(DELIM);
|
|
667
|
+
_set(updates, segments, structuredClone(_get(this.updatePatch, segments)));
|
|
668
|
+
}
|
|
669
|
+
else {
|
|
670
|
+
if (lastSetPath === op.path)
|
|
671
|
+
continue;
|
|
672
|
+
lastSetPath = op.path;
|
|
673
|
+
const segments = op.path.split(DELIM);
|
|
674
|
+
_set(overwrite, segments, structuredClone(_get(this.overwritePatch, segments)));
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
return rows;
|
|
678
|
+
}
|
|
679
|
+
/**
|
|
680
|
+
* Delta-encoded payload (`commitValues: 'delta'`, #13c-B) — same net-change
|
|
681
|
+
* filter as {@link toChangeOnlyPayload}, three encoding differences:
|
|
682
|
+
*
|
|
683
|
+
* 1. **One trace entry per surviving path** (the §2.5 dedup rule — `append`
|
|
684
|
+
* is NOT idempotent on replay, so duplicate entries would multiply
|
|
685
|
+
* tails). The verb is resolved from the path's op mix + base→final
|
|
686
|
+
* relationship; entries are ordered by each path's LAST touch,
|
|
687
|
+
* preserving last-writer-wins for nested/overlapping paths.
|
|
688
|
+
* 2. **Verb resolution per path** — for a path with NO surviving relative
|
|
689
|
+
* (see 3):
|
|
690
|
+
* - last op `'delete'` AND final value gone → `delete` (the path stays
|
|
691
|
+
* enumerated in `overwrite` with `undefined` for key-set consumers).
|
|
692
|
+
* Requires the parent to be a real container in the base — replay uses
|
|
693
|
+
* `nativeDelete`, which is inert on a primitive/absent parent, whereas
|
|
694
|
+
* the `'full'` flattening (`_set` of `undefined`) COERCES that parent
|
|
695
|
+
* into an object; when they would disagree we keep the flattening;
|
|
696
|
+
* - ONLY `'merge'` ops → `merge` with the accumulated `updatePatch`
|
|
697
|
+
* delta;
|
|
698
|
+
* - otherwise (`set`/mixed): the value the stage's 'full' rows give the
|
|
699
|
+
* path ({@link fullRowsValue}). If base and that value are arrays and
|
|
700
|
+
* base is a STRICT PREFIX → `append` storing only the tail; else `set`
|
|
701
|
+
* storing the full value.
|
|
702
|
+
* 3. **OVERLAP FAMILIES take the full-value fallback.** `overwrite` /
|
|
703
|
+
* `updates` are nested path TREES, so two surviving paths where one is
|
|
704
|
+
* an ancestor of the other (`a` and `a.p`) share storage. Delta's
|
|
705
|
+
* per-path encodings would clobber each other there (an `append`
|
|
706
|
+
* ancestor stores only a TAIL, a `delete` ancestor `undefined`), so every
|
|
707
|
+
* path that has a surviving ancestor/descendant is committed as a plain
|
|
708
|
+
* `set` of the value the 'full' rows give it, and the family's
|
|
709
|
+
* shallowest path (whose set covers the whole subtree) is emitted LAST.
|
|
710
|
+
*
|
|
711
|
+
* The result is ADMITTED ({@link admit}) like the 'full' payload: a compact
|
|
712
|
+
* row is kept only where the bundle provably folds back, and a family that
|
|
713
|
+
* does not is recorded as what the stage read. So both encodings commit the
|
|
714
|
+
* value the stage read; before 9.30.0 they shared a replay that did not
|
|
715
|
+
* (`replayPathVerbs` / `replayFamilyVerbs`, deleted).
|
|
716
|
+
*/
|
|
717
|
+
toDeltaPayload() {
|
|
718
|
+
const fullValueAt = this.fullRowsValue();
|
|
719
|
+
return this.admit((lossy) => this.deltaRows(lossy, fullValueAt));
|
|
720
|
+
}
|
|
721
|
+
/** The delta rows; a family in `lossy` is written as its read-back, at its last touch ({@link emitReadBack}). */
|
|
722
|
+
deltaRows(lossy, fullValueAt) {
|
|
723
|
+
const rows = { overwrite: {}, updates: {}, trace: [] };
|
|
724
|
+
const { overwrite, updates, trace } = rows;
|
|
725
|
+
const inLossy = (path) => lossy?.families.has(lossy.rootOf.get(path)) === true;
|
|
726
|
+
const survivors = this.netChangeSurvivors(this.opsByPath()).filter((s) => !inLossy(s.path));
|
|
727
|
+
const { rootOf, byRoot } = groupIntoFamilies(survivors, new Set(survivors.map((s) => s.path)));
|
|
728
|
+
// THE VERB CHOICE — the delta encoder picks a verb per path; the VALUES
|
|
729
|
+
// it encodes come from the one replay (`fullValueAt`), never a replica of
|
|
730
|
+
// the verb law (9.30.0: CLAUDE.md's verb-switch replicas lost two here).
|
|
731
|
+
const emit = (s) => {
|
|
732
|
+
const { path, segments, verbs, before } = s;
|
|
733
|
+
const prov = provenance(s.readKeys);
|
|
734
|
+
if (byRoot.get(rootOf.get(path)).length > 1) {
|
|
735
|
+
// Rule 3 — one coherent tree: every member a set of its value.
|
|
736
|
+
trace.push({ path, verb: 'set', ...prov });
|
|
737
|
+
_set(overwrite, segments, structuredClone(fullValueAt(segments)));
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
const lastVerb = verbs[verbs.length - 1];
|
|
741
|
+
if (lastVerb === 'delete' && s.after === undefined && this.deleteReplaysAsFlattening(segments)) {
|
|
742
|
+
// Real deletion — replay removes the key. Keep the path enumerated
|
|
743
|
+
// in `overwrite` (undefined) so Object.keys consumers see it.
|
|
744
|
+
trace.push({ path, verb: 'delete', ...prov });
|
|
745
|
+
_set(overwrite, segments, undefined);
|
|
746
|
+
}
|
|
747
|
+
else if (verbs.every((v) => v === 'merge')) {
|
|
748
|
+
trace.push({ path, verb: 'merge', ...prov });
|
|
749
|
+
_set(updates, segments, structuredClone(_get(this.updatePatch, segments)));
|
|
750
|
+
}
|
|
751
|
+
else {
|
|
752
|
+
pushValueRow(rows, path, segments, before, fullValueAt(segments), prov, this.markedBelow(path));
|
|
753
|
+
}
|
|
754
|
+
};
|
|
755
|
+
// A family that did not fold back takes its place by its last touch.
|
|
756
|
+
const pending = lossy === undefined ? [] : [...lossy.families.values()].sort((a, b) => a.slot - b.slot);
|
|
757
|
+
let next = 0;
|
|
758
|
+
const flushBefore = (touch) => {
|
|
759
|
+
while (next < pending.length && pending[next].slot < touch)
|
|
760
|
+
this.emitReadBack(pending[next++], rows, 'delta');
|
|
761
|
+
};
|
|
762
|
+
emitInFamilyOrder(survivors, rootOf, byRoot, emit, flushBefore);
|
|
763
|
+
flushBefore(Infinity);
|
|
764
|
+
return rows;
|
|
765
|
+
}
|
|
766
|
+
// ── The admitted record (9.30.0) ───────────────────────────────────────
|
|
767
|
+
/**
|
|
768
|
+
* THE ADMISSION — a commit is admitted only if its bundle folds back to the
|
|
769
|
+
* stage's read-your-writes view at every path the stage touched. `build`
|
|
770
|
+
* lays the rows out (one encoder: `'full'` or `'delta'`); the candidate is
|
|
771
|
+
* checked ({@link lossyFamilies}), and when some family's rows do not fold
|
|
772
|
+
* back the rows are laid out again with those families written as what the
|
|
773
|
+
* stage read ({@link emitReadBack}) — every other family's bytes exactly as
|
|
774
|
+
* the first layout made them.
|
|
775
|
+
*/
|
|
776
|
+
admit(build) {
|
|
777
|
+
const candidate = build(undefined);
|
|
778
|
+
const lossy = this.lossyFamilies(candidate);
|
|
779
|
+
return lossy === undefined ? candidate : build(lossy);
|
|
780
|
+
}
|
|
781
|
+
/**
|
|
782
|
+
* The families whose rows do not fold back to what the stage read —
|
|
783
|
+
* `undefined` when there are none (`admission.ts · lossyFamilies`: the
|
|
784
|
+
* candidate replayed onto the diff base by `dryFold`, every family of
|
|
785
|
+
* touched paths compared with the working copy at its root and on the way
|
|
786
|
+
* down to it).
|
|
787
|
+
*
|
|
788
|
+
* Without a fold, when the stage staged no `merge` and no nested op: its
|
|
789
|
+
* rows are `set` / `delete` of keys directly under its address, each
|
|
790
|
+
* writing the SAME value into `workingCopy` and `overwritePatch` — the
|
|
791
|
+
* record is the read-back by construction (the property's ROOT-ONLY arm
|
|
792
|
+
* pins this). That keeps the commit of the typed scope's root-key writes
|
|
793
|
+
* exactly as cheap as it was.
|
|
794
|
+
*/
|
|
795
|
+
lossyFamilies(candidate) {
|
|
796
|
+
if (this.stagedMerges === 0 && this.nestedOps === 0)
|
|
797
|
+
return undefined;
|
|
798
|
+
return lossyFamilies(candidate, this.baseSnapshot, this.workingCopy, this.opTrace, this.address);
|
|
799
|
+
}
|
|
800
|
+
/**
|
|
801
|
+
* A family whose rows did not fold back, written as what the stage read:
|
|
802
|
+
* one `set` row per member that changed (descendants by last touch, each
|
|
803
|
+
* with its own read prefix), the ROOT last, and the root's read-back value
|
|
804
|
+
* in `overwrite` — one coherent tree, so no row can clobber another. The
|
|
805
|
+
* root's row is written even when the root itself did not change: it is the
|
|
806
|
+
* row that carries the family's read-back (and the containers L-1's dropped
|
|
807
|
+
* op left). A lone root under `'delta'` keeps the compact `append` when the
|
|
808
|
+
* value it read is its base plus a tail.
|
|
809
|
+
*/
|
|
810
|
+
emitReadBack(family, rows, encoding) {
|
|
811
|
+
let root = family.members[0];
|
|
812
|
+
let changed = 0;
|
|
813
|
+
for (const m of family.members) {
|
|
814
|
+
if (m.path === family.root) {
|
|
815
|
+
root = m;
|
|
816
|
+
continue;
|
|
817
|
+
}
|
|
818
|
+
if (!this.changedSinceBase(m.path.split(DELIM)))
|
|
819
|
+
continue;
|
|
820
|
+
rows.trace.push({ path: m.path, verb: 'set', ...provenance(m.readKeys) });
|
|
821
|
+
changed++;
|
|
822
|
+
}
|
|
823
|
+
const after = _get(this.workingCopy, family.rootSegments);
|
|
824
|
+
if (encoding === 'delta' && changed === 0 && after !== undefined) {
|
|
825
|
+
const before = _get(this.baseSnapshot, family.rootSegments);
|
|
826
|
+
pushValueRow(rows, family.root, family.rootSegments, before, after, provenance(root.readKeys), this.markedBelow(family.root));
|
|
827
|
+
return;
|
|
828
|
+
}
|
|
829
|
+
rows.trace.push({ path: family.root, verb: 'set', ...provenance(root.readKeys) });
|
|
830
|
+
// The clone the check took (`admission.ts · foldsBack`), when it took one.
|
|
831
|
+
_set(rows.overwrite, family.rootSegments, family.held !== undefined ? family.held.value : structuredClone(after));
|
|
832
|
+
}
|
|
833
|
+
/**
|
|
834
|
+
* The value the stage's 'full' rows give a path — what the delta encoder
|
|
835
|
+
* commits for a family member or a path with a `set` — read from ONE
|
|
836
|
+
* {@link dryFold} of those rows over the patch trees themselves, taken the
|
|
837
|
+
* first time it is asked. By reference, as the stage held them: an element
|
|
838
|
+
* a `set` and a `merge` of one path share stays one element. A stage that
|
|
839
|
+
* staged no `merge` and no nested op needs no fold — a path's value is its
|
|
840
|
+
* last staged value. (Before 9.30.0: two replicas of the verb law,
|
|
841
|
+
* `replayPathVerbs` and `replayFamilyVerbs`.)
|
|
842
|
+
*/
|
|
843
|
+
fullRowsValue() {
|
|
844
|
+
if (this.stagedMerges === 0 && this.nestedOps === 0)
|
|
845
|
+
return (segments) => _get(this.overwritePatch, segments);
|
|
846
|
+
let fold;
|
|
847
|
+
return (segments) => {
|
|
848
|
+
fold ??= dryFold(this.baseSnapshot, this.updatePatch, this.overwritePatch, this.fullRows());
|
|
849
|
+
return _get(fold, segments);
|
|
850
|
+
};
|
|
851
|
+
}
|
|
852
|
+
/** The stage's 'full' rows: every op on a path the net-change filter keeps, a `delete` spelled `set`. */
|
|
853
|
+
fullRows() {
|
|
854
|
+
const keep = new Map();
|
|
855
|
+
const rows = [];
|
|
856
|
+
for (const op of this.opTrace) {
|
|
857
|
+
let k = keep.get(op.path);
|
|
858
|
+
if (k === undefined)
|
|
859
|
+
keep.set(op.path, (k = this.changedSinceBase(op.path.split(DELIM))));
|
|
860
|
+
if (k)
|
|
861
|
+
rows.push({ path: op.path, verb: op.verb === 'merge' ? 'merge' : 'set' });
|
|
862
|
+
}
|
|
863
|
+
return rows;
|
|
864
|
+
}
|
|
865
|
+
/**
|
|
866
|
+
* Did the stage change the value at `segments`? Base and final value are
|
|
867
|
+
* fixed at commit, so this is the ONE net-change verdict both payload
|
|
868
|
+
* encodings apply (a no-op write and a write-then-revert both fail it).
|
|
869
|
+
*/
|
|
870
|
+
changedSinceBase(segments) {
|
|
871
|
+
return !deepEqual(_get(this.baseSnapshot, segments), _get(this.workingCopy, segments));
|
|
872
|
+
}
|
|
873
|
+
/**
|
|
874
|
+
* Path → its op-verb sequence, ordered by LAST touch (delete + re-insert
|
|
875
|
+
* moves a re-touched path to the end of the Map's insertion order —
|
|
876
|
+
* preserving last-writer-wins for nested/overlapping paths). Per-write
|
|
877
|
+
* provenance (#P1): the LAST op's readKeys is kept — read prefixes only grow
|
|
878
|
+
* within a stage, so last == union across the path.
|
|
879
|
+
*/
|
|
880
|
+
opsByPath() {
|
|
881
|
+
const byPath = new Map();
|
|
882
|
+
for (let i = 0; i < this.opTrace.length; i++) {
|
|
883
|
+
const op = this.opTrace[i];
|
|
884
|
+
const prev = byPath.get(op.path);
|
|
885
|
+
if (prev) {
|
|
886
|
+
prev.verbs.push(op.verb);
|
|
887
|
+
prev.last = i;
|
|
888
|
+
if (op.readKeys !== undefined)
|
|
889
|
+
prev.readKeys = op.readKeys;
|
|
890
|
+
byPath.delete(op.path);
|
|
891
|
+
byPath.set(op.path, prev);
|
|
892
|
+
}
|
|
893
|
+
else {
|
|
894
|
+
byPath.set(op.path, {
|
|
895
|
+
verbs: [op.verb],
|
|
896
|
+
last: i,
|
|
897
|
+
...(op.readKeys !== undefined && { readKeys: op.readKeys }),
|
|
898
|
+
});
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
return byPath;
|
|
902
|
+
}
|
|
903
|
+
/** The net-change filter — identical to 'full' ({@link changedSinceBase}). Survivors keep last-touch order. */
|
|
904
|
+
netChangeSurvivors(byPath) {
|
|
905
|
+
const survivors = [];
|
|
906
|
+
for (const [path, { verbs, readKeys, last }] of byPath) {
|
|
907
|
+
const segments = path.split(DELIM);
|
|
908
|
+
if (!this.changedSinceBase(segments))
|
|
909
|
+
continue; // no-op or write-then-revert → no net change
|
|
910
|
+
const before = _get(this.baseSnapshot, segments);
|
|
911
|
+
const after = _get(this.workingCopy, segments);
|
|
912
|
+
survivors.push({ path, segments, verbs, readKeys, last, before, after });
|
|
913
|
+
}
|
|
914
|
+
return survivors;
|
|
915
|
+
}
|
|
916
|
+
/**
|
|
917
|
+
* May a staged `delete` at this path commit as the `'delete'` verb?
|
|
918
|
+
*
|
|
919
|
+
* Only when removing the key and the `'full'` mode's flattening (`_set` of
|
|
920
|
+
* `undefined`) land in the same place. They diverge when the parent is not
|
|
921
|
+
* a container: `nativeDelete` walks away untouched, while `nativeSet`
|
|
922
|
+
* COERCES the primitive parent into an object to hold the key. Committing
|
|
923
|
+
* `delete` there would silently drop a state change the stage really made,
|
|
924
|
+
* so those (pathological) deletes keep the historical flattening.
|
|
925
|
+
*/
|
|
926
|
+
deleteReplaysAsFlattening(segments) {
|
|
927
|
+
if (segments.length === 1)
|
|
928
|
+
return true; // parent is the state root — always a container
|
|
929
|
+
const parent = _get(this.baseSnapshot, segments.slice(0, -1));
|
|
930
|
+
return parent !== null && typeof parent === 'object';
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
/**
|
|
934
|
+
* Historical flattening for the `'full'` payload: an explicit delete commits
|
|
935
|
+
* as set-of-undefined. Per-write provenance (#P1) rides each surviving entry
|
|
936
|
+
* untouched.
|
|
937
|
+
*/
|
|
938
|
+
function flattenedTraceRow(op) {
|
|
939
|
+
return op.verb === 'delete'
|
|
940
|
+
? { path: op.path, verb: 'set', ...(op.readKeys !== undefined && { readKeys: op.readKeys }) }
|
|
941
|
+
: op;
|
|
942
|
+
}
|
|
943
|
+
/** A row's `readKeys` fragment — absent unless the provenance dial recorded one (#P1). */
|
|
944
|
+
function provenance(readKeys) {
|
|
945
|
+
return readKeys !== undefined ? { readKeys } : undefined;
|
|
946
|
+
}
|