@executablemd/durable-streams 0.7.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 @@
1
+ export {};
@@ -18,8 +18,10 @@
18
18
  * See protocol spec §7 (structured concurrency), §10 (race semantics).
19
19
  */
20
20
  import { all as effectionAll, ensure, race as effectionRace, spawn, suspend, useScope, } from "effection";
21
- import { DurableCtx } from "./context.js";
21
+ import { DurableContext } from "./context.js";
22
+ import { activeDurabilityFailure, appendDurableEvent, rememberDurabilityFailure, } from "./durability.js";
22
23
  import { ephemeral } from "./ephemeral.js";
24
+ import { EarlyReturnDivergenceError, TerminalDivergenceError } from "./errors.js";
23
25
  import { deserializeError, serializeError } from "./serialize.js";
24
26
  /**
25
27
  * Run a child workflow within a spawned scope, setting up its own
@@ -29,7 +31,7 @@ import { deserializeError, serializeError } from "./serialize.js";
29
31
  *
30
32
  * It:
31
33
  * 1. Checks if the child already completed (has Close event) — short-circuits
32
- * 2. Sets DurableCtx on the child's scope with the child's coroutineId
34
+ * 2. Sets DurableContext on the child's scope with the child's coroutineId
33
35
  * 3. Runs the child workflow (its DurableEffects use the child's coroutineId)
34
36
  * 4. Appends Close(ok|err) when the child terminates
35
37
  *
@@ -38,6 +40,7 @@ import { deserializeError, serializeError } from "./serialize.js";
38
40
  */
