footprintjs 9.22.1 → 9.23.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/CLAUDE.md +4 -4
- package/dist/esm/lib/memory/StageContext.d.ts +45 -13
- package/dist/esm/lib/memory/StageContext.js +77 -20
- package/dist/esm/lib/memory/TransactionBuffer.d.ts +50 -3
- package/dist/esm/lib/memory/TransactionBuffer.js +80 -9
- package/dist/esm/lib/reactive/arrayTraps.d.ts +14 -0
- package/dist/esm/lib/reactive/arrayTraps.js +33 -4
- package/dist/esm/lib/reactive/createTypedScope.js +11 -4
- package/dist/lib/memory/StageContext.js +77 -20
- package/dist/lib/memory/TransactionBuffer.js +80 -9
- package/dist/lib/reactive/arrayTraps.js +33 -4
- package/dist/lib/reactive/createTypedScope.js +11 -4
- package/dist/types/lib/memory/StageContext.d.ts +45 -13
- package/dist/types/lib/memory/TransactionBuffer.d.ts +50 -3
- package/dist/types/lib/reactive/arrayTraps.d.ts +14 -0
- package/package.json +1 -1
package/CLAUDE.md
CHANGED
|
@@ -51,8 +51,8 @@ Entry points: `footprintjs` (public API) · `/recorders` (factories) · `/trace`
|
|
|
51
51
|
- **Closed seams that look open**: ExecutionEnv (fixed type); decide() operators (evaluator.ts:19 module-private); METHOD_ROUTES; the executeNodeStep phase chain; emit dispatcher (rides scope channel).
|
|
52
52
|
|
|
53
53
|
## Change-impact map
|
|
54
|
-
- **Trace verbs** (`set|merge|append|delete`, memory/types.ts:42) → FOUR verb-switch replicas in lockstep: applySmartMerge (utils.ts:254 — live commit AND EventLog.materialise), commitValueAt (commitLogUtils.ts:82), TransactionBuffer.toDeltaPayload/replayPathVerbs (:248-328); plus causalChain/findLastWriter readers. NOT a fifth replica (9.22.1): `utils.ts · supersededByNextSet` — the ONE repeated-path skip (CONSECUTIVE same-path `set` rows: only the last is applied), asked by `applySmartMerge` before its switch and by `replayFamilyVerbs`; its commit-side twin is the last-materialised-path memo in `toChangeOnlyPayload`. Rows are never dropped from the log; a non-consecutive skip is WRONG (a descendant op in between coerces/shells the container) — pinned by test/lib/memory/scenario/repeated-path-byte-identity.test.ts.
|
|
55
|
-
- **StageContext.commit / dials** → dial propagation is TRIPLICATED: ExecutionRuntime.use* (:131-161), createNext/createChild inheritance (StageContext.ts:619/640), SubflowExecutor duck-push (:151-173) — miss one and subflows silently run the default. Applies to all FOUR dials incl. writeProvenance (#P1).
|
|
54
|
+
- **Trace verbs** (`set|merge|append|delete`, memory/types.ts:42) → FOUR verb-switch replicas in lockstep: applySmartMerge (utils.ts:254 — live commit AND EventLog.materialise), commitValueAt (commitLogUtils.ts:82), TransactionBuffer.toDeltaPayload/replayPathVerbs (:248-328); plus causalChain/findLastWriter readers. NOT a fifth replica (9.22.1): `utils.ts · supersededByNextSet` — the ONE repeated-path skip (CONSECUTIVE same-path `set` rows: only the last is applied), asked by `applySmartMerge` before its switch and by `replayFamilyVerbs`; its commit-side twin is the last-materialised-path memo in `toChangeOnlyPayload`. Rows are never dropped from the log; a non-consecutive skip is WRONG (a descendant op in between coerces/shells the container) — pinned by test/lib/memory/scenario/repeated-path-byte-identity.test.ts. Since 9.23.0 the clone in that memo loop (and every `structuredClone` in `toDeltaPayload`) is THE clone — the patch trees hold references until commit; still not a fifth replica, no verb switch was touched.
|
|
55
|
+
- **StageContext.commit / dials** → dial propagation is TRIPLICATED: ExecutionRuntime.use* (:131-161), createNext/createChild inheritance (StageContext.ts:619/640), SubflowExecutor duck-push (:151-173) — miss one and subflows silently run the default. Applies to all FOUR dials incl. writeProvenance (#P1). `commit()`'s FIRST act (9.23.0) is `materialiseWrites` — the `writeTracking` retained form is taken there, once per key, from `_pendingWrites` (bare references + the write-time verdict); anything that reads `_stageWrites` goes through `retainedWrites()` (a non-consuming fold, so a mid-run snapshot is honest), and `discardStaged` drops the pending map with the rest.
|
|
56
56
|
- **runtimeStageId format** (`#`/`/` delimiters) → string parsers everywhere: parseRuntimeStageId, ScopeFacade._getSubflowPath, subflowResults dual-keying, checkpoint lean-filter, narrative buffering, store keys. All are last-delimiter based, which is why a GENERATED branch segment (`<stageId>~<index>`, 9.14.0) needed ZERO parser changes — the marker is opaque to every one of them. Only 3 places may ever know `~`: branchSegment.ts, the builder's 9 refusals, the fan-out handler. A parser that special-cases it is a design smell.
|
|
57
57
|
- **Subflow id prefixing** → DUPLICATED prefixer: builder _prefixNodeTree and traverser prefixNodeTree are byte-twins; change both + stageMap key composition + resume drilling. PINNED since 9.14.0 by test/lib/engine/branch-segment-prefixer-equivalence.test.ts (fails on drift, including drift that keeps the twins equal to each other).
|
|
58
58
|
- **Checkpoint shape** → buildPauseCheckpoint (FlowChartExecutor.ts:987) ↔ resume() validation (:690) ↔ checkpointSanitize ↔ SubflowExecutor resume-seed (skip-inputMapper, :69-72).
|
|
@@ -83,9 +83,9 @@ Seven mechanisms, no state rollback anywhere: **M1** TransactionBuffer staging +
|
|
|
83
83
|
- `getSnapshot().recorders` = ONE row per recorder **id**, across both channels and both delivery tiers (collectRecorderSnapshots, FlowChartExecutor.ts) — a shared-name hook (onError/onPause/onResume) puts one recorder on two lists by design, and rows must survive structuredClone (the narrative row drops `rawValue`, a live reference).
|
|
84
84
|
|
|
85
85
|
## Landmines
|
|
86
|
-
1. TypedScope's set trap JSON-round-trips the ASSIGNED VALUE of every object write (`reactive/structuralWrite.ts` · `unwrapProxy`, called from createTypedScope's top-level/nested set traps and the
|
|
86
|
+
1. TypedScope's set trap JSON-round-trips the ASSIGNED VALUE of every object write (`reactive/structuralWrite.ts` · `unwrapProxy`, called from createTypedScope's top-level/nested set traps and, since 9.23.0, from the ARRAY TRAPS themselves — `arrayTraps.ts` index set, `MUTATING_METHODS` arguments, element-proxy leaf) — Date→string, Map→{}, undefined drops; `$setValue` bypasses it, so the two write paths store different bytes. Scope is the assigned value only, and since 9.23.0 that is true for ELEMENT writes too: the array commit callbacks in `createTypedScope` used to round-trip the WHOLE rebuilt array (stringifying every untouched sibling, O(N) per element write); now the rebuilt array is committed as it is and untouched siblings pass through by reference (pinned: test/lib/reactive/scenario/assigned-value-only.test.ts). A whole-array ASSIGNMENT (`s.arr = [...]`) still round-trips every element — the array IS the assigned value. The shape work the round-trip did for free is explicit now: `delete arr[i]`, a write past the end and `length` growth spell holes as `null` (`arrayTraps` · `fillHoles`). The surrounding state reaches the buffer by reference and is detached with `structuredClone` at commit, which keeps Dates and Maps (9.22.0 / 9.23.0).
|
|
87
87
|
2. Fork "parent breaks when ALL children broke" is implemented (ChildrenExecutor.ts:53) but **unwired** — every live call site passes parentBreakFlag=undefined; fork breaks do NOT propagate in real runs.
|
|
88
|
-
3. TransactionBuffer
|
|
88
|
+
3. **First bite CLOSED (9.23.0 — clone once at commit):** `TransactionBuffer` · `set` now stores the REFERENCE in `overwritePatch` too (it always did in `workingCopy`) and the record's copy is taken ONCE per surviving path when the payload leaves the buffer (`toChangeOnlyPayload` / `toDeltaPayload`); `StageContext` · `trackWrite` holds the reference + verdict in `_pendingWrites` and `materialiseWrites` takes the retained form once per key at `commit()`. So `$setValue(k, o); o.x = 1` commits `x: 1` — what the stage read back — instead of a stale write-time snapshot (pinned: test/lib/memory/scenario/clone-once-at-commit.test.ts). The law "the record never aliases a caller's object" holds at the commit boundary; a mutation AFTER commit reaches nothing. Two things to keep in mind: (a) the engine's OWN nested ops (`set a` then `merge a.b` — the buffer driven directly; the proxy writes root keys) must not edit through a shared container, because only `workingCopy` receives a nested merge's result — `detachHeldAncestors` clones the held ancestor in `overwritePatch` first (the design page missed this; the `set-merge-interleaved` 9.22.0 reference pins it); (b) an UNCLONEABLE value (function, Proxy) now fails at COMMIT, not at the write — the run still fails loudly with the `DataCloneError`, the stage cannot catch it, and none of that stage's writes land. The element-write loop's other O(N)-per-write cost, the array commit callbacks' whole-array JSON round-trip, went in the same release (landmine 1, second moved behaviour); what remains per element write is the array proxy's own shallow copy-on-write (`arrayTraps` · `replaceInElement` / the traps' `[...getCurrent()]`, ~9 µs at N = 10k) — bench 1k 5.7 ms, 10k 104 ms. **Second bite — a raw element held past its stage:** a value obtained through `find`/`filter`/`for…of`/`$getValue`/`$read`/`$toRaw` in stage A and mutated in stage B is the committed object itself, so the mutation edits committed state with no row, and the dev-mode guard (`StageContext` · `warnOnBorrowedMutation`) sees it only if B also READS that key. Cannot be trapped; documented in src/lib/reactive/README.md ("A handle is bound to its stage"). A PROXY held that long is refused at the write (`ScopeFacade` · `assertLive`, sealed by the commit observer — the refusal lives on the facade, not `stageWrite`, because the frame's post-commit re-use is what the engine's double-commit paths need).
|
|
89
89
|
4. **NOT a landmine any more (9.22.0), recorded because it was one:** an indexed array read (`arr[i]`) used to hand back the RAW element, and a nested array write committed with the `merge` verb whose array arm is a set UNION. Both are fixed at the root — `reactive/arrayTraps.ts` wraps elements, and a nested array commits as a `set` of its ROOT KEY (`createTypedScope` · nested array commit callback). Consequences to keep in mind: an array ASSIGNMENT now REPLACES at every depth (`$update` is still the append), and a nested array write appears in `bundle.overwrite`/verb `set`, not `bundle.updates`/verb `merge`. What the proxy still cannot see — an element reached via `find`/`filter`/`for…of`/`forEach` — is REPORTED loudly instead: `StageContext` · `warnOnBorrowedMutation` (a dev-mode WARNING at the frame's first commit, under `readTracking: 'full'` only — a report after the fact, not a refusal). `memory/utils.ts` · `equalPairs` has typed arms (Date by instant, Map/Set by members) so a `$setValue` of a new Date/Set is a change, not a no-op. Every proxy (nested/terminal/element) READS LIVE through `reactive/liveView.ts` — a held handle sees its own writes; it answers from the captured object only when the path is gone. See src/lib/reactive/README.md. **Named, not fixed (out of contract):** a cyclic self-reference reached through a terminal proxy commits array/element writes to the WRONG place (row recorded, fold agrees, value lands beside the cycle edge); a top-level key containing U+001F (the path separator) throws at the seed; an expando on an array (`arr.foo = 1`) is dropped by the array proxy's set trap (indices and `length` only).
|
|
90
90
|
|
|
91
91
|
## Pointers
|
|
@@ -66,8 +66,20 @@ export declare class StageContext {
|
|
|
66
66
|
next?: StageContext;
|
|
67
67
|
children?: StageContext[];
|
|
68
68
|
debug: DiagnosticCollector;
|
|
69
|
-
/** Tracks user-level writes (pre-namespace) for the memory view and onCommit
|
|
69
|
+
/** Tracks user-level writes (pre-namespace) for the memory view and onCommit
|
|
70
|
+
* — in their RETAINED form (cloned / summarised / redacted). Filled from
|
|
71
|
+
* {@link _pendingWrites} by {@link materialiseWrites} at commit. */
|
|
70
72
|
private _stageWrites;
|
|
73
|
+
/**
|
|
74
|
+
* The writes of the CURRENT execution, held by reference with the verdict
|
|
75
|
+
* they were staged under (9.23.0). A key written k times holds its LAST
|
|
76
|
+
* value; the retained form (the `writeTracking` clone or summary, the
|
|
77
|
+
* redaction placeholder or field scrub) is taken ONCE per key at commit —
|
|
78
|
+
* see {@link materialiseWrites}. Same law as the transaction buffer's
|
|
79
|
+
* patch trees: the copy the record needs is paid at the boundary, not at
|
|
80
|
+
* every write. Lazily allocated; a stage that never writes pays nothing.
|
|
81
|
+
*/
|
|
82
|
+
private _pendingWrites?;
|
|
71
83
|
/** Tracks user-level reads (pre-namespace) for the memory view. */
|
|
72
84
|
private _stageReads;
|
|
73
85
|
/**
|
|
@@ -103,8 +115,10 @@ export declare class StageContext {
|
|
|
103
115
|
* How tracked writes are recorded into `_stageWrites` (#13c-A) — the
|
|
104
116
|
* sibling of {@link readTracking}, with the same propagation pattern
|
|
105
117
|
* (inherited via {@link createNext}/{@link createChild}, pushed into
|
|
106
|
-
* subflow root contexts by `SubflowExecutor`). Governs the
|
|
107
|
-
*
|
|
118
|
+
* subflow root contexts by `SubflowExecutor`). Governs the retained form
|
|
119
|
+
* {@link materialiseWrites} takes at commit for the writes of
|
|
120
|
+
* {@link setObject}/{@link updateObject} (a clone under `'full'`, taken
|
|
121
|
+
* once per key — 9.23.0). Affects the
|
|
108
122
|
* snapshot's `stageWrites` payload AND the commit observer's mutations
|
|
109
123
|
* payload (which is a spread of `_stageWrites`) — but NOT the write
|
|
110
124
|
* itself: the transaction buffer, the commit log, and shared state are
|
|
@@ -223,20 +237,37 @@ export declare class StageContext {
|
|
|
223
237
|
/** Returns the active write-provenance policy (used for subflow propagation). */
|
|
224
238
|
getWriteProvenance(): WriteProvenanceMode;
|
|
225
239
|
/**
|
|
226
|
-
* Record a tracked user-level write
|
|
227
|
-
*
|
|
228
|
-
*
|
|
240
|
+
* Record a tracked user-level write, policy-gated (#13c-A) — the single
|
|
241
|
+
* bookkeeping path for {@link setObject} and {@link updateObject}. Holds
|
|
242
|
+
* the REFERENCE and the verdict (9.23.0); the retained form is taken at
|
|
243
|
+
* commit by {@link materialiseWrites}, once per key.
|
|
229
244
|
*
|
|
230
245
|
* Redaction takes precedence over the dial in EVERY mode: a redacted
|
|
231
246
|
* write stores the `'[REDACTED]'` placeholder under `'full'` AND
|
|
232
247
|
* `'summary'` (a summary marker would leak the value's preview/size),
|
|
233
248
|
* and stores nothing under `'off'` (entry skipped entirely — nothing to
|
|
234
249
|
* leak). A field-level verdict scrubs a clone BEFORE the dial sees it, so
|
|
235
|
-
* a summary preview can never show the secret either. The
|
|
236
|
-
*
|
|
237
|
-
* the
|
|
250
|
+
* a summary preview can never show the secret either. The verdict is the
|
|
251
|
+
* one `stageWrite` asked the rule for BEFORE staging, carried to commit
|
|
252
|
+
* with the value. The staged write itself is unaffected — redaction of
|
|
253
|
+
* the committed payload is handled by the transaction buffer's
|
|
254
|
+
* `redactedPaths`.
|
|
238
255
|
*/
|
|
239
256
|
private trackWrite;
|
|
257
|
+
/**
|
|
258
|
+
* `_stageWrites` with every pending write folded in as its retained form
|
|
259
|
+
* — the record a reader sees. Consumes nothing: a mid-run snapshot reads
|
|
260
|
+
* the writes so far, and commit still materialises the final values.
|
|
261
|
+
* Returns `_stageWrites` itself when nothing is pending.
|
|
262
|
+
*/
|
|
263
|
+
private retainedWrites;
|
|
264
|
+
/**
|
|
265
|
+
* Take the retained form of every pending write — ONCE per key, from the
|
|
266
|
+
* value as it stands at commit (9.23.0) — and release the references. The
|
|
267
|
+
* first thing `commit()` does, so the borrowed-mutation guard and the
|
|
268
|
+
* commit observer see the finished record.
|
|
269
|
+
*/
|
|
270
|
+
private materialiseWrites;
|
|
240
271
|
/**
|
|
241
272
|
* The form of a value the engine RETAINS (reads and writes retention):
|
|
242
273
|
* the placeholder beats every dial; a field-level scrub happens before the
|
|
@@ -471,10 +502,11 @@ export declare class StageContext {
|
|
|
471
502
|
* - `stateView` — the first-touch anchor; the next attempt re-anchors on
|
|
472
503
|
* committed state as it stands NOW (a sibling fork branch
|
|
473
504
|
* may legitimately have committed in between);
|
|
474
|
-
* - `_stageWrites` / `_stageReads` — the snapshot
|
|
475
|
-
* reset the execution tree would
|
|
476
|
-
* discarded, which is the exact
|
|
477
|
-
* to prevent
|
|
505
|
+
* - `_stageWrites` / `_pendingWrites` / `_stageReads` — the snapshot
|
|
506
|
+
* payload; without the reset the execution tree would
|
|
507
|
+
* report writes that were discarded, which is the exact
|
|
508
|
+
* lie this feature exists to prevent (the pending map
|
|
509
|
+
* holds bare references — dropping it un-clones nothing);
|
|
478
510
|
* - `_provenanceReads` — the per-write read prefix (#P1); a discarded
|
|
479
511
|
* attempt's reads must never appear in the next attempt's
|
|
480
512
|
* `TraceEntry.readKeys`, or a backward slice would follow
|