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,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
|
package/dist/esm/write.d.ts
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.
|
|
@@ -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.
|
|
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; } });
|