Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ pnpm add @rafters/ledger

Peer dependency: `drizzle-orm >= 0.30.0`

Two entry points: `@rafters/ledger` for the ORM-agnostic core (context, pure helpers, GDPR utilities), and `@rafters/ledger/drizzle` for the Drizzle adapter (schema, `createAuditedDb`, query filters, logging). Dialect-specific column definitions live at `@rafters/ledger/drizzle/soft-delete/sqlite`, `/pg`, and `/mysql`.

## Docs

Full documentation: [docs/](./docs/)
Expand Down
443 changes: 201 additions & 242 deletions docs/api-reference.mdx

Large diffs are not rendered by default.

34 changes: 22 additions & 12 deletions docs/audit-trail.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

`@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.
Two approaches exist. `AuditLogger` hooks into Drizzle's logger interface and captures mutations automatically. The manual functions from `@rafters/ledger/drizzle` 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.

---

Expand All @@ -12,9 +12,9 @@ Add the audit log table to your Drizzle schema file. Import from the dialect-spe

```typescript
// schema.ts
export { auditLog } from "@rafters/ledger/schema/sqlite";
// or: "@rafters/ledger/schema/pg"
// or: "@rafters/ledger/schema/mysql"
export { auditLog } from "@rafters/ledger/drizzle/schema/sqlite";
// or: "@rafters/ledger/drizzle/schema/pg"
// or: "@rafters/ledger/drizzle/schema/mysql"
```

The `auditLog` table has the following columns:
Expand All @@ -39,7 +39,7 @@ The `auditLog` table has the following columns:
If `audit_log` conflicts with an existing table, pass a name to `createAuditLogTable`:

```typescript
import { createAuditLogTable } from "@rafters/ledger/schema/sqlite";
import { createAuditLogTable } from "@rafters/ledger/drizzle/schema/sqlite";

export const auditLog = createAuditLogTable("platform_audit_log");
```
Expand All @@ -49,7 +49,7 @@ export const auditLog = createAuditLogTable("platform_audit_log");
`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";
import { AUDIT_LOG_INDEXES } from "@rafters/ledger/drizzle/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)
Expand Down Expand Up @@ -90,7 +90,7 @@ CREATE INDEX IF NOT EXISTS idx_audit_log_request ON audit_log(request_id);
```typescript
import { drizzle } from "drizzle-orm/d1";
import { uuidv7 } from "uuidv7";
import { AuditLogger } from "@rafters/ledger/logger";
import { AuditLogger } from "@rafters/ledger/drizzle";
import { auditLog } from "./schema.js";

// Create the logger first, pointing at a secondary db instance
Expand Down Expand Up @@ -167,7 +167,7 @@ Because `AuditLogger` receives only the SQL and its params, it has no access to
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";
import { runWithLedgerContext, createLedgerContext } from "@rafters/ledger";

app.use(async (c, next) => {
const context = createLedgerContext({
Expand All @@ -184,7 +184,7 @@ app.use(async (c, next) => {
For background workers and cron jobs where there is no HTTP request, use `createSystemContext`:

```typescript
import { runWithLedgerContext, createSystemContext } from "@rafters/ledger/context";
import { runWithLedgerContext, createSystemContext } from "@rafters/ledger";

