@rivetkit/workflow-engine 0.0.0-0-0-0-preview-guard-stops.9d82529
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/dist/schemas/v1.ts +806 -0
- package/dist/tsup/chunk-DTS4S7RT.js +4324 -0
- package/dist/tsup/chunk-DTS4S7RT.js.map +1 -0
- package/dist/tsup/chunk-KLHCG2KY.cjs +4324 -0
- package/dist/tsup/chunk-KLHCG2KY.cjs.map +1 -0
- package/dist/tsup/index.cjs +93 -0
- package/dist/tsup/index.cjs.map +1 -0
- package/dist/tsup/index.d.cts +1047 -0
- package/dist/tsup/index.d.ts +1047 -0
- package/dist/tsup/index.js +93 -0
- package/dist/tsup/index.js.map +1 -0
- package/dist/tsup/testing.cjs +328 -0
- package/dist/tsup/testing.cjs.map +1 -0
- package/dist/tsup/testing.d.cts +53 -0
- package/dist/tsup/testing.d.ts +53 -0
- package/dist/tsup/testing.js +328 -0
- package/dist/tsup/testing.js.map +1 -0
- package/package.json +75 -0
- package/schemas/serde.ts +623 -0
- package/schemas/v1.bare +212 -0
- package/schemas/versioned.ts +108 -0
- package/src/context.ts +2677 -0
- package/src/driver.ts +114 -0
- package/src/error-utils.ts +87 -0
- package/src/errors.ts +171 -0
- package/src/index.ts +1095 -0
- package/src/keys.ts +303 -0
- package/src/location.ts +171 -0
- package/src/storage.ts +468 -0
- package/src/testing.ts +313 -0
- package/src/types.ts +642 -0
- package/src/utils.ts +48 -0
package/src/storage.ts
ADDED
|
@@ -0,0 +1,468 @@
|
|
|
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
|
+
buildEntryMetadataPrefix,
|
|
19
|
+
buildHistoryKey,
|
|
20
|
+
buildHistoryPrefix,
|
|
21
|
+
buildHistoryPrefixAll,
|
|
22
|
+
buildNameKey,
|
|
23
|
+
buildNamePrefix,
|
|
24
|
+
buildWorkflowErrorKey,
|
|
25
|
+
buildWorkflowOutputKey,
|
|
26
|
+
buildWorkflowStateKey,
|
|
27
|
+
compareKeys,
|
|
28
|
+
parseEntryMetadataKey,
|
|
29
|
+
parseNameKey,
|
|
30
|
+
} from "./keys.js";
|
|
31
|
+
import { isLocationPrefix, locationToKey } from "./location.js";
|
|
32
|
+
import type {
|
|
33
|
+
Entry,
|
|
34
|
+
EntryKind,
|
|
35
|
+
EntryMetadata,
|
|
36
|
+
Location,
|
|
37
|
+
Storage,
|
|
38
|
+
WorkflowEntryMetadataSnapshot,
|
|
39
|
+
WorkflowHistoryEntry,
|
|
40
|
+
WorkflowHistorySnapshot,
|
|
41
|
+
} from "./types.js";
|
|
42
|
+
|
|
43
|
+
export const MAX_KV_BATCH_ENTRIES = 128;
|
|
44
|
+
export const MAX_KV_BATCH_PAYLOAD_BYTES = 976 * 1024;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Create an empty storage instance.
|
|
48
|
+
*/
|
|
49
|
+
export function createStorage(): Storage {
|
|
50
|
+
return {
|
|
51
|
+
nameRegistry: [],
|
|
52
|
+
flushedNameCount: 0,
|
|
53
|
+
history: { entries: new Map() },
|
|
54
|
+
entryMetadata: new Map(),
|
|
55
|
+
output: undefined,
|
|
56
|
+
state: "pending",
|
|
57
|
+
flushedState: undefined,
|
|
58
|
+
error: undefined,
|
|
59
|
+
flushedError: undefined,
|
|
60
|
+
flushedOutput: undefined,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Create a snapshot of workflow history for observers.
|
|
66
|
+
*/
|
|
67
|
+
export function createHistorySnapshot(
|
|
68
|
+
storage: Storage,
|
|
69
|
+
): WorkflowHistorySnapshot {
|
|
70
|
+
const entryMetadata = new Map<string, WorkflowEntryMetadataSnapshot>();
|
|
71
|
+
for (const [id, metadata] of storage.entryMetadata) {
|
|
72
|
+
const { dirty, ...rest } = metadata;
|
|
73
|
+
entryMetadata.set(id, rest);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const entries: WorkflowHistoryEntry[] = [];
|
|
77
|
+
const entryKeys = Array.from(storage.history.entries.keys()).sort();
|
|
78
|
+
for (const key of entryKeys) {
|
|
79
|
+
const entry = storage.history.entries.get(key);
|
|
80
|
+
if (!entry) continue;
|
|
81
|
+
const { dirty, ...rest } = entry;
|
|
82
|
+
entries.push(rest);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
nameRegistry: [...storage.nameRegistry],
|
|
87
|
+
entries,
|
|
88
|
+
entryMetadata,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Generate a UUID v4.
|
|
94
|
+
*/
|
|
95
|
+
export function generateId(): string {
|
|
96
|
+
return crypto.randomUUID();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Create a new entry.
|
|
101
|
+
*/
|
|
102
|
+
export function createEntry(location: Location, kind: EntryKind): Entry {
|
|
103
|
+
return {
|
|
104
|
+
id: generateId(),
|
|
105
|
+
location,
|
|
106
|
+
kind,
|
|
107
|
+
dirty: true,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Create or get metadata for an entry.
|
|
113
|
+
*/
|
|
114
|
+
export function getOrCreateMetadata(
|
|
115
|
+
storage: Storage,
|
|
116
|
+
entryId: string,
|
|
117
|
+
): EntryMetadata {
|
|
118
|
+
let metadata = storage.entryMetadata.get(entryId);
|
|
119
|
+
if (!metadata) {
|
|
120
|
+
metadata = {
|
|
121
|
+
status: "pending",
|
|
122
|
+
attempts: 0,
|
|
123
|
+
lastAttemptAt: 0,
|
|
124
|
+
createdAt: Date.now(),
|
|
125
|
+
rollbackCompletedAt: undefined,
|
|
126
|
+
rollbackError: undefined,
|
|
127
|
+
dirty: true,
|
|
128
|
+
};
|
|
129
|
+
storage.entryMetadata.set(entryId, metadata);
|
|
130
|
+
}
|
|
131
|
+
return metadata;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Load storage from the driver.
|
|
136
|
+
*/
|
|
137
|
+
export async function loadStorage(driver: EngineDriver): Promise<Storage> {
|
|
138
|
+
const storage = createStorage();
|
|
139
|
+
|
|
140
|
+
// Load name registry
|
|
141
|
+
const nameEntries = await driver.list(buildNamePrefix());
|
|
142
|
+
// Sort by index to ensure correct order
|
|
143
|
+
nameEntries.sort((a, b) => compareKeys(a.key, b.key));
|
|
144
|
+
for (const entry of nameEntries) {
|
|
145
|
+
const index = parseNameKey(entry.key);
|
|
146
|
+
storage.nameRegistry[index] = deserializeName(entry.value);
|
|
147
|
+
}
|
|
148
|
+
// Track how many names are already persisted
|
|
149
|
+
storage.flushedNameCount = storage.nameRegistry.length;
|
|
150
|
+
|
|
151
|
+
// Load history entries
|
|
152
|
+
const historyEntries = await driver.list(buildHistoryPrefixAll());
|
|
153
|
+
for (const entry of historyEntries) {
|
|
154
|
+
const parsed = deserializeEntry(entry.value);
|
|
155
|
+
parsed.dirty = false;
|
|
156
|
+
// Use locationToKey to match how context.ts looks up entries
|
|
157
|
+
const key = locationToKey(storage, parsed.location);
|
|
158
|
+
storage.history.entries.set(key, parsed);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Load entry metadata so observers can reconstruct workflow state after
|
|
162
|
+
// the actor wakes and rebuilds storage from persisted history.
|
|
163
|
+
const metadataEntries = await driver.list(buildEntryMetadataPrefix());
|
|
164
|
+
for (const entry of metadataEntries) {
|
|
165
|
+
const entryId = parseEntryMetadataKey(entry.key);
|
|
166
|
+
const metadata = deserializeEntryMetadata(entry.value);
|
|
167
|
+
metadata.dirty = false;
|
|
168
|
+
storage.entryMetadata.set(entryId, metadata);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Load workflow state
|
|
172
|
+
const stateValue = await driver.get(buildWorkflowStateKey());
|
|
173
|
+
if (stateValue) {
|
|
174
|
+
storage.state = deserializeWorkflowState(stateValue);
|
|
175
|
+
storage.flushedState = storage.state;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Load output if present
|
|
179
|
+
const outputValue = await driver.get(buildWorkflowOutputKey());
|
|
180
|
+
if (outputValue) {
|
|
181
|
+
storage.output = deserializeWorkflowOutput(outputValue);
|
|
182
|
+
storage.flushedOutput = storage.output;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Load error if present
|
|
186
|
+
const errorValue = await driver.get(buildWorkflowErrorKey());
|
|
187
|
+
if (errorValue) {
|
|
188
|
+
storage.error = deserializeWorkflowError(errorValue);
|
|
189
|
+
storage.flushedError = storage.error;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return storage;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Load metadata for an entry (lazy loading).
|
|
197
|
+
*/
|
|
198
|
+
export async function loadMetadata(
|
|
199
|
+
storage: Storage,
|
|
200
|
+
driver: EngineDriver,
|
|
201
|
+
entryId: string,
|
|
202
|
+
): Promise<EntryMetadata> {
|
|
203
|
+
// Check if already loaded
|
|
204
|
+
const existing = storage.entryMetadata.get(entryId);
|
|
205
|
+
if (existing) {
|
|
206
|
+
return existing;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Load from driver
|
|
210
|
+
const value = await driver.get(buildEntryMetadataKey(entryId));
|
|
211
|
+
if (value) {
|
|
212
|
+
const metadata = deserializeEntryMetadata(value);
|
|
213
|
+
metadata.dirty = false;
|
|
214
|
+
storage.entryMetadata.set(entryId, metadata);
|
|
215
|
+
return metadata;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Create new metadata
|
|
219
|
+
return getOrCreateMetadata(storage, entryId);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Pending deletions collected by collectLoopPruning to be included
|
|
224
|
+
* in the next flush alongside the state write.
|
|
225
|
+
*/
|
|
226
|
+
export interface PendingDeletions {
|
|
227
|
+
prefixes: Uint8Array[];
|
|
228
|
+
keys: Uint8Array[];
|
|
229
|
+
ranges: { start: Uint8Array; end: Uint8Array }[];
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Flush all dirty data to the driver. Optionally includes pending
|
|
234
|
+
* deletions so that history pruning happens alongside the
|
|
235
|
+
* state write.
|
|
236
|
+
*/
|
|
237
|
+
export async function flush(
|
|
238
|
+
storage: Storage,
|
|
239
|
+
driver: EngineDriver,
|
|
240
|
+
onHistoryUpdated?: () => void,
|
|
241
|
+
pendingDeletions?: PendingDeletions,
|
|
242
|
+
): Promise<void> {
|
|
243
|
+
const writes: KVWrite[] = [];
|
|
244
|
+
const dirtyEntries: Entry[] = [];
|
|
245
|
+
const dirtyMetadata: EntryMetadata[] = [];
|
|
246
|
+
let historyUpdated = false;
|
|
247
|
+
|
|
248
|
+
// Flush only new names (those added since last flush)
|
|
249
|
+
for (
|
|
250
|
+
let i = storage.flushedNameCount;
|
|
251
|
+
i < storage.nameRegistry.length;
|
|
252
|
+
i++
|
|
253
|
+
) {
|
|
254
|
+
const name = storage.nameRegistry[i];
|
|
255
|
+
if (name !== undefined) {
|
|
256
|
+
writes.push({
|
|
257
|
+
key: buildNameKey(i),
|
|
258
|
+
value: serializeName(name),
|
|
259
|
+
});
|
|
260
|
+
historyUpdated = true;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Flush dirty entries
|
|
265
|
+
for (const [, entry] of storage.history.entries) {
|
|
266
|
+
if (entry.dirty) {
|
|
267
|
+
writes.push({
|
|
268
|
+
key: buildHistoryKey(entry.location),
|
|
269
|
+
value: serializeEntry(entry),
|
|
270
|
+
});
|
|
271
|
+
dirtyEntries.push(entry);
|
|
272
|
+
historyUpdated = true;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Flush dirty metadata
|
|
277
|
+
for (const [id, metadata] of storage.entryMetadata) {
|
|
278
|
+
if (metadata.dirty) {
|
|
279
|
+
writes.push({
|
|
280
|
+
key: buildEntryMetadataKey(id),
|
|
281
|
+
value: serializeEntryMetadata(metadata),
|
|
282
|
+
});
|
|
283
|
+
dirtyMetadata.push(metadata);
|
|
284
|
+
historyUpdated = true;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Flush workflow state if changed
|
|
289
|
+
if (storage.state !== storage.flushedState) {
|
|
290
|
+
writes.push({
|
|
291
|
+
key: buildWorkflowStateKey(),
|
|
292
|
+
value: serializeWorkflowState(storage.state),
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Flush output if changed
|
|
297
|
+
if (
|
|
298
|
+
storage.output !== undefined &&
|
|
299
|
+
storage.output !== storage.flushedOutput
|
|
300
|
+
) {
|
|
301
|
+
writes.push({
|
|
302
|
+
key: buildWorkflowOutputKey(),
|
|
303
|
+
value: serializeWorkflowOutput(storage.output),
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Flush error if changed (compare by message since objects aren't reference-equal)
|
|
308
|
+
const errorChanged =
|
|
309
|
+
storage.error !== undefined &&
|
|
310
|
+
(storage.flushedError === undefined ||
|
|
311
|
+
storage.error.name !== storage.flushedError.name ||
|
|
312
|
+
storage.error.message !== storage.flushedError.message);
|
|
313
|
+
if (errorChanged) {
|
|
314
|
+
writes.push({
|
|
315
|
+
key: buildWorkflowErrorKey(),
|
|
316
|
+
value: serializeWorkflowError(storage.error!),
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (writes.length > 0) {
|
|
321
|
+
if (driver.atomicBatch) {
|
|
322
|
+
await driver.batch(writes);
|
|
323
|
+
} else {
|
|
324
|
+
for (const chunk of splitBatchWrites(writes)) {
|
|
325
|
+
await driver.batch(chunk);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Apply pending deletions after the batch write. These are collected
|
|
331
|
+
// by collectLoopPruning so pruning happens alongside the state write.
|
|
332
|
+
if (pendingDeletions) {
|
|
333
|
+
const deleteOps: Promise<void>[] = [];
|
|
334
|
+
for (const prefix of pendingDeletions.prefixes) {
|
|
335
|
+
deleteOps.push(driver.deletePrefix(prefix));
|
|
336
|
+
}
|
|
337
|
+
for (const range of pendingDeletions.ranges) {
|
|
338
|
+
deleteOps.push(driver.deleteRange(range.start, range.end));
|
|
339
|
+
}
|
|
340
|
+
for (const key of pendingDeletions.keys) {
|
|
341
|
+
deleteOps.push(driver.delete(key));
|
|
342
|
+
}
|
|
343
|
+
if (deleteOps.length > 0) {
|
|
344
|
+
await Promise.all(deleteOps);
|
|
345
|
+
historyUpdated = true;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Update flushed tracking after successful write
|
|
350
|
+
for (const entry of dirtyEntries) {
|
|
351
|
+
entry.dirty = false;
|
|
352
|
+
}
|
|
353
|
+
for (const metadata of dirtyMetadata) {
|
|
354
|
+
metadata.dirty = false;
|
|
355
|
+
}
|
|
356
|
+
storage.flushedNameCount = storage.nameRegistry.length;
|
|
357
|
+
storage.flushedState = storage.state;
|
|
358
|
+
storage.flushedOutput = storage.output;
|
|
359
|
+
storage.flushedError = storage.error;
|
|
360
|
+
|
|
361
|
+
if (historyUpdated && onHistoryUpdated) {
|
|
362
|
+
onHistoryUpdated();
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function splitBatchWrites(writes: KVWrite[]): KVWrite[][] {
|
|
367
|
+
const chunks: KVWrite[][] = [];
|
|
368
|
+
let chunk: KVWrite[] = [];
|
|
369
|
+
let chunkBytes = 0;
|
|
370
|
+
|
|
371
|
+
for (const write of writes) {
|
|
372
|
+
const writeBytes = write.key.byteLength + write.value.byteLength;
|
|
373
|
+
if (writeBytes > MAX_KV_BATCH_PAYLOAD_BYTES) {
|
|
374
|
+
throw new Error(
|
|
375
|
+
`KV batch write is ${writeBytes} bytes, exceeding the ${MAX_KV_BATCH_PAYLOAD_BYTES} byte limit`,
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
if (
|
|
380
|
+
chunk.length >= MAX_KV_BATCH_ENTRIES ||
|
|
381
|
+
(chunk.length > 0 &&
|
|
382
|
+
chunkBytes + writeBytes > MAX_KV_BATCH_PAYLOAD_BYTES)
|
|
383
|
+
) {
|
|
384
|
+
chunks.push(chunk);
|
|
385
|
+
chunk = [];
|
|
386
|
+
chunkBytes = 0;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
chunk.push(write);
|
|
390
|
+
chunkBytes += writeBytes;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
if (chunk.length > 0) {
|
|
394
|
+
chunks.push(chunk);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
return chunks;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Delete entries with a given location prefix (used for loop forgetting).
|
|
402
|
+
* Also cleans up associated metadata from both memory and driver.
|
|
403
|
+
*/
|
|
404
|
+
export async function deleteEntriesWithPrefix(
|
|
405
|
+
storage: Storage,
|
|
406
|
+
driver: EngineDriver,
|
|
407
|
+
prefixLocation: Location,
|
|
408
|
+
onHistoryUpdated?: () => void,
|
|
409
|
+
): Promise<void> {
|
|
410
|
+
const deletions = collectDeletionsForPrefix(storage, prefixLocation);
|
|
411
|
+
|
|
412
|
+
// Apply deletions to driver
|
|
413
|
+
await driver.deletePrefix(deletions.prefixes[0]!);
|
|
414
|
+
await Promise.all(deletions.keys.map((key) => driver.delete(key)));
|
|
415
|
+
|
|
416
|
+
if (deletions.keys.length > 0 && onHistoryUpdated) {
|
|
417
|
+
onHistoryUpdated();
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Remove entries matching a location prefix from memory and collect
|
|
423
|
+
* the driver-level deletion operations. The returned PendingDeletions
|
|
424
|
+
* can be applied immediately or batched with a flush.
|
|
425
|
+
*/
|
|
426
|
+
export function collectDeletionsForPrefix(
|
|
427
|
+
storage: Storage,
|
|
428
|
+
prefixLocation: Location,
|
|
429
|
+
): PendingDeletions {
|
|
430
|
+
const pending: PendingDeletions = {
|
|
431
|
+
prefixes: [buildHistoryPrefix(prefixLocation)],
|
|
432
|
+
keys: [],
|
|
433
|
+
ranges: [],
|
|
434
|
+
};
|
|
435
|
+
|
|
436
|
+
for (const [key, entry] of storage.history.entries) {
|
|
437
|
+
if (isLocationPrefix(prefixLocation, entry.location)) {
|
|
438
|
+
pending.keys.push(buildEntryMetadataKey(entry.id));
|
|
439
|
+
storage.entryMetadata.delete(entry.id);
|
|
440
|
+
storage.history.entries.delete(key);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
return pending;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Get an entry by location.
|
|
449
|
+
*/
|
|
450
|
+
export function getEntry(
|
|
451
|
+
storage: Storage,
|
|
452
|
+
location: Location,
|
|
453
|
+
): Entry | undefined {
|
|
454
|
+
const key = locationToKey(storage, location);
|
|
455
|
+
return storage.history.entries.get(key);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Set an entry by location.
|
|
460
|
+
*/
|
|
461
|
+
export function setEntry(
|
|
462
|
+
storage: Storage,
|
|
463
|
+
location: Location,
|
|
464
|
+
entry: Entry,
|
|
465
|
+
): void {
|
|
466
|
+
const key = locationToKey(storage, location);
|
|
467
|
+
storage.history.entries.set(key, entry);
|
|
468
|
+
}
|