(Init): Added shit

This commit is contained in:
2026-05-28 23:46:40 +00:00
parent a5250706cb
commit 8410600c63
46 changed files with 3898 additions and 228 deletions

5
crates/shared/deno.json Normal file
View File

@@ -0,0 +1,5 @@
{
"name": "@elly/shared",
"version": "0.1.0",
"exports": "./mod.ts"
}

106
crates/shared/mod.ts Normal file
View File

@@ -0,0 +1,106 @@
/**
* @elly/shared — Public API barrel.
*
* Both `@elly/core` and `@elly/bot` import from here. This barrel is the only
* sanctioned cross-crate surface. Anything not re-exported is considered
* implementation detail and may change without notice.
*/
// ----- Config --------------------------------------------------------
export {
ApiSchema,
BotSchema,
ChannelsSchema,
ColorsSchema,
ConfigSchema,
DatabaseSchema,
FeaturesSchema,
GuildSchema,
IpcSchema,
KvSchema,
LimitsSchema,
LoggingSchema,
RolesSchema,
} from "./src/config/schema.ts";
export type { Config } from "./src/config/schema.ts";
export type {
ApiConfig,
BotConfig,
ChannelsConfig,
ColorsConfig,
ConfigInput,
DatabaseConfig,
FeaturesConfig,
GuildConfig,
IpcConfig,
KvConfig,
LimitsConfig,
LoggingConfig,
RolesConfig,
} from "./src/config/types.ts";
export { loadConfig, validateConfig } from "./src/config/loader.ts";
export {
BotEnvSchema,
CoreEnvSchema,
loadEnv,
SharedEnvSchema,
} from "./src/config/env.ts";
export type { BotEnv, CoreEnv, SharedEnv } from "./src/config/env.ts";
export {
ConfigError,
ConfigValidationError,
EnvValidationError,
} from "./src/config/errors.ts";
export type { ConfigValidationIssue } from "./src/config/errors.ts";
// ----- Logger --------------------------------------------------------
export { createConsoleLogger, createLogger } from "./src/logger/factory.ts";
export { LOG_LEVEL_ORDER } from "./src/logger/types.ts";
export type {
LogFileOptions,
Logger,
LoggerOptions,
LogLevel,
LogRecord,
} from "./src/logger/types.ts";
// ----- IPC contract --------------------------------------------------
export {
IPC_AUTH_HEADER,
IPC_REQUEST_ID_HEADER,
IpcRoutes,
} from "./src/ipc/routes.ts";
export type { IpcRoute } from "./src/ipc/routes.ts";
export {
IPC_ERROR_STATUS,
IpcError,
IpcErrorBodySchema,
IpcErrorCode,
} from "./src/ipc/errors.ts";
export type { IpcErrorBody } from "./src/ipc/errors.ts";
export {
AnyDomainEventSchema,
HeartbeatEventSchema,
ServerReadyEventSchema,
} from "./src/ipc/events.ts";
export type {
AnyDomainEvent,
BaseDomainEvent,
DomainEventType,
HeartbeatEvent,
ServerReadyEvent,
} from "./src/ipc/events.ts";
// ----- Util ----------------------------------------------------------
export {
err,
isErr,
isOk,
map,
mapErr,
ok,
tryCatch,
tryCatchAsync,
unwrap,
} from "./src/util/result.ts";
export type { Err, Ok, Result } from "./src/util/result.ts";

View File

