@willyim/drizzle-audit 0.3.0 → 0.5.0

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.
Files changed (47) hide show
  1. package/README.md +102 -21
  2. package/dist/src/d1/audit-log-schema.d.ts +3 -2
  3. package/dist/src/d1/audit-log-schema.d.ts.map +1 -1
  4. package/dist/src/d1/audit-log-schema.js +14 -4
  5. package/dist/src/d1/index.d.ts +2 -1
  6. package/dist/src/d1/index.d.ts.map +1 -1
  7. package/dist/src/d1/runtime.d.ts +11 -11
  8. package/dist/src/d1/runtime.d.ts.map +1 -1
  9. package/dist/src/d1/runtime.js +26 -9
  10. package/dist/src/d1/sql.d.ts +2 -2
  11. package/dist/src/d1/sql.d.ts.map +1 -1
  12. package/dist/src/d1/sql.js +61 -29
  13. package/dist/src/d1/types.d.ts +10 -2
  14. package/dist/src/d1/types.d.ts.map +1 -1
  15. package/dist/src/d1-runtime/with-audit.d.ts +15 -11
  16. package/dist/src/d1-runtime/with-audit.d.ts.map +1 -1
  17. package/dist/src/d1-runtime/with-audit.js +53 -60
  18. package/dist/src/index.d.ts +2 -1
  19. package/dist/src/index.d.ts.map +1 -1
  20. package/dist/src/index.js +1 -1
  21. package/dist/src/postgres/audit-log-schema.d.ts +7 -14
  22. package/dist/src/postgres/audit-log-schema.d.ts.map +1 -1
  23. package/dist/src/postgres/audit-log-schema.js +14 -4
  24. package/dist/src/postgres/index.d.ts +3 -2
  25. package/dist/src/postgres/index.d.ts.map +1 -1
  26. package/dist/src/postgres/index.js +1 -1
  27. package/dist/src/postgres/runtime.d.ts +6 -8
  28. package/dist/src/postgres/runtime.d.ts.map +1 -1
  29. package/dist/src/postgres/runtime.js +15 -3
  30. package/dist/src/postgres/sql.d.ts +10 -7
  31. package/dist/src/postgres/sql.d.ts.map +1 -1
  32. package/dist/src/postgres/sql.js +72 -50
  33. package/dist/src/postgres/types.d.ts +10 -2
  34. package/dist/src/postgres/types.d.ts.map +1 -1
  35. package/dist/test/d1-async.integration.test.d.ts +13 -0
  36. package/dist/test/d1-async.integration.test.d.ts.map +1 -0
  37. package/dist/test/d1-async.integration.test.js +159 -0
  38. package/dist/test/d1.integration.test.js +71 -4
  39. package/dist/test/sqlite.integration.test.d.ts +2 -0
  40. package/dist/test/sqlite.integration.test.d.ts.map +1 -0
  41. package/dist/test/{d1-runtime.integration.test.js → sqlite.integration.test.js} +82 -25
  42. package/package.json +2 -1
  43. package/dist/test/d1-runtime.integration.test.d.ts +0 -2
  44. package/dist/test/d1-runtime.integration.test.d.ts.map +0 -1
  45. package/dist/test/postgres.integration.test.d.ts +0 -2
  46. package/dist/test/postgres.integration.test.d.ts.map +0 -1
  47. package/dist/test/postgres.integration.test.js +0 -286
@@ -21,46 +21,58 @@ function assertNonEmpty(value, label) {
21
21
  }
22
22
  return value;
23
23
  }
