@supacloud/db 0.10.0 → 0.12.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/NOTICE CHANGED
@@ -1,22 +1,17 @@
1
1
  SupaCloud
2
2
  Copyright 2025 SupaCloud Contributors
3
3
 
4
- SupaCloud project-owned code in this revision is licensed under the GNU
5
- Affero General Public License, version 3 only (AGPL-3.0-only). The complete
6
- license is provided in LICENSE. This is not an "or later" grant.
4
+ SupaCloud project-owned code in this revision is licensed under the Apache
5
+ License, Version 2.0 (Apache-2.0). The complete license is provided in
6
+ LICENSE.
7
7
 
8
8
  This program is distributed without any warranty; without even the implied
9
9
  warranty of merchantability or fitness for a particular purpose.
10
10
 
11
11
  Third-party components, incorporated upstream code, and their copyright,
12
- license and attribution notices retain their original terms. This notice
13
- does not replace those terms or claim ownership of third-party work.
12
+ license and attribution notices retain their original terms. This notice does
13
+ not replace those terms or claim ownership of third-party work.
14
14
 
15
- Earlier SupaCloud code was distributed under Apache-2.0 or MIT as indicated
16
- in its original files and package metadata. Previously granted permissions
17
- are not revoked by this change. The historical Apache-2.0 text is preserved
18
- in LICENSE-APACHE-2.0.txt. Retain any applicable upstream MIT notices as well.
19
-
20
- Before publishing this licensing change, maintainers must confirm authority
21
- to license the affected contributions and compatibility of incorporated
22
- third-party code. This notice is not evidence of contributor consent.
15
+ Previously released SupaCloud versions retain the permissions granted with
16
+ those versions. See the historical license files and release notices where
17
+ applicable.
package/README.md CHANGED
@@ -250,3 +250,12 @@ bun run typecheck
250
250
  bun run typecheck:test
251
251
  bun run build # bun build --target node + tsc 声明文件
