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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 118 additions & 0 deletions src/core/adapter.ts
Original file line number Diff line number Diff line change
@@ -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<TDb, TAuditTable, TQueryFilter> {
// -- 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<void>;

/**
* 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<AuditLogEntry[]>;

// -- 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<PurgeResult>;

/**
* 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<boolean>;

// -- 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;
}
3 changes: 3 additions & 0 deletions src/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ export type {
SoftDeleteResult,
} from "./types.js";

// Adapter interface
export type { LedgerAdapter } from "./adapter.js";

// Context
export {
createLedgerContext,
Expand Down
92 changes: 92 additions & 0 deletions test/drizzle/adapter-compat.test.ts
Original file line number Diff line number Diff line change
@@ -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<unknown> };
};
select: () => {
from: (table: AuditLog) => {
where: (condition: unknown) => {
orderBy: (order: unknown) => Promise<Array<AuditLog["$inferSelect"]>>;
};
};
};
update: (table: unknown) => {
set: (values: Record<string, unknown>) => {
where: (condition: unknown) => {
returning: () => {
execute: () => Promise<unknown>;
};
execute: () => Promise<unknown>;
};
};
};
delete: (table: unknown) => {
where: (condition: unknown) => {
returning: () => {
execute: () => Promise<unknown>;
};
execute: () => Promise<unknown>;
};
};
};

// -- Build the adapter object from standalone Drizzle functions --
const drizzleAdapter = {
notDeleted,
onlyDeleted,
insertAuditEntry,
getRecordHistory,
purgeUserData,
isUserDataPurged,
createAuditedDb,
} satisfies LedgerAdapter<DrizzleDb, AuditLog, SQL>;

// Alternate verification: assignment check
const _check: LedgerAdapter<DrizzleDb, AuditLog, SQL> = 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",
]);
});
});
Loading