/** * AI Software Factory — PITR drill: SEED marker rows (TASK-0135 deliverable #7). * * Companion to `verify-restore.mjs` (same directory). Seeds the data a PITR * restore-to-target must SURVIVE, each with a unique `PITR-DRILL-` marker * title so the verify script (and the drill log) can find it unambiguously: * * 1. a Workspace (slug `pitr-drill-`) * 2. a Page (title ` page`) + 2 Blocks (PARAGRAPH, HEADING_1) * 3. a Database (title ` db`) + a DatabaseRecord (on its own * record-Page — every record IS a page, per the app invariant) * 4. an OpenSearch document on the content index (alias `factory-content`) * in the same wire shape the worker writes (docId `page:`), * so the OS leg of the drill checks what production actually stores. * * Timestamp semantics (how the "~10 min before / ~2 min before target" * offsets are produced in a real drill — see runbook §8.2): the seed * commits its rows at real wall-clock time (the WAL is the source of truth * — a row only exists in the archive if it was actually committed). The * drill then CHOOSES the PITR target AFTER seeding: * * page seed → wait ~10 min (or: `pg_switch_wal()` + wait for the ship * marker to advance) → record seed → wait ~2 min → * target T → restore to T in the scratch namespace → verify-restore. * * In that order the page is ~12 min before T and the record ~2 min before * T — both "before", which is what PITR must preserve. (A live dry-run * without the waits uses `PITR_TARGET_TIME` a few seconds in the future.) * * Env (all overridable): * DATABASE_URL (default: the local dev DB — see .env example) * SEARCH_URL (default: http://localhost:9200) * SEARCH_INDEX (default: factory-content — the ALIAS; writes land on * the current concrete index) * DRILL_STATE_FILE (default: /tmp/pitr-drill-state.json — the seed writes * the marker ids + timestamps here; verify reads it) * PITR_TARGET_TIME (optional ISO-8601; recorded into the state file for * verify; NOT required for seeding) * * Usage (repo root): * node tests/integration/drill/seed-drill.mjs # seed (+write state) * node tests/integration/drill/seed-drill.mjs --cleanup # delete markers * * The scripts are idempotent-ish: every run uses a fresh timestamp marker, * so re-seeding never collides; `--cleanup` deletes ONLY rows whose * title/slug starts with the marker in the state file (or the latest * `PITR-DRILL-` marker when no state file is present). */ import { PrismaClient } from "@prisma/client"; import { writeFileSync, existsSync, readFileSync, unlinkSync } from "node:fs"; // ── env ───────────────────────────────────────────────────────────────────── const DATABASE_URL = process.env.DATABASE_URL ?? "postgresql://factory:factory@localhost:5432/factory"; const SEARCH_URL = (process.env.SEARCH_URL ?? "http://localhost:9200").replace(/\/+$/, ""); const SEARCH_INDEX = process.env.SEARCH_INDEX ?? "factory-content"; const STATE_FILE = process.env.DRILL_STATE_FILE ?? "/tmp/pitr-drill-state.json"; const MARKER = `PITR-DRILL-${Date.now()}`; const prisma = new PrismaClient({ datasources: { db: { url: DATABASE_URL } } }); // ── cleanup ───────────────────────────────────────────────────────────────── async function cleanup(state) { console.log(`cleanup: removing marker ${state.marker}`); // OS doc first (independent store). for (const docId of state.osDocIds ?? []) { const res = await fetch(`${SEARCH_URL}/${SEARCH_INDEX}/_doc/${encodeURIComponent(docId)}`, { method: "DELETE", }); console.log(` os doc ${docId}: ${res.status === 404 ? "already gone" : res.status === 200 ? "deleted" : `HTTP ${res.status}`}`); } // DB rows: children first (Page/Block/Record cascade on Workspace delete, // but explicit deletes keep the log honest + work if FK cascades change). if (state.pageId) { const blocks = await prisma.block.count({ where: { pageId: state.pageId } }); await prisma.block.deleteMany({ where: { pageId: state.pageId } }); await prisma.page.delete({ where: { id: state.pageId } }).catch(() => {}); console.log(` page ${state.pageId}: deleted (${blocks} blocks)`); } if (state.recordPageId) { await prisma.page.delete({ where: { id: state.recordPageId } }).catch(() => {}); } if (state.recordId) { await prisma.databaseRecord.delete({ where: { id: state.recordId } }).catch(() => {}); } if (state.databaseId) { await prisma.database.delete({ where: { id: state.databaseId } }).catch(() => {}); } if (state.workspaceId) { // cascade: any residual blocks/pages of the workspace await prisma.block.deleteMany({ where: { workspaceId: state.workspaceId } }); await prisma.page.deleteMany({ where: { workspaceId: state.workspaceId } }); await prisma.databaseRecord.deleteMany({ where: { workspaceId: state.workspaceId } }); await prisma.database.deleteMany({ where: { workspaceId: state.workspaceId } }); await prisma.workspace.delete({ where: { id: state.workspaceId } }).catch(() => {}); console.log(` workspace ${state.workspaceId}: deleted`); } if (existsSync(STATE_FILE)) unlinkSync(STATE_FILE); console.log("cleanup: done"); } // ── seed ──────────────────────────────────────────────────────────────────── async function seed() { const now = new Date(); const target = process.env.PITR_TARGET_TIME ? new Date(process.env.PITR_TARGET_TIME) : null; const workspace = await prisma.workspace.create({ data: { name: `${MARKER} workspace`, slug: `pitr-drill-${now.getTime()}` }, }); const page = await prisma.page.create({ data: { workspaceId: workspace.id, title: `${MARKER} page`, position: "a0", createdAt: now, // explicit: the drill log quotes this timestamp }, }); const blockA = await prisma.block.create({ data: { pageId: page.id, workspaceId: workspace.id, type: "PARAGRAPH", position: "a0", content: { text: `${MARKER} — first block. This page must survive a PITR restore to the drill target.` }, createdAt: now, }, }); const blockB = await prisma.block.create({ data: { pageId: page.id, workspaceId: workspace.id, type: "HEADING_1", position: "a1", content: { text: `${MARKER} — heading block` }, createdAt: now, }, }); const recordPage = await prisma.page.create({ data: { workspaceId: workspace.id, title: `${MARKER} record-page`, position: "b0", createdAt: now, }, }); const database = await prisma.database.create({ data: { workspaceId: workspace.id, title: `${MARKER} db` }, }); const record = await prisma.databaseRecord.create({ data: { workspaceId: workspace.id, databaseId: database.id, pageId: recordPage.id, position: "a0", createdAt: now, }, }); // OpenSearch document — same wire shape the worker writes // (scripts/opensearch-reindex.ts mirrorToDoc), indexed under the ALIAS so // it lands on the current concrete index (factory-content-vN). const osDocId = `page:${page.id}`; const osDoc = { docId: osDocId, type: "page", id: page.id, workspaceId: workspace.id, title: `${MARKER} page`, body: `${MARKER} page body — seeded by drill seed-drill.mjs`, grants: { user: true, teams: [], users: [], public: false, workspace: true }, archived: false, url: `/w/${workspace.id}/page/${page.id}`, createdAt: now.toISOString(), updatedAt: now.toISOString(), pageId: page.id, }; // HTTP method is PUT (INDEX is the ES *API* name, not an HTTP verb — an // unknown verb makes OpenSearch drop the socket). const osRes = await fetch(`${SEARCH_URL}/${SEARCH_INDEX}/_doc/${encodeURIComponent(osDocId)}`, { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify(osDoc), }); const osBody = await osRes.json().catch(() => ({})); if (!osRes.ok) { throw new Error(`OS index failed (${osRes.status}): ${JSON.stringify(osBody).slice(0, 300)}`); } const refresh = await fetch(`${SEARCH_URL}/${SEARCH_INDEX}/_refresh`); if (!refresh.ok) throw new Error(`OS refresh failed (${refresh.status})`); const state = { marker: MARKER, seededAt: now.toISOString(), targetTime: target?.toISOString() ?? null, workspaceId: workspace.id, pageId: page.id, blockIds: [blockA.id, blockB.id], recordPageId: recordPage.id, databaseId: database.id, recordId: record.id, osDocIds: [osDocId], searchIndex: SEARCH_INDEX, }; writeFileSync(STATE_FILE, JSON.stringify(state, null, 2)); console.log(`seeded marker ${MARKER}`); console.log(` workspace ${workspace.id}`); console.log(` page ${page.id} (blocks: ${blockA.id}, ${blockB.id})`); console.log(` database ${database.id} record ${record.id} (page ${recordPage.id})`); console.log(` os doc ${osDocId} on ${SEARCH_INDEX}`); console.log(` createdAt ${state.seededAt}`); console.log(`state written to ${STATE_FILE}`); console.log(`next: choose PITR target T >= ${state.seededAt} (runbook §8.2), restore, then: node tests/integration/drill/verify-restore.mjs`); } // ── main ──────────────────────────────────────────────────────────────────── async function main() { const cleanupFlag = process.argv.includes("--cleanup"); try { if (cleanupFlag) { let state = null; if (existsSync(STATE_FILE)) { state = JSON.parse(readFileSync(STATE_FILE, "utf8")); } else { // No state file: find the most recent PITR-DRILL workspace and clean // its rows (best effort — a fresh drill always has a state file). const ws = await prisma.workspace.findFirst({ where: { name: { startsWith: "PITR-DRILL-" } }, orderBy: { createdAt: "desc" }, }); if (!ws) { console.log("no PITR-DRILL- marker found; nothing to clean up"); return; } const page = await prisma.page.findFirst({ where: { workspaceId: ws.id, title: { endsWith: " page" } }, orderBy: { createdAt: "desc" }, }); state = { marker: ws.name.replace(/ workspace$/, ""), workspaceId: ws.id, pageId: page?.id, osDocIds: page ? [`page:${page.id}`] : [], }; console.log(`(no state file — best-effort cleanup of workspace ${ws.id})`); } await cleanup(state); return; } await seed(); } finally { await prisma.$disconnect(); } } main().catch(async (err) => { console.error(`seed-drill failed: ${err.stack ?? err}`); await prisma.$disconnect().catch(() => {}); process.exit(1); });