@executablemd/durable-streams 0.6.0 → 0.8.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.
@@ -0,0 +1,390 @@
1
+ /**
2
+ * Retained events — what a run reads a journal as.
3
+ *
4
+ * A journal is data supplied by a backend, and every phase of a replay reads
5
+ * the same events: a private authority gate, the replay index, public guard
6
+ * policy, and the replay path itself. If those are separate reads of the
7
+ * backend's own objects, a source that answers differently between them decides
8
+ * one thing for validation and another for execution, and nothing downstream
9
+ * can detect the substitution.
10
+ *
11
+ * A retained event is therefore read once and detached. **Every** event that
12
+ * participates in admission, indexing, or terminal reuse is retained, Close as
13
+ * well as Yield: a Close decides whether a coroutine has a terminal result to
14
+ * reuse, so leaving it as the backend's own object lets it belong to a child
15
+ * coroutine while one phase asks and to the root while the next does.
16
+ *
17
+ * The discriminator is settled once, by the classification that chooses a
18
+ * retained event's kind, and never read from the source again. Identity — the
19
+ * coroutine an event belongs to, and a Yield's complete effect description — is
20
+ * settled once too, so no phase can be shown a different event than the phase
21
+ * before it.
22
+ *
23
+ * A Yield's *settlement* stays lazy and separate: the index is built before
24
+ * guards run, and a guard that would refuse an event has to get that chance
25
+ * before the stream is asked to produce its result. A Close keeps its own cell,
26
+ * memoized the same way, so every later read receives the same detached answer.
27
+ */
28
+ function settle(read) {
29
+ try {
30
+ return { kind: "value", value: read() };
31
+ }
32
+ catch (refusal) {
33
+ return { kind: "refusal", refusal };
34
+ }
35
+ }
36
+ function resolve(settled) {
37
+ if (settled.kind === "refusal") {
38
+ throw settled.refusal;
39
+ }
40
+ return settled.value;
41
+ }
42
+ /**
43
+ * A detached copy of one retained JSON value.
44
+ *
45
+ * Every property is read once and rebuilt, so nothing the stream still owns
46
+ * remains reachable: a nested accessor cannot answer one thing to one phase and
47
+ * another to the next, and no later mutation of the source changes what replay
48
+ * used.
49
+ *
50
+ * The copy is ordinary JSON. Detaching is the claim against the *stream*;
51
+ * making the copy immutable would be a claim against the *consumer*, and
52
+ * replayed values are legitimately mutable — an eval binding restored from a
53
+ * journal is pushed to by the iteration that resumes on it. Members are
54
+ * therefore writable and configurable like any other JSON.
55
+ *
56
+ * Keys are defined rather than assigned all the same, because `__proto__`
57
+ * reaches an inherited setter on some runtimes and would rewrite the copy's
58
+ * prototype instead of becoming a member of it.
59
+ *
60
+ * A cycle is refused. `Json` has none, and a value that does is not something
61
+ * this can detach — refusing is remembered like any other refusal.
62
+ */
63
+ export function detachJson(value, seen = new Set()) {
64
+ if (value === null || typeof value !== "object") {
65
+ return value;
66
+ }
67
+ if (seen.has(value)) {
68
+ throw new TypeError("a retained value cannot contain a cycle");
69
+ }
70
+ seen.add(value);
71
+ try {
72
+ if (Array.isArray(value)) {
73
+ const items = [];
74
+ for (let index = 0; index < value.length; index++) {
75
+ items.push(detachJson(value[index], seen));
76
+ }
77
+ return items;
78
+ }
79
+ const detached = {};
80
+ for (const [key, member] of Object.entries(value)) {
81
+ Object.defineProperty(detached, key, {
82
+ value: detachJson(member, seen),
83
+ enumerable: true,
84
+ writable: true,
85
+ configurable: true,
86
+ });
87
+ }
88
+ return detached;
89
+ }
90
+ finally {
91
+ seen.delete(value);
92
+ }
93
+ }
94
+ /**
95
+ * A detached copy, frozen through.
96
+ *
97
+ * The authoritative retained graph is what admission validated and what replay
98
+ * consumes, so nothing that reaches a caller may write to it. Freezing is the
99
+ * claim against *policy*, not against a workflow: what a document finally
100
+ * receives is a fresh mutable copy taken from this, never this.
101
+ */
102
+ function sealJson(value) {
103
+ const detached = detachJson(value);
104
+ freezeDeep(detached);
105
+ return detached;
106
+ }
107
+ function freezeDeep(value) {
108
+ if (value === null || typeof value !== "object") {
109
+ return;
110
+ }
111
+ Object.freeze(value);
112
+ for (const member of Array.isArray(value) ? value : Object.values(value)) {
113
+ freezeDeep(member);
114
+ }
115
+ }
116
+ /** A detached copy of a retained failure's description. */
117
+ function detachError(error) {
118
+ const name = error.name;
119
+ const stack = error.stack;
120
+ return Object.freeze({
121
+ message: error.message,
122
+ ...(name === undefined ? {} : { name }),
123
+ ...(stack === undefined ? {} : { stack }),
124
+ });
125
+ }
126
+ /**
127
+ * A retained result detached from everything the stream still owns.
128
+ *
129
+ * Each member is read exactly once, here, and the tree beneath it is rebuilt.
130
+ * Copying only the outer object would leave a `value` the journal can still
131
+ * rewrite, which is the substitution this exists to prevent.
132
+ */
133
+ function detachResult(result) {
134
+ const status = result.status;
135
+ if (status === "ok") {
136
+ // Every successful shape, including the one that settled to nothing. A
137
+ // `Result<void>` carries no value to detach, but the envelope is still
138
+ // authority — left writable, a caller of a public observation could add one
139
+ // before replay reads it.
140
+ if (!("value" in result)) {
141
+ return Object.freeze({ status });
142
+ }
143
+ const value = result.value;
144
+ return Object.freeze(value === undefined ? { status } : { status, value: sealJson(value) });
145
+ }
146
+ if (status === "err") {
147
+ if (!("error" in result)) {
148
+ throw new TypeError("a retained failure carries the error it failed with");
149
+ }
150
+ return Object.freeze({ status, error: detachError(result.error) });
151
+ }
152
+ return Object.freeze({ status });
153
+ }
154
+ /**
155
+ * A detached copy of an effect description.
156
+ *
157
+ * `type` and `name` are the identity divergence detection compares; every other
158
+ * member is extra data a guard may read. All of it is rebuilt, so a description
159
+ * cannot name one effect while one phase looks and another while the next does.
160
+ */
161
+ function detachDescription(description) {
162
+ // One enumeration, so every member — `type` and `name` included — is read
163
+ // exactly once. Reading them directly and then enumerating would read each of
164
+ // them twice, which is the second read this exists to remove.
165
+ const members = Object.entries(description);
166
+ let type;
167
+ let name;
168
+ const extra = [];
169
+ for (const [key, member] of members) {
170
+ if (key === "type") {
171
+ type = member;
172
+ continue;
173
+ }
174
+ if (key === "name") {
175
+ name = member;
176
+ continue;
177
+ }
178
+ extra.push([key, member]);
179
+ }
180
+ if (typeof type !== "string" || typeof name !== "string") {
181
+ throw new TypeError("a retained effect description carries a type and a name");
182
+ }
183
+ const detached = { type, name };
184
+ for (const [key, member] of extra) {
185
+ Object.defineProperty(detached, key, {
186
+ value: sealJson(member),
187
+ enumerable: true,
188
+ writable: false,
189
+ configurable: false,
190
+ });
191
+ }
192
+ return Object.freeze(detached);
193
+ }
194
+ function readCoroutineId(source) {
195
+ const coroutineId = source.coroutineId;
196
+ if (typeof coroutineId !== "string") {
197
+ throw new TypeError("a retained event belongs to a coroutine");
198
+ }
199
+ return coroutineId;
200
+ }
201
+ function readIdentity(source) {
202
+ return {
203
+ coroutineId: readCoroutineId(source),
204
+ description: detachDescription(source.description),
205
+ };
206
+ }
207
+ /**
208
+ * One retained Yield: one identity, and one cell for what it settled to.
209
+ *
210
+ * Identity is read together and once. Reading `type` here and `coroutineId`
211
+ * there would let a source present an unrelated event to one phase and the root
212
+ * import to the next, which is the whole reason identity is a single settled
213
+ * fact rather than three accessors.
214
+ *
215
+ * The settlement is separate and lazy on purpose: the index is built before
216
+ * guards run, so a guard that would refuse an event must get that chance before
217
+ * the stream is asked for its result. Both outcomes of both reads are kept — a
218
+ * refusal is remembered and re-raised rather than retried, so a source cannot
219
+ * refuse one phase and then answer the next.
220
+ */
221
+ /**
222
+ * Present a retained member the way the event it stands for presents it.
223
+ *
224
+ * Own and enumerable, so a retained event spreads, serializes, and compares
225
+ * like the plain event a backend would have supplied. The settled cells stay
226
+ * genuinely private, which is what keeps them out of all of that.
227
+ */
228
+ function present(target, name, read) {
229
+ Object.defineProperty(target, name, { enumerable: true, get: read });
230
+ }
231
+ class RetainedYield {
232
+ #source;
233
+ #identity;
234
+ #settled;
235
+ constructor(source) {
236
+ this.#source = source;
237
+ present(this, "type", () => "yield");
238
+ present(this, "coroutineId", () => this.#stable().coroutineId);
239
+ present(this, "description", () => this.#stable().description);
240
+ present(this, "result", () => {
241
+ this.#settled ??= settle(() => detachResult(this.#source.result));
242
+ return resolve(this.#settled);
243
+ });
244
+ }
245
+ #stable() {
246
+ this.#identity ??= settle(() => readIdentity(this.#source));
247
+ return resolve(this.#identity);
248
+ }
249
+ }
250
+ /**
251
+ * One retained Close: settled identity, and one cell for its terminal result.
252
+ *
253
+ * A Close decides whether a coroutine has a terminal result to reuse, so it
254
+ * participates in admission exactly as a Yield does. Left as the backend's own
255
+ * object it could belong to a child coroutine while one phase asks and to the
256
+ * root while the next does — a history nobody could admit, reused as a result
257
+ * nobody asked for.
258
+ */
259
+ class RetainedClose {
260
+ #identity;
261
+ #settled;
262
+ constructor(source) {
263
+ // Settled here, while the history is being retained, rather than at a first
264
+ // later read. A Close carries the result a completed run hands back, and
265
+ // deferring that read leaves an interval — between the moment a consumer's
266
+ // private admission accepts the history and the moment terminal reuse
267
+ // consumes it — in which the backend still owns the answer and can replace
268
+ // it. Reading once at a later getter closes repeated reads and leaves that
269
+ // window open.
270
+ //
271
+ // Settling cannot throw: a refusal is captured and re-raised from the
272
+ // getter, so retaining a history is never the thing that fails.
273
+ this.#identity = settle(() => readCoroutineId(source));
274
+ this.#settled = settle(() => detachResult(source.result));
275
+ present(this, "type", () => "close");
276
+ present(this, "coroutineId", () => resolve(this.#identity));
277
+ present(this, "result", () => resolve(this.#settled));
278
+ }
279
+ }
280
+ /**
281
+ * An event that would not say what it is.
282
+ *
283
+ * Classification is the one thing every later phase depends on, so an event
284
+ * that refuses it is not an event this history can describe. The refusal is
285
+ * remembered and re-raised from every member, rather than retried — a source
286
+ * that refuses one phase must not answer the next.
287
+ */
288
+ class RetainedRefusal {
289
+ #refusal;
290
+ constructor(refusal) {
291
+ this.#refusal = refusal;
292
+ for (const name of ["type", "coroutineId", "description", "result"]) {
293
+ present(this, name, () => {
294
+ throw this.#refusal;
295
+ });
296
+ }
297
+ }
298
+ }
299
+ /**
300
+ * The retained form of a journal's events.
301
+ *
302
+ * Idempotent: retaining an already-retained event returns it, so a caller that
303
+ * has produced the stable history hands the same objects onward rather than a
304
+ * second wrapping of them. That is what lets one snapshot serve every phase.
305
+ *
306
+ * Only the event type is read here, which is the least a caller can read and
307
+ * still tell a Yield from a Close. Everything else is the retained event's own.
308
+ */
309
+ export function retainEvents(events) {
310
+ return events.map((event) => {
311
+ if (isRetained(event)) {
312
+ return event;
313
+ }
314
+ // The one read of the source's discriminator. Whatever it says here is what
315
+ // the retained event reports from now on, to every phase.
316
+ const classified = settle(() => event.type);
317
+ if (classified.kind === "refusal") {
318
+ return new RetainedRefusal(classified.refusal);
319
+ }
320
+ if (classified.value === "yield") {
321
+ return new RetainedYield(event);
322
+ }
323
+ if (classified.value === "close") {
324
+ return new RetainedClose(event);
325
+ }
326
+ return new RetainedRefusal(new TypeError("a retained event is a yield or a close"));
327
+ });
328
+ }
329
+ function isRetained(event) {
330
+ return (event instanceof RetainedYield ||
331
+ event instanceof RetainedClose ||
332
+ event instanceof RetainedRefusal);
333
+ }
334
+ /**
335
+ * An isolated observation of a retained event, for public policy to read.
336
+ *
337
+ * A replay guard is composable policy, and composition means handlers read,
338
+ * annotate, and pass along. What it must never mean is that a handler edits the
339
+ * history the execution already validated: the authoritative graph is what
340
+ * admission accepted and what replay consumes, and a guard that could rewrite a
341
+ * root selection or an effect description after admission would hold exactly
342
+ * the authority the private gate exists to keep out of public hands.
343
+ *
344
+ * So policy reads a copy. It is deep and mutable, so middleware may compose over
345
+ * it as freely as it likes, and nothing it does reaches replay.
346
+ */
347
+ export function observeEvent(event) {
348
+ if (event.type === "close") {
349
+ return { type: "close", coroutineId: event.coroutineId, result: consumable(event.result) };
350
+ }
351
+ return {
352
+ type: "yield",
353
+ coroutineId: event.coroutineId,
354
+ description: observeDescription(event.description),
355
+ result: consumable(event.result),
356
+ };
357
+ }
358
+ function observeDescription(description) {
359
+ const copy = { type: description.type, name: description.name };
360
+ for (const [key, member] of Object.entries(description)) {
361
+ if (key === "type" || key === "name") {
362
+ continue;
363
+ }
364
+ Object.defineProperty(copy, key, {
365
+ value: detachJson(member),
366
+ enumerable: true,
367
+ writable: true,
368
+ configurable: true,
369
+ });
370
+ }
371
+ return copy;
372
+ }
373
+ /**
374
+ * A retained result as a consumer may hold it: ordinary mutable JSON.
375
+ *
376
+ * The authoritative copy is frozen so policy cannot rewrite it. A document
377
+ * that resumes on a restored binding writes to it, so what a workflow receives
378
+ * is a fresh copy taken from that authority rather than the authority itself.
379
+ */
380
+ export function consumable(result) {
381
+ if (result.status === "ok") {
382
+ return "value" in result && result.value !== undefined
383
+ ? { status: "ok", value: detachJson(result.value) }
384
+ : { status: "ok" };
385
+ }
386
+ if (result.status === "err") {
387
+ return { status: "err", error: { ...result.error } };
388
+ }
389
+ return { status: "cancelled" };
390
+ }
package/esm/run.js CHANGED
@@ -13,11 +13,16 @@
13
13
  * See integration doc §10, protocol spec §4.
14
14
  */
15
15
  import { useScope } from "effection";
16
- import { DurableCtx } from "./context.js";
17
- import { EarlyReturnDivergenceError } from "./errors.js";
16
+ import { DurableContext } from "./context.js";
17
+ import { activeDurabilityFailure, appendDurableEvent } from "./durability.js";
18
+ import { EarlyReturnDivergenceError, TerminalDivergenceError } from "./errors.js";
18
19
  import { ReplayGuard } from "./replay-guard.js";
20
+ import { consumable, observeEvent } from "./retained.js";
19
21
  import { ReplayIndex } from "./replay-index.js";
20
22
  import { deserializeError, serializeError } from "./serialize.js";
23
+ function unalignedReplay(replayIndex, coroutineId) {
24
+ return replayIndex.firstUnaligned(coroutineId);
25
+ }
21
26
  /**
22
27
  * Run the ReplayGuard check phase over all Yield events.
23
28
  *
@@ -26,12 +31,22 @@ import { deserializeError, serializeError } from "./serialize.js";
26
31
  * phase to gather observations (hash files, check timestamps) and cache
27
32
  * results for the decide phase.
28
33
  *
34
+ * The events come from the index rather than from the stream, so a guard reads
35
+ * the same retained result the replay path will use. Handing over the stream's
36
+ * own events instead would make validation and consumption two separate reads,
37
+ * and a source that answered differently between them could have a guard
38
+ * approve one result while execution used another.
39
+ *
29
40
  * See replay-guard-spec.md §5.5.
30
41
  */
31
- function* runCheckPhase(events, scope) {
32
- for (const event of events) {
33
- if (event.type === "yield") {
34
- yield* ReplayGuard.invoke(scope, "check", [event]);
42
+ function* runCheckPhase(replayIndex, scope) {
43
+ for (const event of replayIndex.retainedYields()) {
44
+ // An isolated observation, not the retained event. Guards compose by
45
+ // reading and passing along; what composition must not become is the power
46
+ // to edit a history the execution already validated.
47
+ const observed = observeEvent(event);
48
+ if (observed.type === "yield") {
49
+ yield* ReplayGuard.invoke(scope, "check", [observed]);
35
50
  }
36
51
  }
37
52
  }
@@ -43,7 +58,8 @@ function* runCheckPhase(events, scope) {
43
58
  * 3. Runs the workflow — replayed effects resolve synchronously from
44
59
  * the index; live effects execute and persist before resuming.
45
60
  * 4. On completion, appends a Close event to the stream.
46
- * 5. On error, appends a Close(err) event.
61
+ * 5. Before any Close, rejects durability failures and retained coroutine
62
+ * history the current definition did not align with.
47
63
  *
48
64
  * Returns the workflow's result value.
49
65
  *
@@ -62,25 +78,47 @@ export function* durableRun(workflow, options) {
62
78
  // Inherit the caller's scope — middleware (e.g., Divergence, ReplayGuard)
63
79
  // is already installed by the caller before yield*-ing into durableRun.
64
80
  const scope = yield* useScope();
65
- scope.set(DurableCtx, {
81
+ const ctx = {
66
82
  replayIndex,
67
83
  stream,
68
84
  coroutineId,
69
85
  childCounter: 0,
70
- });
86
+ durability: {},
87
+ };
88
+ scope.set(DurableContext, ctx);
71
89
  // ── REPLAY GUARD: Check phase ──
72
90
  // Run before the workflow starts. Middleware can yield* for I/O (hash
73
91
  // files, make network requests) to gather observations for the decide
74
92
  // phase. The check loop iterates all Yield events in journal order.
75
93
  // See replay-guard-spec.md §5.5.
76
- yield* runCheckPhase(events, scope);
94
+ yield* runCheckPhase(replayIndex, scope);
95
+ // ── REPLAY GUARD: Admit phase ──
96
+ // The retained history has been offered in full and nothing has been reused
97
+ // yet. A guard that requires something of the history as a whole — that an
98
+ // event it validates is present, and present once — refuses here, before the
99
+ // recorded terminal result below can answer for history nobody validated.
100
+ yield* ReplayGuard.invoke(scope, "admit", [
101
+ {
102
+ coroutineId,
103
+ yields: replayIndex.retainedYields().flatMap((event) => {
104
+ const observed = observeEvent(event);
105
+ return observed.type === "yield" ? [observed] : [];
106
+ }),
107
+ terminal: replayIndex.hasClose(coroutineId),
108
+ },
109
+ ]);
77
110
  // If the root coroutine already has a Close event in the journal,
78
111
  // the workflow completed in a previous run. Return the stored result
79
112
  // directly without re-running the workflow.
80
113
  if (replayIndex.hasClose(coroutineId)) {
81
114
  const closeEvent = replayIndex.getClose(coroutineId);
82
115
  if (closeEvent.result.status === "ok") {
83
- return closeEvent.result.value;
116
+ // A fresh consumer copy, exactly as a replayed Yield's result is. The
117
+ // retained settlement is frozen so policy cannot rewrite it; what a
118
+ // caller receives from a completed run is ordinary data it may hold and
119
+ // change, and changing it cannot reach the next replay.
120
+ const settled = consumable(closeEvent.result);
121
+ return (settled.status === "ok" ? settled.value : undefined);
84
122
  }
85
123
  else if (closeEvent.result.status === "err") {
86
124
  throw deserializeError(closeEvent.result.error);
@@ -89,34 +127,37 @@ export function* durableRun(workflow, options) {
89
127
  throw new Error("Workflow was cancelled");
90
128
  }
91
129
  }
130
+ replayIndex.claim(coroutineId);
92
131
  try {
93
132
  // Workflow<T> is structurally assignable to Operation<T>, so
94
133
  // yield* accepts it directly — no cast needed.
95
134
  const result = yield* workflow();
96
- // §6.3: Check for early return divergence.
97
- // If the generator returned but the replay index has unconsumed yields,
98
- // the workflow has diverged. Skip this check when replay has been
99
- // disabled (run-live mode) — the workflow intentionally diverged and
100
- // the Divergence API already approved it.
101
- if (!replayIndex.isReplayDisabled(coroutineId)) {
102
- const unconsumed = replayIndex.firstUnconsumed();
103
- if (unconsumed) {
104
- throw new EarlyReturnDivergenceError(unconsumed.coroutineId, unconsumed.cursor, unconsumed.totalYields);
105
- }
135
+ const durabilityFailure = activeDurabilityFailure(ctx);
136
+ if (durabilityFailure) {
137
+ throw durabilityFailure;
138
+ }
139
+ const unconsumed = unalignedReplay(replayIndex, coroutineId);
140
+ if (unconsumed) {
141
+ throw new EarlyReturnDivergenceError(unconsumed.coroutineId, unconsumed.cursor, unconsumed.totalYields);
106
142
  }
107
143
  const closeEvent = {
108
144
  type: "close",
109
145
  coroutineId,
110
146
  result: { status: "ok", value: result },
111
147
  };
112
- yield* stream.append(closeEvent);
148
+ yield* appendDurableEvent(ctx, closeEvent);
113
149
  return result;
114
150
  }
115
151
  catch (error) {
116
- // Normalize the error once — use the same Error object for both the
117
- // Close event and the rethrow so that live runs and replayed Close
118
- // events carry identical error shapes.
119
152
  const primary = error instanceof Error ? error : new Error(String(error));
153
+ const durabilityFailure = activeDurabilityFailure(ctx, primary);
154
+ if (durabilityFailure) {
155
+ throw durabilityFailure;
156
+ }
157
+ const unconsumed = unalignedReplay(replayIndex, coroutineId);
158
+ if (unconsumed) {
159
+ throw new TerminalDivergenceError(unconsumed.coroutineId, unconsumed.cursor, unconsumed.totalYields, { cause: primary });
160
+ }
120
161
  const closeEvent = {
121
162
  type: "close",
122
163
  coroutineId,
@@ -126,11 +167,15 @@ export function* durableRun(workflow, options) {
126
167
  },
127
168
  };
128
169
  try {
129
- yield* stream.append(closeEvent);
170
+ yield* appendDurableEvent(ctx, closeEvent);
130
171
  }
131
- catch (appendError) {
132
- const appendFailure = appendError instanceof Error ? appendError : new Error(String(appendError));
133
- throw new AggregateError([primary, appendFailure], "Workflow failed and Close append also failed");
172
+ catch (closeError) {
173
+ const closeDurabilityFailure = activeDurabilityFailure(ctx, closeError);
174
+ if (closeDurabilityFailure) {
175
+ throw closeDurabilityFailure;
176
+ }
177
+ const closeFailure = closeError instanceof Error ? closeError : new Error(String(closeError));
178
+ throw new AggregateError([primary, closeFailure], "Workflow failed and Close append also failed");
134
179
  }
135
180
  throw primary;
136
181
  }
package/esm/serialize.js CHANGED
@@ -5,7 +5,36 @@
5
5
  * - Protocol Result ({ status: "ok" | "err" | "cancelled" })
6
6
  * - Effection Result ({ ok: true, value } | { ok: false, error })
7
7
  * - Error ↔ SerializedError
8
+ *
9
+ * `serializeDurableEvent` is the shared NDJSON representation: file
10
+ * persistence writes it, and gates that inspect the persisted form derive
11
+ * it from the same function.
8
12
  */
13
+ /**
14
+ * Render one durable event as its NDJSON record, terminating newline
15
+ * included.
16
+ *
17
+ * This is ordinary `JSON.stringify(event) + "\n"`. Field order follows the
18
+ * event object's own insertion order; nothing is sorted, normalized, or
19
+ * otherwise canonicalized. The value of sharing it is that file persistence
20
+ * and anything inspecting the persisted form cannot drift apart, not that
21
+ * the output is a canonical form of the event.
22
+ *
23
+ * Serialization fails when `JSON.stringify` throws — a circular structure or
24
+ * a `BigInt` — or when it does not return a string. Values that
25
+ * `JSON.stringify` silently coerces or drops are left alone: `undefined`,
26
+ * function and symbol members are omitted, non-finite numbers become `null`,
27
+ * and non-plain objects serialize through `toJSON`.
28
+ */
29
+ export function serializeDurableEvent(event) {
30
+ const record = JSON.stringify(event);
31
+ // JSON.stringify returns undefined rather than throwing when the value
32
+ // itself has no JSON representation.
33
+ if (typeof record !== "string") {
34
+ throw new TypeError("serializeDurableEvent: event has no JSON representation");
35
+ }
36
+ return `${record}\n`;
37
+ }
9
38
  /** Serialize an Error to a JSON-safe SerializedError. */
10
39
  export function serializeError(error) {
11
40
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@executablemd/durable-streams",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "Durable, replayable event streams for executable.md.",
5
5
  "homepage": "https://executable.md",
6
6
  "repository": {
@@ -24,7 +24,7 @@
24
24
  "scripts": {},
25
25
  "dependencies": {
26
26
  "@durable-streams/client": "^0.2.2",
27
- "effection": "4.1.0-alpha.10"
27
+ "effection": "4.1.0"
28
28
  },
29
29
  "_generatedBy": "dnt@dev"
30
30
  }
@@ -0,0 +1,6 @@
1
+ declare global {
2
+ interface Error {
3
+ cause?: unknown;
4
+ }
5
+ }
6
+ export {};
@@ -5,10 +5,17 @@
5
5
  * inherit the shared replayIndex and stream, but get their own
6
6
  * coroutineId and childCounter.
7
7
  */
8
- import { type Context } from "effection";
8
+ import { type Context, type Operation } from "effection";
9
9
  import type { ReplayIndex } from "./replay-index.js";
10
10
  import type { DurableStream } from "./stream.js";
11
11
  import type { CoroutineId } from "./types.js";
12
+ export interface DurableAppendFence {
13
+ hold(): Operation<void>;
14
+ }
15
+ export interface DurabilityState {
16
+ failure?: Error;
17
+ appendFence?: DurableAppendFence;
18
+ }
12
19
  export interface DurableContext {
13
20
  /** Shared replay index (built from stream on startup). */
14
21
  replayIndex: ReplayIndex;
@@ -18,9 +25,11 @@ export interface DurableContext {
18
25
  coroutineId: CoroutineId;
19
26
  /** Counter for assigning child IDs. */
20
27
  childCounter: number;
28
+ /** Protocol failure shared by the root and every durable child. */
29
+ durability?: DurabilityState;
21
30
  }
22
31
  /**
23
32
  * Effection Context for durable execution state.
24
33
  * Set on the root scope by durableRun(); inherited by child scopes.
25
34
  */
26
- export declare const DurableCtx: Context<DurableContext>;
35
+ export declare const DurableContext: Context<DurableContext>;
@@ -0,0 +1,7 @@
1
+ import type { Operation } from "effection";
2
+ import type { DurableContext } from "./context.js";
3
+ import type { DurableEvent } from "./types.js";
4
+ export declare function findDurabilityFailure(error: unknown): Error | undefined;
5
+ export declare function rememberDurabilityFailure(ctx: DurableContext, error: Error): Error;
6
+ export declare function activeDurabilityFailure(ctx: DurableContext, error?: unknown): Error | undefined;
7
+ export declare function appendDurableEvent(ctx: DurableContext, event: DurableEvent): Operation<void>;