1
49
50import { PrismaClient } from "@prisma/client";
51import { writeFileSync, existsSync, readFileSync, unlinkSync } from "node:fs";
52
53
54
55const DATABASE_URL =
56 process.env.DATABASE_URL ?? "postgresql://factory:factory@localhost:5432/factory";
57const SEARCH_URL = (process.env.SEARCH_URL ?? "http://localhost:9200").replace(/\/+$/, "");
58const SEARCH_INDEX = process.env.SEARCH_INDEX ?? "factory-content";
59const STATE_FILE = process.env.DRILL_STATE_FILE ?? "/tmp/pitr-drill-state.json";
60const MARKER = `PITR-DRILL-${Date.now()}`;
61
62const prisma = new PrismaClient({ datasources: { db: { url: DATABASE_URL } } });
63
64
65
66async function cleanup(state) {
67 console.log(`cleanup: removing marker ${state.marker}`);
68
69 for (const docId of state.osDocIds ?? []) {
70 const res = await fetch(`${SEARCH_URL}/${SEARCH_INDEX}/_doc/${encodeURIComponent(docId)}`, {
71 method: "DELETE",
72 });
73 console.log(` os doc ${docId}: ${res.status === 404 ? "already gone" : res.status === 200 ? "deleted" : `HTTP ${res.status}`}`);
74 }
75
76
77 if (state.pageId) {
78 const blocks = await prisma.block.count({ where: { pageId: state.pageId } });
79 await prisma.block.deleteMany({ where: { pageId: state.pageId } });
80 await prisma.page.delete({ where: { id: state.pageId } }).catch(() => {});
81 console.log(` page ${state.pageId}: deleted (${blocks} blocks)`);
82 }
83 if (state.recordPageId) {
84 await prisma.page.delete({ where: { id: state.recordPageId } }).catch(() => {});
85 }
86 if (state.recordId) {
87 await prisma.databaseRecord.delete({ where: { id: state.recordId } }).catch(() => {});
88 }
89 if (state.databaseId) {
90 await prisma.database.delete({ where: { id: state.databaseId } }).catch(() => {});
91 }
92 if (state.workspaceId) {
93
94 await prisma.block.deleteMany({ where: { workspaceId: state.workspaceId } });
95 await prisma.page.deleteMany({ where: { workspaceId: state.workspaceId } });
96 await prisma.databaseRecord.deleteMany({ where: { workspaceId: state.workspaceId } });
97 await prisma.database.deleteMany({ where: { workspaceId: state.workspaceId } });
98 await prisma.workspace.delete({ where: { id: state.workspaceId } }).catch(() => {});
99 console.log(` workspace ${state.workspaceId}: deleted`);
100 }
101 if (existsSync(STATE_FILE)) unlinkSync(STATE_FILE);
102 console.log("cleanup: done");
103}
104
105
106
107async function seed() {
108 const now = new Date();
109 const target = process.env.PITR_TARGET_TIME ? new Date(process.env.PITR_TARGET_TIME) : null;
110
111 const workspace = await prisma.workspace.create({
112 data: { name: `${MARKER} workspace`, slug: `pitr-drill-${now.getTime()}` },
113 });
114
115 const page = await prisma.page.create({
116 data: {
117 workspaceId: workspace.id,
118 title: `${MARKER} page`,
119 position: "a0",
120 createdAt: now,
121 },
122 });
123 const blockA = await prisma.block.create({
124 data: {
125 pageId: page.id,
126 workspaceId: workspace.id,
127 type: "PARAGRAPH",
128 position: "a0",
129 content: { text: `${MARKER} — first block. This page must survive a PITR restore to the drill target.` },
130 createdAt: now,
131 },
132 });
133 const blockB = await prisma.block.create({
134 data: {
135 pageId: page.id,
136 workspaceId: workspace.id,
137 type: "HEADING_1",
138 position: "a1",
139 content: { text: `${MARKER} — heading block` },
140 createdAt: now,
141 },
142 });
143
144 const recordPage = await prisma.page.create({
145 data: {
146 workspaceId: workspace.id,
147 title: `${MARKER} record-page`,
148 position: "b0",
149 createdAt: now,
150 },
151 });
152 const database = await prisma.database.create({
153 data: { workspaceId: workspace.id, title: `${MARKER} db` },
154 });
155 const record = await prisma.databaseRecord.create({
156 data: {
157 workspaceId: workspace.id,
158 databaseId: database.id,
159 pageId: recordPage.id,
160 position: "a0",
161 createdAt: now,
162 },
163 });
164
165
166
167
168 const osDocId = `page:${page.id}`;
169 const osDoc = {
170 docId: osDocId,
171 type: "page",
172 id: page.id,
173 workspaceId: workspace.id,
174 title: `${MARKER} page`,
175 body: `${MARKER} page body — seeded by drill seed-drill.mjs`,
176 grants: { user: true, teams: [], users: [], public: false, workspace: true },
177 archived: false,
178 url: `/w/${workspace.id}/page/${page.id}`,
179 createdAt: now.toISOString(),
180 updatedAt: now.toISOString(),
181 pageId: page.id,
182 };
183
184
185 const osRes = await fetch(`${SEARCH_URL}/${SEARCH_INDEX}/_doc/${encodeURIComponent(osDocId)}`, {
186 method: "PUT",
187 headers: { "content-type": "application/json" },
188 body: JSON.stringify(osDoc),
189 });
190 const osBody = await osRes.json().catch(() => ({}));
191 if (!osRes.ok) {
192 throw new Error(`OS index failed (${osRes.status}): ${JSON.stringify(osBody).slice(0, 300)}`);
193 }
194 const refresh = await fetch(`${SEARCH_URL}/${SEARCH_INDEX}/_refresh`);
195 if (!refresh.ok) throw new Error(`OS refresh failed (${refresh.status})`);
196
197 const state = {
198 marker: MARKER,
199 seededAt: now.toISOString(),
200 targetTime: target?.toISOString() ?? null,
201 workspaceId: workspace.id,
202 pageId: page.id,
203 blockIds: [blockA.id, blockB.id],
204 recordPageId: recordPage.id,
205 databaseId: database.id,
206 recordId: record.id,
207 osDocIds: [osDocId],
208 searchIndex: SEARCH_INDEX,
209 };
210 writeFileSync(STATE_FILE, JSON.stringify(state, null, 2));
211
212 console.log(`seeded marker ${MARKER}`);
213 console.log(` workspace ${workspace.id}`);
214 console.log(` page ${page.id} (blocks: ${blockA.id}, ${blockB.id})`);
215 console.log(` database ${database.id} record ${record.id} (page ${recordPage.id})`);
216 console.log(` os doc ${osDocId} on ${SEARCH_INDEX}`);
217 console.log(` createdAt ${state.seededAt}`);
218 console.log(`state written to ${STATE_FILE}`);
219 console.log(`next: choose PITR target T >= ${state.seededAt} (runbook §8.2), restore, then:
220 node tests/integration/drill/verify-restore.mjs`);
221}
222
223
224
225async function main() {
226 const cleanupFlag = process.argv.includes("--cleanup");
227 try {
228 if (cleanupFlag) {
229 let state = null;
230 if (existsSync(STATE_FILE)) {
231 state = JSON.parse(readFileSync(STATE_FILE, "utf8"));
232 } else {
233
234
235 const ws = await prisma.workspace.findFirst({
236 where: { name: { startsWith: "PITR-DRILL-" } },
237 orderBy: { createdAt: "desc" },
238 });
239 if (!ws) {
240 console.log("no PITR-DRILL- marker found; nothing to clean up");
241 return;
242 }
243 const page = await prisma.page.findFirst({
244 where: { workspaceId: ws.id, title: { endsWith: " page" } },
245 orderBy: { createdAt: "desc" },
246 });
247 state = {
248 marker: ws.name.replace(/ workspace$/, ""),
249 workspaceId: ws.id,
250 pageId: page?.id,
251 osDocIds: page ? [`page:${page.id}`] : [],
252 };
253 console.log(`(no state file — best-effort cleanup of workspace ${ws.id})`);
254 }
255 await cleanup(state);
256 return;
257 }
258 await seed();
259 } finally {
260 await prisma.$disconnect();
261 }
262}
263
264main().catch(async (err) => {
265 console.error(`seed-drill failed: ${err.stack ?? err}`);
266 await prisma.$disconnect().catch(() => {});
267 process.exit(1);
268});
269