UNPKG

wellcrafted

Version:

Delightful TypeScript patterns for elegant, type-safe applications

207 lines (202 loc) 7.66 kB
import "../result-C_ph_izC.js"; import { tapErr } from "../tap-err-BENAkFsH.js"; //#region src/logger/console-sink.ts /** * Default sink. Writes to `console.*` with a `[source]` prefix. * * Kept as a singleton value (not a factory) because it takes no config — * adding `createConsoleSink({ format })` would be ceremony for a pattern * the user can trivially replace by writing their own sink. * * `console[event.level]` routes directly without a detached lookup table. * `LogLevel` is a subset of the Console method keys, so TS errors at this * access if a future level drifts (e.g. adding `fatal`). Calling the method * through `console[...]` preserves the `this` binding — avoids "Illegal * invocation" in runtimes that require it. * * `satisfies LogSink` (not `: LogSink`) keeps the inferred callable type * precise — `LogSink` is an intersection with optional dispose, and the * annotation form would widen an unnecessary Partial into the value type. * * No dispose handler — `console` is not a resource. * * ### CLI authors: stream routing * * `console[level]` routes by level, not uniformly to stdout: * - `console.log` (not used here) is the only method that writes stdout. * - `console.info`, `console.debug`, `console.warn`, `console.error`, * `console.trace` all write **stderr** in Node/Bun. * * For a CLI that emits structured program output on stdout and diagnostics * on stderr, this default is correct — every logger event goes to stderr. * Authors who expect `log.info` to print to stdout will be surprised; write * a custom sink that routes to `process.stdout` if that's the requirement. */ const consoleSink = (event) => { const prefix = `[${event.source}]`; if (event.data === void 0) console[event.level](prefix, event.message); else console[event.level](prefix, event.message, event.data); }; //#endregion //#region src/logger/create-logger.ts /** Narrow `LoggableError` to the raw tagged object. See `LoggableError` in `types.ts` for the discriminator rationale. */ function unwrapLoggable(err) { return "name" in err ? err : err.error; } /** * Create a logger bound to a `source` namespace and a sink. * * Design choices: * - **Positional args, not a bag.** Two arguments, both with obvious meaning; * a `{ source, sink }` object would be ceremony. * - **`sink` defaults to `consoleSink`.** Most callers during development * want console output with zero setup. Production apps swap it out via DI * at the attach/wire-up site. * - **No global default logger.** There is no `setDefaultLogger()` and no * module-level registry. Every consumer takes a `log?: Logger` option * and defaults to `createLogger('<source>')` if omitted. Globals make * test isolation and sink composition painful. * - **Method shorthand in the return object** over higher-order factories. * The five methods differ in two simple ways (error-unary vs free-form, * plus the level string); spelling them out beats an `emitErr("warn")` * riddle. * * ### Source convention * * `source` is a free-form string, but downstream filtering and tail-log * grep become much easier when call sites converge on a shape. Recommended: * * '<package>/<module>' e.g. 'workspace/sync-supervisor' * '<app>/<feature>' e.g. 'fuji/daemon-route' * '<package>/<area>' e.g. 'auth/oauth-app' * * Keep it lowercase-kebab. Avoid bare factory names like * `'attachSqliteMaterializer'`: they don't carry package context. * * ### Module-scope vs injected * * `const log = createLogger('source')` at module scope is fine for * process-singleton modules (CLI commands, app bootstrap, leaf utilities * the host does not customize per instance). For anything that can be * instantiated more than once per process with different routing needs * (per-document attach primitives, per-account auth state, per-workspace * services), accept `log?: Logger` at the factory boundary and default * to `createLogger('source')` for development ergonomics. * * @example Library code (caller wires the sink) * function attachThing(ydoc: Doc, opts: { log?: Logger }) { * const log = opts.log ?? createLogger('workspace/thing'); * // ... * } * * @example App wiring (share one sink, multiple loggers) * const sink = composeSinks(consoleSink, myCustomSink); * attachThing(ydoc, { log: createLogger('workspace/thing', sink) }); * attachOther(ydoc, { log: createLogger('workspace/other', sink) }); */ function createLogger(source, sink = consoleSink) { const emit = (level, message, data) => { sink({ ts: Date.now(), level, source, message, data }); }; return { error(err) { const tagged = unwrapLoggable(err); emit("error", tagged.message, tagged); }, warn(err) { const tagged = unwrapLoggable(err); emit("warn", tagged.message, tagged); }, info(message, data) { emit("info", message, data); }, debug(message, data) { emit("debug", message, data); }, trace(message, data) { emit("trace", message, data); } }; } //#endregion //#region src/logger/memory-sink.ts /** * In-memory sink for tests. Returns `{ sink, events }` so callers can * both wire the sink and inspect captured events without a module-level * spy or `console.*` interception. * * A factory (not a singleton) so each test gets an isolated array; sharing * state across tests would leak events. * * Returning `{ sink, events }` (rather than an array with a method) keeps * the two roles separate — `sink` goes to `createLogger`, `events` goes to * assertions. * * Uses `satisfies LogSink` on the sink expression rather than `: LogSink =` * to preserve the precise inferred callable type. * * @example * const { sink, events } = memorySink(); * const log = createLogger("test", sink); * log.warn(MyError.Thing({ cause: new Error("boom") })); * expect(events).toHaveLength(1); * expect(events[0]).toMatchObject({ level: "warn", source: "test" }); */ function memorySink() { const events = []; const sink = (event) => { events.push(event); }; return { sink, events }; } //#endregion //#region src/logger/compose-sinks.ts /** * Fan one event out to every sink in order. * * Disposal: the returned sink has a `[Symbol.asyncDispose]` that forwards * to each member via optional chaining. Members without dispose (e.g. * `consoleSink`) are silent no-ops; members that own resources (file, * network) flush and close. Mix pure and stateful sinks freely — no * wrapping required. * * Fan-out is sequential and unguarded. If a member sink throws on emit, * later members do not receive the event — by design, since swallowing * sink errors hides real bugs. Wrap individual sinks yourself for * best-effort delivery. * * Dispose is sequential and awaits each member. If one throws, later * members don't get their chance; callers who want best-effort cleanup * should wrap the composed dispose themselves. * * Built with `Object.assign` + `satisfies LogSink` rather than mutating a * pre-typed `const` — avoids the widening that comes with `: LogSink =` and * keeps the inferred type precise (callable + definite dispose, not * callable + Partial dispose). * * @example * await using file = jsonlFileSink("/tmp/app.jsonl"); * const sink = composeSinks(consoleSink, file); * const log = createLogger("source", sink); */ function composeSinks(...sinks) { const emit = (event) => { for (const sink of sinks) sink(event); }; const dispose = async () => { for (const sink of sinks) await sink[Symbol.asyncDispose]?.(); }; return Object.assign(emit, { [Symbol.asyncDispose]: dispose }); } //#endregion export { composeSinks, consoleSink, createLogger, memorySink, tapErr }; //# sourceMappingURL=index.js.map