foottrace 1.0.3 → 1.2.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 (128) hide show
  1. package/README.md +137 -4
  2. package/dist/cause.js +14 -0
  3. package/dist/esm/cause.d.ts +8 -0
  4. package/dist/esm/cause.js +5 -0
  5. package/dist/esm/index.d.ts +7 -2
  6. package/dist/esm/index.js +2 -1
  7. package/dist/esm/lib/cause/admission.d.ts +27 -0
  8. package/dist/esm/lib/cause/admission.js +59 -0
  9. package/dist/esm/lib/cause/explain.d.ts +5 -0
  10. package/dist/esm/lib/cause/explain.js +114 -0
  11. package/dist/esm/lib/cause/ranking.d.ts +6 -0
  12. package/dist/esm/lib/cause/ranking.js +10 -0
  13. package/dist/esm/lib/cause/recordAdapter.d.ts +9 -0
  14. package/dist/esm/lib/cause/recordAdapter.js +49 -0
  15. package/dist/esm/lib/cause/recordChildren.d.ts +4 -0
  16. package/dist/esm/lib/cause/recordChildren.js +79 -0
  17. package/dist/esm/lib/cause/recordControl.d.ts +13 -0
  18. package/dist/esm/lib/cause/recordControl.js +66 -0
  19. package/dist/esm/lib/cause/recordCoverage.d.ts +24 -0
  20. package/dist/esm/lib/cause/recordCoverage.js +87 -0
  21. package/dist/esm/lib/cause/recordIdentity.d.ts +9 -0
  22. package/dist/esm/lib/cause/recordIdentity.js +67 -0
  23. package/dist/esm/lib/cause/recordLoading.d.ts +7 -0
  24. package/dist/esm/lib/cause/recordLoading.js +116 -0
  25. package/dist/esm/lib/cause/recordParents.d.ts +4 -0
  26. package/dist/esm/lib/cause/recordParents.js +86 -0
  27. package/dist/esm/lib/cause/recordScope.d.ts +8 -0
  28. package/dist/esm/lib/cause/recordScope.js +60 -0
  29. package/dist/esm/lib/cause/recordTypes.d.ts +67 -0
  30. package/dist/esm/lib/cause/recordTypes.js +1 -0
  31. package/dist/esm/lib/cause/recordValidation.d.ts +33 -0
  32. package/dist/esm/lib/cause/recordValidation.js +198 -0
  33. package/dist/esm/lib/cause/session.d.ts +30 -0
  34. package/dist/esm/lib/cause/session.js +161 -0
  35. package/dist/esm/lib/cause/storedRecordAdapter.d.ts +11 -0
  36. package/dist/esm/lib/cause/storedRecordAdapter.js +57 -0
  37. package/dist/esm/lib/cause/strategies.d.ts +3 -0
  38. package/dist/esm/lib/cause/strategies.js +21 -0
  39. package/dist/esm/lib/cause/types.d.ts +90 -0
  40. package/dist/esm/lib/cause/types.js +1 -0
  41. package/dist/esm/lib/cause/validation.d.ts +24 -0
  42. package/dist/esm/lib/cause/validation.js +134 -0
  43. package/dist/esm/lib/cause/walk.d.ts +12 -0
  44. package/dist/esm/lib/cause/walk.js +174 -0
  45. package/dist/esm/lib/ids/attemptId.d.ts +63 -0
  46. package/dist/esm/lib/ids/attemptId.js +91 -0
  47. package/dist/esm/lib/memory/EventLog.d.ts +89 -3
  48. package/dist/esm/lib/memory/EventLog.js +242 -3
  49. package/dist/esm/lib/memory/honesty.d.ts +6 -4
  50. package/dist/esm/lib/memory/honesty.js +7 -4
  51. package/dist/esm/lib/memory/sink.d.ts +148 -0
  52. package/dist/esm/lib/memory/sink.js +92 -0
  53. package/dist/esm/lib/memory/types.d.ts +4 -1
  54. package/dist/esm/lib/memory/types.js +1 -1
  55. package/dist/esm/lib/time-travel/bundles.d.ts +2 -0
  56. package/dist/esm/lib/time-travel/bundles.js +16 -0
  57. package/dist/esm/lib/time-travel/commitStops.js +21 -2
  58. package/dist/esm/lib/time-travel/index.d.ts +2 -0
  59. package/dist/esm/lib/time-travel/index.js +1 -0
  60. package/dist/esm/lib/time-travel/storedRecord.d.ts +195 -0
  61. package/dist/esm/lib/time-travel/storedRecord.js +494 -0
  62. package/dist/esm/lib/time-travel/timeTravel.js +11 -0
  63. package/dist/esm/lib/time-travel/types.d.ts +4 -1
  64. package/dist/esm/write.d.ts +7 -0
  65. package/dist/esm/write.js +6 -0
  66. package/dist/index.js +6 -2
  67. package/dist/lib/cause/admission.js +67 -0
  68. package/dist/lib/cause/explain.js +119 -0
  69. package/dist/lib/cause/ranking.js +14 -0
  70. package/dist/lib/cause/recordAdapter.js +55 -0
  71. package/dist/lib/cause/recordChildren.js +83 -0
  72. package/dist/lib/cause/recordControl.js +74 -0
  73. package/dist/lib/cause/recordCoverage.js +99 -0
  74. package/dist/lib/cause/recordIdentity.js +73 -0
  75. package/dist/lib/cause/recordLoading.js +121 -0
  76. package/dist/lib/cause/recordParents.js +90 -0
  77. package/dist/lib/cause/recordScope.js +66 -0
  78. package/dist/lib/cause/recordTypes.js +2 -0
  79. package/dist/lib/cause/recordValidation.js +213 -0
  80. package/dist/lib/cause/session.js +165 -0
  81. package/dist/lib/cause/storedRecordAdapter.js +61 -0
  82. package/dist/lib/cause/strategies.js +26 -0
  83. package/dist/lib/cause/types.js +2 -0
  84. package/dist/lib/cause/validation.js +150 -0
  85. package/dist/lib/cause/walk.js +180 -0
  86. package/dist/lib/ids/attemptId.js +96 -0
  87. package/dist/lib/memory/EventLog.js +242 -3
  88. package/dist/lib/memory/honesty.js +7 -4
  89. package/dist/lib/memory/sink.js +98 -0
  90. package/dist/lib/memory/types.js +1 -1
  91. package/dist/lib/time-travel/bundles.js +18 -1
  92. package/dist/lib/time-travel/commitStops.js +21 -2
  93. package/dist/lib/time-travel/index.js +3 -1
  94. package/dist/lib/time-travel/storedRecord.js +498 -0
  95. package/dist/lib/time-travel/timeTravel.js +11 -0
  96. package/dist/types/cause.d.ts +8 -0
  97. package/dist/types/index.d.ts +7 -2
  98. package/dist/types/lib/cause/admission.d.ts +27 -0
  99. package/dist/types/lib/cause/explain.d.ts +5 -0
  100. package/dist/types/lib/cause/ranking.d.ts +6 -0
  101. package/dist/types/lib/cause/recordAdapter.d.ts +9 -0
  102. package/dist/types/lib/cause/recordChildren.d.ts +4 -0
  103. package/dist/types/lib/cause/recordControl.d.ts +13 -0
  104. package/dist/types/lib/cause/recordCoverage.d.ts +24 -0
  105. package/dist/types/lib/cause/recordIdentity.d.ts +9 -0
  106. package/dist/types/lib/cause/recordLoading.d.ts +7 -0
  107. package/dist/types/lib/cause/recordParents.d.ts +4 -0
  108. package/dist/types/lib/cause/recordScope.d.ts +8 -0
  109. package/dist/types/lib/cause/recordTypes.d.ts +67 -0
  110. package/dist/types/lib/cause/recordValidation.d.ts +33 -0
  111. package/dist/types/lib/cause/session.d.ts +30 -0
  112. package/dist/types/lib/cause/storedRecordAdapter.d.ts +11 -0
  113. package/dist/types/lib/cause/strategies.d.ts +3 -0
  114. package/dist/types/lib/cause/types.d.ts +90 -0
  115. package/dist/types/lib/cause/validation.d.ts +24 -0
  116. package/dist/types/lib/cause/walk.d.ts +12 -0
  117. package/dist/types/lib/ids/attemptId.d.ts +63 -0
  118. package/dist/types/lib/memory/EventLog.d.ts +89 -3
  119. package/dist/types/lib/memory/honesty.d.ts +6 -4
  120. package/dist/types/lib/memory/sink.d.ts +148 -0
  121. package/dist/types/lib/memory/types.d.ts +4 -1
  122. package/dist/types/lib/time-travel/bundles.d.ts +2 -0
  123. package/dist/types/lib/time-travel/index.d.ts +2 -0
  124. package/dist/types/lib/time-travel/storedRecord.d.ts +195 -0
  125. package/dist/types/lib/time-travel/types.d.ts +4 -1
  126. package/dist/types/write.d.ts +7 -0
  127. package/dist/write.js +8 -1
  128. package/package.json +67 -13