24
- function buildTriggerFunctionSql(qualifiedAuditTable, qualifiedTriggerFunction, contextLiteral, options) {
25
- const workspaceIdColumn = options.workspaceIdColumn?.trim();
26
- const hasWorkspace = Boolean(workspaceIdColumn);
27
- const workspaceContextLiteral = hasWorkspace
28
- ? quoteLiteral("app." + workspaceIdColumn)
29
- : "";
30
- const workspaceCol = hasWorkspace ? quoteIdent(workspaceIdColumn) : "";
31
- const declWorkspace = hasWorkspace ? "\n audit_workspace TEXT;" : "";
32
- const readWorkspace = hasWorkspace
33
- ? `\n audit_workspace := NULLIF(current_setting(${workspaceContextLiteral}, true), '');`
34
- : "";
24
+ /**
25
+ * Normalizes the context columns from the install options, de-duplicating by
26
+ * column name and applying defaults for `sessionKey` / `index`.
27
+ */
28
+ function normalizeContextColumns(options) {
29
+ const resolved = [];
30
+ const seen = new Set();
31
+ for (const entry of options.contextColumns ?? []) {
32
+ const column = entry.column?.trim();
33
+ if (!column) {
34
+ throw new Error("contextColumns[].column must not be empty");
35
+ }
36
+ if (seen.has(column)) {
37
+ continue;
38
+ }
39
+ seen.add(column);
40
+ resolved.push({
41
+ column,
42
+ sessionKey: entry.sessionKey?.trim() || `app.${column}`,
43
+ index: entry.index ?? true,
44
+ });
45
+ }
46
+ return resolved;
47
+ }
48
+ function buildTriggerFunctionSql(qualifiedAuditTable, qualifiedTriggerFunction, contextLiteral, contextColumns) {
49
+ const declContext = contextColumns
50
+ .map((_, i) => `\n audit_ctx_${i} TEXT;`)
51
+ .join("");
52
+ const readContext = contextColumns
53
+ .map((col, i) => `\n audit_ctx_${i} := NULLIF(current_setting(${quoteLiteral(col.sessionKey)}, true), '');`)
54
+ .join("");
55
+ const ctxColIdents = contextColumns.map((col) => quoteIdent(col.column));
56
+ const ctxColPrefix = ctxColIdents.length ? `, ${ctxColIdents.join(", ")}` : "";
57
+ const ctxValExprs = contextColumns.map((_, i) => `audit_ctx_${i}`);
58
+ const ctxValPrefix = ctxValExprs.length ? `, ${ctxValExprs.join(", ")}` : "";
35
59
  const insertColsBase = "table_name, operation, row_id, user_id";
36
- const insertColsInsert = hasWorkspace
37
- ? `${insertColsBase}, ${workspaceCol}, new_data`
38
- : `${insertColsBase}, new_data`;
39
- const insertColsUpdate = hasWorkspace
40
- ? `${insertColsBase}, ${workspaceCol}, old_data, new_data`
41
- : `${insertColsBase}, old_data, new_data`;
42
- const insertColsDelete = hasWorkspace
43
- ? `${insertColsBase}, ${workspaceCol}, old_data`
44
- : `${insertColsBase}, old_data`;
45
- const valuesInsert = hasWorkspace
46
- ? `TG_TABLE_NAME, TG_OP, current_row_id, audit_user, audit_workspace, to_jsonb(NEW)`
47
- : `TG_TABLE_NAME, TG_OP, current_row_id, audit_user, to_jsonb(NEW)`;
48
- const valuesUpdate = hasWorkspace
49
- ? `TG_TABLE_NAME, TG_OP, current_row_id, audit_user, audit_workspace, to_jsonb(OLD), to_jsonb(NEW)`
50
- : `TG_TABLE_NAME, TG_OP, current_row_id, audit_user, to_jsonb(OLD), to_jsonb(NEW)`;
51
- const valuesDelete = hasWorkspace
52
- ? `TG_TABLE_NAME, TG_OP, current_row_id, audit_user, audit_workspace, to_jsonb(OLD)`
53
- : `TG_TABLE_NAME, TG_OP, current_row_id, audit_user, to_jsonb(OLD)`;
60
+ const insertColsInsert = `${insertColsBase}${ctxColPrefix}, new_data`;
61
+ const insertColsUpdate = `${insertColsBase}${ctxColPrefix}, old_data, new_data`;
62
+ const insertColsDelete = `${insertColsBase}${ctxColPrefix}, old_data`;
63
+ const valuesInsert = `TG_TABLE_NAME, TG_OP, current_row_id, audit_user${ctxValPrefix}, to_jsonb(NEW)`;
64
+ const valuesUpdate = `TG_TABLE_NAME, TG_OP, current_row_id, audit_user${ctxValPrefix}, to_jsonb(OLD), to_jsonb(NEW)`;
65
+ const valuesDelete = `TG_TABLE_NAME, TG_OP, current_row_id, audit_user${ctxValPrefix}, to_jsonb(OLD)`;
54
66
  return `
55
67
  CREATE OR REPLACE FUNCTION ${qualifiedTriggerFunction}()
56
68
  RETURNS TRIGGER AS $$
57
69
  DECLARE
58
- audit_user TEXT;${declWorkspace}
70
+ audit_user TEXT;${declContext}
59
71
  row_id_column TEXT;
60
72
  current_row JSONB;
61
73
  current_row_id TEXT;
62
74
  BEGIN
63
- audit_user := NULLIF(current_setting(${contextLiteral}, true), '');${readWorkspace}
75
+ audit_user := NULLIF(current_setting(${contextLiteral}, true), '');${readContext}
64
76
 
65
77
  row_id_column := COALESCE(NULLIF(TG_ARGV[0], ''), ${quoteLiteral(DEFAULT_ROW_ID_COLUMN)});
66
78
 
@@ -102,7 +114,7 @@ export function createAuditInstallSql(options = {}) {
102
114
  const auditTable = assertNonEmpty(options.auditTable ?? DEFAULT_AUDIT_TABLE, "auditTable");
103
115
  const contextKey = assertNonEmpty(options.contextKey ?? DEFAULT_CONTEXT_KEY, "contextKey");
104
116
  const triggerFunctionName = assertNonEmpty(options.triggerFunctionName ?? DEFAULT_TRIGGER_FUNCTION, "triggerFunctionName");
105
- const workspaceIdColumn = options.workspaceIdColumn?.trim();
117
+ const contextColumns = normalizeContextColumns(options);
106
118
  const qualifiedAuditTable = qualifyName(auditTable, auditSchema);
107
119
  const qualifiedTriggerFunction = qualifyName(triggerFunctionName, auditSchema);
108
120
  const contextLiteral = quoteLiteral(contextKey);
@@ -112,7 +124,7 @@ export function createAuditInstallSql(options = {}) {
112
124
  "operation TEXT NOT NULL CHECK (operation IN ('INSERT', 'UPDATE', 'DELETE'))",
113
125
  "row_id TEXT",
114
126
  "user_id TEXT",
115
- ...(workspaceIdColumn ? [quoteIdent(workspaceIdColumn) + " TEXT"] : []),
127
+ ...contextColumns.map((col) => quoteIdent(col.column) + " TEXT"),
116
128
  "old_data JSONB",
117
129
  "new_data JSONB",
118
130
  "created_at TIMESTAMPTZ NOT NULL DEFAULT now()",
@@ -121,11 +133,9 @@ export function createAuditInstallSql(options = {}) {
121
133
  `CREATE INDEX IF NOT EXISTS ${quoteIdent(`${auditTable}_table_name_idx`)} ON ${qualifiedAuditTable} (table_name);`,
122
134
  `CREATE INDEX IF NOT EXISTS ${quoteIdent(`${auditTable}_row_id_idx`)} ON ${qualifiedAuditTable} (row_id);`,
123
135
  `CREATE INDEX IF NOT EXISTS ${quoteIdent(`${auditTable}_user_id_idx`)} ON ${qualifiedAuditTable} (user_id);`,
124
- ...(workspaceIdColumn
125
- ? [
126
- `CREATE INDEX IF NOT EXISTS ${quoteIdent(`${auditTable}_${workspaceIdColumn}_idx`)} ON ${qualifiedAuditTable} (${quoteIdent(workspaceIdColumn)});`,
127
- ]
128
- : []),
136
+ ...contextColumns
137
+ .filter((col) => col.index)
138
+ .map((col) => `CREATE INDEX IF NOT EXISTS ${quoteIdent(`${auditTable}_${col.column}_idx`)} ON ${qualifiedAuditTable} (${quoteIdent(col.column)});`),
129
139
  `CREATE INDEX IF NOT EXISTS ${quoteIdent(`${auditTable}_created_at_idx`)} ON ${qualifiedAuditTable} (created_at DESC);`,
130
140
  ];
131
141
  return [
@@ -135,7 +145,7 @@ CREATE TABLE IF NOT EXISTS ${qualifiedAuditTable} (
135
145
  ${tableColumns.join(",\n ")}
136
146
  );`.trim(),
137
147
  ...indexStatements,
138
- buildTriggerFunctionSql(qualifiedAuditTable, qualifiedTriggerFunction, contextLiteral, { workspaceIdColumn: workspaceIdColumn ?? undefined }),
148
+ buildTriggerFunctionSql(qualifiedAuditTable, qualifiedTriggerFunction, contextLiteral, contextColumns),
139
149
  ].join("\n\n");
140
150
  }
