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/dist/storage.js CHANGED
@@ -1,8 +1,11 @@
1
1
  import { DEFAULT_PROTOCOL, negotiationHeaders, protocolToken } from "./wire/protocol.js";
2
+ import { reviveDocumentValue } from "./document.js";
2
3
  import { wrapStorageError } from "./client/storageError.js";
3
4
  import { randomId } from "./util/randomId.js";
5
+ import { IndexedDbSession, indexedDbBackend } from "./indexedDb.js";
4
6
  import { useEffect, useMemo, useRef } from "@assistant-ui/tap/react-shim";
5
7
  import { resource } from "@assistant-ui/tap";
8
+ import { c } from "@assistant-ui/tap/react-shim/compiler-runtime";
6
9
  // src/storage.ts
7
10
  const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
8
11
  const isOptionalString = (value) => value === void 0 || typeof value === "string";
@@ -11,7 +14,9 @@ const isStatement = (value) => isRecord(value) && isProtocol(value["protocol"])
11
14
  const isIdentified = (value) => typeof value["id"] === "string" && value["id"] !== "" && typeof value["ts"] === "number" && Number.isFinite(value["ts"]);
12
15
  const isLegacyCommand = (value) => isStatement(value) && isPositiveSafeInteger(value["seq"]);
13
16
  const isSessionCommand = (value) => isLegacyCommand(value) && isIdentified(value) && isOptionalString(value["owner"]);
14
- const isOutboxCommand = (value) => isStatement(value) && isIdentified(value) && (value["hold"] === void 0 || isRecord(value["hold"])) && (value["released"] === void 0 || value["released"] === true) && isOptionalString(value["by"]);
17
+ const isOutboxCommand = (value) => isStatement(value) && isIdentified(value) && (value["held"] === void 0 || value["held"] === true) && (value["released"] === void 0 || value["released"] === true) && isOptionalString(value["by"]);
18
+ const isLegacyOutboxCommand = (value) => isStatement(value) && isIdentified(value) && (value["hold"] === void 0 || isRecord(value["hold"])) && (value["released"] === void 0 || value["released"] === true) && isOptionalString(value["by"]);
19
+ const isStoredSettled = (value) => isRecord(value) && isIdentified(value) && typeof value["method"] === "string" && Array.isArray(value["params"]) && (value["seq"] === void 0 || isPositiveSafeInteger(value["seq"])) && (value["failure"] === void 0 ? isRecord(value["result"]) && Array.isArray(value["result"]["value"]) : value["result"] === void 0 && isSendFailure(value["failure"]));
15
20
  const isSessionVerdict = (value) => isRecord(value) && isPositiveSafeInteger(value["seq"]) && isOptionalString(value["id"]) && (value["type"] === "result" || value["type"] === "rejected" || value["type"] === "failed" || value["type"] === "crashed" || value["type"] === "expired") && isOptionalString(value["code"]) && isOptionalString(value["message"]);
16
21
  const isSendFailure = (value) => {
17
22
  if (!isRecord(value)) return false;
@@ -20,13 +25,15 @@ const isSendFailure = (value) => {
20
25
  case "failed": return value["cause"] === "server" && isOptionalString(value["message"]);
21
26
  case "crashed":
22
27
  case "expired": return value["cause"] === "server";
23
- case "aborted": return (value["cause"] === "unmount" || value["cause"] === "stream-gone" || value["cause"] === "session-lost" || value["cause"] === "removed") && (value["predecessorLost"] === void 0 || value["predecessorLost"] === true);
28
+ case "aborted": return (value["cause"] === "unmount" || value["cause"] === "stream-gone" || value["cause"] === "session-lost" || value["cause"] === "removed" || value["cause"] === "quota") && (value["predecessorLost"] === void 0 || value["predecessorLost"] === true);
24
29
  case "lost": return value["cause"] === "unmount" || value["cause"] === "stream-gone" || value["cause"] === "session-lost";
25
30
  default: return false;
26
31
  }
27
32
  };
28
33
  const isFailedCommand = (value) => isRecord(value) && isProtocol(value["protocol"]) && isProducer(value["produced"]) && typeof value["command"] === "string" && Array.isArray(value["params"]) && isSendFailure(value["failure"]) && typeof value["ts"] === "number" && Number.isFinite(value["ts"]) && isPositiveSafeInteger(value["seq"]);
