@willyim/drizzle-audit 0.5.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 +66 -0
- 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/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/package.json +2 -1
package/README.md
CHANGED
|
@@ -179,6 +179,60 @@ withD1AuditedTransaction(db, "user_1", (tx) => { /* ... */ }, {
|
|
|
179
179
|
})
|
|
180
180
|
```
|
|
181
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
|
+
|
|
182
236
|
## CLI
|
|
183
237
|
|
|
184
238
|
Generate a Drizzle migration with audit SQL appended:
|
|
@@ -221,6 +275,18 @@ export function createAuditSql() {
|
|
|
221
275
|
| `setAuditContext(db, actorId, contextKey?, options?)` | Set actor context in current transaction |
|
|
222
276
|
| `withAuditedTransaction(db, actorId, callback, contextKey?, options?)` | Transaction wrapper with actor context |
|
|
223
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
|
+
|
|
224
290
|
### `@willyim/drizzle-audit`
|
|
225
291
|
|
|
226
292
|
| Export | Description |
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"context.test.d.ts","sourceRoot":"","sources":["../../test/context.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { currentAudit, ensureAuditedTx, hasAuditContext, maybeCurrentAudit, runWithAuditContext, } from "../src/context/index.js";
|
|
4
|
+
function makeFakeDb() {
|
|
5
|
+
let txCount = 0;
|
|
6
|
+
const executed = [];
|
|
7
|
+
const tx = {
|
|
8
|
+
async execute(query) {
|
|
9
|
+
executed.push(query);
|
|
10
|
+
return undefined;
|
|
11
|
+
},
|
|
12
|
+
};
|
|
13
|
+
const db = {
|
|
14
|
+
async transaction(cb) {
|
|
15
|
+
txCount++;
|
|
16
|
+
return cb(tx);
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
return {
|
|
20
|
+
db,
|
|
21
|
+
tx,
|
|
22
|
+
executed,
|
|
23
|
+
get txCount() {
|
|
24
|
+
return txCount;
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
test("ensureAuditedTx opens one tx and writes the actor GUC", async () => {
|
|
29
|
+
const fake = makeFakeDb();
|
|
30
|
+
let inside;
|
|
31
|
+
await runWithAuditContext({ actorId: "user_1" }, () => ensureAuditedTx(fake.db, async (tx) => {
|
|
32
|
+
inside = tx;
|
|
33
|
+
}));
|
|
34
|
+
assert.equal(fake.txCount, 1);
|
|
35
|
+
assert.equal(inside, fake.tx);
|
|
36
|
+
// setAuditContext ran at least the actor set_config.
|
|
37
|
+
assert.ok(fake.executed.length >= 1);
|
|
38
|
+
});
|
|
39
|
+
test("nested ensureAuditedTx reuses the open tx (no second transaction)", async () => {
|
|
40
|
+
const fake = makeFakeDb();
|
|
41
|
+
const seen = [];
|
|
42
|
+
await runWithAuditContext({ actorId: "user_1" }, () => ensureAuditedTx(fake.db, async (tx) => {
|
|
43
|
+
seen.push(tx);
|
|
44
|
+
await ensureAuditedTx(fake.db, async (tx2) => {
|
|
45
|
+
seen.push(tx2);
|
|
46
|
+
await ensureAuditedTx(fake.db, async (tx3) => seen.push(tx3));
|
|
47
|
+
});
|
|
48
|
+
}));
|
|
49
|
+
assert.equal(fake.txCount, 1, "only one transaction is opened");
|
|
50
|
+
assert.equal(seen.length, 3);
|
|
51
|
+
assert.ok(seen.every((t) => t === fake.tx));
|
|
52
|
+
});
|
|
53
|
+
test("lazy resolver runs exactly once, on first write", async () => {
|
|
54
|
+
const fake = makeFakeDb();
|
|
55
|
+
let resolved = 0;
|
|
56
|
+
let seenActor;
|
|
57
|
+
await runWithAuditContext(() => {
|
|
58
|
+
resolved++;
|
|
59
|
+
return { actorId: "lazy" };
|
|
60
|
+
}, async () => {
|
|
61
|
+
assert.equal(resolved, 0, "not resolved before any write");
|
|
62
|
+
assert.equal(maybeCurrentAudit(), null);
|
|
63
|
+
await ensureAuditedTx(fake.db, async () => {
|
|
64
|
+
seenActor = currentAudit().actorId;
|
|
65
|
+
// reentrant — must not re-resolve
|
|
66
|
+
await ensureAuditedTx(fake.db, async () => { });
|
|
67
|
+
});
|
|
68
|
+
// resolved value is memoised on the cell
|
|
69
|
+
assert.equal(maybeCurrentAudit()?.actorId, "lazy");
|
|
70
|
+
});
|
|
71
|
+
assert.equal(resolved, 1);
|
|
72
|
+
assert.equal(seenActor, "lazy");
|
|
73
|
+
});
|
|
74
|
+
test("eager context resolves immediately (currentAudit before any write)", async () => {
|
|
75
|
+
await runWithAuditContext({ actorId: "eager", context: { "app.workspace_id": "ws_9" } }, async () => {
|
|
76
|
+
assert.equal(currentAudit().actorId, "eager");
|
|
77
|
+
assert.equal(currentAudit().context?.["app.workspace_id"], "ws_9");
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
test("guardrail: writes / reads outside a context fail loudly", async () => {
|
|
81
|
+
const fake = makeFakeDb();
|
|
82
|
+
assert.equal(hasAuditContext(), false);
|
|
83
|
+
assert.equal(maybeCurrentAudit(), null);
|
|
84
|
+
assert.throws(() => currentAudit(), /no ambient audit context/);
|
|
85
|
+
await assert.rejects(() => ensureAuditedTx(fake.db, async () => { }), /outside an audit context/);
|
|
86
|
+
assert.equal(fake.txCount, 0);
|
|
87
|
+
});
|
|
88
|
+
test("context is isolated per runWithAuditContext scope", async () => {
|
|
89
|
+
const a = runWithAuditContext({ actorId: "A" }, async () => {
|
|
90
|
+
await Promise.resolve();
|
|
91
|
+
return currentAudit().actorId;
|
|
92
|
+
});
|
|
93
|
+
const b = runWithAuditContext({ actorId: "B" }, async () => {
|
|
94
|
+
await Promise.resolve();
|
|
95
|
+
return currentAudit().actorId;
|
|
96
|
+
});
|
|
97
|
+
assert.deepEqual(await Promise.all([a, b]), ["A", "B"]);
|
|
98
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@willyim/drizzle-audit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Lightweight audit logging for Drizzle ORM using database triggers (Postgres + D1/SQLite)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/src/index.js",
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
"exports": {
|
|
9
9
|
".": "./dist/src/index.js",
|
|
10
10
|
"./postgres": "./dist/src/postgres/index.js",
|
|
11
|
+
"./context": "./dist/src/context/index.js",
|
|
11
12
|
"./d1": "./dist/src/d1/index.js",
|
|
12
13
|
"./d1-runtime": "./dist/src/d1-runtime/index.js"
|
|
13
14
|
},
|