39
41
  function* runDurableChild(childWorkflow, childId, parentCtx) {
40
42
  const { replayIndex, stream } = parentCtx;
43
+ replayIndex.claim(childId);
41
44
  // Short-circuit: child already completed in a previous run.
42
45
  // NOTE: Replay guard validation is not bypassed here — the check phase
43
46
  // (runCheckPhase in durableRun) already iterated ALL Yield events
@@ -72,17 +75,32 @@ function* runDurableChild(childWorkflow, childId, parentCtx) {
72
75
  }
73
76
  // Set child's DurableContext on this scope
74
77
  const scope = yield* useScope();
75
- scope.set(DurableCtx, {
78
+ parentCtx.durability ??= {};
79
+ const childCtx = {
76
80
  replayIndex,
77
81
  stream,
78
82
  coroutineId: childId,
79
83
  childCounter: 0,
80
- });
84
+ durability: parentCtx.durability,
85
+ };
86
+ scope.set(DurableContext, childCtx);
81
87
  let closeEvent;
88
+ let suppressClose = false;
82
89
  yield* ensure(function* () {
90
+ if (suppressClose || activeDurabilityFailure(childCtx)) {
91
+ return;
92
+ }
83
93
  // closeEvent still undefined means the child was cancelled before the
84
94
  // normal-return or catch path ran.
85
95
  if (!closeEvent) {
96
+ const unaligned = replayIndex.firstUnaligned(childId);
97
+ if (unaligned) {
98
+ const failure = new TerminalDivergenceError(unaligned.coroutineId, unaligned.cursor, unaligned.totalYields, {
99
+ message: `Divergence: coroutine ${childId} was cancelled before retained history was exhausted`,
100
+ });
101
+ rememberDurabilityFailure(childCtx, failure);
102
+ throw failure;
103
+ }
86
104
  closeEvent = {
87
105
  type: "close",
88
106
  coroutineId: childId,
@@ -92,13 +110,25 @@ function* runDurableChild(childWorkflow, childId, parentCtx) {
92
110
  // Don't re-emit a Close event if one already exists in the journal
93
111
  // (e.g., a cancelled child being replayed via suspend()).
94
112
  if (!replayIndex.hasClose(childId)) {
95
- yield* stream.append(closeEvent);
113
+ yield* appendDurableEvent(childCtx, closeEvent);
96
114
  }
97
115
  });
98
116
  try {
99
117
  // Run the child workflow. DurableEffects inside the child read
100
- // DurableCtx from the scope, so they'll use childId.
118
+ // DurableContext from the scope, so they'll use childId.
101
119
  const result = yield* childWorkflow();
120
+ const durabilityFailure = activeDurabilityFailure(childCtx);
121
+ if (durabilityFailure) {
122
+ suppressClose = true;
123
+ throw durabilityFailure;
124
+ }
125
+ const unaligned = replayIndex.firstUnaligned(childId);
126
+ if (unaligned) {
127
+ suppressClose = true;
128
+ const failure = new EarlyReturnDivergenceError(unaligned.coroutineId, unaligned.cursor, unaligned.totalYields);
129
+ rememberDurabilityFailure(childCtx, failure);
130
+ throw failure;
131
+ }
102
132
  closeEvent = {
103
133
  type: "close",
104
134
  coroutineId: childId,
@@ -107,15 +137,28 @@ function* runDurableChild(childWorkflow, childId, parentCtx) {
107
137
  return result;
108
138
  }
109
139
  catch (error) {
140
+ const primary = error instanceof Error ? error : new Error(String(error));
141
+ const durabilityFailure = activeDurabilityFailure(childCtx, primary);
142
+ if (durabilityFailure) {
143
+ suppressClose = true;
144
+ throw durabilityFailure;
145
+ }
146
+ const unaligned = replayIndex.firstUnaligned(childId);
147
+ if (unaligned) {
148
+ suppressClose = true;
149
+ const failure = new TerminalDivergenceError(unaligned.coroutineId, unaligned.cursor, unaligned.totalYields, { cause: primary });
150
+ rememberDurabilityFailure(childCtx, failure);
151
+ throw failure;
152
+ }
110
153
  closeEvent = {
111
154
  type: "close",
112
155
  coroutineId: childId,
113
156
  result: {
114
157
  status: "err",
115
- error: serializeError(error instanceof Error ? error : new Error(String(error))),
158
+ error: serializeError(primary),
116
159
  },
117
160
  };
118
- throw error;
161
+ throw primary;
119
162
  }
120
163
  }
121
164
  /**
@@ -133,7 +176,7 @@ function* runDurableChild(childWorkflow, childId, parentCtx) {
133
176
  export function durableSpawn(childWorkflow) {
134
177
  return ephemeral((function* () {
135
178
  const scope = yield* useScope();
136
- const ctx = scope.expect(DurableCtx);
179
+ const ctx = scope.expect(DurableContext);
137
180
  // Assign deterministic child ID
138
181
  const childIndex = ctx.childCounter++;
139
182
  const childId = `${ctx.coroutineId}.${childIndex}`;
@@ -158,7 +201,7 @@ export function durableSpawn(childWorkflow) {
158
201
  export function durableAll(workflows) {
159
202
  return ephemeral((function* () {
160
203
  const scope = yield* useScope();
161
- const ctx = scope.expect(DurableCtx);
204
+ const ctx = scope.expect(DurableContext);
162
205
  // Build child Operations, one per workflow. Each gets its own
163
206
  // deterministic coroutineId and Close event handling.
164
207
  const childOps = workflows.map((workflow) => {
@@ -197,7 +240,7 @@ export function durableAll(workflows) {
197
240
  export function durableRace(workflows) {
198
241
  return ephemeral((function* () {
199
242
  const scope = yield* useScope();
200
- const ctx = scope.expect(DurableCtx);
243
+ const ctx = scope.expect(DurableContext);
201
244
  // Build Operations for each child — each gets its own coroutineId
202
245
  // and Close event handling via runDurableChild.
203
246
  const childOps = workflows.map((workflow) => {
package/esm/context.js CHANGED
@@ -10,4 +10,4 @@ import { createContext } from "effection";
10
10
  * Effection Context for durable execution state.
11
11
  * Set on the root scope by durableRun(); inherited by child scopes.
12
12
  */
13
- export const DurableCtx = createContext("@effection/durable");
13
+ export const DurableContext = createContext("@effection/durable");
@@ -0,0 +1,118 @@
1
+ import { ensure, resource, withResolvers } from "effection";
2
+ import { ContinuePastCloseDivergenceError, DivergenceError, DurablePersistenceError, StaleInputError, TerminalDivergenceError, } from "./errors.js";
3
+ import { withDurableEventRejectionOccurrence, } from "./guard.js";
4
+ function createAppendFence() {
5
+ let held = false;
6
+ const waiting = [];
7
+ function release() {
8
+ const next = waiting.shift();
9
+ if (next === undefined) {
10
+ held = false;
11
+ return;
12
+ }
13
+ next.granted = true;
14
+ next.gate.resolve();
15
+ }
16
+ return {
17
+ hold: () => resource(function* (provide) {
18
+ const turn = { gate: withResolvers(), granted: false };
19
+ yield* ensure(() => {
20
+ if (turn.granted) {
21
+ release();
22
+ return;
23
+ }
24
+ const index = waiting.indexOf(turn);
25
+ if (index >= 0) {
26
+ waiting.splice(index, 1);
27
+ }
28
+ });
29
+ if (held) {
30
+ waiting.push(turn);
31
+ yield* turn.gate.operation;
32
+ }
33
+ else {
34
+ held = true;
35
+ turn.granted = true;
36
+ }
37
+ yield* provide();
38
+ }),
39
+ };
40
+ }
41
+ function durabilityState(ctx) {
42
+ ctx.durability ??= {};
43
+ return ctx.durability;
44
+ }
45
+ function appendFence(ctx) {
46
+ const state = durabilityState(ctx);
47
+ state.appendFence ??= createAppendFence();
48
+ return state.appendFence;
49
+ }
50
+ export function findDurabilityFailure(error) {
51
+ const visited = new Set();
52
+ const pending = [error];
53
+ while (pending.length > 0) {
54
+ const current = pending.shift();
55
+ if (visited.has(current)) {
56
+ continue;
57
+ }
58
+ visited.add(current);
59
+ if (current instanceof DurablePersistenceError ||
60
+ current instanceof StaleInputError ||
61
+ current instanceof DivergenceError ||
62
+ current instanceof TerminalDivergenceError ||
63
+ current instanceof ContinuePastCloseDivergenceError) {
64
+ return current;
65
+ }
66
+ if (current instanceof AggregateError) {
67
+ pending.push(...current.errors);
68
+ }
69
+ if (current instanceof Error && current.cause !== undefined) {
70
+ pending.push(current.cause);
71
+ }
72
+ }
73
+ return undefined;
74
+ }
75
+ export function rememberDurabilityFailure(ctx, error) {
76
+ const state = durabilityState(ctx);
77
+ state.failure ??= error;
78
+ return state.failure;
79
+ }
80
+ export function activeDurabilityFailure(ctx, error) {
81
+ if (ctx.durability?.failure) {
82
+ return ctx.durability.failure;
83
+ }
84
+ const failure = findDurabilityFailure(error);
85
+ if (failure) {
86
+ return rememberDurabilityFailure(ctx, failure);
87
+ }
88
+ return undefined;
89
+ }
90
+ export function* appendDurableEvent(ctx, event) {
91
+ const existing = activeDurabilityFailure(ctx);
92
+ if (existing) {
93
+ throw existing;
94
+ }
95
+ yield* appendFence(ctx).hold();
96
+ const admitted = activeDurabilityFailure(ctx);
97
+ if (admitted) {
98
+ throw admitted;
99
+ }
100
+ const occurrence = { rejected: false };
101
+ try {
102
+ yield* withDurableEventRejectionOccurrence(occurrence, () => ctx.stream.append(event));
103
+ }
104
+ catch (error) {
105
+ if (occurrence.rejected && Object.is(error, occurrence.error)) {
106
+ const failure = activeDurabilityFailure(ctx);
107
+ if (failure) {
108
+ throw failure;
109
+ }
110
+ throw error;
111
+ }
112
+ const active = activeDurabilityFailure(ctx);
113
+ if (active) {
114
+ throw active;
115
+ }
116
+ throw rememberDurabilityFailure(ctx, new DurablePersistenceError(event.type, error));
117
+ }
118
+ }
package/esm/effect.js CHANGED
@@ -19,10 +19,14 @@
19
19
  *
20
20
  * See integration doc §5.1, protocol spec §4.2, §5, §6.
21
21
  */
22
- import { DurableCtx } from "./context.js";
22
+ import { DurableContext } from "./context.js";
23
23
  import { Divergence } from "./divergence.js";
24
+ import { activeDurabilityFailure, appendDurableEvent, rememberDurabilityFailure, } from "./durability.js";
24
25
  import { StaleInputError } from "./errors.js";
26
+ import { getJournalProvenance } from "./guard.js";
27
+ import { defaultLiveDurableOperationCoordinator, } from "./live-coordinator.js";
25
28
  import { ReplayGuard } from "./replay-guard.js";
29
+ import { consumable, observeEvent } from "./retained.js";
26
30
  import { protocolToEffection, serializeError } from "./serialize.js";
27
31
  /** Effection void-ok result, used for no-op teardowns. */
28
32
  const VOID_OK = {
@@ -64,6 +68,7 @@ function checkReplay(desc, resolve, routine, ctx) {
64
68
  },
65
69
  ]);
66
70
  if (decision.type === "throw") {
71
+ rememberDurabilityFailure(ctx, decision.error);
67
72
  resolve({ ok: false, error: decision.error });
68
73
  return { path: "replayed", teardown: (exit) => exit(VOID_OK) };
69
74
  }
@@ -73,12 +78,23 @@ function checkReplay(desc, resolve, routine, ctx) {
73
78
  }
74
79
  // Description matches — now check replay guards before replaying.
75
80
  // ── REPLAY GUARD: Decide phase ──
76
- const yieldEvent = {
81
+ // An isolated observation, like the check and admit phases: a decision is
82
+ // policy, and policy reads. Handing the retained description or result
83
+ // here would let a guard rewrite what replay is about to consume.
84
+ const observed = observeEvent({
77
85
  type: "yield",
78
86
  coroutineId: ctx.coroutineId,
79
87
  description: entry.description,
80
88
  result: entry.result,
81
- };
89
+ });
90
+ const yieldEvent = observed.type === "yield"
91
+ ? observed
92
+ : {
93
+ type: "yield",
94
+ coroutineId: ctx.coroutineId,
95
+ description: desc,
96
+ result: entry.result,
97
+ };
82
98
  const outcome = ReplayGuard.invoke(routine.scope, "decide", [yieldEvent]);
83
99
  if (outcome.outcome === "error") {
84
100
  ctx.replayIndex.consumeYield(ctx.coroutineId);
@@ -87,13 +103,17 @@ function checkReplay(desc, resolve, routine, ctx) {
87
103
  coroutineId: ctx.coroutineId,
88
104
  description: desc,
89
105
  });
106
+ rememberDurabilityFailure(ctx, error);
90
107
  resolve({ ok: false, error });
91
108
  return { path: "replayed", teardown: (exit) => exit(VOID_OK) };
92
109
  }
93
110
  // All guards approved — consume the entry and advance cursor
94
111
  ctx.replayIndex.consumeYield(ctx.coroutineId);
95
- // Feed stored result synchronously
96
- resolve(protocolToEffection(entry.result));
112
+ // Feed stored result synchronously, as a fresh mutable copy: the
113
+ // authoritative result stays frozen so policy cannot rewrite it, while a
114
+ // document that resumes on a restored binding still writes to what it
115
+ // receives.
116
+ resolve(protocolToEffection(consumable(entry.result)));
97
117
  return { path: "replayed", teardown: (exit) => exit(VOID_OK) };
98
118
  }
99
119
  // No replay entry. Check for continue-past-close divergence (§6.3).
@@ -107,6 +127,7 @@ function checkReplay(desc, resolve, routine, ctx) {
107
127
  },
108
128
  ]);
