@loggerjs/browser 0.5.2 → 0.5.4

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.
@@ -1,13 +1,99 @@
1
- import { incrementLoggerMetaCounter, safeJsonCodec, toLevelValue } from "@loggerjs/core";
1
+ import { incrementLoggerMetaCounter, safeJsonCodec, safeJsonStringify, toLevelValue } from "@loggerjs/core";
2
2
  //#region src/indexeddb-transport.ts
3
3
  const DEFAULT_DB_NAME = "loggerjs";
4
4
  const DEFAULT_STORE_NAME = "logs";
5
- const DB_VERSION = 2;
5
+ const DEFAULT_SESSION_CONTEXT_KEY = "sessionId";
6
+ const DEFAULT_SPILL_MAX_ENTRIES = 200;
7
+ const DEFAULT_SPILL_MAX_BYTES = 512 * 1024;
8
+ const DEFAULT_SPILL_MAX_ORIGIN_BYTES = 5 * 1024 * 1024;
9
+ const DB_VERSION = 4;
6
10
  const ORDER_INDEX_NAME = "createdAtSeq";
11
+ const SESSION_INDEX_NAME = "sessionId";
12
+ const SESSION_ORDER_INDEX_NAME = "sessionIdCreatedAtSeq";
13
+ const SESSION_STORE_SUFFIX = "__sessions";
14
+ const SESSION_LAST_SEEN_INDEX_NAME = "lastSeenSessionId";
15
+ const SPILL_SCHEMA = "loggerjs.indexeddb-spill.v1";
7
16
  function normalizePositiveInteger(value, fallback) {
8
17
  if (value === void 0 || !Number.isFinite(value)) return fallback;
9
18
  return Math.max(0, Math.floor(value));
10
19
  }
20
+ let fallbackSessionSeq = 0;
21
+ function defaultPageSessionId() {
22
+ const randomUUID = globalThis.crypto?.randomUUID;
23
+ if (randomUUID) return randomUUID.call(globalThis.crypto);
24
+ return `${Date.now().toString(36)}-${(fallbackSessionSeq++).toString(36)}`;
25
+ }
26
+ function normalizeSessionId(value) {
27
+ if (typeof value !== "string") return void 0;
28
+ const normalized = value.trim();
29
+ return normalized.length > 0 ? normalized : void 0;
30
+ }
31
+ function normalizeSessionOptions(session) {
32
+ if (session === false) return void 0;
33
+ if (typeof session === "string") return {
34
+ contextKey: DEFAULT_SESSION_CONTEXT_KEY,
35
+ id: normalizeSessionId(session) ?? defaultPageSessionId()
36
+ };
37
+ return {
38
+ contextKey: session?.contextKey ?? DEFAULT_SESSION_CONTEXT_KEY,
39
+ getId: session?.getId,
40
+ id: normalizeSessionId(session?.id) ?? defaultPageSessionId()
41
+ };
42
+ }
43
+ function resolveLocalStorage(storage) {
44
+ if (storage) return storage;
45
+ try {
46
+ return globalThis.localStorage;
47
+ } catch {
48
+ return;
49
+ }
50
+ }
51
+ function normalizeLocalStorageSpillOptions(spill, dbName, storeName) {
52
+ if (!spill) return void 0;
53
+ const storage = resolveLocalStorage(spill.storage);
54
+ if (!storage) return void 0;
55
+ const namespace = spill.namespace ?? `${dbName}:${storeName}`;
56
+ return {
57
+ drainOnCreate: spill.drainOnCreate ?? true,
58
+ key: `loggerjs:spill:v1:${namespace}`,
59
+ maxBytes: normalizePositiveInteger(spill.maxBytes, DEFAULT_SPILL_MAX_BYTES),
60
+ maxEntries: normalizePositiveInteger(spill.maxEntries, DEFAULT_SPILL_MAX_ENTRIES),
61
+ maxOriginBytes: normalizePositiveInteger(spill.maxOriginBytes, DEFAULT_SPILL_MAX_ORIGIN_BYTES),
62
+ minLevelValue: spill.minLevel === void 0 ? void 0 : toLevelValue(spill.minLevel),
63
+ onDrop: spill.onDrop,
64
+ spillOnPageHide: spill.spillOnPageHide ?? true,
65
+ storage
66
+ };
67
+ }
68
+ function storageByteLength(key, value) {
69
+ return 2 * (key.length + value.length);
70
+ }
71
+ function estimateLocalStorageBytes(storage) {
72
+ try {
73
+ let total = 0;
74
+ for (let index = 0; index < storage.length; index += 1) {
75
+ const key = storage.key(index);
76
+ if (key === null) continue;
77
+ total += storageByteLength(key, storage.getItem(key) ?? "");
78
+ }
79
+ return total;
80
+ } catch {
81
+ return;
82
+ }
83
+ }
84
+ function isQuotaExceededError(error) {
85
+ return typeof DOMException !== "undefined" && error instanceof DOMException && (error.name === "QuotaExceededError" || error.name === "NS_ERROR_DOM_QUOTA_REACHED");
86
+ }
87
+ function isSpillLogEvent(value) {
88
+ if (!value || typeof value !== "object") return false;
89
+ const event = value;
90
+ return typeof event.id === "string" && typeof event.time === "number" && typeof event.seq === "number" && typeof event.level === "number" && typeof event.levelName === "string" && typeof event.logger === "string" && typeof event.message === "string";
91
+ }
92
+ function parseSpillPayload(raw) {
93
+ const parsed = JSON.parse(raw);
94
+ if (parsed.schema !== SPILL_SCHEMA || !Array.isArray(parsed.entries)) return void 0;
95
+ return parsed.entries.filter(isSpillLogEvent);
96
+ }
11
97
  function nowMs() {
12
98
  return typeof performance !== "undefined" && typeof performance.now === "function" ? performance.now() : Date.now();
13
99
  }
