@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.
- package/LICENSE +21 -0
- package/README.md +18 -2
- package/dist/client.d.ts +78 -0
- package/dist/client.js +208 -0
- package/dist/files.d.ts +268 -0
- package/dist/files.js +88 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.js +23 -0
- package/dist/jobs.d.ts +255 -0
- package/dist/jobs.js +64 -0
- package/dist/live-schema.d.ts +39 -0
- package/dist/live-schema.js +97 -0
- package/dist/migrator.d.ts +20 -0
- package/dist/migrator.js +66 -0
- package/dist/policy.d.ts +50 -0
- package/dist/policy.js +111 -0
- package/dist/query-console.d.ts +44 -0
- package/dist/query-console.js +173 -0
- package/dist/read-only-sql.d.ts +13 -0
- package/dist/read-only-sql.js +145 -0
- package/dist/rls.d.ts +37 -0
- package/dist/rls.js +90 -0
- package/dist/schema-digest.d.ts +97 -0
- package/dist/schema-digest.js +178 -0
- package/dist/schema-drift.d.ts +43 -0
- package/dist/schema-drift.js +176 -0
- package/dist/snapshot-readers.d.ts +3 -0
- package/dist/snapshot-readers.js +182 -0
- package/dist/snapshot.d.ts +92 -0
- package/dist/snapshot.js +21 -0
- package/dist/sudo-audit.d.ts +60 -0
- package/dist/sudo-audit.js +45 -0
- package/dist/sudo.d.ts +54 -0
- package/dist/sudo.js +137 -0
- package/dist/table-guard.d.ts +37 -0
- package/dist/table-guard.js +228 -0
- package/dist/tasks.d.ts +312 -0
- package/dist/tasks.js +52 -0
- package/dist/tenant-column.d.ts +21 -0
- package/dist/tenant-column.js +27 -0
- package/dist/views.d.ts +92 -0
- package/dist/views.js +186 -0
- package/dist/workflows.d.ts +289 -0
- package/dist/workflows.js +55 -0
- package/package.json +43 -4
- package/src/client.ts +291 -0
- package/src/files.ts +124 -0
- package/src/index.ts +174 -0
- package/src/jobs.ts +76 -0
- package/src/live-schema.ts +145 -0
- package/src/migrator.ts +92 -0
- package/src/policy.ts +174 -0
- package/src/query-console.ts +215 -0
- package/src/read-only-sql.ts +156 -0
- package/src/rls.ts +109 -0
- package/src/schema-digest.ts +240 -0
- package/src/schema-drift.ts +234 -0
- package/src/snapshot-readers.ts +212 -0
- package/src/snapshot.ts +122 -0
- package/src/sudo-audit.ts +91 -0
- package/src/sudo.ts +200 -0
- package/src/table-guard.ts +296 -0
- package/src/tasks.ts +79 -0
- package/src/tenant-column.ts +29 -0
- package/src/views.ts +291 -0
- 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
|
+
}
|