@monochromatic-dev/module-logger 0.1.0

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 (57) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/LICENSES/GPL-3.0-or-later.txt +674 -0
  3. package/LICENSES/LGPL-3.0-or-later.txt +165 -0
  4. package/README.md +404 -0
  5. package/dist/final/neutral/index.d.mts +673 -0
  6. package/dist/final/neutral/index.mjs +3 -0
  7. package/dist/final/neutral/rolldown-runtime-5duEfhBv.mjs +1 -0
  8. package/dist/final/node/index.d.mts +673 -0
  9. package/dist/final/node/index.mjs +3 -0
  10. package/dist/final/node/rolldown-runtime-5duEfhBv.mjs +1 -0
  11. package/package.json +43 -0
  12. package/src/create-logger.ts +494 -0
  13. package/src/create-logger.unit.test.ts +752 -0
  14. package/src/error-format.ts +43 -0
  15. package/src/index.ts +35 -0
  16. package/src/logger.ts +67 -0
  17. package/src/logger.unit.test.ts +190 -0
  18. package/src/sink/console-control-chars.ts +140 -0
  19. package/src/sink/console-control-chars.unit.test.ts +206 -0
  20. package/src/sink/console.ts +531 -0
  21. package/src/sink/console.unit.test.ts +542 -0
  22. package/src/sink/file.ts +297 -0
  23. package/src/sink/file.unit.test.ts +202 -0
  24. package/src/sink/index.ts +11 -0
  25. package/src/sink/indexed-db-util.ts +96 -0
  26. package/src/sink/indexed-db.browser.test.ts +184 -0
  27. package/src/sink/indexed-db.ts +324 -0
  28. package/src/sink/indexed-db.unit.test.ts +80 -0
  29. package/src/sink/local-storage-key.ts +176 -0
  30. package/src/sink/local-storage-key.unit.test.ts +106 -0
  31. package/src/sink/local-storage-quota.ts +60 -0
  32. package/src/sink/local-storage-quota.unit.test.ts +98 -0
  33. package/src/sink/local-storage-store.ts +368 -0
  34. package/src/sink/local-storage-store.unit.test.ts +329 -0
  35. package/src/sink/local-storage.browser.test.ts +125 -0
  36. package/src/sink/local-storage.ts +182 -0
  37. package/src/sink/local-storage.unit.test.ts +218 -0
  38. package/src/sink/noop.ts +46 -0
  39. package/src/sink/noop.unit.test.ts +47 -0
  40. package/src/sink/opfs.browser.test.ts +84 -0
  41. package/src/sink/opfs.ts +212 -0
  42. package/src/sink/opfs.unit.test.ts +81 -0
  43. package/src/sink/record-buffer.ts +230 -0
  44. package/src/sink/record-buffer.unit.test.ts +288 -0
  45. package/src/sink/session-storage-quota.ts +57 -0
  46. package/src/sink/session-storage-quota.unit.test.ts +98 -0
  47. package/src/sink/session-storage-store.ts +178 -0
  48. package/src/sink/session-storage.browser.test.ts +137 -0
  49. package/src/sink/session-storage.ts +128 -0
  50. package/src/sink/session-storage.unit.test.ts +527 -0
  51. package/src/sink/web-storage-quota-error.ts +43 -0
  52. package/src/sink/web-storage-quota-error.unit.test.ts +55 -0
  53. package/src/sink/web-storage-runtime.ts +49 -0
  54. package/src/startup.unit.test.ts +232 -0
  55. package/src/tagged.ts +74 -0
  56. package/src/tagged.unit.test.ts +211 -0
  57. package/src/types.ts +78 -0