await runWithLedgerContext(
createSystemContext("cron:expired-sessions-cleanup"),
Expand All @@ -198,7 +198,7 @@ await runWithLedgerContext(

## 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.
Import from `@rafters/ledger/drizzle`. 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 {
Expand All @@ -207,10 +207,16 @@ import {
logDelete,
logSoftDelete,
logRestore,
} from "@rafters/ledger/audit";
} from "@rafters/ledger/drizzle";
import { auditLog } from "./schema.js";
```

For constructing an audit entry without inserting it, `createAuditEntry` is available from `@rafters/ledger`:

```typescript
import { createAuditEntry } from "@rafters/ledger";
```

### logInsert

```typescript
Expand Down Expand Up @@ -261,6 +267,8 @@ await logSoftDelete(db, auditLog, tableName, recordId, oldData, newData);
Records both states: `oldData` has `deletedAt: null`, `newData` has `deletedAt` set.

```typescript
import { softDeleteValues } from "@rafters/ledger";

const [oldUser] = await db.select().from(users).where(eq(users.id, id));
const [newUser] = await db
.update(users)
Expand All @@ -279,6 +287,8 @@ await logRestore(db, auditLog, tableName, recordId, oldData, newData);
The inverse of `logSoftDelete`: `oldData` has `deletedAt` set, `newData` has it null.

```typescript
import { restoreValues } from "@rafters/ledger";

const [oldUser] = await db.select().from(users).where(eq(users.id, id));
const [newUser] = await db
.update(users)
Expand All @@ -297,7 +307,7 @@ The return value is the inserted entry with its generated `id` and `createdAt`.
## 4. Querying History

```typescript
import { getRecordHistory } from "@rafters/ledger/audit";
import { getRecordHistory } from "@rafters/ledger/drizzle";

const history = await getRecordHistory(db, auditLog, "users", userId);
```
Expand Down
10 changes: 5 additions & 5 deletions docs/better-auth.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ 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";
import { auditLog } from "@rafters/ledger/drizzle/schema/sqlite";

export function buildAuth(env: Env) {
const db = drizzle(env.DB);
Expand Down Expand Up @@ -141,7 +141,7 @@ export const auth = betterAuth({
| `whereUserId` | `(userId: string) => SQL` | Yes | Returns the WHERE clause for the update. |
| `writeAuditEntry` | `(entry: LedgerAuditEntry) => Promise<void>` | 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`.
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`.

---

Expand Down Expand Up @@ -232,8 +232,8 @@ import {
ledgerPlugin,
createSoftDeleteCallback,
} from "@rafters/ledger/better-auth";
import { softDeleteColumns } from "@rafters/ledger/soft-delete/sqlite";
import { auditLog } from "@rafters/ledger/schema/sqlite";
import { softDeleteColumns } from "@rafters/ledger/drizzle/soft-delete/sqlite";
import { auditLog } from "@rafters/ledger/drizzle/schema/sqlite";

// Schema
export const users = sqliteTable("user", {
Expand Down Expand Up @@ -304,6 +304,6 @@ In this setup:

`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.
`createAuditedDb` (from `@rafters/ledger/drizzle`) 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`.
2 changes: 2 additions & 0 deletions docs/context.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ Every audit entry needs to know who triggered the operation, from where, and on

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.

Context is part of the core; all context functions import from `@rafters/ledger`.

## LedgerContext Shape

| Field | Type | Required | Description |
Expand Down
14 changes: 8 additions & 6 deletions docs/gdpr.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,11 @@ The audit trail remains queryable. `tableName`, `action`, `recordId`, `endpoint`

## Basic Usage

`purgeUserData` imports from `@rafters/ledger`. The audit log schema imports from `@rafters/ledger/drizzle/schema/sqlite` (or `/pg`, `/mysql`).

```typescript
import { purgeUserData } from '@rafters/ledger/gdpr';
import { auditLog } from '@rafters/ledger/schema/sqlite';
import { purgeUserData } from '@rafters/ledger';
import { auditLog } from '@rafters/ledger/drizzle/schema/sqlite';

const result = await purgeUserData(db, auditLog, userId, {
piiFields: ['email', 'name', 'phone', 'address', 'ip', 'userAgent'],
Expand Down Expand Up @@ -145,10 +147,10 @@ const result = await purgeUserData(db, auditLog, userId, {

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:
If you need the stripping function standalone (for anonymizing data outside the audit log, for example), `anonymizeJsonData` is exported from `@rafters/ledger`:

```typescript
import { anonymizeJsonData } from '@rafters/ledger/gdpr';
import { anonymizeJsonData } from '@rafters/ledger';

const raw = { id: 'rec-123', email: 'alice@example.com', name: 'Alice', role: 'admin' };
const clean = anonymizeJsonData(raw, ['email', 'name']);
Expand All @@ -166,8 +168,8 @@ const clean = anonymizeJsonData(raw, ['email', 'name']);
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';
import { isUserDataPurged, purgeUserData } from '@rafters/ledger';
import { auditLog } from '@rafters/ledger/drizzle/schema/sqlite';

async function handleErasureRequest(db: DrizzleDb, userId: string) {
const alreadyPurged = await isUserDataPurged(db, auditLog, userId);
Expand Down
40 changes: 20 additions & 20 deletions docs/getting-started.mdx
Original file line number Diff line number Diff line change
@@ -1,20 +1,20 @@
# 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.
`@rafters/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.
Dialect-specific pieces import from subpaths under `@rafters/ledger/drizzle`. Everything else imports from `@rafters/ledger`.

| 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` |
| Database | Soft-delete columns | Audit log schema |
| --------------------- | ------------------------------------------------------------ | --------------------------------------------------------- |
| SQLite / Cloudflare D1 | `@rafters/ledger/drizzle/soft-delete/sqlite` | `@rafters/ledger/drizzle/schema/sqlite` |
| PostgreSQL | `@rafters/ledger/drizzle/soft-delete/pg` | `@rafters/ledger/drizzle/schema/pg` |
| MySQL | `@rafters/ledger/drizzle/soft-delete/mysql` | `@rafters/ledger/drizzle/schema/mysql` |

All three dialects export the same surface: `softDeleteColumns`, `softDeleteTimestamp`, `auditLog`, and `createAuditLogTable`. The column types differ; the API does not.

Expand All @@ -26,7 +26,7 @@ Spread `softDeleteColumns` into any table definition that should support soft-de

```typescript
import { sqliteTable, text } from "drizzle-orm/sqlite-core";
import { softDeleteColumns } from "@rafters/ledger/soft-delete/sqlite";
import { softDeleteColumns } from "@rafters/ledger/drizzle/soft-delete/sqlite";

export const users = sqliteTable("users", {
id: text("id").primaryKey(),
Expand All @@ -41,7 +41,7 @@ The resulting table has two additional columns: `deleted_at` (integer, timestamp
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";
import { softDeleteTimestamp } from "@rafters/ledger/drizzle/soft-delete/sqlite";

export const events = sqliteTable("events", {
id: text("id").primaryKey(),
Expand All @@ -58,15 +58,15 @@ Import the `auditLog` table from the schema subpath and include it in your Drizz

```typescript
// src/db/schema.ts
export { auditLog } from "@rafters/ledger/schema/sqlite";
export { auditLog } from "@rafters/ledger/drizzle/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";
import { createAuditLogTable } from "@rafters/ledger/drizzle/schema/sqlite";

export const activityLog = createAuditLogTable("activity_log");
```
Expand Down Expand Up @@ -99,7 +99,7 @@ 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";
import { AUDIT_LOG_INDEXES } from "@rafters/ledger/drizzle/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)
Expand All @@ -117,7 +117,7 @@ import { Hono } from "hono";
import {
createLedgerContext,
runWithLedgerContext,
} from "@rafters/ledger/context";
} from "@rafters/ledger";

const app = new Hono();

Expand All @@ -142,7 +142,7 @@ For cron jobs, background workers, and other operations that have no request con
import {
createSystemContext,
runWithLedgerContext,
} from "@rafters/ledger/context";
} from "@rafters/ledger";

// In a Cloudflare Workers scheduled handler
export default {
Expand All @@ -165,7 +165,7 @@ export default {

```typescript
import { drizzle } from "drizzle-orm/d1";
import { createAuditedDb } from "@rafters/ledger/db";
import { createAuditedDb } from "@rafters/ledger/drizzle";
import * as schema from "./schema.ts";

export function buildDb(d1: D1Database) {
Expand Down Expand Up @@ -197,8 +197,8 @@ The `deleted_by` value comes from the ledger context automatically. No changes t

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.
- [soft-delete.mdx](./soft-delete.mdx): Querying live records, restoring soft-deleted rows, and handling the `deletedAt` filter.
- [audit-trail.mdx](./audit-trail.mdx): Writing audit entries manually, querying history for a record, and correlating entries by request ID.
- [context.mdx](./context.mdx): The full `LedgerContext` shape, custom context factories, and using `getLedgerContext()` in handlers.
- [gdpr.mdx](./gdpr.mdx): Anonymizing and erasing user data across multiple tables in a single operation.
- [better-auth.mdx](./better-auth.mdx): The `ledgerPlugin` for better-auth that writes audit entries on sign-in, sign-out, and credential changes.
Loading
Loading