@executablemd/durable-streams 0.7.0 → 0.8.1
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/parse.js
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `parseDurableEvent` is the typed inverse of `serializeDurableEvent`.
|
|
3
|
+
*
|
|
4
|
+
* A backend that retains the NDJSON record — a journal file, a SQLite
|
|
5
|
+
* column — reads it back through this function. The record is parsed,
|
|
6
|
+
* never trusted: every member is checked against the protocol types and
|
|
7
|
+
* the returned event is rebuilt from the checked members, so nothing
|
|
8
|
+
* reaches replay because it merely looked plausible.
|
|
9
|
+
*
|
|
10
|
+
* The closed shapes — the event envelope, a protocol `Result`, a
|
|
11
|
+
* `SerializedError` — reject members they do not declare. An
|
|
12
|
+
* `EffectDescription` declares an index signature, so its extra members
|
|
13
|
+
* are admitted as `Json`; those are the input fields replay guards read.
|
|
14
|
+
*/
|
|
15
|
+
import { Err, Ok } from "effection";
|
|
16
|
+
import { MalformedDurableEventError } from "./errors.js";
|
|
17
|
+
/**
|
|
18
|
+
* Parse one NDJSON record produced by `serializeDurableEvent`.
|
|
19
|
+
*
|
|
20
|
+
* The record's terminating newline is optional — `JSON.parse` ignores
|
|
21
|
+
* trailing whitespace — so a stored record and a line split out of a
|
|
22
|
+
* journal file both parse.
|
|
23
|
+
*/
|
|
24
|
+
export function parseDurableEvent(record) {
|
|
25
|
+
try {
|
|
26
|
+
return Ok(parseEvent(parseRecord(record)));
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
if (error instanceof MalformedDurableEventError) {
|
|
30
|
+
return Err(error);
|
|
31
|
+
}
|
|
32
|
+
throw error;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function parseRecord(record) {
|
|
36
|
+
try {
|
|
37
|
+
return JSON.parse(record);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
// The thrown SyntaxError quotes the offending text, which is the one
|
|
41
|
+
// thing a journal parse failure must not repeat.
|
|
42
|
+
throw new MalformedDurableEventError("expected a JSON record", "$");
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
function parseEvent(value) {
|
|
46
|
+
const members = parseMembers(value, "$");
|
|
47
|
+
const type = parseStringMember(members, "type", "$");
|
|
48
|
+
if (type === "yield") {
|
|
49
|
+
return parseYield(members);
|
|
50
|
+
}
|
|
51
|
+
if (type === "close") {
|
|
52
|
+
return parseClose(members);
|
|
53
|
+
}
|
|
54
|
+
throw new MalformedDurableEventError('expected "yield" or "close"', "$.type");
|
|
55
|
+
}
|
|
56
|
+
function parseYield(members) {
|
|
57
|
+
requireMemberNames(members, ["type", "coroutineId", "description", "result"], "$");
|
|
58
|
+
return {
|
|
59
|
+
type: "yield",
|
|
60
|
+
coroutineId: parseStringMember(members, "coroutineId", "$"),
|
|
61
|
+
description: parseEffectDescription(members.get("description"), "$.description"),
|
|
62
|
+
result: parseResult(members.get("result"), "$.result"),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
function parseClose(members) {
|
|
66
|
+
requireMemberNames(members, ["type", "coroutineId", "result"], "$");
|
|
67
|
+
return {
|
|
68
|
+
type: "close",
|
|
69
|
+
coroutineId: parseStringMember(members, "coroutineId", "$"),
|
|
70
|
+
result: parseResult(members.get("result"), "$.result"),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
function parseEffectDescription(value, path) {
|
|
74
|
+
const members = parseMembers(value, path);
|
|
75
|
+
const description = {
|
|
76
|
+
type: parseStringMember(members, "type", path),
|
|
77
|
+
name: parseStringMember(members, "name", path),
|
|
78
|
+
};
|
|
79
|
+
for (const [key, member] of members) {
|
|
80
|
+
if (key === "type" || key === "name") {
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
// `${path}.*`, not `${path}.${key}`: these member names come from the
|
|
84
|
+
// record, and a record's own names are as much retained content as its
|
|
85
|
+
// values. A path built from one would carry it into logs and terminals.
|
|
86
|
+
define(description, key, parseJsonValue(member, `${path}.*`));
|
|
87
|
+
}
|
|
88
|
+
return description;
|
|
89
|
+
}
|
|
90
|
+
function parseResult(value, path) {
|
|
91
|
+
const members = parseMembers(value, path);
|
|
92
|
+
const status = parseStringMember(members, "status", path);
|
|
93
|
+
switch (status) {
|
|
94
|
+
case "ok": {
|
|
95
|
+
requireMemberNames(members, ["status", "value"], path);
|
|
96
|
+
if (!members.has("value")) {
|
|
97
|
+
return { status: "ok" };
|
|
98
|
+
}
|
|
99
|
+
return { status: "ok", value: parseJsonValue(members.get("value"), `${path}.value`) };
|
|
100
|
+
}
|
|
101
|
+
case "err": {
|
|
102
|
+
requireMemberNames(members, ["status", "error"], path);
|
|
103
|
+
return { status: "err", error: parseSerializedError(members.get("error"), `${path}.error`) };
|
|
104
|
+
}
|
|
105
|
+
case "cancelled": {
|
|
106
|
+
requireMemberNames(members, ["status"], path);
|
|
107
|
+
return { status: "cancelled" };
|
|
108
|
+
}
|
|
109
|
+
default:
|
|
110
|
+
throw new MalformedDurableEventError('expected "ok", "err" or "cancelled"', `${path}.status`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
function parseSerializedError(value, path) {
|
|
114
|
+
const members = parseMembers(value, path);
|
|
115
|
+
requireMemberNames(members, ["message", "name", "stack"], path);
|
|
116
|
+
const error = { message: parseStringMember(members, "message", path) };
|
|
117
|
+
if (members.has("name")) {
|
|
118
|
+
error.name = parseStringMember(members, "name", path);
|
|
119
|
+
}
|
|
120
|
+
if (members.has("stack")) {
|
|
121
|
+
error.stack = parseStringMember(members, "stack", path);
|
|
122
|
+
}
|
|
123
|
+
return error;
|
|
124
|
+
}
|
|
125
|
+
function parseJsonValue(value, path) {
|
|
126
|
+
if (value === null || typeof value === "string" || typeof value === "boolean") {
|
|
127
|
+
return value;
|
|
128
|
+
}
|
|
129
|
+
if (typeof value === "number") {
|
|
130
|
+
// JSON has no infinity literal, but an exponent large enough to
|
|
131
|
+
// overflow — `1e999` — parses to one.
|
|
132
|
+
if (!Number.isFinite(value)) {
|
|
133
|
+
throw new MalformedDurableEventError("expected a finite number", path);
|
|
134
|
+
}
|
|
135
|
+
return value;
|
|
136
|
+
}
|
|
137
|
+
if (Array.isArray(value)) {
|
|
138
|
+
const items = [];
|
|
139
|
+
for (let index = 0; index < value.length; index++) {
|
|
140
|
+
items.push(parseJsonValue(value[index], `${path}[${index}]`));
|
|
141
|
+
}
|
|
142
|
+
return items;
|
|
143
|
+
}
|
|
144
|
+
if (typeof value === "object") {
|
|
145
|
+
const object = {};
|
|
146
|
+
for (const [key, member] of Object.entries(value)) {
|
|
147
|
+
define(object, key, parseJsonValue(member, `${path}.*`));
|
|
148
|
+
}
|
|
149
|
+
return object;
|
|
150
|
+
}
|
|
151
|
+
throw new MalformedDurableEventError(`expected a JSON value, found ${describe(value)}`, path);
|
|
152
|
+
}
|
|
153
|
+
function parseMembers(value, path) {
|
|
154
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
155
|
+
throw new MalformedDurableEventError(`expected an object, found ${describe(value)}`, path);
|
|
156
|
+
}
|
|
157
|
+
return new Map(Object.entries(value));
|
|
158
|
+
}
|
|
159
|
+
function parseStringMember(members, key, path) {
|
|
160
|
+
const member = members.get(key);
|
|
161
|
+
if (typeof member !== "string") {
|
|
162
|
+
throw new MalformedDurableEventError(`expected a string, found ${describe(member)}`, `${path}.${key}`);
|
|
163
|
+
}
|
|
164
|
+
return member;
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Refuse a member this shape does not declare, without naming it.
|
|
168
|
+
*
|
|
169
|
+
* The names it *does* declare are this module's own and safe to print; the one
|
|
170
|
+
* that turned up is content from the record, and a record's member names are
|
|
171
|
+
* as much retained history as its values.
|
|
172
|
+
*/
|
|
173
|
+
function requireMemberNames(members, names, path) {
|
|
174
|
+
for (const key of members.keys()) {
|
|
175
|
+
if (!names.includes(key)) {
|
|
176
|
+
throw new MalformedDurableEventError(`expected only the members ${names.join(", ")}`, path);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Add one member as a plain own data property.
|
|
182
|
+
*
|
|
183
|
+
* `object[key] = value` is not equivalent for the key `__proto__`: it
|
|
184
|
+
* reaches `Object.prototype`'s inherited setter, which under V8 and
|
|
185
|
+
* JavaScriptCore replaces the object's prototype and drops the key while
|
|
186
|
+
* Deno's parse keeps it. `JSON.parse` makes `__proto__` an own property,
|
|
187
|
+
* so any record can carry one, and the same record would otherwise parse
|
|
188
|
+
* to a different object on different runtimes.
|
|
189
|
+
*/
|
|
190
|
+
function define(object, key, value) {
|
|
191
|
+
Object.defineProperty(object, key, {
|
|
192
|
+
value,
|
|
193
|
+
enumerable: true,
|
|
194
|
+
writable: true,
|
|
195
|
+
configurable: true,
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
function describe(value) {
|
|
199
|
+
if (value === null) {
|
|
200
|
+
return "null";
|
|
201
|
+
}
|
|
202
|
+
if (Array.isArray(value)) {
|
|
203
|
+
return "an array";
|
|
204
|
+
}
|
|
205
|
+
return typeof value;
|
|
206
|
+
}
|
package/esm/replay-guard.js
CHANGED
|
@@ -12,14 +12,26 @@
|
|
|
12
12
|
* (e.g., content hash, status code). There is no separate metadata field —
|
|
13
13
|
* inputs belong in the effect description, outputs belong in the result.
|
|
14
14
|
*
|
|
15
|
-
*
|
|
15
|
+
* A guard is **composable policy, not authority**. Guards compose through
|
|
16
|
+
* `Api.around`, and a handler installed further out may decline to call `next`.
|
|
17
|
+
* That is what composition is for, and it is why an invariant that must not be
|
|
18
|
+
* negotiable — durable identity above all — belongs somewhere a caller cannot
|
|
19
|
+
* replace, such as inside the `DurableStream` a consumer hands to `durableRun`.
|
|
20
|
+
*
|
|
21
|
+
* The API has three stages:
|
|
16
22
|
*
|
|
17
23
|
* 1. **check** (before replay begins): Runs in generator context inside
|
|
18
24
|
* `durableRun`, after the journal is loaded but before the workflow starts.
|
|
19
25
|
* I/O is allowed — this is where file hashing, network checks, and other
|
|
20
26
|
* observation-gathering happens. Results are cached in middleware closures.
|
|
21
27
|
*
|
|
22
|
-
* 2. **
|
|
28
|
+
* 2. **admit** (after every check, before terminal reuse): Runs once with the
|
|
29
|
+
* retained history as a whole. A guard that requires something of the
|
|
30
|
+
* history rather than of one event — that an event it validates is present,
|
|
31
|
+
* and present once — refuses here, because a per-event check has nothing to
|
|
32
|
+
* object to in a journal that omits the event. Default is a no-op.
|
|
33
|
+
*
|
|
34
|
+
* 3. **decide** (during replay): Runs synchronously inside
|
|
23
35
|
* `DurableEffect.enter()`, after identity matching succeeds but before
|
|
24
36
|
* the stored result is fed to the generator. Must be pure and side-effect-
|
|
25
37
|
* free. Reads from the cache populated during the check phase.
|
|
@@ -37,6 +49,12 @@ import { createApi } from "effection/experimental";
|
|
|
37
49
|
function* defaultCheck(_event) {
|
|
38
50
|
// No observation — pass through to next middleware or default.
|
|
39
51
|
}
|
|
52
|
+
/**
|
|
53
|
+
* Default admit — no-op. A history nobody objects to is replayed.
|
|
54
|
+
*/
|
|
55
|
+
function* defaultAdmit(_history) {
|
|
56
|
+
// No requirement — pass through to next middleware or default.
|
|
57
|
+
}
|
|
40
58
|
/**
|
|
41
59
|
* Default decide — always replay. This preserves "logs are authoritative"
|
|
42
60
|
* as the default behavior. Guards must be explicitly installed to add
|
|
@@ -78,4 +96,4 @@ function defaultDecide(_event) {
|
|
|
78
96
|
* }
|
|
79
97
|
* ```
|
|
80
98
|
*/
|
|
81
|
-
export const ReplayGuard = createApi("DurableEffection.ReplayGuard", { check: defaultCheck, decide: defaultDecide });
|
|
99
|
+
export const ReplayGuard = createApi("DurableEffection.ReplayGuard", { check: defaultCheck, admit: defaultAdmit, decide: defaultDecide });
|
package/esm/replay-index.js
CHANGED
|
@@ -4,30 +4,50 @@
|
|
|
4
4
|
* Provides per-coroutine cursored access to Yield events and keyed access
|
|
5
5
|
* to Close events. See spec §4.1.
|
|
6
6
|
*/
|
|
7
|
+
import { retainEvents } from "./retained.js";
|
|
7
8
|
export class ReplayIndex {
|
|
8
9
|
yields = new Map();
|
|
10
|
+
/** Every retained Yield in stream order, each owning its own settled cells. */
|
|
11
|
+
retained = [];
|
|
9
12
|
cursors = new Map();
|
|
10
13
|
closes = new Map();
|
|
11
14
|
/** Coroutines where replay has been disabled (run-live mode). */
|
|
12
15
|
disabled = new Set();
|
|
16
|
+
/** Retained coroutine identities reached by the current definition. */
|
|
17
|
+
claimed = new Set();
|
|
18
|
+
/**
|
|
19
|
+
* Index a journal's events by identity, without reading what they settled to.
|
|
20
|
+
*
|
|
21
|
+
* The events are retained first — idempotently, so a caller that already
|
|
22
|
+
* produced the stable history hands the same objects on rather than a second
|
|
23
|
+
* wrapping of them, and every phase then observes one identity and one
|
|
24
|
+
* settlement per event.
|
|
25
|
+
*/
|
|
13
26
|
constructor(events) {
|
|
14
|
-
for (const event of events) {
|
|
27
|
+
for (const event of retainEvents(events)) {
|
|
15
28
|
if (event.type === "yield") {
|
|
16
29
|
let list = this.yields.get(event.coroutineId);
|
|
17
30
|
if (!list) {
|
|
18
31
|
list = [];
|
|
19
32
|
this.yields.set(event.coroutineId, list);
|
|
20
33
|
}
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
result: event.result,
|
|
24
|
-
});
|
|
34
|
+
this.retained.push(event);
|
|
35
|
+
list.push(event);
|
|
25
36
|
}
|
|
26
37
|
if (event.type === "close") {
|
|
27
38
|
this.closes.set(event.coroutineId, event);
|
|
28
39
|
}
|
|
29
40
|
}
|
|
30
41
|
}
|
|
42
|
+
/**
|
|
43
|
+
* Every retained Yield in stream order, as the events a check phase sees.
|
|
44
|
+
*
|
|
45
|
+
* The same objects the replay path consumes, so a guard and a later consumer
|
|
46
|
+
* observe one settled result rather than two reads of the stream.
|
|
47
|
+
*/
|
|
48
|
+
retainedYields() {
|
|
49
|
+
return [...this.retained];
|
|
50
|
+
}
|
|
31
51
|
/**
|
|
32
52
|
* Disable replay for a coroutine (run-live mode).
|
|
33
53
|
*
|
|
@@ -42,6 +62,19 @@ export class ReplayIndex {
|
|
|
42
62
|
isReplayDisabled(coroutineId) {
|
|
43
63
|
return this.disabled.has(coroutineId);
|
|
44
64
|
}
|
|
65
|
+
/** Mark a retained coroutine identity as reached by the current run. */
|
|
66
|
+
claim(coroutineId) {
|
|
67
|
+
this.claimed.add(coroutineId);
|
|
68
|
+
if (!this.closes.has(coroutineId)) {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const retainedIds = new Set([...this.yields.keys(), ...this.closes.keys()]);
|
|
72
|
+
for (const retainedId of retainedIds) {
|
|
73
|
+
if (retainedId.startsWith(`${coroutineId}.`)) {
|
|
74
|
+
this.claimed.add(retainedId);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
45
78
|
/**
|
|
46
79
|
* Returns the next unconsumed yield for this coroutine,
|
|
47
80
|
* or undefined if the cursor is past the end or replay is disabled.
|
|
@@ -95,24 +128,27 @@ export class ReplayIndex {
|
|
|
95
128
|
}
|
|
96
129
|
return false;
|
|
97
130
|
}
|
|
98
|
-
/**
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
firstUnconsumed() {
|
|
109
|
-
for (const [coroutineId, entries] of this.yields.entries()) {
|
|
131
|
+
/** Return the first retained coroutine not aligned with the current subtree. */
|
|
132
|
+
firstUnaligned(subtreeId) {
|
|
133
|
+
if (this.disabled.has(subtreeId)) {
|
|
134
|
+
return undefined;
|
|
135
|
+
}
|
|
136
|
+
const coroutineIds = new Set([...this.yields.keys(), ...this.closes.keys()]);
|
|
137
|
+
for (const coroutineId of coroutineIds) {
|
|
138
|
+
if (coroutineId !== subtreeId && !coroutineId.startsWith(`${subtreeId}.`)) {
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
110
141
|
if (this.disabled.has(coroutineId)) {
|
|
111
142
|
continue;
|
|
112
143
|
}
|
|
113
144
|
if (this.closes.has(coroutineId)) {
|
|
145
|
+
if (!this.claimed.has(coroutineId)) {
|
|
146
|
+
const entries = this.yields.get(coroutineId) ?? [];
|
|
147
|
+
return { coroutineId, cursor: 0, totalYields: entries.length };
|
|
148
|
+
}
|
|
114
149
|
continue;
|
|
115
150
|
}
|
|
151
|
+
const entries = this.yields.get(coroutineId) ?? [];
|
|
116
152
|
const cursor = this.cursors.get(coroutineId) ?? 0;
|
|
117
153
|
if (cursor < entries.length) {
|
|
118
154
|
return { coroutineId, cursor, totalYields: entries.length };
|