foottrace 1.0.2 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +41 -2
  2. package/dist/esm/index.d.ts +5 -2
  3. package/dist/esm/index.js +2 -1
  4. package/dist/esm/lib/ids/attemptId.d.ts +63 -0
  5. package/dist/esm/lib/ids/attemptId.js +91 -0
  6. package/dist/esm/lib/memory/EventLog.d.ts +89 -3
  7. package/dist/esm/lib/memory/EventLog.js +242 -3
  8. package/dist/esm/lib/memory/honesty.d.ts +6 -4
  9. package/dist/esm/lib/memory/honesty.js +7 -4
  10. package/dist/esm/lib/memory/sink.d.ts +148 -0
  11. package/dist/esm/lib/memory/sink.js +92 -0
  12. package/dist/esm/lib/time-travel/bundles.d.ts +6 -0
  13. package/dist/esm/lib/time-travel/bundles.js +25 -0
  14. package/dist/esm/lib/time-travel/commitStops.js +21 -2
  15. package/dist/esm/lib/time-travel/index.d.ts +2 -0
  16. package/dist/esm/lib/time-travel/index.js +1 -0
  17. package/dist/esm/lib/time-travel/storedRecord.d.ts +195 -0
  18. package/dist/esm/lib/time-travel/storedRecord.js +494 -0
  19. package/dist/esm/lib/time-travel/timeTravel.js +11 -0
  20. package/dist/esm/lib/time-travel/types.d.ts +4 -1
  21. package/dist/esm/write.d.ts +7 -0
  22. package/dist/esm/write.js +6 -0
  23. package/dist/index.js +6 -2
  24. package/dist/lib/ids/attemptId.js +96 -0
  25. package/dist/lib/memory/EventLog.js +242 -3
  26. package/dist/lib/memory/honesty.js +7 -4
  27. package/dist/lib/memory/sink.js +98 -0
  28. package/dist/lib/time-travel/bundles.js +27 -1
  29. package/dist/lib/time-travel/commitStops.js +21 -2
  30. package/dist/lib/time-travel/index.js +3 -1
  31. package/dist/lib/time-travel/storedRecord.js +498 -0
  32. package/dist/lib/time-travel/timeTravel.js +11 -0
  33. package/dist/types/index.d.ts +5 -2
  34. package/dist/types/lib/ids/attemptId.d.ts +63 -0
  35. package/dist/types/lib/memory/EventLog.d.ts +89 -3
  36. package/dist/types/lib/memory/honesty.d.ts +6 -4
  37. package/dist/types/lib/memory/sink.d.ts +148 -0
  38. package/dist/types/lib/time-travel/bundles.d.ts +6 -0
  39. package/dist/types/lib/time-travel/index.d.ts +2 -0
  40. package/dist/types/lib/time-travel/storedRecord.d.ts +195 -0
  41. package/dist/types/lib/time-travel/types.d.ts +4 -1
  42. package/dist/types/write.d.ts +7 -0
  43. package/dist/write.js +8 -1
  44. package/package.json +1 -1
@@ -23,10 +23,11 @@
23
23
  * alias of `foottrace` that is a union of string literals: each one is inside the
24
24
  * registry or is named, with its reason, as not an honesty vocabulary.
25
25
  *
26
- * NO OBJECT GAINS A FIELD from being registered. Three signals carry no code of their own — a
27
- * `LogGap` (`'log-gap'`), `CausalNode.incompleteSources` (`'incomplete-sources'`) and
28
- * `FoldedState.redacted` / `redactedPaths` (`'redacted'`) — and a `truncated: { byDepth, byNodes }`
29
- * field means `'truncated'`; they are registered so a reader explains them from the same place.
26
+ * NO OBJECT GAINS A FIELD from being registered. Four signals carry no code of their own — a
27
+ * `LogGap` (`'log-gap'`), `CausalNode.incompleteSources` (`'incomplete-sources'`),
28
+ * `FoldedState.redacted` / `redactedPaths` (`'redacted'`) and an entry of `StoredRecord.unread`
29
+ * (`'unread-entry'`, 1.1.0) — and a `truncated: { byDepth, byNodes }` field means `'truncated'`; they
30
+ * are registered so a reader explains them from the same place.
30
31
  *
31
32
  * NOT HERE: the two strings a redaction leaves where a value was. The engine writes those on every
32
33
  * redacted run; the log's lives in its own leaf, `memory/placeholders.ts` (the scope channel's with
