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
@@ -0,0 +1,358 @@
1
+ // D-http-serving-layer: a native node:http server (zero new dependencies -- this package ships no
2
+ // web framework) exposing read/write JSON endpoints over the field-dependency data model, plus a
3
+ // minimal bundled sanity-check UI page. Every route handler calls straight into the SAME lib/
4
+ // functions bin/bskel.mjs's CLI commands call (declareDependency/removeDependency/
5
+ // buildDependencyListReport/listFeatures/computeWorkflowState) -- there is no second copy of any
6
+ // business logic here, only HTTP transport (routing, CORS, JSON (de)serialization). See
7
+ // DECISIONS.md's D-http-serving-layer for the full design and the CORS-asymmetry security reasoning.
8
+ import http from 'node:http';
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+ import { fileURLToPath } from 'node:url';
12
+ import { listFeatures } from './featurelifecycle.mjs';
13
+ import { computeWorkflowState } from './workflow.mjs';
14
+ import { isValidFeatureId } from './featureid.mjs';
15
+ import {
16
+ buildDependencyListReport, buildDependencyGraph, declareDependency, removeDependency, DependencyOperationError,
17
+ } from './field-dependencies.mjs';
18
+ import { introspectSchema, describeConnectionError } from '../scanners/db/introspect.mjs';
19
+ import {
20
+ proposeTransaction, approveTransaction, applyTransaction, rollbackTransaction, loadTransaction, listTransactions, transactionsDir,
21
+ } from './patch-transactions.mjs';
22
+ import { getPatchKind, replanTransaction, PATCH_KIND_NAMES } from './patch-kinds.mjs';
23
+ import { requireNamedGate, passNamedGate, EXIT } from './gates.mjs';
24
+ import { signPayload } from './attest.mjs';
25
+ import { writeFileAtomic } from './fsutil.mjs';
26
+
27
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
28
+ const UI_HTML_PATH = path.join(__dirname, 'serve-ui.html');
29
+
30
+ // GET/HEAD/their own OPTIONS preflight get the wildcard; POST/DELETE never do -- an unrestricted
31
+ // Access-Control-Allow-Origin on a mutating route would let any website a user's browser has open
32
+ // silently mutate their repo via a background fetch. The bundled UI page (served BY this same
33
+ // server) is same-origin and completely unaffected -- CORS only ever applies cross-origin.
34
+ const CORS_METHODS = new Set(['GET', 'HEAD']);
35
+
36
+ function sendJson(res, status, body, { cors = false } = {}) {
37
+ const payload = JSON.stringify(body, null, 2);
38
+ const headers = { 'Content-Type': 'application/json; charset=utf-8', 'Content-Length': Buffer.byteLength(payload) };
39
+ if (cors) headers['Access-Control-Allow-Origin'] = '*';
40
+ res.writeHead(status, headers);
41
+ res.end(payload);
42
+ }
43
+
44
+ function sendError(res, err, { cors = false } = {}) {
45
+ if (err instanceof DependencyOperationError) {
46
+ sendJson(res, err.httpStatus, { error: err.message, reasonCode: err.reasonCode }, { cors });
47
+ return;
48
+ }
49
+ // D-http-serving-layer: everything user-input-shaped throws DependencyOperationError (see
50
+ // requireValidFeatureIdOr400 in lib/field-dependencies.mjs) -- reaching here means a genuine,
51
+ // unexpected failure (e.g. a filesystem error), so 500 is the honest answer, not a guess.
52
+ sendJson(res, 500, { error: err.message ?? 'internal error' }, { cors });
53
+ }
54
+
55
+ // Bounds the body a single request can force this process to buffer -- a local,
56
+ // single-user dev server still shouldn't let an unbounded body exhaust memory.
57
+ const MAX_BODY_BYTES = 1_000_000;
58
+
59
+ function readJsonBody(req) {
60
+ return new Promise((resolve, reject) => {
61
+ const chunks = [];
62
+ let size = 0;
63
+ req.on('data', (chunk) => {
64
+ size += chunk.length;
65
+ if (size > MAX_BODY_BYTES) {
66
+ reject(new DependencyOperationError(`request body exceeds ${MAX_BODY_BYTES} bytes`, { httpStatus: 413, reasonCode: 'BAD_ARGS' }));
67
+ req.destroy();
68
+ return;
69
+ }
70
+ chunks.push(chunk);
71
+ });
72
+ req.on('end', () => {
73
+ if (chunks.length === 0) { resolve({}); return; }
74
+ let parsed;
75
+ try {
76
+ parsed = JSON.parse(Buffer.concat(chunks).toString('utf8'));
77
+ } catch {
78
+ reject(new DependencyOperationError('request body is not valid JSON', { httpStatus: 400, reasonCode: 'BAD_ARGS' }));
79
+ return;
80
+ }
81
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
82
+ reject(new DependencyOperationError('request body must be a JSON object', { httpStatus: 400, reasonCode: 'BAD_ARGS' }));
83
+ return;
84
+ }
85
+ resolve(parsed);
86
+ });
87
+ req.on('error', reject);
88
+ });
89
+ }
90
+
91
+ let uiHtmlCache = null;
92
+ function serveUiPage(res) {
93
+ if (uiHtmlCache === null) uiHtmlCache = fs.readFileSync(UI_HTML_PATH, 'utf8');
94
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Content-Length': Buffer.byteLength(uiHtmlCache) });
95
+ res.end(uiHtmlCache);
96
+ }
97
+
98
+ const FEATURE_STATUS_RE = /^\/api\/features\/([^/]+)\/status$/;
99
+ const FEATURE_DEPENDENCIES_RE = /^\/api\/features\/([^/]+)\/dependencies$/;
100
+
101
+ // D-ddl-apply: every route below is gated behind `dbConfig` being non-null (only true when
102
+ // `bskel serve --database-url-env <NAME>` was given) -- a plain `bskel serve` never even matches
103
+ // these paths (falls through to the existing 404), matching --host's own "safe default, explicit
104
+ // override" convention. NONE of these routes ever get CORS, GET included -- a deliberate deviation
105
+ // from CORS_METHODS' plain-GET-gets-wildcard convention above: live schema/table names and raw SQL
106
+ // text are not the same sensitivity class as the git-tracked JSON dependency record that
107
+ // convention was scoped against (see DECISIONS.md D-ddl-apply).
108
+ const DB_SCHEMA_RE = /^\/api\/db\/schema$/;
109
+ const PATCH_TRANSACTIONS_RE = /^\/api\/features\/([^/]+)\/patch-transactions$/;
110
+ const PATCH_PROPOSE_RE = /^\/api\/features\/([^/]+)\/patch-transactions\/propose$/;
111
+ const PATCH_TXN_ACTION_RE = /^\/api\/features\/([^/]+)\/patch-transactions\/([^/]+)\/(approve|apply|rollback)$/;
112
+ const TRANSACTION_ID_RE = /^pt-[0-9a-f-]{36}$/;
113
+
114
+ function paramsAndSourceFromProposeBody(kind, body) {
115
+ if (kind === 'config-apply') {
116
+ if (!body.choice || !body.target) {
117
+ throw new DependencyOperationError('propose --kind config-apply requires {choice, target}', { httpStatus: 400, reasonCode: 'BAD_ARGS' });
118
+ }
119
+ return { params: { choice: body.choice, target: body.target }, source: { choice: body.choice } };
120
+ }
121
+ if (kind === 'ddl-apply') {
122
+ if (!body.databaseUrlEnv || !body.sqlText) {
123
+ throw new DependencyOperationError('propose --kind ddl-apply requires {databaseUrlEnv, sqlText}', { httpStatus: 400, reasonCode: 'BAD_ARGS' });
124
+ }
125
+ const schema = body.schema || 'public';
126
+ return { params: { databaseUrlEnv: body.databaseUrlEnv, schema, sqlText: body.sqlText }, source: { database_url_env: body.databaseUrlEnv, schema } };
127
+ }
128
+ throw new DependencyOperationError(`unknown kind "${kind}" -- known kinds: ${PATCH_KIND_NAMES.join(', ')}`, { httpStatus: 400, reasonCode: 'BAD_ARGS' });
129
+ }
130
+
131
+ // D-ddl-apply audit trail: optional (only when `--sign-key` was given at `bskel serve` startup),
132
+ // server-held-key signing of every propose/approve/apply/rollback-refusal touching a `ddl-apply`
133
+ // transaction -- the browser can never hold a private key, so the server process itself signs, per
134
+ // this feature's own design (see DECISIONS.md D-ddl-apply). lib/attest.mjs itself needs no changes
135
+ // -- it's already genuinely payload-shape-agnostic. Deliberately best-effort/non-fatal: a signing
136
+ // failure must not roll back or hide an otherwise-successful propose/approve/apply.
137
+ let signKeyCache = null;
138
+ function loadSignKey(signKeyPath) {
139
+ if (signKeyCache === null) signKeyCache = fs.readFileSync(signKeyPath, 'utf8');
140
+ return signKeyCache;
141
+ }
142
+
143
+ function maybeSignStep(root, dbConfig, txn, step) {
144
+ if (!dbConfig?.signKeyPath || txn.kind !== 'ddl-apply') return;
145
+ try {
146
+ const privateKeyPem = loadSignKey(dbConfig.signKeyPath);
147
+ const payload = {
148
+ transaction_id: txn.transaction_id,
149
+ feature_id: txn.feature_id,
150
+ kind: txn.kind,
151
+ status: txn.status,
152
+ sql_text: txn.target.sql_text,
153
+ schema_hash: txn.preimage.region_hash,
154
+ reason: txn.approval?.reason ?? txn.rollback?.reason ?? null,
155
+ at: new Date().toISOString(),
156
+ };
157
+ const signature = signPayload(payload, privateKeyPem);
158
+ const sigPath = path.join(transactionsDir(root, txn.feature_id), `${txn.transaction_id}.${step}.sig.json`);
159
+ writeFileAtomic(sigPath, `${JSON.stringify({ payload, signature: { algorithm: 'ed25519', value: signature } }, null, 2)}\n`);
160
+ } catch (err) {
161
+ console.error(`warning: could not sign patch-transaction step "${step}" for "${txn.transaction_id}": ${err.message}`);
162
+ }
163
+ }
164
+
165
+ function requirePreflightPassedHttp(root) {
166
+ const result = requireNamedGate(root, 'preflight', null);
167
+ if (result.code !== EXIT.PASS) {
168
+ throw new DependencyOperationError(`blocked: \`preflight\` gate is ${result.status} -- run \`bskel preflight\` first`, { httpStatus: 409, reasonCode: 'GATE_NOT_PASSED' });
169
+ }
170
+ }
171
+
172
+ async function handleDbRoutes(root, dbConfig, method, pathname, req, res) {
173
+ if (method === 'GET' && DB_SCHEMA_RE.test(pathname)) {
174
+ const connectionString = process.env[dbConfig.databaseUrlEnv];
175
+ let live;
176
+ try {
177
+ live = await introspectSchema({ connectionString, schema: dbConfig.schema });
178
+ } catch (err) {
179
+ throw new DependencyOperationError(`could not introspect the live database: ${describeConnectionError(err)}`, { httpStatus: 502, reasonCode: 'REFRESH_FAILED' });
180
+ }
181
+ sendJson(res, 200, live, { cors: false });
182
+ return true;
183
+ }
184
+
185
+ const listMatch = pathname.match(PATCH_TRANSACTIONS_RE);
186
+ if (method === 'GET' && listMatch) {
187
+ const featureId = decodeURIComponent(listMatch[1]);
188
+ if (!isValidFeatureId(featureId)) { sendJson(res, 400, { error: `invalid feature id "${featureId}"` }, { cors: false }); return true; }
189
+ sendJson(res, 200, { feature: featureId, transactions: listTransactions(root, featureId) }, { cors: false });
190
+ return true;
191
+ }
192
+
193
+ const proposeMatch = pathname.match(PATCH_PROPOSE_RE);
194
+ if (method === 'POST' && proposeMatch) {
195
+ const featureId = decodeURIComponent(proposeMatch[1]);
196
+ if (!isValidFeatureId(featureId)) { sendJson(res, 400, { error: `invalid feature id "${featureId}"` }, { cors: false }); return true; }
197
+ const body = await readJsonBody(req);
198
+ const kind = body.kind || 'config-apply';
199
+ const { params, source } = paramsAndSourceFromProposeBody(kind, body);
200
+ const plan = await getPatchKind(kind).planFresh(root, params);
201
+ const txn = proposeTransaction(root, featureId, kind, plan, source);
202
+ maybeSignStep(root, dbConfig, txn, 'propose');
203
+ sendJson(res, 201, txn, { cors: false });
204
+ return true;
205
+ }
206
+
207
+ const actionMatch = pathname.match(PATCH_TXN_ACTION_RE);
208
+ if (method === 'POST' && actionMatch) {
209
+ const featureId = decodeURIComponent(actionMatch[1]);
210
+ const transactionId = decodeURIComponent(actionMatch[2]);
211
+ const action = actionMatch[3];
212
+ if (!isValidFeatureId(featureId)) { sendJson(res, 400, { error: `invalid feature id "${featureId}"` }, { cors: false }); return true; }
213
+ if (!TRANSACTION_ID_RE.test(transactionId)) { sendJson(res, 400, { error: `invalid transaction id "${transactionId}"` }, { cors: false }); return true; }
214
+ const txn = loadTransaction(root, featureId, transactionId);
215
+ if (!txn) { sendJson(res, 404, { error: `no patch transaction "${transactionId}" for feature "${featureId}"` }, { cors: false }); return true; }
216
+ const body = await readJsonBody(req);
217
+
218
+ if (action === 'approve') {
219
+ if (!body.reason || !String(body.reason).trim()) {
220
+ throw new DependencyOperationError('approve requires {reason}', { httpStatus: 400, reasonCode: 'BAD_ARGS' });
221
+ }
222
+ const freshPlan = await replanTransaction(root, txn);
223
+ const updated = approveTransaction(root, featureId, transactionId, body.reason, freshPlan);
224
+ maybeSignStep(root, dbConfig, updated, 'approve');
225
+ sendJson(res, 200, updated, { cors: false });
226
+ return true;
227
+ }
228
+ if (action === 'apply') {
229
+ // D-ddl-apply: {confirm} must exactly equal getPatchKind(kind).requiredConfirmValue(txn)
230
+ // -- the transaction id for a non-drop ddl-apply transaction, or the sorted, comma-joined
231
+ // dropped-table name(s) for one that drops a table (null/never-checked for config-apply).
232
+ // The same human-factors friction cmdPatchApply enforces at the CLI boundary, checked
233
+ // here BEFORE applyTransaction() is ever called, layered on top of (not instead of) the
234
+ // engine's own preimage-hash TOCTOU re-check.
235
+ const requiredConfirm = getPatchKind(txn.kind).requiredConfirmValue(txn);
236
+ if (requiredConfirm !== null && body.confirm !== requiredConfirm) {
237
+ throw new DependencyOperationError(`apply requires {confirm} to exactly equal ${JSON.stringify(requiredConfirm)} for kind "${txn.kind}"`, { httpStatus: 400, reasonCode: 'BAD_ARGS' });
238
+ }
239
+ requirePreflightPassedHttp(root);
240
+ const freshPlan = await replanTransaction(root, txn);
241
+ const updated = await applyTransaction(root, featureId, transactionId, freshPlan, getPatchKind(txn.kind).apply);
242
+ maybeSignStep(root, dbConfig, updated, 'apply');
243
+ passNamedGate(root, 'patch_transactions', featureId, { transaction_id: updated.transaction_id, kind: updated.kind, applied_at: updated.apply.at });
244
+ sendJson(res, 200, updated, { cors: false });
245
+ return true;
246
+ }
247
+ if (action === 'rollback') {
248
+ if (!body.reason || !String(body.reason).trim()) {
249
+ throw new DependencyOperationError('rollback requires {reason}', { httpStatus: 400, reasonCode: 'BAD_ARGS' });
250
+ }
251
+ requirePreflightPassedHttp(root);
252
+ const updated = await rollbackTransaction(root, featureId, transactionId, body.reason, { force: Boolean(body.force) }, getPatchKind(txn.kind).rollback);
253
+ maybeSignStep(root, dbConfig, updated, 'rollback');
254
+ passNamedGate(root, 'patch_transactions', featureId, { transaction_id: updated.transaction_id, kind: updated.kind, rolled_back_at: updated.rollback.at });
255
+ sendJson(res, 200, updated, { cors: false });
256
+ return true;
257
+ }
258
+ }
259
+
260
+ return false;
261
+ }
262
+
263
+ function isDbRoutePath(pathname) {
264
+ return DB_SCHEMA_RE.test(pathname) || PATCH_TRANSACTIONS_RE.test(pathname) || PATCH_PROPOSE_RE.test(pathname) || PATCH_TXN_ACTION_RE.test(pathname);
265
+ }
266
+
267
+ async function handleRequest(root, req, res, dbConfig) {
268
+ let url;
269
+ try {
270
+ url = new URL(req.url, `http://${req.headers.host ?? 'localhost'}`);
271
+ } catch {
272
+ sendJson(res, 400, { error: 'invalid request URL' });
273
+ return;
274
+ }
275
+ const { pathname } = url;
276
+ const method = req.method ?? 'GET';
277
+ // D-ddl-apply: CORS withheld unconditionally on every DB/patch-transaction route, GET included
278
+ // -- see the comment above DB_SCHEMA_RE for why this deviates from CORS_METHODS' plain-GET-
279
+ // gets-wildcard convention.
280
+ const cors = CORS_METHODS.has(method) && !isDbRoutePath(pathname);
281
+
282
+ if (method === 'OPTIONS') {
283
+ const headers = { 'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type' };
284
+ // Only ever grant a preflight for GET-eligible routes -- a POST/DELETE preflight gets no
285
+ // Access-Control-Allow-Origin, so the browser refuses to send the real cross-origin write.
286
+ // DB/patch-transaction routes never grant it either, regardless of method (see above).
287
+ if ((pathname === '/' || pathname.startsWith('/api/')) && !isDbRoutePath(pathname)) headers['Access-Control-Allow-Origin'] = '*';
288
+ res.writeHead(204, headers);
289
+ res.end();
290
+ return;
291
+ }
292
+
293
+ try {
294
+ if (method === 'GET' && pathname === '/') { serveUiPage(res); return; }
295
+ if (method === 'GET' && pathname === '/api/health') { sendJson(res, 200, { status: 'ok', repo: root }, { cors }); return; }
296
+ if (method === 'GET' && pathname === '/api/features') { sendJson(res, 200, { features: listFeatures(root) }, { cors }); return; }
297
+ if (method === 'GET' && pathname === '/api/graph') { sendJson(res, 200, buildDependencyGraph(root), { cors }); return; }
298
+
299
+ const statusMatch = pathname.match(FEATURE_STATUS_RE);
300
+ if (method === 'GET' && statusMatch) {
301
+ const featureId = decodeURIComponent(statusMatch[1]);
302
+ if (!isValidFeatureId(featureId)) { sendJson(res, 400, { error: `invalid feature id "${featureId}"` }, { cors }); return; }
303
+ sendJson(res, 200, computeWorkflowState(root, featureId), { cors });
304
+ return;
305
+ }
306
+
307
+ const depsMatch = pathname.match(FEATURE_DEPENDENCIES_RE);
308
+ if (depsMatch) {
309
+ const featureId = decodeURIComponent(depsMatch[1]);
310
+ if (!isValidFeatureId(featureId)) { sendJson(res, 400, { error: `invalid feature id "${featureId}"` }, { cors }); return; }
311
+
312
+ if (method === 'GET') { sendJson(res, 200, buildDependencyListReport(root, featureId), { cors }); return; }
313
+ if (method === 'POST') {
314
+ const body = await readJsonBody(req);
315
+ const result = declareDependency(root, { feature: featureId, ...body });
316
+ sendJson(res, 201, result); // no CORS -- mutating route, same-origin only
317
+ return;
318
+ }
319
+ if (method === 'DELETE') {
320
+ const body = await readJsonBody(req);
321
+ const result = removeDependency(root, { feature: featureId, ...body });
322
+ sendJson(res, 200, result); // no CORS -- mutating route
323
+ return;
324
+ }
325
+ }
326
+
327
+ // D-ddl-apply: only reachable at all when `bskel serve --database-url-env` was given --
328
+ // otherwise dbConfig is null and every one of these paths simply falls through to the same
329
+ // 404 below as any other unmatched route (not a 403 -- matches --host's own "safe default,
330
+ // explicit override" convention: a plain `bskel serve` behaves exactly as it did before
331
+ // this feature existed).
332
+ if (dbConfig && (await handleDbRoutes(root, dbConfig, method, pathname, req, res))) return;
333
+
334
+ sendJson(res, 404, { error: `not found: ${method} ${pathname}` }, { cors });
335
+ } catch (err) {
336
+ sendError(res, err, { cors });
337
+ }
338
+ }
339
+
340
+ // Default host 127.0.0.1 (loopback only) -- explicit --host opt-in required to expose beyond it,
341
+ // matching this project's established "safe default, explicit override" convention (e.g.
342
+ // --enforce-registry). Returns once the server has actually bound (server.address() is real), not
343
+ // merely once listen() was called -- callers (bin/bskel.mjs's cmdServe, tests) need the REAL bound
344
+ // port when 0 was requested. `dbConfig` (null by default) opts into the D-ddl-apply DB/patch-
345
+ // transaction routes -- see handleDbRoutes() above.
346
+ export function createHttpServer(root, { host = '127.0.0.1', port = 4747, dbConfig = null } = {}) {
347
+ const server = http.createServer((req, res) => {
348
+ handleRequest(root, req, res, dbConfig).catch((err) => sendError(res, err));
349
+ });
350
+ return new Promise((resolve, reject) => {
351
+ server.once('error', reject);
352
+ server.listen(port, host, () => {
353
+ server.removeListener('error', reject);
354
+ const addr = server.address();
355
+ resolve({ server, host: addr.address, port: addr.port, url: `http://${addr.address}:${addr.port}` });
356
+ });
357
+ });
358
+ }
package/lib/lock.mjs CHANGED
@@ -6,13 +6,21 @@
6
6
  // dependency, and needs no cleanup daemon -- a crashed holder just leaves a directory a human can