@@ -0,0 +1,69 @@
/**
* Environment variable schemas and loader.
*
* Each crate validates only the env it actually needs:
* - `@elly/core` → `CoreEnvSchema`
* - `@elly/bot` → `BotEnvSchema`
*
* Loading is fail-fast: a missing or malformed required variable aborts
* the process before any side-effectful work begins.
*/
import { z } from "zod";
import { EnvValidationError, type ConfigValidationIssue } from "./errors.ts";
export const SharedEnvSchema = z.object({
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
LOG_LEVEL: z.enum(["debug", "info", "warn", "error", "fatal"]).optional(),
});
export const CoreEnvSchema = SharedEnvSchema.extend({
IPC_TOKEN: z
.string()
.min(16, "IPC_TOKEN must be at least 16 characters (use a long random string)"),
});
export const BotEnvSchema = SharedEnvSchema.extend({
DISCORD_TOKEN: z.string().min(1, "DISCORD_TOKEN is required"),
IPC_TOKEN: z
.string()
.min(16, "IPC_TOKEN must be at least 16 characters (must match the core crate)"),
});
export type SharedEnv = z.infer<typeof SharedEnvSchema>;
export type CoreEnv = z.infer<typeof CoreEnvSchema>;
export type BotEnv = z.infer<typeof BotEnvSchema>;
/**
* Load and validate environment variables against a Zod object schema.
*
* Only keys declared on the schema's `shape` are read; everything else in
* `Deno.env` is ignored to prevent accidental coupling.
*
* @throws {EnvValidationError} If any required variable is missing or invalid.
*/
export function loadEnv<S extends z.ZodObject<z.ZodRawShape>>(schema: S): z.output<S> {
const raw: Record<string, string> = {};
for (const key of Object.keys(schema.shape)) {
const value = Deno.env.get(key);
if (value !== undefined) {
raw[key] = value;
}
}
const result = schema.safeParse(raw);
if (result.success) {
return result.data;
}
const issues: ConfigValidationIssue[] = result.error.issues.map((issue) => ({
path: issue.path.length === 0 ? "<env>" : String(issue.path[0]),
message: issue.message,
code: issue.code,
}));
const summary = `Environment validation failed:\n${
issues.map((i) => ` - ${i.path}: ${i.message}`).join("\n")
}`;
throw new EnvValidationError(summary, issues);
}

View File

@@ -0,0 +1,43 @@
/**
* Error types raised by the config and env loaders.
*
* These are deliberately distinct from `Error` subclasses inside `@elly/core`
* or `@elly/bot` — they cross the workspace boundary and must be importable
* by both runtimes without dragging unrelated dependencies along.
*/
export class ConfigError extends Error {
readonly path: string;
constructor(message: string, path: string) {
super(message);
this.name = "ConfigError";
this.path = path;
}
}
export class ConfigValidationError extends ConfigError {
readonly issues: readonly ConfigValidationIssue[];
constructor(message: string, path: string, issues: readonly ConfigValidationIssue[]) {
super(message, path);
this.name = "ConfigValidationError";
this.issues = issues;
}
}
export interface ConfigValidationIssue {
readonly path: string;
readonly message: string;
readonly code: string;
}
export class EnvValidationError extends Error {
readonly issues: readonly ConfigValidationIssue[];
constructor(message: string, issues: readonly ConfigValidationIssue[]) {
super(message);
this.name = "EnvValidationError";
this.issues = issues;
}
}

View File

@@ -0,0 +1,79 @@
/**
* TOML config loader for the Elly Discord Bot.
*
* Loads the config file from disk, parses it as TOML, validates it against
* `ConfigSchema`, and returns a fully-typed `Config`. Validation failures
* throw `ConfigValidationError` with structured per-path issues so the
* boot scripts can render a helpful error and exit with a non-zero code.
*/
import { parse as parseToml } from "@std/toml";
import type { z } from "zod";
import { ConfigSchema, type Config } from "./schema.ts";
import {
ConfigError,
ConfigValidationError,
type ConfigValidationIssue,
} from "./errors.ts";
/**
* Read, parse, and validate the config file at `path`.
*
* @throws {ConfigError} If the file is missing or cannot be parsed as TOML.
* @throws {ConfigValidationError} If the parsed object doesn't satisfy the schema.
*/
export async function loadConfig(path: string): Promise<Config> {
const text = await readFile(path);
const raw = parseTomlText(text, path);
return validateConfig(raw, path);
}
/**
* Validate an already-parsed object against `ConfigSchema`. Exposed so tests
* and tooling can validate config objects produced by other sources without
* going through the filesystem.
*/
export function validateConfig(input: unknown, path = "<in-memory>"): Config {
const result = ConfigSchema.safeParse(input);
if (result.success) return result.data;
const issues = zodIssuesToConfigIssues(result.error);
const summary = `Config validation failed for ${path}:\n${
issues.map((i) => ` - ${i.path}: ${i.message}`).join("\n")
}`;
throw new ConfigValidationError(summary, path, issues);
}
async function readFile(path: string): Promise<string> {
try {
return await Deno.readTextFile(path);
} catch (err) {
if (err instanceof Deno.errors.NotFound) {
throw new ConfigError(`Config file not found at ${path}`, path);
}
if (err instanceof Deno.errors.PermissionDenied) {
throw new ConfigError(
`Permission denied reading config at ${path}. Did you grant --allow-read for this path?`,
path,
);
}
throw err;
}
}
function parseTomlText(text: string, path: string): Record<string, unknown> {
try {
return parseToml(text);
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
throw new ConfigError(`Failed to parse TOML at ${path}: ${reason}`, path);
}
}
function zodIssuesToConfigIssues(error: z.ZodError): ConfigValidationIssue[] {
return error.issues.map((issue) => ({
path: issue.path.length === 0 ? "<root>" : issue.path.join("."),
message: issue.message,
code: issue.code,
}));
}