29
- const isLaneWith = (value, isCommand) => isRecord(value) && isPositiveSafeInteger(value["nextSeq"]) && Array.isArray(value["commands"]) && value["commands"].every(isCommand) && (value["outbox"] === void 0 || Array.isArray(value["outbox"]) && value["outbox"].every(isOutboxCommand)) && (value["removed"] === void 0 || Array.isArray(value["removed"]) && value["removed"].every((mark) => isRecord(mark) && typeof mark["id"] === "string" && typeof mark["by"] === "string")) && (value["verdicts"] === void 0 || Array.isArray(value["verdicts"]) && value["verdicts"].every(isSessionVerdict)) && (value["failed"] === void 0 || Array.isArray(value["failed"]) && value["failed"].every(isFailedCommand));
34
+ const isArrayOf = (value, is) => value === void 0 || Array.isArray(value) && value.every(is);
35
+ const isMark = (mark) => isRecord(mark) && typeof mark["id"] === "string" && typeof mark["by"] === "string" && (mark["failure"] === void 0 || isSendFailure(mark["failure"]));
36
+ const isLaneWith = (value, isCommand, legacy = false) => isRecord(value) && isPositiveSafeInteger(value["nextSeq"]) && Array.isArray(value["commands"]) && value["commands"].every(isCommand) && isArrayOf(value["outbox"], legacy ? isLegacyOutboxCommand : isOutboxCommand) && isArrayOf(value["removed"], isMark) && isArrayOf(value["verdicts"], isSessionVerdict) && (legacy ? isArrayOf(value["failed"], isFailedCommand) : value["failed"] === void 0 && isArrayOf(value["inbox"], isStoredSettled));
30
37
  const isSessionLane = (value) => isLaneWith(value, isSessionCommand);
31
38
  const isClientId = (value) => typeof value === "string" && value.length > 0;
32
39
  const isRecordWith = (value, isLane) => isRecord(value) && isClientId(value["clientId"]) && isRecord(value["lanes"]) && Object.keys(value["lanes"]).every(isProtocol) && Object.values(value["lanes"]).every(isLane);
@@ -93,71 +100,162 @@ const liftLegacy = (value, protocol) => {
93
100
  lanes
94
101
  });
95
102
  };
103
+ const upgrade = (record) => ({
104
+ clientId: record.clientId,
105
+ lanes: Object.fromEntries(Object.entries(record.lanes).map(([protocol, legacy]) => {
106
+ const { outbox, failed, ...lane } = legacy;
107
+ const inbox = [...failed ?? []].sort((a, b) => a.ts - b.ts).map((f) => ({
108
+ id: `failed:${protocol}:${f.seq}:${f.ts}`,
109
+ method: f.command,
110
+ params: f.params,
111
+ ...f.meta !== void 0 && { meta: f.meta },
112
+ ts: f.ts,
113
+ failure: f.failure
114
+ }));
115
+ return [protocol, {
116
+ ...lane,
117
+ ...outbox?.length && { outbox: outbox.map(({ hold, ...command }) => ({
118
+ ...command,
119
+ ...hold !== void 0 && { held: true },
120
+ ...hold?.meta !== void 0 && { localMeta: hold.meta }
121
+ })) },
122
+ ...inbox.length > 0 && { inbox }
123
+ }];
124
+ }))
125
+ });
126
+ const isLegacyLane = (lane) => isLaneWith(lane, isSessionCommand, true);
96
127
  const normalize = (parsed, protocol) => {
97
128
  if (!isRecord(parsed)) return invalid();
98
129
  const { statewireStorageVersion: format, savedAt: _savedAt, ...value } = parsed;
99
- if (format === 4) return isSessionRecord(value) ? value : invalid();
100
- if (format === 3) return isRecordWith(value, (lane) => isLaneWith(lane, isLegacyCommand)) ? identify(value) : invalid();
130
+ if (format === 5) return isSessionRecord(value) ? value : invalid();
131
+ if (format === 4) return isRecordWith(value, isLegacyLane) ? upgrade(value) : invalid();
132
+ if (format === 3) return isRecordWith(value, (lane) => isLaneWith(lane, isLegacyCommand, true)) ? upgrade(identify(value)) : invalid();
101
133
  if (format !== void 0 && format !== 1 && format !== 2) throw new Error("statewire: unsupported session storage version");
102
134
  const { ctxNextSeq: _ctxNextSeq, context: _context, ...legacy } = value;
103
- return liftLegacy(legacy, protocol);
135
+ return upgrade(liftLegacy(legacy, protocol));
104
136
  };
