@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
package/src/sink/file.ts
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import type { stat as Stat, } from 'node:fs/promises';
|
|
2
|
+
import type {
|
|
3
|
+
dirname as Dirname,
|
|
4
|
+
join as Join,
|
|
5
|
+
} from 'node:path';
|
|
6
|
+
|
|
7
|
+
import { reportLoggerInternalError, } from '../error-format.ts';
|
|
8
|
+
|
|
9
|
+
import type { Sink, } from '../types.ts';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Sentinel returned by {@link findNodeModulesUp} when no ancestor directory
|
|
13
|
+
* contains a `node_modules`. A unique symbol so it never collides with a real
|
|
14
|
+
* path string the walk might otherwise return, keeping the result free of a
|
|
15
|
+
* banned `string | undefined` union.
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* ```ts
|
|
19
|
+
* const dir = await findNodeModulesUp({ cwd, stat, dirname, join });
|
|
20
|
+
* if (dir === NO_NODE_MODULES_FOUND) {
|
|
21
|
+
* // no ancestor project root
|
|
22
|
+
* }
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
export const NO_NODE_MODULES_FOUND: unique symbol = Symbol('logger:no-node-modules-found',);
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Walks up from `cwd` to find the nearest ancestor directory containing a
|
|
29
|
+
* `node_modules` subdirectory, returning that subdirectory's absolute path.
|
|
30
|
+
*
|
|
31
|
+
* Using find-up rather than cwd-relative placement keeps log directories
|
|
32
|
+
* anchored to the project the caller actually belongs to. Without this,
|
|
33
|
+
* scripts invoked from build output (e.g. `dist/`) or other stray cwds
|
|
34
|
+
* would create `node_modules/.monochromatic/` inside those trees, polluting
|
|
35
|
+
* shipped artifacts.
|
|
36
|
+
*
|
|
37
|
+
* Exported primarily so `index.unit.test.ts` can exercise both the hit
|
|
38
|
+
* and miss paths directly with an injected `stat`.
|
|
39
|
+
*
|
|
40
|
+
* @param cwd - starting directory for the upward search
|
|
41
|
+
*
|
|
42
|
+
* @param stat - `node:fs/promises` stat (injected so the dynamic
|
|
43
|
+
* import stays in one place)
|
|
44
|
+
*
|
|
45
|
+
* @param dirname - `node:path` dirname
|
|
46
|
+
*
|
|
47
|
+
* @param join - `node:path` join
|
|
48
|
+
*
|
|
49
|
+
* @param reportError - logger fault reporter injected for deterministic tests
|
|
50
|
+
*
|
|
51
|
+
* @returns absolute path to the nearest ancestor `node_modules`, or
|
|
52
|
+
* {@link NO_NODE_MODULES_FOUND} when no ancestor contains one
|
|
53
|
+
*
|
|
54
|
+
* @example
|
|
55
|
+
* ```ts
|
|
56
|
+
* const dir = await findNodeModulesUp({ cwd: process.cwd(), stat, dirname, join });
|
|
57
|
+
* ```
|
|
58
|
+
*/
|
|
59
|
+
export async function findNodeModulesUp(
|
|
60
|
+
{
|
|
61
|
+
cwd,
|
|
62
|
+
stat,
|
|
63
|
+
dirname,
|
|
64
|
+
join,
|
|
65
|
+
reportError = reportLoggerInternalError,
|
|
66
|
+
}: {
|
|
67
|
+
readonly cwd: string;
|
|
68
|
+
readonly stat: typeof Stat;
|
|
69
|
+
readonly dirname: typeof Dirname;
|
|
70
|
+
readonly join: typeof Join;
|
|
71
|
+
readonly reportError?: typeof reportLoggerInternalError;
|
|
72
|
+
},
|
|
73
|
+
): Promise<string | typeof NO_NODE_MODULES_FOUND> {
|
|
74
|
+
/**
|
|
75
|
+
* Directory being tested in this iteration; either resolves to a node_modules or triggers the walk to the parent.
|
|
76
|
+
*/
|
|
77
|
+
const candidate = join(
|
|
78
|
+
cwd,
|
|
79
|
+
'node_modules',
|
|
80
|
+
);
|
|
81
|
+
try {
|
|
82
|
+
/**
|
|
83
|
+
* Stat result for `candidate`; only directories count as a hit, guarding against a sibling file also named `node_modules`.
|
|
84
|
+
*/
|
|
85
|
+
const entry = await stat(candidate,);
|
|
86
|
+
if (entry.isDirectory())
|
|
87
|
+
return candidate;
|
|
88
|
+
}
|
|
89
|
+
catch (error: unknown) {
|
|
90
|
+
// Missing candidate is expected while walking ancestors; only unexpected stat failures are logger faults.
|
|
91
|
+
if (!(Error.isError(error,)
|
|
92
|
+
&& ('code' in error)
|
|
93
|
+
&& (error.code === 'ENOENT'))) {
|
|
94
|
+
reportError({
|
|
95
|
+
context: `node_modules candidate ${candidate} unavailable during file sink search`,
|
|
96
|
+
error,
|
|
97
|
+
},);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Parent directory used by the next recursive step; equal to `cwd` only at the filesystem root, which terminates the walk.
|
|
102
|
+
*/
|
|
103
|
+
const parent = dirname(cwd,);
|
|
104
|
+
if (parent === cwd)
|
|
105
|
+
return NO_NODE_MODULES_FOUND;
|
|
106
|
+
return await findNodeModulesUp({
|
|
107
|
+
cwd: parent,
|
|
108
|
+
stat,
|
|
109
|
+
dirname,
|
|
110
|
+
join,
|
|
111
|
+
reportError,
|
|
112
|
+
},);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Builds a file sink that appends JSONL records to the nearest ancestor
|
|
117
|
+
* `node_modules/.monochromatic/{timestamp}.log.jsonl` (resolved once during
|
|
118
|
+
* verification). The resolved path, the cached `appendFile`, and the
|
|
119
|
+
* verification memo live in this instance's closure (no module-global state),
|
|
120
|
+
* so independent loggers and tests never share a log file or need a reset
|
|
121
|
+
* hook. No `flush` hook: each `write` awaits `appendFile` directly, so there
|
|
122
|
+
* is no buffered state to drain.
|
|
123
|
+
*
|
|
124
|
+
* @returns Sink backed by `node:fs/promises`.
|
|
125
|
+
*
|
|
126
|
+
* @example
|
|
127
|
+
* ```ts
|
|
128
|
+
* const { logger } = createLogger({ sinks: [createFileSink()] });
|
|
129
|
+
* logger.error('unhandled rejection');
|
|
130
|
+
* await logger.flush();
|
|
131
|
+
* ```
|
|
132
|
+
*/
|
|
133
|
+
export function createFileSink(): Sink {
|
|
134
|
+
/**
|
|
135
|
+
* Instance-local file-sink resources. `appendFile` and `filePath` are
|
|
136
|
+
* populated during verification and read by `write`; `verifyPromise`
|
|
137
|
+
* memoizes concurrent verification so a caller arriving mid-flight shares
|
|
138
|
+
* the same async work rather than starting a second probe.
|
|
139
|
+
*/
|
|
140
|
+
const state: {
|
|
141
|
+
// oxlint-disable-next-line typescript/consistent-type-imports -- typeof import() cannot use import type syntax
|
|
142
|
+
appendFile?: typeof import('node:fs/promises').appendFile;
|
|
143
|
+
filePath?: string;
|
|
144
|
+
verifyPromise?: Promise<boolean>;
|
|
145
|
+
} = {};
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Actual verification work, invoked exactly once via the memoized
|
|
149
|
+
* `verifyPromise`. Resolves the log path and caches `appendFile`, marking
|
|
150
|
+
* the sink unavailable when the upward search yields
|
|
151
|
+
* {@link NO_NODE_MODULES_FOUND}. The logger owns the resulting
|
|
152
|
+
* availability, so no flag is kept here.
|
|
153
|
+
*
|
|
154
|
+
* @returns Whether file system logging is available.
|
|
155
|
+
*/
|
|
156
|
+
async function runVerify(): Promise<boolean> {
|
|
157
|
+
// Guard: skip dynamic import entirely outside Node.js to avoid
|
|
158
|
+
// browser console errors from attempting to fetch node: URLs.
|
|
159
|
+
if ((globalThis.process
|
|
160
|
+
=== undefined)
|
|
161
|
+
|| (globalThis.process
|
|
162
|
+
.versions
|
|
163
|
+
?.node
|
|
164
|
+
=== undefined))
|
|
165
|
+
return false;
|
|
166
|
+
|
|
167
|
+
try {
|
|
168
|
+
// Dynamic import for Node.js modules: cache appendFile for use in write.
|
|
169
|
+
/**
|
|
170
|
+
* Dynamically imported `node:fs/promises`; held in this scope so its members are reused without re-importing.
|
|
171
|
+
*/
|
|
172
|
+
const fs = await import('node:fs/promises');
|
|
173
|
+
/**
|
|
174
|
+
* Path utilities dynamically imported alongside `fs`; needed by the upward search for the closest node_modules.
|
|
175
|
+
*/
|
|
176
|
+
const {
|
|
177
|
+
dirname,
|
|
178
|
+
join,
|
|
179
|
+
} = await import('node:path');
|
|
180
|
+
|
|
181
|
+
state.appendFile = fs.appendFile;
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Resolved absolute path of the closest ancestor `node_modules`, or the sentinel when none exists (e.g. a stray cwd).
|
|
185
|
+
*/
|
|
186
|
+
const nodeModulesDir = await findNodeModulesUp({
|
|
187
|
+
cwd: process.cwd(),
|
|
188
|
+
stat: fs.stat,
|
|
189
|
+
dirname,
|
|
190
|
+
join,
|
|
191
|
+
},);
|
|
192
|
+
|
|
193
|
+
if (nodeModulesDir === NO_NODE_MODULES_FOUND)
|
|
194
|
+
// Unexpected in a Node environment: the process is running JS, which
|
|
195
|
+
// almost always means there is a node_modules upward. Marking the sink
|
|
196
|
+
// unavailable (rather than creating one at a stray cwd) keeps stray log
|
|
197
|
+
// directories out of build output.
|
|
198
|
+
return false;
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Directory under the chosen `node_modules` where every monochromatic log file lands.
|
|
202
|
+
*/
|
|
203
|
+
const LOG_DIR = join(
|
|
204
|
+
nodeModulesDir,
|
|
205
|
+
'.monochromatic',
|
|
206
|
+
);
|
|
207
|
+
await fs.mkdir(
|
|
208
|
+
LOG_DIR,
|
|
209
|
+
{ recursive: true, },
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* ISO timestamp with colons replaced by dashes so it can be embedded in a cross-platform file name.
|
|
214
|
+
*/
|
|
215
|
+
const timestamp = new Date().toISOString()
|
|
216
|
+
.replaceAll(
|
|
217
|
+
':',
|
|
218
|
+
'-',
|
|
219
|
+
);
|
|
220
|
+
state.filePath = join(
|
|
221
|
+
LOG_DIR,
|
|
222
|
+
`${timestamp}.log.jsonl`,
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
// Verify by writing and reading test data.
|
|
226
|
+
/**
|
|
227
|
+
* Probe record written and read back to confirm the chosen file path round-trips.
|
|
228
|
+
*/
|
|
229
|
+
const testData = `{"test":true,"timestamp":${Date.now()}}\n`;
|
|
230
|
+
await state.appendFile(
|
|
231
|
+
state.filePath,
|
|
232
|
+
testData,
|
|
233
|
+
);
|
|
234
|
+
/**
|
|
235
|
+
* Probe contents read back; matching the literal `"test":true` proves the append + read path works end-to-end.
|
|
236
|
+
*/
|
|
237
|
+
const content = await fs.readFile(
|
|
238
|
+
state.filePath,
|
|
239
|
+
'utf8',
|
|
240
|
+
);
|
|
241
|
+
return content.includes('"test":true',);
|
|
242
|
+
}
|
|
243
|
+
catch (error: unknown) {
|
|
244
|
+
reportLoggerInternalError({
|
|
245
|
+
context: 'file sink verification failed',
|
|
246
|
+
error,
|
|
247
|
+
},);
|
|
248
|
+
return false;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Verifies file system availability via {@link runVerify}, memoizing
|
|
254
|
+
* concurrent calls so a second caller never starts a duplicate probe.
|
|
255
|
+
*
|
|
256
|
+
* @returns Whether file system logging is available.
|
|
257
|
+
*/
|
|
258
|
+
function verify(): Promise<boolean> {
|
|
259
|
+
if (state.verifyPromise
|
|
260
|
+
!== undefined)
|
|
261
|
+
return state.verifyPromise;
|
|
262
|
+
|
|
263
|
+
state.verifyPromise = runVerify();
|
|
264
|
+
return state.verifyPromise;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Writes a single record as a JSONL line to the resolved log file.
|
|
269
|
+
*
|
|
270
|
+
* @param record - Log record to write.
|
|
271
|
+
*
|
|
272
|
+
* @mutates record - `JSON.stringify` may invoke `toJSON`, getters, or proxy traps.
|
|
273
|
+
*/
|
|
274
|
+
async function write(record: object,): Promise<void> {
|
|
275
|
+
// oxlint-disable-next-line typescript/strict-boolean-expressions -- filePath/appendFile are optional (unset before verification); checking presence
|
|
276
|
+
if ((!state.filePath) || (!state.appendFile))
|
|
277
|
+
return;
|
|
278
|
+
|
|
279
|
+
try {
|
|
280
|
+
await state.appendFile(
|
|
281
|
+
state.filePath,
|
|
282
|
+
`${JSON.stringify(record,)}\n`,
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
catch (error: unknown) {
|
|
286
|
+
reportLoggerInternalError({
|
|
287
|
+
context: 'file sink record append failed',
|
|
288
|
+
error,
|
|
289
|
+
},);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return {
|
|
294
|
+
verify,
|
|
295
|
+
write,
|
|
296
|
+
};
|
|
297
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { stat, } from 'node:fs/promises';
|
|
2
|
+
import {
|
|
3
|
+
dirname,
|
|
4
|
+
join,
|
|
5
|
+
} from 'node:path';
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
describe,
|
|
9
|
+
expect,
|
|
10
|
+
it,
|
|
11
|
+
} from '@monochromatic-dev/module-test/ts';
|
|
12
|
+
import {
|
|
13
|
+
sinks,
|
|
14
|
+
type LogRecord,
|
|
15
|
+
} from '@monochromatic-dev/module-logger';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Sink factories under test, read from the built artifact's `sinks` namespace.
|
|
19
|
+
*/
|
|
20
|
+
const {
|
|
21
|
+
createFileSink,
|
|
22
|
+
findNodeModulesUp,
|
|
23
|
+
NO_NODE_MODULES_FOUND,
|
|
24
|
+
} = sinks;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Mock `stat` that always throws an ENOENT-like error, so `findNodeModulesUp`
|
|
28
|
+
* walks the whole tree and exhausts without matching.
|
|
29
|
+
*
|
|
30
|
+
* @returns Never; always throws.
|
|
31
|
+
*/
|
|
32
|
+
function statAlwaysMissing(): never {
|
|
33
|
+
const error: NodeJS.ErrnoException = Object.assign(
|
|
34
|
+
new Error('ENOENT',),
|
|
35
|
+
{ code: 'ENOENT', },
|
|
36
|
+
);
|
|
37
|
+
throw error;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Mock `stat` that always throws an unexpected permission error.
|
|
42
|
+
*
|
|
43
|
+
* @returns Never; always throws.
|
|
44
|
+
*/
|
|
45
|
+
function statAlwaysDenied(): never {
|
|
46
|
+
const error: NodeJS.ErrnoException = Object.assign(
|
|
47
|
+
new Error('EACCES',),
|
|
48
|
+
{ code: 'EACCES', },
|
|
49
|
+
);
|
|
50
|
+
throw error;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Builds a LogRecord for write-path tests.
|
|
55
|
+
*
|
|
56
|
+
* @param message - Message body.
|
|
57
|
+
*
|
|
58
|
+
* @returns Record at a fixed timestamp.
|
|
59
|
+
*/
|
|
60
|
+
function record({ message, }: { readonly message: string; },): LogRecord {
|
|
61
|
+
return {
|
|
62
|
+
level: 'info',
|
|
63
|
+
message,
|
|
64
|
+
timestamp: 0,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
await describe({
|
|
69
|
+
name: 'file sink',
|
|
70
|
+
children: [
|
|
71
|
+
it({
|
|
72
|
+
name: 'exposes callable verify and write methods',
|
|
73
|
+
fn: async () => {
|
|
74
|
+
const sink = createFileSink();
|
|
75
|
+
expect(typeof sink.verify,).toBe('function',);
|
|
76
|
+
expect(typeof sink.write,).toBe('function',);
|
|
77
|
+
},
|
|
78
|
+
},),
|
|
79
|
+
|
|
80
|
+
it({
|
|
81
|
+
name: 'verify resolves a boolean',
|
|
82
|
+
fn: async () => {
|
|
83
|
+
const resolved = await createFileSink().verify();
|
|
84
|
+
expect(typeof resolved,).toBe('boolean',);
|
|
85
|
+
},
|
|
86
|
+
},),
|
|
87
|
+
|
|
88
|
+
it({
|
|
89
|
+
name: 'verify reports availability when running in a package with ancestor node_modules',
|
|
90
|
+
fn: async () => {
|
|
91
|
+
// Tests run from within the monorepo, so an ancestor node_modules
|
|
92
|
+
// always exists. Availability proves find-up located it AND that
|
|
93
|
+
// mkdir/appendFile/readFile all succeeded.
|
|
94
|
+
expect(await createFileSink().verify(),).toBe(true,);
|
|
95
|
+
},
|
|
96
|
+
},),
|
|
97
|
+
|
|
98
|
+
it({
|
|
99
|
+
name: 'concurrent verify calls on one sink share a single in-flight promise',
|
|
100
|
+
fn: async () => {
|
|
101
|
+
// Regression guard for the race that used to return false to late
|
|
102
|
+
// callers. The memo now lives in the instance closure, not a module
|
|
103
|
+
// global, so a fresh sink exercises it cleanly.
|
|
104
|
+
const sink = createFileSink();
|
|
105
|
+
const [a, b, c,] = await Promise.all([
|
|
106
|
+
sink.verify(),
|
|
107
|
+
sink.verify(),
|
|
108
|
+
sink.verify(),
|
|
109
|
+
],);
|
|
110
|
+
expect(a,).toBe(b,);
|
|
111
|
+
expect(b,).toBe(c,);
|
|
112
|
+
},
|
|
113
|
+
},),
|
|
114
|
+
|
|
115
|
+
it({
|
|
116
|
+
name: 'findNodeModulesUp finds the nearest ancestor node_modules',
|
|
117
|
+
fn: async () => {
|
|
118
|
+
const result = await findNodeModulesUp({
|
|
119
|
+
cwd: import.meta.dirname,
|
|
120
|
+
stat,
|
|
121
|
+
dirname,
|
|
122
|
+
join,
|
|
123
|
+
},);
|
|
124
|
+
expect(typeof result,).toBe('string',);
|
|
125
|
+
if ((typeof result) === 'string')
|
|
126
|
+
expect(result.endsWith('node_modules',),).toBe(true,);
|
|
127
|
+
},
|
|
128
|
+
},),
|
|
129
|
+
|
|
130
|
+
it({
|
|
131
|
+
name: 'findNodeModulesUp keeps expected missing candidates silent',
|
|
132
|
+
fn: async () => {
|
|
133
|
+
/** Unexpected faults reported during expected missing-path walk. */
|
|
134
|
+
const reports: unknown[] = [];
|
|
135
|
+
/** Exhausted ancestor result. */
|
|
136
|
+
const result = await findNodeModulesUp({
|
|
137
|
+
cwd: import.meta.dirname,
|
|
138
|
+
stat: statAlwaysMissing as unknown as typeof stat,
|
|
139
|
+
dirname,
|
|
140
|
+
join,
|
|
141
|
+
reportError: function collectUnexpectedReport(report,) {
|
|
142
|
+
reports.push(report,);
|
|
143
|
+
},
|
|
144
|
+
},);
|
|
145
|
+
expect(result,).toBe(NO_NODE_MODULES_FOUND,);
|
|
146
|
+
expect(reports,).toEqual([],);
|
|
147
|
+
},
|
|
148
|
+
},),
|
|
149
|
+
|
|
150
|
+
it({
|
|
151
|
+
name: 'findNodeModulesUp reports unexpected stat failures',
|
|
152
|
+
fn: async () => {
|
|
153
|
+
/** Unexpected faults reported during denied-path walk. */
|
|
154
|
+
const reports: unknown[] = [];
|
|
155
|
+
await findNodeModulesUp({
|
|
156
|
+
cwd: import.meta.dirname,
|
|
157
|
+
stat: statAlwaysDenied as unknown as typeof stat,
|
|
158
|
+
dirname,
|
|
159
|
+
join,
|
|
160
|
+
reportError: function collectUnexpectedReport(report,) {
|
|
161
|
+
reports.push(report,);
|
|
162
|
+
},
|
|
163
|
+
},);
|
|
164
|
+
expect(reports.length > 0,).toBe(true,);
|
|
165
|
+
},
|
|
166
|
+
},),
|
|
167
|
+
|
|
168
|
+
it({
|
|
169
|
+
name: 'write before verify resolves without touching the filesystem',
|
|
170
|
+
fn: async () => {
|
|
171
|
+
// Verification resolves the log path and caches `appendFile`; without
|
|
172
|
+
// it both stay unset, so write takes the unset-guard early return,
|
|
173
|
+
// resolving as a silent no-op rather than throwing or writing.
|
|
174
|
+
const sink = createFileSink();
|
|
175
|
+
await expect(
|
|
176
|
+
sink.write(record({ message: 'before verify', },),),
|
|
177
|
+
)
|
|
178
|
+
.resolves
|
|
179
|
+
.toBeUndefined();
|
|
180
|
+
},
|
|
181
|
+
},),
|
|
182
|
+
|
|
183
|
+
it({
|
|
184
|
+
name: 'a verified sink accepts records across levels and message shapes',
|
|
185
|
+
fn: async () => {
|
|
186
|
+
const sink = createFileSink();
|
|
187
|
+
await sink.verify();
|
|
188
|
+
|
|
189
|
+
const messages = [
|
|
190
|
+
'plain message',
|
|
191
|
+
'Hello δΈη π',
|
|
192
|
+
'',
|
|
193
|
+
'{"key": "value", "nested": {"a": 1}}',
|
|
194
|
+
'line1\nline2\nline3',
|
|
195
|
+
];
|
|
196
|
+
for (const message of messages)
|
|
197
|
+
// oxlint-disable-next-line no-await-in-loop -- sequential appends keep file order deterministic
|
|
198
|
+
await sink.write(record({ message, },),);
|
|
199
|
+
},
|
|
200
|
+
},),
|
|
201
|
+
],
|
|
202
|
+
},);
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export { createConsoleSink, } from './console.ts';
|
|
2
|
+
export {
|
|
3
|
+
createFileSink,
|
|
4
|
+
findNodeModulesUp,
|
|
5
|
+
NO_NODE_MODULES_FOUND,
|
|
6
|
+
} from './file.ts';
|
|
7
|
+
export { createIndexedDbSink, } from './indexed-db.ts';
|
|
8
|
+
export { createLocalStorageSink, } from './local-storage.ts';
|
|
9
|
+
export { createNoopSink, } from './noop.ts';
|
|
10
|
+
export { createOpfsSink, } from './opfs.ts';
|
|
11
|
+
export { createSessionStorageSink, } from './session-storage.ts';
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Promise bridges for the event-based IndexedDB API.
|
|
3
|
+
*
|
|
4
|
+
* IndexedDB predates promises and signals completion only through `success`
|
|
5
|
+
* and `error` events, so awaiting it requires wrapping each request and
|
|
6
|
+
* transaction in a promise once, here, instead of scattering listener wiring
|
|
7
|
+
* through the sink.
|
|
8
|
+
*
|
|
9
|
+
* @module
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Resolves with an IndexedDB request's result once it succeeds, rejecting
|
|
14
|
+
* with the request's error when it fails.
|
|
15
|
+
*
|
|
16
|
+
* @param request - Pending IndexedDB request.
|
|
17
|
+
*
|
|
18
|
+
* @returns Result the request produces.
|
|
19
|
+
*
|
|
20
|
+
* @throws DOMException - Whatever the backend attaches to a failed request.
|
|
21
|
+
*
|
|
22
|
+
* @mutates request - `request.addEventListener` registers success and error
|
|
23
|
+
* listeners on the host-owned request, and `resolve` hands `request.result`,
|
|
24
|
+
* a host-owned value, out to the awaiting caller.
|
|
25
|
+
*
|
|
26
|
+
* @example
|
|
27
|
+
* ```ts
|
|
28
|
+
* const key = await awaitRequest(store.add(batch));
|
|
29
|
+
* ```
|
|
30
|
+
*/
|
|
31
|
+
export function awaitRequest<T,>(request: IDBRequest<T>,): Promise<T> {
|
|
32
|
+
// oxlint-disable-next-line promise/avoid-new -- IndexedDB is event-based and exposes no promise API; a constructed promise is the only bridge to await it.
|
|
33
|
+
return new Promise(function bridgeRequest(
|
|
34
|
+
resolve,
|
|
35
|
+
reject,
|
|
36
|
+
) {
|
|
37
|
+
request.addEventListener(
|
|
38
|
+
'success',
|
|
39
|
+
function resolveResult(): void {
|
|
40
|
+
resolve(request.result,);
|
|
41
|
+
},
|
|
42
|
+
);
|
|
43
|
+
request.addEventListener(
|
|
44
|
+
'error',
|
|
45
|
+
function rejectError(): void {
|
|
46
|
+
reject(request.error ?? new Error('IndexedDB request failed without an error object',),);
|
|
47
|
+
},
|
|
48
|
+
);
|
|
49
|
+
},);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Resolves once an IndexedDB transaction commits, rejecting when it errors or
|
|
54
|
+
* aborts, so a caller can await durability of everything queued on it.
|
|
55
|
+
*
|
|
56
|
+
* @param transaction - Transaction whose settlement is awaited.
|
|
57
|
+
*
|
|
58
|
+
* @throws DOMException - Whatever the backend attaches to a failed or aborted
|
|
59
|
+
* transaction.
|
|
60
|
+
*
|
|
61
|
+
* @mutates transaction - `transaction.addEventListener` registers complete,
|
|
62
|
+
* error, and abort listeners on the host-owned transaction.
|
|
63
|
+
*
|
|
64
|
+
* @example
|
|
65
|
+
* ```ts
|
|
66
|
+
* const transaction = database.transaction('batch', 'readwrite');
|
|
67
|
+
* transaction.objectStore('batch').add(batch);
|
|
68
|
+
* await awaitTransaction(transaction);
|
|
69
|
+
* ```
|
|
70
|
+
*/
|
|
71
|
+
export function awaitTransaction(transaction: IDBTransaction,): Promise<void> {
|
|
72
|
+
// oxlint-disable-next-line promise/avoid-new -- IndexedDB is event-based and exposes no promise API; a constructed promise is the only bridge to await it.
|
|
73
|
+
return new Promise(function bridgeTransaction(
|
|
74
|
+
resolve,
|
|
75
|
+
reject,
|
|
76
|
+
) {
|
|
77
|
+
transaction.addEventListener(
|
|
78
|
+
'complete',
|
|
79
|
+
function resolveCommit(): void {
|
|
80
|
+
resolve();
|
|
81
|
+
},
|
|
82
|
+
);
|
|
83
|
+
transaction.addEventListener(
|
|
84
|
+
'error',
|
|
85
|
+
function rejectError(): void {
|
|
86
|
+
reject(transaction.error ?? new Error('IndexedDB transaction failed without an error object',),);
|
|
87
|
+
},
|
|
88
|
+
);
|
|
89
|
+
transaction.addEventListener(
|
|
90
|
+
'abort',
|
|
91
|
+
function rejectAbort(): void {
|
|
92
|
+
reject(transaction.error ?? new Error('IndexedDB transaction aborted without an error object',),);
|
|
93
|
+
},
|
|
94
|
+
);
|
|
95
|
+
},);
|
|
96
|
+
}
|