backend-skeleton 1.0.0-beta.9 → 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.
- package/README.md +122 -12
- package/bin/bskel.mjs +564 -15
- package/contracts/emit.mjs +5 -1
- package/contracts/export.mjs +26 -3
- package/contracts/openapi.mjs +29 -3
- package/handles/_engine.mjs +79 -29
- package/handles/providers/java-spring/plan.mjs +22 -9
- package/handles/providers/java-spring/templates/HandleController.java.tmpl +7 -3
- package/handles/providers/java-spring/templates/ResourceResolver.java.tmpl +12 -6
- package/handles/providers/python-fastapi/templates/record_snapshot.py.tmpl +101 -39
- package/handles/providers/typescript-express/emit.mjs +23 -23
- package/handles/providers/typescript-express/observe.mjs +101 -0
- package/handles/providers/typescript-express/templates/contractCheck.ts.tmpl +136 -0
- package/handles/providers/typescript-express/templates/observeContract.ts.tmpl +146 -0
- package/handles/providers/typescript-express/templates/observedSchema.ts.tmpl +116 -0
- package/lib/attest.mjs +40 -0
- package/lib/cli.mjs +125 -3
- package/lib/cross-feature-collisions.mjs +286 -0
- package/lib/diff.mjs +35 -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/workflow.mjs +31 -3
- package/package.json +5 -2
- package/scanners/adapters/java-spring.mjs +6 -0
- package/scanners/adapters/python-fastapi.mjs +9 -1
- package/scanners/adapters/typescript-express.mjs +6 -0
- 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/gate-attestation.schema.json +22 -0
- package/schemas/gate-export.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/stack/apply.mjs +4 -1
- package/stack/catalog/ngrok.yml +8 -2
- package/stack/config-apply.mjs +168 -0
package/lib/serve-ui.html
CHANGED
|
@@ -44,6 +44,34 @@
|
|
|
44
44
|
</form>
|
|
45
45
|
<div id="formStatus"></div>
|
|
46
46
|
|
|
47
|
+
<h2>Live DB schema</h2>
|
|
48
|
+
<div id="dbSchemaPanel">
|
|
49
|
+
<p class="muted" id="dbSchemaNote">loading...</p>
|
|
50
|
+
<table id="dbSchemaTable" hidden><thead><tr><th>table</th><th>columns</th><th>primary key</th><th>foreign keys</th><th>indexes</th></tr></thead><tbody></tbody></table>
|
|
51
|
+
</div>
|
|
52
|
+
|
|
53
|
+
<h2>DDL patch transactions</h2>
|
|
54
|
+
<div id="ddlPanel">
|
|
55
|
+
<form id="ddlFeatureForm" style="grid-template-columns: 1fr auto;">
|
|
56
|
+
<label>feature <input name="feature" id="ddlFeatureInput" required></label>
|
|
57
|
+
<button type="submit">Load transactions</button>
|
|
58
|
+
</form>
|
|
59
|
+
<table id="ddlTable"><thead><tr><th>transaction</th><th>kind</th><th>status</th><th>sql</th><th>created</th><th>action</th></tr></thead><tbody></tbody></table>
|
|
60
|
+
<div id="ddlActionStatus"></div>
|
|
61
|
+
|
|
62
|
+
<h3 style="font-size: 0.9rem; margin-top: 1.2rem;">Propose a DDL change</h3>
|
|
63
|
+
<form id="ddlProposeForm">
|
|
64
|
+
<label>feature <input name="feature" id="ddlProposeFeature" required></label>
|
|
65
|
+
<label>database_url_env <input name="databaseUrlEnv" placeholder="e.g. BSKEL_DDL_DATABASE_URL" required></label>
|
|
66
|
+
<label>schema <input name="schema" value="public" required></label>
|
|
67
|
+
<label style="grid-column: 1 / -1">sql_text
|
|
68
|
+
<textarea name="sqlText" rows="4" required style="font: inherit; padding: 0.4rem;" placeholder="CREATE TABLE widgets (id uuid PRIMARY KEY);"></textarea>
|
|
69
|
+
</label>
|
|
70
|
+
<button type="submit">Propose</button>
|
|
71
|
+
</form>
|
|
72
|
+
<div id="ddlProposeStatus"></div>
|
|
73
|
+
</div>
|
|
74
|
+
|
|
47
75
|
<script>
|
|
48
76
|
// D-http-serving-layer: `reason`/`memo` (and every other string field) are free text a human types
|
|
49
77
|
// via `bskel dependency declare --reason/--memo` or this page's own POST form -- never assumed safe
|
|
@@ -106,6 +134,189 @@ document.getElementById('declareForm').addEventListener('submit', async (ev) =>
|
|
|
106
134
|
}
|
|
107
135
|
});
|
|
108
136
|
|
|
137
|
+
// D-ddl-apply: mirrors td()'s own comment above -- every value rendered here (table/column names,
|
|
138
|
+
// raw sql_text, error messages) is free text this tool never assumes is safe to inject as markup.
|
|
139
|
+
// All via td()/textContent, never innerHTML.
|
|
140
|
+
function tdList(items) {
|
|
141
|
+
return td(items.length ? items.join(', ') : '(none)');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// D-ddl-apply (DROP-TABLE-specific confirmation): mirrors scanners/db/ddl-apply.mjs's own
|
|
145
|
+
// requiredConfirmValue() exactly -- the transaction id for a non-drop transaction, or the sorted,
|
|
146
|
+
// comma-joined dropped-table name(s) for one that drops a table. Computed here purely so the
|
|
147
|
+
// placeholder can SHOW the human what to type; the server independently re-derives and enforces
|
|
148
|
+
// the same value, this is not the source of truth.
|
|
149
|
+
function requiredConfirmValue(t) {
|
|
150
|
+
const droppedTables = (t.postcondition?.expected_tables ?? [])
|
|
151
|
+
.filter((x) => x.expect === 'absent')
|
|
152
|
+
.map((x) => x.name)
|
|
153
|
+
.sort();
|
|
154
|
+
return droppedTables.length > 0 ? droppedTables.join(',') : t.transaction_id;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function loadDbSchema() {
|
|
158
|
+
const note = document.getElementById('dbSchemaNote');
|
|
159
|
+
const table = document.getElementById('dbSchemaTable');
|
|
160
|
+
try {
|
|
161
|
+
const res = await fetch('/api/db/schema');
|
|
162
|
+
if (res.status === 404) {
|
|
163
|
+
note.hidden = false;
|
|
164
|
+
note.textContent = 'DDL features are disabled -- start `bskel serve --database-url-env NAME` to enable them.';
|
|
165
|
+
table.hidden = true;
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
const body = await res.json();
|
|
169
|
+
if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`);
|
|
170
|
+
note.hidden = true;
|
|
171
|
+
table.hidden = false;
|
|
172
|
+
const tbody = table.querySelector('tbody');
|
|
173
|
+
tbody.replaceChildren();
|
|
174
|
+
for (const t of body.tables) {
|
|
175
|
+
const tr = document.createElement('tr');
|
|
176
|
+
tr.append(
|
|
177
|
+
td(t.name),
|
|
178
|
+
td(t.columns.map((c) => `${c.name} ${c.type}${c.nullable ? '' : ' NOT NULL'}`).join(', ') || '(none)'),
|
|
179
|
+
tdList(t.primary_key),
|
|
180
|
+
td(t.foreign_keys.map((f) => `${f.column} -> ${f.references_table}.${f.references_column}`).join(', ') || '(none)'),
|
|
181
|
+
tdList(t.indexes),
|
|
182
|
+
);
|
|
183
|
+
tbody.appendChild(tr);
|
|
184
|
+
}
|
|
185
|
+
if (body.tables.length === 0) {
|
|
186
|
+
const tr = document.createElement('tr');
|
|
187
|
+
tr.appendChild(td('(no tables)'));
|
|
188
|
+
tbody.appendChild(tr);
|
|
189
|
+
}
|
|
190
|
+
} catch (err) {
|
|
191
|
+
note.hidden = false;
|
|
192
|
+
note.textContent = `failed to load /api/db/schema: ${err.message}`;
|
|
193
|
+
table.hidden = true;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async function loadDdlTransactions(feature) {
|
|
198
|
+
const tbody = document.querySelector('#ddlTable tbody');
|
|
199
|
+
tbody.replaceChildren();
|
|
200
|
+
if (!feature) return;
|
|
201
|
+
const res = await fetch(`/api/features/${encodeURIComponent(feature)}/patch-transactions`);
|
|
202
|
+
const body = await res.json();
|
|
203
|
+
if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`);
|
|
204
|
+
for (const t of body.transactions) {
|
|
205
|
+
const tr = document.createElement('tr');
|
|
206
|
+
const sqlOrTarget = t.kind === 'ddl-apply' ? t.target.sql_text : `${t.target.file} @ ${(t.target.key_path || []).join('.')}`;
|
|
207
|
+
tr.append(td(t.transaction_id), td(t.kind), td(t.status), td(sqlOrTarget), td(t.created_at));
|
|
208
|
+
|
|
209
|
+
const actionCell = document.createElement('td');
|
|
210
|
+
if (t.status === 'proposed') {
|
|
211
|
+
const form = document.createElement('form');
|
|
212
|
+
form.style.gridTemplateColumns = 'none';
|
|
213
|
+
form.style.display = 'flex';
|
|
214
|
+
form.style.gap = '0.3rem';
|
|
215
|
+
const reasonInput = document.createElement('input');
|
|
216
|
+
reasonInput.placeholder = 'reason';
|
|
217
|
+
reasonInput.required = true;
|
|
218
|
+
const btn = document.createElement('button');
|
|
219
|
+
btn.type = 'submit';
|
|
220
|
+
btn.textContent = 'Approve';
|
|
221
|
+
form.append(reasonInput, btn);
|
|
222
|
+
form.addEventListener('submit', async (ev) => {
|
|
223
|
+
ev.preventDefault();
|
|
224
|
+
await runDdlAction(feature, t.transaction_id, 'approve', { reason: reasonInput.value });
|
|
225
|
+
});
|
|
226
|
+
actionCell.appendChild(form);
|
|
227
|
+
} else if (t.status === 'approved') {
|
|
228
|
+
const form = document.createElement('form');
|
|
229
|
+
form.style.gridTemplateColumns = 'none';
|
|
230
|
+
form.style.display = 'flex';
|
|
231
|
+
form.style.gap = '0.3rem';
|
|
232
|
+
const confirmInput = document.createElement('input');
|
|
233
|
+
// Deliberately starts EMPTY, never pre-filled -- a pre-filled confirm field would let a
|
|
234
|
+
// click submit this whole flow (applying a live DDL change) without the human actually
|
|
235
|
+
// re-reading/re-typing anything, defeating the entire point of this friction. The
|
|
236
|
+
// placeholder names the REQUIRED value -- the dropped table name(s), not the opaque
|
|
237
|
+
// transaction id, when this transaction drops a table (see requiredConfirmValue() above).
|
|
238
|
+
confirmInput.placeholder = `type "${requiredConfirmValue(t)}" to confirm`;
|
|
239
|
+
confirmInput.required = true;
|
|
240
|
+
const btn = document.createElement('button');
|
|
241
|
+
btn.type = 'submit';
|
|
242
|
+
btn.textContent = 'Apply';
|
|
243
|
+
form.append(confirmInput, btn);
|
|
244
|
+
form.addEventListener('submit', async (ev) => {
|
|
245
|
+
ev.preventDefault();
|
|
246
|
+
await runDdlAction(feature, t.transaction_id, 'apply', { confirm: confirmInput.value });
|
|
247
|
+
});
|
|
248
|
+
actionCell.appendChild(form);
|
|
249
|
+
} else {
|
|
250
|
+
actionCell.textContent = '(none)';
|
|
251
|
+
}
|
|
252
|
+
tr.appendChild(actionCell);
|
|
253
|
+
tbody.appendChild(tr);
|
|
254
|
+
}
|
|
255
|
+
if (body.transactions.length === 0) {
|
|
256
|
+
const tr = document.createElement('tr');
|
|
257
|
+
tr.appendChild(td('(none proposed)'));
|
|
258
|
+
tbody.appendChild(tr);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async function runDdlAction(feature, transactionId, action, body) {
|
|
263
|
+
const statusEl = document.getElementById('ddlActionStatus');
|
|
264
|
+
statusEl.textContent = `${action}ing...`;
|
|
265
|
+
try {
|
|
266
|
+
const res = await fetch(`/api/features/${encodeURIComponent(feature)}/patch-transactions/${encodeURIComponent(transactionId)}/${action}`, {
|
|
267
|
+
method: 'POST',
|
|
268
|
+
headers: { 'Content-Type': 'application/json' },
|
|
269
|
+
body: JSON.stringify(body),
|
|
270
|
+
});
|
|
271
|
+
const responseBody = await res.json();
|
|
272
|
+
if (!res.ok) throw new Error(responseBody.error ?? `HTTP ${res.status}`);
|
|
273
|
+
statusEl.textContent = `${action} succeeded: ${transactionId} -> ${responseBody.status}`;
|
|
274
|
+
await loadDdlTransactions(feature);
|
|
275
|
+
} catch (err) {
|
|
276
|
+
statusEl.textContent = `error: ${err.message}`;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
document.getElementById('ddlFeatureForm').addEventListener('submit', async (ev) => {
|
|
281
|
+
ev.preventDefault();
|
|
282
|
+
const feature = document.getElementById('ddlFeatureInput').value;
|
|
283
|
+
try {
|
|
284
|
+
await loadDdlTransactions(feature);
|
|
285
|
+
} catch (err) {
|
|
286
|
+
document.getElementById('ddlActionStatus').textContent = `error: ${err.message}`;
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
document.getElementById('ddlProposeForm').addEventListener('submit', async (ev) => {
|
|
291
|
+
ev.preventDefault();
|
|
292
|
+
const form = new FormData(ev.target);
|
|
293
|
+
const feature = form.get('feature');
|
|
294
|
+
const statusEl = document.getElementById('ddlProposeStatus');
|
|
295
|
+
statusEl.textContent = 'proposing...';
|
|
296
|
+
try {
|
|
297
|
+
const res = await fetch(`/api/features/${encodeURIComponent(feature)}/patch-transactions/propose`, {
|
|
298
|
+
method: 'POST',
|
|
299
|
+
headers: { 'Content-Type': 'application/json' },
|
|
300
|
+
body: JSON.stringify({
|
|
301
|
+
kind: 'ddl-apply',
|
|
302
|
+
databaseUrlEnv: form.get('databaseUrlEnv'),
|
|
303
|
+
schema: form.get('schema'),
|
|
304
|
+
sqlText: form.get('sqlText'),
|
|
305
|
+
}),
|
|
306
|
+
});
|
|
307
|
+
const body = await res.json();
|
|
308
|
+
if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`);
|
|
309
|
+
statusEl.textContent = `proposed: ${body.transaction_id}`;
|
|
310
|
+
ev.target.reset();
|
|
311
|
+
document.getElementById('ddlFeatureInput').value = feature;
|
|
312
|
+
await loadDdlTransactions(feature);
|
|
313
|
+
} catch (err) {
|
|
314
|
+
statusEl.textContent = `error: ${err.message}`;
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
loadDbSchema();
|
|
319
|
+
|
|
109
320
|
loadGraph().catch((err) => {
|
|
110
321
|
const p = document.createElement('p');
|
|
111
322
|
p.style.color = '#d33';
|
package/lib/workflow.mjs
CHANGED
|
@@ -31,6 +31,11 @@ export function listFeatures(root) {
|
|
|
31
31
|
const ESTABLISH_COMMAND = {
|
|
32
32
|
preflight: () => 'bskel preflight',
|
|
33
33
|
scan: (id) => `bskel scan --feature ${id} --terms <a,b,c>`,
|
|
34
|
+
// D-cross-feature-collision: added in the SAME commit as the gate itself this time -- the
|
|
35
|
+
// ESTABLISH_COMMAND/MUTATING_PREFIXES gap was found live twice already this session for other
|
|
36
|
+
// gates (dependencies/conformance below), so this one is wired up immediately rather than
|
|
37
|
+
// discovered later.
|
|
38
|
+
cross_feature: (id) => `bskel scan cross-feature-check --feature ${id}`,
|
|
34
39
|
contract: (id) => `bskel contract emit --feature ${id}`,
|
|
35
40
|
// D-field-dependency: this and `conformance` immediately below were BOTH missing before this
|
|
36
41
|
// item -- found live while adding this one, same crash class, fixed together. Neither had ever
|
|
@@ -41,6 +46,10 @@ const ESTABLISH_COMMAND = {
|
|
|
41
46
|
dependencies: (id) => `bskel dependency declare --feature ${id} --resource <Type> --field <name> --source-feature <id> --source-resource <Type> --source-field <name> --reason "..."`,
|
|
42
47
|
handles: (id) => `bskel handles plan --feature ${id} # then: bskel handles emit --feature ${id}`,
|
|
43
48
|
stack: () => 'bskel stack apply --choice <id> --apply',
|
|
49
|
+
// D-patch-transactions: added in the SAME commit as the gate itself -- per the
|
|
50
|
+
// `D-cross-feature-collision` entry's own explicit warning above (this exact ESTABLISH_COMMAND
|
|
51
|
+
// gap), already found live twice for other gates before it could recur a third time here.
|
|
52
|
+
patch_transactions: (id) => `bskel patch propose --feature ${id} --choice <id> --target <config_check target path> # then: bskel patch approve ... then bskel patch apply ...`,
|
|
44
53
|
conformance: (id) => `bskel observe emit --feature ${id} # then: run the target app, then bskel observe import --feature ${id} --receipts <path>`,
|
|
45
54
|
};
|
|
46
55
|
|
|
@@ -55,17 +64,36 @@ function awaitingDispositionCommand(gateName, featureId) {
|
|
|
55
64
|
if (gateName === 'contract') {
|
|
56
65
|
return `bskel contract waive --feature ${featureId} --code <CODE> (--subject "..."|--all) --reason "..." # or: bskel gate force contract --feature ${featureId} --reason "..." if intentional`;
|
|
57
66
|
}
|
|
67
|
+
if (gateName === 'cross_feature') {
|
|
68
|
+
return `bskel scan cross-feature-waive --feature ${featureId} --signal resource_type|table|operation_id --identifier <name> --other-feature <id> --reason "..." # or: bskel gate force cross_feature --feature ${featureId} --reason "..." if intentional`;
|
|
69
|
+
}
|
|
58
70
|
return `bskel gate force ${gateName} --feature ${featureId} --reason "..."`;
|
|
59
71
|
}
|
|
60
72
|
|
|
61
73
|
// Whether a recommended command WRITES anything (gate state, generated files, applied files) as
|
|
62
74
|
// opposed to being a pure read (bskel verify, bskel status, bskel next itself). Matched by the
|
|
63
75
|
// command's own leading "bskel <verb...>" prefix so this can't silently drift from the actual
|
|
64
|
-
// command names above.
|
|
65
|
-
|
|
76
|
+
// command names above. `bskel observe emit`/`bskel observe import` were BOTH missing until found
|
|
77
|
+
// live while fixing the identical ESTABLISH_COMMAND gap above (same bug class: neither had ever
|
|
78
|
+
// been exercised through `next` recommending the real `conformance`-gate command, so a real
|
|
79
|
+
// `mutating: false` misclassification on a command that actually writes files + passes a gate went
|
|
80
|
+
// unnoticed) -- see D-field-dependency's COST section in DECISIONS.md.
|
|
81
|
+
const MUTATING_PREFIXES = [
|
|
82
|
+
'bskel preflight', 'bskel scan', 'bskel feature init', 'bskel contract emit', 'bskel contract waive',
|
|
83
|
+
'bskel gate force', 'bskel dependency declare', 'bskel dependency remove', 'bskel handles emit',
|
|
84
|
+
'bskel handles plan', 'bskel stack apply', 'bskel observe emit', 'bskel observe import',
|
|
85
|
+
'bskel patch propose', 'bskel patch approve', 'bskel patch apply', 'bskel patch rollback',
|
|
86
|
+
];
|
|
87
|
+
|
|
88
|
+
// Exported (not just inlined into action()) so lib/workflow.mjs's own test suite can assert the
|
|
89
|
+
// classification directly, without needing a full end-to-end fixture that drives a feature all the
|
|
90
|
+
// way to a stale `conformance` gate just to observe one boolean.
|
|
91
|
+
export function isMutatingCommand(command) {
|
|
92
|
+
return MUTATING_PREFIXES.some((p) => command.startsWith(p));
|
|
93
|
+
}
|
|
66
94
|
|
|
67
95
|
function action(command, reason) {
|
|
68
|
-
return { command, reason, mutating:
|
|
96
|
+
return { command, reason, mutating: isMutatingCommand(command) };
|
|
69
97
|
}
|
|
70
98
|
|
|
71
99
|
// featureId === null means "repo scope only" -- feature-scoped gates (scan/contract/handles)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "backend-skeleton",
|
|
3
|
-
"version": "1.0.0
|
|
3
|
+
"version": "1.0.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Deterministic gate layer for AI-assisted backend changes -- blocks brownfield collisions and contract/handle drift via disk-hash checks before code ships. Scaffolding codegen included (Java/Spring, Python/FastAPI, TypeScript/Express).",
|
|
6
6
|
"license": "AGPL-3.0-or-later",
|
|
@@ -38,11 +38,14 @@
|
|
|
38
38
|
"test:java-compile": "node scripts/java-compile-smoke.mjs",
|
|
39
39
|
"test:python-import": "node scripts/python-import-smoke.mjs",
|
|
40
40
|
"test:db-introspect": "node scripts/db-introspect-smoke.mjs",
|
|
41
|
+
"test:ddl-apply": "node scripts/ddl-apply-smoke.mjs",
|
|
42
|
+
"test:cross-feature-fk": "node scripts/cross-feature-fk-smoke.mjs",
|
|
41
43
|
"test:java-integration": "node scripts/java-integration-smoke.mjs",
|
|
42
44
|
"test:java-ast": "node scripts/java-ast-smoke.mjs",
|
|
43
45
|
"test:python-integration": "node scripts/python-integration-smoke.mjs",
|
|
44
46
|
"test:typescript-compile": "node scripts/typescript-typecheck-smoke.mjs",
|
|
45
|
-
"test:spring-initializr-canary": "node scripts/spring-initializr-canary.mjs"
|
|
47
|
+
"test:spring-initializr-canary": "node scripts/spring-initializr-canary.mjs",
|
|
48
|
+
"test:all-smoke": "npm test && npm run test:pack && npm run test:java-compile && npm run test:java-integration && npm run test:python-import && npm run test:python-integration && npm run test:db-introspect && npm run test:ddl-apply && npm run test:cross-feature-fk && npm run test:java-ast && npm run test:typescript-compile"
|
|
46
49
|
},
|
|
47
50
|
"dependencies": {
|
|
48
51
|
"ajv": "^8.20.0",
|
|
@@ -141,6 +141,12 @@ function extractEntity(text, filePath) {
|
|
|
141
141
|
return {
|
|
142
142
|
className: classDecl ? classDecl.name : path.basename(filePath, '.java'),
|
|
143
143
|
table: tableMatch ? tableMatch[1] : null,
|
|
144
|
+
// D-cross-feature-collision: this adapter never GUESSES a table name -- `table: null` above
|
|
145
|
+
// already means "no explicit @Table(name=...)", so tableSource is 'explicit' whenever
|
|
146
|
+
// `table` is non-null and null otherwise. Added only for a consistent field shape with the
|
|
147
|
+
// two adapters that DO guess (python-fastapi/typescript-express) -- cross-feature collision
|
|
148
|
+
// detection reads this field, not each adapter's own null-vs-guessed convention.
|
|
149
|
+
tableSource: tableMatch ? 'explicit' : null,
|
|
144
150
|
idField: idFieldMatch ? idFieldMatch[1] : null,
|
|
145
151
|
file: filePath,
|
|
146
152
|
line: classDecl ? lineNumberAt(text, classDecl.index) : null,
|
|
@@ -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
|
}
|
|
@@ -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
|
+
}
|