7
7
  // `rm -rf`, which the timeout error message below points at directly.
8
8
  //
9
- // Deliberately SYNCHRONOUS, not Promise-based: setGate() (and every passGate/awaitDispositionGate/
10
- // forceGate/passNamedGate caller above it, all the way up through bin/bskel.mjs's cmdXxx functions
11
- // and main()) is synchronous today. Making the lock async would force `async`/`await` through that
12
- // entire call chain for a correctness property that doesn't need it -- this is a short-lived CLI
13
- // process, not a server, so blocking the (single) event loop for up to a few seconds while polling
14
- // for a lock is not a real cost. `Atomics.wait` gives a genuine synchronous blocking sleep on
15
- // Node's main thread (confirmed directly: not restricted to worker threads).
9
+ // withLockSync is deliberately SYNCHRONOUS, not Promise-based: setGate() (and every
10
+ // passGate/awaitDispositionGate/forceGate/passNamedGate caller above it, all the way up through
11
+ // bin/bskel.mjs's cmdXxx functions and main()) is synchronous today. Making the lock async would
12
+ // force `async`/`await` through that entire call chain for a correctness property that doesn't
13
+ // need it -- this is a short-lived CLI process, not a server, so blocking the (single) event loop
14
+ // for up to a few seconds while polling for a lock is not a real cost. `Atomics.wait` gives a
15
+ // genuine synchronous blocking sleep on Node's main thread (confirmed directly: not restricted to
16
+ // worker threads).
17
+ //
18
+ // D-ddl-apply added withLockAsync -- a genuinely separate acquire loop (a non-blocking
19
+ // `await new Promise(setTimeout)` sleep, not Atomics.wait), needed once `bskel serve` became a
20
+ // long-running process handling concurrent requests: withLockSync's synchronous, event-loop-
21
+ // blocking wait is fine for a short-lived CLI process racing another short-lived CLI process, but
22
+ // deadlocks a server racing itself (see withLockAsync's own comment below for the real bug this
23
+ // closes, found live while writing this item's own concurrency test).
16
24
  import fs from 'node:fs';