View File

@@ -0,0 +1,154 @@
/**
* Zod schema for the Elly Discord Bot's TOML configuration.
*
* This is the canonical contract between the operator (`config.toml`) and
* both `@elly/core` and `@elly/bot`. Parsing happens once at boot — if it
* fails, the process exits with a structured error.
*
* Type inference (`Config = z.infer<typeof ConfigSchema>`) lives in
* `./types.ts` and is re-exported from the crate's barrel.
*/
import { z } from "zod";
const DiscordId = z
.string()
.regex(/^\d{17,19}$/, "Must be a 1719 digit Discord snowflake ID");
const NonEmpty = z.string().min(1, "Must not be empty");
const Color = z
.number()
.int()
.min(0, "Color must be >= 0")
.max(0xFFFFFF, "Color must be <= 0xFFFFFF");
export const BotSchema = z.object({
name: NonEmpty,
prefix: NonEmpty,
status: z.string().default(""),
activity_type: z
.enum(["playing", "streaming", "listening", "watching", "competing"])
.default("watching"),
owners: z
.object({
ids: z.array(DiscordId).default([]),
})
.default({ ids: [] }),
});
export const DatabaseSchema = z.object({
path: NonEmpty,
});
export const KvSchema = z
.object({
path: z.string().default("./data/kv"),
})
.default({ path: "./data/kv" });
export const IpcSchema = z
.object({
host: z.string().default("127.0.0.1"),
port: z.number().int().min(1).max(65535).default(8787),
request_timeout_ms: z.number().int().positive().default(10_000),
})
.default({ host: "127.0.0.1", port: 8787, request_timeout_ms: 10_000 });
export const ApiSchema = z.object({
pika_cache_ttl: z.number().int().positive().default(3_600_000),
pika_request_timeout: z.number().int().positive().default(10_000),
});
export const GuildSchema = z.object({
id: DiscordId,
name: NonEmpty,
});
export const ChannelsSchema = z.object({
applications: NonEmpty,
application_logs: NonEmpty,
suggestions: NonEmpty,
suggestion_logs: NonEmpty,
guild_updates: NonEmpty,
discord_changelog: NonEmpty,
inactivity: NonEmpty,
development_logs: NonEmpty,
donations: NonEmpty,
reminders: NonEmpty,
});
export const RolesSchema = z.object({
admin: NonEmpty,
leader: NonEmpty,
officer: NonEmpty,
developer: NonEmpty,
guild_member: NonEmpty,
champion: NonEmpty,
away: NonEmpty,
applications_blacklisted: NonEmpty,
suggestions_blacklisted: NonEmpty,
manageable: z
.object({
ids: z.array(DiscordId).default([]),
})
.default({ ids: [] }),
});
export const FeaturesSchema = z.object({
applications: z.boolean().default(true),
suggestions: z.boolean().default(true),
statistics: z.boolean().default(true),
family: z.boolean().default(true),
qotd: z.boolean().default(true),
reminders: z.boolean().default(true),
staff_simulator: z.boolean().default(true),
channel_filtering: z.boolean().default(true),
auto_moderation: z.boolean().default(false),
welcome_system: z.boolean().default(false),
level_system: z.boolean().default(false),
});
export const LimitsSchema = z.object({
champion_max_days: z.number().int().positive().default(366),
away_max_days: z.number().int().positive().default(355),
purge_max_messages: z.number().int().min(1).max(100).default(100),
reminder_max_duration_days: z.number().int().positive().default(365),
});
export const ColorsSchema = z.object({
primary: Color.default(0x5865F2),
success: Color.default(0x57F287),
warning: Color.default(0xFEE75C),
error: Color.default(0xED4245),
info: Color.default(0x5865F2),
});
export const LoggingSchema = z.object({
level: z.enum(["debug", "info", "warn", "error", "fatal"]).default("info"),
format: z.enum(["console", "json"]).default("console"),
file: z.string().optional(),
file_max_bytes: z
.number()
.int()
.positive()
.default(10 * 1024 * 1024),
file_max_backups: z.number().int().min(0).default(5),
});
export const ConfigSchema = z.object({
bot: BotSchema,
database: DatabaseSchema,
kv: KvSchema,
ipc: IpcSchema,
api: ApiSchema,
guild: GuildSchema,
channels: ChannelsSchema,
roles: RolesSchema,
features: FeaturesSchema,
limits: LimitsSchema,
colors: ColorsSchema,
logging: LoggingSchema,
});
export type Config = z.infer<typeof ConfigSchema>;

