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` 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/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/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/context.ts b/src/core/context.ts similarity index 100% rename from src/context.ts rename to src/core/context.ts 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/types.ts b/src/core/types.ts similarity index 100% rename from src/types.ts rename to src/core/types.ts diff --git a/src/audit.ts b/src/drizzle/audit.ts similarity index 79% rename from src/audit.ts rename to src/drizzle/audit.ts index e3d25f4..fa68a57 100644 --- a/src/audit.ts +++ b/src/drizzle/audit.ts @@ -1,74 +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 { uuidv7 } from "uuidv7"; -import { getLedgerContext } from "./context.js"; +import { createAuditEntry } from "../core/audit.js"; +import type { AuditLogEntry } from "../core/types.js"; import type { auditLog } from "./schema/sqlite.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(), - }; -} +// 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. 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 077cb25..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 "./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/drizzle/gdpr.ts similarity index 62% rename from src/gdpr.ts rename to src/drizzle/gdpr.ts index f441c33..5df0ec0 100644 --- a/src/gdpr.ts +++ b/src/drizzle/gdpr.ts @@ -1,112 +1,25 @@ /** - * 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"; -/** - * 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 */ -const DEFAULT_PII_FIELDS = [ - "email", - "name", - "firstName", - "lastName", - "phone", - "address", - "ip", - "ipAddress", - "userAgent", -]; +// Re-export pure helpers from core for convenience +export { + anonymizeJsonData, + DEFAULT_PII_FIELDS, + type PurgeConfig, + type PurgeResult, +} 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. */ 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 b7d7609..9131f04 100644 --- a/src/logger.ts +++ b/src/drizzle/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/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/drizzle/soft-delete.ts b/src/drizzle/soft-delete.ts new file mode 100644 index 0000000..13a2252 --- /dev/null +++ b/src/drizzle/soft-delete.ts @@ -0,0 +1,62 @@ +/** + * Ledger Soft Delete - Drizzle Adapter + * + * Drizzle ORM query filters for soft-delete patterns. + * For column definitions, import from the dialect-specific module: + * - 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 for convenience +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. + * + * @param table - The table with a deletedAt column + * @returns SQL condition for non-deleted records + * + * @example + * ```typescript + * import { notDeleted } from '@rafters/ledger/soft-delete'; + * + * const users = await db + * .select() + * .from(usersTable) + * .where(notDeleted(usersTable)); + * ``` + */ +export function notDeleted(table: T): SQL { + return isNull(table.deletedAt); +} + +/** + * Filter condition to only get soft-deleted records. + * Useful for "trash" views or admin interfaces. + * + * @param table - The table with a deletedAt column + * @returns SQL condition for deleted records only + */ +export function onlyDeleted(table: T): SQL { + return isNotNull(table.deletedAt); +} + +/** + * Filter condition to get all records including soft-deleted ones. + * Returns a SQL fragment that's always true. + * + * @returns SQL condition that matches all records + */ +export function includingDeleted(): SQL { + return sql`1=1`; +} 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/soft-delete/index.ts b/src/soft-delete/index.ts deleted file mode 100644 index 8ed33c0..0000000 --- a/src/soft-delete/index.ts +++ /dev/null @@ -1,109 +0,0 @@ -/** - * Drizzle Ledger Soft Delete - * - * Dialect-agnostic helpers for implementing soft-delete patterns in Drizzle. - * For column definitions, import from the dialect-specific module: - * - ledger/soft-delete/sqlite - * - ledger/soft-delete/pg - * - ledger/soft-delete/mysql - */ - -import { type Column, isNotNull, isNull, type SQL, sql } from "drizzle-orm"; - -/** - * Filter condition to exclude soft-deleted records. - * Use this in your WHERE clauses to only get active records. - * - * @param table - The table with a deletedAt column - * @returns SQL condition for non-deleted records - * - * @example - * ```typescript - * import { notDeleted } from '@rafters/ledger/soft-delete'; - * - * const users = await db - * .select() - * .from(usersTable) - * .where(notDeleted(usersTable)); - * ``` - */ -export function notDeleted(table: T): SQL { - return isNull(table.deletedAt); -} - -/** - * Filter condition to only get soft-deleted records. - * Useful for "trash" views or admin interfaces. - * - * @param table - The table with a deletedAt column - * @returns SQL condition for deleted records only - */ -export function onlyDeleted(table: T): SQL { - return isNotNull(table.deletedAt); -} - -/** - * Filter condition to get all records including soft-deleted ones. - * Returns a SQL fragment that's always true. - * - * @returns SQL condition that matches all records - */ -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/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/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"], }); 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", },