137
+ const STORAGE_FORMAT = 5;
138
+ const parse = async (raw, codec) => raw === null ? null : JSON.parse(codec === void 0 ? raw : await codec.decode(raw));
105
139
  const decode = (raw, protocol) => raw === null ? null : normalize(JSON.parse(raw), protocol);
106
- const isEmpty = (record) => Object.values(record.lanes).every((lane) => lane.commands.length === 0 && !lane.outbox?.length && !lane.failed?.length);
140
+ const isUnsent = (lane) => lane.commands.length > 0 || (lane.outbox?.length ?? 0) > 0;
141
+ const entryCount = (record) => record === null ? 0 : Object.values(record.lanes).reduce((sum, lane) => sum + lane.commands.length + (lane.outbox?.length ?? 0), 0);
142
+ const noticesOf = (record) => Object.entries(record.lanes).flatMap(([protocol, lane]) => (lane.inbox ?? []).map((n) => ({
143
+ protocol,
144
+ id: n.id,
145
+ method: n.method,
146
+ params: n.params,
147
+ ...n.seq !== void 0 && { seq: n.seq },
148
+ status: "settled",
149
+ ...n.failure !== void 0 && { failure: n.failure },
150
+ ...n.localMeta !== void 0 && { localMeta: n.localMeta }
151
+ })));
107
152
  const active = /* @__PURE__ */ new WeakMap();
