@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,156 @@
1
+ // Static guards for SQL text that reaches a read-only surface (SPEC §11.2).
2
+ //
3
+ // These checks are the UX half of the contract: the dev query endpoint wraps every
4
+ // statement in a read-only transaction, which is what actually rejects writes. They still
5
+ // matter where a query is inlined into another statement — a view definition is embedded
6
+ // in CREATE VIEW, so a second statement must never slip through, and a prefix check alone
7
+ // would accept "select 1; drop table todos".
8
+
9
+ const READ_ONLY_KEYWORDS: ReadonlySet<string> = new Set(["SELECT", "WITH", "VALUES", "TABLE"]);
10
+
11
+ /** True for a single read-only statement: SELECT, WITH, VALUES or TABLE. */
12
+ export function isReadOnlySql(text: string): boolean {
13
+ const keyword = firstKeyword(text);
14
+ if (keyword === null || !READ_ONLY_KEYWORDS.has(keyword)) return false;
15
+ return !hasMultipleStatements(text);
16
+ }
17
+
18
+ /** The statement without a trailing semicolon, for embedding in a wrapper or in DDL. */
19
+ export function stripTrailingSemicolon(text: string): string {
20
+ return text.trim().replace(/;\s*$/, "");
21
+ }
22
+
23
+ type ScanState =
24
+ { kind: "normal" } | { kind: "single" } | { kind: "double" } | { kind: "dollar"; tag: string };
25
+
26
+ interface Cursor {
27
+ readonly text: string;
28
+ index: number;
29
+ }
30
+
31
+ function isSpace(ch: string): boolean {
32
+ return ch === " " || ch === "\t" || ch === "\n" || ch === "\r" || ch === "\f" || ch === "\v";
33
+ }
34
+
35
+ /** True when the text at `cursor.index` starts a comment; advances past it when so. */
36
+ function skipComment(cursor: Cursor): boolean {
37
+ const { text, index } = cursor;
38
+ if (text[index] === "-" && text[index + 1] === "-") {
39
+ const newline = text.indexOf("\n", index + 2);
40
+ cursor.index = newline === -1 ? text.length : newline + 1;
41
+ return true;
42
+ }
43
+ if (text[index] === "/" && text[index + 1] === "*") {
44
+ // Postgres block comments nest; count them so an inner comment cannot end the outer.
45
+ let depth = 1;
46
+ let at = index + 2;
47
+ while (at < text.length && depth > 0) {
48
+ if (text[at] === "/" && text[at + 1] === "*") {
49
+ depth += 1;
50
+ at += 2;
51
+ continue;
52
+ }
53
+ if (text[at] === "*" && text[at + 1] === "/") {
54
+ depth -= 1;
55
+ at += 2;
56
+ continue;
57
+ }
58
+ at += 1;
59
+ }
60
+ cursor.index = at;
61
+ return true;
62
+ }
63
+ return false;
64
+ }
65
+
66
+ /** The first keyword of the statement, comments and whitespace skipped. */
67
+ export function firstKeyword(text: string): string | null {
68
+ const cursor: Cursor = { text, index: 0 };
69
+ while (cursor.index < text.length) {
70
+ while (cursor.index < text.length && isSpace(text[cursor.index] ?? "")) cursor.index += 1;
71
+ if (skipComment(cursor)) continue;
72
+ break;
73
+ }
74
+ const match = /^[A-Za-z]+/.exec(text.slice(cursor.index));
75
+ return match === null ? null : match[0].toUpperCase();
76
+ }
77
+
78
+ /**
79
+ * True when a ';' is followed by anything but comments/whitespace, or by another ';'.
80
+ * Backslashes are NOT treated as escapes: standard_conforming_strings is on, and a
81
+ * missed closing quote would make the scanner swallow a real statement separator (a
82
+ * false "single statement" is the one answer this must never give).
83
+ */
84
+ export function hasMultipleStatements(text: string): boolean {
85
+ const cursor: Cursor = { text, index: 0 };
86
+ let state: ScanState = { kind: "normal" };
87
+ let separatorSeen = false;
88
+
89
+ while (cursor.index < text.length) {
90
+ const ch = text[cursor.index] ?? "";
91
+ if (state.kind === "normal") {
92
+ if (skipComment(cursor)) continue;
93
+ if (isSpace(ch)) {
94
+ cursor.index += 1;
95
+ continue;
96
+ }
97
+ if (separatorSeen) return true;
98
+ if (ch === "'") {
99
+ state = { kind: "single" };
100
+ cursor.index += 1;
101
+ continue;
102
+ }
103
+ if (ch === '"') {
104
+ state = { kind: "double" };
105
+ cursor.index += 1;
106
+ continue;
107
+ }
108
+ if (ch === "$") {
109
+ const tag = dollarTagAt(text, cursor.index);
110
+ if (tag !== null) {
111
+ state = { kind: "dollar", tag };
112
+ cursor.index += tag.length;
113
+ continue;
114
+ }
115
+ }
116
+ if (ch === ";") separatorSeen = true;
117
+ cursor.index += 1;
118
+ continue;
119
+ }
120
+ if (state.kind === "single") {
121
+ if (ch === "'") {
122
+ if (text[cursor.index + 1] === "'") {
123
+ cursor.index += 2;
124
+ continue;
125
+ }
126
+ state = { kind: "normal" };
127
+ }
128
+ cursor.index += 1;
129
+ continue;
130
+ }
131
+ if (state.kind === "double") {
132
+ if (ch === '"') {
133
+ if (text[cursor.index + 1] === '"') {
134
+ cursor.index += 2;
135
+ continue;
136
+ }
137
+ state = { kind: "normal" };
138
+ }
139
+ cursor.index += 1;
140
+ continue;
141
+ }
142
+ if (text.startsWith(state.tag, cursor.index)) {
143
+ cursor.index += state.tag.length;
144
+ state = { kind: "normal" };
145
+ continue;
146
+ }
147
+ cursor.index += 1;
148
+ }
149
+ return false;
150
+ }
151
+
152
+ /** A complete dollar-quote tag (`$tag$`) at `index`, or null for a positional parameter. */
153
+ function dollarTagAt(text: string, index: number): string | null {
154
+ const match = /^\$[A-Za-z_][A-Za-z0-9_]*\$|^\$\$/.exec(text.slice(index));
155
+ return match === null ? null : match[0];
156
+ }
package/src/rls.ts ADDED
@@ -0,0 +1,109 @@
1
+ import { sql } from "drizzle-orm";
2
+ import { PgDialect, pgPolicy, type PgPolicy, type PgPolicyToOption } from "drizzle-orm/pg-core";
3
+
4
+ const dialect = new PgDialect();
5
+
6
+ /** Row-level-security extras returned by `rlsForTable` for a pgTable definition. */
7
+ export interface RlsOptions {
8
+ readonly tenantColumn?: string;
9
+ readonly ownerColumn?: string;
10
+ readonly rolesColumn?: string;
11
+ }
12
+
13
+ export interface RlsExtras {
14
+ readonly enableRLS: true;
15
+ readonly policies: PgPolicy[];
16
+ }
17
+
18
+ /**
19
+ * Tenant isolation as a RESTRICTIVE policy. Deviation from SPEC §8.4.5's original SQL:
20
+ * as written there the policy is permissive, and Postgres ORs permissive policies, so
21
+ * role/owner clauses let callers cross tenants. D-008 (Phase 0 evidence) fixes this by
22
+ * making tenant isolation RESTRICTIVE (always AND-ed); this generator emits that form.
23
+ */
24
+ export function tenantIsolationPolicy(column = "tenant_id"): PgPolicy {
25
+ return pgPolicy("tenant_isolation", {
26
+ as: "restrictive",
27
+ for: "all",
28
+ using: tenantPredicate(column),
29
+ withCheck: tenantPredicate(column),
30
+ });
31
+ }
32
+
33
+ /** Permissive policy granting a caller rows they own (SPEC §8.4.5 owner clause). */
34
+
35
+ export function ownerIsolationPolicy(column = "owner_id"): PgPolicy {
36
+ return pgPolicy("owner_isolation", {
37
+ for: "all",
38
+ using: ownerPredicate(column),
39
+ withCheck: ownerPredicate(column),
40
+ });
41
+ }
42
+
43
+ /**
44
+ * Permissive role policy: a row is visible when the row's `column` names one of the
45
+ * caller's app roles (`app.roles::jsonb ? column`). Columns hold a role name such as
46
+ * "admin"; roles themselves are never Postgres roles (SPEC §8.4.5).
47
+ */
48
+ export function rolePolicy(column: string, policyName = "role_access"): PgPolicy {
49
+ return pgPolicy(policyName, {
50
+ for: "all",
51
+ using: rolePredicate(column),
52
+ withCheck: rolePredicate(column),
53
+ });
54
+ }
55
+
56
+ /**
57
+ * Builds the standard policy set for a scoped table. Use as
58
+ * `pgTable(..., () => rlsForTable({ tenantColumn }).policies).enableRLS()`.
59
+ * (`enableRLS` is returned so callers can assert/forward the flag; Drizzle applies it
60
+ * through `.enableRLS()`, since pgTable's extras callback only accepts config values.)
61
+ */
62
+ export function rlsForTable(options: RlsOptions = {}): RlsExtras {
63
+ const policies: PgPolicy[] = [tenantIsolationPolicy(options.tenantColumn)];
64
+ if (options.ownerColumn !== undefined) policies.push(ownerIsolationPolicy(options.ownerColumn));
65
+ if (options.rolesColumn !== undefined) policies.push(rolePolicy(options.rolesColumn));
66
+ return { enableRLS: true, policies };
67
+ }
68
+
69
+ /** Alias kept for the wording used in the Phase 1 contracts file. */
70
+ export const withRls = rlsForTable;
71
+
72
+ /** Renders a complete CREATE POLICY statement (used by migrations and the manifest). */
73
+ export function policyCreateSql(tableName: string, policy: PgPolicy): string {
74
+ const parts = [`CREATE POLICY ${quoteIdentifier(policy.name)} ON ${quoteIdentifier(tableName)}`];
75
+ if (policy.as !== undefined) parts.push(`AS ${policy.as.toUpperCase()}`);
76
+ parts.push(`FOR ${(policy.for ?? "all").toUpperCase()}`);
77
+ if (policy.to !== undefined) parts.push(`TO ${policyToList(policy.to)}`);
78
+ if (policy.using !== undefined) parts.push(`USING (${dialect.sqlToQuery(policy.using).sql})`);
79
+ if (policy.withCheck !== undefined) {
80
+ parts.push(`WITH CHECK (${dialect.sqlToQuery(policy.withCheck).sql})`);
81
+ }
82
+ return `${parts.join(" ")};`;
83
+ }
84
+
85
+ function tenantPredicate(column: string) {
86
+ return sql`${sql.identifier(column)} = nullif(current_setting('app.tenant_id', true), '')::uuid`;
87
+ }
88
+
89
+ function ownerPredicate(column: string) {
90
+ return sql`${sql.identifier(column)} = nullif(current_setting('app.user_id', true), '')::uuid`;
91
+ }
92
+
93
+ function rolePredicate(column: string) {
94
+ return sql`nullif(current_setting('app.roles', true), '')::jsonb ? ${sql.identifier(column)}`;
95
+ }
96
+
97
+ function quoteIdentifier(name: string): string {
98
+ return `"${name.replaceAll('"', '""')}"`;
99
+ }
100
+
101
+ function policyToList(to: PgPolicyToOption): string {
102
+ if (Array.isArray(to)) return to.map((entry) => policyToList(entry)).join(", ");
103
+ if (typeof to === "string") {
104
+ return ["public", "current_role", "current_user", "session_user"].includes(to)
105
+ ? to.toUpperCase()
106
+ : quoteIdentifier(to);
107
+ }
108
+ return quoteIdentifier(to.name);
109
+ }
@@ -0,0 +1,240 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ import type {
4
+ LiveColumnSnapshot,
5
+ LiveSchemaSnapshot,
6
+ LivePolicySnapshot,
7
+ LiveTableSnapshot,
8
+ } from "./live-schema.ts";
9
+ import { canonicalColumnType, isRestrictivePolicy, normalizePolicySql } from "./schema-drift.ts";
10
+ import type { ColumnSnapshot, PolicySnapshot, SchemaSnapshot, TableSnapshot } from "./snapshot.ts";
11
+
12
+ /**
13
+ * Canonical schema digest (docs/MIGRATION-PLAN.md §3.1, ADR D-159).
14
+ *
15
+ * The declared snapshot (`schemaSnapshot`, from `db/schema.ts`) and the live snapshot
16
+ * (`liveSchemaSnapshot`, from the Postgres catalogs) are projected onto one canonical
17
+ * form and hashed. A release artifact carries these digests as `fromDigest`/`toDigest`;
18
+ * the deploy uses them to check that the database is where the ledger says it is.
19
+ *
20
+ * Equality is fail-closed by construction: only the normalization described below is
21
+ * applied, everything else is a difference, so two different schemas cannot share a
22
+ * digest and a declared schema and the catalog it produces can.
23
+ *
24
+ * Digest scope v1 — the first segment of the digest string is the version:
25
+ * - table: name, `rlsEnabled`;
26
+ * - column: name, canonical SQL type (`canonicalColumnType`), `notNull`, canonical
27
+ * default (`normalizeDefault`), `isPrimaryKey`;
28
+ * - policy: name, command upper-cased, restrictive flag, normalized USING and WITH CHECK
29
+ * predicates (`normalizePolicySql`, shared with `diffSchemaSnapshots`).
30
+ *
31
+ * NOT in v1: foreign keys, indexes, unique/check constraints, enum types, scope columns,
32
+ * policy roles and the table's schema (framework tables live in the connection's current
33
+ * schema, i.e. `public`). They join the projection in v2, which changes the digest string
34
+ * prefix too, so a v1 digest is never compared with a v2 one.
35
+ *
36
+ * Framework bookkeeping tables never reach the projection: `liveSchemaSnapshot` filters
37
+ * the migration tracking table and reads only the current schema, and declared snapshots
38
+ * contain only project tables.
39
+ */
40
+ export const SCHEMA_DIGEST_VERSION = 1;
41
+
42
+ export interface DigestColumn {
43
+ readonly name: string;
44
+ /** `canonicalColumnType` spelling, e.g. `varchar(120)` or `timestamp with time zone`. */
45
+ readonly type: string;
46
+ readonly notNull: boolean;
47
+ /** `normalizeDefault` form; null means the column has no default. */
48
+ readonly default: string | null;
49
+ readonly isPrimaryKey: boolean;
50
+ }
51
+
52
+ export interface DigestPolicy {
53
+ readonly name: string;
54
+ /** SQL command, upper-cased: SELECT, INSERT, UPDATE, DELETE or ALL. */
55
+ readonly command: string;
56
+ /** RESTRICTIVE policies are AND-ed with the permissive set (D-008). */
57
+ readonly restrictive: boolean;
58
+ /** `normalizePolicySql` form; "" means the policy has no USING clause. */
59
+ readonly using: string;
60
+ /** `normalizePolicySql` form; "" means the policy has no WITH CHECK clause. */
61
+ readonly withCheck: string;
62
+ }
63
+
64
+ export interface DigestTable {
65
+ readonly name: string;
66
+ readonly rlsEnabled: boolean;
67
+ /** Sorted by name: `ordinal_position` is not part of the projection. */
68
+ readonly columns: readonly DigestColumn[];
69
+ /** Sorted by name. */
70
+ readonly policies: readonly DigestPolicy[];
71
+ }
72
+
73
+ export interface DigestSchema {
74
+ /** Projection version; part of the canonical JSON and of the digest string prefix. */
75
+ readonly digestVersion: number;
76
+ /** Sorted by name. */
77
+ readonly tables: readonly DigestTable[];
78
+ }
79
+
80
+ /** Project the declared (Drizzle) snapshot onto the canonical form. */
81
+ export function declaredDigestSchema(snapshot: SchemaSnapshot): DigestSchema {
82
+ return {
83
+ digestVersion: SCHEMA_DIGEST_VERSION,
84
+ tables: snapshot.tables.map(declaredDigestTable).toSorted(byName),
85
+ };
86
+ }
87
+
88
+ /** Project the live (catalog) snapshot onto the same canonical form. */
89
+ export function liveDigestSchema(snapshot: LiveSchemaSnapshot): DigestSchema {
90
+ return {
91
+ digestVersion: SCHEMA_DIGEST_VERSION,
92
+ tables: snapshot.tables.map(liveDigestTable).toSorted(byName),
93
+ };
94
+ }
95
+
96
+ function declaredDigestTable(table: TableSnapshot): DigestTable {
97
+ return {
98
+ name: table.name,
99
+ rlsEnabled: table.rlsEnabled,
100
+ columns: table.columns.map(declaredDigestColumn).toSorted(byName),
101
+ policies: table.policies.map(declaredDigestPolicy).toSorted(byName),
102
+ };
103
+ }
104
+
105
+ function liveDigestTable(table: LiveTableSnapshot): DigestTable {
106
+ return {
107
+ name: table.name,
108
+ rlsEnabled: table.rlsEnabled,
109
+ columns: table.columns.map(liveDigestColumn).toSorted(byName),
110
+ policies: table.policies.map(liveDigestPolicy).toSorted(byName),
111
+ };
112
+ }
113
+
114
+ function declaredDigestColumn(column: ColumnSnapshot): DigestColumn {
115
+ return {
116
+ name: column.name,
117
+ type: canonicalColumnType(column.type),
118
+ notNull: column.notNull,
119
+ default: normalizeDefault(column.default),
120
+ isPrimaryKey: column.isPrimaryKey,
121
+ };
122
+ }
123
+
124
+ function liveDigestColumn(column: LiveColumnSnapshot): DigestColumn {
125
+ return {
126
+ name: column.name,
127
+ type: canonicalColumnType(column.type),
128
+ notNull: column.notNull,
129
+ default: normalizeDefault(column.default),
130
+ isPrimaryKey: column.isPrimaryKey,
131
+ };
132
+ }
133
+
134
+ function declaredDigestPolicy(policy: PolicySnapshot): DigestPolicy {
135
+ return {
136
+ name: policy.name,
137
+ command: policy.command.toUpperCase(),
138
+ restrictive: isRestrictivePolicy(policy),
139
+ using: normalizePolicySql(policy.using),
140
+ withCheck: normalizePolicySql(policy.withCheck),
141
+ };
142
+ }
143
+
144
+ function liveDigestPolicy(policy: LivePolicySnapshot): DigestPolicy {
145
+ return {
146
+ name: policy.name,
147
+ command: policy.command.toUpperCase(),
148
+ restrictive: policy.restrictive,
149
+ using: normalizePolicySql(policy.using),
150
+ withCheck: normalizePolicySql(policy.withCheck),
151
+ };
152
+ }
153
+
154
+ /**
155
+ * Canonical JSON: object keys sorted by code unit at every level, no whitespace, arrays in
156
+ * the order the projection produced (already sorted by their semantic key).
157
+ */
158
+ export function canonicalDigestJson(schema: DigestSchema): string {
159
+ return JSON.stringify(sortKeys(schema));
160
+ }
161
+
162
+ /**
163
+ * sha256 of the canonical form, spelled `v<version>:sha256:<64 lowercase hex>`. The
164
+ * version prefix is part of the value on purpose: a digest computed under a different
165
+ * projection must not compare equal to this one.
166
+ */
167
+ export function digestSchema(schema: DigestSchema): string {
168
+ const hash = createHash("sha256").update(canonicalDigestJson(schema), "utf8").digest("hex");
169
+ return `v${schema.digestVersion}:sha256:${hash}`;
170
+ }
171
+
172
+ /**
173
+ * Code-unit comparison, not `localeCompare`: the digest must not depend on the ICU locale
174
+ * of the machine (or browser) that computes it.
175
+ */
176
+ function compareText(left: string, right: string): number {
177
+ if (left === right) return 0;
178
+ return left < right ? -1 : 1;
179
+ }
180
+
181
+ function byName(left: { readonly name: string }, right: { readonly name: string }): number {
182
+ return compareText(left.name, right.name);
183
+ }
184
+
185
+ function sortKeys(value: unknown): unknown {
186
+ if (Array.isArray(value)) return value.map(sortKeys);
187
+ if (value !== null && typeof value === "object") {
188
+ const record = value as Record<string, unknown>;
189
+ return Object.fromEntries(
190
+ Object.keys(record)
191
+ .toSorted(compareText)
192
+ .map((key) => [key, sortKeys(record[key])]),
193
+ );
194
+ }
195
+ return value;
196
+ }
197
+
198
+ /** A cast suffix: the column type is already part of the projection, so casts are noise. */
199
+ const TYPE_CAST = /::[a-z][a-z_ ]*(?:\([0-9, ]*\))?(?:\[\])?/g;
200
+ /** Single-quoted SQL literal, including the doubled-quote escape (`''`). */
201
+ const STRING_LITERAL = /'(?:[^']|'')*'/g;
202
+
203
+ /**
204
+ * Normalize a column default onto the spelling both renderers agree on.
205
+ *
206
+ * Drizzle (`schemaSnapshot`) renders `'untitled'`, `false`, `now()`; `pg_get_expr`
207
+ * (`liveSchemaSnapshot`) renders `'untitled'::text`, `false`, `now()`. The differences
208
+ * are catalog-only noise: explicit casts, keyword case, redundant parentheses and
209
+ * whitespace. String-literal bodies are preserved verbatim, so `'Draft'` and `'draft'`
210
+ * stay different defaults (guessed normalization here would turn drift into a digest
211
+ * match). `CURRENT_TIMESTAMP` is folded onto `now` because Postgres stores both as
212
+ * `now()`.
213
+ *
214
+ * NOT normalized (documented v1 limitations): JSON object/array defaults (`{}` vs
215
+ * `'{}'::jsonb`), date values rendered as full ISO timestamps by Drizzle, array defaults
216
+ * and sequences of `serial` columns. P2's renderer owns producing catalog-compatible
217
+ * defaults; until then such a column is a digest difference, never a false match.
218
+ */
219
+ export function normalizeDefault(value: string | null): string | null {
220
+ if (value === null) return null;
221
+ let normalized = "";
222
+ let cursor = 0;
223
+ for (const match of value.matchAll(STRING_LITERAL)) {
224
+ const start = match.index ?? 0;
225
+ normalized += normalizeDefaultFragment(value.slice(cursor, start));
226
+ normalized += match[0];
227
+ cursor = start + match[0].length;
228
+ }
229
+ normalized += normalizeDefaultFragment(value.slice(cursor));
230
+ return normalized;
231
+ }
232
+
233
+ function normalizeDefaultFragment(fragment: string): string {
234
+ return fragment
235
+ .toLowerCase()
236
+ .replace(TYPE_CAST, "")
237
+ .replace(/[()]/g, "")
238
+ .replace(/\bcurrent_timestamp\b/g, "now")
239
+ .replace(/\s+/g, "");
240
+ }