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.
Files changed (45) hide show
  1. package/README.md +122 -12
  2. package/bin/bskel.mjs +564 -15
  3. package/contracts/emit.mjs +5 -1
  4. package/contracts/export.mjs +26 -3
  5. package/contracts/openapi.mjs +29 -3
  6. package/handles/_engine.mjs +79 -29
  7. package/handles/providers/java-spring/plan.mjs +22 -9
  8. package/handles/providers/java-spring/templates/HandleController.java.tmpl +7 -3
  9. package/handles/providers/java-spring/templates/ResourceResolver.java.tmpl +12 -6
  10. package/handles/providers/python-fastapi/templates/record_snapshot.py.tmpl +101 -39
  11. package/handles/providers/typescript-express/emit.mjs +23 -23
  12. package/handles/providers/typescript-express/observe.mjs +101 -0
  13. package/handles/providers/typescript-express/templates/contractCheck.ts.tmpl +136 -0
  14. package/handles/providers/typescript-express/templates/observeContract.ts.tmpl +146 -0
  15. package/handles/providers/typescript-express/templates/observedSchema.ts.tmpl +116 -0
  16. package/lib/attest.mjs +40 -0
  17. package/lib/cli.mjs +125 -3
  18. package/lib/cross-feature-collisions.mjs +286 -0
  19. package/lib/diff.mjs +35 -0
  20. package/lib/fsutil.mjs +7 -2
  21. package/lib/gate-definitions.mjs +85 -1
  22. package/lib/gates.mjs +5 -1
  23. package/lib/http-server.mjs +192 -6
  24. package/lib/lock.mjs +68 -15
  25. package/lib/patch-kinds.mjs +52 -0
  26. package/lib/patch-transactions.mjs +206 -0
  27. package/lib/serve-ui.html +211 -0
  28. package/lib/workflow.mjs +31 -3
  29. package/package.json +5 -2
  30. package/scanners/adapters/java-spring.mjs +6 -0
  31. package/scanners/adapters/python-fastapi.mjs +9 -1
  32. package/scanners/adapters/typescript-express.mjs +6 -0
  33. package/scanners/db/ddl-apply.mjs +253 -0
  34. package/scanners/db/introspect.mjs +61 -32
  35. package/scanners/db/migrations.mjs +73 -18
  36. package/schemas/cross-feature-report.schema.json +66 -0
  37. package/schemas/cross-feature-resolution.schema.json +28 -0
  38. package/schemas/gate-attestation.schema.json +22 -0
  39. package/schemas/gate-export.schema.json +58 -0
  40. package/schemas/patch-transaction.schema.json +182 -0
  41. package/schemas/scan-report.schema.json +6 -4
  42. package/schemas/stack-choice.schema.json +12 -1
  43. package/stack/apply.mjs +4 -1
  44. package/stack/catalog/ngrok.yml +8 -2
  45. package/stack/config-apply.mjs +168 -0
