wellcrafted
Version:
Delightful TypeScript patterns for elegant, type-safe applications
207 lines (202 loc) • 7.66 kB
JavaScript
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