1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
5import * as Path from "effect/Path";
6import * as SqlClient from "effect/sql/SqlClient";
7
8import { runMigrations } from "../src/persistence/Migrations.ts";
9import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
10import { runMigrateDevDb } from "./migrate-dev-db.ts";
11
12const withDatabase = <A, E, R>(
13 databasePath: string,
14 effect: Effect.Effect<A, E, R | SqlClient.SqlClient>,
15) => effect.pipe(Effect.provide(NodeSqliteClient.layer({ filename: databasePath })));
16
17const insertPeerLink = (sql: SqlClient.SqlClient) => sql`INSERT INTO peer_environment_links
18 (environment_id, secret_name, label, urls_json, access, linked_at, expires_at)
19 VALUES ('environment-box', 'peer-link-box', 'Box', '["https://box.example"]', 'auto',
20 '2026-08-01', '2027-08-01')`;
21
22const createFixtureSource = Effect.fn("createMigrateDevDbFixtureSource")(function* (
23 baseDir: string,
24 toMigrationInclusive?: number,
25) {
26 const fs = yield* FileSystem.FileSystem;
27 const path = yield* Path.Path;
28 const stateDir = path.join(baseDir, "userdata");
29 const databasePath = path.join(stateDir, "statev2.sqlite");
30 yield* fs.makeDirectory(stateDir, { recursive: true });
31 yield* withDatabase(
32 databasePath,
33 Effect.gen(function* () {
34 const sql = yield* SqlClient.SqlClient;
35 yield* runMigrations({ toMigrationInclusive });
36
37 const scripts = JSON.stringify([
38 { id: "lint", name: "Lint", command: "vp lint", icon: "lint", runOnWorktreeCreate: false },
39 {
40 id: "teardown",
41 name: "Teardown",
42 command: "./teardown",
43 icon: "configure",
44 runOnWorktreeCreate: false,
45 runOnSettle: true,
46 },
47 ]);
48 yield* sql`INSERT INTO projection_projects
49 (project_id, title, workspace_root, scripts_json, created_at, updated_at, deleted_at)
50 VALUES
51 ('project-kept', 'Kept', '/tmp/kept', ${scripts}, '2026-08-01', '2026-08-01', NULL),
52 ('project-deleted', 'Deleted', '/tmp/deleted', '[]', '2026-08-01', '2026-08-02', '2026-08-02')`;
53
54 const forkPayload =
55 '{"lineage":{"parentThreadId":"stopped-thread","relationshipToParent":"fork","rootThreadId":"stopped-thread"}}';
56 const subagentPayload =
57 '{"lineage":{"parentThreadId":"subagent-parent","relationshipToParent":"subagent","rootThreadId":"subagent-parent"},"forkedFrom":{"type":"node","nodeId":"node-1"}}';
58 const watchedPayload = JSON.stringify({
59 pullRequests: [
60 {
61 host: "github.com",
62 repository: "owner/repo",
63 number: 7,
64 url: "https://github.com/owner/repo/pull/7",
65 source: "agent",
66 linkedAt: "2026-08-01T00:00:00.000Z",
67 snapshot: null,
68 stack: null,
69 watch: {
70 startedAt: "2026-08-01T00:00:00.000Z",
71 headSha: null,
72 failedChecks: [],
73 passed: false,
74 passedChecks: [],
75 remarksThrough: "2026-08-01T00:00:00.000Z",
76 remarkIds: [],
77 conflicting: false,
78 wakes: 0,
79 },
80 },
81 ],
82 });
83 const threads = [
84 ["stopped-thread", "project-kept", "completed", watchedPayload, "2026-08-01"],
85 ["fork-thread", "project-kept", "completed", forkPayload, "2026-08-02"],
86 ["running-thread", "project-kept", "running", "{}", "2026-08-05"],
87 ["settled-thread", "project-kept", "completed", '{"settledAt":"2026-08-01"}', "2026-08-05"],
88 [
89 "archived-thread",
90 "project-kept",
91 "completed",
92 '{"archivedAt":"2026-08-01"}',
93 "2026-08-05",
94 ],
95 [
96 "limit-thread",
97 "project-kept",
98 "completed",
99 '{"limitRecovery":{"autoResume":true}}',
100 "2026-08-05",
101 ],
102 ["subagent-parent", "project-kept", "completed", "{}", "2026-08-05"],
103 ["subagent-child", "project-kept", "completed", subagentPayload, "2026-08-05"],
104 ["deleted-project-thread", "project-deleted", "completed", "{}", "2026-08-05"],
105 ] as const;
106 for (const [threadId, projectId, runStatus, payload, updatedAt] of threads) {
107 yield* sql`INSERT INTO orchestration_v2_projection_threads
108 (thread_id, project_id, title, default_provider, runtime_mode, interaction_mode, created_at, updated_at, payload_json)
109 VALUES (${threadId}, ${projectId}, ${threadId}, 'codex', 'full-access', 'default', '2026-08-01', ${updatedAt}, ${payload})`;
110 yield* sql`INSERT INTO orchestration_v2_projection_runs
111 (run_id, thread_id, ordinal, provider, status, requested_at, payload_json)
112 VALUES (${`run-${threadId}`}, ${threadId}, 1, 'codex', ${runStatus}, '2026-08-01', '{}')`;
113 yield* sql`INSERT INTO orchestration_events
114 (event_id, aggregate_kind, stream_id, stream_version, event_type, occurred_at, actor_kind, payload_json, metadata_json, application_event_version)
115 VALUES (${`event-${threadId}`}, 'thread', ${threadId}, 0, 'thread.created', '2026-08-01', 'user', '{}', '{}', 2)`;
116 }
117 // A V1 thread the server already imported must not trip the legacy guard.
118 yield* sql`INSERT INTO projection_threads
119 (thread_id, project_id, title, model_selection_json, runtime_mode, created_at, updated_at)
120 VALUES ('stopped-thread', 'project-kept', 'stopped-thread',
121 '{"instanceId":"codex","model":"gpt-5.4"}', 'full-access', '2026-08-01', '2026-08-01')`;
122 yield* sql`INSERT INTO orchestration_v2_projection_provider_sessions
123 (provider_session_id, thread_id, provider, status, updated_at, payload_json)
124 VALUES ('session-shared', 'running-thread', 'codex', 'stopped', '2026-08-01', '{}')`;
125 yield* sql`INSERT INTO orchestration_v2_projection_provider_session_bindings
126 (provider_session_id, thread_id)
127 VALUES ('session-shared', 'running-thread'), ('session-shared', 'stopped-thread')`;
128 yield* sql`INSERT INTO orchestration_v2_projection_context_transfers
129 (context_transfer_id, source_thread_id, target_thread_id, type, status, updated_at, payload_json)
130 VALUES ('transfer-1', 'archived-thread', 'stopped-thread', 'provider_handoff', 'completed', '2026-08-01', '{}')`;
131 yield* sql`INSERT INTO scheduled_tasks
132 (task_id, title, prompt, enabled, schedule_json, project_id, workspace_strategy_json,
133 model_selection_json, runtime_mode, interaction_mode, created_by, creation_source,
134 created_at, updated_at, last_run_status, run_count)
135 VALUES ('task-1', 'Nightly', 'Run it', 1, '{}', 'project-kept', '{}', '{}',
136 'full-access', 'default', 'user', 'user', '2026-08-01', '2026-08-01', 'never', 0)`;
137 yield* sql`INSERT INTO scheduled_task_webhook_replay_keys
138 (task_id, replay_key, delivery_id, expires_at)
139 VALUES ('task-1', 'replay-1', 'delivery-1', 1900000000000)`;
140 yield* sql`INSERT INTO scheduled_task_webhook_pending
141 (delivery_id, task_id, coalesce_key, due_at, task_json, request_json, delivery_ids_json)
142 VALUES ('delivery-1', 'task-1', 'key-1', 1900000000000, '{}', '{}', '[]')`;
143 yield* sql`INSERT INTO auth_sessions (session_id, subject, scopes, method, issued_at, expires_at)
144 VALUES ('session-1', 'user', '[]', 'pairing', '2026-08-01', '2027-08-01')`;
145 if (toMigrationInclusive === undefined) yield* insertPeerLink(sql);
146 }),
147 );
148 return databasePath;
149});
150
151it.layer(NodeServices.layer)("migrate-dev-db", (it) => {
152 it.effect("keeps stopped and settled thread families and clears pending work", () =>
153 Effect.gen(function* () {
154 const fs = yield* FileSystem.FileSystem;
155 const path = yield* Path.Path;
156 const sourceDir = yield* fs.makeTempDirectoryScoped({ prefix: "migrate-dev-db-src-" });
157 const destDir = yield* fs.makeTempDirectoryScoped({ prefix: "migrate-dev-db-dest-" });
158 const source = yield* createFixtureSource(sourceDir);
159
160 const result = yield* runMigrateDevDb(
161 { baseDir: destDir, source, projects: 5, threadsPerProject: 2 },
162 { sharedHome: sourceDir },
163 );
164
165 assert.equal(result.databasePath, path.join(destDir, "userdata", "statev2.sqlite"));
166 const kept = yield* withDatabase(
167 result.databasePath,
168 Effect.gen(function* () {
169 const sql = yield* SqlClient.SqlClient;
170 const threads = yield* sql<{ thread_id: string }>`
171 SELECT thread_id FROM orchestration_v2_projection_threads ORDER BY thread_id`;
172 const events = yield* sql<{ stream_id: string }>`
173 SELECT stream_id FROM orchestration_events ORDER BY stream_id`;
174 const sessions = yield* sql<{ provider_session_id: string }>`
175 SELECT provider_session_id FROM orchestration_v2_projection_provider_sessions`;
176 const [pullRequest] = yield* sql<{ url: string | null; watch: string | null }>`
177 SELECT
178 json_extract(payload_json, '$.pullRequests[0].url') AS url,
179 json_extract(payload_json, '$.pullRequests[0].watch') AS watch
180 FROM orchestration_v2_projection_threads WHERE thread_id = 'stopped-thread'`;
181 const [project] = yield* sql<{ scripts_json: string }>`
182 SELECT scripts_json FROM projection_projects WHERE project_id = 'project-kept'`;
183 const [leftovers] = yield* sql<{
184 auth: number;
185 tasks: number;
186 webhooks: number;
187 transfers: number;
188 links: number;
189 }>`
190 SELECT
191 (SELECT COUNT(*) FROM auth_sessions) AS auth,
192 (SELECT COUNT(*) FROM scheduled_tasks) AS tasks,
193 (SELECT COUNT(*) FROM scheduled_task_webhook_replay_keys) +
194 (SELECT COUNT(*) FROM scheduled_task_webhook_pending) AS webhooks,
195 (SELECT COUNT(*) FROM orchestration_v2_projection_context_transfers) AS transfers,
196 (SELECT COUNT(*) FROM peer_environment_links) AS links`;
197 return { threads, events, sessions, pullRequest, project, leftovers };
198 }),
199 );
200 assert.deepStrictEqual(
201 kept.threads.map((row) => row.thread_id),
202 ["fork-thread", "settled-thread", "stopped-thread"],
203 );
204 assert.deepStrictEqual(
205 kept.events.map((row) => row.stream_id),
206 ["fork-thread", "settled-thread", "stopped-thread"],
207 );
208 assert.deepStrictEqual(
209 kept.sessions.map((row) => row.provider_session_id),
210 ["session-shared"],
211 );
212 assert.deepStrictEqual(
213 kept.pullRequest,
214 { url: "https://github.com/owner/repo/pull/7", watch: null },
215 "copied pull request links keep their history but stop being watched",
216 );
217 assert.deepStrictEqual(
218 JSON.parse(kept.project?.scripts_json ?? "[]"),
219 [
220 {
221 id: "lint",
222 name: "Lint",
223 command: "vp lint",
224 icon: "lint",
225 runOnWorktreeCreate: false,
226 },
227 {
228 id: "teardown",
229 name: "Teardown",
230 command: "./teardown",
231 icon: "configure",
232 runOnWorktreeCreate: false,
233 },
234 ],
235 "copied project scripts stay listed but never run on settle in the original checkout",
236 );
237 assert.deepStrictEqual(kept.leftovers, {
238 auth: 0,
239 tasks: 0,
240 webhooks: 0,
241 transfers: 0,
242 links: 0,
243 });
244 }),
245 );
246
247 it.effect("never copies an archived thread, and new events append after the source's", () =>
248 Effect.gen(function* () {
249 const fs = yield* FileSystem.FileSystem;
250 const sourceDir = yield* fs.makeTempDirectoryScoped({ prefix: "migrate-dev-db-slice-" });
251 const destDir = yield* fs.makeTempDirectoryScoped({ prefix: "migrate-dev-db-slice-dest-" });
252 const source = yield* createFixtureSource(sourceDir);
253 const [sourceSequence] = yield* withDatabase(
254 source,
255 Effect.gen(function* () {
256 const sql = yield* SqlClient.SqlClient;
257 return yield* sql<{ seq: number }>`
258 SELECT seq FROM sqlite_sequence WHERE name = 'orchestration_events'`;
259 }),
260 );
261
262 const result = yield* runMigrateDevDb(
263 { baseDir: destDir, source, projects: 5, threadsPerProject: 100 },
264 { sharedHome: sourceDir },
265 );
266
267 const copied = yield* withDatabase(
268 result.databasePath,
269 Effect.gen(function* () {
270 const sql = yield* SqlClient.SqlClient;
271 const [archivedRows] = yield* sql<{ count: number }>`
272 SELECT
273 (SELECT COUNT(*) FROM orchestration_v2_projection_runs WHERE thread_id = 'archived-thread')
274 + (SELECT COUNT(*) FROM orchestration_events WHERE stream_id = 'archived-thread')
275 AS count`;
276 const [sequence] = yield* sql<{ seq: number }>`
277 SELECT seq FROM sqlite_sequence WHERE name = 'orchestration_events'`;
278 return { archivedRows: archivedRows?.count, sequence: sequence?.seq };
279 }),
280 );
281 assert.equal(copied.archivedRows, 0);
282 assert.equal(copied.sequence, sourceSequence?.seq);
283 }),
284 );
285
286 it.effect("skips a thread family larger than the per-project limit", () =>
287 Effect.gen(function* () {
288 const fs = yield* FileSystem.FileSystem;
289 const sourceDir = yield* fs.makeTempDirectoryScoped({ prefix: "migrate-dev-db-family-" });
290 const destDir = yield* fs.makeTempDirectoryScoped({ prefix: "migrate-dev-db-family-dest-" });
291 const source = yield* createFixtureSource(sourceDir);
292
293 // stopped-thread and its fork are 2 threads; settled-thread is 1.
294 const result = yield* runMigrateDevDb(
295 { baseDir: destDir, source, projects: 5, threadsPerProject: 1 },
296 { sharedHome: sourceDir },
297 );
298 const threads = yield* withDatabase(
299 result.databasePath,
300 Effect.gen(function* () {
301 const sql = yield* SqlClient.SqlClient;
302 return yield* sql<{ thread_id: string }>`
303 SELECT thread_id FROM orchestration_v2_projection_threads`;
304 }),
305 );
306 assert.deepStrictEqual(
307 threads.map((row) => row.thread_id),
308 ["settled-thread"],
309 );
310 }),
311 );
312
313 it.effect("upgrades a source from before the V2 thread tables", () =>
314 Effect.gen(function* () {
315 const fs = yield* FileSystem.FileSystem;
316 const path = yield* Path.Path;
317 const sourceDir = yield* fs.makeTempDirectoryScoped({ prefix: "migrate-dev-db-v1-" });
318 const destDir = yield* fs.makeTempDirectoryScoped({ prefix: "migrate-dev-db-v1-dest-" });
319 const stateDir = path.join(sourceDir, "userdata");
320 const source = path.join(stateDir, "statev2.sqlite");
321 yield* fs.makeDirectory(stateDir, { recursive: true });
322 yield* withDatabase(source, runMigrations({ toMigrationInclusive: 54 }));
323
324 const result = yield* runMigrateDevDb(
325 { baseDir: destDir, source, projects: 5, threadsPerProject: 10 },
326 { sharedHome: sourceDir },
327 );
328 assert.include(result.executedMigrations, "55_OrchestrationV2");
329 }),
330 );
331
332 // The server imports V1 threads at boot, not in a migration, so a source can
333 // reach the V2 schema with its legacy threads still unimported.
334 it.effect.each([
335 { name: "a pre-V2 source", migrateToLatest: false },
336 { name: "a V2 source whose legacy threads were never imported", migrateToLatest: true },
337 ])("refuses $name", ({ migrateToLatest }) =>
338 Effect.gen(function* () {
339 const fs = yield* FileSystem.FileSystem;
340 const path = yield* Path.Path;
341 const sourceDir = yield* fs.makeTempDirectoryScoped({ prefix: "migrate-dev-db-v1-data-" });
342 const destDir = yield* fs.makeTempDirectoryScoped({
343 prefix: "migrate-dev-db-v1-data-dest-",
344 });
345 const stateDir = path.join(sourceDir, "userdata");
346 const source = path.join(stateDir, "statev2.sqlite");
347 yield* fs.makeDirectory(stateDir, { recursive: true });
348 yield* withDatabase(
349 source,
350 Effect.gen(function* () {
351 const sql = yield* SqlClient.SqlClient;
352 yield* runMigrations({ toMigrationInclusive: 54 });
353 yield* sql`INSERT INTO projection_projects
354 (project_id, title, workspace_root, scripts_json, created_at, updated_at)
355 VALUES ('project-legacy', 'Legacy', '/tmp/legacy', '[]', '2026-08-01', '2026-08-01')`;
356 yield* sql`INSERT INTO projection_threads
357 (thread_id, project_id, title, model_selection_json, runtime_mode, created_at, updated_at)
358 VALUES ('thread-legacy', 'project-legacy', 'Legacy thread',
359 '{"instanceId":"codex","model":"gpt-5.4"}', 'full-access', '2026-08-01', '2026-08-01')`;
360 if (migrateToLatest) yield* runMigrations();
361 }),
362 );
363
364 // A live destination connection keeps uncheckpointed rows in the WAL;
365 // the rejected run must leave those bytes alone.
366 const destination = yield* createFixtureSource(destDir);
367 yield* withDatabase(
368 destination,
369 Effect.gen(function* () {
370 const sql = yield* SqlClient.SqlClient;
371 yield* sql.unsafe("PRAGMA journal_mode = WAL").unprepared;
372 yield* sql`UPDATE projection_projects SET title = 'Edited' WHERE project_id = 'project-kept'`;
373 const walBefore = yield* fs.readFile(`${destination}-wal`);
374 assert.isAbove(walBefore.length, 0);
375
376 const error = yield* runMigrateDevDb(
377 { baseDir: destDir, source, projects: 5, threadsPerProject: 10 },
378 { sharedHome: sourceDir },
379 ).pipe(Effect.flip);
380 assert.equal(error._tag, "MigrateDevDbLegacySourceError");
381 assert.deepStrictEqual(yield* fs.readFile(`${destination}-wal`), walBefore);
382 }),
383 );
384 }),
385 );
386
387 it.effect("seeds from a source that recorded another migration below V2", () =>
388 Effect.gen(function* () {
389 const fs = yield* FileSystem.FileSystem;
390 const sourceDir = yield* fs.makeTempDirectoryScoped({ prefix: "migrate-dev-db-local-" });
391 const destDir = yield* fs.makeTempDirectoryScoped({ prefix: "migrate-dev-db-local-dest-" });
392 const source = yield* createFixtureSource(sourceDir);
393 yield* withDatabase(
394 source,
395 Effect.gen(function* () {
396 const sql = yield* SqlClient.SqlClient;
397 yield* sql`UPDATE effect_sql_migrations SET name = 'SiteLocalMigration'
398 WHERE migration_id = 51`;
399 }),
400 );
401
402 const result = yield* runMigrateDevDb(
403 { baseDir: destDir, source, projects: 5, threadsPerProject: 2 },
404 { sharedHome: sourceDir },
405 );
406 const threads = yield* withDatabase(
407 result.databasePath,
408 Effect.gen(function* () {
409 const sql = yield* SqlClient.SqlClient;
410 return yield* sql<{ thread_id: string }>`
411 SELECT thread_id FROM orchestration_v2_projection_threads ORDER BY thread_id`;
412 }),
413 );
414 assert.deepStrictEqual(
415 threads.map((row) => row.thread_id),
416 ["fork-thread", "settled-thread", "stopped-thread"],
417 );
418 }),
419 );
420
421 it.effect("refuses a source whose V2 migration history clashes with this checkout", () =>
422 Effect.gen(function* () {
423 const fs = yield* FileSystem.FileSystem;
424 const sourceDir = yield* fs.makeTempDirectoryScoped({ prefix: "migrate-dev-db-clash-" });
425 const destDir = yield* fs.makeTempDirectoryScoped({ prefix: "migrate-dev-db-clash-dest-" });
426 const source = yield* createFixtureSource(sourceDir);
427 yield* withDatabase(
428 source,
429 Effect.gen(function* () {
430 const sql = yield* SqlClient.SqlClient;
431 yield* sql`UPDATE effect_sql_migrations SET name = 'OtherBuildMigration'
432 WHERE migration_id = 60`;
433 }),
434 );
435
436 const destination = yield* createFixtureSource(destDir);
437 const sourceBefore = yield* fs.readFile(source);
438 const destinationBefore = yield* fs.readFile(destination);
439
440 const error = yield* runMigrateDevDb(
441 { baseDir: destDir, source, projects: 5, threadsPerProject: 2 },
442 { sharedHome: sourceDir },
443 ).pipe(Effect.flip);
444 assert.equal(error._tag, "MigrateDevDbPhaseError");
445 if (error._tag === "MigrateDevDbPhaseError") {
446 assert.equal(error.phase, "migrate");
447 assert.include(String(error.cause), "60_ThreadSnapshotWindowIndexes");
448 }
449 assert.deepStrictEqual(yield* fs.readFile(source), sourceBefore);
450 assert.deepStrictEqual(yield* fs.readFile(destination), destinationBefore);
451 }),
452 );
453
454 it.effect("imports a dev seed whose ids after 67 came from the peer links branch", () =>
455 Effect.gen(function* () {
456 const fs = yield* FileSystem.FileSystem;
457 const sourceDir = yield* fs.makeTempDirectoryScoped({ prefix: "migrate-dev-db-peer-" });
458 const destDir = yield* fs.makeTempDirectoryScoped({ prefix: "migrate-dev-db-peer-dest-" });
459 const source = yield* createFixtureSource(sourceDir, 67);
460 // Mirrors a real ~/.t3 seed written by a build whose 68-70 differ from main's.
461 yield* withDatabase(
462 source,
463 Effect.gen(function* () {
464 const sql = yield* SqlClient.SqlClient;
465 yield* sql`CREATE TABLE peer_environment_links (
466 environment_id TEXT PRIMARY KEY,
467 secret_name TEXT NOT NULL,
468 label TEXT NOT NULL,
469 urls_json TEXT NOT NULL,
470 access TEXT NOT NULL,
471 linked_at TEXT NOT NULL,
472 expires_at TEXT NOT NULL,
473 last_reached_at TEXT,
474 last_error TEXT,
475 fenced INTEGER NOT NULL DEFAULT 0)`;
476 yield* sql`CREATE INDEX orchestration_v2_projection_turn_items_waiting_link_idx
477 ON orchestration_v2_projection_turn_items(thread_id, updated_at DESC, turn_item_id DESC)
478 WHERE type = 'link_request' AND status = 'waiting'`;
479 yield* insertPeerLink(sql);
480 yield* sql`INSERT INTO effect_sql_migrations (migration_id, name) VALUES
481 (68, 'PeerLinks'), (69, 'PeerLinkFence'), (70, 'WaitingLinkRequestIndex')`;
482 }),
483 );
484
485 const result = yield* runMigrateDevDb(
486 { baseDir: destDir, source, projects: 5, threadsPerProject: 2 },
487 { sharedHome: sourceDir },
488 );
489
490 const imported = yield* withDatabase(
491 result.databasePath,
492 Effect.gen(function* () {
493 const sql = yield* SqlClient.SqlClient;
494 const threads = yield* sql<{ thread_id: string }>`
495 SELECT thread_id FROM orchestration_v2_projection_threads ORDER BY thread_id`;
496 const schema = yield* sql<{ name: string }>`
497 SELECT name FROM sqlite_master WHERE name IN (
498 'orchestration_v2_projection_turn_items_stop_request_idx',
499 'orchestration_v2_message_search')
500 ORDER BY name`;
501 return { threads, schema };
502 }),
503 );
504 assert.deepStrictEqual(
505 imported.threads.map((row) => row.thread_id),
506 ["fork-thread", "settled-thread", "stopped-thread"],
507 );
508 assert.deepStrictEqual(
509 imported.schema.map((row) => row.name),
510 [
511 "orchestration_v2_message_search",
512 "orchestration_v2_projection_turn_items_stop_request_idx",
513 ],
514 );
515 }),
516 );
517
518 it.effect("refuses while a dev server holds the destination", () =>
519 Effect.gen(function* () {
520 const fs = yield* FileSystem.FileSystem;
521 const path = yield* Path.Path;
522 const sourceDir = yield* fs.makeTempDirectoryScoped({ prefix: "migrate-dev-db-busy-" });
523 const destDir = yield* fs.makeTempDirectoryScoped({ prefix: "migrate-dev-db-busy-dest-" });
524 const source = yield* createFixtureSource(sourceDir);
525 const stateDir = path.join(destDir, "userdata");
526 yield* fs.makeDirectory(stateDir, { recursive: true });
527 yield* fs.writeFileString(
528 path.join(stateDir, "server-runtime.json"),
529 `{"version":1,"pid":${process.pid}}`,
530 );
531
532 const error = yield* runMigrateDevDb(
533 { baseDir: destDir, source, projects: 5, threadsPerProject: 10 },
534 { sharedHome: sourceDir },
535 ).pipe(Effect.flip);
536 assert.equal(error._tag, "MigrateDevDbServerRunningError");
537 if (error._tag === "MigrateDevDbServerRunningError") {
538 assert.equal(error.pid, process.pid);
539 }
540 }),
541 );
542
543 it.effect("refuses a source that resolves to a destination path", () =>
544 Effect.gen(function* () {
545 const fs = yield* FileSystem.FileSystem;
546 const path = yield* Path.Path;
547 const sharedDir = yield* fs.makeTempDirectoryScoped({ prefix: "migrate-dev-db-overlap-" });
548 const destDir = yield* fs.makeTempDirectoryScoped({ prefix: "migrate-dev-db-overlap-dest-" });
549 const leftoverSnapshot = path.join(destDir, "userdata", "statev2.sqlite.migrate-dev-db-tmp");
550 yield* fs.makeDirectory(path.dirname(leftoverSnapshot), { recursive: true });
551 yield* fs.writeFileString(leftoverSnapshot, "not a real db");
552
553 const error = yield* runMigrateDevDb(
554 { baseDir: destDir, source: leftoverSnapshot, projects: 5, threadsPerProject: 10 },
555 { sharedHome: sharedDir },
556 ).pipe(Effect.flip);
557 assert.equal(error._tag, "MigrateDevDbSourceIsDestinationError");
558 assert.equal(yield* fs.exists(leftoverSnapshot), true);
559 }),
560 );
561
562 it.effect("refuses to rebuild the shared home", () =>
563 Effect.gen(function* () {
564 const fs = yield* FileSystem.FileSystem;
565 const sourceDir = yield* fs.makeTempDirectoryScoped({ prefix: "migrate-dev-db-shared-" });
566 const source = yield* createFixtureSource(sourceDir);
567
568 const error = yield* runMigrateDevDb(
569 { baseDir: sourceDir, source, projects: 5, threadsPerProject: 10 },
570 { sharedHome: sourceDir },
571 ).pipe(Effect.flip);
572 assert.equal(error._tag, "MigrateDevDbSharedHomeError");
573 }),
574 );
575});