108
- const sweep = async (options, currentKey, quota = false) => {
109
- const { backend, retention, namespace = "" } = options;
153
+ /** Same-runtime saves, per backend, for `subscribeOutboxes`. */
154
+ const savers = /* @__PURE__ */ new WeakMap();
155
+ const announce = (backend, key) => {
156
+ for (const listener of savers.get(backend) ?? []) listener(key);
157
+ };
158
+ const sweeping = /* @__PURE__ */ new WeakMap();
159
+ const sweep = (options, currentKey, quota = false) => {
160
+ const run = (sweeping.get(options.backend) ?? Promise.resolve()).catch(() => {}).then(() => sweepOnce(options, currentKey, quota));
161
+ sweeping.set(options.backend, run);
162
+ return run;
163
+ };
164
+ const sweepOnce = async (options, currentKey, quota = false) => {
165
+ const { backend, retention, namespace = "", codec, onEvict } = options;
110
166
  if (!retention || !backend.getAllKeys) return;
111
- const entries = [];
112
- const candidate = (raw) => {
113
- if (raw === null) return void 0;
167
+ const candidate = async (raw) => {
114
168
  let value;
115
169
  try {
116
- value = JSON.parse(raw);
170
+ value = await parse(raw, codec);
117
171
  } catch {
118
172
  return;
119
173
  }
174
+ if (value === null) return void 0;
175
+ let record;
120
176
  try {
121
- if (!isEmpty(normalize(value, "default"))) return void 0;
177
+ record = normalize(value, DEFAULT_PROTOCOL);
122
178
  } catch {
123
179
  return;
124
180
  }
181
+ if (Object.values(record.lanes).some(isUnsent)) return void 0;
125
182
  const savedAt = value["savedAt"] ?? value["updatedAt"];
126
- return typeof savedAt === "number" && Number.isFinite(savedAt) ? savedAt : void 0;
183
+ return typeof savedAt === "number" && Number.isFinite(savedAt) ? {
184
+ savedAt,
185
+ record
186
+ } : void 0;
127
187
  };
188
+ const empty = [];
189
+ const noticed = [];
128
190
  for (const key of await backend.getAllKeys()) {
129
191
  if (!key.startsWith(namespace) || key === currentKey || active.get(backend)?.has(key)) continue;
130
- const savedAt = candidate(await backend.getItem(key));
131
- if (savedAt !== void 0) entries.push({
192
+ const found = await candidate(await backend.getItem(key));
193
+ if (found === void 0) continue;
194
+ (noticesOf(found.record).length > 0 ? noticed : empty).push({
132
195
  key,
133
- savedAt
196
+ ...found
134
197
  });
135
198
  }
136
- entries.sort((a, b) => b.savedAt - a.savedAt);
199
+ const remove = async (entry, reason) => {
200
+ const run = async () => {
201
+ if (active.get(backend)?.has(entry.key)) return;
202
+ const again = await candidate(await backend.getItem(entry.key));
203
+ if (again?.savedAt !== entry.savedAt) return;
204
+ if (active.get(backend)?.has(entry.key)) return;
205
+ await backend.removeItem(entry.key);
206
+ announce(backend, entry.key);
207
+ onEvict?.({
208
+ key: entry.key,
209
+ reason,
210
+ dropped: noticesOf(again.record)
211
+ });
212
+ };
213
+ if (backend.withUnused) await backend.withUnused(entry.key, run);
214
+ else await run();
215
+ };
137
216
  const now = Date.now();
217
+ const expired = (entry) => retention.maxAgeMs !== void 0 && now - entry.savedAt > retention.maxAgeMs;
218
+ empty.sort((a, b) => b.savedAt - a.savedAt);
138
219
  let retained = 1;
139
- const quotaStart = Math.floor(entries.length / 2);
140
- for (const [index, entry] of entries.entries()) {
141
- const expired = retention.maxAgeMs !== void 0 && now - entry.savedAt > retention.maxAgeMs;
220
+ const quotaStart = Math.floor(empty.length / 2);
221
+ for (const [index, entry] of empty.entries()) {
142
222
  const overflow = retention.maxEntries !== void 0 && retained >= retention.maxEntries;
143
- if (expired || overflow || quota && index >= quotaStart) {
144
- const remove = async () => {
145
- if (active.get(backend)?.has(entry.key)) return;
146
- if (candidate(await backend.getItem(entry.key)) !== entry.savedAt) return;
147
- if (active.get(backend)?.has(entry.key)) return;
148
- await backend.removeItem(entry.key);
149
- };
150
- if (backend.withUnused) await backend.withUnused(entry.key, remove);
151
- else await remove();
152
- } else retained++;
223
+ if (expired(entry)) await remove(entry, "expired");
224
+ else if (overflow) await remove(entry, "overflow");
225
+ else if (quota && index >= quotaStart) await remove(entry, "quota");
226
+ else retained++;
153
227
  }
228
+ for (const entry of noticed) if (expired(entry)) await remove(entry, "expired");
154
229
  };
155
230
  const isQuotaError = (error) => error instanceof Error && error.name === "QuotaExceededError";
231
+ const quotaError = (limit) => {
232
+ const error = /* @__PURE__ */ new Error(`statewire: session ${limit} limit reached`);
233
+ error.name = "StatewireQuotaError";
234
+ return error;
235
+ };
236
+ const otherBytes = async (backend, namespace, key) => {
237
+ let total = 0;
238
+ for (const other of await backend.getAllKeys?.() ?? []) {
239
+ if (other === key || !other.startsWith(namespace)) continue;
240
+ total += (await backend.getItem(other))?.length ?? 0;
241
+ }
242
+ return total;
243
+ };
244
+ const validLimit = (value, name) => {
245
+ if (value !== void 0 && (!Number.isSafeInteger(value) || value < 1)) throw new Error(`statewire: invalid session limit ${name}`);
246
+ };
156
247
  const useKeyValueSessionStorage = (options) => {
157
- const { backend, key, namespace = "", protocol = DEFAULT_PROTOCOL, migrate, retention: suppliedRetention, onError } = options;
158
- const latestErrorHandler = useRef(onError);
159
- latestErrorHandler.current = onError;
160
- const reportError = useMemo(() => (error) => latestErrorHandler.current?.(error), []);
248
+ const { backend, key, namespace = "", protocol = DEFAULT_PROTOCOL, migrate, retention: suppliedRetention, limits: suppliedLimits, codec, onError, onEvict } = options;
249
+ const latest = useRef({
250
+ onError,
251
+ onEvict
252
+ });
253
+ latest.current = {
254
+ onError,
255
+ onEvict
256
+ };
257
+ const reportError = useMemo(() => (error) => latest.current.onError?.(error), []);
258
+ const reportEvict = useMemo(() => (event) => latest.current.onEvict?.(event), []);
161
259
  const retain = suppliedRetention !== void 0;
162
260
  const maxAgeMs = suppliedRetention?.maxAgeMs;
163
261
  const maxEntries = suppliedRetention?.maxEntries;
@@ -169,16 +267,27 @@ const useKeyValueSessionStorage = (options) => {
169
267
  maxAgeMs,
170
268
  maxEntries
171
269
  ]);
270
+ const outboxEntries = suppliedLimits?.outboxEntries;
271
+ const recordBytes = suppliedLimits?.recordBytes;
272
+ const totalBytes = suppliedLimits?.totalBytes;
273
+ validLimit(outboxEntries, "outboxEntries");
274
+ validLimit(recordBytes, "recordBytes");
275
+ validLimit(totalBytes, "totalBytes");
276
+ if (totalBytes !== void 0 && !backend.getAllKeys) throw new Error("statewire: a totalBytes limit requires getAllKeys");
172
277
  const configured = useMemo(() => ({
173
278
  backend,
174
279
  key,
175
280
  namespace,
281
+ onEvict: reportEvict,
282
+ ...codec === void 0 ? {} : { codec },
176
283
  ...retention === void 0 ? {} : { retention }
177
284
  }), [
178
285
  backend,
179
286
  key,
180
287
  namespace,
181
- retention
288
+ retention,
289
+ codec,
290
+ reportEvict
182
291
  ]);
183
292
  const storageKey = namespace + key;
184
293
  if (retention !== void 0) {
@@ -198,17 +307,24 @@ const useKeyValueSessionStorage = (options) => {
198
307
  }
199
308
  const state = {
200
309
  record: null,
310
+ bytes: 0,
311
+ others: void 0,
201
312
  ready: void 0,
202
313
  queue: Promise.resolve(),
203
314
  pending: 0,
204
315
  failure: void 0,
205
- asynchronous: typeof raw !== "string" && raw !== null,
206
- hydrated: typeof raw === "string" || raw === null
316
+ asynchronous: codec !== void 0 || typeof raw !== "string" && raw !== null,
317
+ hydrated: false
318
+ };
319
+ const hydrate = (record) => {
320
+ state.record = record;
321
+ state.hydrated = true;
207
322
  };
208
323
  if (state.asynchronous) {
209
- state.ready = Promise.resolve(raw).then((value) => {
210
- state.record = decode(value, protocol);
211
- state.hydrated = true;
324
+ state.ready = Promise.resolve(raw).then(async (value) => {
325
+ state.bytes = value?.length ?? 0;
326
+ const parsed = await parse(value, codec);
327
+ hydrate(parsed === null ? null : normalize(parsed, protocol));
212
328
  }).catch((cause_0) => {
213
329
  const error_1 = wrapStorageError(cause_0);
214
330
  if (state.failure !== error_1) reportError(error_1);
@@ -217,7 +333,8 @@ const useKeyValueSessionStorage = (options) => {
217
333
  });
218
334
  state.ready.catch(() => {});
219
335
  } else try {
220
- state.record = decode(raw, protocol);
336
+ state.bytes = raw?.length ?? 0;
337
+ hydrate(decode(raw, protocol));
221
338
  } catch (cause_1) {
222
339
  const error_2 = wrapStorageError(cause_1);
223
340
  reportError(error_2);
@@ -228,6 +345,7 @@ const useKeyValueSessionStorage = (options) => {
228
345
  backend,
229
346
  storageKey,
230
347
  protocol,
348
+ codec,
231
349
  reportError
232
350
  ]);
233
351
  useEffect(() => {
@@ -253,94 +371,151 @@ const useKeyValueSessionStorage = (options) => {
253
371
  retention,
254
372
  reportError
255
373
  ]);
256
- return useMemo(() => ({
257
- ...state_0.ready === void 0 ? {} : { ready: state_0.ready },
258
- load: () => {
259
- if (state_0.failure !== void 0) throw state_0.failure;
260
- if (!state_0.hydrated) throw new Error("statewire: await session storage readiness before loading");
261
- if (state_0.asynchronous || state_0.pending > 0) return state_0.record;
262
- let raw_0;
263
- try {
264
- raw_0 = backend.getItem(storageKey);
265
- } catch (cause_3) {
266
- const error_4 = wrapStorageError(cause_3);
267
- reportError(error_4);
268
- throw error_4;
269
- }
270
- if (typeof raw_0 !== "string" && raw_0 !== null) throw new Error("statewire: storage backend changed from synchronous to asynchronous");
374
+ useEffect(() => {
375
+ if (totalBytes === void 0) return;
376
+ otherBytes(backend, namespace, storageKey).then((bytes) => {
377
+ state_0.others = bytes;
378
+ }, () => {});
379
+ }, [
380
+ state_0,
381
+ backend,
382
+ namespace,
383
+ storageKey,
384
+ totalBytes
385
+ ]);
386
+ return useMemo(() => {
387
+ const checkLimits = (record_0, bytes_0) => {
388
+ if (entryCount(record_0) <= entryCount(state_0.record)) return;
389
+ if (outboxEntries !== void 0 && entryCount(record_0) > outboxEntries) throw quotaError("outboxEntries");
390
+ if (recordBytes !== void 0 && bytes_0 > recordBytes) throw quotaError("recordBytes");
391
+ if (totalBytes !== void 0 && state_0.others !== void 0 && state_0.others + bytes_0 > totalBytes) throw quotaError("totalBytes");
392
+ };
393
+ const persist = async (encoded) => {
394
+ const stored = codec === void 0 ? encoded : await codec.encode(encoded);
271
395
  try {
272
- return decode(raw_0, protocol);
273
- } catch (cause_4) {
274
- const error_5 = wrapStorageError(cause_4);
275
- reportError(error_5);
276
- throw error_5;
396
+ await backend.setItem(storageKey, stored);
397
+ } catch (error_4) {
398
+ if (!isQuotaError(error_4)) throw error_4;
399
+ if (retention !== void 0) await sweep(configured, storageKey, true);
400
+ await backend.setItem(storageKey, stored);
277
401
  }
278
- },
279
- save: (record) => {
280
- if (state_0.failure !== void 0) throw state_0.failure;
281
- if (!state_0.hydrated) throw new Error("statewire: await session storage readiness before saving");
282
- if (!isSessionRecord(record)) throw new Error("statewire: invalid session record");
283
- const encoded = JSON.stringify({
284
- ...record,
285
- statewireStorageVersion: 4,
286
- savedAt: Date.now()
287
- });
288
- state_0.record = record;
289
- const recoverQuota = async (error_6) => {
290
- if (!isQuotaError(error_6) || retention === void 0) throw error_6;
291
- await sweep(configured, storageKey, true);
292
- await backend.setItem(storageKey, encoded);
293
- };
294
- const write = async () => {
402
+ announce(backend, storageKey);
403
+ };
404
+ return {
405
+ durable: true,
406
+ ...state_0.ready === void 0 ? {} : { ready: state_0.ready },
407
+ read: async () => {
408
+ if (state_0.failure !== void 0) throw state_0.failure;
409
+ await state_0.ready;
410
+ await state_0.queue.catch(() => {});
295
411
  try {
296
- await backend.setItem(storageKey, encoded);
297
- } catch (error_7) {
298
- await recoverQuota(error_7);
412
+ const raw_0 = await backend.getItem(storageKey);
413
+ const parsed_0 = await parse(raw_0, codec);
414
+ state_0.bytes = raw_0?.length ?? 0;
415
+ state_0.record = parsed_0 === null ? null : normalize(parsed_0, protocol);
416
+ return state_0.record;
417
+ } catch (cause_3) {
418
+ const error_5 = wrapStorageError(cause_3);
419
+ reportError(error_5);
420
+ throw error_5;
299
421
  }
300
- };
301
- if (!state_0.asynchronous && state_0.pending === 0) {
302
- let result;
422
+ },
423
+ load: () => {
424
+ if (state_0.failure !== void 0) throw state_0.failure;
425
+ if (!state_0.hydrated) throw new Error("statewire: await session storage readiness before loading");
426
+ if (state_0.asynchronous || state_0.pending > 0) return state_0.record;
427
+ let raw_1;
303
428
  try {
304
- result = backend.setItem(storageKey, encoded);
305
- } catch (error_8) {
306
- if (!isQuotaError(error_8) || retention === void 0) {
307
- state_0.failure = wrapStorageError(error_8);
308
- reportError(state_0.failure);
309
- throw state_0.failure;
429
+ raw_1 = backend.getItem(storageKey);
430
+ } catch (cause_4) {
431
+ const error_6 = wrapStorageError(cause_4);
432
+ reportError(error_6);
433
+ throw error_6;
434
+ }
435
+ if (typeof raw_1 !== "string" && raw_1 !== null) throw new Error("statewire: storage backend changed from synchronous to asynchronous");
436
+ try {
437
+ state_0.record = decode(raw_1, protocol);
438
+ return state_0.record;
439
+ } catch (cause_5) {
440
+ const error_7 = wrapStorageError(cause_5);
441
+ reportError(error_7);
442
+ throw error_7;
443
+ }
444
+ },
445
+ save: (record_1) => {
446
+ if (state_0.failure !== void 0) throw state_0.failure;
447
+ if (!state_0.hydrated) throw new Error("statewire: await session storage readiness before saving");
448
+ if (!isSessionRecord(record_1)) throw new Error("statewire: invalid session record");
449
+ const encoded_0 = JSON.stringify({
450
+ ...record_1,
451
+ statewireStorageVersion: 5,
452
+ savedAt: Date.now()
453
+ });
454
+ checkLimits(record_1, encoded_0.length);
455
+ if (!state_0.asynchronous && state_0.pending === 0) {
456
+ try {
457
+ backend.setItem(storageKey, encoded_0);
458
+ } catch (error_8) {
459
+ if (!isQuotaError(error_8)) {
460
+ state_0.failure = wrapStorageError(error_8);
461
+ reportError(state_0.failure);
462
+ throw state_0.failure;
463
+ }
464
+ if (entryCount(record_1) > entryCount(state_0.record)) {
465
+ if (retention !== void 0) sweep(configured, storageKey, true).catch(() => {});
466
+ throw quotaError("backend");
467
+ }
468
+ state_0.record = record_1;
469
+ state_0.bytes = encoded_0.length;
470
+ state_0.pending++;
471
+ state_0.queue = persist(encoded_0).finally(() => {
472
+ state_0.pending--;
473
+ });
474
+ state_0.queue.catch((cause_6) => {
475
+ const failure = wrapStorageError(cause_6);
476
+ if (state_0.failure !== failure) reportError(failure);
477
+ state_0.failure = failure;
478
+ });
479
+ return;
310
480
  }
311
- result = recoverQuota(error_8);
481
+ state_0.record = record_1;
482
+ state_0.bytes = encoded_0.length;
483
+ announce(backend, storageKey);
484
+ return;
312
485
  }
313
- if (result === void 0) return;
486
+ state_0.record = record_1;
487
+ state_0.bytes = encoded_0.length;
314
488
  state_0.pending++;
315
- state_0.queue = result;
316
- } else {
317
- state_0.pending++;
318
- state_0.queue = state_0.queue.then(() => state_0.ready).then(write);
319
- }
320
- state_0.queue = state_0.queue.catch((cause_5) => {
321
- const error_9 = wrapStorageError(cause_5);
322
- if (state_0.failure !== error_9) reportError(error_9);
323
- state_0.failure = error_9;
324
- throw error_9;
325
- }).finally(() => {
326
- state_0.pending--;
327
- });
328
- state_0.queue.catch(() => {});
329
- },
330
- flush: () => {
331
- if (state_0.failure !== void 0) throw state_0.failure;
332
- if (!state_0.asynchronous && state_0.pending === 0) return;
333
- return (async () => {
334
- await state_0.ready;
335
- for (;;) {
336
- const queue = state_0.queue;
337
- await queue;
338
- if (queue === state_0.queue) return;
339
- }
340
- })();
341
- },
342
- ...migrate === void 0 ? {} : { migrate }
343
- }), [
489
+ state_0.queue = state_0.queue.then(() => state_0.ready).then(() => persist(encoded_0)).catch((cause_7) => {
490
+ const error_9 = wrapStorageError(cause_7);
491
+ if (state_0.failure !== error_9) reportError(error_9);
492
+ state_0.failure = error_9;
493
+ throw error_9;
494
+ }).finally(() => {
495
+ state_0.pending--;
496
+ });
497
+ state_0.queue.catch(() => {});
498
+ },
499
+ flush: () => {
500
+ if (state_0.failure !== void 0) throw state_0.failure;
501
+ if (!state_0.asynchronous && state_0.pending === 0) return;
502
+ return (async () => {
503
+ await state_0.ready;
504
+ for (;;) {
505
+ const queue = state_0.queue;
506
+ await queue;
507
+ if (queue === state_0.queue) return;
508
+ }
509
+ })();
510
+ },
511
+ onEvict: ({ reason, dropped }) => reportEvict({
512
+ key: storageKey,
513
+ reason,
514
+ dropped
515
+ }),
516
+ ...migrate === void 0 ? {} : { migrate }
517
+ };
518
+ }, [
344
519
  state_0,
345
520
  backend,
346
521
  storageKey,
@@ -348,11 +523,98 @@ const useKeyValueSessionStorage = (options) => {
348
523
  migrate,
349
524
  retention,
350
525
  configured,
351
- reportError
526
+ codec,
527
+ outboxEntries,
528
+ recordBytes,
529
+ totalBytes,
530
+ reportError,
531
+ reportEvict
352
532
  ]);
353
533
  };
354
534
  /** Persist sessions through a synchronous or asynchronous string storage backend. */
355
535
  const KeyValueSessionStorage = resource(useKeyValueSessionStorage);
356
- export { KeyValueSessionStorage };
536
+ const pages = /* @__PURE__ */ new Map();
537
+ const useMemorySessionStorage = (t0) => {
538
+ const $ = c(2);
539
+ const { scope } = t0;
540
+ let t1;
541
+ if ($[0] !== scope) {
542
+ t1 = {
543
+ durable: false,
544
+ load: () => pages.get(scope) ?? null,
545
+ save: (record) => {
546
+ pages.set(scope, record);
547
+ }
548
+ };
549
+ $[0] = scope;
550
+ $[1] = t1;
551
+ } else t1 = $[1];
552
+ return t1;
553
+ };
554
+ /** Session storage in page memory, keyed by scope: the record and client identity survive remounts, not reloads. */
555
+ const MemorySessionStorage = resource(useMemorySessionStorage);
556
+ const entriesOf = (record) => Object.entries(record.lanes).flatMap(([protocol, lane]) => {
557
+ const view = (c, state) => ({
558
+ id: c.id,
559
+ protocol,
560
+ command: {
561
+ method: c.method,
562
+ params: c.meta === void 0 ? c.params : reviveDocumentValue(structuredClone(c.params), c.meta)
563
+ },
564
+ ts: c.ts,
565
+ state,
566
+ localMeta: c.localMeta,
567
+ durable: true
568
+ });
569
+ return [...[...lane.commands].sort((a, b) => a.seq - b.seq).map((c) => view(c, "sending")), ...(lane.outbox ?? []).map((c) => view(c, c.held ? "held" : "queued"))];
570
+ });
571
+ /** Reads the outboxes of every session under `namespace`, mounted or not. */
572
+ const readOutboxes = async (backend, options) => {
573
+ if (!backend.getAllKeys) throw new Error("statewire: readOutboxes requires getAllKeys");
574
+ const { namespace, codec, protocol = DEFAULT_PROTOCOL } = options;
575
+ if (!namespace) throw new Error("statewire: readOutboxes requires a namespace");
576
+ const result = [];
577
+ for (const key of await backend.getAllKeys()) {
578
+ if (!key.startsWith(namespace)) continue;
579
+ const parsed = await parse(await backend.getItem(key), codec);
580
+ if (parsed === null) continue;
581
+ const entries = entriesOf(normalize(parsed, protocol));
582
+ if (entries.length > 0) result.push({
583
+ key,
584
+ entries
585
+ });
586
+ }
587
+ return result;
588
+ };
589
+ /** Calls `listener` with `readOutboxes` now and after every save under `namespace`; returns unsubscribe. */
590
+ const subscribeOutboxes = (backend, options, listener, onError) => {
591
+ let closed = false;
592
+ let reading = Promise.resolve();
593
+ const refresh = () => {
594
+ reading = reading.then(async () => {
595
+ if (closed) return;
596
+ try {
597
+ const outboxes = await readOutboxes(backend, options);
598
+ if (!closed) listener(outboxes);
599
+ } catch (error) {
600
+ onError(error);
601
+ }
602
+ });
603
+ };
604
+ const onKey = (key) => {
605
+ if (key.startsWith(options.namespace)) refresh();
606
+ };
607
+ const local = savers.get(backend) ?? /* @__PURE__ */ new Set();
608
+ savers.set(backend, local);
609
+ local.add(onKey);
610
+ const remote = backend.subscribe?.(onKey);
611
+ refresh();
612
+ return () => {
613
+ closed = true;
614
+ local.delete(onKey);
615
+ remote?.();
616
+ };
617
+ };
618
+ export { IndexedDbSession, KeyValueSessionStorage, MemorySessionStorage, STORAGE_FORMAT, indexedDbBackend, readOutboxes, subscribeOutboxes };
357
619
 
358
620
  //# sourceMappingURL=storage.js.map