@@ -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
- constructor(initialMemory) {
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
- /** Immutable identity of this log, absent until the engine binds it. */
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
- /** Wipes history (useful for test resets). */
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
@@ -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
+ }
@@ -2,10 +2,11 @@
2
2
  * types.ts — the record's types: a bundle, its rows, its patches, its encoding, and the
3
3
  * execution tree as the record's readers read it.
4
4
  *
5
- * Imports nothing. The standalone record names nothing outside itself: the engine's frame
5
+ * Imports only the record's leaf attention type. The record names nothing outside itself: the engine's frame
6
6
  * types — the stage snapshot, flow messages and retention dials — remain in FootPrint's
7
7
  * `frameTypes.ts`. The closed record graph is enforced by scripts/check-layering.mjs.
8
8
  */
9
+ import type { Attention } from '../cause/types.js';
9
10
  /** A flat key-value bag representing a state patch (overwrite or merge). */
10
11
  export interface MemoryPatch {
11
12
  [key: string]: any;
@@ -194,6 +195,8 @@ export type CommitValuesMode = 'full' | 'delta';
194
195
  * ```
195
196
  */
196
197
  export interface ExecutionTree {
198
+ /** Optional recorded business attention; absence does not assert health. */
199
+ readonly attention?: readonly Attention[];
197
200
  /** The stage's id (prefixed inside a subflow). `commitStops` reads the root's to place a subflow's seed. */
198
201
  readonly id?: string;
199
202
  /** `[subflowPath/]stageId#executionIndex` — what a reader keys on. A node without one is not a stage. */
@@ -2,7 +2,7 @@
2
2
  * types.ts — the record's types: a bundle, its rows, its patches, its encoding, and the
3
3
  * execution tree as the record's readers read it.
4
4
  *
5
- * Imports nothing. The standalone record names nothing outside itself: the engine's frame
5
+ * Imports only the record's leaf attention type. The record names nothing outside itself: the engine's frame
6
6
  * types — the stage snapshot, flow messages and retention dials — remain in FootPrint's
7
7
  * `frameTypes.ts`. The closed record graph is enforced by scripts/check-layering.mjs.
8
8
  */
@@ -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
  *
@@ -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
  *
@@ -76,6 +89,9 @@ export function readTree(value) {
76
89
  * compatibility break dressed up as validation.
77
90
  */
78
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;
79
95
  if (!isPlainish(row)) {
80
96
  return `not an object (${row === null ? 'null' : Array.isArray(row) ? 'array' : typeof row})`;
81
97
  }