@willyim/drizzle-audit 0.4.0 → 0.6.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 (43) hide show
  1. package/README.md +118 -16
  2. package/dist/src/context/index.d.ts +42 -0
  3. package/dist/src/context/index.d.ts.map +1 -0
  4. package/dist/src/context/index.js +94 -0
  5. package/dist/src/d1/audit-log-schema.d.ts +3 -2
  6. package/dist/src/d1/audit-log-schema.d.ts.map +1 -1
  7. package/dist/src/d1/audit-log-schema.js +14 -4
  8. package/dist/src/d1/index.d.ts +2 -1
  9. package/dist/src/d1/index.d.ts.map +1 -1
  10. package/dist/src/d1/runtime.d.ts +11 -11
  11. package/dist/src/d1/runtime.d.ts.map +1 -1
  12. package/dist/src/d1/runtime.js +26 -9
  13. package/dist/src/d1/sql.d.ts +2 -2
  14. package/dist/src/d1/sql.d.ts.map +1 -1
  15. package/dist/src/d1/sql.js +61 -29
  16. package/dist/src/d1/types.d.ts +10 -2
  17. package/dist/src/d1/types.d.ts.map +1 -1
  18. package/dist/src/d1-runtime/with-audit.d.ts +3 -2
  19. package/dist/src/d1-runtime/with-audit.d.ts.map +1 -1
  20. package/dist/src/d1-runtime/with-audit.js +12 -7
  21. package/dist/src/index.d.ts +2 -1
  22. package/dist/src/index.d.ts.map +1 -1
  23. package/dist/src/index.js +1 -1
  24. package/dist/src/postgres/audit-log-schema.d.ts +3 -2
  25. package/dist/src/postgres/audit-log-schema.d.ts.map +1 -1
  26. package/dist/src/postgres/audit-log-schema.js +14 -4
  27. package/dist/src/postgres/index.d.ts +3 -2
  28. package/dist/src/postgres/index.d.ts.map +1 -1
  29. package/dist/src/postgres/index.js +1 -1
  30. package/dist/src/postgres/runtime.d.ts +6 -8
  31. package/dist/src/postgres/runtime.d.ts.map +1 -1
  32. package/dist/src/postgres/runtime.js +15 -3
  33. package/dist/src/postgres/sql.d.ts +10 -7
  34. package/dist/src/postgres/sql.d.ts.map +1 -1
  35. package/dist/src/postgres/sql.js +72 -50
  36. package/dist/src/postgres/types.d.ts +10 -2
  37. package/dist/src/postgres/types.d.ts.map +1 -1
  38. package/dist/test/context.test.d.ts +2 -0
  39. package/dist/test/context.test.d.ts.map +1 -0
  40. package/dist/test/context.test.js +98 -0
  41. package/dist/test/d1.integration.test.js +71 -4
  42. package/dist/test/sqlite.integration.test.js +65 -2
  43. package/package.json +2 -1
package/README.md CHANGED
@@ -120,50 +120,119 @@ sqlite.exec(createAttachD1AuditTriggersSqlWithColumns([
120
120
  ]))
121
121
  ```
122
122
 
123
- ## Workspace / Tenant Scoping
123
+ ## Context Columns
124
124
 
125
- All three approaches support an optional workspace column for multi-tenant apps.
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).
129
+
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
+ ```
126
138
 
127
139
  ### Postgres
128
140
 
129
141
  ```ts
130
- // Install with workspace column
131
- createAuditInstallSql({ workspaceIdColumn: "workspace_id" })
132
- export const auditLogs = pgAuditLogTable({ workspaceIdColumn: "workspace_id" })
142
+ const contextColumns = [{ column: "workspace_id" }, { column: "tenant_id" }]
133
143
 
134
- // Pass workspace at runtime
144
+ createAuditInstallSql({ contextColumns })
145
+ export const auditLogs = pgAuditLogTable({ contextColumns })
146
+
147
+ // Pass context at runtime (GUC name → value)
135
148
  await withAuditedTransaction(
136
149
  db, userId, async (tx) => { /* ... */ },
137
150
  "app.user_id",
138
- { workspaceId: "ws_1" },
151
+ { context: { "app.workspace_id": "ws_1", "app.tenant_id": "t_1" } },
139
152
  )
140
153
  ```
141
154
 
142
- 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.
143
159
 
144
160
  ### D1 Runtime
145
161
 
