diff --git a/.changeset/initial-release.md b/.changeset/initial-release.md index 0e0c0e9..6180452 100644 --- a/.changeset/initial-release.md +++ b/.changeset/initial-release.md @@ -1,5 +1,5 @@ --- -"@ezmode-games/drizzle-ledger": minor +"@rafters/ledger": patch --- Initial release. Soft-delete, audit trail, and GDPR compliance for Drizzle ORM. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f2e2704..5c0392c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - node-version: [18, 20, 22] + node-version: [22] steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 diff --git a/README.md b/README.md index efb758c..f7e6f37 100644 --- a/README.md +++ b/README.md @@ -1,393 +1,27 @@ -# @ezmode-games/drizzle-ledger - -**Stop rewriting soft-delete for every project.** +# @rafters/ledger Soft-delete, audit trail, and GDPR compliance for [Drizzle ORM](https://orm.drizzle.team/). SQLite, PostgreSQL, MySQL. -Born from [ezmode.games](https://ezmode.games) where we run this in production on Cloudflare D1. Extracted because we were tired of copying the same 500 lines between projects and watching them drift. - ---- - -## The Problem - -Every Drizzle project with user accounts ends up writing the same three things from scratch: - -1. **Soft-delete** - `deleted_at` columns, filter helpers, remembering to exclude deleted records everywhere, praying nobody forgets the WHERE clause. - -2. **Audit trail** - Who changed what, when, from where. Bolting on a log table, manually inserting after every mutation, copy-pasting the pattern across services. - -3. **GDPR purge** - A user exercises their right to be forgotten. Anonymize their data in the audit trail without destroying the trail itself. Nobody thinks about this until a lawyer asks. - -These three things are always the same code. They always interact with each other. And they always get reimplemented poorly because they're "not the product." - ---- - -## Install - ```bash -pnpm add @ezmode-games/drizzle-ledger +pnpm add @rafters/ledger ``` Peer dependency: `drizzle-orm >= 0.30.0` ---- - -## Why Soft-Delete - -Hard deletes are a liability. Not philosophically - practically. - -A user says they didn't authorize a charge. You need to look up their account. It's gone. A moderator bans someone for fraud and the support team needs context. Gone. A user rage-quits, deletes their account, comes back two days later asking to undo it. Gone. - -Soft-delete means `DELETE FROM users WHERE id = ?` becomes `UPDATE users SET deleted_at = NOW(), deleted_by = ? WHERE id = ?`. The row stays. Your queries filter it out with `WHERE deleted_at IS NULL`. When someone needs the data for legal, fraud, support, or undo - it's there. - -`deleted_by` tracks who performed the deletion. Was it the user themselves? An admin? A CRON job? That matters when you're debugging at 2am or responding to a legal request. - -### What it provides - -- **`softDeleteColumns`** - Spread `...softDeleteColumns` into any Drizzle table definition. Adds `deleted_at` (timestamp) and `deleted_by` (text). Dialect-specific: integer timestamp for SQLite, `timestamptz` for Postgres, `timestamp` for MySQL. -- **`softDeleteTimestamp`** - Same thing without `deleted_by` if you don't need to track who deleted it. -- **`notDeleted(table)`** - `WHERE deleted_at IS NULL`. Use this instead of remembering to add the filter manually. -- **`onlyDeleted(table)`** - `WHERE deleted_at IS NOT NULL`. For trash views, admin dashboards, recovery tools. -- **`softDeleteValues(userId?)`** - Returns `{ deletedAt: new Date(), deletedBy: userId ?? null }`. Pass to `.set()`. -- **`restoreValues()`** - Returns `{ deletedAt: null, deletedBy: null }`. Undo a soft-delete. -- **`isSoftDeleted(record)`** - Runtime check on a record object. - -### What it does not provide - -- Cascading soft-deletes. If you soft-delete a user, their posts don't automatically soft-delete. That's your business logic. -- Automatic query filtering. You have to call `notDeleted()` or use `createAuditedDb()`. There's no global middleware that silently hides rows - that will burn you when you're debugging why a record "doesn't exist" when it's sitting right there. -- Unique constraint handling. If you have a unique index on `email`, a soft-deleted row still occupies that slot. Handle this in your schema (partial indexes, or include `deleted_at` in the constraint). - -```typescript -import { sqliteTable, text } from 'drizzle-orm/sqlite-core'; -import { softDeleteColumns } from '@ezmode-games/drizzle-ledger/soft-delete/sqlite'; - -export const users = sqliteTable('users', { - id: text('id').primaryKey(), - name: text('name'), - email: text('email').unique(), - ...softDeleteColumns, -}); -``` - -Postgres: `@ezmode-games/drizzle-ledger/soft-delete/pg`. MySQL: `@ezmode-games/drizzle-ledger/soft-delete/mysql`. - ---- - -## Why a Monkeypatch - -`createAuditedDb(db)` intercepts `db.delete()` and rewrites it to a soft-delete UPDATE for any table that has a `deleted_at` column. Tables without it pass through to a normal hard delete. - -Yes, this is a monkeypatch. No, this is not Ruby. - -The reason is simple: the alternative is discipline. You tell your team "use `softDelete()` instead of `db.delete()`" and hope everyone remembers. In every file. In every service. In every code review. Forever. Somebody will write `db.delete(users).where(...)` at 11pm on a Friday and nobody will catch it until production data is gone. - -The monkeypatch makes the wrong thing impossible. `db.delete()` does the right thing automatically. If a table has `deleted_at`, it soft-deletes. If it doesn't, it hard-deletes. You can still hard-delete explicitly via `hardDeleteTables` config. The escape hatch exists. It's just not the default. - -The right thing happens by default. You can read the entire implementation in ~40 lines. It intercepts one method, checks one column, and rewrites to an UPDATE. That's it. - -### What it provides - -- **`createAuditedDb(db)`** - Wraps a Drizzle instance. `db.delete(table).where(...)` becomes `db.update(table).set(softDeleteValues()).where(...)` when the table has `deleted_at`. -- **`hardDeleteTables`** config - Opt specific tables out of soft-delete. Useful for session tables, logs, anything you actually want gone. -- **`softDeleteValuesFactory`** config - Custom soft-delete values if you need something beyond the default `{ deletedAt: new Date(), deletedBy: contextUserId }`. -- **Context-aware** - Automatically reads `deletedBy` from the AsyncLocalStorage ledger context if one is active. -- **Chainable** - `.where()`, `.returning()`, `.execute()` all work exactly like the original delete. - -### What it does not provide - -- Transaction wrapping. It doesn't start a transaction around the rewritten UPDATE. -- Audit logging. The monkeypatch only handles the delete-to-update rewrite. If you want audit entries logged too, use the `AuditLogger` or write them yourself. -- Undo. It soft-deletes the row. Restoring it is a separate operation (`restoreValues()`). - -```typescript -import { createAuditedDb } from '@ezmode-games/drizzle-ledger/db'; - -const db = createAuditedDb(drizzle(env.DB), { - hardDeleteTables: ['session', 'verification'], // these actually delete -}); - -// This becomes a soft-delete because users has deleted_at -await db.delete(users).where(eq(users.id, userId)); - -// This is a real delete because session is in hardDeleteTables -await db.delete(session).where(eq(session.id, sessionId)); -``` - ---- - -## Audit Trail - -Every mutation in your system should be traceable. Not because you love compliance documents, but because production breaks and you need to answer "what happened?" with data, not guesses. - -The audit log records: **who** changed **what** record in **which** table, **when**, from **where** (IP, user agent, endpoint), and captures the **before** and **after** state as JSON snapshots. - -### Context propagation - -The audit context uses `AsyncLocalStorage`. Set it once in your middleware. Every audit entry created downstream - in any service, any helper, any nested function call - automatically inherits userId, IP, user agent, endpoint, and request ID. No prop drilling. No passing context objects through 6 function signatures. - -```typescript -import { createLedgerContext, runWithLedgerContext } from '@ezmode-games/drizzle-ledger/context'; - -app.use(async (c, next) => { - const context = createLedgerContext({ - userId: c.get('user')?.id, - ip: c.req.header('x-forwarded-for'), - userAgent: c.req.header('user-agent'), - endpoint: `${c.req.method} ${c.req.path}`, - }); - return runWithLedgerContext(context, next); -}); -``` - -Works in Node.js, Bun, Deno, and Cloudflare Workers (AsyncLocalStorage is available in all of them). Lazy-initialized - no overhead if you never call it. - -### Manual audit logging - -For explicit control over what gets logged: - -```typescript -import { logInsert, logUpdate, logSoftDelete, logDelete, logRestore } from '@ezmode-games/drizzle-ledger/audit'; - -// After inserting -const [user] = await db.insert(users).values(data).returning(); -await logInsert(db, auditLog, 'users', user.id, user); - -// After updating -const [oldUser] = await db.select().from(users).where(eq(users.id, id)); -const [newUser] = await db.update(users).set(changes).where(eq(users.id, id)).returning(); -await logUpdate(db, auditLog, 'users', id, oldUser, newUser); -``` - -### Automatic audit logging via Drizzle Logger - -If you'd rather not call `logInsert` / `logUpdate` manually, the `AuditLogger` plugs into Drizzle's logger interface and intercepts SQL queries: - -```typescript -import { AuditLogger } from '@ezmode-games/drizzle-ledger/logger'; - -const logger = new AuditLogger( - async (entry) => { - await db.insert(auditLog).values({ ...entry, id: uuidv7() }); - }, - { excludeTables: ['audit_log', 'session'] } -); - -const db = drizzle(env.DB, { logger }); -``` - -Parses INSERT/UPDATE/DELETE queries, extracts table name and record ID, captures context from AsyncLocalStorage, writes the entry. Fire-and-forget - errors are caught and logged to console, never thrown. Your mutations don't fail because audit logging broke. - -### What it provides for SOC 2 - -SOC 2 Type II auditors want evidence that you track changes to sensitive data. This gives you: - -- **CC6.1 (Logical access)** - Every entry records `userId`, tying mutations to authenticated users. -- **CC7.2 (System monitoring)** - Before/after snapshots of every change. `oldData` and `newData` as JSON. -- **CC8.1 (Change management)** - Timestamped, immutable audit entries with `action` type (INSERT, UPDATE, DELETE, SOFT_DELETE, RESTORE). -- **Source attribution** - IP address, user agent, API endpoint, request ID per entry. -- **Record history** - `getRecordHistory(db, auditLog, 'users', userId)` returns the full change history for any record, newest first. - -### What it does not provide for SOC 2 - -- **Retention policies.** This doesn't auto-delete old audit entries. SOC 2 doesn't mandate a specific retention period, but your policy might. Write a CRON job. -- **Tamper protection.** Entries are regular database rows. Anyone with write access to the audit table can modify them. If you need tamper-evident logs, hash-chain the entries or ship them to an immutable store. -- **Access controls.** This doesn't restrict who can read or write audit entries. That's your database permissions. -- **Encryption at rest.** This stores oldData/newData as plaintext JSON. If those snapshots contain sensitive data, encrypt them before insertion or use database-level encryption. -- **Alerting.** No real-time monitoring, no anomaly detection, no Slack notifications. You get the data. What you do with it is up to you. - -### What it provides for the real world - -Beyond compliance theater: - -- **"What happened to this user's account?"** - Pull the record history. See every change, who made it, when, from where. -- **"Who deleted this?"** - `deleted_by` field + audit entry with `SOFT_DELETE` action and the full before-state. -- **"Can we undo this?"** - `oldData` snapshot contains the pre-change state. You have the data to restore from. -- **"This user says they didn't do this"** - IP, user agent, endpoint, timestamp. Enough to determine if it was them or a compromised account. -- **Context that travels with the request** - Set it once in middleware, forget about it. Every audit entry from every service call in that request automatically gets the right userId and IP. - -### What it does not provide for the real world - -- **Diff computation.** It stores the full before and after state, not a delta. If you want "name changed from X to Y", compute it from oldData/newData yourself. -- **Schema evolution handling.** If you rename a column, old audit entries still have the old column name in their JSON snapshots. This doesn't migrate historical data. -- **Cross-service correlation.** The `requestId` field exists for this purpose, but you have to set it yourself. There's no distributed tracing built in. - ---- - -## GDPR - -### Accountability (Article 5(2)) - -GDPR requires you to demonstrate that personal data is processed lawfully and that you can account for what happens to it. The audit trail is that evidence. Every mutation to every table records who did it, when, from where (IP, user agent, endpoint), and captures the before and after state. - -When a regulator asks "how do you demonstrate accountability for data processing?" - you point at the log. - -#### What it provides - -- **Actor attribution** - Every entry records `userId`. You know which authenticated user performed each operation. -- **Source attribution** - IP address, user agent, API endpoint, request ID. You know where the request came from. -- **Before/after snapshots** - `oldData` and `newData` as JSON. You can see exactly what changed. -- **Timestamped, typed actions** - INSERT, UPDATE, DELETE, SOFT_DELETE, RESTORE. You know what kind of operation was performed and when. - -#### What it does not provide - -- **Proof of lawful basis.** The log shows what happened, not why it was allowed. Tracking consent, legitimate interest, or contractual necessity is your responsibility. -- **Read access logging.** This logs mutations (writes). It does not log SELECT queries. If you need to prove who viewed personal data, you need separate access logging. - -### Records of Processing (Article 30) - -GDPR requires controllers to maintain records of processing activities. Every audit entry is a record of a processing activity - what table was affected, what action was taken, what data was involved, who performed it. - -#### What it provides - -- **Per-record history** - `getRecordHistory(db, auditLog, 'users', userId)` returns every processing activity for a specific record, newest first. -- **Table-level tracking** - `tableName` field on every entry. You can query all processing activities for a specific category of data. -- **Action categorization** - INSERT, UPDATE, DELETE, SOFT_DELETE, RESTORE. Each processing activity is typed. - -#### What it does not provide - -- **Processing purpose.** The log records that data was changed, not why. "Marketing," "service delivery," "fraud prevention" - you need to track purposes separately. -- **Data category classification.** The log doesn't know that `email` is contact data and `ip` is technical data. GDPR Article 30 requires you to describe categories of personal data. That's your documentation. -- **Third-party processor records.** If you share data with a payment processor or email provider, those transfers aren't captured here. - -### Breach Response (Article 33) - -When a breach happens, the first question is "what was affected?" You have 72 hours to notify the supervisory authority. The audit trail tells you which records were accessed or modified, by whom, and when. That's what you need to determine scope. - -#### What it provides - -- **Breach scope determination** - Query the audit log by time range, user, or table to determine what data was affected during an incident. -- **Timeline reconstruction** - Timestamped entries let you establish exactly when unauthorized changes occurred. -- **Actor identification** - `userId`, IP, user agent on every entry. If an account was compromised, you can see what it did. +## Docs -#### What it does not provide +Full documentation: [docs/](./docs/) -- **Breach detection.** This doesn't monitor for anomalies or alert you that a breach is happening. It gives you the data to investigate after you know. -- **Notification delivery.** It doesn't send emails to affected users or file reports with supervisory authorities. That's your process. -- **Risk assessment.** Determining whether a breach is "likely to result in a risk to the rights and freedoms of natural persons" is a human judgment call, not something a log can tell you. - -### Right to Erasure (Article 17) - -A user requests deletion of their personal data. You're legally required to comply. But you also have an audit trail full of their email, name, IP address, and user agent - data you might need for other legal obligations (fraud prevention, financial records, SOC 2). - -GDPR says you can anonymize instead of delete. The audit trail structure stays intact. The PII goes away. - -### What `purgeUserData` does - -1. Finds all audit entries where `userId` matches the target user OR `recordId` matches (catches entries about the user made by admins). -2. Parses the `oldData` and `newData` JSON columns. -3. Recursively strips configured PII fields (email, name, phone, address, ip - anything you specify). Handles nested objects and arrays. -4. Replaces `userId` with `PURGED_USER` (or a custom string) - but only on entries the user themselves created. If an admin modified the user's record, the admin's userId stays. -5. Nullifies `ip` and `userAgent` on the user's own entries. Admin entries keep their metadata. -6. Writes the anonymized data back. - -```typescript -import { purgeUserData, isUserDataPurged } from '@ezmode-games/drizzle-ledger/gdpr'; - -const result = await purgeUserData(db, auditLog, 'user-123', { - piiFields: ['email', 'name', 'phone', 'address', 'ip', 'userAgent'], - anonymizedUserId: 'PURGED_USER', // default -}); -// { entriesAnonymized: 47, tablesProcessed: ['users', 'accounts'] } - -// Idempotency check -const alreadyPurged = await isUserDataPurged(db, auditLog, 'user-123'); -``` - -### What it provides - -- **Selective anonymization** - You define which fields are PII. Different apps have different PII. A gaming platform's PII is different from a healthcare app's PII. -- **Admin preservation** - If `admin-456` modified `user-123`'s record, the admin's userId/IP/userAgent stays untouched. You're purging the user's data, not everyone who ever interacted with them. -- **Recursive stripping** - PII nested inside JSON objects and arrays gets found and removed. -- **Idempotent** - Safe to run multiple times. Already-purged entries don't break anything. -- **Malformed JSON tolerance** - If oldData/newData contains invalid JSON, it gets nullified instead of crashing the purge. - -### What it does not provide - -- **Data in other tables.** This only touches the audit log. If the user's email is in a `users` table, a `newsletter_subscribers` table, and an `orders` table, you need to handle those separately. -- **Data in other systems.** Payment processors, email providers, analytics services, CDN logs. GDPR covers all of it. This handles one table. -- **Data portability (Article 20).** This anonymizes data. It doesn't export it. If you need "give me all my data," build that separately. -- **DPO tooling.** No dashboards, no request tracking, no compliance workflows. - ---- - -## Better Auth Integration - -If you use [Better Auth](https://www.better-auth.com/), the plugin hooks into its `databaseHooks` to automatically log user/account create and update operations. The soft-delete callback intercepts `deleteUser` to perform soft-delete instead of hard delete. - -```typescript -import { ledgerPlugin, createSoftDeleteCallback, isSoftDeletePerformed } from '@ezmode-games/drizzle-ledger/better-auth'; - -export const auth = betterAuth({ - user: { - deleteUser: { - enabled: true, - beforeDelete: createSoftDeleteCallback({ - db, - userTable: users, - whereUserId: (userId) => eq(users.id, userId), - writeAuditEntry: async (entry) => { - await db.insert(auditLog).values({ ...entry, id: uuidv7() }); - }, - }), - }, - }, - plugins: [ - ledgerPlugin({ - writeAuditEntry: async (entry) => { - await db.insert(auditLog).values({ ...entry, id: uuidv7() }); - }, - auditTables: ['user', 'account'], // default. add 'session' if you want noise. - }), - ], -}); -``` - -`createSoftDeleteCallback` performs the soft-delete UPDATE, logs an audit entry, then throws `SoftDeletePerformedError` to prevent Better Auth from executing the actual hard delete. In your client code: - -```typescript -try { - await auth.api.deleteUser({ userId }); -} catch (error) { - if (isSoftDeletePerformed(error)) { - // Success. User was soft-deleted. - return { success: true }; - } - throw error; // Actual error -} -``` - -Yes, using a throw for flow control is ugly. Better Auth's `beforeDelete` hook doesn't have a "cancel the delete" return value. This is the cleanest way to prevent the hard delete from happening. The error has a `.code === 'SOFT_DELETE_PERFORMED'` and `.softDeleted === true` for reliable detection, even across serialization boundaries. - ---- - -## Subpath Exports - -Import only what you need. Every subpath is independently tree-shakeable. - -| Import | What | -|--------|------| -| `@ezmode-games/drizzle-ledger` | Everything | -| `@ezmode-games/drizzle-ledger/soft-delete` | Dialect-agnostic helpers | -| `@ezmode-games/drizzle-ledger/soft-delete/sqlite` | SQLite columns | -| `@ezmode-games/drizzle-ledger/soft-delete/pg` | Postgres columns | -| `@ezmode-games/drizzle-ledger/soft-delete/mysql` | MySQL columns | -| `@ezmode-games/drizzle-ledger/schema/sqlite` | SQLite audit table | -| `@ezmode-games/drizzle-ledger/schema/pg` | Postgres audit table | -| `@ezmode-games/drizzle-ledger/schema/mysql` | MySQL audit table | -| `@ezmode-games/drizzle-ledger/audit` | Manual audit functions | -| `@ezmode-games/drizzle-ledger/context` | AsyncLocalStorage context | -| `@ezmode-games/drizzle-ledger/db` | Automatic soft-delete wrapper | -| `@ezmode-games/drizzle-ledger/gdpr` | GDPR purge | -| `@ezmode-games/drizzle-ledger/logger` | Drizzle Logger with audit | -| `@ezmode-games/drizzle-ledger/better-auth` | Better Auth plugin | - ---- +| Guide | Covers | +|---|---| +| [Getting Started](./docs/getting-started.mdx) | End-to-end setup walkthrough | +| [Soft-Delete](./docs/soft-delete.mdx) | Column helpers, query filters, automatic soft-delete, restore | +| [Audit Trail](./docs/audit-trail.mdx) | AuditLogger, manual logging, history queries | +| [Context](./docs/context.mdx) | AsyncLocalStorage propagation, middleware setup | +| [GDPR](./docs/gdpr.mdx) | `purgeUserData`, PII anonymization, admin preservation | +| [Better Auth](./docs/better-auth.mdx) | `ledgerPlugin`, `createSoftDeleteCallback`, flow control | +| [API Reference](./docs/api-reference.mdx) | Every export, every type, organized by subpath | ## License -MIT - ---- - -Built by [ezmode.games](https://ezmode.games). Extracted from production because we kept copying the same files between projects and watching them drift apart. +MIT. Authored by Sean Silvius. Source: [github.com/rafters-studio/ledger](https://github.com/rafters-studio/ledger). diff --git a/docs/api-reference.mdx b/docs/api-reference.mdx new file mode 100644 index 0000000..78cc404 --- /dev/null +++ b/docs/api-reference.mdx @@ -0,0 +1,714 @@ +# API Reference + +`@rafters/ledger` — soft-delete, audit trail, and GDPR compliance for Drizzle ORM. + +Each subpath is a separate entry point. Import only what you need; nothing in the subpaths cross-imports each other except through the root barrel. + +--- + +## `@rafters/ledger` + +Root barrel. Re-exports everything from all subpaths. + +--- + +## `@rafters/ledger/context` + +AsyncLocalStorage-based context propagation. Cloudflare Workers compatible; does not import from `node:async_hooks`. Degrades gracefully when `AsyncLocalStorage` is unavailable. + +--- + +### `createLedgerContext(options): LedgerContext` + +Constructs a `LedgerContext` from request properties. All fields default to `null` when omitted. + +| Param | Type | Description | +|-------|------|-------------| +| `options.userId` | `string \| null \| undefined` | Authenticated user ID | +| `options.ip` | `string \| null \| undefined` | Client IP address | +| `options.userAgent` | `string \| null \| undefined` | `User-Agent` header value | +| `options.endpoint` | `string \| null \| undefined` | Request identifier, e.g. `"POST /users"` | +| `options.requestId` | `string \| undefined` | Trace/correlation ID | +| `options.metadata` | `Record \| undefined` | Arbitrary additional data | + +--- + +### `createSystemContext(source, metadata?): LedgerContext` + +Constructs a context for operations with no user (cron jobs, migrations, background workers). Sets `userId` to `null` and `endpoint` to `"system:"`. + +| Param | Type | Description | +|-------|------|-------------| +| `source` | `string` | Identifier for the system process, e.g. `"cron:cleanup"` | +| `metadata` | `Record \| undefined` | Arbitrary additional data | + +--- + +### `runWithLedgerContext(context, fn): T | Promise` + +Runs `fn` inside an AsyncLocalStorage scope. All code called within `fn` can read the context via `getLedgerContext()`. + +| Param | Type | Description | +|-------|------|-------------| +| `context` | `LedgerContext` | The context to bind | +| `fn` | `() => T \| Promise` | Function to execute within the context | + +--- + +### `getLedgerContext(): LedgerContext | null` + +Returns the active context. Returns `null` when called outside a `runWithLedgerContext` scope or when `AsyncLocalStorage` is unavailable. + +--- + +### `hasLedgerContext(): boolean` + +Returns `true` if a context is active in the current async scope. + +--- + +## `@rafters/ledger/soft-delete` + +Dialect-agnostic query helpers. No column definitions; import those from a dialect subpath. + +--- + +### `notDeleted(table): SQL` + +WHERE clause fragment that excludes soft-deleted records (`deletedAt IS NULL`). + +| Param | Type | Description | +|-------|------|-------------| +| `table` | `{ deletedAt: Column }` | Table object with a `deletedAt` column | + +--- + +### `onlyDeleted(table): SQL` + +WHERE clause fragment that returns only soft-deleted records (`deletedAt IS NOT NULL`). + +| Param | Type | Description | +|-------|------|-------------| +| `table` | `{ deletedAt: Column }` | Table object with a `deletedAt` column | + +--- + +### `includingDeleted(): SQL` + +Returns `1=1` — a SQL fragment that matches all records, deleted or not. Used to explicitly opt out of soft-delete filtering. + +--- + +### `softDeleteValues(deletedBy?): { deletedAt: Date; deletedBy: string | null }` + +Returns the values object to pass to `.set()` when soft-deleting a record. Sets `deletedAt` to the current time. + +| Param | Type | Description | +|-------|------|-------------| +| `deletedBy` | `string \| null \| undefined` | User ID performing the delete | + +--- + +### `restoreValues(): { deletedAt: null; deletedBy: null }` + +Returns the values object to pass to `.set()` when restoring a soft-deleted record. Nulls both `deletedAt` and `deletedBy`. + +--- + +### `isSoftDeleted(record): boolean` + +Returns `true` if the record's `deletedAt` is non-null. Returns `false` for `null` or `undefined` input. + +| Param | Type | Description | +|-------|------|-------------| +| `record` | `{ deletedAt: Date \| null } \| null \| undefined` | Record to check | + +--- + +### Type: `WithSoftDelete` + +Utility type that augments `T` with full soft-delete fields. + +```ts +type WithSoftDelete = T & { + deletedAt: Date | null; + deletedBy: string | null; +}; +``` + +--- + +### Type: `WithSoftDeleteTimestamp` + +Utility type that augments `T` with only the timestamp field (no `deletedBy`). + +```ts +type WithSoftDeleteTimestamp = T & { + deletedAt: Date | null; +}; +``` + +--- + +## `@rafters/ledger/soft-delete/sqlite` +## `@rafters/ledger/soft-delete/pg` +## `@rafters/ledger/soft-delete/mysql` + +Dialect-specific column definitions. Spread into your table definition. + +| Export | SQLite | Postgres | MySQL | +|--------|--------|----------|-------| +| `softDeleteColumns` | `integer("deleted_at", { mode: "timestamp_ms" })` + `text("deleted_by")` | `timestamp("deleted_at", { withTimezone: true })` + `text("deleted_by")` | `timestamp("deleted_at")` + `varchar("deleted_by", { length: 255 })` | +| `softDeleteTimestamp` | `integer("deleted_at", { mode: "timestamp_ms" })` only | `timestamp("deleted_at", { withTimezone: true })` only | `timestamp("deleted_at")` only | + +Both exports are `const` objects. Use `...softDeleteColumns` or `...softDeleteTimestamp` in your column map. + +--- + +## `@rafters/ledger/db` + +--- + +### `createAuditedDb(db, config?): T` + +Monkeypatches the Drizzle instance so `db.delete(table)` transparently becomes a soft-delete UPDATE for any table that has a `deletedAt` column. Tables without `deletedAt`, or tables listed in `hardDeleteTables`, still execute a real DELETE. + +Returns the same database instance with the patched `delete` method. + +| Param | Type | Description | +|-------|------|-------------| +| `db` | `T extends WithDeleteAndUpdate` | Drizzle database instance | +| `config` | `AuditedDbConfig \| undefined` | Optional configuration | + +--- + +### `hasColumn(table, columnName): boolean` + +Returns `true` if the Drizzle table object has a column with the given name. + +| Param | Type | Description | +|-------|------|-------------| +| `table` | `unknown` | Drizzle table object | +| `columnName` | `string` | Column name to check | + +--- + +### `getTableName(table): string | null` + +Extracts the SQL table name from a Drizzle table object. Returns `null` if the input is not a valid table object. + +| Param | Type | Description | +|-------|------|-------------| +| `table` | `unknown` | Drizzle table object | + +--- + +### Type: `AuditedDbConfig` + +| Field | Type | Description | +|-------|------|-------------| +| `hardDeleteTables` | `string[] \| undefined` | Table names that skip soft-delete and execute real DELETEs | +| `softDeleteValuesFactory` | `(deletedBy?: string \| null) => { deletedAt: Date; deletedBy: string \| null } \| undefined` | Custom factory for soft-delete values | + +--- + +## `@rafters/ledger/audit` + +Manual audit logging functions. These do not auto-capture context from AsyncLocalStorage via the logger; they use it directly. Use these when you need explicit control over what gets logged. + +--- + +### `createAuditEntry(options): AuditLogEntry` + +Creates an `AuditLogEntry` object without inserting it. Pulls context from AsyncLocalStorage unless `options.context` is provided. Generates a UUIDv7 for the `id` field. + +| Param | Type | Description | +|-------|------|-------------| +| `options.tableName` | `string` | Table that was modified | +| `options.recordId` | `string` | Primary key of the affected record | +| `options.action` | `AuditAction` | Operation type | +| `options.oldData` | `Record \| null \| undefined` | Data before the change | +| `options.newData` | `Record \| null \| undefined` | Data after the change | +| `options.context` | `LedgerContext \| null \| undefined` | Override context; falls back to AsyncLocalStorage | + +--- + +### `insertAuditEntry(db, auditTable, entry): Promise` + +Inserts a pre-built `AuditLogEntry` into the database. Serializes `oldData`/`newData` as JSON strings. + +| Param | Type | Description | +|-------|------|-------------| +| `db` | Drizzle db with `insert` | Database instance or transaction | +| `auditTable` | `typeof auditLog` | The audit log table | +| `entry` | `AuditLogEntry` | Entry to insert | + +--- + +### `logInsert(db, auditTable, tableName, recordId, newData): Promise` + +Creates and inserts an INSERT audit entry. `oldData` is always `null`. + +| Param | Type | Description | +|-------|------|-------------| +| `db` | Drizzle db | Database instance | +| `auditTable` | `typeof auditLog` | The audit log table | +| `tableName` | `string` | Table where the record was inserted | +| `recordId` | `string` | Primary key of the inserted record | +| `newData` | `Record` | The inserted data | + +--- + +### `logUpdate(db, auditTable, tableName, recordId, oldData, newData): Promise` + +Creates and inserts an UPDATE audit entry. + +| Param | Type | Description | +|-------|------|-------------| +| `db` | Drizzle db | Database instance | +| `auditTable` | `typeof auditLog` | The audit log table | +| `tableName` | `string` | Table where the record was updated | +| `recordId` | `string` | Primary key of the updated record | +| `oldData` | `Record` | Data before the update | +| `newData` | `Record` | Data after the update | + +--- + +### `logSoftDelete(db, auditTable, tableName, recordId, oldData, newData): Promise` + +Creates and inserts a SOFT_DELETE audit entry. + +| Param | Type | Description | +|-------|------|-------------| +| `db` | Drizzle db | Database instance | +| `auditTable` | `typeof auditLog` | The audit log table | +| `tableName` | `string` | Table where the record was soft-deleted | +| `recordId` | `string` | Primary key of the soft-deleted record | +| `oldData` | `Record` | Data before soft-delete | +| `newData` | `Record` | Data after soft-delete (with `deletedAt` set) | + +--- + +### `logDelete(db, auditTable, tableName, recordId, oldData): Promise` + +Creates and inserts a DELETE audit entry (hard delete). `newData` is always `null`. + +| Param | Type | Description | +|-------|------|-------------| +| `db` | Drizzle db | Database instance | +| `auditTable` | `typeof auditLog` | The audit log table | +| `tableName` | `string` | Table where the record was deleted | +| `recordId` | `string` | Primary key of the deleted record | +| `oldData` | `Record` | Data before deletion | + +--- + +### `logRestore(db, auditTable, tableName, recordId, oldData, newData): Promise` + +Creates and inserts a RESTORE audit entry. + +| Param | Type | Description | +|-------|------|-------------| +| `db` | Drizzle db | Database instance | +| `auditTable` | `typeof auditLog` | The audit log table | +| `tableName` | `string` | Table where the record was restored | +| `recordId` | `string` | Primary key of the restored record | +| `oldData` | `Record` | Data before restore (with `deletedAt` set) | +| `newData` | `Record` | Data after restore (with `deletedAt` null) | + +--- + +### `getRecordHistory(db, auditTable, tableName, recordId): Promise` + +Returns all audit entries for a record, ordered newest first. Deserializes `oldData`/`newData` from JSON strings back to objects. + +| Param | Type | Description | +|-------|------|-------------| +| `db` | Drizzle db | Database instance | +| `auditTable` | `typeof auditLog` | The audit log table | +| `tableName` | `string` | Table to query | +| `recordId` | `string` | Primary key of the record | + +--- + +## `@rafters/ledger/logger` + +Drizzle `Logger` interface implementation. Hooks into the Drizzle query lifecycle instead of requiring manual log calls. Fire-and-forget; audit failures are logged to `console.error` but never throw. + +--- + +### `class AuditLogger implements Logger` + +Pass an instance to `drizzle(driver, { logger })`. On each write query, parses the SQL, resolves context from AsyncLocalStorage, and calls `writeAuditEntry`. + +**Constructor:** + +```ts +new AuditLogger( + writeAuditEntry: (entry: AuditEntryInput) => Promise, + config?: AuditLoggerConfig, +) +``` + +| Param | Type | Description | +|-------|------|-------------| +| `writeAuditEntry` | `(entry: AuditEntryInput) => Promise` | Callback invoked for each audited query | +| `config` | `AuditLoggerConfig \| undefined` | Table filtering and SELECT toggle | + +**Method: `logQuery(query, params): void`** + +Called automatically by Drizzle. Parses `query`, checks include/exclude lists, and fires `writeAuditEntry` without awaiting. + +--- + +### `parseQuery(query): ParsedQuery | null` + +Extracts the action and table name from a SQL string. Returns `null` for DDL, stored procedure calls, or any unparseable input. + +| Param | Type | Description | +|-------|------|-------------| +| `query` | `string` | Raw SQL query string | + +--- + +### `extractRecordId(params): string | null` + +Best-effort extraction of a record ID from query parameters. Returns the first string parameter that looks like a UUID or slug. Returns `null` when none match. + +| Param | Type | Description | +|-------|------|-------------| +| `params` | `unknown[]` | Query parameters array | + +--- + +### Type: `AuditLoggerConfig` + +| Field | Type | Description | +|-------|------|-------------| +| `includeTables` | `string[] \| undefined` | If non-empty, only these tables are audited | +| `excludeTables` | `string[] \| undefined` | Tables to skip; checked after `includeTables` | +| `logSelects` | `boolean \| undefined` | Audit SELECT queries (default: `false`) | + +--- + +### Type: `AuditEntryInput` + +Shape of the object passed to `writeAuditEntry` in `AuditLogger`. + +| Field | Type | Description | +|-------|------|-------------| +| `tableName` | `string` | Parsed table name | +| `recordId` | `string \| null` | Best-effort record ID from params | +| `action` | `"INSERT" \| "UPDATE" \| "DELETE" \| "SELECT"` | Query type | +| `query` | `string` | Original SQL string | +| `params` | `unknown[]` | Query parameters | +| `userId` | `string \| null` | From active `LedgerContext` | +| `ip` | `string \| null` | From active `LedgerContext` | +| `userAgent` | `string \| null` | From active `LedgerContext` | +| `endpoint` | `string \| null` | From active `LedgerContext` | +| `requestId` | `string \| undefined` | From active `LedgerContext` | + +--- + +### Type: `ParsedQuery` + +| Field | Type | Description | +|-------|------|-------------| +| `action` | `"INSERT" \| "UPDATE" \| "DELETE" \| "SELECT"` | Detected SQL action | +| `table` | `string` | Lowercase table name | + +--- + +## `@rafters/ledger/schema/sqlite` +## `@rafters/ledger/schema/pg` +## `@rafters/ledger/schema/mysql` + +Drizzle table definitions for the audit log. Use one dialect; do not mix. + +--- + +### `auditLog` + +Pre-built table definition named `"audit_log"`. Import directly when the default table name is acceptable. + +--- + +### `createAuditLogTable(tableName): SQLiteTable | PgTable | MySQLTable` + +Creates an audit log table with a custom name. Identical column structure to `auditLog`. + +| Param | Type | Description | +|-------|------|-------------| +| `tableName` | `string` | SQL table name | + +--- + +### `AUDIT_LOG_INDEXES` + +`readonly string[]` — SQL statements to create recommended indexes on `(table_name, record_id)`, `user_id`, `created_at`, and `request_id`. + +SQLite/Postgres use `CREATE INDEX IF NOT EXISTS`. MySQL omits `IF NOT EXISTS`. + +--- + +### Column differences by dialect + +| Column | SQLite | Postgres | MySQL | +|--------|--------|----------|-------| +| `id` | `text` | `text` | `varchar(36)` | +| `table_name` | `text` | `text` | `varchar(255)` | +| `record_id` | `text` | `text` | `varchar(255)` | +| `action` | `text` enum | `text` enum | `varchar(20)` | +| `old_data` / `new_data` | `text` | `text` | `text` | +| `user_id` | `text` | `text` | `varchar(255)` | +| `ip` | `text` | `text` | `varchar(45)` | +| `endpoint` | `text` | `text` | `varchar(500)` | +| `request_id` | `text` | `text` | `varchar(255)` | +| `created_at` | `integer timestamp_ms`, default `unixepoch() * 1000` | `timestamptz`, default `now()` | `timestamp`, default `now()` | + +--- + +### Types: `AuditLog`, `AuditLogInsert`, `AuditLogSelect` + +| Type | Description | +|------|-------------| +| `AuditLog` | `typeof auditLog` — the table type itself | +| `AuditLogInsert` | `typeof auditLog.$inferInsert` — insert shape | +| `AuditLogSelect` | `typeof auditLog.$inferSelect` — select shape | + +--- + +## `@rafters/ledger/gdpr` + +GDPR-compliant data purge. Preserves the audit trail structure while removing PII. Records are never deleted. + +--- + +### `purgeUserData(db, auditTable, userId, config?): Promise` + +Finds all audit entries where `userId` matches (actor) or `recordId` matches (subject), then: + +1. Replaces `userId` with `anonymizedUserId` on entries the user created. +2. Nullifies `ip` and `userAgent` on those same entries. +3. Removes PII fields from `oldData`/`newData` JSON on all matched entries. +4. Preserves non-PII audit data (action, timestamps, table name). + +| Param | Type | Description | +|-------|------|-------------| +| `db` | `DrizzleDb` | Database instance with `update` and `select` | +| `auditTable` | `AuditLog` | The audit log table | +| `userId` | `string` | User ID to purge | +| `config` | `PurgeConfig \| undefined` | Field list and replacement value overrides | + +--- + +### `anonymizeJsonData(data, piiFields): Record | null` + +Recursively removes named fields from an object. Processes nested objects and arrays. Returns `null` for null input. + +| Param | Type | Description | +|-------|------|-------------| +| `data` | `Record \| null` | Data to anonymize | +| `piiFields` | `string[]` | Field names to remove | + +--- + +### `isUserDataPurged(db, auditTable, userId): Promise` + +Returns `true` if no audit entries exist with the given `userId`. Useful for idempotency checks before calling `purgeUserData`. + +| Param | Type | Description | +|-------|------|-------------| +| `db` | `DrizzleDb` | Database instance | +| `auditTable` | `AuditLog` | The audit log table | +| `userId` | `string` | User ID to check | + +--- + +### Type: `PurgeConfig` + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `piiFields` | `string[] \| undefined` | `["email", "name", "firstName", "lastName", "phone", "address", "ip", "ipAddress", "userAgent"]` | JSON field names to strip from data columns | +| `anonymizedUserId` | `string \| undefined` | `"PURGED_USER"` | Replacement value for the `userId` column | + +--- + +### Type: `PurgeResult` + +| Field | Type | Description | +|-------|------|-------------| +| `entriesAnonymized` | `number` | Count of rows updated | +| `tablesProcessed` | `string[]` | Unique table names that had entries anonymized | + +--- + +## `@rafters/ledger/better-auth` + +Integration with [better-auth](https://www.better-auth.com/). The plugin provides audit logging via `databaseHooks`. Soft-delete behavior requires `createSoftDeleteCallback` wired separately into `user.deleteUser.beforeDelete`. + +--- + +### `ledgerPlugin(config?): BetterAuthPlugin` + +Returns a better-auth plugin that registers `databaseHooks` for create and update on the configured tables. Optionally logs a SOFT_DELETE audit entry before user deletion. + +| Param | Type | Description | +|-------|------|-------------| +| `config` | `LedgerPluginConfig \| undefined` | Plugin configuration | + +--- + +### `createSoftDeleteCallback(options): (user, request?) => Promise` + +Returns a `beforeDelete` callback that performs a soft-delete UPDATE on the user table and then throws `SoftDeletePerformedError` to prevent the hard delete. Catch the error with `isSoftDeletePerformed()` to treat it as success. + +| Param | Type | Description | +|-------|------|-------------| +| `options.db` | `{ update: (table) => unknown }` | Drizzle instance with update | +| `options.userTable` | `{ id: Column; deletedAt: Column; deletedBy?: Column }` | The user table | +| `options.whereUserId` | `(userId: string) => SQL` | Builds the WHERE clause; e.g. `(id) => eq(users.id, id)` | +| `options.writeAuditEntry` | `(entry: LedgerAuditEntry) => Promise \| undefined` | Optional audit callback | + +--- + +### `createDeleteAuditCallback(writeAuditEntry): (user, request?) => Promise` + +Returns an `afterDelete` callback that logs a DELETE audit entry without interfering with the delete. Use this when you want audit trail for hard deletes. + +| Param | Type | Description | +|-------|------|-------------| +| `writeAuditEntry` | `(entry: LedgerAuditEntry) => Promise` | Callback to write the audit entry | + +--- + +### `isSoftDeletePerformed(error): error is SoftDeletePerformedError` + +Type guard. Returns `true` if `error` is a `SoftDeletePerformedError` or has `code === "SOFT_DELETE_PERFORMED"` and `softDeleted === true`. + +| Param | Type | Description | +|-------|------|-------------| +| `error` | `unknown` | Any caught value | + +--- + +### `class SoftDeletePerformedError extends Error` + +Thrown by `createSoftDeleteCallback` to interrupt the hard delete after a successful soft-delete. Not an actual error condition. + +| Property | Type | Value | +|----------|------|-------| +| `code` | `"SOFT_DELETE_PERFORMED"` | Discriminant for `isSoftDeletePerformed` | +| `softDeleted` | `true` | Always `true` | +| `userId` | `string` | ID of the soft-deleted user | + +--- + +### Type: `LedgerPluginConfig` + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `auditTables` | `("user" \| "account" \| "session" \| "verification")[] \| undefined` | `["user", "account"]` | Tables to register create/update hooks on | +| `softDeleteTables` | `"user"[] \| undefined` | `[]` | Tables for which to log a SOFT_DELETE entry before deletion; currently only `"user"` is supported | +| `writeAuditEntry` | `(entry: LedgerAuditEntry) => Promise \| undefined` | — | Callback invoked for each audit entry; audit logging disabled if omitted | + +--- + +### Type: `LedgerAuditEntry` + +| Field | Type | Description | +|-------|------|-------------| +| `tableName` | `string` | better-auth table (`user`, `account`, etc.) | +| `recordId` | `string` | Record primary key | +| `action` | `"INSERT" \| "UPDATE" \| "SOFT_DELETE" \| "DELETE"` | Operation type | +| `oldData` | `Record \| null` | Data before the operation; `null` for INSERT | +| `newData` | `Record \| null` | Data after the operation; `null` for DELETE | +| `userId` | `string \| null` | User performing the action; for `account` hooks this is `null` | + +--- + +### Type: `SoftDeleteCallbackOptions` + +| Field | Type | Description | +|-------|------|-------------| +| `db` | `{ update: (table) => unknown }` | Drizzle instance | +| `userTable` | `{ id: Column; deletedAt: Column; deletedBy?: Column }` | User table with soft-delete columns | +| `whereUserId` | `(userId: string) => SQL` | WHERE clause builder | +| `writeAuditEntry` | `(entry: LedgerAuditEntry) => Promise \| undefined` | Optional audit callback | + +--- + +## Types (`@rafters/ledger`) + +Root types exported from `@rafters/ledger` (also re-exported from subpaths where used). + +--- + +### `LedgerContext` + +| Field | Type | Description | +|-------|------|-------------| +| `userId` | `string \| null` | Authenticated user ID; `null` for anonymous or system | +| `ip` | `string \| null` | Client IP | +| `userAgent` | `string \| null` | `User-Agent` header | +| `endpoint` | `string \| null` | Request identifier | +| `requestId` | `string \| undefined` | Trace/correlation ID | +| `metadata` | `Record \| undefined` | Arbitrary additional data | + +--- + +### `AuditLogEntry` + +| Field | Type | Description | +|-------|------|-------------| +| `id` | `string` | UUIDv7 | +| `tableName` | `string` | Modified table name | +| `recordId` | `string` | Primary key of the affected record | +| `action` | `"INSERT" \| "UPDATE" \| "DELETE" \| "SOFT_DELETE" \| "RESTORE"` | Operation type | +| `oldData` | `Record \| null` | Data before the change; `null` for INSERT | +| `newData` | `Record \| null` | Data after the change; `null` for DELETE | +| `userId` | `string \| null` | User who performed the action | +| `ip` | `string \| null` | Client IP from context | +| `userAgent` | `string \| null` | User-Agent from context | +| `endpoint` | `string \| null` | Endpoint from context | +| `requestId` | `string \| null` | Trace ID from context | +| `createdAt` | `Date` | When the change occurred | + +--- + +### `LedgerConfig` + +| Field | Type | Description | +|-------|------|-------------| +| `softDelete` | `boolean \| undefined` | Enable soft-delete functionality | +| `audit` | `boolean \| undefined` | Enable audit trail logging | +| `getContext` | `(() => LedgerContext \| null) \| undefined` | Custom context provider | +| `excludeTables` | `string[] \| undefined` | Tables to skip in audit logging | +| `logSelects` | `boolean \| undefined` | Audit SELECT queries (default: `false`) | + +--- + +### `SoftDeleteOptions` + +| Field | Type | Description | +|-------|------|-------------| +| `columnName` | `string \| undefined` | Override the `deletedAt` column name (default: `"deletedAt"`) | +| `deletedByColumn` | `string \| undefined` | Column name for the deleting user | + +--- + +### `SoftDeleteResult` + +| Field | Type | Description | +|-------|------|-------------| +| `record` | `T` | The soft-deleted record | +| `auditEntry` | `AuditLogEntry` | The audit entry created for this operation | + +--- + +### `RestoreResult` + +| Field | Type | Description | +|-------|------|-------------| +| `record` | `T` | The restored record | +| `auditEntry` | `AuditLogEntry` | The audit entry created for this operation | diff --git a/docs/audit-trail.mdx b/docs/audit-trail.mdx new file mode 100644 index 0000000..0563da3 --- /dev/null +++ b/docs/audit-trail.mdx @@ -0,0 +1,331 @@ +# Audit Trail + +`@rafters/ledger` records every INSERT, UPDATE, DELETE, SOFT_DELETE, and RESTORE against a structured audit log table. Every entry captures who did it, from where, and what changed. + +Two approaches exist. `AuditLogger` hooks into Drizzle's logger interface and captures mutations automatically. The manual functions from `@rafters/ledger/audit` let you record exactly the data you want, with full before/after snapshots. Both read request context from `AsyncLocalStorage` so you never thread `userId` and `ip` through your call stack by hand. + +--- + +## 1. Audit Log Schema + +Add the audit log table to your Drizzle schema file. Import from the dialect-specific path. + +```typescript +// schema.ts +export { auditLog } from "@rafters/ledger/schema/sqlite"; +// or: "@rafters/ledger/schema/pg" +// or: "@rafters/ledger/schema/mysql" +``` + +The `auditLog` table has the following columns: + +| Column | Type | Notes | +| ------------ | --------- | ---------------------------------------------------- | +| `id` | text (PK) | UUIDv7 | +| `tableName` | text | The modified table | +| `recordId` | text | Primary key of the affected row | +| `action` | text enum | `INSERT`, `UPDATE`, `DELETE`, `SOFT_DELETE`, `RESTORE` | +| `oldData` | text | JSON-serialized state before the change; null on INSERT | +| `newData` | text | JSON-serialized state after the change; null on DELETE | +| `userId` | text | From ledger context; null for system/anonymous | +| `ip` | text | From ledger context | +| `userAgent` | text | From ledger context | +| `endpoint` | text | From ledger context (e.g., `POST /users`) | +| `requestId` | text | From ledger context; useful for trace correlation | +| `createdAt` | timestamp | Set at insert time | + +### Custom table name + +If `audit_log` conflicts with an existing table, pass a name to `createAuditLogTable`: + +```typescript +import { createAuditLogTable } from "@rafters/ledger/schema/sqlite"; + +export const auditLog = createAuditLogTable("platform_audit_log"); +``` + +### Indexes + +`AUDIT_LOG_INDEXES` exports four index statements. Run them once in your migration after creating the table. + +```typescript +import { AUDIT_LOG_INDEXES } from "@rafters/ledger/schema/sqlite"; +// AUDIT_LOG_INDEXES is a readonly tuple of four SQL strings: +// idx_audit_log_table_record ON audit_log(table_name, record_id) +// idx_audit_log_user ON audit_log(user_id) +// idx_audit_log_created ON audit_log(created_at) +// idx_audit_log_request ON audit_log(request_id) +``` + +### Migration SQL (SQLite / D1) + +```sql +CREATE TABLE audit_log ( + id TEXT PRIMARY KEY, + table_name TEXT NOT NULL, + record_id TEXT NOT NULL, + action TEXT NOT NULL CHECK (action IN ('INSERT','UPDATE','DELETE','SOFT_DELETE','RESTORE')), + old_data TEXT, + new_data TEXT, + user_id TEXT, + ip TEXT, + user_agent TEXT, + endpoint TEXT, + request_id TEXT, + created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000) +); + +CREATE INDEX IF NOT EXISTS idx_audit_log_table_record ON audit_log(table_name, record_id); +CREATE INDEX IF NOT EXISTS idx_audit_log_user ON audit_log(user_id); +CREATE INDEX IF NOT EXISTS idx_audit_log_created ON audit_log(created_at); +CREATE INDEX IF NOT EXISTS idx_audit_log_request ON audit_log(request_id); +``` + +--- + +## 2. Automatic Logging with AuditLogger + +`AuditLogger` implements Drizzle's `Logger` interface. Pass it to `drizzle()` and every mutation query fires your `writeAuditEntry` callback. + +```typescript +import { drizzle } from "drizzle-orm/d1"; +import { uuidv7 } from "uuidv7"; +import { AuditLogger } from "@rafters/ledger/logger"; +import { auditLog } from "./schema.js"; + +// Create the logger first, pointing at a secondary db instance +// (or a queue, or any async sink) +const auditLogger = new AuditLogger( + async (entry) => { + await baseDb + .insert(auditLog) + .values({ + id: uuidv7(), + tableName: entry.tableName, + recordId: entry.recordId ?? "unknown", + action: entry.action, + oldData: null, // AuditLogger does not capture snapshots + newData: null, + userId: entry.userId, + ip: entry.ip, + userAgent: entry.userAgent, + endpoint: entry.endpoint, + requestId: entry.requestId ?? null, + createdAt: new Date(), + }) + .execute(); + }, + { + excludeTables: ["audit_log", "session", "verification"], + logSelects: false, + }, +); + +export const db = drizzle(env.DB, { logger: auditLogger }); +``` + +### Constructor + +```typescript +new AuditLogger( + writeAuditEntry: (entry: AuditEntryInput) => Promise, + config?: AuditLoggerConfig +) +``` + +### AuditEntryInput + +| Field | Type | +| ----------- | ------------------------------------------- | +| `tableName` | string | +| `recordId` | string or null (best-effort extraction) | +| `action` | `INSERT`, `UPDATE`, `DELETE`, or `SELECT` | +| `query` | string (raw SQL) | +| `params` | unknown[] | +| `userId` | string or null | +| `ip` | string or null | +| `userAgent` | string or null | +| `endpoint` | string or null | +| `requestId` | string or undefined | + +### AuditLoggerConfig + +| Option | Type | Default | Effect | +| --------------- | ---------- | ------- | ------------------------------------------------------- | +| `includeTables` | string[] | all | Audit only these tables; empty means audit everything | +| `excludeTables` | string[] | none | Never audit these tables | +| `logSelects` | boolean | false | Include SELECT queries in the audit log | + +### How it works + +`logQuery` fires synchronously after Drizzle executes each statement. The method calls `parseQuery` to extract the action and table from the SQL string, then calls `extractRecordId` to pull the first param value that matches a UUID or slug pattern. The `writeAuditEntry` callback runs fire-and-forget: errors are logged to `console.error` and never thrown, so a failing audit sink never blocks a mutation. + +Because `AuditLogger` receives only the SQL and its params, it has no access to the row state before or after the change. `recordId` is best-effort. For exact before/after snapshots, use the manual functions. + +### Context setup + +Both automatic and manual logging read from `AsyncLocalStorage`. Wire the context in Hono middleware once and every handler downstream picks it up automatically. + +```typescript +import { runWithLedgerContext, createLedgerContext } from "@rafters/ledger/context"; + +app.use(async (c, next) => { + const context = createLedgerContext({ + userId: c.get("user")?.id ?? null, + ip: c.req.header("x-forwarded-for") ?? c.req.header("x-real-ip") ?? null, + userAgent: c.req.header("user-agent") ?? null, + endpoint: `${c.req.method} ${c.req.path}`, + requestId: c.get("requestId"), + }); + return runWithLedgerContext(context, next); +}); +``` + +For background workers and cron jobs where there is no HTTP request, use `createSystemContext`: + +```typescript +import { runWithLedgerContext, createSystemContext } from "@rafters/ledger/context"; + +await runWithLedgerContext( + createSystemContext("cron:expired-sessions-cleanup"), + async () => { + await cleanupExpiredSessions(); + }, +); +``` + +--- + +## 3. Manual Logging Functions + +Import from `@rafters/ledger/audit`. Each function creates an `AuditLogEntry`, serializes `oldData` and `newData` to JSON, and inserts the row. All context fields are read from `AsyncLocalStorage` at call time. + +```typescript +import { + logInsert, + logUpdate, + logDelete, + logSoftDelete, + logRestore, +} from "@rafters/ledger/audit"; +import { auditLog } from "./schema.js"; +``` + +### logInsert + +```typescript +await logInsert(db, auditLog, tableName, recordId, newData); +``` + +Call after inserting a record. `oldData` is always null for INSERT. + +```typescript +const [user] = await db.insert(users).values(payload).returning(); +await logInsert(db, auditLog, "users", user.id, user); +``` + +### logUpdate + +```typescript +await logUpdate(db, auditLog, tableName, recordId, oldData, newData); +``` + +Fetch `oldData` before the update so the full diff is captured. + +```typescript +const [oldUser] = await db.select().from(users).where(eq(users.id, id)); +const [newUser] = await db.update(users).set(changes).where(eq(users.id, id)).returning(); +await logUpdate(db, auditLog, "users", id, oldUser, newUser); +``` + +### logDelete + +```typescript +await logDelete(db, auditLog, tableName, recordId, oldData); +``` + +Fetch `oldData` before deleting. `newData` is always null for DELETE. + +```typescript +const [user] = await db.select().from(users).where(eq(users.id, id)); +await db.delete(users).where(eq(users.id, id)); +await logDelete(db, auditLog, "users", id, user); +``` + +### logSoftDelete + +```typescript +await logSoftDelete(db, auditLog, tableName, recordId, oldData, newData); +``` + +Records both states: `oldData` has `deletedAt: null`, `newData` has `deletedAt` set. + +```typescript +const [oldUser] = await db.select().from(users).where(eq(users.id, id)); +const [newUser] = await db + .update(users) + .set(softDeleteValues(currentUserId)) + .where(eq(users.id, id)) + .returning(); +await logSoftDelete(db, auditLog, "users", id, oldUser, newUser); +``` + +### logRestore + +```typescript +await logRestore(db, auditLog, tableName, recordId, oldData, newData); +``` + +The inverse of `logSoftDelete`: `oldData` has `deletedAt` set, `newData` has it null. + +```typescript +const [oldUser] = await db.select().from(users).where(eq(users.id, id)); +const [newUser] = await db + .update(users) + .set(restoreValues()) + .where(eq(users.id, id)) + .returning(); +await logRestore(db, auditLog, "users", id, oldUser, newUser); +``` + +### All five functions return `AuditLogEntry` + +The return value is the inserted entry with its generated `id` and `createdAt`. Use it if you need the audit entry ID for a response or further logging. + +--- + +## 4. Querying History + +```typescript +import { getRecordHistory } from "@rafters/ledger/audit"; + +const history = await getRecordHistory(db, auditLog, "users", userId); +``` + +Returns `AuditLogEntry[]` sorted newest-first. The `oldData` and `newData` fields are parsed from JSON back into objects. + +```typescript +for (const entry of history) { + console.log(`${entry.action} by ${entry.userId ?? "system"} at ${entry.createdAt.toISOString()}`); + if (entry.action === "UPDATE") { + console.log("before:", entry.oldData); + console.log("after:", entry.newData); + } +} +``` + +--- + +## 5. Automatic vs Manual + +| Concern | Automatic (AuditLogger) | Manual (audit functions) | +| --------------------------- | ------------------------------------ | ------------------------------------ | +| Setup cost | One-time logger configuration | One call per mutation | +| Coverage | All queries automatically | Only what you explicitly log | +| Before/after snapshots | Not available | Full object capture | +| Record ID accuracy | Best-effort from query params | Exact | +| Action granularity | INSERT, UPDATE, DELETE, SELECT | INSERT, UPDATE, DELETE, SOFT_DELETE, RESTORE | +| Audit sink failure behavior | Fire-and-forget; never blocks query | Awaited; caller handles errors | +| Suited for | Compliance coverage with minimal code | Security-sensitive mutations, GDPR exports | + +In practice, both approaches compose. Use `AuditLogger` as a safety net that catches everything, and add manual `logUpdate` / `logDelete` calls around the mutations where you need the full diff. diff --git a/docs/better-auth.mdx b/docs/better-auth.mdx new file mode 100644 index 0000000..1213c84 --- /dev/null +++ b/docs/better-auth.mdx @@ -0,0 +1,309 @@ +# better-auth Integration + +`@rafters/ledger/better-auth` provides three pieces that wire audit logging and soft-delete behavior into [better-auth](https://better-auth.com): `ledgerPlugin` for create/update audit hooks, `createSoftDeleteCallback` for intercepting user deletion, and `createDeleteAuditCallback` for hard-delete audit trails. + +--- + +## Exports + +```typescript +import { + ledgerPlugin, + createSoftDeleteCallback, + createDeleteAuditCallback, + SoftDeletePerformedError, + isSoftDeletePerformed, +} from "@rafters/ledger/better-auth"; +``` + +All five exports come from the same subpath. Import only what you use. + +--- + +## `ledgerPlugin` + +Registers `databaseHooks` inside better-auth for the `create.after` and `update.after` lifecycle events on audited tables. Each event fires `writeAuditEntry` as fire-and-forget: errors are logged via `console.error` and never rethrow into the auth flow. + +```typescript +import { betterAuth } from "better-auth"; +import { drizzle } from "drizzle-orm/d1"; +import { uuidv7 } from "uuidv7"; +import { ledgerPlugin } from "@rafters/ledger/better-auth"; +import { auditLog } from "@rafters/ledger/schema/sqlite"; + +export function buildAuth(env: Env) { + const db = drizzle(env.DB); + + return betterAuth({ + database: drizzle(env.DB), + plugins: [ + ledgerPlugin({ + auditTables: ["user", "account"], + writeAuditEntry: async (entry) => { + await db.insert(auditLog).values({ + id: uuidv7(), + tableName: entry.tableName, + recordId: entry.recordId, + action: entry.action, + oldData: entry.oldData ? JSON.stringify(entry.oldData) : null, + newData: entry.newData ? JSON.stringify(entry.newData) : null, + userId: entry.userId, + ip: null, + userAgent: null, + endpoint: null, + requestId: null, + createdAt: new Date(), + }); + }, + }), + ], + }); +} +``` + +### Config + +| Option | Type | Default | Description | +| --- | --- | --- | --- | +| `auditTables` | `("user" \| "account" \| "session" \| "verification")[]` | `["user", "account"]` | Tables to hook for create/update events. Session and verification are excluded by default due to volume. | +| `writeAuditEntry` | `(entry: LedgerAuditEntry) => Promise` | `undefined` | Called for each auditable event. If omitted, audit logging is disabled. | +| `softDeleteTables` | `"user"[]` | `[]` | When `"user"` is included, the plugin registers a `beforeDelete` hook that logs a `SOFT_DELETE` entry. This does not perform the soft-delete; use `createSoftDeleteCallback` for that. | + +### `LedgerAuditEntry` shape + +| Field | Type | Notes | +| --- | --- | --- | +| `tableName` | `string` | The table that changed (`"user"`, `"account"`, etc.) | +| `recordId` | `string` | Primary key of the affected row | +| `action` | `"INSERT" \| "UPDATE" \| "SOFT_DELETE" \| "DELETE"` | The operation | +| `oldData` | `Record \| null` | Row state before the change; `null` for `INSERT` | +| `newData` | `Record \| null` | Row state after the change; `null` for `DELETE` or `SOFT_DELETE` | +| `userId` | `string \| null` | For `user` table events, this is the user's own ID. For `account` events, it is `null`. | + +Note: better-auth's `update.after` hook does not expose the previous row state, so `oldData` is always `null` on `UPDATE` entries from this plugin. + +--- + +## `createSoftDeleteCallback` + +Creates a `beforeDelete` handler for `user.deleteUser`. When better-auth calls this handler before deleting a user, it: + +1. Executes `UPDATE users SET deleted_at = ?, deleted_by = ? WHERE id = ?` +2. Logs a `SOFT_DELETE` audit entry (if `writeAuditEntry` is provided) +3. Throws `SoftDeletePerformedError` to abort the hard delete + +The throw is the mechanism. better-auth runs `beforeDelete`, sees the throw, and halts. The user row is never removed. + +```typescript +import { eq } from "drizzle-orm"; +import { createSoftDeleteCallback } from "@rafters/ledger/better-auth"; +import { users } from "./schema.ts"; + +const softDelete = createSoftDeleteCallback({ + db, + userTable: users, + whereUserId: (userId) => eq(users.id, userId), + writeAuditEntry: async (entry) => { + await db.insert(auditLog).values({ + id: uuidv7(), + tableName: entry.tableName, + recordId: entry.recordId, + action: entry.action, + oldData: entry.oldData ? JSON.stringify(entry.oldData) : null, + newData: entry.newData ? JSON.stringify(entry.newData) : null, + userId: entry.userId, + ip: null, + userAgent: null, + endpoint: null, + requestId: null, + createdAt: new Date(), + }); + }, +}); + +export const auth = betterAuth({ + user: { + deleteUser: { + enabled: true, + beforeDelete: softDelete, + }, + }, + // ... +}); +``` + +### Config + +| Option | Type | Required | Description | +| --- | --- | --- | --- | +| `db` | Drizzle instance | Yes | Must expose `.update()`. The same instance you use elsewhere. | +| `userTable` | Drizzle table | Yes | Must have `id` and `deletedAt` columns. If it also has `deletedBy`, that column is set automatically. | +| `whereUserId` | `(userId: string) => SQL` | Yes | Returns the WHERE clause for the update. | +| `writeAuditEntry` | `(entry: LedgerAuditEntry) => Promise` | No | Same callback shape as `ledgerPlugin`. Omit to skip audit logging on delete. | + +The `deletedBy` column is optional on the table. If it exists, the callback sets it to `null` (the user deleted their own account; there is no acting admin ID available at this point in the better-auth flow). If you need a specific `deletedBy` value, write the callback manually using `softDeleteValues` from `@rafters/ledger/soft-delete`. + +--- + +## Handling `SoftDeletePerformedError` + +The throw from `createSoftDeleteCallback` propagates out of `auth.api.deleteUser`. Callers must catch it and treat it as success. + +```typescript +import { isSoftDeletePerformed } from "@rafters/ledger/better-auth"; + +async function handleDeleteUser(userId: string) { + try { + await auth.api.deleteUser({ body: { userId } }); + } catch (error) { + if (isSoftDeletePerformed(error)) { + // The user was soft-deleted. This is the expected outcome. + return { success: true, userId }; + } + throw error; + } +} +``` + +`isSoftDeletePerformed` handles two cases: a live `SoftDeletePerformedError` instance in the same runtime, and a serialized plain object with `code: "SOFT_DELETE_PERFORMED"` and `softDeleted: true` that crossed a module boundary or was deserialized from JSON. Both return `true`. + +### `SoftDeletePerformedError` properties + +| Property | Type | Value | +| --- | --- | --- | +| `code` | `string` | `"SOFT_DELETE_PERFORMED"` | +| `softDeleted` | `boolean` | `true` | +| `userId` | `string` | The ID of the user that was soft-deleted | +| `message` | `string` | `"User soft-deleted successfully"` | +| `name` | `string` | `"SoftDeletePerformedError"` | + +--- + +## `createDeleteAuditCallback` + +For cases where you want hard delete (the user row is permanently removed) but still need an audit trail. This goes in `afterDelete`, not `beforeDelete`. It logs a `DELETE` entry and returns normally without throwing. + +```typescript +import { createDeleteAuditCallback } from "@rafters/ledger/better-auth"; + +export const auth = betterAuth({ + user: { + deleteUser: { + enabled: true, + afterDelete: createDeleteAuditCallback(async (entry) => { + await db.insert(auditLog).values({ + id: uuidv7(), + tableName: entry.tableName, + recordId: entry.recordId, + action: entry.action, + oldData: entry.oldData ? JSON.stringify(entry.oldData) : null, + newData: null, + userId: entry.userId, + ip: null, + userAgent: null, + endpoint: null, + requestId: null, + createdAt: new Date(), + }); + }), + }, + }, +}); +``` + +Errors inside `writeAuditEntry` are caught and logged; they do not propagate. The hard delete has already happened by the time `afterDelete` fires, so there is nothing left to abort. + +Do not use `createDeleteAuditCallback` and `createSoftDeleteCallback` on the same `deleteUser` config. `createSoftDeleteCallback` goes in `beforeDelete` and throws to prevent deletion. `createDeleteAuditCallback` goes in `afterDelete` and assumes deletion happened. Pick one. + +--- + +## Full Setup + +A complete `betterAuth` config with the plugin, soft-delete callback, and the user table wired correctly. + +```typescript +import { betterAuth } from "better-auth"; +import { drizzleAdapter } from "better-auth/adapters/drizzle"; +import { drizzle } from "drizzle-orm/d1"; +import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"; +import { eq } from "drizzle-orm"; +import { uuidv7 } from "uuidv7"; +import { + ledgerPlugin, + createSoftDeleteCallback, +} from "@rafters/ledger/better-auth"; +import { softDeleteColumns } from "@rafters/ledger/soft-delete/sqlite"; +import { auditLog } from "@rafters/ledger/schema/sqlite"; + +// Schema +export const users = sqliteTable("user", { + id: text("id").primaryKey(), + name: text("name").notNull(), + email: text("email").notNull().unique(), + emailVerified: integer("email_verified", { mode: "boolean" }).notNull().default(false), + image: text("image"), + createdAt: integer("created_at", { mode: "timestamp" }).notNull(), + updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(), + ...softDeleteColumns, +}); + +// Auth factory +export function buildAuth(env: Env) { + const db = drizzle(env.DB, { schema: { users, auditLog } }); + + async function writeAuditEntry(entry: Parameters[0]["writeAuditEntry"] extends (e: infer E) => unknown ? E : never) { + await db.insert(auditLog).values({ + id: uuidv7(), + tableName: entry.tableName, + recordId: entry.recordId, + action: entry.action, + oldData: entry.oldData ? JSON.stringify(entry.oldData) : null, + newData: entry.newData ? JSON.stringify(entry.newData) : null, + userId: entry.userId, + ip: null, + userAgent: null, + endpoint: null, + requestId: null, + createdAt: new Date(), + }); + } + + return betterAuth({ + database: drizzleAdapter(db, { provider: "sqlite" }), + user: { + deleteUser: { + enabled: true, + beforeDelete: createSoftDeleteCallback({ + db, + userTable: users, + whereUserId: (userId) => eq(users.id, userId), + writeAuditEntry, + }), + }, + }, + plugins: [ + ledgerPlugin({ + auditTables: ["user", "account"], + writeAuditEntry, + }), + ], + }); +} +``` + +In this setup: + +- User and account creates/updates write `INSERT` and `UPDATE` audit entries via `ledgerPlugin`. +- `auth.api.deleteUser` triggers the soft-delete callback, sets `deleted_at` on the user row, writes a `SOFT_DELETE` audit entry, and throws `SoftDeletePerformedError`. +- Callers catch that error with `isSoftDeletePerformed` and treat it as success. +- The user row is preserved. Hard delete never runs. + +--- + +## Relation to `createAuditedDb` + +`ledgerPlugin` and `createSoftDeleteCallback` are better-auth-specific. They fire from better-auth lifecycle hooks, not from Drizzle operations. + +`createAuditedDb` (from `@rafters/ledger/db`) intercepts `db.delete()` calls on your Drizzle instance and converts them to soft-delete updates automatically. These two systems are independent. `createAuditedDb` does not affect better-auth's internal delete operations, and `createSoftDeleteCallback` does not affect your application's direct Drizzle calls. + +Use both if you want full coverage: `createAuditedDb` handles application-layer deletes on any table; `createSoftDeleteCallback` handles the specific path through `auth.api.deleteUser`. diff --git a/docs/context.mdx b/docs/context.mdx new file mode 100644 index 0000000..ff84327 --- /dev/null +++ b/docs/context.mdx @@ -0,0 +1,184 @@ +# Context Propagation + +Every audit entry needs to know who triggered the operation, from where, and on which endpoint. The naive approach threads those values as function arguments through every call site. `@rafters/ledger` does it once in middleware and never again. + +The mechanism is Node.js `AsyncLocalStorage`. Set context at the request boundary, and every audit function called anywhere in that async chain reads it automatically. No prop drilling. No global mutable state. Each request gets its own isolated context. + +## LedgerContext Shape + +| Field | Type | Required | Description | +|---|---|---|---| +| `userId` | `string \| null` | yes | Authenticated user ID. `null` for anonymous or system operations. | +| `ip` | `string \| null` | yes | Client IP address. | +| `userAgent` | `string \| null` | yes | `User-Agent` header value. | +| `endpoint` | `string \| null` | yes | Route or action identifier. Conventionally `"METHOD /path"` for HTTP, `"system:source"` for background work. | +| `requestId` | `string` | no | Trace ID for correlating logs across services. | +| `metadata` | `Record` | no | Arbitrary key/value pairs attached to every audit entry in this context. | + +## Creating Context + +`createLedgerContext` is the standard factory. Every field defaults to `null` if omitted. + +```typescript +import { createLedgerContext } from "@rafters/ledger"; + +const context = createLedgerContext({ + userId: session?.user?.id ?? null, + ip: req.headers["x-forwarded-for"] ?? null, + userAgent: req.headers["user-agent"] ?? null, + endpoint: `${req.method} ${req.url}`, + requestId: crypto.randomUUID(), +}); +``` + +For operations that have no user: scheduled jobs, migrations, background workers. Use `createSystemContext`. It sets `userId` to `null` and formats `endpoint` as `"system:{source}"`. + +```typescript +import { createSystemContext } from "@rafters/ledger"; + +const context = createSystemContext("cron:cleanup", { + triggeredBy: "scheduler", + dryRun: false, +}); +// context.userId => null +// context.endpoint => "system:cron:cleanup" +// context.metadata => { triggeredBy: "scheduler", dryRun: false } +``` + +## Running with Context + +`runWithLedgerContext` wraps a callback in `AsyncLocalStorage.run`. Every async operation within that callback, at any call depth, inherits the context. + +```typescript +import { runWithLedgerContext, createLedgerContext } from "@rafters/ledger"; + +await runWithLedgerContext( + createLedgerContext({ userId: "usr_01j...", ip: "1.2.3.4", userAgent: "Mozilla/5.0", endpoint: "POST /mods" }), + async () => { + // Audit functions called here pick up the context automatically. + await createMod(db, data); + }, +); +``` + +**Hono middleware** + +```typescript +import { createMiddleware } from "hono/factory"; +import { runWithLedgerContext, createLedgerContext } from "@rafters/ledger"; + +export const ledgerMiddleware = createMiddleware(async (c, next) => { + const user = c.get("user"); + + return runWithLedgerContext( + createLedgerContext({ + userId: user?.id ?? null, + ip: c.req.header("x-forwarded-for") ?? c.req.header("x-real-ip") ?? null, + userAgent: c.req.header("user-agent") ?? null, + endpoint: `${c.req.method} ${c.req.path}`, + requestId: c.req.header("x-request-id") ?? crypto.randomUUID(), + }), + next, + ); +}); + +// Mount before route groups that write to the database. +app.use("/api/*", ledgerMiddleware); +``` + +**Express middleware** + +```typescript +import type { Request, Response, NextFunction } from "express"; +import { runWithLedgerContext, createLedgerContext } from "@rafters/ledger"; + +export function ledgerMiddleware(req: Request, _res: Response, next: NextFunction) { + runWithLedgerContext( + createLedgerContext({ + userId: req.user?.id ?? null, + ip: req.ip ?? null, + userAgent: req.headers["user-agent"] ?? null, + endpoint: `${req.method} ${req.path}`, + requestId: req.headers["x-request-id"] as string | undefined, + }), + next, + ); +} + +app.use(ledgerMiddleware); +``` + +**Standalone usage (cron job, background task)** + +```typescript +import { runWithLedgerContext, createSystemContext } from "@rafters/ledger"; + +export async function expiredSessionsJob(db: DrizzleDB) { + await runWithLedgerContext( + createSystemContext("cron:expired-sessions"), + async () => { + await deleteExpiredSessions(db); + }, + ); +} +``` + +## Reading Context + +You rarely call these directly. Audit functions call them internally before writing a log entry. + +`getLedgerContext()` returns the current `LedgerContext` or `null` if called outside any `runWithLedgerContext` scope. + +`hasLedgerContext()` returns a boolean. Use it when you need to branch on whether context is available, without pulling the full context object. + +```typescript +import { getLedgerContext, hasLedgerContext } from "@rafters/ledger"; + +// Inside any function called within runWithLedgerContext: +const context = getLedgerContext(); +// context.userId => "usr_01j..." + +// Guard before reading context in utilities shared between +// request handlers and background jobs: +if (hasLedgerContext()) { + const context = getLedgerContext()!; + logger.info("audit context active", { userId: context.userId }); +} +``` + +## How It Works + +`AsyncLocalStorage` is the Node.js primitive for async-safe implicit state. When `storage.run(context, fn)` executes, every `getStore()` call made inside `fn`, regardless of how deep the async chain goes, returns that `context`. After `fn` resolves, that store is gone. + +The storage instance in `ledger` is lazily initialized: it checks `typeof AsyncLocalStorage !== "undefined"` before constructing. This makes the library safe in environments where `AsyncLocalStorage` is a global (Cloudflare Workers, Bun, Deno) and in Node.js where it comes from `node:async_hooks` exposed as a global via `--experimental-global-customevent` or the runtime's own polyfill. If the global is absent, every context function degrades gracefully: `runWithLedgerContext` runs the callback directly, `getLedgerContext` returns `null`, `hasLedgerContext` returns `false`. Audit entries still write; they just have no context attached. + +## Nested Context + +`runWithLedgerContext` creates a new scope that shadows the outer one. When the inner scope exits, the outer context resumes. + +```typescript +import { runWithLedgerContext, createLedgerContext, createSystemContext, getLedgerContext } from "@rafters/ledger"; + +await runWithLedgerContext( + createLedgerContext({ userId: "usr_01j...", ip: "1.2.3.4", userAgent: null, endpoint: "DELETE /users/me" }), + async () => { + // Outer: userId is "usr_01j..." + await softDeleteUser(db, userId); + + // Temporarily switch to system context for cascading cleanup. + await runWithLedgerContext( + createSystemContext("cascade:user-delete", { triggeredBy: userId }), + async () => { + // Inner: userId is null, endpoint is "system:cascade:user-delete" + await archiveUserContent(db, userId); + }, + ); + + // Back to outer: userId is "usr_01j..." again + const ctx = getLedgerContext(); + // ctx?.userId => "usr_01j..." + }, +); +``` + +This pattern is useful when a user action triggers internal system work that should be attributed to the system, not the user, in the audit log. diff --git a/docs/gdpr.mdx b/docs/gdpr.mdx new file mode 100644 index 0000000..809cf8e --- /dev/null +++ b/docs/gdpr.mdx @@ -0,0 +1,239 @@ +# GDPR: Article 17 Erasure + +GDPR Article 17 requires that you delete a user's personal data on request. Audit trails require that you keep a record of every change ever made. These two requirements are in direct conflict. + +The standard resolutions are wrong. Deleting audit entries eliminates the compliance trail you need to prove the system behaved correctly. Keeping audit entries unchanged with full PII violates the erasure request. Ledger's answer is anonymization: the trail structure stays intact, the identity dissolves. Every row that references the user still exists. None of them identify the user. + +--- + +## How It Works + +`purgeUserData` processes the audit log in a single pass: + +1. Selects all entries where `userId` matches the target user OR `recordId` matches the target user. This covers both sides: actions the user performed and actions performed on the user's record. +2. Parses `oldData` and `newData` as JSON. +3. Recursively removes PII fields from both JSON objects. +4. For entries where `userId` matches the target user, replaces `userId` with `"PURGED_USER"` and sets `ip` and `userAgent` to null. +5. For entries where only `recordId` matches (a different user modified this record), the `userId`, `ip`, and `userAgent` of the acting user are untouched. +6. Writes the anonymized data back to each row. + +The audit trail remains queryable. `tableName`, `action`, `recordId`, `endpoint`, `requestId`, and `createdAt` are all preserved. You can still reconstruct what happened; you just cannot reconstruct who did it. + +--- + +## Basic Usage + +```typescript +import { purgeUserData } from '@rafters/ledger/gdpr'; +import { auditLog } from '@rafters/ledger/schema/sqlite'; + +const result = await purgeUserData(db, auditLog, userId, { + piiFields: ['email', 'name', 'phone', 'address', 'ip', 'userAgent'], +}); + +// { entriesAnonymized: 12, tablesProcessed: ['users', 'orders'] } +``` + +`purgeUserData` returns a `PurgeResult`: + +| Field | Type | Description | +| ------------------- | ---------- | ---------------------------------------------------- | +| `entriesAnonymized` | `number` | Count of audit rows updated | +| `tablesProcessed` | `string[]` | Distinct `tableName` values found across all entries | + +--- + +## Admin Preservation + +When admin-456 modifies user-123's record, that audit entry has `userId: "admin-456"` and `recordId: "user-123"`. If user-123 requests erasure, `purgeUserData` finds this entry because `recordId` matches. It strips PII fields from `oldData` and `newData`. It does not touch `userId`, `ip`, or `userAgent` because those belong to admin-456, not to the user being purged. + +**Before erasure:** + +```json +{ + "id": "01jz1a2b3c4d5e6f7g8h9i0j", + "tableName": "users", + "recordId": "user-123", + "action": "UPDATE", + "userId": "admin-456", + "ip": "10.0.0.1", + "userAgent": "Mozilla/5.0 ...", + "oldData": "{\"id\":\"user-123\",\"email\":\"alice@example.com\",\"name\":\"Alice\",\"role\":\"member\"}", + "newData": "{\"id\":\"user-123\",\"email\":\"alice@example.com\",\"name\":\"Alice\",\"role\":\"admin\"}" +} +``` + +**After `purgeUserData(db, auditLog, "user-123", ...)`:** + +```json +{ + "id": "01jz1a2b3c4d5e6f7g8h9i0j", + "tableName": "users", + "recordId": "user-123", + "action": "UPDATE", + "userId": "admin-456", + "ip": "10.0.0.1", + "userAgent": "Mozilla/5.0 ...", + "oldData": "{\"id\":\"user-123\",\"role\":\"member\"}", + "newData": "{\"id\":\"user-123\",\"role\":\"admin\"}" +} +``` + +`email` and `name` are gone. admin-456's identity is intact. The change record is still auditable. + +**Before erasure (entry created by user-123):** + +```json +{ + "id": "01jz1a2b3c4d5e6f7g8h9i0k", + "tableName": "orders", + "recordId": "order-789", + "action": "INSERT", + "userId": "user-123", + "ip": "203.0.113.42", + "userAgent": "Mozilla/5.0 ...", + "oldData": null, + "newData": "{\"id\":\"order-789\",\"userId\":\"user-123\",\"email\":\"alice@example.com\",\"total\":49.99}" +} +``` + +**After erasure:** + +```json +{ + "id": "01jz1a2b3c4d5e6f7g8h9i0k", + "tableName": "orders", + "recordId": "order-789", + "action": "INSERT", + "userId": "PURGED_USER", + "ip": null, + "userAgent": null, + "oldData": null, + "newData": "{\"id\":\"order-789\",\"total\":49.99}" +} +``` + +`userId`, `ip`, `userAgent`, and `email` are cleared. The order record and the action type are preserved. + +--- + +## PII Fields + +The default field list covers the most common cases: + +``` +email, name, firstName, lastName, phone, address, ip, ipAddress, userAgent +``` + +Pass `piiFields` to override for your data model. The list replaces the default; it does not extend it. + +```typescript +const result = await purgeUserData(db, auditLog, userId, { + piiFields: [ + 'email', + 'name', + 'phone', + 'address', + 'ip', + 'userAgent', + 'taxId', + 'dateOfBirth', + 'billingAddress', + ], +}); +``` + +Field matching is by exact key name. Nested objects are processed recursively, so `{ "contact": { "email": "..." } }` is handled correctly without listing `contact.email` separately. + +If you need the stripping function standalone (for anonymizing data outside the audit log, for example), `anonymizeJsonData` is exported directly: + +```typescript +import { anonymizeJsonData } from '@rafters/ledger/gdpr'; + +const raw = { id: 'rec-123', email: 'alice@example.com', name: 'Alice', role: 'admin' }; +const clean = anonymizeJsonData(raw, ['email', 'name']); +// { id: 'rec-123', role: 'admin' } +``` + +`anonymizeJsonData` returns null if given null. It handles nested objects and arrays of objects without additional configuration. + +--- + +## Idempotency + +`purgeUserData` is safe to call multiple times. After the first call, no entries with `userId` matching the target exist. Subsequent calls find zero matching rows and return `{ entriesAnonymized: 0, tablesProcessed: [] }`. + +If your erasure workflow runs in a queue or retry loop, check before acting to avoid unnecessary work: + +```typescript +import { isUserDataPurged, purgeUserData } from '@rafters/ledger/gdpr'; +import { auditLog } from '@rafters/ledger/schema/sqlite'; + +async function handleErasureRequest(db: DrizzleDb, userId: string) { + const alreadyPurged = await isUserDataPurged(db, auditLog, userId); + + if (alreadyPurged) { + return { skipped: true }; + } + + const result = await purgeUserData(db, auditLog, userId, { + piiFields: ['email', 'name', 'phone', 'address', 'ip', 'userAgent'], + }); + + return { skipped: false, ...result }; +} +``` + +`isUserDataPurged` checks whether any entries still exist with `userId` matching the target. It returns `true` when none are found. Note that this checks `userId` only, not `recordId`: entries where another user acted on the target user's record are expected to remain and do not affect this result. + +--- + +## Custom Anonymized User ID + +The default replacement for `userId` is `"PURGED_USER"`. If your system needs a different sentinel value (for example, to satisfy a downstream invariant or to distinguish between system-level purges and self-requested deletions), set `anonymizedUserId` in the config: + +```typescript +const result = await purgeUserData(db, auditLog, userId, { + anonymizedUserId: 'GDPR_ERASED', + piiFields: ['email', 'name', 'phone', 'address', 'ip', 'userAgent'], +}); +``` + +The replacement is written verbatim to the `userId` column for every entry where the acting user matches the purge target. + +--- + +## API Reference + +### `purgeUserData(db, auditTable, userId, config?)` + +Anonymizes all audit log entries for a user. Returns a `PurgeResult`. + +| Parameter | Type | Description | +| ------------- | -------------- | ---------------------------------------- | +| `db` | `DrizzleDb` | Drizzle database instance | +| `auditTable` | `AuditLog` | The audit log table object | +| `userId` | `string` | User ID to purge | +| `config` | `PurgeConfig?` | Optional configuration | + +### `isUserDataPurged(db, auditTable, userId)` + +Returns `true` if no audit entries exist with this `userId`. Use for idempotency checks. + +### `anonymizeJsonData(data, piiFields)` + +Strips named fields from a JSON object recursively. Returns null if given null. + +### `PurgeConfig` + +| Field | Type | Default | Description | +| ------------------- | ---------- | --------------- | ---------------------------------------------------- | +| `piiFields` | `string[]` | See default list | Field names to remove from `oldData` and `newData` | +| `anonymizedUserId` | `string` | `"PURGED_USER"` | Replacement value written to the `userId` column | + +### `PurgeResult` + +| Field | Type | Description | +| ------------------- | ---------- | ---------------------------------------------------- | +| `entriesAnonymized` | `number` | Number of audit rows updated | +| `tablesProcessed` | `string[]` | Distinct table names found across all matched entries | diff --git a/docs/getting-started.mdx b/docs/getting-started.mdx new file mode 100644 index 0000000..fd0d336 --- /dev/null +++ b/docs/getting-started.mdx @@ -0,0 +1,204 @@ +# Getting Started with @rafters/ledger + +Drizzle Ledger adds soft-delete, audit trail, and GDPR support to any Drizzle ORM project. It works against the standard Drizzle query builder; there is no schema migration runner or ORM replacement involved. + +This guide walks through the five steps to get a fully working setup: choosing your dialect, adding soft-delete columns, creating the audit log table, wiring context through your middleware, and wrapping the database instance. + +--- + +## 1. Pick Your Dialect + +Everything is dialect-specific. Import from the subpath that matches your database. + +| Database | Soft-delete columns | Audit log schema | +| --------------------- | -------------------------------------------- | ----------------------------------------- | +| SQLite / Cloudflare D1 | `@rafters/ledger/soft-delete/sqlite` | `@rafters/ledger/schema/sqlite` | +| PostgreSQL | `@rafters/ledger/soft-delete/pg` | `@rafters/ledger/schema/pg` | +| MySQL | `@rafters/ledger/soft-delete/mysql` | `@rafters/ledger/schema/mysql` | + +All three dialects export the same surface: `softDeleteColumns`, `softDeleteTimestamp`, `auditLog`, and `createAuditLogTable`. The column types differ; the API does not. + +--- + +## 2. Add Soft-Delete Columns to Your Tables + +Spread `softDeleteColumns` into any table definition that should support soft-delete. The spread adds `deletedAt` (timestamp) and `deletedBy` (user ID string) in one line. + +```typescript +import { sqliteTable, text } from "drizzle-orm/sqlite-core"; +import { softDeleteColumns } from "@rafters/ledger/soft-delete/sqlite"; + +export const users = sqliteTable("users", { + id: text("id").primaryKey(), + email: text("email").notNull(), + name: text("name").notNull(), + ...softDeleteColumns, +}); +``` + +The resulting table has two additional columns: `deleted_at` (integer, timestamp_ms) and `deleted_by` (text). Both are nullable; a null `deleted_at` means the record is live. + +If you do not need to track who deleted a record, use `softDeleteTimestamp` instead. It adds only `deletedAt`. + +```typescript +import { softDeleteTimestamp } from "@rafters/ledger/soft-delete/sqlite"; + +export const events = sqliteTable("events", { + id: text("id").primaryKey(), + name: text("name").notNull(), + ...softDeleteTimestamp, +}); +``` + +--- + +## 3. Add the Audit Log Table + +Import the `auditLog` table from the schema subpath and include it in your Drizzle schema exports. + +```typescript +// src/db/schema.ts +export { auditLog } from "@rafters/ledger/schema/sqlite"; +export { users } from "./users.ts"; +export { sessions } from "./sessions.ts"; +``` + +If you need a custom table name (for example, to avoid conflicts with an existing `audit_log` table), use `createAuditLogTable`. + +```typescript +import { createAuditLogTable } from "@rafters/ledger/schema/sqlite"; + +export const activityLog = createAuditLogTable("activity_log"); +``` + +The audit log table structure is the same regardless of table name: + +| Column | Type | Description | +| ------------ | ------------ | ---------------------------------------------------- | +| `id` | text PK | UUIDv7 | +| `table_name` | text | The table that was modified | +| `record_id` | text | Primary key of the affected row | +| `action` | text enum | `INSERT`, `UPDATE`, `DELETE`, `SOFT_DELETE`, `RESTORE` | +| `old_data` | text (JSON) | Row state before the change; null for INSERT | +| `new_data` | text (JSON) | Row state after the change; null for DELETE | +| `user_id` | text | Who made the change; null for system operations | +| `ip` | text | Request IP address | +| `user_agent` | text | Request user agent | +| `endpoint` | text | API endpoint or action identifier | +| `request_id` | text | Trace ID for correlating entries within a request | +| `created_at` | timestamp | When the change occurred | + +Create the table migration with your normal migration workflow. For Cloudflare D1 with Wrangler: + +```bash +wrangler d1 migrations create add-audit-log +# Write the CREATE TABLE statement into the generated file, then: +wrangler d1 migrations apply +``` + +The `AUDIT_LOG_INDEXES` export contains the four recommended index statements. Add them to the migration or run them separately. + +```typescript +import { AUDIT_LOG_INDEXES } from "@rafters/ledger/schema/sqlite"; +// CREATE INDEX IF NOT EXISTS idx_audit_log_table_record ON audit_log(table_name, record_id) +// CREATE INDEX IF NOT EXISTS idx_audit_log_user ON audit_log(user_id) +// CREATE INDEX IF NOT EXISTS idx_audit_log_created ON audit_log(created_at) +// CREATE INDEX IF NOT EXISTS idx_audit_log_request ON audit_log(request_id) +``` + +--- + +## 4. Wire Context in Middleware + +Ledger uses `AsyncLocalStorage` to carry audit context through the async call stack. You set the context once in middleware; every nested database call in that request has access to it without explicit parameter passing. + +```typescript +import { Hono } from "hono"; +import { + createLedgerContext, + runWithLedgerContext, +} from "@rafters/ledger/context"; + +const app = new Hono(); + +app.use(async (c, next) => { + const context = createLedgerContext({ + userId: c.get("user")?.id ?? null, + ip: c.req.header("x-forwarded-for") ?? c.req.header("x-real-ip") ?? null, + userAgent: c.req.header("user-agent") ?? null, + endpoint: `${c.req.method} ${c.req.path}`, + requestId: c.get("requestId"), + }); + + return runWithLedgerContext(context, next); +}); +``` + +`runWithLedgerContext` wraps the entire downstream handler. Any code that runs inside `next()`, including handlers two or three levels deep, reads the same context via `getLedgerContext()`. The context is scoped to the current async execution tree; concurrent requests do not interfere. + +For cron jobs, background workers, and other operations that have no request context, use `createSystemContext`. The `endpoint` field is set to `system:` and `userId` is null. + +```typescript +import { + createSystemContext, + runWithLedgerContext, +} from "@rafters/ledger/context"; + +// In a Cloudflare Workers scheduled handler +export default { + async scheduled(_event: ScheduledEvent, env: Env) { + await runWithLedgerContext( + createSystemContext("cron:session-cleanup"), + async () => { + await cleanupExpiredSessions(env); + } + ); + }, +}; +``` + +--- + +## 5. Wrap Your Database + +`createAuditedDb` monkeypatches the `delete()` method on a Drizzle instance. For any table that has a `deletedAt` column, `db.delete(table).where(condition)` becomes an `UPDATE` that sets `deleted_at` and `deleted_by`. For tables without `deletedAt`, delete passes through unchanged. + +```typescript +import { drizzle } from "drizzle-orm/d1"; +import { createAuditedDb } from "@rafters/ledger/db"; +import * as schema from "./schema.ts"; + +export function buildDb(d1: D1Database) { + const base = drizzle(d1, { schema }); + return createAuditedDb(base, { + hardDeleteTables: ["session", "verification"], + }); +} +``` + +`hardDeleteTables` accepts table names (the SQL table name, not the Drizzle variable name). Any table in this list always hard-deletes regardless of whether it has a `deletedAt` column. Session and verification tables are the common case: they have no meaningful "deleted" state and should be purged immediately. + +With this in place, the following code soft-deletes the user row: + +```typescript +import { eq } from "drizzle-orm"; +import { db } from "./db.ts"; +import { users } from "./schema.ts"; + +// Executes: UPDATE users SET deleted_at = ?, deleted_by = ? WHERE id = ? +await db.delete(users).where(eq(users.id, userId)); +``` + +The `deleted_by` value comes from the ledger context automatically. No changes to call sites are required. + +--- + +## From Here + +The full behavior of each subsystem is documented separately. + +- [soft-delete.md](./soft-delete.md): Querying live records, restoring soft-deleted rows, and handling the `deletedAt` filter. +- [audit-trail.md](./audit-trail.md): Writing audit entries manually, querying history for a record, and correlating entries by request ID. +- [context.md](./context.md): The full `LedgerContext` shape, custom context factories, and using `getLedgerContext()` in handlers. +- [gdpr.md](./gdpr.md): Anonymizing and erasing user data across multiple tables in a single operation. +- [better-auth.md](./better-auth.md): The `ledgerPlugin` for better-auth that writes audit entries on sign-in, sign-out, and credential changes. diff --git a/docs/index.mdx b/docs/index.mdx new file mode 100644 index 0000000..75708c3 --- /dev/null +++ b/docs/index.mdx @@ -0,0 +1,150 @@ +# @rafters/ledger + +Soft-delete, audit trail, and GDPR compliance for [Drizzle ORM](https://orm.drizzle.team/). SQLite, PostgreSQL, MySQL. + +```bash +pnpm add @rafters/ledger +``` + +Peer dependency: `drizzle-orm >= 0.30.0` + +--- + +## What You Get + +| Feature | What it does | +|---|---| +| Soft-delete columns | `...softDeleteColumns` adds `deleted_at` and `deleted_by` to any table | +| Automatic soft-delete | `createAuditedDb` rewrites `db.delete()` to an UPDATE for tables with `deleted_at` | +| Audit trail | `auditLog` schema + context middleware captures who changed what, when, from where | +| GDPR purge | `purgeUserData` anonymizes PII in audit entries; leaves the trail intact | +| Better Auth plugin | Hooks into `databaseHooks` to log user/account mutations and intercept hard deletes | + +--- + +## Quick Start + +This example uses SQLite / Cloudflare D1. Postgres and MySQL use different import paths for the dialect-specific pieces; the API is identical. + +### 1. Schema + +```typescript +// src/db/schema.ts +import { sqliteTable, text } from 'drizzle-orm/sqlite-core'; +import { softDeleteColumns } from '@rafters/ledger/soft-delete/sqlite'; +import { auditLog } from '@rafters/ledger/schema/sqlite'; + +export const users = sqliteTable('users', { + id: text('id').primaryKey(), + name: text('name').notNull(), + email: text('email').notNull().unique(), + ...softDeleteColumns, +}); + +export { auditLog }; +``` + +`softDeleteColumns` adds `deleted_at` (integer timestamp) and `deleted_by` (text) to the table. `auditLog` is a pre-built Drizzle table definition; add it to your migration. + +### 2. Context middleware + +```typescript +// src/middleware/ledger.ts +import { createLedgerContext, runWithLedgerContext } from '@rafters/ledger/context'; + +// Hono example; adapt to your framework +app.use(async (c, next) => { + const context = createLedgerContext({ + userId: c.get('user')?.id, + ip: c.req.header('x-forwarded-for'), + userAgent: c.req.header('user-agent'), + endpoint: `${c.req.method} ${c.req.path}`, + }); + return runWithLedgerContext(context, next); +}); +``` + +`AsyncLocalStorage` propagates this context to every audit entry written downstream in the same request, across any number of service calls. Set it once; forget about it. + +### 3. Audited database instance + +```typescript +// src/db/index.ts +import { drizzle } from 'drizzle-orm/d1'; +import { createAuditedDb } from '@rafters/ledger/db'; +import * as schema from './schema'; + +export function createDb(d1: D1Database) { + const base = drizzle(d1, { schema }); + + return createAuditedDb(base, { + auditLogTable: schema.auditLog, + writeAuditEntry: async (entry) => { + await base.insert(schema.auditLog).values({ ...entry, id: crypto.randomUUID() }); + }, + hardDeleteTables: ['session', 'verification'], + }); +} +``` + +`db.delete(users).where(eq(users.id, id))` now executes a soft-delete UPDATE because `users` has `deleted_at`. Tables in `hardDeleteTables` pass through to real deletes. + +### 4. Querying + +```typescript +import { notDeleted } from '@rafters/ledger/soft-delete'; + +// Only active users +const activeUsers = await db + .select() + .from(users) + .where(notDeleted(users)); + +// Soft-delete a user (runs as UPDATE under the hood) +await db.delete(users).where(eq(users.id, userId)); +``` + +### 5. GDPR purge + +```typescript +import { purgeUserData } from '@rafters/ledger/gdpr'; + +const result = await purgeUserData(db, auditLog, userId, { + piiFields: ['email', 'name', 'phone', 'ip', 'userAgent'], +}); +// { entriesAnonymized: 47, tablesProcessed: ['users', 'accounts'] } +``` + +Strips the specified fields from `oldData` and `newData` JSON columns in every audit entry tied to that user. The audit trail structure stays intact; the personal data does not. + +--- + +## Dialect Support + +| Dialect | Soft-delete columns | Audit log schema | +|---|---|---| +| SQLite / D1 | `@rafters/ledger/soft-delete/sqlite` | `@rafters/ledger/schema/sqlite` | +| PostgreSQL | `@rafters/ledger/soft-delete/pg` | `@rafters/ledger/schema/pg` | +| MySQL | `@rafters/ledger/soft-delete/mysql` | `@rafters/ledger/schema/mysql` | + +Dialect-agnostic helpers (`notDeleted`, `onlyDeleted`, `softDeleteValues`, `restoreValues`, `isSoftDeleted`) import from `@rafters/ledger/soft-delete`. + +--- + +## Docs + +Full documentation lives in [docs/](./docs/): + +| File | Covers | +|---|---| +| `soft-delete.md` | Column helpers, query filters, restore, unique constraint considerations | +| `audit.md` | Manual logging, AuditLogger, context propagation, SOC 2 notes | +| `gdpr.md` | `purgeUserData`, `isUserDataPurged`, what it handles and what it does not | +| `better-auth.md` | `ledgerPlugin`, `createSoftDeleteCallback`, flow control pattern | +| `db.md` | `createAuditedDb` config, `hardDeleteTables`, custom soft-delete values | + +--- + +## License + +MIT. Authored by Sean Silvius. Source: [github.com/rafters-studio/ledger](https://github.com/rafters-studio/ledger). diff --git a/docs/soft-delete.mdx b/docs/soft-delete.mdx new file mode 100644 index 0000000..24f2ab2 --- /dev/null +++ b/docs/soft-delete.mdx @@ -0,0 +1,310 @@ +# Soft Delete + +`@rafters/ledger` implements soft-delete as a first-class pattern: columns that record when and by whom a record was removed, query filters that enforce visibility, and an optional database wrapper that intercepts `delete()` calls automatically. + +--- + +## Column Definitions + +Soft-delete columns are dialect-specific. Import from the subpath that matches your database. + +```typescript +// SQLite / Cloudflare D1 +import { softDeleteColumns, softDeleteTimestamp } from '@rafters/ledger/soft-delete/sqlite'; + +// PostgreSQL +import { softDeleteColumns, softDeleteTimestamp } from '@rafters/ledger/soft-delete/pg'; + +// MySQL +import { softDeleteColumns, softDeleteTimestamp } from '@rafters/ledger/soft-delete/mysql'; +``` + +`softDeleteColumns` adds both `deletedAt` and `deletedBy`. `softDeleteTimestamp` adds `deletedAt` only, for tables where attribution is not needed. + +```typescript +import { sqliteTable, text } from 'drizzle-orm/sqlite-core'; +import { softDeleteColumns } from '@rafters/ledger/soft-delete/sqlite'; + +export const users = sqliteTable('users', { + id: text('id').primaryKey(), + name: text('name'), + ...softDeleteColumns, +}); +``` + +```typescript +import { sqliteTable, text } from 'drizzle-orm/sqlite-core'; +import { softDeleteTimestamp } from '@rafters/ledger/soft-delete/sqlite'; + +export const events = sqliteTable('events', { + id: text('id').primaryKey(), + type: text('type'), + ...softDeleteTimestamp, +}); +``` + +### Dialect column types + +| Dialect | `deletedAt` column type | `deletedBy` column type | +| ------- | ----------------------- | ----------------------- | +| SQLite / D1 | `integer` with `mode: "timestamp_ms"` | `text` | +| PostgreSQL | `timestamp` with timezone | `text` | +| MySQL | `timestamp` | `varchar(255)` | + +SQLite stores the timestamp as an integer millisecond value. Drizzle reads it back as a `Date`. PostgreSQL uses `timestamptz` to preserve timezone information across regions. All three dialects store `deletedBy` as a plain string, matching UUIDv7 user identifiers. + +--- + +## Query Filters + +Three filters cover every visibility case. Import from `@rafters/ledger/soft-delete`. + +```typescript +import { + notDeleted, + onlyDeleted, + includingDeleted, +} from '@rafters/ledger/soft-delete'; +``` + +### `notDeleted(table)` + +Produces `WHERE deleted_at IS NULL`. Use this in every query that should exclude removed records. + +```typescript +const activeUsers = await db + .select() + .from(users) + .where(notDeleted(users)); +``` + +This is the filter you will use most. If a query touches a soft-deletable table and does not call `notDeleted`, it will return deleted records. Make it a habit. + +### `onlyDeleted(table)` + +Produces `WHERE deleted_at IS NOT NULL`. Use this for trash views or admin recovery interfaces. + +```typescript +const deletedUsers = await db + .select() + .from(users) + .where(onlyDeleted(users)); +``` + +### `includingDeleted()` + +Produces `1=1`. Explicitly returns all records regardless of deletion state. Useful when you want to be explicit that deleted records are intentionally included. + +```typescript +const allUsers = await db + .select() + .from(users) + .where(includingDeleted()); +``` + +`includingDeleted` takes no arguments. It is not table-aware; it just returns a SQL fragment that never excludes any row. + +--- + +## Manual Soft-Delete + +For direct control over update queries, three functions handle the value construction and record inspection. + +```typescript +import { + softDeleteValues, + restoreValues, + isSoftDeleted, +} from '@rafters/ledger/soft-delete'; +``` + +### `softDeleteValues(deletedBy?)` + +Returns `{ deletedAt: Date, deletedBy: string | null }` set to the current timestamp. Pass a user ID string to record attribution; omit it or pass `null` when the deletion is system-initiated. + +```typescript +await db + .update(users) + .set(softDeleteValues(currentUserId)) + .where(eq(users.id, userId)); +``` + +```typescript +// System-initiated deletion, no attribution +await db + .update(users) + .set(softDeleteValues()) + .where(eq(users.organizationId, orgId)); +``` + +### `restoreValues()` + +Returns `{ deletedAt: null, deletedBy: null }`. Use in update queries to reverse a soft-delete. + +```typescript +await db + .update(users) + .set(restoreValues()) + .where(eq(users.id, userId)); +``` + +After a restore, `notDeleted(users)` will include the record again. + +### `isSoftDeleted(record)` + +Runtime check on a record object. Returns `true` if `deletedAt` is non-null. + +```typescript +const user = await db.select().from(users).where(eq(users.id, userId)).get(); + +if (isSoftDeleted(user)) { + throw new Error('User account has been deactivated.'); +} +``` + +`isSoftDeleted` accepts `null` and `undefined` safely; both return `false`. + +--- + +## Automatic Soft-Delete with `createAuditedDb` + +`createAuditedDb` wraps a Drizzle database instance and intercepts `db.delete()` calls. The call site does not change: `db.delete(users).where(...)` looks identical whether or not the table has a `deletedAt` column. The behavior changes based on table schema. + +```typescript +import { drizzle } from 'drizzle-orm/d1'; +import { createAuditedDb } from '@rafters/ledger/db'; + +const baseDb = drizzle(env.DB); +export const db = createAuditedDb(baseDb); +``` + +### How the interception works + +When `db.delete(table)` is called: + +1. The wrapper checks if the table has a `deletedAt` column using `hasColumn`. +2. If yes: the delete becomes `db.update(table).set({ deletedAt: ..., deletedBy: ... }).where(condition)`. +3. If no: the original `db.delete` executes unchanged. + +The `deletedBy` value comes from the active ledger context automatically. If `runWithLedgerContext` is set up in middleware with the current user ID, the wrapper reads it without any additional wiring at the call site. + +```typescript +// This is the only call site you write: +await db.delete(users).where(eq(users.id, userId)); + +// With createAuditedDb, the above executes as: +// UPDATE users SET deleted_at = ?, deleted_by = ? WHERE id = ? +// with deleted_by pulled from the active ledger context +``` + +### Tables without `deletedAt` + +Tables that do not include soft-delete columns receive a genuine hard delete. No configuration needed. + +```typescript +const logs = sqliteTable('logs', { + id: text('id').primaryKey(), + message: text('message'), + // no deletedAt column +}); + +// This hard-deletes, unaffected by the wrapper: +await db.delete(logs).where(eq(logs.id, logId)); +``` + +### `hardDeleteTables` config + +Some tables should always hard-delete even if they have a `deletedAt` column. Session and verification tables are the common case: you want those rows gone, not flagged. + +```typescript +export const db = createAuditedDb(baseDb, { + hardDeleteTables: ['session', 'verification'], +}); +``` + +Pass the SQL table name string, not the Drizzle table variable name. The wrapper reads the table name from the Drizzle internal symbol `drizzle:Name`. + +### Full setup with context middleware + +```typescript +import { drizzle } from 'drizzle-orm/d1'; +import { createAuditedDb } from '@rafters/ledger/db'; +import { + runWithLedgerContext, + createLedgerContext, +} from '@rafters/ledger/context'; + +const baseDb = drizzle(env.DB); +export const db = createAuditedDb(baseDb, { + hardDeleteTables: ['session', 'verification'], +}); + +// In Hono middleware: +app.use(async (c, next) => { + const context = createLedgerContext({ + userId: c.get('user')?.id ?? null, + ip: c.req.header('x-forwarded-for') ?? null, + userAgent: c.req.header('user-agent') ?? null, + endpoint: `${c.req.method} ${c.req.path}`, + }); + return runWithLedgerContext(context, next); +}); +``` + +Any `db.delete(users)` call inside a request handler now reads the user ID from the async context automatically. No plumbing through function parameters. + +### `returning()` and `execute()` + +The soft-delete path returns a query chain that supports both `.returning()` and `.execute()`. + +```typescript +const [deleted] = await db + .delete(users) + .where(eq(users.id, userId)) + .returning(); + +// deleted.deletedAt is populated +``` + +--- + +## Type Helpers + +Two utility types add soft-delete fields to an existing record type. + +```typescript +import type { + WithSoftDelete, + WithSoftDeleteTimestamp, +} from '@rafters/ledger/soft-delete'; +``` + +### `WithSoftDelete` + +Adds `deletedAt: Date | null` and `deletedBy: string | null` to `T`. + +```typescript +type User = { + id: string; + name: string; +}; + +type UserWithSoftDelete = WithSoftDelete; +// { id: string; name: string; deletedAt: Date | null; deletedBy: string | null } +``` + +### `WithSoftDeleteTimestamp` + +Adds `deletedAt: Date | null` only. + +```typescript +type Event = { + id: string; + type: string; +}; + +type EventWithTimestamp = WithSoftDeleteTimestamp; +// { id: string; type: string; deletedAt: Date | null } +``` + +These types are useful when constructing function signatures or response types that need to express soft-delete state without importing the full Drizzle column definitions. diff --git a/package.json b/package.json index dbfdfdc..a6f3a2e 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { - "name": "@ezmode-games/drizzle-ledger", + "name": "@rafters/ledger", "version": "0.0.1", - "description": "Soft-delete, audit trail, and GDPR compliance for Drizzle ORM", + "description": "Soft-delete, audit trail, and GDPR compliance for any ORM", "type": "module", "exports": { ".": { @@ -178,6 +178,7 @@ "keywords": [ "drizzle", "orm", + "orm-agnostic", "soft-delete", "audit-trail", "audit-log", @@ -187,15 +188,15 @@ "postgres", "mysql" ], - "author": "ezmode.games", + "author": "Sean Silvius", "license": "MIT", "repository": { "type": "git", - "url": "https://github.com/ezmode-games/drizzle-ledger" + "url": "https://github.com/rafters-studio/ledger" }, - "homepage": "https://github.com/ezmode-games/drizzle-ledger#readme", + "homepage": "https://github.com/rafters-studio/ledger#readme", "bugs": { - "url": "https://github.com/ezmode-games/drizzle-ledger/issues" + "url": "https://github.com/rafters-studio/ledger/issues" }, "packageManager": "pnpm@10.28.0", "engines": { diff --git a/src/better-auth.ts b/src/better-auth.ts index 13a36a2..9fd5a97 100644 --- a/src/better-auth.ts +++ b/src/better-auth.ts @@ -9,7 +9,7 @@ * @example * ```typescript * import { betterAuth } from 'better-auth'; - * import { ledgerPlugin, createSoftDeleteCallback } from '@ezmode-games/drizzle-ledger/better-auth-plugin'; + * import { ledgerPlugin, createSoftDeleteCallback } from '@rafters/ledger/better-auth-plugin'; * import { eq } from 'drizzle-orm'; * * export const auth = betterAuth({ @@ -84,7 +84,7 @@ export interface LedgerPluginConfig { // Helper to safely log errors without blocking auth operations function safeLog(message: string, error?: unknown): void { // eslint-disable-next-line no-console - console.error(`[drizzle-ledger] ${message}`, error ?? ""); + console.error(`[ledger] ${message}`, error ?? ""); } // Type for better-auth user with id @@ -106,7 +106,7 @@ type UserWithId = { id: string } & Record; * * @example * ```typescript - * import { ledgerPlugin } from '@ezmode-games/drizzle-ledger/better-auth-plugin'; + * import { ledgerPlugin } from '@rafters/ledger/better-auth-plugin'; * * export const auth = betterAuth({ * plugins: [ @@ -195,7 +195,7 @@ export function ledgerPlugin(config?: LedgerPluginConfig): BetterAuthPlugin { : undefined; return { - id: "drizzle-ledger", + id: "ledger", init: () => { return { options: { @@ -256,7 +256,7 @@ export interface SoftDeleteCallbackOptions { * * @example * ```typescript - * import { createSoftDeleteCallback } from '@ezmode-games/drizzle-ledger/better-auth-plugin'; + * import { createSoftDeleteCallback } from '@rafters/ledger/better-auth-plugin'; * import { eq } from 'drizzle-orm'; * * export const auth = betterAuth({ @@ -327,7 +327,7 @@ export function createSoftDeleteCallback( * * @example * ```typescript - * import { createDeleteAuditCallback } from '@ezmode-games/drizzle-ledger/better-auth-plugin'; + * import { createDeleteAuditCallback } from '@rafters/ledger/better-auth-plugin'; * * export const auth = betterAuth({ * user: { diff --git a/src/db.ts b/src/db.ts index ae256ec..077cb25 100644 --- a/src/db.ts +++ b/src/db.ts @@ -96,7 +96,7 @@ interface WithDeleteAndUpdate { * @example * ```typescript * import { drizzle } from 'drizzle-orm/d1'; - * import { createAuditedDb } from '@ezmode-games/drizzle-ledger/db'; + * import { createAuditedDb } from '@rafters/ledger/db'; * * const baseDb = drizzle(env.DB); * export const db = createAuditedDb(baseDb); diff --git a/src/gdpr.ts b/src/gdpr.ts index 30f1f38..f441c33 100644 --- a/src/gdpr.ts +++ b/src/gdpr.ts @@ -6,7 +6,7 @@ * * @example * ```typescript - * import { purgeUserData } from '@ezmode-games/drizzle-ledger/gdpr'; + * import { purgeUserData } from '@rafters/ledger/gdpr'; * * // Anonymize all user data in audit logs * const result = await purgeUserData(db, auditLog, 'user-123', { @@ -145,8 +145,8 @@ type DrizzleDb = { update: (table: any) => any; select: () => any }; * * @example * ```typescript - * import { purgeUserData } from '@ezmode-games/drizzle-ledger/gdpr'; - * import { auditLog } from '@ezmode-games/drizzle-ledger'; + * import { purgeUserData } from '@rafters/ledger/gdpr'; + * import { auditLog } from '@rafters/ledger'; * * const result = await purgeUserData(db, auditLog, 'user-123', { * piiFields: ['email', 'name', 'ip', 'address', 'phone'], diff --git a/src/index.ts b/src/index.ts index ec06343..1807aa6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,7 +8,7 @@ * @example * ```typescript * // 1. Add soft-delete columns to your tables - * import { softDeleteColumns } from '@ezmode-games/drizzle-ledger/soft-delete/sqlite'; + * import { softDeleteColumns } from '@rafters/ledger/soft-delete/sqlite'; * * export const users = sqliteTable('users', { * id: text('id').primaryKey(), @@ -17,10 +17,10 @@ * }); * * // 2. Add the audit log table to your schema - * export { auditLog } from '@ezmode-games/drizzle-ledger/schema/sqlite'; + * export { auditLog } from '@rafters/ledger/schema/sqlite'; * * // 3. Set up context in your middleware - * import { runWithLedgerContext, createLedgerContext } from '@ezmode-games/drizzle-ledger/context'; + * import { runWithLedgerContext, createLedgerContext } from '@rafters/ledger/context'; * * app.use(async (c, next) => { * const context = createLedgerContext({ @@ -33,7 +33,7 @@ * }); * * // 4. Use soft-delete in queries - * import { notDeleted, softDeleteValues } from '@ezmode-games/drizzle-ledger/soft-delete'; + * import { notDeleted, softDeleteValues } from '@rafters/ledger/soft-delete'; * * const activeUsers = await db.select().from(users).where(notDeleted(users)); * ``` diff --git a/src/logger.ts b/src/logger.ts index 38c75dd..b7d7609 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -163,7 +163,7 @@ export class AuditLogger implements Logger { requestId: context?.requestId, }).catch((err) => { // Log error but don't throw - audit failures shouldn't break the app - console.error("[drizzle-ledger] Failed to write audit entry:", err); + console.error("[ledger] Failed to write audit entry:", err); }); } } diff --git a/src/schema/index.ts b/src/schema/index.ts index ca25bd8..36a48fa 100644 --- a/src/schema/index.ts +++ b/src/schema/index.ts @@ -3,9 +3,9 @@ * * Convenience re-exports from the SQLite schema (default dialect). * For dialect-specific schemas, import directly: - * - drizzle-ledger/schema/sqlite - * - drizzle-ledger/schema/pg - * - drizzle-ledger/schema/mysql + * - ledger/schema/sqlite + * - ledger/schema/pg + * - ledger/schema/mysql */ export type { AuditLog, AuditLogInsert, AuditLogSelect } from "./sqlite.js"; diff --git a/src/schema/mysql.ts b/src/schema/mysql.ts index 0399526..2ae2fdc 100644 --- a/src/schema/mysql.ts +++ b/src/schema/mysql.ts @@ -5,7 +5,7 @@ * * @example * ```typescript - * export { auditLog } from '@ezmode-games/drizzle-ledger/schema/mysql'; + * export { auditLog } from '@rafters/ledger/schema/mysql'; * ``` */ diff --git a/src/schema/pg.ts b/src/schema/pg.ts index 00753f3..56cedad 100644 --- a/src/schema/pg.ts +++ b/src/schema/pg.ts @@ -5,7 +5,7 @@ * * @example * ```typescript - * export { auditLog } from '@ezmode-games/drizzle-ledger/schema/pg'; + * export { auditLog } from '@rafters/ledger/schema/pg'; * ``` */ diff --git a/src/schema/sqlite.ts b/src/schema/sqlite.ts index 3a22615..a1acb0d 100644 --- a/src/schema/sqlite.ts +++ b/src/schema/sqlite.ts @@ -6,10 +6,10 @@ * @example * ```typescript * // In your schema file - * export { auditLog } from '@ezmode-games/drizzle-ledger/schema/sqlite'; + * export { auditLog } from '@rafters/ledger/schema/sqlite'; * * // Or customize the table name - * import { createAuditLogTable } from '@ezmode-games/drizzle-ledger/schema/sqlite'; + * import { createAuditLogTable } from '@rafters/ledger/schema/sqlite'; * export const auditLog = createAuditLogTable('custom_audit_log'); * ``` */ diff --git a/src/soft-delete/index.ts b/src/soft-delete/index.ts index f49840e..8ed33c0 100644 --- a/src/soft-delete/index.ts +++ b/src/soft-delete/index.ts @@ -3,9 +3,9 @@ * * Dialect-agnostic helpers for implementing soft-delete patterns in Drizzle. * For column definitions, import from the dialect-specific module: - * - drizzle-ledger/soft-delete/sqlite - * - drizzle-ledger/soft-delete/pg - * - drizzle-ledger/soft-delete/mysql + * - ledger/soft-delete/sqlite + * - ledger/soft-delete/pg + * - ledger/soft-delete/mysql */ import { type Column, isNotNull, isNull, type SQL, sql } from "drizzle-orm"; @@ -19,7 +19,7 @@ import { type Column, isNotNull, isNull, type SQL, sql } from "drizzle-orm"; * * @example * ```typescript - * import { notDeleted } from '@ezmode-games/drizzle-ledger/soft-delete'; + * import { notDeleted } from '@rafters/ledger/soft-delete'; * * const users = await db * .select() diff --git a/src/soft-delete/mysql.ts b/src/soft-delete/mysql.ts index ab40869..c5215ca 100644 --- a/src/soft-delete/mysql.ts +++ b/src/soft-delete/mysql.ts @@ -6,7 +6,7 @@ * @example * ```typescript * import { mysqlTable, varchar } from 'drizzle-orm/mysql-core'; - * import { softDeleteColumns } from '@ezmode-games/drizzle-ledger/soft-delete/mysql'; + * import { softDeleteColumns } from '@rafters/ledger/soft-delete/mysql'; * * export const users = mysqlTable('users', { * id: varchar('id', { length: 36 }).primaryKey(), diff --git a/src/soft-delete/pg.ts b/src/soft-delete/pg.ts index 36e0043..075a108 100644 --- a/src/soft-delete/pg.ts +++ b/src/soft-delete/pg.ts @@ -6,7 +6,7 @@ * @example * ```typescript * import { pgTable, text, uuid } from 'drizzle-orm/pg-core'; - * import { softDeleteColumns } from '@ezmode-games/drizzle-ledger/soft-delete/pg'; + * import { softDeleteColumns } from '@rafters/ledger/soft-delete/pg'; * * export const users = pgTable('users', { * id: uuid('id').primaryKey().defaultRandom(), diff --git a/src/soft-delete/sqlite.ts b/src/soft-delete/sqlite.ts index 77d047a..6a522f1 100644 --- a/src/soft-delete/sqlite.ts +++ b/src/soft-delete/sqlite.ts @@ -6,7 +6,7 @@ * @example * ```typescript * import { sqliteTable, text } from 'drizzle-orm/sqlite-core'; - * import { softDeleteColumns } from '@ezmode-games/drizzle-ledger/soft-delete/sqlite'; + * import { softDeleteColumns } from '@rafters/ledger/soft-delete/sqlite'; * * export const users = sqliteTable('users', { * id: text('id').primaryKey(), diff --git a/test/better-auth.test.ts b/test/better-auth.test.ts index c065322..f755742 100644 --- a/test/better-auth.test.ts +++ b/test/better-auth.test.ts @@ -12,7 +12,7 @@ describe("ledgerPlugin", () => { test("returns a valid BetterAuthPlugin", () => { const plugin = ledgerPlugin(); - expect(plugin.id).toBe("drizzle-ledger"); + expect(plugin.id).toBe("ledger"); expect(plugin.init).toBeDefined(); expect(typeof plugin.init).toBe("function"); }); @@ -173,10 +173,7 @@ describe("ledgerPlugin", () => { userHooks?.create?.after?.({ id: "user-123", email: "test@test.com" }), ).resolves.toBeUndefined(); - expect(consoleSpy).toHaveBeenCalledWith( - expect.stringContaining("[drizzle-ledger]"), - expect.any(Error), - ); + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("[ledger]"), expect.any(Error)); consoleSpy.mockRestore(); }); @@ -368,10 +365,7 @@ describe("createDeleteAuditCallback", () => { // Should not throw await expect(callback(user)).resolves.toBeUndefined(); - expect(consoleSpy).toHaveBeenCalledWith( - expect.stringContaining("[drizzle-ledger]"), - expect.any(Error), - ); + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("[ledger]"), expect.any(Error)); consoleSpy.mockRestore(); }); diff --git a/test/logger.test.ts b/test/logger.test.ts index 5a810a8..2907e1d 100644 --- a/test/logger.test.ts +++ b/test/logger.test.ts @@ -242,7 +242,7 @@ describe("AuditLogger", () => { await new Promise((r) => setTimeout(r, 10)); expect(consoleSpy).toHaveBeenCalledWith( - "[drizzle-ledger] Failed to write audit entry:", + "[ledger] Failed to write audit entry:", expect.any(Error), ); diff --git a/vitest.config.ts b/vitest.config.ts index df65892..4143615 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -7,21 +7,21 @@ export default defineConfig({ }, resolve: { alias: { - "@ezmode-games/drizzle-ledger/soft-delete/sqlite": "./src/soft-delete/sqlite.ts", - "@ezmode-games/drizzle-ledger/soft-delete/pg": "./src/soft-delete/pg.ts", - "@ezmode-games/drizzle-ledger/soft-delete/mysql": "./src/soft-delete/mysql.ts", - "@ezmode-games/drizzle-ledger/soft-delete": "./src/soft-delete/index.ts", - "@ezmode-games/drizzle-ledger/schema/sqlite": "./src/schema/sqlite.ts", - "@ezmode-games/drizzle-ledger/schema/pg": "./src/schema/pg.ts", - "@ezmode-games/drizzle-ledger/schema/mysql": "./src/schema/mysql.ts", - "@ezmode-games/drizzle-ledger/schema": "./src/schema/index.ts", - "@ezmode-games/drizzle-ledger/audit": "./src/audit.ts", - "@ezmode-games/drizzle-ledger/context": "./src/context.ts", - "@ezmode-games/drizzle-ledger/db": "./src/db.ts", - "@ezmode-games/drizzle-ledger/gdpr": "./src/gdpr.ts", - "@ezmode-games/drizzle-ledger/logger": "./src/logger.ts", - "@ezmode-games/drizzle-ledger/better-auth": "./src/better-auth.ts", - "@ezmode-games/drizzle-ledger": "./src/index.ts", + "@rafters/ledger/soft-delete/sqlite": "./src/soft-delete/sqlite.ts", + "@rafters/ledger/soft-delete/pg": "./src/soft-delete/pg.ts", + "@rafters/ledger/soft-delete/mysql": "./src/soft-delete/mysql.ts", + "@rafters/ledger/soft-delete": "./src/soft-delete/index.ts", + "@rafters/ledger/schema/sqlite": "./src/schema/sqlite.ts", + "@rafters/ledger/schema/pg": "./src/schema/pg.ts", + "@rafters/ledger/schema/mysql": "./src/schema/mysql.ts", + "@rafters/ledger/schema": "./src/schema/index.ts", + "@rafters/ledger/audit": "./src/audit.ts", + "@rafters/ledger/context": "./src/context.ts", + "@rafters/ledger/db": "./src/db.ts", + "@rafters/ledger/gdpr": "./src/gdpr.ts", + "@rafters/ledger/logger": "./src/logger.ts", + "@rafters/ledger/better-auth": "./src/better-auth.ts", + "@rafters/ledger": "./src/index.ts", }, }, });