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
package/README.md
CHANGED
|
@@ -10,8 +10,8 @@ Foottrace owns the record layer used by footprintjs 10. Older stored records rem
|
|
|
10
10
|
|
|
11
11
|
| Import | Responsibility |
|
|
12
12
|
| --- | --- |
|
|
13
|
-
| `foottrace` | Record types, runtime IDs, replay, time-travel cursors, causal and variable slices, honesty codes |
|
|
14
|
-
| `foottrace/write` | `SharedMemory`, `EventLog`, `RecordFrame`, encoding and write types, writer-owned freezing |
|
|
13
|
+
| `foottrace` | Record types, runtime and attempt IDs, replay, time-travel cursors, causal and variable slices, honesty codes, the stored-record reader |
|
|
14
|
+
| `foottrace/write` | `SharedMemory`, `EventLog`, `RecordFrame`, encoding and write types, writer-owned freezing, the record sink port and `memorySink` |
|
|
15
15
|
| `foottrace/paths` | Record-path encoding/decoding, nested access and safe diagnostic writes |
|
|
16
16
|
|
|
17
17
|
Every declaration has one public entry point. There are no public deep imports or `/testing` entry point. FootPrint's engine, observer channels, recorders and stores remain in `footprintjs`.
|
|
@@ -42,6 +42,45 @@ Writers record during execution. Readers query what was recorded; they do not re
|
|
|
42
42
|
|
|
43
43
|
See the [record contract](docs/guides/record-contract.md), [time-travel contract](src/lib/time-travel/README.md), and [slice contract](src/lib/slice/README.md).
|
|
44
44
|
|
|
45
|
+
## A record that survives the run
|
|
46
|
+
|
|
47
|
+
A log can also hand every entry to a sink **as it lands**, so a run that is killed still leaves its record behind. The sink sees four entries — `opened` (the base), `commit` (each bundle, after it is frozen), `note` (anything the writer wants kept beside the log, as it happens) and `closed` (how the log ended) — and `memorySink` is the reference adapter. Write your own for a file, a bucket or a table by implementing those four methods.
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
import { readStoredRecord, timeTravel } from 'foottrace';
|
|
51
|
+
import { EventLog, RecordFrame, SharedMemory, memorySink } from 'foottrace/write';
|
|
52
|
+
|
|
53
|
+
const sink = memorySink();
|
|
54
|
+
const state = new SharedMemory(undefined, { count: 0 });
|
|
55
|
+
const log = new EventLog(state.getState(), { sink, address: { logRunId: 'run-1', drillPath: [] } });
|
|
56
|
+
const frame = new RecordFrame(state, log);
|
|
57
|
+
frame.write(frame.at([], 'count'), 1, 'set');
|
|
58
|
+
frame.commit(() => ({ stage: 'Increment', stageId: 'increment', runtimeStageId: 'increment#0' }));
|
|
59
|
+
log.note('attempt', { attempt: 1, outcome: 'timeout' });
|
|
60
|
+
// … the process dies here: no log.close('ok') …
|
|
61
|
+
|
|
62
|
+
const run = readStoredRecord(sink.entries).root('run-1')!;
|
|
63
|
+
run.complete; // false — the log never closed
|
|
64
|
+
run.notes; // [{ kind: 'attempt', at, data: { attempt: 1, outcome: 'timeout' } }]
|
|
65
|
+
timeTravel(run.source()).last(); // the cursor over what landed
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
A failing sink never stops the writer: a throw or a rejected promise is counted (`log.sinkFailures`) and reported to `sink.onFailure`. The reader treats what storage gives back as untrusted (`Iterable<unknown>`) and checks each entry: one it does not read into a log is listed in `unread` by name, and a commit sits at its own index, so a commit the store lost is a gap the fold reports by index — never a shifted log. `complete` means the writer closed the log and the reader read it all. Its work is bounded by four documented limits (`StoredRecordLimits`, raised per call). A sink adds nothing to the log's bytes; `meta` and a note's data are opaque, stored and handed back, never read. See [time travel](src/lib/time-travel/README.md#6-a-stored-record-reads-back--even-a-run-that-never-ended-110) and the [record contract](docs/guides/record-contract.md#a-stored-record).
|
|
69
|
+
|
|
70
|
+
## Attempt IDs
|
|
71
|
+
|
|
72
|
+
An attempt under a stage execution has its own ID: `charge#4@2` is attempt 2 of `charge#4`, and `charge#4@2.1@3` is attempt 3 of the first call that stage attempt made. Counts start at 1.
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
import { buildAttemptId, parseAttemptId } from 'foottrace';
|
|
76
|
+
|
|
77
|
+
buildAttemptId('charge#4', 2, { ordinal: 1, attempt: 3 }); // 'charge#4@2.1@3'
|
|
78
|
+
parseAttemptId('charge#4@2'); // { runtimeStageId: 'charge#4', attempt: 2 }
|
|
79
|
+
parseAttemptId('charge#4'); // undefined — a stage execution, not an attempt
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
The parser reads from the right, so a stage ID may contain `@` or `.` and no character is newly reserved. See [ids/](src/lib/ids/README.md#attempt-ids-110).
|
|
83
|
+
|
|
45
84
|
## Paths are not interchangeable
|
|
46
85
|
|
|
47
86
|
- `idPathSegments` (root entry) splits runtime IDs on `/` and drops empty segments. `isExecutionKey` detects `#`; it is not a complete validator.
|
package/dist/esm/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';
|
package/dist/esm/index.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
export { buildRuntimeStageId, createExecutionCounter, parseRuntimeStageId, splitStageId, } from './lib/ids/runtimeStageId.js';
|
|
2
|
+
export { buildAttemptId, parseAttemptId } from './lib/ids/attemptId.js';
|
|
2
3
|
export { UnknownVerbError } from './lib/memory/verbs.js';
|
|
3
4
|
export { applySmartMerge } from './lib/memory/verbs.js';
|
|
4
5
|
export { buildCommitIndex, commitIndexOf, commitValueAt, commitValueAtWithBasis, findCommit, findCommits, findLastWriter, findLastWriterWithBasis, inferLegacyPhases, recordsPhases, } from './lib/memory/commitLogUtils.js';
|
|
5
|
-
export { commitStops, commitStopsStrategy, filterStops, isCommitBundle, splitAxis, stateAt, tagStops, timeTravel, } from './lib/time-travel/index.js';
|
|
6
|
+
export { commitStops, commitStopsStrategy, filterStops, isCommitBundle, readStoredRecord, splitAxis, stateAt, tagStops, timeTravel, } from './lib/time-travel/index.js';
|
|
6
7
|
export { causalChain, flattenCausalDAG, formatCausalChain } from './lib/memory/backtrack.js';
|
|
7
8
|
export { arrayProvenance, elementProvenance, formatForwardSlice, formatSlice, formatTimeline, forwardSliceForKey, forwardSliceToJSON, keysReadFromExecutionTree, keysReadFromMap, keyTimeline, resolveKeysReadSource, sliceForKey, sliceToJSON, } from './lib/slice/index.js';
|
|
8
9
|
export { HONESTY_CODES } from './lib/memory/honesty.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 {};
|
|
@@ -0,0 +1,91 @@
|
|
|
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 { EXECUTION_DELIMITER } from './runtimeStageId.js';
|
|
30
|
+
/**
|
|
31
|
+
* What follows the last `#`: the execution index, `@` and the attempt, then — for a call — `.`, the
|
|
32
|
+
* call's ordinal, `@` and the call's attempt. Counts are canonical positive integers.
|
|
33
|
+
*/
|
|
34
|
+
const AFTER_EXECUTION_DELIMITER = /^(\d+)@([1-9]\d*)(?:\.([1-9]\d*)@([1-9]\d*))?$/;
|
|
35
|
+
const EXECUTION_INDEX = /^\d+$/;
|
|
36
|
+
/** True when `id` ends in `#<digits>` — the one shape every runtimeStageId has. */
|
|
37
|
+
function endsInExecutionIndex(id) {
|
|
38
|
+
const at = id.lastIndexOf(EXECUTION_DELIMITER);
|
|
39
|
+
return at !== -1 && EXECUTION_INDEX.test(id.slice(at + 1));
|
|
40
|
+
}
|
|
41
|
+
/** Refuse a count the parser could not give back: a positive safe integer, or a `RangeError`. */
|
|
42
|
+
function requireCount(name, value) {
|
|
43
|
+
if (!Number.isSafeInteger(value) || value < 1) {
|
|
44
|
+
throw new RangeError(`buildAttemptId: ${name} must be a positive integer (counted from 1), got ${String(value)}`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Build the id of one attempt.
|
|
49
|
+
*
|
|
50
|
+
* @param runtimeStageId The stage execution the attempt belongs to (`[subflowPath/]stageId#index`).
|
|
51
|
+
* @param attempt The stage attempt, from 1.
|
|
52
|
+
* @param call For an attempt of a call made inside the stage attempt: the call's `ordinal` (1 for the
|
|
53
|
+
* first call that stage attempt made) and its own `attempt` (from 1).
|
|
54
|
+
* @throws TypeError when `runtimeStageId` does not end in `#<execution index>`, and RangeError when a
|
|
55
|
+
* count is not a positive integer — what `parseAttemptId` could not give back is never written.
|
|
56
|
+
*/
|
|
57
|
+
export function buildAttemptId(runtimeStageId, attempt, call) {
|
|
58
|
+
if (typeof runtimeStageId !== 'string' || !endsInExecutionIndex(runtimeStageId)) {
|
|
59
|
+
throw new TypeError(`buildAttemptId: ${JSON.stringify(runtimeStageId)} is not a runtimeStageId (it must end in #<execution index>)`);
|
|
60
|
+
}
|
|
61
|
+
requireCount('attempt', attempt);
|
|
62
|
+
if (call === undefined)
|
|
63
|
+
return `${runtimeStageId}@${attempt}`;
|
|
64
|
+
requireCount('call ordinal', call.ordinal);
|
|
65
|
+
requireCount('call attempt', call.attempt);
|
|
66
|
+
return `${runtimeStageId}@${attempt}.${call.ordinal}@${call.attempt}`;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Parse an attempt id into its parts, or `undefined` when `id` is not one (a bare runtimeStageId, a
|
|
70
|
+
* count of 0, a leading zero, a missing part). Never throws.
|
|
71
|
+
*
|
|
72
|
+
* `parseAttemptId(buildAttemptId(...parts))` gives the parts back, whatever the stage id contains.
|
|
73
|
+
*/
|
|
74
|
+
export function parseAttemptId(id) {
|
|
75
|
+
if (typeof id !== 'string')
|
|
76
|
+
return undefined;
|
|
77
|
+
const at = id.lastIndexOf(EXECUTION_DELIMITER);
|
|
78
|
+
if (at === -1)
|
|
79
|
+
return undefined;
|
|
80
|
+
const parts = AFTER_EXECUTION_DELIMITER.exec(id.slice(at + 1));
|
|
81
|
+
if (!parts)
|
|
82
|
+
return undefined;
|
|
83
|
+
const [, index, attempt, ordinal, callAttempt] = parts;
|
|
84
|
+
const counts = [attempt, ordinal, callAttempt].filter((n) => n !== undefined).map(Number);
|
|
85
|
+
if (!counts.every(Number.isSafeInteger))
|
|
86
|
+
return undefined;
|
|
87
|
+
const runtimeStageId = id.slice(0, at + 1 + index.length);
|
|
88
|
+
if (ordinal === undefined)
|
|
89
|
+
return { runtimeStageId, attempt: counts[0] };
|
|
90
|
+
return { runtimeStageId, attempt: counts[0], call: { ordinal: counts[1], attempt: counts[2] } };
|
|
91
|
+
}
|
|
@@ -24,8 +24,20 @@
|
|
|
24
24
|
* contract for state) and a `SharedArrayBuffer` (every copy shares its memory). A frozen RegExp's
|
|
25
25
|
* `lastIndex` is read-only, so a `/g` or `/y` regex read from a bundle throws when `exec` or
|
|
26
26
|
* `replace` advance it.
|
|
27
|
+
*
|
|
28
|
+
* A SINK (1.1.0, optional). `new EventLog(base, { sink, address, meta? })` also hands every entry to a
|
|
29
|
+
* `RecordSink` AS IT LANDS (`sink.ts`): `opened` from the constructor (the base, as the sink's own
|
|
30
|
+
* copy), `commit` from `record` after the bundle is frozen and appended (the bundle as served),
|
|
31
|
+
* `note` from `note`, `closed` from `close`. The log's own record never depends on the sink: the
|
|
32
|
+
* bundle is in `steps` before the sink sees it, and nothing the sink does reaches the writer — a
|
|
33
|
+
* throw, a rejected promise or an entry the log refuses to send (a `note` whose data is not a plain
|
|
34
|
+
* object that survives `structuredClone`) is counted (`sinkFailures`) and reported to
|
|
35
|
+
* `sink.onFailure`, and the writer goes on; a `meta` that will not copy is lost on its own and its
|
|
36
|
+
* entry still goes. Without a sink, nothing here runs: the one-argument log writes the same bytes,
|
|
37
|
+
* and has the same own fields, it always did.
|
|
27
38
|
*/
|
|
28
39
|
import type { EmitSourcePosition, LogAddress } from './eventPosition.js';
|
|
40
|
+
import { type LogOutcome, type Meta, type RecordSink } from './sink.js';
|
|
29
41
|
import type { CommitBundle } from './types.js';
|
|
30
42
|
export declare class EventLog {
|
|
31
43
|
/** Base snapshot BEFORE the first stage mutates anything. */
|
|
@@ -34,7 +46,26 @@ export declare class EventLog {
|
|
|
34
46
|
private steps;
|
|
35
47
|
private sourceAddress?;
|
|
36
48
|
private sourceAddressRetired;
|
|
37
|
-
|
|
49
|
+
/** Where entries go as they land; absent for the one-argument log. */
|
|
50
|
+
private readonly sink?;
|
|
51
|
+
/** Entries the sink did not take; set with the sink. */
|
|
52
|
+
private lostEntries?;
|
|
53
|
+
/** True while `onFailure` runs (until its promise settles): a loss it causes is counted, not reported again. */
|
|
54
|
+
private reporting?;
|
|
55
|
+
/**
|
|
56
|
+
* @param initialMemory The fold base — the state before the first commit (copied).
|
|
57
|
+
* @param options With a `sink`: every entry goes to it as it lands, under `address` — which also
|
|
58
|
+
* becomes this log's {@link address} — and `meta` rides the `opened` entry. The sink is told
|
|
59
|
+
* `opened` before the constructor returns.
|
|
60
|
+
* @throws TypeError when `options` names no sink, a malformed address, or a base that is not a plain
|
|
61
|
+
* object (the `opened` entry carries it) — a writer's mistake, refused at the door. Nothing the sink
|
|
62
|
+
* itself does is ever thrown.
|
|
63
|
+
*/
|
|
64
|
+
constructor(initialMemory: any, options?: {
|
|
65
|
+
readonly sink: RecordSink;
|
|
66
|
+
readonly address: LogAddress;
|
|
67
|
+
readonly meta?: Meta;
|
|
68
|
+
});
|
|
38
69
|
/**
|
|
39
70
|
* The fold BASE — the state as it stood before the first commit.
|
|
40
71
|
*
|
|
@@ -78,6 +109,53 @@ export declare class EventLog {
|
|
|
78
109
|
* will no longer edit — its own object is the one frozen.
|
|
79
110
|
*/
|
|
80
111
|
record(bundle: CommitBundle): void;
|
|
112
|
+
/**
|
|
113
|
+
* Note something beside the log, in the sink, as it happens — for evidence that must survive a run
|
|
114
|
+
* that never closes (an attempt that ended, before the next one starts). `kind` and `data` are the
|
|
115
|
+
* writer's: opaque to the record. Notes never enter the log's bundles, so the log's bytes are the
|
|
116
|
+
* same with or without them. Without a sink, nothing happens.
|
|
117
|
+
*
|
|
118
|
+
* Never throws: a `kind` that is not a string, `data` that is not a plain object surviving
|
|
119
|
+
* `structuredClone`, or a sink that fails is counted in {@link sinkFailures} and reported to
|
|
120
|
+
* `sink.onFailure`. The sink keeps its own copy of `data`.
|
|
121
|
+
*/
|
|
122
|
+
note(kind: string, data: Meta): void;
|
|
123
|
+
/**
|
|
124
|
+
* Tell the sink this log ended — `outcome` and, for the reader's check that the stored log is
|
|
125
|
+
* whole, how many commits it holds now. `meta` rides the entry, copied. Without a sink, nothing
|
|
126
|
+
* happens. The log itself stays as it is: a later `record` still lands, and still reaches the sink
|
|
127
|
+
* (a reader then reads the log as not complete until it is closed again).
|
|
128
|
+
*
|
|
129
|
+
* Never throws: an unknown outcome, a `meta` that is not a plain object surviving `structuredClone`,
|
|
130
|
+
* or a sink that fails is counted in {@link sinkFailures} and reported to `sink.onFailure`.
|
|
131
|
+
*/
|
|
132
|
+
close(outcome: LogOutcome, meta?: Meta): void;
|
|
133
|
+
/**
|
|
134
|
+
* How many entries the sink did not take: a sink method that threw or whose promise rejected, and an
|
|
135
|
+
* entry the log refused to send — plus each `meta` the log could not copy (that entry went without
|
|
136
|
+
* it). Counted as each happens (a rejection, when it settles); 0 without a sink. The stored record
|
|
137
|
+
* says the same from its side: a missing commit is a gap.
|
|
138
|
+
*/
|
|
139
|
+
get sinkFailures(): number;
|
|
140
|
+
/**
|
|
141
|
+
* One entry to the sink. Nothing here reaches the writer: a refusal, a throw or a rejection is a lost
|
|
142
|
+
* entry; a `meta` that cannot be copied is lost on its own — counted and reported — and the entry goes
|
|
143
|
+
* without it.
|
|
144
|
+
*/
|
|
145
|
+
private send;
|
|
146
|
+
/**
|
|
147
|
+
* Count the rejection of an adapter's promise once. A native promise is watched through the real
|
|
148
|
+
* `Promise.prototype.then` (an own `then` on it is not trusted); any other thenable is adopted into a
|
|
149
|
+
* fresh promise first, whose settling functions run once. Either way the rejection is handled.
|
|
150
|
+
*/
|
|
151
|
+
private watch;
|
|
152
|
+
/**
|
|
153
|
+
* Count a lost entry and tell the sink's `onFailure`, if it listens; a report that fails is ignored.
|
|
154
|
+
* A loss caused while `onFailure` runs — until the promise it returns settles, when it returns one —
|
|
155
|
+
* (it wrote to this log again, through the same failing sink) is counted and not reported again: one
|
|
156
|
+
* report deep, never a runaway.
|
|
157
|
+
*/
|
|
158
|
+
private lost;
|
|
81
159
|
/**
|
|
82
160
|
* Every recorded commit bundle, AS SERVED (9.44.2): a new array each call; a bundle freezing sealed
|
|
83
161
|
* whole is the log's own (frozen) object, one holding a Date, Map, buffer … is a copy of its open
|
|
@@ -94,12 +172,20 @@ export declare class EventLog {
|
|
|
94
172
|
recorded(): CommitBundle[];
|
|
95
173
|
/** Number of recorded commits. */
|
|
96
174
|
get length(): number;
|
|
97
|
-
/**
|
|
175
|
+
/**
|
|
176
|
+
* Immutable identity of this log: the sink's address when the log was built with one, otherwise
|
|
177
|
+
* absent until the engine binds it.
|
|
178
|
+
*/
|
|
98
179
|
get address(): LogAddress | undefined;
|
|
99
180
|
/** @internal Bind once; resuming the same log must not rename its earlier positions. */
|
|
100
181
|
bindAddress(logRunId: string, drillPath: readonly string[]): void;
|
|
101
182
|
/** O(1) source-time capture. No counter, observer bookkeeping, or future commit prediction. */
|
|
102
183
|
capturePosition(runId: string): EmitSourcePosition | undefined;
|
|
103
|
-
/**
|
|
184
|
+
/**
|
|
185
|
+
* Wipes history (useful for test resets).
|
|
186
|
+
*
|
|
187
|
+
* @throws Error on a log built with a sink: what the sink stored cannot be wiped, and a second
|
|
188
|
+
* history under the same address would read back as one corrupted log. Start a fresh EventLog.
|
|
189
|
+
*/
|
|
104
190
|
clear(): void;
|
|
105
191
|
}
|
|
@@ -24,9 +24,92 @@
|
|
|
24
24
|
* contract for state) and a `SharedArrayBuffer` (every copy shares its memory). A frozen RegExp's
|
|
25
25
|
* `lastIndex` is read-only, so a `/g` or `/y` regex read from a bundle throws when `exec` or
|
|
26
26
|
* `replace` advance it.
|
|
27
|
+
*
|
|
28
|
+
* A SINK (1.1.0, optional). `new EventLog(base, { sink, address, meta? })` also hands every entry to a
|
|
29
|
+
* `RecordSink` AS IT LANDS (`sink.ts`): `opened` from the constructor (the base, as the sink's own
|
|
30
|
+
* copy), `commit` from `record` after the bundle is frozen and appended (the bundle as served),
|
|
31
|
+
* `note` from `note`, `closed` from `close`. The log's own record never depends on the sink: the
|
|
32
|
+
* bundle is in `steps` before the sink sees it, and nothing the sink does reaches the writer — a
|
|
33
|
+
* throw, a rejected promise or an entry the log refuses to send (a `note` whose data is not a plain
|
|
34
|
+
* object that survives `structuredClone`) is counted (`sinkFailures`) and reported to
|
|
35
|
+
* `sink.onFailure`, and the writer goes on; a `meta` that will not copy is lost on its own and its
|
|
36
|
+
* entry still goes. Without a sink, nothing here runs: the one-argument log writes the same bytes,
|
|
37
|
+
* and has the same own fields, it always did.
|
|
27
38
|
*/
|
|
28
39
|
import { freezeRecord, serveRecord } from '../capture/freeze.js';
|
|
40
|
+
import { isEntryObject, LOG_OUTCOMES, sendEntry, } from './sink.js';
|
|
29
41
|
import { applySmartMergeInto } from './utils.js';
|
|
42
|
+
/** True when `value` has a callable `then` — an adapter that answered with a promise. */
|
|
43
|
+
function isThenable(value) {
|
|
44
|
+
return ((typeof value === 'object' || typeof value === 'function') &&
|
|
45
|
+
value !== null &&
|
|
46
|
+
typeof value.then === 'function');
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* The address, copied segment by segment and frozen — the log's identity cannot be edited through the
|
|
50
|
+
* caller's object, and a path with a hole or a non-string segment is refused rather than copied as one.
|
|
51
|
+
*/
|
|
52
|
+
function frozenAddress(address) {
|
|
53
|
+
const { logRunId, drillPath } = (address ?? {});
|
|
54
|
+
const refused = () => new TypeError('EventLog: a sink needs an address — { logRunId: string, drillPath: string[] }');
|
|
55
|
+
if (typeof logRunId !== 'string' || !Array.isArray(drillPath))
|
|
56
|
+
throw refused();
|
|
57
|
+
const segments = [];
|
|
58
|
+
for (let i = 0; i < drillPath.length; i++) {
|
|
59
|
+
const segment = drillPath[i];
|
|
60
|
+
if (typeof segment !== 'string')
|
|
61
|
+
throw refused();
|
|
62
|
+
segments.push(segment);
|
|
63
|
+
}
|
|
64
|
+
return Object.freeze({ logRunId, drillPath: Object.freeze(segments) });
|
|
65
|
+
}
|
|
66
|
+
/** A writer's payload as the sink keeps it: a plain object, copied — or a `TypeError` naming `field`. */
|
|
67
|
+
function copiedPayload(value, field) {
|
|
68
|
+
if (!isEntryObject(value))
|
|
69
|
+
throw new TypeError(`EventLog: ${field} must be a plain object`);
|
|
70
|
+
return structuredClone(value);
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* The entry with its `meta` copied — or, when `meta` cannot be copied, the entry WITHOUT it and the
|
|
74
|
+
* error: the structural facts (the base, how the log ended, its length) still go; only `meta` is lost.
|
|
75
|
+
*/
|
|
76
|
+
function withCopiedMeta(entry) {
|
|
77
|
+
if (entry.meta === undefined)
|
|
78
|
+
return { ready: entry };
|
|
79
|
+
try {
|
|
80
|
+
return { ready: { ...entry, meta: copiedPayload(entry.meta, 'meta') } };
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
const { meta: _dropped, ...rest } = entry;
|
|
84
|
+
return { ready: rest, dropped: error };
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* The entry as the sink may keep it: a payload checked and copied (the writer keeps its own object),
|
|
89
|
+
* the bundle served. `dropped` is a `meta` that could not be copied (the entry goes without it). Throws
|
|
90
|
+
* when the entry itself cannot be sent; the caller counts it as a lost entry.
|
|
91
|
+
*/
|
|
92
|
+
function detached(entry) {
|
|
93
|
+
switch (entry.kind) {
|
|
94
|
+
case 'opened':
|
|
95
|
+
return withCopiedMeta(entry);
|
|
96
|
+
case 'commit':
|
|
97
|
+
return { ready: { ...entry, bundle: serveRecord(entry.bundle) } };
|
|
98
|
+
case 'note':
|
|
99
|
+
if (typeof entry.note !== 'string')
|
|
100
|
+
throw new TypeError('EventLog.note: kind must be a string');
|
|
101
|
+
return { ready: { ...entry, data: copiedPayload(entry.data, 'note data') } };
|
|
102
|
+
case 'closed':
|
|
103
|
+
if (!LOG_OUTCOMES.includes(entry.outcome)) {
|
|
104
|
+
throw new TypeError(`EventLog.close: outcome must be ${LOG_OUTCOMES.join(', ')}`);
|
|
105
|
+
}
|
|
106
|
+
return withCopiedMeta(entry);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
/** Swallows a rejection nobody waits for. */
|
|
110
|
+
function ignore() {
|
|
111
|
+
// A failure report that fails changes nothing: the loss is already counted.
|
|
112
|
+
}
|
|
30
113
|
export class EventLog {
|
|
31
114
|
/** Base snapshot BEFORE the first stage mutates anything. */
|
|
32
115
|
base;
|
|
@@ -34,12 +117,39 @@ export class EventLog {
|
|
|
34
117
|
steps = [];
|
|
35
118
|
sourceAddress;
|
|
36
119
|
sourceAddressRetired = false;
|
|
37
|
-
|
|
120
|
+
/**
|
|
121
|
+
* @param initialMemory The fold base — the state before the first commit (copied).
|
|
122
|
+
* @param options With a `sink`: every entry goes to it as it lands, under `address` — which also
|
|
123
|
+
* becomes this log's {@link address} — and `meta` rides the `opened` entry. The sink is told
|
|
124
|
+
* `opened` before the constructor returns.
|
|
125
|
+
* @throws TypeError when `options` names no sink, a malformed address, or a base that is not a plain
|
|
126
|
+
* object (the `opened` entry carries it) — a writer's mistake, refused at the door. Nothing the sink
|
|
127
|
+
* itself does is ever thrown.
|
|
128
|
+
*/
|
|
129
|
+
constructor(initialMemory, options) {
|
|
38
130
|
// Normalised to an object: the base is a STATE, and every fold over it
|
|
39
131
|
// (`materialise`, `stateAt`) applies path writes to it. `undefined` —
|
|
40
132
|
// what a run with no `initialContext` used to store here — made every
|
|
41
133
|
// one of those folds throw on the first `set`.
|
|
42
134
|
this.base = initialMemory === undefined ? {} : structuredClone(initialMemory);
|
|
135
|
+
if (options === undefined)
|
|
136
|
+
return;
|
|
137
|
+
if (!isEntryObject(options.sink))
|
|
138
|
+
throw new TypeError('EventLog: options.sink must be a RecordSink');
|
|
139
|
+
if (!isEntryObject(this.base)) {
|
|
140
|
+
throw new TypeError('EventLog: a log that writes to a sink needs a plain-object base — its opened entry carries it');
|
|
141
|
+
}
|
|
142
|
+
const address = frozenAddress(options.address);
|
|
143
|
+
this.sourceAddress = address;
|
|
144
|
+
this.sink = options.sink;
|
|
145
|
+
this.lostEntries = 0;
|
|
146
|
+
this.send({
|
|
147
|
+
kind: 'opened',
|
|
148
|
+
address,
|
|
149
|
+
at: Date.now(),
|
|
150
|
+
initialState: this.getInitialState(),
|
|
151
|
+
...(options.meta === undefined ? {} : { meta: options.meta }),
|
|
152
|
+
});
|
|
43
153
|
}
|
|
44
154
|
/**
|
|
45
155
|
* The fold BASE — the state as it stood before the first commit.
|
|
@@ -96,6 +206,124 @@ export class EventLog {
|
|
|
96
206
|
bundle.idx = this.steps.length;
|
|
97
207
|
freezeRecord(bundle, 'indices');
|
|
98
208
|
this.steps.push(bundle);
|
|
209
|
+
if (this.sink !== undefined)
|
|
210
|
+
this.send({ kind: 'commit', address: this.sourceAddress, at: Date.now(), bundle });
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Note something beside the log, in the sink, as it happens — for evidence that must survive a run
|
|
214
|
+
* that never closes (an attempt that ended, before the next one starts). `kind` and `data` are the
|
|
215
|
+
* writer's: opaque to the record. Notes never enter the log's bundles, so the log's bytes are the
|
|
216
|
+
* same with or without them. Without a sink, nothing happens.
|
|
217
|
+
*
|
|
218
|
+
* Never throws: a `kind` that is not a string, `data` that is not a plain object surviving
|
|
219
|
+
* `structuredClone`, or a sink that fails is counted in {@link sinkFailures} and reported to
|
|
220
|
+
* `sink.onFailure`. The sink keeps its own copy of `data`.
|
|
221
|
+
*/
|
|
222
|
+
note(kind, data) {
|
|
223
|
+
if (this.sink === undefined)
|
|
224
|
+
return;
|
|
225
|
+
this.send({ kind: 'note', address: this.sourceAddress, at: Date.now(), note: kind, data });
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Tell the sink this log ended — `outcome` and, for the reader's check that the stored log is
|
|
229
|
+
* whole, how many commits it holds now. `meta` rides the entry, copied. Without a sink, nothing
|
|
230
|
+
* happens. The log itself stays as it is: a later `record` still lands, and still reaches the sink
|
|
231
|
+
* (a reader then reads the log as not complete until it is closed again).
|
|
232
|
+
*
|
|
233
|
+
* Never throws: an unknown outcome, a `meta` that is not a plain object surviving `structuredClone`,
|
|
234
|
+
* or a sink that fails is counted in {@link sinkFailures} and reported to `sink.onFailure`.
|
|
235
|
+
*/
|
|
236
|
+
close(outcome, meta) {
|
|
237
|
+
if (this.sink === undefined)
|
|
238
|
+
return;
|
|
239
|
+
this.send({
|
|
240
|
+
kind: 'closed',
|
|
241
|
+
address: this.sourceAddress,
|
|
242
|
+
at: Date.now(),
|
|
243
|
+
outcome,
|
|
244
|
+
commits: this.steps.length,
|
|
245
|
+
...(meta === undefined ? {} : { meta }),
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* How many entries the sink did not take: a sink method that threw or whose promise rejected, and an
|
|
250
|
+
* entry the log refused to send — plus each `meta` the log could not copy (that entry went without
|
|
251
|
+
* it). Counted as each happens (a rejection, when it settles); 0 without a sink. The stored record
|
|
252
|
+
* says the same from its side: a missing commit is a gap.
|
|
253
|
+
*/
|
|
254
|
+
get sinkFailures() {
|
|
255
|
+
return this.lostEntries ?? 0;
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* One entry to the sink. Nothing here reaches the writer: a refusal, a throw or a rejection is a lost
|
|
259
|
+
* entry; a `meta` that cannot be copied is lost on its own — counted and reported — and the entry goes
|
|
260
|
+
* without it.
|
|
261
|
+
*/
|
|
262
|
+
send(entry) {
|
|
263
|
+
const sink = this.sink;
|
|
264
|
+
if (sink === undefined)
|
|
265
|
+
return;
|
|
266
|
+
let sent = entry;
|
|
267
|
+
try {
|
|
268
|
+
const { ready, dropped } = detached(entry);
|
|
269
|
+
sent = Object.freeze(ready);
|
|
270
|
+
if (dropped !== undefined)
|
|
271
|
+
this.lost(sink, entry, dropped);
|
|
272
|
+
const returned = sendEntry(sink, sent);
|
|
273
|
+
if (isThenable(returned))
|
|
274
|
+
this.watch(sink, sent, returned);
|
|
275
|
+
}
|
|
276
|
+
catch (error) {
|
|
277
|
+
this.lost(sink, sent, error);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* Count the rejection of an adapter's promise once. A native promise is watched through the real
|
|
282
|
+
* `Promise.prototype.then` (an own `then` on it is not trusted); any other thenable is adopted into a
|
|
283
|
+
* fresh promise first, whose settling functions run once. Either way the rejection is handled.
|
|
284
|
+
*/
|
|
285
|
+
watch(sink, entry, returned) {
|
|
286
|
+
let counted = false;
|
|
287
|
+
const onRejected = (error) => {
|
|
288
|
+
if (counted)
|
|
289
|
+
return;
|
|
290
|
+
counted = true;
|
|
291
|
+
this.lost(sink, entry, error);
|
|
292
|
+
};
|
|
293
|
+
if (returned instanceof Promise)
|
|
294
|
+
Promise.prototype.then.call(returned, undefined, onRejected);
|
|
295
|
+
else
|
|
296
|
+
new Promise((resolve) => resolve(returned)).then(undefined, onRejected);
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Count a lost entry and tell the sink's `onFailure`, if it listens; a report that fails is ignored.
|
|
300
|
+
* A loss caused while `onFailure` runs — until the promise it returns settles, when it returns one —
|
|
301
|
+
* (it wrote to this log again, through the same failing sink) is counted and not reported again: one
|
|
302
|
+
* report deep, never a runaway.
|
|
303
|
+
*/
|
|
304
|
+
lost(sink, entry, error) {
|
|
305
|
+
this.lostEntries = (this.lostEntries ?? 0) + 1;
|
|
306
|
+
if (this.reporting === true)
|
|
307
|
+
return;
|
|
308
|
+
this.reporting = true;
|
|
309
|
+
let pending = false;
|
|
310
|
+
try {
|
|
311
|
+
const reported = sink.onFailure?.({ entry, error });
|
|
312
|
+
if (isThenable(reported)) {
|
|
313
|
+
pending = true;
|
|
314
|
+
const done = () => {
|
|
315
|
+
this.reporting = false;
|
|
316
|
+
};
|
|
317
|
+
new Promise((resolve) => resolve(reported)).then(done, done);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
catch {
|
|
321
|
+
ignore();
|
|
322
|
+
}
|
|
323
|
+
finally {
|
|
324
|
+
if (!pending)
|
|
325
|
+
this.reporting = false;
|
|
326
|
+
}
|
|
99
327
|
}
|
|
100
328
|
/**
|
|
101
329
|
* Every recorded commit bundle, AS SERVED (9.44.2): a new array each call; a bundle freezing sealed
|
|
@@ -119,7 +347,10 @@ export class EventLog {
|
|
|
119
347
|
get length() {
|
|
120
348
|
return this.steps.length;
|
|
121
349
|
}
|
|
122
|
-
/**
|
|
350
|
+
/**
|
|
351
|
+
* Immutable identity of this log: the sink's address when the log was built with one, otherwise
|
|
352
|
+
* absent until the engine binds it.
|
|
353
|
+
*/
|
|
123
354
|
get address() {
|
|
124
355
|
return this.sourceAddress;
|
|
125
356
|
}
|
|
@@ -135,8 +366,16 @@ export class EventLog {
|
|
|
135
366
|
return undefined;
|
|
136
367
|
return Object.freeze({ runId, ...this.sourceAddress, committedThroughIdx: this.steps.length - 1 });
|
|
137
368
|
}
|
|
138
|
-
/**
|
|
369
|
+
/**
|
|
370
|
+
* Wipes history (useful for test resets).
|
|
371
|
+
*
|
|
372
|
+
* @throws Error on a log built with a sink: what the sink stored cannot be wiped, and a second
|
|
373
|
+
* history under the same address would read back as one corrupted log. Start a fresh EventLog.
|
|
374
|
+
*/
|
|
139
375
|
clear() {
|
|
376
|
+
if (this.sink !== undefined) {
|
|
377
|
+
throw new Error('EventLog.clear: this log writes to a sink, and a stored record cannot be wiped — use a new EventLog');
|
|
378
|
+
}
|
|
140
379
|
this.steps = [];
|
|
141
380
|
// A reset prefix cannot reuse old coordinates, even if another stage of
|
|
142
381
|
// the SAME leg binds it again. Keep clear's data behavior, but retire
|