@willyim/drizzle-audit 0.1.3
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 +278 -0
- package/dist/src/cli/generate-migration.d.ts +14 -0
- package/dist/src/cli/generate-migration.d.ts.map +1 -0
- package/dist/src/cli/generate-migration.js +118 -0
- package/dist/src/cli/runner.d.ts +2 -0
- package/dist/src/cli/runner.d.ts.map +1 -0
- package/dist/src/cli/runner.js +23 -0
- package/dist/src/d1/audit-log-schema.d.ts +181 -0
- package/dist/src/d1/audit-log-schema.d.ts.map +1 -0
- package/dist/src/d1/audit-log-schema.js +30 -0
- package/dist/src/d1/index.d.ts +7 -0
- package/dist/src/d1/index.d.ts.map +1 -0
- package/dist/src/d1/index.js +3 -0
- package/dist/src/d1/runtime.d.ts +44 -0
- package/dist/src/d1/runtime.d.ts.map +1 -0
- package/dist/src/d1/runtime.js +68 -0
- package/dist/src/d1/sql.d.ts +37 -0
- package/dist/src/d1/sql.d.ts.map +1 -0
- package/dist/src/d1/sql.js +261 -0
- package/dist/src/d1/types.d.ts +22 -0
- package/dist/src/d1/types.d.ts.map +1 -0
- package/dist/src/d1/types.js +1 -0
- package/dist/src/d1-runtime/index.d.ts +3 -0
- package/dist/src/d1-runtime/index.d.ts.map +1 -0
- package/dist/src/d1-runtime/index.js +1 -0
- package/dist/src/d1-runtime/with-audit.d.ts +77 -0
- package/dist/src/d1-runtime/with-audit.d.ts.map +1 -0
- package/dist/src/d1-runtime/with-audit.js +130 -0
- package/dist/src/index.d.ts +4 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +3 -0
- package/dist/src/postgres/audit-log-schema.d.ts +140 -0
- package/dist/src/postgres/audit-log-schema.d.ts.map +1 -0
- package/dist/src/postgres/audit-log-schema.js +25 -0
- package/dist/src/postgres/index.d.ts +6 -0
- package/dist/src/postgres/index.d.ts.map +1 -0
- package/dist/src/postgres/index.js +3 -0
- package/dist/src/postgres/runtime.d.ts +10 -0
- package/dist/src/postgres/runtime.d.ts.map +1 -0
- package/dist/src/postgres/runtime.js +21 -0
- package/dist/src/postgres/sql.d.ts +14 -0
- package/dist/src/postgres/sql.d.ts.map +1 -0
- package/dist/src/postgres/sql.js +190 -0
- package/dist/src/postgres/types.d.ts +22 -0
- package/dist/src/postgres/types.d.ts.map +1 -0
- package/dist/src/postgres/types.js +1 -0
- package/dist/test/d1-runtime.integration.test.d.ts +2 -0
- package/dist/test/d1-runtime.integration.test.d.ts.map +1 -0
- package/dist/test/d1-runtime.integration.test.js +222 -0
- package/dist/test/d1.integration.test.d.ts +2 -0
- package/dist/test/d1.integration.test.d.ts.map +1 -0
- package/dist/test/d1.integration.test.js +223 -0
- package/dist/test/postgres.integration.test.d.ts +2 -0
- package/dist/test/postgres.integration.test.d.ts.map +1 -0
- package/dist/test/postgres.integration.test.js +286 -0
- package/package.json +66 -0
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { PGlite } from "@electric-sql/pglite";
|
|
4
|
+
import { asc, eq } from "drizzle-orm";
|
|
5
|
+
import { drizzle } from "drizzle-orm/pglite";
|
|
6
|
+
import { integer, pgTable, text } from "drizzle-orm/pg-core";
|
|
7
|
+
import { createAttachAuditTriggersSql, createAuditInstallSql, pgAuditLogTable, withAuditedTransaction, } from "../src/postgres/index.js";
|
|
8
|
+
const users = pgTable("users", {
|
|
9
|
+
id: text("id").primaryKey(),
|
|
10
|
+
name: text("name").notNull(),
|
|
11
|
+
});
|
|
12
|
+
const invoices = pgTable("invoices", {
|
|
13
|
+
invoice_id: text("invoice_id").primaryKey(),
|
|
14
|
+
amount: integer("amount").notNull(),
|
|
15
|
+
});
|
|
16
|
+
const auditLogs = pgAuditLogTable();
|
|
17
|
+
test("postgres auditing works end to end", async () => {
|
|
18
|
+
const client = new PGlite();
|
|
19
|
+
const db = drizzle({
|
|
20
|
+
client,
|
|
21
|
+
schema: {
|
|
22
|
+
auditLogs,
|
|
23
|
+
users,
|
|
24
|
+
invoices,
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
try {
|
|
28
|
+
await client.exec(createAuditInstallSql());
|
|
29
|
+
await client.exec(`
|
|
30
|
+
CREATE TABLE users (
|
|
31
|
+
id TEXT PRIMARY KEY,
|
|
32
|
+
name TEXT NOT NULL
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
CREATE TABLE invoices (
|
|
36
|
+
invoice_id TEXT PRIMARY KEY,
|
|
37
|
+
amount INTEGER NOT NULL
|
|
38
|
+
);
|
|
39
|
+
`);
|
|
40
|
+
await client.exec(createAttachAuditTriggersSql([
|
|
41
|
+
{ table: "users" },
|
|
42
|
+
{ table: "invoices", rowIdColumn: "invoice_id" },
|
|
43
|
+
]));
|
|
44
|
+
// Insert without audit context should succeed with user_id = NULL
|
|
45
|
+
await db.insert(users).values({ id: "user_0", name: "No Context" });
|
|
46
|
+
const existingUsers = await db.select().from(users);
|
|
47
|
+
assert.equal(existingUsers.length, 1);
|
|
48
|
+
const noContextLogs = await db.select().from(auditLogs).orderBy(asc(auditLogs.id));
|
|
49
|
+
assert.equal(noContextLogs.length, 1);
|
|
50
|
+
assert.equal(noContextLogs[0]?.user_id, null);
|
|
51
|
+
assert.equal(noContextLogs[0]?.table_name, "users");
|
|
52
|
+
assert.equal(noContextLogs[0]?.operation, "INSERT");
|
|
53
|
+
await withAuditedTransaction(db, "user_123", async (tx) => {
|
|
54
|
+
await tx.insert(users).values({ id: "user_1", name: "Ada" });
|
|
55
|
+
await tx
|
|
56
|
+
.update(users)
|
|
57
|
+
.set({ name: "Ada Lovelace" })
|
|
58
|
+
.where(eq(users.id, "user_1"));
|
|
59
|
+
await tx.insert(invoices).values({ invoice_id: "inv_1", amount: 42 });
|
|
60
|
+
await tx.delete(users).where(eq(users.id, "user_1"));
|
|
61
|
+
});
|
|
62
|
+
const logs = await db
|
|
63
|
+
.select()
|
|
64
|
+
.from(auditLogs)
|
|
65
|
+
.orderBy(asc(auditLogs.id));
|
|
66
|
+
assert.equal(logs.length, 5);
|
|
67
|
+
assert.equal(logs[1]?.table_name, "users");
|
|
68
|
+
assert.equal(logs[1]?.operation, "INSERT");
|
|
69
|
+
assert.equal(logs[1]?.row_id, "user_1");
|
|
70
|
+
assert.equal(logs[1]?.user_id, "user_123");
|
|
71
|
+
assert.deepEqual(logs[1]?.new_data, { id: "user_1", name: "Ada" });
|
|
72
|
+
assert.equal(logs[2]?.table_name, "users");
|
|
73
|
+
assert.equal(logs[2]?.operation, "UPDATE");
|
|
74
|
+
assert.equal(logs[2]?.row_id, "user_1");
|
|
75
|
+
assert.deepEqual(logs[2]?.old_data, { id: "user_1", name: "Ada" });
|
|
76
|
+
assert.deepEqual(logs[2]?.new_data, {
|
|
77
|
+
id: "user_1",
|
|
78
|
+
name: "Ada Lovelace",
|
|
79
|
+
});
|
|
80
|
+
assert.equal(logs[3]?.table_name, "invoices");
|
|
81
|
+
assert.equal(logs[3]?.operation, "INSERT");
|
|
82
|
+
assert.equal(logs[3]?.row_id, "inv_1");
|
|
83
|
+
assert.deepEqual(logs[3]?.new_data, {
|
|
84
|
+
invoice_id: "inv_1",
|
|
85
|
+
amount: 42,
|
|
86
|
+
});
|
|
87
|
+
assert.equal(logs[4]?.table_name, "users");
|
|
88
|
+
assert.equal(logs[4]?.operation, "DELETE");
|
|
89
|
+
assert.equal(logs[4]?.row_id, "user_1");
|
|
90
|
+
assert.deepEqual(logs[4]?.old_data, {
|
|
91
|
+
id: "user_1",
|
|
92
|
+
name: "Ada Lovelace",
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
finally {
|
|
96
|
+
await client.close();
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
test("migration SQL bundle installs and enforces audit context", async () => {
|
|
100
|
+
const client = new PGlite();
|
|
101
|
+
const db = drizzle({
|
|
102
|
+
client,
|
|
103
|
+
schema: {
|
|
104
|
+
auditLogs,
|
|
105
|
+
users,
|
|
106
|
+
invoices,
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
try {
|
|
110
|
+
await client.exec(`
|
|
111
|
+
CREATE TABLE users (
|
|
112
|
+
id TEXT PRIMARY KEY,
|
|
113
|
+
name TEXT NOT NULL
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
CREATE TABLE invoices (
|
|
117
|
+
invoice_id TEXT PRIMARY KEY,
|
|
118
|
+
amount INTEGER NOT NULL
|
|
119
|
+
);
|
|
120
|
+
`);
|
|
121
|
+
const migrationBundle = [
|
|
122
|
+
createAuditInstallSql(),
|
|
123
|
+
createAttachAuditTriggersSql([
|
|
124
|
+
{ table: "users" },
|
|
125
|
+
{ table: "invoices", rowIdColumn: "invoice_id" },
|
|
126
|
+
]),
|
|
127
|
+
].join("\n\n");
|
|
128
|
+
await client.exec(migrationBundle);
|
|
129
|
+
// Insert without audit context should succeed with user_id = NULL
|
|
130
|
+
await db.insert(users).values({ id: "u_no_ctx", name: "No Context" });
|
|
131
|
+
const noCtxLogs = await db.select().from(auditLogs);
|
|
132
|
+
assert.equal(noCtxLogs.length, 1);
|
|
133
|
+
assert.equal(noCtxLogs[0]?.user_id, null);
|
|
134
|
+
assert.equal(noCtxLogs[0]?.operation, "INSERT");
|
|
135
|
+
await withAuditedTransaction(db, "system:test", async (tx) => {
|
|
136
|
+
await tx.insert(users).values({ id: "u", name: "With Context" });
|
|
137
|
+
});
|
|
138
|
+
const logs = await db.select().from(auditLogs).orderBy(asc(auditLogs.id));
|
|
139
|
+
assert.equal(logs.length, 2);
|
|
140
|
+
assert.equal(logs[1]?.table_name, "users");
|
|
141
|
+
assert.equal(logs[1]?.operation, "INSERT");
|
|
142
|
+
assert.equal(logs[1]?.user_id, "system:test");
|
|
143
|
+
}
|
|
144
|
+
finally {
|
|
145
|
+
await client.close();
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
test("writes without audit context produce audit rows with user_id = NULL", async () => {
|
|
149
|
+
const client = new PGlite();
|
|
150
|
+
const db = drizzle({
|
|
151
|
+
client,
|
|
152
|
+
schema: {
|
|
153
|
+
auditLogs,
|
|
154
|
+
users,
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
try {
|
|
158
|
+
await client.exec(createAuditInstallSql());
|
|
159
|
+
await client.exec(`
|
|
160
|
+
CREATE TABLE users (
|
|
161
|
+
id TEXT PRIMARY KEY,
|
|
162
|
+
name TEXT NOT NULL
|
|
163
|
+
);
|
|
164
|
+
`);
|
|
165
|
+
await client.exec(createAttachAuditTriggersSql([{ table: "users" }]));
|
|
166
|
+
// INSERT without audit context
|
|
167
|
+
await db.insert(users).values({ id: "u1", name: "Alice" });
|
|
168
|
+
// UPDATE without audit context
|
|
169
|
+
await db.update(users).set({ name: "Alice Updated" }).where(eq(users.id, "u1"));
|
|
170
|
+
// DELETE without audit context
|
|
171
|
+
await db.delete(users).where(eq(users.id, "u1"));
|
|
172
|
+
const logs = await db.select().from(auditLogs).orderBy(asc(auditLogs.id));
|
|
173
|
+
assert.equal(logs.length, 3);
|
|
174
|
+
assert.equal(logs[0]?.operation, "INSERT");
|
|
175
|
+
assert.equal(logs[0]?.user_id, null);
|
|
176
|
+
assert.equal(logs[0]?.row_id, "u1");
|
|
177
|
+
assert.deepEqual(logs[0]?.new_data, { id: "u1", name: "Alice" });
|
|
178
|
+
assert.equal(logs[1]?.operation, "UPDATE");
|
|
179
|
+
assert.equal(logs[1]?.user_id, null);
|
|
180
|
+
assert.deepEqual(logs[1]?.old_data, { id: "u1", name: "Alice" });
|
|
181
|
+
assert.deepEqual(logs[1]?.new_data, { id: "u1", name: "Alice Updated" });
|
|
182
|
+
assert.equal(logs[2]?.operation, "DELETE");
|
|
183
|
+
assert.equal(logs[2]?.user_id, null);
|
|
184
|
+
assert.deepEqual(logs[2]?.old_data, { id: "u1", name: "Alice Updated" });
|
|
185
|
+
}
|
|
186
|
+
finally {
|
|
187
|
+
await client.close();
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
test("workspace_id column and context are stored when enabled", async () => {
|
|
191
|
+
const client = new PGlite();
|
|
192
|
+
const auditLogsWithWorkspace = pgAuditLogTable({ workspaceIdColumn: "workspace_id" });
|
|
193
|
+
const db = drizzle({
|
|
194
|
+
client,
|
|
195
|
+
schema: {
|
|
196
|
+
auditLogs: auditLogsWithWorkspace,
|
|
197
|
+
users,
|
|
198
|
+
invoices,
|
|
199
|
+
},
|
|
200
|
+
});
|
|
201
|
+
try {
|
|
202
|
+
await client.exec(createAuditInstallSql({ workspaceIdColumn: "workspace_id" }));
|
|
203
|
+
await client.exec(`
|
|
204
|
+
CREATE TABLE users (
|
|
205
|
+
id TEXT PRIMARY KEY,
|
|
206
|
+
name TEXT NOT NULL
|
|
207
|
+
);
|
|
208
|
+
`);
|
|
209
|
+
await client.exec(createAttachAuditTriggersSql([{ table: "users" }]));
|
|
210
|
+
await withAuditedTransaction(db, "user_1", async (tx) => {
|
|
211
|
+
await tx.insert(users).values({ id: "u1", name: "Alice" });
|
|
212
|
+
}, "app.user_id", { workspaceId: "ws_1" });
|
|
213
|
+
const logs = await db.select().from(auditLogsWithWorkspace);
|
|
214
|
+
assert.equal(logs.length, 1);
|
|
215
|
+
assert.equal(logs[0]?.user_id, "user_1");
|
|
216
|
+
assert.equal(logs[0].workspace_id, "ws_1");
|
|
217
|
+
await withAuditedTransaction(db, "user_2", async (tx) => {
|
|
218
|
+
await tx.insert(users).values({ id: "u2", name: "Bob" });
|
|
219
|
+
});
|
|
220
|
+
const logs2 = await db.select().from(auditLogsWithWorkspace).orderBy(asc(auditLogsWithWorkspace.id));
|
|
221
|
+
assert.equal(logs2.length, 2);
|
|
222
|
+
assert.equal(logs2[1]?.user_id, "user_2");
|
|
223
|
+
assert.equal(logs2[1].workspace_id, null);
|
|
224
|
+
}
|
|
225
|
+
finally {
|
|
226
|
+
await client.close();
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
test("custom workspace column name uses matching context key", async () => {
|
|
230
|
+
const client = new PGlite();
|
|
231
|
+
const auditLogsWithTenant = pgAuditLogTable({ workspaceIdColumn: "tenant_id" });
|
|
232
|
+
const db = drizzle({
|
|
233
|
+
client,
|
|
234
|
+
schema: {
|
|
235
|
+
auditLogs: auditLogsWithTenant,
|
|
236
|
+
users,
|
|
237
|
+
},
|
|
238
|
+
});
|
|
239
|
+
try {
|
|
240
|
+
await client.exec(createAuditInstallSql({ workspaceIdColumn: "tenant_id" }));
|
|
241
|
+
await client.exec(`
|
|
242
|
+
CREATE TABLE users (
|
|
243
|
+
id TEXT PRIMARY KEY,
|
|
244
|
+
name TEXT NOT NULL
|
|
245
|
+
);
|
|
246
|
+
`);
|
|
247
|
+
await client.exec(createAttachAuditTriggersSql([{ table: "users" }]));
|
|
248
|
+
// Use workspaceContextKey matching the trigger's "app.tenant_id"
|
|
249
|
+
await withAuditedTransaction(db, "user_1", async (tx) => {
|
|
250
|
+
await tx.insert(users).values({ id: "u1", name: "Alice" });
|
|
251
|
+
}, "app.user_id", { workspaceId: "tenant_abc", workspaceContextKey: "app.tenant_id" });
|
|
252
|
+
const logs = await db.select().from(auditLogsWithTenant);
|
|
253
|
+
assert.equal(logs.length, 1);
|
|
254
|
+
assert.equal(logs[0]?.user_id, "user_1");
|
|
255
|
+
assert.equal(logs[0].tenant_id, "tenant_abc");
|
|
256
|
+
}
|
|
257
|
+
finally {
|
|
258
|
+
await client.close();
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
test("custom context key for user_id works", async () => {
|
|
262
|
+
const client = new PGlite();
|
|
263
|
+
const db = drizzle({
|
|
264
|
+
client,
|
|
265
|
+
schema: { auditLogs, users },
|
|
266
|
+
});
|
|
267
|
+
try {
|
|
268
|
+
await client.exec(createAuditInstallSql({ contextKey: "myapp.actor" }));
|
|
269
|
+
await client.exec(`
|
|
270
|
+
CREATE TABLE users (
|
|
271
|
+
id TEXT PRIMARY KEY,
|
|
272
|
+
name TEXT NOT NULL
|
|
273
|
+
);
|
|
274
|
+
`);
|
|
275
|
+
await client.exec(createAttachAuditTriggersSql([{ table: "users" }], { contextKey: "myapp.actor" }));
|
|
276
|
+
await withAuditedTransaction(db, "custom_user", async (tx) => {
|
|
277
|
+
await tx.insert(users).values({ id: "u1", name: "Alice" });
|
|
278
|
+
}, "myapp.actor");
|
|
279
|
+
const logs = await db.select().from(auditLogs).orderBy(asc(auditLogs.id));
|
|
280
|
+
assert.equal(logs.length, 1);
|
|
281
|
+
assert.equal(logs[0]?.user_id, "custom_user");
|
|
282
|
+
}
|
|
283
|
+
finally {
|
|
284
|
+
await client.close();
|
|
285
|
+
}
|
|
286
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@willyim/drizzle-audit",
|
|
3
|
+
"version": "0.1.3",
|
|
4
|
+
"description": "Lightweight audit logging for Drizzle ORM using database triggers (Postgres + D1/SQLite)",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/src/index.js",
|
|
7
|
+
"types": "./dist/src/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./dist/src/index.js",
|
|
10
|
+
"./postgres": "./dist/src/postgres/index.js",
|
|
11
|
+
"./d1": "./dist/src/d1/index.js",
|
|
12
|
+
"./d1-runtime": "./dist/src/d1-runtime/index.js"
|
|
13
|
+
},
|
|
14
|
+
"bin": {
|
|
15
|
+
"drizzle-audit": "./dist/src/cli/generate-migration.js"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"dist",
|
|
19
|
+
"README.md",
|
|
20
|
+
"LICENSE"
|
|
21
|
+
],
|
|
22
|
+
"scripts": {
|
|
23
|
+
"build": "tsc -p tsconfig.json",
|
|
24
|
+
"typecheck": "tsc --noEmit",
|
|
25
|
+
"test": "npm run build && node --test ./dist/test/**/*.test.js"
|
|
26
|
+
},
|
|
27
|
+
"keywords": [
|
|
28
|
+
"drizzle",
|
|
29
|
+
"drizzle-orm",
|
|
30
|
+
"audit",
|
|
31
|
+
"audit-log",
|
|
32
|
+
"postgres",
|
|
33
|
+
"d1",
|
|
34
|
+
"sqlite",
|
|
35
|
+
"cloudflare",
|
|
36
|
+
"triggers"
|
|
37
|
+
],
|
|
38
|
+
"license": "MIT",
|
|
39
|
+
"repository": {
|
|
40
|
+
"type": "git",
|
|
41
|
+
"url": "https://github.com/wovalle/willy.im",
|
|
42
|
+
"directory": "packages/drizzle_audit"
|
|
43
|
+
},
|
|
44
|
+
"peerDependencies": {
|
|
45
|
+
"drizzle-kit": ">=1.0.0-0",
|
|
46
|
+
"drizzle-orm": ">=1.0.0-0"
|
|
47
|
+
},
|
|
48
|
+
"peerDependenciesMeta": {
|
|
49
|
+
"drizzle-kit": {
|
|
50
|
+
"optional": true
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
"devDependencies": {
|
|
54
|
+
"@types/node": "^22.19.7",
|
|
55
|
+
"drizzle-orm": "^1.0.0-beta.15-859cf75",
|
|
56
|
+
"typescript": "^5.9.3"
|
|
57
|
+
},
|
|
58
|
+
"publishConfig": {
|
|
59
|
+
"access": "public"
|
|
60
|
+
},
|
|
61
|
+
"optionalDependencies": {
|
|
62
|
+
"@electric-sql/pglite": "^0.3.16",
|
|
63
|
+
"@types/better-sqlite3": "^7.6.13",
|
|
64
|
+
"better-sqlite3": "^11.10.0"
|
|
65
|
+
}
|
|
66
|
+
}
|