authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-08-08 22:51:18-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-08 19:51:18-07:00
logddaa6afef55e0da53950c7ed45f7d91b717475a5
treed8dbe876bb0e67dc7db3db60753e8c0e25473197
parent6f69b4407f1e6e1aa56e46bbb51a0b133374eeae
signature Signed by PGP key B5690EEEBB952194

feat(server): vp run migrate-dev-db seeds worktree dev dbs with real data (#5773)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

3 files changed, 756 insertions(+), 0 deletions(-)

apps/server/scripts/migrate-dev-db.test.ts created+195
...@@ -0,0 +1,195 @@
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/unstable/sql/SqlClient";
7
8import { runMigrations } from "../src/persistence/Migrations.ts";
9import * as NodeSqliteClient from "../src/persistence/NodeSqliteClient.ts";
10import { runMigrateDevDb } from "./migrate-dev-db.ts";
11
12const withDatabase = <A, E>(
13 databasePath: string,
14 effect: Effect.Effect<A, E, SqlClient.SqlClient>,
15) => effect.pipe(Effect.provide(NodeSqliteClient.layer({ filename: databasePath })));
16
17/** A migrated source db with one thread per lifecycle state. Only
18 * `stopped-thread` qualifies for the clone. */
19const createFixtureSource = Effect.fn("createMigrateDevDbFixtureSource")(function* (
20 baseDir: string,
21) {
22 const fs = yield* FileSystem.FileSystem;
23 const path = yield* Path.Path;
24 const stateDir = path.join(baseDir, "userdata");
25 const databasePath = path.join(stateDir, "state.sqlite");
26 yield* fs.makeDirectory(stateDir, { recursive: true });
27 yield* withDatabase(
28 databasePath,
29 Effect.gen(function* () {
30 const sql = yield* SqlClient.SqlClient;
31 yield* runMigrations();
32 // The real shared db carries this column from a branch build without a
33 // matching migration; reproduce that drift so the filter is exercised.
34 yield* sql`ALTER TABLE projection_threads ADD COLUMN monitor_json TEXT`;
35
36 yield* sql`INSERT INTO projection_projects
37 (project_id, title, workspace_root, scripts_json, created_at, updated_at, deleted_at)
38 VALUES
39 ('project-kept', 'Kept', '/tmp/kept', '[]', '2026-08-01', '2026-08-01', NULL),
40 ('project-deleted', 'Deleted', '/tmp/deleted', '[]', '2026-08-01', '2026-08-02', '2026-08-02')`;
41
42 const threads = [
43 ["stopped-thread", "project-kept", "stopped", null, null],
44 ["running-thread", "project-kept", "running", null, null],
45 ["settled-thread", "project-kept", "stopped", "2026-08-01", null],
46 ["monitored-thread", "project-kept", "stopped", null, '{"kind":"pr"}'],
47 ["deleted-project-thread", "project-deleted", "stopped", null, null],
48 ] as const;
49 for (const [threadId, projectId, status, settledAt, monitorJson] of threads) {
50 yield* sql`INSERT INTO projection_threads
51 (thread_id, project_id, title, created_at, updated_at, settled_at, monitor_json)
52 VALUES (${threadId}, ${projectId}, ${threadId}, '2026-08-01', '2026-08-01', ${settledAt}, ${monitorJson})`;
53 yield* sql`INSERT INTO projection_thread_sessions (thread_id, status, updated_at)
54 VALUES (${threadId}, ${status}, '2026-08-01')`;
55 yield* sql`INSERT INTO orchestration_events
56 (event_id, aggregate_kind, stream_id, stream_version, event_type, occurred_at, actor_kind, payload_json, metadata_json)
57 VALUES (${`event-${threadId}`}, 'thread', ${threadId}, 0, 'thread.created', '2026-08-01', 'user', '{}', '{}')`;
58 }
59 yield* sql`INSERT INTO auth_sessions (session_id, subject, scopes, method, issued_at, expires_at)
60 VALUES ('session-1', 'user', '[]', 'pairing', '2026-08-01', '2027-08-01')`;
61 }),
62 );
63 return databasePath;
64});
65
66it.layer(NodeServices.layer)("migrate-dev-db", (it) => {
67 it.effect("keeps only stopped threads from live projects and clears auth state", () =>
68 Effect.gen(function* () {
69 const fs = yield* FileSystem.FileSystem;
70 const path = yield* Path.Path;
71 const sourceDir = yield* fs.makeTempDirectoryScoped({ prefix: "migrate-dev-db-src-" });
72 const destDir = yield* fs.makeTempDirectoryScoped({ prefix: "migrate-dev-db-dest-" });
73 const source = yield* createFixtureSource(sourceDir);
74
75 const result = yield* runMigrateDevDb(
76 { baseDir: destDir, source, projects: 5, threadsPerProject: 10 },
77 { sharedHome: sourceDir },
78 );
79
80 assert.equal(result.databasePath, path.join(destDir, "userdata", "state.sqlite"));
81 const kept = yield* withDatabase(
82 result.databasePath,
83 Effect.gen(function* () {
84 const sql = yield* SqlClient.SqlClient;
85 const threads = yield* sql<{ thread_id: string }>`
86 SELECT thread_id FROM projection_threads ORDER BY thread_id`;
87 const events = yield* sql<{ stream_id: string }>`
88 SELECT stream_id FROM orchestration_events`;
89 const [auth] = yield* sql<{ count: number }>`
90 SELECT COUNT(*) AS count FROM auth_sessions`;
91 return { threads, events, authCount: auth?.count ?? 0 };
92 }),
93 );
94 assert.deepStrictEqual(
95 kept.threads.map((row) => row.thread_id),
96 ["stopped-thread"],
97 );
98 assert.deepStrictEqual(
99 kept.events.map((row) => row.stream_id),
100 ["stopped-thread"],
101 );
102 assert.equal(kept.authCount, 0);
103 }),
104 );
105
106 it.effect("fails loudly on a migration slot collision", () =>
107 Effect.gen(function* () {
108 const fs = yield* FileSystem.FileSystem;
109 const sourceDir = yield* fs.makeTempDirectoryScoped({ prefix: "migrate-dev-db-slot-" });
110 const destDir = yield* fs.makeTempDirectoryScoped({ prefix: "migrate-dev-db-slot-dest-" });
111 const source = yield* createFixtureSource(sourceDir);
112 // Simulate another branch having claimed slot 1 first: the id is
113 // recorded, so this checkout's migration 1 silently never runs.
114 yield* withDatabase(
115 source,
116 Effect.gen(function* () {
117 const sql = yield* SqlClient.SqlClient;
118 yield* sql`UPDATE effect_sql_migrations
119 SET name = 'SomebodyElsesMigration' WHERE migration_id = 1`;
120 }),
121 );
122
123 const error = yield* runMigrateDevDb(
124 { baseDir: destDir, source, projects: 5, threadsPerProject: 10 },
125 { sharedHome: sourceDir },
126 ).pipe(Effect.flip);
127 assert.equal(error._tag, "MigrateDevDbSlotCollisionError");
128 if (error._tag === "MigrateDevDbSlotCollisionError") {
129 assert.equal(error.slot, 1);
130 assert.equal(error.appliedName, "SomebodyElsesMigration");
131 }
132 }),
133 );
134
135 it.effect("refuses while a dev server holds the destination", () =>
136 Effect.gen(function* () {
137 const fs = yield* FileSystem.FileSystem;
138 const path = yield* Path.Path;
139 const sourceDir = yield* fs.makeTempDirectoryScoped({ prefix: "migrate-dev-db-busy-" });
140 const destDir = yield* fs.makeTempDirectoryScoped({ prefix: "migrate-dev-db-busy-dest-" });
141 const source = yield* createFixtureSource(sourceDir);
142 // This test process stands in for a live dev server.
143 const stateDir = path.join(destDir, "userdata");
144 yield* fs.makeDirectory(stateDir, { recursive: true });
145 yield* fs.writeFileString(
146 path.join(stateDir, "server-runtime.json"),
147 `{"version":1,"pid":${process.pid}}`,
148 );
149
150 const error = yield* runMigrateDevDb(
151 { baseDir: destDir, source, projects: 5, threadsPerProject: 10 },
152 { sharedHome: sourceDir },
153 ).pipe(Effect.flip);
154 assert.equal(error._tag, "MigrateDevDbServerRunningError");
155 if (error._tag === "MigrateDevDbServerRunningError") {
156 assert.equal(error.pid, process.pid);
157 }
158 }),
159 );
160
161 it.effect("refuses a source that resolves to a destination path", () =>
162 Effect.gen(function* () {
163 const fs = yield* FileSystem.FileSystem;
164 const path = yield* Path.Path;
165 const sharedDir = yield* fs.makeTempDirectoryScoped({ prefix: "migrate-dev-db-overlap-" });
166 const destDir = yield* fs.makeTempDirectoryScoped({ prefix: "migrate-dev-db-overlap-dest-" });
167 // A leftover snapshot from a prior failed run, passed as --source: it
168 // must not be deleted before it is read.
169 const leftoverSnapshot = path.join(destDir, "userdata", "state.sqlite.migrate-dev-db-tmp");
170 yield* fs.makeDirectory(path.dirname(leftoverSnapshot), { recursive: true });
171 yield* fs.writeFileString(leftoverSnapshot, "not a real db");
172
173 const error = yield* runMigrateDevDb(
174 { baseDir: destDir, source: leftoverSnapshot, projects: 5, threadsPerProject: 10 },
175 { sharedHome: sharedDir },
176 ).pipe(Effect.flip);
177 assert.equal(error._tag, "MigrateDevDbSourceIsDestinationError");
178 assert.equal(yield* fs.exists(leftoverSnapshot), true);
179 }),
180 );
181
182 it.effect("refuses to rebuild the shared home", () =>
183 Effect.gen(function* () {
184 const fs = yield* FileSystem.FileSystem;
185 const sourceDir = yield* fs.makeTempDirectoryScoped({ prefix: "migrate-dev-db-shared-" });
186 const source = yield* createFixtureSource(sourceDir);
187
188 const error = yield* runMigrateDevDb(
189 { baseDir: sourceDir, source, projects: 5, threadsPerProject: 10 },
190 { sharedHome: sourceDir },
191 ).pipe(Effect.flip);
192 assert.equal(error._tag, "MigrateDevDbSharedHomeError");
193 }),
194 );
195});
apps/server/scripts/migrate-dev-db.ts created+560
...@@ -0,0 +1,560 @@
1#!/usr/bin/env node
2
3/**
4 * Rebuild an isolated dev database from a pruned snapshot of the real
5 * ~/.t3 database, then run this checkout's migrations against it.
6 *
7 * `vp run migrate-dev-db` from a worktree:
8 * 1. Nukes `<worktree>/.t3/userdata/state.sqlite`.
9 * 2. Snapshots the real db (read-only VACUUM INTO) and prunes it to the
10 * most recently updated projects and, per project, the most recent
11 * threads that have fully stopped. Working, settled, and monitored
12 * threads are skipped so the dev server never adopts live work.
13 * Auth sessions, pairing links, command receipts, and provider
14 * runtime rows are dropped — pair a fresh browser against dev.
15 * 3. Runs migrations on the result. Because the clone carries the real
16 * `effect_sql_migrations` table, this proves a new migration applies
17 * on top of the real applied set, and the slot check below catches
18 * the silent failure where two branches claim the same
19 * `Migrations/NNN_` id (the second one's CREATE TABLE is skipped).
20 *
21 * The event log (`orchestration_events`) is pruned per stream while
22 * `sqlite_sequence` and `projection_state` carry over untouched, so new
23 * events keep appending after the old high-water mark and projection
24 * cursors never rewind.
25 */
26
27// @effect-diagnostics nodeBuiltinImport:off - node:os resolves the shared T3 home guard.
28import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
29import * as NodeServices from "@effect/platform-node/NodeServices";
30import * as NodeOS from "node:os";
31import { resolveWorktreeT3Home } from "@t3tools/shared/devHome";
32import * as Console from "effect/Console";
33import * as Effect from "effect/Effect";
34import * as FileSystem from "effect/FileSystem";
35import * as Option from "effect/Option";
36import * as Path from "effect/Path";
37import * as Schema from "effect/Schema";
38import * as SqlClient from "effect/unstable/sql/SqlClient";
39import { Command, Flag } from "effect/unstable/cli";
40
41import { migrationManifest, runMigrations } from "../src/persistence/Migrations.ts";
42import * as NodeSqliteClient from "../src/persistence/NodeSqliteClient.ts";
43
44export class MigrateDevDbNotInWorktreeError extends Schema.TaggedErrorClass<MigrateDevDbNotInWorktreeError>()(
45 "MigrateDevDbNotInWorktreeError",
46 {},
47) {
48 override get message(): string {
49 return "Not inside a linked git worktree. Pass --base-dir to target an isolated .t3 directory.";
50 }
51}
52
53export class MigrateDevDbSharedHomeError extends Schema.TaggedErrorClass<MigrateDevDbSharedHomeError>()(
54 "MigrateDevDbSharedHomeError",
55 {},
56) {
57 override get message(): string {
58 return "Refusing to rebuild the shared ~/.t3 database. Use an isolated --base-dir.";
59 }
60}
61
62export class MigrateDevDbSourceMissingError extends Schema.TaggedErrorClass<MigrateDevDbSourceMissingError>()(
63 "MigrateDevDbSourceMissingError",
64 {
65 sourcePath: Schema.String,
66 },
67) {
68 override get message(): string {
69 return `Source database does not exist at '${this.sourcePath}'.`;
70 }
71}
72
73export class MigrateDevDbSourceIsDestinationError extends Schema.TaggedErrorClass<MigrateDevDbSourceIsDestinationError>()(
74 "MigrateDevDbSourceIsDestinationError",
75 {
76 sourcePath: Schema.String,
77 },
78) {
79 override get message(): string {
80 return `Source database '${this.sourcePath}' resolves to a path this command rewrites. Pick a different --source or --base-dir.`;
81 }
82}
83
84export class MigrateDevDbServerRunningError extends Schema.TaggedErrorClass<MigrateDevDbServerRunningError>()(
85 "MigrateDevDbServerRunningError",
86 {
87 databasePath: Schema.String,
88 pid: Schema.Number,
89 },
90) {
91 override get message(): string {
92 return `Dev database at '${this.databasePath}' is open by a running server (pid ${this.pid} per server-runtime.json). Stop that server first; if that pid is not actually a T3 server (stale descriptor, reused pid), delete the server-runtime.json next to the database and retry.`;
93 }
94}
95
96export class MigrateDevDbDestinationBusyError extends Schema.TaggedErrorClass<MigrateDevDbDestinationBusyError>()(
97 "MigrateDevDbDestinationBusyError",
98 {
99 databasePath: Schema.String,
100 reason: Schema.Literals(["write-locked", "wal-held"]),
101 cause: Schema.optional(Schema.Defect()),
102 },
103) {
104 override get message(): string {
105 const detail =
106 this.reason === "write-locked"
107 ? "the database is write-locked"
108 : "another connection is holding its WAL";
109 return `Dev database at '${this.databasePath}' looks in use (${detail}). Stop the dev server first; if none is running, delete the -wal/-shm files next to it.`;
110 }
111}
112
113/**
114 * Two branches claimed the same Migrations/NNN_ slot: the id was already
115 * recorded under a different name, so this checkout's migration was
116 * silently skipped and its schema changes never applied.
117 */
118export class MigrateDevDbSlotCollisionError extends Schema.TaggedErrorClass<MigrateDevDbSlotCollisionError>()(
119 "MigrateDevDbSlotCollisionError",
120 {
121 slot: Schema.Number,
122 codeName: Schema.String,
123 appliedName: Schema.String,
124 },
125) {
126 override get message(): string {
127 return `Migration slot collision at ${this.slot}: this checkout registers '${this.codeName}' but the database already applied '${this.appliedName}' in that slot. Renumber the new migration to a free slot.`;
128 }
129}
130
131export class MigrateDevDbPhaseError extends Schema.TaggedErrorClass<MigrateDevDbPhaseError>()(
132 "MigrateDevDbPhaseError",
133 {
134 phase: Schema.Literals(["snapshot", "prune", "compact", "migrate", "verify"]),
135 databasePath: Schema.String,
136 cause: Schema.Defect(),
137 },
138) {
139 override get message(): string {
140 return `migrate-dev-db failed during ${this.phase} on '${this.databasePath}'.`;
141 }
142}
143
144export interface RunMigrateDevDbInput {
145 /** Isolated .t3 directory. Defaults to `<worktree>/.t3` of the cwd. */
146 readonly baseDir?: string | undefined;
147 /** Source database. Defaults to `~/.t3/userdata/state.sqlite`. */
148 readonly source?: string | undefined;
149 readonly projects: number;
150 readonly threadsPerProject: number;
151}
152
153export interface RunMigrateDevDbOptions {
154 /** Overridable for tests; the directory writes must never target. */
155 readonly sharedHome?: string | undefined;
156}
157
158interface KeptProject {
159 readonly title: string;
160 readonly threads: number;
161}
162
163const removeDatabaseFiles = Effect.fn("removeDatabaseFiles")(function* (databasePath: string) {
164 const fs = yield* FileSystem.FileSystem;
165 for (const suffix of ["", "-wal", "-shm"]) {
166 yield* fs.remove(`${databasePath}${suffix}`).pipe(Effect.orElseSucceed(() => undefined));
167 }
168});
169
170/** The slice of server-runtime.json this script cares about. */
171const ServerRuntimeState = Schema.fromJsonString(Schema.Struct({ pid: Schema.Number }));
172const decodeServerRuntimeState = Schema.decodeEffect(ServerRuntimeState);
173
174const isProcessAlive = (pid: number): boolean => {
175 try {
176 process.kill(pid, 0);
177 return true;
178 } catch (error) {
179 // EPERM means the process exists but belongs to someone else.
180 return (error as NodeJS.ErrnoException).code === "EPERM";
181 }
182};
183
184/** Liveness probe for a running dev server. The server writes its pid to
185 * server-runtime.json next to the database, which also catches an idle
186 * server holding an open-but-inactive connection. The SQL probes below back
187 * that up: BEGIN IMMEDIATE fails while a writer is active, and
188 * wal_checkpoint(TRUNCATE) reports busy while another connection holds the
189 * WAL. A leftover -shm alone is not a signal — read-only connections cannot
190 * clean it up on close. */
191const ensureNotInUse = Effect.fn("ensureDevDbNotInUse")(function* (databasePath: string) {
192 const fs = yield* FileSystem.FileSystem;
193 const path = yield* Path.Path;
194
195 const runtimeStatePath = path.join(path.dirname(databasePath), "server-runtime.json");
196 const runtimeState = yield* fs.readFileString(runtimeStatePath).pipe(
197 Effect.flatMap(decodeServerRuntimeState),
198 // A missing or malformed descriptor is not a liveness signal.
199 Effect.option,
200 );
201 if (Option.isSome(runtimeState) && isProcessAlive(runtimeState.value.pid)) {
202 return yield* new MigrateDevDbServerRunningError({
203 databasePath,
204 pid: runtimeState.value.pid,
205 });
206 }
207
208 if (!(yield* fs.exists(databasePath))) {
209 return;
210 }
211 const checkpoint = yield* Effect.gen(function* () {
212 const sql = yield* SqlClient.SqlClient;
213 yield* sql.unsafe("PRAGMA busy_timeout = 0").unprepared;
214 yield* sql.unsafe("BEGIN IMMEDIATE").unprepared;
215 yield* sql.unsafe("ROLLBACK").unprepared;
216 return yield* sql.unsafe<{ busy: number }>("PRAGMA wal_checkpoint(TRUNCATE)").unprepared;
217 }).pipe(
218 Effect.provide(NodeSqliteClient.layer({ filename: databasePath })),
219 Effect.mapError(
220 (cause) =>
221 new MigrateDevDbDestinationBusyError({
222 databasePath,
223 reason: "write-locked",
224 cause,
225 }),
226 ),
227 );
228 if (checkpoint[0] !== undefined && Number(checkpoint[0].busy) !== 0) {
229 return yield* new MigrateDevDbDestinationBusyError({
230 databasePath,
231 reason: "wal-held",
232 });
233 }
234});
235
236const pruneSnapshot = Effect.fn("pruneDevDbSnapshot")(function* (input: RunMigrateDevDbInput) {
237 const sql = yield* SqlClient.SqlClient;
238
239 // The shared db can carry monitor_json from a branch build even though no
240 // migration in this checkout creates it, so filter it only when present.
241 const threadColumns = yield* sql<{ name: string }>`
242 SELECT name FROM pragma_table_info('projection_threads')`;
243 const monitorFilter = threadColumns.some((column) => column.name === "monitor_json")
244 ? "AND t.monitor_json IS NULL"
245 : "";
246
247 // "Stopped" is the persisted subset of the UI's thread status: the session
248 // reached status 'stopped' and nothing marks the thread settled or
249 // monitored. The in-memory working/monitoring liveness never persists, so
250 // filtering the session status is sufficient.
251 yield* sql.unsafe(`CREATE TEMP TABLE stopped_threads AS
252 SELECT t.thread_id, t.project_id, t.updated_at
253 FROM projection_threads t
254 JOIN projection_thread_sessions s ON s.thread_id = t.thread_id
255 WHERE t.deleted_at IS NULL
256 AND t.archived_at IS NULL
257 AND t.settled_at IS NULL
258 AND (t.settled_override IS NULL OR t.settled_override <> 'settled')
259 ${monitorFilter}
260 AND s.status = 'stopped'`).unprepared;
261
262 // Projects with clonable threads outrank empty-but-recent ones: the point
263 // of the exercise is thread data, not the project list.
264 yield* sql`CREATE TEMP TABLE kept_projects AS
265 SELECT p.project_id
266 FROM projection_projects p
267 LEFT JOIN (
268 SELECT project_id, MAX(updated_at) AS last_stopped_at
269 FROM stopped_threads
270 GROUP BY project_id
271 ) q ON q.project_id = p.project_id
272 WHERE p.deleted_at IS NULL
273 ORDER BY (q.last_stopped_at IS NULL) ASC,
274 COALESCE(q.last_stopped_at, p.updated_at) DESC
275 LIMIT ${input.projects}`;
276
277 yield* sql`CREATE TEMP TABLE kept_threads AS
278 SELECT thread_id FROM (
279 SELECT
280 st.thread_id,
281 ROW_NUMBER() OVER (
282 PARTITION BY st.project_id
283 ORDER BY st.updated_at DESC
284 ) AS recency_rank
285 FROM stopped_threads st
286 JOIN kept_projects kp ON kp.project_id = st.project_id
287 )
288 WHERE recency_rank <= ${input.threadsPerProject}`;
289
290 yield* sql.withTransaction(
291 Effect.gen(function* () {
292 yield* sql`DELETE FROM projection_projects
293 WHERE project_id NOT IN (SELECT project_id FROM kept_projects)`;
294 yield* sql`DELETE FROM projection_threads
295 WHERE thread_id NOT IN (SELECT thread_id FROM kept_threads)`;
296 for (const table of [
297 "projection_thread_messages",
298 "projection_thread_activities",
299 "projection_thread_sessions",
300 "projection_turns",
301 "projection_pending_approvals",
302 "projection_thread_proposed_plans",
303 "checkpoint_diff_blobs",
304 ]) {
305 yield* sql.unsafe(
306 `DELETE FROM ${table} WHERE thread_id NOT IN (SELECT thread_id FROM kept_threads)`,
307 ).unprepared;
308 }
309 yield* sql`DELETE FROM orchestration_events
310 WHERE (aggregate_kind = 'thread'
311 AND stream_id NOT IN (SELECT thread_id FROM kept_threads))
312 OR (aggregate_kind = 'project'
313 AND stream_id NOT IN (SELECT project_id FROM kept_projects))`;
314 yield* sql`DELETE FROM orchestration_command_receipts`;
315 yield* sql`DELETE FROM provider_session_runtime`;
316 yield* sql`DELETE FROM auth_sessions`;
317 yield* sql`DELETE FROM auth_pairing_links`;
318 }),
319 );
320
321 const keptProjects = yield* sql<{ title: string; threads: number }>`
322 SELECT
323 p.title,
324 (SELECT COUNT(*) FROM projection_threads t WHERE t.project_id = p.project_id) AS threads
325 FROM projection_projects p
326 ORDER BY p.updated_at DESC`;
327 const [events] = yield* sql<{ count: number }>`
328 SELECT COUNT(*) AS count FROM orchestration_events`;
329
330 return {
331 projects: keptProjects as ReadonlyArray<KeptProject>,
332 eventCount: events?.count ?? 0,
333 };
334});
335
336/** Compare this checkout's migration registry against what the cloned
337 * database recorded: same slot under a different name means the migration
338 * was skipped, not applied. */
339const verifyMigrationSlots = Effect.fn("verifyMigrationSlots")(function* () {
340 const sql = yield* SqlClient.SqlClient;
341 const applied = yield* sql<{ migration_id: number; name: string }>`
342 SELECT migration_id, name FROM effect_sql_migrations`;
343 const appliedById = new Map(applied.map((row) => [Number(row.migration_id), row.name]));
344 for (const [slot, codeName] of migrationManifest) {
345 const appliedName = appliedById.get(slot);
346 if (appliedName !== undefined && appliedName !== codeName) {
347 return yield* new MigrateDevDbSlotCollisionError({ slot, codeName, appliedName });
348 }
349 }
350});
351
352export const runMigrateDevDb = Effect.fn("runMigrateDevDb")(function* (
353 input: RunMigrateDevDbInput,
354 options: RunMigrateDevDbOptions = {},
355) {
356 // SQLite treats a negative LIMIT as "no limit", which would clone
357 // everything. The CLI flags validate this too; this covers direct callers.
358 if (input.projects < 1 || input.threadsPerProject < 0) {
359 return yield* Effect.die("projects must be >= 1 and threadsPerProject >= 0");
360 }
361 const fs = yield* FileSystem.FileSystem;
362 const path = yield* Path.Path;
363
364 const sharedHome = path.resolve(options.sharedHome ?? path.join(NodeOS.homedir(), ".t3"));
365 const sourcePath = path.resolve(
366 input.source ?? path.join(sharedHome, "userdata", "state.sqlite"),
367 );
368
369 const baseDir =
370 input.baseDir !== undefined
371 ? path.resolve(input.baseDir)
372 : yield* resolveWorktreeT3Home(process.cwd());
373 if (baseDir === undefined) {
374 return yield* new MigrateDevDbNotInWorktreeError();
375 }
376 const stateDir = path.join(baseDir, "userdata");
377 const databasePath = path.join(stateDir, "state.sqlite");
378 const snapshotPath = `${databasePath}.migrate-dev-db-tmp`;
379
380 if (!(yield* fs.exists(sourcePath))) {
381 return yield* new MigrateDevDbSourceMissingError({ sourcePath });
382 }
383 const [canonicalBaseDir, canonicalSharedHome] = yield* Effect.all([
384 fs.realPath(baseDir).pipe(Effect.orElseSucceed(() => baseDir)),
385 fs.realPath(sharedHome).pipe(Effect.orElseSucceed(() => sharedHome)),
386 ]);
387 if (canonicalBaseDir === canonicalSharedHome) {
388 return yield* new MigrateDevDbSharedHomeError();
389 }
390 // The destination db and snapshot both get deleted below; a --source that
391 // resolves to either (e.g. a leftover snapshot file) would be destroyed
392 // before it is ever read.
393 const canonicalSourcePath = yield* fs
394 .realPath(sourcePath)
395 .pipe(Effect.orElseSucceed(() => sourcePath));
396 for (const destination of [databasePath, snapshotPath]) {
397 const canonicalDestination = yield* fs
398 .realPath(destination)
399 .pipe(Effect.orElseSucceed(() => destination));
400 if (canonicalSourcePath === canonicalDestination) {
401 return yield* new MigrateDevDbSourceIsDestinationError({ sourcePath });
402 }
403 }
404
405 yield* fs.makeDirectory(stateDir, { recursive: true });
406 yield* ensureNotInUse(databasePath);
407
408 const wrapPhase =
409 (phase: MigrateDevDbPhaseError["phase"], phaseDatabasePath: string) =>
410 <A, E, R>(effect: Effect.Effect<A, E, R>) =>
411 effect.pipe(
412 Effect.mapError(
413 (cause) => new MigrateDevDbPhaseError({ phase, databasePath: phaseDatabasePath, cause }),
414 ),
415 );
416
417 yield* removeDatabaseFiles(snapshotPath);
418 // The snapshot is a full-size copy of the source; make sure it is removed
419 // even when a phase fails partway through.
420 const { executedMigrations, pruned } = yield* Effect.gen(function* () {
421 yield* Console.log(`Snapshotting ${sourcePath} (read-only)...`);
422 yield* Effect.gen(function* () {
423 const sql = yield* SqlClient.SqlClient;
424 yield* sql`VACUUM INTO ${snapshotPath}`;
425 }).pipe(
426 Effect.provide(NodeSqliteClient.layer({ filename: sourcePath, readonly: true })),
427 wrapPhase("snapshot", sourcePath),
428 );
429
430 // Migrate before pruning: a source older than this checkout would
431 // otherwise crash the prune queries on columns that don't exist yet.
432 // Running against the full snapshot also exercises new migrations on the
433 // same data volume the real database would face.
434 yield* Console.log("Running migrations on the snapshot...");
435 const executed = yield* Effect.gen(function* () {
436 const sql = yield* SqlClient.SqlClient;
437 // Mirror server boot (persistence/Layers/Sqlite.ts).
438 yield* sql.unsafe("PRAGMA foreign_keys = ON").unprepared;
439 return yield* runMigrations();
440 }).pipe(
441 Effect.provide(NodeSqliteClient.layer({ filename: snapshotPath })),
442 wrapPhase("migrate", snapshotPath),
443 );
444
445 // Verify while the snapshot is still the only thing touched: a slot
446 // collision must abort before the old worktree db gets replaced with a
447 // schema whose colliding migration was silently skipped.
448 yield* verifyMigrationSlots().pipe(
449 Effect.provide(NodeSqliteClient.layer({ filename: snapshotPath })),
450 Effect.catchTags({
451 SqlError: (cause) =>
452 Effect.fail(
453 new MigrateDevDbPhaseError({ phase: "verify", databasePath: snapshotPath, cause }),
454 ),
455 }),
456 );
457
458 yield* Console.log(
459 `Pruning to ${input.projects} projects, ${input.threadsPerProject} stopped threads each...`,
460 );
461 const result = yield* pruneSnapshot(input).pipe(
462 Effect.provide(NodeSqliteClient.layer({ filename: snapshotPath })),
463 wrapPhase("prune", snapshotPath),
464 );
465
466 yield* Console.log(`Compacting into ${databasePath}...`);
467 // Re-check right before the swap: a dev server started while the
468 // snapshot was migrating and pruning must not lose its database.
469 yield* ensureNotInUse(databasePath);
470 yield* removeDatabaseFiles(databasePath);
471 yield* Effect.gen(function* () {
472 const sql = yield* SqlClient.SqlClient;
473 yield* sql`VACUUM INTO ${databasePath}`;
474 }).pipe(
475 Effect.provide(NodeSqliteClient.layer({ filename: snapshotPath })),
476 wrapPhase("compact", databasePath),
477 );
478 return { executedMigrations: executed, pruned: result };
479 }).pipe(Effect.ensuring(removeDatabaseFiles(snapshotPath)));
480 yield* fs.chmod(databasePath, 0o600);
481
482 yield* Effect.gen(function* () {
483 const sql = yield* SqlClient.SqlClient;
484 // WAL does not survive VACUUM INTO; set it so first `vp run dev` finds
485 // the database exactly as server boot would have left it.
486 yield* sql.unsafe("PRAGMA journal_mode = WAL").unprepared;
487 }).pipe(
488 Effect.provide(NodeSqliteClient.layer({ filename: databasePath })),
489 wrapPhase("compact", databasePath),
490 );
491
492 const size = (yield* fs.stat(databasePath)).size;
493 return {
494 databasePath,
495 sizeBytes: Number(size),
496 projects: pruned.projects,
497 eventCount: pruned.eventCount,
498 executedMigrations: executedMigrations.map(([id, name]) => `${id}_${name}`),
499 };
500});
501
502const formatSize = (bytes: number): string =>
503 bytes >= 1024 * 1024
504 ? `${(bytes / (1024 * 1024)).toFixed(1)} MB`
505 : `${(bytes / 1024).toFixed(0)} KB`;
506
507export const migrateDevDbCommand = Command.make(
508 "migrate-dev-db",
509 {
510 projects: Flag.integer("projects").pipe(
511 Flag.withDefault(5),
512 Flag.withDescription("How many recently updated projects to keep."),
513 ),
514 threadsPerProject: Flag.integer("threads-per-project").pipe(
515 Flag.withDefault(10),
516 Flag.withDescription("How many recent stopped threads to keep per project."),
517 ),
518 baseDir: Flag.string("base-dir").pipe(
519 Flag.optional,
520 Flag.withDescription("Isolated .t3 directory. Defaults to the current worktree's .t3."),
521 ),
522 source: Flag.string("source").pipe(
523 Flag.optional,
524 Flag.withDescription("Source database. Defaults to ~/.t3/userdata/state.sqlite."),
525 ),
526 },
527 ({ projects, threadsPerProject, baseDir, source }) =>
528 Effect.gen(function* () {
529 const result = yield* runMigrateDevDb({
530 projects,
531 threadsPerProject,
532 baseDir: Option.getOrUndefined(baseDir),
533 source: Option.getOrUndefined(source),
534 });
535 yield* Console.log("");
536 yield* Console.log(
537 `Dev database ready: ${result.databasePath} (${formatSize(result.sizeBytes)})`,
538 );
539 for (const project of result.projects) {
540 yield* Console.log(` ${project.title}: ${project.threads} threads`);
541 }
542 yield* Console.log(` ${result.eventCount} orchestration events kept`);
543 yield* Console.log(
544 result.executedMigrations.length === 0
545 ? " Migrations: already current (no new migrations in this checkout)"
546 : ` Migrations applied: ${result.executedMigrations.join(", ")}`,
547 );
548 }),
549).pipe(
550 Command.withDescription(
551 "Rebuild the worktree dev database from a pruned snapshot of the real ~/.t3 data, then run migrations.",
552 ),
553);
554
555if (import.meta.main) {
556 Command.run(migrateDevDbCommand, { version: "0.0.0" }).pipe(
557 Effect.provide(NodeServices.layer),
558 NodeRuntime.runMain,
559 );
560}
package.json+1
...@@ -10,6 +10,7 @@...@@ -10,6 +10,7 @@
10 "dev:web": "node scripts/dev-runner.ts dev:web",10 "dev:web": "node scripts/dev-runner.ts dev:web",
11 "dev:marketing": "vp run --filter @t3tools/marketing dev",11 "dev:marketing": "vp run --filter @t3tools/marketing dev",
12 "dev:desktop": "node scripts/dev-runner.ts dev:desktop",12 "dev:desktop": "node scripts/dev-runner.ts dev:desktop",
13 "migrate-dev-db": "node apps/server/scripts/migrate-dev-db.ts",
13 "start": "vp run --filter t3 start",14 "start": "vp run --filter t3 start",
14 "start:desktop": "vp run --filter @t3tools/desktop start",15 "start:desktop": "vp run --filter @t3tools/desktop start",
15 "start:marketing": "vp run --filter @t3tools/marketing preview",16 "start:marketing": "vp run --filter @t3tools/marketing preview",