statewire 0.23.0 → 0.24.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/dist/client/core.d.ts +4 -3
- package/dist/client/core.d.ts.map +1 -1
- package/dist/client/core.js +56 -45
- package/dist/client/core.js.map +1 -1
- package/dist/client/session.d.ts +56 -12
- package/dist/client/session.d.ts.map +1 -1
- package/dist/client/session.js +632 -260
- package/dist/client/session.js.map +1 -1
- package/dist/client/sessionStore.d.ts +1 -0
- package/dist/client/sessionStore.d.ts.map +1 -1
- package/dist/client/sessionStore.js +1 -0
- package/dist/client/sessionStore.js.map +1 -1
- package/dist/host/host.d.ts +6 -5
- package/dist/host/host.d.ts.map +1 -1
- package/dist/host/host.js.map +1 -1
- package/dist/indexedDb.d.ts +15 -0
- package/dist/indexedDb.d.ts.map +1 -0
- package/dist/indexedDb.js +171 -0
- package/dist/indexedDb.js.map +1 -0
- package/dist/statewire.d.ts +117 -59
- package/dist/statewire.d.ts.map +1 -1
- package/dist/statewire.js +13 -25
- package/dist/statewire.js.map +1 -1
- package/dist/storage.d.ts +42 -0
- package/dist/storage.d.ts.map +1 -1
- package/dist/storage.js +387 -125
- package/dist/storage.js.map +1 -1
- package/dist/transports/remote.d.ts.map +1 -1
- package/dist/transports/remote.js +7 -9
- package/dist/transports/remote.js.map +1 -1
- package/dist/types.d.ts +6 -7
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +2 -1
- package/src/client/core.ts +81 -64
- package/src/client/session.ts +886 -424
- package/src/client/sessionStore.ts +2 -0
- package/src/host/host.ts +10 -3
- package/src/indexedDb.ts +167 -0
- package/src/statewire.ts +133 -86
- package/src/storage.ts +548 -112
- package/src/transports/remote.ts +13 -14
- package/src/types.ts +6 -7
package/src/storage.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { useEffect, useMemo, useRef } from "react";
|
|
2
2
|
import { randomId } from "./util/randomId";
|
|
3
3
|
import { wrapStorageError } from "./client/storageError";
|
|
4
|
+
import { reviveValue } from "./wire/meta";
|
|
4
5
|
import { resource } from "@assistant-ui/tap";
|
|
5
6
|
import type { StatewireClient } from "./statewire";
|
|
7
|
+
import type { Statewire } from "./types";
|
|
6
8
|
import type { StatewireProtocol } from "./wire/protocol";
|
|
7
9
|
import {
|
|
8
10
|
DEFAULT_PROTOCOL,
|
|
@@ -47,12 +49,32 @@ const isSessionCommand = (
|
|
|
47
49
|
const isOutboxCommand = (
|
|
48
50
|
value: unknown,
|
|
49
51
|
): value is StatewireClient.OutboxCommand =>
|
|
52
|
+
isStatement(value) &&
|
|
53
|
+
isIdentified(value) &&
|
|
54
|
+
(value["held"] === undefined || value["held"] === true) &&
|
|
55
|
+
(value["released"] === undefined || value["released"] === true) &&
|
|
56
|
+
isOptionalString(value["by"]);
|
|
57
|
+
|
|
58
|
+
// Format 4 outbox entries carried the hold as `hold: { meta }`.
|
|
59
|
+
const isLegacyOutboxCommand = (value: unknown) =>
|
|
50
60
|
isStatement(value) &&
|
|
51
61
|
isIdentified(value) &&
|
|
52
62
|
(value["hold"] === undefined || isRecord(value["hold"])) &&
|
|
53
63
|
(value["released"] === undefined || value["released"] === true) &&
|
|
54
64
|
isOptionalString(value["by"]);
|
|
55
65
|
|
|
66
|
+
const isStoredSettled = (
|
|
67
|
+
value: unknown,
|
|
68
|
+
): value is StatewireClient.StoredSettled =>
|
|
69
|
+
isRecord(value) &&
|
|
70
|
+
isIdentified(value) &&
|
|
71
|
+
typeof value["method"] === "string" &&
|
|
72
|
+
Array.isArray(value["params"]) &&
|
|
73
|
+
(value["seq"] === undefined || isPositiveSafeInteger(value["seq"])) &&
|
|
74
|
+
(value["failure"] === undefined
|
|
75
|
+
? isRecord(value["result"]) && Array.isArray(value["result"]["value"])
|
|
76
|
+
: value["result"] === undefined && isSendFailure(value["failure"]));
|
|
77
|
+
|
|
56
78
|
const isSessionVerdict = (
|
|
57
79
|
value: unknown,
|
|
58
80
|
): value is StatewireClient.SessionVerdict =>
|
|
@@ -88,7 +110,8 @@ const isSendFailure = (
|
|
|
88
110
|
(value["cause"] === "unmount" ||
|
|
89
111
|
value["cause"] === "stream-gone" ||
|
|
90
112
|
value["cause"] === "session-lost" ||
|
|
91
|
-
value["cause"] === "removed"
|
|
113
|
+
value["cause"] === "removed" ||
|
|
114
|
+
value["cause"] === "quota") &&
|
|
92
115
|
(value["predecessorLost"] === undefined ||
|
|
93
116
|
value["predecessorLost"] === true)
|
|
94
117
|
);
|
|
@@ -103,9 +126,18 @@ const isSendFailure = (
|
|
|
103
126
|
}
|
|
104
127
|
};
|
|
105
128
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
129
|
+
type FailedCommand = {
|
|
130
|
+
protocol: string;
|
|
131
|
+
produced?: StatewireClient.SessionCommand["produced"];
|
|
132
|
+
command: string;
|
|
133
|
+
params: unknown[];
|
|
134
|
+
meta?: unknown;
|
|
135
|
+
failure: StatewireClient.SendFailure;
|
|
136
|
+
ts: number;
|
|
137
|
+
seq: number;
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const isFailedCommand = (value: unknown): value is FailedCommand =>
|
|
109
141
|
isRecord(value) &&
|
|
110
142
|
isProtocol(value["protocol"]) &&
|
|
111
143
|
isProducer(value["produced"]) &&
|
|
@@ -116,30 +148,34 @@ const isFailedCommand = (
|
|
|
116
148
|
Number.isFinite(value["ts"]) &&
|
|
117
149
|
isPositiveSafeInteger(value["seq"]);
|
|
118
150
|
|
|
151
|
+
const isArrayOf = (value: unknown, is: (item: unknown) => boolean) =>
|
|
152
|
+
value === undefined || (Array.isArray(value) && value.every(is));
|
|
153
|
+
|
|
154
|
+
const isMark = (mark: unknown) =>
|
|
155
|
+
isRecord(mark) &&
|
|
156
|
+
typeof mark["id"] === "string" &&
|
|
157
|
+
typeof mark["by"] === "string" &&
|
|
158
|
+
(mark["failure"] === undefined || isSendFailure(mark["failure"]));
|
|
159
|
+
|
|
119
160
|
const isLaneWith = (
|
|
120
161
|
value: unknown,
|
|
121
162
|
isCommand: (command: unknown) => boolean,
|
|
163
|
+
legacy = false,
|
|
122
164
|
): value is StatewireClient.SessionLane =>
|
|
123
165
|
isRecord(value) &&
|
|
124
166
|
isPositiveSafeInteger(value["nextSeq"]) &&
|
|
125
167
|
Array.isArray(value["commands"]) &&
|
|
126
168
|
value["commands"].every(isCommand) &&
|
|
127
|
-
(
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
))) &&
|
|
138
|
-
(value["verdicts"] === undefined ||
|
|
139
|
-
(Array.isArray(value["verdicts"]) &&
|
|
140
|
-
value["verdicts"].every(isSessionVerdict))) &&
|
|
141
|
-
(value["failed"] === undefined ||
|
|
142
|
-
(Array.isArray(value["failed"]) && value["failed"].every(isFailedCommand)));
|
|
169
|
+
isArrayOf(
|
|
170
|
+
value["outbox"],
|
|
171
|
+
legacy ? isLegacyOutboxCommand : isOutboxCommand,
|
|
172
|
+
) &&
|
|
173
|
+
isArrayOf(value["removed"], isMark) &&
|
|
174
|
+
isArrayOf(value["verdicts"], isSessionVerdict) &&
|
|
175
|
+
(legacy
|
|
176
|
+
? isArrayOf(value["failed"], isFailedCommand)
|
|
177
|
+
: value["failed"] === undefined &&
|
|
178
|
+
isArrayOf(value["inbox"], isStoredSettled));
|
|
143
179
|
|
|
144
180
|
const isSessionLane = (value: unknown): value is StatewireClient.SessionLane =>
|
|
145
181
|
isLaneWith(value, isSessionCommand);
|
|
@@ -209,12 +245,22 @@ const invalid = (): never => {
|
|
|
209
245
|
throw new Error("statewire: invalid stored session record");
|
|
210
246
|
};
|
|
211
247
|
|
|
248
|
+
type LegacyLane = Record<string, unknown> & {
|
|
249
|
+
commands: StatewireClient.SessionCommand[];
|
|
250
|
+
outbox?: (Omit<StatewireClient.OutboxCommand, "held"> & {
|
|
251
|
+
hold?: { meta?: unknown };
|
|
252
|
+
})[];
|
|
253
|
+
failed?: FailedCommand[];
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
type LegacyRecord = { clientId: string; lanes: Record<string, LegacyLane> };
|
|
257
|
+
|
|
212
258
|
// Formats 1 and 2 held one lane: it lifts under the primary protocol, failed
|
|
213
259
|
// entries under their own.
|
|
214
260
|
const liftLegacy = (
|
|
215
261
|
value: Record<string, unknown>,
|
|
216
262
|
protocol: string,
|
|
217
|
-
):
|
|
263
|
+
): LegacyRecord => {
|
|
218
264
|
const { clientId, nextSeq, commands, verdicts, failed } = value;
|
|
219
265
|
const lane = {
|
|
220
266
|
nextSeq,
|
|
@@ -232,24 +278,63 @@ const liftLegacy = (
|
|
|
232
278
|
(!Array.isArray(failed) || !failed.every(isFailedCommand))
|
|
233
279
|
)
|
|
234
280
|
return invalid();
|
|
235
|
-
const lanes: Record<string,
|
|
281
|
+
const lanes: Record<string, LegacyLane> = {
|
|
236
282
|
[protocol]: {
|
|
237
|
-
...lane,
|
|
283
|
+
...(lane as Record<string, unknown>),
|
|
238
284
|
commands: legacyCommands
|
|
239
285
|
.filter((command) => (command.protocol ?? protocol) === protocol)
|
|
240
286
|
.map(({ protocol: _protocol, ...command }) => command),
|
|
241
287
|
},
|
|
242
288
|
};
|
|
243
|
-
for (const entry of failed ?? []) {
|
|
289
|
+
for (const entry of (failed ?? []) as FailedCommand[]) {
|
|
244
290
|
const target = lanes[entry.protocol] ?? { nextSeq: 1, commands: [] };
|
|
245
291
|
lanes[entry.protocol] = {
|
|
246
292
|
...target,
|
|
247
293
|
failed: [...(target.failed ?? []), entry],
|
|
248
294
|
};
|
|
249
295
|
}
|
|
250
|
-
return identify({ clientId, lanes });
|
|
296
|
+
return identify({ clientId, lanes } as never) as never;
|
|
251
297
|
};
|
|
252
298
|
|
|
299
|
+
// Format 5: holds are a flag with client-only localMeta; failed sends are
|
|
300
|
+
// settled updates awaiting their onCommandChange ack.
|
|
301
|
+
const upgrade = (record: LegacyRecord): StatewireClient.SessionRecord => ({
|
|
302
|
+
clientId: record.clientId,
|
|
303
|
+
lanes: Object.fromEntries(
|
|
304
|
+
Object.entries(record.lanes).map(([protocol, legacy]) => {
|
|
305
|
+
const { outbox, failed, ...lane } = legacy;
|
|
306
|
+
const inbox = [...(failed ?? [])]
|
|
307
|
+
.sort((a, b) => a.ts - b.ts)
|
|
308
|
+
.map((f): StatewireClient.StoredSettled => ({
|
|
309
|
+
// Stable across re-migrations until the upgraded record is saved.
|
|
310
|
+
id: `failed:${protocol}:${f.seq}:${f.ts}`,
|
|
311
|
+
method: f.command,
|
|
312
|
+
params: f.params,
|
|
313
|
+
...(f.meta !== undefined && { meta: f.meta }),
|
|
314
|
+
ts: f.ts,
|
|
315
|
+
failure: f.failure,
|
|
316
|
+
}));
|
|
317
|
+
return [
|
|
318
|
+
protocol,
|
|
319
|
+
{
|
|
320
|
+
...(lane as unknown as StatewireClient.SessionLane),
|
|
321
|
+
...(outbox?.length && {
|
|
322
|
+
outbox: outbox.map(({ hold, ...command }) => ({
|
|
323
|
+
...command,
|
|
324
|
+
...(hold !== undefined && { held: true as const }),
|
|
325
|
+
...(hold?.meta !== undefined && { localMeta: hold.meta }),
|
|
326
|
+
})),
|
|
327
|
+
}),
|
|
328
|
+
...(inbox.length > 0 && { inbox }),
|
|
329
|
+
},
|
|
330
|
+
];
|
|
331
|
+
}),
|
|
332
|
+
),
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
const isLegacyLane = (lane: unknown) =>
|
|
336
|
+
isLaneWith(lane, isSessionCommand, true);
|
|
337
|
+
|
|
253
338
|
const normalize = (
|
|
254
339
|
parsed: unknown,
|
|
255
340
|
protocol: string,
|
|
@@ -260,29 +345,64 @@ const normalize = (
|
|
|
260
345
|
savedAt: _savedAt,
|
|
261
346
|
...value
|
|
262
347
|
} = parsed;
|
|
263
|
-
if (format ===
|
|
348
|
+
if (format === 5) return isSessionRecord(value) ? value : invalid();
|
|
349
|
+
if (format === 4)
|
|
350
|
+
return isRecordWith(value, isLegacyLane)
|
|
351
|
+
? upgrade(value as never)
|
|
352
|
+
: invalid();
|
|
264
353
|
if (format === 3)
|
|
265
|
-
return isRecordWith(value, (lane) =>
|
|
266
|
-
|
|
354
|
+
return isRecordWith(value, (lane) =>
|
|
355
|
+
isLaneWith(lane, isLegacyCommand, true),
|
|
356
|
+
)
|
|
357
|
+
? upgrade(identify(value) as never)
|
|
267
358
|
: invalid();
|
|
268
359
|
if (format !== undefined && format !== 1 && format !== 2)
|
|
269
360
|
throw new Error("statewire: unsupported session storage version");
|
|
270
361
|
const { ctxNextSeq: _ctxNextSeq, context: _context, ...legacy } = value;
|
|
271
|
-
return liftLegacy(legacy, protocol);
|
|
362
|
+
return upgrade(liftLegacy(legacy, protocol));
|
|
272
363
|
};
|
|
273
364
|
|
|
365
|
+
export const STORAGE_FORMAT = 5;
|
|
366
|
+
|
|
367
|
+
const parse = async (
|
|
368
|
+
raw: string | null,
|
|
369
|
+
codec: KeyValueSessionStorage.Codec | undefined,
|
|
370
|
+
): Promise<unknown> =>
|
|
371
|
+
raw === null
|
|
372
|
+
? null
|
|
373
|
+
: JSON.parse(codec === undefined ? raw : await codec.decode(raw));
|
|
374
|
+
|
|
274
375
|
const decode = (
|
|
275
376
|
raw: string | null,
|
|
276
377
|
protocol: string,
|
|
277
378
|
): StatewireClient.SessionRecord | null =>
|
|
278
379
|
raw === null ? null : normalize(JSON.parse(raw), protocol);
|
|
279
380
|
|
|
280
|
-
const
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
381
|
+
const isUnsent = (lane: StatewireClient.SessionLane) =>
|
|
382
|
+
lane.commands.length > 0 || (lane.outbox?.length ?? 0) > 0;
|
|
383
|
+
|
|
384
|
+
const entryCount = (record: StatewireClient.SessionRecord | null) =>
|
|
385
|
+
record === null
|
|
386
|
+
? 0
|
|
387
|
+
: Object.values(record.lanes).reduce(
|
|
388
|
+
(sum, lane) => sum + lane.commands.length + (lane.outbox?.length ?? 0),
|
|
389
|
+
0,
|
|
390
|
+
);
|
|
391
|
+
|
|
392
|
+
const noticesOf = (
|
|
393
|
+
record: StatewireClient.SessionRecord,
|
|
394
|
+
): Statewire.CommandUpdate[] =>
|
|
395
|
+
Object.entries(record.lanes).flatMap(([protocol, lane]) =>
|
|
396
|
+
(lane.inbox ?? []).map((n) => ({
|
|
397
|
+
protocol,
|
|
398
|
+
id: n.id,
|
|
399
|
+
method: n.method,
|
|
400
|
+
params: n.params as never[],
|
|
401
|
+
...(n.seq !== undefined && { seq: n.seq }),
|
|
402
|
+
status: "settled" as const,
|
|
403
|
+
...(n.failure !== undefined && { failure: n.failure }),
|
|
404
|
+
...(n.localMeta !== undefined && { localMeta: n.localMeta }),
|
|
405
|
+
})),
|
|
286
406
|
);
|
|
287
407
|
|
|
288
408
|
const active = new WeakMap<
|
|
@@ -290,34 +410,70 @@ const active = new WeakMap<
|
|
|
290
410
|
Map<string, number>
|
|
291
411
|
>();
|
|
292
412
|
|
|
293
|
-
|
|
413
|
+
/** Same-runtime saves, per backend, for `subscribeOutboxes`. */
|
|
414
|
+
const savers = new WeakMap<
|
|
415
|
+
KeyValueSessionStorage.Backend,
|
|
416
|
+
Set<(key: string) => void>
|
|
417
|
+
>();
|
|
418
|
+
|
|
419
|
+
const announce = (backend: KeyValueSessionStorage.Backend, key: string) => {
|
|
420
|
+
for (const listener of savers.get(backend) ?? []) listener(key);
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
type Candidate = {
|
|
424
|
+
key: string;
|
|
425
|
+
savedAt: number;
|
|
426
|
+
record: StatewireClient.SessionRecord;
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
const sweeping = new WeakMap<KeyValueSessionStorage.Backend, Promise<void>>();
|
|
430
|
+
|
|
431
|
+
// Sweeps of one backend run one at a time.
|
|
432
|
+
const sweep = (
|
|
433
|
+
options: KeyValueSessionStorage.Options,
|
|
434
|
+
currentKey: string,
|
|
435
|
+
quota = false,
|
|
436
|
+
): Promise<void> => {
|
|
437
|
+
const run = (sweeping.get(options.backend) ?? Promise.resolve())
|
|
438
|
+
.catch(() => {})
|
|
439
|
+
.then(() => sweepOnce(options, currentKey, quota));
|
|
440
|
+
sweeping.set(options.backend, run);
|
|
441
|
+
return run;
|
|
442
|
+
};
|
|
443
|
+
|
|
444
|
+
// Records with unsent commands are never evicted. Empty records go first;
|
|
445
|
+
// records holding only failure notices go once past their age.
|
|
446
|
+
const sweepOnce = async (
|
|
294
447
|
options: KeyValueSessionStorage.Options,
|
|
295
448
|
currentKey: string,
|
|
296
449
|
quota = false,
|
|
297
450
|
) => {
|
|
298
|
-
const { backend, retention, namespace = "" } = options;
|
|
451
|
+
const { backend, retention, namespace = "", codec, onEvict } = options;
|
|
299
452
|
if (!retention || !backend.getAllKeys) return;
|
|
300
|
-
const
|
|
301
|
-
const candidate = (raw: string | null) => {
|
|
302
|
-
if (raw === null) return undefined;
|
|
453
|
+
const candidate = async (raw: string | null) => {
|
|
303
454
|
let value: unknown;
|
|
304
455
|
try {
|
|
305
|
-
value =
|
|
456
|
+
value = await parse(raw, codec);
|
|
306
457
|
} catch {
|
|
307
458
|
return undefined;
|
|
308
459
|
}
|
|
460
|
+
if (value === null) return undefined;
|
|
461
|
+
let record: StatewireClient.SessionRecord;
|
|
309
462
|
try {
|
|
310
|
-
|
|
463
|
+
record = normalize(value, DEFAULT_PROTOCOL);
|
|
311
464
|
} catch {
|
|
312
465
|
return undefined;
|
|
313
466
|
}
|
|
467
|
+
if (Object.values(record.lanes).some(isUnsent)) return undefined;
|
|
314
468
|
const savedAt =
|
|
315
469
|
(value as Record<string, unknown>)["savedAt"] ??
|
|
316
470
|
(value as Record<string, unknown>)["updatedAt"];
|
|
317
471
|
return typeof savedAt === "number" && Number.isFinite(savedAt)
|
|
318
|
-
? savedAt
|
|
472
|
+
? { savedAt, record }
|
|
319
473
|
: undefined;
|
|
320
474
|
};
|
|
475
|
+
const empty: Candidate[] = [];
|
|
476
|
+
const noticed: Candidate[] = [];
|
|
321
477
|
for (const key of await backend.getAllKeys()) {
|
|
322
478
|
if (
|
|
323
479
|
!key.startsWith(namespace) ||
|
|
@@ -325,36 +481,79 @@ const sweep = async (
|
|
|
325
481
|
active.get(backend)?.has(key)
|
|
326
482
|
)
|
|
327
483
|
continue;
|
|
328
|
-
const
|
|
329
|
-
if (
|
|
484
|
+
const found = await candidate(await backend.getItem(key));
|
|
485
|
+
if (found === undefined) continue;
|
|
486
|
+
(noticesOf(found.record).length > 0 ? noticed : empty).push({
|
|
487
|
+
key,
|
|
488
|
+
...found,
|
|
489
|
+
});
|
|
330
490
|
}
|
|
331
|
-
|
|
491
|
+
const remove = async (
|
|
492
|
+
entry: Candidate,
|
|
493
|
+
reason: KeyValueSessionStorage.EvictEvent["reason"],
|
|
494
|
+
) => {
|
|
495
|
+
const run = async () => {
|
|
496
|
+
if (active.get(backend)?.has(entry.key)) return;
|
|
497
|
+
const again = await candidate(await backend.getItem(entry.key));
|
|
498
|
+
if (again?.savedAt !== entry.savedAt) return;
|
|
499
|
+
if (active.get(backend)?.has(entry.key)) return;
|
|
500
|
+
await backend.removeItem(entry.key);
|
|
501
|
+
announce(backend, entry.key);
|
|
502
|
+
onEvict?.({
|
|
503
|
+
key: entry.key,
|
|
504
|
+
reason,
|
|
505
|
+
dropped: noticesOf(again.record),
|
|
506
|
+
});
|
|
507
|
+
};
|
|
508
|
+
if (backend.withUnused) await backend.withUnused(entry.key, run);
|
|
509
|
+
else await run();
|
|
510
|
+
};
|
|
332
511
|
const now = Date.now();
|
|
512
|
+
const expired = (entry: Candidate) =>
|
|
513
|
+
retention.maxAgeMs !== undefined &&
|
|
514
|
+
now - entry.savedAt > retention.maxAgeMs;
|
|
515
|
+
empty.sort((a, b) => b.savedAt - a.savedAt);
|
|
333
516
|
let retained = 1;
|
|
334
|
-
const quotaStart = Math.floor(
|
|
335
|
-
for (const [index, entry] of
|
|
336
|
-
const expired =
|
|
337
|
-
retention.maxAgeMs !== undefined &&
|
|
338
|
-
now - entry.savedAt > retention.maxAgeMs;
|
|
517
|
+
const quotaStart = Math.floor(empty.length / 2);
|
|
518
|
+
for (const [index, entry] of empty.entries()) {
|
|
339
519
|
const overflow =
|
|
340
520
|
retention.maxEntries !== undefined && retained >= retention.maxEntries;
|
|
341
|
-
if (expired
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
return;
|
|
346
|
-
if (active.get(backend)?.has(entry.key)) return;
|
|
347
|
-
await backend.removeItem(entry.key);
|
|
348
|
-
};
|
|
349
|
-
if (backend.withUnused) await backend.withUnused(entry.key, remove);
|
|
350
|
-
else await remove();
|
|
351
|
-
} else retained++;
|
|
521
|
+
if (expired(entry)) await remove(entry, "expired");
|
|
522
|
+
else if (overflow) await remove(entry, "overflow");
|
|
523
|
+
else if (quota && index >= quotaStart) await remove(entry, "quota");
|
|
524
|
+
else retained++;
|
|
352
525
|
}
|
|
526
|
+
for (const entry of noticed)
|
|
527
|
+
if (expired(entry)) await remove(entry, "expired");
|
|
353
528
|
};
|
|
354
529
|
|
|
355
530
|
const isQuotaError = (error: unknown) =>
|
|
356
531
|
error instanceof Error && error.name === "QuotaExceededError";
|
|
357
532
|
|
|
533
|
+
const quotaError = (limit: string) => {
|
|
534
|
+
const error = new Error(`statewire: session ${limit} limit reached`);
|
|
535
|
+
error.name = "StatewireQuotaError";
|
|
536
|
+
return error;
|
|
537
|
+
};
|
|
538
|
+
|
|
539
|
+
const otherBytes = async (
|
|
540
|
+
backend: KeyValueSessionStorage.Backend,
|
|
541
|
+
namespace: string,
|
|
542
|
+
key: string,
|
|
543
|
+
) => {
|
|
544
|
+
let total = 0;
|
|
545
|
+
for (const other of (await backend.getAllKeys?.()) ?? []) {
|
|
546
|
+
if (other === key || !other.startsWith(namespace)) continue;
|
|
547
|
+
total += (await backend.getItem(other))?.length ?? 0;
|
|
548
|
+
}
|
|
549
|
+
return total;
|
|
550
|
+
};
|
|
551
|
+
|
|
552
|
+
const validLimit = (value: number | undefined, name: string) => {
|
|
553
|
+
if (value !== undefined && (!Number.isSafeInteger(value) || value < 1))
|
|
554
|
+
throw new Error(`statewire: invalid session limit ${name}`);
|
|
555
|
+
};
|
|
556
|
+
|
|
358
557
|
const useKeyValueSessionStorage = (
|
|
359
558
|
options: KeyValueSessionStorage.Options,
|
|
360
559
|
): StatewireClient.SessionStorage => {
|
|
@@ -365,12 +564,20 @@ const useKeyValueSessionStorage = (
|
|
|
365
564
|
protocol = DEFAULT_PROTOCOL,
|
|
366
565
|
migrate,
|
|
367
566
|
retention: suppliedRetention,
|
|
567
|
+
limits: suppliedLimits,
|
|
568
|
+
codec,
|
|
368
569
|
onError,
|
|
570
|
+
onEvict,
|
|
369
571
|
} = options;
|
|
370
|
-
const
|
|
371
|
-
|
|
572
|
+
const latest = useRef({ onError, onEvict });
|
|
573
|
+
latest.current = { onError, onEvict };
|
|
372
574
|
const reportError = useMemo(
|
|
373
|
-
() => (error: unknown) =>
|
|
575
|
+
() => (error: unknown) => latest.current.onError?.(error),
|
|
576
|
+
[],
|
|
577
|
+
);
|
|
578
|
+
const reportEvict = useMemo(
|
|
579
|
+
() => (event: KeyValueSessionStorage.EvictEvent) =>
|
|
580
|
+
latest.current.onEvict?.(event),
|
|
374
581
|
[],
|
|
375
582
|
);
|
|
376
583
|
const retain = suppliedRetention !== undefined;
|
|
@@ -386,14 +593,24 @@ const useKeyValueSessionStorage = (
|
|
|
386
593
|
: undefined,
|
|
387
594
|
[retain, maxAgeMs, maxEntries],
|
|
388
595
|
);
|
|
596
|
+
const outboxEntries = suppliedLimits?.outboxEntries;
|
|
597
|
+
const recordBytes = suppliedLimits?.recordBytes;
|
|
598
|
+
const totalBytes = suppliedLimits?.totalBytes;
|
|
599
|
+
validLimit(outboxEntries, "outboxEntries");
|
|
600
|
+
validLimit(recordBytes, "recordBytes");
|
|
601
|
+
validLimit(totalBytes, "totalBytes");
|
|
602
|
+
if (totalBytes !== undefined && !backend.getAllKeys)
|
|
603
|
+
throw new Error("statewire: a totalBytes limit requires getAllKeys");
|
|
389
604
|
const configured = useMemo(
|
|
390
605
|
() => ({
|
|
391
606
|
backend,
|
|
392
607
|
key,
|
|
393
608
|
namespace,
|
|
609
|
+
onEvict: reportEvict,
|
|
610
|
+
...(codec === undefined ? {} : { codec }),
|
|
394
611
|
...(retention === undefined ? {} : { retention }),
|
|
395
612
|
}),
|
|
396
|
-
[backend, key, namespace, retention],
|
|
613
|
+
[backend, key, namespace, retention, codec, reportEvict],
|
|
397
614
|
);
|
|
398
615
|
const storageKey = namespace + key;
|
|
399
616
|
if (retention !== undefined) {
|
|
@@ -423,18 +640,26 @@ const useKeyValueSessionStorage = (
|
|
|
423
640
|
}
|
|
424
641
|
const state = {
|
|
425
642
|
record: null as StatewireClient.SessionRecord | null,
|
|
643
|
+
bytes: 0,
|
|
644
|
+
others: undefined as number | undefined,
|
|
426
645
|
ready: undefined as Promise<void> | undefined,
|
|
427
646
|
queue: Promise.resolve(),
|
|
428
647
|
pending: 0,
|
|
429
648
|
failure: undefined as unknown,
|
|
430
|
-
asynchronous:
|
|
431
|
-
|
|
649
|
+
asynchronous:
|
|
650
|
+
codec !== undefined || (typeof raw !== "string" && raw !== null),
|
|
651
|
+
hydrated: false,
|
|
652
|
+
};
|
|
653
|
+
const hydrate = (record: StatewireClient.SessionRecord | null) => {
|
|
654
|
+
state.record = record;
|
|
655
|
+
state.hydrated = true;
|
|
432
656
|
};
|
|
433
657
|
if (state.asynchronous) {
|
|
434
658
|
state.ready = Promise.resolve(raw)
|
|
435
|
-
.then((value) => {
|
|
436
|
-
state.
|
|
437
|
-
|
|
659
|
+
.then(async (value) => {
|
|
660
|
+
state.bytes = value?.length ?? 0;
|
|
661
|
+
const parsed = await parse(value, codec);
|
|
662
|
+
hydrate(parsed === null ? null : normalize(parsed, protocol));
|
|
438
663
|
})
|
|
439
664
|
.catch((cause: unknown) => {
|
|
440
665
|
const error = wrapStorageError(cause);
|
|
@@ -445,7 +670,8 @@ const useKeyValueSessionStorage = (
|
|
|
445
670
|
state.ready.catch(() => {});
|
|
446
671
|
} else {
|
|
447
672
|
try {
|
|
448
|
-
state.
|
|
673
|
+
state.bytes = (raw as string | null)?.length ?? 0;
|
|
674
|
+
hydrate(decode(raw as string | null, protocol));
|
|
449
675
|
} catch (cause) {
|
|
450
676
|
const error = wrapStorageError(cause);
|
|
451
677
|
reportError(error);
|
|
@@ -453,7 +679,7 @@ const useKeyValueSessionStorage = (
|
|
|
453
679
|
}
|
|
454
680
|
}
|
|
455
681
|
return state;
|
|
456
|
-
}, [backend, storageKey, protocol, reportError]);
|
|
682
|
+
}, [backend, storageKey, protocol, codec, reportError]);
|
|
457
683
|
|
|
458
684
|
useEffect(() => {
|
|
459
685
|
const entries = active.get(backend) ?? new Map<string, number>();
|
|
@@ -475,9 +701,65 @@ const useKeyValueSessionStorage = (
|
|
|
475
701
|
});
|
|
476
702
|
}, [state, configured, storageKey, retention, reportError]);
|
|
477
703
|
|
|
478
|
-
|
|
479
|
-
()
|
|
704
|
+
useEffect(() => {
|
|
705
|
+
if (totalBytes === undefined) return;
|
|
706
|
+
void otherBytes(backend, namespace, storageKey).then(
|
|
707
|
+
(bytes) => {
|
|
708
|
+
state.others = bytes;
|
|
709
|
+
},
|
|
710
|
+
() => {},
|
|
711
|
+
);
|
|
712
|
+
}, [state, backend, namespace, storageKey, totalBytes]);
|
|
713
|
+
|
|
714
|
+
return useMemo(() => {
|
|
715
|
+
const checkLimits = (
|
|
716
|
+
record: StatewireClient.SessionRecord,
|
|
717
|
+
bytes: number,
|
|
718
|
+
) => {
|
|
719
|
+
// Limits gate writes that add an entry; settling and stamping always land.
|
|
720
|
+
if (entryCount(record) <= entryCount(state.record)) return;
|
|
721
|
+
if (outboxEntries !== undefined && entryCount(record) > outboxEntries)
|
|
722
|
+
throw quotaError("outboxEntries");
|
|
723
|
+
if (recordBytes !== undefined && bytes > recordBytes)
|
|
724
|
+
throw quotaError("recordBytes");
|
|
725
|
+
if (
|
|
726
|
+
totalBytes !== undefined &&
|
|
727
|
+
state.others !== undefined &&
|
|
728
|
+
state.others + bytes > totalBytes
|
|
729
|
+
)
|
|
730
|
+
throw quotaError("totalBytes");
|
|
731
|
+
};
|
|
732
|
+
const persist = async (encoded: string) => {
|
|
733
|
+
const stored =
|
|
734
|
+
codec === undefined ? encoded : await codec.encode(encoded);
|
|
735
|
+
try {
|
|
736
|
+
await backend.setItem(storageKey, stored);
|
|
737
|
+
} catch (error) {
|
|
738
|
+
if (!isQuotaError(error)) throw error;
|
|
739
|
+
if (retention !== undefined) await sweep(configured, storageKey, true);
|
|
740
|
+
await backend.setItem(storageKey, stored);
|
|
741
|
+
}
|
|
742
|
+
announce(backend, storageKey);
|
|
743
|
+
};
|
|
744
|
+
return {
|
|
745
|
+
durable: true,
|
|
480
746
|
...(state.ready === undefined ? {} : { ready: state.ready }),
|
|
747
|
+
read: async () => {
|
|
748
|
+
if (state.failure !== undefined) throw state.failure;
|
|
749
|
+
await state.ready;
|
|
750
|
+
await state.queue.catch(() => {});
|
|
751
|
+
try {
|
|
752
|
+
const raw = await backend.getItem(storageKey);
|
|
753
|
+
const parsed = await parse(raw, codec);
|
|
754
|
+
state.bytes = raw?.length ?? 0;
|
|
755
|
+
state.record = parsed === null ? null : normalize(parsed, protocol);
|
|
756
|
+
return state.record;
|
|
757
|
+
} catch (cause) {
|
|
758
|
+
const error = wrapStorageError(cause);
|
|
759
|
+
reportError(error);
|
|
760
|
+
throw error;
|
|
761
|
+
}
|
|
762
|
+
},
|
|
481
763
|
load: () => {
|
|
482
764
|
if (state.failure !== undefined) throw state.failure;
|
|
483
765
|
if (!state.hydrated)
|
|
@@ -498,7 +780,8 @@ const useKeyValueSessionStorage = (
|
|
|
498
780
|
"statewire: storage backend changed from synchronous to asynchronous",
|
|
499
781
|
);
|
|
500
782
|
try {
|
|
501
|
-
|
|
783
|
+
state.record = decode(raw, protocol);
|
|
784
|
+
return state.record;
|
|
502
785
|
} catch (cause) {
|
|
503
786
|
const error = wrapStorageError(cause);
|
|
504
787
|
reportError(error);
|
|
@@ -515,42 +798,49 @@ const useKeyValueSessionStorage = (
|
|
|
515
798
|
throw new Error("statewire: invalid session record");
|
|
516
799
|
const encoded = JSON.stringify({
|
|
517
800
|
...record,
|
|
518
|
-
statewireStorageVersion:
|
|
801
|
+
statewireStorageVersion: STORAGE_FORMAT,
|
|
519
802
|
savedAt: Date.now(),
|
|
520
803
|
});
|
|
521
|
-
|
|
522
|
-
const recoverQuota = async (error: unknown) => {
|
|
523
|
-
if (!isQuotaError(error) || retention === undefined) throw error;
|
|
524
|
-
await sweep(configured, storageKey, true);
|
|
525
|
-
await backend.setItem(storageKey, encoded);
|
|
526
|
-
};
|
|
527
|
-
const write = async () => {
|
|
528
|
-
try {
|
|
529
|
-
await backend.setItem(storageKey, encoded);
|
|
530
|
-
} catch (error) {
|
|
531
|
-
await recoverQuota(error);
|
|
532
|
-
}
|
|
533
|
-
};
|
|
804
|
+
checkLimits(record, encoded.length);
|
|
534
805
|
if (!state.asynchronous && state.pending === 0) {
|
|
535
|
-
let result: void | Promise<void>;
|
|
536
806
|
try {
|
|
537
|
-
|
|
807
|
+
backend.setItem(storageKey, encoded) as void;
|
|
538
808
|
} catch (error) {
|
|
539
|
-
if (!isQuotaError(error)
|
|
809
|
+
if (!isQuotaError(error)) {
|
|
540
810
|
state.failure = wrapStorageError(error);
|
|
541
811
|
reportError(state.failure);
|
|
542
812
|
throw state.failure;
|
|
543
813
|
}
|
|
544
|
-
|
|
814
|
+
// A write that adds an entry is refused; the rest retry after the sweep.
|
|
815
|
+
if (entryCount(record) > entryCount(state.record)) {
|
|
816
|
+
if (retention !== undefined)
|
|
817
|
+
void sweep(configured, storageKey, true).catch(() => {});
|
|
818
|
+
throw quotaError("backend");
|
|
819
|
+
}
|
|
820
|
+
state.record = record;
|
|
821
|
+
state.bytes = encoded.length;
|
|
822
|
+
state.pending++;
|
|
823
|
+
state.queue = persist(encoded).finally(() => {
|
|
824
|
+
state.pending--;
|
|
825
|
+
});
|
|
826
|
+
state.queue.catch((cause: unknown) => {
|
|
827
|
+
const failure = wrapStorageError(cause);
|
|
828
|
+
if (state.failure !== failure) reportError(failure);
|
|
829
|
+
state.failure = failure;
|
|
830
|
+
});
|
|
831
|
+
return;
|
|
545
832
|
}
|
|
546
|
-
|
|
547
|
-
state.
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
state.pending++;
|
|
551
|
-
state.queue = state.queue.then(() => state.ready).then(write);
|
|
833
|
+
state.record = record;
|
|
834
|
+
state.bytes = encoded.length;
|
|
835
|
+
announce(backend, storageKey);
|
|
836
|
+
return;
|
|
552
837
|
}
|
|
838
|
+
state.record = record;
|
|
839
|
+
state.bytes = encoded.length;
|
|
840
|
+
state.pending++;
|
|
553
841
|
state.queue = state.queue
|
|
842
|
+
.then(() => state.ready)
|
|
843
|
+
.then(() => persist(encoded))
|
|
554
844
|
.catch((cause: unknown) => {
|
|
555
845
|
const error = wrapStorageError(cause);
|
|
556
846
|
if (state.failure !== error) reportError(error);
|
|
@@ -574,19 +864,25 @@ const useKeyValueSessionStorage = (
|
|
|
574
864
|
}
|
|
575
865
|
})();
|
|
576
866
|
},
|
|
867
|
+
onEvict: ({ reason, dropped }) =>
|
|
868
|
+
reportEvict({ key: storageKey, reason, dropped }),
|
|
577
869
|
...(migrate === undefined ? {} : { migrate }),
|
|
578
|
-
}
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
870
|
+
} satisfies StatewireClient.SessionStorage;
|
|
871
|
+
}, [
|
|
872
|
+
state,
|
|
873
|
+
backend,
|
|
874
|
+
storageKey,
|
|
875
|
+
protocol,
|
|
876
|
+
migrate,
|
|
877
|
+
retention,
|
|
878
|
+
configured,
|
|
879
|
+
codec,
|
|
880
|
+
outboxEntries,
|
|
881
|
+
recordBytes,
|
|
882
|
+
totalBytes,
|
|
883
|
+
reportError,
|
|
884
|
+
reportEvict,
|
|
885
|
+
]);
|
|
590
886
|
};
|
|
591
887
|
|
|
592
888
|
/** Persist sessions through a synchronous or asynchronous string storage backend. */
|
|
@@ -600,7 +896,15 @@ export namespace KeyValueSessionStorage {
|
|
|
600
896
|
getAllKeys?: () => readonly string[] | Promise<readonly string[]>;
|
|
601
897
|
/** Runs cleanup under exclusive access, skipping keys in use by another runtime. */
|
|
602
898
|
withUnused?: (key: string, action: () => Promise<void>) => Promise<void>;
|
|
899
|
+
/** Notifies of keys other runtimes wrote; returns unsubscribe. */
|
|
900
|
+
subscribe?: (listener: (key: string) => void) => () => void;
|
|
901
|
+
};
|
|
902
|
+
/** Transforms the stored string, e.g. encryption or compression. */
|
|
903
|
+
export type Codec = {
|
|
904
|
+
encode: (plain: string) => Promise<string>;
|
|
905
|
+
decode: (stored: string) => Promise<string>;
|
|
603
906
|
};
|
|
907
|
+
export type EvictEvent = StatewireClient.EvictEvent;
|
|
604
908
|
export type Options = {
|
|
605
909
|
backend: Backend;
|
|
606
910
|
key: string;
|
|
@@ -609,6 +913,138 @@ export namespace KeyValueSessionStorage {
|
|
|
609
913
|
protocol?: string;
|
|
610
914
|
migrate?: StatewireClient.SessionStorage["migrate"];
|
|
611
915
|
retention?: { maxAgeMs?: number; maxEntries?: number };
|
|
916
|
+
/** Off by default; a write that adds an entry past one rejects `{ fate: "aborted", cause: "quota" }`. */
|
|
917
|
+
limits?: {
|
|
918
|
+
outboxEntries?: number;
|
|
919
|
+
recordBytes?: number;
|
|
920
|
+
totalBytes?: number;
|
|
921
|
+
};
|
|
922
|
+
codec?: Codec;
|
|
612
923
|
onError?: (error: unknown) => void;
|
|
924
|
+
/** Reports evicted records and dropped failure notices. */
|
|
925
|
+
onEvict?: (event: EvictEvent) => void;
|
|
926
|
+
};
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
const pages = new Map<string, StatewireClient.SessionRecord | null>();
|
|
930
|
+
|
|
931
|
+
const useMemorySessionStorage = ({
|
|
932
|
+
scope,
|
|
933
|
+
}: MemorySessionStorage.Options): StatewireClient.SessionStorage =>
|
|
934
|
+
useMemo(
|
|
935
|
+
() => ({
|
|
936
|
+
durable: false,
|
|
937
|
+
load: () => pages.get(scope) ?? null,
|
|
938
|
+
save: (record) => {
|
|
939
|
+
pages.set(scope, record);
|
|
940
|
+
},
|
|
941
|
+
}),
|
|
942
|
+
[scope],
|
|
943
|
+
);
|
|
944
|
+
|
|
945
|
+
/** Session storage in page memory, keyed by scope: the record and client identity survive remounts, not reloads. */
|
|
946
|
+
export const MemorySessionStorage = resource(useMemorySessionStorage);
|
|
947
|
+
|
|
948
|
+
export namespace MemorySessionStorage {
|
|
949
|
+
export type Options = { scope: string };
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
const entriesOf = (
|
|
953
|
+
record: StatewireClient.SessionRecord,
|
|
954
|
+
): StatewireClient.OutboxEntry[] =>
|
|
955
|
+
Object.entries(record.lanes).flatMap(([protocol, lane]) => {
|
|
956
|
+
const view = (
|
|
957
|
+
c: StatewireClient.OutboxCommand | StatewireClient.SessionCommand,
|
|
958
|
+
state: StatewireClient.OutboxState,
|
|
959
|
+
): StatewireClient.OutboxEntry => ({
|
|
960
|
+
id: c.id,
|
|
961
|
+
protocol,
|
|
962
|
+
command: {
|
|
963
|
+
method: c.method,
|
|
964
|
+
params:
|
|
965
|
+
c.meta === undefined
|
|
966
|
+
? c.params
|
|
967
|
+
: (reviveValue(structuredClone(c.params), c.meta) as unknown[]),
|
|
968
|
+
},
|
|
969
|
+
ts: c.ts,
|
|
970
|
+
state,
|
|
971
|
+
localMeta: c.localMeta,
|
|
972
|
+
durable: true,
|
|
973
|
+
});
|
|
974
|
+
return [
|
|
975
|
+
...[...lane.commands]
|
|
976
|
+
.sort((a, b) => a.seq - b.seq)
|
|
977
|
+
.map((c) => view(c, "sending")),
|
|
978
|
+
...(lane.outbox ?? []).map((c) => view(c, c.held ? "held" : "queued")),
|
|
979
|
+
];
|
|
980
|
+
});
|
|
981
|
+
|
|
982
|
+
/** Reads the outboxes of every session under `namespace`, mounted or not. */
|
|
983
|
+
export const readOutboxes = async (
|
|
984
|
+
backend: KeyValueSessionStorage.Backend,
|
|
985
|
+
options: ReadOutboxes.Options,
|
|
986
|
+
): Promise<readonly ReadOutboxes.Outbox[]> => {
|
|
987
|
+
if (!backend.getAllKeys)
|
|
988
|
+
throw new Error("statewire: readOutboxes requires getAllKeys");
|
|
989
|
+
const { namespace, codec, protocol = DEFAULT_PROTOCOL } = options;
|
|
990
|
+
if (!namespace)
|
|
991
|
+
throw new Error("statewire: readOutboxes requires a namespace");
|
|
992
|
+
const result: ReadOutboxes.Outbox[] = [];
|
|
993
|
+
for (const key of await backend.getAllKeys()) {
|
|
994
|
+
if (!key.startsWith(namespace)) continue;
|
|
995
|
+
const parsed = await parse(await backend.getItem(key), codec);
|
|
996
|
+
if (parsed === null) continue;
|
|
997
|
+
const entries = entriesOf(normalize(parsed, protocol));
|
|
998
|
+
if (entries.length > 0) result.push({ key, entries });
|
|
999
|
+
}
|
|
1000
|
+
return result;
|
|
1001
|
+
};
|
|
1002
|
+
|
|
1003
|
+
/** Calls `listener` with `readOutboxes` now and after every save under `namespace`; returns unsubscribe. */
|
|
1004
|
+
export const subscribeOutboxes = (
|
|
1005
|
+
backend: KeyValueSessionStorage.Backend,
|
|
1006
|
+
options: ReadOutboxes.Options,
|
|
1007
|
+
listener: (outboxes: readonly ReadOutboxes.Outbox[]) => void,
|
|
1008
|
+
onError: (error: unknown) => void,
|
|
1009
|
+
): (() => void) => {
|
|
1010
|
+
let closed = false;
|
|
1011
|
+
let reading = Promise.resolve();
|
|
1012
|
+
const refresh = () => {
|
|
1013
|
+
reading = reading.then(async () => {
|
|
1014
|
+
if (closed) return;
|
|
1015
|
+
try {
|
|
1016
|
+
const outboxes = await readOutboxes(backend, options);
|
|
1017
|
+
if (!closed) listener(outboxes);
|
|
1018
|
+
} catch (error) {
|
|
1019
|
+
onError(error);
|
|
1020
|
+
}
|
|
1021
|
+
});
|
|
1022
|
+
};
|
|
1023
|
+
const onKey = (key: string) => {
|
|
1024
|
+
if (key.startsWith(options.namespace)) refresh();
|
|
1025
|
+
};
|
|
1026
|
+
const local = savers.get(backend) ?? new Set();
|
|
1027
|
+
savers.set(backend, local);
|
|
1028
|
+
local.add(onKey);
|
|
1029
|
+
const remote = backend.subscribe?.(onKey);
|
|
1030
|
+
refresh();
|
|
1031
|
+
return () => {
|
|
1032
|
+
closed = true;
|
|
1033
|
+
local.delete(onKey);
|
|
1034
|
+
remote?.();
|
|
1035
|
+
};
|
|
1036
|
+
};
|
|
1037
|
+
|
|
1038
|
+
export namespace ReadOutboxes {
|
|
1039
|
+
export type Options = {
|
|
1040
|
+
namespace: string;
|
|
1041
|
+
codec?: KeyValueSessionStorage.Codec;
|
|
1042
|
+
/** The lane that single-lane formats lift into. */
|
|
1043
|
+
protocol?: string;
|
|
1044
|
+
};
|
|
1045
|
+
export type Outbox = {
|
|
1046
|
+
key: string;
|
|
1047
|
+
entries: readonly StatewireClient.OutboxEntry[];
|
|
613
1048
|
};
|
|
614
1049
|
}
|
|
1050
|
+
export { IndexedDbSession, indexedDbBackend } from "./indexedDb";
|