@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,368 @@
1
+ import { reportLoggerInternalError, } from '../error-format.ts';
2
+ import {
3
+ buildLogKey,
4
+ compareLogKeys,
5
+ parseLogKey,
6
+ type ParsedLogKey,
7
+ } from './local-storage-key.ts';
8
+ import { detectLocalStorageQuotaChars, } from './local-storage-quota.ts';
9
+ import { isQuotaExceededError, } from './web-storage-quota-error.ts';
10
+
11
+ /**
12
+ * Radix for the run nonce so `Number.prototype.toString` yields compact
13
+ * alphanumerics.
14
+ */
15
+ const NONCE_RADIX = 36;
16
+
17
+ /**
18
+ * Length of the run nonce; four base-36 characters make a same-millisecond
19
+ * collision between two tabs vanishingly unlikely while keeping keys short.
20
+ */
21
+ const NONCE_LENGTH = 4;
22
+
23
+ /**
24
+ * One adopted prior-run entry: its parsed identity plus the value length it
25
+ * occupies, captured once at adoption so eviction needs no re-read.
26
+ */
27
+ type PriorEntry = ParsedLogKey & { readonly chars: number; };
28
+
29
+ /**
30
+ * Builds the persistence engine behind the localStorage sink: each `persist`
31
+ * lands one already-serialized batch under a run-scoped counter-incremented
32
+ * key, with proactive and reactive quota eviction. Run identity and counters
33
+ * live in this instance's closure (no module-global state), so independent
34
+ * sinks and tests never share keys or need a reset hook.
35
+ *
36
+ * Unlike sessionStorage, localStorage is shared by every tab of the origin and
37
+ * survives restarts, so this engine differs from the sessionStorage engine in
38
+ * two ways. Keys carry a run identity (see `local-storage-key.ts`), so
39
+ * concurrent tabs never collide on a counter. And on its first persist the
40
+ * engine adopts every strictly-parsed entry left by other runs into its
41
+ * footprint tally, evicting those oldest-first before its own entries;
42
+ * without that, leftovers from dead sessions would fill the store until no
43
+ * run could ever write again. Adoption is deferred to first persist rather
44
+ * than construction so building the default sink set never touches
45
+ * `globalThis.localStorage` on runtimes where the sink never verifies (plain
46
+ * Node warns on mere access). Keys that fail the strict parse, including the
47
+ * host application's, are never counted and never evicted.
48
+ *
49
+ * The engine caps its own footprint (adopted entries included) at half the
50
+ * runtime's localStorage quota, proactively dropping oldest-first, and
51
+ * reactively drops again if the real store still overflows; see
52
+ * {@link createLocalStorageStore.persist}.
53
+ *
54
+ * @returns Engine exposing `persist` for one batch value per call.
55
+ *
56
+ * @example
57
+ * ```ts
58
+ * const store = createLocalStorageStore();
59
+ * store.persist('{"level":"info","message":"hi","timestamp":0}');
60
+ * ```
61
+ */
62
+ export function createLocalStorageStore(): { readonly persist: (batch: string,) => void; } {
63
+ /**
64
+ * Identity of this run, embedded in every key this engine writes: the stamp
65
+ * orders runs for cross-run eviction and the nonce keeps two tabs started
66
+ * in the same millisecond apart.
67
+ */
68
+ const runIdentity = {
69
+ stamp: Date.now(),
70
+ nonce: Math.random()
71
+ .toString(NONCE_RADIX,)
72
+ .slice(
73
+ 2,
74
+ 2 + NONCE_LENGTH,
75
+ )
76
+ .padEnd(
77
+ NONCE_LENGTH,
78
+ '0',
79
+ ),
80
+ };
81
+
82
+ /**
83
+ * Instance-local write cursor, eviction watermark, and footprint tally,
84
+ * mirroring the sessionStorage engine: this run's own entries occupy the
85
+ * contiguous index range `[oldestIndex, lineCounter)` and `usedChars`
86
+ * tracks the code units the engine accounts for (adopted prior-run entries
87
+ * included) so the half-quota cap needs no re-summing. `reportedFailure`
88
+ * gates the give-up diagnostic to once per failure episode, re-armed by the
89
+ * next landed write. `adoptedPrior` defers the prior-run scan to the first
90
+ * persist, which only happens after verification.
91
+ */
92
+ const state: {
93
+ lineCounter: number;
94
+ oldestIndex: number;
95
+ usedChars: number;
96
+ reportedFailure: boolean;
97
+ adoptedPrior: boolean;
98
+ } = {
99
+ lineCounter: 0,
100
+ oldestIndex: 0,
101
+ usedChars: 0,
102
+ reportedFailure: false,
103
+ adoptedPrior: false,
104
+ };
105
+
106
+ /**
107
+ * Prior-run entries adopted at first persist, sorted oldest-first, with a
108
+ * cursor marking how far eviction has consumed them; entries before the
109
+ * cursor are already removed. Prior entries always evict before this run's
110
+ * own, since they predate everything this run writes.
111
+ */
112
+ const prior: {
113
+ entries: readonly PriorEntry[];
114
+ cursor: number;
115
+ } = {
116
+ entries: [],
117
+ cursor: 0,
118
+ };
119
+
120
+ /**
121
+ * Half the detected runtime localStorage quota, in UTF-16 code units, or
122
+ * `Number.POSITIVE_INFINITY` on an unrecognized runtime. The engine keeps
123
+ * its accounted footprint at or below this so the logger never claims more
124
+ * than half the store, leaving the rest for the host application. An
125
+ * infinite cap disables the proactive check, leaving only reactive
126
+ * quota-error eviction.
127
+ */
128
+ const capChars = detectLocalStorageQuotaChars() / 2;
129
+
130
+ /**
131
+ * Builds this run's key for a batch slot.
132
+ *
133
+ * @param index - Zero-based batch slot within this run.
134
+ *
135
+ * @returns Run-scoped namespaced key.
136
+ */
137
+ function ownKey(index: number,): string {
138
+ return buildLogKey({
139
+ stamp: runIdentity.stamp,
140
+ nonce: runIdentity.nonce,
141
+ index,
142
+ },);
143
+ }
144
+
145
+ /**
146
+ * Scans localStorage once for strictly-parsed entries left by other runs,
147
+ * sorts them oldest-first for eviction, and adds their lengths to the
148
+ * footprint tally. Entries another tab writes after this scan are invisible
149
+ * to the tally; the reactive quota loop covers that staleness.
150
+ */
151
+ function adoptPriorEntries(): void {
152
+ /**
153
+ * Entry count at scan time; enumeration is by index because `Storage`
154
+ * exposes no iterator.
155
+ */
156
+ const total = globalThis.localStorage
157
+ .length;
158
+ /**
159
+ * Strictly-parsed foreign-run entries found by the scan, unsorted.
160
+ */
161
+ const found: PriorEntry[] = [];
162
+ for (let slot = 0; slot < total; slot++) {
163
+ /**
164
+ * Key at this enumeration slot; `null` past the end under concurrent removal.
165
+ */
166
+ const key = globalThis.localStorage
167
+ .key(slot,);
168
+ if (key === null)
169
+ continue;
170
+ /**
171
+ * Parsed run identity, absent for any key the engine must not touch.
172
+ */
173
+ const { parsed, } = parseLogKey(key,);
174
+ if (parsed === undefined)
175
+ continue;
176
+ if ((parsed.stamp === runIdentity.stamp) && (parsed.nonce === runIdentity.nonce))
177
+ continue;
178
+ /**
179
+ * Stored batch, read so its length enters the footprint tally.
180
+ */
181
+ const value = globalThis.localStorage
182
+ .getItem(key,);
183
+ if (value === null)
184
+ continue;
185
+ found.push({
186
+ ...parsed,
187
+ chars: value.length,
188
+ },);
189
+ }
190
+ prior.entries = found.toSorted(function byOldestFirst(
191
+ first,
192
+ second,
193
+ ) {
194
+ return compareLogKeys({
195
+ first,
196
+ second,
197
+ },);
198
+ },);
199
+ state.usedChars += found.reduce(
200
+ function sumChars(
201
+ sum,
202
+ entry,
203
+ ) {
204
+ return sum + entry.chars;
205
+ },
206
+ 0,
207
+ );
208
+ }
209
+
210
+ /**
211
+ * Reports whether anything remains this engine may evict: an adopted
212
+ * prior-run entry past the cursor, or one of this run's own entries.
213
+ *
214
+ * @returns Whether an eviction call would reclaim something.
215
+ */
216
+ function hasEvictable(): boolean {
217
+ /**
218
+ * Count of adopted prior-run entries; those before the cursor are gone.
219
+ */
220
+ const priorCount = prior.entries
221
+ .length;
222
+ return (prior.cursor < priorCount)
223
+ || (state.oldestIndex < state.lineCounter);
224
+ }
225
+
226
+ /**
227
+ * Removes the oldest not-yet-evicted adopted prior-run entry, if one
228
+ * remains. Its length leaves the tally from the adoption snapshot: prior
229
+ * keys are never rewritten (counters only advance), so a re-read could only
230
+ * observe the same value or a concurrent removal, and in both cases the
231
+ * snapshot is what the tally counted.
232
+ *
233
+ * @returns Whether a prior-run entry was evicted.
234
+ */
235
+ function evictOldestPrior(): boolean {
236
+ /**
237
+ * Oldest remaining adopted entry, or `undefined` when all are consumed.
238
+ */
239
+ const entry = prior.entries[prior.cursor];
240
+ if (entry === undefined)
241
+ return false;
242
+ prior.cursor++;
243
+ globalThis.localStorage
244
+ .removeItem(entry.key,);
245
+ state.usedChars = Math.max(
246
+ 0,
247
+ state.usedChars - entry.chars,
248
+ );
249
+ return true;
250
+ }
251
+
252
+ /**
253
+ * Removes this run's oldest still-present entry, advancing the watermark
254
+ * and subtracting the reclaimed entry's code units from the running
255
+ * footprint. Reading the value back before removal keeps `usedChars` honest
256
+ * even if the entry drifted from what was written.
257
+ */
258
+ function evictOldestOwn(): void {
259
+ /**
260
+ * Key of the oldest owned entry, removed to reclaim its slot and its space.
261
+ */
262
+ const key = ownKey(state.oldestIndex,);
263
+ /**
264
+ * Value being evicted, read back so its length can leave the footprint tally.
265
+ */
266
+ const evicted = globalThis.localStorage
267
+ .getItem(key,);
268
+ globalThis.localStorage
269
+ .removeItem(key,);
270
+ state.oldestIndex++;
271
+ if (evicted !== null)
272
+ state.usedChars = Math.max(
273
+ 0,
274
+ state.usedChars - evicted.length,
275
+ );
276
+ }
277
+
278
+ /**
279
+ * Evicts the single oldest thing the engine still owns: adopted prior-run
280
+ * entries first (they predate everything this run wrote), then this run's
281
+ * own oldest. Callers guard with {@link hasEvictable}.
282
+ */
283
+ function evictOldest(): void {
284
+ if (evictOldestPrior())
285
+ return;
286
+ if (state.oldestIndex < state.lineCounter)
287
+ evictOldestOwn();
288
+ }
289
+
290
+ /**
291
+ * Persists one serialized batch to localStorage under this run's next
292
+ * counter-incremented key.
293
+ *
294
+ * The first call adopts prior-run entries into the footprint tally. Each
295
+ * call then proactively evicts oldest-first (prior runs before this run's
296
+ * own) until the accounted footprint fits under half the runtime's
297
+ * localStorage quota, writes, and on a quota overflow (the store being
298
+ * fuller than the tally accounts for, such as another live tab writing
299
+ * concurrently) evicts and retries until the batch fits or nothing owned
300
+ * remains to drop. A batch larger than the whole quota therefore evicts
301
+ * everything owned, then reports and gives up rather than looping forever.
302
+ * A non-quota failure is reported without any eviction. The sink only
303
+ * persists after verification, so no availability guard is needed here.
304
+ *
305
+ * @param batch - Serialized JSONL batch to persist.
306
+ */
307
+ function persist(batch: string,): void {
308
+ if (!state.adoptedPrior) {
309
+ state.adoptedPrior = true;
310
+ adoptPriorEntries();
311
+ }
312
+
313
+ /**
314
+ * Code units this batch adds; the key's length is left out as a negligible near-constant.
315
+ */
316
+ const batchChars = batch.length;
317
+
318
+ // Proactively reclaim space so the accounted footprint stays under the
319
+ // half-quota cap, dropping oldest-first while anything owned remains. An
320
+ // infinite cap (unrecognized runtime) makes the guard always false.
321
+ while (hasEvictable() && ((state.usedChars + batchChars) > capChars)) {
322
+ evictOldest();
323
+ }
324
+
325
+ /**
326
+ * Write-attempt bound: one try for each entry still available to evict,
327
+ * followed by one final try after every owned entry has been removed.
328
+ */
329
+ const maxWriteAttempts = (prior
330
+ .entries
331
+ .length
332
+ - prior.cursor)
333
+ + (state.lineCounter - state.oldestIndex)
334
+ + 1;
335
+ for (let writeAttempt = 0; writeAttempt < maxWriteAttempts; writeAttempt++) {
336
+ try {
337
+ globalThis.localStorage
338
+ .setItem(
339
+ ownKey(state.lineCounter,),
340
+ batch,
341
+ );
342
+ state.lineCounter++;
343
+ state.usedChars += batchChars;
344
+ // A landed write re-arms a single give-up report for the next episode.
345
+ state.reportedFailure = false;
346
+ return;
347
+ }
348
+ catch (error: unknown) {
349
+ if (isQuotaExceededError(error,) && hasEvictable()) {
350
+ evictOldest();
351
+ continue;
352
+ }
353
+ // Report once per failure episode, not once per unwritable batch, so a
354
+ // persistently full store does not flood the console every flush.
355
+ if (!state.reportedFailure) {
356
+ reportLoggerInternalError({
357
+ context: 'localStorage sink record write failed (repeats suppressed until a write next succeeds)',
358
+ error,
359
+ },);
360
+ state.reportedFailure = true;
361
+ }
362
+ return;
363
+ }
364
+ }
365
+ }
366
+
367
+ return { persist, };
368
+ }
@@ -0,0 +1,329 @@
1
+ import {
2
+ describe,
3
+ expect,
4
+ it,
5
+ } from '@monochromatic-dev/module-test/ts';
6
+ import {
7
+ _createLocalStorageStore as createLocalStorageStore,
8
+ _parseLogKey as parseLogKey,
9
+ } from '@monochromatic-dev/module-logger';
10
+
11
+ /**
12
+ * One mebibyte of UTF-16 code units; the node cap under test is half the
13
+ * measured 5 MiB quota, so multi-mebibyte batches drive eviction.
14
+ */
15
+ const MIB = 1_048_576;
16
+
17
+ /**
18
+ * Installs `fake` as `globalThis.localStorage` via the property descriptor
19
+ * (plain assignment would call Node's phantom setter-less path on some hosts),
20
+ * restoring the original descriptor, or removing the property when none
21
+ * existed, when the returned guard leaves `using` scope.
22
+ *
23
+ * @param fake - Storage stand-in to install for the duration of the scope.
24
+ *
25
+ * @returns Disposable that restores the original `localStorage` on exit.
26
+ */
27
+ function installFakeLocalStorage(fake: Storage,): Disposable {
28
+ const original = Object.getOwnPropertyDescriptor(globalThis, 'localStorage',);
29
+ Object.defineProperty(globalThis, 'localStorage', {
30
+ configurable: true,
31
+ value: fake,
32
+ },);
33
+ return {
34
+ [Symbol.dispose](): void {
35
+ if (original === undefined)
36
+ Reflect.deleteProperty(globalThis, 'localStorage',);
37
+ else
38
+ Object.defineProperty(globalThis, 'localStorage', original,);
39
+ },
40
+ };
41
+ }
42
+
43
+ /**
44
+ * Builds an in-memory `Storage` stand-in with full enumeration support (the
45
+ * engine's adoption scan walks `length`/`key`), rejecting a `setItem` once
46
+ * stored value lengths would exceed `quotaChars`, throwing the same
47
+ * `QuotaExceededError` a real backend raises. Records every `removeItem`
48
+ * under `removed` so a test can assert exactly which keys were evicted.
49
+ *
50
+ * @param quotaChars - Total value length the store accepts before
51
+ * overflowing; omitted means unlimited.
52
+ *
53
+ * @returns Storage stand-in exposing `removed` and the raw `backing` map.
54
+ */
55
+ function createFakeStorage(
56
+ { quotaChars, }: { readonly quotaChars?: number; } = {},
57
+ ): Storage & {
58
+ readonly backing: Map<string, string>;
59
+ readonly removed: string[];
60
+ } {
61
+ const backing = new Map<string, string>();
62
+ const removed: string[] = [];
63
+ const used = { chars: 0, };
64
+ return {
65
+ backing,
66
+ removed,
67
+ get length() {
68
+ return backing.size;
69
+ },
70
+ key(slot: number,) {
71
+ return [...backing.keys(),][slot] ?? null;
72
+ },
73
+ clear(): void {
74
+ backing.clear();
75
+ used.chars = 0;
76
+ },
77
+ getItem(key: string,) {
78
+ return backing.get(key,) ?? null;
79
+ },
80
+ setItem(key: string, value: string,): void {
81
+ const priorLength = backing.get(key,)?.length ?? 0;
82
+ const nextChars = (used.chars - priorLength) + value.length;
83
+ if ((quotaChars !== undefined) && (nextChars > quotaChars))
84
+ throw new DOMException('exceeded the quota', 'QuotaExceededError',);
85
+ backing.set(key, value,);
86
+ used.chars = nextChars;
87
+ },
88
+ removeItem(key: string,): void {
89
+ removed.push(key,);
90
+ const priorLength = backing.get(key,)?.length ?? 0;
91
+ if (backing.delete(key,))
92
+ used.chars -= priorLength;
93
+ },
94
+ } as unknown as Storage & {
95
+ readonly backing: Map<string, string>;
96
+ readonly removed: string[];
97
+ };
98
+ }
99
+
100
+ /**
101
+ * Builds an in-memory `Storage` stand-in whose first `setItem` succeeds and
102
+ * every later one throws a non-quota error, so a test can prove the engine
103
+ * does not evict for failures other than a quota overflow. Enumeration is
104
+ * supported for the adoption scan; `removeItem` calls land in `removed`.
105
+ *
106
+ * @returns Storage stand-in exposing the evicted-key log as `removed`.
107
+ */
108
+ function createFlakyStorage(): Storage & { readonly removed: string[]; } {
109
+ const backing = new Map<string, string>();
110
+ const removed: string[] = [];
111
+ const calls = { setItem: 0, };
112
+ return {
113
+ removed,
114
+ get length() {
115
+ return backing.size;
116
+ },
117
+ key(slot: number,) {
118
+ return [...backing.keys(),][slot] ?? null;
119
+ },
120
+ getItem(key: string,) {
121
+ return backing.get(key,) ?? null;
122
+ },
123
+ setItem(key: string, value: string,): void {
124
+ calls.setItem += 1;
125
+ if (calls.setItem > 1)
126
+ throw new Error('localStorage disabled mid-session',);
127
+ backing.set(key, value,);
128
+ },
129
+ removeItem(key: string,): void {
130
+ removed.push(key,);
131
+ backing.delete(key,);
132
+ },
133
+ } as unknown as Storage & { readonly removed: string[]; };
134
+ }
135
+
136
+ /**
137
+ * Captures `console.warn` output, restoring the real method when the returned
138
+ * guard leaves `using` scope, so a test can count the engine's give-up
139
+ * reports.
140
+ *
141
+ * @returns Disposable exposing captured warn lines as `calls`.
142
+ */
143
+ function spyConsoleWarn(): Disposable & { readonly calls: string[]; } {
144
+ const original = console.warn;
145
+ const calls: string[] = [];
146
+ console.warn = (...args: unknown[]): void => {
147
+ calls.push(args.map(String,)
148
+ .join(' ',),);
149
+ };
150
+ return {
151
+ calls,
152
+ [Symbol.dispose](): void {
153
+ console.warn = original;
154
+ },
155
+ };
156
+ }
157
+
158
+ // Serial because every test swaps the process-global `localStorage`.
159
+ await describe({
160
+ name: createLocalStorageStore.name,
161
+ concurrency: 1,
162
+ children: [
163
+ it({
164
+ name: 'persists batches under run-scoped counter keys',
165
+ fn: async () => {
166
+ const fake = createFakeStorage();
167
+ using _storage = installFakeLocalStorage(fake,);
168
+ const store = createLocalStorageStore();
169
+
170
+ store.persist('alpha',);
171
+ store.persist('beta',);
172
+
173
+ /**
174
+ * Parsed identities of every landed key; both must carry one shared run identity.
175
+ */
176
+ const parsed = [...fake.backing.keys(),]
177
+ .flatMap((key,) => {
178
+ const { parsed: identity, } = parseLogKey(key,);
179
+ return (identity === undefined) ? [] : [identity,];
180
+ },);
181
+ expect(parsed,)
182
+ .toHaveLength(2,);
183
+ expect(parsed[1]?.stamp,)
184
+ .toBe(parsed[0]?.stamp,);
185
+ expect(parsed[1]?.nonce,)
186
+ .toBe(parsed[0]?.nonce,);
187
+ expect(parsed.map((identity,) => identity.index,),)
188
+ .toEqual([0, 1,],);
189
+ expect(parsed.flatMap((identity,) => fake.backing.get(identity.key,) ?? [],),)
190
+ .toEqual(['alpha', 'beta',],);
191
+ },
192
+ },),
193
+
194
+ it({
195
+ name: 'adopts prior-run entries and evicts them oldest-first under the cap, skipping foreign keys',
196
+ fn: async () => {
197
+ const fake = createFakeStorage();
198
+ // Two dead-run entries totaling 2 MiB, plus two keys the strict parse
199
+ // must protect: a host-application key under the prefix and a
200
+ // malformed run identity.
201
+ fake.setItem('monochromatic.log.1000.aaaa.0', 'x'.repeat(MIB,),);
202
+ fake.setItem('monochromatic.log.2000.bbbb.0', 'x'.repeat(MIB,),);
203
+ fake.setItem('monochromatic.log.legacy-note', 'host data',);
204
+ fake.setItem('monochromatic.log.3000.cccc.nan', 'malformed',);
205
+ using _storage = installFakeLocalStorage(fake,);
206
+ const store = createLocalStorageStore();
207
+
208
+ // Adopted 2 MiB plus this 1 MiB batch exceeds the 2.5 MiB node cap,
209
+ // so exactly the oldest dead-run entry must fall.
210
+ store.persist('y'.repeat(MIB,),);
211
+
212
+ expect(fake.removed,)
213
+ .toEqual(['monochromatic.log.1000.aaaa.0',],);
214
+ expect(fake.backing.has('monochromatic.log.2000.bbbb.0',),)
215
+ .toBe(true,);
216
+ expect(fake.backing.get('monochromatic.log.legacy-note',),)
217
+ .toBe('host data',);
218
+ expect(fake.backing.get('monochromatic.log.3000.cccc.nan',),)
219
+ .toBe('malformed',);
220
+ },
221
+ },),
222
+
223
+ it({
224
+ name: 'evicts its own oldest batch after prior entries are exhausted',
225
+ fn: async () => {
226
+ const fake = createFakeStorage();
227
+ using _storage = installFakeLocalStorage(fake,);
228
+ const store = createLocalStorageStore();
229
+
230
+ store.persist('a'.repeat(MIB,),);
231
+ store.persist('b'.repeat(MIB,),);
232
+ // The third mebibyte breaches the 2.5 MiB cap; with no prior-run
233
+ // entries, this run's own index 0 is the oldest thing owned.
234
+ store.persist('c'.repeat(MIB,),);
235
+
236
+ expect(fake.removed,)
237
+ .toHaveLength(1,);
238
+ /**
239
+ * Identity of the evicted key; it must be this run's slot zero.
240
+ */
241
+ const { parsed: evicted, } = parseLogKey(fake.removed[0] ?? '',);
242
+ expect(evicted?.index,)
243
+ .toBe(0,);
244
+ /**
245
+ * Indices still present after eviction, in insertion order.
246
+ */
247
+ const remaining = [...fake.backing.keys(),]
248
+ .flatMap((key,) => {
249
+ const { parsed: identity, } = parseLogKey(key,);
250
+ return (identity === undefined) ? [] : [identity.index,];
251
+ },);
252
+ expect(remaining,)
253
+ .toEqual([1, 2,],);
254
+ },
255
+ },),
256
+
257
+ it({
258
+ name: 'reactively evicts on a quota overflow the tally did not predict',
259
+ fn: async () => {
260
+ // Real capacity far below the runtime cap heuristic, as when another
261
+ // tab's writes fill space this engine's tally never saw.
262
+ const fake = createFakeStorage({ quotaChars: 1_500_000, },);
263
+ fake.setItem('monochromatic.log.1000.aaaa.0', 'x'.repeat(500_000,),);
264
+ using _storage = installFakeLocalStorage(fake,);
265
+ const store = createLocalStorageStore();
266
+
267
+ // 500k adopted + 1.2M batch fits the 2.5 MiB cap, so no proactive
268
+ // eviction; the fake's 1.5M quota rejects the write, and the retry
269
+ // loop must reclaim the dead-run entry.
270
+ store.persist('z'.repeat(1_200_000,),);
271
+
272
+ expect(fake.removed,)
273
+ .toEqual(['monochromatic.log.1000.aaaa.0',],);
274
+ /**
275
+ * Landed batch values after the retry; only the new batch remains.
276
+ */
277
+ const values = [...fake.backing.values(),];
278
+ expect(values,)
279
+ .toHaveLength(1,);
280
+ expect(values[0]?.length,)
281
+ .toBe(1_200_000,);
282
+ },
283
+ },),
284
+
285
+ it({
286
+ name: 'reports giving up once per episode and re-arms after a landed write',
287
+ fn: async () => {
288
+ const fake = createFakeStorage({ quotaChars: 100, },);
289
+ using _storage = installFakeLocalStorage(fake,);
290
+ using warnSpy = spyConsoleWarn();
291
+ const store = createLocalStorageStore();
292
+
293
+ store.persist('a'.repeat(50,),);
294
+ // Evicts the landed batch, still cannot fit, reports once.
295
+ store.persist('b'.repeat(200,),);
296
+ // Same episode: no second report.
297
+ store.persist('c'.repeat(200,),);
298
+ expect(warnSpy.calls,)
299
+ .toHaveLength(1,);
300
+ expect(warnSpy.calls[0],)
301
+ .toContain('localStorage sink record write failed',);
302
+
303
+ // A landed write re-arms the report for the next episode.
304
+ store.persist('d'.repeat(30,),);
305
+ store.persist('e'.repeat(200,),);
306
+ expect(warnSpy.calls,)
307
+ .toHaveLength(2,);
308
+ },
309
+ },),
310
+
311
+ it({
312
+ name: 'a non-quota failure reports without evicting anything',
313
+ fn: async () => {
314
+ const fake = createFlakyStorage();
315
+ using _storage = installFakeLocalStorage(fake,);
316
+ using warnSpy = spyConsoleWarn();
317
+ const store = createLocalStorageStore();
318
+
319
+ store.persist('one',);
320
+ store.persist('two',);
321
+
322
+ expect(fake.removed,)
323
+ .toHaveLength(0,);
324
+ expect(warnSpy.calls,)
325
+ .toHaveLength(1,);
326
+ },
327
+ },),
328
+ ],
329
+ },);