1import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
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
54const seedV1Database = Effect.gen(function* () {
55 const sql = yield* SqlClient.SqlClient;
56 yield* runMigrations({ toMigrationInclusive: 54 });
57 const events = [
58 {
59 type: "project.created",
60 payload: {
61 projectId,
62 title: "Upgrade",
63 workspaceRoot: "/work/upgrade",
64 defaultModelSelection: null,
65 faviconPath: null,
66 projectIcon: null,
67 scripts: [],
68 createdAt: "2026-01-01T00:00:00.000Z",
69 updatedAt: "2026-01-01T00:00:00.000Z",
70 },
71 },
72 {
73 type: "project.meta-updated",
74 payload: {
75 projectId,
76 defaultThreadEnvMode: "worktree",
77 autoPull: true,
78 faviconPath: "brand/icon.svg",
79 projectIcon: icon,
80 updatedAt: "2026-01-02T00:00:00.000Z",
81 },
82 },
83 ] as const;
84 for (const [version, event] of events.entries()) {
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 ) VALUES (
90 ${`v1:${event.type}`}, 'project', ${projectId}, ${version}, ${event.type},
91 ${event.payload.updatedAt}, ${`command:${event.type}`}, NULL, ${`command:${event.type}`},
92 'client', ${encodeJson(event.payload)}, '{}'
93 )
94 `;
95 }
96 yield* sql`
97 INSERT INTO projection_projects (
98 project_id, title, workspace_root, default_model_selection_json, default_thread_env_mode,
99 auto_pull, favicon_path, project_icon_json, scripts_json, created_at, updated_at, deleted_at
100 ) VALUES (
101 ${projectId}, 'Upgrade', '/work/upgrade', NULL, ${expectedSettings.default_thread_env_mode},
102 1, ${expectedSettings.favicon_path}, ${expectedSettings.project_icon_json}, '[]',
103 '2026-01-01T00:00:00.000Z', '2026-01-02T00:00:00.000Z', NULL
104 )
105 `;
106 yield* sql`
107 INSERT INTO projection_state (projector, last_applied_sequence, updated_at)
108 VALUES ('projection.projects', 2, '2026-01-02T00:00:00.000Z')
109 `;
110});
111
112const unusedEnrichment = {
113 repositoryIdentity: null,
114 faviconPath: null,
115 repositoryIdentityResolved: false,
116};
117
118const layerRuntime = (dbPath: string) => {
119 const layerPlatform = Layer.merge(
120 Layer.mergeAll(NodeServices.layer, NodeCrypto.layer),
121 Layer.mock(SourceControlProviderRegistry.SourceControlProviderRegistry)({
122 resolveLink: () => Effect.die("unused"),
123 }),
124 );
125 const layerServerConfig = ServerConfig.layerTest(process.cwd(), {
126 prefix: "t3-project-upgrade-",
127 });
128 const layerCheckpointStore = CheckpointStore.layer.pipe(
129 Layer.provide(
130 VcsDriverRegistry.layer.pipe(
131 Layer.provide(VcsProcess.layer),
132 Layer.provide(layerServerConfig),
133 Layer.provide(layerPlatform),
134 ),
135 ),
136 );
137 return Layer.mergeAll(
138 RuntimeLayer.layer.pipe(Layer.provide(RuntimeLayer.layerProjectService)),
139 RuntimeLayer.layerProjectService,
140 ).pipe(
141 Layer.provide(
142 Layer.mock(ProjectEnrichmentService.ProjectEnrichmentService)({
143 peek: () => Effect.succeed(unusedEnrichment),
144 getAvailable: () => Effect.succeed(unusedEnrichment),
145 invalidate: () => Effect.void,
146 }),
147 ),
148 Layer.provide(
149 Layer.mock(WorkspacePaths.WorkspacePaths)({
150 normalizeWorkspaceRoot: (workspaceRoot) => Effect.succeed(workspaceRoot),
151 }),
152 ),
153 Layer.provide(McpSessionRegistryTestkit.layer),
154 Layer.provide(McpProviderSessions.layer),
155 Layer.provideMerge(SqlitePersistence.layerFromPath(dbPath)),
156 Layer.provide(layerCheckpointStore),
157 Layer.provide(layerServerConfig),
158 Layer.provide(ServerSettings.layerTest()),
159 Layer.provide(
160 Layer.succeed(ProviderInstanceRegistry.ProviderInstanceRegistry, {
161 getInstance: () => Effect.undefined,
162 listInstances: Effect.succeed([]),
163 listUnavailable: Effect.succeed([]),
164 streamChanges: Stream.empty,
165 subscribeChanges: Effect.never,
166 }),
167 ),
168 Layer.provide(
169 Layer.mock(GitWorkflow.GitWorkflowService)({
170 pruneWorktrees: () => Effect.void,
171 }),
172 ),
173 Layer.provide(layerPlatform),
174 );
175};
176
177it.live("keeps project settings through the V2 migrations and the first V2 boot", () =>
178 Effect.scoped(
179 Effect.gen(function* () {
180 const fs = yield* FileSystem.FileSystem;
181 const path = yield* Path.Path;
182 const stateDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-project-upgrade-" });
183 const dbPath = path.join(stateDir, "statev2.sqlite");
184
185 yield* seedV1Database.pipe(Effect.provide(NodeSqliteClient.layer({ filename: dbPath })));
186 yield* Effect.gen(function* () {
187 yield* (yield* LegacyV1ThreadImporter.LegacyV1ThreadImporter).reconcileShells;
188 assert.deepEqual(yield* readSettings, expectedSettings);
189 const project = yield* (yield* ProjectService.ProjectService).getById(projectId);
190 assert.equal(project._tag, "Some");
191 }).pipe(Effect.provide(layerRuntime(dbPath)));
192 }),
193 ).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))),
194);