1import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions";
4import { assert, it } from "@effect/vitest";
5import { ProjectId } from "@t3tools/contracts";
6import * as Effect from "effect/Effect";
7import * as FileSystem from "effect/FileSystem";
8import * as Layer from "effect/Layer";
9import * as Path from "effect/Path";
10import * as Schema from "effect/Schema";
11import * as Stream from "effect/Stream";
12import * as SqlClient from "effect/sql/SqlClient";
13
14import * as CheckpointStore from "../checkpointing/CheckpointStore.ts";
15import * as ServerConfig from "../config.ts";
16import * as GitWorkflow from "../git/GitWorkflowService.ts";
17import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
18
19import * as McpSessionRegistryTestkit from "../mcp/McpSessionRegistry.testkit.ts";
20import * as SqlitePersistence from "../persistence/Sqlite.ts";
21import { runMigrations } from "../persistence/Migrations.ts";
22import * as ProjectEnrichmentService from "../project/ProjectEnrichmentService.ts";
23import * as ProjectService from "../project/ProjectService.ts";
24import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts";
25import * as ServerSettings from "../serverSettings.ts";
26import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts";
27import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
28import * as VcsProcess from "../vcs/VcsProcess.ts";
29import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
30import * as LegacyV1ThreadImporter from "./legacy/LegacyV1ThreadImporter.ts";
31import * as RuntimeLayer from "./runtimeLayer.ts";
32
33const projectId = ProjectId.make("project:upgrade");
34const icon = { kind: "emoji", emoji: "🦊" } as const;
35const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
36
37const expectedSettings = {
38 default_thread_env_mode: "worktree",
39 auto_pull: 1,
40 favicon_path: "brand/icon.svg",
41 project_icon_json: encodeJson(icon),
42};
43
44const readSettings = Effect.gen(function* () {
45 const sql = yield* SqlClient.SqlClient;
46 const rows = yield* sql<typeof expectedSettings>`
47 SELECT default_thread_env_mode, auto_pull, favicon_path, project_icon_json
48 FROM projection_projects
49 WHERE project_id = ${projectId}
50 `;
51 return rows[0];
52});
53
54/** A released V1 database at migration 54 whose project carries all four settings. */
55const seedV1Database = Effect.gen(function* () {
56 const sql = yield* SqlClient.SqlClient;
57 yield* runMigrations({ toMigrationInclusive: 54 });
58 const events = [
59 {
60 type: "project.created",
61 payload: {
62 projectId,
63 title: "Upgrade",
64 workspaceRoot: "/work/upgrade",
65 defaultModelSelection: null,
66 faviconPath: null,
67 projectIcon: null,
68 scripts: [],
69 createdAt: "2026-01-01T00:00:00.000Z",
70 updatedAt: "2026-01-01T00:00:00.000Z",
71 },
72 },
73 {
74 type: "project.meta-updated",
75 payload: {
76 projectId,
77 defaultThreadEnvMode: "worktree",
78 autoPull: true,
79 faviconPath: "brand/icon.svg",
80 projectIcon: icon,
81 updatedAt: "2026-01-02T00:00:00.000Z",
82 },
83 },
84 ] as const;
85 for (const [version, event] of events.entries()) {
86 yield* sql`
87 INSERT INTO orchestration_events (
88 event_id, aggregate_kind, stream_id, stream_version, event_type, occurred_at,
89 command_id, causation_event_id, correlation_id, actor_kind, payload_json, metadata_json
90 ) VALUES (
91 ${`v1:${event.type}`}, 'project', ${projectId}, ${version}, ${event.type},
92 ${event.payload.updatedAt}, ${`command:${event.type}`}, NULL, ${`command:${event.type}`},
93 'client', ${encodeJson(event.payload)}, '{}'
94 )
95 `;
96 }
97 // The V1 projector had applied both events before the upgrade.
98 yield* sql`
99 INSERT INTO projection_projects (
100 project_id, title, workspace_root, default_model_selection_json, default_thread_env_mode,
101 auto_pull, favicon_path, project_icon_json, scripts_json, created_at, updated_at, deleted_at
102 ) VALUES (
103 ${projectId}, 'Upgrade', '/work/upgrade', NULL, ${expectedSettings.default_thread_env_mode},
104 1, ${expectedSettings.favicon_path}, ${expectedSettings.project_icon_json}, '[]',
105 '2026-01-01T00:00:00.000Z', '2026-01-02T00:00:00.000Z', NULL
106 )
107 `;
108 yield* sql`
109 INSERT INTO projection_state (projector, last_applied_sequence, updated_at)
110 VALUES ('projection.projects', 2, '2026-01-02T00:00:00.000Z')
111 `;
112});
113
114const unusedEnrichment = {
115 repositoryIdentity: null,
116 faviconPath: null,
117 repositoryIdentityResolved: false,
118};
119
120/** The production V2 runtime and project service against one file-backed database. */
121const layerRuntime = (dbPath: string) => {
122 const layerPlatform = Layer.merge(
123 NodeServices.layer,
124 Layer.mock(SourceControlProviderRegistry.SourceControlProviderRegistry)({
125 resolveLink: () => Effect.die("unused"),
126 }),
127 );
128 const layerServerConfig = ServerConfig.layerTest(process.cwd(), {
129 prefix: "t3-project-upgrade-",
130 });
131 const layerCheckpointStore = CheckpointStore.layer.pipe(
132 Layer.provide(
133 VcsDriverRegistry.layer.pipe(
134 Layer.provide(VcsProcess.layer),
135 Layer.provide(layerServerConfig),
136 Layer.provide(layerPlatform),
137 ),
138 ),
139 );
140 return Layer.mergeAll(
141 RuntimeLayer.layer.pipe(Layer.provide(RuntimeLayer.layerProjectService)),
142 RuntimeLayer.layerProjectService,
143 ).pipe(
144 Layer.provide(
145 Layer.mock(ProjectEnrichmentService.ProjectEnrichmentService)({
146 peek: () => Effect.succeed(unusedEnrichment),
147 getAvailable: () => Effect.succeed(unusedEnrichment),
148 invalidate: () => Effect.void,
149 }),
150 ),
151 Layer.provide(
152 Layer.mock(WorkspacePaths.WorkspacePaths)({
153 normalizeWorkspaceRoot: (workspaceRoot) => Effect.succeed(workspaceRoot),
154 }),
155 ),
156 Layer.provide(McpSessionRegistryTestkit.layer),
157 Layer.provide(Layer.merge(McpProviderSessions.layer, ScriptProgress.layerUnavailable)),
158 Layer.provideMerge(SqlitePersistence.layerFromPath(dbPath)),
159 Layer.provide(layerCheckpointStore),
160 Layer.provide(layerServerConfig),
161 Layer.provide(ServerSettings.layerTest()),
162 Layer.provide(
163 Layer.succeed(ProviderInstanceRegistry.ProviderInstanceRegistry, {
164 getInstance: () => Effect.succeed(undefined),
165 listInstances: Effect.succeed([]),
166 listUnavailable: Effect.succeed([]),
167 streamChanges: Stream.empty,
168 subscribeChanges: Effect.never,
169 }),
170 ),
171 Layer.provide(
172 Layer.mock(GitWorkflow.GitWorkflowService)({
173 pruneWorktrees: () => Effect.void,
174 }),
175 ),
176 Layer.provide(layerPlatform),
177 );
178};
179
180it.live("keeps project settings through the V2 migrations and the first V2 boot", () =>
181 Effect.scoped(
182 Effect.gen(function* () {
183 const fs = yield* FileSystem.FileSystem;
184 const path = yield* Path.Path;
185 const stateDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-project-upgrade-" });
186 const dbPath = path.join(stateDir, "statev2.sqlite");
187
188 // Seed the released V1 schema, then boot the V2 runtime, which runs 055+, on the same file.
189 yield* seedV1Database.pipe(Effect.provide(NodeSqliteClient.layer({ filename: dbPath })));
190 yield* Effect.gen(function* () {
191 yield* (yield* LegacyV1ThreadImporter.LegacyV1ThreadImporter).reconcileShells;
192 assert.deepEqual(yield* readSettings, expectedSettings);
193 const project = yield* (yield* ProjectService.ProjectService).getById(projectId);
194 assert.equal(project._tag, "Some");
195 }).pipe(Effect.provide(layerRuntime(dbPath)));
196 }),
197 ).pipe(Effect.provide(NodeServices.layer)),
198);