View File

@@ -0,0 +1,38 @@
/**
* Type re-exports for consumers that only need the inferred types
* (no runtime cost — schemas are tree-shaken out for type-only imports).
*/
import type {
ApiSchema,
BotSchema,
ChannelsSchema,
ColorsSchema,
Config,
ConfigSchema,
DatabaseSchema,
FeaturesSchema,
GuildSchema,
IpcSchema,
KvSchema,
LimitsSchema,
LoggingSchema,
RolesSchema,
} from "./schema.ts";
import type { z } from "zod";
export type { Config };
export type ConfigInput = z.input<typeof ConfigSchema>;
export type BotConfig = z.infer<typeof BotSchema>;
export type DatabaseConfig = z.infer<typeof DatabaseSchema>;
export type KvConfig = z.infer<typeof KvSchema>;
export type IpcConfig = z.infer<typeof IpcSchema>;
export type ApiConfig = z.infer<typeof ApiSchema>;
export type GuildConfig = z.infer<typeof GuildSchema>;
export type ChannelsConfig = z.infer<typeof ChannelsSchema>;
export type RolesConfig = z.infer<typeof RolesSchema>;
export type FeaturesConfig = z.infer<typeof FeaturesSchema>;
export type LimitsConfig = z.infer<typeof LimitsSchema>;
export type ColorsConfig = z.infer<typeof ColorsSchema>;
export type LoggingConfig = z.infer<typeof LoggingSchema>;

View File

@@ -0,0 +1,81 @@
/**
* IPC error envelope — the canonical JSON shape returned for any non-2xx
* response from `@elly/core`. The bot's `CoreClient` parses this and
* surfaces it as a typed error to callers.
*/
import { z } from "zod";
/**
* Stable machine-readable error codes. Adding a code is a non-breaking
* change; renaming/removing one is a breaking change.
*/
export const IpcErrorCode = {
UNAUTHORIZED: "unauthorized",
FORBIDDEN: "forbidden",
NOT_FOUND: "not_found",
METHOD_NOT_ALLOWED: "method_not_allowed",
BAD_REQUEST: "bad_request",
VALIDATION_FAILED: "validation_failed",
CONFLICT: "conflict",
RATE_LIMITED: "rate_limited",
UPSTREAM_FAILURE: "upstream_failure",
INTERNAL: "internal",
} as const;
export type IpcErrorCode = typeof IpcErrorCode[keyof typeof IpcErrorCode];
export const IpcErrorBodySchema = z.object({
error: z.object({
code: z.string(),
message: z.string(),
requestId: z.string().optional(),
details: z.unknown().optional(),
}),
});
export type IpcErrorBody = z.infer<typeof IpcErrorBodySchema>;
/**
* Map of `IpcErrorCode` -> default HTTP status code. The server overrides
* the status per response when needed; this map exists so callers (and the
* default error middleware) have a sensible fallback.
*/
export const IPC_ERROR_STATUS: Record<IpcErrorCode, number> = {
unauthorized: 401,
forbidden: 403,
not_found: 404,
method_not_allowed: 405,
bad_request: 400,
validation_failed: 422,
conflict: 409,
rate_limited: 429,
upstream_failure: 502,
internal: 500,
};
/**
* Error thrown by `CoreClient` (Phase 3) when an IPC response carries an
* `IpcErrorBody`. Exported here so both crates can `instanceof`-check.
*/
export class IpcError extends Error {
readonly code: IpcErrorCode | string;
readonly status: number;
readonly requestId?: string;
readonly details?: unknown;
constructor(args: {
code: IpcErrorCode | string;
message: string;
status: number;
requestId?: string;
details?: unknown;
}) {
super(args.message);
this.name = "IpcError";
this.code = args.code;
this.status = args.status;
this.requestId = args.requestId;
this.details = args.details;
}
}