146
162
  ```ts
147
163
  const audit = withAudit(db, auditLogs, {
148
164
  userId: "user_1",
149
- workspaceId: "ws_1",
165
+ context: { workspace_id: "ws_1", tenant_id: "t_1" },
150
166
  })
151
167
  ```
152
168
 
153
169
  ### D1 Triggers
154
170
 
155
171
  ```ts
156
- createD1AuditInstallSql({ workspaceIdColumn: "workspace_id" })
157
- createAttachD1AuditTriggersSql(
158
- [{ table: "users" }],
159
- { workspaceIdColumn: "workspace_id" },
160
- )
172
+ const contextColumns = [{ column: "workspace_id" }, { column: "tenant_id" }]
173
+
174
+ createD1AuditInstallSql({ contextColumns })
175
+ createAttachD1AuditTriggersSql([{ table: "users" }], { contextColumns })
161
176
 
162
177
  withD1AuditedTransaction(db, "user_1", (tx) => { /* ... */ }, {
163
- workspaceId: "ws_1",
178
+ context: { workspace_id: "ws_1", tenant_id: "t_1" },
164
179
  })
165
180
  ```
166
181
 
182
+ ## Ambient Context (AsyncLocalStorage)
183
+
184
+ The explicit `withAuditedTransaction(db, actorId, cb)` is the cross-runtime floor —
185
+ it works everywhere and you pass the actor by hand. The opt-in
186
+ `@willyim/drizzle-audit/context` export adds an *ambient* layer on top: establish
187
+ "who is acting" **once** at an entry boundary (a request, a job, an engine tick),
188
+ then open-or-reuse a single audited transaction anywhere below — the actor is read
189
+ from the ambient store, never threaded through call signatures.
190
+
191
+ ```ts
192
+ import {
193
+ runWithAuditContext,
194
+ ensureAuditedTx,
195
+ currentAudit,
196
+ } from "@willyim/drizzle-audit/context"
197
+
198
+ // 1. Boundary: set the ambient actor for the unit of work. Pass a thunk to
199
+ // resolve it LAZILY — it only runs if a write actually happens, so read-only
200
+ // requests never pay for it (e.g. resolving a session).
201
+ app.use((req, next) =>
202
+ runWithAuditContext(
203
+ async () => ({
204
+ actorId: (await getSession(req)).userId,
205
+ context: { "app.workspace_id": req.workspaceId },
206
+ }),
207
+ next,
208
+ ),
209
+ )
210
+
211
+ // 2. Anywhere below: open-or-reuse ONE audited tx. Reads don't call this, so
212
+ // they never open a transaction. Reentrant — nested calls share the tx.
213
+ async function archive(db, id) {
214
+ await ensureAuditedTx(db, async (tx) => {
215
+ await tx.update(items).set({ archived: true }).where(eq(items.id, id))
216
+ })
217
+ }
218
+
219
+ // 3. Read the actor for a domain column (inside the callback, where it's resolved).
220
+ async function assign(db, id, to) {
221
+ await ensureAuditedTx(db, async (tx) => {
222
+ await tx.insert(assignments).values({ id, to, assignedBy: currentAudit().actorId })
223
+ })
224
+ }
225
+ ```
226
+
227
+ **Runtime support.** This uses *only* `AsyncLocalStorage` from `node:async_hooks`
228
+ (not `createHook`/`executionAsyncId`/…), which is the subset Cloudflare Workers
229
+ implements. Works on Node, Bun, and Workers — Workers needs the `nodejs_compat`
230
+ flag and a recent compatibility date. If a target lacks ALS, the import throws
231
+ loudly; fall back to the explicit `withAuditedTransaction`.
232
+
233
+ **Guardrail.** `ensureAuditedTx` (and `currentAudit`) throw if there is no ambient
234
+ context — a missed boundary becomes a loud failure instead of an unattributed write.
235
+
167
236
  ## CLI
168
237
 
169
238
  Generate a Drizzle migration with audit SQL appended:
