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