diff --git a/src/core/adapter.ts b/src/core/adapter.ts new file mode 100644 index 0000000..0b199a3 --- /dev/null +++ b/src/core/adapter.ts @@ -0,0 +1,118 @@ +/** + * Ledger Adapter Interface + * + * Defines the contract that any ORM adapter must satisfy. + * TypeScript structural typing handles conformance -- no runtime enforcement needed. + * + * Generic parameters: + * - TDb: The database instance type (e.g., DrizzleSQLiteDatabase) + * - TAuditTable: The audit log table reference type (e.g., typeof auditLog) + * - TQueryFilter: The query filter/condition type returned by filter functions (e.g., SQL) + */ + +import type { AuditLogEntry } from "./types.js"; +import type { PurgeConfig, PurgeResult } from "./gdpr.js"; + +/** + * Configuration for audited database wrapping. + * Adapter implementations may extend this with ORM-specific options. + */ +export interface AuditedDbConfig { + /** Tables to exclude from soft-delete (will hard delete) */ + hardDeleteTables?: string[]; +} + +/** + * The contract every ORM adapter must implement. + * + * Each method corresponds to a capability the adapter provides. + * Adapters are free to expose additional ORM-specific helpers + * (e.g., logInsert, logUpdate, AuditLogger) beyond this contract. + */ +export interface LedgerAdapter { + // -- Query filters -- + + /** + * Return a filter condition that excludes soft-deleted records. + * + * @param table - A table with a deletedAt column + * @returns A query filter for non-deleted records + */ + notDeleted(table: { deletedAt: unknown }): TQueryFilter; + + /** + * Return a filter condition that includes only soft-deleted records. + * + * @param table - A table with a deletedAt column + * @returns A query filter for deleted records only + */ + onlyDeleted(table: { deletedAt: unknown }): TQueryFilter; + + // -- Audit operations -- + + /** + * Persist an audit log entry to the database. + * + * @param db - The database instance + * @param auditTable - The audit log table reference + * @param entry - The audit entry to persist + */ + insertAuditEntry(db: TDb, auditTable: TAuditTable, entry: AuditLogEntry): Promise; + + /** + * Retrieve the full audit history for a specific record, newest first. + * + * @param db - The database instance + * @param auditTable - The audit log table reference + * @param tableName - Name of the table the record belongs to + * @param recordId - Primary key of the record + * @returns Audit entries ordered by creation time descending + */ + getRecordHistory( + db: TDb, + auditTable: TAuditTable, + tableName: string, + recordId: string, + ): Promise; + + // -- GDPR -- + + /** + * Anonymize all audit data for a given user. + * Preserves audit trail structure but removes PII. + * + * @param db - The database instance + * @param auditTable - The audit log table reference + * @param userId - The user whose data should be purged + * @param config - Optional purge configuration + * @returns Result with count of affected records + */ + purgeUserData( + db: TDb, + auditTable: TAuditTable, + userId: string, + config?: PurgeConfig, + ): Promise; + + /** + * Check whether a user's data has already been purged. + * + * @param db - The database instance + * @param auditTable - The audit log table reference + * @param userId - The user to check + * @returns true if no audit entries remain with this userId + */ + isUserDataPurged(db: TDb, auditTable: TAuditTable, userId: string): Promise; + + // -- Soft-delete automation -- + + /** + * Wrap a database instance so that delete() calls are automatically + * converted to soft-delete for tables with a deletedAt column. + * + * @param db - The database instance to wrap + * @param config - Optional configuration + * @returns The wrapped database instance (same type as input) + */ + createAuditedDb(db: TDb, config?: AuditedDbConfig): TDb; +} diff --git a/src/core/index.ts b/src/core/index.ts index 15a1c3a..c108bae 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -16,6 +16,9 @@ export type { SoftDeleteResult, } from "./types.js"; +// Adapter interface +export type { LedgerAdapter } from "./adapter.js"; + // Context export { createLedgerContext, diff --git a/test/drizzle/adapter-compat.test.ts b/test/drizzle/adapter-compat.test.ts new file mode 100644 index 0000000..08f828f --- /dev/null +++ b/test/drizzle/adapter-compat.test.ts @@ -0,0 +1,92 @@ +/** + * Type-level test: Drizzle adapter satisfies LedgerAdapter interface. + * + * This file verifies structural compatibility at compile time. + * If the Drizzle adapter drifts from the interface, this file + * will produce a type error -- no runtime assertions needed. + */ + +import type { SQL } from "drizzle-orm"; +import { describe, expect, test } from "vitest"; +import type { LedgerAdapter } from "../../src/core/adapter.js"; +import { insertAuditEntry, getRecordHistory } from "../../src/drizzle/audit.js"; +import { createAuditedDb } from "../../src/drizzle/db.js"; +import { purgeUserData, isUserDataPurged } from "../../src/drizzle/gdpr.js"; +import { notDeleted, onlyDeleted } from "../../src/drizzle/soft-delete.js"; +import type { AuditLog } from "../../src/drizzle/schema/sqlite.js"; + +// -- Structural db type covering all Drizzle adapter function requirements -- +// Each function uses a slightly different duck-typed db shape. This union +// represents the minimal surface a Drizzle db instance exposes. +type DrizzleDb = { + insert: (table: AuditLog) => { + values: (entry: unknown) => { execute: () => Promise }; + }; + select: () => { + from: (table: AuditLog) => { + where: (condition: unknown) => { + orderBy: (order: unknown) => Promise>; + }; + }; + }; + update: (table: unknown) => { + set: (values: Record) => { + where: (condition: unknown) => { + returning: () => { + execute: () => Promise; + }; + execute: () => Promise; + }; + }; + }; + delete: (table: unknown) => { + where: (condition: unknown) => { + returning: () => { + execute: () => Promise; + }; + execute: () => Promise; + }; + }; +}; + +// -- Build the adapter object from standalone Drizzle functions -- +const drizzleAdapter = { + notDeleted, + onlyDeleted, + insertAuditEntry, + getRecordHistory, + purgeUserData, + isUserDataPurged, + createAuditedDb, +} satisfies LedgerAdapter; + +// Alternate verification: assignment check +const _check: LedgerAdapter = drizzleAdapter; + +describe("LedgerAdapter interface", () => { + test("Drizzle adapter structurally satisfies the interface", () => { + // The real verification is the type-level satisfies/assignment above. + // If this file compiles, the adapter conforms. + expect(drizzleAdapter).toBeDefined(); + expect(drizzleAdapter.notDeleted).toBeTypeOf("function"); + expect(drizzleAdapter.onlyDeleted).toBeTypeOf("function"); + expect(drizzleAdapter.insertAuditEntry).toBeTypeOf("function"); + expect(drizzleAdapter.getRecordHistory).toBeTypeOf("function"); + expect(drizzleAdapter.purgeUserData).toBeTypeOf("function"); + expect(drizzleAdapter.isUserDataPurged).toBeTypeOf("function"); + expect(drizzleAdapter.createAuditedDb).toBeTypeOf("function"); + }); + + test("adapter object has exactly the interface methods", () => { + const keys = Object.keys(drizzleAdapter).sort(); + expect(keys).toEqual([ + "createAuditedDb", + "getRecordHistory", + "insertAuditEntry", + "isUserDataPurged", + "notDeleted", + "onlyDeleted", + "purgeUserData", + ]); + }); +});