@willyim/drizzle-audit 0.3.0 → 0.4.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 +50 -5
- package/dist/src/d1-runtime/with-audit.d.ts +12 -9
- package/dist/src/d1-runtime/with-audit.d.ts.map +1 -1
- package/dist/src/d1-runtime/with-audit.js +41 -53
- package/dist/src/postgres/audit-log-schema.d.ts +4 -12
- package/dist/src/postgres/audit-log-schema.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/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} +17 -23
- 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/README.md
CHANGED
|
@@ -43,7 +43,7 @@ await withAuditedTransaction(db, currentUser.id, async (tx) => {
|
|
|
43
43
|
|
|
44
44
|
Postgres triggers capture full row snapshots (`old_data`/`new_data` as JSONB) automatically.
|
|
45
45
|
|
|
46
|
-
### D1/SQLite — App-Level Wrapper
|
|
46
|
+
### D1/SQLite — App-Level Wrapper
|
|
47
47
|
|
|
48
48
|
For D1 and SQLite, the `withAudit` wrapper is the simplest approach. No triggers, no context tables — it intercepts operations in your JS code where you already have the user session.
|
|
49
49
|
|
|
@@ -69,15 +69,17 @@ export const auditLogs = d1AuditLogTable()
|
|
|
69
69
|
// 3. Use in your app (e.g. React Router action)
|
|
70
70
|
const audit = withAudit(db, auditLogs, { userId: session.userId })
|
|
71
71
|
|
|
72
|
-
audit.insert(users, { id: "u1", name: "Ada" })
|
|
73
|
-
audit.update(users, eq(users.id, "u1"), { name: "Ada Lovelace" })
|
|
74
|
-
audit.delete(users, eq(users.id, "u1"))
|
|
72
|
+
await audit.insert(users, { id: "u1", name: "Ada" })
|
|
73
|
+
await audit.update(users, eq(users.id, "u1"), { name: "Ada Lovelace" })
|
|
74
|
+
await audit.delete(users, eq(users.id, "u1"))
|
|
75
75
|
|
|
76
76
|
// Non-audited access is still available
|
|
77
77
|
audit.db.select().from(users).all()
|
|
78
78
|
```
|
|
79
79
|
|
|
80
|
-
The wrapper auto-detects primary keys from your Drizzle table schema
|
|
80
|
+
The wrapper auto-detects primary keys from your Drizzle table schema and captures old/new row data. All methods return Promises and must be awaited.
|
|
81
|
+
|
|
82
|
+
> **Note:** Each operation runs the data write and audit log insert as sequential statements with no transaction wrapper. This is required for D1 compatibility (D1 does not support `BEGIN`/`COMMIT` over the prepared-statement API). In the unlikely event of a failure between the two writes, one may succeed without the other. If you need atomic auditing, use the trigger-based approach instead (see below).
|
|
81
83
|
|
|
82
84
|
### D1/SQLite — Triggers
|
|
83
85
|
|
|
@@ -204,6 +206,12 @@ export function createAuditSql() {
|
|
|
204
206
|
| `setAuditContext(db, actorId, contextKey?, options?)` | Set actor context in current transaction |
|
|
205
207
|
| `withAuditedTransaction(db, actorId, callback, contextKey?, options?)` | Transaction wrapper with actor context |
|
|
206
208
|
|
|
209
|
+
### `@willyim/drizzle-audit`
|
|
210
|
+
|
|
211
|
+
| Export | Description |
|
|
212
|
+
|---|---|
|
|
213
|
+
| `computeDiff(operation, oldData, newData, options?)` | Compute field-by-field diff from old/new row data |
|
|
214
|
+
|
|
207
215
|
### `@willyim/drizzle-audit/d1`
|
|
208
216
|
|
|
209
217
|
| Export | Description |
|
|
@@ -231,6 +239,43 @@ export function createAuditSql() {
|
|
|
231
239
|
- `.delete(table, where)` — Fetch old rows, delete, audit log (per row)
|
|
232
240
|
- `.db` — Raw Drizzle instance for non-audited operations
|
|
233
241
|
|
|
242
|
+
## Computing Diffs
|
|
243
|
+
|
|
244
|
+
The `computeDiff` utility produces field-by-field diffs from the `old_data`/`new_data` captured by audit triggers. It works with any operation type and requires no Drizzle dependency.
|
|
245
|
+
|
|
246
|
+
```ts
|
|
247
|
+
import { computeDiff } from "@willyim/drizzle-audit"
|
|
248
|
+
|
|
249
|
+
const result = computeDiff(
|
|
250
|
+
"UPDATE",
|
|
251
|
+
{ id: "u1", name: "Ada", email: "ada@example.com" },
|
|
252
|
+
{ id: "u1", name: "Ada Lovelace", email: "ada@example.com" },
|
|
253
|
+
)
|
|
254
|
+
// {
|
|
255
|
+
// operation: "UPDATE",
|
|
256
|
+
// changes: [{ field: "name", old: "Ada", new: "Ada Lovelace" }]
|
|
257
|
+
// }
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
For INSERT operations, pass `null` as `oldData` — all fields appear as additions. For DELETE, pass `null` as `newData` — all fields appear as removals.
|
|
261
|
+
|
|
262
|
+
```ts
|
|
263
|
+
// INSERT — every field is new
|
|
264
|
+
computeDiff("INSERT", null, { id: "u1", name: "Ada" })
|
|
265
|
+
|
|
266
|
+
// DELETE — every field is removed
|
|
267
|
+
computeDiff("DELETE", { id: "u1", name: "Ada" }, null)
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
By default, `updated_at` and `created_at` fields are excluded. Override with `ignoreFields`:
|
|
271
|
+
|
|
272
|
+
```ts
|
|
273
|
+
computeDiff("UPDATE", oldData, newData, { ignoreFields: [] }) // include all fields
|
|
274
|
+
computeDiff("UPDATE", oldData, newData, { ignoreFields: ["internal_note"] })
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
Nested objects are compared using deep equality. Fields are returned sorted alphabetically.
|
|
278
|
+
|
|
234
279
|
## Audit Log Schema
|
|
235
280
|
|
|
236
281
|
### Postgres
|
|
@@ -22,33 +22,36 @@ export type DrizzleSQLiteDb = {
|
|
|
22
22
|
update: (table: any) => any;
|
|
23
23
|
delete: (table: any) => any;
|
|
24
24
|
select: (fields?: any) => any;
|
|
25
|
-
transaction: (cb: (tx: any) => any) => any;
|
|
26
25
|
};
|
|
27
26
|
export type AuditedDb<TDb extends DrizzleSQLiteDb> = {
|
|
28
27
|
/**
|
|
29
28
|
* Insert a row and log an INSERT audit event.
|
|
30
29
|
* Returns the inserted row.
|
|
31
30
|
*/
|
|
32
|
-
insert: <T extends SQLiteTable>(table: T, data: InferInsertModel<T>) => InferSelectModel<T
|
|
31
|
+
insert: <T extends SQLiteTable>(table: T, data: InferInsertModel<T>) => Promise<InferSelectModel<T>>;
|
|
33
32
|
/**
|
|
34
33
|
* Update rows matching `where` and log an UPDATE audit event for each affected row.
|
|
35
34
|
* Captures old_data (before) and new_data (after).
|
|
36
35
|
* Returns the updated rows.
|
|
37
36
|
*/
|
|
38
|
-
update: <T extends SQLiteTable>(table: T, where: SQL, data: Partial<InferInsertModel<T>>) => InferSelectModel<T>[]
|
|
37
|
+
update: <T extends SQLiteTable>(table: T, where: SQL, data: Partial<InferInsertModel<T>>) => Promise<InferSelectModel<T>[]>;
|
|
39
38
|
/**
|
|
40
39
|
* Delete rows matching `where` and log a DELETE audit event for each affected row.
|
|
41
40
|
* Captures old_data.
|
|
42
41
|
* Returns the deleted rows.
|
|
43
42
|
*/
|
|
44
|
-
delete: <T extends SQLiteTable>(table: T, where: SQL) => InferSelectModel<T>[]
|
|
43
|
+
delete: <T extends SQLiteTable>(table: T, where: SQL) => Promise<InferSelectModel<T>[]>;
|
|
45
44
|
/** Access the underlying db for non-audited operations. */
|
|
46
45
|
db: TDb;
|
|
47
46
|
};
|
|
48
47
|
/**
|
|
49
48
|
* Creates an audited wrapper around a Drizzle SQLite database.
|
|
50
|
-
* Each insert/update/delete
|
|
51
|
-
*
|
|
49
|
+
* Each insert/update/delete runs the data write and audit log insert sequentially
|
|
50
|
+
* as individual statements (no transaction). This works with both D1 (async) and
|
|
51
|
+
* better-sqlite3 (sync), but writes are best-effort: a failure between the data
|
|
52
|
+
* write and the audit insert can leave one without the other.
|
|
53
|
+
*
|
|
54
|
+
* For atomic auditing on D1, use the trigger-based approach from `@willyim/drizzle-audit/d1`.
|
|
52
55
|
*
|
|
53
56
|
* @param db - A Drizzle SQLite database instance (D1, better-sqlite3, libsql)
|
|
54
57
|
* @param auditTable - The Drizzle table definition for audit_logs
|
|
@@ -62,13 +65,13 @@ export type AuditedDb<TDb extends DrizzleSQLiteDb> = {
|
|
|
62
65
|
* const audited = withAudit(db, auditLogs, { userId: session.userId })
|
|
63
66
|
*
|
|
64
67
|
* // Audited insert
|
|
65
|
-
* audited.insert(users, { id: "u1", name: "Ada" })
|
|
68
|
+
* await audited.insert(users, { id: "u1", name: "Ada" })
|
|
66
69
|
*
|
|
67
70
|
* // Audited update — captures old + new data
|
|
68
|
-
* audited.update(users, eq(users.id, "u1"), { name: "Ada Lovelace" })
|
|
71
|
+
* await audited.update(users, eq(users.id, "u1"), { name: "Ada Lovelace" })
|
|
69
72
|
*
|
|
70
73
|
* // Audited delete — captures deleted data
|
|
71
|
-
* audited.delete(users, eq(users.id, "u1"))
|
|
74
|
+
* await audited.delete(users, eq(users.id, "u1"))
|
|
72
75
|
*
|
|
73
76
|
* // Non-audited access
|
|
74
77
|
* audited.db.select().from(users).all()
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"with-audit.d.ts","sourceRoot":"","sources":["../../../src/d1-runtime/with-audit.ts"],"names":[],"mappings":"AAAA,OAAO,EAGL,KAAK,gBAAgB,EACrB,KAAK,gBAAgB,EACrB,KAAK,GAAG,EACT,MAAM,aAAa,CAAA;AACpB,OAAO,KAAK,EAAgB,WAAW,EAAE,MAAM,yBAAyB,CAAA;AAIxE,MAAM,MAAM,YAAY,GAAG;IACzB,MAAM,EAAE,MAAM,CAAA;IACd,WAAW,CAAC,EAAE,MAAM,CAAA;CACrB,CAAA;AAED,MAAM,MAAM,mBAAmB,GAAG;IAChC,UAAU,EAAE,MAAM,CAAA;IAClB,SAAS,EAAE,MAAM,CAAA;IACjB,MAAM,EAAE,MAAM,GAAG,IAAI,CAAA;IACrB,OAAO,EAAE,MAAM,CAAA;IACf,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAA;IACvB,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAA;IACvB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAA;CACvB,CAAA;AAED;;;GAGG;AACH,MAAM,MAAM,eAAe,GAAG;IAC5B,MAAM,EAAE,CAAC,KAAK,EAAE,GAAG,KAAK,GAAG,CAAA;IAC3B,MAAM,EAAE,CAAC,KAAK,EAAE,GAAG,KAAK,GAAG,CAAA;IAC3B,MAAM,EAAE,CAAC,KAAK,EAAE,GAAG,KAAK,GAAG,CAAA;IAC3B,MAAM,EAAE,CAAC,MAAM,CAAC,EAAE,GAAG,KAAK,GAAG,CAAA;
|
|
1
|
+
{"version":3,"file":"with-audit.d.ts","sourceRoot":"","sources":["../../../src/d1-runtime/with-audit.ts"],"names":[],"mappings":"AAAA,OAAO,EAGL,KAAK,gBAAgB,EACrB,KAAK,gBAAgB,EACrB,KAAK,GAAG,EACT,MAAM,aAAa,CAAA;AACpB,OAAO,KAAK,EAAgB,WAAW,EAAE,MAAM,yBAAyB,CAAA;AAIxE,MAAM,MAAM,YAAY,GAAG;IACzB,MAAM,EAAE,MAAM,CAAA;IACd,WAAW,CAAC,EAAE,MAAM,CAAA;CACrB,CAAA;AAED,MAAM,MAAM,mBAAmB,GAAG;IAChC,UAAU,EAAE,MAAM,CAAA;IAClB,SAAS,EAAE,MAAM,CAAA;IACjB,MAAM,EAAE,MAAM,GAAG,IAAI,CAAA;IACrB,OAAO,EAAE,MAAM,CAAA;IACf,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAA;IACvB,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAA;IACvB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAA;CACvB,CAAA;AAED;;;GAGG;AACH,MAAM,MAAM,eAAe,GAAG;IAC5B,MAAM,EAAE,CAAC,KAAK,EAAE,GAAG,KAAK,GAAG,CAAA;IAC3B,MAAM,EAAE,CAAC,KAAK,EAAE,GAAG,KAAK,GAAG,CAAA;IAC3B,MAAM,EAAE,CAAC,KAAK,EAAE,GAAG,KAAK,GAAG,CAAA;IAC3B,MAAM,EAAE,CAAC,MAAM,CAAC,EAAE,GAAG,KAAK,GAAG,CAAA;CAC9B,CAAA;AAgBD,MAAM,MAAM,SAAS,CAAC,GAAG,SAAS,eAAe,IAAI;IACnD;;;OAGG;IACH,MAAM,EAAE,CAAC,CAAC,SAAS,WAAW,EAC5B,KAAK,EAAE,CAAC,EACR,IAAI,EAAE,gBAAgB,CAAC,CAAC,CAAC,KACtB,OAAO,CAAC,gBAAgB,CAAC,CAAC,CAAC,CAAC,CAAA;IAEjC;;;;OAIG;IACH,MAAM,EAAE,CAAC,CAAC,SAAS,WAAW,EAC5B,KAAK,EAAE,CAAC,EACR,KAAK,EAAE,GAAG,EACV,IAAI,EAAE,OAAO,CAAC,gBAAgB,CAAC,CAAC,CAAC,CAAC,KAC/B,OAAO,CAAC,gBAAgB,CAAC,CAAC,CAAC,EAAE,CAAC,CAAA;IAEnC;;;;OAIG;IACH,MAAM,EAAE,CAAC,CAAC,SAAS,WAAW,EAC5B,KAAK,EAAE,CAAC,EACR,KAAK,EAAE,GAAG,KACP,OAAO,CAAC,gBAAgB,CAAC,CAAC,CAAC,EAAE,CAAC,CAAA;IAEnC,2DAA2D;IAC3D,EAAE,EAAE,GAAG,CAAA;CACR,CAAA;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AACH,wBAAgB,SAAS,CAAC,GAAG,SAAS,eAAe,EACnD,EAAE,EAAE,GAAG,EACP,UAAU,EAAE,WAAW,EACvB,OAAO,EAAE,YAAY,GACpB,SAAS,CAAC,GAAG,CAAC,CA8FhB"}
|
|
@@ -12,8 +12,12 @@ function getRowId(row, pk) {
|
|
|
12
12
|
}
|
|
13
13
|
/**
|
|
14
14
|
* Creates an audited wrapper around a Drizzle SQLite database.
|
|
15
|
-
* Each insert/update/delete
|
|
16
|
-
*
|
|
15
|
+
* Each insert/update/delete runs the data write and audit log insert sequentially
|
|
16
|
+
* as individual statements (no transaction). This works with both D1 (async) and
|
|
17
|
+
* better-sqlite3 (sync), but writes are best-effort: a failure between the data
|
|
18
|
+
* write and the audit insert can leave one without the other.
|
|
19
|
+
*
|
|
20
|
+
* For atomic auditing on D1, use the trigger-based approach from `@willyim/drizzle-audit/d1`.
|
|
17
21
|
*
|
|
18
22
|
* @param db - A Drizzle SQLite database instance (D1, better-sqlite3, libsql)
|
|
19
23
|
* @param auditTable - The Drizzle table definition for audit_logs
|
|
@@ -27,13 +31,13 @@ function getRowId(row, pk) {
|
|
|
27
31
|
* const audited = withAudit(db, auditLogs, { userId: session.userId })
|
|
28
32
|
*
|
|
29
33
|
* // Audited insert
|
|
30
|
-
* audited.insert(users, { id: "u1", name: "Ada" })
|
|
34
|
+
* await audited.insert(users, { id: "u1", name: "Ada" })
|
|
31
35
|
*
|
|
32
36
|
* // Audited update — captures old + new data
|
|
33
|
-
* audited.update(users, eq(users.id, "u1"), { name: "Ada Lovelace" })
|
|
37
|
+
* await audited.update(users, eq(users.id, "u1"), { name: "Ada Lovelace" })
|
|
34
38
|
*
|
|
35
39
|
* // Audited delete — captures deleted data
|
|
36
|
-
* audited.delete(users, eq(users.id, "u1"))
|
|
40
|
+
* await audited.delete(users, eq(users.id, "u1"))
|
|
37
41
|
*
|
|
38
42
|
* // Non-audited access
|
|
39
43
|
* audited.db.select().from(users).all()
|
|
@@ -69,62 +73,46 @@ export function withAudit(db, auditTable, context) {
|
|
|
69
73
|
}
|
|
70
74
|
return {
|
|
71
75
|
db,
|
|
72
|
-
insert(table, data) {
|
|
76
|
+
async insert(table, data) {
|
|
73
77
|
const tableName = getTableName(table);
|
|
74
78
|
const pk = getPrimaryKeyColumn(table);
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
});
|
|
79
|
+
const [row] = await db.insert(table).values(data).returning().all();
|
|
80
|
+
const rowId = getRowId(row, pk);
|
|
81
|
+
await db
|
|
82
|
+
.insert(auditTable)
|
|
83
|
+
.values(buildAuditRow(tableName, "INSERT", rowId, null, row))
|
|
84
|
+
.run();
|
|
85
|
+
return row;
|
|
83
86
|
},
|
|
84
|
-
update(table, where, data) {
|
|
87
|
+
async update(table, where, data) {
|
|
85
88
|
const tableName = getTableName(table);
|
|
86
89
|
const pk = getPrimaryKeyColumn(table);
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
.
|
|
95
|
-
.
|
|
96
|
-
.
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
for (let i = 0; i < newRows.length; i++) {
|
|
100
|
-
const oldRow = oldRows[i] ?? null;
|
|
101
|
-
const newRow = newRows[i];
|
|
102
|
-
const rowId = getRowId(newRow, pk);
|
|
103
|
-
tx.insert(auditTable)
|
|
104
|
-
.values(buildAuditRow(tableName, "UPDATE", rowId, oldRow, newRow))
|
|
105
|
-
.run();
|
|
106
|
-
}
|
|
107
|
-
return newRows;
|
|
108
|
-
});
|
|
90
|
+
const oldRows = await db.select().from(table).where(where).all();
|
|
91
|
+
const newRows = await db.update(table).set(data).where(where).returning().all();
|
|
92
|
+
for (let i = 0; i < newRows.length; i++) {
|
|
93
|
+
const oldRow = oldRows[i] ?? null;
|
|
94
|
+
const newRow = newRows[i];
|
|
95
|
+
const rowId = getRowId(newRow, pk);
|
|
96
|
+
await db
|
|
97
|
+
.insert(auditTable)
|
|
98
|
+
.values(buildAuditRow(tableName, "UPDATE", rowId, oldRow, newRow))
|
|
99
|
+
.run();
|
|
100
|
+
}
|
|
101
|
+
return newRows;
|
|
109
102
|
},
|
|
110
|
-
delete(table, where) {
|
|
103
|
+
async delete(table, where) {
|
|
111
104
|
const tableName = getTableName(table);
|
|
112
105
|
const pk = getPrimaryKeyColumn(table);
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
.
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
.values(buildAuditRow(tableName, "DELETE", rowId, oldRow, null))
|
|
124
|
-
.run();
|
|
125
|
-
}
|
|
126
|
-
return oldRows;
|
|
127
|
-
});
|
|
106
|
+
const oldRows = await db.select().from(table).where(where).all();
|
|
107
|
+
await db.delete(table).where(where).run();
|
|
108
|
+
for (const oldRow of oldRows) {
|
|
109
|
+
const rowId = getRowId(oldRow, pk);
|
|
110
|
+
await db
|
|
111
|
+
.insert(auditTable)
|
|
112
|
+
.values(buildAuditRow(tableName, "DELETE", rowId, oldRow, null))
|
|
113
|
+
.run();
|
|
114
|
+
}
|
|
115
|
+
return oldRows;
|
|
128
116
|
},
|
|
129
117
|
};
|
|
130
118
|
}
|
|
@@ -20,7 +20,6 @@ export declare function pgAuditLogTable(options?: PgAuditLogTableOptions): impor
|
|
|
20
20
|
enumValues: undefined;
|
|
21
21
|
identity: undefined;
|
|
22
22
|
generated: undefined;
|
|
23
|
-
insertType: unknown;
|
|
24
23
|
}>;
|
|
25
24
|
new_data: import("drizzle-orm/pg-core").PgBuildColumn<"audit_logs", import("drizzle-orm/pg-core").PgJsonbBuilder, {
|
|
26
25
|
name: string;
|
|
@@ -36,7 +35,6 @@ export declare function pgAuditLogTable(options?: PgAuditLogTableOptions): impor
|
|
|
36
35
|
enumValues: undefined;
|
|
37
36
|
identity: undefined;
|
|
38
37
|
generated: undefined;
|
|
39
|
-
insertType: unknown;
|
|
40
38
|
}>;
|
|
41
39
|
created_at: import("drizzle-orm/pg-core").PgBuildColumn<"audit_logs", import("drizzle-orm/pg-core").SetNotNull<import("drizzle-orm/pg-core").SetHasDefault<import("drizzle-orm/pg-core").PgTimestampBuilder>>, {
|
|
42
40
|
name: string;
|
|
@@ -52,7 +50,6 @@ export declare function pgAuditLogTable(options?: PgAuditLogTableOptions): impor
|
|
|
52
50
|
enumValues: undefined;
|
|
53
51
|
identity: undefined;
|
|
54
52
|
generated: undefined;
|
|
55
|
-
insertType: Date | undefined;
|
|
56
53
|
}>;
|
|
57
54
|
id: import("drizzle-orm/pg-core").PgBuildColumn<"audit_logs", import("drizzle-orm/pg-core").SetIsPrimaryKey<import("drizzle-orm/pg-core").PgBigSerial53Builder>, {
|
|
58
55
|
name: string;
|
|
@@ -68,9 +65,8 @@ export declare function pgAuditLogTable(options?: PgAuditLogTableOptions): impor
|
|
|
68
65
|
enumValues: undefined;
|
|
69
66
|
identity: undefined;
|
|
70
67
|
generated: undefined;
|
|
71
|
-
insertType: number | undefined;
|
|
72
68
|
}>;
|
|
73
|
-
table_name: import("drizzle-orm/pg-core").PgBuildColumn<"audit_logs", import("drizzle-orm/pg-core").SetNotNull<import("drizzle-orm/pg-core").PgTextBuilder<
|
|
69
|
+
table_name: import("drizzle-orm/pg-core").PgBuildColumn<"audit_logs", import("drizzle-orm/pg-core").SetNotNull<import("drizzle-orm/pg-core").PgTextBuilder<[string, ...string[]]>>, {
|
|
74
70
|
name: string;
|
|
75
71
|
tableName: "audit_logs";
|
|
76
72
|
dataType: "string";
|
|
@@ -84,9 +80,8 @@ export declare function pgAuditLogTable(options?: PgAuditLogTableOptions): impor
|
|
|
84
80
|
enumValues: undefined;
|
|
85
81
|
identity: undefined;
|
|
86
82
|
generated: undefined;
|
|
87
|
-
insertType: string;
|
|
88
83
|
}>;
|
|
89
|
-
operation: import("drizzle-orm/pg-core").PgBuildColumn<"audit_logs", import("drizzle-orm/pg-core").SetNotNull<import("drizzle-orm/pg-core").PgTextBuilder<
|
|
84
|
+
operation: import("drizzle-orm/pg-core").PgBuildColumn<"audit_logs", import("drizzle-orm/pg-core").SetNotNull<import("drizzle-orm/pg-core").PgTextBuilder<[string, ...string[]]>>, {
|
|
90
85
|
name: string;
|
|
91
86
|
tableName: "audit_logs";
|
|
92
87
|
dataType: "string";
|
|
@@ -100,9 +95,8 @@ export declare function pgAuditLogTable(options?: PgAuditLogTableOptions): impor
|
|
|
100
95
|
enumValues: undefined;
|
|
101
96
|
identity: undefined;
|
|
102
97
|
generated: undefined;
|
|
103
|
-
insertType: string;
|
|
104
98
|
}>;
|
|
105
|
-
row_id: import("drizzle-orm/pg-core").PgBuildColumn<"audit_logs", import("drizzle-orm/pg-core").PgTextBuilder<
|
|
99
|
+
row_id: import("drizzle-orm/pg-core").PgBuildColumn<"audit_logs", import("drizzle-orm/pg-core").PgTextBuilder<[string, ...string[]]>, {
|
|
106
100
|
name: string;
|
|
107
101
|
tableName: "audit_logs";
|
|
108
102
|
dataType: "string";
|
|
@@ -116,9 +110,8 @@ export declare function pgAuditLogTable(options?: PgAuditLogTableOptions): impor
|
|
|
116
110
|
enumValues: undefined;
|
|
117
111
|
identity: undefined;
|
|
118
112
|
generated: undefined;
|
|
119
|
-
insertType: string | null | undefined;
|
|
120
113
|
}>;
|
|
121
|
-
user_id: import("drizzle-orm/pg-core").PgBuildColumn<"audit_logs", import("drizzle-orm/pg-core").PgTextBuilder<
|
|
114
|
+
user_id: import("drizzle-orm/pg-core").PgBuildColumn<"audit_logs", import("drizzle-orm/pg-core").PgTextBuilder<[string, ...string[]]>, {
|
|
122
115
|
name: string;
|
|
123
116
|
tableName: "audit_logs";
|
|
124
117
|
dataType: "string";
|
|
@@ -132,7 +125,6 @@ export declare function pgAuditLogTable(options?: PgAuditLogTableOptions): impor
|
|
|
132
125
|
enumValues: undefined;
|
|
133
126
|
identity: undefined;
|
|
134
127
|
generated: undefined;
|
|
135
|
-
insertType: string | null | undefined;
|
|
136
128
|
}>;
|
|
137
129
|
};
|
|
138
130
|
dialect: "pg";
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"audit-log-schema.d.ts","sourceRoot":"","sources":["../../../src/postgres/audit-log-schema.ts"],"names":[],"mappings":"AASA,MAAM,MAAM,sBAAsB,GAAG;IACnC,+GAA+G;IAC/G,iBAAiB,CAAC,EAAE,MAAM,CAAA;CAC3B,CAAA;AAED,wBAAgB,eAAe,CAAC,OAAO,CAAC,EAAE,sBAAsB
|
|
1
|
+
{"version":3,"file":"audit-log-schema.d.ts","sourceRoot":"","sources":["../../../src/postgres/audit-log-schema.ts"],"names":[],"mappings":"AASA,MAAM,MAAM,sBAAsB,GAAG;IACnC,+GAA+G;IAC/G,iBAAiB,CAAC,EAAE,MAAM,CAAA;CAC3B,CAAA;AAED,wBAAgB,eAAe,CAAC,OAAO,CAAC,EAAE,sBAAsB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2B/D"}
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sqlite.integration.test.d.ts","sourceRoot":"","sources":["../../test/sqlite.integration.test.ts"],"names":[],"mappings":""}
|
|
@@ -3,7 +3,7 @@ import test from "node:test";
|
|
|
3
3
|
import Database from "better-sqlite3";
|
|
4
4
|
import { asc, eq, isNull } from "drizzle-orm";
|
|
5
5
|
import { drizzle } from "drizzle-orm/better-sqlite3";
|
|
6
|
-
import { sqliteTable, text
|
|
6
|
+
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
|
7
7
|
import { d1AuditLogTable } from "../src/d1/index.js";
|
|
8
8
|
import { withAudit } from "../src/d1-runtime/index.js";
|
|
9
9
|
const users = sqliteTable("users", {
|
|
@@ -44,11 +44,11 @@ function setupDb() {
|
|
|
44
44
|
`);
|
|
45
45
|
return { db, sqlite };
|
|
46
46
|
}
|
|
47
|
-
test("withAudit insert logs audit row with new_data", () => {
|
|
47
|
+
test("withAudit insert logs audit row with new_data", async () => {
|
|
48
48
|
const { db, sqlite } = setupDb();
|
|
49
49
|
try {
|
|
50
50
|
const audited = withAudit(db, auditLogs, { userId: "user_1" });
|
|
51
|
-
const row = audited.insert(users, { id: "u1", name: "Ada", email: "ada@example.com" });
|
|
51
|
+
const row = await audited.insert(users, { id: "u1", name: "Ada", email: "ada@example.com" });
|
|
52
52
|
assert.equal(row.id, "u1");
|
|
53
53
|
assert.equal(row.name, "Ada");
|
|
54
54
|
const logs = db.select().from(auditLogs).all();
|
|
@@ -68,13 +68,12 @@ test("withAudit insert logs audit row with new_data", () => {
|
|
|
68
68
|
sqlite.close();
|
|
69
69
|
}
|
|
70
70
|
});
|
|
71
|
-
test("withAudit update captures old and new data", () => {
|
|
71
|
+
test("withAudit update captures old and new data", async () => {
|
|
72
72
|
const { db, sqlite } = setupDb();
|
|
73
73
|
try {
|
|
74
|
-
// Seed a row
|
|
75
74
|
db.insert(users).values({ id: "u1", name: "Ada", email: "ada@example.com" }).run();
|
|
76
75
|
const audited = withAudit(db, auditLogs, { userId: "user_2" });
|
|
77
|
-
const rows = audited.update(users, eq(users.id, "u1"), { name: "Ada Lovelace" });
|
|
76
|
+
const rows = await audited.update(users, eq(users.id, "u1"), { name: "Ada Lovelace" });
|
|
78
77
|
assert.equal(rows.length, 1);
|
|
79
78
|
assert.equal(rows[0]?.name, "Ada Lovelace");
|
|
80
79
|
const logs = db.select().from(auditLogs).all();
|
|
@@ -93,18 +92,16 @@ test("withAudit update captures old and new data", () => {
|
|
|
93
92
|
sqlite.close();
|
|
94
93
|
}
|
|
95
94
|
});
|
|
96
|
-
test("withAudit delete captures old data", () => {
|
|
95
|
+
test("withAudit delete captures old data", async () => {
|
|
97
96
|
const { db, sqlite } = setupDb();
|
|
98
97
|
try {
|
|
99
98
|
db.insert(users).values({ id: "u1", name: "Ada" }).run();
|
|
100
99
|
const audited = withAudit(db, auditLogs, { userId: "user_3" });
|
|
101
|
-
const deleted = audited.delete(users, eq(users.id, "u1"));
|
|
100
|
+
const deleted = await audited.delete(users, eq(users.id, "u1"));
|
|
102
101
|
assert.equal(deleted.length, 1);
|
|
103
102
|
assert.equal(deleted[0]?.id, "u1");
|
|
104
|
-
// Verify row is gone
|
|
105
103
|
const remaining = db.select().from(users).all();
|
|
106
104
|
assert.equal(remaining.length, 0);
|
|
107
|
-
// Verify audit log
|
|
108
105
|
const logs = db.select().from(auditLogs).all();
|
|
109
106
|
assert.equal(logs.length, 1);
|
|
110
107
|
assert.equal(logs[0]?.operation, "DELETE");
|
|
@@ -121,13 +118,13 @@ test("withAudit delete captures old data", () => {
|
|
|
121
118
|
sqlite.close();
|
|
122
119
|
}
|
|
123
120
|
});
|
|
124
|
-
test("withAudit works with custom primary key column", () => {
|
|
121
|
+
test("withAudit works with custom primary key column", async () => {
|
|
125
122
|
const { db, sqlite } = setupDb();
|
|
126
123
|
try {
|
|
127
124
|
const audited = withAudit(db, auditLogs, { userId: "user_1" });
|
|
128
|
-
audited.insert(invoices, { invoice_id: "inv_1", amount: 100 });
|
|
129
|
-
audited.update(invoices, eq(invoices.invoice_id, "inv_1"), { amount: 200 });
|
|
130
|
-
audited.delete(invoices, eq(invoices.invoice_id, "inv_1"));
|
|
125
|
+
await audited.insert(invoices, { invoice_id: "inv_1", amount: 100 });
|
|
126
|
+
await audited.update(invoices, eq(invoices.invoice_id, "inv_1"), { amount: 200 });
|
|
127
|
+
await audited.delete(invoices, eq(invoices.invoice_id, "inv_1"));
|
|
131
128
|
const logs = db.select().from(auditLogs).orderBy(asc(auditLogs.id)).all();
|
|
132
129
|
assert.equal(logs.length, 3);
|
|
133
130
|
assert.equal(logs[0]?.table_name, "invoices");
|
|
@@ -143,7 +140,7 @@ test("withAudit works with custom primary key column", () => {
|
|
|
143
140
|
sqlite.close();
|
|
144
141
|
}
|
|
145
142
|
});
|
|
146
|
-
test("withAudit handles multi-row update", () => {
|
|
143
|
+
test("withAudit handles multi-row update", async () => {
|
|
147
144
|
const { db, sqlite } = setupDb();
|
|
148
145
|
try {
|
|
149
146
|
db.insert(users).values([
|
|
@@ -152,23 +149,21 @@ test("withAudit handles multi-row update", () => {
|
|
|
152
149
|
{ id: "u3", name: "Carol" },
|
|
153
150
|
]).run();
|
|
154
151
|
const audited = withAudit(db, auditLogs, { userId: "admin" });
|
|
155
|
-
|
|
156
|
-
const rows = audited.update(users, isNull(users.email), { email: "bulk@example.com" });
|
|
152
|
+
const rows = await audited.update(users, isNull(users.email), { email: "bulk@example.com" });
|
|
157
153
|
assert.equal(rows.length, 3);
|
|
158
154
|
const logs = db.select().from(auditLogs).orderBy(asc(auditLogs.id)).all();
|
|
159
155
|
assert.equal(logs.length, 3);
|
|
160
156
|
for (const log of logs) {
|
|
161
157
|
assert.equal(log.operation, "UPDATE");
|
|
162
158
|
assert.equal(log.user_id, "admin");
|
|
163
|
-
|
|
164
|
-
assert.equal(newData.email, "bulk@example.com");
|
|
159
|
+
assert.equal(JSON.parse(log.new_data).email, "bulk@example.com");
|
|
165
160
|
}
|
|
166
161
|
}
|
|
167
162
|
finally {
|
|
168
163
|
sqlite.close();
|
|
169
164
|
}
|
|
170
165
|
});
|
|
171
|
-
test("withAudit with workspace_id", () => {
|
|
166
|
+
test("withAudit with workspace_id", async () => {
|
|
172
167
|
const sqlite = new Database(":memory:");
|
|
173
168
|
const auditLogsWithWs = d1AuditLogTable({ workspaceIdColumn: "workspace_id" });
|
|
174
169
|
const db = drizzle({ client: sqlite, schema: { auditLogs: auditLogsWithWs, users } });
|
|
@@ -195,7 +190,7 @@ test("withAudit with workspace_id", () => {
|
|
|
195
190
|
userId: "user_1",
|
|
196
191
|
workspaceId: "ws_1",
|
|
197
192
|
});
|
|
198
|
-
audited.insert(users, { id: "u1", name: "Ada" });
|
|
193
|
+
await audited.insert(users, { id: "u1", name: "Ada" });
|
|
199
194
|
const logs = db.select().from(auditLogsWithWs).all();
|
|
200
195
|
assert.equal(logs.length, 1);
|
|
201
196
|
assert.equal(logs[0]?.user_id, "user_1");
|
|
@@ -205,11 +200,10 @@ test("withAudit with workspace_id", () => {
|
|
|
205
200
|
sqlite.close();
|
|
206
201
|
}
|
|
207
202
|
});
|
|
208
|
-
test("withAudit.db gives access to raw db for non-audited ops", () => {
|
|
203
|
+
test("withAudit.db gives access to raw db for non-audited ops", async () => {
|
|
209
204
|
const { db, sqlite } = setupDb();
|
|
210
205
|
try {
|
|
211
206
|
const audited = withAudit(db, auditLogs, { userId: "user_1" });
|
|
212
|
-
// Direct insert — no audit
|
|
213
207
|
audited.db.insert(users).values({ id: "u1", name: "Ada" }).run();
|
|
214
208
|
const logs = db.select().from(auditLogs).all();
|
|
215
209
|
assert.equal(logs.length, 0);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@willyim/drizzle-audit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.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",
|
|
@@ -53,6 +53,7 @@
|
|
|
53
53
|
"devDependencies": {
|
|
54
54
|
"@types/node": "^22.19.7",
|
|
55
55
|
"drizzle-orm": "^1.0.0-beta.15-859cf75",
|
|
56
|
+
"miniflare": "^4.20260410.0",
|
|
56
57
|
"typescript": "^5.9.3"
|
|
57
58
|
},
|
|
58
59
|
"publishConfig": {
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"d1-runtime.integration.test.d.ts","sourceRoot":"","sources":["../../test/d1-runtime.integration.test.ts"],"names":[],"mappings":""}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"postgres.integration.test.d.ts","sourceRoot":"","sources":["../../test/postgres.integration.test.ts"],"names":[],"mappings":""}
|
|
@@ -1,286 +0,0 @@
|
|
|
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
|
-
});
|