@lostgradient/weft 0.22.1 → 0.23.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.
Files changed (52) hide show
  1. package/README.md +1 -1
  2. package/dist/cli/generated/operation-catalog.snapshot.json +112 -38
  3. package/dist/core/engine/errors.d.ts +1 -1
  4. package/dist/core/engine/errors.js +4 -1
  5. package/dist/core/engine/index.d.ts +1 -1
  6. package/dist/core/engine/index.js +1 -0
  7. package/dist/core/engine/persisted-data-version.js +1 -1
  8. package/dist/core/persisted-data-incompatible-error.d.ts +16 -2
  9. package/dist/core/persisted-data-incompatible-error.js +9 -1
  10. package/dist/core/weft-error.d.ts +1 -1
  11. package/dist/core/weft-error.js +1 -0
  12. package/dist/http.js +1 -1
  13. package/dist/index.d.ts +1 -1
  14. package/dist/index.js +1 -0
  15. package/dist/indexeddb.js +1 -1
  16. package/dist/json-schema.js +1 -1
  17. package/dist/server/fleet-event-feed.d.ts +14 -64
  18. package/dist/server/fleet-event-feed.js +228 -74
  19. package/dist/server/handler/index.d.ts +1 -1
  20. package/dist/server/index.d.ts +8 -6
  21. package/dist/server/operations/event-stream-contracts.js +15 -2
  22. package/dist/server/operations/fleet-events-sse.js +3 -0
  23. package/dist/server/operations/fleet-events-subscription.js +3 -0
  24. package/dist/server/replay-live-feed-internals.d.ts +7 -0
  25. package/dist/server/replay-live-feed-internals.js +107 -7
  26. package/dist/server/runtime/task-result-view.d.ts +31 -12
  27. package/dist/server/runtime/task-result-view.js +31 -9
  28. package/dist/server/task-ledger-transitions-cancellation.d.ts +2 -0
  29. package/dist/server/task-ledger-transitions-cancellation.js +1 -1
  30. package/dist/server/workflow-event-feed.d.ts +88 -0
  31. package/dist/server/workflow-event-feed.js +4 -1
  32. package/dist/storage/bun-sql.js +1 -0
  33. package/dist/storage/compressed-storage.js +1 -1
  34. package/dist/storage/index.d.ts +2 -1
  35. package/dist/storage/interface.d.ts +1 -0
  36. package/dist/storage/interface.js +1 -1
  37. package/dist/storage/key-prefixes.d.ts +1 -1
  38. package/dist/storage/key-prefixes.js +1 -0
  39. package/dist/storage/lmdb.js +1 -1
  40. package/dist/storage/memory.js +1 -1
  41. package/dist/storage/neon.js +2 -2
  42. package/dist/storage/node-sqlite-loader.js +2 -2
  43. package/dist/storage/node-sqlite.js +3 -2
  44. package/dist/storage/postgres.js +2 -2
  45. package/dist/storage/resolve.js +1 -1
  46. package/dist/storage/scoped-storage.js +1 -1
  47. package/dist/storage/testing.js +1 -1
  48. package/dist/storage/turso.js +2 -2
  49. package/dist/version.d.ts +1 -1
  50. package/dist/version.js +1 -1
  51. package/dist/web-extension.js +1 -1
  52. package/package.json +1 -1
@@ -1,110 +1,127 @@
1
1
  import { decode, encode } from "../core/codec.js";
2
+ import { PersistedDataCorruptError } from "../core/persisted-data-incompatible-error.js";
2
3
  import {
3
4
  KEYS,
5
+ MAX_BATCH_OPERATIONS,
4
6
  storageConditionalBatch
5
7
  } from "../storage/interface.js";
8
+ import {
9
+ createDurableSubscription,
10
+ createSerialOperationQueue
11
+ } from "./replay-live-feed-internals.js";
6
12
  import {
7
13
  createReplayLiveFeed,
8
14
  decodeCursor,
9
15
  encodeCursor
10
16
  } from "./workflow-event-feed.js";
