foottrace 1.0.3 → 1.1.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.
Files changed (44) hide show
  1. package/README.md +41 -2
  2. package/dist/esm/index.d.ts +5 -2
  3. package/dist/esm/index.js +2 -1
  4. package/dist/esm/lib/ids/attemptId.d.ts +63 -0
  5. package/dist/esm/lib/ids/attemptId.js +91 -0
  6. package/dist/esm/lib/memory/EventLog.d.ts +89 -3
  7. package/dist/esm/lib/memory/EventLog.js +242 -3
  8. package/dist/esm/lib/memory/honesty.d.ts +6 -4
  9. package/dist/esm/lib/memory/honesty.js +7 -4
  10. package/dist/esm/lib/memory/sink.d.ts +148 -0
  11. package/dist/esm/lib/memory/sink.js +92 -0
  12. package/dist/esm/lib/time-travel/bundles.d.ts +2 -0
  13. package/dist/esm/lib/time-travel/bundles.js +16 -0
  14. package/dist/esm/lib/time-travel/commitStops.js +21 -2
  15. package/dist/esm/lib/time-travel/index.d.ts +2 -0
  16. package/dist/esm/lib/time-travel/index.js +1 -0
  17. package/dist/esm/lib/time-travel/storedRecord.d.ts +195 -0
  18. package/dist/esm/lib/time-travel/storedRecord.js +494 -0
  19. package/dist/esm/lib/time-travel/timeTravel.js +11 -0
  20. package/dist/esm/lib/time-travel/types.d.ts +4 -1
  21. package/dist/esm/write.d.ts +7 -0
  22. package/dist/esm/write.js +6 -0
  23. package/dist/index.js +6 -2
  24. package/dist/lib/ids/attemptId.js +96 -0
  25. package/dist/lib/memory/EventLog.js +242 -3
  26. package/dist/lib/memory/honesty.js +7 -4
  27. package/dist/lib/memory/sink.js +98 -0
  28. package/dist/lib/time-travel/bundles.js +18 -1
  29. package/dist/lib/time-travel/commitStops.js +21 -2
  30. package/dist/lib/time-travel/index.js +3 -1
  31. package/dist/lib/time-travel/storedRecord.js +498 -0
  32. package/dist/lib/time-travel/timeTravel.js +11 -0
  33. package/dist/types/index.d.ts +5 -2
  34. package/dist/types/lib/ids/attemptId.d.ts +63 -0
  35. package/dist/types/lib/memory/EventLog.d.ts +89 -3
  36. package/dist/types/lib/memory/honesty.d.ts +6 -4
  37. package/dist/types/lib/memory/sink.d.ts +148 -0
  38. package/dist/types/lib/time-travel/bundles.d.ts +2 -0
  39. package/dist/types/lib/time-travel/index.d.ts +2 -0
  40. package/dist/types/lib/time-travel/storedRecord.d.ts +195 -0
  41. package/dist/types/lib/time-travel/types.d.ts +4 -1
  42. package/dist/types/write.d.ts +7 -0
  43. package/dist/write.js +8 -1
  44. package/package.json +1 -1
