@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.
@@ -0,0 +1,372 @@
1
+ #!/usr/bin/env node
2
+ var __esm = (fn, res, err) => () => {
3
+ if (fn)
4
+ try {
5
+ res = fn(fn = 0);
6
+ } catch (e) {
7
+ err = [e];
8
+ }
9
+ if (err)
10
+ throw err[0];
11
+ return res;
12
+ };
13
+
14
+ // src/sql-analysis.ts
15
+ import { createHash } from "node:crypto";
16
+ function record(value) {
17
+ return value !== null && typeof value === "object" && !Array.isArray(value);
18
+ }
19
+ function walk(value, visit) {
20
+ if (Array.isArray(value)) {
21
+ for (const item of value)
22
+ walk(item, visit);
23
+ } else if (record(value)) {
24
+ visit(value);
25
+ for (const item of Object.values(value))
26
+ walk(item, visit);
27
+ }
28
+ }
29
+ function names(value) {
30
+ if (!Array.isArray(value))
31
+ return [];
32
+ return value.flatMap((part) => record(part) && record(part["String"]) && typeof part["String"]["sval"] === "string" ? [part["String"]["sval"]] : []);
33
+ }
34
+ async function analyzeSql(sql) {
35
+ const { fingerprint, parse } = await import("libpg-query");
36
+ const result = await parse(sql);
37
+ const defines = new Set, touches = new Set, references = new Set, review = new Set;
38
+ const qualified = (kind, parts) => {
39
+ if (parts.length !== 2) {
40
+ review.add(`Resolve unqualified or unsupported ${kind} name: ${parts.join(".")}`);
41
+ return;
42
+ }
43
+ return objectId(kind, parts);
44
+ };
45
+ const relation = (value) => {
46
+ if (!record(value) || typeof value["relname"] !== "string")
47
+ return;
48
+ return qualified("relation", typeof value["schemaname"] === "string" ? [value["schemaname"], value["relname"]] : [value["relname"]]);
49
+ };
50
+ walk(result, (node) => {
51
+ if (record(node["RangeVar"])) {
52
+ const name = relation(node["RangeVar"]);
53
+ if (name)
54
+ references.add(name);
55
+ }
56
+ if (record(node["FuncCall"])) {
57
+ const name = qualified("function", names(node["FuncCall"]["funcname"]));
58
+ if (name)
59
+ references.add(name);
60
+ }
61
+ if (record(node["Constraint"])) {
62
+ const name = relation(node["Constraint"]["pktable"]);
63
+ if (name)
64
+ references.add(name);
65
+ }
66
+ });
67
+ const statements = record(result) && Array.isArray(result["stmts"]) ? result["stmts"] : [];
68
+ for (const raw of statements) {
69
+ if (!record(raw) || !record(raw["stmt"]))
70
+ throw new Error("Invalid PostgreSQL parse tree");
71
+ const [kind, node] = Object.entries(raw["stmt"])[0] ?? [];
72
+ if (!kind || !record(node))
73
+ throw new Error("Invalid SQL statement");
74
+ const target = relation(node["relation"] ?? node["view"] ?? node["table"]);
75
+ if (target) {
76
+ touches.add(target);
77
+ if (kind === "CreateStmt" || kind === "ViewStmt")
78
+ defines.add(target);
79
+ }
80
+ if (kind === "DropStmt") {
81
+ for (const item of Array.isArray(node["objects"]) ? node["objects"] : []) {
82
+ const name = record(item) && record(item["List"]) ? qualified(node["removeType"] === "OBJECT_TABLE" || node["removeType"] === "OBJECT_VIEW" ? "relation" : "object", names(item["List"]["items"])) : undefined;
83
+ if (name)
84
+ touches.add(name);
85
+ else
86
+ review.add("Review dropped object identity and dependencies");
87
+ }
88
+ review.add("Destructive DROP requires review");
89
+ } else if (kind === "CreateFunctionStmt") {
90
+ const name = qualified("function", names(node["funcname"]));
91
+ if (name) {
92
+ defines.add(name);
93
+ touches.add(name);
94
+ }
95
+ let language, body;
96
+ for (const item of Array.isArray(node["options"]) ? node["options"] : []) {
97
+ if (!record(item) || !record(item["DefElem"]))
98
+ continue;
99
+ const option = item["DefElem"];
100
+ if (option["defname"] === "language")
101
+ language = names([option["arg"]])[0];
102
+ if (option["defname"] === "as" && record(option["arg"]) && record(option["arg"]["List"]))
103
+ body = names(option["arg"]["List"]["items"])[0];
104
+ }
105
+ if (language === "sql" && body !== undefined) {
106
+ const nested = await analyzeSql(body);
107
+ for (const name of [...nested.references, ...nested.touches])
108
+ references.add(name);
109
+ for (const item of nested.review)
110
+ review.add(item);
111
+ } else {
112
+ review.add("Function body dependencies require catalog verification or explicit reviewed dependencies");
113
+ }
114
+ review.add("Review function security, grants and overloads");
115
+ } else if (![
116
+ "SelectStmt",
117
+ "CreateStmt",
118
+ "ViewStmt",
119
+ "AlterTableStmt",
120
+ "IndexStmt",
121
+ "InsertStmt",
122
+ "UpdateStmt",
123
+ "DeleteStmt",
124
+ "CreatePolicyStmt",
125
+ "AlterPolicyStmt",
126
+ "CreateTrigStmt"
127
+ ].includes(kind)) {
128
+ review.add(`Review unsupported dependency semantics: ${kind}`);
129
+ }
130
+ if (["AlterTableStmt", "CreatePolicyStmt", "AlterPolicyStmt", "CreateTrigStmt"].includes(kind)) {
131
+ review.add(`Review schema/access impact: ${kind}`);
132
+ }
133
+ }
134
+ return {
135
+ fingerprint: await fingerprint(sql),
136
+ defines: [...defines].sort(),
137
+ touches: [...touches].sort(),
138
+ references: [...references].sort(),
139
+ review: [...review].sort()
140
+ };
141
+ }
142
+ async function assertReadSql(sql) {
143
+ const { fingerprint, parse } = await import("libpg-query");
144
+ const tree = await parse(sql);
145
+ if (!record(tree) || !Array.isArray(tree["stmts"]) || tree["stmts"].length !== 1)
146
+ throw new Error("Expected one read statement");
147
+ const entry = tree["stmts"][0];
148
+ if (!record(entry) || !record(entry["stmt"]) || !record(entry["stmt"]["SelectStmt"]))
149
+ throw new Error("Expected SELECT");
150
+ walk(tree, (node) => {
151
+ if (["InsertStmt", "UpdateStmt", "DeleteStmt", "MergeStmt", "IntoClause", "intoClause", "LockingClause"].some((name) => (name in node))) {
152
+ throw new Error("Mutating or locking SQL is not a read query");
153
+ }
154
+ });
155
+ return fingerprint(sql);
156
+ }
157
+ async function readSqlDependencyGraph(executor, schemas) {
158
+ if (!schemas.length || schemas.some((schema) => !schema.trim()))
159
+ throw new TypeError("Select explicit application schemas");
160
+ const schemaParameters = schemas.map((_, index) => `$${index + 1}::text`).join(", ");
161
+ const rows = await executor.query(`
162
+ WITH objects AS (
163
+ SELECT 'pg_class'::regclass::oid AS classid, c.oid AS objid, n.nspname,
164
+ 'relation:' || to_json(n.nspname)::text || '.' || to_json(c.relname)::text AS identity
165
+ FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace
166
+ UNION ALL
167
+ SELECT 'pg_proc'::regclass::oid, p.oid, n.nspname,
168
+ 'function:' || to_json(n.nspname)::text || '.' || to_json(p.proname)::text
169
+ FROM pg_proc p JOIN pg_namespace n ON n.oid=p.pronamespace
170
+ UNION ALL
171
+ SELECT 'pg_rewrite'::regclass::oid, r.oid, n.nspname,
172
+ 'relation:' || to_json(n.nspname)::text || '.' || to_json(c.relname)::text
173
+ FROM pg_rewrite r JOIN pg_class c ON c.oid=r.ev_class JOIN pg_namespace n ON n.oid=c.relnamespace
174
+ UNION ALL
175
+ SELECT 'pg_policy'::regclass::oid, p.oid, n.nspname,
176
+ 'relation:' || to_json(n.nspname)::text || '.' || to_json(c.relname)::text
177
+ FROM pg_policy p JOIN pg_class c ON c.oid=p.polrelid JOIN pg_namespace n ON n.oid=c.relnamespace
178
+ UNION ALL
179
+ SELECT 'pg_trigger'::regclass::oid, t.oid, n.nspname,
180
+ 'relation:' || to_json(n.nspname)::text || '.' || to_json(c.relname)::text
181
+ FROM pg_trigger t JOIN pg_class c ON c.oid=t.tgrelid JOIN pg_namespace n ON n.oid=c.relnamespace
182
+ )
183
+ SELECT DISTINCT a.identity AS dependent, b.identity AS dependency
184
+ FROM pg_depend d
185
+ JOIN objects a ON a.classid=d.classid AND a.objid=d.objid
186
+ JOIN objects b ON b.classid=d.refclassid AND b.objid=d.refobjid
187
+ WHERE a.nspname IN (${schemaParameters})
188
+ AND b.nspname IN (${schemaParameters}) AND a.identity<>b.identity
189
+ ORDER BY dependent, dependency`, [...schemas]);
190
+ if (!Array.isArray(rows))
191
+ throw new TypeError("Invalid PostgreSQL dependency rows");
192
+ const edges = rows.map((row) => {
193
+ if (!record(row) || typeof row["dependent"] !== "string" || typeof row["dependency"] !== "string")
194
+ throw new TypeError("Invalid PostgreSQL dependency edge");
195
+ return { dependent: row["dependent"], dependency: row["dependency"] };
196
+ });
197
+ const functions = await executor.query(`
198
+ SELECT DISTINCT n.nspname AS schema, p.proname AS name
199
+ FROM pg_proc p JOIN pg_namespace n ON n.oid=p.pronamespace
200
+ JOIN pg_language l ON l.oid=p.prolang
201
+ WHERE n.nspname IN (${schemaParameters})
202
+ AND (l.lanname='plpgsql' OR (l.lanname='sql' AND p.prosqlbody IS NULL))
203
+ ORDER BY schema, name`, [...schemas]);
204
+ if (!Array.isArray(functions))
205
+ throw new TypeError("Invalid PostgreSQL function rows");
206
+ return {
207
+ edges,
208
+ review: functions.map((row) => {
209
+ if (!record(row) || typeof row["schema"] !== "string" || typeof row["name"] !== "string")
210
+ throw new TypeError("Invalid function identity");
211
+ return `Catalog cannot prove function-body dependencies: ${objectId("function", [row["schema"], row["name"]])}`;
212
+ })
213
+ };
214
+ }
215
+ async function planSqlImpact(sources, baseline = [], catalogEdges = []) {
216
+ const ids = new Set;
217
+ const migrations = [];
218
+ const edges = new Map;
219
+ const addEdge = (dependent, dependency) => {
220
+ if (dependent !== dependency)
221
+ edges.set(JSON.stringify([dependent, dependency]), { dependent, dependency });
222
+ };
223
+ for (const edge of catalogEdges)
224
+ addEdge(edge["dependent"], edge["dependency"]);
225
+ for (const source of sources) {
226
+ if (!source.id.trim() || !source.owner.trim() || ids.has(source.id))
227
+ throw new Error("Migration IDs must be unique and each migration must have an owner");
228
+ ids.add(source.id);
229
+ const analysis = await analyzeSql(source.sql);
230
+ const references = [...new Set([...analysis.references, ...source.dependencies ?? []])].sort();
231
+ for (const target of analysis.touches)
232
+ for (const reference of references)
233
+ addEdge(target, reference);
234
+ migrations.push({
235
+ id: source.id,
236
+ owner: source.owner,
237
+ sha256: createHash("sha256").update(source.sql).digest("hex"),
238
+ analysis: { ...analysis, references }
239
+ });
240
+ }
241
+ const current = new Map(migrations.map((entry) => [entry.id, entry]));
242
+ const previous = new Map(baseline.map((entry) => [entry.id, entry]));
243
+ const errors = [];
244
+ if (previous.size !== baseline.length)
245
+ errors.push("Duplicate migration in baseline");
246
+ for (const [index, old] of baseline.entries()) {
247
+ if (current.get(old.id)?.sha256 !== old.sha256 || migrations[index]?.id !== old.id) {
248
+ errors.push(`Applied migration removed, reordered or rewritten: ${old.id}`);
249
+ }
250
+ }
251
+ const changed = migrations.filter((entry) => previous.get(entry.id)?.sha256 !== entry.sha256);
252
+ const affected = new Set(changed.flatMap((entry) => entry.analysis.touches));
253
+ const reverse = new Map;
254
+ for (const edge of edges.values()) {
255
+ const values = reverse.get(edge["dependency"]) ?? [];
256
+ values.push(edge["dependent"]);
257
+ reverse.set(edge["dependency"], values);
258
+ }
259
+ for (const object of affected)
260
+ for (const dependent of reverse.get(object) ?? [])
261
+ affected.add(dependent);
262
+ const definitions = new Map;
263
+ migrations.forEach((entry, index) => {
264
+ for (const object of entry.analysis.defines)
265
+ if (!definitions.has(object))
266
+ definitions.set(object, index);
267
+ });
268
+ migrations.forEach((entry, index) => {
269
+ for (const reference of entry.analysis.references) {
270
+ if ((definitions.get(reference) ?? -1) > index)
271
+ errors.push(`${entry.id} references an object defined by a later migration: ${reference}`);
272
+ }
273
+ });
274
+ return {
275
+ migrations,
276
+ edges: [...edges.values()].sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))),
277
+ changed: changed.map((entry) => entry.id),
278
+ affectedObjects: [...affected].sort(),
279
+ affectedMigrations: migrations.filter((entry) => [...entry.analysis.touches, ...entry.analysis.references].some((object) => affected.has(object))).map((entry) => entry.id),
280
+ errors,
281
+ review: changed.flatMap((entry) => entry.analysis.review.map((note) => `${entry.id}: ${note}`))
282
+ };
283
+ }
284
+ var objectId = (kind, parts) => `${kind}:${parts.map((part) => JSON.stringify(part)).join(".")}`;
285
+ var init_sql_analysis = () => {};
286
+
287
+ // src/sql-impact-cli.ts
288
+ init_sql_analysis();
289
+ import { createHash as createHash2 } from "node:crypto";
290
+ import { readFile } from "node:fs/promises";
291
+ import { dirname, resolve } from "node:path";
292
+ import { fileURLToPath } from "node:url";
293
+ function object(value) {
294
+ if (!isRecord(value))
295
+ throw new TypeError("Expected configuration object");
296
+ return value;
297
+ }
298
+ function isRecord(value) {
299
+ return value !== null && typeof value === "object" && !Array.isArray(value);
300
+ }
301
+ function text(value) {
302
+ if (typeof value !== "string" || !value.trim())
303
+ throw new TypeError("Expected nonempty text");
304
+ return value;
305
+ }
306
+ function strings(value) {
307
+ if (!Array.isArray(value))
308
+ throw new TypeError("Expected string list");
309
+ return value.map(text);
310
+ }
311
+ async function runSqlImpactFile(path) {
312
+ const absolute = resolve(path), directory = dirname(absolute);
313
+ const raw = JSON.parse(await readFile(absolute, "utf8"));
314
+ const config = object(raw);
315
+ if (!Array.isArray(config.migrations))
316
+ throw new TypeError("Expected ordered migrations");
317
+ const sources = [];
318
+ for (const value of config.migrations) {
319
+ const entry = object(value);
320
+ sources.push({
321
+ id: text(entry.id),
322
+ owner: text(entry.owner),
323
+ sql: await readFile(resolve(directory, text(entry.path)), "utf8"),
324
+ ...entry.dependencies === undefined ? {} : { dependencies: strings(entry.dependencies) }
325
+ });
326
+ }
327
+ const baseline = config.baseline === undefined ? [] : (() => {
328
+ if (!Array.isArray(config.baseline))
329
+ throw new TypeError("Invalid baseline");
330
+ return config.baseline.map((value) => {
331
+ const entry = object(value), sha256 = text(entry.sha256);
332
+ if (!/^[a-f0-9]{64}$/.test(sha256))
333
+ throw new TypeError("Invalid baseline hash");
334
+ return { id: text(entry.id), sha256 };
335
+ });
336
+ })();
337
+ const edges = config.catalogEdges === undefined ? [] : (() => {
338
+ if (!Array.isArray(config.catalogEdges))
339
+ throw new TypeError("Invalid catalogEdges");
340
+ return config.catalogEdges.map((value) => {
341
+ const entry = object(value);
342
+ return { dependent: text(entry.dependent), dependency: text(entry.dependency) };
343
+ });
344
+ })();
345
+ const plan = await planSqlImpact(sources, baseline, edges);
346
+ const catalogReview = config.catalogReview === undefined ? [] : strings(config.catalogReview);
347
+ plan.review.push(...catalogReview);
348
+ if (config.catalogEdges === undefined)
349
+ plan.review.push("No live catalog snapshot supplied; verify deployed dependencies before applying");
350
+ const digest = createHash2("sha256").update(JSON.stringify(plan)).digest("hex");
351
+ const approved = config.approvedDigest === digest;
352
+ return { ...plan, digest, approved, ok: plan.errors.length === 0 && (plan.review.length === 0 || approved) };
353
+ }
354
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
355
+ const args = process.argv.slice(2);
356
+ try {
357
+ const path = args[0];
358
+ if (!path || path.startsWith("-") || args.slice(1).some((arg) => arg !== "--check")) {
359
+ throw new Error("Usage: supacloud-sql-impact <config.json> [--check]");
360
+ }
361
+ const result = await runSqlImpactFile(path);
362
+ console.log(JSON.stringify(result, null, 2));
363
+ if (args.includes("--check") && !result.ok)
364
+ process.exitCode = 1;
365
+ } catch (error) {
366
+ console.error(error instanceof Error ? error.message : "SQL impact analysis failed");
367
+ process.exitCode = 1;
368
+ }
369
+ }
370
+ export {
371
+ runSqlImpactFile
372
+ };
package/package.json CHANGED
@@ -1,12 +1,27 @@
1
1
  {
2
2
  "name": "@supacloud/db",
3
- "version": "0.7.1",
3
+ "version": "0.9.0",
4
4
  "description": "Database governance layer for SupaCloud: RLS policies, RPC functions and grants as first-class resources, with manifest/catalog reconciliation",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
7
7
  "module": "./dist/index.js",
8
8
  "types": "./dist/index.d.ts",
9
+ "bin": {
10
+ "supacloud-sql-impact": "./dist/sql-impact-cli.js"
11
+ },
9
12
  "exports": {
13
+ "./sql-analysis": {
14
+ "types": "./dist/sql-analysis.d.ts",
15
+ "import": "./dist/sql-analysis.js"
16
+ },
17
+ "./drizzle": {
18
+ "types": "./dist/drizzle.d.ts",
19
+ "import": "./dist/drizzle.js"
20
+ },
21
+ "./drizzle-bun": {
22
+ "types": "./dist/drizzle-bun.d.ts",
23
+ "import": "./dist/drizzle-bun.js"
24
+ },
10
25
  "./bun": {
11
26
  "types": "./dist/command-bun.d.ts",
12
27
  "import": "./dist/command-bun.js"
@@ -24,13 +39,14 @@
24
39
  ],
25
40
  "scripts": {
26
41
  "build": "bun run clean && bun run build:js && bun run build:types",
27
- "build:js": "bun build src/index.ts src/command-bun.ts --outdir dist --target node --external bun --external @supacloud/contracts",
42
+ "build:js": "bun build src/index.ts src/command-bun.ts src/drizzle.ts src/drizzle-bun.ts src/sql-analysis.ts src/sql-impact-cli.ts --outdir dist --target node --external bun --external @supacloud/contracts --external drizzle-orm --external libpg-query",
28
43
  "build:types": "tsc -p tsconfig.json --emitDeclarationOnly",
29
44
  "clean": "rm -rf dist",
30
45
  "prepublishOnly": "bun run build",
31
46
  "test": "bun test",
32
47
  "typecheck": "tsc -p tsconfig.json --noEmit",
33
- "typecheck:test": "tsc -p tsconfig.test.json --noEmit"
48
+ "typecheck:test": "tsc -p tsconfig.test.json --noEmit",
49
+ "typecheck:drizzle": "tsc -p tsconfig.drizzle.json --noEmit"
34
50
  },
35
51
  "keywords": [
36
52
  "supacloud",
@@ -46,13 +62,25 @@
46
62
  "directory": "packages/db"
47
63
  },
48
64
  "devDependencies": {
65
+ "@sinclair/typebox": "0.34.52",
66
+ "@typescript/typescript6": "^6.0.2",
67
+ "drizzle-orm": "1.0.0-rc.4",
49
68
  "@types/bun": "^1.4.2",
50
69
  "typescript": "^7.0.2"
51
70
  },
52
71
  "dependencies": {
53
- "@supacloud/contracts": "0.3.1"
72
+ "libpg-query": "18.1.4",
73
+ "@supacloud/contracts": "0.5.0"
74
+ },
75
+ "peerDependencies": {
76
+ "drizzle-orm": "1.0.0-rc.4"
77
+ },
78
+ "peerDependenciesMeta": {
79
+ "drizzle-orm": {
80
+ "optional": true
81
+ }
54
82
  },
55
83
  "overrides": {
56
- "@supacloud/contracts": "0.3.1"
84
+ "@supacloud/contracts": "0.5.0"
57
85
  }
58
86
  }