11
- const MAX_WORKFLOW_OWNED_APPEND_ATTEMPTS = 5;
17
+ const DEFAULT_RETENTION_BATCH_SIZE = 100, REPLAY_PAGE_SIZE = 128;
12
18
  export function createFleetEventFeed(storage, feedOptions) {
13
- const listeners = new Set;
14
- let sequenceInitPromise = null, nextSequence = null, appendChain = Promise.resolve();
15
- const replayLiveFeed = createReplayLiveFeed({
19
+ if (!storage.capabilities().conditionalBatch)
20
+ throw Error("Fleet event feeds require storage with conditional batch support.");
21
+ const livePollIntervalMs = feedOptions?.livePollIntervalMs ?? 100;
22
+ if (!Number.isSafeInteger(livePollIntervalMs) || livePollIntervalMs < 1)
23
+ throw RangeError("Fleet event live poll interval must be positive.");
24
+ const listeners = new Set, disposalController = new AbortController, enqueueAppend = createSerialOperationQueue(), backend = {
16
25
  replay: replayPersistedFleetEvents,
17
26
  snapshotTailSequence,
18
27
  subscribeLive
19
- }, feedOptions);
20
- async function initializeNextSequence() {
21
- if (nextSequence !== null)
22
- return nextSequence;
23
- if (sequenceInitPromise)
24
- return sequenceInitPromise;
25
- sequenceInitPromise = snapshotTailSequence().then((tailSequence) => {
26
- nextSequence = tailSequence + 1;
27
- return nextSequence;
28
- }).catch((error) => {
29
- sequenceInitPromise = null;
30
- throw error;
31
- });
32
- return sequenceInitPromise;
33
- }
34
- async function append(event) {
35
- return appendInternal(event);
28
+ }, replayLiveFeed = createReplayLiveFeed(backend);
29
+ async function append(event, options) {
30
+ const appended = await enqueueAppend(() => appendInternal(event, async () => options?.conditions ?? [], options?.operations ?? []));
31
+ if (appended === null)
32
+ throw Error("Fleet event append conditions unexpectedly disappeared.");
33
+ return appended;
36
34
  }
37
35
  async function appendWorkflowEventIfPresent(event) {
38
- return appendInternal(event, async () => {
36
+ return enqueueAppend(() => appendInternal(event, async () => {
39
37
  const workflowValue = await storage.get(KEYS.workflow(event.workflowId));
40
38
  if (workflowValue === null)
41
39
  return null;
42
40
  return [{ key: KEYS.workflow(event.workflowId), expectedValue: workflowValue }];
43
- });
41
+ }, []));
44
42
  }
45
- async function appendInternal(event, loadConditions) {
46
- const appended = appendChain.then(async () => {
47
- for (let attempt = 1;attempt <= MAX_WORKFLOW_OWNED_APPEND_ATTEMPTS; attempt += 1) {
48
- const conditions = loadConditions === void 0 ? void 0 : await loadConditions();
49
- if (conditions === null)
50
- return null;
51
- const sequence = await initializeNextSequence(), envelope = {
52
- kind: event.kind,
53
- sequence,
54
- cursor: encodeCursor(sequence),
55
- emittedAtMs: event.emittedAtMs,
56
- ...event.workflowId !== void 0 ? { workflowId: event.workflowId } : {},
57
- payload: event.payload
58
- }, operations = [
59
- { type: "put", key: KEYS.fleetEvent(sequence), value: encode(envelope) },
60
- { type: "put", key: KEYS.fleetEventTail(), value: encode({ sequence }) }
61
- ];
62
- if (event.workflowId !== void 0)
63
- operations.push({
64
- type: "put",
65
- key: KEYS.fleetEventByWorkflow(event.workflowId, sequence),
66
- value: new Uint8Array
67
- });
68
- if (conditions === void 0)
69
- await storage.batch(operations);
70
- else if (!await storageConditionalBatch(storage, [...conditions], operations))
71
- continue;
72
- nextSequence = sequence + 1;
73
- fireLive(envelope);
74
- return envelope;
75
- }
76
- throw Error(`Fleet event append for workflow "${event.workflowId ?? "<none>"}" lost its storage precondition after ${MAX_WORKFLOW_OWNED_APPEND_ATTEMPTS} attempts.`);
77
- });
78
- appendChain = appended.then(() => {
79
- return;
80
- }, () => {
81
- return;
82
- });
83
- return appended;
43
+ async function appendInternal(event, loadConditions, callerOperations = [], maxAttempts = 25) {
44
+ if (event.kind === "fleet:gap")
45
+ throw RangeError("The fleet:gap event kind is reserved for retention notices.");
46
+ for (let attempt = 1;attempt <= maxAttempts; attempt += 1) {
47
+ const conditions = loadConditions === void 0 ? [] : await loadConditions();
48
+ if (conditions === null)
49
+ return null;
50
+ const authority = await loadTailAuthority(storage);
51
+ if (authority === null)
52
+ continue;
53
+ const { tail, tailValue } = authority, sequence = tail + 1, envelope = createFleetEventEnvelope(event, sequence), operations = createFleetEventOperations(envelope, callerOperations);
54
+ if (!await storageConditionalBatch(storage, [{ key: KEYS.fleetEventTail(), expectedValue: tailValue }, ...conditions], operations))
55
+ continue;
56
+ fireLive(envelope);
57
+ return envelope;
58
+ }
59
+ throw Error(`Fleet event append for workflow "${event.workflowId ?? "<none>"}" lost its storage precondition after ${maxAttempts} attempts.`);
84
60
  }
85
61
  async function* replayPersistedFleetEvents(options) {
86
- const scanOptions = options.afterSequence >= 0 ? { gt: KEYS.fleetEvent(options.afterSequence) } : void 0;
87
- for await (const [key, value] of storage.scan(KEYS.fleetEventPrefix(), scanOptions)) {
88
- const sequence = parseFleetEventSequenceFromKey(key);
89
- if (sequence === null || sequence <= options.afterSequence)
90
- continue;
91
- const decoded = decodeStorageValue(value);
92
- if (!isFleetEventEnvelope(decoded))
62
+ let deliveredSequence = options.afterSequence, gapCursor = options.requestedCursor ?? (options.afterSequence < 0 ? "-1" : encodeCursor(options.afterSequence));
63
+ while (!0) {
64
+ const page = await loadConsistentReplayPage(storage, deliveredSequence);
65
+ if (deliveredSequence < page.floor - 1) {
66
+ deliveredSequence = page.floor - 1;
67
+ yield createGapEnvelope(deliveredSequence, gapCursor, page.floor);
68
+ gapCursor = encodeCursor(deliveredSequence);
93
69
  continue;
94
- yield decoded;
70
+ }
71
+ for (const envelope of page.envelopes) {
72
+ deliveredSequence = envelope.sequence;
73
+ yield envelope;
74
+ }
75
+ if (page.envelopes.length < REPLAY_PAGE_SIZE)
76
+ return;
95
77
  }
96
78
  }
97
79
  async function snapshotTailSequence() {
98
- const storedTail = await storage.get(KEYS.fleetEventTail()), decodedTail = storedTail === null ? null : decodeStorageValue(storedTail);
80
+ const storedTail = await storage.get(KEYS.fleetEventTail()), decodedTail = storedTail === null ? null : decodeStorageValue(storedTail, KEYS.fleetEventTail());
81
+ if (storedTail !== null && !isTailRecord(decodedTail))
82
+ throw new PersistedDataCorruptError(KEYS.fleetEventTail());
99
83
  if (isTailRecord(decodedTail))
100
84
  return decodedTail.sequence;
101
85
  for await (const [key] of storage.scan(KEYS.fleetEventPrefix(), { reverse: !0 })) {
102
86
  const sequence = parseFleetEventSequenceFromKey(key);
103
87
  if (sequence !== null)
104
88
  return sequence;
89
+ throw new PersistedDataCorruptError(key);
105
90
  }
106
91
  return -1;
107
92
  }
93
+ async function snapshotRetentionFloor() {
94
+ const value = await storage.get(KEYS.fleetEventWatermark());
95
+ if (value === null)
96
+ return 0;
97
+ const decoded = decodeStorageValue(value, KEYS.fleetEventWatermark());
98
+ if (!isFloorRecord(decoded))
99
+ throw new PersistedDataCorruptError(KEYS.fleetEventWatermark());
100
+ return decoded.firstRetainedSequence;
101
+ }
102
+ async function retain(options) {
103
+ validateRetentionOptions(options);
104
+ const requestedLimit = options.limit ?? DEFAULT_RETENTION_BATCH_SIZE, limit = Math.min(requestedLimit, Math.floor((MAX_BATCH_OPERATIONS - 1) / 2));
105
+ for (let attempt = 1;attempt <= 25; attempt += 1) {
106
+ const watermarkValue = await storage.get(KEYS.fleetEventWatermark()), floor = decodeRetentionFloorOrThrow(watermarkValue), tail = await snapshotTailSequence(), target = Math.min(options.beforeSequence, tail + 1);
107
+ if (target <= floor)
108
+ return 0;
109
+ const recordsToDelete = await collectRetentionRecords(storage, target, limit), deletedThrough = recordsToDelete.at(-1)?.key, deletedThroughSequence = deletedThrough === void 0 ? floor : parseFleetEventSequenceFromKey(deletedThrough) + 1, newFloor = recordsToDelete.length < limit ? target : deletedThroughSequence, operations = [
110
+ ...recordsToDelete.flatMap(({ key, workflowId, sequence }) => [
111
+ { type: "delete", key },
112
+ ...workflowId === void 0 ? [] : [{ type: "delete", key: KEYS.fleetEventByWorkflow(workflowId, sequence) }]
113
+ ]),
114
+ {
115
+ type: "put",
116
+ key: KEYS.fleetEventWatermark(),
117
+ value: encode({ firstRetainedSequence: newFloor })
118
+ }
119
+ ];
120
+ if (await storageConditionalBatch(storage, [{ key: KEYS.fleetEventWatermark(), expectedValue: watermarkValue }], operations))
121
+ return recordsToDelete.length;
122
+ }
123
+ throw Error("Fleet event retention lost its storage precondition after 25 attempts.");
124
+ }
108
125
  function subscribeLive(listener) {
109
126
  listeners.add(listener);
110
127
  return () => {
@@ -122,21 +139,152 @@ export function createFleetEventFeed(storage, feedOptions) {
122
139
  append,
123
140
  appendWorkflowEventIfPresent,
124
141
  replay: (options) => replayLiveFeed.replay(options),
125
- subscribe: (options) => replayLiveFeed.subscribe(options),
142
+ subscribe: (options) => createDurableSubscription(backend, {
143
+ pollIntervalMs: livePollIntervalMs,
144
+ lifecycleSignal: disposalController.signal
145
+ }, options),
126
146
  snapshotTailSequence,
147
+ snapshotRetentionFloor,
148
+ retain,
127
149
  dispose() {
150
+ disposalController.abort();
128
151
  listeners.clear();
129
152
  replayLiveFeed.dispose();
130
153
  }
131
154
  };
132
155
  }
133
- function decodeStorageValue(value) {
156
+ async function loadTailAuthority(storage) {
157
+ const tailValue = await storage.get(KEYS.fleetEventTail()), tail = tailValue === null ? -1 : decodeTailOrThrow(tailValue);
158
+ if (await highestFleetEventSequence(storage) > tail) {
159
+ const refreshedTailValue = await storage.get(KEYS.fleetEventTail());
160
+ if (!bytesEqual(refreshedTailValue, tailValue))
161
+ return null;
162
+ throw new PersistedDataCorruptError(KEYS.fleetEventTail());
163
+ }
164
+ return { tail, tailValue };
165
+ }
166
+ async function loadConsistentReplayPage(storage, afterSequence) {
167
+ for (let attempt = 1;attempt <= 25; attempt += 1) {
168
+ const floorValue = await storage.get(KEYS.fleetEventWatermark()), floor = decodeRetentionFloorOrThrow(floorValue), envelopes = [], scanOptions = {
169
+ ...afterSequence >= 0 ? { gt: KEYS.fleetEvent(afterSequence) } : {},
170
+ limit: REPLAY_PAGE_SIZE
171
+ };
172
+ for await (const [key, value] of storage.scan(KEYS.fleetEventPrefix(), scanOptions)) {
173
+ const sequence = parseFleetEventSequenceFromKey(key);
174
+ if (sequence === null)
175
+ throw new PersistedDataCorruptError(key);
176
+ if (sequence <= afterSequence)
177
+ continue;
178
+ const decoded = decodeStorageValue(value, key);
179
+ if (!isFleetEventEnvelope(decoded) || decoded.sequence !== sequence)
180
+ throw new PersistedDataCorruptError(key);
181
+ envelopes.push(decoded);
182
+ }
183
+ const refreshedFloorValue = await storage.get(KEYS.fleetEventWatermark());
184
+ if (bytesEqual(refreshedFloorValue, floorValue))
185
+ return { floor, envelopes };
186
+ }
187
+ throw Error("Fleet event replay could not obtain a stable retention snapshot.");
188
+ }
189
+ function createGapEnvelope(sequence, requestedCursor, firstRetainedSequence) {
190
+ return {
191
+ kind: "fleet:gap",
192
+ sequence,
193
+ cursor: encodeCursor(sequence),
194
+ emittedAtMs: 0,
195
+ payload: { requestedCursor, firstRetainedSequence }
196
+ };
197
+ }
198
+ function bytesEqual(left, right) {
199
+ if (left === null || right === null)
200
+ return left === right;
201
+ if (left.byteLength !== right.byteLength)
202
+ return !1;
203
+ return left.every((value, index) => value === right[index]);
204
+ }
205
+ function createFleetEventEnvelope(event, sequence) {
206
+ return {
207
+ kind: event.kind,
208
+ sequence,
209
+ cursor: encodeCursor(sequence),
210
+ emittedAtMs: event.emittedAtMs,
211
+ ...event.workflowId === void 0 ? {} : { workflowId: event.workflowId },
212
+ payload: event.payload
213
+ };
214
+ }
215
+ function createFleetEventOperations(envelope, callerOperations) {
216
+ return [
217
+ ...callerOperations,
218
+ { type: "put", key: KEYS.fleetEvent(envelope.sequence), value: encode(envelope) },
219
+ { type: "put", key: KEYS.fleetEventTail(), value: encode({ sequence: envelope.sequence }) },
220
+ ...envelope.workflowId === void 0 ? [] : [
221
+ {
222
+ type: "put",
223
+ key: KEYS.fleetEventByWorkflow(envelope.workflowId, envelope.sequence),
224
+ value: new Uint8Array
225
+ }
226
+ ]
227
+ ];
228
+ }
229
+ function validateRetentionOptions(options) {
230
+ if (!Number.isSafeInteger(options.beforeSequence) || options.beforeSequence < 0)
231
+ throw RangeError("Fleet event retention sequence must be a non-negative safe integer.");
232
+ const limit = options.limit ?? DEFAULT_RETENTION_BATCH_SIZE;
233
+ if (!Number.isSafeInteger(limit) || limit < 1)
234
+ throw RangeError("Fleet event retention limit must be positive.");
235
+ }
236
+ async function collectRetentionRecords(storage, target, limit) {
237
+ const records = [];
238
+ for await (const [key, value] of storage.scan(KEYS.fleetEventPrefix(), {
239
+ lt: KEYS.fleetEvent(target),
240
+ limit
241
+ })) {
242
+ const sequence = parseFleetEventSequenceFromKey(key);
243
+ if (sequence === null)
244
+ throw new PersistedDataCorruptError(key);
245
+ if (sequence < target) {
246
+ const envelope = decodeStorageValue(value, key);
247
+ if (!isFleetEventEnvelope(envelope) || envelope.sequence !== sequence)
248
+ throw new PersistedDataCorruptError(key);
249
+ records.push({
250
+ key,
251
+ sequence,
252
+ ...envelope.workflowId === void 0 ? {} : { workflowId: envelope.workflowId }
253
+ });
254
+ }
255
+ }
256
+ return records;
257
+ }
258
+ function decodeRetentionFloorOrThrow(value) {
259
+ if (value === null)
260
+ return 0;
261
+ const decoded = decodeStorageValue(value, KEYS.fleetEventWatermark());
262
+ if (!isFloorRecord(decoded))
263
+ throw new PersistedDataCorruptError(KEYS.fleetEventWatermark());
264
+ return decoded.firstRetainedSequence;
265
+ }
266
+ function decodeStorageValue(value, key) {
134
267
  try {
135
268
  return decode(value);
136
269
  } catch {
137
- return null;
270
+ throw new PersistedDataCorruptError(key);
138
271
  }
139
272
  }
273
+ function decodeTailOrThrow(value) {
274
+ const decoded = decodeStorageValue(value, KEYS.fleetEventTail());
275
+ if (!isTailRecord(decoded))
276
+ throw new PersistedDataCorruptError(KEYS.fleetEventTail());
277
+ return decoded.sequence;
278
+ }
279
+ async function highestFleetEventSequence(storage) {
280
+ for await (const [key] of storage.scan(KEYS.fleetEventPrefix(), { reverse: !0, limit: 1 })) {
281
+ const sequence = parseFleetEventSequenceFromKey(key);
282
+ if (sequence === null)
283
+ throw new PersistedDataCorruptError(key);
284
+ return sequence;
285
+ }
286
+ return -1;
287
+ }
140
288
  function parseFleetEventSequenceFromKey(key) {
141
289
  if (!key.startsWith(KEYS.fleetEventPrefix()))
142
290
  return null;
@@ -149,6 +297,12 @@ function parseFleetEventSequenceFromKey(key) {
149
297
  function isTailRecord(value) {
150
298
  return typeof value === "object" && value !== null && "sequence" in value && Number.isSafeInteger(value.sequence);
151
299
  }
300
+ function isFloorRecord(value) {
301
+ if (typeof value !== "object" || value === null)
302
+ return !1;
303
+ const firstRetainedSequence = value.firstRetainedSequence;
304
+ return Number.isSafeInteger(firstRetainedSequence) && firstRetainedSequence >= 0;
305
+ }
152
306
  function isFleetEventEnvelope(value) {
153
307
  if (typeof value !== "object" || value === null)
154
308
  return !1;
@@ -15,7 +15,7 @@ export { authContextToPrincipal } from './auth-context-principal.ts';
15
15
  export { isOperationFaultLike, type HandlerOptions } from './route-dispatch.ts';
16
16
  export { extractRouteParameters, getRequiredRouteParameter } from './route-matching.ts';
17
17
  export { createEngineEventFeedBackend } from '../engine-event-feed-backend.ts';
18
- export { createFleetEventFeed, type FleetEventEnvelope, type FleetEventFeed, } from '../fleet-event-feed.ts';
18
+ export { createFleetEventFeed, type FleetEventAppendOptions, type FleetEventEnvelope, type FleetEventFeed, type FleetEventFeedOptions, type FleetEventGapEnvelope, type FleetEventInput, type FleetWorkflowEventInput, } from '../fleet-event-feed.ts';
19
19
  export { createWorkflowEventFeed, type Cursor, type EventEnvelope, type WorkflowEventFeed, type WorkflowEventFeedBackend, } from '../workflow-event-feed.ts';
20
20
  /**
21
21
  * Pure HTTP request handler. Maps Request to Response.
@@ -409,13 +409,15 @@ export interface WeftServer extends AsyncDisposable {
409
409
  /**
410
410
  * Mark a terminal task's result as adopted — the durable assertion that
411
411
  * whatever consumed the result (a workflow's own checkpoint, or other
412
- * application logic) has durably incorporated it. `resultDigest` must
413
- * match the terminal record's `resultDigest` from {@link getTaskResult}.
414
- * Returns `true` once adopted; `false` if the record is not currently
415
- * terminal or the digest does not match. Only adopted terminal records
416
- * become eligible for {@link ServeOptions.taskRetentionWindowMs} reaping.
412
+ * application logic) has durably incorporated it. Resolved records require
413
+ * the `resultDigest` returned by {@link getTaskResult}; cancelled and
414
+ * retry-exhausted records omit their private synthetic digest and expose a
415
+ * token-safe `adoptionToken` instead. Returns `true` once adopted; `false` if
416
+ * the record is not currently terminal or the required digest does not
417
+ * match. Only adopted terminal records become eligible for
418
+ * {@link ServeOptions.taskRetentionWindowMs} reaping.
417
419
  */
418
- adoptTaskResult(operationId: string, resultDigest: string): Promise<boolean>;
420
+ adoptTaskResult(operationId: string, adoptionKey: string): Promise<boolean>;
419
421
  /** Send a shutdown message to a specific worker and wait for it to disconnect. Returns true if the worker was found. */
420
422
  shutdownWorker(workerId: string, options?: {
421
423
  timeoutMs?: number;
@@ -1,12 +1,25 @@
1
1
  import { z } from "zod";
2
- export const fleetEventEnvelopeSchema = z.object({
2
+ const committedFleetEventEnvelopeSchema = z.object({
3
3
  kind: z.string(),
4
4
  workflowId: z.string().optional(),
5
5
  sequence: z.number(),
6
6
  cursor: z.string(),
7
7
  emittedAtMs: z.number(),
8
8
  payload: z.unknown()
9
- }), workflowEventEnvelopeSchema = z.object({
9
+ }), fleetEventGapEnvelopeSchema = z.object({
10
+ kind: z.literal("fleet:gap"),
11
+ sequence: z.number(),
12
+ cursor: z.string(),
13
+ emittedAtMs: z.number(),
14
+ payload: z.object({
15
+ requestedCursor: z.string(),
16
+ firstRetainedSequence: z.number()
17
+ })
18
+ });
19
+ export const fleetEventEnvelopeSchema = z.union([
20
+ committedFleetEventEnvelopeSchema,
21
+ fleetEventGapEnvelopeSchema
22
+ ]), workflowEventEnvelopeSchema = z.object({
10
23
  kind: z.string(),
11
24
  workflowId: z.string(),
12
25
  selector: z.enum(["events", "tokens"]),
@@ -67,6 +67,8 @@ function isAsyncIterable(value) {
67
67
  return typeof value[Symbol.asyncIterator] === "function";
68
68
  }
69
69
  function matchesFleetEventFilter(envelope, input) {
70
+ if (envelope.kind === "fleet:gap")
71
+ return !0;
70
72
  if (input.workflowId !== void 0 && envelope.workflowId !== input.workflowId)
71
73
  return !1;
72
74
  if (input.kind !== void 0 && envelope.kind !== input.kind)
@@ -84,6 +86,7 @@ function createFleetEventsIterable(input, context) {
84
86
  signal: controller.signal,
85
87
  replayLimit: MAX_FLEET_SSE_REPLAY_EVENTS,
86
88
  filterEnvelope: (envelope) => matchesFleetEventFilter(envelope, input),
89
+ countReplayEnvelope: (envelope) => envelope.kind !== "fleet:gap",
87
90
  onReplayComplete,
88
91
  createReplayLimitError: (count, limit) => invalidParamsFault(`Fleet event replay window is ${count} matching events; maximum is ${limit}. Supply a more recent fromCursor.`)
89
92
  };
@@ -46,6 +46,7 @@ export const fleetEventsSubscriptionOperation = defineOperation({
46
46
  signal: controller.signal,
47
47
  replayLimit: MAX_FLEET_SUBSCRIPTION_REPLAY_EVENTS,
48
48
  filterEnvelope: (envelope) => matchesFleetEventFilter(envelope, input),
49
+ countReplayEnvelope: (envelope) => envelope.kind !== "fleet:gap",
49
50
  onReplayComplete,
50
51
  createReplayLimitError: (count, limit) => fleetReplayLimitFault(count, limit)
51
52
  }), { close: () => controller.abort() });
@@ -60,6 +61,8 @@ function fleetReplayLimitFault(count, limit) {
60
61
  return invalidParamsFault(`Fleet event replay window is ${count} matching events; maximum is ${limit}. Supply a more recent fromCursor.`);
61
62
  }
62
63
  function matchesFleetEventFilter(envelope, input) {
64
+ if (envelope.kind === "fleet:gap")
65
+ return !0;
63
66
  if (!matchesWorkflowIdFilter(envelope, input.workflowId))
64
67
  return !1;
65
68
  if (!matchesKindFilter(envelope, input.kind))
@@ -8,6 +8,12 @@
8
8
  * @module server/replay-live-feed-internals
9
9
  */
10
10
  import type { ReplayLiveFeedBackend, ReplayLiveSubscribeOptions, SequencedEventEnvelope } from './workflow-event-feed.ts';
11
+ export declare function createSerialOperationQueue(): <T>(operation: () => Promise<T>) => Promise<T>;
12
+ type DurableSubscriptionOptions = {
13
+ readonly pollIntervalMs: number;
14
+ readonly lifecycleSignal?: AbortSignal;
15
+ };
16
+ export declare function createDurableSubscription<TEnvelope extends SequencedEventEnvelope>(backend: ReplayLiveFeedBackend<TEnvelope>, options: DurableSubscriptionOptions, args?: ReplayLiveSubscribeOptions<TEnvelope>): AsyncIterable<TEnvelope>;
11
17
  /** Thrown when a replay window exceeds the caller's configured limit. */
12
18
  export declare class ReplayWindowExceededError extends Error {
13
19
  readonly count: number;
@@ -17,3 +23,4 @@ export declare class ReplayWindowExceededError extends Error {
17
23
  export declare function replayUpTo<TEnvelope extends SequencedEventEnvelope>(backend: ReplayLiveFeedBackend<TEnvelope>, afterSequence: number, snapshot: number, signal: AbortSignal | undefined, replayOptions: ReplayLiveSubscribeOptions<TEnvelope> | undefined): AsyncIterable<TEnvelope>;
18
24
  export declare function shouldDeliverEnvelope<TEnvelope extends SequencedEventEnvelope>(envelope: TEnvelope, replayOptions: ReplayLiveSubscribeOptions<TEnvelope> | undefined): boolean;
19
25
  export declare function drainLive<TEnvelope extends SequencedEventEnvelope>(buffer: TEnvelope[], snapshot: number, signal: AbortSignal | undefined, overflowed: () => boolean, installWaker: (fn: (() => void) | null) => void): AsyncIterable<TEnvelope>;
26
+ export {};
@@ -1,3 +1,96 @@
1
+ export function createSerialOperationQueue() {
2
+ let queue = Promise.resolve();
3
+ return (operation) => {
4
+ const result = queue.then(operation, operation);
5
+ queue = result.then(() => {
6
+ return;
7
+ }, () => {
8
+ return;
9
+ });
10
+ return result;
11
+ };
12
+ }
13
+ export function createDurableSubscription(backend, options, args) {
14
+ const requestedAfter = decodeRequestedCursor(args?.fromCursor), signal = args?.signal === void 0 ? options.lifecycleSignal : options.lifecycleSignal === void 0 ? args.signal : AbortSignal.any([args.signal, options.lifecycleSignal]);
15
+ let waker = null, cleanedUp = !1;
16
+ const wake = () => {
17
+ const pending = waker;
18
+ waker = null;
19
+ pending?.();
20
+ }, unsubscribe = backend.subscribeLive(wake), cleanup = () => {
21
+ if (cleanedUp)
22
+ return;
23
+ cleanedUp = !0;
24
+ signal?.removeEventListener("abort", cleanup);
25
+ unsubscribe();
26
+ wake();
27
+ };
28
+ signal?.addEventListener("abort", cleanup, { once: !0 });
29
+ if (signal?.aborted)
30
+ cleanup();
31
+ async function* generator() {
32
+ try {
33
+ if (signal?.aborted)
34
+ return;
35
+ const snapshot = await backend.snapshotTailSequence();
36
+ yield* replayUpTo(backend, requestedAfter, snapshot, signal, args);
37
+ if (signal?.aborted)
38
+ return;
39
+ args?.onReplayComplete?.();
40
+ yield* tailDurableEvents(backend, snapshot, signal, args, options.pollIntervalMs, (next) => {
41
+ waker = next;
42
+ });
43
+ } finally {
44
+ cleanup();
45
+ }
46
+ }
47
+ return generator();
48
+ }
49
+ async function* tailDurableEvents(backend, snapshot, signal, args, pollIntervalMs, installWaker) {
50
+ let deliveredSequence = snapshot;
51
+ while (!0) {
52
+ if (signal?.aborted)
53
+ break;
54
+ let found = !1;
55
+ for await (const envelope of backend.replay({ afterSequence: deliveredSequence })) {
56
+ if (signal?.aborted)
57
+ break;
58
+ found = !0;
59
+ deliveredSequence = Math.max(deliveredSequence, envelope.sequence);
60
+ if (shouldDeliverEnvelope(envelope, args))
61
+ yield envelope;
62
+ }
63
+ if (found)
64
+ continue;
65
+ await waitForAppendOrPoll(pollIntervalMs, signal, installWaker);
66
+ }
67
+ }
68
+ function decodeRequestedCursor(cursor) {
69
+ if (cursor === void 0)
70
+ return -1;
71
+ if (!/^(?:-1|\d+)$/.test(cursor))
72
+ throw Error("Invalid cursor");
73
+ const sequence = Number(cursor);
74
+ if (!Number.isSafeInteger(sequence) || sequence < -1)
75
+ throw Error("Invalid cursor");
76
+ return sequence;
77
+ }
78
+ async function waitForAppendOrPoll(pollIntervalMs, signal, installWaker) {
79
+ let timer;
80
+ await new Promise((resolve) => {
81
+ const finish = () => {
82
+ if (timer !== void 0)
83
+ clearTimeout(timer);
84
+ installWaker(null);
85
+ resolve();
86
+ };
87
+ installWaker(finish);
88
+ timer = setTimeout(finish, pollIntervalMs);
89
+ if (signal?.aborted)
90
+ finish();
91
+ });
92
+ }
93
+
1
94
  export class ReplayWindowExceededError extends Error {
2
95
  count;
3
96
  limit;
@@ -10,22 +103,29 @@ export class ReplayWindowExceededError extends Error {
10
103
  }
11
104
  export async function* replayUpTo(backend, afterSequence, snapshot, signal, replayOptions) {
12
105
  let replayCount = 0;
13
- for await (const envelope of backend.replay({ afterSequence })) {
106
+ const backendOptions = createBackendReplayOptions(afterSequence, replayOptions?.fromCursor);
107
+ for await (const envelope of backend.replay(backendOptions)) {
14
108
  if (envelope.sequence > snapshot)
15
109
  break;
16
110
  if (signal?.aborted)
17
111
  return;
18
112
  if (!shouldDeliverEnvelope(envelope, replayOptions))
19
113
  continue;
20
- if (shouldCountReplayEnvelope(envelope, replayOptions)) {
21
- replayCount += 1;
22
- const replayLimit = replayOptions?.replayLimit;
23
- if (replayLimit !== void 0 && replayCount > replayLimit)
24
- throw createReplayLimitError(replayOptions, replayCount, replayLimit);
25
- }
114
+ replayCount = updateReplayCount(envelope, replayOptions, replayCount);
26
115
  yield envelope;
27
116
  }
28
117
  }
118
+ function updateReplayCount(envelope, replayOptions, replayCount) {
119
+ if (!shouldCountReplayEnvelope(envelope, replayOptions))
120
+ return replayCount;
121
+ const nextCount = replayCount + 1, replayLimit = replayOptions?.replayLimit;
122
+ if (replayLimit !== void 0 && nextCount > replayLimit)
123
+ throw createReplayLimitError(replayOptions, nextCount, replayLimit);
124
+ return nextCount;
125
+ }
126
+ function createBackendReplayOptions(afterSequence, requestedCursor) {
127
+ return requestedCursor === void 0 ? { afterSequence } : { afterSequence, requestedCursor };
128
+ }
29
129
  export function shouldDeliverEnvelope(envelope, replayOptions) {
30
130
  return replayOptions?.filterEnvelope?.(envelope) ?? !0;
31
131
  }