@@ -50,6 +136,9 @@ function indexExists(store, name) {
50
136
  function ensureIndex(store, name, keyPath) {
51
137
  if (!indexExists(store, name)) store.createIndex(name, keyPath);
52
138
  }
139
+ function sessionStoreNameFor(storeName) {
140
+ return `${storeName}${SESSION_STORE_SUFFIX}`;
141
+ }
53
142
  function getStoreIndex(store, name) {
54
143
  const maybeStore = store;
55
144
  if (typeof maybeStore.index !== "function" || !indexExists(store, name)) return void 0;
@@ -73,13 +162,67 @@ function decodeEntry(entry, codec) {
73
162
  function shouldKeepEntry(entry, options) {
74
163
  if (options.from !== void 0 && entry.createdAt < options.from) return false;
75
164
  if (options.to !== void 0 && entry.createdAt > options.to) return false;
165
+ if (options.sessionId !== void 0 && entry.sessionId !== options.sessionId) return false;
76
166
  if (options.minLevel !== void 0 && entry.level < toLevelValue(options.minLevel)) return false;
77
167
  if (options.logger !== void 0 && entry.logger !== options.logger) return false;
78
168
  if (options.type !== void 0 && entry.type !== options.type) return false;
79
169
  return true;
80
170
  }
171
+ function addSessionSummary(sessions, entry) {
172
+ if (!entry.sessionId) return;
173
+ const current = sessions.get(entry.sessionId);
174
+ if (current) {
175
+ current.firstSeen = Math.min(current.firstSeen, entry.createdAt);
176
+ current.lastSeen = Math.max(current.lastSeen, entry.createdAt);
177
+ current.count += 1;
178
+ current.byteLength += entry.byteLength;
179
+ return;
180
+ }
181
+ sessions.set(entry.sessionId, {
182
+ byteLength: entry.byteLength,
183
+ count: 1,
184
+ firstSeen: entry.createdAt,
185
+ lastSeen: entry.createdAt,
186
+ sessionId: entry.sessionId
187
+ });
188
+ }
189
+ function mergeSessionSummary(current, incoming) {
190
+ if (!current) return incoming;
191
+ return {
192
+ byteLength: current.byteLength + incoming.byteLength,
193
+ count: current.count + incoming.count,
194
+ firstSeen: Math.min(current.firstSeen, incoming.firstSeen),
195
+ lastSeen: Math.max(current.lastSeen, incoming.lastSeen),
196
+ sessionId: incoming.sessionId
197
+ };
198
+ }
199
+ function orderSessions(sessions, order = "desc") {
200
+ const sorted = [...sessions].sort((left, right) => {
201
+ if (left.lastSeen !== right.lastSeen) return left.lastSeen - right.lastSeen;
202
+ return left.sessionId < right.sessionId ? -1 : left.sessionId > right.sessionId ? 1 : 0;
203
+ });
204
+ if (order === "asc") return sorted;
205
+ const reversed = [];
206
+ for (let index = sorted.length - 1; index >= 0; index -= 1) reversed.push(sorted[index]);
207
+ return reversed;
208
+ }
81
209
  function orderRangeForQuery(options) {
82
210
  if (typeof IDBKeyRange === "undefined") return void 0;
211
+ if (options.sessionId !== void 0) {
212
+ const lower = [
213
+ options.sessionId,
214
+ options.from ?? Number.MIN_SAFE_INTEGER,
215
+ Number.MIN_SAFE_INTEGER,
216
+ ""
217
+ ];
218
+ const upper = [
219
+ options.sessionId,
220
+ options.to ?? Number.MAX_SAFE_INTEGER,
221
+ Number.MAX_SAFE_INTEGER,
222
+ "￿"
223
+ ];
224
+ return IDBKeyRange.bound(lower, upper);
225
+ }
83
226
  const lower = options.from === void 0 ? void 0 : [
84
227
  options.from,
85
228
  Number.MIN_SAFE_INTEGER,
@@ -94,6 +237,33 @@ function orderRangeForQuery(options) {
94
237
  if (lower) return IDBKeyRange.lowerBound(lower);
95
238
  if (upper) return IDBKeyRange.upperBound(upper);
96
239
  }
240
+ function orderRangeForSessionId(sessionId) {
241
+ if (typeof IDBKeyRange === "undefined") return void 0;
242
+ return IDBKeyRange.bound([
243
+ sessionId,
244
+ Number.MIN_SAFE_INTEGER,
245
+ Number.MIN_SAFE_INTEGER,
246
+ ""
247
+ ], [
248
+ sessionId,
249
+ Number.MAX_SAFE_INTEGER,
250
+ Number.MAX_SAFE_INTEGER,
251
+ "￿"
252
+ ]);
253
+ }
254
+ function migrateSessionSummaries(logStore, sessionStore) {
255
+ const summaries = /* @__PURE__ */ new Map();
256
+ const request = logStore.openCursor();
257
+ request.addEventListener("success", () => {
258
+ const cursor = request.result;
259
+ if (!cursor) {
260
+ for (const summary of summaries.values()) sessionStore.put(summary);
261
+ return;
262
+ }
263
+ addSessionSummary(summaries, cursor.value);
264
+ cursor.continue();
265
+ });
266
+ }
97
267
  function iterateCursor(request, visit) {
98
268
  return new Promise((resolve, reject) => {
99
269
  request.addEventListener("success", () => {
@@ -118,6 +288,7 @@ function iterateCursor(request, visit) {
118
288
  function indexedDbTransport(options = {}) {
119
289
  const dbName = options.dbName ?? DEFAULT_DB_NAME;
120
290
  const storeName = options.storeName ?? DEFAULT_STORE_NAME;
291
+ const sessionStoreName = sessionStoreNameFor(storeName);
121
292
  const codec = options.codec ?? safeJsonCodec();
122
293
  const batchSize = normalizePositiveInteger(options.batchSize, 100);
123
294
  const flushIntervalMs = normalizePositiveInteger(options.flushIntervalMs, 1e3);
@@ -128,12 +299,18 @@ function indexedDbTransport(options = {}) {
128
299
  const flushOnPageHide = options.flushOnPageHide ?? true;
129
300
  const durability = options.durability ?? "default";
130
301
  const rootIndexedDB = options.indexedDB ?? globalThis.indexedDB;
302
+ const sessionOptions = normalizeSessionOptions(options.session);
303
+ const spillOptions = normalizeLocalStorageSpillOptions(options.localStorageSpill, dbName, storeName);
131
304
  const transactionOptions = durability === "default" ? void 0 : { durability };
132
305
  const buffer = [];
133
306
  let dbPromise;
134
307
  let indexedDBFactoryPromise;
135
308
  let timer;
136
309
  let flushPromise;
310
+ let clearPromise;
311
+ let pendingFlushBatch;
312
+ let spillDrainPromise;
313
+ let localStorageSpillClearCandidate;
137
314
  let lastContext;
138
315
  let closed = false;
139
316
  const statsState = {
@@ -160,13 +337,23 @@ function indexedDbTransport(options = {}) {
160
337
  storageBucketFallbacks: 0,
161
338
  transactionOptionFallbacks: 0,
162
339
  errors: 0,
163
- errorsByOperation: {}
340
+ errorsByOperation: {},
341
+ localStorageSpillWrites: 0,
342
+ localStorageSpillEntries: 0,
343
+ localStorageSpillDrains: 0,
344
+ localStorageSpillDrainedEntries: 0,
345
+ localStorageSpillDropped: 0,
346
+ localStorageSpillDroppedByReason: {},
347
+ localStorageSpillErrors: 0,
348
+ localStorageSpillErrorsByOperation: {}
164
349
  };
165
350
  const snapshotStats = () => ({
166
351
  ...statsState,
167
352
  bufferDepth: buffer.length,
168
353
  droppedByReason: { ...statsState.droppedByReason },
169
354
  errorsByOperation: { ...statsState.errorsByOperation },
355
+ localStorageSpillDroppedByReason: { ...statsState.localStorageSpillDroppedByReason },
356
+ localStorageSpillErrorsByOperation: { ...statsState.localStorageSpillErrorsByOperation },
170
357
  pendingFlush: flushPromise !== void 0,
171
358
  persistedDroppedByReason: { ...statsState.persistedDroppedByReason }
172
359
  });
@@ -193,6 +380,18 @@ function indexedDbTransport(options = {}) {
193
380
  incrementLoggerMetaCounter(`transport.indexeddb.persisted.dropped.${reason}`);
194
381
  options.onPersistedDrop?.(entry, reason);
195
382
  };
383
+ const reportSpillError = (error, operation) => {
384
+ statsState.localStorageSpillErrors += 1;
385
+ incrementRecord(statsState.localStorageSpillErrorsByOperation, operation);
386
+ reportInternalError(error, `localstorage-spill-${operation}`);
387
+ };
388
+ const dropSpillEvent = (event, reason) => {
389
+ statsState.localStorageSpillDropped += 1;
390
+ incrementRecord(statsState.localStorageSpillDroppedByReason, reason);
391
+ incrementLoggerMetaCounter("transport.indexeddb.localstorage_spill.dropped");
392
+ incrementLoggerMetaCounter(`transport.indexeddb.localstorage_spill.dropped.${reason}`);
393
+ spillOptions?.onDrop?.(event, reason);
394
+ };
196
395
  const resolveIndexedDBFactory = async () => {
197
396
  if (options.indexedDB || !options.storageBucketName) return rootIndexedDB;
198
397
  indexedDBFactoryPromise ??= (async () => {
@@ -219,7 +418,7 @@ function indexedDbTransport(options = {}) {
219
418
  if (!idb) throw new Error("IndexedDB is not available for indexedDbTransport");
220
419
  return new Promise((resolve, reject) => {
221
420
  const request = idb.open(dbName, DB_VERSION);
222
- request.addEventListener("upgradeneeded", () => {
421
+ request.addEventListener("upgradeneeded", (event) => {
223
422
  const db = request.result;
224
423
  const store = db.objectStoreNames.contains(storeName) ? request.transaction?.objectStore(storeName) : db.createObjectStore(storeName, { keyPath: "id" });
225
424
  if (!store) return;
@@ -232,6 +431,17 @@ function indexedDbTransport(options = {}) {
232
431
  "seq",
233
432
  "id"
234
433
  ]);
434
+ ensureIndex(store, SESSION_INDEX_NAME, "sessionId");
435
+ ensureIndex(store, SESSION_ORDER_INDEX_NAME, [
436
+ "sessionId",
437
+ "createdAt",
438
+ "seq",
439
+ "id"
440
+ ]);
441
+ const sessionStore = db.objectStoreNames.contains(sessionStoreName) ? request.transaction?.objectStore(sessionStoreName) : db.createObjectStore(sessionStoreName, { keyPath: "sessionId" });
442
+ if (!sessionStore) return;
443
+ ensureIndex(sessionStore, SESSION_LAST_SEEN_INDEX_NAME, ["lastSeen", "sessionId"]);
444
+ if (event.oldVersion < 4) migrateSessionSummaries(store, sessionStore);
235
445
  });
236
446
  request.addEventListener("success", () => {
237
447
  statsState.databaseOpenCount += 1;
@@ -242,13 +452,13 @@ function indexedDbTransport(options = {}) {
242
452
  });
243
453
  return dbPromise;
244
454
  };
245
- const createTransaction = (db, mode) => {
246
- if (mode !== "readwrite" || !transactionOptions) return db.transaction(storeName, mode);
455
+ const createTransaction = (db, mode, stores = storeName) => {
456
+ if (mode !== "readwrite" || !transactionOptions) return db.transaction(stores, mode);
247
457
  try {
248
- return db.transaction(storeName, mode, transactionOptions);
458
+ return db.transaction(stores, mode, transactionOptions);
249
459
  } catch {
250
460
  statsState.transactionOptionFallbacks += 1;
251
- return db.transaction(storeName, mode);
461
+ return db.transaction(stores, mode);
252
462
  }
253
463
  };
254
464
  const withStore = async (mode, run) => {
@@ -265,6 +475,57 @@ function indexedDbTransport(options = {}) {
265
475
  throw error;
266
476
  }
267
477
  };
478
+ const withLogAndSessionStores = async (mode, run) => {
479
+ const tx = createTransaction(await openDb(), mode, [storeName, sessionStoreName]);
480
+ const settled = transactionToPromise(tx);
481
+ try {
482
+ const result = await run(tx.objectStore(storeName), tx.objectStore(sessionStoreName), tx);
483
+ await settled;
484
+ return result;
485
+ } catch (error) {
486
+ if (tx.error === null) try {
487
+ tx.abort();
488
+ } catch {}
489
+ throw error;
490
+ }
491
+ };
492
+ const withSessionStore = async (mode, run) => {
493
+ const tx = createTransaction(await openDb(), mode, sessionStoreName);
494
+ const settled = transactionToPromise(tx);
495
+ try {
496
+ const result = await run(tx.objectStore(sessionStoreName), tx);
497
+ await settled;
498
+ return result;
499
+ } catch (error) {
500
+ if (tx.error === null) try {
501
+ tx.abort();
502
+ } catch {}
503
+ throw error;
504
+ }
505
+ };
506
+ const mergeSessionSummaries = async (sessionStore, entries) => {
507
+ const summaries = /* @__PURE__ */ new Map();
508
+ for (const entry of entries) addSessionSummary(summaries, entry);
509
+ for (const summary of summaries.values()) {
510
+ const current = await requestToPromise(sessionStore.get(summary.sessionId));
511
+ await requestToPromise(sessionStore.put(mergeSessionSummary(current, summary)));
512
+ }
513
+ };
514
+ const rebuildSessionSummaries = async (store, sessionStore, sessionIds) => {
515
+ if (sessionIds.size === 0) return;
516
+ const index = getStoreIndex(store, SESSION_ORDER_INDEX_NAME);
517
+ for (const sessionId of sessionIds) {
518
+ const summaries = /* @__PURE__ */ new Map();
519
+ const range = orderRangeForSessionId(sessionId);
520
+ await iterateCursor(index && range ? index.openCursor(range) : store.openCursor(), (cursor) => {
521
+ const entry = cursor.value;
522
+ if (entry.sessionId === sessionId) addSessionSummary(summaries, entry);
523
+ });
524
+ const summary = summaries.get(sessionId);
525
+ if (summary) await requestToPromise(sessionStore.put(summary));
526
+ else await requestToPromise(sessionStore.delete(sessionId));
527
+ }
528
+ };
268
529
  const getEntries = async () => orderEntries(await withStore("readonly", (store) => requestToPromise(store.getAll())));
269
530
  const countEntries = async () => withStore("readonly", (store) => {
270
531
  const maybeStore = store;
@@ -273,25 +534,31 @@ function indexedDbTransport(options = {}) {
273
534
  });
274
535
  const deleteEntries = async (entries, reason) => {
275
536
  if (entries.length === 0) return;
276
- await withStore("readwrite", async (store) => {
537
+ await withLogAndSessionStores("readwrite", async (store, sessionStore) => {
538
+ const affectedSessionIds = /* @__PURE__ */ new Set();
277
539
  for (const entry of entries) {
278
540
  await requestToPromise(store.delete(entry.id));
541
+ if (entry.sessionId) affectedSessionIds.add(entry.sessionId);
279
542
  dropPersistedEntry(entry, reason);
280
543
  }
544
+ await rebuildSessionSummaries(store, sessionStore, affectedSessionIds);
281
545
  });
282
546
  };
283
- const deleteByCursor = async (reason, openCursor, shouldDelete) => withStore("readwrite", async (store) => {
547
+ const deleteByCursor = async (reason, openCursor, shouldDelete) => withLogAndSessionStores("readwrite", async (store, sessionStore) => {
284
548
  const request = openCursor(store);
285
549
  if (!request) return void 0;
286
550
  let deleted = 0;
551
+ const affectedSessionIds = /* @__PURE__ */ new Set();
287
552
  await iterateCursor(request, (cursor) => {
288
553
  const entry = cursor.value;
289
554
  if (!shouldDelete(entry, deleted)) return false;
290
555
  cursor.delete();
291
556
  deleted += 1;
557
+ if (entry.sessionId) affectedSessionIds.add(entry.sessionId);
292
558
  dropPersistedEntry(entry, reason);
293
559
  return true;
294
560
  });
561
+ await rebuildSessionSummaries(store, sessionStore, affectedSessionIds);
295
562
  return deleted;
296
563
  });
297
564
  const deleteExpiredByCursor = async (cutoff) => {
@@ -322,7 +589,7 @@ function indexedDbTransport(options = {}) {
322
589
  return total;
323
590
  });
324
591
  const queryEntriesByCursor = async (queryOptions) => withStore("readonly", async (store) => {
325
- const index = getStoreIndex(store, ORDER_INDEX_NAME);
592
+ const index = getStoreIndex(store, queryOptions.sessionId === void 0 ? ORDER_INDEX_NAME : SESSION_ORDER_INDEX_NAME);
326
593
  if (!index) return void 0;
327
594
  const entries = [];
328
595
  const limit = queryOptions.limit;
@@ -335,6 +602,20 @@ function indexedDbTransport(options = {}) {
335
602
  });
336
603
  return entries;
337
604
  });
605
+ const listSessions = async (sessionQueryOptions = {}) => {
606
+ const limit = sessionQueryOptions.limit;
607
+ if (limit !== void 0 && limit <= 0) return [];
608
+ return withSessionStore("readonly", async (sessionStore) => {
609
+ const sessions = [];
610
+ const index = getStoreIndex(sessionStore, SESSION_LAST_SEEN_INDEX_NAME);
611
+ if (!index) return orderSessions(await requestToPromise(sessionStore.getAll()), sessionQueryOptions.order).slice(0, limit);
612
+ await iterateCursor(index.openCursor(void 0, sessionQueryOptions.order === "asc" ? "next" : "prev"), (cursor) => {
613
+ sessions.push(cursor.value);
614
+ return limit === void 0 || sessions.length < limit;
615
+ });
616
+ return sessions;
617
+ });
618
+ };
338
619
  const pruneWithEntries = async () => {
339
620
  const now = Date.now();
340
621
  let entries = await getEntries();
@@ -390,10 +671,34 @@ function indexedDbTransport(options = {}) {
390
671
  statsState.lastPruneDurationMs = nowMs() - startedAt;
391
672
  }
392
673
  };
674
+ const sessionIdForEvent = (event) => {
675
+ if (!sessionOptions) return void 0;
676
+ return normalizeSessionId(sessionOptions.getId?.(event)) ?? normalizeSessionId(event.context?.[sessionOptions.contextKey]) ?? sessionOptions.id;
677
+ };
678
+ const eventWithSessionContext = (event, sessionId) => {
679
+ if (!sessionId || !sessionOptions?.contextKey) return event;
680
+ if (event.context?.[sessionOptions.contextKey] === sessionId) return event;
681
+ return {
682
+ ...event,
683
+ context: {
684
+ ...event.context,
685
+ [sessionOptions.contextKey]: sessionId
686
+ }
687
+ };
688
+ };
689
+ const eventWithResolvedSession = (event) => {
690
+ const sessionId = sessionIdForEvent(event);
691
+ return {
692
+ event: eventWithSessionContext(event, sessionId),
693
+ sessionId
694
+ };
695
+ };
393
696
  const eventToEntry = (event) => {
394
- const payload = codec.encode(event);
697
+ const resolved = eventWithResolvedSession(event);
698
+ const payload = codec.encode(resolved.event);
395
699
  return {
396
700
  id: event.id,
701
+ sessionId: resolved.sessionId,
397
702
  seq: event.seq,
398
703
  createdAt: event.time,
399
704
  level: event.level,
@@ -404,17 +709,50 @@ function indexedDbTransport(options = {}) {
404
709
  payload
405
710
  };
406
711
  };
712
+ const clearLocalStorageSpill = (expectedValue) => {
713
+ if (!spillOptions) return false;
714
+ try {
715
+ if (expectedValue !== void 0 && spillOptions.storage.getItem(spillOptions.key) !== expectedValue) return false;
716
+ spillOptions.storage.removeItem(spillOptions.key);
717
+ if (expectedValue === void 0 || localStorageSpillClearCandidate?.value === expectedValue) localStorageSpillClearCandidate = void 0;
718
+ return true;
719
+ } catch (error) {
720
+ reportSpillError(error, "clear");
721
+ return false;
722
+ }
723
+ };
724
+ const rememberLocalStorageSpill = (value, events) => {
725
+ localStorageSpillClearCandidate = {
726
+ pendingIds: new Set(events.map((event) => event.id)),
727
+ value
728
+ };
729
+ };
730
+ const markLocalStorageSpillPersisted = (events) => {
731
+ const candidate = localStorageSpillClearCandidate;
732
+ if (!candidate || events.length === 0) return;
733
+ for (const event of events) candidate.pendingIds.delete(event.id);
734
+ if (candidate.pendingIds.size === 0) clearLocalStorageSpill(candidate.value);
735
+ };
407
736
  const writeBatch = async (events) => {
408
737
  if (events.length === 0) return;
409
738
  const startedAt = nowMs();
410
739
  statsState.lastFlushBatchSize = events.length;
411
740
  const entries = events.map(eventToEntry);
412
741
  try {
413
- await withStore("readwrite", (store) => {
414
- for (const entry of entries) store.put(entry);
742
+ await withLogAndSessionStores("readwrite", async (store, sessionStore) => {
743
+ const rebuildSessionIds = /* @__PURE__ */ new Set();
744
+ for (const entry of entries) {
745
+ const existing = await requestToPromise(store.get(entry.id));
746
+ if (existing?.sessionId) rebuildSessionIds.add(existing.sessionId);
747
+ if (existing && entry.sessionId) rebuildSessionIds.add(entry.sessionId);
748
+ await requestToPromise(store.put(entry));
749
+ }
750
+ await rebuildSessionSummaries(store, sessionStore, rebuildSessionIds);
751
+ await mergeSessionSummaries(sessionStore, entries.filter((entry) => !entry.sessionId || !rebuildSessionIds.has(entry.sessionId)));
415
752
  });
416
753
  statsState.persisted += entries.length;
417
754
  incrementLoggerMetaCounter("transport.indexeddb.persisted", entries.length);
755
+ markLocalStorageSpillPersisted(events);
418
756
  await prune();
419
757
  statsState.flushes += 1;
420
758
  } catch (error) {
@@ -424,6 +762,124 @@ function indexedDbTransport(options = {}) {
424
762
  statsState.lastFlushDurationMs = nowMs() - startedAt;
425
763
  }
426
764
  };
765
+ const stringifySpillPayload = (events) => safeJsonStringify({
766
+ createdAt: Date.now(),
767
+ entries: events,
768
+ schema: SPILL_SCHEMA
769
+ }, { maxArrayLength: spillOptions?.maxEntries ?? DEFAULT_SPILL_MAX_ENTRIES });
770
+ const spillStorageBudget = (currentValue) => {
771
+ if (!spillOptions) return 0;
772
+ const ownBytes = currentValue ? storageByteLength(spillOptions.key, currentValue) : 0;
773
+ const originBytes = estimateLocalStorageBytes(spillOptions.storage);
774
+ if (originBytes === void 0) return spillOptions.maxBytes;
775
+ return Math.max(0, Math.min(spillOptions.maxBytes, spillOptions.maxOriginBytes - originBytes + ownBytes));
776
+ };
777
+ const pendingEventsForSpill = () => {
778
+ const events = [...pendingFlushBatch ?? [], ...buffer];
779
+ const seen = /* @__PURE__ */ new Set();
780
+ const deduped = [];
781
+ for (const event of events) {
782
+ if (seen.has(event.id)) continue;
783
+ seen.add(event.id);
784
+ if (spillOptions?.minLevelValue !== void 0 && event.level < spillOptions.minLevelValue) continue;
785
+ deduped.push(eventWithResolvedSession(event).event);
786
+ }
787
+ return deduped;
788
+ };
789
+ const trimSpillEventsToBudget = (events, budget) => {
790
+ if (!spillOptions) return void 0;
791
+ while (events.length > spillOptions.maxEntries) {
792
+ const dropped = events.shift();
793
+ if (dropped) dropSpillEvent(dropped, "max-entries");
794
+ }
795
+ while (events.length > 0) {
796
+ const value = stringifySpillPayload(events);
797
+ if (storageByteLength(spillOptions.key, value) <= budget) return value;
798
+ const dropped = events.shift();
799
+ if (dropped) dropSpillEvent(dropped, "max-bytes");
800
+ }
801
+ };
802
+ const writeLocalStorageSpill = () => {
803
+ if (!spillOptions) return;
804
+ const pendingEvents = pendingEventsForSpill();
805
+ if (pendingEvents.length === 0) return;
806
+ try {
807
+ const currentValue = spillOptions.storage.getItem(spillOptions.key);
808
+ let events = pendingEvents;
809
+ if (currentValue) {
810
+ const merged = /* @__PURE__ */ new Map();
811
+ try {
812
+ for (const event of parseSpillPayload(currentValue) ?? []) merged.set(event.id, event);
813
+ } catch {}
814
+ for (const event of pendingEvents) merged.set(event.id, event);
815
+ events = [...merged.values()];
816
+ }
817
+ const budget = spillStorageBudget(currentValue);
818
+ const value = trimSpillEventsToBudget(events, budget);
819
+ if (!value) return;
820
+ try {
821
+ spillOptions.storage.setItem(spillOptions.key, value);
822
+ } catch (error) {
823
+ if (!isQuotaExceededError(error)) throw error;
824
+ while (events.length > 0) {
825
+ const dropped = events.shift();
826
+ if (dropped) dropSpillEvent(dropped, "quota");
827
+ const retryValue = trimSpillEventsToBudget(events, budget);
828
+ if (!retryValue) return;
829
+ try {
830
+ spillOptions.storage.setItem(spillOptions.key, retryValue);
831
+ rememberLocalStorageSpill(retryValue, events);
832
+ statsState.localStorageSpillWrites += 1;
833
+ statsState.localStorageSpillEntries += events.length;
834
+ return;
835
+ } catch (retryError) {
836
+ if (!isQuotaExceededError(retryError)) throw retryError;
837
+ }
838
+ }
839
+ return;
840
+ }
841
+ rememberLocalStorageSpill(value, events);
842
+ statsState.localStorageSpillWrites += 1;
843
+ statsState.localStorageSpillEntries += events.length;
844
+ } catch (error) {
845
+ reportSpillError(error, "write");
846
+ }
847
+ };
848
+ const drainLocalStorageSpill = async () => {
849
+ if (!spillOptions) return;
850
+ let raw;
851
+ try {
852
+ raw = spillOptions.storage.getItem(spillOptions.key);
853
+ } catch (error) {
854
+ reportSpillError(error, "read");
855
+ return;
856
+ }
857
+ if (!raw) return;
858
+ let events;
859
+ try {
860
+ events = parseSpillPayload(raw);
861
+ } catch (error) {
862
+ reportSpillError(error, "parse");
863
+ clearLocalStorageSpill();
864
+ return;
865
+ }
866
+ if (!events || events.length === 0) {
867
+ clearLocalStorageSpill();
868
+ return;
869
+ }
870
+ await writeBatch(events);
871
+ clearLocalStorageSpill(raw);
872
+ statsState.localStorageSpillDrains += 1;
873
+ statsState.localStorageSpillDrainedEntries += events.length;
874
+ };
875
+ const ensureSpillDrained = async () => {
876
+ const promise = spillDrainPromise;
877
+ if (!promise) return;
878
+ await promise;
879
+ };
880
+ if (spillOptions?.drainOnCreate) spillDrainPromise = drainLocalStorageSpill().catch((error) => reportSpillError(error, "drain")).finally(() => {
881
+ spillDrainPromise = void 0;
882
+ });
427
883
  const clearTimer = () => {
428
884
  if (timer) clearTimeout(timer);
429
885
  timer = void 0;
@@ -431,7 +887,15 @@ function indexedDbTransport(options = {}) {
431
887
  const flushPending = async () => {
432
888
  if (flushPromise) return flushPromise;
433
889
  clearTimer();
434
- flushPromise = writeBatch(buffer.splice(0, buffer.length)).finally(() => {
890
+ const batch = buffer.splice(0, buffer.length);
891
+ pendingFlushBatch = batch;
892
+ const clearBarrier = clearPromise;
893
+ flushPromise = (async () => {
894
+ await clearBarrier;
895
+ await ensureSpillDrained();
896
+ await writeBatch(batch);
897
+ })().finally(() => {
898
+ if (pendingFlushBatch === batch) pendingFlushBatch = void 0;
435
899
  flushPromise = void 0;
436
900
  if (buffer.length > 0 && !closed) schedule();
437
901
  });
@@ -444,14 +908,28 @@ function indexedDbTransport(options = {}) {
444
908
  }, flushIntervalMs);
445
909
  };
446
910
  const onPageHide = () => {
447
- flushPending().catch((error) => reportInternalError(error, "pagehide-flush"));
911
+ if (spillOptions?.spillOnPageHide) writeLocalStorageSpill();
912
+ if (flushOnPageHide) flushPending().catch((error) => reportInternalError(error, "pagehide-flush"));
448
913
  };
449
914
  const onVisibilityChange = () => {
450
- if (typeof document !== "undefined" && document.visibilityState === "hidden") flushPending().catch((error) => reportInternalError(error, "visibility-flush"));
915
+ if (typeof document !== "undefined" && document.visibilityState === "hidden") {
916
+ if (spillOptions?.spillOnPageHide) writeLocalStorageSpill();
917
+ if (flushOnPageHide) flushPending().catch((error) => reportInternalError(error, "visibility-flush"));
918
+ }
919
+ };
920
+ const addVisibilityChangeListener = () => {
921
+ if (typeof document !== "undefined" && typeof document.addEventListener === "function") {
922
+ document.addEventListener("visibilitychange", onVisibilityChange);
923
+ return () => document.removeEventListener("visibilitychange", onVisibilityChange);
924
+ }
925
+ if (typeof globalThis.addEventListener !== "function") return void 0;
926
+ globalThis.addEventListener("visibilitychange", onVisibilityChange);
927
+ return () => globalThis.removeEventListener?.("visibilitychange", onVisibilityChange);
451
928
  };
452
- if (flushOnPageHide) {
929
+ let removeVisibilityChangeListener;
930
+ if (flushOnPageHide || spillOptions?.spillOnPageHide) {
453
931
  globalThis.addEventListener?.("pagehide", onPageHide);
454
- globalThis.addEventListener?.("visibilitychange", onVisibilityChange);
932
+ removeVisibilityChangeListener = addVisibilityChangeListener();
455
933
  }
456
934
  return {
457
935
  name: options.name ?? "indexeddb",
@@ -482,16 +960,36 @@ function indexedDbTransport(options = {}) {
482
960
  return await countEntries() ?? (await getEntries()).length;
483
961
  },
484
962
  async clear() {
963
+ if (clearPromise) return clearPromise;
964
+ const inFlightFlush = flushPromise;
965
+ const inFlightDrain = spillDrainPromise;
485
966
  buffer.length = 0;
486
967
  clearTimer();
487
- await withStore("readwrite", (store) => requestToPromise(store.clear()));
968
+ clearLocalStorageSpill();
969
+ clearPromise = (async () => {
970
+ await inFlightFlush;
971
+ await inFlightDrain;
972
+ await withLogAndSessionStores("readwrite", async (store, sessionStore) => {
973
+ await requestToPromise(store.clear());
974
+ await requestToPromise(sessionStore.clear());
975
+ });
976
+ })().finally(() => {
977
+ clearPromise = void 0;
978
+ });
979
+ return clearPromise;
488
980
  },
489
981
  async remove(ids) {
490
982
  await flushPending();
491
983
  const items = typeof ids === "string" ? [ids] : ids;
492
984
  if (items.length === 0) return;
493
- await withStore("readwrite", async (store) => {
494
- for (const id of items) await requestToPromise(store.delete(id));
985
+ await withLogAndSessionStores("readwrite", async (store, sessionStore) => {
986
+ const affectedSessionIds = /* @__PURE__ */ new Set();
987
+ for (const id of items) {
988
+ const entry = await requestToPromise(store.get(id));
989
+ if (entry?.sessionId) affectedSessionIds.add(entry.sessionId);
990
+ await requestToPromise(store.delete(id));
991
+ }
992
+ await rebuildSessionSummaries(store, sessionStore, affectedSessionIds);
495
993
  });
496
994
  },
497
995
  async *query(queryOptions = {}) {
@@ -507,6 +1005,10 @@ function indexedDbTransport(options = {}) {
507
1005
  if (event) yield event;
508
1006
  }
509
1007
  },
1008
+ async sessions(sessionQueryOptions = {}) {
1009
+ await flushPending();
1010
+ return listSessions(sessionQueryOptions);
1011
+ },
510
1012
  stats() {
511
1013
  return snapshotStats();
512
1014
  },
@@ -514,12 +1016,12 @@ function indexedDbTransport(options = {}) {
514
1016
  closed = true;
515
1017
  clearTimer();
516
1018
  globalThis.removeEventListener?.("pagehide", onPageHide);
517
- globalThis.removeEventListener?.("visibilitychange", onVisibilityChange);
518
- const flushed = flushPending();
519
- const promise = dbPromise;
520
- dbPromise = void 0;
521
- return flushed.finally(() => {
522
- promise?.then((db) => db.close());
1019
+ removeVisibilityChangeListener?.();
1020
+ removeVisibilityChangeListener = void 0;
1021
+ return flushPending().finally(() => {
1022
+ const promise = dbPromise;
1023
+ dbPromise = void 0;
1024
+ return promise?.then((db) => db.close());
523
1025
  });
524
1026
  }
525
1027
  };