@@ -75,6 +76,7 @@ export declare const HONESTY_CODES: Readonly<{
75
76
  'initial+log': string;
76
77
  'log-only': string;
77
78
  'log-gap': string;
79
+ 'unread-entry': string;
78
80
  'incomplete-sources': string;
79
81
  redacted: string;
80
82
  deleted: string;
@@ -23,10 +23,11 @@
23
23
  * alias of `foottrace` that is a union of string literals: each one is inside the
24
24
  * registry or is named, with its reason, as not an honesty vocabulary.
25
25
  *
26
- * NO OBJECT GAINS A FIELD from being registered. Three signals carry no code of their own — a
27
- * `LogGap` (`'log-gap'`), `CausalNode.incompleteSources` (`'incomplete-sources'`) and
28
- * `FoldedState.redacted` / `redactedPaths` (`'redacted'`) — and a `truncated: { byDepth, byNodes }`
29
- * field means `'truncated'`; they are registered so a reader explains them from the same place.
26
+ * NO OBJECT GAINS A FIELD from being registered. Four signals carry no code of their own — a
27
+ * `LogGap` (`'log-gap'`), `CausalNode.incompleteSources` (`'incomplete-sources'`),
28
+ * `FoldedState.redacted` / `redactedPaths` (`'redacted'`) and an entry of `StoredRecord.unread`
29
+ * (`'unread-entry'`, 1.1.0) — and a `truncated: { byDepth, byNodes }` field means `'truncated'`; they
30
+ * are registered so a reader explains them from the same place.
30
31
  *
31
32
  * NOT HERE: the two strings a redaction leaves where a value was. The engine writes those on every
32
33
  * redacted run; the log's lives in its own leaf, `memory/placeholders.ts` (the scope channel's with
@@ -81,6 +82,8 @@ export const HONESTY_CODES = /* @__PURE__ */ Object.freeze({
81
82
  '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.',
82
83
  // ── a row of a stored log that is not a commit bundle — `LogGap` ──────────
83
84
  '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.',
85
+ // ── an entry a sink stored that no log could hold — `StoredRecord.unread` (1.1.0) ─
86
+ '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.",
84
87
  // ── untracked reads a stage also consumed — `CausalNode.incompleteSources` ─
85
88
  '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.',
86
89
  // ── values scrubbed at write time — `FoldedState.redacted` / `redactedPaths`, a value basis ─
@@ -0,0 +1,148 @@
1
+ /**
2
+ * sink.ts — the record's sink port: where a log's entries go AS THEY LAND, so a run that never ends
3
+ * still leaves its record behind.
4
+ *
5
+ * A log that is given a sink (`new EventLog(base, { sink, address })`) hands it four kinds of entry,
6
+ * each stamped with the log's address and the time it was written (`at`, epoch ms):
7
+ *
8
+ * - `opened` — once, from the constructor: the fold base (`initialState`, the sink's own copy);
9
+ * - `commit` — per `record()`, AFTER the bundle is frozen: the log's own bundle, as served;
10
+ * - `note` — per `note(kind, data)`: something the writer wants kept beside the log, as it happens;
11
+ * - `closed` — per `close(outcome)`: how the log ended, and how many commits it held then (`commits`).
12
+ *
13
+ * These are RECORD facts. What the writer means by them rides `meta` (on `opened` / `closed`) and a
14
+ * note's `kind` and `data`: opaque here — stored and handed back, never read or interpreted. Nothing in
15
+ * this file names an engine.
16
+ *
17
+ * THE STORED FORM. A sink stores each entry as a {@link RecordEntry}: the method's entry plus the
18
+ * method's name as `kind`. A note's own kind cannot also be called `kind`, so it is stored as `note`.
19
+ * `memorySink` is the reference adapter; `readStoredRecord` (`foottrace`) reads the entries back.
20
+ *
21
+ * @example
22
+ * ```typescript
23
+ * import { EventLog, memorySink } from 'foottrace/write';
24
+ * import { readStoredRecord } from 'foottrace';
25
+ *
26
+ * const sink = memorySink();
27
+ * const log = new EventLog({ count: 0 }, { sink, address: { logRunId: 'run-1', drillPath: [] } });
28
+ * log.note('attempt', { attempt: 1, outcome: 'failed' });
29
+ * // … the process dies here: no `closed` entry …
30
+ * readStoredRecord(sink.entries).root('run-1')?.complete; // false — and its note is still there
31
+ * ```
32
+ */
33
+ import type { LogAddress } from './eventPosition.js';
34
+ import type { CommitBundle } from './types.js';
35
+ /**
36
+ * What a writer puts beside the record — on a log's `opened` / `closed` entry, or as a note's data.
37
+ * A plain object that survives `structuredClone`; opaque to foottrace: stored and returned, never read.
38
+ */
39
+ export type Meta = Readonly<Record<string, unknown>>;
40
+ /** How a log's writer said it ended. A pause ends this log; it is not a failure. */
41
+ export type LogOutcome = 'ok' | 'failed' | 'paused';
42
+ /** The outcomes a `closed` entry may carry, in one place for the writer's and the reader's checks. */
43
+ export declare const LOG_OUTCOMES: readonly LogOutcome[];
44
+ /**
45
+ * One stored entry: what a sink keeps per call, discriminated by `kind` (the method that received it).
46
+ * A note's own kind is stored as `note`.
47
+ */
48
+ export type RecordEntry = {
49
+ readonly kind: 'opened';
50
+ readonly address: LogAddress;
51
+ readonly at: number;
52
+ /** The fold base — the state before the log's first commit. */
53
+ readonly initialState: Record<string, unknown>;
54
+ readonly meta?: Meta;
55
+ } | {
56
+ readonly kind: 'commit';
57
+ readonly address: LogAddress;
58
+ readonly at: number;
59
+ /** The log's own bundle, frozen, its position stamped as `idx`. */
60
+ readonly bundle: CommitBundle;
61
+ } | {
62
+ readonly kind: 'note';
63
+ readonly address: LogAddress;
64
+ readonly at: number;
65
+ /** The note's own kind, as the writer named it (`note('attempt', …)` → `'attempt'`). */
66
+ readonly note: string;
67
+ readonly data: Meta;
68
+ } | {
69
+ readonly kind: 'closed';
70
+ readonly address: LogAddress;
71
+ readonly at: number;
72
+ readonly outcome: LogOutcome;
73
+ /** The log's length when it closed — how many commits the stored log must hold to be whole. */
74
+ readonly commits: number;
75
+ readonly meta?: Meta;
76
+ };
77
+ /**
78
+ * Where a log's entries go as they land — the port an adapter implements (memory, a file, S3, a table).
79
+ *
80
+ * Every method is called synchronously from the writer; an adapter that writes somewhere slow queues
81
+ * the entry and drains in `flush`. A method may throw, or return a promise that rejects: the log
82
+ * counts it (`EventLog · sinkFailures`), reports it to `onFailure`, and goes on — a failing sink never
83
+ * stops the writer. The entries an adapter is handed are its to keep: the bundle is frozen and shared
84
+ * with the log, everything else is the adapter's own copy.
85
+ */
86
+ export interface RecordSink {
87
+ /** A log opened at `address` with its fold base. Called once, from the log's constructor. */
88
+ opened(entry: {
89
+ readonly address: LogAddress;
90
+ readonly at: number;
91
+ readonly initialState: Record<string, unknown>;
92
+ readonly meta?: Meta;
93
+ }): void;
94
+ /** A bundle landed in the log at `address` (its position is `bundle.idx`). */
95
+ commit(entry: {
96
+ readonly address: LogAddress;
97
+ readonly at: number;
98
+ readonly bundle: CommitBundle;
99
+ }): void;
100
+ /** The writer noted something beside the log — `kind` and `data` are opaque. */
101
+ note(entry: {
102
+ readonly address: LogAddress;
103
+ readonly at: number;
104
+ readonly kind: string;
105
+ readonly data: Meta;
106
+ }): void;
107
+ /** The log at `address` closed with `outcome`, holding `commits` bundles. */
108
+ closed(entry: {
109
+ readonly address: LogAddress;
110
+ readonly at: number;
111
+ readonly outcome: LogOutcome;
112
+ readonly commits: number;
113
+ readonly meta?: Meta;
114
+ }): void;
115
+ /** Drain whatever the adapter queued. The log never calls it; whoever owns the sink does. */
116
+ flush?(): Promise<void>;
117
+ /** Told of every entry this sink did not take — the entry and what went wrong. A throw here is ignored. */
118
+ onFailure?(failure: {
119
+ readonly entry: RecordEntry;
120
+ readonly error: unknown;
121
+ }): void;
122
+ }
123
+ /** A plain object — not null, not an array, not a primitive. */
124
+ export declare function isEntryObject(value: unknown): value is Record<string, unknown>;
125
+ /**
126
+ * Hand `entry` to the sink's method for its kind, as that method's entry; returns what the method
127
+ * returned (a promise from an async adapter). Throws whatever the method throws — the log catches.
128
+ */
129
+ export declare function sendEntry(sink: RecordSink, entry: RecordEntry): unknown;
130
+ /**
131
+ * A sink that keeps every entry in memory, in arrival order — the reference adapter: what any adapter
132
+ * must give back for `readStoredRecord` to read the same record. `entries` is a new array on each read.
133
+ * What it keeps is frozen as far as freezing reaches — the base, `meta` and a note's `data` are the
134
+ * copies a log hands it, and the bundle is already frozen — so no reader can rewrite its containers. A
135
+ * Date's time, a Map's or Set's entries and a buffer's bytes are not sealed by freezing (`deepFreeze`'s
136
+ * stated limit). A caller that hands it objects directly hands them over: they are frozen in place.
137
+ *
138
+ * @example
139
+ * ```typescript
140
+ * const sink = memorySink();
141
+ * const log = new EventLog({}, { sink, address: { logRunId: 'run-1', drillPath: [] } });
142
+ * log.close('ok');
143
+ * sink.entries.map((e) => e.kind); // ['opened', 'closed']
144
+ * ```
145
+ */
146
+ export declare function memorySink(): RecordSink & {
147
+ readonly entries: readonly RecordEntry[];
148
+ };
@@ -0,0 +1,92 @@
1
+ /**
2
+ * sink.ts — the record's sink port: where a log's entries go AS THEY LAND, so a run that never ends
3
+ * still leaves its record behind.
4
+ *
5
+ * A log that is given a sink (`new EventLog(base, { sink, address })`) hands it four kinds of entry,
6
+ * each stamped with the log's address and the time it was written (`at`, epoch ms):
7
+ *
8
+ * - `opened` — once, from the constructor: the fold base (`initialState`, the sink's own copy);
9
+ * - `commit` — per `record()`, AFTER the bundle is frozen: the log's own bundle, as served;
10
+ * - `note` — per `note(kind, data)`: something the writer wants kept beside the log, as it happens;
11
+ * - `closed` — per `close(outcome)`: how the log ended, and how many commits it held then (`commits`).
12
+ *
13
+ * These are RECORD facts. What the writer means by them rides `meta` (on `opened` / `closed`) and a
14
+ * note's `kind` and `data`: opaque here — stored and handed back, never read or interpreted. Nothing in
15
+ * this file names an engine.
16
+ *
17
+ * THE STORED FORM. A sink stores each entry as a {@link RecordEntry}: the method's entry plus the
18
+ * method's name as `kind`. A note's own kind cannot also be called `kind`, so it is stored as `note`.
19
+ * `memorySink` is the reference adapter; `readStoredRecord` (`foottrace`) reads the entries back.
20
+ *
21
+ * @example
22
+ * ```typescript
23
+ * import { EventLog, memorySink } from 'foottrace/write';
24
+ * import { readStoredRecord } from 'foottrace';
25
+ *
26
+ * const sink = memorySink();
27
+ * const log = new EventLog({ count: 0 }, { sink, address: { logRunId: 'run-1', drillPath: [] } });
28
+ * log.note('attempt', { attempt: 1, outcome: 'failed' });
29
+ * // … the process dies here: no `closed` entry …
30
+ * readStoredRecord(sink.entries).root('run-1')?.complete; // false — and its note is still there
31
+ * ```
32
+ */
33
+ import { deepFreeze } from '../capture/freeze.js';
34
+ /** The outcomes a `closed` entry may carry, in one place for the writer's and the reader's checks. */
35
+ export const LOG_OUTCOMES = /* @__PURE__ */ Object.freeze(['ok', 'failed', 'paused']);
36
+ /** A plain object — not null, not an array, not a primitive. */
37
+ export function isEntryObject(value) {
38
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
39
+ }
40
+ /** `{ meta }` when there is one, `{}` otherwise — an absent field stays absent. */
41
+ function withMeta(meta) {
42
+ return meta === undefined ? {} : { meta };
43
+ }
44
+ /**
45
+ * Hand `entry` to the sink's method for its kind, as that method's entry; returns what the method
46
+ * returned (a promise from an async adapter). Throws whatever the method throws — the log catches.
47
+ */
48
+ export function sendEntry(sink, entry) {
49
+ const { address, at } = entry;
50
+ switch (entry.kind) {
51
+ case 'opened':
52
+ return sink.opened({ address, at, initialState: entry.initialState, ...withMeta(entry.meta) });
53
+ case 'commit':
54
+ return sink.commit({ address, at, bundle: entry.bundle });
55
+ case 'note':
56
+ return sink.note({ address, at, kind: entry.note, data: entry.data });
57
+ case 'closed':
58
+ return sink.closed({ address, at, outcome: entry.outcome, commits: entry.commits, ...withMeta(entry.meta) });
59
+ }
60
+ }
61
+ /**
62
+ * A sink that keeps every entry in memory, in arrival order — the reference adapter: what any adapter
63
+ * must give back for `readStoredRecord` to read the same record. `entries` is a new array on each read.
64
+ * What it keeps is frozen as far as freezing reaches — the base, `meta` and a note's `data` are the
65
+ * copies a log hands it, and the bundle is already frozen — so no reader can rewrite its containers. A
66
+ * Date's time, a Map's or Set's entries and a buffer's bytes are not sealed by freezing (`deepFreeze`'s
67
+ * stated limit). A caller that hands it objects directly hands them over: they are frozen in place.
68
+ *
69
+ * @example
70
+ * ```typescript
71
+ * const sink = memorySink();
72
+ * const log = new EventLog({}, { sink, address: { logRunId: 'run-1', drillPath: [] } });
73
+ * log.close('ok');
74
+ * sink.entries.map((e) => e.kind); // ['opened', 'closed']
75
+ * ```
76
+ */
77
+ export function memorySink() {
78
+ const stored = [];
79
+ const keep = (entry) => {
80
+ stored.push(deepFreeze(entry));
81
+ };
82
+ return {
83
+ get entries() {
84
+ return stored.slice();
85
+ },
86
+ opened: ({ address, at, initialState, meta }) => keep({ kind: 'opened', address, at, initialState, ...withMeta(meta) }),
87
+ commit: ({ address, at, bundle }) => keep({ kind: 'commit', address, at, bundle }),
88
+ note: ({ address, at, kind, data }) => keep({ kind: 'note', address, at, note: kind, data }),
89
+ closed: ({ address, at, outcome, commits, meta }) => keep({ kind: 'closed', address, at, outcome, commits, ...withMeta(meta) }),
90
+ flush: () => Promise.resolve(),
91
+ };
92
+ }
@@ -19,6 +19,8 @@ import type { CommitBundle, ExecutionTree } from '../memory/types.js';
19
19
  import type { LogGap } from './types.js';
20
20
  /** A plain object — not null, not an array, not a primitive. */
21
21
  export declare function isPlainish(value: unknown): value is Record<string, unknown>;
22
+ /** A row every reader refuses with `reason` — a gap that keeps its index. */
23
+ export declare function absentRow(reason: string): object;
22
24
  /**
23
25
  * Read a source's `executionTree` as an {@link ExecutionTree}, or as absent.
24
26
  *
@@ -41,6 +43,10 @@ export declare function readTree(value: unknown): ExecutionTree | undefined;
41
43
  * `changedSince` reads its paths), so a row without one is not a bundle the
42
44
  * fold can survive — it is the crash a gap exists to prevent. `CommitBundle`
43
45
  * declares it required, so no live log is affected.
46
+ * - Each trace entry needs a known verb and a string `path`. Readers use
47
+ * string path operations; accepting a number can silently lose a causal
48
+ * writer in a short log and crash the indexed lookup in a longer one.
49
+ * Empty strings and historical path spellings remain valid strings.
44
50
  * - `updates` / `overwrite`, when present, must be plain objects. They are
45
51
  * read only AT THE PATHS THE TRACE NAMES, so an absent one is harmless and
46
52
  * is tolerated, exactly as the fold tolerates an absent `redactedPaths`.
@@ -39,6 +39,19 @@ const GAP_BUNDLE = Object.freeze({
39
39
  export function isPlainish(value) {
40
40
  return typeof value === 'object' && value !== null && !Array.isArray(value);
41
41
  }
42
+ /**
43
+ * Rows that hold an index for a commit the record does not have (1.1.0) — what a STORED log could not
44
+ * supply at a position: no readable commit entry (`storedRecord.ts`). Each
45
+ * carries the reason every reader then reports for its index, so the fold, the cursor and the stored
46
+ * log say the same thing about it. Kept by identity (a JSON copy of one is an ordinary unreadable row).
47
+ */
48
+ const ABSENT_ROWS = new WeakMap();
49
+ /** A row every reader refuses with `reason` — a gap that keeps its index. */
50
+ export function absentRow(reason) {
51
+ const row = Object.freeze({});
52
+ ABSENT_ROWS.set(row, reason);
53
+ return row;
54
+ }
42
55
  /**
43
56
  * Read a source's `executionTree` as an {@link ExecutionTree}, or as absent.
44
57
  *
@@ -63,6 +76,10 @@ export function readTree(value) {
63
76
  * `changedSince` reads its paths), so a row without one is not a bundle the
64
77
  * fold can survive — it is the crash a gap exists to prevent. `CommitBundle`
65
78
  * declares it required, so no live log is affected.
79
+ * - Each trace entry needs a known verb and a string `path`. Readers use
80
+ * string path operations; accepting a number can silently lose a causal
81
+ * writer in a short log and crash the indexed lookup in a longer one.
82
+ * Empty strings and historical path spellings remain valid strings.
66
83
  * - `updates` / `overwrite`, when present, must be plain objects. They are
67
84
  * read only AT THE PATHS THE TRACE NAMES, so an absent one is harmless and
68
85
  * is tolerated, exactly as the fold tolerates an absent `redactedPaths`.
@@ -72,6 +89,9 @@ export function readTree(value) {
72
89
  * compatibility break dressed up as validation.
73
90
  */
74
91
  export function bundleRefusal(row) {
92
+ const absent = typeof row === 'object' && row !== null ? ABSENT_ROWS.get(row) : undefined;
93
+ if (absent !== undefined)
94
+ return absent;
75
95
  if (!isPlainish(row)) {
76
96
  return `not an object (${row === null ? 'null' : Array.isArray(row) ? 'array' : typeof row})`;
77
97
  }
@@ -95,6 +115,11 @@ export function bundleRefusal(row) {
95
115
  if (!isVerb(verb)) {
96
116
  return `trace[${i}].verb is ${verb === undefined ? 'missing' : JSON.stringify(verb)}, not ${VERBS.join(' | ')}`;
97
117
  }
118
+ const path = isPlainish(entry) ? entry.path : undefined;
119
+ if (typeof path !== 'string') {
120
+ const kind = path === undefined ? 'missing' : path === null ? 'null' : Array.isArray(path) ? 'an array' : `a ${typeof path}`;
121
+ return `trace[${i}].path is ${kind}, not a string`;
122
+ }
98
123
  }
99
124
  return undefined;
100
125
  }
@@ -10,6 +10,7 @@
10
10
  */
11
11
  import { isExecutionKey, isWithinSubflow, parseRuntimeStageId, stageIdOf } from '../ids/runtimeStageId.js';
12
12
  import { buildCommitIndex, inferLegacyPhases, recordsPhases } from '../memory/commitLogUtils.js';
13
+ import { isPlainish } from './bundles.js';
13
14
  /**
14
15
  * Every `runtimeStageId` in the execution tree that the engine marked as a
15
16
  * subflow mount. Authoritative, as is a bundle's recorded `phase: 'exit'`
@@ -89,7 +90,7 @@ export function commitStops(commitLog, executionTree, subflowResults) {
89
90
  // the MOUNT's commit, made before any of them ran. It gets no stop of its
90
91
  // own; it belongs to the `'start'` bookend, which is where a reader looks
91
92
  // for "the input this chart began with" anyway. See {@link seedCommits}.
92
- const seeds = seedCommits(commitLog, executionTree);
93
+ const seeds = seedCommits(commitLog, executionTree, subflowResults);
93
94
  const firsts = [];
94
95
  for (const [id, i] of buildCommitIndex(commitLog))
95
96
  if (id && i >= seeds)
@@ -159,6 +160,22 @@ function phasesOf(commitLog, haveTree) {
159
160
  return commitLog.map((bundle) => bundle.phase);
160
161
  return haveTree ? [] : inferLegacyPhases(commitLog);
161
162
  }
163
+ /**
164
+ * True when the results map's entry for `head` IS this log — its history opens with this very bundle.
165
+ * The log is then that mount's own, and its head run is the mount's seed (R13: a seed carries its
166
+ * mount's runtimeStageId). This is what a TREE-LESS cursor over a mount log reads in place of the
167
+ * tree's root — a stored log read back by `readStoredRecord`, or a log whose stage after the seed was
168
+ * lost — so the seed opens `start` instead of standing as a stop (marked as its own mount).
169
+ */
170
+ function opensOwnMount(subflowResults, head, first) {
171
+ if (!isPlainish(subflowResults))
172
+ return false;
173
+ const result = subflowResults[head];
174
+ if (!isPlainish(result) || !isPlainish(result.treeContext))
175
+ return false;
176
+ const history = result.treeContext.history;
177
+ return Array.isArray(history) && history[0] === first;
178
+ }
162
179
  /** `commitStops` as a {@link TimeTravelStrategy} — the default for `timeTravel`. */
163
180
  export const commitStopsStrategy = { stopsFor: commitStops };
164
181
  /**
@@ -179,7 +196,7 @@ export const commitStopsStrategy = { stopsFor: commitStops };
179
196
  * whose id carries the same subflow path. Without a tree such a log reads as
180
197
  * one stop.
181
198
  */
182
- function seedCommits(commitLog, executionTree) {
199
+ function seedCommits(commitLog, executionTree, subflowResults) {
183
200
  const head = commitLog[0]?.runtimeStageId;
184
201
  if (head === undefined)
185
202
  return 0;
@@ -192,6 +209,8 @@ function seedCommits(commitLog, executionTree) {
192
209
  let next = 1;
193
210
  while (next < commitLog.length && commitLog[next].runtimeStageId === head)
194
211
  next++;
212
+ if (executionTree === undefined && opensOwnMount(subflowResults, head, commitLog[0]))
213
+ return next;
195
214
  const address = stageIdOf(head);
196
215
  if (next >= commitLog.length) {
197
216
  const rootId = executionTree?.id;
@@ -17,6 +17,8 @@ export { filterStops, splitAxis } from './axis.js';
17
17
  export { isCommitBundle } from './bundles.js';
18
18
  export { commitStops, commitStopsStrategy } from './commitStops.js';
19
19
  export { stateAt } from './stateAt.js';
20
+ export type { StoredLog, StoredNote, StoredRecord, StoredRecordLimits } from './storedRecord.js';
21
+ export { readStoredRecord } from './storedRecord.js';
20
22
  export { tagStops } from './tagStops.js';
21
23
  export { timeTravel } from './timeTravel.js';
22
24
  export type { AxisRefusal, AxisSplit, BookendedAxis, FoldBasis, FoldedState, FoldSource, LogGap, Mark, Move, MoveRefusal, Stop, StopKind, TimeTravel, TimeTravelOptions, TimeTravelSource, TimeTravelStrategy, } from './types.js';
@@ -16,5 +16,6 @@ export { filterStops, splitAxis } from './axis.js';
16
16
  export { isCommitBundle } from './bundles.js';
17
17
  export { commitStops, commitStopsStrategy } from './commitStops.js';
18
18
  export { stateAt } from './stateAt.js';
19
+ export { readStoredRecord } from './storedRecord.js';
19
20
  export { tagStops } from './tagStops.js';
20
21
  export { timeTravel } from './timeTravel.js';
@@ -0,0 +1,195 @@
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 type { LogAddress } from '../memory/eventPosition.js';
57
+ import { type LogOutcome, type Meta, type RecordEntry } from '../memory/sink.js';
58
+ import type { CommitBundle } from '../memory/types.js';
59
+ import type { LogGap, TimeTravelSource } from './types.js';
60
+ /** A note the writer kept beside a log (`EventLog · note`), as stored. */
61
+ export interface StoredNote {
62
+ /** The note's kind, as the writer named it. */
63
+ readonly kind: string;
64
+ /** When it was written (epoch ms). */
65
+ readonly at: number;
66
+ /** What the writer put in it — opaque. */
67
+ readonly data: Meta;
68
+ }
69
+ /** One log read back from a sink's entries. */
70
+ export interface StoredLog {
71
+ /** Which log this is: the run leg that owned it and the mounts it sits under (root: `drillPath: []`). */
72
+ readonly address: LogAddress;
73
+ /** The fold base the `opened` entry carried; ABSENT when no readable `opened` entry was stored. */
74
+ readonly initialState?: Record<string, unknown>;
75
+ /**
76
+ * The bundles, each at its own index (`bundle.idx`). An index with no readable bundle holds an empty
77
+ * stand-in (it belongs to no stage and carries nothing) and is named in {@link gaps} — check `gaps`
78
+ * before a causal query, which takes the bundles as they are; fold {@link source}, not this, so the
79
+ * fold reports them.
80
+ */
81
+ readonly commitLog: readonly CommitBundle[];
82
+ /** Every index of {@link commitLog} that holds no readable bundle, with why — empty when the log is whole. */
83
+ readonly gaps: readonly LogGap[];
84
+ /**
85
+ * The notes, in arrival order. A store that delivers an entry twice as two copies repeats its note:
86
+ * payloads are never compared (the very same object twice is listed in `unread` instead).
87
+ */
88
+ readonly notes: readonly StoredNote[];
89
+ /**
90
+ * `true` when a `closed` entry covers every stored commit and the reader read the log whole: the
91
+ * writer said it ended. `false` for a log that never closed — a crashed run, or one still going —
92
+ * for one that went on after its last close, and for one the reader did not read whole (a limit
93
+ * cut it, or reading stopped at the `entries` limit — see `StoredRecord.unread`).
94
+ */
95
+ readonly complete: boolean;
96
+ /** How the covering `closed` entry said the log ended; ABSENT while the log is not complete. */
97
+ readonly outcome?: LogOutcome;
98
+ /**
99
+ * What the writer put on the `opened` entry and on the covering `closed` entry (present only while
100
+ * the log is complete) — opaque, never merged.
101
+ */
102
+ readonly meta: {
103
+ readonly opened?: Meta;
104
+ readonly closed?: Meta;
105
+ };
106
+ /**
107
+ * The entries this log was read from, in arrival order, as stored — the evidence the rest is read
108
+ * from (each entry's `at`, every `closed` entry). An entry listed in `StoredRecord.unread` is not
109
+ * here. A commit's bundle is the stored row: {@link gaps} names one that is not a readable bundle.
110
+ */
111
+ readonly entries: readonly RecordEntry[];
112
+ /**
113
+ * The log as `timeTravel` and `stateAt` read it: `{ commitLog, initialState?, subflowResults? }`. A
114
+ * gap is a row the fold refuses with the same reason {@link gaps} gives (`skipped`), so a fold that
115
+ * crosses one says so. `subflowResults` holds every stored mount log of the run leg — this one too,
116
+ * when it is a mount — keyed by each mount's runtimeStageId (the last segment of its `drillPath`):
117
+ * one flat map per leg, as a live snapshot's, so the cursor marks those stops as mounts and
118
+ * `drill(mountRuntimeStageId)` opens the mount's own stored log. A mount log's head commit stamped
119
+ * with its own mount id is its seed (it opens `start`, as on a live cursor). The same object on
120
+ * every call.
121
+ */
122
+ source(): TimeTravelSource;
123
+ }
124
+ /** Every log a sink's entries hold, and every entry no log holds. */
125
+ export interface StoredRecord {
126
+ /** Every log, keyed by its address as a JSON array — `["<logRunId>", ...drillPath]` — in first-arrival order. */
127
+ readonly logs: ReadonlyMap<string, StoredLog>;
128
+ /** The root log of the run leg `logRunId` (`drillPath: []`), or `undefined` when none was stored. */
129
+ root(logRunId: string): StoredLog | undefined;
130
+ /** The log at `address`, or `undefined` when none was stored. */
131
+ log(address: LogAddress): StoredLog | undefined;
132
+ /**
133
+ * Entries not read into any log, by position in the input, with the reason: an entry that could not
134
+ * be read, one past a limit of the reader, or one for a place another entry holds (a second `opened`,
135
+ * a second commit for an index, the same entry again). In input order; empty when every entry was
136
+ * read. A commit that could not be read at all, or that a limit cut, leaves no row: its index is a
137
+ * gap only when a later stored commit or a close reaches past it.
138
+ */
139
+ readonly unread: readonly {
140
+ readonly index: number;
141
+ readonly reason: string;
142
+ }[];
143
+ }
144
+ /**
145
+ * The reader's limits — what bounds its work on untrusted input. Each defaults to the value below; a
146
+ * caller that reads larger records raises it. Whatever a limit stops is listed in `unread` by name.
147
+ */
148
+ export interface StoredRecordLimits {
149
+ /**
150
+ * Entries read; past it, reading stops, one `unread` item says so, and no log reads complete.
151
+ * Default 16,777,216 (2^24).
152
+ */
153
+ readonly entries?: number;
154
+ /**
155
+ * Gaps held across the WHOLE record — rows for commits the store does not have — spent on the logs
156
+ * that need the fewest first. A commit (or a close) that would need more is unread. Default
157
+ * 1,048,576 (2^20). The other rows a log holds are its stored commits.
158
+ */
159
+ readonly missingCommits?: number;
160
+ /** Segments in an address's `drillPath`. Default 1,024. */
161
+ readonly drillDepth?: number;
162
+ /** Characters in an address — its `logRunId` and `drillPath` segments together. Default 65,536. */
163
+ readonly addressLength?: number;
164
+ }
165
+ /** The defaults — internal to the package; documented on {@link StoredRecordLimits}. */
166
+ export declare const DEFAULT_LIMITS: Required<StoredRecordLimits>;
167
+ /**
168
+ * Read a sink's stored entries back into logs.
169
+ *
170
+ * Bounded on untrusted input: no recursion, no payload walk, work per entry bounded by the address
171
+ * limits, at most `limits.missingCommits` gap rows across the record, and at most `limits.entries`
172
+ * entries read — whatever a limit stops is listed in `unread` by name.
173
+ *
174
+ * @param entries What a `RecordSink` stored (`memorySink().entries`, or entries parsed back from a
175
+ * file or a table), in the order they were written — typed `unknown` because stored entries come
176
+ * back unvalidated; each is checked here.
177
+ * @param limits Raise a default to read a larger record ({@link StoredRecordLimits}).
178
+ * @returns Every log by address, the run-leg roots, and every entry not read into a log.
179
+ * @throws RangeError when a limit is not a non-negative integer or `Infinity`; TypeError when
180
+ * `entries` is not iterable; whatever the input's own iterator throws. Never for an entry that is
181
+ * data (parsed JSON, a structured clone).
182
+ *
183
+ * @example
184
+ * ```typescript
185
+ * import { readStoredRecord, stateAt, timeTravel } from 'foottrace';
186
+ *
187
+ * const stored = readStoredRecord(entries); // e.g. JSON lines read back after a crash
188
+ * const run = stored.root('run-1');
189
+ * run?.complete; // false — it never closed
190
+ * run?.notes.map((n) => n.kind); // ['attempt', 'attempt'] — kept as they happened
191
+ * timeTravel(run!.source()).last(); // the cursor over what landed
192
+ * stateAt(run!.source(), run!.commitLog.length - 1).skipped; // a lost commit, by index and reason
193
+ * ```
194
+ */
195
+ export declare function readStoredRecord(entries: Iterable<unknown>, limits?: StoredRecordLimits): StoredRecord;