wellcrafted
Version:
Delightful TypeScript patterns for elegant, type-safe applications
262 lines (257 loc) • 11.4 kB
TypeScript
import { Err } from "../result-_socO0Ud.js";
import { AnyTaggedError } from "../types-ojNaDB4n.js";
import { tapErr } from "../tap-err-C0xfVXtz.js";
//#region src/logger/types.d.ts
/**
* Five levels, no `fatal`. Matches Rust's `tracing` exactly.
*
* Process termination is the application's call, not the library's — so
* there is no `fatal`. Apps that want to exit on a specific error do so
* explicitly (`process.exit`, `Bun.exit`) at the call site.
*/
type LogLevel = "trace" | "debug" | "info" | "warn" | "error";
/**
* The normalized event every sink receives.
*
* - `ts` is epoch millis (not a `Date`) — cheap to create, easy to serialize,
* trivially monotonic for ordering. Sinks that want ISO-8601 on the wire
* convert at serialization time.
* - `source` is the logger's namespace, stamped once at `createLogger` and
* carried on every event. Analogous to `tracing`'s `target`.
* - `message` is human text. For `warn`/`error` it's copied from the
* typed error's `message` field (so `event.message === event.data.message`
* on those levels — intentional duplication so sinks have one uniform
* rendering path regardless of event origin; they never need to know
* whether `data` is a tagged error or free-form payload). The variant
* template owns the phrasing; sinks just render.
* - `data` carries the structured payload. For `warn`/`error` it's the
* typed error object itself (including `name` + captured fields). For
* `info`/`debug`/`trace` it's free-form, caller-supplied.
*/
type LogEvent = {
ts: number;
level: LogLevel;
source: string;
message: string;
data?: unknown;
};
/**
* A sink is a callable that accepts events, with optional resource cleanup.
*
* The intersection with `Partial<AsyncDisposable>` lets the same type cover
* both pure functions (no-op dispose) and stateful sinks (file writers,
* network sockets). Callers who need guaranteed cleanup narrow to a
* `LogSink & AsyncDisposable` return type or bind with `await using`.
*/
type LogSink = ((event: LogEvent) => void) & Partial<AsyncDisposable>;
/**
* Accepted by `log.warn` / `log.error`. Union of two shapes:
*
* - `AnyTaggedError` — the raw tagged error `{ name, message, ...fields }`.
* Arrives via `result.error` after narrowing a Result.
* - `Err<AnyTaggedError>` — the `{ error: tagged, data: null }` wrapper
* that `defineErrors` factories return directly.
*
* The logger unwraps via `"name" in err` inside `unwrapLoggable` — a
* purely structural discriminator:
*
* - `AnyTaggedError` always has `name` at the top level (stamped by
* `defineErrors` from the factory key — a hard invariant of every
* tagged error).
* - `Err<AnyTaggedError>` has exactly `{ error, data }` at the top level.
* The tagged error's `name` lives on `err.error.name`, not `err.name`.
*
* Intentionally **not** checking `err.data === null`: that's also true for
* `Ok(T)` when `T = null` (see wellcrafted's `Ok(null)`/`Err(null)`
* structural collision edge — discussed in
* `docs/articles/ok-null-is-fine-err-null-is-a-lie.md`). Always discriminate
* by an invariant non-null property (`name` here), never by null-presence.
*
* Native `Error` instances also satisfy `AnyTaggedError` structurally
* (`name` and `message` are both present), so `log.warn(new Error("x"))`
* works out of the box — useful when migrating from `console.warn(err)`
* call sites that caught a plain `Error`.
*
* @example Err-wrapped (direct mint)
* log.warn(MyError.Thing({ cause }));
*
* @example Raw tagged (from result.error, after narrowing)
* if (isErr(result)) log.warn(result.error);
*
* @example Plain Error (migration from console.warn)
* try { risky(); } catch (e) { log.warn(e as Error); }
*/
type LoggableError = AnyTaggedError | Err<AnyTaggedError>;
/**
* The logger surface.
*
* Shape split is intentional:
* - `warn`/`error` take a typed error **unary** — message and structured
* data both live on the variant, level is chosen at the call site.
* - `trace`/`debug`/`info` are free-form — diagnostic events don't need
* enumeration and often don't have a useful "name" to dedupe on.
*
* Mirrors Rust's `tracing::warn!(?err)` vs `tracing::info!("msg", ...)`.
*/
type Logger = {
error(err: LoggableError): void;
warn(err: LoggableError): void;
info(message: string, data?: unknown): void;
debug(message: string, data?: unknown): void;
trace(message: string, data?: unknown): void;
};
//# sourceMappingURL=types.d.ts.map
//#endregion
//#region src/logger/console-sink.d.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.
*/
declare const consoleSink: (event: LogEvent) => void;
//# sourceMappingURL=console-sink.d.ts.map
//#endregion
//#region src/logger/create-logger.d.ts
/**
* 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) });
*/
declare function createLogger(source: string, sink?: LogSink): Logger;
//# sourceMappingURL=create-logger.d.ts.map
//#endregion
//#region src/logger/memory-sink.d.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" });
*/
declare function memorySink(): {
sink: LogSink;
events: LogEvent[];
};
//# sourceMappingURL=memory-sink.d.ts.map
//#endregion
//#region src/logger/compose-sinks.d.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);
*/
declare function composeSinks(...sinks: LogSink[]): LogSink;
//# sourceMappingURL=compose-sinks.d.ts.map
//#endregion
export { LogEvent, LogLevel, LogSink, LoggableError, Logger, composeSinks, consoleSink, createLogger, memorySink, tapErr };
//# sourceMappingURL=index.d.ts.map