@wtfalch/audit 0.1.0 → 0.3.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 +34 -0
- package/dist/ledger.d.ts +71 -5
- package/dist/ledger.js +49 -5
- package/dist/schema.d.ts +4 -2
- package/dist/schema.js +3 -2
- package/dist/tables.d.ts +159 -0
- package/dist/tables.js +31 -12
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -70,6 +70,40 @@ const touched = await ledger.erase(tx, { subject: personId, pseudonym, email });
|
|
|
70
70
|
uses: `tenant.invoice_paid` is out, `invoice.paid` is in. The core's
|
|
71
71
|
namespaces are the trusted base's.
|
|
72
72
|
|
|
73
|
+
## A writer for an embedding service
|
|
74
|
+
|
|
75
|
+
A service that wants a trail (a forum's moderation, a mail admin's writes) gets
|
|
76
|
+
a writer, never the signer. The host binds one per namespace:
|
|
77
|
+
|
|
78
|
+
```ts
|
|
79
|
+
export const forumAudit = ledger.writer({ namespace: 'thread', handle: db });
|
|
80
|
+
createThreads({ db, gates, audit: forumAudit });
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
The writer signs `thread.*` and refuses everything else; the actor is whoever
|
|
84
|
+
the service resolved; context, actor class and tenant default to what the
|
|
85
|
+
host bound. A caller passes its transaction as the second argument when the
|
|
86
|
+
row must commit with the change it records.
|
|
87
|
+
|
|
88
|
+
## A host's own columns
|
|
89
|
+
|
|
90
|
+
A host that needs a column beside the ledger's (a team, a region) declares
|
|
91
|
+
its table over the package's builders and hands it to the ledger:
|
|
92
|
+
|
|
93
|
+
```ts
|
|
94
|
+
import { AUDIT_COLUMNS, auditIndexes, createLedger } from '@wtfalch/audit';
|
|
95
|
+
|
|
96
|
+
export const auditEvents = pgTable('audit_events', { ...AUDIT_COLUMNS, teamId: uuid('team_id') }, (t) =>
|
|
97
|
+
auditIndexes(t),
|
|
98
|
+
);
|
|
99
|
+
const ledger = createLedger({ vocabulary, table: auditEvents, schemaVersion: core.version });
|
|
100
|
+
await ledger.sign(tx, { ...input, extra: { teamId } });
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
`extra` takes host columns only: a ledger column there is refused, and so is a
|
|
104
|
+
key the table does not declare. The host's own migration adds the column; the
|
|
105
|
+
package's migration never learns of it.
|
|
106
|
+
|
|
73
107
|
## Tests
|
|
74
108
|
|
|
75
109
|
```sh
|
package/dist/ledger.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { PgDatabase, PgQueryResultHKT } from 'drizzle-orm/pg-core';
|
|
2
|
-
import { type AuditEventRow,
|
|
2
|
+
import { type AuditEventRow, type AuditTable } from './tables.js';
|
|
3
3
|
import type { LedgerVocabulary } from './vocabulary.js';
|
|
4
4
|
/**
|
|
5
5
|
* The host's drizzle handle or a transaction open on it. postgres-js in the
|
|
@@ -42,6 +42,12 @@ export interface SignInput {
|
|
|
42
42
|
readonly id: string;
|
|
43
43
|
} | null;
|
|
44
44
|
readonly occurredAt?: Date;
|
|
45
|
+
/**
|
|
46
|
+
* Values for the host's own columns, by the drizzle key the host's table
|
|
47
|
+
* declares them under (`{ teamId }`), written beside the ledger's. Never a
|
|
48
|
+
* ledger column: those are validated above and refused here.
|
|
49
|
+
*/
|
|
50
|
+
readonly extra?: Readonly<Record<string, unknown>>;
|
|
45
51
|
}
|
|
46
52
|
export interface PageOptions {
|
|
47
53
|
/** One tenant's rows; `null` for rows with no tenant; omit for every tenant. */
|
|
@@ -65,6 +71,55 @@ export interface Page {
|
|
|
65
71
|
readonly id: number;
|
|
66
72
|
} | null;
|
|
67
73
|
}
|
|
74
|
+
/**
|
|
75
|
+
* What an embedding service hands a bound writer for one row. The namespace
|
|
76
|
+
* is fixed when the host binds the writer, so `action` must sit in it; the
|
|
77
|
+
* actor is whoever the service resolved (a forum's `who`, a mail admin's
|
|
78
|
+
* principal), never chosen by the ledger. Everything else is optional and
|
|
79
|
+
* defaults as `SignInput` does.
|
|
80
|
+
*/
|
|
81
|
+
export interface WriterEvent {
|
|
82
|
+
readonly action: string;
|
|
83
|
+
readonly actor: {
|
|
84
|
+
readonly id: string;
|
|
85
|
+
readonly display: string;
|
|
86
|
+
readonly class?: string;
|
|
87
|
+
};
|
|
88
|
+
readonly target: {
|
|
89
|
+
readonly type: string;
|
|
90
|
+
readonly id: string;
|
|
91
|
+
};
|
|
92
|
+
readonly tenantId?: string | null;
|
|
93
|
+
readonly outcome?: string;
|
|
94
|
+
readonly reason?: string | null;
|
|
95
|
+
readonly reference?: string | null;
|
|
96
|
+
readonly before?: unknown;
|
|
97
|
+
readonly after?: unknown;
|
|
98
|
+
readonly subject?: {
|
|
99
|
+
readonly class: string;
|
|
100
|
+
readonly id: string;
|
|
101
|
+
} | null;
|
|
102
|
+
readonly request?: SignInput['request'];
|
|
103
|
+
readonly extra?: SignInput['extra'];
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* A writer the host bound to one namespace. `handle` is the caller's
|
|
107
|
+
* transaction when the row must commit with the change it records;
|
|
108
|
+
* otherwise the handle the host bound.
|
|
109
|
+
*/
|
|
110
|
+
export type AuditWriter = (event: WriterEvent, handle?: Handle) => Promise<void>;
|
|
111
|
+
export interface WriterOptions {
|
|
112
|
+
/** The namespace the writer may sign in: `thread` admits `thread.pinned` and refuses `tenant.created`. */
|
|
113
|
+
readonly namespace: string;
|
|
114
|
+
/** The handle used when the caller passes none. */
|
|
115
|
+
readonly handle: Handle;
|
|
116
|
+
/** The context every row carries. Defaults to the vocabulary's first context. */
|
|
117
|
+
readonly context?: string;
|
|
118
|
+
/** The actor class when the event names none. Defaults to the vocabulary's first actor class. */
|
|
119
|
+
readonly actorClass?: string;
|
|
120
|
+
/** The tenant when the event names none. Defaults to null: a row on the estate log. */
|
|
121
|
+
readonly tenantId?: string | null;
|
|
122
|
+
}
|
|
68
123
|
export interface EraseInput {
|
|
69
124
|
readonly subject: string;
|
|
70
125
|
readonly pseudonym: string;
|
|
@@ -79,10 +134,17 @@ export interface EraseInput {
|
|
|
79
134
|
* or reads a session: the guarantee that a row's actor is real is the host's,
|
|
80
135
|
* and it holds only while the signer stays private.
|
|
81
136
|
*/
|
|
137
|
+
export interface LedgerOptions {
|
|
138
|
+
readonly vocabulary: LedgerVocabulary;
|
|
139
|
+
/** The host's own table over `AUDIT_COLUMNS`, when it has columns beside the ledger's. Defaults to the package's `auditEvents`. */
|
|
140
|
+
readonly table?: AuditTable;
|
|
141
|
+
/** What `schema_version` every row carries. Defaults to 1; a host whose shared audit words are versioned passes theirs. */
|
|
142
|
+
readonly schemaVersion?: number;
|
|
143
|
+
}
|
|
82
144
|
export interface Ledger {
|
|
83
145
|
readonly vocabulary: LedgerVocabulary;
|
|
84
146
|
readonly tables: {
|
|
85
|
-
readonly events:
|
|
147
|
+
readonly events: AuditTable;
|
|
86
148
|
};
|
|
87
149
|
/** Validates against the vocabulary and inserts, on the handle given: a transaction when the row must commit with the change it records. */
|
|
88
150
|
sign(handle: Handle, input: SignInput): Promise<void>;
|
|
@@ -92,7 +154,11 @@ export interface Ledger {
|
|
|
92
154
|
erase(handle: Handle, input: EraseInput): Promise<number>;
|
|
93
155
|
/** One tenant's rows, oldest first, for a tenant's export. Deterministic order; no secrets are in this table to omit. */
|
|
94
156
|
exportRows(handle: Handle, tenantId: string): Promise<readonly AuditEventRow[]>;
|
|
157
|
+
/**
|
|
158
|
+
* A writer for one embedding service, bound to one namespace. This is what
|
|
159
|
+
* a host hands to `createThreads({ audit })` or a postmaster: it can write
|
|
160
|
+
* that namespace's events and nothing else, and it never sees `sign`.
|
|
161
|
+
*/
|
|
162
|
+
writer(options: WriterOptions): AuditWriter;
|
|
95
163
|
}
|
|
96
|
-
export declare function createLedger(options:
|
|
97
|
-
vocabulary: LedgerVocabulary;
|
|
98
|
-
}): Ledger;
|
|
164
|
+
export declare function createLedger(options: LedgerOptions): Ledger;
|
package/dist/ledger.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { and, asc, desc, eq, lt, or, sql } from 'drizzle-orm';
|
|
2
2
|
import { rowSchema } from './schema.js';
|
|
3
|
-
import { auditEvents } from './tables.js';
|
|
3
|
+
import { auditEvents as auditEvents_ } from './tables.js';
|
|
4
|
+
const LEDGER_KEYS = new Set(Object.keys(auditEvents_));
|
|
4
5
|
export function createLedger(options) {
|
|
5
|
-
const { vocabulary } = options;
|
|
6
|
-
const schema = rowSchema(vocabulary);
|
|
6
|
+
const { vocabulary, table: auditEvents = auditEvents_, schemaVersion = 1 } = options;
|
|
7
|
+
const schema = rowSchema(vocabulary, { schemaVersion });
|
|
7
8
|
async function sign(handle, input) {
|
|
8
9
|
const meta = vocabulary.events[input.action];
|
|
9
10
|
if (!meta) {
|
|
@@ -31,11 +32,19 @@ export function createLedger(options) {
|
|
|
31
32
|
before: input.before ?? null,
|
|
32
33
|
after: input.after ?? null,
|
|
33
34
|
erased_at: null,
|
|
34
|
-
schema_version:
|
|
35
|
+
schema_version: schemaVersion,
|
|
35
36
|
subject_class: input.subject?.class ?? null,
|
|
36
37
|
subject_id: input.subject?.id ?? null,
|
|
37
38
|
});
|
|
39
|
+
const extra = input.extra ?? {};
|
|
40
|
+
for (const key of Object.keys(extra)) {
|
|
41
|
+
if (LEDGER_KEYS.has(key))
|
|
42
|
+
throw new Error(`audit: "${key}" is a ledger column, not a host column`);
|
|
43
|
+
if (!(key in auditEvents))
|
|
44
|
+
throw new Error(`audit: the ledger's table has no column "${key}"`);
|
|
45
|
+
}
|
|
38
46
|
await handle.insert(auditEvents).values({
|
|
47
|
+
...extra,
|
|
39
48
|
occurredAt,
|
|
40
49
|
tenantId: row.tenant_id,
|
|
41
50
|
actorClass: row.actor_class,
|
|
@@ -110,5 +119,40 @@ export function createLedger(options) {
|
|
|
110
119
|
.where(eq(auditEvents.tenantId, tenantId))
|
|
111
120
|
.orderBy(asc(auditEvents.occurredAt), asc(auditEvents.id));
|
|
112
121
|
}
|
|
113
|
-
|
|
122
|
+
function writer(options) {
|
|
123
|
+
const { namespace } = options;
|
|
124
|
+
if (!/^[a-z][a-z0-9_]*$/.test(namespace)) {
|
|
125
|
+
throw new Error(`audit: "${namespace}" is not a namespace`);
|
|
126
|
+
}
|
|
127
|
+
const context = options.context ?? vocabulary.contexts[0];
|
|
128
|
+
const actorClass = options.actorClass ?? vocabulary.actorClasses[0];
|
|
129
|
+
if (context === undefined || actorClass === undefined) {
|
|
130
|
+
throw new Error('audit: the vocabulary has no context or actor class to default to');
|
|
131
|
+
}
|
|
132
|
+
return async (event, handle) => {
|
|
133
|
+
if (!event.action.startsWith(`${namespace}.`)) {
|
|
134
|
+
throw new Error(`audit: "${event.action}" is outside the namespace "${namespace}" this writer is bound to`);
|
|
135
|
+
}
|
|
136
|
+
await sign(handle ?? options.handle, {
|
|
137
|
+
action: event.action,
|
|
138
|
+
tenantId: event.tenantId === undefined ? (options.tenantId ?? null) : event.tenantId,
|
|
139
|
+
actor: {
|
|
140
|
+
class: event.actor.class ?? actorClass,
|
|
141
|
+
id: event.actor.id,
|
|
142
|
+
display: event.actor.display,
|
|
143
|
+
},
|
|
144
|
+
context,
|
|
145
|
+
target: event.target,
|
|
146
|
+
outcome: event.outcome,
|
|
147
|
+
reason: event.reason,
|
|
148
|
+
reference: event.reference,
|
|
149
|
+
before: event.before,
|
|
150
|
+
after: event.after,
|
|
151
|
+
subject: event.subject,
|
|
152
|
+
request: event.request,
|
|
153
|
+
extra: event.extra,
|
|
154
|
+
});
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
return { vocabulary, tables: { events: auditEvents }, sign, page, erase, exportRows, writer };
|
|
114
158
|
}
|
package/dist/schema.d.ts
CHANGED
|
@@ -20,7 +20,9 @@ export declare function isJsonValue(value: unknown, seen?: WeakSet<object>): boo
|
|
|
20
20
|
* the break-glass context must carry the session, a reason from the closed
|
|
21
21
|
* code set and a reference; every other row's reason is free text.
|
|
22
22
|
*/
|
|
23
|
-
export declare function rowSchema(vocabulary: LedgerVocabulary
|
|
23
|
+
export declare function rowSchema(vocabulary: LedgerVocabulary, options?: {
|
|
24
|
+
schemaVersion?: number;
|
|
25
|
+
}): z.ZodObject<{
|
|
24
26
|
occurred_at: z.ZodISODateTime;
|
|
25
27
|
tenant_id: z.ZodNullable<z.ZodString>;
|
|
26
28
|
actor_class: z.ZodEnum<{
|
|
@@ -49,7 +51,7 @@ export declare function rowSchema(vocabulary: LedgerVocabulary): z.ZodObject<{
|
|
|
49
51
|
before: z.ZodNullable<z.ZodUnknown>;
|
|
50
52
|
after: z.ZodNullable<z.ZodUnknown>;
|
|
51
53
|
erased_at: z.ZodNullable<z.ZodISODateTime>;
|
|
52
|
-
schema_version: z.ZodLiteral<
|
|
54
|
+
schema_version: z.ZodLiteral<number>;
|
|
53
55
|
subject_class: z.ZodNullable<z.ZodEnum<{
|
|
54
56
|
[x: string]: string;
|
|
55
57
|
}>>;
|
package/dist/schema.js
CHANGED
|
@@ -60,7 +60,8 @@ const enumOf = (values) => z.enum([...values]);
|
|
|
60
60
|
* the break-glass context must carry the session, a reason from the closed
|
|
61
61
|
* code set and a reference; every other row's reason is free text.
|
|
62
62
|
*/
|
|
63
|
-
export function rowSchema(vocabulary) {
|
|
63
|
+
export function rowSchema(vocabulary, options = {}) {
|
|
64
|
+
const schemaVersion = options.schemaVersion ?? 1;
|
|
64
65
|
const eventNames = Object.keys(vocabulary.events);
|
|
65
66
|
const reasonCodes = new Set(vocabulary.breakGlassReasonCodes ?? []);
|
|
66
67
|
return z
|
|
@@ -85,7 +86,7 @@ export function rowSchema(vocabulary) {
|
|
|
85
86
|
before: jsonColumn.nullable(),
|
|
86
87
|
after: jsonColumn.nullable(),
|
|
87
88
|
erased_at: z.iso.datetime({ offset: true }).nullable(),
|
|
88
|
-
schema_version: z.literal(
|
|
89
|
+
schema_version: z.literal(schemaVersion),
|
|
89
90
|
subject_class: enumOf(vocabulary.actorClasses).nullable(),
|
|
90
91
|
subject_id: identifier(AUDIT_LIMITS.id).nullable(),
|
|
91
92
|
})
|
package/dist/tables.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type ExtraConfigColumn } from 'drizzle-orm/pg-core';
|
|
1
2
|
/**
|
|
2
3
|
* The ledger, mirrored from `migrations/0001_audit.sql`, which is the source
|
|
3
4
|
* of truth: the SQL carries the CHECKs, the append-only triggers, the partial
|
|
@@ -5,6 +6,162 @@
|
|
|
5
6
|
* re-exports this from its own schema module so its drizzle instance and its
|
|
6
7
|
* types know it.
|
|
7
8
|
*/
|
|
9
|
+
/**
|
|
10
|
+
* The ledger's columns, as drizzle builders, so a host that needs a column
|
|
11
|
+
* of its own beside them (a team, a region) declares
|
|
12
|
+
* `pgTable('audit_events', { ...AUDIT_COLUMNS, teamId: uuid('team_id') }, …)`
|
|
13
|
+
* and hands that table to `createLedger`. The package's own `auditEvents`
|
|
14
|
+
* is the same call with nothing added.
|
|
15
|
+
*/
|
|
16
|
+
export declare const AUDIT_COLUMNS: {
|
|
17
|
+
id: import("drizzle-orm").IsPrimaryKey<import("drizzle-orm").NotNull<import("drizzle-orm").IsIdentity<import("drizzle-orm/pg-core").PgBigInt53BuilderInitial<"id">, "always">>>;
|
|
18
|
+
occurredAt: import("drizzle-orm").HasDefault<import("drizzle-orm").NotNull<import("drizzle-orm/pg-core").PgTimestampBuilderInitial<"occurred_at">>>;
|
|
19
|
+
tenantId: import("drizzle-orm/pg-core").PgUUIDBuilderInitial<"tenant_id">;
|
|
20
|
+
actorClass: import("drizzle-orm").NotNull<import("drizzle-orm/pg-core").PgTextBuilder<{
|
|
21
|
+
name: "actor_class";
|
|
22
|
+
dataType: "string";
|
|
23
|
+
columnType: "PgText";
|
|
24
|
+
data: string;
|
|
25
|
+
enumValues: [string, ...string[]];
|
|
26
|
+
driverParam: string;
|
|
27
|
+
}>>;
|
|
28
|
+
actorId: import("drizzle-orm").NotNull<import("drizzle-orm/pg-core").PgTextBuilder<{
|
|
29
|
+
name: "actor_id";
|
|
30
|
+
dataType: "string";
|
|
31
|
+
columnType: "PgText";
|
|
32
|
+
data: string;
|
|
33
|
+
enumValues: [string, ...string[]];
|
|
34
|
+
driverParam: string;
|
|
35
|
+
}>>;
|
|
36
|
+
actorDisplay: import("drizzle-orm").NotNull<import("drizzle-orm/pg-core").PgTextBuilder<{
|
|
37
|
+
name: "actor_display";
|
|
38
|
+
dataType: "string";
|
|
39
|
+
columnType: "PgText";
|
|
40
|
+
data: string;
|
|
41
|
+
enumValues: [string, ...string[]];
|
|
42
|
+
driverParam: string;
|
|
43
|
+
}>>;
|
|
44
|
+
action: import("drizzle-orm").NotNull<import("drizzle-orm/pg-core").PgTextBuilder<{
|
|
45
|
+
name: "action";
|
|
46
|
+
dataType: "string";
|
|
47
|
+
columnType: "PgText";
|
|
48
|
+
data: string;
|
|
49
|
+
enumValues: [string, ...string[]];
|
|
50
|
+
driverParam: string;
|
|
51
|
+
}>>;
|
|
52
|
+
targetType: import("drizzle-orm").NotNull<import("drizzle-orm/pg-core").PgTextBuilder<{
|
|
53
|
+
name: "target_type";
|
|
54
|
+
dataType: "string";
|
|
55
|
+
columnType: "PgText";
|
|
56
|
+
data: string;
|
|
57
|
+
enumValues: [string, ...string[]];
|
|
58
|
+
driverParam: string;
|
|
59
|
+
}>>;
|
|
60
|
+
targetId: import("drizzle-orm").NotNull<import("drizzle-orm/pg-core").PgTextBuilder<{
|
|
61
|
+
name: "target_id";
|
|
62
|
+
dataType: "string";
|
|
63
|
+
columnType: "PgText";
|
|
64
|
+
data: string;
|
|
65
|
+
enumValues: [string, ...string[]];
|
|
66
|
+
driverParam: string;
|
|
67
|
+
}>>;
|
|
68
|
+
outcome: import("drizzle-orm").NotNull<import("drizzle-orm/pg-core").PgTextBuilder<{
|
|
69
|
+
name: "outcome";
|
|
70
|
+
dataType: "string";
|
|
71
|
+
columnType: "PgText";
|
|
72
|
+
data: string;
|
|
73
|
+
enumValues: [string, ...string[]];
|
|
74
|
+
driverParam: string;
|
|
75
|
+
}>>;
|
|
76
|
+
context: import("drizzle-orm").NotNull<import("drizzle-orm/pg-core").PgTextBuilder<{
|
|
77
|
+
name: "context";
|
|
78
|
+
dataType: "string";
|
|
79
|
+
columnType: "PgText";
|
|
80
|
+
data: string;
|
|
81
|
+
enumValues: [string, ...string[]];
|
|
82
|
+
driverParam: string;
|
|
83
|
+
}>>;
|
|
84
|
+
sessionId: import("drizzle-orm/pg-core").PgTextBuilder<{
|
|
85
|
+
name: "session_id";
|
|
86
|
+
dataType: "string";
|
|
87
|
+
columnType: "PgText";
|
|
88
|
+
data: string;
|
|
89
|
+
enumValues: [string, ...string[]];
|
|
90
|
+
driverParam: string;
|
|
91
|
+
}>;
|
|
92
|
+
reason: import("drizzle-orm/pg-core").PgTextBuilder<{
|
|
93
|
+
name: "reason";
|
|
94
|
+
dataType: "string";
|
|
95
|
+
columnType: "PgText";
|
|
96
|
+
data: string;
|
|
97
|
+
enumValues: [string, ...string[]];
|
|
98
|
+
driverParam: string;
|
|
99
|
+
}>;
|
|
100
|
+
reference: import("drizzle-orm/pg-core").PgTextBuilder<{
|
|
101
|
+
name: "reference";
|
|
102
|
+
dataType: "string";
|
|
103
|
+
columnType: "PgText";
|
|
104
|
+
data: string;
|
|
105
|
+
enumValues: [string, ...string[]];
|
|
106
|
+
driverParam: string;
|
|
107
|
+
}>;
|
|
108
|
+
requestId: import("drizzle-orm/pg-core").PgTextBuilder<{
|
|
109
|
+
name: "request_id";
|
|
110
|
+
dataType: "string";
|
|
111
|
+
columnType: "PgText";
|
|
112
|
+
data: string;
|
|
113
|
+
enumValues: [string, ...string[]];
|
|
114
|
+
driverParam: string;
|
|
115
|
+
}>;
|
|
116
|
+
ip: import("drizzle-orm/pg-core").PgTextBuilder<{
|
|
117
|
+
name: "ip";
|
|
118
|
+
dataType: "string";
|
|
119
|
+
columnType: "PgText";
|
|
120
|
+
data: string;
|
|
121
|
+
enumValues: [string, ...string[]];
|
|
122
|
+
driverParam: string;
|
|
123
|
+
}>;
|
|
124
|
+
userAgent: import("drizzle-orm/pg-core").PgTextBuilder<{
|
|
125
|
+
name: "user_agent";
|
|
126
|
+
dataType: "string";
|
|
127
|
+
columnType: "PgText";
|
|
128
|
+
data: string;
|
|
129
|
+
enumValues: [string, ...string[]];
|
|
130
|
+
driverParam: string;
|
|
131
|
+
}>;
|
|
132
|
+
tenantVisible: import("drizzle-orm").NotNull<import("drizzle-orm/pg-core").PgBooleanBuilderInitial<"tenant_visible">>;
|
|
133
|
+
before: import("drizzle-orm/pg-core").PgJsonbBuilderInitial<"before">;
|
|
134
|
+
after: import("drizzle-orm/pg-core").PgJsonbBuilderInitial<"after">;
|
|
135
|
+
erasedAt: import("drizzle-orm/pg-core").PgTimestampBuilderInitial<"erased_at">;
|
|
136
|
+
schemaVersion: import("drizzle-orm").HasDefault<import("drizzle-orm").NotNull<import("drizzle-orm/pg-core").PgSmallIntBuilderInitial<"schema_version">>>;
|
|
137
|
+
subjectClass: import("drizzle-orm/pg-core").PgTextBuilder<{
|
|
138
|
+
name: "subject_class";
|
|
139
|
+
dataType: "string";
|
|
140
|
+
columnType: "PgText";
|
|
141
|
+
data: string;
|
|
142
|
+
enumValues: [string, ...string[]];
|
|
143
|
+
driverParam: string;
|
|
144
|
+
}>;
|
|
145
|
+
subjectId: import("drizzle-orm/pg-core").PgTextBuilder<{
|
|
146
|
+
name: "subject_id";
|
|
147
|
+
dataType: "string";
|
|
148
|
+
columnType: "PgText";
|
|
149
|
+
data: string;
|
|
150
|
+
enumValues: [string, ...string[]];
|
|
151
|
+
driverParam: string;
|
|
152
|
+
}>;
|
|
153
|
+
};
|
|
154
|
+
/** The indexes every ledger table carries, for a host declaring its own table over `AUDIT_COLUMNS`. */
|
|
155
|
+
export declare function auditIndexes(t: {
|
|
156
|
+
[K in keyof typeof AUDIT_COLUMNS]: ExtraConfigColumn;
|
|
157
|
+
}): import("drizzle-orm/pg-core").IndexBuilder[];
|
|
158
|
+
/**
|
|
159
|
+
* The ledger, mirrored from `migrations/0001_audit.sql`, which is the source
|
|
160
|
+
* of truth: the SQL carries the CHECKs, the append-only triggers, the partial
|
|
161
|
+
* indexes and the erasure function that drizzle-kit does not generate. A host
|
|
162
|
+
* re-exports this from its own schema module so its drizzle instance and its
|
|
163
|
+
* types know it, or declares a wider table over `AUDIT_COLUMNS`.
|
|
164
|
+
*/
|
|
8
165
|
export declare const auditEvents: import("drizzle-orm/pg-core").PgTableWithColumns<{
|
|
9
166
|
name: "audit_events";
|
|
10
167
|
schema: undefined;
|
|
@@ -420,6 +577,8 @@ export declare const auditEvents: import("drizzle-orm/pg-core").PgTableWithColum
|
|
|
420
577
|
};
|
|
421
578
|
dialect: "pg";
|
|
422
579
|
}>;
|
|
580
|
+
/** The package's table, or a host's declared over `AUDIT_COLUMNS` with more columns; structurally either. */
|
|
581
|
+
export type AuditTable = typeof auditEvents;
|
|
423
582
|
export type AuditEventRow = typeof auditEvents.$inferSelect;
|
|
424
583
|
export type AuditEventInsert = typeof auditEvents.$inferInsert;
|
|
425
584
|
export declare const tables: {
|
package/dist/tables.js
CHANGED
|
@@ -7,7 +7,14 @@ import { bigint, boolean, index, jsonb, pgTable, smallint, text, timestamp, uuid
|
|
|
7
7
|
* re-exports this from its own schema module so its drizzle instance and its
|
|
8
8
|
* types know it.
|
|
9
9
|
*/
|
|
10
|
-
|
|
10
|
+
/**
|
|
11
|
+
* The ledger's columns, as drizzle builders, so a host that needs a column
|
|
12
|
+
* of its own beside them (a team, a region) declares
|
|
13
|
+
* `pgTable('audit_events', { ...AUDIT_COLUMNS, teamId: uuid('team_id') }, …)`
|
|
14
|
+
* and hands that table to `createLedger`. The package's own `auditEvents`
|
|
15
|
+
* is the same call with nothing added.
|
|
16
|
+
*/
|
|
17
|
+
export const AUDIT_COLUMNS = {
|
|
11
18
|
id: bigint('id', { mode: 'number' }).generatedAlwaysAsIdentity().primaryKey(),
|
|
12
19
|
occurredAt: timestamp('occurred_at', { withTimezone: true }).notNull().default(sql `now()`),
|
|
13
20
|
tenantId: uuid('tenant_id'),
|
|
@@ -32,15 +39,27 @@ export const auditEvents = pgTable('audit_events', {
|
|
|
32
39
|
schemaVersion: smallint('schema_version').notNull().default(1),
|
|
33
40
|
subjectClass: text('subject_class'),
|
|
34
41
|
subjectId: text('subject_id'),
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
.on(t.
|
|
41
|
-
.
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
42
|
+
};
|
|
43
|
+
/** The indexes every ledger table carries, for a host declaring its own table over `AUDIT_COLUMNS`. */
|
|
44
|
+
export function auditIndexes(t) {
|
|
45
|
+
return [
|
|
46
|
+
index('audit_events_tenant_time_idx').on(t.tenantId, t.occurredAt.desc(), t.id.desc()),
|
|
47
|
+
index('audit_events_actor_time_idx').on(t.actorId, t.occurredAt.desc()),
|
|
48
|
+
index('audit_events_action_time_idx').on(t.action, t.occurredAt.desc()),
|
|
49
|
+
index('audit_events_subject_time_idx')
|
|
50
|
+
.on(t.subjectClass, t.subjectId, t.occurredAt.desc(), t.id.desc())
|
|
51
|
+
.where(sql `${t.subjectId} is not null`),
|
|
52
|
+
index('audit_events_request_idx')
|
|
53
|
+
.on(t.requestId, t.occurredAt.desc())
|
|
54
|
+
.where(sql `${t.requestId} is not null`),
|
|
55
|
+
];
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* The ledger, mirrored from `migrations/0001_audit.sql`, which is the source
|
|
59
|
+
* of truth: the SQL carries the CHECKs, the append-only triggers, the partial
|
|
60
|
+
* indexes and the erasure function that drizzle-kit does not generate. A host
|
|
61
|
+
* re-exports this from its own schema module so its drizzle instance and its
|
|
62
|
+
* types know it, or declares a wider table over `AUDIT_COLUMNS`.
|
|
63
|
+
*/
|
|
64
|
+
export const auditEvents = pgTable('audit_events', AUDIT_COLUMNS, (t) => auditIndexes(t));
|
|
46
65
|
export const tables = { events: auditEvents };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wtfalch/audit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "The estate's append-only audit ledger: the table's shape and walls, erasure, export and readers, with a signer a host builds once inside its trusted base and never exports. Per-app Postgres, a host-supplied vocabulary of closed sets, no framework.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|