1import { assert, describe, it } from "@effect/vitest";
2import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
3import * as Effect from "effect/Effect";
4import * as Exit from "effect/Exit";
5import * as Migrator from "effect/sql/Migrator";
6import * as SqlClient from "effect/sql/SqlClient";
7
8import { migrationManifest, runMigrations } from "./Migrations.ts";
9import PullRequestFilesViewed from "./Migrations/053_PullRequestFilesViewed.ts";
10import RemoveRedundantProjectionIndexes from "./Migrations/056_RemoveRedundantProjectionIndexes.ts";
11import OrchestrationV2 from "./Migrations/055_OrchestrationV2.ts";
12
13// The V2 schema is unchanged from the published September 15–16 previews.
14const seedPreview = Effect.gen(function* () {
15 const sql = yield* SqlClient.SqlClient;
16 yield* runMigrations({ toMigrationInclusive: 52 });
17 yield* Migrator.make({})({
18 loader: Migrator.fromRecord({ "53_OrchestrationV2": OrchestrationV2 }),
19 });
20 yield* sql`
21 INSERT INTO orchestration_v2_legacy_imports
22 (thread_id, source_updated_at, shell_imported_at, transcript_imported_at, imported_message_count)
23 VALUES ('preview-thread', '2026-09-15', '2026-09-15', '2026-09-16', 42)
24 `;
25 yield* sql`
26 UPDATE effect_sql_migrations SET created_at = '2026-09-15 00:00:00' WHERE migration_id = 53
27 `;
28});
29
30describe("V2 preview upgrade", () => {
31 it.effect("upgrades a published preview without replaying V2 or losing import progress", () =>
32 Effect.gen(function* () {
33 const sql = yield* SqlClient.SqlClient;
34 yield* seedPreview;
35 const imports = yield* sql`SELECT * FROM orchestration_v2_legacy_imports`;
36 assert.deepStrictEqual(yield* runMigrations(), [
37 [53, "PullRequestFilesViewed"],
38 [54, "ProjectionThreadsAutoSettleDisabledAt"],
39 [56, "RemoveRedundantProjectionIndexes"],
40 [57, "ScheduledTaskWebhooks"],
41 [58, "WebhookRelayDeliveries"],
42 [59, "McpAppModelContext"],
43 [60, "ThreadSnapshotWindowIndexes"],
44 [61, "PullRequestReviewGuides"],
45 [62, "ThreadFailureLookupIndex"],
46 ]);
47 assert.deepStrictEqual(yield* runMigrations(), []);
48 assert.deepStrictEqual(yield* sql`SELECT * FROM orchestration_v2_legacy_imports`, imports);
49 const history = yield* sql<{ readonly migration_id: number; readonly name: string }>`
50 SELECT migration_id, name FROM effect_sql_migrations ORDER BY migration_id
51 `;
52 assert.deepStrictEqual(
53 history.map((row) => [row.migration_id, row.name] as const),
54 migrationManifest,
55 );
56 assert.deepStrictEqual(
57 yield* sql`SELECT created_at FROM effect_sql_migrations WHERE migration_id = 55`,
58 [{ created_at: "2026-09-15 00:00:00" }],
59 );
60 yield* sql`
61 INSERT INTO pull_request_files_viewed
62 (provider, host, repository, number, viewer, path, revision, viewed_at)
63 VALUES ('github', 'github.com', 'owner/repo', 1, 'viewer', 'file.ts', 'revision', '2026-09-17')
64 `;
65 assert.strictEqual((yield* sql`SELECT * FROM pull_request_files_viewed`).length, 1);
66 }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))),
67 );
68
69 it.effect.each([false, true])(
70 "upgrades preview migration 54 with index cleanup %s",
71 (withIndexes) =>
72 Effect.gen(function* () {
73 const sql = yield* SqlClient.SqlClient;
74 yield* runMigrations({ toMigrationInclusive: 52 });
75 yield* Migrator.make({})({
76 loader: Migrator.fromRecord({
77 "53_PullRequestFilesViewed": PullRequestFilesViewed,
78 "54_OrchestrationV2": OrchestrationV2,
79 ...(withIndexes
80 ? { "55_RemoveRedundantProjectionIndexes": RemoveRedundantProjectionIndexes }
81 : {}),
82 }),
83 });
84 yield* runMigrations();
85 assert.deepStrictEqual(yield* runMigrations(), []);
86 const history = yield* sql<{
87 readonly migration_id: number;
88 readonly name: string;
89 }>`SELECT migration_id, name FROM effect_sql_migrations ORDER BY migration_id`;
90 assert.deepStrictEqual(
91 history.map((row) => [row.migration_id, row.name] as const),
92 migrationManifest,
93 );
94 const columns = yield* sql<{
95 readonly name: string;
96 }>`PRAGMA table_info(projection_threads)`;
97 assert.ok(columns.some((column) => column.name === "auto_settle_disabled_at"));
98 }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))),
99 );
100
101 it.effect("rolls back schema and ledger together on failure and can retry", () =>
102 Effect.gen(function* () {
103 const sql = yield* SqlClient.SqlClient;
104 yield* seedPreview;
105 yield* sql`
106 CREATE TRIGGER fail_preview_upgrade BEFORE INSERT ON effect_sql_migrations
107 WHEN NEW.name = 'PullRequestFilesViewed'
108 BEGIN SELECT RAISE(ABORT, 'injected failure'); END
109 `;
110 assert.ok(Exit.isFailure(yield* Effect.exit(runMigrations())));
111 assert.deepStrictEqual(
112 yield* sql`SELECT migration_id, name FROM effect_sql_migrations WHERE migration_id >= 53`,
113 [{ migration_id: 53, name: "OrchestrationV2" }],
114 );
115 assert.deepStrictEqual(
116 yield* sql`SELECT name FROM sqlite_master WHERE name = 'pull_request_files_viewed'`,
117 [],
118 );
119 assert.strictEqual((yield* sql`SELECT * FROM orchestration_v2_legacy_imports`).length, 1);
120 yield* sql`DROP TRIGGER fail_preview_upgrade`;
121 assert.deepStrictEqual(yield* runMigrations(), [
122 [53, "PullRequestFilesViewed"],
123 [54, "ProjectionThreadsAutoSettleDisabledAt"],
124 [56, "RemoveRedundantProjectionIndexes"],
125 [57, "ScheduledTaskWebhooks"],
126 [58, "WebhookRelayDeliveries"],
127 [59, "McpAppModelContext"],
128 [60, "ThreadSnapshotWindowIndexes"],
129 [61, "PullRequestReviewGuides"],
130 [62, "ThreadFailureLookupIndex"],
131 ]);
132 }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))),
133 );
134
135 it.effect("refuses unexpected later migrations without modifying their history", () =>
136 Effect.gen(function* () {
137 const sql = yield* SqlClient.SqlClient;
138 yield* seedPreview;
139 yield* sql`INSERT INTO effect_sql_migrations (migration_id, name) VALUES (54, 'UnknownFork')`;
140 const history = yield* sql`SELECT * FROM effect_sql_migrations ORDER BY migration_id`;
141 assert.ok(Exit.isFailure(yield* Effect.exit(runMigrations())));
142 assert.deepStrictEqual(
143 yield* sql`SELECT * FROM effect_sql_migrations ORDER BY migration_id`,
144 history,
145 );
146 }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))),
147 );
148});