@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,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
|
+
},);
|