@@ -0,0 +1,494 @@
1
+ /**
2
+ * storedRecord.ts — reading back what a sink stored (1.1.0).
3
+ *
4
+ * WHY THIS FILE EXISTS. A log that writes to a `RecordSink` (`foottrace/write`) leaves its record in
5
+ * storage AS IT LANDS: an `opened` entry with the fold base, one `commit` entry per bundle, `note`
6
+ * entries, and a `closed` entry with how the log ended. A run that is killed never writes its
7
+ * `closed` entry. This reader turns stored entries back into logs a person or a model can read —
8
+ * and says, before any fold, which of them never finished (`complete: false`) and what each one is
9
+ * missing.
10
+ *
11
+ * WHAT IT READS. Entries come back from storage unvalidated (a parsed file, a table scan) — untrusted
12
+ * input — so it takes `Iterable<unknown>` and checks each one where it is read: the 9.18.0 law for
13
+ * stored recordings, no cast at the seam. An entry it does not read into a log is never dropped in
14
+ * silence: it is listed in `unread` with its position and the reason.
15
+ *
16
+ * THE LAWS IT KEEPS.
17
+ * 1. Grouped by address. Every entry names its log (`logRunId` + `drillPath`); entries of many
18
+ * logs may arrive interleaved, and each log keeps its own entries in arrival order.
19
+ * 2. A commit sits at its own index. `EventLog · record` stamps `bundle.idx` before the sink sees
20
+ * the bundle, so a commit lost on the way (a sink that failed once — the writer goes on) leaves a
21
+ * GAP at its index instead of shifting every commit after it; so does a bundle that is not a
22
+ * readable bundle. Every gap keeps its index, and the fold reports it (`skipped`) with the same
23
+ * reason the stored log gives (`gaps`). One place, one entry: the first READABLE commit entry
24
+ * for an index stands (a torn first delivery yields to a readable redelivery), the first
25
+ * `opened` stands, and every other entry for a place already held — a redelivery or a conflict,
26
+ * the very same object included — is listed in `unread`. Payloads are never compared, so no
27
+ * stored value is walked.
28
+ * 3. Complete means the writer finished — and the reader read it all. A log is `complete` when a
29
+ * `closed` entry covers every stored commit (`closed.commits` is past the last index); the
30
+ * covering close with the largest `commits` gives the `outcome` and the length, so no loss an
31
+ * earlier close showed can hide behind a later one. A log that was closed and then went on (a
32
+ * later commit) is not complete until it is closed again. A closed log whose store lost commits
33
+ * is complete AND has gaps (`closed.commits` makes even a lost LAST commit a gap). A log a limit
34
+ * cut, and every log once reading stopped at the `entries` limit, is not complete: what the
35
+ * reader did not read could have gone on.
36
+ * 4. Opaque stays opaque. `meta` and a note's `kind` and `data` are handed back as stored, never
37
+ * read or merged.
38
+ * 5. Read, never touched. Nothing here copies, freezes or edits the entries it is given — not the
39
+ * base, not `meta`: the stored log hands back their own values.
40
+ * 6. Bounded work (see {@link StoredRecordLimits}). Every walk is a loop over the input, never a
41
+ * recursion, and never through an input's own iterator except the input itself; the work per
42
+ * entry is bounded by the address limits; the rows a log holds are its stored commits plus gaps
43
+ * from ONE budget for the whole record, spent on the logs that need the fewest gaps first (so a
44
+ * corrupt log asking for the most is the one cut); and reading stops after `entries` entries. Whatever a
45
+ * limit stops is listed in `unread` by name — never a hang, never a silent cut.
46
+ *
47
+ * Entries are data. An entry whose fields throw while it is checked (a getter, a revoked proxy) is
48
+ * listed in `unread`, and what the reader computes with — the kind, a commit's index and bundle, a
49
+ * close's length — is taken once, as checked. An in-memory accessor that changes or throws on a later
50
+ * read is out of contract, as everywhere in the record (docs/extraction.md): parsed JSON and structured
51
+ * clones carry none.
52
+ *
53
+ * DAG position: memory ← time-travel; this file is a reader beside `timeTravel` and imports only
54
+ * record files.
55
+ */
56
+ import { stageIdOf } from '../ids/runtimeStageId.js';
57
+ import { LOG_OUTCOMES } from '../memory/sink.js';
58
+ import { absentRow, bundleRefusal, isPlainish, readLog } from './bundles.js';
59
+ /** The defaults — internal to the package; documented on {@link StoredRecordLimits}. */
60
+ export const DEFAULT_LIMITS = Object.freeze({
61
+ entries: 2 ** 24,
62
+ missingCommits: 2 ** 20,
63
+ drillDepth: 1024,
64
+ addressLength: 65_536,
65
+ });
66
+ /** The limits to read with: each a non-negative integer or `Infinity`, or a `RangeError` naming it. */
67
+ function limitsOf(given) {
68
+ if (given === undefined)
69
+ return DEFAULT_LIMITS;
70
+ const limits = { ...DEFAULT_LIMITS };
71
+ for (const name of Object.keys(DEFAULT_LIMITS)) {
72
+ const value = given[name];
73
+ if (value === undefined)
74
+ continue;
75
+ if (typeof value !== 'number' || !(value === Infinity || (Number.isSafeInteger(value) && value >= 0))) {
76
+ throw new RangeError(`readStoredRecord: limits.${name} must be a non-negative integer or Infinity`);
77
+ }
78
+ limits[name] = value;
79
+ }
80
+ return limits;
81
+ }
82
+ /** What an index holds when the record has no readable commit for it. */
83
+ const NO_COMMIT = absentRow('no readable commit entry for this index');
84
+ const KINDS = ['opened', 'commit', 'note', 'closed'];
85
+ /** A value as a reason names it: `missing`, `null`, `an array`, a short quoted string, a number, or `a <type>`. */
86
+ function described(value) {
87
+ if (value === undefined)
88
+ return 'missing';
89
+ if (value === null)
90
+ return 'null';
91
+ if (Array.isArray(value))
92
+ return 'an array';
93
+ if (typeof value === 'string')
94
+ return JSON.stringify(value.length > 40 ? `${value.slice(0, 40)}…` : value);
95
+ if (typeof value === 'number')
96
+ return String(value);
97
+ return `a ${typeof value}`;
98
+ }
99
+ /** A position or a count: a non-negative safe integer. */
100
+ function isCount(value) {
101
+ return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
102
+ }
103
+ /**
104
+ * The address an entry names, as a fresh frozen copy — or why it is not one the reader takes. Bounded
105
+ * and iterator-free: the drill path's length is read once and checked before any segment; segments are
106
+ * read by index, checked and copied in one pass that stops at the first one past the length limit.
107
+ */
108
+ function readAddress(address, limits) {
109
+ if (!isPlainish(address))
110
+ return `address is ${described(address)}, not a log address`;
111
+ const { logRunId, drillPath } = address;
112
+ if (typeof logRunId !== 'string')
113
+ return `address.logRunId is ${described(logRunId)}, not a string`;
114
+ if (!Array.isArray(drillPath))
115
+ return 'address.drillPath is not an array of strings';
116
+ const depth = drillPath.length;
117
+ if (depth > limits.drillDepth) {
118
+ return `address.drillPath has ${depth} segments, past the reader's limit of ${limits.drillDepth}`;
119
+ }
120
+ const tooLong = `address is longer than the reader's limit of ${limits.addressLength} characters`;
121
+ let length = logRunId.length;
122
+ if (length > limits.addressLength)
123
+ return tooLong;
124
+ const segments = [];
125
+ for (let i = 0; i < depth; i++) {
126
+ const segment = drillPath[i];
127
+ if (typeof segment !== 'string')
128
+ return 'address.drillPath is not an array of strings';
129
+ length += segment.length;
130
+ if (length > limits.addressLength)
131
+ return tooLong;
132
+ segments.push(segment);
133
+ }
134
+ return Object.freeze({ logRunId, drillPath: Object.freeze(segments) });
135
+ }
136
+ /** Why an optional `meta` field cannot be read, or `undefined`. */
137
+ function metaRefusal(meta) {
138
+ return meta === undefined || isPlainish(meta) ? undefined : `meta is ${described(meta)}, not a plain object`;
139
+ }
140
+ /**
141
+ * What the reader keeps of the fields only one kind carries — each field it computes with read ONCE
142
+ * (a commit's bundle and index, a close's length) — or why they cannot be read.
143
+ */
144
+ function payloadOf(row) {
145
+ switch (row.kind) {
146
+ case 'opened': {
147
+ if (!isPlainish(row.initialState))
148
+ return `initialState is ${described(row.initialState)}, not a plain object`;
149
+ return metaRefusal(row.meta) ?? { count: 0 };
150
+ }
151
+ case 'commit': {
152
+ const bundle = row.bundle;
153
+ if (!isPlainish(bundle))
154
+ return `bundle is ${described(bundle)}, not an object`;
155
+ const idx = bundle.idx;
156
+ return isCount(idx) ? { count: idx, bundle } : `bundle.idx is ${described(idx)}, not a commit index`;
157
+ }
158
+ case 'note':
159
+ if (typeof row.note !== 'string')
160
+ return `note is ${described(row.note)}, not a string`;
161
+ return isPlainish(row.data) ? { count: 0 } : `data is ${described(row.data)}, not a plain object`;
162
+ default: {
163
+ if (!LOG_OUTCOMES.includes(row.outcome)) {
164
+ return `outcome is ${described(row.outcome)}, not ${LOG_OUTCOMES.join(' | ')}`;
165
+ }
166
+ const commits = row.commits;
167
+ if (!isCount(commits))
168
+ return `commits is ${described(commits)}, not a commit count`;
169
+ return metaRefusal(row.meta) ?? { count: commits };
170
+ }
171
+ }
172
+ }
173
+ /** Read one stored entry, or say why not: what the reader keeps of it, and its address (the reader's own copy). */
174
+ function readEntry(row, index, limits) {
175
+ if (!isPlainish(row))
176
+ return `not an object (${row === null ? 'null' : Array.isArray(row) ? 'array' : typeof row})`;
177
+ if (typeof row.kind !== 'string' || !KINDS.includes(row.kind)) {
178
+ return `kind is ${described(row.kind)}, not ${KINDS.join(' | ')}`;
179
+ }
180
+ const address = readAddress(row.address, limits);
181
+ if (typeof address === 'string')
182
+ return address;
183
+ if (typeof row.at !== 'number' || !Number.isFinite(row.at))
184
+ return `at is ${described(row.at)}, not a finite number`;
185
+ const payload = payloadOf(row);
186
+ if (typeof payload === 'string')
187
+ return payload;
188
+ const entry = row;
189
+ const kind = row.kind;
190
+ const { count, bundle } = payload;
191
+ if (bundle === undefined)
192
+ return { item: { entry, index, kind, count }, address };
193
+ const refusal = bundleRefusal(bundle);
194
+ const item = refusal === undefined ? { entry, index, kind, count, bundle } : { entry, index, kind, count, bundle, refusal };
195
+ return { item, address };
196
+ }
197
+ /** The key a log is filed under: its address as a JSON array — one spelling per address, whatever the ids hold. */
198
+ function keyOf(logRunId, drillPath) {
199
+ return JSON.stringify([logRunId, ...drillPath]);
200
+ }
201
+ /** The close that covers `last`: the one with the largest `commits` past it (the latest of equals). */
202
+ function coveringClose(closes, last) {
203
+ let best;
204
+ for (const close of closes) {
205
+ if (close.count > last && (best === undefined || close.count >= best.count))
206
+ best = close;
207
+ }
208
+ return best;
209
+ }
210
+ /** One place, one entry: the first readable commit for an index, the first opened. The rest is unread. */
211
+ function groupOf(key, address, arrival, placed, unread) {
212
+ let opened;
213
+ const commits = new Map();
214
+ const closes = [];
215
+ const dropped = new Set();
216
+ for (const item of placed) {
217
+ if (item.kind === 'opened') {
218
+ if (opened === undefined)
219
+ opened = item;
220
+ else {
221
+ dropped.add(item);
222
+ unread.push({
223
+ index: item.index,
224
+ reason: `a second opened entry for this log — the one at ${opened.index} stands`,
225
+ });
226
+ }
227
+ }
228
+ else if (item.kind === 'commit') {
229
+ const held = commits.get(item.count);
230
+ if (held === undefined)
231
+ commits.set(item.count, item);
232
+ else if (held.refusal !== undefined && item.refusal === undefined) {
233
+ // A torn first delivery yields to a readable redelivery.
234
+ commits.set(item.count, item);
235
+ dropped.add(held);
236
+ }
237
+ else
238
+ dropped.add(item);
239
+ }
240
+ else if (item.kind === 'closed')
241
+ closes.push(item);
242
+ }
243
+ // Each commit entry not placed is named against the entry that holds its index in the end.
244
+ for (const item of dropped) {
245
+ if (item.kind !== 'commit')
246
+ continue;
247
+ const holder = commits.get(item.count);
248
+ const reason = item.refusal !== undefined && holder.refusal === undefined
249
+ ? `an unreadable commit entry for index ${item.count} — the readable one at ${holder.index} stands`
250
+ : `a second commit entry for index ${item.count} — the one at ${holder.index} stands`;
251
+ unread.push({ index: item.index, reason });
252
+ }
253
+ const present = [...commits.keys()].sort((a, b) => a - b);
254
+ const last = present.length > 0 ? present[present.length - 1] : -1;
255
+ const close = coveringClose(closes, last);
256
+ const wanted = close !== undefined ? close.count : last + 1;
257
+ return { key, address, arrival, placed, opened, commits, closes, dropped, present, wanted };
258
+ }
259
+ /**
260
+ * The longest prefix of a log the gap budget can hold: with `present` (the stored indices, ascending)
261
+ * below it, a length `n` needs `n - (stored indices below n)` gaps. Returns the longest `n ≤ wanted`
262
+ * whose gaps fit in `budget` — one pass over the stored indices.
263
+ */
264
+ function lengthWithin(wanted, present, budget) {
265
+ for (let i = 0; i < present.length; i++) {
266
+ // Below present[i] lie i stored indices and present[i] - i gaps: it fits while those gaps do.
267
+ if (present[i] - i > budget)
268
+ return Math.min(wanted, budget + i);
269
+ }
270
+ return Math.min(wanted, budget + present.length);
271
+ }
272
+ /**
273
+ * Spend the record's gap budget on one log and read it: a log that asks for more rows than the budget
274
+ * holds is cut — what lies past the cut is unread, by name, and the log is not complete.
275
+ */
276
+ function readGroup(group, limits, budget, stopped, unread) {
277
+ const { commits, closes, present, wanted } = group;
278
+ const left = budget.missing;
279
+ const fits = lengthWithin(wanted, present, left);
280
+ const past = new Set();
281
+ if (fits < wanted) {
282
+ const limit = `the reader's limit of ${limits.missingCommits} missing commits for the record (${left} were left for this log)`;
283
+ for (const [idx, item] of commits) {
284
+ if (idx < fits)
285
+ continue;
286
+ past.add(item);
287
+ commits.delete(idx);
288
+ unread.push({ index: item.index, reason: `bundle.idx is ${idx}, past ${limit}` });
289
+ }
290
+ for (const close of closes) {
291
+ const count = close.count;
292
+ if (count <= fits)
293
+ continue;
294
+ past.add(close);
295
+ unread.push({ index: close.index, reason: `commits is ${count}, past ${limit}` });
296
+ }
297
+ }
298
+ const stillPresent = past.size === 0 ? present : present.filter((idx) => idx < fits);
299
+ const lastKept = stillPresent.length > 0 ? stillPresent[stillPresent.length - 1] : -1;
300
+ const closing = coveringClose(past.size === 0 ? closes : closes.filter((close) => !past.has(close)), lastKept);
301
+ const length = closing !== undefined ? closing.count : lastKept + 1;
302
+ budget.missing -= length - stillPresent.length;
303
+ const rows = [];
304
+ for (let idx = 0; idx < length; idx++) {
305
+ const item = commits.get(idx);
306
+ if (item === undefined)
307
+ rows.push(NO_COMMIT);
308
+ else if (item.refusal !== undefined)
309
+ rows.push(absentRow(item.refusal));
310
+ else
311
+ rows.push(item.bundle);
312
+ }
313
+ Object.freeze(rows);
314
+ const { log, gaps } = readLog(rows);
315
+ const inLog = [];
316
+ const notes = [];
317
+ for (const item of group.placed) {
318
+ if (group.dropped.has(item) || past.has(item))
319
+ continue;
320
+ inLog.push(item.entry);
321
+ if (item.entry.kind === 'note') {
322
+ notes.push(Object.freeze({ kind: item.entry.note, at: item.entry.at, data: item.entry.data }));
323
+ }
324
+ }
325
+ // Complete only when the writer closed it AND the reader read it whole.
326
+ const ended = closing !== undefined && past.size === 0 && !stopped ? closing.entry : undefined;
327
+ const opened = group.opened?.entry;
328
+ const meta = {};
329
+ if (opened?.meta !== undefined)
330
+ meta.opened = opened.meta;
331
+ if (ended?.meta !== undefined)
332
+ meta.closed = ended.meta;
333
+ return {
334
+ key: group.key,
335
+ address: group.address,
336
+ rows,
337
+ fields: {
338
+ ...(opened ? { initialState: opened.initialState } : {}),
339
+ commitLog: Object.freeze(log),
340
+ gaps,
341
+ notes: Object.freeze(notes),
342
+ complete: ended !== undefined,
343
+ ...(ended ? { outcome: ended.outcome } : {}),
344
+ meta: Object.freeze(meta),
345
+ entries: Object.freeze(inLog),
346
+ },
347
+ };
348
+ }
349
+ /**
350
+ * The mount logs of one run leg, as a live snapshot's `subflowResults` reads to the cursor: keyed by
351
+ * each mount's runtimeStageId (the last segment of its drill path), ONE flat map for the whole leg —
352
+ * so `drill` reaches a nested mount from any cursor of the leg. Built once per leg, in one pass; the
353
+ * first log stored for a mount id holds it. `undefined` when the leg stored no mount log.
354
+ */
355
+ function mountsOf(groups) {
356
+ let results;
357
+ for (const group of groups) {
358
+ const { drillPath } = group.address;
359
+ if (drillPath.length === 0)
360
+ continue;
361
+ const mount = drillPath[drillPath.length - 1];
362
+ // A null-prototype map: a stored drill segment is a key, never a prototype.
363
+ results ??= Object.create(null);
364
+ if (results[mount] !== undefined)
365
+ continue;
366
+ const { initialState } = group.fields;
367
+ results[mount] = Object.freeze({
368
+ subflowId: stageIdOf(mount),
369
+ treeContext: Object.freeze({ history: group.rows, ...(initialState ? { initialState } : {}) }),
370
+ });
371
+ }
372
+ return results && Object.freeze(results);
373
+ }
374
+ /**
375
+ * Read a sink's stored entries back into logs.
376
+ *
377
+ * Bounded on untrusted input: no recursion, no payload walk, work per entry bounded by the address
378
+ * limits, at most `limits.missingCommits` gap rows across the record, and at most `limits.entries`
379
+ * entries read — whatever a limit stops is listed in `unread` by name.
380
+ *
381
+ * @param entries What a `RecordSink` stored (`memorySink().entries`, or entries parsed back from a
382
+ * file or a table), in the order they were written — typed `unknown` because stored entries come
383
+ * back unvalidated; each is checked here.
384
+ * @param limits Raise a default to read a larger record ({@link StoredRecordLimits}).
385
+ * @returns Every log by address, the run-leg roots, and every entry not read into a log.
386
+ * @throws RangeError when a limit is not a non-negative integer or `Infinity`; TypeError when
387
+ * `entries` is not iterable; whatever the input's own iterator throws. Never for an entry that is
388
+ * data (parsed JSON, a structured clone).
389
+ *
390
+ * @example
391
+ * ```typescript
392
+ * import { readStoredRecord, stateAt, timeTravel } from 'foottrace';
393
+ *
394
+ * const stored = readStoredRecord(entries); // e.g. JSON lines read back after a crash
395
+ * const run = stored.root('run-1');
396
+ * run?.complete; // false — it never closed
397
+ * run?.notes.map((n) => n.kind); // ['attempt', 'attempt'] — kept as they happened
398
+ * timeTravel(run!.source()).last(); // the cursor over what landed
399
+ * stateAt(run!.source(), run!.commitLog.length - 1).skipped; // a lost commit, by index and reason
400
+ * ```
401
+ */
402
+ export function readStoredRecord(entries, limits) {
403
+ const bounds = limitsOf(limits);
404
+ const unread = [];
405
+ const byKey = new Map();
406
+ const seen = new Map();
407
+ let stopped = false;
408
+ let index = 0;
409
+ for (const row of entries) {
410
+ if (index >= bounds.entries) {
411
+ stopped = true;
412
+ unread.push({ index, reason: `entries from here on are past the reader's limit of ${bounds.entries}; not read` });
413
+ break;
414
+ }
415
+ let read;
416
+ try {
417
+ read = readEntry(row, index, bounds);
418
+ }
419
+ catch {
420
+ read = 'the entry threw when it was read';
421
+ }
422
+ if (typeof read === 'string') {
423
+ unread.push({ index, reason: read });
424
+ }
425
+ else if (seen.has(row)) {
426
+ unread.push({ index, reason: `the same entry as at ${seen.get(row)}` });
427
+ }
428
+ else {
429
+ seen.set(row, index);
430
+ // One address per log: an entry's own copy is kept only by the first entry of its log.
431
+ const key = keyOf(read.address.logRunId, read.address.drillPath);
432
+ const log = byKey.get(key);
433
+ if (log === undefined)
434
+ byKey.set(key, { address: read.address, placed: [read.item] });
435
+ else
436
+ log.placed.push(read.item);
437
+ }
438
+ index++;
439
+ }
440
+ // Group, then spend the gap budget on the logs that need the fewest gaps first.
441
+ let arrival = 0;
442
+ const grouped = [];
443
+ for (const [key, { address, placed }] of byKey)
444
+ grouped.push(groupOf(key, address, arrival++, placed, unread));
445
+ const byNeed = [...grouped].sort((a, b) => a.wanted - a.present.length - (b.wanted - b.present.length) || a.arrival - b.arrival);
446
+ const budget = { missing: bounds.missingCommits };
447
+ const read = new Map();
448
+ for (const group of byNeed)
449
+ read.set(group, readGroup(group, bounds, budget, stopped, unread));
450
+ const groups = grouped.map((group) => read.get(group));
451
+ const legs = new Map();
452
+ for (const group of groups) {
453
+ const leg = legs.get(group.address.logRunId);
454
+ if (leg === undefined)
455
+ legs.set(group.address.logRunId, [group]);
456
+ else
457
+ leg.push(group);
458
+ }
459
+ const mountsByLeg = new Map();
460
+ const logs = new Map();
461
+ for (const group of groups) {
462
+ const { logRunId } = group.address;
463
+ const { initialState } = group.fields;
464
+ let cached;
465
+ const log = Object.freeze({
466
+ address: group.address,
467
+ ...group.fields,
468
+ source() {
469
+ if (cached === undefined) {
470
+ if (!mountsByLeg.has(logRunId))
471
+ mountsByLeg.set(logRunId, mountsOf(legs.get(logRunId)));
472
+ const subflowResults = mountsByLeg.get(logRunId);
473
+ cached = Object.freeze({
474
+ commitLog: group.rows,
475
+ ...(initialState ? { initialState } : {}),
476
+ ...(subflowResults ? { subflowResults } : {}),
477
+ });
478
+ }
479
+ return cached;
480
+ },
481
+ });
482
+ logs.set(group.key, log);
483
+ }
484
+ unread.sort((a, b) => a.index - b.index);
485
+ return Object.freeze({
486
+ logs,
487
+ root: (logRunId) => (typeof logRunId === 'string' ? logs.get(keyOf(logRunId, [])) : undefined),
488
+ log: (address) => {
489
+ const own = readAddress(address, bounds);
490
+ return typeof own === 'string' ? undefined : logs.get(keyOf(own.logRunId, own.drillPath));
491
+ },
492
+ unread: Object.freeze(unread.map((item) => Object.freeze({ index: item.index, reason: item.reason }))),
493
+ });
494
+ }
@@ -228,6 +228,13 @@ class Cursor {
228
228
  }
