@executablemd/durable-streams 0.8.1 → 0.9.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.
@@ -97,6 +97,7 @@ function* runDurableChild(childWorkflow, childId, parentCtx) {
97
97
  if (unaligned) {
98
98
  const failure = new TerminalDivergenceError(unaligned.coroutineId, unaligned.cursor, unaligned.totalYields, {
99
99
  message: `Divergence: coroutine ${childId} was cancelled before retained history was exhausted`,
100
+ unconsumed: unaligned.entry,
100
101
  });
101
102
  rememberDurabilityFailure(childCtx, failure);
102
103
  throw failure;
@@ -125,7 +126,7 @@ function* runDurableChild(childWorkflow, childId, parentCtx) {
125
126
  const unaligned = replayIndex.firstUnaligned(childId);
126
127
  if (unaligned) {
127
128
  suppressClose = true;
128
- const failure = new EarlyReturnDivergenceError(unaligned.coroutineId, unaligned.cursor, unaligned.totalYields);
129
+ const failure = new EarlyReturnDivergenceError(unaligned.coroutineId, unaligned.cursor, unaligned.totalYields, unaligned.entry);
129
130
  rememberDurabilityFailure(childCtx, failure);
130
131
  throw failure;
131
132
  }
@@ -146,7 +147,7 @@ function* runDurableChild(childWorkflow, childId, parentCtx) {
146
147
  const unaligned = replayIndex.firstUnaligned(childId);
147
148
  if (unaligned) {
148
149
  suppressClose = true;
149
- const failure = new TerminalDivergenceError(unaligned.coroutineId, unaligned.cursor, unaligned.totalYields, { cause: primary });
150
+ const failure = new TerminalDivergenceError(unaligned.coroutineId, unaligned.cursor, unaligned.totalYields, { cause: primary, unconsumed: unaligned.entry });
150
151
  rememberDurabilityFailure(childCtx, failure);
151
152
  throw failure;
152
153
  }
package/esm/effect.js CHANGED
@@ -28,6 +28,7 @@ import { defaultLiveDurableOperationCoordinator, } from "./live-coordinator.js";
28
28
  import { ReplayGuard } from "./replay-guard.js";
29
29
  import { consumable, observeEvent } from "./retained.js";
30
30
  import { protocolToEffection, serializeError } from "./serialize.js";
31
+ import { advanceDurablePosition } from "./position.js";
31
32
  /** Effection void-ok result, used for no-op teardowns. */
32
33
  const VOID_OK = {
33
34
  ok: true,
@@ -43,6 +44,7 @@ const VOID_OK = {
43
44
  */
44
45
  function checkReplay(desc, resolve, routine, ctx) {
45
46
  const entry = ctx.replayIndex.peekYield(ctx.coroutineId);
47
+ let abandoned;
46
48
  // ── REPLAY PATH ──
47
49
  // Use a labeled block so that divergence decisions of type "run-live"
48
50
  // can break out to fall through to the live execution path.
@@ -74,6 +76,7 @@ function checkReplay(desc, resolve, routine, ctx) {
74
76
  }
75
77
  // decision.type === "run-live"
76
78
  ctx.replayIndex.disableReplay(ctx.coroutineId);
79
+ abandoned = entry;
77
80
  break replay;
78
81
  }
79
82
  // Description matches — now check replay guards before replaying.
@@ -98,6 +101,7 @@ function checkReplay(desc, resolve, routine, ctx) {
98
101
  const outcome = ReplayGuard.invoke(routine.scope, "decide", [yieldEvent]);
99
102
  if (outcome.outcome === "error") {
100
103
  ctx.replayIndex.consumeYield(ctx.coroutineId);
104
+ advanceDurablePosition(ctx);
101
105
  const error = outcome.error ??
102
106
  new StaleInputError(`Stale input detected for ${desc.type}("${desc.name}")`, {
103
107
  coroutineId: ctx.coroutineId,
@@ -109,6 +113,7 @@ function checkReplay(desc, resolve, routine, ctx) {
109
113
  }
110
114
  // All guards approved — consume the entry and advance cursor
111
115
  ctx.replayIndex.consumeYield(ctx.coroutineId);
116
+ advanceDurablePosition(ctx);
112
117
  // Feed stored result synchronously, as a fresh mutable copy: the
113
118
  // authoritative result stays frozen so policy cannot rewrite it, while a
114
119
  // document that resumes on a restored binding still writes to what it
@@ -136,7 +141,15 @@ function checkReplay(desc, resolve, routine, ctx) {
136
141
  break replay;
137
142
  }
138
143
  } // end replay block
139
- return { path: "live" };
144
+ return {
145
+ path: "live",
146
+ abandoned: {
147
+ ...(abandoned === undefined ? {} : { entry: abandoned }),
148
+ // The index's own account, not this call's: it stays true for every
149
+ // operation after the one that diverged.
150
+ steppedOver: ctx.replayIndex.abandonedHistory(ctx.coroutineId),
151
+ },
152
+ };
140
153
  }
141
154
  /**
142
155
  * Creates a DurableEffect using a callback-based executor.
@@ -187,6 +200,9 @@ export function createDurableEffect(desc, execute) {
187
200
  routine.scope.run(function* () {
188
201
  try {
189
202
  yield* appendDurableEvent(ctx, event);
203
+ // After the append commits, not before: a position counts what the
204
+ // journal holds, so an append that failed must not move it.
205
+ advanceDurablePosition(ctx);
190
206
  resolve(protocolToEffection(result));
191
207
  }
192
208
  catch (err) {
@@ -292,7 +308,8 @@ export function createDurableOperation(desc, execute, options = {}) {
292
308
  result: published,
293
309
  };
294
310
  yield* appendDurableEvent(ctx, event);
295
- }, activateFailure, getJournalProvenance(ctx.stream));
311
+ advanceDurablePosition(ctx);
312
+ }, activateFailure, getJournalProvenance(ctx.stream), replay.abandoned);
296
313
  resolve(protocolToEffection(result));
297
314
  }
298
315
  catch (err) {
package/esm/errors.js CHANGED
@@ -1,6 +1,66 @@
1
1
  /**
2
2
  * Error types for the durable execution protocol.
3
3
  */
4
+ /**
5
+ * The description field an authored source position occupies.
6
+ *
7
+ * A durable effect's identity is its `type` and `name` and nothing else, so a
8
+ * position travels beside them under this stable namespaced field. It is
9
+ * stored, filtered diagnostic data: never compared during divergence
10
+ * detection, never part of admission.
11
+ */
12
+ export const SOURCE_POSITION_FIELD = "executablemd.source-position";
13
+ /**
14
+ * Render one effect description for a divergence diagnostic.
15
+ *
16
+ * The identity renders as `type("name")`. When the description retains the
17
+ * exact normalized source shape under `SOURCE_POSITION_FIELD` — an optional
18
+ * non-empty `path`, an integer `offset` of at least 0, integer `line` and
19
+ * `column` of at least 1, and no other member — its human spelling,
20
+ * `path:line:column` or `line:column` without a path, never the offset, is
21
+ * appended as ` at …`. Anything else renders nothing at all: formatting a
22
+ * diagnostic must not introduce a new failure.
23
+ */
24
+ export function describeEffect(description) {
25
+ return `${description.type}("${description.name}")${renderedSource(description)}`;
26
+ }
27
+ const SOURCE_MEMBERS = ["path", "offset", "line", "column"];
28
+ function renderedSource(description) {
29
+ const field = description[SOURCE_POSITION_FIELD];
30
+ if (field === null || typeof field !== "object" || Array.isArray(field)) {
31
+ return "";
32
+ }
33
+ if (Object.keys(field).some((member) => !SOURCE_MEMBERS.includes(member))) {
34
+ return "";
35
+ }
36
+ const { path, offset, line, column } = field;
37
+ if (!isCoordinate(offset, 0) || !isCoordinate(line, 1) || !isCoordinate(column, 1)) {
38
+ return "";
39
+ }
40
+ if (path === undefined) {
41
+ return ` at ${line}:${column}`;
42
+ }
43
+ if (typeof path !== "string" || path === "") {
44
+ return "";
45
+ }
46
+ return ` at ${path}:${line}:${column}`;
47
+ }
48
+ function isCoordinate(value, least) {
49
+ return typeof value === "number" && Number.isInteger(value) && value >= least;
50
+ }
51
+ /**
52
+ * The terminal-divergence message, naming the first retained entry the
53
+ * terminating subtree did not reach when one was selected.
54
+ */
55
+ function withUnreached(message, unconsumed) {
56
+ if (unconsumed === undefined) {
57
+ return message;
58
+ }
59
+ if (unconsumed.type === "close") {
60
+ return `${message}; first unreached entry is the Close of coroutine ${unconsumed.coroutineId}`;
61
+ }
62
+ return `${message}; first unreached entry is ${describeEffect(unconsumed.description)}`;
63
+ }
4
64
  /**
5
65
  * Raised when a durable event cannot be persisted.
6
66
  *
@@ -53,8 +113,7 @@ export class DivergenceError extends Error {
53
113
  constructor(coroutineId, position, expected, actual, message) {
54
114
  super(message ??
55
115
  `Divergence at ${coroutineId}[${position}]: ` +
56
- `expected ${expected.type}("${expected.name}"), ` +
57
- `got ${actual.type}("${actual.name}")`);
116
+ `expected ${describeEffect(expected)}, got ${describeEffect(actual)}`);
58
117
  this.coroutineId = coroutineId;
59
118
  this.position = position;
60
119
  this.expected = expected;
@@ -71,13 +130,16 @@ export class TerminalDivergenceError extends Error {
71
130
  coroutineId;
72
131
  consumedCount;
73
132
  totalCount;
133
+ /** The first retained entry the terminating subtree did not reach. */
134
+ unconsumed;
74
135
  constructor(coroutineId, consumedCount, totalCount, options = {}) {
75
- super(options.message ??
136
+ super(withUnreached(options.message ??
76
137
  `Divergence: workflow ${coroutineId} terminated after ${consumedCount} yields, ` +
77
- `but journal has ${totalCount} yield entries`, { cause: options.cause });
138
+ `but journal has ${totalCount} yield entries`, options.unconsumed), { cause: options.cause });
78
139
  this.coroutineId = coroutineId;
79
140
  this.consumedCount = consumedCount;
80
141
  this.totalCount = totalCount;
142
+ this.unconsumed = options.unconsumed;
81
143
  }
82
144
  }
83
145
  /**
@@ -86,10 +148,11 @@ export class TerminalDivergenceError extends Error {
86
148
  */
87
149
  export class EarlyReturnDivergenceError extends TerminalDivergenceError {
88
150
  name = "EarlyReturnDivergenceError";
89
- constructor(coroutineId, consumedCount, totalCount) {
151
+ constructor(coroutineId, consumedCount, totalCount, unconsumed) {
90
152
  super(coroutineId, consumedCount, totalCount, {
91
153
  message: `Divergence: generator ${coroutineId} returned after ${consumedCount} yields, ` +
92
154
  `but journal has ${totalCount} yield entries`,
155
+ unconsumed,
93
156
  });
94
157
  }
95
158
  }
@@ -1,7 +1,7 @@
1
1
  import { serializeError } from "./serialize.js";
2
2
  /** The ordinary live path: execute once, publish once, then return the same result. */
3
3
  export const defaultLiveDurableOperationCoordinator = {
4
- *run(execute, publish, _activateFailure, _journalProvenance) {
4
+ *run(execute, publish, _activateFailure, _journalProvenance, _abandoned) {
5
5
  let result;
6
6
  try {
7
7
  result = { status: "ok", value: yield* execute() };
package/esm/mod.js CHANGED
@@ -21,7 +21,7 @@ export { establishJournalProvenance, preserveJournalProvenance } from "./guard.j
21
21
  // HTTP-backed stream adapter
22
22
  export { useHttpDurableStream } from "./http-stream.js";
23
23
  // Errors
24
- export { ContinuePastCloseDivergenceError, DivergenceError, DurablePersistenceError, EarlyReturnDivergenceError, MalformedDurableEventError, StaleInputError, TerminalDivergenceError, } from "./errors.js";
24
+ export { ContinuePastCloseDivergenceError, describeEffect, DivergenceError, DurablePersistenceError, EarlyReturnDivergenceError, MalformedDurableEventError, SOURCE_POSITION_FIELD, StaleInputError, TerminalDivergenceError, } from "./errors.js";
25
25
  // Divergence API — pluggable policy for replay mismatches (DEC-031)
26
26
  export { Divergence } from "./divergence.js";
27
27
  // ReplayGuard API — pluggable validation for replay staleness detection
@@ -34,6 +34,7 @@ export { deserializeError, effectionToProtocol, protocolToEffection, serializeDu
34
34
  export { parseDurableEvent } from "./parse.js";
35
35
  // Core effect factories
36
36
  export { createDurableEffect, createDurableOperation } from "./effect.js";
37
+ export { durablePosition } from "./position.js";
37
38
  // Structured live-operation coordination
38
39
  export { defaultLiveDurableOperationCoordinator } from "./live-coordinator.js";
39
40
  // Workflow-enabled effects
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Where a coroutine is in its own durable history.
3
+ *
4
+ * A durable operation is identified by what it is and what it is called. Some
5
+ * operations have no name to give: a wait that a document reaches at one point
6
+ * in one procedure is that wait *because of where it is*, not because somebody
7
+ * named it. This is the coordinate such an operation names itself by — the
8
+ * coroutine it runs in, and how many durable yields that coroutine has already
9
+ * settled.
10
+ *
11
+ * ## Why it is not the replay cursor
12
+ *
13
+ * Replay already keeps a cursor per coroutine, and it looks like the same
14
+ * number. It is not. The cursor advances only while entries are being consumed
15
+ * from retained history; once a coroutine runs past the end of what was
16
+ * retained, the cursor stops while the coroutine keeps performing durable work.
17
+ * A position taken from it would be stable across replays of the retained
18
+ * prefix and would collide for every operation in the live suffix.
19
+ *
20
+ * So the position advances on both — a replayed yield and a committed live one
21
+ * — which is what makes the index a coroutine reaches at one point in its
22
+ * procedure the same index it reaches there next time.
23
+ *
24
+ * ## Observation, never authority
25
+ *
26
+ * A position describes where execution is. It authorizes nothing, proves
27
+ * nothing about who is executing, and is not durable state: it is derived by
28
+ * counting what the journal already holds. Anything that must be trusted is
29
+ * checked by whoever owns the authority to check it.
30
+ */
31
+ import { DurableContext } from "./context.js";
32
+ /**
33
+ * The position this coroutine has reached.
34
+ *
35
+ * Read before performing a durable operation, it is that operation's
36
+ * coordinate. Read after, it is the next one's.
37
+ */
38
+ export function* durablePosition() {
39
+ const context = yield* DurableContext.expect();
40
+ return Object.freeze({
41
+ coroutineId: context.coroutineId,
42
+ index: context.position ?? 0,
43
+ });
44
+ }
45
+ /**
46
+ * Record that this coroutine settled one durable yield.
47
+ *
48
+ * Called from both settlement paths, which is the whole point of the field. A
49
+ * caller outside this package never advances a position: it is a count of what
50
+ * happened, and only the code that made it happen may say so.
51
+ */
52
+ export function advanceDurablePosition(context) {
53
+ context.position = (context.position ?? 0) + 1;
54
+ }
@@ -92,6 +92,25 @@ export class ReplayIndex {
92
92
  const cursor = this.cursors.get(coroutineId) ?? 0;
93
93
  this.cursors.set(coroutineId, cursor + 1);
94
94
  }
95
+ /**
96
+ * Whether this coroutine walked away from retained history it never consumed.
97
+ *
98
+ * True once replay has been disabled for it while entries it had not reached
99
+ * are still there — which is what a run-live decision leaves behind. It stays
100
+ * true for every later operation in that coroutine, because the history is
101
+ * still unconsumed however far past it execution has gone.
102
+ *
103
+ * An effect whose live work reaches a service this journal does not enclose
104
+ * reads it to decide whether running live here is something it may do at all.
105
+ * It is the index's own account, so nothing a document supplies takes part.
106
+ */
107
+ abandonedHistory(coroutineId) {
108
+ if (!this.disabled.has(coroutineId)) {
109
+ return false;
110
+ }
111
+ const list = this.yields.get(coroutineId);
112
+ return list !== undefined && (this.cursors.get(coroutineId) ?? 0) < list.length;
113
+ }
95
114
  /** Returns the current cursor position for this coroutine. */
96
115
  getCursor(coroutineId) {
97
116
  return this.cursors.get(coroutineId) ?? 0;
@@ -128,7 +147,14 @@ export class ReplayIndex {
128
147
  }
129
148
  return false;
130
149
  }
131
- /** Return the first retained coroutine not aligned with the current subtree. */
150
+ /**
151
+ * Return the first retained coroutine not aligned with the current subtree.
152
+ *
153
+ * `entry` is the first unmatched retained protocol entry itself — the Yield
154
+ * at the cursor, or for an unclaimed completed child its first Yield when it
155
+ * has one and otherwise its Close — so a terminal diagnostic can name what
156
+ * the terminating subtree did not reach, not just where it stopped.
157
+ */
132
158
  firstUnaligned(subtreeId) {
133
159
  if (this.disabled.has(subtreeId)) {
134
160
  return undefined;
@@ -141,17 +167,23 @@ export class ReplayIndex {
141
167
  if (this.disabled.has(coroutineId)) {
142
168
  continue;
143
169
  }
144
- if (this.closes.has(coroutineId)) {
170
+ const close = this.closes.get(coroutineId);
171
+ if (close !== undefined) {
145
172
  if (!this.claimed.has(coroutineId)) {
146
173
  const entries = this.yields.get(coroutineId) ?? [];
147
- return { coroutineId, cursor: 0, totalYields: entries.length };
174
+ return {
175
+ coroutineId,
176
+ cursor: 0,
177
+ totalYields: entries.length,
178
+ entry: entries[0] ?? close,
179
+ };
148
180
  }
149
181
  continue;
150
182
  }
151
183
  const entries = this.yields.get(coroutineId) ?? [];
152
184
  const cursor = this.cursors.get(coroutineId) ?? 0;
153
185
  if (cursor < entries.length) {
154
- return { coroutineId, cursor, totalYields: entries.length };
186
+ return { coroutineId, cursor, totalYields: entries.length, entry: entries[cursor] };
155
187
  }
156
188
  }
157
189
  return undefined;
package/esm/run.js CHANGED
@@ -12,7 +12,7 @@
12
12
  *
13
13
  * See integration doc §10, protocol spec §4.
14
14
  */
15
- import { useScope } from "effection";
15
+ import { scoped, useScope } from "effection";
16
16
  import { DurableContext } from "./context.js";
17
17
  import { activeDurabilityFailure, appendDurableEvent } from "./durability.js";
18
18
  import { EarlyReturnDivergenceError, TerminalDivergenceError } from "./errors.js";
@@ -138,14 +138,19 @@ export function* durableRun(workflow, options) {
138
138
  }
139
139
  const unconsumed = unalignedReplay(replayIndex, coroutineId);
140
140
  if (unconsumed) {
141
- throw new EarlyReturnDivergenceError(unconsumed.coroutineId, unconsumed.cursor, unconsumed.totalYields);
141
+ throw new EarlyReturnDivergenceError(unconsumed.coroutineId, unconsumed.cursor, unconsumed.totalYields, unconsumed.entry);
142
142
  }
143
143
  const closeEvent = {
144
144
  type: "close",
145
145
  coroutineId,
146
146
  result: { status: "ok", value: result },
147
147
  };
148
- yield* appendDurableEvent(ctx, closeEvent);
148
+ // The append's ordered turn is a resource of the scope it runs in, and
149
+ // this frame survives a rejected close: the catch below appends the
150
+ // compensating Close(err) from the same scope, so a turn still held here
151
+ // would make that append wait forever. A boundary around the attempt
152
+ // returns the turn as part of the outcome.
153
+ yield* scoped(() => appendDurableEvent(ctx, closeEvent));
149
154
  return result;
150
155
  }
151
156
  catch (error) {
@@ -156,7 +161,7 @@ export function* durableRun(workflow, options) {
156
161
  }
157
162
  const unconsumed = unalignedReplay(replayIndex, coroutineId);
158
163
  if (unconsumed) {
159
- throw new TerminalDivergenceError(unconsumed.coroutineId, unconsumed.cursor, unconsumed.totalYields, { cause: primary });
164
+ throw new TerminalDivergenceError(unconsumed.coroutineId, unconsumed.cursor, unconsumed.totalYields, { cause: primary, unconsumed: unconsumed.entry });
160
165
  }
161
166
  const closeEvent = {
162
167
  type: "close",
@@ -167,7 +172,7 @@ export function* durableRun(workflow, options) {
167
172
  },
168
173
  };
169
174
  try {
170
- yield* appendDurableEvent(ctx, closeEvent);
175
+ yield* scoped(() => appendDurableEvent(ctx, closeEvent));
171
176
  }
172
177
  catch (closeError) {
173
178
  const closeDurabilityFailure = activeDurabilityFailure(ctx, closeError);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@executablemd/durable-streams",
3
- "version": "0.8.1",
3
+ "version": "0.9.1",
4
4
  "description": "Durable, replayable event streams for executable.md.",
5
5
  "homepage": "https://executable.md",
6
6
  "repository": {
@@ -25,6 +25,13 @@ export interface DurableContext {
25
25
  coroutineId: CoroutineId;
26
26
  /** Counter for assigning child IDs. */
27
27
  childCounter: number;
28
+ /**
29
+ * How many durable yields this coroutine has settled.
30
+ *
31
+ * Advanced by both settlement paths, so a coroutine running past its retained
32
+ * history keeps counting where replay left off. See `position.ts`.
33
+ */
34
+ position?: number;
28
35
  /** Protocol failure shared by the root and every durable child. */
29
36
  durability?: DurabilityState;
30
37
  }
package/types/errors.d.ts CHANGED
@@ -1,7 +1,28 @@
1
1
  /**
2
2
  * Error types for the durable execution protocol.
3
3
  */
4
- import type { CoroutineId, EffectDescription } from "./types.js";
4
+ import type { Close, CoroutineId, EffectDescription, Yield } from "./types.js";
5
+ /**
6
+ * The description field an authored source position occupies.
7
+ *
8
+ * A durable effect's identity is its `type` and `name` and nothing else, so a
9
+ * position travels beside them under this stable namespaced field. It is
10
+ * stored, filtered diagnostic data: never compared during divergence
11
+ * detection, never part of admission.
12
+ */
13
+ export declare const SOURCE_POSITION_FIELD = "executablemd.source-position";
14
+ /**
15
+ * Render one effect description for a divergence diagnostic.
16
+ *
17
+ * The identity renders as `type("name")`. When the description retains the
18
+ * exact normalized source shape under `SOURCE_POSITION_FIELD` — an optional
19
+ * non-empty `path`, an integer `offset` of at least 0, integer `line` and
20
+ * `column` of at least 1, and no other member — its human spelling,
21
+ * `path:line:column` or `line:column` without a path, never the offset, is
22
+ * appended as ` at …`. Anything else renders nothing at all: formatting a
23
+ * diagnostic must not introduce a new failure.
24
+ */
25
+ export declare function describeEffect(description: EffectDescription): string;
5
26
  /**
6
27
  * Raised when a durable event cannot be persisted.
7
28
  *
@@ -58,9 +79,12 @@ export declare class TerminalDivergenceError extends Error {
58
79
  coroutineId: CoroutineId;
59
80
  consumedCount: number;
60
81
  totalCount: number;
82
+ /** The first retained entry the terminating subtree did not reach. */
83
+ unconsumed?: Yield | Close;
61
84
  constructor(coroutineId: CoroutineId, consumedCount: number, totalCount: number, options?: {
62
85
  cause?: unknown;
63
86
  message?: string;
87
+ unconsumed?: Yield | Close;
64
88
  });
65
89
  }
66
90
  /**
@@ -69,7 +93,7 @@ export declare class TerminalDivergenceError extends Error {
69
93
  */
70
94
  export declare class EarlyReturnDivergenceError extends TerminalDivergenceError {
71
95
  name: string;
72
- constructor(coroutineId: CoroutineId, consumedCount: number, totalCount: number);
96
+ constructor(coroutineId: CoroutineId, consumedCount: number, totalCount: number, unconsumed?: Yield | Close);
73
97
  }
74
98
  /**
75
99
  * Raised when the journal has a Close event for a coroutine but the
@@ -1,11 +1,38 @@
1
1
  import type { Operation } from "effection";
2
2
  import type { JournalProvenance } from "./guard.js";
3
- import type { Json, Result } from "./types.js";
3
+ import type { EffectDescription, Json, Result } from "./types.js";
4
4
  /** Activates the first infrastructure failure for the enclosing durable run. */
5
5
  export type ActivateDurabilityFailure = (failure: unknown) => Error;
6
+ /**
7
+ * What replay left behind at this position, when it left anything.
8
+ *
9
+ * Present only when a retained entry was there and divergence policy chose to
10
+ * run live rather than refuse. It is the engine's own account of the position,
11
+ * so an effect whose live work reaches outside this journal can decide whether
12
+ * running live here is something it may do — without asking anything a document
13
+ * supplies.
14
+ */
15
+ export interface AbandonedRetainedEntry {
16
+ readonly description: EffectDescription;
17
+ readonly result: Result;
18
+ }
19
+ /**
20
+ * What replay left behind in this coroutine, as the engine sees it.
21
+ *
22
+ * `entry` is the retained entry this very operation stepped over, when there
23
+ * was one. `steppedOver` is the wider fact and the load-bearing one: it stays
24
+ * true for every operation after the one that diverged, because disabling
25
+ * replay does not consume the history it walked away from. An effect that reads
26
+ * only `entry` would be safe at the position that diverged and unguarded at
27
+ * every position after it.
28
+ */
29
+ export interface AbandonedRetainedHistory {
30
+ readonly entry?: AbandonedRetainedEntry;
31
+ readonly steppedOver: boolean;
32
+ }
6
33
  /** Coordinates one live structured durable operation with its publication. */
7
34
  export interface LiveDurableOperationCoordinator {
8
- run<T extends Json>(execute: () => Operation<T>, publish: (result: Result) => Operation<void>, activateFailure: ActivateDurabilityFailure, journalProvenance: JournalProvenance | undefined): Operation<Result>;
35
+ run<T extends Json>(execute: () => Operation<T>, publish: (result: Result) => Operation<void>, activateFailure: ActivateDurabilityFailure, journalProvenance: JournalProvenance | undefined, abandoned: AbandonedRetainedHistory): Operation<Result>;
9
36
  }
10
37
  /** The ordinary live path: execute once, publish once, then return the same result. */
11
38
  export declare const defaultLiveDurableOperationCoordinator: LiveDurableOperationCoordinator;
package/types/mod.d.ts CHANGED
@@ -17,15 +17,18 @@ export { establishJournalProvenance, preserveJournalProvenance } from "./guard.j
17
17
  export type { DurableEventGate, JournalProvenance } from "./guard.js";
18
18
  export { useHttpDurableStream } from "./http-stream.js";
19
19
  export type { HttpDurableStreamHandle, HttpDurableStreamOptions } from "./http-stream.js";
20
- export { ContinuePastCloseDivergenceError, DivergenceError, DurablePersistenceError, EarlyReturnDivergenceError, MalformedDurableEventError, StaleInputError, TerminalDivergenceError, } from "./errors.js";
20
+ export { ContinuePastCloseDivergenceError, describeEffect, DivergenceError, DurablePersistenceError, EarlyReturnDivergenceError, MalformedDurableEventError, SOURCE_POSITION_FIELD, StaleInputError, TerminalDivergenceError, } from "./errors.js";
21
21
  export { Divergence } from "./divergence.js";
22
22
  export type { DivergenceDecision, DivergenceInfo, DivergenceKind } from "./divergence.js";
23
23
  export { ReplayGuard } from "./replay-guard.js";
24
24
  export type { ReplayOutcome, RetainedHistory } from "./replay-guard.js";
25
+ export type { AbandonedRetainedEntry, AbandonedRetainedHistory } from "./live-coordinator.js";
25
26
  export { DurableContext } from "./context.js";
26
27
  export { deserializeError, effectionToProtocol, protocolToEffection, serializeDurableEvent, serializeError, } from "./serialize.js";
27
28
  export { parseDurableEvent } from "./parse.js";
28
29
  export { createDurableEffect, createDurableOperation } from "./effect.js";
30
+ export { durablePosition } from "./position.js";
31
+ export type { DurablePosition } from "./position.js";
29
32
  export type { Executor } from "./effect.js";
30
33
  export { defaultLiveDurableOperationCoordinator } from "./live-coordinator.js";
31
34
  export type { ActivateDurabilityFailure, LiveDurableOperationCoordinator, } from "./live-coordinator.js";
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Where a coroutine is in its own durable history.
3
+ *
4
+ * A durable operation is identified by what it is and what it is called. Some
5
+ * operations have no name to give: a wait that a document reaches at one point
6
+ * in one procedure is that wait *because of where it is*, not because somebody
7
+ * named it. This is the coordinate such an operation names itself by — the
8
+ * coroutine it runs in, and how many durable yields that coroutine has already
9
+ * settled.
10
+ *
11
+ * ## Why it is not the replay cursor
12
+ *
13
+ * Replay already keeps a cursor per coroutine, and it looks like the same
14
+ * number. It is not. The cursor advances only while entries are being consumed
15
+ * from retained history; once a coroutine runs past the end of what was
16
+ * retained, the cursor stops while the coroutine keeps performing durable work.
17
+ * A position taken from it would be stable across replays of the retained
18
+ * prefix and would collide for every operation in the live suffix.
19
+ *
20
+ * So the position advances on both — a replayed yield and a committed live one
21
+ * — which is what makes the index a coroutine reaches at one point in its
22
+ * procedure the same index it reaches there next time.
23
+ *
24
+ * ## Observation, never authority
25
+ *
26
+ * A position describes where execution is. It authorizes nothing, proves
27
+ * nothing about who is executing, and is not durable state: it is derived by
28
+ * counting what the journal already holds. Anything that must be trusted is
29
+ * checked by whoever owns the authority to check it.
30
+ */
31
+ import { type Operation } from "effection";
32
+ import type { CoroutineId } from "./types.js";
33
+ /**
34
+ * One coroutine's coordinate: which coroutine, and how far along it is.
35
+ *
36
+ * The pair rather than the index alone, because two coroutines each reach index
37
+ * 0, and the two are different places. A child's coordinate is its own — a
38
+ * child coroutine counts its own yields from zero — and the coroutine id is
39
+ * what keeps that from colliding with its parent's.
40
+ */
41
+ export interface DurablePosition {
42
+ readonly coroutineId: CoroutineId;
43
+ readonly index: number;
44
+ }
45
+ /**
46
+ * The position this coroutine has reached.
47
+ *
48
+ * Read before performing a durable operation, it is that operation's
49
+ * coordinate. Read after, it is the next one's.
50
+ */
51
+ export declare function durablePosition(): Operation<DurablePosition>;
52
+ /**
53
+ * Record that this coroutine settled one durable yield.
54
+ *
55
+ * Called from both settlement paths, which is the whole point of the field. A
56
+ * caller outside this package never advances a position: it is a count of what
57
+ * happened, and only the code that made it happen may say so.
58
+ */
59
+ export declare function advanceDurablePosition(context: {
60
+ position?: number;
61
+ }): void;
@@ -54,6 +54,19 @@ export declare class ReplayIndex {
54
54
  peekYield(coroutineId: CoroutineId): YieldEntry | undefined;
55
55
  /** Advances the cursor for this coroutine by one position. */
56
56
  consumeYield(coroutineId: CoroutineId): void;
57
+ /**
58
+ * Whether this coroutine walked away from retained history it never consumed.
59
+ *
60
+ * True once replay has been disabled for it while entries it had not reached
61
+ * are still there — which is what a run-live decision leaves behind. It stays
62
+ * true for every later operation in that coroutine, because the history is
63
+ * still unconsumed however far past it execution has gone.
64
+ *
65
+ * An effect whose live work reaches a service this journal does not enclose
66
+ * reads it to decide whether running live here is something it may do at all.
67
+ * It is the index's own account, so nothing a document supplies takes part.
68
+ */
69
+ abandonedHistory(coroutineId: CoroutineId): boolean;
57
70
  /** Returns the current cursor position for this coroutine. */
58
71
  getCursor(coroutineId: CoroutineId): number;
59
72
  /** Returns true if a Close event exists for this coroutine (and replay is not disabled). */
@@ -67,11 +80,19 @@ export declare class ReplayIndex {
67
80
  * unconsumed entries belong to child coroutines rather than the root.
68
81
  */
69
82
  hasAnyUnconsumedYields(): boolean;
70
- /** Return the first retained coroutine not aligned with the current subtree. */
83
+ /**
84
+ * Return the first retained coroutine not aligned with the current subtree.
85
+ *
86
+ * `entry` is the first unmatched retained protocol entry itself — the Yield
87
+ * at the cursor, or for an unclaimed completed child its first Yield when it
88
+ * has one and otherwise its Close — so a terminal diagnostic can name what
89
+ * the terminating subtree did not reach, not just where it stopped.
90
+ */
71
91
  firstUnaligned(subtreeId: CoroutineId): {
72
92
  coroutineId: CoroutineId;
73
93
  cursor: number;
74
94
  totalYields: number;
95
+ entry: Yield | Close;
75
96
  } | undefined;
76
97
  /**
77
98
  * Returns true if the cursor for this coroutine has been fully consumed