View File

@@ -0,0 +1,71 @@
/**
* Domain event contract.
*
* Core publishes typed events to an in-process bus and bridges them to the
* bot over the SSE endpoint (`IpcRoutes.EVENTS`). Each event has:
* - `type`: a string literal discriminator (`"applications.approved"`, …)
* - `id`: a ULID, unique per event, useful for idempotency on the consumer
* - `timestamp`: epoch milliseconds
* - `payload`: type-narrowed by `type`
*
* Phase 4 extends `AnyDomainEvent` with feature-specific events. Phase 2
* ships only `heartbeat` and `server.ready` for SSE plumbing verification.
*/
import { z } from "zod";
export interface BaseDomainEvent<TType extends string, TPayload> {
readonly type: TType;
readonly id: string;
readonly timestamp: number;
readonly payload: TPayload;
}
// ---------------------------------------------------------------------
// Phase 2 events
// ---------------------------------------------------------------------
export interface ServerReadyEvent
extends BaseDomainEvent<"server.ready", { version: string; pid: number }> {}
export interface HeartbeatEvent
extends BaseDomainEvent<"heartbeat", { uptimeMs: number }> {}
// ---------------------------------------------------------------------
// Discriminated union of every event the bot can receive
// ---------------------------------------------------------------------
export type AnyDomainEvent = ServerReadyEvent | HeartbeatEvent;
export type DomainEventType = AnyDomainEvent["type"];
// ---------------------------------------------------------------------
// Runtime validation (used by the bot when parsing SSE messages)
// ---------------------------------------------------------------------
const BaseEventShape = {
id: z.string().min(1),
timestamp: z.number().int().nonnegative(),
};
export const ServerReadyEventSchema = z.object({
type: z.literal("server.ready"),
...BaseEventShape,
payload: z.object({
version: z.string(),
pid: z.number().int(),
}),
});
export const HeartbeatEventSchema = z.object({
type: z.literal("heartbeat"),
...BaseEventShape,
payload: z.object({
uptimeMs: z.number().int().nonnegative(),
}),
});
export const AnyDomainEventSchema = z.discriminatedUnion("type", [
ServerReadyEventSchema,
HeartbeatEventSchema,
]);

View File

@@ -0,0 +1,30 @@
/**
* IPC route constants.
*
* Single source of truth for all HTTP paths exposed by `@elly/core`. Both
* the server (route registration) and the bot's `CoreClient` (URL construction)
* import from here so the two cannot drift.
*
* Versioned under `/v1/...` — future incompatible changes ship as `/v2/...`
* while keeping `/v1/` alive during transitions.
*/
export const IpcRoutes = {
/** Unauthenticated liveness probe. Returns 200 if the process is alive. */
HEALTH: "/health",
/** Authenticated version/diagnostic payload. */
VERSION: "/v1/version",
/** Server-sent events stream of domain events for the bot to react to. */
EVENTS: "/v1/events",
} as const;
export type IpcRoute = typeof IpcRoutes[keyof typeof IpcRoutes];
/** Standard request header carrying the IPC bearer token. */
export const IPC_AUTH_HEADER = "authorization";
/** Header for correlating a request across crates and log lines. */
export const IPC_REQUEST_ID_HEADER = "x-request-id";

View File

