backend-skeleton 1.0.0-beta.9 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +185 -13
- package/bin/bskel.mjs +689 -33
- package/contracts/emit.mjs +5 -1
- package/contracts/export.mjs +65 -7
- package/contracts/openapi.mjs +321 -30
- package/contracts/validate.mjs +23 -4
- package/handles/_engine.mjs +123 -30
- package/handles/capability-codec.mjs +94 -0
- package/handles/codec.mjs +13 -3
- package/handles/providers/java-spring/emit.mjs +78 -33
- package/handles/providers/java-spring/observe.mjs +4 -3
- package/handles/providers/java-spring/plan.mjs +73 -16
- package/handles/providers/java-spring/templates/HandleCodec.java.tmpl +19 -1
- package/handles/providers/java-spring/templates/HandleController.java.tmpl +19 -9
- package/handles/providers/java-spring/templates/HandleService.java.tmpl +21 -2
- package/handles/providers/java-spring/templates/RecordHandleSnapshot.java.tmpl +1 -1
- package/handles/providers/java-spring/templates/ResourceResolver.java.tmpl +36 -9
- package/handles/providers/java-spring/templates/ResourceResolverStub.java.tmpl +8 -2
- package/handles/providers/java-spring.mjs +8 -0
- package/handles/providers/python-fastapi/emit.mjs +21 -26
- package/handles/providers/python-fastapi/observe.mjs +6 -5
- package/handles/providers/python-fastapi/templates/codec.py.tmpl +18 -3
- package/handles/providers/python-fastapi/templates/record_snapshot.py.tmpl +129 -39
- package/handles/providers/python-fastapi.mjs +3 -3
- package/handles/providers/typescript-express/emit.mjs +144 -55
- package/handles/providers/typescript-express/observe.mjs +102 -0
- package/handles/providers/typescript-express/templates/codec.ts.tmpl +13 -3
- package/handles/providers/typescript-express/templates/contractCheck.ts.tmpl +136 -0
- package/handles/providers/typescript-express/templates/handleEntities.ts.tmpl +89 -0
- package/handles/providers/typescript-express/templates/handleService.ts.tmpl +81 -0
- package/handles/providers/typescript-express/templates/migration.sql.tmpl +36 -0
- package/handles/providers/typescript-express/templates/observeContract.ts.tmpl +146 -0
- package/handles/providers/typescript-express/templates/observedSchema.ts.tmpl +116 -0
- package/handles/providers/typescript-express/templates/recordSnapshotWrapper.ts.tmpl +123 -0
- package/handles/providers/typescript-express/templates/registry.ts.tmpl +19 -10
- package/handles/providers/typescript-express/templates/resolver.ts.tmpl +13 -0
- package/handles/providers/typescript-express/templates/resolverPolicy.ts.tmpl +20 -0
- package/handles/providers/typescript-express/templates/router.ts.tmpl +113 -2
- package/handles/providers/typescript-express.mjs +7 -4
- package/lib/attest.mjs +40 -0
- package/lib/cli.mjs +136 -5
- package/lib/cross-feature-collisions.mjs +286 -0
- package/lib/diff.mjs +35 -0
- package/lib/exit-codes.mjs +21 -0
- package/lib/fsutil.mjs +7 -2
- package/lib/gate-definitions.mjs +85 -1
- package/lib/gates.mjs +5 -1
- package/lib/http-server.mjs +192 -6
- package/lib/lock.mjs +68 -15
- package/lib/patch-kinds.mjs +52 -0
- package/lib/patch-transactions.mjs +206 -0
- package/lib/serve-ui.html +211 -0
- package/lib/verify.mjs +23 -6
- package/lib/workflow.mjs +31 -3
- package/package.json +8 -2
- package/scanners/adapters/_java-spring-analyzer.mjs +9 -1
- package/scanners/adapters/java-spring.mjs +114 -10
- package/scanners/adapters/javascript-express.mjs +46 -13
- package/scanners/adapters/python-fastapi.mjs +9 -1
- package/scanners/adapters/typescript-express.mjs +19 -2
- package/scanners/db/ddl-apply.mjs +253 -0
- package/scanners/db/introspect.mjs +61 -32
- package/scanners/db/migrations.mjs +73 -18
- package/schemas/cross-feature-report.schema.json +66 -0
- package/schemas/cross-feature-resolution.schema.json +28 -0
- package/schemas/feature-contract.schema.json +3 -3
- package/schemas/gate-attestation.schema.json +22 -0
- package/schemas/gate-export.schema.json +58 -0
- package/schemas/handles-plan.schema.json +2 -0
- package/schemas/oracle-manifest.schema.json +58 -0
- package/schemas/patch-transaction.schema.json +182 -0
- package/schemas/scan-report.schema.json +6 -4
- package/schemas/stack-choice.schema.json +12 -1
- package/schemas/stack-record.schema.json +6 -1
- package/stack/apply.mjs +51 -7
- package/stack/catalog/ngrok.yml +8 -2
- package/stack/config-apply.mjs +168 -0
|
@@ -226,10 +226,40 @@ function resolveEsmImport(fromFile, specifier, suffixes) {
|
|
|
226
226
|
return null;
|
|
227
227
|
}
|
|
228
228
|
|
|
229
|
-
//
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
229
|
+
// D-javascript-express-adapter (Update): found by the same shadow-validation-style audit that
|
|
230
|
+
// closed D-module-attribution-base-package's own EXIT item for this adapter -- cross-file mount
|
|
231
|
+
// resolution only ever recognized `export default router;`, a real but narrower limitation than
|
|
232
|
+
// java-spring's own moduleOf() bug (endpoints still get FOUND either way, only their prefix goes
|
|
233
|
+
// unresolved). A router handed off via a bare named export (`export { router };`) or an
|
|
234
|
+
// export-prefixed declaration (`export const router = Router();`, ordinary and common) previously
|
|
235
|
+
// had no path to being recognized as this file's "the" exported mountable at all.
|
|
236
|
+
//
|
|
237
|
+
// Three real ways a module hands a locally-declared mountable to whoever imports it -- `export
|
|
238
|
+
// { router as r }` aliasing is deliberately NOT resolved, same restraint as this file's own
|
|
239
|
+
// `Router as R` import-aliasing decision (D-javascript-express-adapter COST): a documented, narrow
|
|
240
|
+
// limitation, not a silent guess at which local name an alias refers to.
|
|
241
|
+
function exportedMountableName(text, mountables) {
|
|
242
|
+
const defaultMatch = text.match(/export\s+default\s+([\w$]+)\s*;?/);
|
|
243
|
+
if (defaultMatch && mountables.has(defaultMatch[1])) return defaultMatch[1];
|
|
244
|
+
const namedMatch = text.match(/export\s*\{\s*([\w$]+)\s*\}/);
|
|
245
|
+
if (namedMatch && mountables.has(namedMatch[1])) return namedMatch[1];
|
|
246
|
+
const exportedDeclMatch = text.match(/\bexport\s+(?:const|let|var)\s+([\w$]+)\s*=/);
|
|
247
|
+
if (exportedDeclMatch && mountables.has(exportedDeclMatch[1])) return exportedDeclMatch[1];
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// `import target from '...'` (default) OR `import { target } from '...'` (named, unaliased) --
|
|
252
|
+
// two real ways an imported mountable's LOCAL name reaches this file. `import { target as alias }`
|
|
253
|
+
// is deliberately not resolved, same restraint as exportedMountableName's own aliasing decision
|
|
254
|
+
// above -- a bare, unaliased single-name clause only, matching this file's existing default-import
|
|
255
|
+
// regex's own narrow scope (never a general multi-specifier import-clause parser).
|
|
256
|
+
function importSourceFor(text, target) {
|
|
257
|
+
const defaultImportRe = new RegExp(`import\\s+${target}\\s*(?:,\\s*\\{[^}]*\\})?\\s*from\\s*["']([^"']+)["']`);
|
|
258
|
+
const defaultMatch = text.match(defaultImportRe);
|
|
259
|
+
if (defaultMatch) return defaultMatch[1];
|
|
260
|
+
const namedImportRe = new RegExp(`import\\s*\\{\\s*${target}\\s*\\}\\s*from\\s*["']([^"']+)["']`);
|
|
261
|
+
const namedMatch = text.match(namedImportRe);
|
|
262
|
+
return namedMatch ? namedMatch[1] : null;
|
|
233
263
|
}
|
|
234
264
|
|
|
235
265
|
// Builds the mount graph over (file, variable) nodes. Two edge kinds, both from the same
|
|
@@ -261,13 +291,12 @@ function buildMountEdges(files, fileInfo, suffixes) {
|
|
|
261
291
|
edges.push({ from: nodeKey(file, fromVar), to: nodeKey(file, target), prefix: pathMatch[1] });
|
|
262
292
|
continue;
|
|
263
293
|
}
|
|
264
|
-
const
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
const toFile = resolveEsmImport(file, importMatch[1], suffixes);
|
|
294
|
+
const importSource = importSourceFor(info.text, target);
|
|
295
|
+
if (!importSource) continue;
|
|
296
|
+
const toFile = resolveEsmImport(file, importSource, suffixes);
|
|
268
297
|
if (!toFile || !fileInfo.has(toFile)) continue;
|
|
269
298
|
const toInfo = fileInfo.get(toFile);
|
|
270
|
-
const toVar =
|
|
299
|
+
const toVar = exportedMountableName(toInfo.text, toInfo.mountables);
|
|
271
300
|
if (!toVar) continue;
|
|
272
301
|
edges.push({ from: nodeKey(file, fromVar), to: nodeKey(toFile, toVar), prefix: pathMatch[1] });
|
|
273
302
|
}
|
|
@@ -391,10 +420,14 @@ export const adapter = {
|
|
|
391
420
|
// walk), not how many capabilities it can offer. generic-grep is `low` because it has no module
|
|
392
421
|
// inference and no prefix resolution at all -- this adapter has both.
|
|
393
422
|
confidence: 'high',
|
|
394
|
-
// D-
|
|
395
|
-
//
|
|
396
|
-
//
|
|
397
|
-
|
|
423
|
+
// D-oracle-corpus-pinning: promoted from synthetic-only -- 3 real, pinned community repos
|
|
424
|
+
// now exist in test/fixtures/oracle-manifest.json (JeanCaicedo/employees-api-mysql,
|
|
425
|
+
// Serkanbyx/chat-app-backend, nekesam/helloworld), found via a genuine search effort that
|
|
426
|
+
// contradicted this adapter's own prior 'may be structurally unpromotable' hedge. The
|
|
427
|
+
// committed synthetic fixture still carries the exact-count regression weight; the real
|
|
428
|
+
// corpus is diagnostic/diversity coverage on top, same role every other adapter's real
|
|
429
|
+
// oracle plays.
|
|
430
|
+
verificationBasis: 'community-sample',
|
|
398
431
|
capabilities: {
|
|
399
432
|
// false: plain Express has no operationId concept at all. --openapi-file is the honest path
|
|
400
433
|
// forward for an app that has one; see CAPABILITY_SATISFIERS in scanners/capabilities.mjs.
|
|
@@ -155,7 +155,15 @@ function extractTableEntities(text, file) {
|
|
|
155
155
|
const bodyEnd = i + 1 < classMatches.length ? classMatches[i + 1].index : text.length;
|
|
156
156
|
const body = text.slice(bodyStart, bodyEnd);
|
|
157
157
|
const idMatch = body.match(/(\w+)\s*:[^=\n]+=\s*Field\([^)]*primary_key\s*=\s*True/);
|
|
158
|
-
|
|
158
|
+
// D-cross-feature-collision: a real `__tablename__ = "..."` override, previously never
|
|
159
|
+
// checked at all -- `table` fell back to the lowercased class name unconditionally, which
|
|
160
|
+
// silently mis-derives the real table name whenever a class overrides it. Cross-feature
|
|
161
|
+
// collision detection needs to know which case this was: 'explicit' (the real, trustworthy
|
|
162
|
+
// name) vs 'inferred' (a guess that could be wrong), not just a bare string either way.
|
|
163
|
+
const tablenameMatch = body.match(/__tablename__\s*(?::\s*\w+\s*)?=\s*["']([^"']+)["']/);
|
|
164
|
+
const table = tablenameMatch ? tablenameMatch[1] : m[1].toLowerCase();
|
|
165
|
+
const tableSource = tablenameMatch ? 'explicit' : 'inferred';
|
|
166
|
+
entities.push({ className: m[1], table, tableSource, idField: idMatch ? idMatch[1] : null, file, line: lineNumberAt(text, m.index) });
|
|
159
167
|
}
|
|
160
168
|
return entities;
|
|
161
169
|
}
|
|
@@ -41,6 +41,17 @@ const ENTITY_CLASS_RE = /@Entity\s*\(\s*(?:["'`]([^"'`]*)["'`])?\s*\)\s*\n?\s*ex
|
|
|
41
41
|
// exported symbol -- same file-level granularity java's own DTO tracking already settled for.
|
|
42
42
|
const DTO_DIR_SEGMENT = `${path.sep}dto${path.sep}`;
|
|
43
43
|
|
|
44
|
+
// D-module-attribution-base-package (Update): found by the same shadow-validation pass that fixed
|
|
45
|
+
// java-spring's own moduleOf() -- a real project not using a `dto/` folder at all (flat `CreateUserDto.ts` files, or
|
|
46
|
+
// NestJS's own common `create-user.dto.ts` naming) had every DTO silently invisible, the same class
|
|
47
|
+
// of single-convention overfit, just on a narrower surface (DTO tracking only, not module/entity/
|
|
48
|
+
// controller extraction). This does NOT reopen the CONTENT-detection problem the comment above
|
|
49
|
+
// explicitly rejected (interface/type/class-validator/Zod/undecorated class all have different
|
|
50
|
+
// shapes) -- it's an independent, NAME-only signal: the file's own basename ends in "dto"
|
|
51
|
+
// (case-insensitive), the same near-definitional marker this file's own entity-matching step below
|
|
52
|
+
// already leans on for MATCHING. Catches both `CreateUserDto.ts` and `create-user.dto.ts`.
|
|
53
|
+
const DTO_NAME_SUFFIX_RE = /dto$/i;
|
|
54
|
+
|
|
44
55
|
// Two independent signals required, mirroring java-spring's "build file AND src layout" /
|
|
45
56
|
// python-fastapi's "dependency declared AND source-confirmed" combined bar: (a) package.json
|
|
46
57
|
// declares express, (b) at least one .ts file actually imports Router from 'express' and calls
|
|
@@ -195,6 +206,12 @@ function extractTableEntities(text, file) {
|
|
|
195
206
|
entities.push({
|
|
196
207
|
className,
|
|
197
208
|
table: m[1] || className.toLowerCase(),
|
|
209
|
+
// D-cross-feature-collision: m[1] is ENTITY_CLASS_RE's own captured explicit
|
|
210
|
+
// `@Entity('table_name')` literal argument -- already extracted above, just never
|
|
211
|
+
// separately flagged as the confidence signal it actually is (vs. the lowercased-
|
|
212
|
+
// classname fallback on the same line, which is a guess TypeORM's own real default-
|
|
213
|
+
// naming convention happens to often match, but not always).
|
|
214
|
+
tableSource: m[1] ? 'explicit' : 'inferred',
|
|
198
215
|
idField: idMatch ? idMatch[2] : null,
|
|
199
216
|
idFieldIsUuid: idMatch ? /['"]uuid['"]/.test(idMatch[1]) : false,
|
|
200
217
|
file,
|
|
@@ -245,8 +262,8 @@ export function scanTypeScriptExpress(repoRoot, projectRoot) {
|
|
|
245
262
|
moduleEntry(moduleName).controllers.push({ className, basePath: prefix, operationIds: [], endpoints, file });
|
|
246
263
|
}
|
|
247
264
|
}
|
|
248
|
-
if (file.includes(DTO_DIR_SEGMENT)) {
|
|
249
|
-
allDtos.push({ className: path.basename(file, '.ts'), file }); // no `line` -- path-based, no content parsed
|
|
265
|
+
if (file.includes(DTO_DIR_SEGMENT) || DTO_NAME_SUFFIX_RE.test(path.basename(file, '.ts'))) {
|
|
266
|
+
allDtos.push({ className: path.basename(file, '.ts'), file }); // no `line` -- path/name-based, no content parsed
|
|
250
267
|
}
|
|
251
268
|
allEntities.push(...extractTableEntities(text, file));
|
|
252
269
|
}
|
|
@@ -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
|
}
|