@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
@@ -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,2 @@
1
+ export {};
2
+ //# sourceMappingURL=context.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"context.test.d.ts","sourceRoot":"","sources":["../../test/context.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,98 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { currentAudit, ensureAuditedTx, hasAuditContext, maybeCurrentAudit, runWithAuditContext, } from "../src/context/index.js";
4
+ function makeFakeDb() {
5
+ let txCount = 0;
6
+ const executed = [];
7
+ const tx = {
8
+ async execute(query) {
9
+ executed.push(query);
10
+ return undefined;
11
+ },
12
+ };
13
+ const db = {
14
+ async transaction(cb) {
15
+ txCount++;
16
+ return cb(tx);
17
+ },
18
+ };
19
+ return {
20
+ db,
21
+ tx,
22
+ executed,
23
+ get txCount() {
24
+ return txCount;
25
+ },
26
+ };
27
+ }
28
+ test("ensureAuditedTx opens one tx and writes the actor GUC", async () => {
29
+ const fake = makeFakeDb();
30
+ let inside;
31
+ await runWithAuditContext({ actorId: "user_1" }, () => ensureAuditedTx(fake.db, async (tx) => {
32
+ inside = tx;
33
+ }));
34
+ assert.equal(fake.txCount, 1);
35
+ assert.equal(inside, fake.tx);
36
+ // setAuditContext ran at least the actor set_config.
37
+ assert.ok(fake.executed.length >= 1);
38
+ });
39
+ test("nested ensureAuditedTx reuses the open tx (no second transaction)", async () => {
40
+ const fake = makeFakeDb();
41
+ const seen = [];
42
+ await runWithAuditContext({ actorId: "user_1" }, () => ensureAuditedTx(fake.db, async (tx) => {
43
+ seen.push(tx);
44
+ await ensureAuditedTx(fake.db, async (tx2) => {
45
+ seen.push(tx2);
46
+ await ensureAuditedTx(fake.db, async (tx3) => seen.push(tx3));
47
+ });
48
+ }));
49
+ assert.equal(fake.txCount, 1, "only one transaction is opened");
50
+ assert.equal(seen.length, 3);
51
+ assert.ok(seen.every((t) => t === fake.tx));
52
+ });
53
+ test("lazy resolver runs exactly once, on first write", async () => {
54
+ const fake = makeFakeDb();
55
+ let resolved = 0;
56
+ let seenActor;
57
+ await runWithAuditContext(() => {
58
+ resolved++;
59
+ return { actorId: "lazy" };
60
+ }, async () => {
61
+ assert.equal(resolved, 0, "not resolved before any write");
62
+ assert.equal(maybeCurrentAudit(), null);
63
+ await ensureAuditedTx(fake.db, async () => {
64
+ seenActor = currentAudit().actorId;
65
+ // reentrant — must not re-resolve
66
+ await ensureAuditedTx(fake.db, async () => { });
67
+ });
68
+ // resolved value is memoised on the cell
69
+ assert.equal(maybeCurrentAudit()?.actorId, "lazy");
70
+ });
71
+ assert.equal(resolved, 1);
72
+ assert.equal(seenActor, "lazy");
73
+ });
74
+ test("eager context resolves immediately (currentAudit before any write)", async () => {
75
+ await runWithAuditContext({ actorId: "eager", context: { "app.workspace_id": "ws_9" } }, async () => {
76
+ assert.equal(currentAudit().actorId, "eager");
77
+ assert.equal(currentAudit().context?.["app.workspace_id"], "ws_9");
78
+ });
79
+ });
80
+ test("guardrail: writes / reads outside a context fail loudly", async () => {
81
+ const fake = makeFakeDb();
82
+ assert.equal(hasAuditContext(), false);
83
+ assert.equal(maybeCurrentAudit(), null);
84
+ assert.throws(() => currentAudit(), /no ambient audit context/);
85
+ await assert.rejects(() => ensureAuditedTx(fake.db, async () => { }), /outside an audit context/);
86
+ assert.equal(fake.txCount, 0);
87
+ });
88
+ test("context is isolated per runWithAuditContext scope", async () => {
89
+ const a = runWithAuditContext({ actorId: "A" }, async () => {
90
+ await Promise.resolve();
91
+ return currentAudit().actorId;
92
+ });
93
+ const b = runWithAuditContext({ actorId: "B" }, async () => {
94
+ await Promise.resolve();
95
+ return currentAudit().actorId;
96
+ });
97
+ assert.deepEqual(await Promise.all([a, b]), ["A", "B"]);
98
+ });
@@ -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 {
@@ -165,7 +165,9 @@ test("withAudit handles multi-row update", async () => {
165
165
  });
166
166
  test("withAudit with workspace_id", async () => {
167
167
  const sqlite = new Database(":memory:");
168
- const auditLogsWithWs = d1AuditLogTable({ workspaceIdColumn: "workspace_id" });
168
+ const auditLogsWithWs = d1AuditLogTable({
169
+ contextColumns: [{ column: "workspace_id" }],
170
+ });
169
171
  const db = drizzle({ client: sqlite, schema: { auditLogs: auditLogsWithWs, users } });
170
172
  try {
171
173
  sqlite.exec(`
@@ -188,7 +190,7 @@ test("withAudit with workspace_id", async () => {
188
190
  `);
189
191
  const audited = withAudit(db, auditLogsWithWs, {
190
192
  userId: "user_1",
191
- workspaceId: "ws_1",
193
+ context: { workspace_id: "ws_1" },
192
194
  });
193
195
  await audited.insert(users, { id: "u1", name: "Ada" });
194
196
  const logs = db.select().from(auditLogsWithWs).all();
@@ -200,6 +202,67 @@ test("withAudit with workspace_id", async () => {
200
202
  sqlite.close();
201
203
  }
202
204
  });
205
+ test("withAudit with generic context columns", async () => {
206
+ const sqlite = new Database(":memory:");
207
+ const auditLogsWithCtx = d1AuditLogTable({
208
+ contextColumns: [
209
+ { column: "workspace_id" },
210
+ { column: "tenant_id" },
211
+ { column: "request_id" },
212
+ ],
213
+ });
214
+ const db = drizzle({ client: sqlite, schema: { auditLogs: auditLogsWithCtx, users } });
215
+ try {
216
+ sqlite.exec(`
217
+ CREATE TABLE audit_logs (
218
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
219
+ table_name TEXT NOT NULL,
220
+ operation TEXT NOT NULL,
221
+ row_id TEXT,
222
+ user_id TEXT,
223
+ workspace_id TEXT,
224
+ tenant_id TEXT,
225
+ request_id TEXT,
226
+ old_data TEXT,
227
+ new_data TEXT,
228
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
229
+ );
230
+ CREATE TABLE users (
231
+ id TEXT PRIMARY KEY,
232
+ name TEXT NOT NULL,
233
+ email TEXT
234
+ );
235
+ `);
236
+ const audited = withAudit(db, auditLogsWithCtx, {
237
+ userId: "user_1",
238
+ context: {
239
+ workspace_id: "ws_1",
240
+ tenant_id: "tenant_1",
241
+ request_id: "req_1",
242
+ },
243
+ });
244
+ await audited.insert(users, { id: "u1", name: "Ada" });
245
+ const logs = db.select().from(auditLogsWithCtx).all();
246
+ assert.equal(logs.length, 1);
247
+ const row = logs[0];
248
+ assert.equal(row.user_id, "user_1");
249
+ assert.equal(row.workspace_id, "ws_1");
250
+ assert.equal(row.tenant_id, "tenant_1");
251
+ assert.equal(row.request_id, "req_1");
252
+ // No context: extra columns stay NULL.
253
+ const auditedNoCtx = withAudit(db, auditLogsWithCtx, { userId: "user_2" });
254
+ await auditedNoCtx.insert(users, { id: "u2", name: "Bob" });
255
+ const all = db.select().from(auditLogsWithCtx).orderBy(asc(auditLogsWithCtx.id)).all();
256
+ const last = all[all.length - 1];
257
+ assert.equal(last.user_id, "user_2");
258
+ assert.equal(last.workspace_id, null);
259
+ assert.equal(last.tenant_id, null);
260
+ assert.equal(last.request_id, null);
261
+ }
262
+ finally {
263
+ sqlite.close();
264
+ }
265
+ });
203
266
  test("withAudit.db gives access to raw db for non-audited ops", async () => {
204
267
  const { db, sqlite } = setupDb();
205
268
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@willyim/drizzle-audit",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "Lightweight audit logging for Drizzle ORM using database triggers (Postgres + D1/SQLite)",
5
5
  "type": "module",
6
6
  "main": "./dist/src/index.js",
@@ -8,6 +8,7 @@
8
8
  "exports": {
9
9
  ".": "./dist/src/index.js",
10
10
  "./postgres": "./dist/src/postgres/index.js",
11
+ "./context": "./dist/src/context/index.js",
11
12
  "./d1": "./dist/src/d1/index.js",
12
13
  "./d1-runtime": "./dist/src/d1-runtime/index.js"
13
14
  },