@@ -0,0 +1,141 @@
/**
* Universal structured logger factory.
*
* Construct one logger per crate at boot and pass it through the dependency
* container. Child loggers share their parent's sinks — call `.child()`
* freely for per-request, per-command, or per-job context.
*/
import {
LOG_LEVEL_ORDER,
type LogLevel,
type Logger,
type LoggerOptions,
type LogRecord,
type LogSink,
} from "./types.ts";
import { ConsoleSink, RotatingFileSink } from "./sinks.ts";
/**
* Build a configured root logger.
*
* Sinks created:
* - one `ConsoleSink` (always)
* - one `RotatingFileSink` (only if `options.file` is provided)
*/
export function createLogger(options: LoggerOptions): Logger {
const sinks: LogSink[] = [new ConsoleSink(options.format)];
if (options.file) {
sinks.push(
new RotatingFileSink(options.file.path, {
maxBytes: options.file.maxBytes,
maxBackups: options.file.maxBackups,
}),
);
}
return new StructuredLogger(options.name, options.level, sinks, {});
}
/**
* Convenience helper for crates that want a logger without any persistent
* file sink (e.g. tests, ad-hoc scripts). Output is colorized console only.
*/
export function createConsoleLogger(name: string, level: LogLevel = "info"): Logger {
return createLogger({ name, level, format: "console" });
}
// =====================================================================
// Implementation
// =====================================================================
class StructuredLogger implements Logger {
constructor(
private readonly name: string,
private readonly level: LogLevel,
private readonly sinks: ReadonlyArray<LogSink>,
private readonly context: Readonly<Record<string, unknown>>,
) {}
debug(msg: string, fields?: Record<string, unknown>): void {
this.log("debug", msg, fields);
}
info(msg: string, fields?: Record<string, unknown>): void {
this.log("info", msg, fields);
}
warn(msg: string, fields?: Record<string, unknown>): void {
this.log("warn", msg, fields);
}
error(msg: string, fields?: Record<string, unknown>): void {
this.log("error", msg, fields);
}
fatal(msg: string, fields?: Record<string, unknown>): void {
this.log("fatal", msg, fields);
}
child(context: Record<string, unknown>): Logger {
return new StructuredLogger(this.name, this.level, this.sinks, {
...this.context,
...context,
});
}
async flush(): Promise<void> {
await Promise.all(this.sinks.map((s) => s.flush()));
}
private log(level: LogLevel, msg: string, fields?: Record<string, unknown>): void {
if (LOG_LEVEL_ORDER[level] < LOG_LEVEL_ORDER[this.level]) return;
const fieldsNormalized = normalizeFields(fields);
const record: LogRecord = {
time: new Date().toISOString(),
level,
logger: this.name,
msg,
...this.context,
...fieldsNormalized,
};
for (const sink of this.sinks) {
sink.write(record);
}
}
}
/**
* Normalize special values in user-supplied fields:
* - Errors are expanded into `{ name, message, stack, cause? }` for JSON
* serialization. The console formatter receives the same expanded shape.
*/
function normalizeFields(
fields: Record<string, unknown> | undefined,
): Record<string, unknown> | undefined {
if (!fields) return undefined;
let mutated: Record<string, unknown> | null = null;
for (const [k, v] of Object.entries(fields)) {
if (v instanceof Error) {
mutated ??= { ...fields };
mutated[k] = serializeError(v);
}
}
return mutated ?? fields;
}
function serializeError(err: Error): Record<string, unknown> {
const out: Record<string, unknown> = {
name: err.name,
message: err.message,
};
if (err.stack) out.stack = err.stack;
const cause = (err as { cause?: unknown }).cause;
if (cause !== undefined) {
out.cause = cause instanceof Error ? serializeError(cause) : cause;
}
return out;
}

View File

