@willyim/drizzle-audit 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +278 -0
- package/dist/src/cli/generate-migration.d.ts +14 -0
- package/dist/src/cli/generate-migration.d.ts.map +1 -0
- package/dist/src/cli/generate-migration.js +118 -0
- package/dist/src/cli/runner.d.ts +2 -0
- package/dist/src/cli/runner.d.ts.map +1 -0
- package/dist/src/cli/runner.js +23 -0
- package/dist/src/d1/audit-log-schema.d.ts +181 -0
- package/dist/src/d1/audit-log-schema.d.ts.map +1 -0
- package/dist/src/d1/audit-log-schema.js +30 -0
- package/dist/src/d1/index.d.ts +7 -0
- package/dist/src/d1/index.d.ts.map +1 -0
- package/dist/src/d1/index.js +3 -0
- package/dist/src/d1/runtime.d.ts +44 -0
- package/dist/src/d1/runtime.d.ts.map +1 -0
- package/dist/src/d1/runtime.js +68 -0
- package/dist/src/d1/sql.d.ts +37 -0
- package/dist/src/d1/sql.d.ts.map +1 -0
- package/dist/src/d1/sql.js +261 -0
- package/dist/src/d1/types.d.ts +22 -0
- package/dist/src/d1/types.d.ts.map +1 -0
- package/dist/src/d1/types.js +1 -0
- package/dist/src/d1-runtime/index.d.ts +3 -0
- package/dist/src/d1-runtime/index.d.ts.map +1 -0
- package/dist/src/d1-runtime/index.js +1 -0
- package/dist/src/d1-runtime/with-audit.d.ts +77 -0
- package/dist/src/d1-runtime/with-audit.d.ts.map +1 -0
- package/dist/src/d1-runtime/with-audit.js +130 -0
- package/dist/src/index.d.ts +4 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +3 -0
- package/dist/src/postgres/audit-log-schema.d.ts +140 -0
- package/dist/src/postgres/audit-log-schema.d.ts.map +1 -0
- package/dist/src/postgres/audit-log-schema.js +25 -0
- package/dist/src/postgres/index.d.ts +6 -0
- package/dist/src/postgres/index.d.ts.map +1 -0
- package/dist/src/postgres/index.js +3 -0
- package/dist/src/postgres/runtime.d.ts +10 -0
- package/dist/src/postgres/runtime.d.ts.map +1 -0
- package/dist/src/postgres/runtime.js +21 -0
- package/dist/src/postgres/sql.d.ts +14 -0
- package/dist/src/postgres/sql.d.ts.map +1 -0
- package/dist/src/postgres/sql.js +190 -0
- package/dist/src/postgres/types.d.ts +22 -0
- package/dist/src/postgres/types.d.ts.map +1 -0
- package/dist/src/postgres/types.js +1 -0
- package/dist/test/d1-runtime.integration.test.d.ts +2 -0
- package/dist/test/d1-runtime.integration.test.d.ts.map +1 -0
- package/dist/test/d1-runtime.integration.test.js +222 -0
- package/dist/test/d1.integration.test.d.ts +2 -0
- package/dist/test/d1.integration.test.d.ts.map +1 -0
- package/dist/test/d1.integration.test.js +223 -0
- package/dist/test/postgres.integration.test.d.ts +2 -0
- package/dist/test/postgres.integration.test.d.ts.map +1 -0
- package/dist/test/postgres.integration.test.js +286 -0
- package/package.json +66 -0
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { D1AuditSqlExecutor } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Sets the audit context for the current transaction by writing to the
|
|
4
|
+
* _audit_context table. Must be called inside a transaction before any
|
|
5
|
+
* audited writes.
|
|
6
|
+
*
|
|
7
|
+
* D1/SQLite has no session variables, so triggers read context from this table.
|
|
8
|
+
*/
|
|
9
|
+
export declare function setD1AuditContext(db: D1AuditSqlExecutor, actorId: string, options?: {
|
|
10
|
+
workspaceId?: string;
|
|
11
|
+
contextTable?: string;
|
|
12
|
+
}): Promise<unknown> | undefined;
|
|
13
|
+
/**
|
|
14
|
+
* Clears the audit context after a transaction completes.
|
|
15
|
+
* Called automatically by withD1AuditedTransaction.
|
|
16
|
+
*/
|
|
17
|
+
export declare function clearD1AuditContext(db: D1AuditSqlExecutor, options?: {
|
|
18
|
+
contextTable?: string;
|
|
19
|
+
}): unknown;
|
|
20
|
+
/**
|
|
21
|
+
* Wraps a Drizzle SQLite transaction with audit context. Sets the actor
|
|
22
|
+
* before the callback and clears the context after (success or failure).
|
|
23
|
+
*
|
|
24
|
+
* Works with any Drizzle SQLite instance (better-sqlite3, D1, libsql).
|
|
25
|
+
*
|
|
26
|
+
* @example
|
|
27
|
+
* // better-sqlite3 (sync)
|
|
28
|
+
* withD1AuditedTransaction(db, "user_123", (tx) => {
|
|
29
|
+
* tx.insert(users).values({ id: "u1", name: "Ada" }).run()
|
|
30
|
+
* })
|
|
31
|
+
*
|
|
32
|
+
* @example
|
|
33
|
+
* // D1 (async)
|
|
34
|
+
* await withD1AuditedTransaction(db, "user_123", async (tx) => {
|
|
35
|
+
* await tx.insert(users).values({ id: "u1", name: "Ada" })
|
|
36
|
+
* })
|
|
37
|
+
*/
|
|
38
|
+
export declare function withD1AuditedTransaction<TDb extends D1AuditSqlExecutor, TResult>(db: TDb & {
|
|
39
|
+
transaction: (cb: (tx: any) => TResult) => TResult;
|
|
40
|
+
}, actorId: string, callback: (tx: TDb) => TResult, options?: {
|
|
41
|
+
workspaceId?: string;
|
|
42
|
+
contextTable?: string;
|
|
43
|
+
}): TResult;
|
|
44
|
+
//# sourceMappingURL=runtime.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"runtime.d.ts","sourceRoot":"","sources":["../../../src/d1/runtime.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAA;AAUpD;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAC/B,EAAE,EAAE,kBAAkB,EACtB,OAAO,EAAE,MAAM,EACf,OAAO,CAAC,EAAE;IAAE,WAAW,CAAC,EAAE,MAAM,CAAC;IAAC,YAAY,CAAC,EAAE,MAAM,CAAA;CAAE,gCAyB1D;AAED;;;GAGG;AACH,wBAAgB,mBAAmB,CACjC,EAAE,EAAE,kBAAkB,EACtB,OAAO,CAAC,EAAE;IAAE,YAAY,CAAC,EAAE,MAAM,CAAA;CAAE,WAMpC;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,wBAAwB,CAAC,GAAG,SAAS,kBAAkB,EAAE,OAAO,EAC9E,EAAE,EAAE,GAAG,GAAG;IAAE,WAAW,EAAE,CAAC,EAAE,EAAE,CAAC,EAAE,EAAE,GAAG,KAAK,OAAO,KAAK,OAAO,CAAA;CAAE,EAChE,OAAO,EAAE,MAAM,EACf,QAAQ,EAAE,CAAC,EAAE,EAAE,GAAG,KAAK,OAAO,EAC9B,OAAO,CAAC,EAAE;IAAE,WAAW,CAAC,EAAE,MAAM,CAAC;IAAC,YAAY,CAAC,EAAE,MAAM,CAAA;CAAE,GACxD,OAAO,CAWT"}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { sql } from "drizzle-orm";
|
|
2
|
+
const DEFAULT_CONTEXT_TABLE = "_audit_context";
|
|
3
|
+
function assertActorId(actorId) {
|
|
4
|
+
if (actorId.trim().length === 0) {
|
|
5
|
+
throw new Error("actorId must not be empty");
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Sets the audit context for the current transaction by writing to the
|
|
10
|
+
* _audit_context table. Must be called inside a transaction before any
|
|
11
|
+
* audited writes.
|
|
12
|
+
*
|
|
13
|
+
* D1/SQLite has no session variables, so triggers read context from this table.
|
|
14
|
+
*/
|
|
15
|
+
export function setD1AuditContext(db, actorId, options) {
|
|
16
|
+
assertActorId(actorId);
|
|
17
|
+
const table = options?.contextTable ?? DEFAULT_CONTEXT_TABLE;
|
|
18
|
+
const result = db.run(sql `INSERT OR REPLACE INTO ${sql.identifier(table)} (key, value) VALUES ('user_id', ${actorId})`);
|
|
19
|
+
// Handle async drivers (D1) by chaining if result is a Promise
|
|
20
|
+
if (result && typeof result.then === "function") {
|
|
21
|
+
return result.then(() => {
|
|
22
|
+
if (options?.workspaceId !== undefined && options.workspaceId !== "") {
|
|
23
|
+
return db.run(sql `INSERT OR REPLACE INTO ${sql.identifier(table)} (key, value) VALUES ('workspace_id', ${options.workspaceId})`);
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
if (options?.workspaceId !== undefined && options.workspaceId !== "") {
|
|
28
|
+
db.run(sql `INSERT OR REPLACE INTO ${sql.identifier(table)} (key, value) VALUES ('workspace_id', ${options.workspaceId})`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Clears the audit context after a transaction completes.
|
|
33
|
+
* Called automatically by withD1AuditedTransaction.
|
|
34
|
+
*/
|
|
35
|
+
export function clearD1AuditContext(db, options) {
|
|
36
|
+
const table = options?.contextTable ?? DEFAULT_CONTEXT_TABLE;
|
|
37
|
+
return db.run(sql `DELETE FROM ${sql.identifier(table)} WHERE key IN ('user_id', 'workspace_id')`);
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Wraps a Drizzle SQLite transaction with audit context. Sets the actor
|
|
41
|
+
* before the callback and clears the context after (success or failure).
|
|
42
|
+
*
|
|
43
|
+
* Works with any Drizzle SQLite instance (better-sqlite3, D1, libsql).
|
|
44
|
+
*
|
|
45
|
+
* @example
|
|
46
|
+
* // better-sqlite3 (sync)
|
|
47
|
+
* withD1AuditedTransaction(db, "user_123", (tx) => {
|
|
48
|
+
* tx.insert(users).values({ id: "u1", name: "Ada" }).run()
|
|
49
|
+
* })
|
|
50
|
+
*
|
|
51
|
+
* @example
|
|
52
|
+
* // D1 (async)
|
|
53
|
+
* await withD1AuditedTransaction(db, "user_123", async (tx) => {
|
|
54
|
+
* await tx.insert(users).values({ id: "u1", name: "Ada" })
|
|
55
|
+
* })
|
|
56
|
+
*/
|
|
57
|
+
export function withD1AuditedTransaction(db, actorId, callback, options) {
|
|
58
|
+
assertActorId(actorId);
|
|
59
|
+
return db.transaction((tx) => {
|
|
60
|
+
setD1AuditContext(tx, actorId, options);
|
|
61
|
+
try {
|
|
62
|
+
return callback(tx);
|
|
63
|
+
}
|
|
64
|
+
finally {
|
|
65
|
+
clearD1AuditContext(tx, options);
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { D1AuditInstallOptions, D1AuditTriggerTarget } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Generates SQL to install the audit_logs table and _audit_context table.
|
|
4
|
+
*
|
|
5
|
+
* The _audit_context table stores user_id (and optionally workspace_id) for
|
|
6
|
+
* the current transaction. Since D1/SQLite has no session variables, triggers
|
|
7
|
+
* read context from this table instead.
|
|
8
|
+
*/
|
|
9
|
+
export declare function createD1AuditInstallSql(options?: D1AuditInstallOptions): string;
|
|
10
|
+
/**
|
|
11
|
+
* Generates SQL to attach INSERT, UPDATE, and DELETE audit triggers to a single table.
|
|
12
|
+
*
|
|
13
|
+
* Unlike the Postgres variant, D1/SQLite triggers cannot serialize full row data dynamically
|
|
14
|
+
* (no `row_to_json` equivalent). Audit rows capture `table_name`, `operation`, `row_id`,
|
|
15
|
+
* and `user_id`. For full row snapshots, use `createAttachD1AuditTriggerSqlWithColumns`.
|
|
16
|
+
*/
|
|
17
|
+
export declare function createAttachD1AuditTriggerSql(target: D1AuditTriggerTarget, options?: D1AuditInstallOptions): string;
|
|
18
|
+
export declare function createAttachD1AuditTriggersSql(targets: D1AuditTriggerTarget[], options?: D1AuditInstallOptions): string;
|
|
19
|
+
export type D1AuditTriggerTargetWithColumns = D1AuditTriggerTarget & {
|
|
20
|
+
/** Column names to capture in old_data/new_data JSON snapshots */
|
|
21
|
+
columns: string[];
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* Column-aware variant: generates triggers that capture full row snapshots
|
|
25
|
+
* using json_object() with the specified column names.
|
|
26
|
+
*
|
|
27
|
+
* Unlike Postgres, SQLite cannot enumerate columns dynamically in triggers,
|
|
28
|
+
* so you must list the columns to capture.
|
|
29
|
+
*
|
|
30
|
+
* @example
|
|
31
|
+
* createAttachD1AuditTriggerSqlWithColumns(
|
|
32
|
+
* { table: "users", columns: ["id", "name", "email"] },
|
|
33
|
+
* )
|
|
34
|
+
*/
|
|
35
|
+
export declare function createAttachD1AuditTriggerSqlWithColumns(target: D1AuditTriggerTargetWithColumns, options?: D1AuditInstallOptions): string;
|
|
36
|
+
export declare function createAttachD1AuditTriggersSqlWithColumns(targets: D1AuditTriggerTargetWithColumns[], options?: D1AuditInstallOptions): string;
|
|
37
|
+
//# sourceMappingURL=sql.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sql.d.ts","sourceRoot":"","sources":["../../../src/d1/sql.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,qBAAqB,EAAE,oBAAoB,EAAE,MAAM,YAAY,CAAA;AAqB7E;;;;;;GAMG;AACH,wBAAgB,uBAAuB,CAAC,OAAO,GAAE,qBAA0B,UA6C1E;AAoHD;;;;;;GAMG;AACH,wBAAgB,6BAA6B,CAC3C,MAAM,EAAE,oBAAoB,EAC5B,OAAO,GAAE,qBAA0B,UAOpC;AAED,wBAAgB,8BAA8B,CAC5C,OAAO,EAAE,oBAAoB,EAAE,EAC/B,OAAO,GAAE,qBAA0B,UAQpC;AAID,MAAM,MAAM,+BAA+B,GAAG,oBAAoB,GAAG;IACnE,kEAAkE;IAClE,OAAO,EAAE,MAAM,EAAE,CAAA;CAClB,CAAA;AA4HD;;;;;;;;;;;GAWG;AACH,wBAAgB,wCAAwC,CACtD,MAAM,EAAE,+BAA+B,EACvC,OAAO,GAAE,qBAA0B,UAUpC;AAED,wBAAgB,yCAAyC,CACvD,OAAO,EAAE,+BAA+B,EAAE,EAC1C,OAAO,GAAE,qBAA0B,UAQpC"}
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
const DEFAULT_AUDIT_TABLE = "audit_logs";
|
|
2
|
+
const DEFAULT_CONTEXT_TABLE = "_audit_context";
|
|
3
|
+
const DEFAULT_ROW_ID_COLUMN = "id";
|
|
4
|
+
function quoteIdent(value) {
|
|
5
|
+
return `"${value.replaceAll('"', '""')}"`;
|
|
6
|
+
}
|
|
7
|
+
function quoteLiteral(value) {
|
|
8
|
+
return `'${value.replaceAll("'", "''")}'`;
|
|
9
|
+
}
|
|
10
|
+
function assertNonEmpty(value, label) {
|
|
11
|
+
if (value.trim().length === 0) {
|
|
12
|
+
throw new Error(`${label} must not be empty`);
|
|
13
|
+
}
|
|
14
|
+
return value;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Generates SQL to install the audit_logs table and _audit_context table.
|
|
18
|
+
*
|
|
19
|
+
* The _audit_context table stores user_id (and optionally workspace_id) for
|
|
20
|
+
* the current transaction. Since D1/SQLite has no session variables, triggers
|
|
21
|
+
* read context from this table instead.
|
|
22
|
+
*/
|
|
23
|
+
export function createD1AuditInstallSql(options = {}) {
|
|
24
|
+
const auditTable = assertNonEmpty(options.auditTable ?? DEFAULT_AUDIT_TABLE, "auditTable");
|
|
25
|
+
const contextTable = assertNonEmpty(options.contextTable ?? DEFAULT_CONTEXT_TABLE, "contextTable");
|
|
26
|
+
const workspaceIdColumn = options.workspaceIdColumn?.trim();
|
|
27
|
+
const auditColumns = [
|
|
28
|
+
"id INTEGER PRIMARY KEY AUTOINCREMENT",
|
|
29
|
+
"table_name TEXT NOT NULL",
|
|
30
|
+
"operation TEXT NOT NULL CHECK (operation IN ('INSERT', 'UPDATE', 'DELETE'))",
|
|
31
|
+
"row_id TEXT",
|
|
32
|
+
"user_id TEXT",
|
|
33
|
+
...(workspaceIdColumn ? [`${quoteIdent(workspaceIdColumn)} TEXT`] : []),
|
|
34
|
+
"old_data TEXT",
|
|
35
|
+
"new_data TEXT",
|
|
36
|
+
"created_at TEXT NOT NULL DEFAULT (datetime('now'))",
|
|
37
|
+
];
|
|
38
|
+
const contextColumns = [
|
|
39
|
+
"key TEXT PRIMARY KEY",
|
|
40
|
+
"value TEXT",
|
|
41
|
+
];
|
|
42
|
+
const indexStatements = [
|
|
43
|
+
`CREATE INDEX IF NOT EXISTS ${quoteIdent(`${auditTable}_table_name_idx`)} ON ${quoteIdent(auditTable)} (table_name);`,
|
|
44
|
+
`CREATE INDEX IF NOT EXISTS ${quoteIdent(`${auditTable}_row_id_idx`)} ON ${quoteIdent(auditTable)} (row_id);`,
|
|
45
|
+
`CREATE INDEX IF NOT EXISTS ${quoteIdent(`${auditTable}_user_id_idx`)} ON ${quoteIdent(auditTable)} (user_id);`,
|
|
46
|
+
...(workspaceIdColumn
|
|
47
|
+
? [
|
|
48
|
+
`CREATE INDEX IF NOT EXISTS ${quoteIdent(`${auditTable}_${workspaceIdColumn}_idx`)} ON ${quoteIdent(auditTable)} (${quoteIdent(workspaceIdColumn)});`,
|
|
49
|
+
]
|
|
50
|
+
: []),
|
|
51
|
+
`CREATE INDEX IF NOT EXISTS ${quoteIdent(`${auditTable}_created_at_idx`)} ON ${quoteIdent(auditTable)} (created_at);`,
|
|
52
|
+
];
|
|
53
|
+
return [
|
|
54
|
+
`CREATE TABLE IF NOT EXISTS ${quoteIdent(auditTable)} (\n ${auditColumns.join(",\n ")}\n);`,
|
|
55
|
+
`CREATE TABLE IF NOT EXISTS ${quoteIdent(contextTable)} (\n ${contextColumns.join(",\n ")}\n);`,
|
|
56
|
+
...indexStatements,
|
|
57
|
+
].join("\n\n");
|
|
58
|
+
}
|
|
59
|
+
function buildInsertTriggerSql(target, options) {
|
|
60
|
+
const table = assertNonEmpty(target.table, "table");
|
|
61
|
+
const rowIdColumn = assertNonEmpty(target.rowIdColumn ?? DEFAULT_ROW_ID_COLUMN, "rowIdColumn");
|
|
62
|
+
const auditTable = assertNonEmpty(options.auditTable ?? DEFAULT_AUDIT_TABLE, "auditTable");
|
|
63
|
+
const contextTable = assertNonEmpty(options.contextTable ?? DEFAULT_CONTEXT_TABLE, "contextTable");
|
|
64
|
+
const triggerPrefix = target.triggerPrefix ?? table;
|
|
65
|
+
const triggerName = `${triggerPrefix}_audit_insert`;
|
|
66
|
+
const workspaceIdColumn = options.workspaceIdColumn?.trim();
|
|
67
|
+
return `
|
|
68
|
+
DROP TRIGGER IF EXISTS ${quoteIdent(triggerName)};
|
|
69
|
+
|
|
70
|
+
CREATE TRIGGER ${quoteIdent(triggerName)}
|
|
71
|
+
AFTER INSERT ON ${quoteIdent(table)}
|
|
72
|
+
FOR EACH ROW
|
|
73
|
+
BEGIN
|
|
74
|
+
INSERT INTO ${quoteIdent(auditTable)} (table_name, operation, row_id, user_id${workspaceIdColumn ? `, ${quoteIdent(workspaceIdColumn)}` : ""})
|
|
75
|
+
VALUES (
|
|
76
|
+
${quoteLiteral(table)},
|
|
77
|
+
'INSERT',
|
|
78
|
+
NEW.${quoteIdent(rowIdColumn)},
|
|
79
|
+
(SELECT value FROM ${quoteIdent(contextTable)} WHERE key = 'user_id')${workspaceIdColumn ? `,\n (SELECT value FROM ${quoteIdent(contextTable)} WHERE key = 'workspace_id')` : ""}
|
|
80
|
+
);
|
|
81
|
+
END;`.trim();
|
|
82
|
+
}
|
|
83
|
+
function buildUpdateTriggerSql(target, options) {
|
|
84
|
+
const table = assertNonEmpty(target.table, "table");
|
|
85
|
+
const rowIdColumn = assertNonEmpty(target.rowIdColumn ?? DEFAULT_ROW_ID_COLUMN, "rowIdColumn");
|
|
86
|
+
const auditTable = assertNonEmpty(options.auditTable ?? DEFAULT_AUDIT_TABLE, "auditTable");
|
|
87
|
+
const contextTable = assertNonEmpty(options.contextTable ?? DEFAULT_CONTEXT_TABLE, "contextTable");
|
|
88
|
+
const triggerPrefix = target.triggerPrefix ?? table;
|
|
89
|
+
const triggerName = `${triggerPrefix}_audit_update`;
|
|
90
|
+
const workspaceIdColumn = options.workspaceIdColumn?.trim();
|
|
91
|
+
return `
|
|
92
|
+
DROP TRIGGER IF EXISTS ${quoteIdent(triggerName)};
|
|
93
|
+
|
|
94
|
+
CREATE TRIGGER ${quoteIdent(triggerName)}
|
|
95
|
+
AFTER UPDATE ON ${quoteIdent(table)}
|
|
96
|
+
FOR EACH ROW
|
|
97
|
+
BEGIN
|
|
98
|
+
INSERT INTO ${quoteIdent(auditTable)} (table_name, operation, row_id, user_id${workspaceIdColumn ? `, ${quoteIdent(workspaceIdColumn)}` : ""})
|
|
99
|
+
VALUES (
|
|
100
|
+
${quoteLiteral(table)},
|
|
101
|
+
'UPDATE',
|
|
102
|
+
NEW.${quoteIdent(rowIdColumn)},
|
|
103
|
+
(SELECT value FROM ${quoteIdent(contextTable)} WHERE key = 'user_id')${workspaceIdColumn ? `,\n (SELECT value FROM ${quoteIdent(contextTable)} WHERE key = 'workspace_id')` : ""}
|
|
104
|
+
);
|
|
105
|
+
END;`.trim();
|
|
106
|
+
}
|
|
107
|
+
function buildDeleteTriggerSql(target, options) {
|
|
108
|
+
const table = assertNonEmpty(target.table, "table");
|
|
109
|
+
const rowIdColumn = assertNonEmpty(target.rowIdColumn ?? DEFAULT_ROW_ID_COLUMN, "rowIdColumn");
|
|
110
|
+
const auditTable = assertNonEmpty(options.auditTable ?? DEFAULT_AUDIT_TABLE, "auditTable");
|
|
111
|
+
const contextTable = assertNonEmpty(options.contextTable ?? DEFAULT_CONTEXT_TABLE, "contextTable");
|
|
112
|
+
const triggerPrefix = target.triggerPrefix ?? table;
|
|
113
|
+
const triggerName = `${triggerPrefix}_audit_delete`;
|
|
114
|
+
const workspaceIdColumn = options.workspaceIdColumn?.trim();
|
|
115
|
+
return `
|
|
116
|
+
DROP TRIGGER IF EXISTS ${quoteIdent(triggerName)};
|
|
117
|
+
|
|
118
|
+
CREATE TRIGGER ${quoteIdent(triggerName)}
|
|
119
|
+
AFTER DELETE ON ${quoteIdent(table)}
|
|
120
|
+
FOR EACH ROW
|
|
121
|
+
BEGIN
|
|
122
|
+
INSERT INTO ${quoteIdent(auditTable)} (table_name, operation, row_id, user_id${workspaceIdColumn ? `, ${quoteIdent(workspaceIdColumn)}` : ""})
|
|
123
|
+
VALUES (
|
|
124
|
+
${quoteLiteral(table)},
|
|
125
|
+
'DELETE',
|
|
126
|
+
OLD.${quoteIdent(rowIdColumn)},
|
|
127
|
+
(SELECT value FROM ${quoteIdent(contextTable)} WHERE key = 'user_id')${workspaceIdColumn ? `,\n (SELECT value FROM ${quoteIdent(contextTable)} WHERE key = 'workspace_id')` : ""}
|
|
128
|
+
);
|
|
129
|
+
END;`.trim();
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Generates SQL to attach INSERT, UPDATE, and DELETE audit triggers to a single table.
|
|
133
|
+
*
|
|
134
|
+
* Unlike the Postgres variant, D1/SQLite triggers cannot serialize full row data dynamically
|
|
135
|
+
* (no `row_to_json` equivalent). Audit rows capture `table_name`, `operation`, `row_id`,
|
|
136
|
+
* and `user_id`. For full row snapshots, use `createAttachD1AuditTriggerSqlWithColumns`.
|
|
137
|
+
*/
|
|
138
|
+
export function createAttachD1AuditTriggerSql(target, options = {}) {
|
|
139
|
+
return [
|
|
140
|
+
buildInsertTriggerSql(target, options),
|
|
141
|
+
buildUpdateTriggerSql(target, options),
|
|
142
|
+
buildDeleteTriggerSql(target, options),
|
|
143
|
+
].join("\n\n");
|
|
144
|
+
}
|
|
145
|
+
export function createAttachD1AuditTriggersSql(targets, options = {}) {
|
|
146
|
+
if (targets.length === 0) {
|
|
147
|
+
throw new Error("targets must contain at least one audited table");
|
|
148
|
+
}
|
|
149
|
+
return targets
|
|
150
|
+
.map((target) => createAttachD1AuditTriggerSql(target, options))
|
|
151
|
+
.join("\n\n");
|
|
152
|
+
}
|
|
153
|
+
function buildJsonObjectExpr(columns, ref) {
|
|
154
|
+
return `json_object(${columns.map((c) => `${quoteLiteral(c)}, ${ref}.${quoteIdent(c)}`).join(", ")})`;
|
|
155
|
+
}
|
|
156
|
+
function buildInsertTriggerWithColumnsSql(target, options) {
|
|
157
|
+
const table = assertNonEmpty(target.table, "table");
|
|
158
|
+
const rowIdColumn = assertNonEmpty(target.rowIdColumn ?? DEFAULT_ROW_ID_COLUMN, "rowIdColumn");
|
|
159
|
+
const auditTable = assertNonEmpty(options.auditTable ?? DEFAULT_AUDIT_TABLE, "auditTable");
|
|
160
|
+
const contextTable = assertNonEmpty(options.contextTable ?? DEFAULT_CONTEXT_TABLE, "contextTable");
|
|
161
|
+
const triggerPrefix = target.triggerPrefix ?? table;
|
|
162
|
+
const triggerName = `${triggerPrefix}_audit_insert`;
|
|
163
|
+
const workspaceIdColumn = options.workspaceIdColumn?.trim();
|
|
164
|
+
return `
|
|
165
|
+
DROP TRIGGER IF EXISTS ${quoteIdent(triggerName)};
|
|
166
|
+
|
|
167
|
+
CREATE TRIGGER ${quoteIdent(triggerName)}
|
|
168
|
+
AFTER INSERT ON ${quoteIdent(table)}
|
|
169
|
+
FOR EACH ROW
|
|
170
|
+
BEGIN
|
|
171
|
+
INSERT INTO ${quoteIdent(auditTable)} (table_name, operation, row_id, user_id${workspaceIdColumn ? `, ${quoteIdent(workspaceIdColumn)}` : ""}, new_data)
|
|
172
|
+
VALUES (
|
|
173
|
+
${quoteLiteral(table)},
|
|
174
|
+
'INSERT',
|
|
175
|
+
NEW.${quoteIdent(rowIdColumn)},
|
|
176
|
+
(SELECT value FROM ${quoteIdent(contextTable)} WHERE key = 'user_id')${workspaceIdColumn ? `,\n (SELECT value FROM ${quoteIdent(contextTable)} WHERE key = 'workspace_id')` : ""},
|
|
177
|
+
${buildJsonObjectExpr(target.columns, "NEW")}
|
|
178
|
+
);
|
|
179
|
+
END;`.trim();
|
|
180
|
+
}
|
|
181
|
+
function buildUpdateTriggerWithColumnsSql(target, options) {
|
|
182
|
+
const table = assertNonEmpty(target.table, "table");
|
|
183
|
+
const rowIdColumn = assertNonEmpty(target.rowIdColumn ?? DEFAULT_ROW_ID_COLUMN, "rowIdColumn");
|
|
184
|
+
const auditTable = assertNonEmpty(options.auditTable ?? DEFAULT_AUDIT_TABLE, "auditTable");
|
|
185
|
+
const contextTable = assertNonEmpty(options.contextTable ?? DEFAULT_CONTEXT_TABLE, "contextTable");
|
|
186
|
+
const triggerPrefix = target.triggerPrefix ?? table;
|
|
187
|
+
const triggerName = `${triggerPrefix}_audit_update`;
|
|
188
|
+
const workspaceIdColumn = options.workspaceIdColumn?.trim();
|
|
189
|
+
return `
|
|
190
|
+
DROP TRIGGER IF EXISTS ${quoteIdent(triggerName)};
|
|
191
|
+
|
|
192
|
+
CREATE TRIGGER ${quoteIdent(triggerName)}
|
|
193
|
+
AFTER UPDATE ON ${quoteIdent(table)}
|
|
194
|
+
FOR EACH ROW
|
|
195
|
+
BEGIN
|
|
196
|
+
INSERT INTO ${quoteIdent(auditTable)} (table_name, operation, row_id, user_id${workspaceIdColumn ? `, ${quoteIdent(workspaceIdColumn)}` : ""}, old_data, new_data)
|
|
197
|
+
VALUES (
|
|
198
|
+
${quoteLiteral(table)},
|
|
199
|
+
'UPDATE',
|
|
200
|
+
NEW.${quoteIdent(rowIdColumn)},
|
|
201
|
+
(SELECT value FROM ${quoteIdent(contextTable)} WHERE key = 'user_id')${workspaceIdColumn ? `,\n (SELECT value FROM ${quoteIdent(contextTable)} WHERE key = 'workspace_id')` : ""},
|
|
202
|
+
${buildJsonObjectExpr(target.columns, "OLD")},
|
|
203
|
+
${buildJsonObjectExpr(target.columns, "NEW")}
|
|
204
|
+
);
|
|
205
|
+
END;`.trim();
|
|
206
|
+
}
|
|
207
|
+
function buildDeleteTriggerWithColumnsSql(target, options) {
|
|
208
|
+
const table = assertNonEmpty(target.table, "table");
|
|
209
|
+
const rowIdColumn = assertNonEmpty(target.rowIdColumn ?? DEFAULT_ROW_ID_COLUMN, "rowIdColumn");
|
|
210
|
+
const auditTable = assertNonEmpty(options.auditTable ?? DEFAULT_AUDIT_TABLE, "auditTable");
|
|
211
|
+
const contextTable = assertNonEmpty(options.contextTable ?? DEFAULT_CONTEXT_TABLE, "contextTable");
|
|
212
|
+
const triggerPrefix = target.triggerPrefix ?? table;
|
|
213
|
+
const triggerName = `${triggerPrefix}_audit_delete`;
|
|
214
|
+
const workspaceIdColumn = options.workspaceIdColumn?.trim();
|
|
215
|
+
return `
|
|
216
|
+
DROP TRIGGER IF EXISTS ${quoteIdent(triggerName)};
|
|
217
|
+
|
|
218
|
+
CREATE TRIGGER ${quoteIdent(triggerName)}
|
|
219
|
+
AFTER DELETE ON ${quoteIdent(table)}
|
|
220
|
+
FOR EACH ROW
|
|
221
|
+
BEGIN
|
|
222
|
+
INSERT INTO ${quoteIdent(auditTable)} (table_name, operation, row_id, user_id${workspaceIdColumn ? `, ${quoteIdent(workspaceIdColumn)}` : ""}, old_data)
|
|
223
|
+
VALUES (
|
|
224
|
+
${quoteLiteral(table)},
|
|
225
|
+
'DELETE',
|
|
226
|
+
OLD.${quoteIdent(rowIdColumn)},
|
|
227
|
+
(SELECT value FROM ${quoteIdent(contextTable)} WHERE key = 'user_id')${workspaceIdColumn ? `,\n (SELECT value FROM ${quoteIdent(contextTable)} WHERE key = 'workspace_id')` : ""},
|
|
228
|
+
${buildJsonObjectExpr(target.columns, "OLD")}
|
|
229
|
+
);
|
|
230
|
+
END;`.trim();
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Column-aware variant: generates triggers that capture full row snapshots
|
|
234
|
+
* using json_object() with the specified column names.
|
|
235
|
+
*
|
|
236
|
+
* Unlike Postgres, SQLite cannot enumerate columns dynamically in triggers,
|
|
237
|
+
* so you must list the columns to capture.
|
|
238
|
+
*
|
|
239
|
+
* @example
|
|
240
|
+
* createAttachD1AuditTriggerSqlWithColumns(
|
|
241
|
+
* { table: "users", columns: ["id", "name", "email"] },
|
|
242
|
+
* )
|
|
243
|
+
*/
|
|
244
|
+
export function createAttachD1AuditTriggerSqlWithColumns(target, options = {}) {
|
|
245
|
+
if (target.columns.length === 0) {
|
|
246
|
+
throw new Error("columns must contain at least one column name");
|
|
247
|
+
}
|
|
248
|
+
return [
|
|
249
|
+
buildInsertTriggerWithColumnsSql(target, options),
|
|
250
|
+
buildUpdateTriggerWithColumnsSql(target, options),
|
|
251
|
+
buildDeleteTriggerWithColumnsSql(target, options),
|
|
252
|
+
].join("\n\n");
|
|
253
|
+
}
|
|
254
|
+
export function createAttachD1AuditTriggersSqlWithColumns(targets, options = {}) {
|
|
255
|
+
if (targets.length === 0) {
|
|
256
|
+
throw new Error("targets must contain at least one audited table");
|
|
257
|
+
}
|
|
258
|
+
return targets
|
|
259
|
+
.map((target) => createAttachD1AuditTriggerSqlWithColumns(target, options))
|
|
260
|
+
.join("\n\n");
|
|
261
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { SQL } from "drizzle-orm";
|
|
2
|
+
/**
|
|
3
|
+
* Minimal interface for executing raw SQL. Any Drizzle SQLite instance
|
|
4
|
+
* (D1, better-sqlite3, libsql) or transaction satisfies this.
|
|
5
|
+
*/
|
|
6
|
+
export type D1AuditSqlExecutor = {
|
|
7
|
+
run: (query: SQL) => unknown;
|
|
8
|
+
};
|
|
9
|
+
export type D1AuditInstallOptions = {
|
|
10
|
+
/** Name of the audit log table (default: "audit_logs") */
|
|
11
|
+
auditTable?: string;
|
|
12
|
+
/** Name of the context table used to pass user_id to triggers (default: "_audit_context") */
|
|
13
|
+
contextTable?: string;
|
|
14
|
+
/** Optional workspace column name (e.g. "workspace_id") */
|
|
15
|
+
workspaceIdColumn?: string;
|
|
16
|
+
};
|
|
17
|
+
export type D1AuditTriggerTarget = {
|
|
18
|
+
table: string;
|
|
19
|
+
rowIdColumn?: string;
|
|
20
|
+
triggerPrefix?: string;
|
|
21
|
+
};
|
|
22
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../src/d1/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,GAAG,EAAE,MAAM,aAAa,CAAA;AAEtC;;;GAGG;AACH,MAAM,MAAM,kBAAkB,GAAG;IAC/B,GAAG,EAAE,CAAC,KAAK,EAAE,GAAG,KAAK,OAAO,CAAA;CAC7B,CAAA;AAED,MAAM,MAAM,qBAAqB,GAAG;IAClC,0DAA0D;IAC1D,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,6FAA6F;IAC7F,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,2DAA2D;IAC3D,iBAAiB,CAAC,EAAE,MAAM,CAAA;CAC3B,CAAA;AAED,MAAM,MAAM,oBAAoB,GAAG;IACjC,KAAK,EAAE,MAAM,CAAA;IACb,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,aAAa,CAAC,EAAE,MAAM,CAAA;CACvB,CAAA"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/d1-runtime/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAA;AAC3C,YAAY,EACV,YAAY,EACZ,SAAS,EACT,mBAAmB,EACnB,eAAe,GAChB,MAAM,iBAAiB,CAAA"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { withAudit } from "./with-audit.js";
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { type InferInsertModel, type InferSelectModel, type SQL } from "drizzle-orm";
|
|
2
|
+
import type { SQLiteTable } from "drizzle-orm/sqlite-core";
|
|
3
|
+
export type AuditContext = {
|
|
4
|
+
userId: string;
|
|
5
|
+
workspaceId?: string;
|
|
6
|
+
};
|
|
7
|
+
export type AuditLogInsertShape = {
|
|
8
|
+
table_name: string;
|
|
9
|
+
operation: string;
|
|
10
|
+
row_id: string | null;
|
|
11
|
+
user_id: string;
|
|
12
|
+
old_data: string | null;
|
|
13
|
+
new_data: string | null;
|
|
14
|
+
[key: string]: unknown;
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* Minimal interface for a Drizzle SQLite database/transaction.
|
|
18
|
+
* Works with D1, better-sqlite3, libsql.
|
|
19
|
+
*/
|
|
20
|
+
export type DrizzleSQLiteDb = {
|
|
21
|
+
insert: (table: any) => any;
|
|
22
|
+
update: (table: any) => any;
|
|
23
|
+
delete: (table: any) => any;
|
|
24
|
+
select: (fields?: any) => any;
|
|
25
|
+
transaction: (cb: (tx: any) => any) => any;
|
|
26
|
+
};
|
|
27
|
+
export type AuditedDb<TDb extends DrizzleSQLiteDb> = {
|
|
28
|
+
/**
|
|
29
|
+
* Insert a row and log an INSERT audit event.
|
|
30
|
+
* Returns the inserted row.
|
|
31
|
+
*/
|
|
32
|
+
insert: <T extends SQLiteTable>(table: T, data: InferInsertModel<T>) => InferSelectModel<T>;
|
|
33
|
+
/**
|
|
34
|
+
* Update rows matching `where` and log an UPDATE audit event for each affected row.
|
|
35
|
+
* Captures old_data (before) and new_data (after).
|
|
36
|
+
* Returns the updated rows.
|
|
37
|
+
*/
|
|
38
|
+
update: <T extends SQLiteTable>(table: T, where: SQL, data: Partial<InferInsertModel<T>>) => InferSelectModel<T>[];
|
|
39
|
+
/**
|
|
40
|
+
* Delete rows matching `where` and log a DELETE audit event for each affected row.
|
|
41
|
+
* Captures old_data.
|
|
42
|
+
* Returns the deleted rows.
|
|
43
|
+
*/
|
|
44
|
+
delete: <T extends SQLiteTable>(table: T, where: SQL) => InferSelectModel<T>[];
|
|
45
|
+
/** Access the underlying db for non-audited operations. */
|
|
46
|
+
db: TDb;
|
|
47
|
+
};
|
|
48
|
+
/**
|
|
49
|
+
* Creates an audited wrapper around a Drizzle SQLite database.
|
|
50
|
+
* Each insert/update/delete is wrapped in a transaction that atomically
|
|
51
|
+
* writes to both the target table and the audit_logs table.
|
|
52
|
+
*
|
|
53
|
+
* @param db - A Drizzle SQLite database instance (D1, better-sqlite3, libsql)
|
|
54
|
+
* @param auditTable - The Drizzle table definition for audit_logs
|
|
55
|
+
* @param context - The audit context (userId, optional workspaceId)
|
|
56
|
+
*
|
|
57
|
+
* @example
|
|
58
|
+
* import { withAudit } from "drizzle-audit/d1-runtime"
|
|
59
|
+
* import { d1AuditLogTable } from "drizzle-audit/d1"
|
|
60
|
+
*
|
|
61
|
+
* const auditLogs = d1AuditLogTable()
|
|
62
|
+
* const audited = withAudit(db, auditLogs, { userId: session.userId })
|
|
63
|
+
*
|
|
64
|
+
* // Audited insert
|
|
65
|
+
* audited.insert(users, { id: "u1", name: "Ada" })
|
|
66
|
+
*
|
|
67
|
+
* // Audited update — captures old + new data
|
|
68
|
+
* audited.update(users, eq(users.id, "u1"), { name: "Ada Lovelace" })
|
|
69
|
+
*
|
|
70
|
+
* // Audited delete — captures deleted data
|
|
71
|
+
* audited.delete(users, eq(users.id, "u1"))
|
|
72
|
+
*
|
|
73
|
+
* // Non-audited access
|
|
74
|
+
* audited.db.select().from(users).all()
|
|
75
|
+
*/
|
|
76
|
+
export declare function withAudit<TDb extends DrizzleSQLiteDb>(db: TDb, auditTable: SQLiteTable, context: AuditContext): AuditedDb<TDb>;
|
|
77
|
+
//# sourceMappingURL=with-audit.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"with-audit.d.ts","sourceRoot":"","sources":["../../../src/d1-runtime/with-audit.ts"],"names":[],"mappings":"AAAA,OAAO,EAGL,KAAK,gBAAgB,EACrB,KAAK,gBAAgB,EACrB,KAAK,GAAG,EACT,MAAM,aAAa,CAAA;AACpB,OAAO,KAAK,EAAgB,WAAW,EAAE,MAAM,yBAAyB,CAAA;AAIxE,MAAM,MAAM,YAAY,GAAG;IACzB,MAAM,EAAE,MAAM,CAAA;IACd,WAAW,CAAC,EAAE,MAAM,CAAA;CACrB,CAAA;AAED,MAAM,MAAM,mBAAmB,GAAG;IAChC,UAAU,EAAE,MAAM,CAAA;IAClB,SAAS,EAAE,MAAM,CAAA;IACjB,MAAM,EAAE,MAAM,GAAG,IAAI,CAAA;IACrB,OAAO,EAAE,MAAM,CAAA;IACf,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAA;IACvB,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAA;IACvB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAA;CACvB,CAAA;AAED;;;GAGG;AACH,MAAM,MAAM,eAAe,GAAG;IAC5B,MAAM,EAAE,CAAC,KAAK,EAAE,GAAG,KAAK,GAAG,CAAA;IAC3B,MAAM,EAAE,CAAC,KAAK,EAAE,GAAG,KAAK,GAAG,CAAA;IAC3B,MAAM,EAAE,CAAC,KAAK,EAAE,GAAG,KAAK,GAAG,CAAA;IAC3B,MAAM,EAAE,CAAC,MAAM,CAAC,EAAE,GAAG,KAAK,GAAG,CAAA;IAC7B,WAAW,EAAE,CAAC,EAAE,EAAE,CAAC,EAAE,EAAE,GAAG,KAAK,GAAG,KAAK,GAAG,CAAA;CAC3C,CAAA;AAgBD,MAAM,MAAM,SAAS,CAAC,GAAG,SAAS,eAAe,IAAI;IACnD;;;OAGG;IACH,MAAM,EAAE,CAAC,CAAC,SAAS,WAAW,EAC5B,KAAK,EAAE,CAAC,EACR,IAAI,EAAE,gBAAgB,CAAC,CAAC,CAAC,KACtB,gBAAgB,CAAC,CAAC,CAAC,CAAA;IAExB;;;;OAIG;IACH,MAAM,EAAE,CAAC,CAAC,SAAS,WAAW,EAC5B,KAAK,EAAE,CAAC,EACR,KAAK,EAAE,GAAG,EACV,IAAI,EAAE,OAAO,CAAC,gBAAgB,CAAC,CAAC,CAAC,CAAC,KAC/B,gBAAgB,CAAC,CAAC,CAAC,EAAE,CAAA;IAE1B;;;;OAIG;IACH,MAAM,EAAE,CAAC,CAAC,SAAS,WAAW,EAC5B,KAAK,EAAE,CAAC,EACR,KAAK,EAAE,GAAG,KACP,gBAAgB,CAAC,CAAC,CAAC,EAAE,CAAA;IAE1B,2DAA2D;IAC3D,EAAE,EAAE,GAAG,CAAA;CACR,CAAA;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,wBAAgB,SAAS,CAAC,GAAG,SAAS,eAAe,EACnD,EAAE,EAAE,GAAG,EACP,UAAU,EAAE,WAAW,EACvB,OAAO,EAAE,YAAY,GACpB,SAAS,CAAC,GAAG,CAAC,CAoHhB"}
|