252
252
  ```
253
+
254
+ ## Database Source Defaults
255
+
256
+ `supacloud-db assess|generate|check --root <project>` provides offline source-boundary
257
+ assessment, RPC contracts and freshness checks. Use `database.sources.json` to
258
+ separate Drizzle declarations, maintained function SQL, append-only migrations,
259
+ derived contracts and audit output. `check` and `assess` never write or connect;
260
+ `generate` only writes derived contracts and refuses changes to recorded migration
261
+ history. See the repository's `docs/database-source-contracts.md` for adoption.
@@ -0,0 +1,17 @@
1
+ export interface RpcParameter {
2
+ name: string;
3
+ sqlType: string;
4
+ optional: boolean;
5
+ }
6
+ export interface RpcSourceContract {
7
+ name: string;
8
+ identity: string;
9
+ parameters: RpcParameter[];
10
+ returnType: string;
11
+ callable: boolean;
12
+ }
13
+ /** Tracks explicit top-level DDL only; dynamic SQL and deployed parity need catalog verification. */
14
+ export declare function migrationFunctionIdentities(sources: readonly string[]): Promise<Set<string>>;
15
+ /** Source declarations, never an assertion about role inheritance or a deployed database. */
16
+ export declare function rpcSourceContract(sql: string, role: string): Promise<RpcSourceContract>;
17
+ export declare function renderRpcSourceTypes(contracts: readonly RpcSourceContract[]): string;
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export declare function databaseSourcesMain(args: string[]): Promise<number>;
@@ -0,0 +1,577 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/source-contracts-cli.ts
4
+ import { existsSync, realpathSync } from "node:fs";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ // src/source-contracts.ts
8
+ import { createHash } from "node:crypto";
9
+ import { lstat, mkdir, open, readFile, readdir, writeFile } from "node:fs/promises";
10
+ import { dirname, isAbsolute, join, relative, resolve } from "node:path";
11
+ import * as ts from "@typescript/typescript6";
12
+
13
+ // src/rpc-source-contracts.ts
14
+ import { parse } from "libpg-query";
15
+ function names(nodes = []) {
16
+ return nodes.map((node) => {
17
+ if (!("String" in node) || typeof node.String.sval !== "string")
18
+ throw new Error("Invalid SQL identifier");
19
+ return node.String.sval;
20
+ });
21
+ }
22
+ function typeName(type) {
23
+ if (!type?.names?.length)
24
+ throw new Error("Missing SQL type");
25
+ const parts = names(type.names);
26
+ if (parts[0] === "pg_catalog")
27
+ parts.shift();
28
+ const aliases = {
29
+ int2: "smallint",
30
+ int4: "integer",
31
+ int8: "bigint",
32
+ bool: "boolean",
33
+ float4: "real",
34
+ float8: "double precision",
35
+ varchar: "character varying",
36
+ timestamptz: "timestamp with time zone",
37
+ timestamp: "timestamp without time zone",
38
+ timetz: "time with time zone",
39
+ time: "time without time zone"
40
+ };
41
+ const value = parts.join(".");
42
+ return `${type.setof ? "SETOF " : ""}${aliases[value] ?? value}${type.arrayBounds?.length ? "[]" : ""}`;
43
+ }
44
+ function qualified(nodes) {
45
+ const parts = names(nodes);
46
+ if (parts.length !== 2 || parts.some((part) => !/^[a-z_][a-z0-9_$]*$/.test(part))) {
47
+ throw new Error("Use an explicit lowercase schema-qualified function name");
48
+ }
49
+ return parts.join(".");
50
+ }
51
+ async function migrationFunctionIdentities(sources) {
52
+ const identities = new Set;
53
+ const targetIdentity = (node) => {
54
+ if (!("ObjectWithArgs" in node) || node.ObjectWithArgs.args_unspecified)
55
+ throw new Error("Migration function changes require an explicit signature");
56
+ const target = node.ObjectWithArgs;
57
+ const types = (target.objargs ?? []).map((argument) => {
58
+ if (!("TypeName" in argument))
59
+ throw new Error("Invalid migration function type");
60
+ return typeName(argument.TypeName);
61
+ });
62
+ return `${qualified(target.objname)}(${types.join(", ")})`;
63
+ };
64
+ for (const source of sources) {
65
+ for (const { stmt } of (await parse(source)).stmts ?? []) {
66
+ if (!stmt)
67
+ continue;
68
+ if ("CreateFunctionStmt" in stmt && !stmt.CreateFunctionStmt.is_procedure) {
69
+ const fn = stmt.CreateFunctionStmt;
70
+ const types = (fn.parameters ?? []).flatMap((node) => {
71
+ if (!("FunctionParameter" in node))
72
+ throw new Error("Invalid migration parameter");
73
+ return ["FUNC_PARAM_OUT", "FUNC_PARAM_TABLE"].includes(node.FunctionParameter.mode ?? "") ? [] : [typeName(node.FunctionParameter.argType)];
74
+ });
75
+ identities.add(`${qualified(fn.funcname)}(${types.join(", ")})`);
76
+ } else if ("DropStmt" in stmt && stmt.DropStmt.removeType === "OBJECT_FUNCTION") {
77
+ for (const target of stmt.DropStmt.objects ?? [])
78
+ identities.delete(targetIdentity(target));
79
+ } else if ("RenameStmt" in stmt && stmt.RenameStmt.renameType === "OBJECT_FUNCTION") {
80
+ const rename = stmt.RenameStmt;
81
+ if (!rename.object || !rename.newname || !/^[a-z_][a-z0-9_$]*$/.test(rename.newname))
82
+ throw new Error("Invalid function rename");
83
+ const old = targetIdentity(rename.object);
84
+ identities.delete(old);
85
+ const schema = old.slice(0, old.indexOf("."));
86
+ identities.add(`${schema}.${rename.newname}${old.slice(old.indexOf("("))}`);
87
+ }
88
+ }
89
+ }
90
+ return identities;
91
+ }
92
+ async function rpcSourceContract(sql, role) {
93
+ const statements = (await parse(sql)).stmts ?? [];
94
+ const functions = statements.flatMap(({ stmt }) => stmt && ("CreateFunctionStmt" in stmt) ? [stmt.CreateFunctionStmt] : []);
95
+ const fn = functions[0];
96
+ if (functions.length !== 1 || !fn || fn.is_procedure)
97
+ throw new Error("Each source must declare exactly one function");
98
+ const name = qualified(fn.funcname);
99
+ const parameters = [];
100
+ let tableResult = false, unnamed = false;
101
+ for (const node of fn.parameters ?? []) {
102
+ if (!("FunctionParameter" in node))
103
+ throw new Error("Invalid function parameter");
104
+ const parameter = node.FunctionParameter;
105
+ if (["FUNC_PARAM_OUT", "FUNC_PARAM_TABLE"].includes(parameter.mode ?? "")) {
106
+ tableResult = true;
107
+ continue;
108
+ }
109
+ if (parameter.mode === "FUNC_PARAM_VARIADIC")
110
+ throw new Error("Variadic RPC contracts require a reviewed adapter");
111
+ if (parameter.mode === "FUNC_PARAM_INOUT")
112
+ tableResult = true;
113
+ unnamed ||= !parameter.name;
114
+ parameters.push({ name: parameter.name ?? "", sqlType: typeName(parameter.argType), optional: parameter.defexpr !== undefined });
115
+ }
116
+ const identity = `${name}(${parameters.map((parameter) => parameter.sqlType).join(", ")})`;
117
+ let publicGrant;
118
+ let directGrant = false;
119
+ for (const { stmt } of statements) {
120
+ if (!stmt)
121
+ throw new Error("Missing SQL statement");
122
+ if ("CreateFunctionStmt" in stmt)
123
+ continue;
124
+ if (!("GrantStmt" in stmt))
125
+ throw new Error("Function sources may contain only a function and its EXECUTE ACL");
126
+ const grant = stmt.GrantStmt;
127
+ if (grant.objtype !== "OBJECT_FUNCTION" || grant.targtype !== "ACL_TARGET_OBJECT")
128
+ throw new Error("Foreign ACL target");
129
+ if (grant.privileges?.some((privilege) => !("AccessPriv" in privilege) || privilege.AccessPriv.priv_name !== "execute")) {
130
+ throw new Error("Only EXECUTE function privileges are supported");
131
+ }
132
+ for (const target of grant.objects ?? []) {
133
+ if (!("ObjectWithArgs" in target) || target.ObjectWithArgs.args_unspecified)
134
+ throw new Error("Explicit ACL signature required");
135
+ const object = target.ObjectWithArgs;
136
+ const types = (object.objargs ?? []).map((argument) => {
137
+ if (!("TypeName" in argument))
138
+ throw new Error("Invalid ACL argument");
139
+ return typeName(argument.TypeName);
140
+ });
141
+ if (`${qualified(object.objname)}(${types.join(", ")})` !== identity)
142
+ throw new Error("ACL does not belong to this function");
143
+ }
144
+ if (!grant.is_grant && grant.grant_option)
145
+ continue;
146
+ for (const node of grant.grantees ?? []) {
147
+ if (!("RoleSpec" in node))
148
+ throw new Error("Invalid ACL role");
149
+ if (node.RoleSpec.roletype === "ROLESPEC_PUBLIC")
150
+ publicGrant = grant.is_grant === true;
151
+ if (node.RoleSpec.rolename === role)
152
+ directGrant = grant.is_grant === true;
153
+ }
154
+ }
155
+ const returns = tableResult ? "record" : typeName(fn.returnType);
156
+ const internal = unnamed || ["trigger", "event_trigger"].includes(returns);
157
+ if (!internal && publicGrant === undefined)
158
+ throw new Error(`Explicit PUBLIC EXECUTE grant/revocation required: ${identity}`);
159
+ return { name, identity, parameters, returnType: returns, callable: !internal && (publicGrant === true || directGrant) };
160
+ }
161
+ function tsType(sqlType) {
162
+ if (sqlType.startsWith("SETOF "))
163
+ return `Array<${tsType(sqlType.slice(6))} | null>`;
164
+ if (sqlType.endsWith("[]"))
165
+ return `Array<${tsType(sqlType.slice(0, -2))} | null>`;
166
+ if (["bigint", "numeric", "decimal"].includes(sqlType))
167
+ return "number | string";
168
+ if (["smallint", "integer", "real", "double precision"].includes(sqlType))
169
+ return "number";
170
+ if (sqlType === "boolean")
171
+ return "boolean";
172
+ if (sqlType === "void")
173
+ return "void";
174
+ if ([
175
+ "text",
176
+ "uuid",
177
+ "character varying",
178
+ "character",
179
+ "date",
180
+ "interval",
181
+ "timestamp with time zone",
182
+ "timestamp without time zone",
183
+ "time with time zone",
184
+ "time without time zone"
185
+ ].includes(sqlType))
186
+ return "string";
187
+ return "unknown";
188
+ }
189
+ function renderRpcSourceTypes(contracts) {
190
+ const groups = new Map;
191
+ for (const contract of contracts.filter((item) => item.callable)) {
192
+ groups.set(contract.name, [...groups.get(contract.name) ?? [], contract]);
193
+ }
194
+ const lines = [
195
+ "// Generated by supacloud-db. Source contracts only; decode untrusted responses.",
196
+ "export interface RpcDefinitions {"
197
+ ];
198
+ for (const [name, overloads] of [...groups].sort(([a], [b]) => a.localeCompare(b))) {
199
+ const shapes = overloads.map((overload) => {
200
+ const args = overload.parameters.length ? `{ ${overload.parameters.map((parameter) => `${JSON.stringify(parameter.name)}${parameter.optional ? "?" : ""}: ${tsType(parameter.sqlType)} | null`).join("; ")} }` : "Record<string, never>";
201
+ return `{ args: ${args}; result: ${tsType(overload.returnType)} | null }`;
202
+ });
203
+ lines.push(` ${JSON.stringify(name)}: ${shapes.join(" | ")};`);
204
+ }
205
+ lines.push("}", "export type RpcName = keyof RpcDefinitions;", 'export type RpcArgs<Name extends RpcName> = RpcDefinitions[Name]["args"];', 'export type RpcResult<Name extends RpcName> = RpcDefinitions[Name]["result"];', "");
206
+ return lines.join(`
207
+ `);
208
+ }
209
+
210
+ // src/source-contracts.ts
211
+ var CONFIG = "database.sources.json";
212
+ var GENERATOR = "supacloud-db/source-contracts/v1";
213
+ var skip = new Set(["node_modules", ".git", ".worktrees", ".wt", "dist", "coverage", ".next", ".svelte-kit"]);
214
+ var steps = [
215
+ "Keep the existing append-only migration directory and deployment ledger.",
216
+ "Review Drizzle declarations and per-function SQL against a local migration replay.",
217
+ "Add database.sources.json; replace ordinary audit dump consumers.",
218
+ "Run supacloud-db generate, review contracts, then add supacloud-db check to default checks.",
219
+ "Verify PostgreSQL definitions, ACL, RLS and behavior separately before deployment."
220
+ ];
221
+ var hash = (value) => createHash("sha256").update(value).digest("hex");
222
+ var slash = (value) => value.replaceAll("\\", "/");
223
+ var within = (file, directory) => file === directory || file.startsWith(`${directory}/`);
224
+ function record(value) {
225
+ return value !== null && typeof value === "object" && !Array.isArray(value);
226
+ }
227
+ function pathValue(value) {
228
+ if (typeof value !== "string" || !value || isAbsolute(value) || value.includes("\\") || value.split("/").some((part) => !part || part === "." || part === "..")) {
229
+ throw new Error("Expected a normalized project-relative path");
230
+ }
231
+ return value;
232
+ }
233
+ function paths(value) {
234
+ if (!Array.isArray(value))
235
+ throw new Error("Expected a path list");
236
+ const result = value.map(pathValue);
237
+ if (new Set(result).size !== result.length)
238
+ throw new Error("Duplicate path");
239
+ return result;
240
+ }
241
+ function parseDatabaseSourcesConfig(value) {
242
+ if (!record(value) || value.version !== 1 || Object.keys(value).some((key) => !["version", "schema", "functions", "migrations", "contracts", "audit", "consumers", "auditConsumers", "role"].includes(key))) {
243
+ throw new Error("Invalid database.sources.json");
244
+ }
245
+ const schema = paths(value.schema), consumers = paths(value.consumers), auditConsumers = paths(value.auditConsumers);
246
+ if (!schema.length || !consumers.length || schema.some((file) => !file.endsWith(".ts")))
247
+ throw new Error("Select TypeScript schemas and consumer roots");
248
+ if (typeof value.role !== "string" || !/^[a-z_][a-z0-9_]*$/.test(value.role))
249
+ throw new Error("Select an explicit database role");
250
+ const config = {
251
+ version: 1,
252
+ schema,
253
+ consumers,
254
+ auditConsumers,
255
+ role: value.role,
256
+ functions: pathValue(value.functions),
257
+ migrations: pathValue(value.migrations),
258
+ contracts: pathValue(value.contracts),
259
+ audit: pathValue(value.audit)
260
+ };
261
+ const areas = [...schema, config.functions, config.migrations, config.contracts, config.audit];
262
+ for (const [index, area] of areas.entries()) {
263
+ if (areas.some((other, otherIndex) => index !== otherIndex && (within(area, other) || within(other, area)))) {
264
+ throw new Error("Schema, function, migration, contract and audit paths must be separate");
265
+ }
266
+ }
267
+ for (const consumer of consumers) {
268
+ if ([config.contracts, config.audit, config.migrations, config.functions].some((area) => within(consumer, area) || within(area, consumer))) {
269
+ throw new Error("Consumer roots must be separate from database artifacts");
270
+ }
271
+ }
272
+ if (auditConsumers.some((file) => !consumers.some((directory) => within(file, directory)) || !file.startsWith("scripts/") || !/\.[cm]?[jt]s$/.test(file))) {
273
+ throw new Error("Audit-only exceptions must name explicit files under scripts/");
274
+ }
275
+ return config;
276
+ }
277
+ async function exists(path) {
278
+ try {
279
+ await lstat(path);
280
+ return true;
281
+ } catch (error) {
282
+ if (record(error) && error.code === "ENOENT")
283
+ return false;
284
+ throw error;
285
+ }
286
+ }
287
+ async function safePath(root, file) {
288
+ pathValue(file);
289
+ let current = root;
290
+ for (const part of file.split("/")) {
291
+ current = join(current, part);
292
+ if (await exists(current) && (await lstat(current)).isSymbolicLink())
293
+ throw new Error(`Symlink path is not allowed: ${file}`);
294
+ }
295
+ return current;
296
+ }
297
+ async function filesUnder(root, directory, excluded = []) {
298
+ if (excluded.some((area) => within(directory, area)))
299
+ return [];
300
+ const base = directory ? await safePath(root, directory) : root;
301
+ if (!await exists(base))
302
+ return [];
303
+ const stat = await lstat(base);
304
+ if (stat.isFile())
305
+ return [directory];
306
+ const found = [];
307
+ for (const entry of (await readdir(base, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name))) {
308
+ if (skip.has(entry.name) || entry.name.startsWith(".env"))
309
+ continue;
310
+ const file = directory ? `${directory}/${entry.name}` : entry.name;
311
+ if (entry.isSymbolicLink())
312
+ throw new Error(`Symlink source is not allowed: ${file}`);
313
+ if (entry.isDirectory())
314
+ found.push(...await filesUnder(root, file, excluded));
315
+ else if (entry.isFile())
316
+ found.push(file);
317
+ }
318
+ return found;
319
+ }
320
+ function staticPaths(source) {
321
+ const bindings = new Map;
322
+ const collect = (node) => {
323
+ if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer) {
324
+ bindings.set(node.name.text, node.initializer);
325
+ }
326
+ ts.forEachChild(node, collect);
327
+ };
328
+ collect(source);
329
+ const evaluate = (node, depth = 0) => {
330
+ if (depth > 12)
331
+ return;
332
+ if (ts.isStringLiteralLike(node))
333
+ return node.text;
334
+ if (ts.isIdentifier(node)) {
335
+ const binding = bindings.get(node.text);
336
+ return binding ? evaluate(binding, depth + 1) : undefined;
337
+ }
338
+ if (ts.isParenthesizedExpression(node))
339
+ return evaluate(node.expression, depth + 1);
340
+ if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.PlusToken) {
341
+ const left = evaluate(node.left, depth + 1), right = evaluate(node.right, depth + 1);
342
+ return left === undefined || right === undefined ? undefined : left + right;
343
+ }
344
+ if (ts.isTemplateExpression(node)) {
345
+ let value = node.head.text;
346
+ for (const span of node.templateSpans) {
347
+ const part = evaluate(span.expression, depth + 1);
348
+ if (part === undefined)
349
+ return;
350
+ value += part + span.literal.text;
351
+ }
352
+ return value;
353
+ }
354
+ if (ts.isCallExpression(node) || ts.isNewExpression(node)) {
355
+ const name = ts.isIdentifier(node.expression) ? node.expression.text : ts.isPropertyAccessExpression(node.expression) ? node.expression.name.text : "";
356
+ if (name === "URL")
357
+ return node.arguments?.[0] ? evaluate(node.arguments[0], depth + 1) : undefined;
358
+ if (name === "join" || name === "resolve") {
359
+ const parts = (node.arguments ?? []).map((argument) => evaluate(argument, depth + 1));
360
+ if (parts.every((part) => part !== undefined))
361
+ return parts.join("/");
362
+ }
363
+ }
364
+ return;
365
+ };
366
+ const references = [];
367
+ const visit = (node) => {
368
+ const value = evaluate(node);
369
+ if (value !== undefined)
370
+ references.push({ value, line: source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1 });
371
+ ts.forEachChild(node, visit);
372
+ };
373
+ visit(source);
374
+ return references;
375
+ }
376
+ async function boundaryFindings(root, config, schemaInputs = []) {
377
+ const all = await filesUnder(root, "", config ? [config.audit, config.contracts, config.migrations] : ["output"]);
378
+ const dumps = new Set;
379
+ for (const file of all.filter((file) => file.endsWith(".sql"))) {
380
+ if (config && within(file, config.migrations))
381
+ continue;
382
+ const handle = await open(await safePath(root, file), "r");
383
+ let source;
384
+ try {
385
+ const buffer = Buffer.alloc(2048);
386
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
387
+ source = buffer.subarray(0, bytesRead).toString("utf8");
388
+ } finally {
389
+ await handle.close();
390
+ }
391
+ if (source.includes("-- PostgreSQL database dump") || /(?:^|\/)bootstrap\/schema\.sql$/.test(file))
392
+ dumps.add(file);
393
+ }
394
+ const consumers = all.filter((file) => /\.[cm]?[jt]sx?$/.test(file) && (config ? schemaInputs.includes(file) || config.consumers.some((directory) => within(file, directory)) : !within(file, "output") && !within(file, "generated")));
395
+ const findings = [];
396
+ for (const file of consumers) {
397
+ if (config?.auditConsumers.includes(file))
398
+ continue;
399
+ const source = ts.createSourceFile(file, await readFile(await safePath(root, file), "utf8"), ts.ScriptTarget.Latest, true);
400
+ const reported = new Set;
401
+ for (const reference of staticPaths(source)) {
402
+ const candidates = [
403
+ slash(relative(root, resolve(root, reference.value))),
404
+ slash(relative(root, resolve(root, dirname(file), reference.value)))
405
+ ];
406
+ if (candidates.some((candidate) => dumps.has(candidate) || config && (within(candidate, config.audit) || config.auditConsumers.some((script) => candidate === script || candidate === script.replace(/\.[cm]?[jt]s$/, "")))) || /(?:^|\/)bootstrap\/schema\.sql$/.test(reference.value)) {
407
+ if (reported.has(reference.line))
408
+ continue;
409
+ reported.add(reference.line);
410
+ findings.push({ file, line: reference.line, code: "audit-input", message: "Ordinary source references an audit dump or audit-only script; use maintained sources or migration replay." });
411
+ }
412
+ }
413
+ }
414
+ return findings;
415
+ }
416
+ function migrationInputs(value) {
417
+ if (!record(value) || value.generator !== GENERATOR || !Array.isArray(value.migrations))
418
+ throw new Error("Invalid previous contract manifest");
419
+ return value.migrations.map((item) => {
420
+ if (!record(item) || typeof item.sha256 !== "string" || !/^[a-f0-9]{64}$/.test(item.sha256))
421
+ throw new Error("Invalid migration baseline");
422
+ return { path: pathValue(item.path), sha256: item.sha256 };
423
+ });
424
+ }
425
+ async function databaseSources(projectRoot, action = "check") {
426
+ const root = resolve(projectRoot);
427
+ const report = { version: 1, scope: "local-source-contracts", ok: false, findings: [], written: [], steps };
428
+ try {
429
+ if ((await lstat(root)).isSymbolicLink())
430
+ throw new Error("Project root must not be a symlink");
431
+ const configPath = await safePath(root, CONFIG);
432
+ const config = await exists(configPath) ? parseDatabaseSourcesConfig(JSON.parse(await readFile(configPath, "utf8"))) : undefined;
433
+ if (!config) {
434
+ report.findings.push(...await boundaryFindings(root));
435
+ report.findings.push({ file: CONFIG, code: "adoption-required", message: "No database source configuration; review the adoption steps before generating contracts." });
436
+ return report;
437
+ }
438
+ for (const directory of [config.functions, config.migrations, ...config.consumers]) {
439
+ if (!await exists(await safePath(root, directory)))
440
+ throw new Error(`Missing source directory: ${directory}`);
441
+ }
442
+ const inputs = new Map;
443
+ const input = async (file) => {
444
+ const text = await readFile(await safePath(root, file), "utf8");
445
+ inputs.set(file, hash(text));
446
+ return text;
447
+ };
448
+ await input(CONFIG);
449
+ for (const file of config.schema) {
450
+ await input(file);
451
+ const visitImports = async (path) => {
452
+ const tree = ts.createSourceFile(path, await input(path), ts.ScriptTarget.Latest, true);
453
+ const imports = [];
454
+ const visit = (node) => {
455
+ if ((ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier) && node.moduleSpecifier.text.startsWith("."))
456
+ imports.push(node.moduleSpecifier.text);
457
+ ts.forEachChild(node, visit);
458
+ };
459
+ visit(tree);
460
+ for (const specifier of imports) {
461
+ const base = slash(relative(root, resolve(root, dirname(path), specifier)));
462
+ const candidates = [base, `${base}.ts`, `${base}/index.ts`];
463
+ let target;
464
+ for (const candidate of candidates) {
465
+ const full = await safePath(root, candidate);
466
+ if (await exists(full) && (await lstat(full)).isFile()) {
467
+ target = candidate;
468
+ break;
469
+ }
470
+ }
471
+ if (!target)
472
+ throw new Error(`Unresolved local schema import: ${path} -> ${specifier}`);
473
+ if ([config.audit, config.contracts, config.migrations, config.functions].some((area) => within(target, area))) {
474
+ throw new Error(`Schema imports a non-structure artifact: ${target}`);
475
+ }
476
+ if (!inputs.has(target))
477
+ await visitImports(target);
478
+ }
479
+ };
480
+ await visitImports(file);
481
+ }
482
+ report.findings.push(...await boundaryFindings(root, config, [...inputs.keys()]));
483
+ const migrations = [];
484
+ const migrationSql = [];
485
+ for (const file of (await filesUnder(root, config.migrations)).filter((file) => file.endsWith(".sql")).sort()) {
486
+ const text = await input(file);
487
+ migrations.push({ path: file, sha256: hash(text) });
488
+ migrationSql.push(text);
489
+ }
490
+ const contracts = [];
491
+ for (const file of (await filesUnder(root, config.functions)).filter((file) => file.endsWith(".sql")).sort()) {
492
+ try {
493
+ contracts.push(await rpcSourceContract(await input(file), config.role));
494
+ } catch (error) {
495
+ report.findings.push({ file, code: "function-contract", message: error instanceof Error ? error.message : "Invalid function source" });
496
+ }
497
+ }
498
+ if (new Set(contracts.map((item) => item.identity)).size !== contracts.length)
499
+ throw new Error("Duplicate function signature ownership");
500
+ const owned = new Set(contracts.map((item) => item.identity));
501
+ for (const identity of await migrationFunctionIdentities(migrationSql)) {
502
+ if (!owned.has(identity))
503
+ report.findings.push({
504
+ file: config.functions,
505
+ code: "unowned-function",
506
+ message: `Explicit migration function lacks a maintained source: ${identity}`
507
+ });
508
+ }
509
+ contracts.sort((a, b) => a.identity.localeCompare(b.identity));
510
+ const catalog = JSON.stringify({ version: 1, scope: report.scope, role: config.role, functions: contracts }, null, 2) + `
511
+ `;
512
+ const types = renderRpcSourceTypes(contracts);
513
+ const generated = new Map([["rpc-catalog.json", catalog], ["rpc-types.ts", types]]);
514
+ const manifest = JSON.stringify({
515
+ generator: GENERATOR,
516
+ inputs: Object.fromEntries([...inputs].sort(([a], [b]) => a.localeCompare(b))),
517
+ migrations,
518
+ outputs: Object.fromEntries([...generated].map(([name, content]) => [name, hash(content)]))
519
+ }, null, 2) + `
520
+ `;
521
+ const manifestPath = await safePath(root, `${config.contracts}/manifest.json`);
522
+ if (await exists(manifestPath)) {
523
+ const previous = migrationInputs(JSON.parse(await readFile(manifestPath, "utf8")));
524
+ if (previous.some((old, index) => old.path !== migrations[index]?.path || old.sha256 !== migrations[index]?.sha256)) {
525
+ report.findings.push({ file: config.migrations, code: "migration-rewritten", message: "Previously recorded migrations were removed, reordered or rewritten. Restore history; do not regenerate the baseline." });
526
+ }
527
+ }
528
+ generated.set("manifest.json", manifest);
529
+ for (const name of generated.keys())
530
+ await safePath(root, `${config.contracts}/${name}`);
531
+ if (action === "generate" && !report.findings.length) {
532
+ await mkdir(await safePath(root, config.contracts), { recursive: true });
533
+ for (const [name, content] of generated) {
534
+ const file = `${config.contracts}/${name}`, target = await safePath(root, file);
535
+ if (!await exists(target) || await readFile(target, "utf8") !== content) {
536
+ await writeFile(target, content);
537
+ report.written.push(file);
538
+ }
539
+ }
540
+ } else {
541
+ for (const [name, content] of generated) {
542
+ const file = `${config.contracts}/${name}`, target = await safePath(root, file);
543
+ if (!await exists(target) || await readFile(target, "utf8") !== content) {
544
+ report.findings.push({ file, code: "stale-contract", message: "Missing or stale generated contract; explicitly generate, review and commit." });
545
+ }
546
+ }
547
+ }
548
+ report.ok = report.findings.length === 0;
549
+ } catch (error) {
550
+ report.findings.push({ file: CONFIG, code: "invalid-source-project", message: error instanceof Error ? error.message : "Invalid source project" });
551
+ }
552
+ return report;
553
+ }
554
+
555
+ // src/source-contracts-cli.ts
556
+ async function databaseSourcesMain(args) {
557
+ const [action, flag, root] = args;
558
+ if (!["assess", "check", "generate"].includes(action ?? "") || args.length !== 1 && (args.length !== 3 || flag !== "--root" || !root || root.startsWith("-"))) {
559
+ throw new Error("Usage: supacloud-db <assess|check|generate> [--root project]");
560
+ }
561
+ if (action !== "assess" && action !== "check" && action !== "generate")
562
+ throw new Error("Invalid action");
563
+ const report = await databaseSources(root ?? ".", action);
564
+ console.log(JSON.stringify(report, null, 2));
565
+ return action === "assess" || report.ok ? 0 : 1;
566
+ }
567
+ if (process.argv[1] && existsSync(process.argv[1]) && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))) {
568
+ try {
569
+ process.exitCode = await databaseSourcesMain(process.argv.slice(2));
570
+ } catch (error) {
571
+ console.error(error instanceof Error ? error.message : "Database source check failed");
572
+ process.exitCode = 1;
573
+ }
574
+ }
575
+ export {
576
+ databaseSourcesMain
577
+ };
@@ -0,0 +1,27 @@
1
+ export interface DatabaseSourcesConfig {
2
+ version: 1;
3
+ schema: string[];
4
+ functions: string;
5
+ migrations: string;
6
+ contracts: string;
7
+ audit: string;
8
+ consumers: string[];
9
+ auditConsumers: string[];
10
+ role: string;
11
+ }
12
+ export interface SourceFinding {
13
+ file: string;
14
+ code: string;
15
+ message: string;
16
+ line?: number;
17
+ }
18
+ export interface DatabaseSourcesReport {
19
+ version: 1;
20
+ scope: "local-source-contracts";
21
+ ok: boolean;
22
+ findings: SourceFinding[];
23
+ written: string[];
24
+ steps: string[];
25
+ }
26
+ export declare function parseDatabaseSourcesConfig(value: unknown): DatabaseSourcesConfig;
27
+ export declare function databaseSources(projectRoot: string, action?: "assess" | "check" | "generate"): Promise<DatabaseSourcesReport>;