← all parts

audit.log v1.0.1

Append-only domain event log over a part-owned Postgres table, written and queried through a contract-stable interface and a driver-free SqlExecutor seam.

provides audit.log@1

Attestations

adapterdefault
verified2026-06-11
expires2026-06-25
conformance9 tests
signaturedev (pre-v0)

Invariants

Testable claims, not adjectives — each maps to at least one named conformance test.

  • Importing the part performs no I/O and never throws; inputs are validated at call time, and every failure — invalid input or a storage error from the executor — surfaces as a typed AuditError (raw driver errors never escape)
  • append inserts exactly one row with a server-assigned monotonic id and occurred_at timestamp, returns the stored event, and the event is immediately readable via query
  • The log is append-only: no mutation is exported, and the database itself rejects UPDATE and DELETE on the events table (trigger) — the trail cannot be rewritten even with direct table access
  • query returns events newest-first by id, honors actor/action/target/since/until filters, and paginates deterministically via the `before` cursor under a bounded limit
  • action, target, and metadata round-trip faithfully; every value is parameterized, so SQL metacharacters in inputs are stored literally and never executed
  • An invalid event (blank action, over-long field, oversized metadata) or invalid query (non-positive or over-max limit) fails with a typed AuditError and issues zero SQL
  • The part operates solely through the provided SqlExecutor seam — it imports no database driver — and every statement it issues targets only its own audit_events table

Interface

auditLog(db: SqlExecutor): AuditLog AuditLog { append(event: AuditEventInput): Promise<AuditEvent>; query(filter?: AuditQuery): Promise<AuditEvent[]> } class AuditError extends Error { code: AuditErrorCode } types: SqlExecutor, AuditEvent, AuditEventInput, AuditQuery, AuditLog, AuditErrorCode

Seams — what your app writes

Sufficient without reading src/; that is part of the quality bar.

# Seams — audit.log What YOUR app provides. Reading `contract.json` + this file is enough to wire the part — you never need to read `src/`. Never edit `src/` (attested interior; edits void the attestation and fail CI). ## 1. No env, no adapter — a connection seam + one migration This part reads **no env vars** and ships **no registry adapters**. It owns one Postgres table, `audit_events`, and reaches it through a connection you hand in. Import: ```jsonc // tsconfig.json → compilerOptions (recommended alias) "paths": { "@parts/*": ["./parts/*/src"] } ``` ```ts import { auditLog, AuditError } from "@parts/audit.log"; ``` Never deep-import `src/internal/**` (lint-enforced). ## 2. The connection seam (`SqlExecutor`) The part is **driver-free**: it never imports `pg`. You give it the minimal executor it needs — the same shape `partkit migrate` uses: ```ts interface SqlExecutor { query(sql: string, params?: readonly unknown[]): Promise<{ rows: Record<string, unknown>[] }>; } ``` Wrap your existing `pg` Pool once (copy `examples/pg-executor.ts`): ```ts const db: SqlExecutor = { query: (sql, params) => pool.query(sql, params ? [...params] : undefined), }; const log = auditLog(db); ``` Because you pass the executor, you control the connection and transaction: hand in a pooled client mid-transaction to record the event **in the same transaction** as the business write it describes — the event lands only if that write commits. ## 3. Run the migration before first use `partkit add audit.log` vendors `parts/audit.log/migrations/001-create-audit-events.sql` but does not run it. Apply it with: ```sh partkit migrate # reads DATABASE_URL; records the _part_migrations ledger ``` This creates `audit_events` and the append-only triggers. The table is **interior** — the boundary in the repo mirrors a boundary in the database: - Write events only through `log.append(...)`. Do not `INSERT` into `audit_events` directly. - Read events only through `log.query(...)`. Do not `SELECT` from `audit_events` directly — that table is the part's, and its shape can change across versions; the interface is the contract. - Never write a migration that touches `audit_events` from your app's chain. ## 4. Append and query ```ts const event = await log.append({ actor: userId, // who (optional; null = system/anonymous) action: "billing.charge", // what (required, non-empty) target: "invoice:42", // the object (optional) metadata: { amount: 4200, currency: "usd" }, // arbitrary jsonb }); // event.id (string), event.occurredAt (Date, SERVER time) const trail = await log.query({ actor: userId, // exact-match filters (all optional) action: "billing.charge", since: new Date(Date.now() - 7 * 864e5), limit: 100, // 1..1000, default 100 before: cursor, // pass a prior event.id to page to older events }); // newest-first by id ``` `occurred_at` is **server-assigned and not settable** — a trustworthy timeline is the point of an audit log. If you need a separate "business" time, put it in `metadata`. ## 5. Error handling Every failure is an `AuditError` with `.code`: - `invalid_event` — blank action, an over-long field, or metadata over 64 KB. - `invalid_query` — limit outside 1..1000, or a malformed `before` cursor. - `storage` — the executor (database) failed. The raw driver error is on `.cause` (which may contain credentials — don't log it blindly); `.message` is generic and safe to surface. ## 6. What you must NOT do - Edit or import anything under `src/internal/**`. - `INSERT`/`SELECT`/`UPDATE`/`DELETE` against `audit_events` directly — use `append`/`query`. (UPDATE/DELETE/TRUNCATE are refused by the database anyway: the log is append-only.) - Pass a timestamp expecting it to become `occurred_at` — it won't. - Log an `AuditError.cause` without scrubbing it.

Install

$ partkit add audit.log