backend-skeleton 1.0.0-beta.8 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +122 -12
  2. package/bin/bskel.mjs +738 -12
  3. package/contracts/completeness.mjs +10 -0
  4. package/contracts/emit.mjs +5 -1
  5. package/contracts/export.mjs +26 -3
  6. package/contracts/openapi.mjs +29 -3
  7. package/handles/_engine.mjs +79 -29
  8. package/handles/providers/java-spring/plan.mjs +22 -9
  9. package/handles/providers/java-spring/templates/HandleController.java.tmpl +7 -3
  10. package/handles/providers/java-spring/templates/ResourceResolver.java.tmpl +12 -6
  11. package/handles/providers/python-fastapi/templates/record_snapshot.py.tmpl +101 -39
  12. package/handles/providers/typescript-express/emit.mjs +23 -23
  13. package/handles/providers/typescript-express/observe.mjs +101 -0
  14. package/handles/providers/typescript-express/templates/contractCheck.ts.tmpl +136 -0
  15. package/handles/providers/typescript-express/templates/observeContract.ts.tmpl +146 -0
  16. package/handles/providers/typescript-express/templates/observedSchema.ts.tmpl +116 -0
  17. package/lib/attest.mjs +40 -0
  18. package/lib/cli.mjs +169 -2
  19. package/lib/cross-feature-collisions.mjs +286 -0
  20. package/lib/diff.mjs +35 -0
  21. package/lib/field-dependencies.mjs +355 -0
  22. package/lib/fsutil.mjs +7 -2
  23. package/lib/gate-definitions.mjs +117 -1
  24. package/lib/gates.mjs +5 -1
  25. package/lib/http-server.mjs +358 -0
  26. package/lib/lock.mjs +68 -15
  27. package/lib/patch-kinds.mjs +52 -0
  28. package/lib/patch-transactions.mjs +206 -0
  29. package/lib/serve-ui.html +328 -0
  30. package/lib/workflow.mjs +39 -3
  31. package/package.json +5 -2
  32. package/scanners/adapters/java-spring.mjs +6 -0
  33. package/scanners/adapters/python-fastapi.mjs +9 -1
  34. package/scanners/adapters/typescript-express.mjs +6 -0
  35. package/scanners/db/ddl-apply.mjs +253 -0
  36. package/scanners/db/introspect.mjs +61 -32
  37. package/scanners/db/migrations.mjs +73 -18
  38. package/schemas/cross-feature-report.schema.json +66 -0
  39. package/schemas/cross-feature-resolution.schema.json +28 -0
  40. package/schemas/field-dependency.schema.json +49 -0
  41. package/schemas/gate-attestation.schema.json +22 -0
  42. package/schemas/gate-export.schema.json +58 -0
  43. package/schemas/patch-transaction.schema.json +182 -0
  44. package/schemas/scan-report.schema.json +6 -4
  45. package/schemas/stack-choice.schema.json +12 -1
  46. package/stack/apply.mjs +4 -1
  47. package/stack/catalog/ngrok.yml +8 -2
  48. package/stack/config-apply.mjs +168 -0