@@ -202,10 +271,22 @@ export function createAuditSql() {
202
271
  | `createAuditInstallSql(options?)` | SQL to create the audit table, indexes, and trigger function |
203
272
  | `createAttachAuditTriggerSql(target, options?)` | SQL to attach audit trigger to one table |
204
273
  | `createAttachAuditTriggersSql(targets, options?)` | Same, for multiple tables |
205
- | `createAuditAddWorkspaceColumnSql(options)` | SQL to add workspace column to existing install |
274
+ | `createAuditAddContextColumnsSql(options)` | SQL to add context columns + regenerate trigger on existing install |
206
275
  | `setAuditContext(db, actorId, contextKey?, options?)` | Set actor context in current transaction |
207
276
  | `withAuditedTransaction(db, actorId, callback, contextKey?, options?)` | Transaction wrapper with actor context |
208
277
 
278
+ ### `@willyim/drizzle-audit/context`
279
+
280
+ Opt-in ambient layer (AsyncLocalStorage). See [Ambient Context](#ambient-context-asynclocalstorage).
281
+
282
+ | Export | Description |
283
+ |---|---|
284
+ | `runWithAuditContext(audit, fn)` | Set the ambient actor for `fn` (`audit` is an `AuditContext` or a lazy thunk) |
285
+ | `ensureAuditedTx(db, run, contextKey?)` | Open-or-reuse one audited tx; actor read from the ambient context |
286
+ | `currentAudit()` | The resolved ambient `AuditContext` (throws if absent/unresolved) |
287
+ | `maybeCurrentAudit()` | The resolved ambient `AuditContext`, or `null` |
288
+ | `hasAuditContext()` | Whether an ambient context is in scope |
289
+
209
290
  ### `@willyim/drizzle-audit`
210
291
 
211
292
  | Export | Description |
@@ -318,6 +399,27 @@ CREATE TABLE audit_logs (
318
399
  | **Bypass risk** | Low (DB-level) | Medium (must use wrapper) | Low (DB-level) |
319
400
  | **Best for** | Postgres apps | D1/Cloudflare Workers | SQLite apps needing DB-level guarantees |
320
401
 
402
+ ## Migrating from 0.2.x → 0.3.0
403
+
404
+ The `workspace_id`-specific options were removed in favor of the generic
405
+ [Context Columns](#context-columns) API. Mechanical replacements:
406
+
407
+ | Removed | Replacement |
408
+ |---|---|
409
+ | `pgAuditLogTable({ workspaceIdColumn: "workspace_id" })` | `pgAuditLogTable({ contextColumns: [{ column: "workspace_id" }] })` |
410
+ | `d1AuditLogTable({ workspaceIdColumn: "workspace_id" })` | `d1AuditLogTable({ contextColumns: [{ column: "workspace_id" }] })` |
411
+ | `createAuditInstallSql({ workspaceIdColumn: "workspace_id" })` | `createAuditInstallSql({ contextColumns: [{ column: "workspace_id" }] })` |
412
+ | `createD1AuditInstallSql({ workspaceIdColumn })` | `createD1AuditInstallSql({ contextColumns: [{ column }] })` |
413
+ | `createAttachD1AuditTriggersSql(targets, { workspaceIdColumn })` | `createAttachD1AuditTriggersSql(targets, { contextColumns: [{ column }] })` |
414
+ | `createAuditAddWorkspaceColumnSql(options)` | `createAuditAddContextColumnsSql({ contextColumns: [...] })` |
415
+ | Postgres runtime `{ workspaceId: v }` | `{ context: { "app.workspace_id": v } }` |
416
+ | Postgres runtime `{ workspaceId: v, workspaceContextKey: "app.tenant_id" }` | `{ context: { "app.tenant_id": v } }` |
417
+ | D1 runtime `{ workspaceId: v }` | `{ context: { workspace_id: v } }` |
418
+ | `withAudit(db, table, { userId, workspaceId: v })` | `withAudit(db, table, { userId, context: { workspace_id: v } })` |
419
+
420
+ The on-disk column name (`workspace_id`) is unchanged, so no data migration is
421
+ needed — only call sites change.
422
+
321
423
  ## License
322
424
 
323
425
  MIT
@@ -0,0 +1,42 @@
1
+ import type { AuditSqlExecutor, AuditTransactionCapable } from "../postgres/types.js";
2
+ /** Opaque to the library: an actor string + the GUC map written for the tx. */
3
+ export type AuditContext = {
4
+ /** Value written to the actor GUC (default `app.user_id`). */
5
+ actorId: string;
6
+ /** Extra session GUCs (full GUC name → value), e.g. `app.workspace_id`. */
7
+ context?: Record<string, string>;
8
+ };
9
+ /**
10
+ * How the ambient actor is resolved. Pass a plain `AuditContext` to resolve it
11
+ * eagerly, or a thunk to resolve it LAZILY — the thunk runs only when the first
12
+ * audited write actually happens, so read-only units of work never pay for it
13
+ * (e.g. resolving a session on a request that only reads).
14
+ */
15
+ export type AuditContextInput = AuditContext | (() => AuditContext | Promise<AuditContext>);
16
+ /**
17
+ * Establish the ambient audit actor for the duration of `fn`. No transaction is
18
+ * opened here — `ensureAuditedTx` opens one lazily on the first write below.
19
+ */
20
+ export declare function runWithAuditContext<T>(audit: AuditContextInput, fn: () => T): T;
21
+ /** The ambient audit context if one is set AND already resolved, else null. */
22
+ export declare function maybeCurrentAudit(): AuditContext | null;
23
+ /**
24
+ * The ambient audit context. Throws if no context is set, or if it is set but
25
+ * not yet resolved (a lazy resolver that has not run). It is always resolved
26
+ * inside an `ensureAuditedTx` callback, which is the intended place to read it
27
+ * (e.g. to stamp a domain "createdBy" column from the actor).
28
+ */
29
+ export declare function currentAudit(): AuditContext;
30
+ /** True if an ambient audit context is in scope (resolved or not). */
31
+ export declare function hasAuditContext(): boolean;
32
+ /**
33
+ * THE primitive: ensure this unit of work runs inside ONE audited transaction,
34
+ * reusing an already-open one if present (reentrant). The actor is read from the
35
+ * ambient context (resolving it on first use). Reads should never call this, so
36
+ * they never open a transaction.
37
+ *
38
+ * Throws if called with no ambient audit context — the guardrail that turns a
39
+ * missed boundary into a loud failure instead of an unattributed write.
40
+ */
41
+ export declare function ensureAuditedTx<TTransaction extends AuditSqlExecutor, TResult>(db: AuditTransactionCapable<TTransaction>, run: (tx: TTransaction) => Promise<TResult> | TResult, contextKey?: string): Promise<TResult>;
42
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/context/index.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EACV,gBAAgB,EAChB,uBAAuB,EACxB,MAAM,sBAAsB,CAAA;AA+B7B,+EAA+E;AAC/E,MAAM,MAAM,YAAY,GAAG;IACzB,8DAA8D;IAC9D,OAAO,EAAE,MAAM,CAAA;IACf,2EAA2E;IAC3E,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CACjC,CAAA;AAED;;;;;GAKG;AACH,MAAM,MAAM,iBAAiB,GACzB,YAAY,GACZ,CAAC,MAAM,YAAY,GAAG,OAAO,CAAC,YAAY,CAAC,CAAC,CAAA;AAYhD;;;GAGG;AACH,wBAAgB,mBAAmB,CAAC,CAAC,EACnC,KAAK,EAAE,iBAAiB,EACxB,EAAE,EAAE,MAAM,CAAC,GACV,CAAC,CAIH;AAED,+EAA+E;AAC/E,wBAAgB,iBAAiB,IAAI,YAAY,GAAG,IAAI,CAEvD;AAED;;;;;GAKG;AACH,wBAAgB,YAAY,IAAI,YAAY,CAc3C;AAED,sEAAsE;AACtE,wBAAgB,eAAe,IAAI,OAAO,CAEzC;AAED;;;;;;;;GAQG;AACH,wBAAsB,eAAe,CACnC,YAAY,SAAS,gBAAgB,EACrC,OAAO,EAEP,EAAE,EAAE,uBAAuB,CAAC,YAAY,CAAC,EACzC,GAAG,EAAE,CAAC,EAAE,EAAE,YAAY,KAAK,OAAO,CAAC,OAAO,CAAC,GAAG,OAAO,EACrD,UAAU,SAAgB,GACzB,OAAO,CAAC,OAAO,CAAC,CAyBlB"}
@@ -0,0 +1,94 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ import { setAuditContext } from "../postgres/runtime.js";
3
+ // ─────────────────────────────────────────────────────────────────────────────
4
+ // Ambient audit context (opt-in).
5
+ //
6
+ // The explicit `withAuditedTransaction(db, actorId, cb)` from the main entry is
7
+ // the cross-runtime floor: it works everywhere and takes the actor by hand. This
8
+ // module adds an *ambient* layer on top of it, built on `AsyncLocalStorage`:
9
+ //
10
+ // - establish "who is acting" ONCE at an entry boundary (a request, a job, an
11
+ // engine tick) with `runWithAuditContext`, then
12
+ // - open-or-reuse a single audited transaction anywhere below with
13
+ // `ensureAuditedTx` — the actor is read from the ambient store, never
14
+ // threaded through call signatures.
15
+ //
16
+ // Runtime support: this uses ONLY `AsyncLocalStorage` from `node:async_hooks`
17
+ // (not `createHook`/`executionAsyncId`/…), which is the subset Cloudflare
18
+ // Workers implements. It works on Node, Bun, and Workers (Workers needs the
19
+ // `nodejs_compat` flag + a recent compatibility date). If a target lacks ALS,
20
+ // fall back to the explicit `withAuditedTransaction` path.
21
+ // ─────────────────────────────────────────────────────────────────────────────
22
+ if (typeof AsyncLocalStorage === "undefined") {
23
+ throw new Error("@willyim/drizzle-audit/context requires AsyncLocalStorage from node:async_hooks. " +
24
+ "On Cloudflare Workers enable the `nodejs_compat` flag (with a recent compatibility " +
25
+ "date). On other runtimes use the explicit withAuditedTransaction(db, actorId, cb) " +
26
+ "from @willyim/drizzle-audit/postgres instead.");
27
+ }
28
+ const als = new AsyncLocalStorage();
29
+ /**
30
+ * Establish the ambient audit actor for the duration of `fn`. No transaction is
31
+ * opened here — `ensureAuditedTx` opens one lazily on the first write below.
32
+ */
33
+ export function runWithAuditContext(audit, fn) {
34
+ const resolve = typeof audit === "function" ? audit : () => audit;
35
+ const resolved = typeof audit === "function" ? null : audit;
36
+ return als.run({ resolve, resolved, tx: null }, fn);
37
+ }
38
+ /** The ambient audit context if one is set AND already resolved, else null. */
39
+ export function maybeCurrentAudit() {
40
+ return als.getStore()?.resolved ?? null;
41
+ }
42
+ /**
43
+ * The ambient audit context. Throws if no context is set, or if it is set but
44
+ * not yet resolved (a lazy resolver that has not run). It is always resolved
45
+ * inside an `ensureAuditedTx` callback, which is the intended place to read it
46
+ * (e.g. to stamp a domain "createdBy" column from the actor).
47
+ */
48
+ export function currentAudit() {
49
+ const cell = als.getStore();
50
+ if (!cell) {
51
+ throw new Error("no ambient audit context — wrap the unit of work in runWithAuditContext(...)");
52
+ }
53
+ if (!cell.resolved) {
54
+ throw new Error("ambient audit context not resolved yet — read currentAudit() inside an " +
55
+ "ensureAuditedTx callback, or pass an eager AuditContext to runWithAuditContext");
56
+ }
57
+ return cell.resolved;
58
+ }
59
+ /** True if an ambient audit context is in scope (resolved or not). */
60
+ export function hasAuditContext() {
61
+ return als.getStore() !== undefined;
62
+ }
63
+ /**
64
+ * THE primitive: ensure this unit of work runs inside ONE audited transaction,
65
+ * reusing an already-open one if present (reentrant). The actor is read from the
66
+ * ambient context (resolving it on first use). Reads should never call this, so
67
+ * they never open a transaction.
68
+ *
69
+ * Throws if called with no ambient audit context — the guardrail that turns a
70
+ * missed boundary into a loud failure instead of an unattributed write.
71
+ */
72
+ export async function ensureAuditedTx(db, run, contextKey = "app.user_id") {
73
+ const cell = als.getStore();
74
+ if (!cell) {
75
+ throw new Error("audited write outside an audit context — wrap the unit of work in " +
76
+ "runWithAuditContext(...) (or use the explicit withAuditedTransaction)");
77
+ }
78
+ // Reentrant: a tx is already open in this context — reuse it.
79
+ if (cell.tx)
80
+ return run(cell.tx);
81
+ const audit = cell.resolved ?? (cell.resolved = await cell.resolve());
82
+ return db.transaction(async (tx) => {
83
+ await setAuditContext(tx, audit.actorId, contextKey, {
84
+ context: audit.context,
85
+ });
86
+ cell.tx = tx;
87
+ try {
88
+ return await run(tx);
89
+ }
90
+ finally {
91
+ cell.tx = null;
92
+ }
93
+ });
94
+ }
@@ -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"}