@supacloud/db 0.7.1 → 0.9.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/README.md +49 -1
- package/dist/command-adapter.d.ts +4 -4
- package/dist/command-bun.d.ts +4 -0
- package/dist/command-bun.js +333 -1
- package/dist/drizzle-bun.d.ts +10 -0
- package/dist/drizzle-bun.js +93 -0
- package/dist/drizzle.d.ts +26 -0
- package/dist/drizzle.js +61 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +81 -0
- package/dist/migration-bindings.d.ts +3 -3
- package/dist/query.d.ts +36 -0
- package/dist/sql-analysis.d.ts +47 -0
- package/dist/sql-analysis.js +292 -0
- package/dist/sql-impact-cli.d.ts +14 -0
- package/dist/sql-impact-cli.js +372 -0
- package/package.json +33 -5
package/dist/index.js
CHANGED
|
@@ -1018,6 +1018,84 @@ ALTER TABLE supacloud_commands.execution_receipts
|
|
|
1018
1018
|
DROP COLUMN IF EXISTS next_attempt_at,
|
|
1019
1019
|
DROP COLUMN IF EXISTS recovery_attempts;
|
|
1020
1020
|
`;
|
|
1021
|
+
// src/query.ts
|
|
1022
|
+
class QueryBoundaryError extends Error {
|
|
1023
|
+
code;
|
|
1024
|
+
constructor(code) {
|
|
1025
|
+
super(code);
|
|
1026
|
+
this.code = code;
|
|
1027
|
+
this.name = "QueryBoundaryError";
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
function defineReadQuery(definition) {
|
|
1031
|
+
const contract = { ...definition };
|
|
1032
|
+
if (!/^[A-Za-z][A-Za-z0-9_.:-]{0,127}$/.test(contract.name) || contract.fingerprint !== undefined && !/^[a-f0-9]{16,64}$/.test(contract.fingerprint) || contract.slowMs !== undefined && (!Number.isFinite(contract.slowMs) || contract.slowMs < 0)) {
|
|
1033
|
+
throw new TypeError("Invalid query observation metadata");
|
|
1034
|
+
}
|
|
1035
|
+
const emit = (event) => {
|
|
1036
|
+
try {
|
|
1037
|
+
Promise.resolve(contract.observe?.(Object.freeze(event))).catch(() => {});
|
|
1038
|
+
} catch {}
|
|
1039
|
+
};
|
|
1040
|
+
const executeUnknown = async (identity, raw, signal) => {
|
|
1041
|
+
if (signal?.aborted)
|
|
1042
|
+
throw new QueryBoundaryError("QUERY_ABORTED");
|
|
1043
|
+
let input;
|
|
1044
|
+
try {
|
|
1045
|
+
input = contract.input(raw);
|
|
1046
|
+
} catch {
|
|
1047
|
+
throw new QueryBoundaryError("QUERY_INPUT_INVALID");
|
|
1048
|
+
}
|
|
1049
|
+
let decision;
|
|
1050
|
+
try {
|
|
1051
|
+
decision = await contract.authorize(identity, input);
|
|
1052
|
+
} catch {
|
|
1053
|
+
throw new QueryBoundaryError("QUERY_AUTHORIZATION_FAILED");
|
|
1054
|
+
}
|
|
1055
|
+
if (decision === "deny")
|
|
1056
|
+
throw new QueryBoundaryError("QUERY_REJECTED");
|
|
1057
|
+
if (decision !== "allow")
|
|
1058
|
+
throw new QueryBoundaryError("QUERY_AUTHORIZATION_FAILED");
|
|
1059
|
+
if (signal?.aborted)
|
|
1060
|
+
throw new QueryBoundaryError("QUERY_ABORTED");
|
|
1061
|
+
const started = performance.now();
|
|
1062
|
+
const metadata = { operation: contract.name, ...contract.fingerprint ? { fingerprint: contract.fingerprint } : {} };
|
|
1063
|
+
emit({ ...metadata, phase: "started" });
|
|
1064
|
+
try {
|
|
1065
|
+
const value = await contract.execute(input, identity, signal);
|
|
1066
|
+
if (signal?.aborted)
|
|
1067
|
+
throw new QueryBoundaryError("QUERY_ABORTED");
|
|
1068
|
+
let result;
|
|
1069
|
+
try {
|
|
1070
|
+
result = contract.result(value);
|
|
1071
|
+
} catch {
|
|
1072
|
+
throw new QueryBoundaryError("QUERY_RESULT_INVALID");
|
|
1073
|
+
}
|
|
1074
|
+
const durationMs = performance.now() - started;
|
|
1075
|
+
emit({ ...metadata, phase: "succeeded", durationMs, slow: durationMs >= (contract.slowMs ?? 500) });
|
|
1076
|
+
return result;
|
|
1077
|
+
} catch (error) {
|
|
1078
|
+
const durationMs = performance.now() - started;
|
|
1079
|
+
emit({ ...metadata, phase: "failed", durationMs, slow: durationMs >= (contract.slowMs ?? 500) });
|
|
1080
|
+
throw error;
|
|
1081
|
+
}
|
|
1082
|
+
};
|
|
1083
|
+
const execute = (identity, input, signal) => contract.policy ? contract.policy.execute((signal) => executeUnknown(identity, input, signal), signal) : executeUnknown(identity, input, signal);
|
|
1084
|
+
return Object.freeze({
|
|
1085
|
+
execute: (identity, input, signal) => execute(identity, input, signal),
|
|
1086
|
+
executeUnknown: execute
|
|
1087
|
+
});
|
|
1088
|
+
}
|
|
1089
|
+
async function decodePostgrestQuery(query, decode) {
|
|
1090
|
+
const response = await query;
|
|
1091
|
+
if (response.error !== null)
|
|
1092
|
+
throw new QueryBoundaryError("QUERY_TRANSPORT_FAILED");
|
|
1093
|
+
try {
|
|
1094
|
+
return decode(response.data);
|
|
1095
|
+
} catch {
|
|
1096
|
+
throw new QueryBoundaryError("QUERY_RESULT_INVALID");
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1021
1099
|
// src/command-adapter.ts
|
|
1022
1100
|
import {
|
|
1023
1101
|
CommandError,
|
|
@@ -1173,13 +1251,16 @@ export {
|
|
|
1173
1251
|
COMMAND_PERSISTENCE_SQL,
|
|
1174
1252
|
COMMAND_PERSISTENCE_UPGRADE_SQL,
|
|
1175
1253
|
DatabaseAccessError,
|
|
1254
|
+
QueryBoundaryError,
|
|
1176
1255
|
RpcContractError,
|
|
1177
1256
|
applyModulePlan,
|
|
1178
1257
|
buildDatabaseManifest,
|
|
1179
1258
|
createDatabaseAccessBoundary,
|
|
1180
1259
|
createPostgresCommandStore,
|
|
1181
1260
|
createRpcClient,
|
|
1261
|
+
decodePostgrestQuery,
|
|
1182
1262
|
defineDatabaseModule,
|
|
1263
|
+
defineReadQuery,
|
|
1183
1264
|
defineRpcContract,
|
|
1184
1265
|
explainObject,
|
|
1185
1266
|
extractSearchPath,
|
|
@@ -40,9 +40,6 @@ export declare function renderMigrationBindings(options: {
|
|
|
40
40
|
sql: string;
|
|
41
41
|
}[];
|
|
42
42
|
attestation: {
|
|
43
|
-
environment: string;
|
|
44
|
-
projectRef: string;
|
|
45
|
-
schema: 'supacloud.migration-binding-attestation.v1';
|
|
46
43
|
manifestSha256: string;
|
|
47
44
|
files: {
|
|
48
45
|
file: string;
|
|
@@ -53,5 +50,8 @@ export declare function renderMigrationBindings(options: {
|
|
|
53
50
|
type: MigrationBindingType;
|
|
54
51
|
}>;
|
|
55
52
|
}[];
|
|
53
|
+
environment: string;
|
|
54
|
+
projectRef: string;
|
|
55
|
+
schema: "supacloud.migration-binding-attestation.v1";
|
|
56
56
|
};
|
|
57
57
|
};
|
package/dist/query.d.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export interface QueryEvent {
|
|
2
|
+
readonly operation: string;
|
|
3
|
+
readonly fingerprint?: string;
|
|
4
|
+
readonly phase: "started" | "succeeded" | "failed";
|
|
5
|
+
readonly durationMs?: number;
|
|
6
|
+
readonly slow?: boolean;
|
|
7
|
+
}
|
|
8
|
+
export type QueryObserver = (event: Readonly<QueryEvent>) => void | Promise<void>;
|
|
9
|
+
export interface ReadQueryDefinition<Input, Result, Identity> {
|
|
10
|
+
name: string;
|
|
11
|
+
/** Parser-derived SQL fingerprint or a generated operation hash; never raw SQL. */
|
|
12
|
+
fingerprint?: string;
|
|
13
|
+
input(value: unknown): Input;
|
|
14
|
+
result(value: unknown): Result;
|
|
15
|
+
authorize(identity: Identity, input: NoInfer<Input>): "allow" | "deny" | Promise<"allow" | "deny">;
|
|
16
|
+
execute(input: NoInfer<Input>, identity: Identity, signal?: AbortSignal): Promise<unknown>;
|
|
17
|
+
observe?: QueryObserver;
|
|
18
|
+
slowMs?: number;
|
|
19
|
+
policy?: {
|
|
20
|
+
execute<Value>(run: (signal: AbortSignal) => Promise<Value>, parent?: AbortSignal): Promise<Value>;
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
export declare class QueryBoundaryError extends Error {
|
|
24
|
+
readonly code: "QUERY_INPUT_INVALID" | "QUERY_REJECTED" | "QUERY_AUTHORIZATION_FAILED" | "QUERY_RESULT_INVALID" | "QUERY_ABORTED" | "QUERY_TRANSPORT_FAILED";
|
|
25
|
+
constructor(code: "QUERY_INPUT_INVALID" | "QUERY_REJECTED" | "QUERY_AUTHORIZATION_FAILED" | "QUERY_RESULT_INVALID" | "QUERY_ABORTED" | "QUERY_TRANSPORT_FAILED");
|
|
26
|
+
}
|
|
27
|
+
/** Explicit composition shared by SQL, pg_graphql and PostgREST. No DI, caching or SQL rewriting. */
|
|
28
|
+
export declare function defineReadQuery<Input, Result, Identity>(definition: ReadQueryDefinition<Input, Result, Identity>): Readonly<{
|
|
29
|
+
execute: (identity: Identity, input: Input, signal?: AbortSignal) => Promise<Result>;
|
|
30
|
+
executeUnknown: (identity: Identity, input: unknown, signal?: AbortSignal) => Promise<Result>;
|
|
31
|
+
}>;
|
|
32
|
+
/** Preserve the official client's selected/embedded result type; validate before returning it. */
|
|
33
|
+
export declare function decodePostgrestQuery<Query extends PromiseLike<{
|
|
34
|
+
data: unknown;
|
|
35
|
+
error: unknown;
|
|
36
|
+
}>>(query: Query, decode: (value: unknown) => NoInfer<Awaited<Query>["data"]>): Promise<Awaited<Query>["data"]>;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export interface SqlAnalysis {
|
|
2
|
+
fingerprint: string;
|
|
3
|
+
defines: string[];
|
|
4
|
+
touches: string[];
|
|
5
|
+
references: string[];
|
|
6
|
+
review: string[];
|
|
7
|
+
}
|
|
8
|
+
/** PostgreSQL 18 grammar, not regex-based SQL rewriting. Names group overloads conservatively. */
|
|
9
|
+
export declare function analyzeSql(sql: string): Promise<SqlAnalysis>;
|
|
10
|
+
/** Read-only admission check; PostgreSQL READ ONLY and least-privilege grants remain mandatory. */
|
|
11
|
+
export declare function assertReadSql(sql: string): Promise<string>;
|
|
12
|
+
export interface SqlMigrationSource {
|
|
13
|
+
id: string;
|
|
14
|
+
owner: string;
|
|
15
|
+
sql: string;
|
|
16
|
+
/** Object IDs from a reviewed catalog graph; never silently inferred for dynamic SQL. */
|
|
17
|
+
dependencies?: readonly string[];
|
|
18
|
+
}
|
|
19
|
+
export interface SqlDependencyEdge {
|
|
20
|
+
dependent: string;
|
|
21
|
+
dependency: string;
|
|
22
|
+
}
|
|
23
|
+
export interface SqlCatalogDependencies {
|
|
24
|
+
edges: SqlDependencyEdge[];
|
|
25
|
+
review: string[];
|
|
26
|
+
}
|
|
27
|
+
/** Collapse columns/overloads to their owning object for conservative impact propagation. */
|
|
28
|
+
export declare function readSqlDependencyGraph(executor: {
|
|
29
|
+
query(sql: string, parameters: unknown[]): Promise<unknown>;
|
|
30
|
+
}, schemas: readonly string[]): Promise<SqlCatalogDependencies>;
|
|
31
|
+
export interface SqlMigrationSnapshot {
|
|
32
|
+
id: string;
|
|
33
|
+
owner: string;
|
|
34
|
+
sha256: string;
|
|
35
|
+
analysis: SqlAnalysis;
|
|
36
|
+
}
|
|
37
|
+
export interface SqlImpactPlan {
|
|
38
|
+
migrations: SqlMigrationSnapshot[];
|
|
39
|
+
edges: SqlDependencyEdge[];
|
|
40
|
+
changed: string[];
|
|
41
|
+
affectedObjects: string[];
|
|
42
|
+
affectedMigrations: string[];
|
|
43
|
+
errors: string[];
|
|
44
|
+
review: string[];
|
|
45
|
+
}
|
|
46
|
+
/** Offline, append-only migration review. Does not execute SQL or approve a deployment. */
|
|
47
|
+
export declare function planSqlImpact(sources: readonly SqlMigrationSource[], baseline?: readonly Pick<SqlMigrationSnapshot, "id" | "sha256">[], catalogEdges?: readonly SqlDependencyEdge[]): Promise<SqlImpactPlan>;
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
var __esm = (fn, res, err) => () => {
|
|
2
|
+
if (fn)
|
|
3
|
+
try {
|
|
4
|
+
res = fn(fn = 0);
|
|
5
|
+
} catch (e) {
|
|
6
|
+
err = [e];
|
|
7
|
+
}
|
|
8
|
+
if (err)
|
|
9
|
+
throw err[0];
|
|
10
|
+
return res;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
// src/sql-analysis.ts
|
|
14
|
+
import { createHash } from "node:crypto";
|
|
15
|
+
function record(value) {
|
|
16
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
17
|
+
}
|
|
18
|
+
function walk(value, visit) {
|
|
19
|
+
if (Array.isArray(value)) {
|
|
20
|
+
for (const item of value)
|
|
21
|
+
walk(item, visit);
|
|
22
|
+
} else if (record(value)) {
|
|
23
|
+
visit(value);
|
|
24
|
+
for (const item of Object.values(value))
|
|
25
|
+
walk(item, visit);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function names(value) {
|
|
29
|
+
if (!Array.isArray(value))
|
|
30
|
+
return [];
|
|
31
|
+
return value.flatMap((part) => record(part) && record(part["String"]) && typeof part["String"]["sval"] === "string" ? [part["String"]["sval"]] : []);
|
|
32
|
+
}
|
|
33
|
+
async function analyzeSql(sql) {
|
|
34
|
+
const { fingerprint, parse } = await import("libpg-query");
|
|
35
|
+
const result = await parse(sql);
|
|
36
|
+
const defines = new Set, touches = new Set, references = new Set, review = new Set;
|
|
37
|
+
const qualified = (kind, parts) => {
|
|
38
|
+
if (parts.length !== 2) {
|
|
39
|
+
review.add(`Resolve unqualified or unsupported ${kind} name: ${parts.join(".")}`);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
return objectId(kind, parts);
|
|
43
|
+
};
|
|
44
|
+
const relation = (value) => {
|
|
45
|
+
if (!record(value) || typeof value["relname"] !== "string")
|
|
46
|
+
return;
|
|
47
|
+
return qualified("relation", typeof value["schemaname"] === "string" ? [value["schemaname"], value["relname"]] : [value["relname"]]);
|
|
48
|
+
};
|
|
49
|
+
walk(result, (node) => {
|
|
50
|
+
if (record(node["RangeVar"])) {
|
|
51
|
+
const name = relation(node["RangeVar"]);
|
|
52
|
+
if (name)
|
|
53
|
+
references.add(name);
|
|
54
|
+
}
|
|
55
|
+
if (record(node["FuncCall"])) {
|
|
56
|
+
const name = qualified("function", names(node["FuncCall"]["funcname"]));
|
|
57
|
+
if (name)
|
|
58
|
+
references.add(name);
|
|
59
|
+
}
|
|
60
|
+
if (record(node["Constraint"])) {
|
|
61
|
+
const name = relation(node["Constraint"]["pktable"]);
|
|
62
|
+
if (name)
|
|
63
|
+
references.add(name);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
const statements = record(result) && Array.isArray(result["stmts"]) ? result["stmts"] : [];
|
|
67
|
+
for (const raw of statements) {
|
|
68
|
+
if (!record(raw) || !record(raw["stmt"]))
|
|
69
|
+
throw new Error("Invalid PostgreSQL parse tree");
|
|
70
|
+
const [kind, node] = Object.entries(raw["stmt"])[0] ?? [];
|
|
71
|
+
if (!kind || !record(node))
|
|
72
|
+
throw new Error("Invalid SQL statement");
|
|
73
|
+
const target = relation(node["relation"] ?? node["view"] ?? node["table"]);
|
|
74
|
+
if (target) {
|
|
75
|
+
touches.add(target);
|
|
76
|
+
if (kind === "CreateStmt" || kind === "ViewStmt")
|
|
77
|
+
defines.add(target);
|
|
78
|
+
}
|
|
79
|
+
if (kind === "DropStmt") {
|
|
80
|
+
for (const item of Array.isArray(node["objects"]) ? node["objects"] : []) {
|
|
81
|
+
const name = record(item) && record(item["List"]) ? qualified(node["removeType"] === "OBJECT_TABLE" || node["removeType"] === "OBJECT_VIEW" ? "relation" : "object", names(item["List"]["items"])) : undefined;
|
|
82
|
+
if (name)
|
|
83
|
+
touches.add(name);
|
|
84
|
+
else
|
|
85
|
+
review.add("Review dropped object identity and dependencies");
|
|
86
|
+
}
|
|
87
|
+
review.add("Destructive DROP requires review");
|
|
88
|
+
} else if (kind === "CreateFunctionStmt") {
|
|
89
|
+
const name = qualified("function", names(node["funcname"]));
|
|
90
|
+
if (name) {
|
|
91
|
+
defines.add(name);
|
|
92
|
+
touches.add(name);
|
|
93
|
+
}
|
|
94
|
+
let language, body;
|
|
95
|
+
for (const item of Array.isArray(node["options"]) ? node["options"] : []) {
|
|
96
|
+
if (!record(item) || !record(item["DefElem"]))
|
|
97
|
+
continue;
|
|
98
|
+
const option = item["DefElem"];
|
|
99
|
+
if (option["defname"] === "language")
|
|
100
|
+
language = names([option["arg"]])[0];
|
|
101
|
+
if (option["defname"] === "as" && record(option["arg"]) && record(option["arg"]["List"]))
|
|
102
|
+
body = names(option["arg"]["List"]["items"])[0];
|
|
103
|
+
}
|
|
104
|
+
if (language === "sql" && body !== undefined) {
|
|
105
|
+
const nested = await analyzeSql(body);
|
|
106
|
+
for (const name of [...nested.references, ...nested.touches])
|
|
107
|
+
references.add(name);
|
|
108
|
+
for (const item of nested.review)
|
|
109
|
+
review.add(item);
|
|
110
|
+
} else {
|
|
111
|
+
review.add("Function body dependencies require catalog verification or explicit reviewed dependencies");
|
|
112
|
+
}
|
|
113
|
+
review.add("Review function security, grants and overloads");
|
|
114
|
+
} else if (![
|
|
115
|
+
"SelectStmt",
|
|
116
|
+
"CreateStmt",
|
|
117
|
+
"ViewStmt",
|
|
118
|
+
"AlterTableStmt",
|
|
119
|
+
"IndexStmt",
|
|
120
|
+
"InsertStmt",
|
|
121
|
+
"UpdateStmt",
|
|
122
|
+
"DeleteStmt",
|
|
123
|
+
"CreatePolicyStmt",
|
|
124
|
+
"AlterPolicyStmt",
|
|
125
|
+
"CreateTrigStmt"
|
|
126
|
+
].includes(kind)) {
|
|
127
|
+
review.add(`Review unsupported dependency semantics: ${kind}`);
|
|
128
|
+
}
|
|
129
|
+
if (["AlterTableStmt", "CreatePolicyStmt", "AlterPolicyStmt", "CreateTrigStmt"].includes(kind)) {
|
|
130
|
+
review.add(`Review schema/access impact: ${kind}`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return {
|
|
134
|
+
fingerprint: await fingerprint(sql),
|
|
135
|
+
defines: [...defines].sort(),
|
|
136
|
+
touches: [...touches].sort(),
|
|
137
|
+
references: [...references].sort(),
|
|
138
|
+
review: [...review].sort()
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
async function assertReadSql(sql) {
|
|
142
|
+
const { fingerprint, parse } = await import("libpg-query");
|
|
143
|
+
const tree = await parse(sql);
|
|
144
|
+
if (!record(tree) || !Array.isArray(tree["stmts"]) || tree["stmts"].length !== 1)
|
|
145
|
+
throw new Error("Expected one read statement");
|
|
146
|
+
const entry = tree["stmts"][0];
|
|
147
|
+
if (!record(entry) || !record(entry["stmt"]) || !record(entry["stmt"]["SelectStmt"]))
|
|
148
|
+
throw new Error("Expected SELECT");
|
|
149
|
+
walk(tree, (node) => {
|
|
150
|
+
if (["InsertStmt", "UpdateStmt", "DeleteStmt", "MergeStmt", "IntoClause", "intoClause", "LockingClause"].some((name) => (name in node))) {
|
|
151
|
+
throw new Error("Mutating or locking SQL is not a read query");
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
return fingerprint(sql);
|
|
155
|
+
}
|
|
156
|
+
async function readSqlDependencyGraph(executor, schemas) {
|
|
157
|
+
if (!schemas.length || schemas.some((schema) => !schema.trim()))
|
|
158
|
+
throw new TypeError("Select explicit application schemas");
|
|
159
|
+
const schemaParameters = schemas.map((_, index) => `$${index + 1}::text`).join(", ");
|
|
160
|
+
const rows = await executor.query(`
|
|
161
|
+
WITH objects AS (
|
|
162
|
+
SELECT 'pg_class'::regclass::oid AS classid, c.oid AS objid, n.nspname,
|
|
163
|
+
'relation:' || to_json(n.nspname)::text || '.' || to_json(c.relname)::text AS identity
|
|
164
|
+
FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace
|
|
165
|
+
UNION ALL
|
|
166
|
+
SELECT 'pg_proc'::regclass::oid, p.oid, n.nspname,
|
|
167
|
+
'function:' || to_json(n.nspname)::text || '.' || to_json(p.proname)::text
|
|
168
|
+
FROM pg_proc p JOIN pg_namespace n ON n.oid=p.pronamespace
|
|
169
|
+
UNION ALL
|
|
170
|
+
SELECT 'pg_rewrite'::regclass::oid, r.oid, n.nspname,
|
|
171
|
+
'relation:' || to_json(n.nspname)::text || '.' || to_json(c.relname)::text
|
|
172
|
+
FROM pg_rewrite r JOIN pg_class c ON c.oid=r.ev_class JOIN pg_namespace n ON n.oid=c.relnamespace
|
|
173
|
+
UNION ALL
|
|
174
|
+
SELECT 'pg_policy'::regclass::oid, p.oid, n.nspname,
|
|
175
|
+
'relation:' || to_json(n.nspname)::text || '.' || to_json(c.relname)::text
|
|
176
|
+
FROM pg_policy p JOIN pg_class c ON c.oid=p.polrelid JOIN pg_namespace n ON n.oid=c.relnamespace
|
|
177
|
+
UNION ALL
|
|
178
|
+
SELECT 'pg_trigger'::regclass::oid, t.oid, n.nspname,
|
|
179
|
+
'relation:' || to_json(n.nspname)::text || '.' || to_json(c.relname)::text
|
|
180
|
+
FROM pg_trigger t JOIN pg_class c ON c.oid=t.tgrelid JOIN pg_namespace n ON n.oid=c.relnamespace
|
|
181
|
+
)
|
|
182
|
+
SELECT DISTINCT a.identity AS dependent, b.identity AS dependency
|
|
183
|
+
FROM pg_depend d
|
|
184
|
+
JOIN objects a ON a.classid=d.classid AND a.objid=d.objid
|
|
185
|
+
JOIN objects b ON b.classid=d.refclassid AND b.objid=d.refobjid
|
|
186
|
+
WHERE a.nspname IN (${schemaParameters})
|
|
187
|
+
AND b.nspname IN (${schemaParameters}) AND a.identity<>b.identity
|
|
188
|
+
ORDER BY dependent, dependency`, [...schemas]);
|
|
189
|
+
if (!Array.isArray(rows))
|
|
190
|
+
throw new TypeError("Invalid PostgreSQL dependency rows");
|
|
191
|
+
const edges = rows.map((row) => {
|
|
192
|
+
if (!record(row) || typeof row["dependent"] !== "string" || typeof row["dependency"] !== "string")
|
|
193
|
+
throw new TypeError("Invalid PostgreSQL dependency edge");
|
|
194
|
+
return { dependent: row["dependent"], dependency: row["dependency"] };
|
|
195
|
+
});
|
|
196
|
+
const functions = await executor.query(`
|
|
197
|
+
SELECT DISTINCT n.nspname AS schema, p.proname AS name
|
|
198
|
+
FROM pg_proc p JOIN pg_namespace n ON n.oid=p.pronamespace
|
|
199
|
+
JOIN pg_language l ON l.oid=p.prolang
|
|
200
|
+
WHERE n.nspname IN (${schemaParameters})
|
|
201
|
+
AND (l.lanname='plpgsql' OR (l.lanname='sql' AND p.prosqlbody IS NULL))
|
|
202
|
+
ORDER BY schema, name`, [...schemas]);
|
|
203
|
+
if (!Array.isArray(functions))
|
|
204
|
+
throw new TypeError("Invalid PostgreSQL function rows");
|
|
205
|
+
return {
|
|
206
|
+
edges,
|
|
207
|
+
review: functions.map((row) => {
|
|
208
|
+
if (!record(row) || typeof row["schema"] !== "string" || typeof row["name"] !== "string")
|
|
209
|
+
throw new TypeError("Invalid function identity");
|
|
210
|
+
return `Catalog cannot prove function-body dependencies: ${objectId("function", [row["schema"], row["name"]])}`;
|
|
211
|
+
})
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
async function planSqlImpact(sources, baseline = [], catalogEdges = []) {
|
|
215
|
+
const ids = new Set;
|
|
216
|
+
const migrations = [];
|
|
217
|
+
const edges = new Map;
|
|
218
|
+
const addEdge = (dependent, dependency) => {
|
|
219
|
+
if (dependent !== dependency)
|
|
220
|
+
edges.set(JSON.stringify([dependent, dependency]), { dependent, dependency });
|
|
221
|
+
};
|
|
222
|
+
for (const edge of catalogEdges)
|
|
223
|
+
addEdge(edge["dependent"], edge["dependency"]);
|
|
224
|
+
for (const source of sources) {
|
|
225
|
+
if (!source.id.trim() || !source.owner.trim() || ids.has(source.id))
|
|
226
|
+
throw new Error("Migration IDs must be unique and each migration must have an owner");
|
|
227
|
+
ids.add(source.id);
|
|
228
|
+
const analysis = await analyzeSql(source.sql);
|
|
229
|
+
const references = [...new Set([...analysis.references, ...source.dependencies ?? []])].sort();
|
|
230
|
+
for (const target of analysis.touches)
|
|
231
|
+
for (const reference of references)
|
|
232
|
+
addEdge(target, reference);
|
|
233
|
+
migrations.push({
|
|
234
|
+
id: source.id,
|
|
235
|
+
owner: source.owner,
|
|
236
|
+
sha256: createHash("sha256").update(source.sql).digest("hex"),
|
|
237
|
+
analysis: { ...analysis, references }
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
const current = new Map(migrations.map((entry) => [entry.id, entry]));
|
|
241
|
+
const previous = new Map(baseline.map((entry) => [entry.id, entry]));
|
|
242
|
+
const errors = [];
|
|
243
|
+
if (previous.size !== baseline.length)
|
|
244
|
+
errors.push("Duplicate migration in baseline");
|
|
245
|
+
for (const [index, old] of baseline.entries()) {
|
|
246
|
+
if (current.get(old.id)?.sha256 !== old.sha256 || migrations[index]?.id !== old.id) {
|
|
247
|
+
errors.push(`Applied migration removed, reordered or rewritten: ${old.id}`);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
const changed = migrations.filter((entry) => previous.get(entry.id)?.sha256 !== entry.sha256);
|
|
251
|
+
const affected = new Set(changed.flatMap((entry) => entry.analysis.touches));
|
|
252
|
+
const reverse = new Map;
|
|
253
|
+
for (const edge of edges.values()) {
|
|
254
|
+
const values = reverse.get(edge["dependency"]) ?? [];
|
|
255
|
+
values.push(edge["dependent"]);
|
|
256
|
+
reverse.set(edge["dependency"], values);
|
|
257
|
+
}
|
|
258
|
+
for (const object of affected)
|
|
259
|
+
for (const dependent of reverse.get(object) ?? [])
|
|
260
|
+
affected.add(dependent);
|
|
261
|
+
const definitions = new Map;
|
|
262
|
+
migrations.forEach((entry, index) => {
|
|
263
|
+
for (const object of entry.analysis.defines)
|
|
264
|
+
if (!definitions.has(object))
|
|
265
|
+
definitions.set(object, index);
|
|
266
|
+
});
|
|
267
|
+
migrations.forEach((entry, index) => {
|
|
268
|
+
for (const reference of entry.analysis.references) {
|
|
269
|
+
if ((definitions.get(reference) ?? -1) > index)
|
|
270
|
+
errors.push(`${entry.id} references an object defined by a later migration: ${reference}`);
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
return {
|
|
274
|
+
migrations,
|
|
275
|
+
edges: [...edges.values()].sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))),
|
|
276
|
+
changed: changed.map((entry) => entry.id),
|
|
277
|
+
affectedObjects: [...affected].sort(),
|
|
278
|
+
affectedMigrations: migrations.filter((entry) => [...entry.analysis.touches, ...entry.analysis.references].some((object) => affected.has(object))).map((entry) => entry.id),
|
|
279
|
+
errors,
|
|
280
|
+
review: changed.flatMap((entry) => entry.analysis.review.map((note) => `${entry.id}: ${note}`))
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
var objectId = (kind, parts) => `${kind}:${parts.map((part) => JSON.stringify(part)).join(".")}`;
|
|
284
|
+
var init_sql_analysis = () => {};
|
|
285
|
+
init_sql_analysis();
|
|
286
|
+
|
|
287
|
+
export {
|
|
288
|
+
analyzeSql,
|
|
289
|
+
assertReadSql,
|
|
290
|
+
planSqlImpact,
|
|
291
|
+
readSqlDependencyGraph
|
|
292
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { type SqlDependencyEdge } from "./sql-analysis";
|
|
3
|
+
export declare function runSqlImpactFile(path: string): Promise<{
|
|
4
|
+
digest: string;
|
|
5
|
+
approved: boolean;
|
|
6
|
+
ok: boolean;
|
|
7
|
+
migrations: import("./sql-analysis").SqlMigrationSnapshot[];
|
|
8
|
+
edges: SqlDependencyEdge[];
|
|
9
|
+
changed: string[];
|
|
10
|
+
affectedObjects: string[];
|
|
11
|
+
affectedMigrations: string[];
|
|
12
|
+
errors: string[];
|
|
13
|
+
review: string[];
|
|
14
|
+
}>;
|