@@ -0,0 +1,527 @@
1
+ import {
2
+ describe,
3
+ expect,
4
+ it,
5
+ } from '@monochromatic-dev/module-test/ts';
6
+ import { wait, } from '@monochromatic-dev/module-async-time/ts';
7
+ import {
8
+ _detectSessionStorageQuotaChars as detectSessionStorageQuotaChars,
9
+ sinks,
10
+ type LogRecord,
11
+ } from '@monochromatic-dev/module-logger';
12
+
13
+ /**
14
+ * Sink factories under test, read from the built artifact's `sinks` namespace.
15
+ */
16
+ const {
17
+ createSessionStorageSink,
18
+ } = sinks;
19
+
20
+ /**
21
+ * Swaps `globalThis.sessionStorage` for `fake`, restoring the real backend when
22
+ * the returned guard leaves `using` scope, so a fake never leaks into a later
23
+ * test in the serial suite.
24
+ *
25
+ * @param fake - Storage stand-in to install for the duration of the scope.
26
+ *
27
+ * @returns Disposable that restores the original `sessionStorage` on exit.
28
+ */
29
+ function installFakeStorage(fake: Storage,): Disposable {
30
+ const original = globalThis.sessionStorage;
31
+ globalThis.sessionStorage = fake;
32
+ return {
33
+ [Symbol.dispose](): void {
34
+ globalThis.sessionStorage = original;
35
+ },
36
+ };
37
+ }
38
+
39
+ /**
40
+ * Builds an in-memory `Storage` stand-in that rejects a `setItem` once stored
41
+ * value lengths would exceed `byteBudget`, throwing the same
42
+ * `QuotaExceededError` a real backend raises. Records every `removeItem` under
43
+ * `removed` so a test can assert exactly which keys the sink evicted.
44
+ *
45
+ * @param byteBudget - Total value length the store accepts before overflowing.
46
+ *
47
+ * @returns Storage stand-in exposing the evicted-key log as `removed`.
48
+ */
49
+ function createQuotaStorage(byteBudget: number,): Storage & { readonly removed: string[]; } {
50
+ const store = new Map<string, string>();
51
+ const removed: string[] = [];
52
+ const used = { bytes: 0, };
53
+ return {
54
+ removed,
55
+ clear(): void {
56
+ store.clear();
57
+ used.bytes = 0;
58
+ },
59
+ getItem(key: string,) {
60
+ return store.get(key,) ?? null;
61
+ },
62
+ setItem(key: string, value: string,): void {
63
+ const priorLength = store.get(key,)?.length ?? 0;
64
+ const nextBytes = (used.bytes - priorLength) + value.length;
65
+ if (nextBytes > byteBudget)
66
+ throw new DOMException('exceeded the quota', 'QuotaExceededError',);
67
+ store.set(key, value,);
68
+ used.bytes = nextBytes;
69
+ },
70
+ removeItem(key: string,): void {
71
+ removed.push(key,);
72
+ const priorLength = store.get(key,)?.length ?? 0;
73
+ if (store.delete(key,))
74
+ used.bytes -= priorLength;
75
+ },
76
+ } as unknown as Storage & { readonly removed: string[]; };
77
+ }
78
+
79
+ /**
80
+ * Builds an in-memory `Storage` stand-in whose first `setItem` succeeds and
81
+ * every later one throws a non-quota error, so a test can prove the sink does
82
+ * not evict for failures other than a quota overflow. Records `removeItem`
83
+ * calls under `removed`.
84
+ *
85
+ * @returns Storage stand-in exposing the evicted-key log as `removed`.
86
+ */
87
+ function createFlakyStorage(): Storage & { readonly removed: string[]; } {
88
+ const store = new Map<string, string>();
89
+ const removed: string[] = [];
90
+ const calls = { setItem: 0, };
91
+ return {
92
+ removed,
93
+ getItem(key: string,) {
94
+ return store.get(key,) ?? null;
95
+ },
96
+ setItem(key: string, value: string,): void {
97
+ calls.setItem += 1;
98
+ if (calls.setItem > 1)
99
+ throw new Error('sessionStorage disabled mid-session',);
100
+ store.set(key, value,);
101
+ },
102
+ removeItem(key: string,): void {
103
+ removed.push(key,);
104
+ store.delete(key,);
105
+ },
106
+ } as unknown as Storage & { readonly removed: string[]; };
107
+ }
108
+
109
+ /**
110
+ * Captures `console.warn` output, restoring the real method when the returned
111
+ * guard leaves `using` scope, so a test can count the sink's give-up reports.
112
+ *
113
+ * @returns Disposable exposing captured warn lines as `calls`.
114
+ */
115
+ function spyConsoleWarn(): Disposable & { readonly calls: string[]; } {
116
+ const original = console.warn;
117
+ const calls: string[] = [];
118
+ console.warn = (...args: unknown[]): void => {
119
+ calls.push(args.map(String,)
120
+ .join(' ',),);
121
+ };
122
+ return {
123
+ calls,
124
+ [Symbol.dispose](): void {
125
+ console.warn = original;
126
+ },
127
+ };
128
+ }
129
+
130
+ /**
131
+ * Counts captured warn lines that are the sessionStorage sink's give-up report.
132
+ *
133
+ * @param calls - Captured `console.warn` lines from {@link spyConsoleWarn}.
134
+ *
135
+ * @returns How many lines report a sink write failure.
136
+ */
137
+ function sinkFailureCount(calls: readonly string[],): number {
138
+ return calls.filter(function isSinkFailure(line,) {
139
+ return line.includes('sessionStorage sink record write failed',);
140
+ },).length;
141
+ }
142
+
143
+ // Node exposes an in-memory Web Storage `sessionStorage` (on by default in the
144
+ // v26 the test runner uses), so the whole sink is exercised directly against a
145
+ // genuine backend here. Election is by probe alone: every runtime whose
146
+ // backend round-trips keeps the sink, and one uniform buffered write path
147
+ // (flushing on the byte cap, on `warn`-or-worse severity, on a quiet-period
148
+ // deadline, and on the `flush` hook) keeps the per-record cost acceptable
149
+ // everywhere; there is no per-runtime mode. The browser availability path is
150
+ // covered by `session-storage.browser.test.ts`; OPFS, whose backend node
151
+ // lacks, covers the unavailable-verify fallback in `opfs.unit.test.ts`.
152
+ await describe({
153
+ name: 'sessionStorage sink (node web storage)',
154
+ // Serial because every test shares the one process-global sessionStorage.
155
+ concurrency: 1,
156
+ children: [
157
+ it({
158
+ name: 'verify resolves true wherever the backend round-trips a probe',
159
+ fn: async () => {
160
+ // Node's web storage round-trips, so the probe elects the sink; the
161
+ // buffered write path, not a runtime brand check, keeps it affordable.
162
+ const sink = createSessionStorageSink();
163
+ expect(await sink.verify(),)
164
+ .toBe(true,);
165
+ },
166
+ },),
167
+
168
+ it({
169
+ name: 'buffers a routine record until the flush hook persists it as a batch',
170
+ fn: async () => {
171
+ globalThis.sessionStorage
172
+ .clear();
173
+ const sink = createSessionStorageSink();
174
+ await sink.verify();
175
+
176
+ /**
177
+ * Routine record; severity below `warn` stays buffered.
178
+ */
179
+ const record: LogRecord = {
180
+ level: 'info',
181
+ message: 'one',
182
+ timestamp: 0,
183
+ };
184
+ await sink.write(record,);
185
+
186
+ // Below every flush trigger, nothing has reached the store yet.
187
+ expect(globalThis.sessionStorage
188
+ .getItem('monochromatic.log.0',),)
189
+ .toBe(null,);
190
+
191
+ await sink.flush?.();
192
+
193
+ // The drained buffer lands as one batch on the first counter slot.
194
+ expect(globalThis.sessionStorage
195
+ .getItem('monochromatic.log.0',),)
196
+ .toBe(JSON.stringify(record,),);
197
+ },
198
+ },),
199
+
200
+ it({
201
+ name: 'a warn record flushes itself and every buffered record in one JSONL batch',
202
+ fn: async () => {
203
+ globalThis.sessionStorage
204
+ .clear();
205
+ const sink = createSessionStorageSink();
206
+ await sink.verify();
207
+
208
+ /**
209
+ * Routine record buffered first; must survive into the batch the warning triggers.
210
+ */
211
+ const first: LogRecord = {
212
+ level: 'info',
213
+ message: 'one',
214
+ timestamp: 0,
215
+ };
216
+ /**
217
+ * Warning record whose severity forces the synchronous flush.
218
+ */
219
+ const second: LogRecord = {
220
+ level: 'warn',
221
+ message: 'two',
222
+ timestamp: 0,
223
+ };
224
+ await sink.write(first,);
225
+ await sink.write(second,);
226
+
227
+ // Both records share one newline-joined batch under the first slot, in
228
+ // write order, with no second key claimed.
229
+ expect(globalThis.sessionStorage
230
+ .getItem('monochromatic.log.0',),)
231
+ .toBe(`${JSON.stringify(first,)}\n${JSON.stringify(second,)}`,);
232
+ expect(globalThis.sessionStorage
233
+ .getItem('monochromatic.log.1',),)
234
+ .toBe(null,);
235
+ },
236
+ },),
237
+
238
+ it({
239
+ name: 'the quiet-period deadline persists a buffered record without any trigger call',
240
+ fn: async () => {
241
+ globalThis.sessionStorage
242
+ .clear();
243
+ const sink = createSessionStorageSink();
244
+ await sink.verify();
245
+
246
+ /**
247
+ * Routine record left to the deadline timer.
248
+ */
249
+ const record: LogRecord = {
250
+ level: 'debug',
251
+ message: 'idle',
252
+ timestamp: 0,
253
+ };
254
+ await sink.write(record,);
255
+
256
+ /**
257
+ * Comfortably past the sink's 250 ms quiet-period deadline.
258
+ */
259
+ const pastDeadlineMs = 400;
260
+ await wait(pastDeadlineMs,);
261
+
262
+ expect(globalThis.sessionStorage
263
+ .getItem('monochromatic.log.0',),)
264
+ .toBe(JSON.stringify(record,),);
265
+ },
266
+ timeout: 5_000,
267
+ },),
268
+
269
+ it({
270
+ name: 'reaching the byte cap flushes synchronously and isolates the breaching record',
271
+ fn: async () => {
272
+ globalThis.sessionStorage
273
+ .clear();
274
+ const sink = createSessionStorageSink();
275
+ await sink.verify();
276
+
277
+ /**
278
+ * Small routine record buffered first; must not share a batch with the cap-breaching record.
279
+ */
280
+ const small: LogRecord = {
281
+ level: 'info',
282
+ message: 'small',
283
+ timestamp: 0,
284
+ };
285
+ // A message larger than the 32 KiB buffer cap: appending it would
286
+ // breach the cap, so the small record flushes first and the large one
287
+ // then flushes alone, all from inside `write` with no explicit flush.
288
+ const large: LogRecord = {
289
+ level: 'info',
290
+ message: 'L'.repeat(40_000,),
291
+ timestamp: 1,
292
+ };
293
+ await sink.write(small,);
294
+ await sink.write(large,);
295
+
296
+ expect(globalThis.sessionStorage
297
+ .getItem('monochromatic.log.0',),)
298
+ .toBe(JSON.stringify(small,),);
299
+ expect(globalThis.sessionStorage
300
+ .getItem('monochromatic.log.1',),)
301
+ .toBe(JSON.stringify(large,),);
302
+ },
303
+ },),
304
+
305
+ it({
306
+ name: 'keeps only its newest entries against the real backend, evicting oldest as it writes',
307
+ fn: async () => {
308
+ globalThis.sessionStorage
309
+ .clear();
310
+ const sink = createSessionStorageSink();
311
+ await sink.verify();
312
+
313
+ // Each record's message is about a megabyte, far past the buffer cap,
314
+ // so every write flushes itself as its own batch; twelve of them exceed
315
+ // the half-quota cap several times over, so the engine evicts
316
+ // oldest-first and the earliest slot is gone while the newest survives.
317
+ const bulk = 'x'.repeat(1_024 * 1_024,);
318
+ const writeCount = 12;
319
+ await Promise.all(
320
+ Array.from(
321
+ { length: writeCount, },
322
+ function writeBulk(_unused, index,) {
323
+ return sink.write({ level: 'info', message: bulk, timestamp: index, },);
324
+ },
325
+ ),
326
+ );
327
+
328
+ // The newest slot always lands; the very first was reclaimed long ago.
329
+ expect(
330
+ globalThis.sessionStorage
331
+ .getItem(`monochromatic.log.${writeCount - 1}`,) !== null,
332
+ )
333
+ .toBe(true,);
334
+ expect(
335
+ globalThis.sessionStorage
336
+ .getItem('monochromatic.log.0',) !== null,
337
+ )
338
+ .toBe(false,);
339
+ },
340
+ },),
341
+
342
+ it({
343
+ name: 'caps its own footprint at half the runtime quota, proactively evicting oldest',
344
+ fn: async () => {
345
+ /**
346
+ * Half the detected runtime quota: the footprint ceiling the engine enforces.
347
+ */
348
+ const capChars = detectSessionStorageQuotaChars() / 2;
349
+ // A fake store far larger than the cap, so only the proactive half-quota
350
+ // cap (never a real overflow) drives the eviction under test.
351
+ const fake = createQuotaStorage(capChars * 10,);
352
+ using _restore = installFakeStorage(fake,);
353
+ const sink = createSessionStorageSink();
354
+
355
+ // Each record is about 40% of the cap, far past the buffer cap, so each
356
+ // write flushes itself: two fit under half the quota, but the third
357
+ // would breach it, so the oldest is dropped first.
358
+ const chunk = 'y'.repeat(Math.floor(capChars * 0.4,),);
359
+ await sink.write({ level: 'info', message: chunk, timestamp: 0, },);
360
+ await sink.write({ level: 'info', message: chunk, timestamp: 1, },);
361
+ await sink.write({ level: 'info', message: chunk, timestamp: 2, },);
362
+
363
+ // Exactly the oldest slot was proactively reclaimed; the two newest stay.
364
+ expect(fake.removed
365
+ .join(',',),)
366
+ .toBe('monochromatic.log.0',);
367
+ expect(
368
+ globalThis.sessionStorage
369
+ .getItem('monochromatic.log.0',) !== null,
370
+ )
371
+ .toBe(false,);
372
+ expect(
373
+ globalThis.sessionStorage
374
+ .getItem('monochromatic.log.2',) !== null,
375
+ )
376
+ .toBe(true,);
377
+ },
378
+ },),
379
+
380
+ it({
381
+ name: 'leaves foreign entries intact and drops the write when it has never written',
382
+ fn: async () => {
383
+ // A tiny fake quota already filled by another origin consumer's key, so
384
+ // the sink's first-ever flush cannot fit.
385
+ const fake = createQuotaStorage(64,);
386
+ using _restore = installFakeStorage(fake,);
387
+ globalThis.sessionStorage
388
+ .setItem('foreign', 'F'.repeat(64,),);
389
+
390
+ const sink = createSessionStorageSink();
391
+ await sink.write({ level: 'info', message: 'hello', timestamp: 0, },);
392
+ await sink.flush?.();
393
+
394
+ // Never having landed a write, the engine must not reclaim foreign data.
395
+ expect(fake.removed.length,)
396
+ .toBe(0,);
397
+ expect(
398
+ globalThis.sessionStorage
399
+ .getItem('foreign',) !== null,
400
+ )
401
+ .toBe(true,);
402
+ },
403
+ },),
404
+
405
+ it({
406
+ name: 'evicts every owned entry then gives up for a record larger than the quota',
407
+ fn: async () => {
408
+ const budget = 512;
409
+ const fake = createQuotaStorage(budget,);
410
+ using _restore = installFakeStorage(fake,);
411
+ const sink = createSessionStorageSink();
412
+
413
+ // Error severity flushes each record synchronously, so two small
414
+ // records land as their own batches within budget.
415
+ await sink.write({ level: 'error', message: 'a', timestamp: 0, },);
416
+ await sink.write({ level: 'error', message: 'b', timestamp: 1, },);
417
+
418
+ // A record larger than the whole budget can never fit; its flush must
419
+ // evict both owned entries, then report and return rather than loop.
420
+ await sink.write({ level: 'error', message: 'Z'.repeat(budget * 2,), timestamp: 2, },);
421
+
422
+ expect(fake.removed
423
+ .join(',',),)
424
+ .toBe('monochromatic.log.0,monochromatic.log.1',);
425
+ expect(
426
+ globalThis.sessionStorage
427
+ .getItem('monochromatic.log.2',) !== null,
428
+ )
429
+ .toBe(false,);
430
+ },
431
+ },),
432
+
433
+ it({
434
+ name: 'does not evict on a non-quota write failure',
435
+ fn: async () => {
436
+ const fake = createFlakyStorage();
437
+ using _restore = installFakeStorage(fake,);
438
+ const sink = createSessionStorageSink();
439
+
440
+ // Error severity flushes each record; the first lands, the second
441
+ // fails with a non-quota error.
442
+ await sink.write({ level: 'error', message: 'one', timestamp: 0, },);
443
+ await sink.write({ level: 'error', message: 'two', timestamp: 1, },);
444
+
445
+ // A non-quota failure is reported without touching earlier entries.
446
+ expect(fake.removed.length,)
447
+ .toBe(0,);
448
+ expect(
449
+ globalThis.sessionStorage
450
+ .getItem('monochromatic.log.0',) !== null,
451
+ )
452
+ .toBe(true,);
453
+ },
454
+ },),
455
+
456
+ it({
457
+ name: 'reports an unrecoverable write only once, not once per record',
458
+ fn: async () => {
459
+ // A store that rejects every write and holds nothing of the sink's own,
460
+ // so each error-severity flush reaches the give-up path.
461
+ const fake = createQuotaStorage(0,);
462
+ using _restore = installFakeStorage(fake,);
463
+ using warn = spyConsoleWarn();
464
+ const sink = createSessionStorageSink();
465
+
466
+ await sink.write({ level: 'error', message: 'one', timestamp: 0, },);
467
+ await sink.write({ level: 'error', message: 'two', timestamp: 1, },);
468
+ await sink.write({ level: 'error', message: 'three', timestamp: 2, },);
469
+
470
+ // Three failing flushes, a single console report rather than a flood.
471
+ expect(sinkFailureCount(warn.calls,),)
472
+ .toBe(1,);
473
+ },
474
+ },),
475
+
476
+ it({
477
+ name: 're-arms the give-up report after a write next succeeds',
478
+ fn: async () => {
479
+ // Budget fits one small record; an oversized record can never fit even
480
+ // after evicting the small one, so it reaches the give-up path.
481
+ const fake = createQuotaStorage(200,);
482
+ using _restore = installFakeStorage(fake,);
483
+ using warn = spyConsoleWarn();
484
+ const sink = createSessionStorageSink();
485
+ /**
486
+ * Record larger than the whole budget; unwritable even after eviction.
487
+ */
488
+ const oversized = {
489
+ level: 'error' as const,
490
+ message: 'Z'.repeat(1_000,),
491
+ timestamp: 0,
492
+ };
493
+
494
+ await sink.write({ level: 'error', message: 'a', timestamp: 0, },);
495
+ await sink.write(oversized,);
496
+ await sink.write(oversized,);
497
+ await sink.write({ level: 'error', message: 'b', timestamp: 1, },);
498
+ await sink.write(oversized,);
499
+
500
+ // First give-up reports; its repeat is suppressed; the landed 'b' write
501
+ // re-arms a single report for the next failure.
502
+ expect(sinkFailureCount(warn.calls,),)
503
+ .toBe(2,);
504
+ },
505
+ },),
506
+
507
+ it({
508
+ name: 'the flush hook is exposed and draining an empty buffer is a no-op',
509
+ fn: async () => {
510
+ globalThis.sessionStorage
511
+ .clear();
512
+ const sink = createSessionStorageSink();
513
+ await sink.verify();
514
+
515
+ expect((typeof sink.flush) === 'function',)
516
+ .toBe(true,);
517
+
518
+ // Nothing buffered: draining twice claims no key and throws nothing.
519
+ await sink.flush?.();
520
+ await sink.flush?.();
521
+ expect(globalThis.sessionStorage
522
+ .getItem('monochromatic.log.0',),)
523
+ .toBe(null,);
524
+ },
525
+ },),
526
+ ],
527
+ },);
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Quota-overflow recognition shared by the web storage persistence engines.
3
+ *
4
+ * @module
5
+ */
6
+
7
+ /**
8
+ * Error `name` values engines raise for a storage quota overflow: the DOM
9
+ * standard name every current browser and Node web storage use, plus Firefox's
10
+ * legacy alias. Quota overflow is matched by `name` rather than by class or
11
+ * numeric `code` because the concrete type differs by engine (a `DOMException`
12
+ * in Chromium and Node, a differently-branded object historically in Firefox),
13
+ * while the standard name is stable across them.
14
+ */
15
+ const QUOTA_EXCEEDED_NAMES: ReadonlySet<string> = new Set([
16
+ 'QuotaExceededError',
17
+ 'NS_ERROR_DOM_QUOTA_REACHED',
18
+ ],);
19
+
20
+ /**
21
+ * Reports whether a caught `setItem` value is a storage quota overflow, so
22
+ * eviction reclaims space only for a full store and never for an unrelated
23
+ * write fault such as a disabled-storage `SecurityError`.
24
+ *
25
+ * @param error - Caught value from a `setItem` failure.
26
+ *
27
+ * @returns Whether `error` names a quota overflow.
28
+ *
29
+ * @example
30
+ * ```ts
31
+ * try { sessionStorage.setItem(k, v); }
32
+ * catch (error: unknown) { if (isQuotaExceededError(error)) evictOldest(); }
33
+ * ```
34
+ */
35
+ export function isQuotaExceededError(error: unknown,): boolean {
36
+ return (
37
+ ((typeof error) === 'object')
38
+ && (error !== null)
39
+ && ('name' in error)
40
+ && ((typeof error.name) === 'string')
41
+ && QUOTA_EXCEEDED_NAMES.has(error.name,)
42
+ );
43
+ }
@@ -0,0 +1,55 @@
1
+ import {
2
+ describe,
3
+ expect,
4
+ it,
5
+ } from '@monochromatic-dev/module-test/ts';
6
+ import {
7
+ _isQuotaExceededError as isQuotaExceededError,
8
+ } from '@monochromatic-dev/module-logger';
9
+
10
+ await describe({
11
+ name: isQuotaExceededError.name,
12
+ children: [
13
+ it({
14
+ name: 'recognizes the standard DOMException name',
15
+ fn: async () => {
16
+ /**
17
+ * Overflow shaped exactly as current engines raise it.
18
+ */
19
+ const overflow = new DOMException('full', 'QuotaExceededError',);
20
+ expect(isQuotaExceededError(overflow,),)
21
+ .toBe(true,);
22
+ },
23
+ },),
24
+
25
+ it({
26
+ name: 'recognizes the legacy Firefox name on any object shape',
27
+ fn: async () => {
28
+ expect(isQuotaExceededError({ name: 'NS_ERROR_DOM_QUOTA_REACHED', },),)
29
+ .toBe(true,);
30
+ },
31
+ },),
32
+
33
+ it({
34
+ name: 'rejects a plain Error whose message merely mentions quota',
35
+ fn: async () => {
36
+ /**
37
+ * Non-quota failure that only talks about quota in prose.
38
+ */
39
+ const impostor = new Error('quota exceeded',);
40
+ expect(isQuotaExceededError(impostor,),)
41
+ .toBe(false,);
42
+ },
43
+ },),
44
+
45
+ it({
46
+ name: 'rejects non-object caught values',
47
+ fn: async () => {
48
+ expect(isQuotaExceededError(null,),)
49
+ .toBe(false,);
50
+ expect(isQuotaExceededError('QuotaExceededError',),)
51
+ .toBe(false,);
52
+ },
53
+ },),
54
+ ],
55
+ },);
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Shared host-runtime detection for web storage quota heuristics.
3
+ *
4
+ * The Web Storage API exposes no way to read a store's quota, so the
5
+ * per-storage-area quota modules pair this detection with their own tables of
6
+ * measured defaults. Detection is by host global rather than user-agent
7
+ * string: Deno and Bun both shim `process` with a Node-compatible
8
+ * `process.versions.node`, so their own globals are tested before the Node
9
+ * check to avoid misclassifying them.
10
+ *
11
+ * @module
12
+ */
13
+
14
+ /**
15
+ * Host runtimes distinguishable by global probes, plus `unknown` for
16
+ * everything else so callers fall back to uncapped reactive eviction.
17
+ */
18
+ export type WebStorageRuntime = 'browser' | 'bun' | 'deno' | 'node' | 'unknown';
19
+
20
+ /**
21
+ * Detects the current host runtime for web storage quota lookups.
22
+ *
23
+ * A `node` result also covers Node-embedding hosts such as Electron, whose
24
+ * renderer exposes `process.versions.node` alongside a DOM; callers that need
25
+ * to tell those apart check `'document' in globalThis` themselves.
26
+ *
27
+ * @returns Detected runtime, or `unknown` when no marker global matches.
28
+ *
29
+ * @example
30
+ * ```ts
31
+ * const quota = RUNTIME_QUOTA_CHARS[detectWebStorageRuntime()];
32
+ * ```
33
+ */
34
+ export function detectWebStorageRuntime(): WebStorageRuntime {
35
+ if ('Deno' in globalThis)
36
+ return 'deno';
37
+
38
+ if ('Bun' in globalThis)
39
+ return 'bun';
40
+
41
+ if (((typeof process) !== 'undefined') && ((typeof process.versions
42
+ .node) === 'string'))
43
+ return 'node';
44
+
45
+ if ('document' in globalThis)
46
+ return 'browser';
47
+
48
+ return 'unknown';
49
+ }