@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.
- package/esm/_dnt.polyfills.js +1 -0
- package/esm/combinators.js +54 -11
- package/esm/context.js +1 -1
- package/esm/durability.js +118 -0
- package/esm/effect.js +64 -24
- package/esm/errors.js +54 -7
- package/esm/guard.js +67 -1
- package/esm/live-coordinator.js +16 -0
- package/esm/mod.js +14 -2
- package/esm/parse.js +206 -0
- package/esm/replay-guard.js +21 -3
- package/esm/replay-index.js +53 -17
- package/esm/retained.js +390 -0
- package/esm/run.js +74 -29
- package/package.json +2 -2
- package/types/_dnt.polyfills.d.ts +6 -0
- package/types/context.d.ts +11 -2
- package/types/durability.d.ts +7 -0
- package/types/effect.d.ts +6 -1
- package/types/errors.d.ts +42 -3
- package/types/guard.d.ts +35 -1
- package/types/live-coordinator.d.ts +11 -0
- package/types/mod.d.ts +10 -5
- package/types/parse.d.ts +23 -0
- package/types/replay-guard.d.ts +39 -3
- package/types/replay-index.d.ts +24 -12
- package/types/retained.d.ts +83 -0
- package/types/run.d.ts +2 -1
package/esm/retained.js
ADDED
|
@@ -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 {
|
|
17
|
-
import {
|
|
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(
|
|
32
|
-
for (const event of
|
|
33
|
-
|
|
34
|
-
|
|
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.
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
if (
|
|
102
|
-
|
|
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*
|
|
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*
|
|
170
|
+
yield* appendDurableEvent(ctx, closeEvent);
|
|
130
171
|
}
|
|
131
|
-
catch (
|
|
132
|
-
const
|
|
133
|
-
|
|
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@executablemd/durable-streams",
|
|
3
|
-
"version": "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
|
|
27
|
+
"effection": "4.1.0"
|
|
28
28
|
},
|
|
29
29
|
"_generatedBy": "dnt@dev"
|
|
30
30
|
}
|
package/types/context.d.ts
CHANGED
|
@@ -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
|
|
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>;
|
package/types/effect.d.ts
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
* See integration doc §5.1, protocol spec §4.2, §5, §6.
|
|
21
21
|
*/
|
|
22
22
|
import type { Operation } from "effection";
|
|
23
|
+
import { type LiveDurableOperationCoordinator } from "./live-coordinator.js";
|
|
23
24
|
import type { DurableEffect, EffectDescription, Json, Result } from "./types.js";
|
|
24
25
|
/**
|
|
25
26
|
* Executor function signature for live execution (callback-based).
|
|
@@ -56,5 +57,9 @@ export declare function createDurableEffect<T>(desc: EffectDescription, execute:
|
|
|
56
57
|
*
|
|
57
58
|
* @param desc Structured description for the journal and divergence detection
|
|
58
59
|
* @param execute Returns an Operation to run during live execution
|
|
60
|
+
* @param options.coordinator Selects the live execution/publication boundary;
|
|
61
|
+
* replay never invokes it
|
|
59
62
|
*/
|
|
60
|
-
export declare function createDurableOperation<T extends Json>(desc: EffectDescription, execute: () => Operation<T
|
|
63
|
+
export declare function createDurableOperation<T extends Json>(desc: EffectDescription, execute: () => Operation<T>, options?: {
|
|
64
|
+
coordinator?: LiveDurableOperationCoordinator;
|
|
65
|
+
}): DurableEffect<T>;
|