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