@willyim/drizzle-audit 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +278 -0
- package/dist/src/cli/generate-migration.d.ts +14 -0
- package/dist/src/cli/generate-migration.d.ts.map +1 -0
- package/dist/src/cli/generate-migration.js +118 -0
- package/dist/src/cli/runner.d.ts +2 -0
- package/dist/src/cli/runner.d.ts.map +1 -0
- package/dist/src/cli/runner.js +23 -0
- package/dist/src/d1/audit-log-schema.d.ts +181 -0
- package/dist/src/d1/audit-log-schema.d.ts.map +1 -0
- package/dist/src/d1/audit-log-schema.js +30 -0
- package/dist/src/d1/index.d.ts +7 -0
- package/dist/src/d1/index.d.ts.map +1 -0
- package/dist/src/d1/index.js +3 -0
- package/dist/src/d1/runtime.d.ts +44 -0
- package/dist/src/d1/runtime.d.ts.map +1 -0
- package/dist/src/d1/runtime.js +68 -0
- package/dist/src/d1/sql.d.ts +37 -0
- package/dist/src/d1/sql.d.ts.map +1 -0
- package/dist/src/d1/sql.js +261 -0
- package/dist/src/d1/types.d.ts +22 -0
- package/dist/src/d1/types.d.ts.map +1 -0
- package/dist/src/d1/types.js +1 -0
- package/dist/src/d1-runtime/index.d.ts +3 -0
- package/dist/src/d1-runtime/index.d.ts.map +1 -0
- package/dist/src/d1-runtime/index.js +1 -0
- package/dist/src/d1-runtime/with-audit.d.ts +77 -0
- package/dist/src/d1-runtime/with-audit.d.ts.map +1 -0
- package/dist/src/d1-runtime/with-audit.js +130 -0
- package/dist/src/index.d.ts +4 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +3 -0
- package/dist/src/postgres/audit-log-schema.d.ts +140 -0
- package/dist/src/postgres/audit-log-schema.d.ts.map +1 -0
- package/dist/src/postgres/audit-log-schema.js +25 -0
- package/dist/src/postgres/index.d.ts +6 -0
- package/dist/src/postgres/index.d.ts.map +1 -0
- package/dist/src/postgres/index.js +3 -0
- package/dist/src/postgres/runtime.d.ts +10 -0
- package/dist/src/postgres/runtime.d.ts.map +1 -0
- package/dist/src/postgres/runtime.js +21 -0
- package/dist/src/postgres/sql.d.ts +14 -0
- package/dist/src/postgres/sql.d.ts.map +1 -0
- package/dist/src/postgres/sql.js +190 -0
- package/dist/src/postgres/types.d.ts +22 -0
- package/dist/src/postgres/types.d.ts.map +1 -0
- package/dist/src/postgres/types.js +1 -0
- package/dist/test/d1-runtime.integration.test.d.ts +2 -0
- package/dist/test/d1-runtime.integration.test.d.ts.map +1 -0
- package/dist/test/d1-runtime.integration.test.js +222 -0
- package/dist/test/d1.integration.test.d.ts +2 -0
- package/dist/test/d1.integration.test.d.ts.map +1 -0
- package/dist/test/d1.integration.test.js +223 -0
- package/dist/test/postgres.integration.test.d.ts +2 -0
- package/dist/test/postgres.integration.test.d.ts.map +1 -0
- package/dist/test/postgres.integration.test.js +286 -0
- package/package.json +66 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { getTableColumns, getTableName, } from "drizzle-orm";
|
|
2
|
+
function getPrimaryKeyColumn(table) {
|
|
3
|
+
const cols = getTableColumns(table);
|
|
4
|
+
const pk = Object.values(cols).find((c) => c.primary);
|
|
5
|
+
return pk ?? null;
|
|
6
|
+
}
|
|
7
|
+
function getRowId(row, pk) {
|
|
8
|
+
if (!pk)
|
|
9
|
+
return null;
|
|
10
|
+
const val = row[pk.name];
|
|
11
|
+
return val != null ? String(val) : null;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Creates an audited wrapper around a Drizzle SQLite database.
|
|
15
|
+
* Each insert/update/delete is wrapped in a transaction that atomically
|
|
16
|
+
* writes to both the target table and the audit_logs table.
|
|
17
|
+
*
|
|
18
|
+
* @param db - A Drizzle SQLite database instance (D1, better-sqlite3, libsql)
|
|
19
|
+
* @param auditTable - The Drizzle table definition for audit_logs
|
|
20
|
+
* @param context - The audit context (userId, optional workspaceId)
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* import { withAudit } from "drizzle-audit/d1-runtime"
|
|
24
|
+
* import { d1AuditLogTable } from "drizzle-audit/d1"
|
|
25
|
+
*
|
|
26
|
+
* const auditLogs = d1AuditLogTable()
|
|
27
|
+
* const audited = withAudit(db, auditLogs, { userId: session.userId })
|
|
28
|
+
*
|
|
29
|
+
* // Audited insert
|
|
30
|
+
* audited.insert(users, { id: "u1", name: "Ada" })
|
|
31
|
+
*
|
|
32
|
+
* // Audited update — captures old + new data
|
|
33
|
+
* audited.update(users, eq(users.id, "u1"), { name: "Ada Lovelace" })
|
|
34
|
+
*
|
|
35
|
+
* // Audited delete — captures deleted data
|
|
36
|
+
* audited.delete(users, eq(users.id, "u1"))
|
|
37
|
+
*
|
|
38
|
+
* // Non-audited access
|
|
39
|
+
* audited.db.select().from(users).all()
|
|
40
|
+
*/
|
|
41
|
+
export function withAudit(db, auditTable, context) {
|
|
42
|
+
const workspaceIdColumn = (() => {
|
|
43
|
+
const cols = getTableColumns(auditTable);
|
|
44
|
+
const known = new Set([
|
|
45
|
+
"id",
|
|
46
|
+
"table_name",
|
|
47
|
+
"operation",
|
|
48
|
+
"row_id",
|
|
49
|
+
"user_id",
|
|
50
|
+
"old_data",
|
|
51
|
+
"new_data",
|
|
52
|
+
"created_at",
|
|
53
|
+
]);
|
|
54
|
+
const extra = Object.keys(cols).find((k) => !known.has(k));
|
|
55
|
+
return extra ?? null;
|
|
56
|
+
})();
|
|
57
|
+
function buildAuditRow(tableName, operation, rowId, oldData, newData) {
|
|
58
|
+
return {
|
|
59
|
+
table_name: tableName,
|
|
60
|
+
operation,
|
|
61
|
+
row_id: rowId,
|
|
62
|
+
user_id: context.userId,
|
|
63
|
+
old_data: oldData ? JSON.stringify(oldData) : null,
|
|
64
|
+
new_data: newData ? JSON.stringify(newData) : null,
|
|
65
|
+
...(workspaceIdColumn && context.workspaceId
|
|
66
|
+
? { [workspaceIdColumn]: context.workspaceId }
|
|
67
|
+
: {}),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
db,
|
|
72
|
+
insert(table, data) {
|
|
73
|
+
const tableName = getTableName(table);
|
|
74
|
+
const pk = getPrimaryKeyColumn(table);
|
|
75
|
+
return db.transaction((tx) => {
|
|
76
|
+
const [row] = tx.insert(table).values(data).returning().all();
|
|
77
|
+
const rowId = getRowId(row, pk);
|
|
78
|
+
tx.insert(auditTable)
|
|
79
|
+
.values(buildAuditRow(tableName, "INSERT", rowId, null, row))
|
|
80
|
+
.run();
|
|
81
|
+
return row;
|
|
82
|
+
});
|
|
83
|
+
},
|
|
84
|
+
update(table, where, data) {
|
|
85
|
+
const tableName = getTableName(table);
|
|
86
|
+
const pk = getPrimaryKeyColumn(table);
|
|
87
|
+
return db.transaction((tx) => {
|
|
88
|
+
const oldRows = tx
|
|
89
|
+
.select()
|
|
90
|
+
.from(table)
|
|
91
|
+
.where(where)
|
|
92
|
+
.all();
|
|
93
|
+
const newRows = tx
|
|
94
|
+
.update(table)
|
|
95
|
+
.set(data)
|
|
96
|
+
.where(where)
|
|
97
|
+
.returning()
|
|
98
|
+
.all();
|
|
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
|
+
});
|
|
109
|
+
},
|
|
110
|
+
delete(table, where) {
|
|
111
|
+
const tableName = getTableName(table);
|
|
112
|
+
const pk = getPrimaryKeyColumn(table);
|
|
113
|
+
return db.transaction((tx) => {
|
|
114
|
+
const oldRows = tx
|
|
115
|
+
.select()
|
|
116
|
+
.from(table)
|
|
117
|
+
.where(where)
|
|
118
|
+
.all();
|
|
119
|
+
tx.delete(table).where(where).run();
|
|
120
|
+
for (const oldRow of oldRows) {
|
|
121
|
+
const rowId = getRowId(oldRow, pk);
|
|
122
|
+
tx.insert(auditTable)
|
|
123
|
+
.values(buildAuditRow(tableName, "DELETE", rowId, oldRow, null))
|
|
124
|
+
.run();
|
|
125
|
+
}
|
|
126
|
+
return oldRows;
|
|
127
|
+
});
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,qBAAqB,CAAA;AACnC,cAAc,eAAe,CAAA;AAC7B,cAAc,uBAAuB,CAAA"}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
export type PgAuditLogTableOptions = {
|
|
2
|
+
/** When set (e.g. "workspace_id"), the table definition includes this optional column to match the install. */
|
|
3
|
+
workspaceIdColumn?: string;
|
|
4
|
+
};
|
|
5
|
+
export declare function pgAuditLogTable(options?: PgAuditLogTableOptions): import("drizzle-orm/pg-core").PgTableWithColumns<{
|
|
6
|
+
name: "audit_logs";
|
|
7
|
+
schema: undefined;
|
|
8
|
+
columns: {
|
|
9
|
+
old_data: import("drizzle-orm/pg-core").PgBuildColumn<"audit_logs", import("drizzle-orm/pg-core").PgJsonbBuilder, {
|
|
10
|
+
name: string;
|
|
11
|
+
tableName: "audit_logs";
|
|
12
|
+
dataType: "object json";
|
|
13
|
+
data: unknown;
|
|
14
|
+
driverParam: unknown;
|
|
15
|
+
notNull: false;
|
|
16
|
+
hasDefault: false;
|
|
17
|
+
isPrimaryKey: false;
|
|
18
|
+
isAutoincrement: false;
|
|
19
|
+
hasRuntimeDefault: false;
|
|
20
|
+
enumValues: undefined;
|
|
21
|
+
identity: undefined;
|
|
22
|
+
generated: undefined;
|
|
23
|
+
insertType: unknown;
|
|
24
|
+
}>;
|
|
25
|
+
new_data: import("drizzle-orm/pg-core").PgBuildColumn<"audit_logs", import("drizzle-orm/pg-core").PgJsonbBuilder, {
|
|
26
|
+
name: string;
|
|
27
|
+
tableName: "audit_logs";
|
|
28
|
+
dataType: "object json";
|
|
29
|
+
data: unknown;
|
|
30
|
+
driverParam: unknown;
|
|
31
|
+
notNull: false;
|
|
32
|
+
hasDefault: false;
|
|
33
|
+
isPrimaryKey: false;
|
|
34
|
+
isAutoincrement: false;
|
|
35
|
+
hasRuntimeDefault: false;
|
|
36
|
+
enumValues: undefined;
|
|
37
|
+
identity: undefined;
|
|
38
|
+
generated: undefined;
|
|
39
|
+
insertType: unknown;
|
|
40
|
+
}>;
|
|
41
|
+
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
|
+
name: string;
|
|
43
|
+
tableName: "audit_logs";
|
|
44
|
+
dataType: "object date";
|
|
45
|
+
data: Date;
|
|
46
|
+
driverParam: string;
|
|
47
|
+
notNull: true;
|
|
48
|
+
hasDefault: true;
|
|
49
|
+
isPrimaryKey: false;
|
|
50
|
+
isAutoincrement: false;
|
|
51
|
+
hasRuntimeDefault: false;
|
|
52
|
+
enumValues: undefined;
|
|
53
|
+
identity: undefined;
|
|
54
|
+
generated: undefined;
|
|
55
|
+
insertType: Date | undefined;
|
|
56
|
+
}>;
|
|
57
|
+
id: import("drizzle-orm/pg-core").PgBuildColumn<"audit_logs", import("drizzle-orm/pg-core").SetIsPrimaryKey<import("drizzle-orm/pg-core").PgBigSerial53Builder>, {
|
|
58
|
+
name: string;
|
|
59
|
+
tableName: "audit_logs";
|
|
60
|
+
dataType: "number int53";
|
|
61
|
+
data: number;
|
|
62
|
+
driverParam: number;
|
|
63
|
+
notNull: true;
|
|
64
|
+
hasDefault: true;
|
|
65
|
+
isPrimaryKey: false;
|
|
66
|
+
isAutoincrement: false;
|
|
67
|
+
hasRuntimeDefault: false;
|
|
68
|
+
enumValues: undefined;
|
|
69
|
+
identity: undefined;
|
|
70
|
+
generated: undefined;
|
|
71
|
+
insertType: number | undefined;
|
|
72
|
+
}>;
|
|
73
|
+
table_name: import("drizzle-orm/pg-core").PgBuildColumn<"audit_logs", import("drizzle-orm/pg-core").SetNotNull<import("drizzle-orm/pg-core").PgTextBuilder<undefined>>, {
|
|
74
|
+
name: string;
|
|
75
|
+
tableName: "audit_logs";
|
|
76
|
+
dataType: "string";
|
|
77
|
+
data: string;
|
|
78
|
+
driverParam: string;
|
|
79
|
+
notNull: true;
|
|
80
|
+
hasDefault: false;
|
|
81
|
+
isPrimaryKey: false;
|
|
82
|
+
isAutoincrement: false;
|
|
83
|
+
hasRuntimeDefault: false;
|
|
84
|
+
enumValues: undefined;
|
|
85
|
+
identity: undefined;
|
|
86
|
+
generated: undefined;
|
|
87
|
+
insertType: string;
|
|
88
|
+
}>;
|
|
89
|
+
operation: import("drizzle-orm/pg-core").PgBuildColumn<"audit_logs", import("drizzle-orm/pg-core").SetNotNull<import("drizzle-orm/pg-core").PgTextBuilder<undefined>>, {
|
|
90
|
+
name: string;
|
|
91
|
+
tableName: "audit_logs";
|
|
92
|
+
dataType: "string";
|
|
93
|
+
data: string;
|
|
94
|
+
driverParam: string;
|
|
95
|
+
notNull: true;
|
|
96
|
+
hasDefault: false;
|
|
97
|
+
isPrimaryKey: false;
|
|
98
|
+
isAutoincrement: false;
|
|
99
|
+
hasRuntimeDefault: false;
|
|
100
|
+
enumValues: undefined;
|
|
101
|
+
identity: undefined;
|
|
102
|
+
generated: undefined;
|
|
103
|
+
insertType: string;
|
|
104
|
+
}>;
|
|
105
|
+
row_id: import("drizzle-orm/pg-core").PgBuildColumn<"audit_logs", import("drizzle-orm/pg-core").PgTextBuilder<undefined>, {
|
|
106
|
+
name: string;
|
|
107
|
+
tableName: "audit_logs";
|
|
108
|
+
dataType: "string";
|
|
109
|
+
data: string;
|
|
110
|
+
driverParam: string;
|
|
111
|
+
notNull: false;
|
|
112
|
+
hasDefault: false;
|
|
113
|
+
isPrimaryKey: false;
|
|
114
|
+
isAutoincrement: false;
|
|
115
|
+
hasRuntimeDefault: false;
|
|
116
|
+
enumValues: undefined;
|
|
117
|
+
identity: undefined;
|
|
118
|
+
generated: undefined;
|
|
119
|
+
insertType: string | null | undefined;
|
|
120
|
+
}>;
|
|
121
|
+
user_id: import("drizzle-orm/pg-core").PgBuildColumn<"audit_logs", import("drizzle-orm/pg-core").PgTextBuilder<undefined>, {
|
|
122
|
+
name: string;
|
|
123
|
+
tableName: "audit_logs";
|
|
124
|
+
dataType: "string";
|
|
125
|
+
data: string;
|
|
126
|
+
driverParam: string;
|
|
127
|
+
notNull: false;
|
|
128
|
+
hasDefault: false;
|
|
129
|
+
isPrimaryKey: false;
|
|
130
|
+
isAutoincrement: false;
|
|
131
|
+
hasRuntimeDefault: false;
|
|
132
|
+
enumValues: undefined;
|
|
133
|
+
identity: undefined;
|
|
134
|
+
generated: undefined;
|
|
135
|
+
insertType: string | null | undefined;
|
|
136
|
+
}>;
|
|
137
|
+
};
|
|
138
|
+
dialect: "pg";
|
|
139
|
+
}>;
|
|
140
|
+
//# sourceMappingURL=audit-log-schema.d.ts.map
|
|
@@ -0,0 +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;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2B/D"}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { bigserial, index, jsonb, pgTable, text, timestamp, } from "drizzle-orm/pg-core";
|
|
2
|
+
export function pgAuditLogTable(options) {
|
|
3
|
+
const workspaceIdColumn = options?.workspaceIdColumn?.trim();
|
|
4
|
+
const columns = {
|
|
5
|
+
id: bigserial("id", { mode: "number" }).primaryKey(),
|
|
6
|
+
table_name: text("table_name").notNull(),
|
|
7
|
+
operation: text("operation").notNull(),
|
|
8
|
+
row_id: text("row_id"),
|
|
9
|
+
user_id: text("user_id"),
|
|
10
|
+
...(workspaceIdColumn
|
|
11
|
+
? { [workspaceIdColumn]: text(workspaceIdColumn) }
|
|
12
|
+
: {}),
|
|
13
|
+
old_data: jsonb("old_data"),
|
|
14
|
+
new_data: jsonb("new_data"),
|
|
15
|
+
created_at: timestamp("created_at", { withTimezone: true })
|
|
16
|
+
.defaultNow()
|
|
17
|
+
.notNull(),
|
|
18
|
+
};
|
|
19
|
+
return pgTable("audit_logs", columns, (table) => [
|
|
20
|
+
index("audit_logs_table_name_idx").on(table.table_name),
|
|
21
|
+
index("audit_logs_row_id_idx").on(table.row_id),
|
|
22
|
+
index("audit_logs_user_id_idx").on(table.user_id),
|
|
23
|
+
index("audit_logs_created_at_idx").on(table.created_at),
|
|
24
|
+
]);
|
|
25
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { pgAuditLogTable } from "./audit-log-schema.js";
|
|
2
|
+
export type { PgAuditLogTableOptions } from "./audit-log-schema.js";
|
|
3
|
+
export { createAttachAuditTriggerSql, createAttachAuditTriggersSql, createAuditAddWorkspaceColumnSql, createAuditInstallSql, } from "./sql.js";
|
|
4
|
+
export { setAuditContext, withAuditedTransaction } from "./runtime.js";
|
|
5
|
+
export type { AuditInstallOptions, AuditSqlExecutor, AuditTransactionCapable, AuditTriggerTarget, } from "./types.js";
|
|
6
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/postgres/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAA;AACvD,YAAY,EAAE,sBAAsB,EAAE,MAAM,uBAAuB,CAAA;AACnE,OAAO,EACL,2BAA2B,EAC3B,4BAA4B,EAC5B,gCAAgC,EAChC,qBAAqB,GACtB,MAAM,UAAU,CAAA;AACjB,OAAO,EAAE,eAAe,EAAE,sBAAsB,EAAE,MAAM,cAAc,CAAA;AAEtE,YAAY,EACV,mBAAmB,EACnB,gBAAgB,EAChB,uBAAuB,EACvB,kBAAkB,GACnB,MAAM,YAAY,CAAA"}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export { pgAuditLogTable } from "./audit-log-schema.js";
|
|
2
|
+
export { createAttachAuditTriggerSql, createAttachAuditTriggersSql, createAuditAddWorkspaceColumnSql, createAuditInstallSql, } from "./sql.js";
|
|
3
|
+
export { setAuditContext, withAuditedTransaction } from "./runtime.js";
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { AuditSqlExecutor, AuditTransactionCapable } from "./types.js";
|
|
2
|
+
export declare function setAuditContext(db: AuditSqlExecutor, actorId: string, contextKey?: string, options?: {
|
|
3
|
+
workspaceId?: string;
|
|
4
|
+
workspaceContextKey?: string;
|
|
5
|
+
}): Promise<void>;
|
|
6
|
+
export declare function withAuditedTransaction<TTransaction extends AuditSqlExecutor, TResult>(db: AuditTransactionCapable<TTransaction>, actorId: string, callback: (tx: TTransaction) => Promise<TResult> | TResult, contextKey?: string, options?: {
|
|
7
|
+
workspaceId?: string;
|
|
8
|
+
workspaceContextKey?: string;
|
|
9
|
+
}): Promise<TResult>;
|
|
10
|
+
//# sourceMappingURL=runtime.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"runtime.d.ts","sourceRoot":"","sources":["../../../src/postgres/runtime.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EACV,gBAAgB,EAChB,uBAAuB,EACxB,MAAM,YAAY,CAAA;AAQnB,wBAAsB,eAAe,CACnC,EAAE,EAAE,gBAAgB,EACpB,OAAO,EAAE,MAAM,EACf,UAAU,SAAgB,EAC1B,OAAO,CAAC,EAAE;IAAE,WAAW,CAAC,EAAE,MAAM,CAAC;IAAC,mBAAmB,CAAC,EAAE,MAAM,CAAA;CAAE,iBAajE;AAED,wBAAsB,sBAAsB,CAC1C,YAAY,SAAS,gBAAgB,EACrC,OAAO,EAEP,EAAE,EAAE,uBAAuB,CAAC,YAAY,CAAC,EACzC,OAAO,EAAE,MAAM,EACf,QAAQ,EAAE,CAAC,EAAE,EAAE,YAAY,KAAK,OAAO,CAAC,OAAO,CAAC,GAAG,OAAO,EAC1D,UAAU,SAAgB,EAC1B,OAAO,CAAC,EAAE;IAAE,WAAW,CAAC,EAAE,MAAM,CAAC;IAAC,mBAAmB,CAAC,EAAE,MAAM,CAAA;CAAE,oBAQjE"}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { sql } from "drizzle-orm";
|
|
2
|
+
function assertActorId(actorId) {
|
|
3
|
+
if (actorId.trim().length === 0) {
|
|
4
|
+
throw new Error("actorId must not be empty");
|
|
5
|
+
}
|
|
6
|
+
}
|
|
7
|
+
export async function setAuditContext(db, actorId, contextKey = "app.user_id", options) {
|
|
8
|
+
assertActorId(actorId);
|
|
9
|
+
await db.execute(sql `select set_config(${contextKey}, ${actorId}, true) as audit_context`);
|
|
10
|
+
if (options?.workspaceId !== undefined && options.workspaceId !== "") {
|
|
11
|
+
const wsKey = options.workspaceContextKey ?? "app.workspace_id";
|
|
12
|
+
await db.execute(sql `select set_config(${wsKey}, ${options.workspaceId}, true) as workspace_context`);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export async function withAuditedTransaction(db, actorId, callback, contextKey = "app.user_id", options) {
|
|
16
|
+
assertActorId(actorId);
|
|
17
|
+
return db.transaction(async (tx) => {
|
|
18
|
+
await setAuditContext(tx, actorId, contextKey, options);
|
|
19
|
+
return await callback(tx);
|
|
20
|
+
});
|
|
21
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { AuditInstallOptions, AuditTriggerTarget } from "./types.js";
|
|
2
|
+
export declare function createAuditInstallSql(options?: AuditInstallOptions): string;
|
|
3
|
+
export declare function createAttachAuditTriggerSql(target: AuditTriggerTarget, options?: AuditInstallOptions): string;
|
|
4
|
+
export declare function createAttachAuditTriggersSql(targets: AuditTriggerTarget[], options?: AuditInstallOptions): string;
|
|
5
|
+
/**
|
|
6
|
+
* Generates SQL to add the workspace column and replace the trigger function on an
|
|
7
|
+
* existing audit_logs table. Use in a new migration when adding workspace_id after
|
|
8
|
+
* the initial install. Options must match your install (auditSchema, auditTable,
|
|
9
|
+
* triggerFunctionName, contextKey, workspaceIdColumn).
|
|
10
|
+
*/
|
|
11
|
+
export declare function createAuditAddWorkspaceColumnSql(options: AuditInstallOptions & {
|
|
12
|
+
workspaceIdColumn: string;
|
|
13
|
+
}): string;
|
|
14
|
+
//# sourceMappingURL=sql.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sql.d.ts","sourceRoot":"","sources":["../../../src/postgres/sql.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,mBAAmB,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAA;AAqHzE,wBAAgB,qBAAqB,CAAC,OAAO,GAAE,mBAAwB,UA6DtE;AAED,wBAAgB,2BAA2B,CACzC,MAAM,EAAE,kBAAkB,EAC1B,OAAO,GAAE,mBAAwB,UAiClC;AAED,wBAAgB,4BAA4B,CAC1C,OAAO,EAAE,kBAAkB,EAAE,EAC7B,OAAO,GAAE,mBAAwB,UASlC;AAED;;;;;GAKG;AACH,wBAAgB,gCAAgC,CAC9C,OAAO,EAAE,mBAAmB,GAAG;IAAE,iBAAiB,EAAE,MAAM,CAAA;CAAE,UAkC7D"}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
const DEFAULT_AUDIT_SCHEMA = "public";
|
|
2
|
+
const DEFAULT_AUDIT_TABLE = "audit_logs";
|
|
3
|
+
const DEFAULT_CONTEXT_KEY = "app.user_id";
|
|
4
|
+
const DEFAULT_TRIGGER_FUNCTION = "drizzle_audit_trigger";
|
|
5
|
+
const DEFAULT_ROW_ID_COLUMN = "id";
|
|
6
|
+
function quoteIdent(value) {
|
|
7
|
+
return `"${value.replaceAll('"', '""')}"`;
|
|
8
|
+
}
|
|
9
|
+
function quoteLiteral(value) {
|
|
10
|
+
return `'${value.replaceAll("'", "''")}'`;
|
|
11
|
+
}
|
|
12
|
+
function qualifyName(name, schema) {
|
|
13
|
+
if (!schema) {
|
|
14
|
+
return quoteIdent(name);
|
|
15
|
+
}
|
|
16
|
+
return `${quoteIdent(schema)}.${quoteIdent(name)}`;
|
|
17
|
+
}
|
|
18
|
+
function assertNonEmpty(value, label) {
|
|
19
|
+
if (value.trim().length === 0) {
|
|
20
|
+
throw new Error(`${label} must not be empty`);
|
|
21
|
+
}
|
|
22
|
+
return value;
|
|
23
|
+
}
|
|
24
|
+
function buildTriggerFunctionSql(qualifiedAuditTable, qualifiedTriggerFunction, contextLiteral, options) {
|
|
25
|
+
const workspaceIdColumn = options.workspaceIdColumn?.trim();
|
|
26
|
+
const hasWorkspace = Boolean(workspaceIdColumn);
|
|
27
|
+
const workspaceContextLiteral = hasWorkspace
|
|
28
|
+
? quoteLiteral("app." + workspaceIdColumn)
|
|
29
|
+
: "";
|
|
30
|
+
const workspaceCol = hasWorkspace ? quoteIdent(workspaceIdColumn) : "";
|
|
31
|
+
const declWorkspace = hasWorkspace ? "\n audit_workspace TEXT;" : "";
|
|
32
|
+
const readWorkspace = hasWorkspace
|
|
33
|
+
? `\n audit_workspace := NULLIF(current_setting(${workspaceContextLiteral}, true), '');`
|
|
34
|
+
: "";
|
|
35
|
+
const insertColsBase = "table_name, operation, row_id, user_id";
|
|
36
|
+
const insertColsInsert = hasWorkspace
|
|
37
|
+
? `${insertColsBase}, ${workspaceCol}, new_data`
|
|
38
|
+
: `${insertColsBase}, new_data`;
|
|
39
|
+
const insertColsUpdate = hasWorkspace
|
|
40
|
+
? `${insertColsBase}, ${workspaceCol}, old_data, new_data`
|
|
41
|
+
: `${insertColsBase}, old_data, new_data`;
|
|
42
|
+
const insertColsDelete = hasWorkspace
|
|
43
|
+
? `${insertColsBase}, ${workspaceCol}, old_data`
|
|
44
|
+
: `${insertColsBase}, old_data`;
|
|
45
|
+
const valuesInsert = hasWorkspace
|
|
46
|
+
? `TG_TABLE_NAME, TG_OP, current_row_id, audit_user, audit_workspace, to_jsonb(NEW)`
|
|
47
|
+
: `TG_TABLE_NAME, TG_OP, current_row_id, audit_user, to_jsonb(NEW)`;
|
|
48
|
+
const valuesUpdate = hasWorkspace
|
|
49
|
+
? `TG_TABLE_NAME, TG_OP, current_row_id, audit_user, audit_workspace, to_jsonb(OLD), to_jsonb(NEW)`
|
|
50
|
+
: `TG_TABLE_NAME, TG_OP, current_row_id, audit_user, to_jsonb(OLD), to_jsonb(NEW)`;
|
|
51
|
+
const valuesDelete = hasWorkspace
|
|
52
|
+
? `TG_TABLE_NAME, TG_OP, current_row_id, audit_user, audit_workspace, to_jsonb(OLD)`
|
|
53
|
+
: `TG_TABLE_NAME, TG_OP, current_row_id, audit_user, to_jsonb(OLD)`;
|
|
54
|
+
return `
|
|
55
|
+
CREATE OR REPLACE FUNCTION ${qualifiedTriggerFunction}()
|
|
56
|
+
RETURNS TRIGGER AS $$
|
|
57
|
+
DECLARE
|
|
58
|
+
audit_user TEXT;${declWorkspace}
|
|
59
|
+
row_id_column TEXT;
|
|
60
|
+
current_row JSONB;
|
|
61
|
+
current_row_id TEXT;
|
|
62
|
+
BEGIN
|
|
63
|
+
audit_user := NULLIF(current_setting(${contextLiteral}, true), '');${readWorkspace}
|
|
64
|
+
|
|
65
|
+
row_id_column := COALESCE(NULLIF(TG_ARGV[0], ''), ${quoteLiteral(DEFAULT_ROW_ID_COLUMN)});
|
|
66
|
+
|
|
67
|
+
IF TG_OP = 'DELETE' THEN
|
|
68
|
+
current_row := to_jsonb(OLD);
|
|
69
|
+
ELSE
|
|
70
|
+
current_row := to_jsonb(NEW);
|
|
71
|
+
END IF;
|
|
72
|
+
|
|
73
|
+
IF NOT (current_row ? row_id_column) THEN
|
|
74
|
+
RAISE EXCEPTION 'Missing row id column "%" on audited table %.%.',
|
|
75
|
+
row_id_column,
|
|
76
|
+
TG_TABLE_SCHEMA,
|
|
77
|
+
TG_TABLE_NAME;
|
|
78
|
+
END IF;
|
|
79
|
+
|
|
80
|
+
current_row_id := current_row ->> row_id_column;
|
|
81
|
+
|
|
82
|
+
IF TG_OP = 'INSERT' THEN
|
|
83
|
+
INSERT INTO ${qualifiedAuditTable} (${insertColsInsert})
|
|
84
|
+
VALUES (${valuesInsert});
|
|
85
|
+
RETURN NEW;
|
|
86
|
+
END IF;
|
|
87
|
+
|
|
88
|
+
IF TG_OP = 'UPDATE' THEN
|
|
89
|
+
INSERT INTO ${qualifiedAuditTable} (${insertColsUpdate})
|
|
90
|
+
VALUES (${valuesUpdate});
|
|
91
|
+
RETURN NEW;
|
|
92
|
+
END IF;
|
|
93
|
+
|
|
94
|
+
INSERT INTO ${qualifiedAuditTable} (${insertColsDelete})
|
|
95
|
+
VALUES (${valuesDelete});
|
|
96
|
+
RETURN OLD;
|
|
97
|
+
END;
|
|
98
|
+
$$ LANGUAGE plpgsql;`.trim();
|
|
99
|
+
}
|
|
100
|
+
export function createAuditInstallSql(options = {}) {
|
|
101
|
+
const auditSchema = assertNonEmpty(options.auditSchema ?? DEFAULT_AUDIT_SCHEMA, "auditSchema");
|
|
102
|
+
const auditTable = assertNonEmpty(options.auditTable ?? DEFAULT_AUDIT_TABLE, "auditTable");
|
|
103
|
+
const contextKey = assertNonEmpty(options.contextKey ?? DEFAULT_CONTEXT_KEY, "contextKey");
|
|
104
|
+
const triggerFunctionName = assertNonEmpty(options.triggerFunctionName ?? DEFAULT_TRIGGER_FUNCTION, "triggerFunctionName");
|
|
105
|
+
const workspaceIdColumn = options.workspaceIdColumn?.trim();
|
|
106
|
+
const qualifiedAuditTable = qualifyName(auditTable, auditSchema);
|
|
107
|
+
const qualifiedTriggerFunction = qualifyName(triggerFunctionName, auditSchema);
|
|
108
|
+
const contextLiteral = quoteLiteral(contextKey);
|
|
109
|
+
const tableColumns = [
|
|
110
|
+
"id BIGSERIAL PRIMARY KEY",
|
|
111
|
+
"table_name TEXT NOT NULL",
|
|
112
|
+
"operation TEXT NOT NULL CHECK (operation IN ('INSERT', 'UPDATE', 'DELETE'))",
|
|
113
|
+
"row_id TEXT",
|
|
114
|
+
"user_id TEXT",
|
|
115
|
+
...(workspaceIdColumn ? [quoteIdent(workspaceIdColumn) + " TEXT"] : []),
|
|
116
|
+
"old_data JSONB",
|
|
117
|
+
"new_data JSONB",
|
|
118
|
+
"created_at TIMESTAMPTZ NOT NULL DEFAULT now()",
|
|
119
|
+
];
|
|
120
|
+
const indexStatements = [
|
|
121
|
+
`CREATE INDEX IF NOT EXISTS ${quoteIdent(`${auditTable}_table_name_idx`)} ON ${qualifiedAuditTable} (table_name);`,
|
|
122
|
+
`CREATE INDEX IF NOT EXISTS ${quoteIdent(`${auditTable}_row_id_idx`)} ON ${qualifiedAuditTable} (row_id);`,
|
|
123
|
+
`CREATE INDEX IF NOT EXISTS ${quoteIdent(`${auditTable}_user_id_idx`)} ON ${qualifiedAuditTable} (user_id);`,
|
|
124
|
+
...(workspaceIdColumn
|
|
125
|
+
? [
|
|
126
|
+
`CREATE INDEX IF NOT EXISTS ${quoteIdent(`${auditTable}_${workspaceIdColumn}_idx`)} ON ${qualifiedAuditTable} (${quoteIdent(workspaceIdColumn)});`,
|
|
127
|
+
]
|
|
128
|
+
: []),
|
|
129
|
+
`CREATE INDEX IF NOT EXISTS ${quoteIdent(`${auditTable}_created_at_idx`)} ON ${qualifiedAuditTable} (created_at DESC);`,
|
|
130
|
+
];
|
|
131
|
+
return [
|
|
132
|
+
`CREATE SCHEMA IF NOT EXISTS ${quoteIdent(auditSchema)};`,
|
|
133
|
+
`
|
|
134
|
+
CREATE TABLE IF NOT EXISTS ${qualifiedAuditTable} (
|
|
135
|
+
${tableColumns.join(",\n ")}
|
|
136
|
+
);`.trim(),
|
|
137
|
+
...indexStatements,
|
|
138
|
+
buildTriggerFunctionSql(qualifiedAuditTable, qualifiedTriggerFunction, contextLiteral, { workspaceIdColumn: workspaceIdColumn ?? undefined }),
|
|
139
|
+
].join("\n\n");
|
|
140
|
+
}
|
|
141
|
+
export function createAttachAuditTriggerSql(target, options = {}) {
|
|
142
|
+
const table = assertNonEmpty(target.table, "table");
|
|
143
|
+
const schema = target.schema?.trim() || undefined;
|
|
144
|
+
const rowIdColumn = assertNonEmpty(target.rowIdColumn ?? DEFAULT_ROW_ID_COLUMN, "rowIdColumn");
|
|
145
|
+
const triggerName = assertNonEmpty(target.triggerName ?? `${table}_audit`, "triggerName");
|
|
146
|
+
const auditSchema = assertNonEmpty(options.auditSchema ?? DEFAULT_AUDIT_SCHEMA, "auditSchema");
|
|
147
|
+
const triggerFunctionName = assertNonEmpty(options.triggerFunctionName ?? DEFAULT_TRIGGER_FUNCTION, "triggerFunctionName");
|
|
148
|
+
const qualifiedTable = qualifyName(table, schema);
|
|
149
|
+
const qualifiedTriggerFunction = qualifyName(triggerFunctionName, auditSchema);
|
|
150
|
+
return `
|
|
151
|
+
DROP TRIGGER IF EXISTS ${quoteIdent(triggerName)} ON ${qualifiedTable};
|
|
152
|
+
|
|
153
|
+
CREATE TRIGGER ${quoteIdent(triggerName)}
|
|
154
|
+
AFTER INSERT OR UPDATE OR DELETE
|
|
155
|
+
ON ${qualifiedTable}
|
|
156
|
+
FOR EACH ROW
|
|
157
|
+
EXECUTE FUNCTION ${qualifiedTriggerFunction}(${quoteLiteral(rowIdColumn)});
|
|
158
|
+
`.trim();
|
|
159
|
+
}
|
|
160
|
+
export function createAttachAuditTriggersSql(targets, options = {}) {
|
|
161
|
+
if (targets.length === 0) {
|
|
162
|
+
throw new Error("targets must contain at least one audited table");
|
|
163
|
+
}
|
|
164
|
+
return targets
|
|
165
|
+
.map((target) => createAttachAuditTriggerSql(target, options))
|
|
166
|
+
.join("\n\n");
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Generates SQL to add the workspace column and replace the trigger function on an
|
|
170
|
+
* existing audit_logs table. Use in a new migration when adding workspace_id after
|
|
171
|
+
* the initial install. Options must match your install (auditSchema, auditTable,
|
|
172
|
+
* triggerFunctionName, contextKey, workspaceIdColumn).
|
|
173
|
+
*/
|
|
174
|
+
export function createAuditAddWorkspaceColumnSql(options) {
|
|
175
|
+
const workspaceIdColumn = assertNonEmpty(options.workspaceIdColumn.trim(), "workspaceIdColumn");
|
|
176
|
+
const auditSchema = assertNonEmpty(options.auditSchema ?? DEFAULT_AUDIT_SCHEMA, "auditSchema");
|
|
177
|
+
const auditTable = assertNonEmpty(options.auditTable ?? DEFAULT_AUDIT_TABLE, "auditTable");
|
|
178
|
+
const contextKey = assertNonEmpty(options.contextKey ?? DEFAULT_CONTEXT_KEY, "contextKey");
|
|
179
|
+
const triggerFunctionName = assertNonEmpty(options.triggerFunctionName ?? DEFAULT_TRIGGER_FUNCTION, "triggerFunctionName");
|
|
180
|
+
const qualifiedAuditTable = qualifyName(auditTable, auditSchema);
|
|
181
|
+
const qualifiedTriggerFunction = qualifyName(triggerFunctionName, auditSchema);
|
|
182
|
+
const contextLiteral = quoteLiteral(contextKey);
|
|
183
|
+
return [
|
|
184
|
+
`ALTER TABLE ${qualifiedAuditTable} ADD COLUMN IF NOT EXISTS ${quoteIdent(workspaceIdColumn)} TEXT;`,
|
|
185
|
+
`CREATE INDEX IF NOT EXISTS ${quoteIdent(`${auditTable}_${workspaceIdColumn}_idx`)} ON ${qualifiedAuditTable} (${quoteIdent(workspaceIdColumn)});`,
|
|
186
|
+
buildTriggerFunctionSql(qualifiedAuditTable, qualifiedTriggerFunction, contextLiteral, {
|
|
187
|
+
workspaceIdColumn,
|
|
188
|
+
}),
|
|
189
|
+
].join("\n\n");
|
|
190
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { SQL } from "drizzle-orm";
|
|
2
|
+
export type AuditSqlExecutor = {
|
|
3
|
+
execute: (query: SQL) => Promise<unknown>;
|
|
4
|
+
};
|
|
5
|
+
export type AuditTransactionCapable<TTransaction extends AuditSqlExecutor> = {
|
|
6
|
+
transaction: <TResult>(callback: (tx: TTransaction) => Promise<TResult>) => Promise<TResult>;
|
|
7
|
+
};
|
|
8
|
+
export type AuditInstallOptions = {
|
|
9
|
+
auditSchema?: string;
|
|
10
|
+
auditTable?: string;
|
|
11
|
+
contextKey?: string;
|
|
12
|
+
triggerFunctionName?: string;
|
|
13
|
+
/** When set (e.g. "workspace_id"), the audit table and trigger include this column; trigger reads from session variable app.${workspaceIdColumn}. */
|
|
14
|
+
workspaceIdColumn?: string;
|
|
15
|
+
};
|
|
16
|
+
export type AuditTriggerTarget = {
|
|
17
|
+
table: string;
|
|
18
|
+
schema?: string;
|
|
19
|
+
rowIdColumn?: string;
|
|
20
|
+
triggerName?: string;
|
|
21
|
+
};
|
|
22
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../src/postgres/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,GAAG,EAAE,MAAM,aAAa,CAAA;AAEtC,MAAM,MAAM,gBAAgB,GAAG;IAC7B,OAAO,EAAE,CAAC,KAAK,EAAE,GAAG,KAAK,OAAO,CAAC,OAAO,CAAC,CAAA;CAC1C,CAAA;AAED,MAAM,MAAM,uBAAuB,CAAC,YAAY,SAAS,gBAAgB,IAAI;IAC3E,WAAW,EAAE,CAAC,OAAO,EACnB,QAAQ,EAAE,CAAC,EAAE,EAAE,YAAY,KAAK,OAAO,CAAC,OAAO,CAAC,KAC7C,OAAO,CAAC,OAAO,CAAC,CAAA;CACtB,CAAA;AAED,MAAM,MAAM,mBAAmB,GAAG;IAChC,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,mBAAmB,CAAC,EAAE,MAAM,CAAA;IAC5B,qJAAqJ;IACrJ,iBAAiB,CAAC,EAAE,MAAM,CAAA;CAC3B,CAAA;AAED,MAAM,MAAM,kBAAkB,GAAG;IAC/B,KAAK,EAAE,MAAM,CAAA;IACb,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,WAAW,CAAC,EAAE,MAAM,CAAA;CACrB,CAAA"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"d1-runtime.integration.test.d.ts","sourceRoot":"","sources":["../../test/d1-runtime.integration.test.ts"],"names":[],"mappings":""}
|