@facilis.dev/db 0.0.0-stage → 0.1.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.
Files changed (66) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +18 -2
  3. package/dist/client.d.ts +78 -0
  4. package/dist/client.js +208 -0
  5. package/dist/files.d.ts +268 -0
  6. package/dist/files.js +88 -0
  7. package/dist/index.d.ts +23 -0
  8. package/dist/index.js +23 -0
  9. package/dist/jobs.d.ts +255 -0
  10. package/dist/jobs.js +64 -0
  11. package/dist/live-schema.d.ts +39 -0
  12. package/dist/live-schema.js +97 -0
  13. package/dist/migrator.d.ts +20 -0
  14. package/dist/migrator.js +66 -0
  15. package/dist/policy.d.ts +50 -0
  16. package/dist/policy.js +111 -0
  17. package/dist/query-console.d.ts +44 -0
  18. package/dist/query-console.js +173 -0
  19. package/dist/read-only-sql.d.ts +13 -0
  20. package/dist/read-only-sql.js +145 -0
  21. package/dist/rls.d.ts +37 -0
  22. package/dist/rls.js +90 -0
  23. package/dist/schema-digest.d.ts +97 -0
  24. package/dist/schema-digest.js +178 -0
  25. package/dist/schema-drift.d.ts +43 -0
  26. package/dist/schema-drift.js +176 -0
  27. package/dist/snapshot-readers.d.ts +3 -0
  28. package/dist/snapshot-readers.js +182 -0
  29. package/dist/snapshot.d.ts +92 -0
  30. package/dist/snapshot.js +21 -0
  31. package/dist/sudo-audit.d.ts +60 -0
  32. package/dist/sudo-audit.js +45 -0
  33. package/dist/sudo.d.ts +54 -0
  34. package/dist/sudo.js +137 -0
  35. package/dist/table-guard.d.ts +37 -0
  36. package/dist/table-guard.js +228 -0
  37. package/dist/tasks.d.ts +312 -0
  38. package/dist/tasks.js +52 -0
  39. package/dist/tenant-column.d.ts +21 -0
  40. package/dist/tenant-column.js +27 -0
  41. package/dist/views.d.ts +92 -0
  42. package/dist/views.js +186 -0
  43. package/dist/workflows.d.ts +289 -0
  44. package/dist/workflows.js +55 -0
  45. package/package.json +43 -4
  46. package/src/client.ts +291 -0
  47. package/src/files.ts +124 -0
  48. package/src/index.ts +174 -0
  49. package/src/jobs.ts +76 -0
  50. package/src/live-schema.ts +145 -0
  51. package/src/migrator.ts +92 -0
  52. package/src/policy.ts +174 -0
  53. package/src/query-console.ts +215 -0
  54. package/src/read-only-sql.ts +156 -0
  55. package/src/rls.ts +109 -0
  56. package/src/schema-digest.ts +240 -0
  57. package/src/schema-drift.ts +234 -0
  58. package/src/snapshot-readers.ts +212 -0
  59. package/src/snapshot.ts +122 -0
  60. package/src/sudo-audit.ts +91 -0
  61. package/src/sudo.ts +200 -0
  62. package/src/table-guard.ts +296 -0
  63. package/src/tasks.ts +79 -0
  64. package/src/tenant-column.ts +29 -0
  65. package/src/views.ts +291 -0
  66. package/src/workflows.ts +76 -0
