@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
package/README.md CHANGED
@@ -43,7 +43,7 @@ await withAuditedTransaction(db, currentUser.id, async (tx) => {
43
43
 
44
44
  Postgres triggers capture full row snapshots (`old_data`/`new_data` as JSONB) automatically.
45
45
 
46
- ### D1/SQLite — App-Level Wrapper (recommended)
46
+ ### D1/SQLite — App-Level Wrapper
47
47
 
48
48
  For D1 and SQLite, the `withAudit` wrapper is the simplest approach. No triggers, no context tables — it intercepts operations in your JS code where you already have the user session.
49
49
 
@@ -69,15 +69,17 @@ export const auditLogs = d1AuditLogTable()
69
69
  // 3. Use in your app (e.g. React Router action)
70
70
  const audit = withAudit(db, auditLogs, { userId: session.userId })
71
71
 
72
- audit.insert(users, { id: "u1", name: "Ada" })
73
- audit.update(users, eq(users.id, "u1"), { name: "Ada Lovelace" })
74
- audit.delete(users, eq(users.id, "u1"))
72
+ await audit.insert(users, { id: "u1", name: "Ada" })
73
+ await audit.update(users, eq(users.id, "u1"), { name: "Ada Lovelace" })
74
+ await audit.delete(users, eq(users.id, "u1"))
75
75
 
76
76
  // Non-audited access is still available
77
77
  audit.db.select().from(users).all()
78
78
  ```
79
79
 
80
- The wrapper auto-detects primary keys from your Drizzle table schema, captures old/new row data, and runs each operation + audit log insert in a transaction.
80
+ The wrapper auto-detects primary keys from your Drizzle table schema and captures old/new row data. All methods return Promises and must be awaited.
81
+
82
+ > **Note:** Each operation runs the data write and audit log insert as sequential statements with no transaction wrapper. This is required for D1 compatibility (D1 does not support `BEGIN`/`COMMIT` over the prepared-statement API). In the unlikely event of a failure between the two writes, one may succeed without the other. If you need atomic auditing, use the trigger-based approach instead (see below).
81
83
 
82
84
  ### D1/SQLite — Triggers
83
85
 
@@ -118,47 +120,62 @@ sqlite.exec(createAttachD1AuditTriggersSqlWithColumns([
118
120
  ]))
119
121
  ```
120
122
 
121
- ## Workspace / Tenant Scoping
123
+ ## Context Columns
124
+
125
+ Beyond `user_id`, you can declare arbitrary extra context columns (e.g.
126
+ `workspace_id`, `tenant_id`, `request_id`). Each is an extra `TEXT` column on
127
+ `audit_logs`, populated at write-time from a named runtime context value (a
128
+ Postgres session GUC, or a `_audit_context` KV row in D1).
122
129
 
123
- All three approaches support an optional workspace column for multi-tenant apps.
130
+ ```ts
131
+ type AuditContextColumn = {
132
+ column: string // audit table column (TEXT, nullable)
133
+ sessionKey?: string // Postgres GUC the trigger reads — default `app.${column}`
134
+ // D1: the _audit_context key — default `${column}`
135
+ index?: boolean // create an index on the column — default true
136
+ }
137
+ ```
124
138
 
125
139
  ### Postgres
126
140
 
127
141
  ```ts
128
- // Install with workspace column
129
- createAuditInstallSql({ workspaceIdColumn: "workspace_id" })
130
- export const auditLogs = pgAuditLogTable({ workspaceIdColumn: "workspace_id" })
142
+ const contextColumns = [{ column: "workspace_id" }, { column: "tenant_id" }]
131
143
 
132
- // Pass workspace at runtime
144
+ createAuditInstallSql({ contextColumns })
145
+ export const auditLogs = pgAuditLogTable({ contextColumns })
146
+
147
+ // Pass context at runtime (GUC name → value)
133
148
  await withAuditedTransaction(
134
149
  db, userId, async (tx) => { /* ... */ },
135
150
  "app.user_id",
136
- { workspaceId: "ws_1" },
151
+ { context: { "app.workspace_id": "ws_1", "app.tenant_id": "t_1" } },
137
152
  )
138
153
  ```
139
154
 
140
- To add workspace to an existing install, use `createAuditAddWorkspaceColumnSql()`.
155
+ To add context columns to an existing install, use
156
+ `createAuditAddContextColumnsSql({ contextColumns })` — it adds each column and
157
+ regenerates the trigger over the full set. Pass the complete list of columns the
158
+ table should have.
141
159
 
142
160
  ### D1 Runtime
143
161
 
144
162
  ```ts
145
163
  const audit = withAudit(db, auditLogs, {
146
164
  userId: "user_1",
147
- workspaceId: "ws_1",
165
+ context: { workspace_id: "ws_1", tenant_id: "t_1" },
148
166
  })
149
167
  ```
150
168
 
151
169
  ### D1 Triggers
152
170
 
153
171
  ```ts
154
- createD1AuditInstallSql({ workspaceIdColumn: "workspace_id" })
155
- createAttachD1AuditTriggersSql(
156
- [{ table: "users" }],
157
- { workspaceIdColumn: "workspace_id" },
158
- )
172
+ const contextColumns = [{ column: "workspace_id" }, { column: "tenant_id" }]
173
+
174
+ createD1AuditInstallSql({ contextColumns })
175
+ createAttachD1AuditTriggersSql([{ table: "users" }], { contextColumns })
159
176
 
160
177
  withD1AuditedTransaction(db, "user_1", (tx) => { /* ... */ }, {
161
- workspaceId: "ws_1",
178
+ context: { workspace_id: "ws_1", tenant_id: "t_1" },
162
179
  })
163
180
  ```
164
181
 
@@ -200,10 +217,16 @@ export function createAuditSql() {
200
217
  | `createAuditInstallSql(options?)` | SQL to create the audit table, indexes, and trigger function |
201
218
  | `createAttachAuditTriggerSql(target, options?)` | SQL to attach audit trigger to one table |
202
219
  | `createAttachAuditTriggersSql(targets, options?)` | Same, for multiple tables |
203
- | `createAuditAddWorkspaceColumnSql(options)` | SQL to add workspace column to existing install |
220
+ | `createAuditAddContextColumnsSql(options)` | SQL to add context columns + regenerate trigger on existing install |
204
221
  | `setAuditContext(db, actorId, contextKey?, options?)` | Set actor context in current transaction |
205
222
  | `withAuditedTransaction(db, actorId, callback, contextKey?, options?)` | Transaction wrapper with actor context |
206
223
 
224
+ ### `@willyim/drizzle-audit`
225
+
226
+ | Export | Description |
227
+ |---|---|
228
+ | `computeDiff(operation, oldData, newData, options?)` | Compute field-by-field diff from old/new row data |
229
+
207
230
  ### `@willyim/drizzle-audit/d1`
208
231
 
209
232
  | Export | Description |
@@ -231,6 +254,43 @@ export function createAuditSql() {
231
254
  - `.delete(table, where)` — Fetch old rows, delete, audit log (per row)
232
255
  - `.db` — Raw Drizzle instance for non-audited operations
233
256
 
257
+ ## Computing Diffs
258
+
259
+ The `computeDiff` utility produces field-by-field diffs from the `old_data`/`new_data` captured by audit triggers. It works with any operation type and requires no Drizzle dependency.
260
+
261
+ ```ts
262
+ import { computeDiff } from "@willyim/drizzle-audit"
263
+
264
+ const result = computeDiff(
265
+ "UPDATE",
266
+ { id: "u1", name: "Ada", email: "ada@example.com" },
267
+ { id: "u1", name: "Ada Lovelace", email: "ada@example.com" },
268
+ )
269
+ // {
270
+ // operation: "UPDATE",
271
+ // changes: [{ field: "name", old: "Ada", new: "Ada Lovelace" }]
272
+ // }
273
+ ```
274
+
275
+ For INSERT operations, pass `null` as `oldData` — all fields appear as additions. For DELETE, pass `null` as `newData` — all fields appear as removals.
276
+
277
+ ```ts
278
+ // INSERT — every field is new
279
+ computeDiff("INSERT", null, { id: "u1", name: "Ada" })
280
+
281
+ // DELETE — every field is removed
282
+ computeDiff("DELETE", { id: "u1", name: "Ada" }, null)
283
+ ```
284
+
285
+ By default, `updated_at` and `created_at` fields are excluded. Override with `ignoreFields`:
286
+
287
+ ```ts
288
+ computeDiff("UPDATE", oldData, newData, { ignoreFields: [] }) // include all fields
289
+ computeDiff("UPDATE", oldData, newData, { ignoreFields: ["internal_note"] })
290
+ ```
291
+
292
+ Nested objects are compared using deep equality. Fields are returned sorted alphabetically.
293
+
234
294
  ## Audit Log Schema
235
295
 
236
296
  ### Postgres
@@ -273,6 +333,27 @@ CREATE TABLE audit_logs (
273
333
  | **Bypass risk** | Low (DB-level) | Medium (must use wrapper) | Low (DB-level) |
274
334
  | **Best for** | Postgres apps | D1/Cloudflare Workers | SQLite apps needing DB-level guarantees |
275
335
 
336
+ ## Migrating from 0.2.x → 0.3.0
337
+
338
+ The `workspace_id`-specific options were removed in favor of the generic
339
+ [Context Columns](#context-columns) API. Mechanical replacements:
340
+
341
+ | Removed | Replacement |
342
+ |---|---|
343
+ | `pgAuditLogTable({ workspaceIdColumn: "workspace_id" })` | `pgAuditLogTable({ contextColumns: [{ column: "workspace_id" }] })` |
344
+ | `d1AuditLogTable({ workspaceIdColumn: "workspace_id" })` | `d1AuditLogTable({ contextColumns: [{ column: "workspace_id" }] })` |
345
+ | `createAuditInstallSql({ workspaceIdColumn: "workspace_id" })` | `createAuditInstallSql({ contextColumns: [{ column: "workspace_id" }] })` |
346
+ | `createD1AuditInstallSql({ workspaceIdColumn })` | `createD1AuditInstallSql({ contextColumns: [{ column }] })` |
347
+ | `createAttachD1AuditTriggersSql(targets, { workspaceIdColumn })` | `createAttachD1AuditTriggersSql(targets, { contextColumns: [{ column }] })` |
348
+ | `createAuditAddWorkspaceColumnSql(options)` | `createAuditAddContextColumnsSql({ contextColumns: [...] })` |
349
+ | Postgres runtime `{ workspaceId: v }` | `{ context: { "app.workspace_id": v } }` |
350
+ | Postgres runtime `{ workspaceId: v, workspaceContextKey: "app.tenant_id" }` | `{ context: { "app.tenant_id": v } }` |
351
+ | D1 runtime `{ workspaceId: v }` | `{ context: { workspace_id: v } }` |
352
+ | `withAudit(db, table, { userId, workspaceId: v })` | `withAudit(db, table, { userId, context: { workspace_id: v } })` |
353
+
354
+ The on-disk column name (`workspace_id`) is unchanged, so no data migration is
355
+ needed — only call sites change.
356
+
276
357
  ## License
277
358
 
278
359
  MIT
@@ -1,6 +1,7 @@
1
+ import type { AuditContextColumn } from "./types.js";
1
2
  export type D1AuditLogTableOptions = {
2
- /** When set (e.g. "workspace_id"), the table definition includes this optional column. */
3
- workspaceIdColumn?: string;
3
+ /** Extra context columns to include in the table definition, matching the install. */
4
+ contextColumns?: AuditContextColumn[];
4
5
  };
5
6
  export declare function d1AuditLogTable(options?: D1AuditLogTableOptions): import("drizzle-orm/sqlite-core").SQLiteTableWithColumns<{
6
7
  name: "audit_logs";
@@ -1 +1 @@
1
- {"version":3,"file":"audit-log-schema.d.ts","sourceRoot":"","sources":["../../../src/d1/audit-log-schema.ts"],"names":[],"mappings":"AAOA,MAAM,MAAM,sBAAsB,GAAG;IACnC,0FAA0F;IAC1F,iBAAiB,CAAC,EAAE,MAAM,CAAA;CAC3B,CAAA;AAED,wBAAgB,eAAe,CAAC,OAAO,CAAC,EAAE,sBAAsB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyB/D;AAED,wBAAgB,mBAAmB,CAAC,OAAO,CAAC,EAAE;IAAE,YAAY,CAAC,EAAE,MAAM,CAAA;CAAE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAMtE"}
1
+ {"version":3,"file":"audit-log-schema.d.ts","sourceRoot":"","sources":["../../../src/d1/audit-log-schema.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAA;AAEpD,MAAM,MAAM,sBAAsB,GAAG;IACnC,sFAAsF;IACtF,cAAc,CAAC,EAAE,kBAAkB,EAAE,CAAA;CACtC,CAAA;AAiBD,wBAAgB,eAAe,CAAC,OAAO,CAAC,EAAE,sBAAsB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuB/D;AAED,wBAAgB,mBAAmB,CAAC,OAAO,CAAC,EAAE;IAAE,YAAY,CAAC,EAAE,MAAM,CAAA;CAAE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAMtE"}
@@ -1,15 +1,25 @@
1
1
  import { index, integer, sqliteTable, text, } from "drizzle-orm/sqlite-core";
2
+ function resolveColumns(options) {
3
+ const columns = [];
4
+ const seen = new Set();
5
+ for (const entry of options?.contextColumns ?? []) {
6
+ const column = entry.column?.trim();
7
+ if (column && !seen.has(column)) {
8
+ seen.add(column);
9
+ columns.push(column);
10
+ }
11
+ }
12
+ return columns;
13
+ }
2
14
  export function d1AuditLogTable(options) {
3
- const workspaceIdColumn = options?.workspaceIdColumn?.trim();
15
+ const contextColumns = resolveColumns(options);
4
16
  const columns = {
5
17
  id: integer("id").primaryKey({ autoIncrement: true }),
6
18
  table_name: text("table_name").notNull(),
7
19
  operation: text("operation").notNull(),
8
20
  row_id: text("row_id"),
9
21
  user_id: text("user_id"),
10
- ...(workspaceIdColumn
11
- ? { [workspaceIdColumn]: text(workspaceIdColumn) }
12
- : {}),
22
+ ...Object.fromEntries(contextColumns.map((c) => [c, text(c)])),
13
23
  old_data: text("old_data"),
14
24
  new_data: text("new_data"),
15
25
  created_at: text("created_at").notNull().default("(datetime('now'))"),
@@ -3,5 +3,6 @@ export type { D1AuditLogTableOptions } from "./audit-log-schema.js";
3
3
  export { createAttachD1AuditTriggerSql, createAttachD1AuditTriggersSql, createAttachD1AuditTriggerSqlWithColumns, createAttachD1AuditTriggersSqlWithColumns, createD1AuditInstallSql, } from "./sql.js";
4
4
  export type { D1AuditTriggerTargetWithColumns } from "./sql.js";
5
5
  export { clearD1AuditContext, setD1AuditContext, withD1AuditedTransaction, } from "./runtime.js";
6
- export type { D1AuditInstallOptions, D1AuditSqlExecutor, D1AuditTriggerTarget, } from "./types.js";
6
+ export type { D1AuditContextOptions } from "./runtime.js";
7
+ export type { AuditContextColumn, D1AuditInstallOptions, D1AuditSqlExecutor, D1AuditTriggerTarget, } from "./types.js";
7
8
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/d1/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,mBAAmB,EAAE,MAAM,uBAAuB,CAAA;AAC5E,YAAY,EAAE,sBAAsB,EAAE,MAAM,uBAAuB,CAAA;AACnE,OAAO,EACL,6BAA6B,EAC7B,8BAA8B,EAC9B,wCAAwC,EACxC,yCAAyC,EACzC,uBAAuB,GACxB,MAAM,UAAU,CAAA;AACjB,YAAY,EAAE,+BAA+B,EAAE,MAAM,UAAU,CAAA;AAC/D,OAAO,EACL,mBAAmB,EACnB,iBAAiB,EACjB,wBAAwB,GACzB,MAAM,cAAc,CAAA;AAErB,YAAY,EACV,qBAAqB,EACrB,kBAAkB,EAClB,oBAAoB,GACrB,MAAM,YAAY,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/d1/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,mBAAmB,EAAE,MAAM,uBAAuB,CAAA;AAC5E,YAAY,EAAE,sBAAsB,EAAE,MAAM,uBAAuB,CAAA;AACnE,OAAO,EACL,6BAA6B,EAC7B,8BAA8B,EAC9B,wCAAwC,EACxC,yCAAyC,EACzC,uBAAuB,GACxB,MAAM,UAAU,CAAA;AACjB,YAAY,EAAE,+BAA+B,EAAE,MAAM,UAAU,CAAA;AAC/D,OAAO,EACL,mBAAmB,EACnB,iBAAiB,EACjB,wBAAwB,GACzB,MAAM,cAAc,CAAA;AACrB,YAAY,EAAE,qBAAqB,EAAE,MAAM,cAAc,CAAA;AAEzD,YAAY,EACV,kBAAkB,EAClB,qBAAqB,EACrB,kBAAkB,EAClB,oBAAoB,GACrB,MAAM,YAAY,CAAA"}
@@ -1,4 +1,9 @@
1
1
  import type { D1AuditSqlExecutor } from "./types.js";
2
+ export type D1AuditContextOptions = {
3
+ /** Map of context key → value written as KV rows the triggers read. */
4
+ context?: Record<string, string>;
5
+ contextTable?: string;
6
+ };
2
7
  /**
3
8
  * Sets the audit context for the current transaction by writing to the
4
9
  * _audit_context table. Must be called inside a transaction before any
@@ -6,17 +11,15 @@ import type { D1AuditSqlExecutor } from "./types.js";
6
11
  *
7
12
  * D1/SQLite has no session variables, so triggers read context from this table.
8
13
  */
9
- export declare function setD1AuditContext(db: D1AuditSqlExecutor, actorId: string, options?: {
10
- workspaceId?: string;
11
- contextTable?: string;
12
- }): Promise<unknown> | undefined;
14
+ export declare function setD1AuditContext(db: D1AuditSqlExecutor, actorId: string, options?: D1AuditContextOptions): Promise<unknown> | undefined;
13
15
  /**
14
16
  * Clears the audit context after a transaction completes.
15
17
  * Called automatically by withD1AuditedTransaction.
18
+ *
19
+ * Clears `user_id` plus any keys set via `context` so the next transaction
20
+ * starts clean.
16
21
  */
17
- export declare function clearD1AuditContext(db: D1AuditSqlExecutor, options?: {
18
- contextTable?: string;
19
- }): unknown;
22
+ export declare function clearD1AuditContext(db: D1AuditSqlExecutor, options?: D1AuditContextOptions): unknown;
20
23
  /**
21
24
  * Wraps a Drizzle SQLite transaction with audit context. Sets the actor
22
25
  * before the callback and clears the context after (success or failure).
@@ -37,8 +40,5 @@ export declare function clearD1AuditContext(db: D1AuditSqlExecutor, options?: {
37
40
  */
38
41
  export declare function withD1AuditedTransaction<TDb extends D1AuditSqlExecutor, TResult>(db: TDb & {
39
42
  transaction: (cb: (tx: any) => TResult) => TResult;
40
- }, actorId: string, callback: (tx: TDb) => TResult, options?: {
41
- workspaceId?: string;
42
- contextTable?: string;
43
- }): TResult;
43
+ }, actorId: string, callback: (tx: TDb) => TResult, options?: D1AuditContextOptions): TResult;
44
44
  //# sourceMappingURL=runtime.d.ts.map
@@ -1 +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"}
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,MAAM,MAAM,qBAAqB,GAAG;IAClC,uEAAuE;IACvE,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IAChC,YAAY,CAAC,EAAE,MAAM,CAAA;CACtB,CAAA;AAkBD;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAC/B,EAAE,EAAE,kBAAkB,EACtB,OAAO,EAAE,MAAM,EACf,OAAO,CAAC,EAAE,qBAAqB,gCAwBhC;AAED;;;;;;GAMG;AACH,wBAAgB,mBAAmB,CACjC,EAAE,EAAE,kBAAkB,EACtB,OAAO,CAAC,EAAE,qBAAqB,WAYhC;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,qBAAqB,GAC9B,OAAO,CAWT"}
@@ -5,6 +5,19 @@ function assertActorId(actorId) {
5
5
  throw new Error("actorId must not be empty");
6
6
  }
7
7
  }
8
+ /**
9
+ * Builds the KV map (key → value) to write. Empty/undefined values are dropped
10
+ * so the corresponding column stays NULL.
11
+ */
12
+ function resolveContext(options) {
13
+ const context = {};
14
+ for (const [key, value] of Object.entries(options?.context ?? {})) {
15
+ if (value !== undefined && value !== "") {
16
+ context[key] = value;
17
+ }
18
+ }
19
+ return context;
20
+ }
8
21
  /**
9
22
  * Sets the audit context for the current transaction by writing to the
10
23
  * _audit_context table. Must be called inside a transaction before any
@@ -15,26 +28,30 @@ function assertActorId(actorId) {
15
28
  export function setD1AuditContext(db, actorId, options) {
16
29
  assertActorId(actorId);
17
30
  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})`);
31
+ const context = resolveContext(options);
32
+ const writeKv = (key, value) => db.run(sql `INSERT OR REPLACE INTO ${sql.identifier(table)} (key, value) VALUES (${key}, ${value})`);
33
+ const result = writeKv("user_id", actorId);
19
34
  // Handle async drivers (D1) by chaining if result is a Promise
20
35
  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
- });
36
+ return Object.entries(context).reduce((prev, [key, value]) => prev.then(() => writeKv(key, value)), result);
26
37
  }
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})`);
38
+ for (const [key, value] of Object.entries(context)) {
39
+ writeKv(key, value);
29
40
  }
30
41
  }
31
42
  /**
32
43
  * Clears the audit context after a transaction completes.
33
44
  * Called automatically by withD1AuditedTransaction.
45
+ *
46
+ * Clears `user_id` plus any keys set via `context` so the next transaction
47
+ * starts clean.
34
48
  */
35
49
  export function clearD1AuditContext(db, options) {
36
50
  const table = options?.contextTable ?? DEFAULT_CONTEXT_TABLE;
37
- return db.run(sql `DELETE FROM ${sql.identifier(table)} WHERE key IN ('user_id', 'workspace_id')`);
51
+ const keys = ["user_id", ...Object.keys(options?.context ?? {})];
52
+ const uniqueKeys = [...new Set(keys)];
53
+ const keyList = sql.join(uniqueKeys.map((k) => sql `${k}`), sql `, `);
54
+ return db.run(sql `DELETE FROM ${sql.identifier(table)} WHERE key IN (${keyList})`);
38
55
  }
39
56
  /**
40
57
  * Wraps a Drizzle SQLite transaction with audit context. Sets the actor
@@ -2,8 +2,8 @@ import type { D1AuditInstallOptions, D1AuditTriggerTarget } from "./types.js";
2
2
  /**
3
3
  * Generates SQL to install the audit_logs table and _audit_context table.
4
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
5
+ * The _audit_context table stores user_id (and any configured context columns)
6
+ * for the current transaction. Since D1/SQLite has no session variables, triggers
7
7
  * read context from this table instead.
8
8
  */
9
9
  export declare function createD1AuditInstallSql(options?: D1AuditInstallOptions): string;
@@ -1 +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"}
1
+ {"version":3,"file":"sql.d.ts","sourceRoot":"","sources":["../../../src/d1/sql.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAEV,qBAAqB,EACrB,oBAAoB,EACrB,MAAM,YAAY,CAAA;AAyEnB;;;;;;GAMG;AACH,wBAAgB,uBAAuB,CAAC,OAAO,GAAE,qBAA0B,UA8C1E;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"}
@@ -13,29 +13,63 @@ function assertNonEmpty(value, label) {
13
13
  }
14
14
  return value;
15
15
  }
16
+ /**
17
+ * Normalizes the context columns from the install options, de-duplicating by
18
+ * column name. For D1 the KV key defaults to the column name itself.
19
+ */
20
+ function normalizeContextColumns(options) {
21
+ const resolved = [];
22
+ const seen = new Set();
23
+ for (const entry of options.contextColumns ?? []) {
24
+ const column = entry.column?.trim();
25
+ if (!column) {
26
+ throw new Error("contextColumns[].column must not be empty");
27
+ }
28
+ if (seen.has(column)) {
29
+ continue;
30
+ }
31
+ seen.add(column);
32
+ resolved.push({
33
+ column,
34
+ sessionKey: entry.sessionKey?.trim() || column,
35
+ index: entry.index ?? true,
36
+ });
37
+ }
38
+ return resolved;
39
+ }
40
+ /** Builds the column-name suffix for an INSERT column list. */
41
+ function contextColsClause(contextColumns) {
42
+ return contextColumns.map((c) => `, ${quoteIdent(c.column)}`).join("");
43
+ }
44
+ /** Builds the matching VALUES suffix that reads each KV row from the context table. */
45
+ function contextValuesClause(contextColumns, contextTable) {
46
+ return contextColumns
47
+ .map((c) => `,\n (SELECT value FROM ${quoteIdent(contextTable)} WHERE key = ${quoteLiteral(c.sessionKey)})`)
48
+ .join("");
49
+ }
16
50
  /**
17
51
  * Generates SQL to install the audit_logs table and _audit_context table.
18
52
  *
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
53
+ * The _audit_context table stores user_id (and any configured context columns)
54
+ * for the current transaction. Since D1/SQLite has no session variables, triggers
21
55
  * read context from this table instead.
22
56
  */
23
57
  export function createD1AuditInstallSql(options = {}) {
24
58
  const auditTable = assertNonEmpty(options.auditTable ?? DEFAULT_AUDIT_TABLE, "auditTable");
25
59
  const contextTable = assertNonEmpty(options.contextTable ?? DEFAULT_CONTEXT_TABLE, "contextTable");
26
- const workspaceIdColumn = options.workspaceIdColumn?.trim();
60
+ const contextColumns = normalizeContextColumns(options);
27
61
  const auditColumns = [
28
62
  "id INTEGER PRIMARY KEY AUTOINCREMENT",
29
63
  "table_name TEXT NOT NULL",
30
64
  "operation TEXT NOT NULL CHECK (operation IN ('INSERT', 'UPDATE', 'DELETE'))",
31
65
  "row_id TEXT",
32
66
  "user_id TEXT",
33
- ...(workspaceIdColumn ? [`${quoteIdent(workspaceIdColumn)} TEXT`] : []),
67
+ ...contextColumns.map((c) => `${quoteIdent(c.column)} TEXT`),
34
68
  "old_data TEXT",
35
69
  "new_data TEXT",
36
70
  "created_at TEXT NOT NULL DEFAULT (datetime('now'))",
37
71
  ];
38
- const contextColumns = [
72
+ const contextTableColumns = [
39
73
  "key TEXT PRIMARY KEY",
40
74
  "value TEXT",
41
75
  ];
@@ -43,16 +77,14 @@ export function createD1AuditInstallSql(options = {}) {
43
77
  `CREATE INDEX IF NOT EXISTS ${quoteIdent(`${auditTable}_table_name_idx`)} ON ${quoteIdent(auditTable)} (table_name);`,
44
78
  `CREATE INDEX IF NOT EXISTS ${quoteIdent(`${auditTable}_row_id_idx`)} ON ${quoteIdent(auditTable)} (row_id);`,
45
79
  `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
- : []),
80
+ ...contextColumns
81
+ .filter((c) => c.index)
82
+ .map((c) => `CREATE INDEX IF NOT EXISTS ${quoteIdent(`${auditTable}_${c.column}_idx`)} ON ${quoteIdent(auditTable)} (${quoteIdent(c.column)});`),
51
83
  `CREATE INDEX IF NOT EXISTS ${quoteIdent(`${auditTable}_created_at_idx`)} ON ${quoteIdent(auditTable)} (created_at);`,
52
84
  ];
53
85
  return [
54
86
  `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);`,
87
+ `CREATE TABLE IF NOT EXISTS ${quoteIdent(contextTable)} (\n ${contextTableColumns.join(",\n ")}\n);`,
56
88
  ...indexStatements,
57
89
  ].join("\n\n");
58
90
  }
@@ -63,7 +95,7 @@ function buildInsertTriggerSql(target, options) {
63
95
  const contextTable = assertNonEmpty(options.contextTable ?? DEFAULT_CONTEXT_TABLE, "contextTable");
64
96
  const triggerPrefix = target.triggerPrefix ?? table;
65
97
  const triggerName = `${triggerPrefix}_audit_insert`;
66
- const workspaceIdColumn = options.workspaceIdColumn?.trim();
98
+ const contextColumns = normalizeContextColumns(options);
67
99
  return `
68
100
  DROP TRIGGER IF EXISTS ${quoteIdent(triggerName)};
69
101
 
@@ -71,12 +103,12 @@ CREATE TRIGGER ${quoteIdent(triggerName)}
71
103
  AFTER INSERT ON ${quoteIdent(table)}
72
104
  FOR EACH ROW
73
105
  BEGIN
74
- INSERT INTO ${quoteIdent(auditTable)} (table_name, operation, row_id, user_id${workspaceIdColumn ? `, ${quoteIdent(workspaceIdColumn)}` : ""})
106
+ INSERT INTO ${quoteIdent(auditTable)} (table_name, operation, row_id, user_id${contextColsClause(contextColumns)})
75
107
  VALUES (
76
108
  ${quoteLiteral(table)},
77
109
  'INSERT',
78
110
  NEW.${quoteIdent(rowIdColumn)},
79
- (SELECT value FROM ${quoteIdent(contextTable)} WHERE key = 'user_id')${workspaceIdColumn ? `,\n (SELECT value FROM ${quoteIdent(contextTable)} WHERE key = 'workspace_id')` : ""}
111
+ (SELECT value FROM ${quoteIdent(contextTable)} WHERE key = 'user_id')${contextValuesClause(contextColumns, contextTable)}
80
112
  );
81
113
  END;`.trim();
82
114
  }
@@ -87,7 +119,7 @@ function buildUpdateTriggerSql(target, options) {
87
119
  const contextTable = assertNonEmpty(options.contextTable ?? DEFAULT_CONTEXT_TABLE, "contextTable");
88
120
  const triggerPrefix = target.triggerPrefix ?? table;
89
121
  const triggerName = `${triggerPrefix}_audit_update`;
90
- const workspaceIdColumn = options.workspaceIdColumn?.trim();
122
+ const contextColumns = normalizeContextColumns(options);
91
123
  return `
92
124
  DROP TRIGGER IF EXISTS ${quoteIdent(triggerName)};
93
125
 
@@ -95,12 +127,12 @@ CREATE TRIGGER ${quoteIdent(triggerName)}
95
127
  AFTER UPDATE ON ${quoteIdent(table)}
96
128
  FOR EACH ROW
97
129
  BEGIN
98
- INSERT INTO ${quoteIdent(auditTable)} (table_name, operation, row_id, user_id${workspaceIdColumn ? `, ${quoteIdent(workspaceIdColumn)}` : ""})
130
+ INSERT INTO ${quoteIdent(auditTable)} (table_name, operation, row_id, user_id${contextColsClause(contextColumns)})
99
131
  VALUES (
100
132
  ${quoteLiteral(table)},
101
133
  'UPDATE',
102
134
  NEW.${quoteIdent(rowIdColumn)},
103
- (SELECT value FROM ${quoteIdent(contextTable)} WHERE key = 'user_id')${workspaceIdColumn ? `,\n (SELECT value FROM ${quoteIdent(contextTable)} WHERE key = 'workspace_id')` : ""}
135
+ (SELECT value FROM ${quoteIdent(contextTable)} WHERE key = 'user_id')${contextValuesClause(contextColumns, contextTable)}
104
136
  );
105
137
  END;`.trim();
106
138
  }
@@ -111,7 +143,7 @@ function buildDeleteTriggerSql(target, options) {
111
143
  const contextTable = assertNonEmpty(options.contextTable ?? DEFAULT_CONTEXT_TABLE, "contextTable");
112
144
  const triggerPrefix = target.triggerPrefix ?? table;
113
145
  const triggerName = `${triggerPrefix}_audit_delete`;
114
- const workspaceIdColumn = options.workspaceIdColumn?.trim();
146
+ const contextColumns = normalizeContextColumns(options);
115
147
  return `
116
148
  DROP TRIGGER IF EXISTS ${quoteIdent(triggerName)};
117
149
 
@@ -119,12 +151,12 @@ CREATE TRIGGER ${quoteIdent(triggerName)}
119
151
  AFTER DELETE ON ${quoteIdent(table)}
120
152
  FOR EACH ROW
121
153
  BEGIN
122
- INSERT INTO ${quoteIdent(auditTable)} (table_name, operation, row_id, user_id${workspaceIdColumn ? `, ${quoteIdent(workspaceIdColumn)}` : ""})
154
+ INSERT INTO ${quoteIdent(auditTable)} (table_name, operation, row_id, user_id${contextColsClause(contextColumns)})
123
155
  VALUES (
124
156
  ${quoteLiteral(table)},
125
157
  'DELETE',
126
158
  OLD.${quoteIdent(rowIdColumn)},
127
- (SELECT value FROM ${quoteIdent(contextTable)} WHERE key = 'user_id')${workspaceIdColumn ? `,\n (SELECT value FROM ${quoteIdent(contextTable)} WHERE key = 'workspace_id')` : ""}
159
+ (SELECT value FROM ${quoteIdent(contextTable)} WHERE key = 'user_id')${contextValuesClause(contextColumns, contextTable)}
128
160
  );
129
161
  END;`.trim();
130
162
  }
@@ -160,7 +192,7 @@ function buildInsertTriggerWithColumnsSql(target, options) {
160
192
  const contextTable = assertNonEmpty(options.contextTable ?? DEFAULT_CONTEXT_TABLE, "contextTable");
161
193
  const triggerPrefix = target.triggerPrefix ?? table;
162
194
  const triggerName = `${triggerPrefix}_audit_insert`;
163
- const workspaceIdColumn = options.workspaceIdColumn?.trim();
195
+ const contextColumns = normalizeContextColumns(options);
164
196
  return `
165
197
  DROP TRIGGER IF EXISTS ${quoteIdent(triggerName)};
166
198
 
@@ -168,12 +200,12 @@ CREATE TRIGGER ${quoteIdent(triggerName)}
168
200
  AFTER INSERT ON ${quoteIdent(table)}
169
201
  FOR EACH ROW
170
202
  BEGIN
171
- INSERT INTO ${quoteIdent(auditTable)} (table_name, operation, row_id, user_id${workspaceIdColumn ? `, ${quoteIdent(workspaceIdColumn)}` : ""}, new_data)
203
+ INSERT INTO ${quoteIdent(auditTable)} (table_name, operation, row_id, user_id${contextColsClause(contextColumns)}, new_data)
172
204
  VALUES (
173
205
  ${quoteLiteral(table)},
174
206
  'INSERT',
175
207
  NEW.${quoteIdent(rowIdColumn)},
176
- (SELECT value FROM ${quoteIdent(contextTable)} WHERE key = 'user_id')${workspaceIdColumn ? `,\n (SELECT value FROM ${quoteIdent(contextTable)} WHERE key = 'workspace_id')` : ""},
208
+ (SELECT value FROM ${quoteIdent(contextTable)} WHERE key = 'user_id')${contextValuesClause(contextColumns, contextTable)},
177
209
  ${buildJsonObjectExpr(target.columns, "NEW")}
178
210
  );
179
211
  END;`.trim();
@@ -185,7 +217,7 @@ function buildUpdateTriggerWithColumnsSql(target, options) {
185
217
  const contextTable = assertNonEmpty(options.contextTable ?? DEFAULT_CONTEXT_TABLE, "contextTable");
186
218
  const triggerPrefix = target.triggerPrefix ?? table;
187
219
  const triggerName = `${triggerPrefix}_audit_update`;
188
- const workspaceIdColumn = options.workspaceIdColumn?.trim();
220
+ const contextColumns = normalizeContextColumns(options);
189
221
  return `
190
222
  DROP TRIGGER IF EXISTS ${quoteIdent(triggerName)};
191
223
 
@@ -193,12 +225,12 @@ CREATE TRIGGER ${quoteIdent(triggerName)}
193
225
  AFTER UPDATE ON ${quoteIdent(table)}
194
226
  FOR EACH ROW
195
227
  BEGIN
196
- INSERT INTO ${quoteIdent(auditTable)} (table_name, operation, row_id, user_id${workspaceIdColumn ? `, ${quoteIdent(workspaceIdColumn)}` : ""}, old_data, new_data)
228
+ INSERT INTO ${quoteIdent(auditTable)} (table_name, operation, row_id, user_id${contextColsClause(contextColumns)}, old_data, new_data)
197
229
  VALUES (
198
230
  ${quoteLiteral(table)},
199
231
  'UPDATE',
200
232
  NEW.${quoteIdent(rowIdColumn)},
201
- (SELECT value FROM ${quoteIdent(contextTable)} WHERE key = 'user_id')${workspaceIdColumn ? `,\n (SELECT value FROM ${quoteIdent(contextTable)} WHERE key = 'workspace_id')` : ""},
233
+ (SELECT value FROM ${quoteIdent(contextTable)} WHERE key = 'user_id')${contextValuesClause(contextColumns, contextTable)},
202
234
  ${buildJsonObjectExpr(target.columns, "OLD")},
203
235
  ${buildJsonObjectExpr(target.columns, "NEW")}
204
236
  );
@@ -211,7 +243,7 @@ function buildDeleteTriggerWithColumnsSql(target, options) {
211
243
  const contextTable = assertNonEmpty(options.contextTable ?? DEFAULT_CONTEXT_TABLE, "contextTable");
212
244
  const triggerPrefix = target.triggerPrefix ?? table;
213
245
  const triggerName = `${triggerPrefix}_audit_delete`;
214
- const workspaceIdColumn = options.workspaceIdColumn?.trim();
246
+ const contextColumns = normalizeContextColumns(options);
215
247
  return `
216
248
  DROP TRIGGER IF EXISTS ${quoteIdent(triggerName)};
217
249
 
@@ -219,12 +251,12 @@ CREATE TRIGGER ${quoteIdent(triggerName)}
219
251
  AFTER DELETE ON ${quoteIdent(table)}
220
252
  FOR EACH ROW
221
253
  BEGIN
222
- INSERT INTO ${quoteIdent(auditTable)} (table_name, operation, row_id, user_id${workspaceIdColumn ? `, ${quoteIdent(workspaceIdColumn)}` : ""}, old_data)
254
+ INSERT INTO ${quoteIdent(auditTable)} (table_name, operation, row_id, user_id${contextColsClause(contextColumns)}, old_data)
223
255
  VALUES (
224
256
  ${quoteLiteral(table)},
225
257
  'DELETE',
226
258
  OLD.${quoteIdent(rowIdColumn)},
227
- (SELECT value FROM ${quoteIdent(contextTable)} WHERE key = 'user_id')${workspaceIdColumn ? `,\n (SELECT value FROM ${quoteIdent(contextTable)} WHERE key = 'workspace_id')` : ""},
259
+ (SELECT value FROM ${quoteIdent(contextTable)} WHERE key = 'user_id')${contextValuesClause(contextColumns, contextTable)},
228
260
  ${buildJsonObjectExpr(target.columns, "OLD")}
229
261
  );
230
262
  END;`.trim();
@@ -6,13 +6,21 @@ import type { SQL } from "drizzle-orm";
6
6
  export type D1AuditSqlExecutor = {
7
7
  run: (query: SQL) => unknown;
8
8
  };
9
+ export type AuditContextColumn = {
10
+ /** Column added to the audit table (TEXT, nullable). */
11
+ column: string;
12
+ /** The `_audit_context` key the trigger reads. Default `${column}`. */
13
+ sessionKey?: string;
14
+ /** Create an index on the column. Default true. */
15
+ index?: boolean;
16
+ };
9
17
  export type D1AuditInstallOptions = {
10
18
  /** Name of the audit log table (default: "audit_logs") */
11
19
  auditTable?: string;
12
20
  /** Name of the context table used to pass user_id to triggers (default: "_audit_context") */
13
21
  contextTable?: string;
14
- /** Optional workspace column name (e.g. "workspace_id") */
15
- workspaceIdColumn?: string;
22
+ /** Extra context columns added to the audit table and populated by triggers from the _audit_context KV table. */
23
+ contextColumns?: AuditContextColumn[];
16
24
  };
17
25
  export type D1AuditTriggerTarget = {
18
26
  table: string;