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