/** * @file A lane's own database: create it once at provisioning and boot, drop * it on remove. CCAM stays stack-agnostic here — `createdb` vs `mysqladmin * create` vs `touch foo.db` genuinely differ, so the actual command lives in * the profile's `db-create.sh`/`db-drop.sh` hooks (already in the A1 * allowlist); this module only decides WHEN to call them and guards WHICH * name a drop may ever target. * @author Nguyễn Ngọc Trí Vĩ */ const fs = require("node:fs"); const path = require("node:path"); const { assertManaged } = require("./worktree"); const { dbName, slotDirs } = require("./lane-slots"); const { runHook } = require("./lane-profile"); /** * Where CCAM records that a slot's database has already been created. * * A hook can't be trusted to know this on its own without being stack-aware * (a plain `createdb` errors on a database that already exists; `touch` * wouldn't), so CCAM tracks it itself with one flag file per database name, * beside the rest of a slot's runtime bookkeeping. This is also how `upLane` * tells "freshly created" from "already existed" to decide whether to seed. */ function markerPath(slot, name) { return path.join(slotDirs(slot).stateDir, `db-created-${name}`); } /** * Ensure a lane's database exists, creating it via the profile's `db-create` * hook the first time only. * * A no-op when the profile declares no `DB_PREFIX` — no name is derived, so * no hook is ever called and nothing is created. * * @param {object} lane - Lane row; `slot` must already be allocated. * @param {object} profile - Resolved profile. * @param {{onLine?: Function, timeoutMs?: number}} [options] * @returns {Promise<{name: string|null, created: boolean}>} */ async function ensureDatabase(lane, profile, options = {}) { const name = dbName(profile, lane.slot); if (!name) return { name: null, created: false }; const marker = markerPath(lane.slot, name); if (fs.existsSync(marker)) return { name, created: false }; const result = await runHook(lane, profile, "db-create", [name], options); if (result.code !== 0) { throw Object.assign(new Error(`db-create hook exited ${result.code}`), { code: "EDBCREATE", logPath: result.logPath, }); } fs.mkdirSync(path.dirname(marker), { recursive: true }); fs.writeFileSync(marker, new Date().toISOString()); return { name, created: true }; } /** * Drop a database via the profile's `db-drop` hook. * * Two checks run before anything is spawned, and neither trusts the caller: * `assertManaged` (an adopted lane's data is the user's own, never CCAM's to * destroy) and a name check against what THIS lane's slot actually derives — * the lane's own database or its `_test` sibling, and nothing else. Only a * name CCAM itself computed can ever reach the hook. * * @param {object} lane - Lane row; `slot` must already be allocated. * @param {object} profile - Resolved profile. * @param {string} name - The database to drop; must be `dbName` or `${dbName}_test`. * @param {{onLine?: Function, timeoutMs?: number}} [options] * @returns {Promise<{name: string}>} */ async function dropDatabase(lane, profile, name, options = {}) { assertManaged(lane); const derived = dbName(profile, lane.slot); const allowed = derived && (name === derived || name === `${derived}_test`); if (!allowed) { throw Object.assign(new Error(`refusing to drop undeclared database: ${name}`), { code: "EBADDBNAME", }); } const result = await runHook(lane, profile, "db-drop", [name], options); if (result.code !== 0) { throw Object.assign(new Error(`db-drop hook exited ${result.code}`), { code: "EDBDROP", logPath: result.logPath, }); } fs.rmSync(markerPath(lane.slot, name), { force: true }); return { name }; } module.exports = { ensureDatabase, dropDatabase, markerPath };