@@ -0,0 +1,234 @@
1
+ import type { LivePolicySnapshot, LiveSchemaSnapshot, LiveTableSnapshot } from "./live-schema.ts";
2
+ import type { PolicySnapshot, SchemaSnapshot, TableSnapshot } from "./snapshot.ts";
3
+
4
+ /**
5
+ * Code↔live schema diff (SPEC §11.1): a pure comparison of the snapshot derived from
6
+ * `db/schema.ts` with the one read from the running database. The Studio renders the
7
+ * resulting entries; the release preview (SPEC §11.3) can reuse the same typed drift.
8
+ */
9
+
10
+ export type SchemaDriftKind =
11
+ | "table_missing"
12
+ | "table_extra"
13
+ | "column_missing"
14
+ | "column_extra"
15
+ | "column_type"
16
+ | "column_nullability"
17
+ | "rls_disabled"
18
+ | "policy_missing"
19
+ | "policy_extra"
20
+ | "policy_changed";
21
+
22
+ /**
23
+ * One difference. `expected` is the code-side value and `actual` the live one, so a
24
+ * consumer never has to guess which side of the diff it is looking at.
25
+ */
26
+ export interface SchemaDrift {
27
+ kind: SchemaDriftKind;
28
+ table: string;
29
+ column?: string;
30
+ policy?: string;
31
+ expected?: string | null;
32
+ actual?: string | null;
33
+ }
34
+
35
+ /** Compare the code schema against the live one, deterministically ordered. */
36
+ export function diffSchemaSnapshots(code: SchemaSnapshot, live: LiveSchemaSnapshot): SchemaDrift[] {
37
+ const liveTables = new Map(live.tables.map((table) => [table.name, table]));
38
+ const codeTables = new Set(code.tables.map((table) => table.name));
39
+ const drift: SchemaDrift[] = [];
40
+
41
+ for (const table of code.tables) {
42
+ const liveTable = liveTables.get(table.name);
43
+ if (liveTable === undefined) {
44
+ drift.push({ kind: "table_missing", table: table.name });
45
+ continue;
46
+ }
47
+ diffColumns(table, liveTable, drift);
48
+ // A scoped table without RLS is the one combo the security invariants forbid
49
+ // (SPEC §8.4.5); RLS off on an unscoped table is reported by the panel badge only.
50
+ if (table.scopeColumn !== undefined && !liveTable.rlsEnabled) {
51
+ drift.push({ kind: "rls_disabled", table: table.name });
52
+ }
53
+ diffPolicies(table, liveTable, drift);
54
+ }
55
+
56
+ for (const table of live.tables) {
57
+ if (!codeTables.has(table.name)) drift.push({ kind: "table_extra", table: table.name });
58
+ }
59
+
60
+ return drift.toSorted(compareDrift);
61
+ }
62
+
63
+ function diffColumns(
64
+ table: TableSnapshot,
65
+ liveTable: LiveTableSnapshot,
66
+ drift: SchemaDrift[],
67
+ ): void {
68
+ const liveColumns = new Map(liveTable.columns.map((column) => [column.name, column]));
69
+ for (const column of table.columns) {
70
+ const liveColumn = liveColumns.get(column.name);
71
+ if (liveColumn === undefined) {
72
+ drift.push({ kind: "column_missing", table: table.name, column: column.name });
73
+ continue;
74
+ }
75
+ const expectedType = canonicalColumnType(column.type);
76
+ const actualType = canonicalColumnType(liveColumn.type);
77
+ if (expectedType !== actualType) {
78
+ drift.push({
79
+ kind: "column_type",
80
+ table: table.name,
81
+ column: column.name,
82
+ expected: column.type,
83
+ actual: liveColumn.type,
84
+ });
85
+ }
86
+ if (column.notNull !== liveColumn.notNull) {
87
+ drift.push({
88
+ kind: "column_nullability",
89
+ table: table.name,
90
+ column: column.name,
91
+ expected: nullability(column.notNull),
92
+ actual: nullability(liveColumn.notNull),
93
+ });
94
+ }
95
+ }
96
+ const codeColumns = new Set(table.columns.map((column) => column.name));
97
+ for (const liveColumn of liveTable.columns) {
98
+ if (!codeColumns.has(liveColumn.name)) {
99
+ drift.push({ kind: "column_extra", table: table.name, column: liveColumn.name });
100
+ }
101
+ }
102
+ }
103
+
104
+ function diffPolicies(
105
+ table: TableSnapshot,
106
+ liveTable: LiveTableSnapshot,
107
+ drift: SchemaDrift[],
108
+ ): void {
109
+ const livePolicies = new Map(liveTable.policies.map((policy) => [policy.name, policy]));
110
+ const codePolicies = new Set(table.policies.map((policy) => policy.name));
111
+ for (const policy of table.policies) {
112
+ const livePolicy = livePolicies.get(policy.name);
113
+ if (livePolicy === undefined) {
114
+ drift.push({ kind: "policy_missing", table: table.name, policy: policy.name });
115
+ continue;
116
+ }
117
+ const expected = policySignature(policy);
118
+ const actual = livePolicySignature(livePolicy);
119
+ if (expected !== actual) {
120
+ drift.push({
121
+ kind: "policy_changed",
122
+ table: table.name,
123
+ policy: policy.name,
124
+ expected,
125
+ actual,
126
+ });
127
+ }
128
+ }
129
+ for (const livePolicy of liveTable.policies) {
130
+ if (!codePolicies.has(livePolicy.name)) {
131
+ drift.push({ kind: "policy_extra", table: table.name, policy: livePolicy.name });
132
+ }
133
+ }
134
+ }
135
+
136
+ function nullability(notNull: boolean): string {
137
+ return notNull ? "not null" : "nullable";
138
+ }
139
+
140
+ /** Code-side signature: command + permissiveness + rendered predicates. */
141
+ function policySignature(policy: PolicySnapshot): string {
142
+ return [
143
+ policy.command.toUpperCase(),
144
+ isRestrictivePolicy(policy) ? "RESTRICTIVE" : "PERMISSIVE",
145
+ normalizePolicySql(policy.using),
146
+ normalizePolicySql(policy.withCheck),
147
+ ].join("|");
148
+ }
149
+
150
+ function livePolicySignature(policy: LivePolicySnapshot): string {
151
+ return [
152
+ policy.command.toUpperCase(),
153
+ policy.restrictive ? "RESTRICTIVE" : "PERMISSIVE",
154
+ normalizePolicySql(policy.using),
155
+ normalizePolicySql(policy.withCheck),
156
+ ].join("|");
157
+ }
158
+
159
+ /**
160
+ * Code-side permissiveness: `PolicySnapshot` keeps only the rendered SQL, so the flag is
161
+ * read from the `AS RESTRICTIVE` clause the generator emits (D-008).
162
+ */
163
+ export function isRestrictivePolicy(policy: PolicySnapshot): boolean {
164
+ return /\bas\s+restrictive\b/i.test(policy.sql);
165
+ }
166
+
167
+ /**
168
+ * Postgres re-renders a policy's qualifier (adds parentheses and explicit `::text`
169
+ * casts), so raw SQL text never matches the Drizzle rendering even when the policies are
170
+ * identical. Compare a normalized form instead: lower-case, casts removed, quotes and
171
+ * parentheses dropped, whitespace collapsed away.
172
+ *
173
+ * Exported so the canonical schema digest (./schema-digest.ts) normalizes the same way
174
+ * instead of carrying a second copy.
175
+ */
176
+ export function normalizePolicySql(value: string | null): string {
177
+ if (value === null) return "";
178
+ return value
179
+ .toLowerCase()
180
+ .replace(
181
+ /::(?:uuid|text|jsonb|json|integer|int4|int8|bigint|boolean|bool|numeric|date|timestamptz|timestamp(?: with(?:out)? time zone)?)\b/g,
182
+ "",
183
+ )
184
+ .replace(/["'()]/g, "")
185
+ .replace(/\s+/g, "");
186
+ }
187
+
188
+ const TYPE_ALIASES: Readonly<Record<string, string>> = {
189
+ serial: "integer",
190
+ serial4: "integer",
191
+ bigserial: "bigint",
192
+ serial8: "bigint",
193
+ smallserial: "smallint",
194
+ serial2: "smallint",
195
+ int: "integer",
196
+ int4: "integer",
197
+ int8: "bigint",
198
+ int2: "smallint",
199
+ bool: "boolean",
200
+ float4: "real",
201
+ float8: "double precision",
202
+ decimal: "numeric",
203
+ bpchar: "char",
204
+ };
205
+
206
+ /**
207
+ * Normalize both renderers onto one spelling: Drizzle says `varchar(120)`/`serial`,
208
+ * `format_type()` says `character varying(120)`/`integer`. Without this every column
209
+ * would look like drift.
210
+ */
211
+ export function canonicalColumnType(value: string): string {
212
+ const array = value.trim().endsWith("[]") ? "[]" : "";
213
+ const bare = array === "[]" ? value.trim().slice(0, -2) : value.trim();
214
+ const type = bare
215
+ .toLowerCase()
216
+ .replace(/\bcharacter varying\b/g, "varchar")
217
+ .replace(/\bcharacter\b/g, "char")
218
+ // Only the "without" suffix is catalog-only: Drizzle renders no-tz columns as plain
219
+ // `timestamp`/`time`, while the catalog spells the default timezone out. Keeping
220
+ // "with time zone" makes a tz change a real type drift.
221
+ .replace(/\s+without\s+time zone\b/g, "")
222
+ .replace(/\s*,\s*/g, ",");
223
+ const base = /^([a-z][a-z0-9_]*)/.exec(type)?.[1] ?? type;
224
+ return (TYPE_ALIASES[base] ?? base) + type.slice(base.length) + array;
225
+ }
226
+
227
+ function compareDrift(left: SchemaDrift, right: SchemaDrift): number {
228
+ return (
229
+ left.table.localeCompare(right.table) ||
230
+ (left.column ?? "").localeCompare(right.column ?? "") ||
231
+ (left.policy ?? "").localeCompare(right.policy ?? "") ||
232
+ left.kind.localeCompare(right.kind)
233
+ );
234
+ }
@@ -0,0 +1,212 @@
1
+ import { is, SQL } from "drizzle-orm";
2
+ import { getTableConfig, PgDialect, PgTable, type PgColumn } from "drizzle-orm/pg-core";
3
+
4
+ import { DEFAULT_OWNER_COLUMNS, DEFAULT_TENANT_COLUMNS } from "./client.ts";
5
+ import { policyCreateSql } from "./rls.ts";
6
+ import type {
7
+ ColumnSnapshot,
8
+ IndexSnapshot,
9
+ PolicyCommand,
10
+ ScopeColumnSnapshot,
11
+ TableSnapshot,
12
+ UnsupportedDeclarationSnapshot,
13
+ } from "./snapshot.ts";
14
+
15
+ /**
16
+ * The read half of the declared snapshot: Drizzle's table config -> the public shapes in
17
+ * ./snapshot.ts. Kept separate so the contract file stays a contract.
18
+ */
19
+
20
+ const dialect = new PgDialect();
21
+
22
+ export function snapshotTable(table: PgTable, enums: Map<string, string[]>): TableSnapshot {
23
+ const config = getTableConfig(table);
24
+ const primaryKey = primaryKeyColumns(config);
25
+ const columns = config.columns.map((column) => snapshotColumn(column, primaryKey, enums));
26
+ const unsupported: UnsupportedDeclarationSnapshot[] = [];
27
+ const indexes = config.indexes.map((index) => snapshotIndex(index, config.name, unsupported));
28
+ const snapshot: TableSnapshot = {
29
+ name: config.name,
30
+ rlsEnabled: config.enableRLS,
31
+ columns,
32
+ foreignKeys: config.foreignKeys.map((foreignKey) => {
33
+ const reference = foreignKey.reference();
34
+ const onDelete = toForeignKeyAction(foreignKey.onDelete);
35
+ const onUpdate = toForeignKeyAction(foreignKey.onUpdate);
36
+ return {
37
+ columns: reference.columns.map((column) => column.name),
38
+ targetTable: getTableConfig(reference.foreignTable).name,
39
+ targetColumns: reference.foreignColumns.map((column) => column.name),
40
+ ...(onDelete === undefined ? {} : { onDelete }),
41
+ ...(onUpdate === undefined ? {} : { onUpdate }),
42
+ };
43
+ }),
44
+ policies: config.policies.map((policy) => ({
45
+ name: policy.name,
46
+ command: (policy.for ?? "all").toUpperCase() as PolicyCommand,
47
+ using: policy.using === undefined ? null : dialect.sqlToQuery(policy.using).sql,
48
+ withCheck: policy.withCheck === undefined ? null : dialect.sqlToQuery(policy.withCheck).sql,
49
+ sql: policyCreateSql(config.name, policy),
50
+ })),
51
+ primaryKey,
52
+ indexes,
53
+ checks: config.checks.map((check) => ({
54
+ name: check.name,
55
+ expression: dialect.sqlToQuery(check.value).sql,
56
+ })),
57
+ uniqueConstraints: config.uniqueConstraints.map((constraint) => ({
58
+ name:
59
+ constraint.name ?? constraint.getName() ?? derivedUniqueName(config.name, constraint.columns),
60
+ columns: constraint.columns.map((column) => column.name),
61
+ })),
62
+ };
63
+
64
+ if (unsupported.length > 0) snapshot.unsupported = unsupported;
65
+ const scopeColumn = detectScopeColumn(columns.map((column) => column.name));
66
+ if (scopeColumn !== undefined) snapshot.scopeColumn = scopeColumn;
67
+ return snapshot;
68
+ }
69
+
70
+ /** The table's ordered PK: a table-level `primaryKey()` wins over column-level `.primaryKey()`. */
71
+ function primaryKeyColumns(config: ReturnType<typeof getTableConfig>): string[] {
72
+ const tableLevel = config.primaryKeys[0];
73
+ if (tableLevel !== undefined) return tableLevel.columns.map((column) => column.name);
74
+ return config.columns.filter((column) => column.primary).map((column) => column.name);
75
+ }
76
+
77
+ function snapshotColumn(
78
+ column: PgColumn,
79
+ primaryKey: readonly string[],
80
+ enums: Map<string, string[]>,
81
+ ): ColumnSnapshot {
82
+ const snapshot: ColumnSnapshot = {
83
+ name: column.name,
84
+ type: column.getSQLType(),
85
+ notNull: column.notNull,
86
+ default: renderDefault(column.default),
87
+ // A composite (table-level) PK is declared on the table, not on its columns, so
88
+ // `column.primary` alone would disagree with the live catalog's indisprimary view.
89
+ isPrimaryKey: column.primary || primaryKey.includes(column.name),
90
+ isUnique: column.isUnique,
91
+ };
92
+ if (column.generatedIdentity !== undefined) snapshot.identity = column.generatedIdentity.type;
93
+ if (column.generated !== undefined) snapshot.generated = true;
94
+ const enumValues = column.enumValues;
95
+ if (enumValues !== undefined && enumValues.length > 0) {
96
+ recordEnum(enums, column.getSQLType().replace(/\[\]$/, ""), enumValues);
97
+ }
98
+ return snapshot;
99
+ }
100
+
101
+ /**
102
+ * Enum columns expose their values and their SQL type is the enum name (`mood` or
103
+ * `mood[]`). Two columns of the same enum must agree; a conflict is a broken declaration,
104
+ * so it throws instead of picking one.
105
+ */
106
+ function recordEnum(enums: Map<string, string[]>, name: string, values: readonly string[]): void {
107
+ const existing = enums.get(name);
108
+ if (existing === undefined) {
109
+ enums.set(name, [...values]);
110
+ return;
111
+ }
112
+ if (existing.length !== values.length || existing.some((value, index) => value !== values[index])) {
113
+ throw new Error(
114
+ `schema declares enum "${name}" with conflicting values: [${existing.join(", ")}] vs [${values.join(", ")}]`,
115
+ );
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Indexes are only representable when they are a plain (optionally unique) b-tree over
121
+ * plain columns; every other shape is recorded as unsupported rather than misrendered.
122
+ */
123
+ function snapshotIndex(
124
+ index: ReturnType<typeof getTableConfig>["indexes"][number],
125
+ tableName: string,
126
+ unsupported: UnsupportedDeclarationSnapshot[],
127
+ ): IndexSnapshot {
128
+ const config = index.config;
129
+ const columns = config.columns.map(indexColumnName);
130
+ const name =
131
+ config.name ??
132
+ `${tableName}_${columns.filter((column): column is string => column !== null).join("_")}_${
133
+ config.unique ? "unique_" : ""
134
+ }idx`;
135
+ const unsupportedShape =
136
+ config.concurrently === true ||
137
+ config.only === true ||
138
+ config.where !== undefined ||
139
+ config.with !== undefined ||
140
+ (config.method !== undefined && config.method !== "btree") ||
141
+ columns.some((column) => column === null);
142
+ if (unsupportedShape) {
143
+ unsupported.push({ construct: "index", name });
144
+ return { name, columns: [], unique: config.unique };
145
+ }
146
+ return {
147
+ name,
148
+ columns: columns.filter((column): column is string => column !== null),
149
+ unique: config.unique,
150
+ };
151
+ }
152
+
153
+ /** An index member that is not a plain ordered column returns null (unsupported shape). */
154
+ function indexColumnName(column: unknown): string | null {
155
+ if (typeof column !== "object" || column === null) return null;
156
+ const candidate = column as {
157
+ name?: unknown;
158
+ indexConfig?: { order?: unknown; nulls?: unknown; opClass?: unknown };
159
+ };
160
+ if (typeof candidate.name !== "string" || candidate.name.length === 0) return null;
161
+ const extra = candidate.indexConfig;
162
+ // Drizzle fills the ascending defaults explicitly (`asc`/`last`); anything else is a
163
+ // shape the plain-column rendering below would silently drop.
164
+ const unsupported =
165
+ extra !== undefined &&
166
+ ((extra.order !== undefined && extra.order !== "asc") ||
167
+ (extra.nulls !== undefined && extra.nulls !== "last") ||
168
+ extra.opClass !== undefined);
169
+ return unsupported ? null : candidate.name;
170
+ }
171
+
172
+ function derivedUniqueName(tableName: string, columns: readonly { name: string }[]): string {
173
+ return `${tableName}_${columns.map((column) => column.name).join("_")}_unique`;
174
+ }
175
+
176
+ /**
177
+ * SPEC §7.3 does not model SQL's SET DEFAULT, and "no action" is Postgres' default, so
178
+ * both are omitted rather than invented or echoed as noise.
179
+ */
180
+ function toForeignKeyAction(
181
+ action: "cascade" | "restrict" | "no action" | "set null" | "set default" | undefined,
182
+ ): "cascade" | "set null" | "restrict" | undefined {
183
+ switch (action) {
184
+ case "cascade":
185
+ case "restrict":
186
+ case "set null":
187
+ return action;
188
+ default:
189
+ return undefined;
190
+ }
191
+ }
192
+
193
+ function renderDefault(value: PgColumn["default"]): string | null {
194
+ if (value === undefined) return null;
195
+ if (is(value, SQL)) return dialect.sqlToQuery(value).sql;
196
+ if (typeof value === "string") return quoteLiteral(value);
197
+ if (value instanceof Date) return quoteLiteral(value.toISOString());
198
+ if (typeof value === "object" && value !== null) return JSON.stringify(value);
199
+ return String(value);
200
+ }
201
+
202
+ function detectScopeColumn(columnNames: readonly string[]): ScopeColumnSnapshot | undefined {
203
+ const tenant = columnNames.find((name) => DEFAULT_TENANT_COLUMNS.includes(name));
204
+ if (tenant !== undefined) return { name: tenant, kind: "tenant" };
205
+ const owner = columnNames.find((name) => DEFAULT_OWNER_COLUMNS.includes(name));
206
+ if (owner !== undefined) return { name: owner, kind: "owner" };
207
+ return undefined;
208
+ }
209
+
210
+ function quoteLiteral(value: string): string {
211
+ return `'${value.replaceAll("'", "''")}'`;
212
+ }
@@ -0,0 +1,122 @@
1
+ import { is } from "drizzle-orm";
2
+ import { PgTable } from "drizzle-orm/pg-core";
3
+
4
+ import type { FacilisDb } from "./client.ts";
5
+ import { snapshotTable } from "./snapshot-readers.ts";
6
+
7
+ /** SPEC §7.3 taxonomy. Core re-exports these types as the manifest contract. */
8
+ export type PolicyCommand = "SELECT" | "INSERT" | "UPDATE" | "DELETE" | "ALL";
9
+
10
+ export interface PolicySnapshot {
11
+ name: string;
12
+ command: PolicyCommand;
13
+ using: string | null;
14
+ withCheck: string | null;
15
+ sql: string;
16
+ }
17
+
18
+ export interface ColumnSnapshot {
19
+ name: string;
20
+ type: string;
21
+ notNull: boolean;
22
+ default: string | null;
23
+ isPrimaryKey: boolean;
24
+ isUnique: boolean;
25
+ /**
26
+ * Identity columns are readable but not renderable by the v1 generator
27
+ * (`GENERATED ... AS IDENTITY` sequence options are lost in this projection), so the
28
+ * flag is carried and the P2 planner refuses instead of writing a plain column.
29
+ */
30
+ identity?: "always" | "byDefault";
31
+ /** Generated/computed columns: same fail-closed treatment, no faithful v1 DDL. */
32
+ generated?: true;
33
+ }
34
+
35
+ export interface ForeignKeySnapshot {
36
+ columns: string[];
37
+ targetTable: string;
38
+ targetColumns: string[];
39
+ onDelete?: "cascade" | "set null" | "restrict" | "no action";
40
+ onUpdate?: "cascade" | "set null" | "restrict" | "no action";
41
+ }
42
+
43
+ export interface IndexSnapshot {
44
+ name: string;
45
+ /** Plain column names only; any other index shape lands in `unsupported`. */
46
+ columns: string[];
47
+ unique: boolean;
48
+ }
49
+
50
+ export interface CheckSnapshot {
51
+ name: string;
52
+ /** Rendered with PgDialect, like `default`; columns may stay table-qualified. */
53
+ expression: string;
54
+ }
55
+
56
+ /** A table-level `unique()`, distinct from a column's `isUnique`. */
57
+ export interface UniqueConstraintSnapshot {
58
+ name: string;
59
+ columns: string[];
60
+ }
61
+
62
+ export interface EnumSnapshot {
63
+ name: string;
64
+ values: string[];
65
+ }
66
+
67
+ /**
68
+ * A declaration the snapshot can read but the v1 shapes above cannot represent (expression,
69
+ * partial or method-specific indexes; `ON DELETE SET DEFAULT`). It is carried, never
70
+ * dropped, so the P2 generator can fail the release closed instead of silently ignoring it
71
+ * (D-158: an unsupported declaration fails the release, never degrades silently).
72
+ */
73
+ export interface UnsupportedDeclarationSnapshot {
74
+ construct: "index" | "foreign_key";
75
+ name: string;
76
+ }
77
+
78
+ export interface ScopeColumnSnapshot {
79
+ name: string;
80
+ kind: "tenant" | "owner";
81
+ }
82
+
83
+ export interface TableSnapshot {
84
+ name: string;
85
+ rlsEnabled: boolean;
86
+ columns: ColumnSnapshot[];
87
+ foreignKeys: ForeignKeySnapshot[];
88
+ policies: PolicySnapshot[];
89
+ scopeColumn?: ScopeColumnSnapshot;
90
+ /** Ordered column names of the primary key; empty when the table has none. */
91
+ primaryKey?: string[];
92
+ indexes?: IndexSnapshot[];
93
+ checks?: CheckSnapshot[];
94
+ uniqueConstraints?: UniqueConstraintSnapshot[];
95
+ unsupported?: UnsupportedDeclarationSnapshot[];
96
+ }
97
+
98
+ export interface SchemaSnapshot {
99
+ tables: TableSnapshot[];
100
+ /** Enum types referenced by columns, deduped by name. */
101
+ enums?: EnumSnapshot[];
102
+ }
103
+
104
+ /**
105
+ * Derives the SPEC §7.3 schema snapshot from the Drizzle schema namespace passed to
106
+ * `createDb`. No database round-trip: the code schema is the source of truth, as in
107
+ * SPEC §11.1's code↔live comparison.
108
+ */
109
+ export async function schemaSnapshot(db: FacilisDb): Promise<SchemaSnapshot> {
110
+ const enums = new Map<string, string[]>();
111
+ const tables = Object.values(db.schema)
112
+ .filter((value): value is PgTable => is(value, PgTable))
113
+ .map((table) => snapshotTable(table, enums))
114
+ .toSorted((left, right) => left.name.localeCompare(right.name));
115
+
116
+ return {
117
+ tables,
118
+ enums: [...enums.entries()]
119
+ .map(([name, values]) => ({ name, values }))
120
+ .toSorted((left, right) => left.name.localeCompare(right.name)),
121
+ };
122
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Sudo audit trail (D-099).
3
+ *
4
+ * A sudo escalation is recorded on a dedicated, append-only sink, never in the database:
5
+ * the trail must survive the transaction rollback it describes, and the framework has no
6
+ * update/delete operation for it. The default sink is stdout (one JSON line per event) so a
7
+ * deploy's log platform can ship it to WORM/retention storage; tests swap the sink.
8
+ *
9
+ * Fail closed: if the sink throws, the escalation must not happen, so writeSudoAudit throws
10
+ * a SudoAuditUnavailableError and the caller refuses to switch roles.
11
+ */
12
+
13
+ export type SudoAuditEventName = "started" | "succeeded" | "failed";
14
+
15
+ /** One immutable audit entry. `id` is shared by every entry of the same sudo call. */
16
+ export interface SudoAuditEvent {
17
+ readonly event: SudoAuditEventName;
18
+ /** Shared call id; also written to the app.sudo_id GUC while the role is elevated. */
19
+ readonly id: string;
20
+ /** ISO-8601 timestamp of the append (not of the call start). */
21
+ readonly at: string;
22
+ /** Acting user id; absent for a system job (actor is the label). */
23
+ readonly actorUserId?: string;
24
+ /** "user:<id>" for a route session, "system:<job>" for a job/CLI caller. */
25
+ readonly actor: string;
26
+ /** SHA-256 of the session id; the raw cookie/token is never logged. */
27
+ readonly sessionHash?: string;
28
+ readonly roles: readonly string[];
29
+ readonly tenantId?: string;
30
+ /** Request pathname, e.g. "/api/order-requests". */
31
+ readonly route?: string;
32
+ /** Request method, when known. */
33
+ readonly method?: string;
34
+ /** The route's declared permission (the first one), for context. */
35
+ readonly permission?: string;
36
+ readonly operation: string;
37
+ readonly reason: string;
38
+ readonly tables: readonly string[];
39
+ /** Succeeded entries: the declared tables the elevated callback actually reached (D-157). */
40
+ readonly observedTables?: readonly string[];
41
+ readonly requestId?: string;
42
+ /** Terminal entries only: how the elevated block ended. */
43
+ readonly outcome?: "succeeded" | "failed";
44
+ /** Terminal failure entries: canonical code, never the internal message. */
45
+ readonly errorCode?: string;
46
+ readonly durationMs?: number;
47
+ }
48
+
49
+ /** Appends one entry. Synchronous by design: a sink cannot silently promise to log later. */
50
+ export type SudoAuditSink = (event: SudoAuditEvent) => void;
51
+
52
+ /** Thrown when the audit sink is not writable; sudo refuses to escalate untraced. */
53
+ export class SudoAuditUnavailableError extends Error {
54
+ readonly code = "sudo_audit_unavailable";
55
+
56
+ constructor(cause: unknown) {
57
+ super("The sudo audit sink is not writable; refusing to escalate a database call.", { cause });
58
+ this.name = "SudoAuditUnavailableError";
59
+ }
60
+ }
61
+
62
+ /** New call id: WebCrypto randomUUID (available in Node and the browser VM alike). */
63
+ export function newSudoId(): string {
64
+ return globalThis.crypto.randomUUID();
65
+ }
66
+
67
+ const consoleSink: SudoAuditSink = (event): void => {
68
+ // oxlint-disable-next-line no-console -- the default audit sink IS stdout: one JSON line per entry for the deploy's log pipeline.
69
+ console.log("facilis:sudo " + JSON.stringify(event));
70
+ };
71
+
72
+ let sink: SudoAuditSink = consoleSink;
73
+
74
+ /** Replaces the process-wide sink (tests/tooling). Production leaves the stdout default. */
75
+ export function setSudoAuditSink(next: SudoAuditSink): void {
76
+ sink = next;
77
+ }
78
+
79
+ /** Restores the default stdout sink. */
80
+ export function resetSudoAuditSink(): void {
81
+ sink = consoleSink;
82
+ }
83
+
84
+ /** Append one entry, failing closed: a broken sink throws instead of dropping the trail. */
85
+ export function writeSudoAudit(event: SudoAuditEvent): void {
86
+ try {
87
+ sink(event);
88
+ } catch (cause) {
89
+ throw new SudoAuditUnavailableError(cause);
90
+ }
91
+ }