@@ -15,6 +15,14 @@ import { isValidFeatureId } from './featureid.mjs';
15
15
  import {
16
16
  buildDependencyListReport, buildDependencyGraph, declareDependency, removeDependency, DependencyOperationError,
17
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';
18
26
 
19
27
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
20
28
  const UI_HTML_PATH = path.join(__dirname, 'serve-ui.html');
@@ -90,7 +98,173 @@ function serveUiPage(res) {
90
98
  const FEATURE_STATUS_RE = /^\/api\/features\/([^/]+)\/status$/;
91
99
  const FEATURE_DEPENDENCIES_RE = /^\/api\/features\/([^/]+)\/dependencies$/;
92
100
 
93
- async function handleRequest(root, req, res) {
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) {
94
268
  let url;
95
269
  try {
96
270
  url = new URL(req.url, `http://${req.headers.host ?? 'localhost'}`);
@@ -100,13 +274,17 @@ async function handleRequest(root, req, res) {
100
274
  }
101
275
  const { pathname } = url;
102
276
  const method = req.method ?? 'GET';
103
- const cors = CORS_METHODS.has(method);
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);
104
281
 
105
282
  if (method === 'OPTIONS') {
106
283
  const headers = { 'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type' };
107
284
  // Only ever grant a preflight for GET-eligible routes -- a POST/DELETE preflight gets no
108
285
  // Access-Control-Allow-Origin, so the browser refuses to send the real cross-origin write.
109
- if (pathname === '/' || pathname.startsWith('/api/')) headers['Access-Control-Allow-Origin'] = '*';
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'] = '*';
110
288
  res.writeHead(204, headers);
111
289
  res.end();
112
290
  return;
@@ -146,6 +324,13 @@ async function handleRequest(root, req, res) {
146
324
  }
147
325
  }
148
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
+
149
334
  sendJson(res, 404, { error: `not found: ${method} ${pathname}` }, { cors });
150
335
  } catch (err) {
151
336
  sendError(res, err, { cors });
@@ -156,10 +341,11 @@ async function handleRequest(root, req, res) {
156
341
  // matching this project's established "safe default, explicit override" convention (e.g.
157
342
  // --enforce-registry). Returns once the server has actually bound (server.address() is real), not
158
343
  // merely once listen() was called -- callers (bin/bskel.mjs's cmdServe, tests) need the REAL bound
159
- // port when 0 was requested.
160
- export function createHttpServer(root, { host = '127.0.0.1', port = 4747 } = {}) {
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 } = {}) {
161
347
  const server = http.createServer((req, res) => {
162
- handleRequest(root, req, res).catch((err) => sendError(res, err));
348
+ handleRequest(root, req, res, dbConfig).catch((err) => sendError(res, err));
163
349
  });
164
350
  return new Promise((resolve, reject) => {
165
351
  server.once('error', reject);
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
+ }
@@ -0,0 +1,206 @@
1
+ // D-patch-transactions: the general, kind-agnostic content-addressed patch-transaction primitive.
2
+ // Generalizes lib/patch-approvals.mjs's (A3/D-patch-strategy) per-{resource,field} approval shape
3
+ // into a real lifecycle (propose -> approve -> apply -> rollback) with preimage-hash verification
4
+ // re-checked at every step and content-addressed rollback material saved BEFORE any edit is ever
5
+ // applied. Slice 1 wires exactly one `kind` ("config-apply", stack/config-apply.mjs) -- a later
6
+ // slice can add a second kind without touching this state machine at all.
7
+ //
8
+ // One file per transaction (specs/<featureId>/patch-transactions/<transaction_id>.json), not one
9
+ // growing array like patch-approvals.json -- each record carries real state transitions and
10
+ // references a potentially large rollback blob, so appending to one shared JSON array under a
11
+ // lock on every transition would be worse for both concurrency and the "content-addressed"
12
+ // framing this feature is named for. Blobs (specs/<featureId>/patch-transactions/blobs/<sha256>.
13
+ // blob) dedupe automatically: two transactions proposed against the same preimage produce the
14
+ // identical filename.
15
+ import fs from 'node:fs';
16
+ import path from 'node:path';
17
+ import { randomUUID } from 'node:crypto';
18
+ import { readJsonIfExists, writeFileAtomic, sha256String } from './fsutil.mjs';
19
+ import { specPath } from './paths.mjs';
20
+ import { validateAgainstSchema, formatSchemaErrors } from './schema-validate.mjs';
21
+ import { withLockSync, withLockAsync } from './lock.mjs';
22
+
23
+ const TRANSACTION_SCHEMA = 'sbf.patch-transaction/1';
24
+
25
+ export function transactionsDir(root, featureId) {
26
+ return specPath(root, featureId, 'patch-transactions');
27
+ }
28
+
29
+ export function blobsDir(root, featureId) {
30
+ return path.join(transactionsDir(root, featureId), 'blobs');
31
+ }
32
+
33
+ export function transactionPath(root, featureId, transactionId) {
34
+ return path.join(transactionsDir(root, featureId), `${transactionId}.json`);
35
+ }
36
+
37
+ export function blobPath(root, featureId, hash) {
38
+ return path.join(blobsDir(root, featureId), `${hash}.blob`);
39
+ }
40
+
41
+ export function newTransactionId() {
42
+ return `pt-${randomUUID()}`;
43
+ }
44
+
45
+ // Idempotent -- identical content always produces the identical path, so re-saving the same
46
+ // preimage (e.g. a second propose against a target nothing has touched since) is a harmless no-op
47
+ // write, never a duplicate.
48
+ export function saveBlob(root, featureId, content) {
49
+ const hash = sha256String(content);
50
+ writeFileAtomic(blobPath(root, featureId, hash), content);
51
+ return hash;
52
+ }
53
+
54
+ export function readBlob(root, featureId, hash) {
55
+ return fs.readFileSync(blobPath(root, featureId, hash), 'utf8');
56
+ }
57
+
58
+ export function loadTransaction(root, featureId, transactionId) {
59
+ const p = transactionPath(root, featureId, transactionId);
60
+ const parsed = readJsonIfExists(p);
61
+ if (parsed === null) return null;
62
+ const { ok, errors } = validateAgainstSchema('patch-transaction.schema.json', parsed);
63
+ if (!ok) {
64
+ throw new Error(`${p}: does not match schemas/patch-transaction.schema.json:\n${formatSchemaErrors(errors).join('\n')}`);
65
+ }
66
+ return parsed;
67
+ }
68
+
69
+ export function saveTransaction(root, featureId, txn) {
70
+ const { ok, errors } = validateAgainstSchema('patch-transaction.schema.json', txn);
71
+ if (!ok) {
72
+ throw new Error(`refusing to write an invalid patch transaction for "${featureId}":\n${formatSchemaErrors(errors).join('\n')}`);
73
+ }
74
+ writeFileAtomic(transactionPath(root, featureId, txn.transaction_id), `${JSON.stringify(txn, null, 2)}\n`);
75
+ return txn;
76
+ }
77
+
78
+ export function listTransactions(root, featureId) {
79
+ const dir = transactionsDir(root, featureId);
80
+ if (!fs.existsSync(dir)) return [];
81
+ return fs.readdirSync(dir)
82
+ .filter((name) => name.endsWith('.json'))
83
+ .map((name) => loadTransaction(root, featureId, name.slice(0, -'.json'.length)))
84
+ .filter(Boolean)
85
+ .sort((a, b) => a.created_at.localeCompare(b.created_at));
86
+ }
87
+
88
+ // `kindPlan` is whatever a kind-specific planner (e.g. stack/config-apply.mjs's planConfigApply())
89
+ // returns -- this module never inspects `kind`-specific fields beyond the ones every plan must
90
+ // carry (target/preimage/proposed_value/postcondition/originalContent/renderedContent).
91
+ // `source` is kind-specific, opaque bookkeeping this module never inspects -- for kind
92
+ // "config-apply" it's `{choice}`, the stack catalog choice id needed to re-locate the same
93
+ // config_check/apply block at approve/apply time (the record's own target.file alone is not
94
+ // enough, since a catalog choice is what the CLI's --choice flag resolves, not stored per-file).
95
+ export function proposeTransaction(root, featureId, kind, kindPlan, source) {
96
+ return withLockSync(root, 'state', () => {
97
+ const transactionId = newTransactionId();
98
+ // The blob is keyed by its OWN content hash, never trusted from kindPlan.preimage.file_hash
99
+ // blindly -- if a planner ever computed that field differently than a plain sha256 of the
100
+ // same bytes, silently trusting it here would save a rollback blob under the WRONG filename,
101
+ // making rollback impossible later. Fail loudly instead; this should never actually fire.
102
+ const blobHash = saveBlob(root, featureId, kindPlan.originalContent);
103
+ if (blobHash !== kindPlan.preimage.file_hash) {
104
+ throw new Error(`internal error: kindPlan.preimage.file_hash ("${kindPlan.preimage.file_hash}") does not match the original content's own hash ("${blobHash}") -- refusing to propose a transaction whose rollback blob would be unrecoverable`);
105
+ }
106
+ const txn = {
107
+ schema: TRANSACTION_SCHEMA,
108
+ transaction_id: transactionId,
109
+ feature_id: featureId,
110
+ kind,
111
+ source,
112
+ target: kindPlan.target,
113
+ preimage: kindPlan.preimage,
114
+ current_value: kindPlan.current_value,
115
+ proposed_value: kindPlan.proposed_value,
116
+ postcondition: kindPlan.postcondition,
117
+ status: 'proposed',
118
+ created_at: new Date().toISOString(),
119
+ };
120
+ return saveTransaction(root, featureId, txn);
121
+ });
122
+ }
123
+
124
+ class StaleTransactionError extends Error {
125
+ constructor(message) {
126
+ super(message);
127
+ this.name = 'StaleTransactionError';
128
+ }
129
+ }
130
+
131
+ function requireFreshPreimage(txn, freshKindPlan) {
132
+ if (freshKindPlan.preimage.region_hash !== txn.preimage.region_hash) {
133
+ throw new StaleTransactionError(
134
+ `transaction "${txn.transaction_id}"'s target has changed since it was proposed -- re-propose it against the current content`,
135
+ );
136
+ }
137
+ }
138
+
139
+ // `freshKindPlan` MUST come from re-running the same kind's planner against the CURRENT on-disk
140
+ // state, never from the stored transaction record -- this is what makes "re-verify preimage at
141
+ // every step" real rather than assumed.
142
+ export function approveTransaction(root, featureId, transactionId, reason, freshKindPlan) {
143
+ if (!reason || !reason.trim()) {
144
+ throw new Error('approving a patch transaction requires a reason -- every approval must be auditable');
145
+ }
146
+ return withLockSync(root, 'state', () => {
147
+ const txn = loadTransaction(root, featureId, transactionId);
148
+ if (!txn) throw new Error(`no patch transaction "${transactionId}" for feature "${featureId}"`);
149
+ requireFreshPreimage(txn, freshKindPlan);
150
+ txn.approval = { reason, at: new Date().toISOString() };
151
+ txn.status = 'approved';
152
+ return saveTransaction(root, featureId, txn);
153
+ });
154
+ }
155
+
156
+ // No --force escape here (mirrors cmdHandlesPatchApprove's permanent "a stale approval is
157
+ // rejected outright, never bypassed" precedent) -- a stale forward edit's collateral effects have
158
+ // never been re-verified, unlike a rollback restoring a known-good, git-recoverable prior state.
159
+ //
160
+ // D-ddl-apply: `executeApply(root, featureId, txn, freshKindPlan)` is an INJECTED, kind-specific
161
+ // async function that performs the actual mutation and returns the fields to merge into `txn.apply`
162
+ // (alongside `at`, added here uniformly). This module still never imports a kind-specific module
163
+ // and never branches on `kind` -- it only calls whatever executor its caller (lib/patch-kinds.mjs)
164
+ // injected, so the "kind-agnostic" framing stays literally true; this is dependency injection, not
165
+ // a switch statement. Uses withLockAsync, not withLockSync -- a naive `async` callback passed to
166
+ // withLockSync would release the lock the instant the callback RETURNS a Promise, not once that
167
+ // Promise RESOLVES, releasing the lock before a live DB write (or any other async executor) has
168
+ // actually finished. withLockAsync's `try { return await fn() } finally { ... }` makes that
169
+ // ordering bug structurally impossible.
170
+ export async function applyTransaction(root, featureId, transactionId, freshKindPlan, executeApply) {
171
+ return withLockAsync(root, 'state', async () => {
172
+ const txn = loadTransaction(root, featureId, transactionId);
173
+ if (!txn) throw new Error(`no patch transaction "${transactionId}" for feature "${featureId}"`);
174
+ if (txn.status !== 'approved') {
175
+ throw new Error(`transaction "${transactionId}" is "${txn.status}", not "approved" -- approve it first`);
176
+ }
177
+ requireFreshPreimage(txn, freshKindPlan);
178
+ const applyResult = await executeApply(root, featureId, txn, freshKindPlan);
179
+ txn.apply = { at: new Date().toISOString(), ...applyResult };
180
+ txn.status = 'applied';
181
+ return saveTransaction(root, featureId, txn);
182
+ });
183
+ }
184
+
185
+ // `executeRollback(root, featureId, txn, {force})` is the injected, kind-specific async restore --
186
+ // for config-apply it re-validates the file-hash drift check and restores from the CAS blob
187
+ // exactly as before (moved verbatim, not reimplemented); for ddl-apply it always throws (rollback
188
+ // of a live DDL apply is out of scope for Slice 1 -- see scanners/db/ddl-apply.mjs's
189
+ // executeDdlRollback). This function itself no longer knows what "restore" means for any kind --
190
+ // the drift-check-then-restore logic lives entirely in the executor now.
191
+ export async function rollbackTransaction(root, featureId, transactionId, reason, { force = false } = {}, executeRollback) {
192
+ if (!reason || !reason.trim()) {
193
+ throw new Error('rolling back a patch transaction requires a reason -- every rollback must be auditable');
194
+ }
195
+ return withLockAsync(root, 'state', async () => {
196
+ const txn = loadTransaction(root, featureId, transactionId);
197
+ if (!txn) throw new Error(`no patch transaction "${transactionId}" for feature "${featureId}"`);
198
+ if (txn.status !== 'applied') {
199
+ throw new Error(`transaction "${transactionId}" is "${txn.status}", not "applied" -- only an applied transaction can be rolled back`);
200
+ }
201
+ const rollbackResult = await executeRollback(root, featureId, txn, { force });
202
+ txn.rollback = { reason, at: new Date().toISOString(), ...(force ? { forced: true } : {}), ...rollbackResult };
203
+ txn.status = 'rolled_back';
204
+ return saveTransaction(root, featureId, txn);
205
+ });
206
+ }