@@ -0,0 +1,202 @@
/**
* Logger sink implementations.
*
* - `ConsoleSink`: synchronous writes to stdout/stderr, optionally
* ANSI-colorized for `console` format or compact JSON for `json` format.
* - `RotatingFileSink`: asynchronous, append-only JSON lines with size-based
* rotation. Writes are serialized through a promise queue so concurrent
* `write()` calls cannot interleave.
*
* Both sinks survive transient errors (logged to stderr) — a failing log
* sink must never crash the host process.
*/
import { dirname } from "@std/path";
import { ensureDir } from "@std/fs";
import {
bold,
cyan,
gray,
green,
magenta,
red,
yellow,
} from "@std/fmt/colors";
import {
LOG_LEVEL_ORDER,
type LogLevel,
type LogRecord,
type LogSink,
} from "./types.ts";
const encoder = new TextEncoder();
const LEVEL_COLOR: Record<LogLevel, (s: string) => string> = {
debug: cyan,
info: green,
warn: yellow,
error: red,
fatal: (s) => bold(magenta(s)),
};
// =====================================================================
// ConsoleSink
// =====================================================================
export class ConsoleSink implements LogSink {
constructor(private readonly format: "console" | "json") {}
write(record: LogRecord): void {
const line = this.format === "json"
? JSON.stringify(record) + "\n"
: this.formatConsole(record) + "\n";
const useStderr = LOG_LEVEL_ORDER[record.level] >= LOG_LEVEL_ORDER.warn;
const writer = useStderr ? Deno.stderr : Deno.stdout;
try {
writer.writeSync(encoder.encode(line));
} catch {
// Never propagate sink failures to user code.
}
}
flush(): Promise<void> {
return Promise.resolve();
}
private formatConsole(record: LogRecord): string {
const { time, level, logger, msg, ...rest } = record;
const ts = gray(time);
const lvl = LEVEL_COLOR[level](level.toUpperCase().padEnd(5));
const name = cyan(`[${logger}]`);
let fields = "";
const keys = Object.keys(rest);
if (keys.length > 0) {
const parts: string[] = [];
for (const k of keys) {
parts.push(`${k}=${formatValue(rest[k])}`);
}
fields = " " + gray(parts.join(" "));
}
return `${ts} ${lvl} ${name} ${msg}${fields}`;
}
}
function formatValue(v: unknown): string {
if (v === null) return "null";
if (v === undefined) return "undefined";
switch (typeof v) {
case "string":
return /\s/.test(v) ? JSON.stringify(v) : v;
case "number":
case "boolean":
case "bigint":
return String(v);
case "function":
return "[function]";
}
if (v instanceof Error) {
return `${v.name}: ${v.message}`;
}
try {
return JSON.stringify(v);
} catch {
return String(v);
}
}
// =====================================================================
// RotatingFileSink
// =====================================================================
export class RotatingFileSink implements LogSink {
private queue: Promise<void> = Promise.resolve();
private readonly maxBytes: number;
private readonly maxBackups: number;
private dirEnsured = false;
constructor(
private readonly path: string,
options: { maxBytes?: number; maxBackups?: number } = {},
) {
this.maxBytes = options.maxBytes ?? 10 * 1024 * 1024;
this.maxBackups = options.maxBackups ?? 5;
}
write(record: LogRecord): void {
const line = JSON.stringify(record) + "\n";
this.queue = this.queue.then(() => this.doWrite(line)).catch((err) => {
const msg = err instanceof Error ? err.message : String(err);
try {
Deno.stderr.writeSync(
encoder.encode(`[logger] RotatingFileSink write failed: ${msg}\n`),
);
} catch {
// give up
}
});
}
async flush(): Promise<void> {
await this.queue;
}
private async doWrite(line: string): Promise<void> {
if (!this.dirEnsured) {
await ensureDir(dirname(this.path));
this.dirEnsured = true;
}
try {
const stat = await Deno.stat(this.path);
if (stat.size + line.length > this.maxBytes) {
await this.rotate();
}
} catch (err) {
if (!(err instanceof Deno.errors.NotFound)) throw err;
}
await Deno.writeTextFile(this.path, line, { append: true });
}
private async rotate(): Promise<void> {
// Remove the oldest backup if it would be pushed past the retention limit.
if (this.maxBackups > 0) {
const oldest = `${this.path}.${this.maxBackups}`;
try {
await Deno.remove(oldest);
} catch (err) {
if (!(err instanceof Deno.errors.NotFound)) throw err;
}
}
// Shift .{i} -> .{i+1} for i in [maxBackups-1 .. 1].
for (let i = this.maxBackups - 1; i >= 1; i--) {
const src = `${this.path}.${i}`;
const dst = `${this.path}.${i + 1}`;
try {
await Deno.rename(src, dst);
} catch (err) {
if (!(err instanceof Deno.errors.NotFound)) throw err;
}
}
// Rotate the active log to .1 (only if there is at least one backup slot).
if (this.maxBackups > 0) {
try {
await Deno.rename(this.path, `${this.path}.1`);
} catch (err) {
if (!(err instanceof Deno.errors.NotFound)) throw err;
}
} else {
// No retention — just truncate.
try {
await Deno.remove(this.path);
} catch (err) {
if (!(err instanceof Deno.errors.NotFound)) throw err;
}
}
}
}

View File

