@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.
- package/README.md +102 -21
- package/dist/src/d1/audit-log-schema.d.ts +3 -2
- package/dist/src/d1/audit-log-schema.d.ts.map +1 -1
- package/dist/src/d1/audit-log-schema.js +14 -4
- package/dist/src/d1/index.d.ts +2 -1
- package/dist/src/d1/index.d.ts.map +1 -1
- package/dist/src/d1/runtime.d.ts +11 -11
- package/dist/src/d1/runtime.d.ts.map +1 -1
- package/dist/src/d1/runtime.js +26 -9
- package/dist/src/d1/sql.d.ts +2 -2
- package/dist/src/d1/sql.d.ts.map +1 -1
- package/dist/src/d1/sql.js +61 -29
- package/dist/src/d1/types.d.ts +10 -2
- package/dist/src/d1/types.d.ts.map +1 -1
- package/dist/src/d1-runtime/with-audit.d.ts +15 -11
- package/dist/src/d1-runtime/with-audit.d.ts.map +1 -1
- package/dist/src/d1-runtime/with-audit.js +53 -60
- package/dist/src/index.d.ts +2 -1
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +1 -1
- package/dist/src/postgres/audit-log-schema.d.ts +7 -14
- package/dist/src/postgres/audit-log-schema.d.ts.map +1 -1
- package/dist/src/postgres/audit-log-schema.js +14 -4
- package/dist/src/postgres/index.d.ts +3 -2
- package/dist/src/postgres/index.d.ts.map +1 -1
- package/dist/src/postgres/index.js +1 -1
- package/dist/src/postgres/runtime.d.ts +6 -8
- package/dist/src/postgres/runtime.d.ts.map +1 -1
- package/dist/src/postgres/runtime.js +15 -3
- package/dist/src/postgres/sql.d.ts +10 -7
- package/dist/src/postgres/sql.d.ts.map +1 -1
- package/dist/src/postgres/sql.js +72 -50
- package/dist/src/postgres/types.d.ts +10 -2
- package/dist/src/postgres/types.d.ts.map +1 -1
- package/dist/test/d1-async.integration.test.d.ts +13 -0
- package/dist/test/d1-async.integration.test.d.ts.map +1 -0
- package/dist/test/d1-async.integration.test.js +159 -0
- package/dist/test/d1.integration.test.js +71 -4
- package/dist/test/sqlite.integration.test.d.ts +2 -0
- package/dist/test/sqlite.integration.test.d.ts.map +1 -0
- package/dist/test/{d1-runtime.integration.test.js → sqlite.integration.test.js} +82 -25
- package/package.json +2 -1
- package/dist/test/d1-runtime.integration.test.d.ts +0 -2
- package/dist/test/d1-runtime.integration.test.d.ts.map +0 -1
- package/dist/test/postgres.integration.test.d.ts +0 -2
- package/dist/test/postgres.integration.test.d.ts.map +0 -1
- package/dist/test/postgres.integration.test.js +0 -286
package/dist/src/postgres/sql.js
CHANGED
|
@@ -21,46 +21,58 @@ function assertNonEmpty(value, label) {
|
|
|
21
21
|
}
|
|
22
22
|
return value;
|
|
23
23
|
}
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
const
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
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 =
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
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;${
|
|
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), '');${
|
|
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
|
|
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
|
-
...(
|
|
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
|
-
...
|
|
125
|
-
|
|
126
|
-
|
|
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,
|
|
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
|
|
170
|
-
* existing audit_logs table. Use in a new migration when adding
|
|
171
|
-
* the initial install.
|
|
172
|
-
*
|
|
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
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
/**
|
|
14
|
-
|
|
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,
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../src/postgres/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,GAAG,EAAE,MAAM,aAAa,CAAA;AAEtC,MAAM,MAAM,gBAAgB,GAAG;IAC7B,OAAO,EAAE,CAAC,KAAK,EAAE,GAAG,KAAK,OAAO,CAAC,OAAO,CAAC,CAAA;CAC1C,CAAA;AAED,MAAM,MAAM,uBAAuB,CAAC,YAAY,SAAS,gBAAgB,IAAI;IAC3E,WAAW,EAAE,CAAC,OAAO,EACnB,QAAQ,EAAE,CAAC,EAAE,EAAE,YAAY,KAAK,OAAO,CAAC,OAAO,CAAC,KAC7C,OAAO,CAAC,OAAO,CAAC,CAAA;CACtB,CAAA;AAED,MAAM,MAAM,kBAAkB,GAAG;IAC/B,wDAAwD;IACxD,MAAM,EAAE,MAAM,CAAA;IACd,8DAA8D;IAC9D,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,mDAAmD;IACnD,KAAK,CAAC,EAAE,OAAO,CAAA;CAChB,CAAA;AAED,MAAM,MAAM,mBAAmB,GAAG;IAChC,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,mBAAmB,CAAC,EAAE,MAAM,CAAA;IAC5B,qGAAqG;IACrG,cAAc,CAAC,EAAE,kBAAkB,EAAE,CAAA;CACtC,CAAA;AAED,MAAM,MAAM,kBAAkB,GAAG;IAC/B,KAAK,EAAE,MAAM,CAAA;IACb,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,WAAW,CAAC,EAAE,MAAM,CAAA;CACrB,CAAA"}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration tests for withAudit against a real async D1 driver (miniflare v4).
|
|
3
|
+
*
|
|
4
|
+
* These tests exercise the actual failure mode described in issue #29:
|
|
5
|
+
* - drizzle-orm/d1 .all()/.run() return Promises, not plain values.
|
|
6
|
+
* - drizzle-orm/d1 db.transaction() issues a raw BEGIN which D1 rejects.
|
|
7
|
+
*
|
|
8
|
+
* All tests here FAIL on the current sync implementation and must PASS after the fix.
|
|
9
|
+
*
|
|
10
|
+
* The existing sqlite.integration.test.ts covers the better-sqlite3 (sync) path.
|
|
11
|
+
*/
|
|
12
|
+
export {};
|
|
13
|
+
//# sourceMappingURL=d1-async.integration.test.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"d1-async.integration.test.d.ts","sourceRoot":"","sources":["../../test/d1-async.integration.test.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG"}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration tests for withAudit against a real async D1 driver (miniflare v4).
|
|
3
|
+
*
|
|
4
|
+
* These tests exercise the actual failure mode described in issue #29:
|
|
5
|
+
* - drizzle-orm/d1 .all()/.run() return Promises, not plain values.
|
|
6
|
+
* - drizzle-orm/d1 db.transaction() issues a raw BEGIN which D1 rejects.
|
|
7
|
+
*
|
|
8
|
+
* All tests here FAIL on the current sync implementation and must PASS after the fix.
|
|
9
|
+
*
|
|
10
|
+
* The existing sqlite.integration.test.ts covers the better-sqlite3 (sync) path.
|
|
11
|
+
*/
|
|
12
|
+
import assert from "node:assert/strict";
|
|
13
|
+
import { after, before, test } from "node:test";
|
|
14
|
+
import { asc, eq, isNull } from "drizzle-orm";
|
|
15
|
+
import { drizzle } from "drizzle-orm/d1";
|
|
16
|
+
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
|
17
|
+
import { Miniflare } from "miniflare";
|
|
18
|
+
import { d1AuditLogTable } from "../src/d1/index.js";
|
|
19
|
+
import { withAudit } from "../src/d1-runtime/index.js";
|
|
20
|
+
const users = sqliteTable("users", {
|
|
21
|
+
id: text("id").primaryKey(),
|
|
22
|
+
name: text("name").notNull(),
|
|
23
|
+
email: text("email"),
|
|
24
|
+
});
|
|
25
|
+
const invoices = sqliteTable("invoices", {
|
|
26
|
+
invoice_id: text("invoice_id").primaryKey(),
|
|
27
|
+
amount: integer("amount").notNull(),
|
|
28
|
+
status: text("status").notNull().default("pending"),
|
|
29
|
+
});
|
|
30
|
+
const auditLogs = d1AuditLogTable();
|
|
31
|
+
let mf;
|
|
32
|
+
before(async () => {
|
|
33
|
+
mf = new Miniflare({
|
|
34
|
+
modules: true,
|
|
35
|
+
script: `export default { fetch() { return new Response("ok") } }`,
|
|
36
|
+
d1Databases: { DB: "drizzle-audit-test" },
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
after(async () => {
|
|
40
|
+
await mf.dispose();
|
|
41
|
+
});
|
|
42
|
+
async function setupDb() {
|
|
43
|
+
const d1 = await mf.getD1Database("DB");
|
|
44
|
+
// D1 exec() treats each newline as a statement separator — use prepare().run() for DDL
|
|
45
|
+
for (const sql of [
|
|
46
|
+
"DROP TABLE IF EXISTS audit_logs",
|
|
47
|
+
"DROP TABLE IF EXISTS users",
|
|
48
|
+
"DROP TABLE IF EXISTS invoices",
|
|
49
|
+
"CREATE TABLE audit_logs (id INTEGER PRIMARY KEY AUTOINCREMENT, table_name TEXT NOT NULL, operation TEXT NOT NULL, row_id TEXT, user_id TEXT, old_data TEXT, new_data TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')))",
|
|
50
|
+
"CREATE TABLE users (id TEXT PRIMARY KEY, name TEXT NOT NULL, email TEXT)",
|
|
51
|
+
"CREATE TABLE invoices (invoice_id TEXT PRIMARY KEY, amount INTEGER NOT NULL, status TEXT NOT NULL DEFAULT 'pending')",
|
|
52
|
+
]) {
|
|
53
|
+
await d1.prepare(sql).run();
|
|
54
|
+
}
|
|
55
|
+
return drizzle(d1, { schema: { auditLogs, users, invoices } });
|
|
56
|
+
}
|
|
57
|
+
test("withAudit insert logs audit row with new_data (real D1)", async () => {
|
|
58
|
+
const db = await setupDb();
|
|
59
|
+
const audited = withAudit(db, auditLogs, { userId: "user_1" });
|
|
60
|
+
const row = await audited.insert(users, { id: "u1", name: "Ada", email: "ada@example.com" });
|
|
61
|
+
assert.equal(row.id, "u1");
|
|
62
|
+
assert.equal(row.name, "Ada");
|
|
63
|
+
const logs = await db.select().from(auditLogs).all();
|
|
64
|
+
assert.equal(logs.length, 1);
|
|
65
|
+
assert.equal(logs[0]?.table_name, "users");
|
|
66
|
+
assert.equal(logs[0]?.operation, "INSERT");
|
|
67
|
+
assert.equal(logs[0]?.row_id, "u1");
|
|
68
|
+
assert.equal(logs[0]?.user_id, "user_1");
|
|
69
|
+
assert.equal(logs[0]?.old_data, null);
|
|
70
|
+
assert.deepEqual(JSON.parse(logs[0]?.new_data), {
|
|
71
|
+
id: "u1",
|
|
72
|
+
name: "Ada",
|
|
73
|
+
email: "ada@example.com",
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
test("withAudit update captures old and new data (real D1)", async () => {
|
|
77
|
+
const db = await setupDb();
|
|
78
|
+
await db.insert(users).values({ id: "u1", name: "Ada", email: "ada@example.com" });
|
|
79
|
+
const audited = withAudit(db, auditLogs, { userId: "user_2" });
|
|
80
|
+
const rows = await audited.update(users, eq(users.id, "u1"), { name: "Ada Lovelace" });
|
|
81
|
+
assert.equal(rows.length, 1);
|
|
82
|
+
assert.equal(rows[0]?.name, "Ada Lovelace");
|
|
83
|
+
const logs = await db.select().from(auditLogs).all();
|
|
84
|
+
assert.equal(logs.length, 1);
|
|
85
|
+
assert.equal(logs[0]?.operation, "UPDATE");
|
|
86
|
+
assert.equal(logs[0]?.row_id, "u1");
|
|
87
|
+
assert.equal(logs[0]?.user_id, "user_2");
|
|
88
|
+
const oldData = JSON.parse(logs[0]?.old_data);
|
|
89
|
+
assert.equal(oldData.name, "Ada");
|
|
90
|
+
assert.equal(oldData.email, "ada@example.com");
|
|
91
|
+
const newData = JSON.parse(logs[0]?.new_data);
|
|
92
|
+
assert.equal(newData.name, "Ada Lovelace");
|
|
93
|
+
assert.equal(newData.email, "ada@example.com");
|
|
94
|
+
});
|
|
95
|
+
test("withAudit delete captures old data (real D1)", async () => {
|
|
96
|
+
const db = await setupDb();
|
|
97
|
+
await db.insert(users).values({ id: "u1", name: "Ada" });
|
|
98
|
+
const audited = withAudit(db, auditLogs, { userId: "user_3" });
|
|
99
|
+
const deleted = await audited.delete(users, eq(users.id, "u1"));
|
|
100
|
+
assert.equal(deleted.length, 1);
|
|
101
|
+
assert.equal(deleted[0]?.id, "u1");
|
|
102
|
+
const remaining = await db.select().from(users).all();
|
|
103
|
+
assert.equal(remaining.length, 0);
|
|
104
|
+
const logs = await db.select().from(auditLogs).all();
|
|
105
|
+
assert.equal(logs.length, 1);
|
|
106
|
+
assert.equal(logs[0]?.operation, "DELETE");
|
|
107
|
+
assert.equal(logs[0]?.row_id, "u1");
|
|
108
|
+
assert.equal(logs[0]?.user_id, "user_3");
|
|
109
|
+
assert.deepEqual(JSON.parse(logs[0]?.old_data), {
|
|
110
|
+
id: "u1",
|
|
111
|
+
name: "Ada",
|
|
112
|
+
email: null,
|
|
113
|
+
});
|
|
114
|
+
assert.equal(logs[0]?.new_data, null);
|
|
115
|
+
});
|
|
116
|
+
test("withAudit works with custom primary key column (real D1)", async () => {
|
|
117
|
+
const db = await setupDb();
|
|
118
|
+
const audited = withAudit(db, auditLogs, { userId: "user_1" });
|
|
119
|
+
await audited.insert(invoices, { invoice_id: "inv_1", amount: 100 });
|
|
120
|
+
await audited.update(invoices, eq(invoices.invoice_id, "inv_1"), { amount: 200 });
|
|
121
|
+
await audited.delete(invoices, eq(invoices.invoice_id, "inv_1"));
|
|
122
|
+
const logs = await db.select().from(auditLogs).orderBy(asc(auditLogs.id)).all();
|
|
123
|
+
assert.equal(logs.length, 3);
|
|
124
|
+
assert.equal(logs[0]?.table_name, "invoices");
|
|
125
|
+
assert.equal(logs[0]?.row_id, "inv_1");
|
|
126
|
+
assert.equal(logs[1]?.operation, "UPDATE");
|
|
127
|
+
assert.equal(logs[1]?.row_id, "inv_1");
|
|
128
|
+
assert.equal(JSON.parse(logs[1]?.old_data).amount, 100);
|
|
129
|
+
assert.equal(JSON.parse(logs[1]?.new_data).amount, 200);
|
|
130
|
+
assert.equal(logs[2]?.operation, "DELETE");
|
|
131
|
+
assert.equal(logs[2]?.row_id, "inv_1");
|
|
132
|
+
});
|
|
133
|
+
test("withAudit handles multi-row update (real D1)", async () => {
|
|
134
|
+
const db = await setupDb();
|
|
135
|
+
await db.insert(users).values([
|
|
136
|
+
{ id: "u1", name: "Ada" },
|
|
137
|
+
{ id: "u2", name: "Bob" },
|
|
138
|
+
{ id: "u3", name: "Carol" },
|
|
139
|
+
]);
|
|
140
|
+
const audited = withAudit(db, auditLogs, { userId: "admin" });
|
|
141
|
+
const rows = await audited.update(users, isNull(users.email), { email: "bulk@example.com" });
|
|
142
|
+
assert.equal(rows.length, 3);
|
|
143
|
+
const logs = await db.select().from(auditLogs).orderBy(asc(auditLogs.id)).all();
|
|
144
|
+
assert.equal(logs.length, 3);
|
|
145
|
+
for (const log of logs) {
|
|
146
|
+
assert.equal(log.operation, "UPDATE");
|
|
147
|
+
assert.equal(log.user_id, "admin");
|
|
148
|
+
assert.equal(JSON.parse(log.new_data).email, "bulk@example.com");
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
test("withAudit.db gives access to raw db for non-audited ops (real D1)", async () => {
|
|
152
|
+
const db = await setupDb();
|
|
153
|
+
const audited = withAudit(db, auditLogs, { userId: "user_1" });
|
|
154
|
+
await audited.db.insert(users).values({ id: "u1", name: "Ada" });
|
|
155
|
+
const logs = await db.select().from(auditLogs).all();
|
|
156
|
+
assert.equal(logs.length, 0);
|
|
157
|
+
const rows = await db.select().from(users).all();
|
|
158
|
+
assert.equal(rows.length, 1);
|
|
159
|
+
});
|
|
@@ -122,20 +122,22 @@ test("d1 column-aware triggers capture full row data", () => {
|
|
|
122
122
|
});
|
|
123
123
|
test("d1 workspace_id column and context are stored when enabled", () => {
|
|
124
124
|
const sqlite = new Database(":memory:");
|
|
125
|
-
const auditLogsWithWorkspace = d1AuditLogTable({
|
|
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({
|
|
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" }], {
|
|
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
|
-
}, {
|
|
140
|
+
}, { context: { workspace_id: "ws_1" } });
|
|
139
141
|
const logs = db.select().from(auditLogsWithWorkspace).all();
|
|
140
142
|
assert.equal(logs.length, 1);
|
|
141
143
|
assert.equal(logs[0]?.user_id, "user_1");
|
|
@@ -157,6 +159,71 @@ test("d1 workspace_id column and context are stored when enabled", () => {
|
|
|
157
159
|
sqlite.close();
|
|
158
160
|
}
|
|
159
161
|
});
|
|
162
|
+
test("d1 generic contextColumns populate and stay NULL without context", () => {
|
|
163
|
+
const sqlite = new Database(":memory:");
|
|
164
|
+
const contextColumns = [
|
|
165
|
+
{ column: "workspace_id" },
|
|
166
|
+
{ column: "tenant_id" },
|
|
167
|
+
{ column: "request_id" },
|
|
168
|
+
];
|
|
169
|
+
const auditLogsWithCtx = d1AuditLogTable({ contextColumns });
|
|
170
|
+
const db = drizzle({
|
|
171
|
+
client: sqlite,
|
|
172
|
+
schema: { auditLogs: auditLogsWithCtx, auditContext, users },
|
|
173
|
+
});
|
|
174
|
+
try {
|
|
175
|
+
sqlite.exec(createD1AuditInstallSql({ contextColumns }));
|
|
176
|
+
sqlite.exec(`
|
|
177
|
+
CREATE TABLE users (
|
|
178
|
+
id TEXT PRIMARY KEY,
|
|
179
|
+
name TEXT NOT NULL
|
|
180
|
+
);
|
|
181
|
+
`);
|
|
182
|
+
sqlite.exec(createAttachD1AuditTriggersSql([{ table: "users" }], { contextColumns }));
|
|
183
|
+
withD1AuditedTransaction(db, "user_1", (tx) => {
|
|
184
|
+
tx.insert(users).values({ id: "u1", name: "Alice" }).run();
|
|
185
|
+
tx.update(users).set({ name: "Alice Updated" }).where(eq(users.id, "u1")).run();
|
|
186
|
+
tx.delete(users).where(eq(users.id, "u1")).run();
|
|
187
|
+
}, {
|
|
188
|
+
context: {
|
|
189
|
+
workspace_id: "ws_1",
|
|
190
|
+
tenant_id: "tenant_1",
|
|
191
|
+
request_id: "req_1",
|
|
192
|
+
},
|
|
193
|
+
});
|
|
194
|
+
const logs = db
|
|
195
|
+
.select()
|
|
196
|
+
.from(auditLogsWithCtx)
|
|
197
|
+
.orderBy(asc(auditLogsWithCtx.id))
|
|
198
|
+
.all();
|
|
199
|
+
assert.equal(logs.length, 3);
|
|
200
|
+
for (const log of logs) {
|
|
201
|
+
const row = log;
|
|
202
|
+
assert.equal(row.user_id, "user_1");
|
|
203
|
+
assert.equal(row.workspace_id, "ws_1");
|
|
204
|
+
assert.equal(row.tenant_id, "tenant_1");
|
|
205
|
+
assert.equal(row.request_id, "req_1");
|
|
206
|
+
}
|
|
207
|
+
assert.equal(logs[0]?.operation, "INSERT");
|
|
208
|
+
assert.equal(logs[1]?.operation, "UPDATE");
|
|
209
|
+
assert.equal(logs[2]?.operation, "DELETE");
|
|
210
|
+
// No context: all context columns NULL.
|
|
211
|
+
db.insert(users).values({ id: "u2", name: "No Context" }).run();
|
|
212
|
+
const all = db
|
|
213
|
+
.select()
|
|
214
|
+
.from(auditLogsWithCtx)
|
|
215
|
+
.orderBy(asc(auditLogsWithCtx.id))
|
|
216
|
+
.all();
|
|
217
|
+
const last = all[all.length - 1];
|
|
218
|
+
assert.equal(last.user_id, null);
|
|
219
|
+
assert.equal(last.workspace_id, null);
|
|
220
|
+
assert.equal(last.tenant_id, null);
|
|
221
|
+
assert.equal(last.request_id, null);
|
|
222
|
+
}
|
|
223
|
+
finally {
|
|
224
|
+
sqlite.close();
|
|
225
|
+
}
|
|
226
|
+
});
|
|
160
227
|
test("d1 writes without audit context produce rows with user_id = NULL", () => {
|
|
161
228
|
const { db, sqlite } = setupDb();
|
|
162
229
|
try {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sqlite.integration.test.d.ts","sourceRoot":"","sources":["../../test/sqlite.integration.test.ts"],"names":[],"mappings":""}
|