Drizzle Ledger

MIT

Soft-Delete + Audit Trail + GDPR

Stop rewriting soft-delete for every project. Adds soft-delete columns, automatic delete-to-update rewriting, full audit trail with AsyncLocalStorage context, and GDPR purge to any Drizzle ORM project. SQLite, PostgreSQL, MySQL.

pnpm add @ezmode-games/drizzle-ledger
Source npm

Architecture

Drizzle Table ...softDeleteColumns createAuditedDb()
db.delete() → soft-delete audit trail → who/what/when purgeUserData() → GDPR

1. Soft-Delete Columns

Spread softDeleteColumns into any table definition. Adds deleted_at (timestamp) and deleted_by (text). Dialect-specific types: integer timestamp for SQLite, timestamptz for Postgres, timestamp for MySQL.

Add to a Table

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,
});

// Adds: deleted_at (timestamp), deleted_by (text)

Query Helpers

import { notDeleted, onlyDeleted } from "@ezmode-games/drizzle-ledger/soft-delete";

// Active records only
const active = await db
  .select()
  .from(users)
  .where(notDeleted(users));

// Trash view / admin dashboard
const deleted = await db
  .select()
  .from(users)
  .where(onlyDeleted(users));

Dialect Imports

soft-delete/sqlite
integer timestamp
soft-delete/pg
timestamptz
soft-delete/mysql
timestamp

Also Available

softDeleteTimestamp deleted_at only, no deleted_by
softDeleteValues(userId?) for manual .set() calls
restoreValues() undo a soft-delete
isSoftDeleted(record) runtime check

2. Automatic Soft-Delete

createAuditedDb(db) intercepts db.delete() and rewrites it to a soft-delete UPDATE for any table with a deleted_at column. Tables without it pass through to a normal hard delete. The right thing happens by default.

import { createAuditedDb } from "@ezmode-games/drizzle-ledger/db";

const db = createAuditedDb(drizzle(env.DB), {
  hardDeleteTables: ["session", "verification"],
});

// This becomes a soft-delete (users has deleted_at)
await db.delete(users).where(eq(users.id, userId));

// This is a real delete (session is in hardDeleteTables)
await db.delete(session).where(eq(session.id, sessionId));

How It Works

  • Table has deleted_at? Soft-delete (UPDATE with timestamp)
  • Table in hardDeleteTables? Hard delete (normal DELETE)
  • No deleted_at column? Hard delete (pass-through)
  • .where(), .returning(), .execute() all work as normal

3. Audit Trail

Every mutation records who changed what record in which table, when, from where (IP, user agent, endpoint), with before and after state as JSON snapshots.

Context Propagation

Set context once in middleware via AsyncLocalStorage. Every audit entry downstream automatically inherits userId, IP, user agent, endpoint, and request ID. No prop drilling.

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.

Manual Logging

import { logInsert, logUpdate } 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 Logging

Plug into Drizzle's logger interface. Intercepts INSERT/UPDATE/DELETE queries automatically.

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 });

SOC 2 Coverage

CC6.1 Logical access - userId on every entry
CC7.2 System monitoring - before/after snapshots
CC8.1 Change management - timestamped, typed actions
Source IP, user agent, endpoint, request ID

4. GDPR Purge

A user exercises their right to erasure (Article 17). You need to remove their PII from the audit trail without destroying the trail itself. purgeUserData anonymizes their entries. The structure stays intact. The PII goes away.

import { purgeUserData, isUserDataPurged } from "@ezmode-games/drizzle-ledger/gdpr";

const result = await purgeUserData(db, auditLog, "user-123", {
  piiFields: ["email", "name", "phone", "address", "ip"],
  anonymizedUserId: "PURGED_USER",
});
// { entriesAnonymized: 47, tablesProcessed: ["users", "accounts"] }

// Idempotency check
const purged = await isUserDataPurged(db, auditLog, "user-123");

What It Does

  • Finds all audit entries for the user (by userId and recordId)
  • Recursively strips configured PII fields from JSON snapshots
  • Replaces userId with PURGED_USER on user's own entries
  • Nullifies IP and userAgent on user's own entries
  • Preserves admin entries untouched - admin userId/IP stays
  • Idempotent - safe to run multiple times

GDPR Coverage

Art. 5(2) Accountability - full audit trail
Art. 17 Right to erasure - purgeUserData
Art. 30 Records of processing - per-record history
Art. 33 Breach response - scope determination

5. Better Auth Integration

Hooks into Better Auth's databaseHooks to automatically log user/account operations. The soft-delete callback intercepts deleteUser to perform soft-delete instead of hard delete.

import { ledgerPlugin, createSoftDeleteCallback } 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"],
    }),
  ],
});

Detection

createSoftDeleteCallback performs the soft-delete, logs an audit entry, then throws to prevent the hard delete. Use isSoftDeletePerformed(error) to detect success in your client code.

6. Subpath Exports

Every subpath is independently tree-shakeable. Import only what you need.

soft-delete/sqlite SQLite columns
soft-delete/pg Postgres columns
soft-delete/mysql MySQL columns
soft-delete Dialect-agnostic helpers
schema/sqlite SQLite audit table
schema/pg Postgres audit table
schema/mysql MySQL audit table
audit Manual audit functions
context AsyncLocalStorage context
db Automatic soft-delete wrapper
gdpr GDPR purge
logger Drizzle Logger with audit
better-auth Better Auth plugin

Stack

TypeScript Drizzle ORM SQLite PostgreSQL MySQL AsyncLocalStorage

MIT Licensed. View source on GitHub.