@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,288 @@
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
+ _createRecordBuffer as createRecordBuffer,
9
+ type Level,
10
+ } from '@monochromatic-dev/module-logger';
11
+
12
+ /**
13
+ * Builds a capturing backend handoff so a test can assert exactly which
14
+ * batches left the buffer and in what shape.
15
+ *
16
+ * @returns Handoff function plus the captured batch list.
17
+ */
18
+ function createCapturingFlush(): {
19
+ readonly batches: string[];
20
+ readonly onFlush: (batch: string,) => void;
21
+ } {
22
+ const batches: string[] = [];
23
+ return {
24
+ batches,
25
+ onFlush: function captureBatch(batch: string,): void {
26
+ batches.push(batch,);
27
+ },
28
+ };
29
+ }
30
+
31
+ /**
32
+ * Installs a capturing `globalThis.addEventListener` (absent under Node, so
33
+ * the buffer's lifecycle registration is otherwise a no-op there), restoring
34
+ * the prior value when the returned guard leaves `using` scope.
35
+ *
36
+ * @returns Disposable exposing captured handlers by event type.
37
+ */
38
+ function installFakeGlobalListeners(): Disposable & {
39
+ readonly handlers: Map<string, () => void>;
40
+ } {
41
+ const original = globalThis.addEventListener;
42
+ const handlers = new Map<string, () => void>();
43
+ globalThis.addEventListener = (function captureListener(
44
+ type: string,
45
+ handler: () => void,
46
+ ): void {
47
+ handlers.set(type, handler,);
48
+ }) as unknown as typeof globalThis.addEventListener;
49
+ return {
50
+ handlers,
51
+ [Symbol.dispose](): void {
52
+ globalThis.addEventListener = original;
53
+ },
54
+ };
55
+ }
56
+
57
+ /**
58
+ * Installs a fake `globalThis.document` (absent under Node) whose
59
+ * `visibilityState` is controllable and whose `addEventListener` captures
60
+ * handlers, restoring the prior value when the returned guard leaves `using`
61
+ * scope.
62
+ *
63
+ * @returns Disposable exposing captured handlers and mutable visibility.
64
+ */
65
+ function installFakeDocument(): Disposable & {
66
+ readonly handlers: Map<string, () => void>;
67
+ readonly visibility: { state: string; };
68
+ } {
69
+ const original = globalThis.document;
70
+ const handlers = new Map<string, () => void>();
71
+ const visibility = { state: 'visible', };
72
+ globalThis.document = {
73
+ addEventListener: function captureListener(
74
+ type: string,
75
+ handler: () => void,
76
+ ): void {
77
+ handlers.set(type, handler,);
78
+ },
79
+ get visibilityState(): string {
80
+ return visibility.state;
81
+ },
82
+ } as unknown as Document;
83
+ return {
84
+ handlers,
85
+ visibility,
86
+ [Symbol.dispose](): void {
87
+ globalThis.document = original;
88
+ },
89
+ };
90
+ }
91
+
92
+ /**
93
+ * Severities the buffer must flush synchronously on `add`.
94
+ */
95
+ const URGENT_LEVELS: readonly Level[] = ['warn', 'error', 'fatal',];
96
+
97
+ /**
98
+ * Severities the buffer must keep buffered on `add`.
99
+ */
100
+ const ROUTINE_LEVELS: readonly Level[] = ['trace', 'debug', 'info',];
101
+
102
+ // Serial because the lifecycle tests swap `globalThis` members, and because
103
+ // every buffer registers a process-wide pagehide listener, so a concurrent
104
+ // dispatch could drain a sibling test's buffer mid-assertion.
105
+ await describe({
106
+ name: createRecordBuffer.name,
107
+ concurrency: 1,
108
+ children: [
109
+ it({
110
+ name: 'a routine record stays buffered until drain, then leaves verbatim',
111
+ fn: async () => {
112
+ const { batches, onFlush, } = createCapturingFlush();
113
+ const buffer = createRecordBuffer({ onFlush, },);
114
+
115
+ buffer.add({ level: 'info', serialized: 'r1', },);
116
+ expect(batches.length,)
117
+ .toBe(0,);
118
+
119
+ buffer.drain();
120
+ expect(batches.join('|',),)
121
+ .toBe('r1',);
122
+ },
123
+ },),
124
+
125
+ it({
126
+ name: 'drain joins buffered records with newlines in add order',
127
+ fn: async () => {
128
+ const { batches, onFlush, } = createCapturingFlush();
129
+ const buffer = createRecordBuffer({ onFlush, },);
130
+
131
+ buffer.add({ level: 'info', serialized: 'r1', },);
132
+ buffer.add({ level: 'debug', serialized: 'r2', },);
133
+ buffer.add({ level: 'trace', serialized: 'r3', },);
134
+ buffer.drain();
135
+
136
+ expect(batches.join('|',),)
137
+ .toBe('r1\nr2\nr3',);
138
+ },
139
+ },),
140
+
141
+ ...URGENT_LEVELS.map(function mapUrgent(level,) {
142
+ return it({
143
+ name: `a ${level} record flushes itself and everything buffered ahead of it`,
144
+ fn: async () => {
145
+ const { batches, onFlush, } = createCapturingFlush();
146
+ const buffer = createRecordBuffer({ onFlush, },);
147
+
148
+ buffer.add({ level: 'info', serialized: 'ahead', },);
149
+ buffer.add({ level, serialized: 'urgent', },);
150
+
151
+ expect(batches.join('|',),)
152
+ .toBe('ahead\nurgent',);
153
+ },
154
+ },);
155
+ },),
156
+
157
+ ...ROUTINE_LEVELS.map(function mapRoutine(level,) {
158
+ return it({
159
+ name: `a ${level} record does not flush on add`,
160
+ fn: async () => {
161
+ const { batches, onFlush, } = createCapturingFlush();
162
+ const buffer = createRecordBuffer({ onFlush, },);
163
+
164
+ buffer.add({ level, serialized: 'routine', },);
165
+ expect(batches.length,)
166
+ .toBe(0,);
167
+
168
+ // Leave nothing armed behind for later tests.
169
+ buffer.drain();
170
+ },
171
+ },);
172
+ },),
173
+
174
+ it({
175
+ name: 'reaching the byte cap flushes synchronously from inside add',
176
+ fn: async () => {
177
+ const { batches, onFlush, } = createCapturingFlush();
178
+ const buffer = createRecordBuffer({ onFlush, },);
179
+
180
+ // A single record past the 32 KiB cap leaves immediately on add.
181
+ buffer.add({ level: 'info', serialized: 'L'.repeat(40_000,), },);
182
+ expect(batches.length,)
183
+ .toBe(1,);
184
+ },
185
+ },),
186
+
187
+ it({
188
+ name: 'an addition that would breach the cap flushes existing entries first',
189
+ fn: async () => {
190
+ const { batches, onFlush, } = createCapturingFlush();
191
+ const buffer = createRecordBuffer({ onFlush, },);
192
+
193
+ buffer.add({ level: 'info', serialized: 'small', },);
194
+ buffer.add({ level: 'info', serialized: 'L'.repeat(40_000,), },);
195
+
196
+ // Two separate batches: the small record is never a batch-mate of the
197
+ // cap-breaching one.
198
+ expect(batches.length,)
199
+ .toBe(2,);
200
+ expect(batches[0],)
201
+ .toBe('small',);
202
+ },
203
+ },),
204
+
205
+ it({
206
+ name: 'the quiet-period deadline drains without any trigger call',
207
+ fn: async () => {
208
+ const { batches, onFlush, } = createCapturingFlush();
209
+ const buffer = createRecordBuffer({ onFlush, },);
210
+
211
+ buffer.add({ level: 'debug', serialized: 'idle', },);
212
+
213
+ /**
214
+ * Comfortably past the buffer's 250 ms quiet-period deadline.
215
+ */
216
+ const pastDeadlineMs = 400;
217
+ await wait(pastDeadlineMs,);
218
+
219
+ expect(batches.join('|',),)
220
+ .toBe('idle',);
221
+ },
222
+ timeout: 5_000,
223
+ },),
224
+
225
+ it({
226
+ name: 'drain on an empty buffer is a no-op',
227
+ fn: async () => {
228
+ const { batches, onFlush, } = createCapturingFlush();
229
+ const buffer = createRecordBuffer({ onFlush, },);
230
+
231
+ buffer.drain();
232
+ buffer.drain();
233
+ expect(batches.length,)
234
+ .toBe(0,);
235
+ },
236
+ },),
237
+
238
+ it({
239
+ name: 'registers a pagehide listener that drains the buffer',
240
+ fn: async () => {
241
+ using listeners = installFakeGlobalListeners();
242
+ const { batches, onFlush, } = createCapturingFlush();
243
+ const buffer = createRecordBuffer({ onFlush, },);
244
+
245
+ buffer.add({ level: 'info', serialized: 'leaving', },);
246
+ /**
247
+ * Captured pagehide handler; the buffer must have registered one.
248
+ */
249
+ const onPagehide = listeners.handlers
250
+ .get('pagehide',);
251
+ expect((typeof onPagehide) === 'function',)
252
+ .toBe(true,);
253
+
254
+ onPagehide?.();
255
+ expect(batches.join('|',),)
256
+ .toBe('leaving',);
257
+ },
258
+ },),
259
+
260
+ it({
261
+ name: 'registers a visibilitychange listener that drains only when hidden',
262
+ fn: async () => {
263
+ using fakeDocument = installFakeDocument();
264
+ const { batches, onFlush, } = createCapturingFlush();
265
+ const buffer = createRecordBuffer({ onFlush, },);
266
+
267
+ buffer.add({ level: 'info', serialized: 'tabbed away', },);
268
+ /**
269
+ * Captured visibilitychange handler; the buffer must have registered one.
270
+ */
271
+ const onVisibilityChange = fakeDocument.handlers
272
+ .get('visibilitychange',);
273
+ expect((typeof onVisibilityChange) === 'function',)
274
+ .toBe(true,);
275
+
276
+ // Still visible: the handler must not drain.
277
+ onVisibilityChange?.();
278
+ expect(batches.length,)
279
+ .toBe(0,);
280
+
281
+ fakeDocument.visibility.state = 'hidden';
282
+ onVisibilityChange?.();
283
+ expect(batches.join('|',),)
284
+ .toBe('tabbed away',);
285
+ },
286
+ },),
287
+ ],
288
+ },);
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Per-runtime default sessionStorage quota heuristics.
3
+ *
4
+ * The Web Storage API exposes no way to read the sessionStorage quota (unlike
5
+ * `navigator.storage.estimate()`, which reports the unrelated persistent-storage
6
+ * budget), so the sink caps its own footprint from a table of measured
7
+ * defaults. Each figure was fill-probed on a fresh store: values are written at
8
+ * a growing single key until a `QuotaExceededError`, binary-searching the
9
+ * largest that fits. Figures are UTF-16 code units (JS string length, counting
10
+ * key plus value) because that is what sessionStorage measures and what the
11
+ * sink compares `serialized.length` against.
12
+ *
13
+ * @module
14
+ */
15
+
16
+ import {
17
+ detectWebStorageRuntime,
18
+ type WebStorageRuntime,
19
+ } from './web-storage-runtime.ts';
20
+
21
+ /**
22
+ * Measured default per-origin sessionStorage quotas, in UTF-16 code units, one
23
+ * bucket per detectable runtime:
24
+ *
25
+ * - `deno`: 10 MiB on Deno 2.9.
26
+ * - `node`: 5 MiB on Node 26.
27
+ * - `browser`: 5 MiB, measured identical on Chromium, Firefox, and WebKit under
28
+ * Playwright v1.61, so the three engines share one bucket and no fragile
29
+ * user-agent sniffing is needed to tell them apart.
30
+ *
31
+ * Bun 1.3 exposes no `sessionStorage`, so its bucket is uncapped: its sink
32
+ * never verifies and never reaches the cap. An unrecognized runtime is also
33
+ * uncapped so the caller relies on reactive eviction alone.
34
+ */
35
+ const RUNTIME_QUOTA_CHARS: Record<WebStorageRuntime, number> = {
36
+ browser: 5_242_880,
37
+ bun: Number.POSITIVE_INFINITY,
38
+ deno: 10_485_760,
39
+ node: 5_242_880,
40
+ unknown: Number.POSITIVE_INFINITY,
41
+ };
42
+
43
+ /**
44
+ * Detects the current runtime's default sessionStorage quota in UTF-16 code
45
+ * units, or `Number.POSITIVE_INFINITY` when the runtime is unrecognized so the
46
+ * caller leaves its footprint uncapped and relies on reactive eviction alone.
47
+ *
48
+ * @returns Total quota in code units, or `Number.POSITIVE_INFINITY` if unknown.
49
+ *
50
+ * @example
51
+ * ```ts
52
+ * const capChars = detectSessionStorageQuotaChars() / 2; // half the total
53
+ * ```
54
+ */
55
+ export function detectSessionStorageQuotaChars(): number {
56
+ return RUNTIME_QUOTA_CHARS[detectWebStorageRuntime()];
57
+ }
@@ -0,0 +1,98 @@
1
+ import {
2
+ describe,
3
+ expect,
4
+ it,
5
+ } from '@monochromatic-dev/module-test/ts';
6
+ import {
7
+ _detectSessionStorageQuotaChars as detectSessionStorageQuotaChars,
8
+ } from '@monochromatic-dev/module-logger';
9
+
10
+ /**
11
+ * Temporarily sets `globalThis` keys to the supplied values, restoring each to
12
+ * its prior value (or deleting keys that were absent) when the returned guard
13
+ * leaves `using` scope, so a runtime-detection test can impersonate Deno, Bun,
14
+ * or a browser without leaking the fake globals into later tests.
15
+ *
16
+ * @param overrides - Global keys to install for the duration of the scope.
17
+ *
18
+ * @returns Disposable that restores the original globals on exit.
19
+ */
20
+ function withGlobalOverrides(overrides: Record<string, unknown>,): Disposable {
21
+ const host = globalThis as unknown as Record<string, unknown>;
22
+ const saved = Object.entries(overrides,)
23
+ .map(function captureAndSet([key, value,],) {
24
+ const had = key in host;
25
+ const prior = host[key];
26
+ host[key] = value;
27
+ return {
28
+ key,
29
+ had,
30
+ prior,
31
+ };
32
+ },);
33
+ return {
34
+ [Symbol.dispose](): void {
35
+ for (const { key, had, prior, } of saved) {
36
+ if (had)
37
+ host[key] = prior;
38
+ else
39
+ // `delete host[key]` (dynamic key) is banned; this removes the key so
40
+ // an absent-before global (e.g. `Deno`) does not leak into later tests.
41
+ Reflect.deleteProperty(host, key,);
42
+ }
43
+ },
44
+ };
45
+ }
46
+
47
+ await describe({
48
+ name: detectSessionStorageQuotaChars.name,
49
+ // Serial because every test mutates process-global runtime markers.
50
+ concurrency: 1,
51
+ children: [
52
+ it({
53
+ name: 'reads node web storage as 5 MiB',
54
+ fn: async () => {
55
+ expect(detectSessionStorageQuotaChars(),)
56
+ .toBe(5_242_880,);
57
+ },
58
+ },),
59
+
60
+ it({
61
+ name: 'reads Deno web storage as 10 MiB',
62
+ fn: async () => {
63
+ using _override = withGlobalOverrides({ Deno: {}, },);
64
+ expect(detectSessionStorageQuotaChars(),)
65
+ .toBe(10_485_760,);
66
+ },
67
+ },),
68
+
69
+ it({
70
+ name: 'leaves Bun uncapped since it exposes no sessionStorage',
71
+ fn: async () => {
72
+ using _override = withGlobalOverrides({ Bun: {}, },);
73
+ expect(detectSessionStorageQuotaChars(),)
74
+ .toBe(Number.POSITIVE_INFINITY,);
75
+ },
76
+ },),
77
+
78
+ it({
79
+ name: 'reads a browser engine as 5 MiB',
80
+ fn: async () => {
81
+ // No node markers, but a DOM: the browser bucket, shared by Chromium,
82
+ // Firefox, and WebKit.
83
+ using _override = withGlobalOverrides({ process: undefined, document: {}, },);
84
+ expect(detectSessionStorageQuotaChars(),)
85
+ .toBe(5_242_880,);
86
+ },
87
+ },),
88
+
89
+ it({
90
+ name: 'leaves an unrecognized runtime uncapped',
91
+ fn: async () => {
92
+ using _override = withGlobalOverrides({ process: undefined, },);
93
+ expect(detectSessionStorageQuotaChars(),)
94
+ .toBe(Number.POSITIVE_INFINITY,);
95
+ },
96
+ },),
97
+ ],
98
+ },);
@@ -0,0 +1,178 @@
1
+ import { reportLoggerInternalError, } from '../error-format.ts';
2
+ import { detectSessionStorageQuotaChars, } from './session-storage-quota.ts';
3
+ import { isQuotaExceededError, } from './web-storage-quota-error.ts';
4
+
5
+ /**
6
+ * Prefix for sessionStorage keys to namespace log entries.
7
+ */
8
+ const STORAGE_KEY_PREFIX = 'monochromatic.log';
9
+
10
+ /**
11
+ * Builds the namespaced sessionStorage key for a log entry at `index`.
12
+ *
13
+ * @param index - Zero-based slot number of an entry.
14
+ *
15
+ * @returns Prefixed key such as `monochromatic.log.3`.
16
+ *
17
+ * @example
18
+ * ```ts
19
+ * storageKey(3); // 'monochromatic.log.3'
20
+ * ```
21
+ */
22
+ function storageKey(index: number,): string {
23
+ return `${STORAGE_KEY_PREFIX}.${index}`;
24
+ }
25
+
26
+ /**
27
+ * Builds the persistence engine behind the sessionStorage sink: each `persist`
28
+ * lands one already-serialized batch under a counter-incremented key, with
29
+ * proactive and reactive quota eviction. The counter lives in this instance's
30
+ * closure (no module-global state), so independent sinks and tests never share
31
+ * keys or need a reset hook.
32
+ *
33
+ * The engine caps its own footprint at half the runtime's sessionStorage
34
+ * quota, proactively dropping its oldest entries, and reactively drops them
35
+ * again if the real store overflows; see {@link createSessionStorageStore.persist}.
36
+ *
37
+ * @returns Engine exposing `persist` for one batch value per call.
38
+ *
39
+ * @example
40
+ * ```ts
41
+ * const store = createSessionStorageStore();
42
+ * store.persist('{"level":"info","message":"hi","timestamp":0}');
43
+ * ```
44
+ */
45
+ export function createSessionStorageStore(): { readonly persist: (batch: string,) => void; } {
46
+ /**
47
+ * Instance-local write cursor, eviction watermark, and footprint tally.
48
+ * `lineCounter` is the next slot to write and advances only when a `setItem`
49
+ * actually lands, so this engine's present entries occupy the contiguous
50
+ * range `[oldestIndex, lineCounter)`. `oldestIndex` is the lowest slot the
51
+ * engine still owns; eviction removes that entry and climbs `oldestIndex`
52
+ * toward `lineCounter`, so `oldestIndex < lineCounter` doubles as the "a
53
+ * prior write succeeded and an owned entry remains" guard that keeps
54
+ * eviction from ever touching another origin consumer's keys. `usedChars`
55
+ * tracks the code units this engine currently occupies so the half-quota cap
56
+ * needs no re-summing. `reportedFailure` gates the give-up diagnostic to
57
+ * once per failure episode: a persistently full store (another writer owning
58
+ * the space) would otherwise emit one `console.warn` per batch, so the flag
59
+ * stays set until a write next lands, which re-arms a single report for the
60
+ * next episode.
61
+ */
62
+ const state: {
63
+ lineCounter: number;
64
+ oldestIndex: number;
65
+ usedChars: number;
66
+ reportedFailure: boolean;
67
+ } = {
68
+ lineCounter: 0,
69
+ oldestIndex: 0,
70
+ usedChars: 0,
71
+ reportedFailure: false,
72
+ };
73
+
74
+ /**
75
+ * Half the detected runtime sessionStorage quota, in UTF-16 code units, or
76
+ * `Number.POSITIVE_INFINITY` on an unrecognized runtime. The engine keeps
77
+ * its own footprint at or below this so the logger never claims more than
78
+ * half the store, leaving the rest for the host application. An infinite cap
79
+ * disables the proactive check, leaving only reactive quota-error eviction.
80
+ */
81
+ const capChars = detectSessionStorageQuotaChars() / 2;
82
+
83
+ /**
84
+ * Removes this engine's oldest still-present entry, advancing the watermark
85
+ * and subtracting the reclaimed entry's code units from the running
86
+ * footprint. Reading the value back before removal keeps `usedChars` honest
87
+ * even if the entry drifted from what was written.
88
+ */
89
+ function evictOldest(): void {
90
+ /**
91
+ * Key of the oldest owned entry, removed to reclaim its slot and its space.
92
+ */
93
+ const key = storageKey(state.oldestIndex,);
94
+ /**
95
+ * Value being evicted, read back so its length can leave the footprint tally.
96
+ */
97
+ const evicted = globalThis.sessionStorage
98
+ .getItem(key,);
99
+ globalThis.sessionStorage
100
+ .removeItem(key,);
101
+ state.oldestIndex++;
102
+ if (evicted !== null)
103
+ state.usedChars = Math.max(
104
+ 0,
105
+ state.usedChars - evicted.length,
106
+ );
107
+ }
108
+
109
+ /**
110
+ * Persists one serialized batch to sessionStorage under a
111
+ * counter-incremented key.
112
+ *
113
+ * First it proactively drops its own oldest entries so its footprint stays
114
+ * at or below half the runtime's sessionStorage quota, leaving the rest for
115
+ * the host application. It then writes, and on a quota overflow (the store
116
+ * being fuller than the cap accounts for), and only while an owned entry
117
+ * remains (so the reclaimed keys are its own, never another origin
118
+ * consumer's), it drops its oldest still-present entry and retries until the
119
+ * batch fits or nothing of its own remains to drop. A batch larger than the
120
+ * whole quota therefore evicts every owned entry, then reports and gives up
121
+ * rather than looping forever. A non-quota failure is reported without any
122
+ * eviction. The sink only persists after verification, so no availability
123
+ * guard is needed here.
124
+ *
125
+ * @param batch - Serialized JSONL batch to persist.
126
+ */
127
+ function persist(batch: string,): void {
128
+ /**
129
+ * Code units this batch adds; the key's length is left out as a negligible near-constant.
130
+ */
131
+ const batchChars = batch.length;
132
+
133
+ // Proactively reclaim space so the engine's own footprint stays under the
134
+ // half-quota cap, dropping oldest-first while an owned entry remains. An
135
+ // infinite cap (unrecognized runtime) makes the guard always false.
136
+ while ((state.oldestIndex < state.lineCounter) && ((state.usedChars + batchChars) > capChars)) {
137
+ evictOldest();
138
+ }
139
+
140
+ /**
141
+ * Write-attempt bound: one try for each entry still available to evict,
142
+ * followed by one final try after every owned entry has been removed.
143
+ */
144
+ const maxWriteAttempts = (state.lineCounter - state.oldestIndex) + 1;
145
+ for (let writeAttempt = 0; writeAttempt < maxWriteAttempts; writeAttempt++) {
146
+ try {
147
+ globalThis.sessionStorage
148
+ .setItem(
149
+ storageKey(state.lineCounter,),
150
+ batch,
151
+ );
152
+ state.lineCounter++;
153
+ state.usedChars += batchChars;
154
+ // A landed write re-arms a single give-up report for the next episode.
155
+ state.reportedFailure = false;
156
+ return;
157
+ }
158
+ catch (error: unknown) {
159
+ if (isQuotaExceededError(error,) && (state.oldestIndex < state.lineCounter)) {
160
+ evictOldest();
161
+ continue;
162
+ }
163
+ // Report once per failure episode, not once per unwritable batch, so a
164
+ // persistently full store does not flood the console every flush.
165
+ if (!state.reportedFailure) {
166
+ reportLoggerInternalError({
167
+ context: 'sessionStorage sink record write failed (repeats suppressed until a write next succeeds)',
168
+ error,
169
+ },);
170
+ state.reportedFailure = true;
171
+ }
172
+ return;
173
+ }
174
+ }
175
+ }
176
+
177
+ return { persist, };
178
+ }