@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,97 @@
1
+ import type { LiveSchemaSnapshot } from "./live-schema.js";
2
+ import type { SchemaSnapshot } from "./snapshot.js";
3
+ /**
4
+ * Canonical schema digest (docs/MIGRATION-PLAN.md §3.1, ADR D-159).
5
+ *
6
+ * The declared snapshot (`schemaSnapshot`, from `db/schema.ts`) and the live snapshot
7
+ * (`liveSchemaSnapshot`, from the Postgres catalogs) are projected onto one canonical
8
+ * form and hashed. A release artifact carries these digests as `fromDigest`/`toDigest`;
9
+ * the deploy uses them to check that the database is where the ledger says it is.
10
+ *
11
+ * Equality is fail-closed by construction: only the normalization described below is
12
+ * applied, everything else is a difference, so two different schemas cannot share a
13
+ * digest and a declared schema and the catalog it produces can.
14
+ *
15
+ * Digest scope v1 — the first segment of the digest string is the version:
16
+ * - table: name, `rlsEnabled`;
17
+ * - column: name, canonical SQL type (`canonicalColumnType`), `notNull`, canonical
18
+ * default (`normalizeDefault`), `isPrimaryKey`;
19
+ * - policy: name, command upper-cased, restrictive flag, normalized USING and WITH CHECK
20
+ * predicates (`normalizePolicySql`, shared with `diffSchemaSnapshots`).
21
+ *
22
+ * NOT in v1: foreign keys, indexes, unique/check constraints, enum types, scope columns,
23
+ * policy roles and the table's schema (framework tables live in the connection's current
24
+ * schema, i.e. `public`). They join the projection in v2, which changes the digest string
25
+ * prefix too, so a v1 digest is never compared with a v2 one.
26
+ *
27
+ * Framework bookkeeping tables never reach the projection: `liveSchemaSnapshot` filters
28
+ * the migration tracking table and reads only the current schema, and declared snapshots
29
+ * contain only project tables.
30
+ */
31
+ export declare const SCHEMA_DIGEST_VERSION = 1;
32
+ export interface DigestColumn {
33
+ readonly name: string;
34
+ /** `canonicalColumnType` spelling, e.g. `varchar(120)` or `timestamp with time zone`. */
35
+ readonly type: string;
36
+ readonly notNull: boolean;
37
+ /** `normalizeDefault` form; null means the column has no default. */
38
+ readonly default: string | null;
39
+ readonly isPrimaryKey: boolean;
40
+ }
41
+ export interface DigestPolicy {
42
+ readonly name: string;
43
+ /** SQL command, upper-cased: SELECT, INSERT, UPDATE, DELETE or ALL. */
44
+ readonly command: string;
45
+ /** RESTRICTIVE policies are AND-ed with the permissive set (D-008). */
46
+ readonly restrictive: boolean;
47
+ /** `normalizePolicySql` form; "" means the policy has no USING clause. */
48
+ readonly using: string;
49
+ /** `normalizePolicySql` form; "" means the policy has no WITH CHECK clause. */
50
+ readonly withCheck: string;
51
+ }
52
+ export interface DigestTable {
53
+ readonly name: string;
54
+ readonly rlsEnabled: boolean;
55
+ /** Sorted by name: `ordinal_position` is not part of the projection. */
56
+ readonly columns: readonly DigestColumn[];
57
+ /** Sorted by name. */
58
+ readonly policies: readonly DigestPolicy[];
59
+ }
60
+ export interface DigestSchema {
61
+ /** Projection version; part of the canonical JSON and of the digest string prefix. */
62
+ readonly digestVersion: number;
63
+ /** Sorted by name. */
64
+ readonly tables: readonly DigestTable[];
65
+ }
66
+ /** Project the declared (Drizzle) snapshot onto the canonical form. */
67
+ export declare function declaredDigestSchema(snapshot: SchemaSnapshot): DigestSchema;
68
+ /** Project the live (catalog) snapshot onto the same canonical form. */
69
+ export declare function liveDigestSchema(snapshot: LiveSchemaSnapshot): DigestSchema;
70
+ /**
71
+ * Canonical JSON: object keys sorted by code unit at every level, no whitespace, arrays in
72
+ * the order the projection produced (already sorted by their semantic key).
73
+ */
74
+ export declare function canonicalDigestJson(schema: DigestSchema): string;
75
+ /**
76
+ * sha256 of the canonical form, spelled `v<version>:sha256:<64 lowercase hex>`. The
77
+ * version prefix is part of the value on purpose: a digest computed under a different
78
+ * projection must not compare equal to this one.
79
+ */
80
+ export declare function digestSchema(schema: DigestSchema): string;
81
+ /**
82
+ * Normalize a column default onto the spelling both renderers agree on.
83
+ *
84
+ * Drizzle (`schemaSnapshot`) renders `'untitled'`, `false`, `now()`; `pg_get_expr`
85
+ * (`liveSchemaSnapshot`) renders `'untitled'::text`, `false`, `now()`. The differences
86
+ * are catalog-only noise: explicit casts, keyword case, redundant parentheses and
87
+ * whitespace. String-literal bodies are preserved verbatim, so `'Draft'` and `'draft'`
88
+ * stay different defaults (guessed normalization here would turn drift into a digest
89
+ * match). `CURRENT_TIMESTAMP` is folded onto `now` because Postgres stores both as
90
+ * `now()`.
91
+ *
92
+ * NOT normalized (documented v1 limitations): JSON object/array defaults (`{}` vs
93
+ * `'{}'::jsonb`), date values rendered as full ISO timestamps by Drizzle, array defaults
94
+ * and sequences of `serial` columns. P2's renderer owns producing catalog-compatible
95
+ * defaults; until then such a column is a digest difference, never a false match.
96
+ */
97
+ export declare function normalizeDefault(value: string | null): string | null;
@@ -0,0 +1,178 @@
1
+ import { createHash } from "node:crypto";
2
+ import { canonicalColumnType, isRestrictivePolicy, normalizePolicySql } from "./schema-drift.js";
3
+ /**
4
+ * Canonical schema digest (docs/MIGRATION-PLAN.md §3.1, ADR D-159).
5
+ *
6
+ * The declared snapshot (`schemaSnapshot`, from `db/schema.ts`) and the live snapshot
7
+ * (`liveSchemaSnapshot`, from the Postgres catalogs) are projected onto one canonical
8
+ * form and hashed. A release artifact carries these digests as `fromDigest`/`toDigest`;
9
+ * the deploy uses them to check that the database is where the ledger says it is.
10
+ *
11
+ * Equality is fail-closed by construction: only the normalization described below is
12
+ * applied, everything else is a difference, so two different schemas cannot share a
13
+ * digest and a declared schema and the catalog it produces can.
14
+ *
15
+ * Digest scope v1 — the first segment of the digest string is the version:
16
+ * - table: name, `rlsEnabled`;
17
+ * - column: name, canonical SQL type (`canonicalColumnType`), `notNull`, canonical
18
+ * default (`normalizeDefault`), `isPrimaryKey`;
19
+ * - policy: name, command upper-cased, restrictive flag, normalized USING and WITH CHECK
20
+ * predicates (`normalizePolicySql`, shared with `diffSchemaSnapshots`).
21
+ *
22
+ * NOT in v1: foreign keys, indexes, unique/check constraints, enum types, scope columns,
23
+ * policy roles and the table's schema (framework tables live in the connection's current
24
+ * schema, i.e. `public`). They join the projection in v2, which changes the digest string
25
+ * prefix too, so a v1 digest is never compared with a v2 one.
26
+ *
27
+ * Framework bookkeeping tables never reach the projection: `liveSchemaSnapshot` filters
28
+ * the migration tracking table and reads only the current schema, and declared snapshots
29
+ * contain only project tables.
30
+ */
31
+ export const SCHEMA_DIGEST_VERSION = 1;
32
+ /** Project the declared (Drizzle) snapshot onto the canonical form. */
33
+ export function declaredDigestSchema(snapshot) {
34
+ return {
35
+ digestVersion: SCHEMA_DIGEST_VERSION,
36
+ tables: snapshot.tables.map(declaredDigestTable).toSorted(byName),
37
+ };
38
+ }
39
+ /** Project the live (catalog) snapshot onto the same canonical form. */
40
+ export function liveDigestSchema(snapshot) {
41
+ return {
42
+ digestVersion: SCHEMA_DIGEST_VERSION,
43
+ tables: snapshot.tables.map(liveDigestTable).toSorted(byName),
44
+ };
45
+ }
46
+ function declaredDigestTable(table) {
47
+ return {
48
+ name: table.name,
49
+ rlsEnabled: table.rlsEnabled,
50
+ columns: table.columns.map(declaredDigestColumn).toSorted(byName),
51
+ policies: table.policies.map(declaredDigestPolicy).toSorted(byName),
52
+ };
53
+ }
54
+ function liveDigestTable(table) {
55
+ return {
56
+ name: table.name,
57
+ rlsEnabled: table.rlsEnabled,
58
+ columns: table.columns.map(liveDigestColumn).toSorted(byName),
59
+ policies: table.policies.map(liveDigestPolicy).toSorted(byName),
60
+ };
61
+ }
62
+ function declaredDigestColumn(column) {
63
+ return {
64
+ name: column.name,
65
+ type: canonicalColumnType(column.type),
66
+ notNull: column.notNull,
67
+ default: normalizeDefault(column.default),
68
+ isPrimaryKey: column.isPrimaryKey,
69
+ };
70
+ }
71
+ function liveDigestColumn(column) {
72
+ return {
73
+ name: column.name,
74
+ type: canonicalColumnType(column.type),
75
+ notNull: column.notNull,
76
+ default: normalizeDefault(column.default),
77
+ isPrimaryKey: column.isPrimaryKey,
78
+ };
79
+ }
80
+ function declaredDigestPolicy(policy) {
81
+ return {
82
+ name: policy.name,
83
+ command: policy.command.toUpperCase(),
84
+ restrictive: isRestrictivePolicy(policy),
85
+ using: normalizePolicySql(policy.using),
86
+ withCheck: normalizePolicySql(policy.withCheck),
87
+ };
88
+ }
89
+ function liveDigestPolicy(policy) {
90
+ return {
91
+ name: policy.name,
92
+ command: policy.command.toUpperCase(),
93
+ restrictive: policy.restrictive,
94
+ using: normalizePolicySql(policy.using),
95
+ withCheck: normalizePolicySql(policy.withCheck),
96
+ };
97
+ }
98
+ /**
99
+ * Canonical JSON: object keys sorted by code unit at every level, no whitespace, arrays in
100
+ * the order the projection produced (already sorted by their semantic key).
101
+ */
102
+ export function canonicalDigestJson(schema) {
103
+ return JSON.stringify(sortKeys(schema));
104
+ }
105
+ /**
106
+ * sha256 of the canonical form, spelled `v<version>:sha256:<64 lowercase hex>`. The
107
+ * version prefix is part of the value on purpose: a digest computed under a different
108
+ * projection must not compare equal to this one.
109
+ */
110
+ export function digestSchema(schema) {
111
+ const hash = createHash("sha256").update(canonicalDigestJson(schema), "utf8").digest("hex");
112
+ return `v${schema.digestVersion}:sha256:${hash}`;
113
+ }
114
+ /**
115
+ * Code-unit comparison, not `localeCompare`: the digest must not depend on the ICU locale
116
+ * of the machine (or browser) that computes it.
117
+ */
118
+ function compareText(left, right) {
119
+ if (left === right)
120
+ return 0;
121
+ return left < right ? -1 : 1;
122
+ }
123
+ function byName(left, right) {
124
+ return compareText(left.name, right.name);
125
+ }
126
+ function sortKeys(value) {
127
+ if (Array.isArray(value))
128
+ return value.map(sortKeys);
129
+ if (value !== null && typeof value === "object") {
130
+ const record = value;
131
+ return Object.fromEntries(Object.keys(record)
132
+ .toSorted(compareText)
133
+ .map((key) => [key, sortKeys(record[key])]));
134
+ }
135
+ return value;
136
+ }
137
+ /** A cast suffix: the column type is already part of the projection, so casts are noise. */
138
+ const TYPE_CAST = /::[a-z][a-z_ ]*(?:\([0-9, ]*\))?(?:\[\])?/g;
139
+ /** Single-quoted SQL literal, including the doubled-quote escape (`''`). */
140
+ const STRING_LITERAL = /'(?:[^']|'')*'/g;
141
+ /**
142
+ * Normalize a column default onto the spelling both renderers agree on.
143
+ *
144
+ * Drizzle (`schemaSnapshot`) renders `'untitled'`, `false`, `now()`; `pg_get_expr`
145
+ * (`liveSchemaSnapshot`) renders `'untitled'::text`, `false`, `now()`. The differences
146
+ * are catalog-only noise: explicit casts, keyword case, redundant parentheses and
147
+ * whitespace. String-literal bodies are preserved verbatim, so `'Draft'` and `'draft'`
148
+ * stay different defaults (guessed normalization here would turn drift into a digest
149
+ * match). `CURRENT_TIMESTAMP` is folded onto `now` because Postgres stores both as
150
+ * `now()`.
151
+ *
152
+ * NOT normalized (documented v1 limitations): JSON object/array defaults (`{}` vs
153
+ * `'{}'::jsonb`), date values rendered as full ISO timestamps by Drizzle, array defaults
154
+ * and sequences of `serial` columns. P2's renderer owns producing catalog-compatible
155
+ * defaults; until then such a column is a digest difference, never a false match.
156
+ */
157
+ export function normalizeDefault(value) {
158
+ if (value === null)
159
+ return null;
160
+ let normalized = "";
161
+ let cursor = 0;
162
+ for (const match of value.matchAll(STRING_LITERAL)) {
163
+ const start = match.index ?? 0;
164
+ normalized += normalizeDefaultFragment(value.slice(cursor, start));
165
+ normalized += match[0];
166
+ cursor = start + match[0].length;
167
+ }
168
+ normalized += normalizeDefaultFragment(value.slice(cursor));
169
+ return normalized;
170
+ }
171
+ function normalizeDefaultFragment(fragment) {
172
+ return fragment
173
+ .toLowerCase()
174
+ .replace(TYPE_CAST, "")
175
+ .replace(/[()]/g, "")
176
+ .replace(/\bcurrent_timestamp\b/g, "now")
177
+ .replace(/\s+/g, "");
178
+ }
@@ -0,0 +1,43 @@
1
+ import type { LiveSchemaSnapshot } from "./live-schema.js";
2
+ import type { PolicySnapshot, SchemaSnapshot } from "./snapshot.js";
3
+ /**
4
+ * Code↔live schema diff (SPEC §11.1): a pure comparison of the snapshot derived from
5
+ * `db/schema.ts` with the one read from the running database. The Studio renders the
6
+ * resulting entries; the release preview (SPEC §11.3) can reuse the same typed drift.
7
+ */
8
+ export type SchemaDriftKind = "table_missing" | "table_extra" | "column_missing" | "column_extra" | "column_type" | "column_nullability" | "rls_disabled" | "policy_missing" | "policy_extra" | "policy_changed";
9
+ /**
10
+ * One difference. `expected` is the code-side value and `actual` the live one, so a
11
+ * consumer never has to guess which side of the diff it is looking at.
12
+ */
13
+ export interface SchemaDrift {
14
+ kind: SchemaDriftKind;
15
+ table: string;
16
+ column?: string;
17
+ policy?: string;
18
+ expected?: string | null;
19
+ actual?: string | null;
20
+ }
21
+ /** Compare the code schema against the live one, deterministically ordered. */
22
+ export declare function diffSchemaSnapshots(code: SchemaSnapshot, live: LiveSchemaSnapshot): SchemaDrift[];
23
+ /**
24
+ * Code-side permissiveness: `PolicySnapshot` keeps only the rendered SQL, so the flag is
25
+ * read from the `AS RESTRICTIVE` clause the generator emits (D-008).
26
+ */
27
+ export declare function isRestrictivePolicy(policy: PolicySnapshot): boolean;
28
+ /**
29
+ * Postgres re-renders a policy's qualifier (adds parentheses and explicit `::text`
30
+ * casts), so raw SQL text never matches the Drizzle rendering even when the policies are
31
+ * identical. Compare a normalized form instead: lower-case, casts removed, quotes and
32
+ * parentheses dropped, whitespace collapsed away.
33
+ *
34
+ * Exported so the canonical schema digest (./schema-digest.ts) normalizes the same way
35
+ * instead of carrying a second copy.
36
+ */
37
+ export declare function normalizePolicySql(value: string | null): string;
38
+ /**
39
+ * Normalize both renderers onto one spelling: Drizzle says `varchar(120)`/`serial`,
40
+ * `format_type()` says `character varying(120)`/`integer`. Without this every column
41
+ * would look like drift.
42
+ */
43
+ export declare function canonicalColumnType(value: string): string;
@@ -0,0 +1,176 @@
1
+ /** Compare the code schema against the live one, deterministically ordered. */
2
+ export function diffSchemaSnapshots(code, live) {
3
+ const liveTables = new Map(live.tables.map((table) => [table.name, table]));
4
+ const codeTables = new Set(code.tables.map((table) => table.name));
5
+ const drift = [];
6
+ for (const table of code.tables) {
7
+ const liveTable = liveTables.get(table.name);
8
+ if (liveTable === undefined) {
9
+ drift.push({ kind: "table_missing", table: table.name });
10
+ continue;
11
+ }
12
+ diffColumns(table, liveTable, drift);
13
+ // A scoped table without RLS is the one combo the security invariants forbid
14
+ // (SPEC §8.4.5); RLS off on an unscoped table is reported by the panel badge only.
15
+ if (table.scopeColumn !== undefined && !liveTable.rlsEnabled) {
16
+ drift.push({ kind: "rls_disabled", table: table.name });
17
+ }
18
+ diffPolicies(table, liveTable, drift);
19
+ }
20
+ for (const table of live.tables) {
21
+ if (!codeTables.has(table.name))
22
+ drift.push({ kind: "table_extra", table: table.name });
23
+ }
24
+ return drift.toSorted(compareDrift);
25
+ }
26
+ function diffColumns(table, liveTable, drift) {
27
+ const liveColumns = new Map(liveTable.columns.map((column) => [column.name, column]));
28
+ for (const column of table.columns) {
29
+ const liveColumn = liveColumns.get(column.name);
30
+ if (liveColumn === undefined) {
31
+ drift.push({ kind: "column_missing", table: table.name, column: column.name });
32
+ continue;
33
+ }
34
+ const expectedType = canonicalColumnType(column.type);
35
+ const actualType = canonicalColumnType(liveColumn.type);
36
+ if (expectedType !== actualType) {
37
+ drift.push({
38
+ kind: "column_type",
39
+ table: table.name,
40
+ column: column.name,
41
+ expected: column.type,
42
+ actual: liveColumn.type,
43
+ });
44
+ }
45
+ if (column.notNull !== liveColumn.notNull) {
46
+ drift.push({
47
+ kind: "column_nullability",
48
+ table: table.name,
49
+ column: column.name,
50
+ expected: nullability(column.notNull),
51
+ actual: nullability(liveColumn.notNull),
52
+ });
53
+ }
54
+ }
55
+ const codeColumns = new Set(table.columns.map((column) => column.name));
56
+ for (const liveColumn of liveTable.columns) {
57
+ if (!codeColumns.has(liveColumn.name)) {
58
+ drift.push({ kind: "column_extra", table: table.name, column: liveColumn.name });
59
+ }
60
+ }
61
+ }
62
+ function diffPolicies(table, liveTable, drift) {
63
+ const livePolicies = new Map(liveTable.policies.map((policy) => [policy.name, policy]));
64
+ const codePolicies = new Set(table.policies.map((policy) => policy.name));
65
+ for (const policy of table.policies) {
66
+ const livePolicy = livePolicies.get(policy.name);
67
+ if (livePolicy === undefined) {
68
+ drift.push({ kind: "policy_missing", table: table.name, policy: policy.name });
69
+ continue;
70
+ }
71
+ const expected = policySignature(policy);
72
+ const actual = livePolicySignature(livePolicy);
73
+ if (expected !== actual) {
74
+ drift.push({
75
+ kind: "policy_changed",
76
+ table: table.name,
77
+ policy: policy.name,
78
+ expected,
79
+ actual,
80
+ });
81
+ }
82
+ }
83
+ for (const livePolicy of liveTable.policies) {
84
+ if (!codePolicies.has(livePolicy.name)) {
85
+ drift.push({ kind: "policy_extra", table: table.name, policy: livePolicy.name });
86
+ }
87
+ }
88
+ }
89
+ function nullability(notNull) {
90
+ return notNull ? "not null" : "nullable";
91
+ }
92
+ /** Code-side signature: command + permissiveness + rendered predicates. */
93
+ function policySignature(policy) {
94
+ return [
95
+ policy.command.toUpperCase(),
96
+ isRestrictivePolicy(policy) ? "RESTRICTIVE" : "PERMISSIVE",
97
+ normalizePolicySql(policy.using),
98
+ normalizePolicySql(policy.withCheck),
99
+ ].join("|");
100
+ }
101
+ function livePolicySignature(policy) {
102
+ return [
103
+ policy.command.toUpperCase(),
104
+ policy.restrictive ? "RESTRICTIVE" : "PERMISSIVE",
105
+ normalizePolicySql(policy.using),
106
+ normalizePolicySql(policy.withCheck),
107
+ ].join("|");
108
+ }
109
+ /**
110
+ * Code-side permissiveness: `PolicySnapshot` keeps only the rendered SQL, so the flag is
111
+ * read from the `AS RESTRICTIVE` clause the generator emits (D-008).
112
+ */
113
+ export function isRestrictivePolicy(policy) {
114
+ return /\bas\s+restrictive\b/i.test(policy.sql);
115
+ }
116
+ /**
117
+ * Postgres re-renders a policy's qualifier (adds parentheses and explicit `::text`
118
+ * casts), so raw SQL text never matches the Drizzle rendering even when the policies are
119
+ * identical. Compare a normalized form instead: lower-case, casts removed, quotes and
120
+ * parentheses dropped, whitespace collapsed away.
121
+ *
122
+ * Exported so the canonical schema digest (./schema-digest.ts) normalizes the same way
123
+ * instead of carrying a second copy.
124
+ */
125
+ export function normalizePolicySql(value) {
126
+ if (value === null)
127
+ return "";
128
+ return value
129
+ .toLowerCase()
130
+ .replace(/::(?:uuid|text|jsonb|json|integer|int4|int8|bigint|boolean|bool|numeric|date|timestamptz|timestamp(?: with(?:out)? time zone)?)\b/g, "")
131
+ .replace(/["'()]/g, "")
132
+ .replace(/\s+/g, "");
133
+ }
134
+ const TYPE_ALIASES = {
135
+ serial: "integer",
136
+ serial4: "integer",
137
+ bigserial: "bigint",
138
+ serial8: "bigint",
139
+ smallserial: "smallint",
140
+ serial2: "smallint",
141
+ int: "integer",
142
+ int4: "integer",
143
+ int8: "bigint",
144
+ int2: "smallint",
145
+ bool: "boolean",
146
+ float4: "real",
147
+ float8: "double precision",
148
+ decimal: "numeric",
149
+ bpchar: "char",
150
+ };
151
+ /**
152
+ * Normalize both renderers onto one spelling: Drizzle says `varchar(120)`/`serial`,
153
+ * `format_type()` says `character varying(120)`/`integer`. Without this every column
154
+ * would look like drift.
155
+ */
156
+ export function canonicalColumnType(value) {
157
+ const array = value.trim().endsWith("[]") ? "[]" : "";
158
+ const bare = array === "[]" ? value.trim().slice(0, -2) : value.trim();
159
+ const type = bare
160
+ .toLowerCase()
161
+ .replace(/\bcharacter varying\b/g, "varchar")
162
+ .replace(/\bcharacter\b/g, "char")
163
+ // Only the "without" suffix is catalog-only: Drizzle renders no-tz columns as plain
164
+ // `timestamp`/`time`, while the catalog spells the default timezone out. Keeping
165
+ // "with time zone" makes a tz change a real type drift.
166
+ .replace(/\s+without\s+time zone\b/g, "")
167
+ .replace(/\s*,\s*/g, ",");
168
+ const base = /^([a-z][a-z0-9_]*)/.exec(type)?.[1] ?? type;
169
+ return (TYPE_ALIASES[base] ?? base) + type.slice(base.length) + array;
170
+ }
171
+ function compareDrift(left, right) {
172
+ return (left.table.localeCompare(right.table) ||
173
+ (left.column ?? "").localeCompare(right.column ?? "") ||
174
+ (left.policy ?? "").localeCompare(right.policy ?? "") ||
175
+ left.kind.localeCompare(right.kind));
176
+ }
@@ -0,0 +1,3 @@
1
+ import { PgTable } from "drizzle-orm/pg-core";
2
+ import type { TableSnapshot } from "./snapshot.js";
3
+ export declare function snapshotTable(table: PgTable, enums: Map<string, string[]>): TableSnapshot;
@@ -0,0 +1,182 @@
1
+ import { is, SQL } from "drizzle-orm";
2
+ import { getTableConfig, PgDialect, PgTable } from "drizzle-orm/pg-core";
3
+ import { DEFAULT_OWNER_COLUMNS, DEFAULT_TENANT_COLUMNS } from "./client.js";
4
+ import { policyCreateSql } from "./rls.js";
5
+ /**
6
+ * The read half of the declared snapshot: Drizzle's table config -> the public shapes in
7
+ * ./snapshot.ts. Kept separate so the contract file stays a contract.
8
+ */
9
+ const dialect = new PgDialect();
10
+ export function snapshotTable(table, enums) {
11
+ const config = getTableConfig(table);
12
+ const primaryKey = primaryKeyColumns(config);
13
+ const columns = config.columns.map((column) => snapshotColumn(column, primaryKey, enums));
14
+ const unsupported = [];
15
+ const indexes = config.indexes.map((index) => snapshotIndex(index, config.name, unsupported));
16
+ const snapshot = {
17
+ name: config.name,
18
+ rlsEnabled: config.enableRLS,
19
+ columns,
20
+ foreignKeys: config.foreignKeys.map((foreignKey) => {
21
+ const reference = foreignKey.reference();
22
+ const onDelete = toForeignKeyAction(foreignKey.onDelete);
23
+ const onUpdate = toForeignKeyAction(foreignKey.onUpdate);
24
+ return {
25
+ columns: reference.columns.map((column) => column.name),
26
+ targetTable: getTableConfig(reference.foreignTable).name,
27
+ targetColumns: reference.foreignColumns.map((column) => column.name),
28
+ ...(onDelete === undefined ? {} : { onDelete }),
29
+ ...(onUpdate === undefined ? {} : { onUpdate }),
30
+ };
31
+ }),
32
+ policies: config.policies.map((policy) => ({
33
+ name: policy.name,
34
+ command: (policy.for ?? "all").toUpperCase(),
35
+ using: policy.using === undefined ? null : dialect.sqlToQuery(policy.using).sql,
36
+ withCheck: policy.withCheck === undefined ? null : dialect.sqlToQuery(policy.withCheck).sql,
37
+ sql: policyCreateSql(config.name, policy),
38
+ })),
39
+ primaryKey,
40
+ indexes,
41
+ checks: config.checks.map((check) => ({
42
+ name: check.name,
43
+ expression: dialect.sqlToQuery(check.value).sql,
44
+ })),
45
+ uniqueConstraints: config.uniqueConstraints.map((constraint) => ({
46
+ name: constraint.name ?? constraint.getName() ?? derivedUniqueName(config.name, constraint.columns),
47
+ columns: constraint.columns.map((column) => column.name),
48
+ })),
49
+ };
50
+ if (unsupported.length > 0)
51
+ snapshot.unsupported = unsupported;
52
+ const scopeColumn = detectScopeColumn(columns.map((column) => column.name));
53
+ if (scopeColumn !== undefined)
54
+ snapshot.scopeColumn = scopeColumn;
55
+ return snapshot;
56
+ }
57
+ /** The table's ordered PK: a table-level `primaryKey()` wins over column-level `.primaryKey()`. */
58
+ function primaryKeyColumns(config) {
59
+ const tableLevel = config.primaryKeys[0];
60
+ if (tableLevel !== undefined)
61
+ return tableLevel.columns.map((column) => column.name);
62
+ return config.columns.filter((column) => column.primary).map((column) => column.name);
63
+ }
64
+ function snapshotColumn(column, primaryKey, enums) {
65
+ const snapshot = {
66
+ name: column.name,
67
+ type: column.getSQLType(),
68
+ notNull: column.notNull,
69
+ default: renderDefault(column.default),
70
+ // A composite (table-level) PK is declared on the table, not on its columns, so
71
+ // `column.primary` alone would disagree with the live catalog's indisprimary view.
72
+ isPrimaryKey: column.primary || primaryKey.includes(column.name),
73
+ isUnique: column.isUnique,
74
+ };
75
+ if (column.generatedIdentity !== undefined)
76
+ snapshot.identity = column.generatedIdentity.type;
77
+ if (column.generated !== undefined)
78
+ snapshot.generated = true;
79
+ const enumValues = column.enumValues;
80
+ if (enumValues !== undefined && enumValues.length > 0) {
81
+ recordEnum(enums, column.getSQLType().replace(/\[\]$/, ""), enumValues);
82
+ }
83
+ return snapshot;
84
+ }
85
+ /**
86
+ * Enum columns expose their values and their SQL type is the enum name (`mood` or
87
+ * `mood[]`). Two columns of the same enum must agree; a conflict is a broken declaration,
88
+ * so it throws instead of picking one.
89
+ */
90
+ function recordEnum(enums, name, values) {
91
+ const existing = enums.get(name);
92
+ if (existing === undefined) {
93
+ enums.set(name, [...values]);
94
+ return;
95
+ }
96
+ if (existing.length !== values.length || existing.some((value, index) => value !== values[index])) {
97
+ throw new Error(`schema declares enum "${name}" with conflicting values: [${existing.join(", ")}] vs [${values.join(", ")}]`);
98
+ }
99
+ }
100
+ /**
101
+ * Indexes are only representable when they are a plain (optionally unique) b-tree over
102
+ * plain columns; every other shape is recorded as unsupported rather than misrendered.
103
+ */
104
+ function snapshotIndex(index, tableName, unsupported) {
105
+ const config = index.config;
106
+ const columns = config.columns.map(indexColumnName);
107
+ const name = config.name ??
108
+ `${tableName}_${columns.filter((column) => column !== null).join("_")}_${config.unique ? "unique_" : ""}idx`;
109
+ const unsupportedShape = config.concurrently === true ||
110
+ config.only === true ||
111
+ config.where !== undefined ||
112
+ config.with !== undefined ||
113
+ (config.method !== undefined && config.method !== "btree") ||
114
+ columns.some((column) => column === null);
115
+ if (unsupportedShape) {
116
+ unsupported.push({ construct: "index", name });
117
+ return { name, columns: [], unique: config.unique };
118
+ }
119
+ return {
120
+ name,
121
+ columns: columns.filter((column) => column !== null),
122
+ unique: config.unique,
123
+ };
124
+ }
125
+ /** An index member that is not a plain ordered column returns null (unsupported shape). */
126
+ function indexColumnName(column) {
127
+ if (typeof column !== "object" || column === null)
128
+ return null;
129
+ const candidate = column;
130
+ if (typeof candidate.name !== "string" || candidate.name.length === 0)
131
+ return null;
132
+ const extra = candidate.indexConfig;
133
+ // Drizzle fills the ascending defaults explicitly (`asc`/`last`); anything else is a
134
+ // shape the plain-column rendering below would silently drop.
135
+ const unsupported = extra !== undefined &&
136
+ ((extra.order !== undefined && extra.order !== "asc") ||
137
+ (extra.nulls !== undefined && extra.nulls !== "last") ||
138
+ extra.opClass !== undefined);
139
+ return unsupported ? null : candidate.name;
140
+ }
141
+ function derivedUniqueName(tableName, columns) {
142
+ return `${tableName}_${columns.map((column) => column.name).join("_")}_unique`;
143
+ }
144
+ /**
145
+ * SPEC §7.3 does not model SQL's SET DEFAULT, and "no action" is Postgres' default, so
146
+ * both are omitted rather than invented or echoed as noise.
147
+ */
148
+ function toForeignKeyAction(action) {
149
+ switch (action) {
150
+ case "cascade":
151
+ case "restrict":
152
+ case "set null":
153
+ return action;
154
+ default:
155
+ return undefined;
156
+ }
157
+ }
158
+ function renderDefault(value) {
159
+ if (value === undefined)
160
+ return null;
161
+ if (is(value, SQL))
162
+ return dialect.sqlToQuery(value).sql;
163
+ if (typeof value === "string")
164
+ return quoteLiteral(value);
165
+ if (value instanceof Date)
166
+ return quoteLiteral(value.toISOString());
167
+ if (typeof value === "object" && value !== null)
168
+ return JSON.stringify(value);
169
+ return String(value);
170
+ }
171
+ function detectScopeColumn(columnNames) {
172
+ const tenant = columnNames.find((name) => DEFAULT_TENANT_COLUMNS.includes(name));
173
+ if (tenant !== undefined)
174
+ return { name: tenant, kind: "tenant" };
175
+ const owner = columnNames.find((name) => DEFAULT_OWNER_COLUMNS.includes(name));
176
+ if (owner !== undefined)
177
+ return { name: owner, kind: "owner" };
178
+ return undefined;
179
+ }
180
+ function quoteLiteral(value) {
181
+ return `'${value.replaceAll("'", "''")}'`;
182
+ }