@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/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
+ }
@@ -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
- * The API has two phases:
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. **decide** (during replay): Runs synchronously inside
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 });
@@ -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
- list.push({
22
- description: event.description,
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
- * Return the first non-disabled coroutine with unconsumed yields.
100
- *
101
- * NOTE: Closed coroutines are skipped because their yields were consumed
102
- * by the child's own replay path (via runDurableChild). This means
103
- * orphaned children (recorded in the journal but never spawned in the
104
- * current run) are not detected here. Orphan detection requires tracking
105
- * which coroutine IDs were visited during the current run, which is a
106
- * future enhancement.
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 };