@shaferllc/keel 0.66.0 → 0.68.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/AGENTS.md +167 -0
- package/README.md +30 -1
- package/bin/keel-mcp.mjs +9 -0
- package/dist/core/application.d.ts +5 -5
- package/dist/core/application.js +2 -2
- package/dist/core/cache.d.ts +82 -5
- package/dist/core/cache.js +181 -23
- package/dist/core/cli/stubs.d.ts +12 -0
- package/dist/core/cli/stubs.js +120 -0
- package/dist/core/events.d.ts +129 -5
- package/dist/core/events.js +165 -7
- package/dist/core/health.d.ts +141 -0
- package/dist/core/health.js +226 -0
- package/dist/core/helpers.d.ts +9 -3
- package/dist/core/helpers.js +11 -3
- package/dist/core/index.d.ts +15 -10
- package/dist/core/index.js +7 -4
- package/dist/core/lock.d.ts +139 -0
- package/dist/core/lock.js +215 -0
- package/dist/core/logger.d.ts +82 -4
- package/dist/core/logger.js +141 -23
- package/dist/core/mail.d.ts +128 -7
- package/dist/core/mail.js +264 -16
- package/dist/core/queue.d.ts +134 -9
- package/dist/core/queue.js +304 -14
- package/dist/core/storage.d.ts +159 -6
- package/dist/core/storage.js +287 -7
- package/dist/mcp/server.d.ts +19 -0
- package/dist/mcp/server.js +355 -0
- package/docs/ai-manifest.json +2472 -0
- package/docs/ai.md +128 -0
- package/docs/architecture.md +331 -0
- package/docs/authentication.md +453 -0
- package/docs/authorization.md +167 -0
- package/docs/broadcasting.md +137 -0
- package/docs/broker.md +500 -0
- package/docs/cache.md +558 -0
- package/docs/configuration.md +311 -0
- package/docs/console.md +356 -0
- package/docs/container.md +467 -0
- package/docs/controllers.md +265 -0
- package/docs/cors.md +51 -0
- package/docs/database.md +530 -0
- package/docs/debugging.md +129 -0
- package/docs/decorators.md +127 -0
- package/docs/errors.md +395 -0
- package/docs/events.md +496 -0
- package/docs/examples/architecture-app.ts +27 -0
- package/docs/examples/authentication.ts +61 -0
- package/docs/examples/authorization.ts +79 -0
- package/docs/examples/broadcasting.ts +60 -0
- package/docs/examples/broker-cache-validate.ts +34 -0
- package/docs/examples/broker-fault-tolerance.ts +29 -0
- package/docs/examples/broker-middleware.ts +27 -0
- package/docs/examples/broker.ts +203 -0
- package/docs/examples/cache.ts +222 -0
- package/docs/examples/configuration.ts +81 -0
- package/docs/examples/container.ts +134 -0
- package/docs/examples/controllers.ts +86 -0
- package/docs/examples/database.ts +118 -0
- package/docs/examples/debugging.ts +41 -0
- package/docs/examples/decorators.ts +40 -0
- package/docs/examples/errors.ts +121 -0
- package/docs/examples/events.ts +204 -0
- package/docs/examples/factories.ts +84 -0
- package/docs/examples/hashing.ts +71 -0
- package/docs/examples/health.ts +94 -0
- package/docs/examples/helpers.ts +171 -0
- package/docs/examples/hooks.ts +54 -0
- package/docs/examples/inertia.ts +81 -0
- package/docs/examples/locks.ts +120 -0
- package/docs/examples/logger.ts +92 -0
- package/docs/examples/mail.ts +160 -0
- package/docs/examples/middleware.ts +119 -0
- package/docs/examples/migrations.ts +126 -0
- package/docs/examples/models.ts +239 -0
- package/docs/examples/notification.ts +124 -0
- package/docs/examples/providers.ts +123 -0
- package/docs/examples/queues.ts +254 -0
- package/docs/examples/rate-limiting.ts +42 -0
- package/docs/examples/redis.ts +99 -0
- package/docs/examples/request-response.ts +197 -0
- package/docs/examples/routing.ts +186 -0
- package/docs/examples/scheduling.ts +62 -0
- package/docs/examples/sessions.ts +102 -0
- package/docs/examples/static-files.ts +63 -0
- package/docs/examples/storage.ts +132 -0
- package/docs/examples/templates.ts +58 -0
- package/docs/examples/testing.ts +66 -0
- package/docs/examples/transformer.ts +141 -0
- package/docs/examples/transformers.ts +49 -0
- package/docs/examples/url-builder.ts +86 -0
- package/docs/examples/validation.ts +102 -0
- package/docs/examples/views.tsx +62 -0
- package/docs/examples/vite.ts +106 -0
- package/docs/factories.md +166 -0
- package/docs/getting-started.md +290 -0
- package/docs/hashing.md +259 -0
- package/docs/health.md +225 -0
- package/docs/helpers.md +347 -0
- package/docs/hono.md +186 -0
- package/docs/hooks.md +118 -0
- package/docs/inertia.md +241 -0
- package/docs/locks.md +323 -0
- package/docs/logger.md +290 -0
- package/docs/mail.md +678 -0
- package/docs/middleware.md +425 -0
- package/docs/migrations.md +476 -0
- package/docs/models.md +810 -0
- package/docs/notifications.md +474 -0
- package/docs/providers.md +363 -0
- package/docs/queues.md +679 -0
- package/docs/rate-limiting.md +155 -0
- package/docs/redis.md +178 -0
- package/docs/request-response.md +953 -0
- package/docs/routing.md +804 -0
- package/docs/scheduling.md +110 -0
- package/docs/security.md +85 -0
- package/docs/sessions.md +354 -0
- package/docs/social-auth.md +174 -0
- package/docs/static-files.md +211 -0
- package/docs/storage.md +450 -0
- package/docs/templates.md +315 -0
- package/docs/testing.md +125 -0
- package/docs/transformers.md +381 -0
- package/docs/url-builder.md +295 -0
- package/docs/validation.md +288 -0
- package/docs/views.md +267 -0
- package/docs/vite.md +434 -0
- package/llms-full.txt +17694 -0
- package/llms.txt +116 -0
- package/package.json +26 -7
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Distributed locks — "only one of you may do this at a time", across processes
|
|
3
|
+
* and across nodes. The counterpart to the cache's stampede protection: that
|
|
4
|
+
* collapses concurrent work *inside one isolate*, this one coordinates work
|
|
5
|
+
* *between* them.
|
|
6
|
+
*
|
|
7
|
+
* const [ran] = await lock("invoice:42").run(async () => charge(invoice));
|
|
8
|
+
* if (!ran) return; // someone else is already charging it
|
|
9
|
+
*
|
|
10
|
+
* Like every other backend in keel, the store is a small pluggable seam and the
|
|
11
|
+
* core imports no driver — `MemoryLockStore` is the built-in default (per
|
|
12
|
+
* isolate, so it's for tests and single-process apps); the locks guide has Redis
|
|
13
|
+
* and database recipes for the real thing.
|
|
14
|
+
*
|
|
15
|
+
* ## Two safety properties, both easy to get wrong
|
|
16
|
+
*
|
|
17
|
+
* **Ownership.** Every acquisition mints a random owner token, and release and
|
|
18
|
+
* extend only succeed for the owner. Without that, a lock whose TTL expired
|
|
19
|
+
* mid-work gets picked up by process B, and process A's `release()` — arriving
|
|
20
|
+
* late — would delete *B's* lock, letting a third process in. A store must
|
|
21
|
+
* compare-and-delete, not just delete.
|
|
22
|
+
*
|
|
23
|
+
* **TTL.** A lock always expires. If a holder crashes, the lock must not be held
|
|
24
|
+
* forever, so there's no "hold until released" mode. Pick a TTL longer than the
|
|
25
|
+
* work; if the work might outrun it, `extend()` from inside.
|
|
26
|
+
*/
|
|
27
|
+
/**
|
|
28
|
+
* An in-memory `LockStore` — the default. Per-isolate, so it coordinates
|
|
29
|
+
* *within* one process and nothing more: perfect for tests and single-process
|
|
30
|
+
* apps, useless across a cluster. Point `setLockStore()` at Redis for that.
|
|
31
|
+
*/
|
|
32
|
+
export class MemoryLockStore {
|
|
33
|
+
entries = new Map();
|
|
34
|
+
/** The live entry for a key, dropping it if it has expired. */
|
|
35
|
+
live(key) {
|
|
36
|
+
const entry = this.entries.get(key);
|
|
37
|
+
if (!entry)
|
|
38
|
+
return undefined;
|
|
39
|
+
if (entry.expiresAt <= Date.now()) {
|
|
40
|
+
this.entries.delete(key);
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
return entry;
|
|
44
|
+
}
|
|
45
|
+
async acquire(key, owner, ttlMs) {
|
|
46
|
+
if (this.live(key))
|
|
47
|
+
return false;
|
|
48
|
+
this.entries.set(key, { owner, expiresAt: Date.now() + ttlMs });
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
async release(key, owner) {
|
|
52
|
+
const entry = this.live(key);
|
|
53
|
+
if (!entry || entry.owner !== owner)
|
|
54
|
+
return false;
|
|
55
|
+
this.entries.delete(key);
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
async extend(key, owner, ttlMs) {
|
|
59
|
+
const entry = this.live(key);
|
|
60
|
+
if (!entry || entry.owner !== owner)
|
|
61
|
+
return false;
|
|
62
|
+
entry.expiresAt = Date.now() + ttlMs;
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
async isLocked(key) {
|
|
66
|
+
return this.live(key) !== undefined;
|
|
67
|
+
}
|
|
68
|
+
async remainingTime(key) {
|
|
69
|
+
const entry = this.live(key);
|
|
70
|
+
return entry ? entry.expiresAt - Date.now() : null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/** A lock that has expired or was never held. */
|
|
74
|
+
export class LockNotHeldError extends Error {
|
|
75
|
+
constructor(key) {
|
|
76
|
+
super(`The lock "${key}" is not held by you — it expired or was released.`);
|
|
77
|
+
this.name = "LockNotHeldError";
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
81
|
+
/** A random owner token. Web Crypto, so it works on Node and the edge. */
|
|
82
|
+
function mintOwner() {
|
|
83
|
+
return crypto.randomUUID();
|
|
84
|
+
}
|
|
85
|
+
/* ---------------------------------- lock ---------------------------------- */
|
|
86
|
+
export class Lock {
|
|
87
|
+
key;
|
|
88
|
+
ttlMs;
|
|
89
|
+
store;
|
|
90
|
+
/** Non-null only while this instance holds the lock. */
|
|
91
|
+
owner;
|
|
92
|
+
constructor(key,
|
|
93
|
+
/** How long the lock is held before it expires on its own. Milliseconds. */
|
|
94
|
+
ttlMs, store, owner) {
|
|
95
|
+
this.key = key;
|
|
96
|
+
this.ttlMs = ttlMs;
|
|
97
|
+
this.store = store;
|
|
98
|
+
this.owner = owner;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Take the lock, run `fn`, and always give it back — the form you want almost
|
|
102
|
+
* every time, because the `finally` is what stops a throwing callback from
|
|
103
|
+
* leaving the lock held until its TTL runs out.
|
|
104
|
+
*
|
|
105
|
+
* Returns `[ran, result]`: `ran` is false if someone else holds it, in which
|
|
106
|
+
* case `fn` never ran.
|
|
107
|
+
*
|
|
108
|
+
* const [ran, invoice] = await lock("invoice:42").run(() => charge(id));
|
|
109
|
+
* if (!ran) return; // another worker is on it
|
|
110
|
+
*/
|
|
111
|
+
async run(fn, options = {}) {
|
|
112
|
+
if (!(await this.acquire(options)))
|
|
113
|
+
return [false, undefined];
|
|
114
|
+
try {
|
|
115
|
+
return [true, await fn()];
|
|
116
|
+
}
|
|
117
|
+
finally {
|
|
118
|
+
await this.release();
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
/** `run()`, but never waits: if the lock is held, give up at once. */
|
|
122
|
+
runImmediately(fn) {
|
|
123
|
+
return this.run(fn, { timeout: 0 });
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Take the lock, waiting up to `timeout` for it. Returns whether we got it.
|
|
127
|
+
*
|
|
128
|
+
* Prefer `run()` — with a bare `acquire()` you own the `try/finally`, and a
|
|
129
|
+
* throw between here and `release()` leaks the lock for the rest of its TTL.
|
|
130
|
+
*/
|
|
131
|
+
async acquire(options = {}) {
|
|
132
|
+
const { timeout = 0, retryDelay = 50 } = options;
|
|
133
|
+
const deadline = Date.now() + timeout;
|
|
134
|
+
const owner = mintOwner();
|
|
135
|
+
for (;;) {
|
|
136
|
+
if (await this.store.acquire(this.key, owner, this.ttlMs)) {
|
|
137
|
+
this.owner = owner;
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
// Wait only if there's time left to wait *and* to sleep before the deadline.
|
|
141
|
+
if (Date.now() + retryDelay >= deadline)
|
|
142
|
+
return false;
|
|
143
|
+
await sleep(retryDelay);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
/** Take the lock only if it's free right now. */
|
|
147
|
+
acquireImmediately() {
|
|
148
|
+
return this.acquire({ timeout: 0 });
|
|
149
|
+
}
|
|
150
|
+
/** Give the lock back. A no-op if we no longer hold it (it may have expired). */
|
|
151
|
+
async release() {
|
|
152
|
+
if (!this.owner)
|
|
153
|
+
return false;
|
|
154
|
+
const released = await this.store.release(this.key, this.owner);
|
|
155
|
+
this.owner = undefined;
|
|
156
|
+
return released;
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Push the expiry out — for work that might outrun the TTL. Throws
|
|
160
|
+
* `LockNotHeldError` if we've already lost it, because the alternative (a
|
|
161
|
+
* silent no-op) would let you carry on believing you hold a lock you don't.
|
|
162
|
+
*/
|
|
163
|
+
async extend(ttlMs = this.ttlMs) {
|
|
164
|
+
if (!this.owner || !(await this.store.extend(this.key, this.owner, ttlMs))) {
|
|
165
|
+
this.owner = undefined;
|
|
166
|
+
throw new LockNotHeldError(this.key);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
/** Whether *anyone* holds this key — not necessarily us. */
|
|
170
|
+
isLocked() {
|
|
171
|
+
return this.store.isLocked(this.key);
|
|
172
|
+
}
|
|
173
|
+
/** Whether we held this lock but no longer do. */
|
|
174
|
+
async isExpired() {
|
|
175
|
+
if (!this.owner)
|
|
176
|
+
return false; // never acquired — not the same as expired
|
|
177
|
+
return !(await this.store.isLocked(this.key));
|
|
178
|
+
}
|
|
179
|
+
/** Milliseconds until the lock expires, or null if nobody holds it. */
|
|
180
|
+
getRemainingTime() {
|
|
181
|
+
return this.store.remainingTime(this.key);
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Freeze the lock (key, TTL, owner token) to a string, so another process can
|
|
185
|
+
* `restoreLock()` it and release or extend the *same* lock — the handoff you
|
|
186
|
+
* need when one process takes the lock and another finishes the work.
|
|
187
|
+
*/
|
|
188
|
+
serialize() {
|
|
189
|
+
return JSON.stringify({ key: this.key, ttlMs: this.ttlMs, owner: this.owner });
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
/* --------------------------------- global --------------------------------- */
|
|
193
|
+
let store = new MemoryLockStore();
|
|
194
|
+
/** Register the lock store used by `lock()`. */
|
|
195
|
+
export function setLockStore(next) {
|
|
196
|
+
store = next;
|
|
197
|
+
return store;
|
|
198
|
+
}
|
|
199
|
+
/** The active lock store. */
|
|
200
|
+
export function getLockStore() {
|
|
201
|
+
return store;
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* A lock on `key`, held for `ttlMs` once acquired (default 30s).
|
|
205
|
+
*
|
|
206
|
+
* const [ran] = await lock("invoice:42").run(() => charge(invoice));
|
|
207
|
+
*/
|
|
208
|
+
export function lock(key, ttlMs = 30_000) {
|
|
209
|
+
return new Lock(key, ttlMs, store);
|
|
210
|
+
}
|
|
211
|
+
/** Rebuild a lock from `serialize()` — same key, same TTL, same owner token. */
|
|
212
|
+
export function restoreLock(serialized) {
|
|
213
|
+
const { key, ttlMs, owner } = JSON.parse(serialized);
|
|
214
|
+
return new Lock(key, ttlMs, store, owner);
|
|
215
|
+
}
|
package/dist/core/logger.d.ts
CHANGED
|
@@ -5,8 +5,37 @@
|
|
|
5
5
|
*
|
|
6
6
|
* logger().info("user registered", { userId: user.id });
|
|
7
7
|
* logger().error("payment failed", { orderId, error });
|
|
8
|
+
*
|
|
9
|
+
* Output goes through a `Sink` — `console` by default, but any function, so logs
|
|
10
|
+
* can go to a file, an HTTP collector, or a buffer in tests. Register extra
|
|
11
|
+
* loggers by name (`setLogger(audit, "audit")`) when a subsystem needs its own
|
|
12
|
+
* level or destination.
|
|
8
13
|
*/
|
|
9
|
-
|
|
14
|
+
/** Levels, quietest last. `trace` is the most verbose; `fatal` the most severe. */
|
|
15
|
+
export type LogLevel = "trace" | "debug" | "info" | "warn" | "error" | "fatal";
|
|
16
|
+
/** One log event, as handed to a `Sink`. */
|
|
17
|
+
export interface LogRecord {
|
|
18
|
+
level: LogLevel;
|
|
19
|
+
/** ISO 8601. */
|
|
20
|
+
time: string;
|
|
21
|
+
msg: string;
|
|
22
|
+
/** Bindings merged with the call's context, after redaction. */
|
|
23
|
+
fields: Record<string, unknown>;
|
|
24
|
+
}
|
|
25
|
+
/** Where log lines go. Return nothing; a sink that throws is a sink that lies. */
|
|
26
|
+
export type Sink = (record: LogRecord) => void;
|
|
27
|
+
export interface RedactOptions {
|
|
28
|
+
/**
|
|
29
|
+
* Field paths to redact — a top-level key (`"password"`), a dot path
|
|
30
|
+
* (`"req.headers.authorization"`), or a wildcard segment (`"*.password"`,
|
|
31
|
+
* `"user.*.token"`).
|
|
32
|
+
*/
|
|
33
|
+
paths: string[];
|
|
34
|
+
/** What to replace matched values with. Default: `"[redacted]"`. */
|
|
35
|
+
censor?: string;
|
|
36
|
+
/** Delete the key outright instead of censoring it. Default: false. */
|
|
37
|
+
remove?: boolean;
|
|
38
|
+
}
|
|
10
39
|
export interface LoggerOptions {
|
|
11
40
|
/** Minimum level to emit. Default: "info". */
|
|
12
41
|
level?: LogLevel;
|
|
@@ -15,20 +44,69 @@ export interface LoggerOptions {
|
|
|
15
44
|
/** Fields merged into every log line. */
|
|
16
45
|
bindings?: Record<string, unknown>;
|
|
17
46
|
/**
|
|
18
|
-
* Field paths to redact
|
|
19
|
-
*
|
|
47
|
+
* Field paths to redact. A `string[]` is shorthand for `{ paths }` — matched
|
|
48
|
+
* values become `"[redacted]"`.
|
|
20
49
|
*/
|
|
21
|
-
redact?: string[];
|
|
50
|
+
redact?: string[] | RedactOptions;
|
|
51
|
+
/** Where lines go. Default: the console (`console.log` / `.warn` / `.error`). */
|
|
52
|
+
sink?: Sink;
|
|
53
|
+
/** Silence the logger entirely — nothing is emitted, at any level. */
|
|
54
|
+
enabled?: boolean;
|
|
55
|
+
}
|
|
56
|
+
/** The default sink: JSON to stdout, or a pretty single line when `pretty`. */
|
|
57
|
+
export declare function consoleSink(pretty?: boolean): Sink;
|
|
58
|
+
/** A sink that collects records in memory — for tests. Assert on `.records`. */
|
|
59
|
+
export declare class MemorySink {
|
|
60
|
+
readonly records: LogRecord[];
|
|
61
|
+
/** The sink function to hand to `LoggerOptions.sink`. */
|
|
62
|
+
readonly sink: Sink;
|
|
63
|
+
/** Records at one level. */
|
|
64
|
+
at(level: LogLevel): LogRecord[];
|
|
65
|
+
/** The messages logged, in order. */
|
|
66
|
+
messages(): string[];
|
|
67
|
+
clear(): void;
|
|
22
68
|
}
|
|
23
69
|
export declare class Logger {
|
|
24
70
|
private options;
|
|
25
71
|
private threshold;
|
|
72
|
+
private redact?;
|
|
73
|
+
private sink;
|
|
26
74
|
constructor(options?: LoggerOptions);
|
|
75
|
+
/**
|
|
76
|
+
* Whether a level would be emitted. Check it before building an expensive
|
|
77
|
+
* context object — the work of assembling it happens whether or not the line
|
|
78
|
+
* is ever written.
|
|
79
|
+
*
|
|
80
|
+
* if (logger().isLevelEnabled("debug")) {
|
|
81
|
+
* logger().debug("state", { snapshot: expensiveSnapshot() });
|
|
82
|
+
* }
|
|
83
|
+
*/
|
|
84
|
+
isLevelEnabled(level: LogLevel): boolean;
|
|
85
|
+
/** Run `fn` only if `level` would be emitted — the callback form of the above. */
|
|
86
|
+
ifLevelEnabled(level: LogLevel, fn: (log: Logger) => void): void;
|
|
27
87
|
private write;
|
|
88
|
+
trace(message: string, context?: Record<string, unknown>): void;
|
|
28
89
|
debug(message: string, context?: Record<string, unknown>): void;
|
|
29
90
|
info(message: string, context?: Record<string, unknown>): void;
|
|
30
91
|
warn(message: string, context?: Record<string, unknown>): void;
|
|
31
92
|
error(message: string, context?: Record<string, unknown>): void;
|
|
93
|
+
/** An unrecoverable failure — the loudest level. */
|
|
94
|
+
fatal(message: string, context?: Record<string, unknown>): void;
|
|
95
|
+
/** Log at a level chosen at runtime. */
|
|
96
|
+
log(level: LogLevel, message: string, context?: Record<string, unknown>): void;
|
|
32
97
|
/** A child logger with additional bound fields (e.g. a request id). */
|
|
33
98
|
child(bindings: Record<string, unknown>): Logger;
|
|
34
99
|
}
|
|
100
|
+
/**
|
|
101
|
+
* Register a logger under a name, so a subsystem can have its own level or
|
|
102
|
+
* destination:
|
|
103
|
+
*
|
|
104
|
+
* setLogger(new Logger({ level: "trace", sink: auditSink }), "audit");
|
|
105
|
+
* namedLogger("audit").trace("permission granted", { userId });
|
|
106
|
+
*
|
|
107
|
+
* The default logger is the application's `Logger` singleton — resolve that with
|
|
108
|
+
* the global `logger()` helper, not this.
|
|
109
|
+
*/
|
|
110
|
+
export declare function setLogger(instance: Logger, name: string): Logger;
|
|
111
|
+
/** A logger registered with `setLogger`. Throws for an unknown name. */
|
|
112
|
+
export declare function namedLogger(name: string): Logger;
|
package/dist/core/logger.js
CHANGED
|
@@ -5,49 +5,135 @@
|
|
|
5
5
|
*
|
|
6
6
|
* logger().info("user registered", { userId: user.id });
|
|
7
7
|
* logger().error("payment failed", { orderId, error });
|
|
8
|
+
*
|
|
9
|
+
* Output goes through a `Sink` — `console` by default, but any function, so logs
|
|
10
|
+
* can go to a file, an HTTP collector, or a buffer in tests. Register extra
|
|
11
|
+
* loggers by name (`setLogger(audit, "audit")`) when a subsystem needs its own
|
|
12
|
+
* level or destination.
|
|
8
13
|
*/
|
|
9
|
-
const LEVELS = {
|
|
14
|
+
const LEVELS = {
|
|
15
|
+
trace: 10,
|
|
16
|
+
debug: 20,
|
|
17
|
+
info: 30,
|
|
18
|
+
warn: 40,
|
|
19
|
+
error: 50,
|
|
20
|
+
fatal: 60,
|
|
21
|
+
};
|
|
10
22
|
const REDACTED = "[redacted]";
|
|
11
|
-
|
|
12
|
-
|
|
23
|
+
function normalizeRedact(redact) {
|
|
24
|
+
if (!redact)
|
|
25
|
+
return undefined;
|
|
26
|
+
const options = Array.isArray(redact) ? { paths: redact } : redact;
|
|
27
|
+
if (!options.paths.length)
|
|
28
|
+
return undefined;
|
|
29
|
+
return { paths: options.paths, censor: options.censor ?? REDACTED, remove: options.remove ?? false };
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Return a copy of `obj` with `keys` (a split dot path) redacted. Clones only
|
|
33
|
+
* along the path, so untouched branches keep their identity. A `*` segment
|
|
34
|
+
* matches every key at that level.
|
|
35
|
+
*/
|
|
36
|
+
function redactPath(obj, keys, options) {
|
|
13
37
|
const [head, ...rest] = keys;
|
|
14
|
-
if (head === undefined
|
|
38
|
+
if (head === undefined)
|
|
39
|
+
return obj;
|
|
40
|
+
const targets = head === "*" ? Object.keys(obj) : head in obj ? [head] : [];
|
|
41
|
+
if (!targets.length)
|
|
15
42
|
return obj;
|
|
16
43
|
const clone = { ...obj };
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
44
|
+
for (const key of targets) {
|
|
45
|
+
if (rest.length === 0) {
|
|
46
|
+
if (options.remove)
|
|
47
|
+
delete clone[key];
|
|
48
|
+
else
|
|
49
|
+
clone[key] = options.censor;
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
const child = clone[key];
|
|
53
|
+
if (child && typeof child === "object" && !Array.isArray(child)) {
|
|
54
|
+
clone[key] = redactPath(child, rest, options);
|
|
55
|
+
}
|
|
24
56
|
}
|
|
25
57
|
}
|
|
26
58
|
return clone;
|
|
27
59
|
}
|
|
60
|
+
/** The default sink: JSON to stdout, or a pretty single line when `pretty`. */
|
|
61
|
+
export function consoleSink(pretty = false) {
|
|
62
|
+
return ({ level, time, msg, fields }) => {
|
|
63
|
+
const write = level === "warn"
|
|
64
|
+
? console.warn
|
|
65
|
+
: level === "error" || level === "fatal"
|
|
66
|
+
? console.error
|
|
67
|
+
: console.log;
|
|
68
|
+
if (pretty) {
|
|
69
|
+
const extra = Object.keys(fields).length ? " " + JSON.stringify(fields) : "";
|
|
70
|
+
write(`[${time}] ${level.toUpperCase().padEnd(5)} ${msg}${extra}`);
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
write(JSON.stringify({ level, time, msg, ...fields }));
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
/** A sink that collects records in memory — for tests. Assert on `.records`. */
|
|
78
|
+
export class MemorySink {
|
|
79
|
+
records = [];
|
|
80
|
+
/** The sink function to hand to `LoggerOptions.sink`. */
|
|
81
|
+
sink = (record) => {
|
|
82
|
+
this.records.push(record);
|
|
83
|
+
};
|
|
84
|
+
/** Records at one level. */
|
|
85
|
+
at(level) {
|
|
86
|
+
return this.records.filter((r) => r.level === level);
|
|
87
|
+
}
|
|
88
|
+
/** The messages logged, in order. */
|
|
89
|
+
messages() {
|
|
90
|
+
return this.records.map((r) => r.msg);
|
|
91
|
+
}
|
|
92
|
+
clear() {
|
|
93
|
+
this.records.length = 0;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
28
96
|
export class Logger {
|
|
29
97
|
options;
|
|
30
98
|
threshold;
|
|
99
|
+
redact;
|
|
100
|
+
sink;
|
|
31
101
|
constructor(options = {}) {
|
|
32
102
|
this.options = options;
|
|
33
103
|
this.threshold = LEVELS[options.level ?? "info"];
|
|
104
|
+
this.redact = normalizeRedact(options.redact);
|
|
105
|
+
this.sink = options.sink ?? consoleSink(options.pretty);
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Whether a level would be emitted. Check it before building an expensive
|
|
109
|
+
* context object — the work of assembling it happens whether or not the line
|
|
110
|
+
* is ever written.
|
|
111
|
+
*
|
|
112
|
+
* if (logger().isLevelEnabled("debug")) {
|
|
113
|
+
* logger().debug("state", { snapshot: expensiveSnapshot() });
|
|
114
|
+
* }
|
|
115
|
+
*/
|
|
116
|
+
isLevelEnabled(level) {
|
|
117
|
+
return this.options.enabled !== false && LEVELS[level] >= this.threshold;
|
|
118
|
+
}
|
|
119
|
+
/** Run `fn` only if `level` would be emitted — the callback form of the above. */
|
|
120
|
+
ifLevelEnabled(level, fn) {
|
|
121
|
+
if (this.isLevelEnabled(level))
|
|
122
|
+
fn(this);
|
|
34
123
|
}
|
|
35
124
|
write(level, message, context) {
|
|
36
|
-
if (
|
|
125
|
+
if (!this.isLevelEnabled(level))
|
|
37
126
|
return;
|
|
38
|
-
const time = new Date().toISOString();
|
|
39
127
|
let fields = { ...this.options.bindings, ...context };
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
const extra = Object.keys(fields).length ? " " + JSON.stringify(fields) : "";
|
|
45
|
-
const fn = level === "warn" ? console.warn : level === "error" ? console.error : console.log;
|
|
46
|
-
fn(`[${time}] ${level.toUpperCase().padEnd(5)} ${message}${extra}`);
|
|
47
|
-
}
|
|
48
|
-
else {
|
|
49
|
-
console.log(JSON.stringify({ level, time, msg: message, ...fields }));
|
|
128
|
+
if (this.redact) {
|
|
129
|
+
for (const path of this.redact.paths) {
|
|
130
|
+
fields = redactPath(fields, path.split("."), this.redact);
|
|
131
|
+
}
|
|
50
132
|
}
|
|
133
|
+
this.sink({ level, time: new Date().toISOString(), msg: message, fields });
|
|
134
|
+
}
|
|
135
|
+
trace(message, context) {
|
|
136
|
+
this.write("trace", message, context);
|
|
51
137
|
}
|
|
52
138
|
debug(message, context) {
|
|
53
139
|
this.write("debug", message, context);
|
|
@@ -61,6 +147,14 @@ export class Logger {
|
|
|
61
147
|
error(message, context) {
|
|
62
148
|
this.write("error", message, context);
|
|
63
149
|
}
|
|
150
|
+
/** An unrecoverable failure — the loudest level. */
|
|
151
|
+
fatal(message, context) {
|
|
152
|
+
this.write("fatal", message, context);
|
|
153
|
+
}
|
|
154
|
+
/** Log at a level chosen at runtime. */
|
|
155
|
+
log(level, message, context) {
|
|
156
|
+
this.write(level, message, context);
|
|
157
|
+
}
|
|
64
158
|
/** A child logger with additional bound fields (e.g. a request id). */
|
|
65
159
|
child(bindings) {
|
|
66
160
|
return new Logger({
|
|
@@ -69,3 +163,27 @@ export class Logger {
|
|
|
69
163
|
});
|
|
70
164
|
}
|
|
71
165
|
}
|
|
166
|
+
/* -------------------------------- registry -------------------------------- */
|
|
167
|
+
const loggers = new Map();
|
|
168
|
+
/**
|
|
169
|
+
* Register a logger under a name, so a subsystem can have its own level or
|
|
170
|
+
* destination:
|
|
171
|
+
*
|
|
172
|
+
* setLogger(new Logger({ level: "trace", sink: auditSink }), "audit");
|
|
173
|
+
* namedLogger("audit").trace("permission granted", { userId });
|
|
174
|
+
*
|
|
175
|
+
* The default logger is the application's `Logger` singleton — resolve that with
|
|
176
|
+
* the global `logger()` helper, not this.
|
|
177
|
+
*/
|
|
178
|
+
export function setLogger(instance, name) {
|
|
179
|
+
loggers.set(name, instance);
|
|
180
|
+
return instance;
|
|
181
|
+
}
|
|
182
|
+
/** A logger registered with `setLogger`. Throws for an unknown name. */
|
|
183
|
+
export function namedLogger(name) {
|
|
184
|
+
const instance = loggers.get(name);
|
|
185
|
+
if (!instance) {
|
|
186
|
+
throw new Error(`No logger named "${name}". Register it with setLogger(logger, name).`);
|
|
187
|
+
}
|
|
188
|
+
return instance;
|
|
189
|
+
}
|