@@ -195,6 +195,12 @@ function extractTableEntities(text, file) {
195
195
  entities.push({
196
196
  className,
197
197
  table: m[1] || className.toLowerCase(),
198
+ // D-cross-feature-collision: m[1] is ENTITY_CLASS_RE's own captured explicit
199
+ // `@Entity('table_name')` literal argument -- already extracted above, just never
200
+ // separately flagged as the confidence signal it actually is (vs. the lowercased-
201
+ // classname fallback on the same line, which is a guess TypeORM's own real default-
202
+ // naming convention happens to often match, but not always).
203
+ tableSource: m[1] ? 'explicit' : 'inferred',
198
204
  idField: idMatch ? idMatch[2] : null,
199
205
  idFieldIsUuid: idMatch ? /['"]uuid['"]/.test(idMatch[1]) : false,
200
206
  file,
@@ -0,0 +1,253 @@
1
+ // D-ddl-apply: the "ddl-apply" kind for lib/patch-transactions.mjs -- a human-authored DDL
2
+ // statement (or `;`-separated statements) proposed, approved, and applied against a REAL live
3
+ // Postgres database. This is this project's first live-DB WRITE path, deliberately crossing
4
+ // D-migration-scope's "bskel never applies a migration automatically" boundary -- see
5
+ // DECISIONS.md's D-ddl-apply for why that boundary is judged safe to lift here specifically, and
6
+ // for why this kind's own safety story (real Postgres transaction wrapping with automatic
7
+ // rollback on postcondition failure, no automated rollback of an already-applied transaction) is
8
+ // deliberately stricter than config-apply's, given the larger blast radius.
9
+ //
10
+ // Mirrors stack/config-apply.mjs's planConfigApply() contract exactly (the same five required
11
+ // plan fields: target/preimage/postcondition/originalContent/renderedContent), reusing
12
+ // scanners/db/introspect.mjs's introspectSchema()/introspectWithClient()/describeConnectionError()
13
+ // as-is -- Plane C's read machinery, extended here to a write.
14
+ import pg from 'pg';
15
+ import { sha256String } from '../../lib/fsutil.mjs';
16
+ import { introspectSchema, introspectWithClient, describeConnectionError, listSchemaNames } from './introspect.mjs';
17
+ import { extractTablesFromSql } from './migrations.mjs';
18
+
19
+ const { Client } = pg;
20
+
21
+ export class DdlApplyPlanError extends Error {}
22
+ export class DdlApplyExecutionError extends Error {}
23
+
24
+ // Deliberately a regex allowlist, not a real SQL parser -- same "good-enough regex, not a real
25
+ // parser" restraint scanners/db/migrations.mjs's own header already names as this project's
26
+ // established restraint for SQL text.
27
+ const ALLOWED_STATEMENT_RE = /^\s*(CREATE|ALTER|DROP)\s+(TABLE|INDEX|UNIQUE\s+INDEX|SCHEMA)\b/i;
28
+ // CONCURRENTLY forms (CREATE/DROP INDEX CONCURRENTLY) are refused explicitly, HERE, at propose
29
+ // time, before any write connection ever opens -- those forms cannot run inside a transaction
30
+ // block at all (Postgres itself refuses them there). ALLOWED_STATEMENT_RE alone does NOT exclude
31
+ // them (it only anchors what the statement STARTS with, not what follows) -- found live by this
32
+ // item's own test suite, not assumed: a first draft relied on ALLOWED_STATEMENT_RE alone and
33
+ // silently accepted "CREATE INDEX CONCURRENTLY ..." as allowlisted. This second, independent check
34
+ // is the actual enforcement.
35
+ const CONCURRENTLY_RE = /\bCONCURRENTLY\b/i;
36
+ const DROP_TABLE_RE = /^\s*DROP\s+TABLE\s+(?:IF\s+EXISTS\s+)?"?(\w+)"?/i;
37
+ // Fine-grained postcondition precision for INDEX/SCHEMA DDL (closing the gap D-ddl-apply's own
38
+ // EXIT list named -- these two forms previously only got the coarser "did schema_hash change at
39
+ // all" check, unlike TABLE's exact per-name verification).
40
+ const CREATE_INDEX_RE = /^\s*CREATE\s+(?:UNIQUE\s+)?INDEX\s+(?:IF\s+NOT\s+EXISTS\s+)?"?(\w+)"?/i;
41
+ const DROP_INDEX_RE = /^\s*DROP\s+INDEX\s+(?:IF\s+EXISTS\s+)?"?(\w+)"?/i;
42
+ const CREATE_SCHEMA_RE = /^\s*CREATE\s+SCHEMA\s+(?:IF\s+NOT\s+EXISTS\s+)?"?(\w+)"?/i;
43
+ const DROP_SCHEMA_RE = /^\s*DROP\s+SCHEMA\s+(?:IF\s+EXISTS\s+)?"?(\w+)"?/i;
44
+
45
+ export function splitStatements(sqlText) {
46
+ return sqlText.split(';').map((s) => s.trim()).filter(Boolean);
47
+ }
48
+
49
+ export function assertLooksLikeDdl(sqlText) {
50
+ const statements = splitStatements(sqlText);
51
+ if (statements.length === 0) {
52
+ throw new DdlApplyPlanError('sql_text has no non-empty statements');
53
+ }
54
+ for (const stmt of statements) {
55
+ if (!ALLOWED_STATEMENT_RE.test(stmt) || CONCURRENTLY_RE.test(stmt)) {
56
+ throw new DdlApplyPlanError(
57
+ `statement is not in the Slice 1 allowlist (CREATE/ALTER/DROP TABLE/INDEX/SCHEMA, no CONCURRENTLY): "${stmt.slice(0, 80)}${stmt.length > 80 ? '...' : ''}"`,
58
+ );
59
+ }
60
+ }
61
+ return statements;
62
+ }
63
+
64
+ function resolveConnectionString(databaseUrlEnv) {
65
+ const connectionString = process.env[databaseUrlEnv];
66
+ if (!connectionString) {
67
+ throw new DdlApplyPlanError(`--database-url-env ${databaseUrlEnv} names an environment variable that isn't set -- export it first (never read from .env directly; see D-db-schema-plane in DECISIONS.md)`);
68
+ }
69
+ return connectionString;
70
+ }
71
+
72
+ // Classifies every table named by a DROP TABLE statement as expected to be 'absent' afterward,
73
+ // and every table named by a CREATE TABLE/ALTER TABLE ADD COLUMN statement (via Plane A's own
74
+ // extractTablesFromSql -- reused, not reimplemented) as expected to be 'present'. Other DDL forms
75
+ // (CREATE/DROP INDEX, CREATE/DROP SCHEMA, and any ALTER TABLE variant other than ADD COLUMN)
76
+ // contribute no entries here -- Slice 1 only checks their effect via the coarser
77
+ // schema-hash-changed check (see executeDdlApply below), named explicitly, not hidden.
78
+ export function classifyTableExpectations(statements) {
79
+ const expectations = new Map();
80
+ for (const stmt of statements) {
81
+ const dropMatch = stmt.match(DROP_TABLE_RE);
82
+ if (dropMatch) {
83
+ expectations.set(dropMatch[1], 'absent');
84
+ continue;
85
+ }
86
+ for (const t of extractTablesFromSql(`${stmt};`, '(ddl-apply proposal)')) {
87
+ expectations.set(t.name, 'present');
88
+ }
89
+ }
90
+ return [...expectations.entries()].map(([name, expect]) => ({ name, expect })).sort((a, b) => a.name.localeCompare(b.name));
91
+ }
92
+
93
+ // Same shape and reasoning as classifyTableExpectations(), for CREATE/DROP INDEX statements.
94
+ export function classifyIndexExpectations(statements) {
95
+ const expectations = new Map();
96
+ for (const stmt of statements) {
97
+ const dropMatch = stmt.match(DROP_INDEX_RE);
98
+ if (dropMatch) { expectations.set(dropMatch[1], 'absent'); continue; }
99
+ const createMatch = stmt.match(CREATE_INDEX_RE);
100
+ if (createMatch) expectations.set(createMatch[1], 'present');
101
+ }
102
+ return [...expectations.entries()].map(([name, expect]) => ({ name, expect })).sort((a, b) => a.name.localeCompare(b.name));
103
+ }
104
+
105
+ // Same shape and reasoning as classifyTableExpectations(), for CREATE/DROP SCHEMA statements.
106
+ export function classifySchemaExpectations(statements) {
107
+ const expectations = new Map();
108
+ for (const stmt of statements) {
109
+ const dropMatch = stmt.match(DROP_SCHEMA_RE);
110
+ if (dropMatch) { expectations.set(dropMatch[1], 'absent'); continue; }
111
+ const createMatch = stmt.match(CREATE_SCHEMA_RE);
112
+ if (createMatch) expectations.set(createMatch[1], 'present');
113
+ }
114
+ return [...expectations.entries()].map(([name, expect]) => ({ name, expect })).sort((a, b) => a.name.localeCompare(b.name));
115
+ }
116
+
117
+ // D-ddl-apply (DROP-TABLE-specific confirmation): a non-drop ddl-apply transaction keeps the
118
+ // original design (confirm = transaction id). Any transaction whose SQL drops one or more tables
119
+ // requires retyping the sorted, comma-joined dropped-table name(s) instead -- a materially
120
+ // stronger attention check than a random-looking UUID for the one statement type in the Slice 1
121
+ // allowlist that causes real, irreversible data loss. Consulted by both bin/bskel.mjs's
122
+ // cmdPatchApply and lib/http-server.mjs's apply route via lib/patch-kinds.mjs's dispatch table --
123
+ // neither hardcodes this logic itself.
124
+ export function requiredConfirmValue(txn) {
125
+ const droppedTables = (txn.postcondition.expected_tables ?? [])
126
+ .filter((t) => t.expect === 'absent')
127
+ .map((t) => t.name)
128
+ .sort();
129
+ if (droppedTables.length > 0) return droppedTables.join(',');
130
+ return txn.transaction_id;
131
+ }
132
+
133
+ // Planner. Mirrors planConfigApply(root, catalogEntry, targetPath)'s contract exactly. Opens a
134
+ // READ-ONLY introspection connection (introspectSchema(), unchanged) purely to compute the
135
+ // preimage -- this function itself never writes to the database; the actual DDL execution only
136
+ // ever happens inside executeDdlApply(), which lib/patch-transactions.mjs calls after its own
137
+ // TOCTOU re-check (comparing a freshly re-planned preimage against the stored one) has passed.
138
+ export async function planDdlApply(root, { databaseUrlEnv, schema = 'public', sqlText }) {
139
+ const statements = assertLooksLikeDdl(sqlText);
140
+ const connectionString = resolveConnectionString(databaseUrlEnv);
141
+
142
+ let live;
143
+ try {
144
+ live = await introspectSchema({ connectionString, schema });
145
+ } catch (err) {
146
+ throw new DdlApplyPlanError(`could not introspect the live database: ${describeConnectionError(err)}`);
147
+ }
148
+
149
+ const originalContent = JSON.stringify(live.tables);
150
+ return {
151
+ target: { database_url_env: databaseUrlEnv, schema, sql_text: sqlText },
152
+ // region_hash === file_hash today, honestly -- Slice 1 has no sub-schema "region" concept
153
+ // for DDL (unlike config-apply's single-key-within-a-file span); both are the whole live
154
+ // schema's own hash. See DECISIONS.md D-ddl-apply.
155
+ preimage: { region_hash: live.schema_hash, file_hash: sha256String(originalContent) },
156
+ current_value: `schema_hash ${live.schema_hash.slice(0, 12)}...`,
157
+ proposed_value: sqlText,
158
+ postcondition: {
159
+ kind: 'db-schema-diff',
160
+ schema,
161
+ expected_tables: classifyTableExpectations(statements),
162
+ expected_indexes: classifyIndexExpectations(statements),
163
+ expected_schemas: classifySchemaExpectations(statements),
164
+ },
165
+ originalContent,
166
+ renderedContent: sqlText,
167
+ };
168
+ }
169
+
170
+ // The apply executor lib/patch-transactions.mjs's applyTransaction() calls (via lib/patch-kinds.mjs's
171
+ // injection) once its own preimage TOCTOU re-check has already passed. Opens ONE read-write
172
+ // `pg.Client`, runs every allowlisted statement inside a single real Postgres transaction,
173
+ // re-introspects using that SAME open, uncommitted transaction (introspectWithClient()) to check
174
+ // the postcondition, and COMMITs only if it holds -- ROLLBACKs (the SQL never takes effect at
175
+ // all) and throws otherwise, leaving the patch-transaction record `approved`, unchanged.
176
+ export async function executeDdlApply(root, featureId, txn, freshKindPlan) {
177
+ const statements = splitStatements(txn.target.sql_text);
178
+ const connectionString = resolveConnectionString(txn.target.database_url_env);
179
+ const schema = txn.target.schema;
180
+
181
+ const client = new Client({ connectionString });
182
+ await client.connect();
183
+ try {
184
+ await client.query('BEGIN');
185
+ try {
186
+ for (const stmt of statements) {
187
+ await client.query(stmt);
188
+ }
189
+ const after = await introspectWithClient(client, schema);
190
+
191
+ if (after.schema_hash === freshKindPlan.preimage.region_hash) {
192
+ throw new DdlApplyExecutionError('the proposed DDL executed without error, but the live schema is byte-identical to before -- refusing to report this transaction as applied when nothing observably changed (this usually means the statement(s) were already no-ops, e.g. re-running an idempotent IF NOT EXISTS against a schema that already has it)');
193
+ }
194
+
195
+ const liveTableNames = new Set(after.tables.map((t) => t.name));
196
+ for (const { name, expect } of txn.postcondition.expected_tables ?? []) {
197
+ const present = liveTableNames.has(name);
198
+ if (expect === 'present' && !present) {
199
+ throw new DdlApplyExecutionError(`postcondition failed: table "${name}" does not exist live after execution`);
200
+ }
201
+ if (expect === 'absent' && present) {
202
+ throw new DdlApplyExecutionError(`postcondition failed: table "${name}" was targeted by a DROP TABLE statement but still exists live after execution`);
203
+ }
204
+ }
205
+
206
+ const liveIndexNames = new Set(after.tables.flatMap((t) => t.indexes));
207
+ for (const { name, expect } of txn.postcondition.expected_indexes ?? []) {
208
+ const present = liveIndexNames.has(name);
209
+ if (expect === 'present' && !present) {
210
+ throw new DdlApplyExecutionError(`postcondition failed: index "${name}" does not exist live after execution`);
211
+ }
212
+ if (expect === 'absent' && present) {
213
+ throw new DdlApplyExecutionError(`postcondition failed: index "${name}" was targeted by a DROP INDEX statement but still exists live after execution`);
214
+ }
215
+ }
216
+
217
+ const expectedSchemas = txn.postcondition.expected_schemas ?? [];
218
+ if (expectedSchemas.length > 0) {
219
+ const liveSchemaNames = await listSchemaNames(client);
220
+ for (const { name, expect } of expectedSchemas) {
221
+ const present = liveSchemaNames.has(name);
222
+ if (expect === 'present' && !present) {
223
+ throw new DdlApplyExecutionError(`postcondition failed: schema "${name}" does not exist live after execution`);
224
+ }
225
+ if (expect === 'absent' && present) {
226
+ throw new DdlApplyExecutionError(`postcondition failed: schema "${name}" was targeted by a DROP SCHEMA statement but still exists live after execution`);
227
+ }
228
+ }
229
+ }
230
+
231
+ await client.query('COMMIT');
232
+ return { postimage_schema_hash: after.schema_hash, executed_statements: statements };
233
+ } catch (err) {
234
+ await client.query('ROLLBACK').catch(() => {}); // best-effort -- the connection may already be unusable
235
+ throw err;
236
+ }
237
+ } finally {
238
+ await client.end();
239
+ }
240
+ }
241
+
242
+ // D-ddl-apply: rollback of an APPLIED ddl-apply transaction is explicitly out of scope for Slice
243
+ // 1 -- there is no live-DB equivalent of config-apply's "restore exact original bytes from a
244
+ // blob" (a dropped table's rows are gone; reversing an ALTER COLUMN TYPE can lose precision), and
245
+ // auto-generating reverse DDL is itself a lossy, risky guess this project has repeatedly refused
246
+ // to ship elsewhere (patchField()'s permanent manual stub, D-config-patch's own "a wrong automatic
247
+ // edit is worse than asking a human" framing). Refuses immediately and always, naming the real
248
+ // mitigation.
249
+ export async function executeDdlRollback() {
250
+ throw new DdlApplyExecutionError(
251
+ 'rollback is not supported for kind "ddl-apply" in Slice 1 -- propose a new forward ddl-apply transaction containing the reverse DDL instead, and run it through the same propose/approve/apply/confirm flow (see D-ddl-apply in DECISIONS.md)',
252
+ );
253
+ }
@@ -36,6 +36,12 @@ const FOREIGN_KEYS_SQL = `
36
36
  ORDER BY tc.table_name, kcu.column_name`;
37
37
  const INDEXES_SQL = `SELECT tablename AS table_name, indexname AS index_name FROM pg_indexes WHERE schemaname = $1 ORDER BY tablename, indexname`;
38
38
  const RLS_POLICIES_SQL = `SELECT tablename AS table_name, policyname AS policy_name FROM pg_policies WHERE schemaname = $1 ORDER BY tablename, policyname`;
39
+ // D-ddl-apply (INDEX/SCHEMA postcondition precision): NOT schema-scoped, deliberately -- a
40
+ // `CREATE SCHEMA X` statement creates a schema that is NOT the `schema` param every query above is
41
+ // scoped to (typically `public`), so none of those queries can ever observe "does schema X now
42
+ // exist". information_schema.schemata lists every schema in the database, which is exactly what's
43
+ // needed here.
44
+ const SCHEMA_NAMES_SQL = `SELECT schema_name FROM information_schema.schemata`;
39
45
 
40
46
  function groupByTable(rows, tableKey = 'table_name') {
41
47
  const map = new Map();
@@ -60,49 +66,72 @@ export function describeConnectionError(err) {
60
66
  return err.code ?? String(err);
61
67
  }
62
68
 
69
+ // D-ddl-apply (INDEX/SCHEMA postcondition precision): used by executeDdlApply() to verify a
70
+ // CREATE/DROP SCHEMA statement's real effect against a live re-introspection -- the same open,
71
+ // uncommitted client/transaction its DDL just ran in, mirroring introspectWithClient()'s own
72
+ // contract exactly (caller owns connect/BEGIN/COMMIT/ROLLBACK/end).
73
+ export async function listSchemaNames(client) {
74
+ const res = await client.query(SCHEMA_NAMES_SQL);
75
+ return new Set(res.rows.map((r) => r.schema_name));
76
+ }
77
+
78
+ // D-ddl-apply: split out of introspectSchema() so scanners/db/ddl-apply.mjs's write path can
79
+ // re-introspect using the SAME open, uncommitted `pg.Client`/transaction its DDL just ran in --
80
+ // observing the not-yet-committed effect of its own statements, without a second connection and
81
+ // without ever exposing a way to introspect outside of a transaction. Callers own connect()/
82
+ // BEGIN.../COMMIT/ROLLBACK/end() -- this function only ever runs the six read queries and shapes
83
+ // the result, identically regardless of which transaction mode the caller opened.
84
+ export async function introspectWithClient(client, schema = 'public') {
85
+ // Sequential, not Promise.all -- a single pg.Client processes one query at a time over one
86
+ // connection; issuing several concurrently on the same client is deprecated (pg queues them
87
+ // internally today, but warns, and that queuing behavior is going away in pg 9). A Pool
88
+ // would allow real concurrency, but this is a one-shot CLI invocation, not a long-lived
89
+ // server -- the simplicity of one client, one connection, sequential queries is the right
90
+ // trade-off here, not premature optimization for concurrency nothing needs.
91
+ const tablesRes = await client.query(TABLES_SQL, [schema]);
92
+ const columnsRes = await client.query(COLUMNS_SQL, [schema]);
93
+ const pkRes = await client.query(PRIMARY_KEYS_SQL, [schema]);
94
+ const fkRes = await client.query(FOREIGN_KEYS_SQL, [schema]);
95
+ const indexesRes = await client.query(INDEXES_SQL, [schema]);
96
+ const policiesRes = await client.query(RLS_POLICIES_SQL, [schema]);
97
+
98
+ const columnsByTable = groupByTable(columnsRes.rows);
99
+ const pkByTable = groupByTable(pkRes.rows);
100
+ const fkByTable = groupByTable(fkRes.rows);
101
+ const indexesByTable = groupByTable(indexesRes.rows);
102
+ const policiesByTable = groupByTable(policiesRes.rows);
103
+
104
+ const tables = tablesRes.rows.map(({ table_name: name }) => ({
105
+ name,
106
+ columns: (columnsByTable.get(name) ?? []).map((c) => ({ name: c.column_name, type: c.data_type, nullable: c.is_nullable === 'YES' })),
107
+ primary_key: (pkByTable.get(name) ?? []).map((r) => r.column_name),
108
+ foreign_keys: (fkByTable.get(name) ?? []).map((r) => ({ column: r.column_name, references_table: r.foreign_table_name, references_column: r.foreign_column_name })),
109
+ indexes: (indexesByTable.get(name) ?? []).map((r) => r.index_name),
110
+ rls_policies: (policiesByTable.get(name) ?? []).map((r) => r.policy_name),
111
+ }));
112
+
113
+ // D-cross-feature-fk-inference (staleness/freshness token): stamped AFTER the queries complete,
114
+ // reflecting when introspection actually ran -- never included in schema_hash's own input (that
115
+ // hashes `tables` alone), so adding this field cannot change what schema_hash means or perturb
116
+ // any existing hash-equality check.
117
+ return { schema, tables, schema_hash: sha256String(JSON.stringify(tables)), generated_at: new Date().toISOString() };
118
+ }
119
+
63
120
  // Entry point. `connectionString` is whatever `process.env[databaseUrlEnv]` resolved to -- the
64
121
  // caller (scanners/index.mjs) owns reading that env var and failing loudly if it's unset; this
65
122
  // function only ever receives an already-resolved string. `BEGIN TRANSACTION READ ONLY` is
66
123
  // structural defense-in-depth -- every query here is already a SELECT, but a read-only
67
124
  // transaction means the database itself refuses any write this connection could ever attempt,
68
- // not just "we didn't write any queries that would".
125
+ // not just "we didn't write any queries that would". A thin connect/BEGIN-READ-ONLY/COMMIT/end
126
+ // wrapper around introspectWithClient() -- the query/shaping logic itself lives there now.
69
127
  export async function introspectSchema({ connectionString, schema = 'public' }) {
70
128
  const client = new Client({ connectionString });
71
129
  await client.connect();
72
130
  try {
73
131
  await client.query('BEGIN TRANSACTION READ ONLY');
74
-
75
- // Sequential, not Promise.all -- a single pg.Client processes one query at a time over one
76
- // connection; issuing several concurrently on the same client is deprecated (pg queues them
77
- // internally today, but warns, and that queuing behavior is going away in pg 9). A Pool
78
- // would allow real concurrency, but this is a one-shot CLI invocation, not a long-lived
79
- // server -- the simplicity of one client, one connection, sequential queries is the right
80
- // trade-off here, not premature optimization for concurrency nothing needs.
81
- const tablesRes = await client.query(TABLES_SQL, [schema]);
82
- const columnsRes = await client.query(COLUMNS_SQL, [schema]);
83
- const pkRes = await client.query(PRIMARY_KEYS_SQL, [schema]);
84
- const fkRes = await client.query(FOREIGN_KEYS_SQL, [schema]);
85
- const indexesRes = await client.query(INDEXES_SQL, [schema]);
86
- const policiesRes = await client.query(RLS_POLICIES_SQL, [schema]);
87
-
132
+ const result = await introspectWithClient(client, schema);
88
133
  await client.query('COMMIT');
89
-
90
- const columnsByTable = groupByTable(columnsRes.rows);
91
- const pkByTable = groupByTable(pkRes.rows);
92
- const fkByTable = groupByTable(fkRes.rows);
93
- const indexesByTable = groupByTable(indexesRes.rows);
94
- const policiesByTable = groupByTable(policiesRes.rows);
95
-
96
- const tables = tablesRes.rows.map(({ table_name: name }) => ({
97
- name,
98
- columns: (columnsByTable.get(name) ?? []).map((c) => ({ name: c.column_name, type: c.data_type, nullable: c.is_nullable === 'YES' })),
99
- primary_key: (pkByTable.get(name) ?? []).map((r) => r.column_name),
100
- foreign_keys: (fkByTable.get(name) ?? []).map((r) => ({ column: r.column_name, references_table: r.foreign_table_name, references_column: r.foreign_column_name })),
101
- indexes: (indexesByTable.get(name) ?? []).map((r) => r.index_name),
102
- rls_policies: (policiesByTable.get(name) ?? []).map((r) => r.policy_name),
103
- }));
104
-
105
- return { schema, tables, schema_hash: sha256String(JSON.stringify(tables)) };
134
+ return result;
106
135
  } finally {
107
136
  await client.end();
108
137
  }
@@ -59,8 +59,38 @@ const ALTER_ADD_COLUMN_RE = /ALTER\s+TABLE\s+"?(\w+)"?\s+ADD\s+COLUMN\s+(?:IF\s+
59
59
  const CONSTRAINT_LEAD_RE = /^(PRIMARY\s+KEY|FOREIGN\s+KEY|UNIQUE|CHECK|CONSTRAINT)\b/i;
60
60
  const COLUMN_NAME_RE = /^"?(\w+)"?/;
61
61
 
62
- function extractTablesFromSql(sqlText, sourceFile) {
63
- const tables = new Map(); // name -> Set<column>
62
+ // D-cross-feature-fk-inference (Plane A FK extraction): closes this project's own named EXIT item
63
+ // ("Plane A (migration-file) FK extraction is out of scope") -- `--db` alone (no
64
+ // `--database-url-env`) now contributes real FK data to the `db_foreign_key` cross-feature signal
65
+ // via a real, disposable Postgres connection's absence, using nothing but migration-file text.
66
+ // Same "good-enough regex, not a real parser" restraint as everything else in this module --
67
+ // single-column FKs only; a composite `FOREIGN KEY (a, b) REFERENCES ...` segment simply doesn't
68
+ // match either regex below and is silently skipped, the same fail-safe-by-omission behavior
69
+ // CONSTRAINT_LEAD_RE's own unmatched segments already have.
70
+ //
71
+ // Table-level: `FOREIGN KEY (col) REFERENCES other_table (ocol)` -- ocol is optional (references
72
+ // the parent's PK when omitted; genuinely rare in real migrations, still handled).
73
+ const TABLE_LEVEL_FK_RE = /^FOREIGN\s+KEY\s*\(\s*"?(\w+)"?\s*\)\s+REFERENCES\s+"?(\w+)"?\s*(?:\(\s*"?(\w+)"?\s*\))?/i;
74
+ // Inline column-level: applied to a column-definition segment ALREADY matched by COLUMN_NAME_RE --
75
+ // `col_name TYPE ... REFERENCES other_table(ocol)` or bare `REFERENCES other_table`. When ocol is
76
+ // omitted, references_column is left `null` -- the referenced PK's real column name is genuinely
77
+ // unknowable from the migration file alone, and guessing "id" would be a false-confidence
78
+ // fabrication this project has repeatedly refused to ship elsewhere.
79
+ const INLINE_REFERENCES_RE = /REFERENCES\s+"?(\w+)"?\s*(?:\(\s*"?(\w+)"?\s*\))?/i;
80
+ // `ALTER TABLE t ADD CONSTRAINT name FOREIGN KEY (col) REFERENCES other_table (ocol)` -- a very
81
+ // common Flyway pattern for adding a constraint in a LATER migration than the table's own CREATE.
82
+ const ALTER_ADD_FK_RE = /ALTER\s+TABLE\s+"?(\w+)"?\s+ADD\s+CONSTRAINT\s+"?\w+"?\s+FOREIGN\s+KEY\s*\(\s*"?(\w+)"?\s*\)\s+REFERENCES\s+"?(\w+)"?\s*(?:\(\s*"?(\w+)"?\s*\))?/gi;
83
+
84
+ function newTableEntry() {
85
+ return { columns: new Set(), foreignKeys: [] };
86
+ }
87
+
88
+ // D-ddl-apply: exported (was module-private) so scanners/db/ddl-apply.mjs's postcondition check
89
+ // can reuse this exact extraction instead of a second copy -- it needs to know which table
90
+ // name(s) a proposed CREATE TABLE/ALTER TABLE ADD COLUMN statement declares, to assert the live,
91
+ // re-introspected schema actually reflects them after apply.
92
+ export function extractTablesFromSql(sqlText, sourceFile) {
93
+ const tables = new Map(); // name -> {columns: Set, foreignKeys: Array}
64
94
 
65
95
  CREATE_TABLE_RE.lastIndex = 0;
66
96
  let m;
@@ -70,37 +100,62 @@ function extractTablesFromSql(sqlText, sourceFile) {
70
100
  const closeParen = matchBalancedParens(sqlText, openParen);
71
101
  if (closeParen === -1) continue; // malformed -- skip, don't misattribute
72
102
  const body = sqlText.slice(openParen + 1, closeParen);
73
- const columns = new Set();
103
+ if (!tables.has(tableName)) tables.set(tableName, newTableEntry());
104
+ const entry = tables.get(tableName);
74
105
  for (const segment of splitTopLevelCommas(body)) {
75
- if (CONSTRAINT_LEAD_RE.test(segment)) continue;
106
+ if (CONSTRAINT_LEAD_RE.test(segment)) {
107
+ const fkMatch = segment.match(TABLE_LEVEL_FK_RE);
108
+ if (fkMatch) {
109
+ entry.foreignKeys.push({ column: fkMatch[1], references_table: fkMatch[2], references_column: fkMatch[3] ?? null });
110
+ }
111
+ continue;
112
+ }
76
113
  const colMatch = segment.match(COLUMN_NAME_RE);
77
- if (colMatch) columns.add(colMatch[1]);
114
+ if (!colMatch) continue;
115
+ entry.columns.add(colMatch[1]);
116
+ const inlineMatch = segment.match(INLINE_REFERENCES_RE);
117
+ if (inlineMatch) {
118
+ entry.foreignKeys.push({ column: colMatch[1], references_table: inlineMatch[1], references_column: inlineMatch[2] ?? null });
119
+ }
78
120
  }
79
- if (!tables.has(tableName)) tables.set(tableName, new Set());
80
- for (const c of columns) tables.get(tableName).add(c);
81
121
  }
82
122
 
83
123
  ALTER_ADD_COLUMN_RE.lastIndex = 0;
84
124
  while ((m = ALTER_ADD_COLUMN_RE.exec(sqlText))) {
85
125
  const [, tableName, columnName] = m;
86
- if (!tables.has(tableName)) tables.set(tableName, new Set());
87
- tables.get(tableName).add(columnName);
126
+ if (!tables.has(tableName)) tables.set(tableName, newTableEntry());
127
+ tables.get(tableName).columns.add(columnName);
128
+ }
129
+
130
+ ALTER_ADD_FK_RE.lastIndex = 0;
131
+ while ((m = ALTER_ADD_FK_RE.exec(sqlText))) {
132
+ const [, tableName, column, referencesTable, referencesColumn] = m;
133
+ if (!tables.has(tableName)) tables.set(tableName, newTableEntry());
134
+ tables.get(tableName).foreignKeys.push({ column, references_table: referencesTable, references_column: referencesColumn ?? null });
88
135
  }
89
136
 
90
- return [...tables.entries()].map(([name, columns]) => ({ name, columns: [...columns].sort(), source_file: sourceFile }));
137
+ return [...tables.entries()].map(([name, entry]) => ({
138
+ name,
139
+ columns: [...entry.columns].sort(),
140
+ foreign_keys: entry.foreignKeys,
141
+ source_file: sourceFile,
142
+ }));
91
143
  }
92
144
 
93
- // Entry point. Returns `{ tool, files, tables }` -- `tool: 'none'` (empty files/tables) is a real,
94
- // expected, and reported outcome, not an error -- most real repos (including the oracle repo
95
- // itself) have no migration-file convention at all; their schema lives elsewhere (JPA ddl-auto,
96
- // or -- the oracle repo's actual case -- entirely outside this repo, in an external Supabase
97
- // project).
145
+ // Entry point. Returns `{ tool, files, tables, generated_at }` -- `tool: 'none'` (empty
146
+ // files/tables) is a real, expected, and reported outcome, not an error -- most real repos
147
+ // (including the oracle repo itself) have no migration-file convention at all; their schema lives
148
+ // elsewhere (JPA ddl-auto, or -- the oracle repo's actual case -- entirely outside this repo, in
149
+ // an external Supabase project). `generated_at` (D-cross-feature-fk-inference, staleness/freshness
150
+ // token) reflects when THIS scan ran -- stamped once, reused across all 3 return sites below, so a
151
+ // single call always reports one consistent timestamp regardless of which branch it takes.
98
152
  export function scanMigrations(repoRoot) {
153
+ const generatedAt = new Date().toISOString();
99
154
  const flywayFiles = listRgFiles(repoRoot, '**/db/migration/**/*.sql');
100
155
  const liquibaseFiles = listRgFiles(repoRoot, '**/db/changelog/**/*.{xml,yaml,yml,sql}');
101
156
 
102
157
  if (flywayFiles.length === 0 && liquibaseFiles.length === 0) {
103
- return { tool: 'none', files: [], tables: [] };
158
+ return { tool: 'none', files: [], tables: [], generated_at: generatedAt };
104
159
  }
105
160
 
106
161
  if (flywayFiles.length > 0) {
@@ -109,7 +164,7 @@ export function scanMigrations(repoRoot) {
109
164
  const text = fs.readFileSync(file, 'utf8');
110
165
  tables.push(...extractTablesFromSql(text, path.relative(repoRoot, file)));
111
166
  }
112
- return { tool: 'flyway', files: flywayFiles.map((f) => path.relative(repoRoot, f)), tables };
167
+ return { tool: 'flyway', files: flywayFiles.map((f) => path.relative(repoRoot, f)), tables, generated_at: generatedAt };
113
168
  }
114
169
 
115
170
  // Liquibase changelogs are DETECTED (filenames recorded) but not deep-parsed in this first
@@ -122,5 +177,5 @@ export function scanMigrations(repoRoot) {
122
177
  const text = fs.readFileSync(file, 'utf8');
123
178
  tables.push(...extractTablesFromSql(text, path.relative(repoRoot, file)));
124
179
  }
125
- return { tool: 'liquibase', files: liquibaseFiles.map((f) => path.relative(repoRoot, f)), tables };
180
+ return { tool: 'liquibase', files: liquibaseFiles.map((f) => path.relative(repoRoot, f)), tables, generated_at: generatedAt };
126
181
  }
@@ -0,0 +1,66 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "urn:sbf:cross-feature-report:1",
4
+ "title": "backend-skeleton cross-feature identity-collision report",
5
+ "description": "Validates specs/<feature_id>/cross-feature-report.json, written by `bskel scan cross-feature-check`. Mirrors feature-contract.schema.json's own report-artifact precedent (always written, even when it finds nothing) -- see D-cross-feature-collision in DECISIONS.md. `findings` were originally NAME-identity collisions only; D-cross-feature-fk-inference added a 4th signal, `db_foreign_key`, a REAL live-DB foreign-key edge correlated against declared table ownership (see `fk_check`/`unknowns` below) -- resource_type/table/operation_id remain pure NAME-identity, never a dependency-direction claim (see lib/field-dependencies.mjs's own declared-dependency system for that).",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "required": ["schema", "feature_id", "generated_at", "findings", "fk_check", "unknowns"],
9
+ "properties": {
10
+ "schema": { "const": "sbf.cross-feature-report/1" },
11
+ "feature_id": { "type": "string", "pattern": "^[0-9]{3}-[a-z0-9]+(-[a-z0-9]+)*$" },
12
+ "generated_at": { "type": "string", "format": "date-time" },
13
+ "findings": {
14
+ "type": "array",
15
+ "items": {
16
+ "type": "object",
17
+ "additionalProperties": false,
18
+ "required": ["signal", "identifier", "other_feature", "confidence"],
19
+ "properties": {
20
+ "signal": { "enum": ["resource_type", "table", "operation_id", "db_foreign_key"] },
21
+ "identifier": {
22
+ "description": "The colliding string itself -- a resourceType/DTO className, a DB table name, an operationId, or (for db_foreign_key) a 'child.column -> parent.column' edge description -- copied verbatim from whichever side this finding was computed FROM (this feature's own scan report/contract/live schema).",
23
+ "type": "string"
24
+ },
25
+ "other_feature": { "type": "string", "pattern": "^[0-9]{3}-[a-z0-9]+(-[a-z0-9]+)*$" },
26
+ "confidence": {
27
+ "description": "'high': the identifier was copied verbatim from a real source annotation on both sides (resource_type/operation_id always; table/db_foreign_key only when BOTH sides had an explicit @Table/__tablename__-equivalent). 'medium': at least one side's table name was INFERRED (an adapter's classname-lowercase fallback guess, not a real annotation) -- reported, but does not by itself block the gate. For db_foreign_key, the FK edge itself is never in doubt (a live, Postgres-enforced constraint) -- confidence scores only which FEATURE each side was attributed to, the same table-name-inference risk the table signal already scores.",
28
+ "enum": ["high", "medium"]
29
+ },
30
+ "direction": {
31
+ "description": "db_foreign_key only, absent for the other 3 signals. 'references': THIS feature's table has the FK column, pointing at other_feature's table. 'referenced_by': other_feature's table has the FK column, pointing at THIS feature's table.",
32
+ "enum": ["references", "referenced_by"]
33
+ }
34
+ }
35
+ }
36
+ },
37
+ "fk_check": {
38
+ "description": "D-cross-feature-fk-inference: always present, names how (if at all) live FK data was obtained for this check -- never silent about the difference between a fresh live connection and a possibly-stale persisted snapshot.",
39
+ "type": "object",
40
+ "additionalProperties": false,
41
+ "required": ["mode", "schema", "source_feature", "generated_at"],
42
+ "properties": {
43
+ "mode": {
44
+ "description": "'live': --db --database-url-env <NAME> was given, this run opened a fresh connection. 'persisted': no live connection this run -- reused a db_schema.live snapshot already on disk from a PRIOR `bskel scan --db --database-url-env` run (possibly stale -- see generated_at). 'migrations': no Plane C (live or persisted) data at all -- fell back to Plane A migration-file-derived FK data (a migration FILE existing is not proof it was ever actually applied, a materially lower-trust source than any live/persisted introspection). 'unavailable': no live connection, no persisted snapshot, and no migration-file data found anywhere -- zero db_foreign_key findings are possible this run (see unknowns).",
45
+ "enum": ["live", "persisted", "migrations", "unavailable"]
46
+ },
47
+ "schema": { "type": ["string", "null"] },
48
+ "source_feature": {
49
+ "description": "Which feature's OWN persisted brownfield-scan.json the snapshot came from, when mode is 'persisted' or 'migrations' (sourced from a persisted snapshot rather than a fresh --db pass). Null for 'live' and 'unavailable'.",
50
+ "type": ["string", "null"],
51
+ "pattern": "^([0-9]{3}-[a-z0-9]+(-[a-z0-9]+)*)?$"
52
+ },
53
+ "generated_at": {
54
+ "description": "D-cross-feature-fk-inference (staleness/freshness token): when the underlying Plane A/C data this check used was actually captured -- lets a human bound how stale a 'persisted'/'migrations'-mode correlation is, rather than only knowing WHICH snapshot was used. Null only for 'unavailable'.",
55
+ "type": ["string", "null"],
56
+ "format": "date-time"
57
+ }
58
+ }
59
+ },
60
+ "unknowns": {
61
+ "description": "Plain-string notes for real gaps this check found but could not turn into a finding -- an FK edge whose other side matches no active feature's declared table, or (fk_check.mode === 'unavailable') a single note explaining no live FK data was available at all. Same 'detect and warn, never silently omit' discipline scan-report.schema.json's own unknowns already establishes.",
62
+ "type": "array",
63
+ "items": { "type": "string" }
64
+ }
65
+ }
66
+ }
@@ -0,0 +1,28 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "urn:sbf:cross-feature-resolution:1",
4
+ "title": "backend-skeleton cross-feature collision waiver resolution",
5
+ "description": "Documentation only, like contract-resolution.schema.json -- nothing loads this at runtime beyond a direct JSON parse (see the analogous contracts/completeness.mjs's loadResolution). Validates specs/<feature_id>/cross-feature-resolution.json, written by `bskel scan cross-feature-waive`. Deliberately separate from cross-feature-report.schema.json's own artifact, mirroring D-contract-completeness's own reasoning for keeping waivers out of the report they apply to. Each waiver is a specific {signal, identifier, other_feature} triple, never a wildcard -- a collision that doesn't exist yet is never silently covered, same discipline contract-resolution.schema.json's own {code, subject} waivers already establish.",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "required": ["schema", "feature_id", "waivers"],
9
+ "properties": {
10
+ "schema": { "const": "sbf.cross-feature-resolution/1" },
11
+ "feature_id": { "type": "string", "pattern": "^[0-9]{3}-[a-z0-9]+(-[a-z0-9]+)*$" },
12
+ "waivers": {
13
+ "type": "array",
14
+ "items": {
15
+ "type": "object",
16
+ "additionalProperties": false,
17
+ "required": ["signal", "identifier", "other_feature", "reason", "at"],
18
+ "properties": {
19
+ "signal": { "enum": ["resource_type", "table", "operation_id", "db_foreign_key"] },
20
+ "identifier": { "type": "string" },
21
+ "other_feature": { "type": "string", "pattern": "^[0-9]{3}-[a-z0-9]+(-[a-z0-9]+)*$" },
22
+ "reason": { "type": "string" },
23
+ "at": { "type": "string", "format": "date-time" }
24
+ }
25
+ }
26
+ }
27
+ }
28
+ }