# `sink` _(no submodules)_ ## Purpose The public entry point of `@suaveplan/api-audit-file`. The `createFileAuditSink` factory composes the schema, hmac, rotation, retention, instruments, and fs-helpers modules into a single object that implements the `RequestAuditSink` contract from `@suaveplan/api-host/request-pipeline`. The sink writes each accepted record as one JSON line with an HMAC-SHA-256 signature, rolls the file when size OR age limits are reached, and keeps at most `maxFiles` rotated siblings. The sink is queue-buffered: callers `await emit()` and resolve as soon as the record has been appended to disk. A bounded queue protects the host from a stalled filesystem: records arriving while the queue is at `maxQueueSize` are dropped with a typed `AUDIT_FILE.QUEUE_FULL` and an `api.audit.dropped_total{sink="file"}` increment. `dispose()` flushes the queue and is idempotent. ## Features - `createFileAuditSink({ path, hmacKey, ... })` — async factory. Returns a sink ready to `emit()`. - Schema validation at the boundary: invalid records throw `AUDIT_FILE.INVALID_RECORD` and increment `dropped_total`. - `strict-sync` (default) and `append` modes for the durability / throughput trade-off. - Boot-time `mkdir -p` of the parent directory + `W_OK` probe; unwriteable targets throw `AUDIT_FILE.PATH_NOT_WRITEABLE` from the factory itself so misconfiguration is visible at host startup. - Size + time rotation; `maxFiles` retention; per-eviction OTel counter. - Idempotent `dispose()`; observable `queueDepth` getter for probes. ## Usage: Basic ```ts import { createFileAuditSink } from "@suaveplan/api-audit-file/sink"; const sink = await createFileAuditSink({ path: "/var/log/suaveplan/audit.log", hmacKey: new TextEncoder().encode(process.env.AUDIT_HMAC_KEY ?? ""), }); await sink.emit({ at: new Date().toISOString(), requestId: "r-1", traceId: null, route: "/admin/users", plugin: "core-admin", method: "POST", principal: { id: "user-1", scopes: ["admin"] }, tenantId: "tenant-a", status: 200, outcome: "success", }); await sink.dispose(); ``` ## Usage: Advanced Run with a custom rotation policy, `append` mode, real `MeterProvider`, and an injected `FakeClock` for deterministic tests: ```ts import { FakeClock } from "@suaveplan/testing"; import { MeterProvider, InMemoryMetricExporter, PeriodicExportingMetricReader, } from "@opentelemetry/sdk-metrics"; import { createFileAuditSink } from "@suaveplan/api-audit-file/sink"; import { createAuditFileInstruments } from "@suaveplan/api-audit-file/instruments"; const clock = new FakeClock(0); const exporter = new InMemoryMetricExporter(0); const reader = new PeriodicExportingMetricReader({ exporter }); const meterProvider = new MeterProvider({ readers: [reader] }); const sink = await createFileAuditSink({ path: "/tmp/audit.log", hmacKey: new TextEncoder().encode("secret"), clock: clock.read, rotation: { maxSizeBytes: 1024, maxAgeMs: 60_000, maxFiles: 5 }, appendMode: "append", maxQueueSize: 256, instruments: createAuditFileInstruments({ meterProvider }), }); ``` ## API Reference - `interface CreateFileAuditSinkOptions` - `path: string` (required) - `hmacKey: Uint8Array` (required, non-empty) - `clock?: Clock` — default `() => Date.now()` - `rotation?: Partial` - `appendMode?: "strict-sync" | "append"` — default `"strict-sync"` - `maxQueueSize?: number` — default 1024 - `fs?: FsAdapter` — default `nodeFsAdapter` - `logger?: LoggerLike` — default no-op - `instruments?: AuditFileInstruments` — default global meter - `interface FileAuditSink` - `emit(record): Promise` - `dispose(): Promise` - `readonly queueDepth: number` - `createFileAuditSink(options): Promise` ## Implementation Notes The drain loop is mutex-style: a single in-flight drain promise covers every concurrent `emit()` call so writes are serialised against the file handle even when callers fan out. Backpressure is signalled synchronously — `emit()` throws `QUEUE_FULL` before any await when the bounded queue is at capacity, so the host can apply load shedding without observing a write-latency spike. Rotation runs in the same drain frame as the triggering write so the rename always happens before the segment grows past the cap by one extra line. Retention runs after rotation; a retention failure is logged but does not propagate — the sink stays available even if `unlink` is transiently denied. The on-disk format is intentionally trivial (`{ record, hmac }` per line) so an operator with `jq` and `openssl dgst -sha256 -hmac` can verify integrity without any Suaveplan tooling on hand.