17
25
  import path from 'node:path';
18
26
 
@@ -38,31 +46,76 @@ function tryAcquire(lockPath) {
38
46
  }
39
47
  }
40
48
 
49
+ function timeoutMessage(lockName, timeoutMs, lockPath) {
50
+ return `could not acquire lock "${lockName}" within ${timeoutMs}ms (${lockPath} already exists) -- ` +
51
+ 'another bskel process may be running against this repo, or a previous run crashed and left ' +
52
+ `this lock behind. If nothing else is running, remove ${lockPath} and try again.`;
53
+ }
54
+
41
55
  // Runs `fn` with an exclusive lock named `lockName`, scoped to this repo. Retries acquisition
42
56
  // with a short fixed backoff until `timeoutMs` elapses, then throws with a message naming the
43
57
  // exact stale-lock path to remove -- this tool is a short-lived CLI, not a daemon, so "another
44
58
  // bskel process is stuck or crashed" is the only realistic cause, and the fix is always the same
45
59
  // (confirm nothing else is running, then delete the lock directory).
46
- export function withLockSync(repoRoot, lockName, fn, { timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
60
+ function acquireOrThrow(repoRoot, lockName, timeoutMs) {
47
61
  const dir = locksDir(repoRoot);
48
62
  fs.mkdirSync(dir, { recursive: true });
49
63
  const lockPath = path.join(dir, `${lockName}.lock`);
50
64
 
51
65
  const deadline = Date.now() + timeoutMs;
52
66
  while (!tryAcquire(lockPath)) {
53
- if (Date.now() >= deadline) {
54
- throw new Error(
55
- `could not acquire lock "${lockName}" within ${timeoutMs}ms (${lockPath} already exists) -- ` +
56
- 'another bskel process may be running against this repo, or a previous run crashed and left ' +
57
- `this lock behind. If nothing else is running, remove ${lockPath} and try again.`,
58
- );
59
- }
67
+ if (Date.now() >= deadline) throw new Error(timeoutMessage(lockName, timeoutMs, lockPath));
60
68
  sleepSync(RETRY_INTERVAL_MS);
61
69
  }
70
+ return lockPath;
71
+ }
62
72
 
73
+ export function withLockSync(repoRoot, lockName, fn, { timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
74
+ const lockPath = acquireOrThrow(repoRoot, lockName, timeoutMs);
63
75
  try {
64
76
  return fn();
65
77
  } finally {
66
78
  fs.rmSync(lockPath, { recursive: true, force: true });
67
79
  }
68
80
  }
81
+
82
+ // D-ddl-apply: the async counterpart, needed because a live DDL apply/rollback executor is
83
+ // inherently a Promise-returning `pg` round trip.
84
+ //
85
+ // Deliberately does NOT reuse acquireOrThrow()'s synchronous poll loop -- a real bug found live
86
+ // while writing this item's own concurrency test, not assumed: acquireOrThrow()'s wait step
87
+ // (sleepSync -> Atomics.wait) BLOCKS THE ENTIRE EVENT LOOP synchronously between mkdir attempts.
88
+ // Called from an async function, that's fatal -- while a waiter spins inside that synchronous
89
+ // loop, nothing else on the event loop can run, INCLUDING the current lock holder's own pending
90
+ // I/O (e.g. the `pg` query it's awaiting), so the holder can never finish and release the lock the
91
+ // waiter is spinning for. A single-process `bskel serve` handling two concurrent requests that both
92
+ // touch the 'state' lock would deadlock the whole server, not just wait. This async acquire loop
93
+ // uses `await new Promise(setTimeout)` instead -- a real, non-blocking sleep that yields control
94
+ // back to the event loop on every retry, so concurrent I/O (including the current holder's) can
95
+ // keep making progress while this call waits its turn.
96
+ async function acquireAsyncOrThrow(repoRoot, lockName, timeoutMs) {
97
+ const dir = locksDir(repoRoot);
98
+ fs.mkdirSync(dir, { recursive: true });
99
+ const lockPath = path.join(dir, `${lockName}.lock`);
100
+
101
+ const deadline = Date.now() + timeoutMs;
102
+ while (!tryAcquire(lockPath)) {
103
+ if (Date.now() >= deadline) throw new Error(timeoutMessage(lockName, timeoutMs, lockPath));
104
+ await new Promise((resolve) => setTimeout(resolve, RETRY_INTERVAL_MS));
105
+ }
106
+ return lockPath;
107
+ }
108
+
109
+ // withLockSync's own `finally { fn() has already returned }` release timing would be WRONG for an
110
+ // async callback: if a caller passed an async fn to withLockSync, `finally` would fire (releasing
111
+ // the lock) the instant the Promise was RETURNED, not once it RESOLVED -- releasing the lock before
112
+ // the actual DB write even finishes. `await fn()` inside this function's own try means `finally`
113
+ // cannot run until the Promise settles, making that mistake structurally impossible.
114
+ export async function withLockAsync(repoRoot, lockName, fn, { timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
115
+ const lockPath = await acquireAsyncOrThrow(repoRoot, lockName, timeoutMs);
116
+ try {
117
+ return await fn();
118
+ } finally {
119
+ fs.rmSync(lockPath, { recursive: true, force: true });
120
+ }
121
+ }
@@ -0,0 +1,52 @@
1
+ // D-ddl-apply: the single, shared per-kind dispatch table for lib/patch-transactions.mjs's
2
+ // propose/approve/apply/rollback lifecycle -- imported by both bin/bskel.mjs (CLI) and
3
+ // lib/http-server.mjs (the new DDL routes), so there is exactly one copy of "which planner/
4
+ // executor goes with which kind" (matching D-http-serving-layer's own "no second copy of business
5
+ // logic" rule). Before this module existed, bin/bskel.mjs's cmdPatchPropose and
6
+ // replanFromTransaction both hardcoded a direct call to planConfigApply() -- this generalizes that
7
+ // into a real lookup, without lib/patch-transactions.mjs's own engine ever importing a kind-specific
8
+ // module or branching on `kind` itself.
9
+ import { loadCatalogEntry } from '../stack/apply.mjs';
10
+ import { planConfigApply, executeConfigApply, executeConfigRollback } from '../stack/config-apply.mjs';
11
+ import { planDdlApply, executeDdlApply, executeDdlRollback, requiredConfirmValue } from '../scanners/db/ddl-apply.mjs';
12
+
13
+ const PATCH_KINDS = {
14
+ 'config-apply': {
15
+ // params: {choice, target}. planFresh loads the catalog entry itself (never trusts a stored
16
+ // plan) so a catalog change is caught here too, not just by the engine's own region_hash
17
+ // TOCTOU check.
18
+ planFresh: (root, params) => planConfigApply(root, loadCatalogEntry(params.choice), params.target),
19
+ paramsFromTxn: (txn) => ({ choice: txn.source.choice, target: txn.target.file }),
20
+ apply: executeConfigApply,
21
+ rollback: executeConfigRollback,
22
+ // null == no confirm required at all -- config-apply never has, unchanged.
23
+ requiredConfirmValue: () => null,
24
+ },
25
+ 'ddl-apply': {
26
+ // params: {databaseUrlEnv, schema, sqlText}.
27
+ planFresh: (root, params) => planDdlApply(root, params),
28
+ paramsFromTxn: (txn) => ({ databaseUrlEnv: txn.source.database_url_env, schema: txn.source.schema, sqlText: txn.target.sql_text }),
29
+ apply: executeDdlApply,
30
+ rollback: executeDdlRollback,
31
+ // transaction id for a non-drop transaction; the sorted, comma-joined dropped-table name(s)
32
+ // for one that drops a table -- see requiredConfirmValue()'s own comment for why.
33
+ requiredConfirmValue,
34
+ },
35
+ };
36
+
37
+ export const PATCH_KIND_NAMES = Object.freeze(Object.keys(PATCH_KINDS));
38
+
39
+ export function getPatchKind(kind) {
40
+ const entry = PATCH_KINDS[kind];
41
+ if (!entry) throw new Error(`unknown patch-transaction kind "${kind}" -- known kinds: ${PATCH_KIND_NAMES.join(', ')}`);
42
+ return entry;
43
+ }
44
+
45
+ // Re-runs the SAME kind's planner fresh from the transaction's own recorded source/target --
46
+ // never trusts the stored plan -- so a stale preimage (the target moved since propose/approve) is
47
+ // caught by the engine's own region_hash comparison, AND the underlying reference (a catalog
48
+ // choice; a live DB connection) is re-validated as still resolving at all, in case IT changed.
49
+ export async function replanTransaction(root, txn) {
50
+ const kind = getPatchKind(txn.kind);
51
+ return kind.planFresh(root, kind.paramsFromTxn(txn));
52
+ }