@lostgradient/weft 0.22.0 → 0.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -4
- package/dist/cli/console.js +5 -5
- package/dist/cli/generated/operation-catalog.snapshot.json +112 -38
- package/dist/cli/help-text.d.ts +1 -1
- package/dist/cli/help-text.js +1 -1
- package/dist/core/engine/errors.d.ts +1 -1
- package/dist/core/engine/errors.js +4 -1
- package/dist/core/engine/index.d.ts +23 -1
- package/dist/core/engine/index.js +1 -0
- package/dist/core/engine/persisted-data-version.js +1 -1
- package/dist/core/persisted-data-incompatible-error.d.ts +16 -2
- package/dist/core/persisted-data-incompatible-error.js +9 -1
- package/dist/core/weft-error.d.ts +1 -1
- package/dist/core/weft-error.js +1 -0
- package/dist/http.js +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -0
- package/dist/indexeddb.js +1 -1
- package/dist/json-schema.js +1 -1
- package/dist/runtime/portable.js +3 -3
- package/dist/server/fleet-event-feed.d.ts +14 -64
- package/dist/server/fleet-event-feed.js +228 -74
- package/dist/server/handler/index.d.ts +1 -1
- package/dist/server/index.d.ts +8 -6
- package/dist/server/operations/event-stream-contracts.js +15 -2
- package/dist/server/operations/fleet-events-sse.js +3 -0
- package/dist/server/operations/fleet-events-subscription.js +3 -0
- package/dist/server/replay-live-feed-internals.d.ts +7 -0
- package/dist/server/replay-live-feed-internals.js +107 -7
- package/dist/server/runtime/task-result-view.d.ts +31 -12
- package/dist/server/runtime/task-result-view.js +31 -9
- package/dist/server/task-ledger-transitions-cancellation.d.ts +2 -0
- package/dist/server/task-ledger-transitions-cancellation.js +1 -1
- package/dist/server/workflow-event-feed.d.ts +88 -0
- package/dist/server/workflow-event-feed.js +4 -1
- package/dist/storage/bun-sql.js +1 -0
- package/dist/storage/compressed-storage.js +1 -1
- package/dist/storage/index.d.ts +2 -1
- package/dist/storage/interface.d.ts +1 -0
- package/dist/storage/interface.js +1 -1
- package/dist/storage/key-prefixes.d.ts +1 -1
- package/dist/storage/key-prefixes.js +1 -0
- package/dist/storage/lmdb.js +1 -1
- package/dist/storage/memory.js +1 -1
- package/dist/storage/neon.js +2 -2
- package/dist/storage/node-sqlite-loader.js +2 -0
- package/dist/storage/node-sqlite.js +4 -0
- package/dist/storage/postgres.js +2 -2
- package/dist/storage/resolve.js +1 -1
- package/dist/storage/scoped-storage.js +1 -1
- package/dist/storage/testing.js +1 -1
- package/dist/storage/turso.js +2 -2
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/web-extension.js +1 -1
- package/dist/worker/protocol.js +1 -1
- package/package.json +5 -7
|
@@ -1,40 +1,8 @@
|
|
|
1
1
|
import { type Storage } from '../storage/interface.ts';
|
|
2
|
-
import { type Cursor, type
|
|
2
|
+
import { type Cursor, type FleetEventAppendOptions, type FleetEventEnvelope, type FleetEventFeedOptions, type FleetEventInput, type FleetWorkflowEventInput, type ReplayLiveSubscribeOptions } from './workflow-event-feed.ts';
|
|
3
|
+
export type { FleetEventAppendOptions, FleetEventEnvelope, FleetEventFeedOptions, FleetEventGapEnvelope, FleetEventInput, FleetWorkflowEventInput, } from './workflow-event-feed.ts';
|
|
3
4
|
/**
|
|
4
|
-
*
|
|
5
|
-
* lifecycle and system events, optionally scoped to one `workflowId`.
|
|
6
|
-
* Returned by `FleetEventFeed.replay()` / `FleetEventFeed.subscribe()`, and
|
|
7
|
-
* consumed directly by the `/v1/events/sse` REST route.
|
|
8
|
-
*
|
|
9
|
-
* @example
|
|
10
|
-
* ```ts
|
|
11
|
-
* import type { FleetEventEnvelope } from '@lostgradient/weft/server/handler';
|
|
12
|
-
*
|
|
13
|
-
* declare const envelope: FleetEventEnvelope;
|
|
14
|
-
* console.log(envelope.kind); // e.g. 'workflow:completed'
|
|
15
|
-
* console.log(envelope.workflowId); // string | undefined
|
|
16
|
-
* ```
|
|
17
|
-
*/
|
|
18
|
-
export type FleetEventEnvelope = {
|
|
19
|
-
readonly kind: FeedEventKind;
|
|
20
|
-
readonly workflowId?: string | undefined;
|
|
21
|
-
readonly sequence: number;
|
|
22
|
-
readonly cursor: Cursor;
|
|
23
|
-
readonly emittedAtMs: number;
|
|
24
|
-
readonly payload: unknown;
|
|
25
|
-
};
|
|
26
|
-
export type FleetEventInput = {
|
|
27
|
-
readonly kind: FeedEventKind;
|
|
28
|
-
readonly workflowId?: string | undefined;
|
|
29
|
-
readonly emittedAtMs: number;
|
|
30
|
-
readonly payload: unknown;
|
|
31
|
-
};
|
|
32
|
-
export type FleetWorkflowEventInput = FleetEventInput & {
|
|
33
|
-
readonly workflowId: string;
|
|
34
|
-
};
|
|
35
|
-
/**
|
|
36
|
-
* The fleet-wide event feed: append cross-workflow events, replay committed
|
|
37
|
-
* history from a cursor, then subscribe for live delivery. This is the shape
|
|
5
|
+
* Append cross-workflow events, replay history, then subscribe for live delivery. This is the shape
|
|
38
6
|
* of `HandlerOptions.fleetEventFeed` — build a real one with
|
|
39
7
|
* `createFleetEventFeed()` to drive `/v1/events/sse` through `handleRequest()`
|
|
40
8
|
* without `serve()`.
|
|
@@ -50,7 +18,7 @@ export type FleetWorkflowEventInput = FleetEventInput & {
|
|
|
50
18
|
* ```
|
|
51
19
|
*/
|
|
52
20
|
export type FleetEventFeed = {
|
|
53
|
-
append(event: FleetEventInput): Promise<FleetEventEnvelope>;
|
|
21
|
+
append(event: FleetEventInput, options?: FleetEventAppendOptions): Promise<FleetEventEnvelope>;
|
|
54
22
|
appendWorkflowEventIfPresent(event: FleetWorkflowEventInput): Promise<FleetEventEnvelope | null>;
|
|
55
23
|
replay(options?: {
|
|
56
24
|
fromCursor?: Cursor;
|
|
@@ -58,39 +26,21 @@ export type FleetEventFeed = {
|
|
|
58
26
|
}): AsyncIterable<FleetEventEnvelope>;
|
|
59
27
|
subscribe(options?: ReplayLiveSubscribeOptions<FleetEventEnvelope>): AsyncIterable<FleetEventEnvelope>;
|
|
60
28
|
snapshotTailSequence(): Promise<number>;
|
|
29
|
+
snapshotRetentionFloor(): Promise<number>;
|
|
30
|
+
retain(options: {
|
|
31
|
+
beforeSequence: number;
|
|
32
|
+
limit?: number;
|
|
33
|
+
}): Promise<number>;
|
|
61
34
|
dispose(): void;
|
|
62
35
|
};
|
|
63
36
|
/**
|
|
64
37
|
* Build a `FleetEventFeed` backed by the given `Storage` — typically
|
|
65
|
-
* `engine.storage
|
|
66
|
-
* drive `/v1/events/sse` through `handleRequest()` directly, without
|
|
67
|
-
* `serve()`. Call once per storage instance and share the returned feed
|
|
68
|
-
* across every transport that needs it.
|
|
69
|
-
*
|
|
70
|
-
* **This feed does not subscribe to `Engine` events on its own.** `serve()`
|
|
71
|
-
* bridges engine lifecycle events into it via `wireEventBroadcasting()`; a
|
|
72
|
-
* direct `handleRequest()` host that skips `serve()` is responsible for
|
|
73
|
-
* calling `fleetEventFeed.append()` (or `appendWorkflowEventIfPresent()`)
|
|
74
|
-
* itself for whatever events it wants `/v1/events/sse` to carry — e.g. from
|
|
75
|
-
* `engine.addEventListener(...)` handlers wired up separately.
|
|
76
|
-
*
|
|
38
|
+
* `engine.storage` and share it across every fleet transport.
|
|
77
39
|
* @example
|
|
78
40
|
* ```ts
|
|
79
|
-
* import {
|
|
80
|
-
* import {
|
|
81
|
-
*
|
|
82
|
-
* handleRequest,
|
|
83
|
-
* type HandlerOptions,
|
|
84
|
-
* } from '@lostgradient/weft/server/handler';
|
|
85
|
-
*
|
|
86
|
-
* const engine = new Engine({ storage: new MemoryStorage() });
|
|
87
|
-
* const fleetEventFeed = createFleetEventFeed(engine.storage);
|
|
88
|
-
* const options: HandlerOptions = { fleetEventFeed };
|
|
89
|
-
*
|
|
90
|
-
* async function handleFleetEventsSse(request: Request): Promise<Response> {
|
|
91
|
-
* return handleRequest(request, engine, options);
|
|
92
|
-
* }
|
|
93
|
-
* void handleFleetEventsSse;
|
|
41
|
+
* import { MemoryStorage } from '@lostgradient/weft';
|
|
42
|
+
* import { createFleetEventFeed } from '@lostgradient/weft/server/handler';
|
|
43
|
+
* const feed = createFleetEventFeed(new MemoryStorage());
|
|
94
44
|
* ```
|
|
95
45
|
*/
|
|
96
|
-
export declare function createFleetEventFeed(storage: Storage, feedOptions?:
|
|
46
|
+
export declare function createFleetEventFeed(storage: Storage, feedOptions?: FleetEventFeedOptions): FleetEventFeed;
|
|
@@ -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
|
|
17
|
+
const DEFAULT_RETENTION_BATCH_SIZE = 100, REPLAY_PAGE_SIZE = 128;
|
|
12
18
|
export function createFleetEventFeed(storage, feedOptions) {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
const
|
|
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
|
-
},
|
|
20
|
-
async function
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
const
|
|
89
|
-
if (
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
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) =>
|
|
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
|
|
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
|
-
|
|
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.
|
package/dist/server/index.d.ts
CHANGED
|
@@ -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.
|
|
413
|
-
*
|
|
414
|
-
*
|
|
415
|
-
*
|
|
416
|
-
*
|
|
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,
|
|
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
|
-
|
|
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
|
-
}),
|
|
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 {};
|