229
229
  // ── Drilling (law 1: a separate cursor, not a second position) ───────────
230
230
  drill(mountRuntimeStageId) {
231
+ // Never back into the mount this cursor is, or one it was drilled out of: a stored or hand-built
232
+ // log can name one (a mount id is unique in a sound run), and a viewer that opens every mount
233
+ // would loop. The walk is as deep as the drill.
234
+ for (let at = this; at !== undefined; at = at.parent) {
235
+ if (at.mountRuntimeStageId === mountRuntimeStageId)
236
+ return undefined;
237
+ }
231
238
  // On a chain, the mount is looked for in every leg — a subflow that ran
232
239
  // before the pause is in the first snapshot's results, one that ran after
233
240
  // is in the second's. Ids are unique across a sound chain (chain.ts
@@ -249,6 +256,10 @@ class Cursor {
249
256
  const tree = result?.treeContext;
250
257
  if (!results || !result || !tree || !Array.isArray(tree.history))
251
258
  return undefined;
259
+ // … nor into the log this cursor already reads (a mount log opened on its own knows no mount id).
260
+ if (this.sources.some((source) => source.commitLog === tree.history || source.history === tree.history)) {
261
+ return undefined;
262
+ }
252
263
  // `path` is the subflow PATH — the same string for every iteration of a
253
264
  // mount inside a loop. `mountRuntimeStageId` is the iteration actually
254
265
  // opened, so a UI can label and key three drill panels over `sfx#2`,
@@ -452,7 +452,10 @@ export interface TimeTravel<TMeta = unknown> {
452
452
  /**
453
453
  * A cursor over the subflow mounted at `mountRuntimeStageId` — the same
454
454
  * interface, its own log, its own fold base. `undefined` when that mount is
455
- * not in this run's subflow results.
455
+ * not in this run's subflow results, and when it is this cursor's own mount,
456
+ * one it was drilled out of, or the log it already reads (1.1.0: a stored or
457
+ * hand-built log can name one, and a viewer that opens every mount must not
458
+ * loop).
456
459
  *
457
460
  * A stop inside a subflow is NOT a stop of this cursor: subflows run in
458
461
  * isolated runtimes and commit to their own log, so the outer axis holds
@@ -15,6 +15,11 @@
15
15
  * The step's encoding is two dials (`RecordEncoding`): `commitValues` (`'full'` | `'delta'`) and
16
16
  * `writeProvenance` (`'off'` | `'reads-prefix'`, `WriteProvenanceMode`).
17
17
  *
18
+ * A log can also hand every entry to a `RecordSink` AS IT LANDS (`new EventLog(base, { sink,
19
+ * address })`): `opened`, `commit`, `note`, `closed` — so a run that never ends still leaves its
20
+ * record. `memorySink` is the reference adapter; `readStoredRecord` (`foottrace`) reads the stored
21
+ * entries back. A failing sink never stops the writer (`EventLog · sinkFailures`).
22
+ *
18
23
  * Every type this door names is the record's own: nothing here loads or names the engine
19
24
  * (test/architecture/layering.test.ts). Its names and the bytes they write are promised under the
20
25
  * record fixtures' re-pin policy (test/fixtures/README.md): a refactor never moves a byte.
@@ -41,6 +46,8 @@
41
46
  */
42
47
  export { EventLog } from './lib/memory/EventLog.js';
43
48
  export { SharedMemory } from './lib/memory/SharedMemory.js';
49
+ export type { RecordEntry, RecordSink } from './lib/memory/sink.js';
50
+ export { memorySink } from './lib/memory/sink.js';
44
51
  export type { CommitStamp } from './lib/memory/recordCommit.js';
45
52
  export type { RecordEncoding, WriteScrub, WriteVerb } from './lib/memory/RecordFrame.js';
46
53
  export { RecordFrame } from './lib/memory/RecordFrame.js';
package/dist/esm/write.js CHANGED
@@ -15,6 +15,11 @@
15
15
  * The step's encoding is two dials (`RecordEncoding`): `commitValues` (`'full'` | `'delta'`) and
16
16
  * `writeProvenance` (`'off'` | `'reads-prefix'`, `WriteProvenanceMode`).
17
17
  *
18
+ * A log can also hand every entry to a `RecordSink` AS IT LANDS (`new EventLog(base, { sink,
19
+ * address })`): `opened`, `commit`, `note`, `closed` — so a run that never ends still leaves its
20
+ * record. `memorySink` is the reference adapter; `readStoredRecord` (`foottrace`) reads the stored
21
+ * entries back. A failing sink never stops the writer (`EventLog · sinkFailures`).
22
+ *
18
23
  * Every type this door names is the record's own: nothing here loads or names the engine
19
24
  * (test/architecture/layering.test.ts). Its names and the bytes they write are promised under the
20
25
  * record fixtures' re-pin policy (test/fixtures/README.md): a refactor never moves a byte.
@@ -42,6 +47,7 @@
42
47
  // The heap and the log.
43
48
  export { EventLog } from './lib/memory/EventLog.js';
44
49
  export { SharedMemory } from './lib/memory/SharedMemory.js';
50
+ export { memorySink } from './lib/memory/sink.js';
45
51
  export { RecordFrame } from './lib/memory/RecordFrame.js';
46
52
  export { deepFreeze, freezeRecord } from './lib/capture/freeze.js';
47
53
  export { LOG_PLACEHOLDER } from './lib/memory/placeholders.js';
package/dist/index.js CHANGED
@@ -1,12 +1,15 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.deepEqual = exports.subflowSegmentsOf = exports.stageIdOf = exports.idPathSegments = exports.joinPath = exports.isExecutionKey = exports.PATH_DELIMITER = exports.EXECUTION_DELIMITER = exports.CommitRangeIndex = exports.HONESTY_CODES = exports.sliceToJSON = exports.sliceForKey = exports.resolveKeysReadSource = exports.keyTimeline = exports.keysReadFromMap = exports.keysReadFromExecutionTree = exports.forwardSliceToJSON = exports.forwardSliceForKey = exports.formatTimeline = exports.formatSlice = exports.formatForwardSlice = exports.elementProvenance = exports.arrayProvenance = exports.formatCausalChain = exports.flattenCausalDAG = exports.causalChain = exports.timeTravel = exports.tagStops = exports.stateAt = exports.splitAxis = exports.isCommitBundle = exports.filterStops = exports.commitStopsStrategy = exports.commitStops = exports.recordsPhases = exports.inferLegacyPhases = exports.findLastWriterWithBasis = exports.findLastWriter = exports.findCommits = exports.findCommit = exports.commitValueAtWithBasis = exports.commitValueAt = exports.commitIndexOf = exports.buildCommitIndex = exports.applySmartMerge = exports.UnknownVerbError = exports.splitStageId = exports.parseRuntimeStageId = exports.createExecutionCounter = exports.buildRuntimeStageId = void 0;
4
- exports.serveRecord = void 0;
3
+ exports.idPathSegments = exports.joinPath = exports.isExecutionKey = exports.PATH_DELIMITER = exports.EXECUTION_DELIMITER = exports.CommitRangeIndex = exports.HONESTY_CODES = exports.sliceToJSON = exports.sliceForKey = exports.resolveKeysReadSource = exports.keyTimeline = exports.keysReadFromMap = exports.keysReadFromExecutionTree = exports.forwardSliceToJSON = exports.forwardSliceForKey = exports.formatTimeline = exports.formatSlice = exports.formatForwardSlice = exports.elementProvenance = exports.arrayProvenance = exports.formatCausalChain = exports.flattenCausalDAG = exports.causalChain = exports.timeTravel = exports.tagStops = exports.stateAt = exports.splitAxis = exports.readStoredRecord = exports.isCommitBundle = exports.filterStops = exports.commitStopsStrategy = exports.commitStops = exports.recordsPhases = exports.inferLegacyPhases = exports.findLastWriterWithBasis = exports.findLastWriter = exports.findCommits = exports.findCommit = exports.commitValueAtWithBasis = exports.commitValueAt = exports.commitIndexOf = exports.buildCommitIndex = exports.applySmartMerge = exports.UnknownVerbError = exports.parseAttemptId = exports.buildAttemptId = exports.splitStageId = exports.parseRuntimeStageId = exports.createExecutionCounter = exports.buildRuntimeStageId = void 0;
4
+ exports.serveRecord = exports.deepEqual = exports.subflowSegmentsOf = exports.stageIdOf = void 0;
5
5
  var runtimeStageId_js_1 = require("./lib/ids/runtimeStageId.js");
6
6
  Object.defineProperty(exports, "buildRuntimeStageId", { enumerable: true, get: function () { return runtimeStageId_js_1.buildRuntimeStageId; } });
7
7
  Object.defineProperty(exports, "createExecutionCounter", { enumerable: true, get: function () { return runtimeStageId_js_1.createExecutionCounter; } });
8
8
  Object.defineProperty(exports, "parseRuntimeStageId", { enumerable: true, get: function () { return runtimeStageId_js_1.parseRuntimeStageId; } });
9
9
  Object.defineProperty(exports, "splitStageId", { enumerable: true, get: function () { return runtimeStageId_js_1.splitStageId; } });
10
+ var attemptId_js_1 = require("./lib/ids/attemptId.js");
11
+ Object.defineProperty(exports, "buildAttemptId", { enumerable: true, get: function () { return attemptId_js_1.buildAttemptId; } });
12
+ Object.defineProperty(exports, "parseAttemptId", { enumerable: true, get: function () { return attemptId_js_1.parseAttemptId; } });
10
13
  var verbs_js_1 = require("./lib/memory/verbs.js");
11
14
  Object.defineProperty(exports, "UnknownVerbError", { enumerable: true, get: function () { return verbs_js_1.UnknownVerbError; } });
12
15
  var verbs_js_2 = require("./lib/memory/verbs.js");
@@ -27,6 +30,7 @@ Object.defineProperty(exports, "commitStops", { enumerable: true, get: function
27
30
  Object.defineProperty(exports, "commitStopsStrategy", { enumerable: true, get: function () { return index_js_1.commitStopsStrategy; } });
28
31
  Object.defineProperty(exports, "filterStops", { enumerable: true, get: function () { return index_js_1.filterStops; } });
29
32
  Object.defineProperty(exports, "isCommitBundle", { enumerable: true, get: function () { return index_js_1.isCommitBundle; } });
33
+ Object.defineProperty(exports, "readStoredRecord", { enumerable: true, get: function () { return index_js_1.readStoredRecord; } });
30
34
  Object.defineProperty(exports, "splitAxis", { enumerable: true, get: function () { return index_js_1.splitAxis; } });
31
35
  Object.defineProperty(exports, "stateAt", { enumerable: true, get: function () { return index_js_1.stateAt; } });
32
36
  Object.defineProperty(exports, "tagStops", { enumerable: true, get: function () { return index_js_1.tagStops; } });