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
13const seedPreview = Effect.gen(function* () {
14 const sql = yield* SqlClient.SqlClient;
15 yield* runMigrations({ toMigrationInclusive: 52 });
16 yield* Migrator.make({})({
17 loader: Migrator.fromRecord({ "53_OrchestrationV2": OrchestrationV2 }),
18 });
19 yield* sql`
20 INSERT INTO orchestration_v2_legacy_imports
21 (thread_id, source_updated_at, shell_imported_at, transcript_imported_at, imported_message_count)
22 VALUES ('preview-thread', '2026-09-15', '2026-09-15', '2026-09-16', 42)
23 `;
24 yield* sql`
25 UPDATE effect_sql_migrations SET created_at = '2026-09-15 00:00:00' WHERE migration_id = 53
26 `;
27});
28
29describe("V2 preview upgrade", () => {
30 it.effect("upgrades a published preview without replaying V2 or losing import progress", () =>
31 Effect.gen(function* () {
32 const sql = yield* SqlClient.SqlClient;
33 yield* seedPreview;
34 const imports = yield* sql`SELECT * FROM orchestration_v2_legacy_imports`;
35 assert.deepStrictEqual(yield* runMigrations(), [
36 [53, "PullRequestFilesViewed"],
37 [54, "ProjectionThreadsAutoSettleDisabledAt"],
38 [56, "RemoveRedundantProjectionIndexes"],
39 [57, "ScheduledTaskWebhooks"],
40 [58, "WebhookRelayDeliveries"],
41 [59, "McpAppModelContext"],
42 [60, "ThreadSnapshotWindowIndexes"],
43 [61, "PullRequestReviewGuides"],
44 [62, "ThreadFailureLookupIndex"],
45 [63, "DropUnusedV1Projections"],
46 [64, "CanonicalizeThreadPullRequestLinks"],
47 [65, "CodexUsageNativeTurnIndex"],
48 [66, "RepairClientSettlementTimestamps"],
49 [67, "WebhookDedupe"],
50 [68, "ShellStopRequestIndex"],
51 [69, "ThreadMessageSearch"],
52 [70, "PeerLinks"],
53 [71, "PeerLinkFence"],
54 [72, "WaitingLinkRequestIndex"],
55 [73, "ActiveTurnItemIndexes"],
56 [74, "ExcludeAttachmentPlaceholdersFromSearch"],
57 [75, "SubagentDeliveryState"],
58 [76, "ProjectionProjectsPinArchive"],
59 [77, "RepairBooleanSettledOverride"],
60 [78, "DropOrchestrationEventCorrelationIndex"],
61 [79, "DelegatedRecoveryIndexes"],
62 [80, "ScheduledTaskThreadMode"],
63 [81, "ProjectionMessageSearchText"],
64 [82, "TurnItemConversationIndex"],
65 [83, "CommandReadIndexes"],
66 [84, "LastExecutedRunIndex"],
67 [85, "TurnItemCounts"],
68 [86, "QueuedTurnInputIndex"],
69 [87, "ProjectionProjectsCustomTitle"],
70 [88, "TurnItemTypeStatusIndex"],
71 ]);
72 assert.deepStrictEqual(yield* runMigrations(), []);
73 assert.deepStrictEqual(yield* sql`SELECT * FROM orchestration_v2_legacy_imports`, imports);
74 const history = yield* sql<{ readonly migration_id: number; readonly name: string }>`
75 SELECT migration_id, name FROM effect_sql_migrations ORDER BY migration_id
76 `;
77 assert.deepStrictEqual(
78 history.map((row) => [row.migration_id, row.name] as const),
79 migrationManifest,
80 );
81 assert.deepStrictEqual(
82 yield* sql`SELECT created_at FROM effect_sql_migrations WHERE migration_id = 55`,
83 [{ created_at: "2026-09-15 00:00:00" }],
84 );
85 yield* sql`
86 INSERT INTO pull_request_files_viewed
87 (provider, host, repository, number, viewer, path, revision, viewed_at)
88 VALUES ('github', 'github.com', 'owner/repo', 1, 'viewer', 'file.ts', 'revision', '2026-09-17')
89 `;
90 assert.strictEqual((yield* sql`SELECT * FROM pull_request_files_viewed`).length, 1);
91 }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))),
92 );
93
94 it.effect.each([false, true])(
95 "upgrades preview migration 54 with index cleanup %s",
96 (withIndexes) =>
97 Effect.gen(function* () {
98 const sql = yield* SqlClient.SqlClient;
99 yield* runMigrations({ toMigrationInclusive: 52 });
100 yield* Migrator.make({})({
101 loader: Migrator.fromRecord({
102 "53_PullRequestFilesViewed": PullRequestFilesViewed,
103 "54_OrchestrationV2": OrchestrationV2,
104 ...(withIndexes
105 ? { "55_RemoveRedundantProjectionIndexes": RemoveRedundantProjectionIndexes }
106 : {}),
107 }),
108 });
109 yield* runMigrations();
110 assert.deepStrictEqual(yield* runMigrations(), []);
111 const history = yield* sql<{
112 readonly migration_id: number;
113 readonly name: string;
114 }>`SELECT migration_id, name FROM effect_sql_migrations ORDER BY migration_id`;
115 assert.deepStrictEqual(
116 history.map((row) => [row.migration_id, row.name] as const),
117 migrationManifest,
118 );
119 const columns = yield* sql<{
120 readonly name: string;
121 }>`PRAGMA table_info(projection_threads)`;
122 assert.ok(columns.some((column) => column.name === "auto_settle_disabled_at"));
123 }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))),
124 );
125
126 it.effect("rolls back schema and ledger together on failure and can retry", () =>
127 Effect.gen(function* () {
128 const sql = yield* SqlClient.SqlClient;
129 yield* seedPreview;
130 yield* sql`
131 CREATE TRIGGER fail_preview_upgrade BEFORE INSERT ON effect_sql_migrations
132 WHEN NEW.name = 'PullRequestFilesViewed'
133 BEGIN SELECT RAISE(ABORT, 'injected failure'); END
134 `;
135 assert.ok(Exit.isFailure(yield* Effect.exit(runMigrations())));
136 assert.deepStrictEqual(
137 yield* sql`SELECT migration_id, name FROM effect_sql_migrations WHERE migration_id >= 53`,
138 [{ migration_id: 53, name: "OrchestrationV2" }],
139 );
140 assert.deepStrictEqual(
141 yield* sql`SELECT name FROM sqlite_master WHERE name = 'pull_request_files_viewed'`,
142 [],
143 );
144 assert.strictEqual((yield* sql`SELECT * FROM orchestration_v2_legacy_imports`).length, 1);
145 yield* sql`DROP TRIGGER fail_preview_upgrade`;
146 assert.deepStrictEqual(yield* runMigrations(), [
147 [53, "PullRequestFilesViewed"],
148 [54, "ProjectionThreadsAutoSettleDisabledAt"],
149 [56, "RemoveRedundantProjectionIndexes"],
150 [57, "ScheduledTaskWebhooks"],
151 [58, "WebhookRelayDeliveries"],
152 [59, "McpAppModelContext"],
153 [60, "ThreadSnapshotWindowIndexes"],
154 [61, "PullRequestReviewGuides"],
155 [62, "ThreadFailureLookupIndex"],
156 [63, "DropUnusedV1Projections"],
157 [64, "CanonicalizeThreadPullRequestLinks"],
158 [65, "CodexUsageNativeTurnIndex"],
159 [66, "RepairClientSettlementTimestamps"],
160 [67, "WebhookDedupe"],
161 [68, "ShellStopRequestIndex"],
162 [69, "ThreadMessageSearch"],
163 [70, "PeerLinks"],
164 [71, "PeerLinkFence"],
165 [72, "WaitingLinkRequestIndex"],
166 [73, "ActiveTurnItemIndexes"],
167 [74, "ExcludeAttachmentPlaceholdersFromSearch"],
168 [75, "SubagentDeliveryState"],
169 [76, "ProjectionProjectsPinArchive"],
170 [77, "RepairBooleanSettledOverride"],
171 [78, "DropOrchestrationEventCorrelationIndex"],
172 [79, "DelegatedRecoveryIndexes"],
173 [80, "ScheduledTaskThreadMode"],
174 [81, "ProjectionMessageSearchText"],
175 [82, "TurnItemConversationIndex"],
176 [83, "CommandReadIndexes"],
177 [84, "LastExecutedRunIndex"],
178 [85, "TurnItemCounts"],
179 [86, "QueuedTurnInputIndex"],
180 [87, "ProjectionProjectsCustomTitle"],
181 [88, "TurnItemTypeStatusIndex"],
182 ]);
183 }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))),
184 );
185
186 it.effect("refuses unexpected later migrations without modifying their history", () =>
187 Effect.gen(function* () {
188 const sql = yield* SqlClient.SqlClient;
189 yield* seedPreview;
190 yield* sql`INSERT INTO effect_sql_migrations (migration_id, name) VALUES (54, 'UnknownFork')`;
191 const history = yield* sql`SELECT * FROM effect_sql_migrations ORDER BY migration_id`;
192 assert.ok(Exit.isFailure(yield* Effect.exit(runMigrations())));
193 assert.deepStrictEqual(
194 yield* sql`SELECT * FROM effect_sql_migrations ORDER BY migration_id`,
195 history,
196 );
197 }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))),
198 );
199});