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.
Files changed (43) hide show
  1. package/dist/client/core.d.ts +4 -3
  2. package/dist/client/core.d.ts.map +1 -1
  3. package/dist/client/core.js +56 -45
  4. package/dist/client/core.js.map +1 -1
  5. package/dist/client/session.d.ts +56 -12
  6. package/dist/client/session.d.ts.map +1 -1
  7. package/dist/client/session.js +632 -260
  8. package/dist/client/session.js.map +1 -1
  9. package/dist/client/sessionStore.d.ts +1 -0
  10. package/dist/client/sessionStore.d.ts.map +1 -1
  11. package/dist/client/sessionStore.js +1 -0
  12. package/dist/client/sessionStore.js.map +1 -1
  13. package/dist/host/host.d.ts +6 -5
  14. package/dist/host/host.d.ts.map +1 -1
  15. package/dist/host/host.js.map +1 -1
  16. package/dist/indexedDb.d.ts +15 -0
  17. package/dist/indexedDb.d.ts.map +1 -0
  18. package/dist/indexedDb.js +171 -0
  19. package/dist/indexedDb.js.map +1 -0
  20. package/dist/statewire.d.ts +117 -59
  21. package/dist/statewire.d.ts.map +1 -1
  22. package/dist/statewire.js +13 -25
  23. package/dist/statewire.js.map +1 -1
  24. package/dist/storage.d.ts +42 -0
  25. package/dist/storage.d.ts.map +1 -1
  26. package/dist/storage.js +387 -125
  27. package/dist/storage.js.map +1 -1
  28. package/dist/transports/remote.d.ts.map +1 -1
  29. package/dist/transports/remote.js +7 -9
  30. package/dist/transports/remote.js.map +1 -1
  31. package/dist/types.d.ts +6 -7
  32. package/dist/types.d.ts.map +1 -1
  33. package/dist/types.js.map +1 -1
  34. package/package.json +2 -1
  35. package/src/client/core.ts +81 -64
  36. package/src/client/session.ts +886 -424
  37. package/src/client/sessionStore.ts +2 -0
  38. package/src/host/host.ts +10 -3
  39. package/src/indexedDb.ts +167 -0
  40. package/src/statewire.ts +133 -86
  41. package/src/storage.ts +548 -112
  42. package/src/transports/remote.ts +13 -14
  43. 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
- const isFailedCommand = (
107
- value: unknown,
108
- ): value is StatewireClient.FailedCommand =>
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
- (value["outbox"] === undefined ||
128
- (Array.isArray(value["outbox"]) &&
129
- value["outbox"].every(isOutboxCommand))) &&
130
- (value["removed"] === undefined ||
131
- (Array.isArray(value["removed"]) &&
132
- value["removed"].every(
133
- (mark) =>
134
- isRecord(mark) &&
135
- typeof mark["id"] === "string" &&
136
- typeof mark["by"] === "string",
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
- ): StatewireClient.SessionRecord => {
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, StatewireClient.SessionLane> = {
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 === 4) return isSessionRecord(value) ? value : invalid();
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) => isLaneWith(lane, isLegacyCommand))
266
- ? identify(value)
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 isEmpty = (record: StatewireClient.SessionRecord) =>
281
- Object.values(record.lanes).every(
282
- (lane) =>
283
- lane.commands.length === 0 &&
284
- !lane.outbox?.length &&
285
- !lane.failed?.length,
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
- const sweep = async (
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 entries: { key: string; savedAt: number }[] = [];
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 = JSON.parse(raw);
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
- if (!isEmpty(normalize(value, DEFAULT_PROTOCOL))) return undefined;
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 savedAt = candidate(await backend.getItem(key));
329
- if (savedAt !== undefined) entries.push({ key, savedAt });
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
- entries.sort((a, b) => b.savedAt - a.savedAt);
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(entries.length / 2);
335
- for (const [index, entry] of entries.entries()) {
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 || overflow || (quota && index >= quotaStart)) {
342
- const remove = async () => {
343
- if (active.get(backend)?.has(entry.key)) return;
344
- if (candidate(await backend.getItem(entry.key)) !== entry.savedAt)
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 latestErrorHandler = useRef(onError);
371
- latestErrorHandler.current = onError;
572
+ const latest = useRef({ onError, onEvict });
573
+ latest.current = { onError, onEvict };
372
574
  const reportError = useMemo(
373
- () => (error: unknown) => latestErrorHandler.current?.(error),
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: typeof raw !== "string" && raw !== null,
431
- hydrated: typeof raw === "string" || raw === null,
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.record = decode(value, protocol);
437
- state.hydrated = true;
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.record = decode(raw as string | null, protocol);
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
- return useMemo(
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
- return decode(raw, protocol);
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: 4,
801
+ statewireStorageVersion: STORAGE_FORMAT,
519
802
  savedAt: Date.now(),
520
803
  });
521
- state.record = record;
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
- result = backend.setItem(storageKey, encoded);
807
+ backend.setItem(storageKey, encoded) as void;
538
808
  } catch (error) {
539
- if (!isQuotaError(error) || retention === undefined) {
809
+ if (!isQuotaError(error)) {
540
810
  state.failure = wrapStorageError(error);
541
811
  reportError(state.failure);
542
812
  throw state.failure;
543
813
  }
544
- result = recoverQuota(error);
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
- if (result === undefined) return;
547
- state.pending++;
548
- state.queue = result;
549
- } else {
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
- state,
581
- backend,
582
- storageKey,
583
- protocol,
584
- migrate,
585
- retention,
586
- configured,
587
- reportError,
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";