1import { assert, describe, it } from "@effect/vitest";
2import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
3import * as NodeServices from "@effect/platform-node/NodeServices";
4import * as Effect from "effect/Effect";
5import * as Exit from "effect/Exit";
6import * as FileSystem from "effect/FileSystem";
7import * as Path from "effect/Path";
8import * as SqlClient from "effect/sql/SqlClient";
9
10import { migrationManifest, runMigrations } from "./Migrations.ts";
11
12const v2Migrations = migrationManifest.filter(([id]) => id >= 55);
13const newerMigrationId = Math.max(...migrationManifest.map(([id]) => id)) + 1;
14
15const history = Effect.gen(function* () {
16 const sql = yield* SqlClient.SqlClient;
17 const rows = yield* sql<{ readonly migration_id: number; readonly name: string }>`
18 SELECT migration_id, name FROM effect_sql_migrations ORDER BY migration_id
19 `;
20 return rows.map((row) => [row.migration_id, row.name] as const);
21});
22
23const v2TableCount = Effect.gen(function* () {
24 const sql = yield* SqlClient.SqlClient;
25 const rows = yield* sql`
26 SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE 'orchestration_v2_%'
27 `;
28 return rows.length;
29});
30
31describe("runMigrations", () => {
32 it.effect("refuses to start when another migration took a V2 id", () =>
33 Effect.gen(function* () {
34 const sql = yield* SqlClient.SqlClient;
35 yield* runMigrations({ toMigrationInclusive: 54 });
36 yield* sql`
37 INSERT INTO effect_sql_migrations (migration_id, name) VALUES (55, 'FutureMainMigration')
38 `;
39 const before = yield* history;
40
41 assert.deepStrictEqual(yield* runMigrations({ toMigrationInclusive: 54 }), []);
42 const exit = yield* Effect.exit(runMigrations());
43
44 assert.isTrue(Exit.isFailure(exit));
45 assert.include(String(exit), "55_OrchestrationV2 (recorded: FutureMainMigration)");
46 assert.deepStrictEqual(yield* history, before);
47 assert.strictEqual(yield* v2TableCount, 0);
48 }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))),
49 );
50
51 it.effect("refuses to start when a later id would skip V2 migrations", () =>
52 Effect.gen(function* () {
53 const sql = yield* SqlClient.SqlClient;
54 yield* runMigrations({ toMigrationInclusive: 54 });
55 yield* sql`
56 INSERT INTO effect_sql_migrations (migration_id, name)
57 VALUES (${newerMigrationId}, 'FutureMainMigration')
58 `;
59 const before = yield* history;
60
61 const exit = yield* Effect.exit(runMigrations());
62
63 assert.isTrue(Exit.isFailure(exit));
64 for (const [id, name] of v2Migrations) {
65 assert.include(String(exit), `${id}_${name} (recorded: nothing)`);
66 }
67 assert.deepStrictEqual(yield* history, before);
68 assert.strictEqual(yield* v2TableCount, 0);
69 }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))),
70 );
71
72 it.effect("upgrades a database at exactly 54", () =>
73 Effect.gen(function* () {
74 yield* runMigrations({ toMigrationInclusive: 54 });
75 assert.deepStrictEqual(yield* runMigrations(), v2Migrations);
76 assert.deepStrictEqual(yield* history, migrationManifest);
77 assert.isAbove(yield* v2TableCount, 0);
78 }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))),
79 );
80
81 it.effect("drops the unused correlation index and keeps correlation values", () =>
82 Effect.gen(function* () {
83 const sql = yield* SqlClient.SqlClient;
84 yield* runMigrations({ toMigrationInclusive: 54 });
85 yield* sql`
86 INSERT INTO orchestration_events (
87 event_id, aggregate_kind, stream_id, stream_version, event_type, occurred_at,
88 command_id, causation_event_id, correlation_id, actor_kind, payload_json, metadata_json
89 )
90 VALUES (
91 'event-1', 'thread', 'thread-1', 0, 'thread.created', '2026-01-01T00:00:00.000Z',
92 'command-1', NULL, 'correlation-1', 'client', '{}', '{}'
93 )
94 `;
95
96 yield* runMigrations();
97
98 const indexes = yield* sql<{ readonly name: string }>`
99 SELECT name FROM sqlite_master
100 WHERE type = 'index' AND tbl_name = 'orchestration_events' AND sql IS NOT NULL
101 `;
102 const indexNames = indexes.map((row) => row.name);
103 assert.notInclude(indexNames, "idx_orch_events_correlation_id");
104 for (const kept of [
105 "idx_orch_events_stream_version",
106 "idx_orch_events_stream_sequence",
107 "idx_orch_events_command_id",
108 "idx_orchestration_events_agent_stream_sequence",
109 ]) {
110 assert.include(indexNames, kept);
111 }
112 const events = yield* sql<{ readonly correlation_id: string | null }>`
113 SELECT correlation_id FROM orchestration_events WHERE event_id = 'event-1'
114 `;
115 assert.deepStrictEqual(events, [{ correlation_id: "correlation-1" }]);
116 }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))),
117 );
118
119 it.effect.each([30, 54])("still starts with a site-local migration at id %s", (id) =>
120 Effect.gen(function* () {
121 const sql = yield* SqlClient.SqlClient;
122 yield* runMigrations({ toMigrationInclusive: 54 });
123 yield* sql`
124 UPDATE effect_sql_migrations SET name = 'SiteLocalMigration' WHERE migration_id = ${id}
125 `;
126
127 assert.deepStrictEqual(yield* runMigrations(), v2Migrations);
128 assert.isAbove(yield* v2TableCount, 0);
129 }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))),
130 );
131
132 it.effect("only checks V2 migrations within the requested limit", () =>
133 Effect.gen(function* () {
134 const sql = yield* SqlClient.SqlClient;
135 yield* runMigrations({ toMigrationInclusive: 55 });
136 yield* sql`
137 INSERT INTO effect_sql_migrations (migration_id, name)
138 VALUES (56, 'SiteLocalMigration'), (${newerMigrationId}, 'NewerV2Migration')
139 `;
140 const before = yield* history;
141
142 assert.deepStrictEqual(yield* runMigrations({ toMigrationInclusive: 55 }), []);
143 const exit = yield* Effect.exit(runMigrations({ toMigrationInclusive: 56 }));
144 assert.isTrue(Exit.isFailure(exit));
145 assert.include(
146 String(exit),
147 "56_RemoveRedundantProjectionIndexes (recorded: SiteLocalMigration)",
148 );
149 assert.notInclude(String(exit), "57_ScheduledTaskWebhooks");
150 assert.deepStrictEqual(yield* history, before);
151 }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))),
152 );
153
154 it.effect("keeps the migrations that finished before a later one failed", () =>
155 Effect.gen(function* () {
156 const sql = yield* SqlClient.SqlClient;
157 yield* runMigrations({ toMigrationInclusive: 81 });
158 yield* sql`CREATE TABLE orchestration_v2_projection_turn_item_counts (thread_id TEXT)`;
159
160 const exit = yield* Effect.exit(runMigrations());
161
162 assert.isTrue(Exit.isFailure(exit));
163 assert.deepStrictEqual(
164 yield* history,
165 migrationManifest.filter(([id]) => id <= 84),
166 );
167 }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))),
168 );
169
170 it.effect("reapplies peer link migrations whose ledger rows are missing", () =>
171 Effect.gen(function* () {
172 const sql = yield* SqlClient.SqlClient;
173 yield* runMigrations({ toMigrationInclusive: 72 });
174 yield* sql`DELETE FROM effect_sql_migrations WHERE migration_id BETWEEN 70 AND 72`;
175
176 assert.deepStrictEqual(
177 yield* runMigrations(),
178 migrationManifest.filter(([id]) => id >= 70),
179 );
180 assert.deepStrictEqual(yield* history, migrationManifest);
181 }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))),
182 );
183
184 it.effect("still starts after a newer build recorded a later V2 migration", () =>
185 Effect.gen(function* () {
186 const sql = yield* SqlClient.SqlClient;
187 yield* runMigrations();
188 yield* sql`
189 INSERT INTO effect_sql_migrations (migration_id, name)
190 VALUES (${newerMigrationId}, 'NewerV2Migration')
191 `;
192
193 assert.deepStrictEqual(yield* runMigrations(), []);
194 }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))),
195 );
196});
197
198// The npm launcher reads the highest migration id from these file names.
199it.effect("registers every migration file", () =>
200 Effect.gen(function* () {
201 const fs = yield* FileSystem.FileSystem;
202 const path = yield* Path.Path;
203 const dir = path.join(import.meta.dirname, "Migrations");
204 const files = (yield* fs.readDirectory(dir)).flatMap((name) => {
205 const match = /^(\d+)_(\w+)\.ts$/.exec(name);
206 return match ? [[Number(match[1]), match[2]] as const] : [];
207 });
208 assert.deepStrictEqual(
209 files.toSorted(([a], [b]) => a - b),
210 migrationManifest,
211 );
212 }).pipe(Effect.provide(NodeServices.layer)),
213);