@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.
- package/CHANGELOG.md +11 -0
- package/LICENSES/GPL-3.0-or-later.txt +674 -0
- package/LICENSES/LGPL-3.0-or-later.txt +165 -0
- package/README.md +404 -0
- package/dist/final/neutral/index.d.mts +673 -0
- package/dist/final/neutral/index.mjs +3 -0
- package/dist/final/neutral/rolldown-runtime-5duEfhBv.mjs +1 -0
- package/dist/final/node/index.d.mts +673 -0
- package/dist/final/node/index.mjs +3 -0
- package/dist/final/node/rolldown-runtime-5duEfhBv.mjs +1 -0
- package/package.json +43 -0
- package/src/create-logger.ts +494 -0
- package/src/create-logger.unit.test.ts +752 -0
- package/src/error-format.ts +43 -0
- package/src/index.ts +35 -0
- package/src/logger.ts +67 -0
- package/src/logger.unit.test.ts +190 -0
- package/src/sink/console-control-chars.ts +140 -0
- package/src/sink/console-control-chars.unit.test.ts +206 -0
- package/src/sink/console.ts +531 -0
- package/src/sink/console.unit.test.ts +542 -0
- package/src/sink/file.ts +297 -0
- package/src/sink/file.unit.test.ts +202 -0
- package/src/sink/index.ts +11 -0
- package/src/sink/indexed-db-util.ts +96 -0
- package/src/sink/indexed-db.browser.test.ts +184 -0
- package/src/sink/indexed-db.ts +324 -0
- package/src/sink/indexed-db.unit.test.ts +80 -0
- package/src/sink/local-storage-key.ts +176 -0
- package/src/sink/local-storage-key.unit.test.ts +106 -0
- package/src/sink/local-storage-quota.ts +60 -0
- package/src/sink/local-storage-quota.unit.test.ts +98 -0
- package/src/sink/local-storage-store.ts +368 -0
- package/src/sink/local-storage-store.unit.test.ts +329 -0
- package/src/sink/local-storage.browser.test.ts +125 -0
- package/src/sink/local-storage.ts +182 -0
- package/src/sink/local-storage.unit.test.ts +218 -0
- package/src/sink/noop.ts +46 -0
- package/src/sink/noop.unit.test.ts +47 -0
- package/src/sink/opfs.browser.test.ts +84 -0
- package/src/sink/opfs.ts +212 -0
- package/src/sink/opfs.unit.test.ts +81 -0
- package/src/sink/record-buffer.ts +230 -0
- package/src/sink/record-buffer.unit.test.ts +288 -0
- package/src/sink/session-storage-quota.ts +57 -0
- package/src/sink/session-storage-quota.unit.test.ts +98 -0
- package/src/sink/session-storage-store.ts +178 -0
- package/src/sink/session-storage.browser.test.ts +137 -0
- package/src/sink/session-storage.ts +128 -0
- package/src/sink/session-storage.unit.test.ts +527 -0
- package/src/sink/web-storage-quota-error.ts +43 -0
- package/src/sink/web-storage-quota-error.unit.test.ts +55 -0
- package/src/sink/web-storage-runtime.ts +49 -0
- package/src/startup.unit.test.ts +232 -0
- package/src/tagged.ts +74 -0
- package/src/tagged.unit.test.ts +211 -0
- 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
|
+
}
|