@willyim/drizzle-audit 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +118 -16
- package/dist/src/context/index.d.ts +42 -0
- package/dist/src/context/index.d.ts.map +1 -0
- package/dist/src/context/index.js +94 -0
- 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 +3 -2
- package/dist/src/d1-runtime/with-audit.d.ts.map +1 -1
- package/dist/src/d1-runtime/with-audit.js +12 -7
- 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 +3 -2
- 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/context.test.d.ts +2 -0
- package/dist/test/context.test.d.ts.map +1 -0
- package/dist/test/context.test.js +98 -0
- package/dist/test/d1.integration.test.js +71 -4
- package/dist/test/sqlite.integration.test.js +65 -2
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -120,50 +120,119 @@ sqlite.exec(createAttachD1AuditTriggersSqlWithColumns([
|
|
|
120
120
|
]))
|
|
121
121
|
```
|
|
122
122
|
|
|
123
|
-
##
|
|
123
|
+
## Context Columns
|
|
124
124
|
|
|
125
|
-
|
|
125
|
+
Beyond `user_id`, you can declare arbitrary extra context columns (e.g.
|
|
126
|
+
`workspace_id`, `tenant_id`, `request_id`). Each is an extra `TEXT` column on
|
|
127
|
+
`audit_logs`, populated at write-time from a named runtime context value (a
|
|
128
|
+
Postgres session GUC, or a `_audit_context` KV row in D1).
|
|
129
|
+
|
|
130
|
+
```ts
|
|
131
|
+
type AuditContextColumn = {
|
|
132
|
+
column: string // audit table column (TEXT, nullable)
|
|
133
|
+
sessionKey?: string // Postgres GUC the trigger reads — default `app.${column}`
|
|
134
|
+
// D1: the _audit_context key — default `${column}`
|
|
135
|
+
index?: boolean // create an index on the column — default true
|
|
136
|
+
}
|
|
137
|
+
```
|
|
126
138
|
|
|
127
139
|
### Postgres
|
|
128
140
|
|
|
129
141
|
```ts
|
|
130
|
-
|
|
131
|
-
createAuditInstallSql({ workspaceIdColumn: "workspace_id" })
|
|
132
|
-
export const auditLogs = pgAuditLogTable({ workspaceIdColumn: "workspace_id" })
|
|
142
|
+
const contextColumns = [{ column: "workspace_id" }, { column: "tenant_id" }]
|
|
133
143
|
|
|
134
|
-
|
|
144
|
+
createAuditInstallSql({ contextColumns })
|
|
145
|
+
export const auditLogs = pgAuditLogTable({ contextColumns })
|
|
146
|
+
|
|
147
|
+
// Pass context at runtime (GUC name → value)
|
|
135
148
|
await withAuditedTransaction(
|
|
136
149
|
db, userId, async (tx) => { /* ... */ },
|
|
137
150
|
"app.user_id",
|
|
138
|
-
{
|
|
151
|
+
{ context: { "app.workspace_id": "ws_1", "app.tenant_id": "t_1" } },
|
|
139
152
|
)
|
|
140
153
|
```
|
|
141
154
|
|
|
142
|
-
To add
|
|
155
|
+
To add context columns to an existing install, use
|
|
156
|
+
`createAuditAddContextColumnsSql({ contextColumns })` — it adds each column and
|
|
157
|
+
regenerates the trigger over the full set. Pass the complete list of columns the
|
|
158
|
+
table should have.
|
|
143
159
|
|
|
144
160
|
### D1 Runtime
|
|
145
161
|
|
|
146
162
|
```ts
|
|
147
163
|
const audit = withAudit(db, auditLogs, {
|
|
148
164
|
userId: "user_1",
|
|
149
|
-
|
|
165
|
+
context: { workspace_id: "ws_1", tenant_id: "t_1" },
|
|
150
166
|
})
|
|
151
167
|
```
|
|
152
168
|
|
|
153
169
|
### D1 Triggers
|
|
154
170
|
|
|
155
171
|
```ts
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
)
|
|
172
|
+
const contextColumns = [{ column: "workspace_id" }, { column: "tenant_id" }]
|
|
173
|
+
|
|
174
|
+
createD1AuditInstallSql({ contextColumns })
|
|
175
|
+
createAttachD1AuditTriggersSql([{ table: "users" }], { contextColumns })
|
|
161
176
|
|
|
162
177
|
withD1AuditedTransaction(db, "user_1", (tx) => { /* ... */ }, {
|
|
163
|
-
|
|
178
|
+
context: { workspace_id: "ws_1", tenant_id: "t_1" },
|
|
164
179
|
})
|
|
165
180
|
```
|
|
166
181
|
|
|
182
|
+
## Ambient Context (AsyncLocalStorage)
|
|
183
|
+
|
|
184
|
+
The explicit `withAuditedTransaction(db, actorId, cb)` is the cross-runtime floor —
|
|
185
|
+
it works everywhere and you pass the actor by hand. The opt-in
|
|
186
|
+
`@willyim/drizzle-audit/context` export adds an *ambient* layer on top: establish
|
|
187
|
+
"who is acting" **once** at an entry boundary (a request, a job, an engine tick),
|
|
188
|
+
then open-or-reuse a single audited transaction anywhere below — the actor is read
|
|
189
|
+
from the ambient store, never threaded through call signatures.
|
|
190
|
+
|
|
191
|
+
```ts
|
|
192
|
+
import {
|
|
193
|
+
runWithAuditContext,
|
|
194
|
+
ensureAuditedTx,
|
|
195
|
+
currentAudit,
|
|
196
|
+
} from "@willyim/drizzle-audit/context"
|
|
197
|
+
|
|
198
|
+
// 1. Boundary: set the ambient actor for the unit of work. Pass a thunk to
|
|
199
|
+
// resolve it LAZILY — it only runs if a write actually happens, so read-only
|
|
200
|
+
// requests never pay for it (e.g. resolving a session).
|
|
201
|
+
app.use((req, next) =>
|
|
202
|
+
runWithAuditContext(
|
|
203
|
+
async () => ({
|
|
204
|
+
actorId: (await getSession(req)).userId,
|
|
205
|
+
context: { "app.workspace_id": req.workspaceId },
|
|
206
|
+
}),
|
|
207
|
+
next,
|
|
208
|
+
),
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
// 2. Anywhere below: open-or-reuse ONE audited tx. Reads don't call this, so
|
|
212
|
+
// they never open a transaction. Reentrant — nested calls share the tx.
|
|
213
|
+
async function archive(db, id) {
|
|
214
|
+
await ensureAuditedTx(db, async (tx) => {
|
|
215
|
+
await tx.update(items).set({ archived: true }).where(eq(items.id, id))
|
|
216
|
+
})
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// 3. Read the actor for a domain column (inside the callback, where it's resolved).
|
|
220
|
+
async function assign(db, id, to) {
|
|
221
|
+
await ensureAuditedTx(db, async (tx) => {
|
|
222
|
+
await tx.insert(assignments).values({ id, to, assignedBy: currentAudit().actorId })
|
|
223
|
+
})
|
|
224
|
+
}
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
**Runtime support.** This uses *only* `AsyncLocalStorage` from `node:async_hooks`
|
|
228
|
+
(not `createHook`/`executionAsyncId`/…), which is the subset Cloudflare Workers
|
|
229
|
+
implements. Works on Node, Bun, and Workers — Workers needs the `nodejs_compat`
|
|
230
|
+
flag and a recent compatibility date. If a target lacks ALS, the import throws
|
|
231
|
+
loudly; fall back to the explicit `withAuditedTransaction`.
|
|
232
|
+
|
|
233
|
+
**Guardrail.** `ensureAuditedTx` (and `currentAudit`) throw if there is no ambient
|
|
234
|
+
context — a missed boundary becomes a loud failure instead of an unattributed write.
|
|
235
|
+
|
|
167
236
|
## CLI
|
|
168
237
|
|
|
169
238
|
Generate a Drizzle migration with audit SQL appended:
|
|
@@ -202,10 +271,22 @@ export function createAuditSql() {
|
|
|
202
271
|
| `createAuditInstallSql(options?)` | SQL to create the audit table, indexes, and trigger function |
|
|
203
272
|
| `createAttachAuditTriggerSql(target, options?)` | SQL to attach audit trigger to one table |
|
|
204
273
|
| `createAttachAuditTriggersSql(targets, options?)` | Same, for multiple tables |
|
|
205
|
-
| `
|
|
274
|
+
| `createAuditAddContextColumnsSql(options)` | SQL to add context columns + regenerate trigger on existing install |
|
|
206
275
|
| `setAuditContext(db, actorId, contextKey?, options?)` | Set actor context in current transaction |
|
|
207
276
|
| `withAuditedTransaction(db, actorId, callback, contextKey?, options?)` | Transaction wrapper with actor context |
|
|
208
277
|
|
|
278
|
+
### `@willyim/drizzle-audit/context`
|
|
279
|
+
|
|
280
|
+
Opt-in ambient layer (AsyncLocalStorage). See [Ambient Context](#ambient-context-asynclocalstorage).
|
|
281
|
+
|
|
282
|
+
| Export | Description |
|
|
283
|
+
|---|---|
|
|
284
|
+
| `runWithAuditContext(audit, fn)` | Set the ambient actor for `fn` (`audit` is an `AuditContext` or a lazy thunk) |
|
|
285
|
+
| `ensureAuditedTx(db, run, contextKey?)` | Open-or-reuse one audited tx; actor read from the ambient context |
|
|
286
|
+
| `currentAudit()` | The resolved ambient `AuditContext` (throws if absent/unresolved) |
|
|
287
|
+
| `maybeCurrentAudit()` | The resolved ambient `AuditContext`, or `null` |
|
|
288
|
+
| `hasAuditContext()` | Whether an ambient context is in scope |
|
|
289
|
+
|
|
209
290
|
### `@willyim/drizzle-audit`
|
|
210
291
|
|
|
211
292
|
| Export | Description |
|
|
@@ -318,6 +399,27 @@ CREATE TABLE audit_logs (
|
|
|
318
399
|
| **Bypass risk** | Low (DB-level) | Medium (must use wrapper) | Low (DB-level) |
|
|
319
400
|
| **Best for** | Postgres apps | D1/Cloudflare Workers | SQLite apps needing DB-level guarantees |
|
|
320
401
|
|
|
402
|
+
## Migrating from 0.2.x → 0.3.0
|
|
403
|
+
|
|
404
|
+
The `workspace_id`-specific options were removed in favor of the generic
|
|
405
|
+
[Context Columns](#context-columns) API. Mechanical replacements:
|
|
406
|
+
|
|
407
|
+
| Removed | Replacement |
|
|
408
|
+
|---|---|
|
|
409
|
+
| `pgAuditLogTable({ workspaceIdColumn: "workspace_id" })` | `pgAuditLogTable({ contextColumns: [{ column: "workspace_id" }] })` |
|
|
410
|
+
| `d1AuditLogTable({ workspaceIdColumn: "workspace_id" })` | `d1AuditLogTable({ contextColumns: [{ column: "workspace_id" }] })` |
|
|
411
|
+
| `createAuditInstallSql({ workspaceIdColumn: "workspace_id" })` | `createAuditInstallSql({ contextColumns: [{ column: "workspace_id" }] })` |
|
|
412
|
+
| `createD1AuditInstallSql({ workspaceIdColumn })` | `createD1AuditInstallSql({ contextColumns: [{ column }] })` |
|
|
413
|
+
| `createAttachD1AuditTriggersSql(targets, { workspaceIdColumn })` | `createAttachD1AuditTriggersSql(targets, { contextColumns: [{ column }] })` |
|
|
414
|
+
| `createAuditAddWorkspaceColumnSql(options)` | `createAuditAddContextColumnsSql({ contextColumns: [...] })` |
|
|
415
|
+
| Postgres runtime `{ workspaceId: v }` | `{ context: { "app.workspace_id": v } }` |
|
|
416
|
+
| Postgres runtime `{ workspaceId: v, workspaceContextKey: "app.tenant_id" }` | `{ context: { "app.tenant_id": v } }` |
|
|
417
|
+
| D1 runtime `{ workspaceId: v }` | `{ context: { workspace_id: v } }` |
|
|
418
|
+
| `withAudit(db, table, { userId, workspaceId: v })` | `withAudit(db, table, { userId, context: { workspace_id: v } })` |
|
|
419
|
+
|
|
420
|
+
The on-disk column name (`workspace_id`) is unchanged, so no data migration is
|
|
421
|
+
needed — only call sites change.
|
|
422
|
+
|
|
321
423
|
## License
|
|
322
424
|
|
|
323
425
|
MIT
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { AuditSqlExecutor, AuditTransactionCapable } from "../postgres/types.js";
|
|
2
|
+
/** Opaque to the library: an actor string + the GUC map written for the tx. */
|
|
3
|
+
export type AuditContext = {
|
|
4
|
+
/** Value written to the actor GUC (default `app.user_id`). */
|
|
5
|
+
actorId: string;
|
|
6
|
+
/** Extra session GUCs (full GUC name → value), e.g. `app.workspace_id`. */
|
|
7
|
+
context?: Record<string, string>;
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* How the ambient actor is resolved. Pass a plain `AuditContext` to resolve it
|
|
11
|
+
* eagerly, or a thunk to resolve it LAZILY — the thunk runs only when the first
|
|
12
|
+
* audited write actually happens, so read-only units of work never pay for it
|
|
13
|
+
* (e.g. resolving a session on a request that only reads).
|
|
14
|
+
*/
|
|
15
|
+
export type AuditContextInput = AuditContext | (() => AuditContext | Promise<AuditContext>);
|
|
16
|
+
/**
|
|
17
|
+
* Establish the ambient audit actor for the duration of `fn`. No transaction is
|
|
18
|
+
* opened here — `ensureAuditedTx` opens one lazily on the first write below.
|
|
19
|
+
*/
|
|
20
|
+
export declare function runWithAuditContext<T>(audit: AuditContextInput, fn: () => T): T;
|
|
21
|
+
/** The ambient audit context if one is set AND already resolved, else null. */
|
|
22
|
+
export declare function maybeCurrentAudit(): AuditContext | null;
|
|
23
|
+
/**
|
|
24
|
+
* The ambient audit context. Throws if no context is set, or if it is set but
|
|
25
|
+
* not yet resolved (a lazy resolver that has not run). It is always resolved
|
|
26
|
+
* inside an `ensureAuditedTx` callback, which is the intended place to read it
|
|
27
|
+
* (e.g. to stamp a domain "createdBy" column from the actor).
|
|
28
|
+
*/
|
|
29
|
+
export declare function currentAudit(): AuditContext;
|
|
30
|
+
/** True if an ambient audit context is in scope (resolved or not). */
|
|
31
|
+
export declare function hasAuditContext(): boolean;
|
|
32
|
+
/**
|
|
33
|
+
* THE primitive: ensure this unit of work runs inside ONE audited transaction,
|
|
34
|
+
* reusing an already-open one if present (reentrant). The actor is read from the
|
|
35
|
+
* ambient context (resolving it on first use). Reads should never call this, so
|
|
36
|
+
* they never open a transaction.
|
|
37
|
+
*
|
|
38
|
+
* Throws if called with no ambient audit context — the guardrail that turns a
|
|
39
|
+
* missed boundary into a loud failure instead of an unattributed write.
|
|
40
|
+
*/
|
|
41
|
+
export declare function ensureAuditedTx<TTransaction extends AuditSqlExecutor, TResult>(db: AuditTransactionCapable<TTransaction>, run: (tx: TTransaction) => Promise<TResult> | TResult, contextKey?: string): Promise<TResult>;
|
|
42
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/context/index.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EACV,gBAAgB,EAChB,uBAAuB,EACxB,MAAM,sBAAsB,CAAA;AA+B7B,+EAA+E;AAC/E,MAAM,MAAM,YAAY,GAAG;IACzB,8DAA8D;IAC9D,OAAO,EAAE,MAAM,CAAA;IACf,2EAA2E;IAC3E,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CACjC,CAAA;AAED;;;;;GAKG;AACH,MAAM,MAAM,iBAAiB,GACzB,YAAY,GACZ,CAAC,MAAM,YAAY,GAAG,OAAO,CAAC,YAAY,CAAC,CAAC,CAAA;AAYhD;;;GAGG;AACH,wBAAgB,mBAAmB,CAAC,CAAC,EACnC,KAAK,EAAE,iBAAiB,EACxB,EAAE,EAAE,MAAM,CAAC,GACV,CAAC,CAIH;AAED,+EAA+E;AAC/E,wBAAgB,iBAAiB,IAAI,YAAY,GAAG,IAAI,CAEvD;AAED;;;;;GAKG;AACH,wBAAgB,YAAY,IAAI,YAAY,CAc3C;AAED,sEAAsE;AACtE,wBAAgB,eAAe,IAAI,OAAO,CAEzC;AAED;;;;;;;;GAQG;AACH,wBAAsB,eAAe,CACnC,YAAY,SAAS,gBAAgB,EACrC,OAAO,EAEP,EAAE,EAAE,uBAAuB,CAAC,YAAY,CAAC,EACzC,GAAG,EAAE,CAAC,EAAE,EAAE,YAAY,KAAK,OAAO,CAAC,OAAO,CAAC,GAAG,OAAO,EACrD,UAAU,SAAgB,GACzB,OAAO,CAAC,OAAO,CAAC,CAyBlB"}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import { setAuditContext } from "../postgres/runtime.js";
|
|
3
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
4
|
+
// Ambient audit context (opt-in).
|
|
5
|
+
//
|
|
6
|
+
// The explicit `withAuditedTransaction(db, actorId, cb)` from the main entry is
|
|
7
|
+
// the cross-runtime floor: it works everywhere and takes the actor by hand. This
|
|
8
|
+
// module adds an *ambient* layer on top of it, built on `AsyncLocalStorage`:
|
|
9
|
+
//
|
|
10
|
+
// - establish "who is acting" ONCE at an entry boundary (a request, a job, an
|
|
11
|
+
// engine tick) with `runWithAuditContext`, then
|
|
12
|
+
// - open-or-reuse a single audited transaction anywhere below with
|
|
13
|
+
// `ensureAuditedTx` — the actor is read from the ambient store, never
|
|
14
|
+
// threaded through call signatures.
|
|
15
|
+
//
|
|
16
|
+
// Runtime support: this uses ONLY `AsyncLocalStorage` from `node:async_hooks`
|
|
17
|
+
// (not `createHook`/`executionAsyncId`/…), which is the subset Cloudflare
|
|
18
|
+
// Workers implements. It works on Node, Bun, and Workers (Workers needs the
|
|
19
|
+
// `nodejs_compat` flag + a recent compatibility date). If a target lacks ALS,
|
|
20
|
+
// fall back to the explicit `withAuditedTransaction` path.
|
|
21
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
22
|
+
if (typeof AsyncLocalStorage === "undefined") {
|
|
23
|
+
throw new Error("@willyim/drizzle-audit/context requires AsyncLocalStorage from node:async_hooks. " +
|
|
24
|
+
"On Cloudflare Workers enable the `nodejs_compat` flag (with a recent compatibility " +
|
|
25
|
+
"date). On other runtimes use the explicit withAuditedTransaction(db, actorId, cb) " +
|
|
26
|
+
"from @willyim/drizzle-audit/postgres instead.");
|
|
27
|
+
}
|
|
28
|
+
const als = new AsyncLocalStorage();
|
|
29
|
+
/**
|
|
30
|
+
* Establish the ambient audit actor for the duration of `fn`. No transaction is
|
|
31
|
+
* opened here — `ensureAuditedTx` opens one lazily on the first write below.
|
|
32
|
+
*/
|
|
33
|
+
export function runWithAuditContext(audit, fn) {
|
|
34
|
+
const resolve = typeof audit === "function" ? audit : () => audit;
|
|
35
|
+
const resolved = typeof audit === "function" ? null : audit;
|
|
36
|
+
return als.run({ resolve, resolved, tx: null }, fn);
|
|
37
|
+
}
|
|
38
|
+
/** The ambient audit context if one is set AND already resolved, else null. */
|
|
39
|
+
export function maybeCurrentAudit() {
|
|
40
|
+
return als.getStore()?.resolved ?? null;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* The ambient audit context. Throws if no context is set, or if it is set but
|
|
44
|
+
* not yet resolved (a lazy resolver that has not run). It is always resolved
|
|
45
|
+
* inside an `ensureAuditedTx` callback, which is the intended place to read it
|
|
46
|
+
* (e.g. to stamp a domain "createdBy" column from the actor).
|
|
47
|
+
*/
|
|
48
|
+
export function currentAudit() {
|
|
49
|
+
const cell = als.getStore();
|
|
50
|
+
if (!cell) {
|
|
51
|
+
throw new Error("no ambient audit context — wrap the unit of work in runWithAuditContext(...)");
|
|
52
|
+
}
|
|
53
|
+
if (!cell.resolved) {
|
|
54
|
+
throw new Error("ambient audit context not resolved yet — read currentAudit() inside an " +
|
|
55
|
+
"ensureAuditedTx callback, or pass an eager AuditContext to runWithAuditContext");
|
|
56
|
+
}
|
|
57
|
+
return cell.resolved;
|
|
58
|
+
}
|
|
59
|
+
/** True if an ambient audit context is in scope (resolved or not). */
|
|
60
|
+
export function hasAuditContext() {
|
|
61
|
+
return als.getStore() !== undefined;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* THE primitive: ensure this unit of work runs inside ONE audited transaction,
|
|
65
|
+
* reusing an already-open one if present (reentrant). The actor is read from the
|
|
66
|
+
* ambient context (resolving it on first use). Reads should never call this, so
|
|
67
|
+
* they never open a transaction.
|
|
68
|
+
*
|
|
69
|
+
* Throws if called with no ambient audit context — the guardrail that turns a
|
|
70
|
+
* missed boundary into a loud failure instead of an unattributed write.
|
|
71
|
+
*/
|
|
72
|
+
export async function ensureAuditedTx(db, run, contextKey = "app.user_id") {
|
|
73
|
+
const cell = als.getStore();
|
|
74
|
+
if (!cell) {
|
|
75
|
+
throw new Error("audited write outside an audit context — wrap the unit of work in " +
|
|
76
|
+
"runWithAuditContext(...) (or use the explicit withAuditedTransaction)");
|
|
77
|
+
}
|
|
78
|
+
// Reentrant: a tx is already open in this context — reuse it.
|
|
79
|
+
if (cell.tx)
|
|
80
|
+
return run(cell.tx);
|
|
81
|
+
const audit = cell.resolved ?? (cell.resolved = await cell.resolve());
|
|
82
|
+
return db.transaction(async (tx) => {
|
|
83
|
+
await setAuditContext(tx, audit.actorId, contextKey, {
|
|
84
|
+
context: audit.context,
|
|
85
|
+
});
|
|
86
|
+
cell.tx = tx;
|
|
87
|
+
try {
|
|
88
|
+
return await run(tx);
|
|
89
|
+
}
|
|
90
|
+
finally {
|
|
91
|
+
cell.tx = null;
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import type { AuditContextColumn } from "./types.js";
|
|
1
2
|
export type D1AuditLogTableOptions = {
|
|
2
|
-
/**
|
|
3
|
-
|
|
3
|
+
/** Extra context columns to include in the table definition, matching the install. */
|
|
4
|
+
contextColumns?: AuditContextColumn[];
|
|
4
5
|
};
|
|
5
6
|
export declare function d1AuditLogTable(options?: D1AuditLogTableOptions): import("drizzle-orm/sqlite-core").SQLiteTableWithColumns<{
|
|
6
7
|
name: "audit_logs";
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"audit-log-schema.d.ts","sourceRoot":"","sources":["../../../src/d1/audit-log-schema.ts"],"names":[],"mappings":"AAOA,MAAM,MAAM,sBAAsB,GAAG;IACnC,
|
|
1
|
+
{"version":3,"file":"audit-log-schema.d.ts","sourceRoot":"","sources":["../../../src/d1/audit-log-schema.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAA;AAEpD,MAAM,MAAM,sBAAsB,GAAG;IACnC,sFAAsF;IACtF,cAAc,CAAC,EAAE,kBAAkB,EAAE,CAAA;CACtC,CAAA;AAiBD,wBAAgB,eAAe,CAAC,OAAO,CAAC,EAAE,sBAAsB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuB/D;AAED,wBAAgB,mBAAmB,CAAC,OAAO,CAAC,EAAE;IAAE,YAAY,CAAC,EAAE,MAAM,CAAA;CAAE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAMtE"}
|
|
@@ -1,15 +1,25 @@
|
|
|
1
1
|
import { index, integer, sqliteTable, text, } from "drizzle-orm/sqlite-core";
|
|
2
|
+
function resolveColumns(options) {
|
|
3
|
+
const columns = [];
|
|
4
|
+
const seen = new Set();
|
|
5
|
+
for (const entry of options?.contextColumns ?? []) {
|
|
6
|
+
const column = entry.column?.trim();
|
|
7
|
+
if (column && !seen.has(column)) {
|
|
8
|
+
seen.add(column);
|
|
9
|
+
columns.push(column);
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
return columns;
|
|
13
|
+
}
|
|
2
14
|
export function d1AuditLogTable(options) {
|
|
3
|
-
const
|
|
15
|
+
const contextColumns = resolveColumns(options);
|
|
4
16
|
const columns = {
|
|
5
17
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
|
6
18
|
table_name: text("table_name").notNull(),
|
|
7
19
|
operation: text("operation").notNull(),
|
|
8
20
|
row_id: text("row_id"),
|
|
9
21
|
user_id: text("user_id"),
|
|
10
|
-
...(
|
|
11
|
-
? { [workspaceIdColumn]: text(workspaceIdColumn) }
|
|
12
|
-
: {}),
|
|
22
|
+
...Object.fromEntries(contextColumns.map((c) => [c, text(c)])),
|
|
13
23
|
old_data: text("old_data"),
|
|
14
24
|
new_data: text("new_data"),
|
|
15
25
|
created_at: text("created_at").notNull().default("(datetime('now'))"),
|
package/dist/src/d1/index.d.ts
CHANGED
|
@@ -3,5 +3,6 @@ export type { D1AuditLogTableOptions } from "./audit-log-schema.js";
|
|
|
3
3
|
export { createAttachD1AuditTriggerSql, createAttachD1AuditTriggersSql, createAttachD1AuditTriggerSqlWithColumns, createAttachD1AuditTriggersSqlWithColumns, createD1AuditInstallSql, } from "./sql.js";
|
|
4
4
|
export type { D1AuditTriggerTargetWithColumns } from "./sql.js";
|
|
5
5
|
export { clearD1AuditContext, setD1AuditContext, withD1AuditedTransaction, } from "./runtime.js";
|
|
6
|
-
export type {
|
|
6
|
+
export type { D1AuditContextOptions } from "./runtime.js";
|
|
7
|
+
export type { AuditContextColumn, D1AuditInstallOptions, D1AuditSqlExecutor, D1AuditTriggerTarget, } from "./types.js";
|
|
7
8
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/d1/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,mBAAmB,EAAE,MAAM,uBAAuB,CAAA;AAC5E,YAAY,EAAE,sBAAsB,EAAE,MAAM,uBAAuB,CAAA;AACnE,OAAO,EACL,6BAA6B,EAC7B,8BAA8B,EAC9B,wCAAwC,EACxC,yCAAyC,EACzC,uBAAuB,GACxB,MAAM,UAAU,CAAA;AACjB,YAAY,EAAE,+BAA+B,EAAE,MAAM,UAAU,CAAA;AAC/D,OAAO,EACL,mBAAmB,EACnB,iBAAiB,EACjB,wBAAwB,GACzB,MAAM,cAAc,CAAA;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/d1/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,mBAAmB,EAAE,MAAM,uBAAuB,CAAA;AAC5E,YAAY,EAAE,sBAAsB,EAAE,MAAM,uBAAuB,CAAA;AACnE,OAAO,EACL,6BAA6B,EAC7B,8BAA8B,EAC9B,wCAAwC,EACxC,yCAAyC,EACzC,uBAAuB,GACxB,MAAM,UAAU,CAAA;AACjB,YAAY,EAAE,+BAA+B,EAAE,MAAM,UAAU,CAAA;AAC/D,OAAO,EACL,mBAAmB,EACnB,iBAAiB,EACjB,wBAAwB,GACzB,MAAM,cAAc,CAAA;AACrB,YAAY,EAAE,qBAAqB,EAAE,MAAM,cAAc,CAAA;AAEzD,YAAY,EACV,kBAAkB,EAClB,qBAAqB,EACrB,kBAAkB,EAClB,oBAAoB,GACrB,MAAM,YAAY,CAAA"}
|
package/dist/src/d1/runtime.d.ts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
import type { D1AuditSqlExecutor } from "./types.js";
|
|
2
|
+
export type D1AuditContextOptions = {
|
|
3
|
+
/** Map of context key → value written as KV rows the triggers read. */
|
|
4
|
+
context?: Record<string, string>;
|
|
5
|
+
contextTable?: string;
|
|
6
|
+
};
|
|
2
7
|
/**
|
|
3
8
|
* Sets the audit context for the current transaction by writing to the
|
|
4
9
|
* _audit_context table. Must be called inside a transaction before any
|
|
@@ -6,17 +11,15 @@ import type { D1AuditSqlExecutor } from "./types.js";
|
|
|
6
11
|
*
|
|
7
12
|
* D1/SQLite has no session variables, so triggers read context from this table.
|
|
8
13
|
*/
|
|
9
|
-
export declare function setD1AuditContext(db: D1AuditSqlExecutor, actorId: string, options?:
|
|
10
|
-
workspaceId?: string;
|
|
11
|
-
contextTable?: string;
|
|
12
|
-
}): Promise<unknown> | undefined;
|
|
14
|
+
export declare function setD1AuditContext(db: D1AuditSqlExecutor, actorId: string, options?: D1AuditContextOptions): Promise<unknown> | undefined;
|
|
13
15
|
/**
|
|
14
16
|
* Clears the audit context after a transaction completes.
|
|
15
17
|
* Called automatically by withD1AuditedTransaction.
|
|
18
|
+
*
|
|
19
|
+
* Clears `user_id` plus any keys set via `context` so the next transaction
|
|
20
|
+
* starts clean.
|
|
16
21
|
*/
|
|
17
|
-
export declare function clearD1AuditContext(db: D1AuditSqlExecutor, options?:
|
|
18
|
-
contextTable?: string;
|
|
19
|
-
}): unknown;
|
|
22
|
+
export declare function clearD1AuditContext(db: D1AuditSqlExecutor, options?: D1AuditContextOptions): unknown;
|
|
20
23
|
/**
|
|
21
24
|
* Wraps a Drizzle SQLite transaction with audit context. Sets the actor
|
|
22
25
|
* before the callback and clears the context after (success or failure).
|
|
@@ -37,8 +40,5 @@ export declare function clearD1AuditContext(db: D1AuditSqlExecutor, options?: {
|
|
|
37
40
|
*/
|
|
38
41
|
export declare function withD1AuditedTransaction<TDb extends D1AuditSqlExecutor, TResult>(db: TDb & {
|
|
39
42
|
transaction: (cb: (tx: any) => TResult) => TResult;
|
|
40
|
-
}, actorId: string, callback: (tx: TDb) => TResult, options?:
|
|
41
|
-
workspaceId?: string;
|
|
42
|
-
contextTable?: string;
|
|
43
|
-
}): TResult;
|
|
43
|
+
}, actorId: string, callback: (tx: TDb) => TResult, options?: D1AuditContextOptions): TResult;
|
|
44
44
|
//# sourceMappingURL=runtime.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"runtime.d.ts","sourceRoot":"","sources":["../../../src/d1/runtime.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAA;AAUpD;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAC/B,EAAE,EAAE,kBAAkB,EACtB,OAAO,EAAE,MAAM,EACf,OAAO,CAAC,EAAE
|
|
1
|
+
{"version":3,"file":"runtime.d.ts","sourceRoot":"","sources":["../../../src/d1/runtime.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAA;AAUpD,MAAM,MAAM,qBAAqB,GAAG;IAClC,uEAAuE;IACvE,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IAChC,YAAY,CAAC,EAAE,MAAM,CAAA;CACtB,CAAA;AAkBD;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAC/B,EAAE,EAAE,kBAAkB,EACtB,OAAO,EAAE,MAAM,EACf,OAAO,CAAC,EAAE,qBAAqB,gCAwBhC;AAED;;;;;;GAMG;AACH,wBAAgB,mBAAmB,CACjC,EAAE,EAAE,kBAAkB,EACtB,OAAO,CAAC,EAAE,qBAAqB,WAYhC;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,wBAAwB,CAAC,GAAG,SAAS,kBAAkB,EAAE,OAAO,EAC9E,EAAE,EAAE,GAAG,GAAG;IAAE,WAAW,EAAE,CAAC,EAAE,EAAE,CAAC,EAAE,EAAE,GAAG,KAAK,OAAO,KAAK,OAAO,CAAA;CAAE,EAChE,OAAO,EAAE,MAAM,EACf,QAAQ,EAAE,CAAC,EAAE,EAAE,GAAG,KAAK,OAAO,EAC9B,OAAO,CAAC,EAAE,qBAAqB,GAC9B,OAAO,CAWT"}
|
package/dist/src/d1/runtime.js
CHANGED
|
@@ -5,6 +5,19 @@ function assertActorId(actorId) {
|
|
|
5
5
|
throw new Error("actorId must not be empty");
|
|
6
6
|
}
|
|
7
7
|
}
|
|
8
|
+
/**
|
|
9
|
+
* Builds the KV map (key → value) to write. Empty/undefined values are dropped
|
|
10
|
+
* so the corresponding column stays NULL.
|
|
11
|
+
*/
|
|
12
|
+
function resolveContext(options) {
|
|
13
|
+
const context = {};
|
|
14
|
+
for (const [key, value] of Object.entries(options?.context ?? {})) {
|
|
15
|
+
if (value !== undefined && value !== "") {
|
|
16
|
+
context[key] = value;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return context;
|
|
20
|
+
}
|
|
8
21
|
/**
|
|
9
22
|
* Sets the audit context for the current transaction by writing to the
|
|
10
23
|
* _audit_context table. Must be called inside a transaction before any
|
|
@@ -15,26 +28,30 @@ function assertActorId(actorId) {
|
|
|
15
28
|
export function setD1AuditContext(db, actorId, options) {
|
|
16
29
|
assertActorId(actorId);
|
|
17
30
|
const table = options?.contextTable ?? DEFAULT_CONTEXT_TABLE;
|
|
18
|
-
const
|
|
31
|
+
const context = resolveContext(options);
|
|
32
|
+
const writeKv = (key, value) => db.run(sql `INSERT OR REPLACE INTO ${sql.identifier(table)} (key, value) VALUES (${key}, ${value})`);
|
|
33
|
+
const result = writeKv("user_id", actorId);
|
|
19
34
|
// Handle async drivers (D1) by chaining if result is a Promise
|
|
20
35
|
if (result && typeof result.then === "function") {
|
|
21
|
-
return
|
|
22
|
-
if (options?.workspaceId !== undefined && options.workspaceId !== "") {
|
|
23
|
-
return db.run(sql `INSERT OR REPLACE INTO ${sql.identifier(table)} (key, value) VALUES ('workspace_id', ${options.workspaceId})`);
|
|
24
|
-
}
|
|
25
|
-
});
|
|
36
|
+
return Object.entries(context).reduce((prev, [key, value]) => prev.then(() => writeKv(key, value)), result);
|
|
26
37
|
}
|
|
27
|
-
|
|
28
|
-
|
|
38
|
+
for (const [key, value] of Object.entries(context)) {
|
|
39
|
+
writeKv(key, value);
|
|
29
40
|
}
|
|
30
41
|
}
|
|
31
42
|
/**
|
|
32
43
|
* Clears the audit context after a transaction completes.
|
|
33
44
|
* Called automatically by withD1AuditedTransaction.
|
|
45
|
+
*
|
|
46
|
+
* Clears `user_id` plus any keys set via `context` so the next transaction
|
|
47
|
+
* starts clean.
|
|
34
48
|
*/
|
|
35
49
|
export function clearD1AuditContext(db, options) {
|
|
36
50
|
const table = options?.contextTable ?? DEFAULT_CONTEXT_TABLE;
|
|
37
|
-
|
|
51
|
+
const keys = ["user_id", ...Object.keys(options?.context ?? {})];
|
|
52
|
+
const uniqueKeys = [...new Set(keys)];
|
|
53
|
+
const keyList = sql.join(uniqueKeys.map((k) => sql `${k}`), sql `, `);
|
|
54
|
+
return db.run(sql `DELETE FROM ${sql.identifier(table)} WHERE key IN (${keyList})`);
|
|
38
55
|
}
|
|
39
56
|
/**
|
|
40
57
|
* Wraps a Drizzle SQLite transaction with audit context. Sets the actor
|
package/dist/src/d1/sql.d.ts
CHANGED
|
@@ -2,8 +2,8 @@ import type { D1AuditInstallOptions, D1AuditTriggerTarget } from "./types.js";
|
|
|
2
2
|
/**
|
|
3
3
|
* Generates SQL to install the audit_logs table and _audit_context table.
|
|
4
4
|
*
|
|
5
|
-
* The _audit_context table stores user_id (and
|
|
6
|
-
* the current transaction. Since D1/SQLite has no session variables, triggers
|
|
5
|
+
* The _audit_context table stores user_id (and any configured context columns)
|
|
6
|
+
* for the current transaction. Since D1/SQLite has no session variables, triggers
|
|
7
7
|
* read context from this table instead.
|
|
8
8
|
*/
|
|
9
9
|
export declare function createD1AuditInstallSql(options?: D1AuditInstallOptions): string;
|
package/dist/src/d1/sql.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sql.d.ts","sourceRoot":"","sources":["../../../src/d1/sql.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,
|
|
1
|
+
{"version":3,"file":"sql.d.ts","sourceRoot":"","sources":["../../../src/d1/sql.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAEV,qBAAqB,EACrB,oBAAoB,EACrB,MAAM,YAAY,CAAA;AAyEnB;;;;;;GAMG;AACH,wBAAgB,uBAAuB,CAAC,OAAO,GAAE,qBAA0B,UA8C1E;AAoHD;;;;;;GAMG;AACH,wBAAgB,6BAA6B,CAC3C,MAAM,EAAE,oBAAoB,EAC5B,OAAO,GAAE,qBAA0B,UAOpC;AAED,wBAAgB,8BAA8B,CAC5C,OAAO,EAAE,oBAAoB,EAAE,EAC/B,OAAO,GAAE,qBAA0B,UAQpC;AAID,MAAM,MAAM,+BAA+B,GAAG,oBAAoB,GAAG;IACnE,kEAAkE;IAClE,OAAO,EAAE,MAAM,EAAE,CAAA;CAClB,CAAA;AA4HD;;;;;;;;;;;GAWG;AACH,wBAAgB,wCAAwC,CACtD,MAAM,EAAE,+BAA+B,EACvC,OAAO,GAAE,qBAA0B,UAUpC;AAED,wBAAgB,yCAAyC,CACvD,OAAO,EAAE,+BAA+B,EAAE,EAC1C,OAAO,GAAE,qBAA0B,UAQpC"}
|