@rivetkit/workflow-engine 2.1.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +203 -0
- package/dist/schemas/v1.ts +781 -0
- package/dist/tsup/chunk-GJ66YE5W.cjs +3441 -0
- package/dist/tsup/chunk-GJ66YE5W.cjs.map +1 -0
- package/dist/tsup/chunk-JWHWQBZP.js +3441 -0
- package/dist/tsup/chunk-JWHWQBZP.js.map +1 -0
- package/dist/tsup/index.cjs +93 -0
- package/dist/tsup/index.cjs.map +1 -0
- package/dist/tsup/index.d.cts +884 -0
- package/dist/tsup/index.d.ts +884 -0
- package/dist/tsup/index.js +93 -0
- package/dist/tsup/index.js.map +1 -0
- package/dist/tsup/testing.cjs +316 -0
- package/dist/tsup/testing.cjs.map +1 -0
- package/dist/tsup/testing.d.cts +52 -0
- package/dist/tsup/testing.d.ts +52 -0
- package/dist/tsup/testing.js +316 -0
- package/dist/tsup/testing.js.map +1 -0
- package/package.json +70 -0
- package/schemas/serde.ts +609 -0
- package/schemas/v1.bare +203 -0
- package/schemas/versioned.ts +107 -0
- package/src/context.ts +1845 -0
- package/src/driver.ts +103 -0
- package/src/errors.ts +170 -0
- package/src/index.ts +907 -0
- package/src/keys.ts +277 -0
- package/src/location.ts +168 -0
- package/src/storage.ts +364 -0
- package/src/testing.ts +292 -0
- package/src/types.ts +508 -0
- package/src/utils.ts +48 -0
package/src/storage.ts
ADDED
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
import {
|
|
2
|
+
deserializeEntry,
|
|
3
|
+
deserializeEntryMetadata,
|
|
4
|
+
deserializeName,
|
|
5
|
+
deserializeWorkflowError,
|
|
6
|
+
deserializeWorkflowOutput,
|
|
7
|
+
deserializeWorkflowState,
|
|
8
|
+
serializeEntry,
|
|
9
|
+
serializeEntryMetadata,
|
|
10
|
+
serializeName,
|
|
11
|
+
serializeWorkflowError,
|
|
12
|
+
serializeWorkflowOutput,
|
|
13
|
+
serializeWorkflowState,
|
|
14
|
+
} from "../schemas/serde.js";
|
|
15
|
+
import type { EngineDriver, KVWrite } from "./driver.js";
|
|
16
|
+
import {
|
|
17
|
+
buildEntryMetadataKey,
|
|
18
|
+
buildHistoryKey,
|
|
19
|
+
buildHistoryPrefix,
|
|
20
|
+
buildHistoryPrefixAll,
|
|
21
|
+
buildNameKey,
|
|
22
|
+
buildNamePrefix,
|
|
23
|
+
buildWorkflowErrorKey,
|
|
24
|
+
buildWorkflowOutputKey,
|
|
25
|
+
buildWorkflowStateKey,
|
|
26
|
+
compareKeys,
|
|
27
|
+
parseNameKey,
|
|
28
|
+
} from "./keys.js";
|
|
29
|
+
import { isLocationPrefix, locationToKey } from "./location.js";
|
|
30
|
+
import type {
|
|
31
|
+
Entry,
|
|
32
|
+
EntryKind,
|
|
33
|
+
EntryMetadata,
|
|
34
|
+
Location,
|
|
35
|
+
Storage,
|
|
36
|
+
WorkflowEntryMetadataSnapshot,
|
|
37
|
+
WorkflowHistoryEntry,
|
|
38
|
+
WorkflowHistorySnapshot,
|
|
39
|
+
} from "./types.js";
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Create an empty storage instance.
|
|
43
|
+
*/
|
|
44
|
+
export function createStorage(): Storage {
|
|
45
|
+
return {
|
|
46
|
+
nameRegistry: [],
|
|
47
|
+
flushedNameCount: 0,
|
|
48
|
+
history: { entries: new Map() },
|
|
49
|
+
entryMetadata: new Map(),
|
|
50
|
+
output: undefined,
|
|
51
|
+
state: "pending",
|
|
52
|
+
flushedState: undefined,
|
|
53
|
+
error: undefined,
|
|
54
|
+
flushedError: undefined,
|
|
55
|
+
flushedOutput: undefined,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Create a snapshot of workflow history for observers.
|
|
61
|
+
*/
|
|
62
|
+
export function createHistorySnapshot(
|
|
63
|
+
storage: Storage,
|
|
64
|
+
): WorkflowHistorySnapshot {
|
|
65
|
+
const entryMetadata = new Map<string, WorkflowEntryMetadataSnapshot>();
|
|
66
|
+
for (const [id, metadata] of storage.entryMetadata) {
|
|
67
|
+
const { dirty, ...rest } = metadata;
|
|
68
|
+
entryMetadata.set(id, rest);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const entries: WorkflowHistoryEntry[] = [];
|
|
72
|
+
const entryKeys = Array.from(storage.history.entries.keys()).sort();
|
|
73
|
+
for (const key of entryKeys) {
|
|
74
|
+
const entry = storage.history.entries.get(key);
|
|
75
|
+
if (!entry) continue;
|
|
76
|
+
const { dirty, ...rest } = entry;
|
|
77
|
+
entries.push(rest);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
nameRegistry: [...storage.nameRegistry],
|
|
82
|
+
entries,
|
|
83
|
+
entryMetadata,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Generate a UUID v4.
|
|
89
|
+
*/
|
|
90
|
+
export function generateId(): string {
|
|
91
|
+
return crypto.randomUUID();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Create a new entry.
|
|
96
|
+
*/
|
|
97
|
+
export function createEntry(location: Location, kind: EntryKind): Entry {
|
|
98
|
+
return {
|
|
99
|
+
id: generateId(),
|
|
100
|
+
location,
|
|
101
|
+
kind,
|
|
102
|
+
dirty: true,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Create or get metadata for an entry.
|
|
108
|
+
*/
|
|
109
|
+
export function getOrCreateMetadata(
|
|
110
|
+
storage: Storage,
|
|
111
|
+
entryId: string,
|
|
112
|
+
): EntryMetadata {
|
|
113
|
+
let metadata = storage.entryMetadata.get(entryId);
|
|
114
|
+
if (!metadata) {
|
|
115
|
+
metadata = {
|
|
116
|
+
status: "pending",
|
|
117
|
+
attempts: 0,
|
|
118
|
+
lastAttemptAt: 0,
|
|
119
|
+
createdAt: Date.now(),
|
|
120
|
+
rollbackCompletedAt: undefined,
|
|
121
|
+
rollbackError: undefined,
|
|
122
|
+
dirty: true,
|
|
123
|
+
};
|
|
124
|
+
storage.entryMetadata.set(entryId, metadata);
|
|
125
|
+
}
|
|
126
|
+
return metadata;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Load storage from the driver.
|
|
131
|
+
*/
|
|
132
|
+
export async function loadStorage(
|
|
133
|
+
driver: EngineDriver,
|
|
134
|
+
): Promise<Storage> {
|
|
135
|
+
const storage = createStorage();
|
|
136
|
+
|
|
137
|
+
// Load name registry
|
|
138
|
+
const nameEntries = await driver.list(buildNamePrefix());
|
|
139
|
+
// Sort by index to ensure correct order
|
|
140
|
+
nameEntries.sort((a, b) => compareKeys(a.key, b.key));
|
|
141
|
+
for (const entry of nameEntries) {
|
|
142
|
+
const index = parseNameKey(entry.key);
|
|
143
|
+
storage.nameRegistry[index] = deserializeName(entry.value);
|
|
144
|
+
}
|
|
145
|
+
// Track how many names are already persisted
|
|
146
|
+
storage.flushedNameCount = storage.nameRegistry.length;
|
|
147
|
+
|
|
148
|
+
// Load history entries
|
|
149
|
+
const historyEntries = await driver.list(buildHistoryPrefixAll());
|
|
150
|
+
for (const entry of historyEntries) {
|
|
151
|
+
const parsed = deserializeEntry(entry.value);
|
|
152
|
+
parsed.dirty = false;
|
|
153
|
+
// Use locationToKey to match how context.ts looks up entries
|
|
154
|
+
const key = locationToKey(storage, parsed.location);
|
|
155
|
+
storage.history.entries.set(key, parsed);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Load workflow state
|
|
159
|
+
const stateValue = await driver.get(buildWorkflowStateKey());
|
|
160
|
+
if (stateValue) {
|
|
161
|
+
storage.state = deserializeWorkflowState(stateValue);
|
|
162
|
+
storage.flushedState = storage.state;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Load output if present
|
|
166
|
+
const outputValue = await driver.get(buildWorkflowOutputKey());
|
|
167
|
+
if (outputValue) {
|
|
168
|
+
storage.output = deserializeWorkflowOutput(outputValue);
|
|
169
|
+
storage.flushedOutput = storage.output;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Load error if present
|
|
173
|
+
const errorValue = await driver.get(buildWorkflowErrorKey());
|
|
174
|
+
if (errorValue) {
|
|
175
|
+
storage.error = deserializeWorkflowError(errorValue);
|
|
176
|
+
storage.flushedError = storage.error;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return storage;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Load metadata for an entry (lazy loading).
|
|
184
|
+
*/
|
|
185
|
+
export async function loadMetadata(
|
|
186
|
+
storage: Storage,
|
|
187
|
+
driver: EngineDriver,
|
|
188
|
+
entryId: string,
|
|
189
|
+
): Promise<EntryMetadata> {
|
|
190
|
+
// Check if already loaded
|
|
191
|
+
const existing = storage.entryMetadata.get(entryId);
|
|
192
|
+
if (existing) {
|
|
193
|
+
return existing;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Load from driver
|
|
197
|
+
const value = await driver.get(buildEntryMetadataKey(entryId));
|
|
198
|
+
if (value) {
|
|
199
|
+
const metadata = deserializeEntryMetadata(value);
|
|
200
|
+
metadata.dirty = false;
|
|
201
|
+
storage.entryMetadata.set(entryId, metadata);
|
|
202
|
+
return metadata;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Create new metadata
|
|
206
|
+
return getOrCreateMetadata(storage, entryId);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Flush all dirty data to the driver.
|
|
211
|
+
*/
|
|
212
|
+
export async function flush(
|
|
213
|
+
storage: Storage,
|
|
214
|
+
driver: EngineDriver,
|
|
215
|
+
onHistoryUpdated?: () => void,
|
|
216
|
+
): Promise<void> {
|
|
217
|
+
const writes: KVWrite[] = [];
|
|
218
|
+
let historyUpdated = false;
|
|
219
|
+
|
|
220
|
+
// Flush only new names (those added since last flush)
|
|
221
|
+
for (
|
|
222
|
+
let i = storage.flushedNameCount;
|
|
223
|
+
i < storage.nameRegistry.length;
|
|
224
|
+
i++
|
|
225
|
+
) {
|
|
226
|
+
const name = storage.nameRegistry[i];
|
|
227
|
+
if (name !== undefined) {
|
|
228
|
+
writes.push({
|
|
229
|
+
key: buildNameKey(i),
|
|
230
|
+
value: serializeName(name),
|
|
231
|
+
});
|
|
232
|
+
historyUpdated = true;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Flush dirty entries
|
|
237
|
+
for (const [, entry] of storage.history.entries) {
|
|
238
|
+
if (entry.dirty) {
|
|
239
|
+
writes.push({
|
|
240
|
+
key: buildHistoryKey(entry.location),
|
|
241
|
+
value: serializeEntry(entry),
|
|
242
|
+
});
|
|
243
|
+
entry.dirty = false;
|
|
244
|
+
historyUpdated = true;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Flush dirty metadata
|
|
249
|
+
for (const [id, metadata] of storage.entryMetadata) {
|
|
250
|
+
if (metadata.dirty) {
|
|
251
|
+
writes.push({
|
|
252
|
+
key: buildEntryMetadataKey(id),
|
|
253
|
+
value: serializeEntryMetadata(metadata),
|
|
254
|
+
});
|
|
255
|
+
metadata.dirty = false;
|
|
256
|
+
historyUpdated = true;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Flush workflow state if changed
|
|
261
|
+
if (storage.state !== storage.flushedState) {
|
|
262
|
+
writes.push({
|
|
263
|
+
key: buildWorkflowStateKey(),
|
|
264
|
+
value: serializeWorkflowState(storage.state),
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Flush output if changed
|
|
269
|
+
if (
|
|
270
|
+
storage.output !== undefined &&
|
|
271
|
+
storage.output !== storage.flushedOutput
|
|
272
|
+
) {
|
|
273
|
+
writes.push({
|
|
274
|
+
key: buildWorkflowOutputKey(),
|
|
275
|
+
value: serializeWorkflowOutput(storage.output),
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Flush error if changed (compare by message since objects aren't reference-equal)
|
|
280
|
+
const errorChanged =
|
|
281
|
+
storage.error !== undefined &&
|
|
282
|
+
(storage.flushedError === undefined ||
|
|
283
|
+
storage.error.name !== storage.flushedError.name ||
|
|
284
|
+
storage.error.message !== storage.flushedError.message);
|
|
285
|
+
if (errorChanged) {
|
|
286
|
+
writes.push({
|
|
287
|
+
key: buildWorkflowErrorKey(),
|
|
288
|
+
value: serializeWorkflowError(storage.error!),
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (writes.length > 0) {
|
|
293
|
+
await driver.batch(writes);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Update flushed tracking after successful write
|
|
297
|
+
storage.flushedNameCount = storage.nameRegistry.length;
|
|
298
|
+
storage.flushedState = storage.state;
|
|
299
|
+
storage.flushedOutput = storage.output;
|
|
300
|
+
storage.flushedError = storage.error;
|
|
301
|
+
|
|
302
|
+
if (historyUpdated && onHistoryUpdated) {
|
|
303
|
+
onHistoryUpdated();
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Delete entries with a given location prefix (used for loop forgetting).
|
|
309
|
+
* Also cleans up associated metadata from both memory and driver.
|
|
310
|
+
*/
|
|
311
|
+
export async function deleteEntriesWithPrefix(
|
|
312
|
+
storage: Storage,
|
|
313
|
+
driver: EngineDriver,
|
|
314
|
+
prefixLocation: Location,
|
|
315
|
+
onHistoryUpdated?: () => void,
|
|
316
|
+
): Promise<void> {
|
|
317
|
+
// Collect entry IDs for metadata cleanup
|
|
318
|
+
const entryIds: string[] = [];
|
|
319
|
+
|
|
320
|
+
// Collect entries to delete and their IDs
|
|
321
|
+
for (const [key, entry] of storage.history.entries) {
|
|
322
|
+
// Check if the entry's location starts with the prefix location
|
|
323
|
+
if (isLocationPrefix(prefixLocation, entry.location)) {
|
|
324
|
+
entryIds.push(entry.id);
|
|
325
|
+
storage.entryMetadata.delete(entry.id);
|
|
326
|
+
storage.history.entries.delete(key);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Delete entries from driver using binary prefix
|
|
331
|
+
await driver.deletePrefix(buildHistoryPrefix(prefixLocation));
|
|
332
|
+
|
|
333
|
+
// Delete metadata from driver in parallel
|
|
334
|
+
await Promise.all(
|
|
335
|
+
entryIds.map((id) => driver.delete(buildEntryMetadataKey(id))),
|
|
336
|
+
);
|
|
337
|
+
|
|
338
|
+
if (entryIds.length > 0 && onHistoryUpdated) {
|
|
339
|
+
onHistoryUpdated();
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Get an entry by location.
|
|
345
|
+
*/
|
|
346
|
+
export function getEntry(
|
|
347
|
+
storage: Storage,
|
|
348
|
+
location: Location,
|
|
349
|
+
): Entry | undefined {
|
|
350
|
+
const key = locationToKey(storage, location);
|
|
351
|
+
return storage.history.entries.get(key);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Set an entry by location.
|
|
356
|
+
*/
|
|
357
|
+
export function setEntry(
|
|
358
|
+
storage: Storage,
|
|
359
|
+
location: Location,
|
|
360
|
+
entry: Entry,
|
|
361
|
+
): void {
|
|
362
|
+
const key = locationToKey(storage, location);
|
|
363
|
+
storage.history.entries.set(key, entry);
|
|
364
|
+
}
|
package/src/testing.ts
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import type { EngineDriver, KVEntry, KVWrite } from "./driver.js";
|
|
2
|
+
import { EvictedError } from "./errors.js";
|
|
3
|
+
import { compareKeys, keyStartsWith, keyToHex } from "./keys.js";
|
|
4
|
+
import type { Message, WorkflowMessageDriver } from "./types.js";
|
|
5
|
+
import { sleep } from "./utils.js";
|
|
6
|
+
|
|
7
|
+
interface Waiter {
|
|
8
|
+
nameSet?: Set<string>;
|
|
9
|
+
resolve: () => void;
|
|
10
|
+
reject: (error: Error) => void;
|
|
11
|
+
abortSignal: AbortSignal;
|
|
12
|
+
onAbort: () => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
class InMemoryWorkflowMessageDriver implements WorkflowMessageDriver {
|
|
16
|
+
#messages: Message[] = [];
|
|
17
|
+
#waiters = new Set<Waiter>();
|
|
18
|
+
|
|
19
|
+
async addMessage(message: Message): Promise<void> {
|
|
20
|
+
this.#messages.push(message);
|
|
21
|
+
this.#notifyWaiters(message.name);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async receiveMessages(opts: {
|
|
25
|
+
names?: readonly string[];
|
|
26
|
+
count: number;
|
|
27
|
+
completable: boolean;
|
|
28
|
+
}): Promise<Message[]> {
|
|
29
|
+
const limitedCount = Math.max(1, opts.count);
|
|
30
|
+
const nameSet =
|
|
31
|
+
opts.names && opts.names.length > 0
|
|
32
|
+
? new Set(opts.names)
|
|
33
|
+
: undefined;
|
|
34
|
+
const selected: Array<{ message: Message; index: number }> = [];
|
|
35
|
+
|
|
36
|
+
for (let i = 0; i < this.#messages.length && selected.length < limitedCount; i++) {
|
|
37
|
+
const message = this.#messages[i];
|
|
38
|
+
if (nameSet && !nameSet.has(message.name)) {
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
selected.push({ message, index: i });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (selected.length === 0) {
|
|
45
|
+
return [];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (!opts.completable) {
|
|
49
|
+
for (let i = selected.length - 1; i >= 0; i--) {
|
|
50
|
+
this.#messages.splice(selected[i].index, 1);
|
|
51
|
+
}
|
|
52
|
+
return selected.map((entry) => entry.message);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return selected.map((entry) => {
|
|
56
|
+
const { message } = entry;
|
|
57
|
+
return {
|
|
58
|
+
...message,
|
|
59
|
+
complete: async () => {
|
|
60
|
+
await this.completeMessage(message.id);
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async completeMessage(messageId: string): Promise<void> {
|
|
67
|
+
const index = this.#messages.findIndex((message) => message.id === messageId);
|
|
68
|
+
if (index !== -1) {
|
|
69
|
+
this.#messages.splice(index, 1);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async waitForMessages(
|
|
74
|
+
messageNames: string[],
|
|
75
|
+
abortSignal: AbortSignal,
|
|
76
|
+
): Promise<void> {
|
|
77
|
+
if (abortSignal.aborted) {
|
|
78
|
+
throw new EvictedError();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const nameSet = messageNames.length > 0 ? new Set(messageNames) : undefined;
|
|
82
|
+
if (
|
|
83
|
+
this.#messages.some((message) =>
|
|
84
|
+
nameSet ? nameSet.has(message.name) : true,
|
|
85
|
+
)
|
|
86
|
+
) {
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
await new Promise<void>((resolve, reject) => {
|
|
91
|
+
const waiter: Waiter = {
|
|
92
|
+
nameSet,
|
|
93
|
+
resolve: () => {
|
|
94
|
+
this.#removeWaiter(waiter);
|
|
95
|
+
resolve();
|
|
96
|
+
},
|
|
97
|
+
reject: (error) => {
|
|
98
|
+
this.#removeWaiter(waiter);
|
|
99
|
+
reject(error);
|
|
100
|
+
},
|
|
101
|
+
abortSignal,
|
|
102
|
+
onAbort: () => {
|
|
103
|
+
waiter.reject(new EvictedError());
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
abortSignal.addEventListener("abort", waiter.onAbort, { once: true });
|
|
107
|
+
this.#waiters.add(waiter);
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
#removeWaiter(waiter: Waiter): void {
|
|
112
|
+
if (this.#waiters.delete(waiter)) {
|
|
113
|
+
waiter.abortSignal.removeEventListener("abort", waiter.onAbort);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
#notifyWaiters(name: string): void {
|
|
118
|
+
for (const waiter of [...this.#waiters]) {
|
|
119
|
+
if (waiter.nameSet && !waiter.nameSet.has(name)) {
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
waiter.resolve();
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
clear(): void {
|
|
127
|
+
this.#messages = [];
|
|
128
|
+
for (const waiter of [...this.#waiters]) {
|
|
129
|
+
waiter.reject(new Error("cleared"));
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* In-memory implementation of EngineDriver for testing.
|
|
136
|
+
* Uses binary keys (Uint8Array) with hex encoding for internal Map storage.
|
|
137
|
+
*/
|
|
138
|
+
export class InMemoryDriver implements EngineDriver {
|
|
139
|
+
// Map from hex-encoded key to { originalKey, value }
|
|
140
|
+
private kv = new Map<string, { key: Uint8Array; value: Uint8Array }>();
|
|
141
|
+
private alarms = new Map<string, number>();
|
|
142
|
+
#inMemoryMessageDriver = new InMemoryWorkflowMessageDriver();
|
|
143
|
+
|
|
144
|
+
/** Simulated latency per operation (ms) */
|
|
145
|
+
latency = 10;
|
|
146
|
+
|
|
147
|
+
/** How often the worker polls for work */
|
|
148
|
+
workerPollInterval = 100;
|
|
149
|
+
messageDriver: WorkflowMessageDriver = this.#inMemoryMessageDriver;
|
|
150
|
+
|
|
151
|
+
async get(key: Uint8Array): Promise<Uint8Array | null> {
|
|
152
|
+
await sleep(this.latency);
|
|
153
|
+
const entry = this.kv.get(keyToHex(key));
|
|
154
|
+
return entry?.value ?? null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async set(key: Uint8Array, value: Uint8Array): Promise<void> {
|
|
158
|
+
await sleep(this.latency);
|
|
159
|
+
this.kv.set(keyToHex(key), { key, value });
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async delete(key: Uint8Array): Promise<void> {
|
|
163
|
+
await sleep(this.latency);
|
|
164
|
+
this.kv.delete(keyToHex(key));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async deletePrefix(prefix: Uint8Array): Promise<void> {
|
|
168
|
+
await sleep(this.latency);
|
|
169
|
+
for (const [hexKey, entry] of this.kv) {
|
|
170
|
+
if (keyStartsWith(entry.key, prefix)) {
|
|
171
|
+
this.kv.delete(hexKey);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async list(prefix: Uint8Array): Promise<KVEntry[]> {
|
|
177
|
+
await sleep(this.latency);
|
|
178
|
+
const results: KVEntry[] = [];
|
|
179
|
+
for (const entry of this.kv.values()) {
|
|
180
|
+
if (keyStartsWith(entry.key, prefix)) {
|
|
181
|
+
results.push({ key: entry.key, value: entry.value });
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
// Sort by key lexicographically
|
|
185
|
+
return results.sort((a, b) => compareKeys(a.key, b.key));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async batch(writes: KVWrite[]): Promise<void> {
|
|
189
|
+
await sleep(this.latency);
|
|
190
|
+
for (const { key, value } of writes) {
|
|
191
|
+
this.kv.set(keyToHex(key), { key, value });
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async setAlarm(workflowId: string, wakeAt: number): Promise<void> {
|
|
196
|
+
await sleep(this.latency);
|
|
197
|
+
this.alarms.set(workflowId, wakeAt);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async clearAlarm(workflowId: string): Promise<void> {
|
|
201
|
+
await sleep(this.latency);
|
|
202
|
+
this.alarms.delete(workflowId);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async waitForMessages(
|
|
206
|
+
messageNames: string[],
|
|
207
|
+
abortSignal: AbortSignal,
|
|
208
|
+
): Promise<void> {
|
|
209
|
+
const driver = this.messageDriver as WorkflowMessageDriver & {
|
|
210
|
+
waitForMessages?: (
|
|
211
|
+
messageNames: string[],
|
|
212
|
+
abortSignal: AbortSignal,
|
|
213
|
+
) => Promise<void>;
|
|
214
|
+
};
|
|
215
|
+
if (driver.waitForMessages) {
|
|
216
|
+
await driver.waitForMessages(messageNames, abortSignal);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
while (true) {
|
|
221
|
+
if (abortSignal.aborted) {
|
|
222
|
+
throw new EvictedError();
|
|
223
|
+
}
|
|
224
|
+
const messages = await this.messageDriver.receiveMessages({
|
|
225
|
+
names: messageNames.length > 0 ? messageNames : undefined,
|
|
226
|
+
count: 1,
|
|
227
|
+
completable: true,
|
|
228
|
+
});
|
|
229
|
+
if (messages.length > 0) {
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
await sleep(Math.max(1, this.latency));
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Get the alarm time for a workflow (for testing).
|
|
238
|
+
*/
|
|
239
|
+
getAlarm(workflowId: string): number | undefined {
|
|
240
|
+
return this.alarms.get(workflowId);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Check if any alarms are due and return their workflow IDs.
|
|
245
|
+
*/
|
|
246
|
+
getDueAlarms(): string[] {
|
|
247
|
+
const now = Date.now();
|
|
248
|
+
const due: string[] = [];
|
|
249
|
+
for (const [workflowId, wakeAt] of this.alarms) {
|
|
250
|
+
if (wakeAt <= now) {
|
|
251
|
+
due.push(workflowId);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return due;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Clear all data (for testing).
|
|
259
|
+
*/
|
|
260
|
+
clear(): void {
|
|
261
|
+
this.kv.clear();
|
|
262
|
+
this.alarms.clear();
|
|
263
|
+
this.#inMemoryMessageDriver.clear();
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Get a snapshot of all data (for testing/debugging).
|
|
268
|
+
*/
|
|
269
|
+
snapshot(): {
|
|
270
|
+
kv: Record<string, Uint8Array>;
|
|
271
|
+
alarms: Record<string, number>;
|
|
272
|
+
} {
|
|
273
|
+
const kvSnapshot: Record<string, Uint8Array> = {};
|
|
274
|
+
for (const [hexKey, entry] of this.kv) {
|
|
275
|
+
kvSnapshot[hexKey] = entry.value;
|
|
276
|
+
}
|
|
277
|
+
return {
|
|
278
|
+
kv: kvSnapshot,
|
|
279
|
+
alarms: Object.fromEntries(this.alarms),
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Get all hex-encoded keys (for testing).
|
|
285
|
+
*/
|
|
286
|
+
keys(): string[] {
|
|
287
|
+
return [...this.kv.keys()];
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Re-export main exports for convenience
|
|
292
|
+
export * from "./index.js";
|