141
151
  export function createAttachAuditTriggerSql(target, options = {}) {
@@ -166,25 +176,37 @@ export function createAttachAuditTriggersSql(targets, options = {}) {
166
176
  .join("\n\n");
167
177
  }
168
178
  /**
169
- * Generates SQL to add the workspace column and replace the trigger function on an
170
- * existing audit_logs table. Use in a new migration when adding workspace_id after
171
- * the initial install. Options must match your install (auditSchema, auditTable,
172
- * triggerFunctionName, contextKey, workspaceIdColumn).
179
+ * Generates SQL to add context columns and regenerate the trigger function on an
180
+ * existing audit_logs table. Use in a new migration when adding context columns
181
+ * after the initial install.
182
+ *
183
+ * Pass the FULL set of context columns the audit table should have (the trigger is
184
+ * a single CREATE OR REPLACE, so it must reference every column). Columns are added
185
+ * with `ADD COLUMN IF NOT EXISTS`, so already-present columns are left untouched.
186
+ * Options must match your install (auditSchema, auditTable, triggerFunctionName,
187
+ * contextKey).
173
188
  */
174
- export function createAuditAddWorkspaceColumnSql(options) {
175
- const workspaceIdColumn = assertNonEmpty(options.workspaceIdColumn.trim(), "workspaceIdColumn");
189
+ export function createAuditAddContextColumnsSql(options = {}) {
176
190
  const auditSchema = assertNonEmpty(options.auditSchema ?? DEFAULT_AUDIT_SCHEMA, "auditSchema");
177
191
  const auditTable = assertNonEmpty(options.auditTable ?? DEFAULT_AUDIT_TABLE, "auditTable");
178
192
  const contextKey = assertNonEmpty(options.contextKey ?? DEFAULT_CONTEXT_KEY, "contextKey");
179
193
  const triggerFunctionName = assertNonEmpty(options.triggerFunctionName ?? DEFAULT_TRIGGER_FUNCTION, "triggerFunctionName");
194
+ const contextColumns = normalizeContextColumns(options);
195
+ if (contextColumns.length === 0) {
196
+ throw new Error("createAuditAddContextColumnsSql requires at least one context column");
197
+ }
180
198
  const qualifiedAuditTable = qualifyName(auditTable, auditSchema);
181
199
  const qualifiedTriggerFunction = qualifyName(triggerFunctionName, auditSchema);
182
200
  const contextLiteral = quoteLiteral(contextKey);
183
201
  return [
184
- `ALTER TABLE ${qualifiedAuditTable} ADD COLUMN IF NOT EXISTS ${quoteIdent(workspaceIdColumn)} TEXT;`,
185
- `CREATE INDEX IF NOT EXISTS ${quoteIdent(`${auditTable}_${workspaceIdColumn}_idx`)} ON ${qualifiedAuditTable} (${quoteIdent(workspaceIdColumn)});`,
186
- buildTriggerFunctionSql(qualifiedAuditTable, qualifiedTriggerFunction, contextLiteral, {
187
- workspaceIdColumn,
188
- }),
202
+ ...contextColumns.flatMap((col) => [
203
+ `ALTER TABLE ${qualifiedAuditTable} ADD COLUMN IF NOT EXISTS ${quoteIdent(col.column)} TEXT;`,
204
+ ...(col.index
205
+ ? [
206
+ `CREATE INDEX IF NOT EXISTS ${quoteIdent(`${auditTable}_${col.column}_idx`)} ON ${qualifiedAuditTable} (${quoteIdent(col.column)});`,
207
+ ]
208
+ : []),
209
+ ]),
210
+ buildTriggerFunctionSql(qualifiedAuditTable, qualifiedTriggerFunction, contextLiteral, contextColumns),
189
211
  ].join("\n\n");
190
212
  }
@@ -5,13 +5,21 @@ export type AuditSqlExecutor = {
5
5
  export type AuditTransactionCapable<TTransaction extends AuditSqlExecutor> = {
6
6
  transaction: <TResult>(callback: (tx: TTransaction) => Promise<TResult>) => Promise<TResult>;
7
7
  };
8
+ export type AuditContextColumn = {
9
+ /** Column added to the audit table (TEXT, nullable). */
10
+ column: string;
11
+ /** Session GUC the trigger reads. Default `app.${column}`. */
12
+ sessionKey?: string;
13
+ /** Create an index on the column. Default true. */
14
+ index?: boolean;
15
+ };
8
16
  export type AuditInstallOptions = {
9
17
  auditSchema?: string;
10
18
  auditTable?: string;
11
19
  contextKey?: string;
12
20
  triggerFunctionName?: string;
13
- /** When set (e.g. "workspace_id"), the audit table and trigger include this column; trigger reads from session variable app.${workspaceIdColumn}. */
14
- workspaceIdColumn?: string;
21
+ /** Extra context columns added to the audit table and populated by the trigger from session GUCs. */
22
+ contextColumns?: AuditContextColumn[];
15
23
  };
16
24
  export type AuditTriggerTarget = {
17
25
  table: string;
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../src/postgres/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,GAAG,EAAE,MAAM,aAAa,CAAA;AAEtC,MAAM,MAAM,gBAAgB,GAAG;IAC7B,OAAO,EAAE,CAAC,KAAK,EAAE,GAAG,KAAK,OAAO,CAAC,OAAO,CAAC,CAAA;CAC1C,CAAA;AAED,MAAM,MAAM,uBAAuB,CAAC,YAAY,SAAS,gBAAgB,IAAI;IAC3E,WAAW,EAAE,CAAC,OAAO,EACnB,QAAQ,EAAE,CAAC,EAAE,EAAE,YAAY,KAAK,OAAO,CAAC,OAAO,CAAC,KAC7C,OAAO,CAAC,OAAO,CAAC,CAAA;CACtB,CAAA;AAED,MAAM,MAAM,mBAAmB,GAAG;IAChC,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,mBAAmB,CAAC,EAAE,MAAM,CAAA;IAC5B,qJAAqJ;IACrJ,iBAAiB,CAAC,EAAE,MAAM,CAAA;CAC3B,CAAA;AAED,MAAM,MAAM,kBAAkB,GAAG;IAC/B,KAAK,EAAE,MAAM,CAAA;IACb,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,WAAW,CAAC,EAAE,MAAM,CAAA;CACrB,CAAA"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../src/postgres/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,GAAG,EAAE,MAAM,aAAa,CAAA;AAEtC,MAAM,MAAM,gBAAgB,GAAG;IAC7B,OAAO,EAAE,CAAC,KAAK,EAAE,GAAG,KAAK,OAAO,CAAC,OAAO,CAAC,CAAA;CAC1C,CAAA;AAED,MAAM,MAAM,uBAAuB,CAAC,YAAY,SAAS,gBAAgB,IAAI;IAC3E,WAAW,EAAE,CAAC,OAAO,EACnB,QAAQ,EAAE,CAAC,EAAE,EAAE,YAAY,KAAK,OAAO,CAAC,OAAO,CAAC,KAC7C,OAAO,CAAC,OAAO,CAAC,CAAA;CACtB,CAAA;AAED,MAAM,MAAM,kBAAkB,GAAG;IAC/B,wDAAwD;IACxD,MAAM,EAAE,MAAM,CAAA;IACd,8DAA8D;IAC9D,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,mDAAmD;IACnD,KAAK,CAAC,EAAE,OAAO,CAAA;CAChB,CAAA;AAED,MAAM,MAAM,mBAAmB,GAAG;IAChC,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,mBAAmB,CAAC,EAAE,MAAM,CAAA;IAC5B,qGAAqG;IACrG,cAAc,CAAC,EAAE,kBAAkB,EAAE,CAAA;CACtC,CAAA;AAED,MAAM,MAAM,kBAAkB,GAAG;IAC/B,KAAK,EAAE,MAAM,CAAA;IACb,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,WAAW,CAAC,EAAE,MAAM,CAAA;CACrB,CAAA"}
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Integration tests for withAudit against a real async D1 driver (miniflare v4).
3
+ *
4
+ * These tests exercise the actual failure mode described in issue #29:
5
+ * - drizzle-orm/d1 .all()/.run() return Promises, not plain values.
6
+ * - drizzle-orm/d1 db.transaction() issues a raw BEGIN which D1 rejects.
7
+ *
8
+ * All tests here FAIL on the current sync implementation and must PASS after the fix.
9
+ *
10
+ * The existing sqlite.integration.test.ts covers the better-sqlite3 (sync) path.
11
+ */
12
+ export {};
13
+ //# sourceMappingURL=d1-async.integration.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"d1-async.integration.test.d.ts","sourceRoot":"","sources":["../../test/d1-async.integration.test.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG"}
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Integration tests for withAudit against a real async D1 driver (miniflare v4).
3
+ *
4
+ * These tests exercise the actual failure mode described in issue #29:
5
+ * - drizzle-orm/d1 .all()/.run() return Promises, not plain values.
6
+ * - drizzle-orm/d1 db.transaction() issues a raw BEGIN which D1 rejects.
7
+ *
8
+ * All tests here FAIL on the current sync implementation and must PASS after the fix.
9
+ *
10
+ * The existing sqlite.integration.test.ts covers the better-sqlite3 (sync) path.
11
+ */
12
+ import assert from "node:assert/strict";
13
+ import { after, before, test } from "node:test";
14
+ import { asc, eq, isNull } from "drizzle-orm";
15
+ import { drizzle } from "drizzle-orm/d1";
16
+ import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
17
+ import { Miniflare } from "miniflare";
18
+ import { d1AuditLogTable } from "../src/d1/index.js";
19
+ import { withAudit } from "../src/d1-runtime/index.js";
20
+ const users = sqliteTable("users", {
21
+ id: text("id").primaryKey(),
22
+ name: text("name").notNull(),
23
+ email: text("email"),
24
+ });
25
+ const invoices = sqliteTable("invoices", {
26
+ invoice_id: text("invoice_id").primaryKey(),
27
+ amount: integer("amount").notNull(),
28
+ status: text("status").notNull().default("pending"),
29
+ });
30
+ const auditLogs = d1AuditLogTable();
31
+ let mf;
32
+ before(async () => {
33
+ mf = new Miniflare({
34
+ modules: true,
35
+ script: `export default { fetch() { return new Response("ok") } }`,
36
+ d1Databases: { DB: "drizzle-audit-test" },
37
+ });
38
+ });
39
+ after(async () => {
40
+ await mf.dispose();
41
+ });
42
+ async function setupDb() {
43
+ const d1 = await mf.getD1Database("DB");
44
+ // D1 exec() treats each newline as a statement separator — use prepare().run() for DDL
45
+ for (const sql of [
46
+ "DROP TABLE IF EXISTS audit_logs",
47
+ "DROP TABLE IF EXISTS users",
48
+ "DROP TABLE IF EXISTS invoices",
49
+ "CREATE TABLE audit_logs (id INTEGER PRIMARY KEY AUTOINCREMENT, table_name TEXT NOT NULL, operation TEXT NOT NULL, row_id TEXT, user_id TEXT, old_data TEXT, new_data TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')))",
50
+ "CREATE TABLE users (id TEXT PRIMARY KEY, name TEXT NOT NULL, email TEXT)",
51
+ "CREATE TABLE invoices (invoice_id TEXT PRIMARY KEY, amount INTEGER NOT NULL, status TEXT NOT NULL DEFAULT 'pending')",
52
+ ]) {
53
+ await d1.prepare(sql).run();
54
+ }
55
+ return drizzle(d1, { schema: { auditLogs, users, invoices } });
56
+ }
57
+ test("withAudit insert logs audit row with new_data (real D1)", async () => {
58
+ const db = await setupDb();
59
+ const audited = withAudit(db, auditLogs, { userId: "user_1" });
60
+ const row = await audited.insert(users, { id: "u1", name: "Ada", email: "ada@example.com" });
61
+ assert.equal(row.id, "u1");
62
+ assert.equal(row.name, "Ada");
63
+ const logs = await db.select().from(auditLogs).all();
64
+ assert.equal(logs.length, 1);
65
+ assert.equal(logs[0]?.table_name, "users");
66
+ assert.equal(logs[0]?.operation, "INSERT");
67
+ assert.equal(logs[0]?.row_id, "u1");
68
+ assert.equal(logs[0]?.user_id, "user_1");
69
+ assert.equal(logs[0]?.old_data, null);
70
+ assert.deepEqual(JSON.parse(logs[0]?.new_data), {
71
+ id: "u1",
72
+ name: "Ada",
73
+ email: "ada@example.com",
74
+ });
75
+ });
76
+ test("withAudit update captures old and new data (real D1)", async () => {
77
+ const db = await setupDb();
78
+ await db.insert(users).values({ id: "u1", name: "Ada", email: "ada@example.com" });
79
+ const audited = withAudit(db, auditLogs, { userId: "user_2" });
80
+ const rows = await audited.update(users, eq(users.id, "u1"), { name: "Ada Lovelace" });
81
+ assert.equal(rows.length, 1);
82
+ assert.equal(rows[0]?.name, "Ada Lovelace");
83
+ const logs = await db.select().from(auditLogs).all();
84
+ assert.equal(logs.length, 1);
85
+ assert.equal(logs[0]?.operation, "UPDATE");
86
+ assert.equal(logs[0]?.row_id, "u1");
87
+ assert.equal(logs[0]?.user_id, "user_2");
88
+ const oldData = JSON.parse(logs[0]?.old_data);
89
+ assert.equal(oldData.name, "Ada");
90
+ assert.equal(oldData.email, "ada@example.com");
91
+ const newData = JSON.parse(logs[0]?.new_data);
92
+ assert.equal(newData.name, "Ada Lovelace");
93
+ assert.equal(newData.email, "ada@example.com");
94
+ });
95
+ test("withAudit delete captures old data (real D1)", async () => {
96
+ const db = await setupDb();
97
+ await db.insert(users).values({ id: "u1", name: "Ada" });
98
+ const audited = withAudit(db, auditLogs, { userId: "user_3" });
99
+ const deleted = await audited.delete(users, eq(users.id, "u1"));
100
+ assert.equal(deleted.length, 1);
101
+ assert.equal(deleted[0]?.id, "u1");
102
+ const remaining = await db.select().from(users).all();
103
+ assert.equal(remaining.length, 0);
104
+ const logs = await db.select().from(auditLogs).all();
105
+ assert.equal(logs.length, 1);
106
+ assert.equal(logs[0]?.operation, "DELETE");
107
+ assert.equal(logs[0]?.row_id, "u1");
108
+ assert.equal(logs[0]?.user_id, "user_3");
109
+ assert.deepEqual(JSON.parse(logs[0]?.old_data), {
110
+ id: "u1",
111
+ name: "Ada",
112
+ email: null,
113
+ });
114
+ assert.equal(logs[0]?.new_data, null);
115
+ });
116
+ test("withAudit works with custom primary key column (real D1)", async () => {
117
+ const db = await setupDb();
118
+ const audited = withAudit(db, auditLogs, { userId: "user_1" });
119
+ await audited.insert(invoices, { invoice_id: "inv_1", amount: 100 });
120
+ await audited.update(invoices, eq(invoices.invoice_id, "inv_1"), { amount: 200 });
121
+ await audited.delete(invoices, eq(invoices.invoice_id, "inv_1"));
122
+ const logs = await db.select().from(auditLogs).orderBy(asc(auditLogs.id)).all();
123
+ assert.equal(logs.length, 3);
124
+ assert.equal(logs[0]?.table_name, "invoices");
125
+ assert.equal(logs[0]?.row_id, "inv_1");
126
+ assert.equal(logs[1]?.operation, "UPDATE");
127
+ assert.equal(logs[1]?.row_id, "inv_1");
128
+ assert.equal(JSON.parse(logs[1]?.old_data).amount, 100);
129
+ assert.equal(JSON.parse(logs[1]?.new_data).amount, 200);
130
+ assert.equal(logs[2]?.operation, "DELETE");
131
+ assert.equal(logs[2]?.row_id, "inv_1");
132
+ });
133
+ test("withAudit handles multi-row update (real D1)", async () => {
134
+ const db = await setupDb();
135
+ await db.insert(users).values([
136
+ { id: "u1", name: "Ada" },
137
+ { id: "u2", name: "Bob" },
138
+ { id: "u3", name: "Carol" },
139
+ ]);
140
+ const audited = withAudit(db, auditLogs, { userId: "admin" });
141
+ const rows = await audited.update(users, isNull(users.email), { email: "bulk@example.com" });
142
+ assert.equal(rows.length, 3);
143
+ const logs = await db.select().from(auditLogs).orderBy(asc(auditLogs.id)).all();
144
+ assert.equal(logs.length, 3);
145
+ for (const log of logs) {
146
+ assert.equal(log.operation, "UPDATE");
147
+ assert.equal(log.user_id, "admin");
148
+ assert.equal(JSON.parse(log.new_data).email, "bulk@example.com");
149
+ }
150
+ });
151
+ test("withAudit.db gives access to raw db for non-audited ops (real D1)", async () => {
152
+ const db = await setupDb();
153
+ const audited = withAudit(db, auditLogs, { userId: "user_1" });
154
+ await audited.db.insert(users).values({ id: "u1", name: "Ada" });
155
+ const logs = await db.select().from(auditLogs).all();
156
+ assert.equal(logs.length, 0);
157
+ const rows = await db.select().from(users).all();
158
+ assert.equal(rows.length, 1);
159
+ });
@@ -122,20 +122,22 @@ test("d1 column-aware triggers capture full row data", () => {
122
122
  });
123
123
  test("d1 workspace_id column and context are stored when enabled", () => {
124
124
  const sqlite = new Database(":memory:");
125
- const auditLogsWithWorkspace = d1AuditLogTable({ workspaceIdColumn: "workspace_id" });
125
+ const auditLogsWithWorkspace = d1AuditLogTable({
126
+ contextColumns: [{ column: "workspace_id" }],
127
+ });
126
128
  const db = drizzle({ client: sqlite, schema: { auditLogs: auditLogsWithWorkspace, auditContext, users } });
127
129
  try {
128
- sqlite.exec(createD1AuditInstallSql({ workspaceIdColumn: "workspace_id" }));
130
+ sqlite.exec(createD1AuditInstallSql({ contextColumns: [{ column: "workspace_id" }] }));
129
131
  sqlite.exec(`
130
132
  CREATE TABLE users (
131
133
  id TEXT PRIMARY KEY,
132
134
  name TEXT NOT NULL
133
135
  );
134
136
  `);
135
- sqlite.exec(createAttachD1AuditTriggersSql([{ table: "users" }], { workspaceIdColumn: "workspace_id" }));
137
+ sqlite.exec(createAttachD1AuditTriggersSql([{ table: "users" }], { contextColumns: [{ column: "workspace_id" }] }));
136
138
  withD1AuditedTransaction(db, "user_1", (tx) => {
137
139
  tx.insert(users).values({ id: "u1", name: "Alice" }).run();
138
- }, { workspaceId: "ws_1" });
140
+ }, { context: { workspace_id: "ws_1" } });
139
141
  const logs = db.select().from(auditLogsWithWorkspace).all();
140
142
  assert.equal(logs.length, 1);
141
143
  assert.equal(logs[0]?.user_id, "user_1");
@@ -157,6 +159,71 @@ test("d1 workspace_id column and context are stored when enabled", () => {
157
159
  sqlite.close();
158
160
  }
159
161
  });
162
+ test("d1 generic contextColumns populate and stay NULL without context", () => {
163
+ const sqlite = new Database(":memory:");
164
+ const contextColumns = [
165
+ { column: "workspace_id" },
166
+ { column: "tenant_id" },
167
+ { column: "request_id" },
168
+ ];
169
+ const auditLogsWithCtx = d1AuditLogTable({ contextColumns });
170
+ const db = drizzle({
171
+ client: sqlite,
172
+ schema: { auditLogs: auditLogsWithCtx, auditContext, users },
173
+ });
174
+ try {
175
+ sqlite.exec(createD1AuditInstallSql({ contextColumns }));
176
+ sqlite.exec(`
177
+ CREATE TABLE users (
178
+ id TEXT PRIMARY KEY,
179
+ name TEXT NOT NULL
180
+ );
181
+ `);
182
+ sqlite.exec(createAttachD1AuditTriggersSql([{ table: "users" }], { contextColumns }));
183
+ withD1AuditedTransaction(db, "user_1", (tx) => {
184
+ tx.insert(users).values({ id: "u1", name: "Alice" }).run();
185
+ tx.update(users).set({ name: "Alice Updated" }).where(eq(users.id, "u1")).run();
186
+ tx.delete(users).where(eq(users.id, "u1")).run();
187
+ }, {
188
+ context: {
189
+ workspace_id: "ws_1",
190
+ tenant_id: "tenant_1",
191
+ request_id: "req_1",
192
+ },
193
+ });
194
+ const logs = db
195
+ .select()
196
+ .from(auditLogsWithCtx)
197
+ .orderBy(asc(auditLogsWithCtx.id))
198
+ .all();
199
+ assert.equal(logs.length, 3);
200
+ for (const log of logs) {
201
+ const row = log;
202
+ assert.equal(row.user_id, "user_1");
203
+ assert.equal(row.workspace_id, "ws_1");
204
+ assert.equal(row.tenant_id, "tenant_1");
205
+ assert.equal(row.request_id, "req_1");
206
+ }
207
+ assert.equal(logs[0]?.operation, "INSERT");
208
+ assert.equal(logs[1]?.operation, "UPDATE");
209
+ assert.equal(logs[2]?.operation, "DELETE");
210
+ // No context: all context columns NULL.
211
+ db.insert(users).values({ id: "u2", name: "No Context" }).run();
212
+ const all = db
213
+ .select()
214
+ .from(auditLogsWithCtx)
215
+ .orderBy(asc(auditLogsWithCtx.id))
216
+ .all();
217
+ const last = all[all.length - 1];
218
+ assert.equal(last.user_id, null);
219
+ assert.equal(last.workspace_id, null);
220
+ assert.equal(last.tenant_id, null);
221
+ assert.equal(last.request_id, null);
222
+ }
223
+ finally {
224
+ sqlite.close();
225
+ }
226
+ });
160
227
  test("d1 writes without audit context produce rows with user_id = NULL", () => {
161
228
  const { db, sqlite } = setupDb();
162
229
  try {
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=sqlite.integration.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sqlite.integration.test.d.ts","sourceRoot":"","sources":["../../test/sqlite.integration.test.ts"],"names":[],"mappings":""}