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,96 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* attemptId — the address of one attempt under a stage execution.
|
|
4
|
+
*
|
|
5
|
+
* Format:
|
|
6
|
+
* <runtimeStageId>@<n> attempt n of the stage execution 'charge#4@2'
|
|
7
|
+
* <runtimeStageId>@<n>.<k>@<m> attempt m of the k-th call made 'charge#4@2.1@3'
|
|
8
|
+
* during stage attempt n
|
|
9
|
+
*
|
|
10
|
+
* Every number after the runtimeStageId is a positive integer, counted from 1: attempt 1 is the first
|
|
11
|
+
* try, call 1 the first call the stage attempt made. Written without leading zeros, so one attempt has
|
|
12
|
+
* one spelling and an id is a safe join key.
|
|
13
|
+
*
|
|
14
|
+
* PARSED FROM THE RIGHT. A runtimeStageId always ends in `#<execution index>`, and the suffix grammar
|
|
15
|
+
* holds no `#`, so the LAST `#` of an attempt id is its execution delimiter whatever the stage id
|
|
16
|
+
* contains — `@`, `.` and `~` stay legal in stage ids, and no byte is newly reserved (the id doors'
|
|
17
|
+
* refusal, FootPrint's `refuseReservedId`, is untouched). The delimiter is read from the grammar's one
|
|
18
|
+
* owner, `runtimeStageId.ts`.
|
|
19
|
+
*
|
|
20
|
+
* Imports nothing but the runtime-ID grammar: a leaf of the record, checked by the package fence.
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* ```typescript
|
|
24
|
+
* buildAttemptId('charge#4', 2); // 'charge#4@2'
|
|
25
|
+
* buildAttemptId('charge#4', 2, { ordinal: 1, attempt: 3 }); // 'charge#4@2.1@3'
|
|
26
|
+
* parseAttemptId('sf/v1.2@x#7@3'); // { runtimeStageId: 'sf/v1.2@x#7', attempt: 3 }
|
|
27
|
+
* parseAttemptId('charge#4'); // undefined — a stage execution, not an attempt
|
|
28
|
+
* ```
|
|
29
|
+
*/
|
|
30
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
31
|
+
exports.parseAttemptId = exports.buildAttemptId = void 0;
|
|
32
|
+
const runtimeStageId_js_1 = require("./runtimeStageId.js");
|
|
33
|
+
/**
|
|
34
|
+
* What follows the last `#`: the execution index, `@` and the attempt, then — for a call — `.`, the
|
|
35
|
+
* call's ordinal, `@` and the call's attempt. Counts are canonical positive integers.
|
|
36
|
+
*/
|
|
37
|
+
const AFTER_EXECUTION_DELIMITER = /^(\d+)@([1-9]\d*)(?:\.([1-9]\d*)@([1-9]\d*))?$/;
|
|
38
|
+
const EXECUTION_INDEX = /^\d+$/;
|
|
39
|
+
/** True when `id` ends in `#<digits>` — the one shape every runtimeStageId has. */
|
|
40
|
+
function endsInExecutionIndex(id) {
|
|
41
|
+
const at = id.lastIndexOf(runtimeStageId_js_1.EXECUTION_DELIMITER);
|
|
42
|
+
return at !== -1 && EXECUTION_INDEX.test(id.slice(at + 1));
|
|
43
|
+
}
|
|
44
|
+
/** Refuse a count the parser could not give back: a positive safe integer, or a `RangeError`. */
|
|
45
|
+
function requireCount(name, value) {
|
|
46
|
+
if (!Number.isSafeInteger(value) || value < 1) {
|
|
47
|
+
throw new RangeError(`buildAttemptId: ${name} must be a positive integer (counted from 1), got ${String(value)}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Build the id of one attempt.
|
|
52
|
+
*
|
|
53
|
+
* @param runtimeStageId The stage execution the attempt belongs to (`[subflowPath/]stageId#index`).
|
|
54
|
+
* @param attempt The stage attempt, from 1.
|
|
55
|
+
* @param call For an attempt of a call made inside the stage attempt: the call's `ordinal` (1 for the
|
|
56
|
+
* first call that stage attempt made) and its own `attempt` (from 1).
|
|
57
|
+
* @throws TypeError when `runtimeStageId` does not end in `#<execution index>`, and RangeError when a
|
|
58
|
+
* count is not a positive integer — what `parseAttemptId` could not give back is never written.
|
|
59
|
+
*/
|
|
60
|
+
function buildAttemptId(runtimeStageId, attempt, call) {
|
|
61
|
+
if (typeof runtimeStageId !== 'string' || !endsInExecutionIndex(runtimeStageId)) {
|
|
62
|
+
throw new TypeError(`buildAttemptId: ${JSON.stringify(runtimeStageId)} is not a runtimeStageId (it must end in #<execution index>)`);
|
|
63
|
+
}
|
|
64
|
+
requireCount('attempt', attempt);
|
|
65
|
+
if (call === undefined)
|
|
66
|
+
return `${runtimeStageId}@${attempt}`;
|
|
67
|
+
requireCount('call ordinal', call.ordinal);
|
|
68
|
+
requireCount('call attempt', call.attempt);
|
|
69
|
+
return `${runtimeStageId}@${attempt}.${call.ordinal}@${call.attempt}`;
|
|
70
|
+
}
|
|
71
|
+
exports.buildAttemptId = buildAttemptId;
|
|
72
|
+
/**
|
|
73
|
+
* Parse an attempt id into its parts, or `undefined` when `id` is not one (a bare runtimeStageId, a
|
|
74
|
+
* count of 0, a leading zero, a missing part). Never throws.
|
|
75
|
+
*
|
|
76
|
+
* `parseAttemptId(buildAttemptId(...parts))` gives the parts back, whatever the stage id contains.
|
|
77
|
+
*/
|
|
78
|
+
function parseAttemptId(id) {
|
|
79
|
+
if (typeof id !== 'string')
|
|
80
|
+
return undefined;
|
|
81
|
+
const at = id.lastIndexOf(runtimeStageId_js_1.EXECUTION_DELIMITER);
|
|
82
|
+
if (at === -1)
|
|
83
|
+
return undefined;
|
|
84
|
+
const parts = AFTER_EXECUTION_DELIMITER.exec(id.slice(at + 1));
|
|
85
|
+
if (!parts)
|
|
86
|
+
return undefined;
|
|
87
|
+
const [, index, attempt, ordinal, callAttempt] = parts;
|
|
88
|
+
const counts = [attempt, ordinal, callAttempt].filter((n) => n !== undefined).map(Number);
|
|
89
|
+
if (!counts.every(Number.isSafeInteger))
|
|
90
|
+
return undefined;
|
|
91
|
+
const runtimeStageId = id.slice(0, at + 1 + index.length);
|
|
92
|
+
if (ordinal === undefined)
|
|
93
|
+
return { runtimeStageId, attempt: counts[0] };
|
|
94
|
+
return { runtimeStageId, attempt: counts[0], call: { ordinal: counts[1], attempt: counts[2] } };
|
|
95
|
+
}
|
|
96
|
+
exports.parseAttemptId = parseAttemptId;
|
|
@@ -25,11 +25,94 @@
|
|
|
25
25
|
* contract for state) and a `SharedArrayBuffer` (every copy shares its memory). A frozen RegExp's
|
|
26
26
|
* `lastIndex` is read-only, so a `/g` or `/y` regex read from a bundle throws when `exec` or
|
|
27
27
|
* `replace` advance it.
|
|
28
|
+
*
|
|
29
|
+
* A SINK (1.1.0, optional). `new EventLog(base, { sink, address, meta? })` also hands every entry to a
|
|
30
|
+
* `RecordSink` AS IT LANDS (`sink.ts`): `opened` from the constructor (the base, as the sink's own
|
|
31
|
+
* copy), `commit` from `record` after the bundle is frozen and appended (the bundle as served),
|
|
32
|
+
* `note` from `note`, `closed` from `close`. The log's own record never depends on the sink: the
|
|
33
|
+
* bundle is in `steps` before the sink sees it, and nothing the sink does reaches the writer — a
|
|
34
|
+
* throw, a rejected promise or an entry the log refuses to send (a `note` whose data is not a plain
|
|
35
|
+
* object that survives `structuredClone`) is counted (`sinkFailures`) and reported to
|
|
36
|
+
* `sink.onFailure`, and the writer goes on; a `meta` that will not copy is lost on its own and its
|
|
37
|
+
* entry still goes. Without a sink, nothing here runs: the one-argument log writes the same bytes,
|
|
38
|
+
* and has the same own fields, it always did.
|
|
28
39
|
*/
|
|
29
40
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
30
41
|
exports.EventLog = void 0;
|
|
31
42
|
const freeze_js_1 = require("../capture/freeze.js");
|
|
43
|
+
const sink_js_1 = require("./sink.js");
|
|
32
44
|
const utils_js_1 = require("./utils.js");
|
|
45
|
+
/** True when `value` has a callable `then` — an adapter that answered with a promise. */
|
|
46
|
+
function isThenable(value) {
|
|
47
|
+
return ((typeof value === 'object' || typeof value === 'function') &&
|
|
48
|
+
value !== null &&
|
|
49
|
+
typeof value.then === 'function');
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* The address, copied segment by segment and frozen — the log's identity cannot be edited through the
|
|
53
|
+
* caller's object, and a path with a hole or a non-string segment is refused rather than copied as one.
|
|
54
|
+
*/
|
|
55
|
+
function frozenAddress(address) {
|
|
56
|
+
const { logRunId, drillPath } = (address ?? {});
|
|
57
|
+
const refused = () => new TypeError('EventLog: a sink needs an address — { logRunId: string, drillPath: string[] }');
|
|
58
|
+
if (typeof logRunId !== 'string' || !Array.isArray(drillPath))
|
|
59
|
+
throw refused();
|
|
60
|
+
const segments = [];
|
|
61
|
+
for (let i = 0; i < drillPath.length; i++) {
|
|
62
|
+
const segment = drillPath[i];
|
|
63
|
+
if (typeof segment !== 'string')
|
|
64
|
+
throw refused();
|
|
65
|
+
segments.push(segment);
|
|
66
|
+
}
|
|
67
|
+
return Object.freeze({ logRunId, drillPath: Object.freeze(segments) });
|
|
68
|
+
}
|
|
69
|
+
/** A writer's payload as the sink keeps it: a plain object, copied — or a `TypeError` naming `field`. */
|
|
70
|
+
function copiedPayload(value, field) {
|
|
71
|
+
if (!(0, sink_js_1.isEntryObject)(value))
|
|
72
|
+
throw new TypeError(`EventLog: ${field} must be a plain object`);
|
|
73
|
+
return structuredClone(value);
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* The entry with its `meta` copied — or, when `meta` cannot be copied, the entry WITHOUT it and the
|
|
77
|
+
* error: the structural facts (the base, how the log ended, its length) still go; only `meta` is lost.
|
|
78
|
+
*/
|
|
79
|
+
function withCopiedMeta(entry) {
|
|
80
|
+
if (entry.meta === undefined)
|
|
81
|
+
return { ready: entry };
|
|
82
|
+
try {
|
|
83
|
+
return { ready: { ...entry, meta: copiedPayload(entry.meta, 'meta') } };
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
const { meta: _dropped, ...rest } = entry;
|
|
87
|
+
return { ready: rest, dropped: error };
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* The entry as the sink may keep it: a payload checked and copied (the writer keeps its own object),
|
|
92
|
+
* the bundle served. `dropped` is a `meta` that could not be copied (the entry goes without it). Throws
|
|
93
|
+
* when the entry itself cannot be sent; the caller counts it as a lost entry.
|
|
94
|
+
*/
|
|
95
|
+
function detached(entry) {
|
|
96
|
+
switch (entry.kind) {
|
|
97
|
+
case 'opened':
|
|
98
|
+
return withCopiedMeta(entry);
|
|
99
|
+
case 'commit':
|
|
100
|
+
return { ready: { ...entry, bundle: (0, freeze_js_1.serveRecord)(entry.bundle) } };
|
|
101
|
+
case 'note':
|
|
102
|
+
if (typeof entry.note !== 'string')
|
|
103
|
+
throw new TypeError('EventLog.note: kind must be a string');
|
|
104
|
+
return { ready: { ...entry, data: copiedPayload(entry.data, 'note data') } };
|
|
105
|
+
case 'closed':
|
|
106
|
+
if (!sink_js_1.LOG_OUTCOMES.includes(entry.outcome)) {
|
|
107
|
+
throw new TypeError(`EventLog.close: outcome must be ${sink_js_1.LOG_OUTCOMES.join(', ')}`);
|
|
108
|
+
}
|
|
109
|
+
return withCopiedMeta(entry);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
/** Swallows a rejection nobody waits for. */
|
|
113
|
+
function ignore() {
|
|
114
|
+
// A failure report that fails changes nothing: the loss is already counted.
|
|
115
|
+
}
|
|
33
116
|
class EventLog {
|
|
34
117
|
/** Base snapshot BEFORE the first stage mutates anything. */
|
|
35
118
|
base;
|
|
@@ -37,12 +120,39 @@ class EventLog {
|
|
|
37
120
|
steps = [];
|
|
38
121
|
sourceAddress;
|
|
39
122
|
sourceAddressRetired = false;
|
|
40
|
-
|
|
123
|
+
/**
|
|
124
|
+
* @param initialMemory The fold base — the state before the first commit (copied).
|
|
125
|
+
* @param options With a `sink`: every entry goes to it as it lands, under `address` — which also
|
|
126
|
+
* becomes this log's {@link address} — and `meta` rides the `opened` entry. The sink is told
|
|
127
|
+
* `opened` before the constructor returns.
|
|
128
|
+
* @throws TypeError when `options` names no sink, a malformed address, or a base that is not a plain
|
|
129
|
+
* object (the `opened` entry carries it) — a writer's mistake, refused at the door. Nothing the sink
|
|
130
|
+
* itself does is ever thrown.
|
|
131
|
+
*/
|
|
132
|
+
constructor(initialMemory, options) {
|
|
41
133
|
// Normalised to an object: the base is a STATE, and every fold over it
|
|
42
134
|
// (`materialise`, `stateAt`) applies path writes to it. `undefined` —
|
|
43
135
|
// what a run with no `initialContext` used to store here — made every
|
|
44
136
|
// one of those folds throw on the first `set`.
|
|
45
137
|
this.base = initialMemory === undefined ? {} : structuredClone(initialMemory);
|
|
138
|
+
if (options === undefined)
|
|
139
|
+
return;
|
|
140
|
+
if (!(0, sink_js_1.isEntryObject)(options.sink))
|
|
141
|
+
throw new TypeError('EventLog: options.sink must be a RecordSink');
|
|
142
|
+
if (!(0, sink_js_1.isEntryObject)(this.base)) {
|
|
143
|
+
throw new TypeError('EventLog: a log that writes to a sink needs a plain-object base — its opened entry carries it');
|
|
144
|
+
}
|
|
145
|
+
const address = frozenAddress(options.address);
|
|
146
|
+
this.sourceAddress = address;
|
|
147
|
+
this.sink = options.sink;
|
|
148
|
+
this.lostEntries = 0;
|
|
149
|
+
this.send({
|
|
150
|
+
kind: 'opened',
|
|
151
|
+
address,
|
|
152
|
+
at: Date.now(),
|
|
153
|
+
initialState: this.getInitialState(),
|
|
154
|
+
...(options.meta === undefined ? {} : { meta: options.meta }),
|
|
155
|
+
});
|
|
46
156
|
}
|
|
47
157
|
/**
|
|
48
158
|
* The fold BASE — the state as it stood before the first commit.
|
|
@@ -99,6 +209,124 @@ class EventLog {
|
|
|
99
209
|
bundle.idx = this.steps.length;
|
|
100
210
|
(0, freeze_js_1.freezeRecord)(bundle, 'indices');
|
|
101
211
|
this.steps.push(bundle);
|
|
212
|
+
if (this.sink !== undefined)
|
|
213
|
+
this.send({ kind: 'commit', address: this.sourceAddress, at: Date.now(), bundle });
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Note something beside the log, in the sink, as it happens — for evidence that must survive a run
|
|
217
|
+
* that never closes (an attempt that ended, before the next one starts). `kind` and `data` are the
|
|
218
|
+
* writer's: opaque to the record. Notes never enter the log's bundles, so the log's bytes are the
|
|
219
|
+
* same with or without them. Without a sink, nothing happens.
|
|
220
|
+
*
|
|
221
|
+
* Never throws: a `kind` that is not a string, `data` that is not a plain object surviving
|
|
222
|
+
* `structuredClone`, or a sink that fails is counted in {@link sinkFailures} and reported to
|
|
223
|
+
* `sink.onFailure`. The sink keeps its own copy of `data`.
|
|
224
|
+
*/
|
|
225
|
+
note(kind, data) {
|
|
226
|
+
if (this.sink === undefined)
|
|
227
|
+
return;
|
|
228
|
+
this.send({ kind: 'note', address: this.sourceAddress, at: Date.now(), note: kind, data });
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Tell the sink this log ended — `outcome` and, for the reader's check that the stored log is
|
|
232
|
+
* whole, how many commits it holds now. `meta` rides the entry, copied. Without a sink, nothing
|
|
233
|
+
* happens. The log itself stays as it is: a later `record` still lands, and still reaches the sink
|
|
234
|
+
* (a reader then reads the log as not complete until it is closed again).
|
|
235
|
+
*
|
|
236
|
+
* Never throws: an unknown outcome, a `meta` that is not a plain object surviving `structuredClone`,
|
|
237
|
+
* or a sink that fails is counted in {@link sinkFailures} and reported to `sink.onFailure`.
|
|
238
|
+
*/
|
|
239
|
+
close(outcome, meta) {
|
|
240
|
+
if (this.sink === undefined)
|
|
241
|
+
return;
|
|
242
|
+
this.send({
|
|
243
|
+
kind: 'closed',
|
|
244
|
+
address: this.sourceAddress,
|
|
245
|
+
at: Date.now(),
|
|
246
|
+
outcome,
|
|
247
|
+
commits: this.steps.length,
|
|
248
|
+
...(meta === undefined ? {} : { meta }),
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* How many entries the sink did not take: a sink method that threw or whose promise rejected, and an
|
|
253
|
+
* entry the log refused to send — plus each `meta` the log could not copy (that entry went without
|
|
254
|
+
* it). Counted as each happens (a rejection, when it settles); 0 without a sink. The stored record
|
|
255
|
+
* says the same from its side: a missing commit is a gap.
|
|
256
|
+
*/
|
|
257
|
+
get sinkFailures() {
|
|
258
|
+
return this.lostEntries ?? 0;
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* One entry to the sink. Nothing here reaches the writer: a refusal, a throw or a rejection is a lost
|
|
262
|
+
* entry; a `meta` that cannot be copied is lost on its own — counted and reported — and the entry goes
|
|
263
|
+
* without it.
|
|
264
|
+
*/
|
|
265
|
+
send(entry) {
|
|
266
|
+
const sink = this.sink;
|
|
267
|
+
if (sink === undefined)
|
|
268
|
+
return;
|
|
269
|
+
let sent = entry;
|
|
270
|
+
try {
|
|
271
|
+
const { ready, dropped } = detached(entry);
|
|
272
|
+
sent = Object.freeze(ready);
|
|
273
|
+
if (dropped !== undefined)
|
|
274
|
+
this.lost(sink, entry, dropped);
|
|
275
|
+
const returned = (0, sink_js_1.sendEntry)(sink, sent);
|
|
276
|
+
if (isThenable(returned))
|
|
277
|
+
this.watch(sink, sent, returned);
|
|
278
|
+
}
|
|
279
|
+
catch (error) {
|
|
280
|
+
this.lost(sink, sent, error);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Count the rejection of an adapter's promise once. A native promise is watched through the real
|
|
285
|
+
* `Promise.prototype.then` (an own `then` on it is not trusted); any other thenable is adopted into a
|
|
286
|
+
* fresh promise first, whose settling functions run once. Either way the rejection is handled.
|
|
287
|
+
*/
|
|
288
|
+
watch(sink, entry, returned) {
|
|
289
|
+
let counted = false;
|
|
290
|
+
const onRejected = (error) => {
|
|
291
|
+
if (counted)
|
|
292
|
+
return;
|
|
293
|
+
counted = true;
|
|
294
|
+
this.lost(sink, entry, error);
|
|
295
|
+
};
|
|
296
|
+
if (returned instanceof Promise)
|
|
297
|
+
Promise.prototype.then.call(returned, undefined, onRejected);
|
|
298
|
+
else
|
|
299
|
+
new Promise((resolve) => resolve(returned)).then(undefined, onRejected);
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Count a lost entry and tell the sink's `onFailure`, if it listens; a report that fails is ignored.
|
|
303
|
+
* A loss caused while `onFailure` runs — until the promise it returns settles, when it returns one —
|
|
304
|
+
* (it wrote to this log again, through the same failing sink) is counted and not reported again: one
|
|
305
|
+
* report deep, never a runaway.
|
|
306
|
+
*/
|
|
307
|
+
lost(sink, entry, error) {
|
|
308
|
+
this.lostEntries = (this.lostEntries ?? 0) + 1;
|
|
309
|
+
if (this.reporting === true)
|
|
310
|
+
return;
|
|
311
|
+
this.reporting = true;
|
|
312
|
+
let pending = false;
|
|
313
|
+
try {
|
|
314
|
+
const reported = sink.onFailure?.({ entry, error });
|
|
315
|
+
if (isThenable(reported)) {
|
|
316
|
+
pending = true;
|
|
317
|
+
const done = () => {
|
|
318
|
+
this.reporting = false;
|
|
319
|
+
};
|
|
320
|
+
new Promise((resolve) => resolve(reported)).then(done, done);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
catch {
|
|
324
|
+
ignore();
|
|
325
|
+
}
|
|
326
|
+
finally {
|
|
327
|
+
if (!pending)
|
|
328
|
+
this.reporting = false;
|
|
329
|
+
}
|
|
102
330
|
}
|
|
103
331
|
/**
|
|
104
332
|
* Every recorded commit bundle, AS SERVED (9.44.2): a new array each call; a bundle freezing sealed
|
|
@@ -122,7 +350,10 @@ class EventLog {
|
|
|
122
350
|
get length() {
|
|
123
351
|
return this.steps.length;
|
|
124
352
|
}
|
|
125
|
-
/**
|
|
353
|
+
/**
|
|
354
|
+
* Immutable identity of this log: the sink's address when the log was built with one, otherwise
|
|
355
|
+
* absent until the engine binds it.
|
|
356
|
+
*/
|
|
126
357
|
get address() {
|
|
127
358
|
return this.sourceAddress;
|
|
128
359
|
}
|
|
@@ -138,8 +369,16 @@ class EventLog {
|
|
|
138
369
|
return undefined;
|
|
139
370
|
return Object.freeze({ runId, ...this.sourceAddress, committedThroughIdx: this.steps.length - 1 });
|
|
140
371
|
}
|
|
141
|
-
/**
|
|
372
|
+
/**
|
|
373
|
+
* Wipes history (useful for test resets).
|
|
374
|
+
*
|
|
375
|
+
* @throws Error on a log built with a sink: what the sink stored cannot be wiped, and a second
|
|
376
|
+
* history under the same address would read back as one corrupted log. Start a fresh EventLog.
|
|
377
|
+
*/
|
|
142
378
|
clear() {
|
|
379
|
+
if (this.sink !== undefined) {
|
|
380
|
+
throw new Error('EventLog.clear: this log writes to a sink, and a stored record cannot be wiped — use a new EventLog');
|
|
381
|
+
}
|
|
143
382
|
this.steps = [];
|
|
144
383
|
// A reset prefix cannot reuse old coordinates, even if another stage of
|
|
145
384
|
// the SAME leg binds it again. Keep clear's data behavior, but retire
|
|
@@ -24,10 +24,11 @@
|
|
|
24
24
|
* alias of `foottrace` that is a union of string literals: each one is inside the
|
|
25
25
|
* registry or is named, with its reason, as not an honesty vocabulary.
|
|
26
26
|
*
|
|
27
|
-
* NO OBJECT GAINS A FIELD from being registered.
|
|
28
|
-
* `LogGap` (`'log-gap'`), `CausalNode.incompleteSources` (`'incomplete-sources'`)
|
|
29
|
-
* `FoldedState.redacted` / `redactedPaths` (`'redacted'`)
|
|
30
|
-
*
|
|
27
|
+
* NO OBJECT GAINS A FIELD from being registered. Four signals carry no code of their own — a
|
|
28
|
+
* `LogGap` (`'log-gap'`), `CausalNode.incompleteSources` (`'incomplete-sources'`),
|
|
29
|
+
* `FoldedState.redacted` / `redactedPaths` (`'redacted'`) and an entry of `StoredRecord.unread`
|
|
30
|
+
* (`'unread-entry'`, 1.1.0) — and a `truncated: { byDepth, byNodes }` field means `'truncated'`; they
|
|
31
|
+
* are registered so a reader explains them from the same place.
|
|
31
32
|
*
|
|
32
33
|
* NOT HERE: the two strings a redaction leaves where a value was. The engine writes those on every
|
|
33
34
|
* redacted run; the log's lives in its own leaf, `memory/placeholders.ts` (the scope channel's with
|
|
@@ -84,6 +85,8 @@ exports.HONESTY_CODES = Object.freeze({
|
|
|
84
85
|
'log-only': 'No initialState travelled with this log (a snapshot stored before 9.17, a redacted snapshot or a hand-built log), so the fold started from an empty object: a key seeded before the run is whole only once a commit since wrote its whole value, and until then it holds just what its merges, appends and nested writes added, or nothing.',
|
|
85
86
|
// ── a row of a stored log that is not a commit bundle — `LogGap` ──────────
|
|
86
87
|
'log-gap': 'A row of the stored log could not be read as a commit bundle, so it keeps its index but contributes no state, and any fold that crosses it is missing whatever that row wrote.',
|
|
88
|
+
// ── an entry a sink stored that no log could hold — `StoredRecord.unread` (1.1.0) ─
|
|
89
|
+
'unread-entry': "An entry a sink stored was not read into any log (it could not be read as an opened, commit, note or closed entry, it lay past one of the reader's limits, or another entry holds its place), so whatever it said (a base, a commit, a note or how the log ended) is not in the record read back.",
|
|
87
90
|
// ── untracked reads a stage also consumed — `CausalNode.incompleteSources` ─
|
|
88
91
|
'incomplete-sources': 'The stage also consumed read paths that bypass read tracking (args, env or an unshadowed silent read), so a slice through it may be incomplete: those reads produce no read-to-write edge to follow.',
|
|
89
92
|
// ── values scrubbed at write time — `FoldedState.redacted` / `redactedPaths`, a value basis ─
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* sink.ts — the record's sink port: where a log's entries go AS THEY LAND, so a run that never ends
|
|
4
|
+
* still leaves its record behind.
|
|
5
|
+
*
|
|
6
|
+
* A log that is given a sink (`new EventLog(base, { sink, address })`) hands it four kinds of entry,
|
|
7
|
+
* each stamped with the log's address and the time it was written (`at`, epoch ms):
|
|
8
|
+
*
|
|
9
|
+
* - `opened` — once, from the constructor: the fold base (`initialState`, the sink's own copy);
|
|
10
|
+
* - `commit` — per `record()`, AFTER the bundle is frozen: the log's own bundle, as served;
|
|
11
|
+
* - `note` — per `note(kind, data)`: something the writer wants kept beside the log, as it happens;
|
|
12
|
+
* - `closed` — per `close(outcome)`: how the log ended, and how many commits it held then (`commits`).
|
|
13
|
+
*
|
|
14
|
+
* These are RECORD facts. What the writer means by them rides `meta` (on `opened` / `closed`) and a
|
|
15
|
+
* note's `kind` and `data`: opaque here — stored and handed back, never read or interpreted. Nothing in
|
|
16
|
+
* this file names an engine.
|
|
17
|
+
*
|
|
18
|
+
* THE STORED FORM. A sink stores each entry as a {@link RecordEntry}: the method's entry plus the
|
|
19
|
+
* method's name as `kind`. A note's own kind cannot also be called `kind`, so it is stored as `note`.
|
|
20
|
+
* `memorySink` is the reference adapter; `readStoredRecord` (`foottrace`) reads the entries back.
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* ```typescript
|
|
24
|
+
* import { EventLog, memorySink } from 'foottrace/write';
|
|
25
|
+
* import { readStoredRecord } from 'foottrace';
|
|
26
|
+
*
|
|
27
|
+
* const sink = memorySink();
|
|
28
|
+
* const log = new EventLog({ count: 0 }, { sink, address: { logRunId: 'run-1', drillPath: [] } });
|
|
29
|
+
* log.note('attempt', { attempt: 1, outcome: 'failed' });
|
|
30
|
+
* // … the process dies here: no `closed` entry …
|
|
31
|
+
* readStoredRecord(sink.entries).root('run-1')?.complete; // false — and its note is still there
|
|
32
|
+
* ```
|
|
33
|
+
*/
|
|
34
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
35
|
+
exports.memorySink = exports.sendEntry = exports.isEntryObject = exports.LOG_OUTCOMES = void 0;
|
|
36
|
+
const freeze_js_1 = require("../capture/freeze.js");
|
|
37
|
+
/** The outcomes a `closed` entry may carry, in one place for the writer's and the reader's checks. */
|
|
38
|
+
exports.LOG_OUTCOMES = Object.freeze(['ok', 'failed', 'paused']);
|
|
39
|
+
/** A plain object — not null, not an array, not a primitive. */
|
|
40
|
+
function isEntryObject(value) {
|
|
41
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
42
|
+
}
|
|
43
|
+
exports.isEntryObject = isEntryObject;
|
|
44
|
+
/** `{ meta }` when there is one, `{}` otherwise — an absent field stays absent. */
|
|
45
|
+
function withMeta(meta) {
|
|
46
|
+
return meta === undefined ? {} : { meta };
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Hand `entry` to the sink's method for its kind, as that method's entry; returns what the method
|
|
50
|
+
* returned (a promise from an async adapter). Throws whatever the method throws — the log catches.
|
|
51
|
+
*/
|
|
52
|
+
function sendEntry(sink, entry) {
|
|
53
|
+
const { address, at } = entry;
|
|
54
|
+
switch (entry.kind) {
|
|
55
|
+
case 'opened':
|
|
56
|
+
return sink.opened({ address, at, initialState: entry.initialState, ...withMeta(entry.meta) });
|
|
57
|
+
case 'commit':
|
|
58
|
+
return sink.commit({ address, at, bundle: entry.bundle });
|
|
59
|
+
case 'note':
|
|
60
|
+
return sink.note({ address, at, kind: entry.note, data: entry.data });
|
|
61
|
+
case 'closed':
|
|
62
|
+
return sink.closed({ address, at, outcome: entry.outcome, commits: entry.commits, ...withMeta(entry.meta) });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
exports.sendEntry = sendEntry;
|
|
66
|
+
/**
|
|
67
|
+
* A sink that keeps every entry in memory, in arrival order — the reference adapter: what any adapter
|
|
68
|
+
* must give back for `readStoredRecord` to read the same record. `entries` is a new array on each read.
|
|
69
|
+
* What it keeps is frozen as far as freezing reaches — the base, `meta` and a note's `data` are the
|
|
70
|
+
* copies a log hands it, and the bundle is already frozen — so no reader can rewrite its containers. A
|
|
71
|
+
* Date's time, a Map's or Set's entries and a buffer's bytes are not sealed by freezing (`deepFreeze`'s
|
|
72
|
+
* stated limit). A caller that hands it objects directly hands them over: they are frozen in place.
|
|
73
|
+
*
|
|
74
|
+
* @example
|
|
75
|
+
* ```typescript
|
|
76
|
+
* const sink = memorySink();
|
|
77
|
+
* const log = new EventLog({}, { sink, address: { logRunId: 'run-1', drillPath: [] } });
|
|
78
|
+
* log.close('ok');
|
|
79
|
+
* sink.entries.map((e) => e.kind); // ['opened', 'closed']
|
|
80
|
+
* ```
|
|
81
|
+
*/
|
|
82
|
+
function memorySink() {
|
|
83
|
+
const stored = [];
|
|
84
|
+
const keep = (entry) => {
|
|
85
|
+
stored.push((0, freeze_js_1.deepFreeze)(entry));
|
|
86
|
+
};
|
|
87
|
+
return {
|
|
88
|
+
get entries() {
|
|
89
|
+
return stored.slice();
|
|
90
|
+
},
|
|
91
|
+
opened: ({ address, at, initialState, meta }) => keep({ kind: 'opened', address, at, initialState, ...withMeta(meta) }),
|
|
92
|
+
commit: ({ address, at, bundle }) => keep({ kind: 'commit', address, at, bundle }),
|
|
93
|
+
note: ({ address, at, kind, data }) => keep({ kind: 'note', address, at, note: kind, data }),
|
|
94
|
+
closed: ({ address, at, outcome, commits, meta }) => keep({ kind: 'closed', address, at, outcome, commits, ...withMeta(meta) }),
|
|
95
|
+
flush: () => Promise.resolve(),
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
exports.memorySink = memorySink;
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* not the recording.
|
|
18
18
|
*/
|
|
19
19
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
20
|
-
exports.readLog = exports.isCommitBundle = exports.bundleRefusal = exports.readTree = exports.isPlainish = void 0;
|
|
20
|
+
exports.readLog = exports.isCommitBundle = exports.bundleRefusal = exports.readTree = exports.absentRow = exports.isPlainish = void 0;
|
|
21
21
|
const verbs_js_1 = require("../memory/verbs.js");
|
|
22
22
|
/**
|
|
23
23
|
* The stand-in a gap leaves at its index: a commit that belongs to no stage
|
|
@@ -43,6 +43,20 @@ function isPlainish(value) {
|
|
|
43
43
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
44
44
|
}
|
|
45
45
|
exports.isPlainish = isPlainish;
|
|
46
|
+
/**
|
|
47
|
+
* Rows that hold an index for a commit the record does not have (1.1.0) — what a STORED log could not
|
|
48
|
+
* supply at a position: no readable commit entry (`storedRecord.ts`). Each
|
|
49
|
+
* carries the reason every reader then reports for its index, so the fold, the cursor and the stored
|
|
50
|
+
* log say the same thing about it. Kept by identity (a JSON copy of one is an ordinary unreadable row).
|
|
51
|
+
*/
|
|
52
|
+
const ABSENT_ROWS = new WeakMap();
|
|
53
|
+
/** A row every reader refuses with `reason` — a gap that keeps its index. */
|
|
54
|
+
function absentRow(reason) {
|
|
55
|
+
const row = Object.freeze({});
|
|
56
|
+
ABSENT_ROWS.set(row, reason);
|
|
57
|
+
return row;
|
|
58
|
+
}
|
|
59
|
+
exports.absentRow = absentRow;
|
|
46
60
|
/**
|
|
47
61
|
* Read a source's `executionTree` as an {@link ExecutionTree}, or as absent.
|
|
48
62
|
*
|
|
@@ -81,6 +95,9 @@ exports.readTree = readTree;
|
|
|
81
95
|
* compatibility break dressed up as validation.
|
|
82
96
|
*/
|
|
83
97
|
function bundleRefusal(row) {
|
|
98
|
+
const absent = typeof row === 'object' && row !== null ? ABSENT_ROWS.get(row) : undefined;
|
|
99
|
+
if (absent !== undefined)
|
|
100
|
+
return absent;
|
|
84
101
|
if (!isPlainish(row)) {
|
|
85
102
|
return `not an object (${row === null ? 'null' : Array.isArray(row) ? 'array' : typeof row})`;
|
|
86
103
|
}
|
|
@@ -13,6 +13,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
13
13
|
exports.commitStopsStrategy = exports.commitStops = void 0;
|
|
14
14
|
const runtimeStageId_js_1 = require("../ids/runtimeStageId.js");
|
|
15
15
|
const commitLogUtils_js_1 = require("../memory/commitLogUtils.js");
|
|
16
|
+
const bundles_js_1 = require("./bundles.js");
|
|
16
17
|
/**
|
|
17
18
|
* Every `runtimeStageId` in the execution tree that the engine marked as a
|
|
18
19
|
* subflow mount. Authoritative, as is a bundle's recorded `phase: 'exit'`
|
|
@@ -92,7 +93,7 @@ function commitStops(commitLog, executionTree, subflowResults) {
|
|
|
92
93
|
// the MOUNT's commit, made before any of them ran. It gets no stop of its
|
|
93
94
|
// own; it belongs to the `'start'` bookend, which is where a reader looks
|
|
94
95
|
// for "the input this chart began with" anyway. See {@link seedCommits}.
|
|
95
|
-
const seeds = seedCommits(commitLog, executionTree);
|
|
96
|
+
const seeds = seedCommits(commitLog, executionTree, subflowResults);
|
|
96
97
|
const firsts = [];
|
|
97
98
|
for (const [id, i] of (0, commitLogUtils_js_1.buildCommitIndex)(commitLog))
|
|
98
99
|
if (id && i >= seeds)
|
|
@@ -163,6 +164,22 @@ function phasesOf(commitLog, haveTree) {
|
|
|
163
164
|
return commitLog.map((bundle) => bundle.phase);
|
|
164
165
|
return haveTree ? [] : (0, commitLogUtils_js_1.inferLegacyPhases)(commitLog);
|
|
165
166
|
}
|
|
167
|
+
/**
|
|
168
|
+
* True when the results map's entry for `head` IS this log — its history opens with this very bundle.
|
|
169
|
+
* The log is then that mount's own, and its head run is the mount's seed (R13: a seed carries its
|
|
170
|
+
* mount's runtimeStageId). This is what a TREE-LESS cursor over a mount log reads in place of the
|
|
171
|
+
* tree's root — a stored log read back by `readStoredRecord`, or a log whose stage after the seed was
|
|
172
|
+
* lost — so the seed opens `start` instead of standing as a stop (marked as its own mount).
|
|
173
|
+
*/
|
|
174
|
+
function opensOwnMount(subflowResults, head, first) {
|
|
175
|
+
if (!(0, bundles_js_1.isPlainish)(subflowResults))
|
|
176
|
+
return false;
|
|
177
|
+
const result = subflowResults[head];
|
|
178
|
+
if (!(0, bundles_js_1.isPlainish)(result) || !(0, bundles_js_1.isPlainish)(result.treeContext))
|
|
179
|
+
return false;
|
|
180
|
+
const history = result.treeContext.history;
|
|
181
|
+
return Array.isArray(history) && history[0] === first;
|
|
182
|
+
}
|
|
166
183
|
/** `commitStops` as a {@link TimeTravelStrategy} — the default for `timeTravel`. */
|
|
167
184
|
exports.commitStopsStrategy = { stopsFor: commitStops };
|
|
168
185
|
/**
|
|
@@ -183,7 +200,7 @@ exports.commitStopsStrategy = { stopsFor: commitStops };
|
|
|
183
200
|
* whose id carries the same subflow path. Without a tree such a log reads as
|
|
184
201
|
* one stop.
|
|
185
202
|
*/
|
|
186
|
-
function seedCommits(commitLog, executionTree) {
|
|
203
|
+
function seedCommits(commitLog, executionTree, subflowResults) {
|
|
187
204
|
const head = commitLog[0]?.runtimeStageId;
|
|
188
205
|
if (head === undefined)
|
|
189
206
|
return 0;
|
|
@@ -196,6 +213,8 @@ function seedCommits(commitLog, executionTree) {
|
|
|
196
213
|
let next = 1;
|
|
197
214
|
while (next < commitLog.length && commitLog[next].runtimeStageId === head)
|
|
198
215
|
next++;
|
|
216
|
+
if (executionTree === undefined && opensOwnMount(subflowResults, head, commitLog[0]))
|
|
217
|
+
return next;
|
|
199
218
|
const address = (0, runtimeStageId_js_1.stageIdOf)(head);
|
|
200
219
|
if (next >= commitLog.length) {
|
|
201
220
|
const rootId = executionTree?.id;
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* zero-dependency id grammar).
|
|
15
15
|
*/
|
|
16
16
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
-
exports.timeTravel = exports.tagStops = exports.stateAt = exports.commitStopsStrategy = exports.commitStops = exports.isCommitBundle = exports.splitAxis = exports.filterStops = void 0;
|
|
17
|
+
exports.timeTravel = exports.tagStops = exports.readStoredRecord = exports.stateAt = exports.commitStopsStrategy = exports.commitStops = exports.isCommitBundle = exports.splitAxis = exports.filterStops = void 0;
|
|
18
18
|
var axis_js_1 = require("./axis.js");
|
|
19
19
|
Object.defineProperty(exports, "filterStops", { enumerable: true, get: function () { return axis_js_1.filterStops; } });
|
|
20
20
|
Object.defineProperty(exports, "splitAxis", { enumerable: true, get: function () { return axis_js_1.splitAxis; } });
|
|
@@ -25,6 +25,8 @@ Object.defineProperty(exports, "commitStops", { enumerable: true, get: function
|
|
|
25
25
|
Object.defineProperty(exports, "commitStopsStrategy", { enumerable: true, get: function () { return commitStops_js_1.commitStopsStrategy; } });
|
|
26
26
|
var stateAt_js_1 = require("./stateAt.js");
|
|
27
27
|
Object.defineProperty(exports, "stateAt", { enumerable: true, get: function () { return stateAt_js_1.stateAt; } });
|
|
28
|
+
var storedRecord_js_1 = require("./storedRecord.js");
|
|
29
|
+
Object.defineProperty(exports, "readStoredRecord", { enumerable: true, get: function () { return storedRecord_js_1.readStoredRecord; } });
|
|
28
30
|
var tagStops_js_1 = require("./tagStops.js");
|
|
29
31
|
Object.defineProperty(exports, "tagStops", { enumerable: true, get: function () { return tagStops_js_1.tagStops; } });
|
|
30
32
|
var timeTravel_js_1 = require("./timeTravel.js");
|