@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,184 @@
1
+ // oxlint-disable typescript/no-unsafe-assignment, typescript/no-unsafe-member-access -- browser evaluate callbacks lose type info across page boundary
2
+
3
+ import {
4
+ expect,
5
+ test,
6
+ } from '@playwright/test';
7
+
8
+ declare global {
9
+ // oxlint-disable-next-line typescript/consistent-type-imports -- typeof import() cannot use import type syntax
10
+ var moduleLogger: typeof import('@monochromatic-dev/module-logger');
11
+ }
12
+
13
+ test.describe('IndexedDB sink', () => {
14
+ test.beforeEach(async ({ page, },) => {
15
+ await page.goto('/',);
16
+ await page.waitForFunction(() => globalThis.moduleLogger !== undefined);
17
+ },);
18
+
19
+ test('createIndexedDbSink exposes a callable verify', async ({ page, },) => {
20
+ const typeofVerify = await page.evaluate(() => {
21
+ const { createIndexedDbSink, } = globalThis.moduleLogger.sinks;
22
+ return typeof createIndexedDbSink().verify;
23
+ },);
24
+ expect(typeofVerify,).toBe('function',);
25
+ });
26
+
27
+ test('verify detects availability', async ({ page, },) => {
28
+ const result = await page.evaluate(async () => {
29
+ const { createIndexedDbSink, } = globalThis.moduleLogger.sinks;
30
+ return createIndexedDbSink().verify();
31
+ },);
32
+ expect(result,).toBe(true,);
33
+ });
34
+
35
+ test('a verified sink writes records across levels and message shapes', async ({ page, },) => {
36
+ const allSucceeded = await page.evaluate(async () => {
37
+ const { createIndexedDbSink, } = globalThis.moduleLogger.sinks;
38
+ const sink = createIndexedDbSink();
39
+ await sink.verify();
40
+ const levels = ['trace', 'debug', 'info', 'warn', 'error', 'fatal',] as const;
41
+ const messages = [
42
+ 'test message',
43
+ 'Hello δΈ–η•Œ 🌍',
44
+ '',
45
+ '{"key": "value", "nested": {"a": 1}}',
46
+ ];
47
+ for (const level of levels) {
48
+ for (const message of messages) {
49
+ try {
50
+ void sink.write({
51
+ level,
52
+ message,
53
+ timestamp: Date.now(),
54
+ },);
55
+ }
56
+ catch (error: unknown) {
57
+ console.warn('IndexedDB sink browser test write failed', error,);
58
+ return false;
59
+ }
60
+ }
61
+ }
62
+ await sink.flush?.();
63
+ return true;
64
+ },);
65
+ expect(allSucceeded,).toBe(true,);
66
+ });
67
+
68
+ test('a flushed batch is readable back out of the database as JSONL', async ({ page, },) => {
69
+ const result = await page.evaluate(async () => {
70
+ const { createIndexedDbSink, } = globalThis.moduleLogger.sinks;
71
+ const sink = createIndexedDbSink();
72
+ await sink.verify();
73
+
74
+ const testMessage = `unique-test-${Date.now()}`;
75
+ await sink.write({
76
+ level: 'info' as const,
77
+ message: testMessage,
78
+ timestamp: Date.now(),
79
+ },);
80
+ // Routine severity buffers; the flush hook forces the batch out and
81
+ // resolves only after its transaction settles, so the read below is
82
+ // deterministic.
83
+ await sink.flush?.();
84
+
85
+ const database: IDBDatabase = await new Promise((resolve, reject,) => {
86
+ const request = globalThis.indexedDB.open('monochromatic.log', 1,);
87
+ request.addEventListener('success', () => {
88
+ resolve(request.result,);
89
+ },);
90
+ request.addEventListener('error', () => {
91
+ reject(request.error ?? new Error('open failed',),);
92
+ },);
93
+ },);
94
+ const batches: string[] = await new Promise((resolve, reject,) => {
95
+ const request = database
96
+ .transaction('batch', 'readonly',)
97
+ .objectStore('batch',)
98
+ .getAll();
99
+ request.addEventListener('success', () => {
100
+ resolve(request.result,);
101
+ },);
102
+ request.addEventListener('error', () => {
103
+ reject(request.error ?? new Error('getAll failed',),);
104
+ },);
105
+ },);
106
+ database.close();
107
+
108
+ const holding = batches.find(batch => batch.includes(testMessage,),);
109
+ if (holding === undefined)
110
+ return { found: false, message: null, level: null, testMessage, };
111
+ const lines = holding.split('\n',);
112
+ const parsed = JSON.parse(lines.find(line => line.includes(testMessage,),) ?? 'null',);
113
+ return {
114
+ found: true,
115
+ message: parsed.message,
116
+ level: parsed.level,
117
+ testMessage,
118
+ };
119
+ },);
120
+
121
+ expect(result.found,).toBe(true,);
122
+ expect(result.message,).toBe(result.testMessage,);
123
+ expect(result.level,).toBe('info',);
124
+ });
125
+
126
+ test('retention trims the store back to the cap, oldest first', async ({ page, },) => {
127
+ test.setTimeout(120_000,);
128
+ const result = await page.evaluate(async () => {
129
+ const { createIndexedDbSink, } = globalThis.moduleLogger.sinks;
130
+ const sink = createIndexedDbSink();
131
+ await sink.verify();
132
+
133
+ // Every warn record flushes its own batch, so this issues one batch
134
+ // transaction per record and must cross the 2048-batch retention cap
135
+ // regardless of what earlier tests left in the store.
136
+ const BATCHES_PAST_CAP = 2_049;
137
+ for (let index = 0; index < BATCHES_PAST_CAP; index++) {
138
+ void sink.write({
139
+ level: 'warn' as const,
140
+ message: `retention-${index}`,
141
+ timestamp: Date.now(),
142
+ },);
143
+ }
144
+ await sink.flush?.();
145
+
146
+ const database: IDBDatabase = await new Promise((resolve, reject,) => {
147
+ const request = globalThis.indexedDB.open('monochromatic.log', 1,);
148
+ request.addEventListener('success', () => {
149
+ resolve(request.result,);
150
+ },);
151
+ request.addEventListener('error', () => {
152
+ reject(request.error ?? new Error('open failed',),);
153
+ },);
154
+ },);
155
+ const store = database
156
+ .transaction('batch', 'readonly',)
157
+ .objectStore('batch',);
158
+ const count: number = await new Promise((resolve, reject,) => {
159
+ const request = store.count();
160
+ request.addEventListener('success', () => {
161
+ resolve(request.result,);
162
+ },);
163
+ request.addEventListener('error', () => {
164
+ reject(request.error ?? new Error('count failed',),);
165
+ },);
166
+ },);
167
+ // The newest write must have survived the trim.
168
+ const newestPresent: boolean = await new Promise((resolve, reject,) => {
169
+ const request = store.getAll();
170
+ request.addEventListener('success', () => {
171
+ resolve(request.result.some((batch: string,) => batch.includes(`retention-${BATCHES_PAST_CAP - 1}`,),),);
172
+ },);
173
+ request.addEventListener('error', () => {
174
+ reject(request.error ?? new Error('getAll failed',),);
175
+ },);
176
+ },);
177
+ database.close();
178
+ return { count, newestPresent, };
179
+ },);
180
+
181
+ expect(result.count,).toBe(2_048,);
182
+ expect(result.newestPresent,).toBe(true,);
183
+ });
184
+ });
@@ -0,0 +1,324 @@
1
+ import { reportLoggerInternalError, } from '../error-format.ts';
2
+ import {
3
+ awaitRequest,
4
+ awaitTransaction,
5
+ } from './indexed-db-util.ts';
6
+ import { createRecordBuffer, } from './record-buffer.ts';
7
+
8
+ import type {
9
+ Level,
10
+ Sink,
11
+ } from '../types.ts';
12
+
13
+ /**
14
+ * Database holding this logger's batches, one per origin, shared by every tab.
15
+ */
16
+ const DATABASE_NAME = 'monochromatic.log';
17
+
18
+ /**
19
+ * Schema version; bump only with an upgrade path in `onupgradeneeded`.
20
+ */
21
+ const DATABASE_VERSION = 1;
22
+
23
+ /**
24
+ * Object store holding one newline-joined JSONL batch string per
25
+ * auto-incremented key, so key order is arrival order across every tab and
26
+ * retention can trim oldest-first without any run bookkeeping.
27
+ */
28
+ const BATCH_STORE = 'batch';
29
+
30
+ /**
31
+ * Retention cap on stored batches, trimmed oldest-first inside each persist
32
+ * transaction. At the buffer's 32 KiB flush cap this bounds the store near
33
+ * 64 MiB, well under the multi-gigabyte origin quota
34
+ * (`navigator.storage.estimate()` reported 10 GiB on the measuring machine)
35
+ * while months of sessions still fit. A count cap instead of a byte tally
36
+ * because severity-flushed batches vary in size and an exact byte budget
37
+ * would need a cross-session tally re-summed at startup; the bound is
38
+ * approximate by design.
39
+ */
40
+ const MAX_STORED_BATCHES = 2_048;
41
+
42
+ /**
43
+ * Opens (creating on first use) the logger's IndexedDB database with the
44
+ * batch store ready.
45
+ *
46
+ * @returns Open database connection.
47
+ *
48
+ * @throws DOMException - When the backend refuses to open, for example in a
49
+ * storage-partitioned context that denies IndexedDB.
50
+ */
51
+ async function openLogDatabase(): Promise<IDBDatabase> {
52
+ /**
53
+ * Open request; the upgrade handler runs only when the database is new or
54
+ * below {@link DATABASE_VERSION}.
55
+ */
56
+ const request = globalThis.indexedDB
57
+ .open(
58
+ DATABASE_NAME,
59
+ DATABASE_VERSION,
60
+ );
61
+ request.onupgradeneeded = function createBatchStore(): void {
62
+ request.result
63
+ .createObjectStore(
64
+ BATCH_STORE,
65
+ { autoIncrement: true, },
66
+ );
67
+ };
68
+ return await awaitRequest(request,);
69
+ }
70
+
71
+ /**
72
+ * Persists one batch and trims the store back under the retention cap, all
73
+ * inside one readwrite transaction so a crash between the steps cannot leave
74
+ * the trim half-applied.
75
+ *
76
+ * @param database - Open connection from {@link openLogDatabase}.
77
+ *
78
+ * @param batch - Newline-joined JSONL batch string to persist.
79
+ *
80
+ * @throws DOMException - When the transaction errors or aborts, for example
81
+ * under an origin-quota overflow.
82
+ *
83
+ * @mutates database - `database.transaction` opens a readwrite transaction,
84
+ * registering live state on the host-owned connection, and the queued add
85
+ * and trim change the store that connection controls.
86
+ */
87
+ async function persistBatch(
88
+ {
89
+ database,
90
+ batch,
91
+ }: {
92
+ readonly database: IDBDatabase;
93
+ readonly batch: string;
94
+ },
95
+ ): Promise<void> {
96
+ /**
97
+ * Single transaction carrying the add, the count, and any trim.
98
+ */
99
+ const transaction = database.transaction(
100
+ BATCH_STORE,
101
+ 'readwrite',
102
+ );
103
+ /**
104
+ * Batch store within this transaction.
105
+ */
106
+ const store = transaction.objectStore(BATCH_STORE,);
107
+ store.add(batch,);
108
+ /**
109
+ * Stored batch count including the add queued in this transaction.
110
+ */
111
+ const count = await awaitRequest(store.count(),);
112
+ if (count > MAX_STORED_BATCHES) {
113
+ /**
114
+ * Oldest keys past the cap; `getAllKeys` returns keys in ascending order,
115
+ * which for an auto-incremented store is arrival order.
116
+ */
117
+ const staleKeys = await awaitRequest(store.getAllKeys(
118
+ null,
119
+ count - MAX_STORED_BATCHES,
120
+ ),);
121
+ /**
122
+ * Newest key still to be trimmed; everything at or below it goes.
123
+ */
124
+ const newestStale = staleKeys.at(-1,);
125
+ if (newestStale !== undefined)
126
+ store.delete(IDBKeyRange.upperBound(newestStale,),);
127
+ }
128
+ await awaitTransaction(transaction,);
129
+ }
130
+
131
+ /**
132
+ * Builds an IndexedDB sink that buffers serialized records through the shared
133
+ * {@link createRecordBuffer} policy and persists each newline-joined JSONL
134
+ * batch as one string value per transaction, measured at 0.15 Β΅s of
135
+ * main-thread enqueue per record on headless Chromium 149 (one `add` per
136
+ * 32 KiB batch). The connection lives in this instance's closure (no
137
+ * module-global state), so independent loggers and tests never share a
138
+ * handle or need a reset hook.
139
+ *
140
+ * Records are readable the moment their transaction settles (DevTools
141
+ * Application tab included), survive tab close and browser restart, and
142
+ * auto-incremented keys serialize across tabs, so no run-scoped naming is
143
+ * needed. Retention trims oldest-first past {@link MAX_STORED_BATCHES}.
144
+ * Transactions use the default relaxed durability: relaxed commits reach the
145
+ * browser's storage backend promptly and survive renderer crashes, and the
146
+ * OS-crash window `durability: 'strict'` would close is the rarest failure
147
+ * class, not worth an fsync per batch.
148
+ *
149
+ * Flush triggers (32 KiB in-write cap, `warn`-or-worse severity, 250 ms
150
+ * quiet-period deadline, page lifecycle, and the `flush` hook) are the
151
+ * buffer's; see {@link createRecordBuffer}. The sink's `flush` hook awaits
152
+ * every issued batch transaction before resolving.
153
+ *
154
+ * @returns Sink backed by IndexedDB.
155
+ *
156
+ * @example
157
+ * ```ts
158
+ * const { logger } = createLogger({ sinks: [createIndexedDbSink()] });
159
+ * logger.warn('quota nearing limit');
160
+ * ```
161
+ */
162
+ export function createIndexedDbSink(): Sink {
163
+ /**
164
+ * Instance-local open connection, set by `verify` and reused by every batch
165
+ * write. Absent until a successful verification.
166
+ */
167
+ const state: { database?: IDBDatabase; } = {};
168
+
169
+ /**
170
+ * Batch transactions issued and not yet settled; the `flush` hook drains
171
+ * this so logger-level `flush()` observes every issued batch.
172
+ */
173
+ const pendingBatchWrites = new Set<Promise<void>>();
174
+
175
+ /**
176
+ * Verifies IndexedDB is available and round-trips a probe value, keeping
177
+ * the opened connection for subsequent writes. The logger calls this once
178
+ * and owns the resulting availability.
179
+ *
180
+ * @returns Whether IndexedDB logging is available.
181
+ */
182
+ async function verify(): Promise<boolean> {
183
+ try {
184
+ if ((typeof globalThis.indexedDB) === 'undefined')
185
+ return false;
186
+ /**
187
+ * Connection kept for the sink's lifetime once the probe passes.
188
+ */
189
+ const database = await openLogDatabase();
190
+ /**
191
+ * Probe transaction: add, read back, and remove one sentinel value.
192
+ */
193
+ const transaction = database.transaction(
194
+ BATCH_STORE,
195
+ 'readwrite',
196
+ );
197
+ /**
198
+ * Batch store within the probe transaction.
199
+ */
200
+ const store = transaction.objectStore(BATCH_STORE,);
201
+ /**
202
+ * Timestamp-based probe value so concurrent verifications never read each other's writes.
203
+ */
204
+ const probeValue = `probe-${Date.now()}`;
205
+ /**
206
+ * Key the store assigned to the probe, used to read it back and remove it.
207
+ */
208
+ const probeKey = await awaitRequest(store.add(probeValue,),);
209
+ /**
210
+ * Probe value read back; equality proves the backend round-trips writes.
211
+ */
212
+ const readBack = await awaitRequest(store.get(probeKey,) as IDBRequest<unknown>,);
213
+ store.delete(probeKey,);
214
+ await awaitTransaction(transaction,);
215
+
216
+ if (readBack !== probeValue)
217
+ return false;
218
+ state.database = database;
219
+ return true;
220
+ }
221
+ catch (error: unknown) {
222
+ if ('indexedDB' in globalThis)
223
+ reportLoggerInternalError({
224
+ context: 'IndexedDB sink verification failed',
225
+ error,
226
+ },);
227
+ return false;
228
+ }
229
+ }
230
+
231
+ /**
232
+ * Writes one batch through {@link persistBatch}, swallowing and reporting
233
+ * failures so the pending-write set always settles.
234
+ *
235
+ * @param batch - Newline-joined JSONL batch from the buffer.
236
+ */
237
+ async function writeBatch(batch: string,): Promise<void> {
238
+ if (!state.database)
239
+ return;
240
+
241
+ try {
242
+ await persistBatch({
243
+ database: state.database,
244
+ batch,
245
+ },);
246
+ }
247
+ catch (error: unknown) {
248
+ reportLoggerInternalError({
249
+ context: 'IndexedDB sink record write failed',
250
+ error,
251
+ },);
252
+ }
253
+ }
254
+
255
+ /**
256
+ * Removes a tracked batch write from {@link pendingBatchWrites} once it
257
+ * settles.
258
+ *
259
+ * @param pending - Promise returned by {@link writeBatch}.
260
+ */
261
+ async function removePendingWhenSettled(pending: Promise<void>,): Promise<void> {
262
+ await pending;
263
+ pendingBatchWrites.delete(pending,);
264
+ }
265
+
266
+ /**
267
+ * Backend handoff for the buffer: issues the batch transaction without
268
+ * awaiting and tracks it for the `flush` hook.
269
+ *
270
+ * @param batch - Newline-joined JSONL batch from the buffer.
271
+ */
272
+ function handOffBatch(batch: string,): void {
273
+ /**
274
+ * In-flight batch write; never rejects, because {@link writeBatch} reports internally.
275
+ */
276
+ const pending = writeBatch(batch,);
277
+ pendingBatchWrites.add(pending,);
278
+ void removePendingWhenSettled(pending,);
279
+ }
280
+
281
+ /**
282
+ * Shared buffering stage; every flush trigger issues one batch transaction.
283
+ */
284
+ const buffer = createRecordBuffer({ onFlush: handOffBatch, },);
285
+
286
+ /**
287
+ * Buffers a log record through the shared policy; see
288
+ * {@link createRecordBuffer} for the flush triggers.
289
+ *
290
+ * @param record - Log record to buffer and eventually persist.
291
+ *
292
+ * @mutates record - `JSON.stringify` may invoke `toJSON`, getters, or proxy traps.
293
+ */
294
+ function write(record: {
295
+ level: Level;
296
+ message: string;
297
+ timestamp: number;
298
+ },): Promise<void> {
299
+ buffer.add({
300
+ level: record.level,
301
+ serialized: JSON.stringify(record,),
302
+ },);
303
+ return Promise.resolve();
304
+ }
305
+
306
+ /**
307
+ * Drains the buffer into the store and resolves once every issued batch
308
+ * transaction has settled.
309
+ */
310
+ async function flush(): Promise<void> {
311
+ buffer.drain();
312
+ /**
313
+ * Snapshot of in-flight batch writes at drain time.
314
+ */
315
+ const writes = [...pendingBatchWrites,];
316
+ await Promise.all(writes,);
317
+ }
318
+
319
+ return {
320
+ flush,
321
+ verify,
322
+ write,
323
+ };
324
+ }
@@ -0,0 +1,80 @@
1
+ import {
2
+ describe,
3
+ expect,
4
+ it,
5
+ } from '@monochromatic-dev/module-test/ts';
6
+ import {
7
+ sinks,
8
+ } from '@monochromatic-dev/module-logger';
9
+
10
+ /**
11
+ * Sink factories under test, read from the built artifact's `sinks` namespace.
12
+ */
13
+ const {
14
+ createIndexedDbSink,
15
+ } = sinks;
16
+
17
+ // Node, Deno, and Bun expose no `indexedDB` (probed on Node 26, Deno 2.9,
18
+ // Bun 1.3), so this file exercises the unavailable-backend fallback that the
19
+ // browser test (which runs where IndexedDB exists) never reaches: `verify`
20
+ // short-circuits on the missing global, and drained batches hit the
21
+ // unset-connection guard. The available path lives in
22
+ // `indexed-db.browser.test.ts`; the shared buffering policy is covered in
23
+ // `record-buffer.unit.test.ts`.
24
+ await describe({
25
+ name: 'IndexedDB sink (node fallback)',
26
+ children: [
27
+ it({
28
+ name: 'verify resolves false when IndexedDB is absent',
29
+ fn: async () => {
30
+ const sink = createIndexedDbSink();
31
+ expect(await sink.verify(),)
32
+ .toBe(false,);
33
+ },
34
+ },),
35
+
36
+ it({
37
+ name: 'write buffers without throwing when IndexedDB is absent',
38
+ fn: async () => {
39
+ // The record buffers; nothing touches the missing backend until a
40
+ // flush trigger fires.
41
+ const sink = createIndexedDbSink();
42
+ /**
43
+ * Resolved write result; the sink write contract is `Promise<void>`.
44
+ */
45
+ const result = await sink.write({
46
+ level: 'info',
47
+ message: 'dropped',
48
+ timestamp: 0,
49
+ },);
50
+ expect(result,)
51
+ .toBeUndefined();
52
+ },
53
+ },),
54
+
55
+ it({
56
+ name: 'flush drains the buffer into the unset-connection guard and resolves',
57
+ fn: async () => {
58
+ // A warn record drains synchronously on add, an info record drains on
59
+ // the flush hook; both batches hit the guard and are dropped silently.
60
+ const sink = createIndexedDbSink();
61
+ await sink.write({
62
+ level: 'warn',
63
+ message: 'urgent but backendless',
64
+ timestamp: 0,
65
+ },);
66
+ await sink.write({
67
+ level: 'info',
68
+ message: 'buffered but backendless',
69
+ timestamp: 1,
70
+ },);
71
+ /**
72
+ * Resolved flush result; must settle even with no connection to write to.
73
+ */
74
+ const result = await sink.flush?.();
75
+ expect(result,)
76
+ .toBeUndefined();
77
+ },
78
+ },),
79
+ ],
80
+ },);