@@ -0,0 +1,76 @@
/**
* Public type contract for the universal structured logger.
*
* Both `@elly/core` and `@elly/bot` consume this. Sinks (console, file)
* are implementation details and never appear in user code.
*/
export type LogLevel = "debug" | "info" | "warn" | "error" | "fatal";
export const LOG_LEVEL_ORDER: Record<LogLevel, number> = {
debug: 10,
info: 20,
warn: 30,
error: 40,
fatal: 50,
};
export interface LogRecord {
/** ISO 8601 timestamp (always UTC). */
readonly time: string;
/** Severity level. */
readonly level: LogLevel;
/** Logger name (typically a crate identifier, e.g. `@elly/core`). */
readonly logger: string;
/** Human-readable message. */
readonly msg: string;
/** Arbitrary structured context — flattened into the record. */
readonly [key: string]: unknown;
}
export interface LogFileOptions {
readonly path: string;
readonly maxBytes?: number;
readonly maxBackups?: number;
}
export interface LoggerOptions {
/** Logger name attached to every record. */
readonly name: string;
/** Minimum level to emit (records below this are silently dropped). */
readonly level: LogLevel;
/** Console output format. File output is always JSON. */
readonly format: "console" | "json";
/** Optional rotating-JSON file sink configuration. */
readonly file?: LogFileOptions;
}
export interface Logger {
debug(msg: string, fields?: Record<string, unknown>): void;
info(msg: string, fields?: Record<string, unknown>): void;
warn(msg: string, fields?: Record<string, unknown>): void;
error(msg: string, fields?: Record<string, unknown>): void;
fatal(msg: string, fields?: Record<string, unknown>): void;
/**
* Derive a child logger with extra static context merged into every record.
* The parent's sinks are shared — child loggers do not allocate new file
* handles or buffers.
*/
child(context: Record<string, unknown>): Logger;
/**
* Wait for any buffered/asynchronous sink writes (e.g. the rotating file
* sink) to flush. Call before process exit to avoid losing log lines.
*/
flush(): Promise<void>;
}
/**
* Sinks are the pluggable output backends used by the logger. Internal —
* not exported from the crate barrel.
*/
export interface LogSink {
write(record: LogRecord): void;
flush(): Promise<void>;
}

View File

@@ -0,0 +1,75 @@
/**
* Result<T, E> — a lightweight, framework-agnostic result type used across
* `@elly/shared`, `@elly/core`, and `@elly/bot`.
*
* Prefer this over throwing for expected/recoverable failures crossing
* module or IPC boundaries. Reserve exceptions for genuinely exceptional
* conditions (programmer error, invariant violation, infrastructure failure).
*/
export type Ok<T> = { readonly ok: true; readonly value: T };
export type Err<E> = { readonly ok: false; readonly error: E };
export type Result<T, E = Error> = Ok<T> | Err<E>;
export function ok<T>(value: T): Ok<T> {
return { ok: true, value };
}
export function err<E>(error: E): Err<E> {
return { ok: false, error };
}
export function isOk<T, E>(result: Result<T, E>): result is Ok<T> {
return result.ok;
}
export function isErr<T, E>(result: Result<T, E>): result is Err<E> {
return !result.ok;
}
/**
* Unwrap a Result or throw the error. Use sparingly — defeats the purpose of
* Result in most call sites.
*/
export function unwrap<T, E>(result: Result<T, E>): T {
if (result.ok) return result.value;
throw result.error instanceof Error
? result.error
: new Error(`Result.unwrap on Err: ${String(result.error)}`);
}
/**
* Map the success value of a Result without affecting the error branch.
*/
export function map<T, U, E>(result: Result<T, E>, fn: (value: T) => U): Result<U, E> {
return result.ok ? ok(fn(result.value)) : result;
}
/**
* Map the error value of a Result without affecting the success branch.
*/
export function mapErr<T, E, F>(result: Result<T, E>, fn: (error: E) => F): Result<T, F> {
return result.ok ? result : err(fn(result.error));
}
/**
* Wrap a throwing synchronous function in a Result.
*/
export function tryCatch<T>(fn: () => T): Result<T, Error> {
try {
return ok(fn());
} catch (e) {
return err(e instanceof Error ? e : new Error(String(e)));
}
}
/**
* Wrap a throwing async function in a Result.
*/
export async function tryCatchAsync<T>(fn: () => Promise<T>): Promise<Result<T, Error>> {
try {
return ok(await fn());
} catch (e) {
return err(e instanceof Error ? e : new Error(String(e)));
}
}