109
129
  if (decision.type === "throw") {
130
+ rememberDurabilityFailure(ctx, decision.error);
110
131
  resolve({ ok: false, error: decision.error });
111
132
  return { path: "replayed", teardown: (exit) => exit(VOID_OK) };
112
133
  }
@@ -133,7 +154,12 @@ export function createDurableEffect(desc, execute) {
133
154
  description: `${desc.type}(${desc.name})`,
134
155
  effectDescription: desc,
135
156
  enter(resolve, routine) {
136
- const ctx = routine.scope.expect(DurableCtx);
157
+ const ctx = routine.scope.expect(DurableContext);
158
+ const durabilityFailure = activeDurabilityFailure(ctx);
159
+ if (durabilityFailure) {
160
+ resolve({ ok: false, error: durabilityFailure });
161
+ return (exit) => exit(VOID_OK);
162
+ }
137
163
  const replay = checkReplay(desc, resolve, routine, ctx);
138
164
  if (replay.path === "replayed") {
139
165
  return replay.teardown;
@@ -160,7 +186,7 @@ export function createDurableEffect(desc, execute) {
160
186
  // down, the append is cancelled.
161
187
  routine.scope.run(function* () {
162
188
  try {
163
- yield* ctx.stream.append(event);
189
+ yield* appendDurableEvent(ctx, event);
164
190
  resolve(protocolToEffection(result));
165
191
  }
166
192
  catch (err) {
@@ -221,13 +247,20 @@ export function createDurableEffect(desc, execute) {
221
247
  *
222
248
  * @param desc Structured description for the journal and divergence detection
223
249
  * @param execute Returns an Operation to run during live execution
250
+ * @param options.coordinator Selects the live execution/publication boundary;
251
+ * replay never invokes it
224
252
  */
225
- export function createDurableOperation(desc, execute) {
253
+ export function createDurableOperation(desc, execute, options = {}) {
226
254
  return {
227
255
  description: `${desc.type}(${desc.name})`,
228
256
  effectDescription: desc,
229
257
  enter(resolve, routine) {
230
- const ctx = routine.scope.expect(DurableCtx);
258
+ const ctx = routine.scope.expect(DurableContext);
259
+ const durabilityFailure = activeDurabilityFailure(ctx);
260
+ if (durabilityFailure) {
261
+ resolve({ ok: false, error: durabilityFailure });
262
+ return (exit) => exit(VOID_OK);
263
+ }
231
264
  const replay = checkReplay(desc, resolve, routine, ctx);
232
265
  if (replay.path === "replayed") {
233
266
  return replay.teardown;
@@ -236,23 +269,30 @@ export function createDurableOperation(desc, execute) {
236
269
  // Run the entire execute → capture → persist → resolve sequence
237
270
  // as a structured operation in the routine's scope.
238
271
  routine.scope.run(function* () {
239
- let result;
240
- try {
241
- const value = yield* execute();
242
- result = { status: "ok", value: value };
243
- }
244
- catch (e) {
245
- const error = e instanceof Error ? e : new Error(String(e));
246
- result = { status: "err", error: serializeError(error) };
272
+ const active = activeDurabilityFailure(ctx);
273
+ if (active) {
274
+ resolve({ ok: false, error: active });
275
+ return;
247
276
  }
248
- const event = {
249
- type: "yield",
250
- coroutineId: ctx.coroutineId,
251
- description: desc,
252
- result,
253
- };
254
277
  try {
255
- yield* ctx.stream.append(event);
278
+ const coordinator = options.coordinator ?? defaultLiveDurableOperationCoordinator;
279
+ const activateFailure = (failure) => {
280
+ const existing = activeDurabilityFailure(ctx);
281
+ if (existing) {
282
+ return existing;
283
+ }
284
+ const error = failure instanceof Error ? failure : new Error(String(failure));
285
+ return rememberDurabilityFailure(ctx, error);
286
+ };
287
+ const result = yield* coordinator.run(execute, function* (published) {
288
+ const event = {
289
+ type: "yield",
290
+ coroutineId: ctx.coroutineId,
291
+ description: desc,
292
+ result: published,
293
+ };
294
+ yield* appendDurableEvent(ctx, event);
295
+ }, activateFailure, getJournalProvenance(ctx.stream));
256
296
  resolve(protocolToEffection(result));
257
297
  }
258
298
  catch (err) {
package/esm/errors.js CHANGED
@@ -1,6 +1,38 @@
1
1
  /**
2
2
  * Error types for the durable execution protocol.
3
3
  */
4
+ /**
5
+ * Raised when a durable event cannot be persisted.
6
+ *
7
+ * Persistence failures are protocol failures, not workflow outcomes. The
8
+ * adapter error remains available as the cause, and no compensating Close is
9
+ * written over the unpersisted event.
10
+ */
11
+ export class DurablePersistenceError extends Error {
12
+ name = "DurablePersistenceError";
13
+ constructor(eventType, cause) {
14
+ super(`Failed to persist durable ${eventType} event`, { cause });
15
+ }
16
+ }
17
+ /**
18
+ * Raised when a persisted record does not describe a `DurableEvent`.
19
+ *
20
+ * `path` locates the offending member within the record, such as
21
+ * `$.result.error.message`. Members the protocol does not name appear as `*`,
22
+ * because a record's own member names are as much retained content as its
23
+ * values. Neither the path nor the message repeats anything from the record: a
24
+ * journal is retained, filtered history, and a parse failure is not a reason to
25
+ * copy its contents into an error that travels to logs and terminals.
26
+ */
27
+ export class MalformedDurableEventError extends Error {
28
+ name = "MalformedDurableEventError";
29
+ /** Location of the offending member within the record. */
30
+ path;
31
+ constructor(reason, path) {
32
+ super(`${reason} at ${path}`);
33
+ this.path = path;
34
+ }
35
+ }
4
36
  /**
5
37
  * Raised when the replay index entry at the current cursor position
6
38
  * does not match the effect yielded by the generator. See spec §6.2.
@@ -30,22 +62,37 @@ export class DivergenceError extends Error {
30
62
  }
31
63
  }
32
64
  /**
33
- * Raised when the generator finishes (returns) while the replay index
34
- * still has unconsumed entries for this coroutine. See spec §6.3.
65
+ * Raised when a workflow terminates while replay still has unconsumed entries.
66
+ * The retained journal describes effects that the current execution did not
67
+ * reach, so no terminal Close may be appended over that history.
35
68
  */
36
- export class EarlyReturnDivergenceError extends Error {
37
- name = "EarlyReturnDivergenceError";
69
+ export class TerminalDivergenceError extends Error {
70
+ name = "TerminalDivergenceError";
38
71
  coroutineId;
39
72
  consumedCount;
40
73
  totalCount;
41
- constructor(coroutineId, consumedCount, totalCount) {
42
- super(`Divergence: generator ${coroutineId} returned after ${consumedCount} yields, ` +
43
- `but journal has ${totalCount} yield entries`);
74
+ constructor(coroutineId, consumedCount, totalCount, options = {}) {
75
+ super(options.message ??
76
+ `Divergence: workflow ${coroutineId} terminated after ${consumedCount} yields, ` +
77
+ `but journal has ${totalCount} yield entries`, { cause: options.cause });
44
78
  this.coroutineId = coroutineId;
45
79
  this.consumedCount = consumedCount;
46
80
  this.totalCount = totalCount;
47
81
  }
48
82
  }
83
+ /**
84
+ * Raised when the generator finishes (returns) while the replay index
85
+ * still has unconsumed entries for this coroutine. See spec §6.3.
86
+ */
87
+ export class EarlyReturnDivergenceError extends TerminalDivergenceError {
88
+ name = "EarlyReturnDivergenceError";
89
+ constructor(coroutineId, consumedCount, totalCount) {
90
+ super(coroutineId, consumedCount, totalCount, {
91
+ message: `Divergence: generator ${coroutineId} returned after ${consumedCount} yields, ` +
92
+ `but journal has ${totalCount} yield entries`,
93
+ });
94
+ }
95
+ }
49
96
  /**
50
97
  * Raised when the journal has a Close event for a coroutine but the
51
98
  * generator has not finished after consuming all recorded yields.
package/esm/guard.js CHANGED
@@ -13,6 +13,58 @@
13
13
  * twice, which preserves the protocol invariant that one durable yield
14
14
  * produces at most one journal event.
15
15
  */
16
+ import { createContext } from "effection";
17
+ class JournalProvenance {
18
+ #opaque = undefined;
19
+ }
20
+ const journalProvenances = (() => {
21
+ // This security witness is deliberately canonical-module-local. A loaded
22
+ // copy cannot read this copy's association or enroll a stream into it.
23
+ const provenances = new WeakMap();
24
+ return {
25
+ establish(stream) {
26
+ if (provenances.has(stream)) {
27
+ throw new Error("this durable stream already has journal provenance");
28
+ }
29
+ const provenance = new JournalProvenance();
30
+ provenances.set(stream, provenance);
31
+ return provenance;
32
+ },
33
+ preserve(source, target) {
34
+ const provenance = provenances.get(source);
35
+ if (provenance !== undefined) {
36
+ provenances.set(target, provenance);
37
+ }
38
+ },
39
+ get(stream) {
40
+ return provenances.get(stream);
41
+ },
42
+ };
43
+ })();
44
+ /** Establish the provenance a provider retains for one exact journal backend. */
45
+ export function establishJournalProvenance(stream) {
46
+ return journalProvenances.establish(stream);
47
+ }
48
+ /**
49
+ * Carry an exact source stream's provenance onto a trusted wrapper of it.
50
+ *
51
+ * Preservation is visible composition rather than new authority: it transfers
52
+ * only the witness already associated with that exact source, so an unproven
53
+ * source leaves the target unproven. The target is returned so the wrapping
54
+ * site reads as one expression.
55
+ */
56
+ export function preserveJournalProvenance(source, target) {
57
+ journalProvenances.preserve(source, target);
58
+ return target;
59
+ }
60
+ /** @internal The live durable path reads provenance without receiving stream authority. */
61
+ export function getJournalProvenance(stream) {
62
+ return journalProvenances.get(stream);
63
+ }
64
+ const EventRejectionOccurrence = createContext("effectionx.durable-streams.event-rejection-occurrence", undefined);
65
+ export function withDurableEventRejectionOccurrence(occurrence, operation) {
66
+ return EventRejectionOccurrence.with(occurrence, operation);
67
+ }
16
68
  /**
17
69
  * Wrap a durable stream so every live append passes through `gate` first.
18
70
  *
@@ -26,6 +78,10 @@
26
78
  * Rejection is per event. The rejected event never reaches the backend, but
27
79
  * the resulting failure may lead the workflow to append a later `Close`
28
80
  * event with an `err` result, and that close crosses the gate on its own.
81
+ *
82
+ * The guard is policy-neutral, so the wrapper it returns is unproven. An
83
+ * authorized wrapping site preserves journal provenance explicitly through
84
+ * {@link preserveJournalProvenance}.
29
85
  */
30
86
  export function guardDurableStream(stream, gate) {
31
87
  return {
@@ -34,7 +90,17 @@ export function guardDurableStream(stream, gate) {
34
90
  // The gate sees a copy so "inspect or reject" is enforced rather than
35
91
  // merely documented: the backend always receives the event the effect
36
92
  // produced, whatever the gate did to the one it was handed.
37
- yield* gate(structuredClone(event));
93
+ try {
94
+ yield* gate(structuredClone(event));
95
+ }
96
+ catch (error) {
97
+ const occurrence = yield* EventRejectionOccurrence.get();
98
+ if (occurrence !== undefined) {
99
+ occurrence.rejected = true;
100
+ occurrence.error = error;
101
+ }
102
+ throw error;
103
+ }
38
104
  yield* stream.append(event);
39
105
  },
40
106
  };
@@ -0,0 +1,16 @@
1
+ import { serializeError } from "./serialize.js";
2
+ /** The ordinary live path: execute once, publish once, then return the same result. */
3
+ export const defaultLiveDurableOperationCoordinator = {
4
+ *run(execute, publish, _activateFailure, _journalProvenance) {
5
+ let result;
6
+ try {
7
+ result = { status: "ok", value: yield* execute() };
8
+ }
9
+ catch (error) {
10
+ const failure = error instanceof Error ? error : new Error(String(error));
11
+ result = { status: "err", error: serializeError(failure) };
12
+ }
13
+ yield* publish(result);
14
+ return result;
15
+ },
16
+ };
package/esm/mod.js CHANGED
@@ -5,25 +5,37 @@
5
5
  * Implements the two-event durable execution protocol for generator-based
6
6
  * structured concurrency, with Durable Streams as the persistence backend.
7
7
  */
8
+ // Protocol types
9
+ import "./_dnt.polyfills.js";
8
10
  // ReplayIndex
9
11
  export { ReplayIndex } from "./replay-index.js";
12
+ // `retainEvents` is public because `@executablemd/core` owns its own journal
13
+ // gate and must produce the stable history across the package boundary. The
14
+ // retained classes and the detach helpers stay internal.
15
+ export { retainEvents } from "./retained.js";
10
16
  export { InMemoryStream } from "./stream.js";
11
17
  // Pre-persistence gate — runs before an event reaches its backend
12
18
  export { guardDurableStream } from "./guard.js";
19
+ // Journal provenance — proves which backend a publication stream descends from
20
+ export { establishJournalProvenance, preserveJournalProvenance } from "./guard.js";
13
21
  // HTTP-backed stream adapter
14
22
  export { useHttpDurableStream } from "./http-stream.js";
15
23
  // Errors
16
- export { ContinuePastCloseDivergenceError, DivergenceError, EarlyReturnDivergenceError, StaleInputError, } from "./errors.js";
24
+ export { ContinuePastCloseDivergenceError, DivergenceError, DurablePersistenceError, EarlyReturnDivergenceError, MalformedDurableEventError, StaleInputError, TerminalDivergenceError, } from "./errors.js";
17
25
  // Divergence API — pluggable policy for replay mismatches (DEC-031)
18
26
  export { Divergence } from "./divergence.js";
19
27
  // ReplayGuard API — pluggable validation for replay staleness detection
20
28
  export { ReplayGuard } from "./replay-guard.js";
21
29
  // Context
22
- export { DurableCtx } from "./context.js";
30
+ export { DurableContext } from "./context.js";
23
31
  // Serialization utilities
24
32
  export { deserializeError, effectionToProtocol, protocolToEffection, serializeDurableEvent, serializeError, } from "./serialize.js";
33
+ // The typed inverse of serializeDurableEvent
34
+ export { parseDurableEvent } from "./parse.js";
25
35
  // Core effect factories
26
36
  export { createDurableEffect, createDurableOperation } from "./effect.js";
37
+ // Structured live-operation coordination
38
+ export { defaultLiveDurableOperationCoordinator } from "./live-coordinator.js";
27
39
  // Workflow-enabled effects
28
40
  export { durableAction, durableCall, durableSleep, versionCheck } from "./operations.js";
29
41
  // Structured concurrency combinators