From ace94a0a6dab636ea051802a80883a2cdd9cb7bb Mon Sep 17 00:00:00 2001 From: Sean Silvius Date: Mon, 13 Apr 2026 14:09:56 -0700 Subject: [PATCH 1/5] Extract ORM-agnostic core into src/core/ Co-Authored-By: Claude Opus 4.6 (1M context) --- src/audit.ts | 70 ++------------- src/better-auth.ts | 52 +---------- src/context.ts | 185 ++------------------------------------- src/core/audit.ts | 70 +++++++++++++++ src/core/context.ts | 182 ++++++++++++++++++++++++++++++++++++++ src/core/errors.ts | 53 +++++++++++ src/core/gdpr.ts | 89 +++++++++++++++++++ src/core/index.ts | 49 +++++++++++ src/core/soft-delete.ts | 62 +++++++++++++ src/core/types.ts | 100 +++++++++++++++++++++ src/db.ts | 4 +- src/gdpr.ts | 94 +++----------------- src/logger.ts | 2 +- src/soft-delete/index.ts | 65 ++------------ src/types.ts | 104 ++-------------------- 15 files changed, 656 insertions(+), 525 deletions(-) create mode 100644 src/core/audit.ts create mode 100644 src/core/context.ts create mode 100644 src/core/errors.ts create mode 100644 src/core/gdpr.ts create mode 100644 src/core/index.ts create mode 100644 src/core/soft-delete.ts create mode 100644 src/core/types.ts diff --git a/src/audit.ts b/src/audit.ts index e3d25f4..ccf2146 100644 --- a/src/audit.ts +++ b/src/audit.ts @@ -5,70 +5,11 @@ * Automatically captures context from AsyncLocalStorage. */ -import { uuidv7 } from "uuidv7"; -import { getLedgerContext } from "./context.js"; import type { auditLog } from "./schema/sqlite.js"; -import type { AuditLogEntry, LedgerContext } from "./types.js"; +import type { AuditLogEntry } from "./core/types.js"; -/** - * Action types for audit logging. - */ -export type AuditAction = "INSERT" | "UPDATE" | "DELETE" | "SOFT_DELETE" | "RESTORE"; - -/** - * Options for creating an audit entry. - */ -export interface AuditEntryOptions { - /** Name of the table being modified */ - tableName: string; - /** Primary key of the record */ - recordId: string; - /** Type of operation */ - action: AuditAction; - /** Data before the change (null for INSERT) */ - oldData?: Record | null; - /** Data after the change (null for DELETE) */ - newData?: Record | null; - /** Override context (uses AsyncLocalStorage context if not provided) */ - context?: LedgerContext | null; -} - -/** - * Create an audit log entry object. - * This doesn't insert into the database - use `insertAuditEntry` for that. - * - * @param options - The audit entry options - * @returns An audit log entry ready for insertion - * - * @example - * ```typescript - * const entry = createAuditEntry({ - * tableName: 'users', - * recordId: 'user-123', - * action: 'UPDATE', - * oldData: { name: 'Old Name' }, - * newData: { name: 'New Name' }, - * }); - * ``` - */ -export function createAuditEntry(options: AuditEntryOptions): AuditLogEntry { - const context = options.context ?? getLedgerContext(); - - return { - id: uuidv7(), - tableName: options.tableName, - recordId: options.recordId, - action: options.action, - oldData: options.oldData ?? null, - newData: options.newData ?? null, - userId: context?.userId ?? null, - ip: context?.ip ?? null, - userAgent: context?.userAgent ?? null, - endpoint: context?.endpoint ?? null, - requestId: context?.requestId ?? null, - createdAt: new Date(), - }; -} +// Re-export pure helpers from core +export { type AuditAction, type AuditEntryOptions, createAuditEntry } from "./core/audit.js"; /** * Insert an audit log entry into the database. @@ -133,6 +74,7 @@ export async function logInsert( recordId: string, newData: Record, ): Promise { + const { createAuditEntry } = await import("./core/audit.js"); const entry = createAuditEntry({ tableName, recordId, @@ -170,6 +112,7 @@ export async function logUpdate( oldData: Record, newData: Record, ): Promise { + const { createAuditEntry } = await import("./core/audit.js"); const entry = createAuditEntry({ tableName, recordId, @@ -205,6 +148,7 @@ export async function logDelete( recordId: string, oldData: Record, ): Promise { + const { createAuditEntry } = await import("./core/audit.js"); const entry = createAuditEntry({ tableName, recordId, @@ -244,6 +188,7 @@ export async function logSoftDelete( oldData: Record, newData: Record, ): Promise { + const { createAuditEntry } = await import("./core/audit.js"); const entry = createAuditEntry({ tableName, recordId, @@ -283,6 +228,7 @@ export async function logRestore( oldData: Record, newData: Record, ): Promise { + const { createAuditEntry } = await import("./core/audit.js"); const entry = createAuditEntry({ tableName, recordId, diff --git a/src/better-auth.ts b/src/better-auth.ts index 9fd5a97..658f9cd 100644 --- a/src/better-auth.ts +++ b/src/better-auth.ts @@ -38,7 +38,8 @@ */ import type { BetterAuthPlugin, User } from "better-auth"; -import { softDeleteValues } from "./soft-delete/index.js"; +import { softDeleteValues } from "./core/soft-delete.js"; +import { SoftDeletePerformedError, isSoftDeletePerformed } from "./core/errors.js"; /** * Audit entry passed to the writeAuditEntry callback. @@ -360,50 +361,5 @@ export function createDeleteAuditCallback( }; } -/** - * Error thrown when soft-delete is performed successfully. - * Check for this error type to handle soft-delete success cases. - */ -export class SoftDeletePerformedError extends Error { - readonly code = "SOFT_DELETE_PERFORMED" as const; - readonly softDeleted = true as const; - readonly userId: string; - - constructor(userId: string) { - super("User soft-deleted successfully"); - this.name = "SoftDeletePerformedError"; - this.userId = userId; - } -} - -/** - * Check if an error is a soft-delete success error. - * - * @param error - The error to check - * @returns true if this is a soft-delete success - * - * @example - * ```typescript - * try { - * await auth.api.deleteUser({ userId }); - * } catch (error) { - * if (isSoftDeletePerformed(error)) { - * // Success! User was soft-deleted - * return { success: true }; - * } - * throw error; - * } - * ``` - */ -export function isSoftDeletePerformed(error: unknown): error is SoftDeletePerformedError { - if (error instanceof SoftDeletePerformedError) return true; - if (error instanceof Error) { - return ( - "code" in error && - (error as Error & { code?: string }).code === "SOFT_DELETE_PERFORMED" && - "softDeleted" in error && - (error as Error & { softDeleted?: boolean }).softDeleted === true - ); - } - return false; -} +// Re-export error types from core for backwards compatibility +export { SoftDeletePerformedError, isSoftDeletePerformed } from "./core/errors.js"; diff --git a/src/context.ts b/src/context.ts index 5bc31e6..e4bfdbe 100644 --- a/src/context.ts +++ b/src/context.ts @@ -1,182 +1,13 @@ /** * Drizzle Ledger Context * - * AsyncLocalStorage-based context for passing audit information through - * the async call stack without explicit parameter threading. - * - * @example - * ```typescript - * // In middleware - * app.use(async (c, next) => { - * return runWithLedgerContext({ - * 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'), - * }, next); - * }); - * - * // Anywhere in your code - * const context = getLedgerContext(); - * console.log(context?.userId); // Access current user - * ``` - */ - -import type { LedgerContext } from "./types.js"; - -// Use global AsyncLocalStorage (Cloudflare Workers compatible) -// Do NOT import from 'node:async_hooks' - it doesn't exist in Workers runtime - -/** - * Lazily initialized AsyncLocalStorage instance for ledger context. - * This allows context to flow through async operations without explicit passing. - * Lazy initialization ensures the global is available when first accessed. - * Returns null if AsyncLocalStorage is not available (graceful degradation). - */ -let ledgerStorage: AsyncLocalStorage | null = null; -let storageInitialized = false; - -function getStorage(): AsyncLocalStorage | null { - if (!storageInitialized) { - storageInitialized = true; - // Check if AsyncLocalStorage is available (not available in some test environments) - if (typeof AsyncLocalStorage !== "undefined") { - ledgerStorage = new AsyncLocalStorage(); - } - } - return ledgerStorage; -} - -/** - * Run a function with the given ledger context. - * All async operations within the callback will have access to this context. - * - * @param context - The audit context for this execution - * @param fn - The function to run with the context - * @returns The result of the function - * - * @example - * ```typescript - * const result = await runWithLedgerContext( - * { userId: 'user-123', ip: '1.2.3.4', userAgent: null, endpoint: 'POST /mods' }, - * async () => { - * // All DB operations here will use this context for audit logging - * return await createMod(data); - * } - * ); - * ``` + * Re-exports from core for backwards compatibility. */ -export function runWithLedgerContext( - context: LedgerContext, - fn: () => T | Promise, -): T | Promise { - const storage = getStorage(); - // If AsyncLocalStorage is not available, just run the function without context - if (!storage) { - return fn(); - } - return storage.run(context, fn); -} -/** - * Get the current ledger context. - * Returns null if called outside of a runWithLedgerContext callback. - * - * @returns The current context or null - * - * @example - * ```typescript - * const context = getLedgerContext(); - * if (context) { - * console.log(`Action by user ${context.userId} from ${context.ip}`); - * } - * ``` - */ -export function getLedgerContext(): LedgerContext | null { - const storage = getStorage(); - if (!storage) { - return null; - } - return storage.getStore() ?? null; -} - -/** - * Check if we're currently running within a ledger context. - * - * @returns true if a context is available - */ -export function hasLedgerContext(): boolean { - const storage = getStorage(); - if (!storage) { - return false; - } - return storage.getStore() !== undefined; -} - -/** - * Create a context object from common request properties. - * Convenience function for creating context in middleware. - * - * @param options - Request properties - * @returns A LedgerContext object - * - * @example - * ```typescript - * const context = createLedgerContext({ - * userId: session?.user?.id, - * ip: req.headers['x-forwarded-for'], - * userAgent: req.headers['user-agent'], - * endpoint: `${req.method} ${req.url}`, - * }); - * ``` - */ -export function createLedgerContext(options: { - userId?: string | null; - ip?: string | null; - userAgent?: string | null; - endpoint?: string | null; - requestId?: string; - metadata?: Record; -}): LedgerContext { - return { - userId: options.userId ?? null, - ip: options.ip ?? null, - userAgent: options.userAgent ?? null, - endpoint: options.endpoint ?? null, - requestId: options.requestId, - metadata: options.metadata, - }; -} - -/** - * Create a system context for operations not triggered by a user request. - * Useful for cron jobs, migrations, or background workers. - * - * @param source - Identifier for the system process (e.g., 'cron:cleanup', 'migration:v2') - * @param metadata - Optional additional metadata - * @returns A LedgerContext with userId as null - * - * @example - * ```typescript - * await runWithLedgerContext( - * createSystemContext('cron:expired-sessions-cleanup'), - * async () => { - * await cleanupExpiredSessions(); - * } - * ); - * ``` - */ -export function createSystemContext( - source: string, - metadata?: Record, -): LedgerContext { - return { - userId: null, - ip: null, - userAgent: null, - endpoint: `system:${source}`, - requestId: undefined, - metadata, - }; -} +export { + createLedgerContext, + createSystemContext, + getLedgerContext, + hasLedgerContext, + runWithLedgerContext, +} from "./core/context.js"; diff --git a/src/core/audit.ts b/src/core/audit.ts new file mode 100644 index 0000000..ed97027 --- /dev/null +++ b/src/core/audit.ts @@ -0,0 +1,70 @@ +/** + * Ledger Audit - Pure Helpers + * + * ORM-agnostic functions for creating audit entries. + * Depends only on context and uuidv7. + */ + +import { uuidv7 } from "uuidv7"; +import { getLedgerContext } from "./context.js"; +import type { AuditLogEntry, LedgerContext } from "./types.js"; + +/** + * Action types for audit logging. + */ +export type AuditAction = "INSERT" | "UPDATE" | "DELETE" | "SOFT_DELETE" | "RESTORE"; + +/** + * Options for creating an audit entry. + */ +export interface AuditEntryOptions { + /** Name of the table being modified */ + tableName: string; + /** Primary key of the record */ + recordId: string; + /** Type of operation */ + action: AuditAction; + /** Data before the change (null for INSERT) */ + oldData?: Record | null; + /** Data after the change (null for DELETE) */ + newData?: Record | null; + /** Override context (uses AsyncLocalStorage context if not provided) */ + context?: LedgerContext | null; +} + +/** + * Create an audit log entry object. + * This doesn't insert into the database - use `insertAuditEntry` for that. + * + * @param options - The audit entry options + * @returns An audit log entry ready for insertion + * + * @example + * ```typescript + * const entry = createAuditEntry({ + * tableName: 'users', + * recordId: 'user-123', + * action: 'UPDATE', + * oldData: { name: 'Old Name' }, + * newData: { name: 'New Name' }, + * }); + * ``` + */ +export function createAuditEntry(options: AuditEntryOptions): AuditLogEntry { + const context = options.context ?? getLedgerContext(); + + return { + id: uuidv7(), + tableName: options.tableName, + recordId: options.recordId, + action: options.action, + oldData: options.oldData ?? null, + newData: options.newData ?? null, + userId: context?.userId ?? null, + ip: context?.ip ?? null, + userAgent: context?.userAgent ?? null, + endpoint: context?.endpoint ?? null, + requestId: context?.requestId ?? null, + createdAt: new Date(), + }; +} diff --git a/src/core/context.ts b/src/core/context.ts new file mode 100644 index 0000000..5bc31e6 --- /dev/null +++ b/src/core/context.ts @@ -0,0 +1,182 @@ +/** + * Drizzle Ledger Context + * + * AsyncLocalStorage-based context for passing audit information through + * the async call stack without explicit parameter threading. + * + * @example + * ```typescript + * // In middleware + * app.use(async (c, next) => { + * return runWithLedgerContext({ + * 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'), + * }, next); + * }); + * + * // Anywhere in your code + * const context = getLedgerContext(); + * console.log(context?.userId); // Access current user + * ``` + */ + +import type { LedgerContext } from "./types.js"; + +// Use global AsyncLocalStorage (Cloudflare Workers compatible) +// Do NOT import from 'node:async_hooks' - it doesn't exist in Workers runtime + +/** + * Lazily initialized AsyncLocalStorage instance for ledger context. + * This allows context to flow through async operations without explicit passing. + * Lazy initialization ensures the global is available when first accessed. + * Returns null if AsyncLocalStorage is not available (graceful degradation). + */ +let ledgerStorage: AsyncLocalStorage | null = null; +let storageInitialized = false; + +function getStorage(): AsyncLocalStorage | null { + if (!storageInitialized) { + storageInitialized = true; + // Check if AsyncLocalStorage is available (not available in some test environments) + if (typeof AsyncLocalStorage !== "undefined") { + ledgerStorage = new AsyncLocalStorage(); + } + } + return ledgerStorage; +} + +/** + * Run a function with the given ledger context. + * All async operations within the callback will have access to this context. + * + * @param context - The audit context for this execution + * @param fn - The function to run with the context + * @returns The result of the function + * + * @example + * ```typescript + * const result = await runWithLedgerContext( + * { userId: 'user-123', ip: '1.2.3.4', userAgent: null, endpoint: 'POST /mods' }, + * async () => { + * // All DB operations here will use this context for audit logging + * return await createMod(data); + * } + * ); + * ``` + */ +export function runWithLedgerContext( + context: LedgerContext, + fn: () => T | Promise, +): T | Promise { + const storage = getStorage(); + // If AsyncLocalStorage is not available, just run the function without context + if (!storage) { + return fn(); + } + return storage.run(context, fn); +} + +/** + * Get the current ledger context. + * Returns null if called outside of a runWithLedgerContext callback. + * + * @returns The current context or null + * + * @example + * ```typescript + * const context = getLedgerContext(); + * if (context) { + * console.log(`Action by user ${context.userId} from ${context.ip}`); + * } + * ``` + */ +export function getLedgerContext(): LedgerContext | null { + const storage = getStorage(); + if (!storage) { + return null; + } + return storage.getStore() ?? null; +} + +/** + * Check if we're currently running within a ledger context. + * + * @returns true if a context is available + */ +export function hasLedgerContext(): boolean { + const storage = getStorage(); + if (!storage) { + return false; + } + return storage.getStore() !== undefined; +} + +/** + * Create a context object from common request properties. + * Convenience function for creating context in middleware. + * + * @param options - Request properties + * @returns A LedgerContext object + * + * @example + * ```typescript + * const context = createLedgerContext({ + * userId: session?.user?.id, + * ip: req.headers['x-forwarded-for'], + * userAgent: req.headers['user-agent'], + * endpoint: `${req.method} ${req.url}`, + * }); + * ``` + */ +export function createLedgerContext(options: { + userId?: string | null; + ip?: string | null; + userAgent?: string | null; + endpoint?: string | null; + requestId?: string; + metadata?: Record; +}): LedgerContext { + return { + userId: options.userId ?? null, + ip: options.ip ?? null, + userAgent: options.userAgent ?? null, + endpoint: options.endpoint ?? null, + requestId: options.requestId, + metadata: options.metadata, + }; +} + +/** + * Create a system context for operations not triggered by a user request. + * Useful for cron jobs, migrations, or background workers. + * + * @param source - Identifier for the system process (e.g., 'cron:cleanup', 'migration:v2') + * @param metadata - Optional additional metadata + * @returns A LedgerContext with userId as null + * + * @example + * ```typescript + * await runWithLedgerContext( + * createSystemContext('cron:expired-sessions-cleanup'), + * async () => { + * await cleanupExpiredSessions(); + * } + * ); + * ``` + */ +export function createSystemContext( + source: string, + metadata?: Record, +): LedgerContext { + return { + userId: null, + ip: null, + userAgent: null, + endpoint: `system:${source}`, + requestId: undefined, + metadata, + }; +} diff --git a/src/core/errors.ts b/src/core/errors.ts new file mode 100644 index 0000000..06aac5d --- /dev/null +++ b/src/core/errors.ts @@ -0,0 +1,53 @@ +/** + * Ledger Errors + * + * ORM-agnostic error types used across the library. + */ + +/** + * Error thrown when soft-delete is performed successfully. + * Check for this error type to handle soft-delete success cases. + */ +export class SoftDeletePerformedError extends Error { + readonly code = "SOFT_DELETE_PERFORMED" as const; + readonly softDeleted = true as const; + readonly userId: string; + + constructor(userId: string) { + super("User soft-deleted successfully"); + this.name = "SoftDeletePerformedError"; + this.userId = userId; + } +} + +/** + * Check if an error is a soft-delete success error. + * + * @param error - The error to check + * @returns true if this is a soft-delete success + * + * @example + * ```typescript + * try { + * await auth.api.deleteUser({ userId }); + * } catch (error) { + * if (isSoftDeletePerformed(error)) { + * // Success! User was soft-deleted + * return { success: true }; + * } + * throw error; + * } + * ``` + */ +export function isSoftDeletePerformed(error: unknown): error is SoftDeletePerformedError { + if (error instanceof SoftDeletePerformedError) return true; + if (error instanceof Error) { + return ( + "code" in error && + (error as Error & { code?: string }).code === "SOFT_DELETE_PERFORMED" && + "softDeleted" in error && + (error as Error & { softDeleted?: boolean }).softDeleted === true + ); + } + return false; +} diff --git a/src/core/gdpr.ts b/src/core/gdpr.ts new file mode 100644 index 0000000..51c5821 --- /dev/null +++ b/src/core/gdpr.ts @@ -0,0 +1,89 @@ +/** + * Ledger GDPR - Pure Helpers + * + * ORM-agnostic GDPR compliance utilities. + */ + +/** + * Configuration for GDPR purge operation. + */ +export interface PurgeConfig { + /** Fields to remove from JSON data columns (defaults to common PII fields) */ + piiFields?: string[]; + /** Replacement value for userId (default: 'PURGED_USER') */ + anonymizedUserId?: string; +} + +/** + * Result of a GDPR purge operation. + */ +export interface PurgeResult { + /** Number of audit entries anonymized */ + entriesAnonymized: number; + /** Tables that had audit entries anonymized */ + tablesProcessed: string[]; +} + +/** Default PII fields to remove from JSON data */ +export const DEFAULT_PII_FIELDS = [ + "email", + "name", + "firstName", + "lastName", + "phone", + "address", + "ip", + "ipAddress", + "userAgent", +]; + +/** + * Remove PII fields from a JSON object. + * Recursively processes nested objects. + * + * @param data - The data to anonymize (can be null) + * @param piiFields - Fields to remove + * @returns Anonymized data with PII fields removed + * + * @example + * ```typescript + * const data = { id: '123', email: 'test@test.com', name: 'John', role: 'admin' }; + * const result = anonymizeJsonData(data, ['email', 'name']); + * // { id: '123', role: 'admin' } + * ``` + */ +export function anonymizeJsonData( + data: Record | null, + piiFields: string[], +): Record | null { + if (data === null) { + return null; + } + + const result: Record = {}; + const piiFieldsSet = new Set(piiFields); + + for (const [key, value] of Object.entries(data)) { + // Skip PII fields + if (piiFieldsSet.has(key)) { + continue; + } + + // Recursively process nested objects + if (value !== null && typeof value === "object" && !Array.isArray(value)) { + result[key] = anonymizeJsonData(value as Record, piiFields); + } else if (Array.isArray(value)) { + // Process arrays - anonymize objects within arrays + result[key] = value.map((item) => { + if (item !== null && typeof item === "object") { + return anonymizeJsonData(item as Record, piiFields); + } + return item; + }); + } else { + result[key] = value; + } + } + + return result; +} diff --git a/src/core/index.ts b/src/core/index.ts new file mode 100644 index 0000000..15a1c3a --- /dev/null +++ b/src/core/index.ts @@ -0,0 +1,49 @@ +/** + * Ledger Core + * + * ORM-agnostic core utilities for audit trail, soft-delete, and GDPR compliance. + * + * @packageDocumentation + */ + +// Types +export type { + AuditLogEntry, + LedgerConfig, + LedgerContext, + RestoreResult, + SoftDeleteOptions, + SoftDeleteResult, +} from "./types.js"; + +// Context +export { + createLedgerContext, + createSystemContext, + getLedgerContext, + hasLedgerContext, + runWithLedgerContext, +} from "./context.js"; + +// Soft-delete (pure helpers) +export { + isSoftDeleted, + restoreValues, + softDeleteValues, + type WithSoftDelete, + type WithSoftDeleteTimestamp, +} from "./soft-delete.js"; + +// Audit (pure helpers) +export { type AuditAction, type AuditEntryOptions, createAuditEntry } from "./audit.js"; + +// GDPR (pure helpers) +export { + anonymizeJsonData, + DEFAULT_PII_FIELDS, + type PurgeConfig, + type PurgeResult, +} from "./gdpr.js"; + +// Errors +export { isSoftDeletePerformed, SoftDeletePerformedError } from "./errors.js"; diff --git a/src/core/soft-delete.ts b/src/core/soft-delete.ts new file mode 100644 index 0000000..df16946 --- /dev/null +++ b/src/core/soft-delete.ts @@ -0,0 +1,62 @@ +/** + * Ledger Soft Delete - Pure Helpers + * + * ORM-agnostic helpers for soft-delete patterns. + * These functions have zero ORM dependencies. + */ + +/** + * Values to set when soft-deleting a record. + * + * @param deletedBy - Optional user ID who is deleting the record + * @returns Object with deletedAt set to current timestamp + */ +export function softDeleteValues(deletedBy?: string | null): { + deletedAt: Date; + deletedBy: string | null; +} { + return { + deletedAt: new Date(), + deletedBy: deletedBy ?? null, + }; +} + +/** + * Values to set when restoring a soft-deleted record. + * + * @returns Object with deletedAt and deletedBy set to null + */ +export function restoreValues(): { + deletedAt: null; + deletedBy: null; +} { + return { + deletedAt: null, + deletedBy: null, + }; +} + +/** + * Check if a record is soft-deleted. + * + * @param record - A record with a deletedAt field + * @returns true if the record is soft-deleted + */ +export function isSoftDeleted(record: { deletedAt: Date | null } | null | undefined): boolean { + return record?.deletedAt !== null && record?.deletedAt !== undefined; +} + +/** + * Type helper to add soft-delete columns to a table type. + */ +export type WithSoftDelete = T & { + deletedAt: Date | null; + deletedBy: string | null; +}; + +/** + * Type helper for minimal soft-delete (just timestamp). + */ +export type WithSoftDeleteTimestamp = T & { + deletedAt: Date | null; +}; diff --git a/src/core/types.ts b/src/core/types.ts new file mode 100644 index 0000000..0c755a4 --- /dev/null +++ b/src/core/types.ts @@ -0,0 +1,100 @@ +/** + * Drizzle Ledger Types + * + * Core type definitions for audit trail and soft-delete functionality. + */ + +/** + * Context passed through AsyncLocalStorage for audit logging. + * Contains information about who is performing the action and from where. + */ +export interface LedgerContext { + /** User ID performing the action (null for system/anonymous) */ + userId: string | null; + /** IP address of the request */ + ip: string | null; + /** User agent string */ + userAgent: string | null; + /** API endpoint or action identifier */ + endpoint: string | null; + /** Optional request ID for tracing */ + requestId?: string; + /** Optional additional metadata */ + metadata?: Record; +} + +/** + * Audit log entry representing a single database change. + */ +export interface AuditLogEntry { + /** Unique identifier for this audit entry */ + id: string; + /** Name of the table that was modified */ + tableName: string; + /** Primary key of the affected record */ + recordId: string; + /** Type of operation performed */ + action: "INSERT" | "UPDATE" | "DELETE" | "SOFT_DELETE" | "RESTORE"; + /** Data before the change (null for INSERT) */ + oldData: Record | null; + /** Data after the change (null for DELETE) */ + newData: Record | null; + /** User who performed the action */ + userId: string | null; + /** IP address of the request */ + ip: string | null; + /** User agent string */ + userAgent: string | null; + /** API endpoint or action identifier */ + endpoint: string | null; + /** Request ID for tracing */ + requestId: string | null; + /** When the change occurred */ + createdAt: Date; +} + +/** + * Configuration options for the ledger. + */ +export interface LedgerConfig { + /** Enable soft-delete functionality */ + softDelete?: boolean; + /** Enable audit trail logging */ + audit?: boolean; + /** Function to get the current context */ + getContext?: () => LedgerContext | null; + /** Tables to exclude from audit logging */ + excludeTables?: string[]; + /** Whether to log SELECT queries (default: false) */ + logSelects?: boolean; +} + +/** + * Options for soft-delete columns. + */ +export interface SoftDeleteOptions { + /** Column name for the deleted timestamp (default: 'deletedAt') */ + columnName?: string; + /** Column name for who deleted (optional) */ + deletedByColumn?: string; +} + +/** + * Result of a soft-delete operation. + */ +export interface SoftDeleteResult { + /** The soft-deleted record */ + record: T; + /** The audit log entry created */ + auditEntry: AuditLogEntry; +} + +/** + * Result of a restore operation. + */ +export interface RestoreResult { + /** The restored record */ + record: T; + /** The audit log entry created */ + auditEntry: AuditLogEntry; +} diff --git a/src/db.ts b/src/db.ts index 077cb25..f63416f 100644 --- a/src/db.ts +++ b/src/db.ts @@ -5,8 +5,8 @@ * convert delete() calls to soft-delete for tables with deletedAt column. */ -import { getLedgerContext } from "./context.js"; -import { softDeleteValues } from "./soft-delete/index.js"; +import { getLedgerContext } from "./core/context.js"; +import { softDeleteValues } from "./core/soft-delete.js"; /** * Configuration for createAuditedDb. diff --git a/src/gdpr.ts b/src/gdpr.ts index f441c33..63d53b3 100644 --- a/src/gdpr.ts +++ b/src/gdpr.ts @@ -20,93 +20,19 @@ import { eq, or } from "drizzle-orm"; import type { AuditLog } from "./schema/sqlite.js"; -/** - * Configuration for GDPR purge operation. - */ -export interface PurgeConfig { - /** Fields to remove from JSON data columns (defaults to common PII fields) */ - piiFields?: string[]; - /** Replacement value for userId (default: 'PURGED_USER') */ - anonymizedUserId?: string; -} +// Re-export pure helpers from core +export { + anonymizeJsonData, + DEFAULT_PII_FIELDS, + type PurgeConfig, + type PurgeResult, +} from "./core/gdpr.js"; -/** - * Result of a GDPR purge operation. - */ -export interface PurgeResult { - /** Number of audit entries anonymized */ - entriesAnonymized: number; - /** Tables that had audit entries anonymized */ - tablesProcessed: string[]; -} - -/** Default PII fields to remove from JSON data */ -const DEFAULT_PII_FIELDS = [ - "email", - "name", - "firstName", - "lastName", - "phone", - "address", - "ip", - "ipAddress", - "userAgent", -]; +import { anonymizeJsonData, DEFAULT_PII_FIELDS } from "./core/gdpr.js"; /** Default replacement value for userId */ const DEFAULT_ANONYMIZED_USER_ID = "PURGED_USER"; -/** - * Remove PII fields from a JSON object. - * Recursively processes nested objects. - * - * @param data - The data to anonymize (can be null) - * @param piiFields - Fields to remove - * @returns Anonymized data with PII fields removed - * - * @example - * ```typescript - * const data = { id: '123', email: 'test@test.com', name: 'John', role: 'admin' }; - * const result = anonymizeJsonData(data, ['email', 'name']); - * // { id: '123', role: 'admin' } - * ``` - */ -export function anonymizeJsonData( - data: Record | null, - piiFields: string[], -): Record | null { - if (data === null) { - return null; - } - - const result: Record = {}; - const piiFieldsSet = new Set(piiFields); - - for (const [key, value] of Object.entries(data)) { - // Skip PII fields - if (piiFieldsSet.has(key)) { - continue; - } - - // Recursively process nested objects - if (value !== null && typeof value === "object" && !Array.isArray(value)) { - result[key] = anonymizeJsonData(value as Record, piiFields); - } else if (Array.isArray(value)) { - // Process arrays - anonymize objects within arrays - result[key] = value.map((item) => { - if (item !== null && typeof item === "object") { - return anonymizeJsonData(item as Record, piiFields); - } - return item; - }); - } else { - result[key] = value; - } - } - - return result; -} - /** * Safely parse JSON string, returning null on error. */ @@ -159,8 +85,8 @@ export async function purgeUserData( db: DrizzleDb, auditTable: AuditLog, userId: string, - config?: PurgeConfig, -): Promise { + config?: import("./core/gdpr.js").PurgeConfig, +): Promise { const piiFields = config?.piiFields ?? DEFAULT_PII_FIELDS; const anonymizedUserId = config?.anonymizedUserId ?? DEFAULT_ANONYMIZED_USER_ID; diff --git a/src/logger.ts b/src/logger.ts index b7d7609..c27e328 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -6,7 +6,7 @@ */ import type { Logger } from "drizzle-orm"; -import { getLedgerContext } from "./context.js"; +import { getLedgerContext } from "./core/context.js"; /** * Parsed query information. diff --git a/src/soft-delete/index.ts b/src/soft-delete/index.ts index 8ed33c0..a08e9b8 100644 --- a/src/soft-delete/index.ts +++ b/src/soft-delete/index.ts @@ -10,6 +10,15 @@ import { type Column, isNotNull, isNull, type SQL, sql } from "drizzle-orm"; +// Re-export pure helpers from core +export { + isSoftDeleted, + restoreValues, + softDeleteValues, + type WithSoftDelete, + type WithSoftDeleteTimestamp, +} from "../core/soft-delete.js"; + /** * Filter condition to exclude soft-deleted records. * Use this in your WHERE clauses to only get active records. @@ -51,59 +60,3 @@ export function onlyDeleted(table: T): SQL { export function includingDeleted(): SQL { return sql`1=1`; } - -/** - * Values to set when soft-deleting a record. - * - * @param deletedBy - Optional user ID who is deleting the record - * @returns Object with deletedAt set to current timestamp - */ -export function softDeleteValues(deletedBy?: string | null): { - deletedAt: Date; - deletedBy: string | null; -} { - return { - deletedAt: new Date(), - deletedBy: deletedBy ?? null, - }; -} - -/** - * Values to set when restoring a soft-deleted record. - * - * @returns Object with deletedAt and deletedBy set to null - */ -export function restoreValues(): { - deletedAt: null; - deletedBy: null; -} { - return { - deletedAt: null, - deletedBy: null, - }; -} - -/** - * Check if a record is soft-deleted. - * - * @param record - A record with a deletedAt field - * @returns true if the record is soft-deleted - */ -export function isSoftDeleted(record: { deletedAt: Date | null } | null | undefined): boolean { - return record?.deletedAt !== null && record?.deletedAt !== undefined; -} - -/** - * Type helper to add soft-delete columns to a table type. - */ -export type WithSoftDelete = T & { - deletedAt: Date | null; - deletedBy: string | null; -}; - -/** - * Type helper for minimal soft-delete (just timestamp). - */ -export type WithSoftDeleteTimestamp = T & { - deletedAt: Date | null; -}; diff --git a/src/types.ts b/src/types.ts index 0c755a4..165c171 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,100 +1,14 @@ /** * Drizzle Ledger Types * - * Core type definitions for audit trail and soft-delete functionality. + * Re-exports from core for backwards compatibility. */ -/** - * Context passed through AsyncLocalStorage for audit logging. - * Contains information about who is performing the action and from where. - */ -export interface LedgerContext { - /** User ID performing the action (null for system/anonymous) */ - userId: string | null; - /** IP address of the request */ - ip: string | null; - /** User agent string */ - userAgent: string | null; - /** API endpoint or action identifier */ - endpoint: string | null; - /** Optional request ID for tracing */ - requestId?: string; - /** Optional additional metadata */ - metadata?: Record; -} - -/** - * Audit log entry representing a single database change. - */ -export interface AuditLogEntry { - /** Unique identifier for this audit entry */ - id: string; - /** Name of the table that was modified */ - tableName: string; - /** Primary key of the affected record */ - recordId: string; - /** Type of operation performed */ - action: "INSERT" | "UPDATE" | "DELETE" | "SOFT_DELETE" | "RESTORE"; - /** Data before the change (null for INSERT) */ - oldData: Record | null; - /** Data after the change (null for DELETE) */ - newData: Record | null; - /** User who performed the action */ - userId: string | null; - /** IP address of the request */ - ip: string | null; - /** User agent string */ - userAgent: string | null; - /** API endpoint or action identifier */ - endpoint: string | null; - /** Request ID for tracing */ - requestId: string | null; - /** When the change occurred */ - createdAt: Date; -} - -/** - * Configuration options for the ledger. - */ -export interface LedgerConfig { - /** Enable soft-delete functionality */ - softDelete?: boolean; - /** Enable audit trail logging */ - audit?: boolean; - /** Function to get the current context */ - getContext?: () => LedgerContext | null; - /** Tables to exclude from audit logging */ - excludeTables?: string[]; - /** Whether to log SELECT queries (default: false) */ - logSelects?: boolean; -} - -/** - * Options for soft-delete columns. - */ -export interface SoftDeleteOptions { - /** Column name for the deleted timestamp (default: 'deletedAt') */ - columnName?: string; - /** Column name for who deleted (optional) */ - deletedByColumn?: string; -} - -/** - * Result of a soft-delete operation. - */ -export interface SoftDeleteResult { - /** The soft-deleted record */ - record: T; - /** The audit log entry created */ - auditEntry: AuditLogEntry; -} - -/** - * Result of a restore operation. - */ -export interface RestoreResult { - /** The restored record */ - record: T; - /** The audit log entry created */ - auditEntry: AuditLogEntry; -} +export type { + AuditLogEntry, + LedgerConfig, + LedgerContext, + RestoreResult, + SoftDeleteOptions, + SoftDeleteResult, +} from "./core/types.js"; From c400bf838aa06c22089587a089f106e3365016b5 Mon Sep 17 00:00:00 2001 From: Sean Silvius Date: Mon, 13 Apr 2026 14:13:05 -0700 Subject: [PATCH 2/5] Move Drizzle-coupled code into src/drizzle/ adapter Co-Authored-By: Claude Opus 4.6 (1M context) --- src/context.ts | 13 ---- src/{ => drizzle}/audit.ts | 17 ++-- src/{ => drizzle}/db.ts | 4 +- src/{ => drizzle}/gdpr.ts | 29 ++----- src/drizzle/index.ts | 64 +++++++++++++++ src/{ => drizzle}/logger.ts | 2 +- src/{ => drizzle}/schema/index.ts | 0 src/{ => drizzle}/schema/mysql.ts | 0 src/{ => drizzle}/schema/pg.ts | 0 src/{ => drizzle}/schema/sqlite.ts | 0 .../index.ts => drizzle/soft-delete.ts} | 12 +-- src/{ => drizzle}/soft-delete/mysql.ts | 0 src/{ => drizzle}/soft-delete/pg.ts | 0 src/{ => drizzle}/soft-delete/sqlite.ts | 0 src/index.ts | 77 +------------------ src/types.ts | 14 ---- 16 files changed, 91 insertions(+), 141 deletions(-) delete mode 100644 src/context.ts rename src/{ => drizzle}/audit.ts (93%) rename src/{ => drizzle}/db.ts (96%) rename src/{ => drizzle}/gdpr.ts (85%) create mode 100644 src/drizzle/index.ts rename src/{ => drizzle}/logger.ts (98%) rename src/{ => drizzle}/schema/index.ts (100%) rename src/{ => drizzle}/schema/mysql.ts (100%) rename src/{ => drizzle}/schema/pg.ts (100%) rename src/{ => drizzle}/schema/sqlite.ts (100%) rename src/{soft-delete/index.ts => drizzle/soft-delete.ts} (84%) rename src/{ => drizzle}/soft-delete/mysql.ts (100%) rename src/{ => drizzle}/soft-delete/pg.ts (100%) rename src/{ => drizzle}/soft-delete/sqlite.ts (100%) delete mode 100644 src/types.ts diff --git a/src/context.ts b/src/context.ts deleted file mode 100644 index e4bfdbe..0000000 --- a/src/context.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * Drizzle Ledger Context - * - * Re-exports from core for backwards compatibility. - */ - -export { - createLedgerContext, - createSystemContext, - getLedgerContext, - hasLedgerContext, - runWithLedgerContext, -} from "./core/context.js"; diff --git a/src/audit.ts b/src/drizzle/audit.ts similarity index 93% rename from src/audit.ts rename to src/drizzle/audit.ts index ccf2146..fa68a57 100644 --- a/src/audit.ts +++ b/src/drizzle/audit.ts @@ -1,15 +1,15 @@ /** - * Drizzle Ledger Audit + * Ledger Audit - Drizzle Adapter * - * Functions for logging database changes to an audit trail. - * Automatically captures context from AsyncLocalStorage. + * Drizzle-coupled functions for inserting and querying audit entries. */ +import { createAuditEntry } from "../core/audit.js"; +import type { AuditLogEntry } from "../core/types.js"; import type { auditLog } from "./schema/sqlite.js"; -import type { AuditLogEntry } from "./core/types.js"; -// Re-export pure helpers from core -export { type AuditAction, type AuditEntryOptions, createAuditEntry } from "./core/audit.js"; +// Re-export pure helpers from core for convenience +export { type AuditAction, type AuditEntryOptions, createAuditEntry } from "../core/audit.js"; /** * Insert an audit log entry into the database. @@ -74,7 +74,6 @@ export async function logInsert( recordId: string, newData: Record, ): Promise { - const { createAuditEntry } = await import("./core/audit.js"); const entry = createAuditEntry({ tableName, recordId, @@ -112,7 +111,6 @@ export async function logUpdate( oldData: Record, newData: Record, ): Promise { - const { createAuditEntry } = await import("./core/audit.js"); const entry = createAuditEntry({ tableName, recordId, @@ -148,7 +146,6 @@ export async function logDelete( recordId: string, oldData: Record, ): Promise { - const { createAuditEntry } = await import("./core/audit.js"); const entry = createAuditEntry({ tableName, recordId, @@ -188,7 +185,6 @@ export async function logSoftDelete( oldData: Record, newData: Record, ): Promise { - const { createAuditEntry } = await import("./core/audit.js"); const entry = createAuditEntry({ tableName, recordId, @@ -228,7 +224,6 @@ export async function logRestore( oldData: Record, newData: Record, ): Promise { - const { createAuditEntry } = await import("./core/audit.js"); const entry = createAuditEntry({ tableName, recordId, diff --git a/src/db.ts b/src/drizzle/db.ts similarity index 96% rename from src/db.ts rename to src/drizzle/db.ts index f63416f..80eadda 100644 --- a/src/db.ts +++ b/src/drizzle/db.ts @@ -5,8 +5,8 @@ * convert delete() calls to soft-delete for tables with deletedAt column. */ -import { getLedgerContext } from "./core/context.js"; -import { softDeleteValues } from "./core/soft-delete.js"; +import { getLedgerContext } from "../core/context.js"; +import { softDeleteValues } from "../core/soft-delete.js"; /** * Configuration for createAuditedDb. diff --git a/src/gdpr.ts b/src/drizzle/gdpr.ts similarity index 85% rename from src/gdpr.ts rename to src/drizzle/gdpr.ts index 63d53b3..5df0ec0 100644 --- a/src/gdpr.ts +++ b/src/drizzle/gdpr.ts @@ -1,34 +1,21 @@ /** - * Drizzle Ledger GDPR Purge + * Ledger GDPR - Drizzle Adapter * - * GDPR-compliant user data purge that anonymizes audit logs - * without deleting the audit trail. - * - * @example - * ```typescript - * import { purgeUserData } from '@rafters/ledger/gdpr'; - * - * // Anonymize all user data in audit logs - * const result = await purgeUserData(db, auditLog, 'user-123', { - * piiFields: ['email', 'name', 'ip', 'address', 'phone'], - * }); - * - * console.log(`Anonymized ${result.entriesAnonymized} audit entries`); - * ``` + * Drizzle-coupled GDPR purge functions. */ import { eq, or } from "drizzle-orm"; +import { anonymizeJsonData, DEFAULT_PII_FIELDS } from "../core/gdpr.js"; +import type { PurgeConfig, PurgeResult } from "../core/gdpr.js"; import type { AuditLog } from "./schema/sqlite.js"; -// Re-export pure helpers from core +// Re-export pure helpers from core for convenience export { anonymizeJsonData, DEFAULT_PII_FIELDS, type PurgeConfig, type PurgeResult, -} from "./core/gdpr.js"; - -import { anonymizeJsonData, DEFAULT_PII_FIELDS } from "./core/gdpr.js"; +} from "../core/gdpr.js"; /** Default replacement value for userId */ const DEFAULT_ANONYMIZED_USER_ID = "PURGED_USER"; @@ -85,8 +72,8 @@ export async function purgeUserData( db: DrizzleDb, auditTable: AuditLog, userId: string, - config?: import("./core/gdpr.js").PurgeConfig, -): Promise { + config?: PurgeConfig, +): Promise { const piiFields = config?.piiFields ?? DEFAULT_PII_FIELDS; const anonymizedUserId = config?.anonymizedUserId ?? DEFAULT_ANONYMIZED_USER_ID; diff --git a/src/drizzle/index.ts b/src/drizzle/index.ts new file mode 100644 index 0000000..2d7b575 --- /dev/null +++ b/src/drizzle/index.ts @@ -0,0 +1,64 @@ +/** + * Ledger Drizzle Adapter + * + * Drizzle ORM-coupled utilities for audit trail, soft-delete, and GDPR compliance. + * + * @packageDocumentation + */ + +// Audit (Drizzle-coupled) +export { + type AuditAction, + type AuditEntryOptions, + createAuditEntry, + getRecordHistory, + insertAuditEntry, + logDelete, + logInsert, + logRestore, + logSoftDelete, + logUpdate, +} from "./audit.js"; + +// Audited Database +export { type AuditedDbConfig, createAuditedDb, getTableName, hasColumn } from "./db.js"; + +// GDPR (Drizzle-coupled) +export { + anonymizeJsonData, + DEFAULT_PII_FIELDS, + isUserDataPurged, + type PurgeConfig, + type PurgeResult, + purgeUserData, +} from "./gdpr.js"; + +// Logger +export { + type AuditEntryInput, + AuditLogger, + type AuditLoggerConfig, + extractRecordId, + type ParsedQuery, + parseQuery, +} from "./logger.js"; + +// Schema (SQLite default) +export { + AUDIT_LOG_INDEXES, + type AuditLog, + type AuditLogInsert, + type AuditLogSelect, +} from "./schema/index.js"; + +// Soft-delete (Drizzle query filters + pure helpers) +export { + includingDeleted, + isSoftDeleted, + notDeleted, + onlyDeleted, + restoreValues, + softDeleteValues, + type WithSoftDelete, + type WithSoftDeleteTimestamp, +} from "./soft-delete.js"; diff --git a/src/logger.ts b/src/drizzle/logger.ts similarity index 98% rename from src/logger.ts rename to src/drizzle/logger.ts index c27e328..9131f04 100644 --- a/src/logger.ts +++ b/src/drizzle/logger.ts @@ -6,7 +6,7 @@ */ import type { Logger } from "drizzle-orm"; -import { getLedgerContext } from "./core/context.js"; +import { getLedgerContext } from "../core/context.js"; /** * Parsed query information. diff --git a/src/schema/index.ts b/src/drizzle/schema/index.ts similarity index 100% rename from src/schema/index.ts rename to src/drizzle/schema/index.ts diff --git a/src/schema/mysql.ts b/src/drizzle/schema/mysql.ts similarity index 100% rename from src/schema/mysql.ts rename to src/drizzle/schema/mysql.ts diff --git a/src/schema/pg.ts b/src/drizzle/schema/pg.ts similarity index 100% rename from src/schema/pg.ts rename to src/drizzle/schema/pg.ts diff --git a/src/schema/sqlite.ts b/src/drizzle/schema/sqlite.ts similarity index 100% rename from src/schema/sqlite.ts rename to src/drizzle/schema/sqlite.ts diff --git a/src/soft-delete/index.ts b/src/drizzle/soft-delete.ts similarity index 84% rename from src/soft-delete/index.ts rename to src/drizzle/soft-delete.ts index a08e9b8..13a2252 100644 --- a/src/soft-delete/index.ts +++ b/src/drizzle/soft-delete.ts @@ -1,16 +1,16 @@ /** - * Drizzle Ledger Soft Delete + * Ledger Soft Delete - Drizzle Adapter * - * Dialect-agnostic helpers for implementing soft-delete patterns in Drizzle. + * Drizzle ORM query filters for soft-delete patterns. * For column definitions, import from the dialect-specific module: - * - ledger/soft-delete/sqlite - * - ledger/soft-delete/pg - * - ledger/soft-delete/mysql + * - ledger/drizzle/soft-delete/sqlite + * - ledger/drizzle/soft-delete/pg + * - ledger/drizzle/soft-delete/mysql */ import { type Column, isNotNull, isNull, type SQL, sql } from "drizzle-orm"; -// Re-export pure helpers from core +// Re-export pure helpers from core for convenience export { isSoftDeleted, restoreValues, diff --git a/src/soft-delete/mysql.ts b/src/drizzle/soft-delete/mysql.ts similarity index 100% rename from src/soft-delete/mysql.ts rename to src/drizzle/soft-delete/mysql.ts diff --git a/src/soft-delete/pg.ts b/src/drizzle/soft-delete/pg.ts similarity index 100% rename from src/soft-delete/pg.ts rename to src/drizzle/soft-delete/pg.ts diff --git a/src/soft-delete/sqlite.ts b/src/drizzle/soft-delete/sqlite.ts similarity index 100% rename from src/soft-delete/sqlite.ts rename to src/drizzle/soft-delete/sqlite.ts diff --git a/src/index.ts b/src/index.ts index 1807aa6..e649bcc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -39,22 +39,11 @@ * ``` */ -// Audit -export { - type AuditAction, - type AuditEntryOptions, - createAuditEntry, - getRecordHistory, - insertAuditEntry, - logDelete, - logInsert, - logRestore, - logSoftDelete, - logUpdate, -} from "./audit.js"; +// Core (ORM-agnostic) +export * from "./core/index.js"; -// Audited Database -export { type AuditedDbConfig, createAuditedDb, getTableName, hasColumn } from "./db.js"; +// Drizzle adapter +export * from "./drizzle/index.js"; // Better Auth Plugin export { @@ -67,61 +56,3 @@ export { type SoftDeleteCallbackOptions, SoftDeletePerformedError, } from "./better-auth.js"; - -// Context -export { - createLedgerContext, - createSystemContext, - getLedgerContext, - hasLedgerContext, - runWithLedgerContext, -} from "./context.js"; - -// GDPR -export { - anonymizeJsonData, - isUserDataPurged, - type PurgeConfig, - type PurgeResult, - purgeUserData, -} from "./gdpr.js"; - -// Logger -export { - type AuditEntryInput, - AuditLogger, - type AuditLoggerConfig, - extractRecordId, - type ParsedQuery, - parseQuery, -} from "./logger.js"; - -// Schema (SQLite default) -export { - AUDIT_LOG_INDEXES, - type AuditLog, - type AuditLogInsert, - type AuditLogSelect, -} from "./schema/index.js"; - -// Soft-delete (dialect-agnostic helpers) -export { - includingDeleted, - isSoftDeleted, - notDeleted, - onlyDeleted, - restoreValues, - softDeleteValues, - type WithSoftDelete, - type WithSoftDeleteTimestamp, -} from "./soft-delete/index.js"; - -// Types -export type { - AuditLogEntry, - LedgerConfig, - LedgerContext, - RestoreResult, - SoftDeleteOptions, - SoftDeleteResult, -} from "./types.js"; diff --git a/src/types.ts b/src/types.ts deleted file mode 100644 index 165c171..0000000 --- a/src/types.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** - * Drizzle Ledger Types - * - * Re-exports from core for backwards compatibility. - */ - -export type { - AuditLogEntry, - LedgerConfig, - LedgerContext, - RestoreResult, - SoftDeleteOptions, - SoftDeleteResult, -} from "./core/types.js"; From 31f89efbc5e620f82fe1c60c419e9706e8bce4aa Mon Sep 17 00:00:00 2001 From: Sean Silvius Date: Mon, 13 Apr 2026 14:14:39 -0700 Subject: [PATCH 3/5] Update tsup and package.json exports for core/drizzle split Co-Authored-By: Claude Opus 4.6 (1M context) --- package.json | 133 ++++++++++++++++--------------------------------- tsup.config.ts | 43 +++++++--------- 2 files changed, 62 insertions(+), 114 deletions(-) diff --git a/package.json b/package.json index a6f3a2e..9ae2c9a 100644 --- a/package.json +++ b/package.json @@ -14,134 +14,84 @@ "default": "./dist/index.cjs" } }, - "./soft-delete": { + "./drizzle": { "import": { - "types": "./dist/soft-delete/index.d.ts", - "default": "./dist/soft-delete/index.js" + "types": "./dist/drizzle/index.d.ts", + "default": "./dist/drizzle/index.js" }, "require": { - "types": "./dist/soft-delete/index.d.cts", - "default": "./dist/soft-delete/index.cjs" + "types": "./dist/drizzle/index.d.cts", + "default": "./dist/drizzle/index.cjs" } }, - "./soft-delete/sqlite": { + "./drizzle/schema": { "import": { - "types": "./dist/soft-delete/sqlite.d.ts", - "default": "./dist/soft-delete/sqlite.js" + "types": "./dist/drizzle/schema/index.d.ts", + "default": "./dist/drizzle/schema/index.js" }, "require": { - "types": "./dist/soft-delete/sqlite.d.cts", - "default": "./dist/soft-delete/sqlite.cjs" + "types": "./dist/drizzle/schema/index.d.cts", + "default": "./dist/drizzle/schema/index.cjs" } }, - "./soft-delete/pg": { + "./drizzle/schema/sqlite": { "import": { - "types": "./dist/soft-delete/pg.d.ts", - "default": "./dist/soft-delete/pg.js" + "types": "./dist/drizzle/schema/sqlite.d.ts", + "default": "./dist/drizzle/schema/sqlite.js" }, "require": { - "types": "./dist/soft-delete/pg.d.cts", - "default": "./dist/soft-delete/pg.cjs" + "types": "./dist/drizzle/schema/sqlite.d.cts", + "default": "./dist/drizzle/schema/sqlite.cjs" } }, - "./soft-delete/mysql": { + "./drizzle/schema/pg": { "import": { - "types": "./dist/soft-delete/mysql.d.ts", - "default": "./dist/soft-delete/mysql.js" + "types": "./dist/drizzle/schema/pg.d.ts", + "default": "./dist/drizzle/schema/pg.js" }, "require": { - "types": "./dist/soft-delete/mysql.d.cts", - "default": "./dist/soft-delete/mysql.cjs" + "types": "./dist/drizzle/schema/pg.d.cts", + "default": "./dist/drizzle/schema/pg.cjs" } }, - "./schema": { + "./drizzle/schema/mysql": { "import": { - "types": "./dist/schema/index.d.ts", - "default": "./dist/schema/index.js" + "types": "./dist/drizzle/schema/mysql.d.ts", + "default": "./dist/drizzle/schema/mysql.js" }, "require": { - "types": "./dist/schema/index.d.cts", - "default": "./dist/schema/index.cjs" + "types": "./dist/drizzle/schema/mysql.d.cts", + "default": "./dist/drizzle/schema/mysql.cjs" } }, - "./schema/sqlite": { + "./drizzle/soft-delete/sqlite": { "import": { - "types": "./dist/schema/sqlite.d.ts", - "default": "./dist/schema/sqlite.js" + "types": "./dist/drizzle/soft-delete/sqlite.d.ts", + "default": "./dist/drizzle/soft-delete/sqlite.js" }, "require": { - "types": "./dist/schema/sqlite.d.cts", - "default": "./dist/schema/sqlite.cjs" + "types": "./dist/drizzle/soft-delete/sqlite.d.cts", + "default": "./dist/drizzle/soft-delete/sqlite.cjs" } }, - "./schema/pg": { + "./drizzle/soft-delete/pg": { "import": { - "types": "./dist/schema/pg.d.ts", - "default": "./dist/schema/pg.js" + "types": "./dist/drizzle/soft-delete/pg.d.ts", + "default": "./dist/drizzle/soft-delete/pg.js" }, "require": { - "types": "./dist/schema/pg.d.cts", - "default": "./dist/schema/pg.cjs" + "types": "./dist/drizzle/soft-delete/pg.d.cts", + "default": "./dist/drizzle/soft-delete/pg.cjs" } }, - "./schema/mysql": { + "./drizzle/soft-delete/mysql": { "import": { - "types": "./dist/schema/mysql.d.ts", - "default": "./dist/schema/mysql.js" + "types": "./dist/drizzle/soft-delete/mysql.d.ts", + "default": "./dist/drizzle/soft-delete/mysql.js" }, "require": { - "types": "./dist/schema/mysql.d.cts", - "default": "./dist/schema/mysql.cjs" - } - }, - "./audit": { - "import": { - "types": "./dist/audit.d.ts", - "default": "./dist/audit.js" - }, - "require": { - "types": "./dist/audit.d.cts", - "default": "./dist/audit.cjs" - } - }, - "./context": { - "import": { - "types": "./dist/context.d.ts", - "default": "./dist/context.js" - }, - "require": { - "types": "./dist/context.d.cts", - "default": "./dist/context.cjs" - } - }, - "./db": { - "import": { - "types": "./dist/db.d.ts", - "default": "./dist/db.js" - }, - "require": { - "types": "./dist/db.d.cts", - "default": "./dist/db.cjs" - } - }, - "./gdpr": { - "import": { - "types": "./dist/gdpr.d.ts", - "default": "./dist/gdpr.js" - }, - "require": { - "types": "./dist/gdpr.d.cts", - "default": "./dist/gdpr.cjs" - } - }, - "./logger": { - "import": { - "types": "./dist/logger.d.ts", - "default": "./dist/logger.js" - }, - "require": { - "types": "./dist/logger.d.cts", - "default": "./dist/logger.cjs" + "types": "./dist/drizzle/soft-delete/mysql.d.cts", + "default": "./dist/drizzle/soft-delete/mysql.cjs" } }, "./better-auth": { @@ -208,6 +158,9 @@ "peerDependenciesMeta": { "better-auth": { "optional": true + }, + "drizzle-orm": { + "optional": true } }, "dependencies": { diff --git a/tsup.config.ts b/tsup.config.ts index 198b819..43a4806 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -1,27 +1,22 @@ -import { defineConfig } from 'tsup'; +import { defineConfig } from "tsup"; export default defineConfig({ - entry: { - index: 'src/index.ts', - 'soft-delete/index': 'src/soft-delete/index.ts', - 'soft-delete/sqlite': 'src/soft-delete/sqlite.ts', - 'soft-delete/pg': 'src/soft-delete/pg.ts', - 'soft-delete/mysql': 'src/soft-delete/mysql.ts', - 'schema/index': 'src/schema/index.ts', - 'schema/sqlite': 'src/schema/sqlite.ts', - 'schema/pg': 'src/schema/pg.ts', - 'schema/mysql': 'src/schema/mysql.ts', - audit: 'src/audit.ts', - context: 'src/context.ts', - db: 'src/db.ts', - gdpr: 'src/gdpr.ts', - logger: 'src/logger.ts', - 'better-auth': 'src/better-auth.ts', - }, - format: ['esm', 'cjs'], - dts: true, - clean: true, - splitting: false, - sourcemap: true, - external: ['drizzle-orm', 'better-auth'], + entry: { + index: "src/core/index.ts", + "drizzle/index": "src/drizzle/index.ts", + "drizzle/schema/index": "src/drizzle/schema/index.ts", + "drizzle/schema/sqlite": "src/drizzle/schema/sqlite.ts", + "drizzle/schema/pg": "src/drizzle/schema/pg.ts", + "drizzle/schema/mysql": "src/drizzle/schema/mysql.ts", + "drizzle/soft-delete/sqlite": "src/drizzle/soft-delete/sqlite.ts", + "drizzle/soft-delete/pg": "src/drizzle/soft-delete/pg.ts", + "drizzle/soft-delete/mysql": "src/drizzle/soft-delete/mysql.ts", + "better-auth": "src/better-auth.ts", + }, + format: ["esm", "cjs"], + dts: true, + clean: true, + splitting: false, + sourcemap: true, + external: ["drizzle-orm", "better-auth"], }); From 6a6045f157eae6d55ecde8c364622d775cc4228c Mon Sep 17 00:00:00 2001 From: Sean Silvius Date: Mon, 13 Apr 2026 14:19:29 -0700 Subject: [PATCH 4/5] Update tests for core/drizzle module structure Split and move test files to mirror the src/core and src/drizzle layout. Tests for pure helpers (context, soft-delete values, anonymizeJsonData) now live under test/core/, while Drizzle-coupled tests (db, logger, query filters, purgeUserData) live under test/drizzle/. Vitest aliases updated to match the new package export paths. All 112 tests pass unchanged. Co-Authored-By: Claude Opus 4.6 (1M context) --- test/{ => core}/context.test.ts | 2 +- test/core/gdpr.test.ts | 106 +++++++++++++++++++++++++ test/core/soft-delete.test.ts | 44 ++++++++++ test/{ => drizzle}/db.test.ts | 4 +- test/{ => drizzle}/gdpr.test.ts | 106 +------------------------ test/{ => drizzle}/logger.test.ts | 9 ++- test/{ => drizzle}/soft-delete.test.ts | 57 +------------ vitest.config.ts | 31 +++++--- 8 files changed, 183 insertions(+), 176 deletions(-) rename test/{ => core}/context.test.ts (98%) create mode 100644 test/core/gdpr.test.ts create mode 100644 test/core/soft-delete.test.ts rename test/{ => drizzle}/db.test.ts (97%) rename test/{ => drizzle}/gdpr.test.ts (84%) rename test/{ => drizzle}/logger.test.ts (97%) rename test/{ => drizzle}/soft-delete.test.ts (71%) diff --git a/test/context.test.ts b/test/core/context.test.ts similarity index 98% rename from test/context.test.ts rename to test/core/context.test.ts index 8fc73ba..873b2a3 100644 --- a/test/context.test.ts +++ b/test/core/context.test.ts @@ -5,7 +5,7 @@ import { getLedgerContext, hasLedgerContext, runWithLedgerContext, -} from "../src/context.js"; +} from "../../src/core/context.js"; describe("createLedgerContext", () => { test("creates context with all fields", () => { diff --git a/test/core/gdpr.test.ts b/test/core/gdpr.test.ts new file mode 100644 index 0000000..8fae273 --- /dev/null +++ b/test/core/gdpr.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, test } from "vitest"; +import { anonymizeJsonData } from "../../src/core/gdpr.js"; + +describe("anonymizeJsonData", () => { + test("removes specified PII fields", () => { + const data = { id: "123", email: "test@test.com", name: "John", role: "admin" }; + const result = anonymizeJsonData(data, ["email", "name"]); + + expect(result).toEqual({ id: "123", role: "admin" }); + }); + + test("handles null input", () => { + expect(anonymizeJsonData(null, ["email"])).toBeNull(); + }); + + test("handles nested objects", () => { + const data = { user: { email: "test@test.com", id: "123" } }; + const result = anonymizeJsonData(data, ["email"]); + + expect(result).toEqual({ user: { id: "123" } }); + }); + + test("handles deeply nested objects", () => { + const data = { + level1: { + level2: { + email: "deep@test.com", + keepMe: "value", + }, + }, + }; + const result = anonymizeJsonData(data, ["email"]); + + expect(result).toEqual({ + level1: { + level2: { + keepMe: "value", + }, + }, + }); + }); + + test("handles arrays of objects", () => { + const data = { + users: [ + { id: "1", email: "a@test.com" }, + { id: "2", email: "b@test.com" }, + ], + }; + const result = anonymizeJsonData(data, ["email"]); + + expect(result).toEqual({ + users: [{ id: "1" }, { id: "2" }], + }); + }); + + test("handles arrays of primitives", () => { + const data = { tags: ["tag1", "tag2"], email: "remove@test.com" }; + const result = anonymizeJsonData(data, ["email"]); + + expect(result).toEqual({ tags: ["tag1", "tag2"] }); + }); + + test("handles empty object", () => { + expect(anonymizeJsonData({}, ["email"])).toEqual({}); + }); + + test("preserves non-PII fields", () => { + const data = { + id: "user-123", + createdAt: "2024-01-01", + role: "admin", + email: "remove@test.com", + }; + const result = anonymizeJsonData(data, ["email"]); + + expect(result).toEqual({ + id: "user-123", + createdAt: "2024-01-01", + role: "admin", + }); + }); + + test("handles mixed nested and top-level PII", () => { + const data = { + email: "top@test.com", + profile: { + name: "John", + address: "123 Street", + settings: { + phone: "555-1234", + theme: "dark", + }, + }, + }; + const result = anonymizeJsonData(data, ["email", "name", "address", "phone"]); + + expect(result).toEqual({ + profile: { + settings: { + theme: "dark", + }, + }, + }); + }); +}); diff --git a/test/core/soft-delete.test.ts b/test/core/soft-delete.test.ts new file mode 100644 index 0000000..24f26e1 --- /dev/null +++ b/test/core/soft-delete.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, test } from "vitest"; +import { isSoftDeleted, restoreValues, softDeleteValues } from "../../src/core/soft-delete.js"; + +describe("softDeleteValues", () => { + test("returns current timestamp and null deletedBy", () => { + const values = softDeleteValues(); + expect(values.deletedAt).toBeInstanceOf(Date); + expect(values.deletedBy).toBeNull(); + }); + + test("includes deletedBy when provided", () => { + const values = softDeleteValues("user-123"); + expect(values.deletedAt).toBeInstanceOf(Date); + expect(values.deletedBy).toBe("user-123"); + }); + + test("handles null deletedBy", () => { + const values = softDeleteValues(null); + expect(values.deletedBy).toBeNull(); + }); +}); + +describe("restoreValues", () => { + test("returns null for both fields", () => { + const values = restoreValues(); + expect(values.deletedAt).toBeNull(); + expect(values.deletedBy).toBeNull(); + }); +}); + +describe("isSoftDeleted", () => { + test("returns true for deleted record", () => { + expect(isSoftDeleted({ deletedAt: new Date() })).toBe(true); + }); + + test("returns false for active record", () => { + expect(isSoftDeleted({ deletedAt: null })).toBe(false); + }); + + test("returns false for null/undefined", () => { + expect(isSoftDeleted(null)).toBe(false); + expect(isSoftDeleted(undefined)).toBe(false); + }); +}); diff --git a/test/db.test.ts b/test/drizzle/db.test.ts similarity index 97% rename from test/db.test.ts rename to test/drizzle/db.test.ts index 045ec9e..95fa73e 100644 --- a/test/db.test.ts +++ b/test/drizzle/db.test.ts @@ -1,7 +1,7 @@ import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; import { describe, expect, test, vi } from "vitest"; -import { createAuditedDb, getTableName, hasColumn } from "../src/db.js"; -import { createLedgerContext, runWithLedgerContext } from "../src/context.js"; +import { createAuditedDb, getTableName, hasColumn } from "../../src/drizzle/db.js"; +import { createLedgerContext, runWithLedgerContext } from "../../src/core/context.js"; // Test tables const usersWithSoftDelete = sqliteTable("users", { diff --git a/test/gdpr.test.ts b/test/drizzle/gdpr.test.ts similarity index 84% rename from test/gdpr.test.ts rename to test/drizzle/gdpr.test.ts index 6dbbe4c..8516136 100644 --- a/test/gdpr.test.ts +++ b/test/drizzle/gdpr.test.ts @@ -1,109 +1,5 @@ import { describe, expect, test, vi } from "vitest"; -import { anonymizeJsonData, isUserDataPurged, purgeUserData } from "../src/gdpr.js"; - -describe("anonymizeJsonData", () => { - test("removes specified PII fields", () => { - const data = { id: "123", email: "test@test.com", name: "John", role: "admin" }; - const result = anonymizeJsonData(data, ["email", "name"]); - - expect(result).toEqual({ id: "123", role: "admin" }); - }); - - test("handles null input", () => { - expect(anonymizeJsonData(null, ["email"])).toBeNull(); - }); - - test("handles nested objects", () => { - const data = { user: { email: "test@test.com", id: "123" } }; - const result = anonymizeJsonData(data, ["email"]); - - expect(result).toEqual({ user: { id: "123" } }); - }); - - test("handles deeply nested objects", () => { - const data = { - level1: { - level2: { - email: "deep@test.com", - keepMe: "value", - }, - }, - }; - const result = anonymizeJsonData(data, ["email"]); - - expect(result).toEqual({ - level1: { - level2: { - keepMe: "value", - }, - }, - }); - }); - - test("handles arrays of objects", () => { - const data = { - users: [ - { id: "1", email: "a@test.com" }, - { id: "2", email: "b@test.com" }, - ], - }; - const result = anonymizeJsonData(data, ["email"]); - - expect(result).toEqual({ - users: [{ id: "1" }, { id: "2" }], - }); - }); - - test("handles arrays of primitives", () => { - const data = { tags: ["tag1", "tag2"], email: "remove@test.com" }; - const result = anonymizeJsonData(data, ["email"]); - - expect(result).toEqual({ tags: ["tag1", "tag2"] }); - }); - - test("handles empty object", () => { - expect(anonymizeJsonData({}, ["email"])).toEqual({}); - }); - - test("preserves non-PII fields", () => { - const data = { - id: "user-123", - createdAt: "2024-01-01", - role: "admin", - email: "remove@test.com", - }; - const result = anonymizeJsonData(data, ["email"]); - - expect(result).toEqual({ - id: "user-123", - createdAt: "2024-01-01", - role: "admin", - }); - }); - - test("handles mixed nested and top-level PII", () => { - const data = { - email: "top@test.com", - profile: { - name: "John", - address: "123 Street", - settings: { - phone: "555-1234", - theme: "dark", - }, - }, - }; - const result = anonymizeJsonData(data, ["email", "name", "address", "phone"]); - - expect(result).toEqual({ - profile: { - settings: { - theme: "dark", - }, - }, - }); - }); -}); +import { isUserDataPurged, purgeUserData } from "../../src/drizzle/gdpr.js"; describe("purgeUserData", () => { test("anonymizes audit entries for user", async () => { diff --git a/test/logger.test.ts b/test/drizzle/logger.test.ts similarity index 97% rename from test/logger.test.ts rename to test/drizzle/logger.test.ts index 2907e1d..11f0812 100644 --- a/test/logger.test.ts +++ b/test/drizzle/logger.test.ts @@ -1,6 +1,11 @@ import { describe, expect, test, vi } from "vitest"; -import { createLedgerContext, runWithLedgerContext } from "../src/context.js"; -import { type AuditEntryInput, AuditLogger, extractRecordId, parseQuery } from "../src/logger.js"; +import { createLedgerContext, runWithLedgerContext } from "../../src/core/context.js"; +import { + type AuditEntryInput, + AuditLogger, + extractRecordId, + parseQuery, +} from "../../src/drizzle/logger.js"; describe("parseQuery", () => { test("parses INSERT query", () => { diff --git a/test/soft-delete.test.ts b/test/drizzle/soft-delete.test.ts similarity index 71% rename from test/soft-delete.test.ts rename to test/drizzle/soft-delete.test.ts index cdd2056..68e8159 100644 --- a/test/soft-delete.test.ts +++ b/test/drizzle/soft-delete.test.ts @@ -2,26 +2,19 @@ import { sqliteTable, text } from "drizzle-orm/sqlite-core"; import { pgTable, text as pgText, uuid } from "drizzle-orm/pg-core"; import { mysqlTable, varchar } from "drizzle-orm/mysql-core"; import { describe, expect, test } from "vitest"; -import { - includingDeleted, - isSoftDeleted, - notDeleted, - onlyDeleted, - restoreValues, - softDeleteValues, -} from "../src/soft-delete/index.js"; +import { includingDeleted, notDeleted, onlyDeleted } from "../../src/drizzle/soft-delete.js"; import { softDeleteColumns as sqliteColumns, softDeleteTimestamp as sqliteTimestamp, -} from "../src/soft-delete/sqlite.js"; +} from "../../src/drizzle/soft-delete/sqlite.js"; import { softDeleteColumns as pgColumns, softDeleteTimestamp as pgTimestampOnly, -} from "../src/soft-delete/pg.js"; +} from "../../src/drizzle/soft-delete/pg.js"; import { softDeleteColumns as mysqlColumns, softDeleteTimestamp as mysqlTimestampOnly, -} from "../src/soft-delete/mysql.js"; +} from "../../src/drizzle/soft-delete/mysql.js"; // Test tables for each dialect const sqliteUsers = sqliteTable("users", { @@ -133,45 +126,3 @@ describe("includingDeleted", () => { expect(condition).toBeDefined(); }); }); - -describe("softDeleteValues", () => { - test("returns current timestamp and null deletedBy", () => { - const values = softDeleteValues(); - expect(values.deletedAt).toBeInstanceOf(Date); - expect(values.deletedBy).toBeNull(); - }); - - test("includes deletedBy when provided", () => { - const values = softDeleteValues("user-123"); - expect(values.deletedAt).toBeInstanceOf(Date); - expect(values.deletedBy).toBe("user-123"); - }); - - test("handles null deletedBy", () => { - const values = softDeleteValues(null); - expect(values.deletedBy).toBeNull(); - }); -}); - -describe("restoreValues", () => { - test("returns null for both fields", () => { - const values = restoreValues(); - expect(values.deletedAt).toBeNull(); - expect(values.deletedBy).toBeNull(); - }); -}); - -describe("isSoftDeleted", () => { - test("returns true for deleted record", () => { - expect(isSoftDeleted({ deletedAt: new Date() })).toBe(true); - }); - - test("returns false for active record", () => { - expect(isSoftDeleted({ deletedAt: null })).toBe(false); - }); - - test("returns false for null/undefined", () => { - expect(isSoftDeleted(null)).toBe(false); - expect(isSoftDeleted(undefined)).toBe(false); - }); -}); diff --git a/vitest.config.ts b/vitest.config.ts index 4143615..5555e3d 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -7,19 +7,24 @@ export default defineConfig({ }, resolve: { alias: { - "@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/drizzle/soft-delete/sqlite": "./src/drizzle/soft-delete/sqlite.ts", + "@rafters/ledger/drizzle/soft-delete/pg": "./src/drizzle/soft-delete/pg.ts", + "@rafters/ledger/drizzle/soft-delete/mysql": "./src/drizzle/soft-delete/mysql.ts", + "@rafters/ledger/drizzle/soft-delete": "./src/drizzle/soft-delete.ts", + "@rafters/ledger/drizzle/schema/sqlite": "./src/drizzle/schema/sqlite.ts", + "@rafters/ledger/drizzle/schema/pg": "./src/drizzle/schema/pg.ts", + "@rafters/ledger/drizzle/schema/mysql": "./src/drizzle/schema/mysql.ts", + "@rafters/ledger/drizzle/schema": "./src/drizzle/schema/index.ts", + "@rafters/ledger/drizzle/audit": "./src/drizzle/audit.ts", + "@rafters/ledger/drizzle/db": "./src/drizzle/db.ts", + "@rafters/ledger/drizzle/gdpr": "./src/drizzle/gdpr.ts", + "@rafters/ledger/drizzle/logger": "./src/drizzle/logger.ts", + "@rafters/ledger/drizzle": "./src/drizzle/index.ts", + "@rafters/ledger/core/context": "./src/core/context.ts", + "@rafters/ledger/core/soft-delete": "./src/core/soft-delete.ts", + "@rafters/ledger/core/gdpr": "./src/core/gdpr.ts", + "@rafters/ledger/core/errors": "./src/core/errors.ts", + "@rafters/ledger/core": "./src/core/index.ts", "@rafters/ledger/better-auth": "./src/better-auth.ts", "@rafters/ledger": "./src/index.ts", }, From e1c96b89a1fc361ebff8db3f0820529492706de0 Mon Sep 17 00:00:00 2001 From: Sean Silvius Date: Mon, 13 Apr 2026 14:22:05 -0700 Subject: [PATCH 5/5] Update docs for @rafters/ledger core/drizzle architecture All import paths updated to reflect the ORM-agnostic core at @rafters/ledger and the Drizzle adapter at @rafters/ledger/drizzle. Dialect-specific columns and schema now live at @rafters/ledger/drizzle/soft-delete/* and @rafters/ledger/drizzle/schema/*. api-reference.mdx reorganized into three sections by entry point. Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 2 + docs/api-reference.mdx | 443 ++++++++++++++++++--------------------- docs/audit-trail.mdx | 34 +-- docs/better-auth.mdx | 10 +- docs/context.mdx | 2 + docs/gdpr.mdx | 14 +- docs/getting-started.mdx | 40 ++-- docs/index.mdx | 31 +-- docs/soft-delete.mdx | 26 +-- 9 files changed, 289 insertions(+), 313 deletions(-) diff --git a/README.md b/README.md index f7e6f37..2d7f902 100644 --- a/README.md +++ b/README.md @@ -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/) diff --git a/docs/api-reference.mdx b/docs/api-reference.mdx index 78cc404..1b5f0d0 100644 --- a/docs/api-reference.mdx +++ b/docs/api-reference.mdx @@ -1,22 +1,27 @@ # API Reference -`@rafters/ledger` — soft-delete, audit trail, and GDPR compliance for Drizzle ORM. +`@rafters/ledger` is organized across three entry points. Import from the entry point that owns the export; do not rely on the root barrel for tree-shaking in edge environments. -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. +| Entry point | Owns | +|---|---| +| `@rafters/ledger` | Core: context, pure soft-delete helpers, pure audit entry construction, GDPR utilities, types | +| `@rafters/ledger/drizzle` | Drizzle adapter: schema, `createAuditedDb`, query filters, manual log functions, `AuditLogger` | +| `@rafters/ledger/better-auth` | better-auth integration: `ledgerPlugin`, `createSoftDeleteCallback`, `createDeleteAuditCallback` | ---- - -## `@rafters/ledger` +Dialect-specific column definitions live at sub-subpaths of the Drizzle adapter: -Root barrel. Re-exports everything from all subpaths. +| Subpath | Exports | +|---|---| +| `@rafters/ledger/drizzle/soft-delete/sqlite` | `softDeleteColumns`, `softDeleteTimestamp` for SQLite/D1 | +| `@rafters/ledger/drizzle/soft-delete/pg` | `softDeleteColumns`, `softDeleteTimestamp` for PostgreSQL | +| `@rafters/ledger/drizzle/soft-delete/mysql` | `softDeleteColumns`, `softDeleteTimestamp` for MySQL | +| `@rafters/ledger/drizzle/schema/sqlite` | `auditLog`, `createAuditLogTable`, `AUDIT_LOG_INDEXES` for SQLite/D1 | +| `@rafters/ledger/drizzle/schema/pg` | `auditLog`, `createAuditLogTable`, `AUDIT_LOG_INDEXES` for PostgreSQL | +| `@rafters/ledger/drizzle/schema/mysql` | `auditLog`, `createAuditLogTable`, `AUDIT_LOG_INDEXES` for MySQL | --- -## `@rafters/ledger/context` - -AsyncLocalStorage-based context propagation. Cloudflare Workers compatible; does not import from `node:async_hooks`. Degrades gracefully when `AsyncLocalStorage` is unavailable. - ---- +## Core (`@rafters/ledger`) ### `createLedgerContext(options): LedgerContext` @@ -67,61 +72,85 @@ Returns `true` if a context is active in the current async scope. --- -## `@rafters/ledger/soft-delete` +### `softDeleteValues(deletedBy?): { deletedAt: Date; deletedBy: string | null }` -Dialect-agnostic query helpers. No column definitions; import those from a dialect subpath. +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 | -### `notDeleted(table): SQL` +--- -WHERE clause fragment that excludes soft-deleted records (`deletedAt IS NULL`). +### `restoreValues(): { deletedAt: null; deletedBy: null }` -| Param | Type | Description | -|-------|------|-------------| -| `table` | `{ deletedAt: Column }` | Table object with a `deletedAt` column | +Returns the values object to pass to `.set()` when restoring a soft-deleted record. Nulls both `deletedAt` and `deletedBy`. --- -### `onlyDeleted(table): SQL` +### `isSoftDeleted(record): boolean` -WHERE clause fragment that returns only soft-deleted records (`deletedAt IS NOT NULL`). +Returns `true` if the record's `deletedAt` is non-null. Returns `false` for `null` or `undefined` input. | Param | Type | Description | |-------|------|-------------| -| `table` | `{ deletedAt: Column }` | Table object with a `deletedAt` column | +| `record` | `{ deletedAt: Date \| null } \| null \| undefined` | Record to check | --- -### `includingDeleted(): SQL` +### `createAuditEntry(options): AuditLogEntry` -Returns `1=1` — a SQL fragment that matches all records, deleted or not. Used to explicitly opt out of soft-delete filtering. +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 | --- -### `softDeleteValues(deletedBy?): { deletedAt: Date; deletedBy: string | null }` +### `purgeUserData(db, auditTable, userId, config?): Promise` -Returns the values object to pass to `.set()` when soft-deleting a record. Sets `deletedAt` to the current time. +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 | |-------|------|-------------| -| `deletedBy` | `string \| null \| undefined` | User ID performing the delete | +| `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 | --- -### `restoreValues(): { deletedAt: null; deletedBy: null }` +### `anonymizeJsonData(data, piiFields): Record | null` -Returns the values object to pass to `.set()` when restoring a soft-deleted record. Nulls both `deletedAt` and `deletedBy`. +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 | --- -### `isSoftDeleted(record): boolean` +### `isUserDataPurged(db, auditTable, userId): Promise` -Returns `true` if the record's `deletedAt` is non-null. Returns `false` for `null` or `undefined` input. +Returns `true` if no audit entries exist with the given `userId`. Useful for idempotency checks before calling `purgeUserData`. | Param | Type | Description | |-------|------|-------------| -| `record` | `{ deletedAt: Date \| null } \| null \| undefined` | Record to check | +| `db` | `DrizzleDb` | Database instance | +| `auditTable` | `AuditLog` | The audit log table | +| `userId` | `string` | User ID to check | --- @@ -150,25 +179,97 @@ type WithSoftDeleteTimestamp = T & { --- -## `@rafters/ledger/soft-delete/sqlite` -## `@rafters/ledger/soft-delete/pg` -## `@rafters/ledger/soft-delete/mysql` +### Type: `LedgerContext` -Dialect-specific column definitions. Spread into your table definition. +| 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 | -| 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. +### Type: `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 | + +--- + +### 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 | --- -## `@rafters/ledger/db` +### Type: `PurgeResult` + +| Field | Type | Description | +|-------|------|-------------| +| `entriesAnonymized` | `number` | Count of rows updated | +| `tablesProcessed` | `string[]` | Unique table names that had entries anonymized | --- +### Type: `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`) | + +--- + +### Type: `SoftDeleteOptions` + +| Field | Type | Description | +|-------|------|-------------| +| `columnName` | `string \| undefined` | Override the `deletedAt` column name (default: `"deletedAt"`) | +| `deletedByColumn` | `string \| undefined` | Column name for the deleting user | + +--- + +### Type: `SoftDeleteResult` + +| Field | Type | Description | +|-------|------|-------------| +| `record` | `T` | The soft-deleted record | +| `auditEntry` | `AuditLogEntry` | The audit entry created for this operation | + +--- + +### Type: `RestoreResult` + +| Field | Type | Description | +|-------|------|-------------| +| `record` | `T` | The restored record | +| `auditEntry` | `AuditLogEntry` | The audit entry created for this operation | + +--- + +## Drizzle Adapter (`@rafters/ledger/drizzle`) + ### `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. @@ -182,54 +283,50 @@ Returns the same database instance with the patched `delete` method. --- -### `hasColumn(table, columnName): boolean` +### `notDeleted(table): SQL` -Returns `true` if the Drizzle table object has a column with the given name. +WHERE clause fragment that excludes soft-deleted records (`deletedAt IS NULL`). | Param | Type | Description | |-------|------|-------------| -| `table` | `unknown` | Drizzle table object | -| `columnName` | `string` | Column name to check | +| `table` | `{ deletedAt: Column }` | Table object with a `deletedAt` column | --- -### `getTableName(table): string | null` +### `onlyDeleted(table): SQL` -Extracts the SQL table name from a Drizzle table object. Returns `null` if the input is not a valid table object. +WHERE clause fragment that returns only soft-deleted records (`deletedAt IS NOT NULL`). | Param | Type | Description | |-------|------|-------------| -| `table` | `unknown` | Drizzle table object | +| `table` | `{ deletedAt: Column }` | Table object with a `deletedAt` column | --- -### Type: `AuditedDbConfig` +### `includingDeleted(): SQL` -| 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 | +Returns `1=1` — a SQL fragment that matches all records, deleted or not. Used to explicitly opt out of soft-delete filtering. --- -## `@rafters/ledger/audit` +### `hasColumn(table, columnName): boolean` + +Returns `true` if the Drizzle table object has a column with the given name. -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. +| Param | Type | Description | +|-------|------|-------------| +| `table` | `unknown` | Drizzle table object | +| `columnName` | `string` | Column name to check | --- -### `createAuditEntry(options): AuditLogEntry` +### `getTableName(table): string | null` -Creates an `AuditLogEntry` object without inserting it. Pulls context from AsyncLocalStorage unless `options.context` is provided. Generates a UUIDv7 for the `id` field. +Extracts the SQL table name from a Drizzle table object. Returns `null` if the input is not a valid table object. | 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 | +| `table` | `unknown` | Drizzle table object | --- @@ -331,12 +428,6 @@ Returns all audit entries for a record, ordered newest first. Deserializes `oldD --- -## `@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`. @@ -381,59 +472,28 @@ Best-effort extraction of a record ID from query parameters. Returns the first s --- -### 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` +### Dialect-specific soft-delete columns -Shape of the object passed to `writeAuditEntry` in `AuditLogger`. +`@rafters/ledger/drizzle/soft-delete/sqlite`, `/pg`, `/mysql` -| 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` +| 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 | -| Field | Type | Description | -|-------|------|-------------| -| `action` | `"INSERT" \| "UPDATE" \| "DELETE" \| "SELECT"` | Detected SQL action | -| `table` | `string` | Lowercase table name | +Both exports are `const` objects. Use `...softDeleteColumns` or `...softDeleteTimestamp` in your column map. --- -## `@rafters/ledger/schema/sqlite` -## `@rafters/ledger/schema/pg` -## `@rafters/ledger/schema/mysql` +### Dialect-specific audit log schema -Drizzle table definitions for the audit log. Use one dialect; do not mix. +`@rafters/ledger/drizzle/schema/sqlite`, `/pg`, `/mysql` ---- - -### `auditLog` +#### `auditLog` Pre-built table definition named `"audit_log"`. Import directly when the default table name is acceptable. ---- - -### `createAuditLogTable(tableName): SQLiteTable | PgTable | MySQLTable` +#### `createAuditLogTable(tableName): SQLiteTable | PgTable | MySQLTable` Creates an audit log table with a custom name. Identical column structure to `auditLog`. @@ -441,17 +501,13 @@ Creates an audit log table with a custom name. Identical column structure to `au |-------|------|-------------| | `tableName` | `string` | SQL table name | ---- - -### `AUDIT_LOG_INDEXES` +#### `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 differences by dialect | Column | SQLite | Postgres | MySQL | |--------|--------|----------|-------| @@ -466,9 +522,7 @@ SQLite/Postgres use `CREATE INDEX IF NOT EXISTS`. MySQL omits `IF NOT EXISTS`. | `request_id` | `text` | `text` | `varchar(255)` | | `created_at` | `integer timestamp_ms`, default `unixepoch() * 1000` | `timestamptz`, default `now()` | `timestamp`, default `now()` | ---- - -### Types: `AuditLog`, `AuditLogInsert`, `AuditLogSelect` +#### Types: `AuditLog`, `AuditLogInsert`, `AuditLogSelect` | Type | Description | |------|-------------| @@ -478,72 +532,54 @@ SQLite/Postgres use `CREATE INDEX IF NOT EXISTS`. MySQL omits `IF NOT EXISTS`. --- -## `@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). +### Type: `AuditedDbConfig` -| Param | Type | Description | +| Field | 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 | +| `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 | --- -### `anonymizeJsonData(data, piiFields): Record | null` - -Recursively removes named fields from an object. Processes nested objects and arrays. Returns `null` for null input. +### Type: `AuditLoggerConfig` -| Param | Type | Description | +| Field | Type | Description | |-------|------|-------------| -| `data` | `Record \| null` | Data to anonymize | -| `piiFields` | `string[]` | Field names to remove | +| `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`) | --- -### `isUserDataPurged(db, auditTable, userId): Promise` +### Type: `AuditEntryInput` -Returns `true` if no audit entries exist with the given `userId`. Useful for idempotency checks before calling `purgeUserData`. +Shape of the object passed to `writeAuditEntry` in `AuditLogger`. -| Param | Type | Description | +| Field | 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 | +| `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: `PurgeResult` +### Type: `ParsedQuery` | Field | Type | Description | |-------|------|-------------| -| `entriesAnonymized` | `number` | Count of rows updated | -| `tablesProcessed` | `string[]` | Unique table names that had entries anonymized | +| `action` | `"INSERT" \| "UPDATE" \| "DELETE" \| "SELECT"` | Detected SQL action | +| `table` | `string` | Lowercase table name | --- -## `@rafters/ledger/better-auth` +## Better Auth (`@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`. @@ -635,80 +671,3 @@ Thrown by `createSoftDeleteCallback` to interrupt the hard delete after a succes | `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 index 0563da3..18f4468 100644 --- a/docs/audit-trail.mdx +++ b/docs/audit-trail.mdx @@ -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. --- @@ -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: @@ -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"); ``` @@ -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) @@ -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 @@ -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({ @@ -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"), @@ -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 { @@ -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 @@ -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) @@ -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) @@ -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); ``` diff --git a/docs/better-auth.mdx b/docs/better-auth.mdx index 1213c84..3b56d3c 100644 --- a/docs/better-auth.mdx +++ b/docs/better-auth.mdx @@ -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); @@ -141,7 +141,7 @@ export const auth = betterAuth({ | `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`. +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`. --- @@ -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", { @@ -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`. diff --git a/docs/context.mdx b/docs/context.mdx index ff84327..ee6e63f 100644 --- a/docs/context.mdx +++ b/docs/context.mdx @@ -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 | diff --git a/docs/gdpr.mdx b/docs/gdpr.mdx index 809cf8e..e0b2614 100644 --- a/docs/gdpr.mdx +++ b/docs/gdpr.mdx @@ -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'], @@ -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']); @@ -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); diff --git a/docs/getting-started.mdx b/docs/getting-started.mdx index fd0d336..80729f3 100644 --- a/docs/getting-started.mdx +++ b/docs/getting-started.mdx @@ -1,6 +1,6 @@ # 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. @@ -8,13 +8,13 @@ This guide walks through the five steps to get a fully working setup: choosing y ## 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. @@ -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(), @@ -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(), @@ -58,7 +58,7 @@ 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"; ``` @@ -66,7 +66,7 @@ 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"); ``` @@ -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) @@ -117,7 +117,7 @@ import { Hono } from "hono"; import { createLedgerContext, runWithLedgerContext, -} from "@rafters/ledger/context"; +} from "@rafters/ledger"; const app = new Hono(); @@ -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 { @@ -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) { @@ -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. diff --git a/docs/index.mdx b/docs/index.mdx index 75708c3..c936ff7 100644 --- a/docs/index.mdx +++ b/docs/index.mdx @@ -31,8 +31,8 @@ This example uses SQLite / Cloudflare D1. Postgres and MySQL use different impor ```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'; +import { softDeleteColumns } from '@rafters/ledger/drizzle/soft-delete/sqlite'; +import { auditLog } from '@rafters/ledger/drizzle/schema/sqlite'; export const users = sqliteTable('users', { id: text('id').primaryKey(), @@ -50,7 +50,7 @@ export { auditLog }; ```typescript // src/middleware/ledger.ts -import { createLedgerContext, runWithLedgerContext } from '@rafters/ledger/context'; +import { createLedgerContext, runWithLedgerContext } from '@rafters/ledger'; // Hono example; adapt to your framework app.use(async (c, next) => { @@ -71,7 +71,7 @@ app.use(async (c, next) => { ```typescript // src/db/index.ts import { drizzle } from 'drizzle-orm/d1'; -import { createAuditedDb } from '@rafters/ledger/db'; +import { createAuditedDb } from '@rafters/ledger/drizzle'; import * as schema from './schema'; export function createDb(d1: D1Database) { @@ -92,7 +92,7 @@ export function createDb(d1: D1Database) { ### 4. Querying ```typescript -import { notDeleted } from '@rafters/ledger/soft-delete'; +import { notDeleted } from '@rafters/ledger/drizzle'; // Only active users const activeUsers = await db @@ -107,7 +107,7 @@ await db.delete(users).where(eq(users.id, userId)); ### 5. GDPR purge ```typescript -import { purgeUserData } from '@rafters/ledger/gdpr'; +import { purgeUserData } from '@rafters/ledger'; const result = await purgeUserData(db, auditLog, userId, { piiFields: ['email', 'name', 'phone', 'ip', 'userAgent'], @@ -123,11 +123,11 @@ Strips the specified fields from `oldData` and `newData` JSON columns in every a | 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` | +| SQLite / 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` | -Dialect-agnostic helpers (`notDeleted`, `onlyDeleted`, `softDeleteValues`, `restoreValues`, `isSoftDeleted`) import from `@rafters/ledger/soft-delete`. +Dialect-agnostic query filters (`notDeleted`, `onlyDeleted`) import from `@rafters/ledger/drizzle`. Pure helpers (`softDeleteValues`, `restoreValues`, `isSoftDeleted`) import from `@rafters/ledger`. --- @@ -137,11 +137,12 @@ 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 | +| `getting-started.mdx` | End-to-end setup: dialect selection, columns, audit table, context, db wrapper | +| `soft-delete.mdx` | Column helpers, query filters, restore, unique constraint considerations | +| `audit-trail.mdx` | Manual logging, AuditLogger, context propagation, SOC 2 notes | +| `gdpr.mdx` | `purgeUserData`, `isUserDataPurged`, what it handles and what it does not | +| `better-auth.mdx` | `ledgerPlugin`, `createSoftDeleteCallback`, flow control pattern | +| `api-reference.mdx` | Every export, organized by entry point | --- diff --git a/docs/soft-delete.mdx b/docs/soft-delete.mdx index 24f2ab2..5183e20 100644 --- a/docs/soft-delete.mdx +++ b/docs/soft-delete.mdx @@ -10,20 +10,20 @@ Soft-delete columns are dialect-specific. Import from the subpath that matches y ```typescript // SQLite / Cloudflare D1 -import { softDeleteColumns, softDeleteTimestamp } from '@rafters/ledger/soft-delete/sqlite'; +import { softDeleteColumns, softDeleteTimestamp } from '@rafters/ledger/drizzle/soft-delete/sqlite'; // PostgreSQL -import { softDeleteColumns, softDeleteTimestamp } from '@rafters/ledger/soft-delete/pg'; +import { softDeleteColumns, softDeleteTimestamp } from '@rafters/ledger/drizzle/soft-delete/pg'; // MySQL -import { softDeleteColumns, softDeleteTimestamp } from '@rafters/ledger/soft-delete/mysql'; +import { softDeleteColumns, softDeleteTimestamp } from '@rafters/ledger/drizzle/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'; +import { softDeleteColumns } from '@rafters/ledger/drizzle/soft-delete/sqlite'; export const users = sqliteTable('users', { id: text('id').primaryKey(), @@ -34,7 +34,7 @@ export const users = sqliteTable('users', { ```typescript import { sqliteTable, text } from 'drizzle-orm/sqlite-core'; -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(), @@ -57,14 +57,14 @@ SQLite stores the timestamp as an integer millisecond value. Drizzle reads it ba ## Query Filters -Three filters cover every visibility case. Import from `@rafters/ledger/soft-delete`. +`notDeleted` and `onlyDeleted` import from `@rafters/ledger/drizzle`. These are Drizzle-specific SQL fragments. ```typescript import { notDeleted, onlyDeleted, includingDeleted, -} from '@rafters/ledger/soft-delete'; +} from '@rafters/ledger/drizzle'; ``` ### `notDeleted(table)` @@ -108,14 +108,14 @@ const allUsers = await db ## Manual Soft-Delete -For direct control over update queries, three functions handle the value construction and record inspection. +For direct control over update queries, three functions handle the value construction and record inspection. These are pure helpers with no Drizzle dependency; import them from `@rafters/ledger`. ```typescript import { softDeleteValues, restoreValues, isSoftDeleted, -} from '@rafters/ledger/soft-delete'; +} from '@rafters/ledger'; ``` ### `softDeleteValues(deletedBy?)` @@ -172,7 +172,7 @@ if (isSoftDeleted(user)) { ```typescript import { drizzle } from 'drizzle-orm/d1'; -import { createAuditedDb } from '@rafters/ledger/db'; +import { createAuditedDb } from '@rafters/ledger/drizzle'; const baseDb = drizzle(env.DB); export const db = createAuditedDb(baseDb); @@ -228,11 +228,11 @@ Pass the SQL table name string, not the Drizzle table variable name. The wrapper ```typescript import { drizzle } from 'drizzle-orm/d1'; -import { createAuditedDb } from '@rafters/ledger/db'; +import { createAuditedDb } from '@rafters/ledger/drizzle'; import { runWithLedgerContext, createLedgerContext, -} from '@rafters/ledger/context'; +} from '@rafters/ledger'; const baseDb = drizzle(env.DB); export const db = createAuditedDb(baseDb, { @@ -276,7 +276,7 @@ Two utility types add soft-delete fields to an existing record type. import type { WithSoftDelete, WithSoftDeleteTimestamp, -} from '@rafters/ledger/soft-delete'; +} from '@rafters/ledger'; ``` ### `WithSoftDelete`