1import * as Arr from "effect/Array";
2import * as Context from "effect/Context";
3import * as Queue from "effect/Queue";
4import * as Tracer from "effect/Tracer";
5import { assert, it } from "@effect/vitest";
6import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
7import * as NodeServices from "@effect/platform-node/NodeServices";
8import { CommandId, type Project, ProjectId, ProviderInstanceId } from "@t3tools/contracts";
9import * as Deferred from "effect/Deferred";
10import * as Effect from "effect/Effect";
11import * as Fiber from "effect/Fiber";
12import * as FileSystem from "effect/FileSystem";
13import * as Layer from "effect/Layer";
14import * as Metric from "effect/Metric";
15import * as Option from "effect/Option";
16import * as PubSub from "effect/PubSub";
17import * as Ref from "effect/Ref";
18import { TestClock } from "effect/testing";
19import * as SqlClient from "effect/sql/SqlClient";
20
21import * as ServerConfig from "../config.ts";
22import {
23 orchestrationCommandDuration,
24 orchestrationCommandsTotal,
25} from "../observability/Metrics.ts";
26import * as EventSink from "../orchestration-v2/EventSink.ts";
27import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
28import * as LegacyV1ThreadImporter from "../orchestration-v2/legacy/LegacyV1ThreadImporter.ts";
29import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts";
30import * as ProjectStore from "../orchestration-v2/ProjectStore.ts";
31import * as RuntimeLayer from "../orchestration-v2/runtimeLayer.ts";
32import * as ThreadCommandExecutor from "../orchestration-v2/ThreadCommandExecutor.ts";
33import * as SqlitePersistence from "../persistence/Sqlite.ts";
34import * as ServerSettings from "../serverSettings.ts";
35import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
36import * as ProjectEnrichmentService from "./ProjectEnrichmentService.ts";
37import * as ProjectFaviconResolver from "./ProjectFaviconResolver.ts";
38import * as ProjectService from "./ProjectService.ts";
39import * as RepositoryIdentityResolver from "./RepositoryIdentityResolver.ts";
40import * as T3ProjectFileLoader from "./T3ProjectFileLoader.ts";
41
42const layerWorkspacePaths = Layer.succeed(WorkspacePaths.WorkspacePaths, {
43 normalizeWorkspaceRoot: (workspaceRoot) => Effect.succeed(workspaceRoot.replace(/\/$/, "")),
44 resolveRelativePathWithinRoot: ({ workspaceRoot, relativePath }) =>
45 Effect.succeed({ absolutePath: `${workspaceRoot}/${relativePath}`, relativePath }),
46});
47
48const layerMetadata = Layer.merge(
49 Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, {
50 resolve: (workspaceRoot) =>
51 Effect.succeed({
52 canonicalKey: `github.com/t3tools/${workspaceRoot.split("/").at(-1)}`,
53 locator: {
54 source: "git-remote" as const,
55 remoteName: "origin",
56 remoteUrl: `git@github.com:t3tools/${workspaceRoot.split("/").at(-1)}.git`,
57 },
58 rootPath: workspaceRoot,
59 }),
60 }),
61 Layer.succeed(ProjectFaviconResolver.ProjectFaviconResolver, {
62 invalidate: () => Effect.void,
63 resolvePath: (workspaceRoot) => Effect.succeed(`${workspaceRoot}/favicon.svg`),
64 }),
65);
66
67class EnrichmentCompletions extends Context.Service<EnrichmentCompletions, Queue.Queue<void>>()(
68 "t3/project/ProjectService.test/EnrichmentCompletions",
69) {}
70
71const layerEnrichmentCompletions = Layer.effectContext(
72 Effect.gen(function* () {
73 const completions = yield* Queue.unbounded<void>();
74 const tracer = Tracer.make({
75 span: (options) => {
76 const span = new Tracer.NativeSpan(options);
77 const end = span.end.bind(span);
78 span.end = (...args) => {
79 end(...args);
80 if (
81 span.name === "ProjectEnrichmentService.resolveRepositoryIdentity" ||
82 span.name === "ProjectEnrichmentService.resolveFavicon"
83 ) {
84 Queue.offerUnsafe(completions, undefined);
85 }
86 };
87 return span;
88 },
89 });
90 return Context.make(EnrichmentCompletions, completions).pipe(
91 Context.add(Tracer.Tracer, tracer),
92 );
93 }),
94);
95
96const layerTestFor = (
97 projectMetadataLayer: Layer.Layer<
98 | ProjectFaviconResolver.ProjectFaviconResolver
99 | RepositoryIdentityResolver.RepositoryIdentityResolver
100 >,
101) =>
102 RuntimeLayer.layerProjectService.pipe(
103 Layer.provideMerge(ProjectEnrichmentService.layer),
104 Layer.provideMerge(layerWorkspacePaths),
105 Layer.provideMerge(projectMetadataLayer),
106 Layer.provideMerge(SqlitePersistence.layerMemory),
107 Layer.provideMerge(ServerSettings.layerTest()),
108 Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "project-service-test-" })),
109 Layer.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)),
110 Layer.provideMerge(layerEnrichmentCompletions),
111 );
112
113const layerTest = layerTestFor(layerMetadata);
114
115it.effect("resolves only the requested active project page in creation order", () =>
116 Effect.gen(function* () {
117 const inspected = yield* Ref.make<ReadonlySet<string>>(new Set());
118 const note = (workspaceRoot: string) =>
119 Ref.update(inspected, (roots) => new Set([...roots, workspaceRoot]));
120 const metadata = Layer.merge(
121 Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, {
122 resolve: (workspaceRoot) =>
123 note(workspaceRoot).pipe(
124 Effect.as({
125 canonicalKey: `example.test${workspaceRoot}`,
126 locator: {
127 source: "git-remote" as const,
128 remoteName: "origin",
129 remoteUrl: `https://example.test${workspaceRoot}.git`,
130 },
131 rootPath: workspaceRoot,
132 }),
133 ),
134 }),
135 Layer.succeed(ProjectFaviconResolver.ProjectFaviconResolver, {
136 invalidate: () => Effect.void,
137 resolvePath: (workspaceRoot) => note(workspaceRoot).pipe(Effect.as(null)),
138 }),
139 );
140 yield* Effect.gen(function* () {
141 const sql = yield* SqlClient.SqlClient;
142 const service = yield* ProjectService.ProjectService;
143 const enrichment = yield* ProjectEnrichmentService.ProjectEnrichmentService;
144 const changes = yield* enrichment.subscribeChanges;
145 const rows = Arr.makeBy(6, (index) => ({
146 project_id: `page:${index}`,
147 title: `Project ${index}`,
148 workspace_root: `/work/page-${index}`,
149 scripts_json: "[]",
150 created_at: "2026-10-01T00:00:00.000Z",
151 updated_at: "2026-10-01T00:00:00.000Z",
152 deleted_at: index === 1 ? "2026-10-02T00:00:00.000Z" : null,
153 auto_pull: 0,
154 }));
155 yield* sql`INSERT INTO projection_projects ${sql.insert(rows)}`;
156 const first = yield* service.listPage({ cursor: 0, limit: 2 });
157 assert.deepEqual(
158 first.projects.map((project) => project.id),
159 ["page:0", "page:2"],
160 );
161 assert.equal(first.nextCursor, 2);
162 assert.deepEqual(
163 first.projects.map((project) => project.repositoryIdentity?.canonicalKey),
164 ["example.test/work/page-0", "example.test/work/page-2"],
165 );
166 // A sentinel queued after the page lets the enrichment workers finish
167 // earlier requests before checking which workspaces were inspected.
168 yield* enrichment.request("/work/page-sentinel");
169 for (;;) {
170 if ((yield* PubSub.take(changes)).workspaceRoot === "/work/page-sentinel") break;
171 }
172 assert.deepEqual(
173 new Set(yield* Ref.get(inspected)),
174 new Set(["/work/page-0", "/work/page-2", "/work/page-sentinel"]),
175 );
176 const second = yield* service.listPage({ cursor: first.nextCursor!, limit: 2 });
177 assert.deepEqual(
178 second.projects.map((project) => project.id),
179 ["page:3", "page:4"],
180 );
181 assert.equal(second.nextCursor, 4);
182 const last = yield* service.listPage({ cursor: second.nextCursor!, limit: 2 });
183 assert.deepEqual(
184 last.projects.map((project) => project.id),
185 ["page:5"],
186 );
187 assert.isNull(last.nextCursor);
188 assert.isFalse((yield* Ref.get(inspected)).has("/work/page-1"));
189 assert.deepEqual(yield* service.listPage({ cursor: 99, limit: 2 }), {
190 projects: [],
191 nextCursor: null,
192 });
193 }).pipe(Effect.scoped, Effect.provide(layerTestFor(metadata)));
194 }),
195);
196
197const layerProjectServiceDependencies = Layer.mergeAll(
198 RuntimeLayer.layerEventSink,
199 ProjectStore.layer,
200 ProjectionStore.layer,
201 IdAllocator.layer,
202 ThreadCommandExecutor.layer,
203).pipe(
204 Layer.provideMerge(LegacyV1ThreadImporter.layer.pipe(Layer.provide(RuntimeLayer.layerEventSink))),
205 Layer.provideMerge(ProjectEnrichmentService.layer),
206 Layer.provideMerge(layerWorkspacePaths),
207 Layer.provideMerge(layerMetadata),
208 Layer.provideMerge(SqlitePersistence.layerMemory),
209 Layer.provideMerge(ServerSettings.layerTest()),
210 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "project-service-race-" })),
211 Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)),
212);
213
214const waitForProject = Effect.fn("ProjectServiceTest.waitForProject")(function* (
215 service: ProjectService.ProjectService["Service"],
216 projectId: ProjectId,
217 predicate: (project: Project) => boolean,
218) {
219 return yield* Effect.scoped(
220 Effect.gen(function* () {
221 const completions = yield* EnrichmentCompletions;
222 for (;;) {
223 const project = Option.getOrThrow(yield* service.getById(projectId));
224 if (predicate(project)) return project;
225 yield* Queue.take(completions);
226 }
227 }),
228 );
229});
230
231it.layer(layerTest)("ProjectService", (it) => {
232 it.effect("creates, updates, resolves, snapshots, and soft-deletes projects", () =>
233 Effect.gen(function* () {
234 const service = yield* ProjectService.ProjectService;
235 const projectId = ProjectId.make("project:service-test");
236 const modelSelection = {
237 instanceId: ProviderInstanceId.make("codex_custom"),
238 model: "gpt-5.1-codex",
239 } as const;
240 yield* TestClock.setTime(Date.parse("2026-06-20T10:00:00.000Z"));
241
242 const created = yield* service.create({
243 commandId: CommandId.make("command:project:create"),
244 projectId,
245 title: "Project",
246 workspaceRoot: "/work/project/",
247 defaultModelSelection: modelSelection,
248 scripts: [
249 {
250 id: "setup",
251 name: "Setup",
252 command: "vp install",
253 icon: "configure",
254 runOnWorktreeCreate: true,
255 },
256 ],
257 });
258 assert.equal(created.workspaceRoot, "/work/project");
259 assert.isNull(created.repositoryIdentity);
260 assert.isNull(created.faviconPath);
261
262 const hydratedCreated = yield* waitForProject(
263 service,
264 projectId,
265 (project) => project.repositoryIdentity !== null && project.faviconPath !== null,
266 );
267 assert.equal(hydratedCreated?.repositoryIdentity?.canonicalKey, "github.com/t3tools/project");
268 assert.equal(hydratedCreated?.faviconPath, "/work/project/favicon.svg");
269
270 const updated = yield* service.update({
271 commandId: CommandId.make("command:project:update"),
272 projectId,
273 title: "Renamed",
274 autoPull: true,
275 projectIcon: { kind: "emoji", emoji: "🦊" },
276 faviconPath: "/work/project/custom.svg",
277 defaultThreadEnvMode: "worktree",
278 });
279 assert.equal(updated.title, "Renamed");
280 assert.equal(updated.createdAt, created.createdAt);
281 assert.isTrue(updated.autoPull);
282 assert.deepEqual(updated.projectIcon, { kind: "emoji", emoji: "🦊" });
283 assert.equal(updated.faviconPath, "/work/project/custom.svg");
284 assert.equal(updated.defaultThreadEnvMode, "worktree");
285
286 const byId = yield* service.getById(projectId);
287 const byWorkspace = yield* service.getByWorkspaceRoot("/work/project/");
288 assert.isTrue(Option.isSome(byId));
289 assert.isTrue(Option.isSome(byWorkspace));
290 assert.equal(Option.getOrThrow(byWorkspace).id, projectId);
291 assert.isTrue(Option.getOrThrow(byId).autoPull);
292 assert.deepEqual(Option.getOrThrow(byId).projectIcon, updated.projectIcon);
293 assert.equal(Option.getOrThrow(byId).faviconPath, updated.faviconPath);
294 assert.equal(Option.getOrThrow(byId).defaultThreadEnvMode, "worktree");
295 const reset = yield* service.update({
296 commandId: CommandId.make("command:project:reset-appearance"),
297 projectId,
298 autoPull: false,
299 projectIcon: null,
300 faviconPath: null,
301 defaultThreadEnvMode: null,
302 });
303 assert.isFalse(reset.autoPull);
304 assert.isNull(reset.projectIcon);
305 assert.equal(
306 (yield* waitForProject(
307 service,
308 projectId,
309 (project) => project.faviconPath === hydratedCreated.faviconPath,
310 )).faviconPath,
311 hydratedCreated.faviconPath,
312 );
313 assert.isNull(reset.defaultThreadEnvMode);
314 assert.deepEqual(
315 (yield* service.snapshot).projects.map((project) => project.id),
316 [projectId],
317 );
318
319 const deleted = yield* service.delete({
320 commandId: CommandId.make("command:project:delete"),
321 projectId,
322 });
323 assert.isNotNull(deleted.deletedAt);
324 assert.isTrue(Option.isNone(yield* service.getById(projectId)));
325 assert.isTrue(Option.isSome(yield* service.getById(projectId, { includeDeleted: true })));
326 assert.deepEqual((yield* service.snapshot).projects, []);
327
328 const sql = yield* SqlClient.SqlClient;
329 const changes = yield* sql<{ readonly event_type: string }>`
330 SELECT event_type
331 FROM orchestration_events
332 WHERE aggregate_kind = 'project' AND stream_id = ${projectId}
333 ORDER BY sequence ASC
334 `;
335 assert.deepEqual(
336 changes.map((change) => change.event_type),
337 ["project.created", "project.meta-updated", "project.meta-updated", "project.deleted"],
338 );
339 }),
340 );
341
342 it.effect("records the outcome and duration of a committed project command", () =>
343 Effect.gen(function* () {
344 const service = yield* ProjectService.ProjectService;
345 const successes = Metric.value(
346 Metric.withAttributes(orchestrationCommandsTotal, {
347 commandType: "project.create",
348 outcome: "success",
349 }),
350 );
351 const durations = Metric.value(
352 Metric.withAttributes(orchestrationCommandDuration, { commandType: "project.create" }),
353 );
354 const successesBefore = (yield* successes).count;
355 const durationsBefore = (yield* durations).count;
356
357 yield* service.create({
358 commandId: CommandId.make("command:metrics:create"),
359 projectId: ProjectId.make("project:metrics"),
360 title: "Metrics",
361 workspaceRoot: "/work/metrics",
362 });
363
364 assert.strictEqual((yield* successes).count - successesBefore, 1);
365 assert.strictEqual((yield* durations).count - durationsBefore, 1);
366 }),
367 );
368
369 it.effect("auto-bootstraps a workspace exactly once", () =>
370 Effect.gen(function* () {
371 const service = yield* ProjectService.ProjectService;
372 yield* TestClock.setTime(Date.parse("2026-06-20T10:00:00.000Z"));
373 const input = {
374 commandId: CommandId.make("command:bootstrap:first"),
375 projectId: ProjectId.make("project:bootstrap"),
376 title: "Bootstrap",
377 workspaceRoot: "/work/bootstrap/",
378 };
379 const first = yield* service.bootstrap(input);
380 const second = yield* service.bootstrap({
381 ...input,
382 commandId: CommandId.make("command:bootstrap:second"),
383 projectId: ProjectId.make("project:bootstrap:unused"),
384 });
385 assert.isTrue(first.created);
386 assert.isFalse(second.created);
387 assert.equal(second.project.id, first.project.id);
388 }),
389 );
390
391 it.effect("commits the event, the row and the receipt together, once per command id", () =>
392 Effect.gen(function* () {
393 const service = yield* ProjectService.ProjectService;
394 const sql = yield* SqlClient.SqlClient;
395 const projectId = ProjectId.make("project:retry");
396 const input = {
397 commandId: CommandId.make("command:retry:create"),
398 projectId,
399 title: "Retry",
400 workspaceRoot: "/work/retry",
401 };
402 const first = yield* service.create(input);
403 const retried = yield* service.create(input);
404 assert.deepEqual(retried, first);
405 const updateInput = {
406 commandId: CommandId.make("command:retry:update"),
407 projectId,
408 autoPull: true,
409 };
410 yield* service.update(updateInput);
411 yield* service.update(updateInput);
412
413 const committed = yield* sql<{
414 readonly sequence: number;
415 readonly event_type: string;
416 readonly receipt_sequence: number;
417 readonly status: string;
418 readonly aggregate_kind: string;
419 }>`
420 SELECT events.sequence, events.event_type,
421 receipts.result_sequence AS receipt_sequence, receipts.status, receipts.aggregate_kind
422 FROM orchestration_events AS events
423 JOIN orchestration_command_receipts AS receipts ON receipts.command_id = events.command_id
424 WHERE events.stream_id = ${projectId}
425 ORDER BY events.sequence
426 `;
427 assert.deepEqual(
428 committed.map((row) => [row.event_type, row.status, row.aggregate_kind]),
429 [
430 ["project.created", "accepted", "project"],
431 ["project.meta-updated", "accepted", "project"],
432 ],
433 );
434 for (const row of committed) assert.equal(row.receipt_sequence, row.sequence);
435 assert.isTrue(Option.getOrThrow(yield* service.getById(projectId)).autoPull);
436 }),
437 );
438
439 it.effect("replays a rejected receipt instead of re-planning the command", () =>
440 Effect.gen(function* () {
441 const service = yield* ProjectService.ProjectService;
442 const sql = yield* SqlClient.SqlClient;
443 const projectId = ProjectId.make("project:rejected");
444 yield* service.create({
445 commandId: CommandId.make("command:rejected:create"),
446 projectId,
447 title: "Rejected",
448 workspaceRoot: "/work/rejected",
449 });
450 const invalid = {
451 commandId: CommandId.make("command:rejected:script"),
452 projectId,
453 scripts: [
454 {
455 id: "Not.Valid",
456 name: "Invalid",
457 command: "true",
458 icon: "play" as const,
459 runOnWorktreeCreate: false,
460 },
461 ],
462 };
463 const first = yield* service.update(invalid).pipe(Effect.flip);
464 assert.equal(first._tag, "ProjectInvalidError");
465 assert.include(first.message, "Script IDs must be 1-24 lowercase letters");
466 const replayed = yield* service
467 .update({ ...invalid, scripts: [{ ...invalid.scripts[0]!, id: "valid" }] })
468 .pipe(Effect.flip);
469 assert.deepEqual(replayed, first);
470 const receipts = yield* sql<{ readonly status: string; readonly aggregate_id: string }>`
471 SELECT status, aggregate_id FROM orchestration_command_receipts
472 WHERE command_id = ${invalid.commandId}
473 `;
474 assert.deepEqual(receipts, [{ status: "rejected", aggregate_id: projectId }]);
475 const events = yield* sql`
476 SELECT sequence FROM orchestration_events WHERE command_id = ${invalid.commandId}
477 `;
478 assert.deepEqual(events, []);
479 assert.deepEqual(Option.getOrThrow(yield* service.getById(projectId)).scripts, []);
480 }),
481 );
482
483 it.effect("rejects a workspace another active project holds", () =>
484 Effect.gen(function* () {
485 const service = yield* ProjectService.ProjectService;
486 const other = ProjectId.make("project:conflict:mover");
487 yield* service.create({
488 commandId: CommandId.make("command:conflict:holder"),
489 projectId: ProjectId.make("project:conflict:holder"),
490 title: "Holder",
491 workspaceRoot: "/work/conflict",
492 });
493 yield* service.create({
494 commandId: CommandId.make("command:conflict:mover"),
495 projectId: other,
496 title: "Mover",
497 workspaceRoot: "/work/conflict-mover",
498 });
499 const move = yield* service
500 .update({
501 commandId: CommandId.make("command:conflict:move"),
502 projectId: other,
503 workspaceRoot: "/work/conflict",
504 })
505 .pipe(Effect.flip);
506 assert.equal(move._tag, "ProjectConflictError");
507 assert.equal(
508 Option.getOrThrow(yield* service.getById(other)).workspaceRoot,
509 "/work/conflict-mover",
510 );
511 }),
512 );
513
514 it.effect("replays a workspace conflict after the workspace frees up", () =>
515 Effect.gen(function* () {
516 const service = yield* ProjectService.ProjectService;
517 const holderId = ProjectId.make("project:freed:holder");
518 yield* service.create({
519 commandId: CommandId.make("command:freed:holder"),
520 projectId: holderId,
521 title: "Holder",
522 workspaceRoot: "/work/freed",
523 });
524 const claim = {
525 commandId: CommandId.make("command:freed:claim"),
526 projectId: ProjectId.make("project:freed:claim"),
527 title: "Claim",
528 workspaceRoot: "/work/freed",
529 };
530 const first = yield* service.create(claim).pipe(Effect.flip);
531 yield* service.delete({
532 commandId: CommandId.make("command:freed:delete"),
533 projectId: holderId,
534 });
535 const replayed = yield* service.create(claim).pipe(Effect.flip);
536 assert.deepEqual(replayed, first);
537 assert.instanceOf(replayed, ProjectService.ProjectConflictError);
538 assert.isTrue(
539 Option.isNone(yield* service.getById(claim.projectId, { includeDeleted: true })),
540 );
541 }),
542 );
543
544 it.effect("returns the deleted project when a completed delete is retried", () =>
545 Effect.gen(function* () {
546 const service = yield* ProjectService.ProjectService;
547 const sql = yield* SqlClient.SqlClient;
548 const projectId = ProjectId.make("project:delete-retry");
549 yield* service.create({
550 commandId: CommandId.make("command:delete-retry:create"),
551 projectId,
552 title: "Delete retry",
553 workspaceRoot: "/work/delete-retry",
554 });
555 // Enrichment landing between the two deletes would change the project the retry reads.
556 yield* waitForProject(
557 service,
558 projectId,
559 (project) => project.faviconPath !== null && project.repositoryIdentity !== null,
560 );
561 const input = { commandId: CommandId.make("command:delete-retry:delete"), projectId };
562 const deleted = yield* service.delete(input);
563 assert.deepEqual(yield* service.delete(input), deleted);
564 const events = yield* sql<{ readonly command_id: string }>`
565 SELECT command_id FROM orchestration_events
566 WHERE stream_id = ${projectId} AND event_type = 'project.deleted'
567 `;
568 assert.deepEqual(events, [{ command_id: input.commandId }]);
569 }),
570 );
571
572 it.effect("treats a deleted project as missing for updates and new deletes", () =>
573 Effect.gen(function* () {
574 const service = yield* ProjectService.ProjectService;
575 const sql = yield* SqlClient.SqlClient;
576 const projectId = ProjectId.make("project:deleted-target");
577 yield* service.create({
578 commandId: CommandId.make("command:deleted-target:create"),
579 projectId,
580 title: "Deleted target",
581 workspaceRoot: "/work/deleted-target",
582 });
583 yield* service.delete({
584 commandId: CommandId.make("command:deleted-target:delete"),
585 projectId,
586 });
587 const again = yield* service
588 .delete({ commandId: CommandId.make("command:deleted-target:delete-again"), projectId })
589 .pipe(Effect.flip);
590 assert.instanceOf(again, ProjectService.ProjectNotFoundError);
591 const events = yield* sql<{ readonly event_type: string }>`
592 SELECT event_type FROM orchestration_events
593 WHERE stream_id = ${projectId} ORDER BY sequence ASC
594 `;
595 assert.deepEqual(
596 events.map((event) => event.event_type),
597 ["project.created", "project.deleted"],
598 );
599 }),
600 );
601
602 it.effect("rolls back the event and receipt when the row write fails", () =>
603 Effect.gen(function* () {
604 const service = yield* ProjectService.ProjectService;
605 const sql = yield* SqlClient.SqlClient;
606 const projectId = ProjectId.make("project:atomic");
607 yield* sql`
608 CREATE TRIGGER fail_project_row BEFORE INSERT ON projection_projects
609 WHEN NEW.project_id = 'project:atomic'
610 BEGIN SELECT RAISE(ABORT, 'injected row failure'); END
611 `;
612 const input = {
613 commandId: CommandId.make("command:atomic:create"),
614 projectId,
615 title: "Atomic",
616 workspaceRoot: "/work/atomic",
617 };
618 const failure = yield* service.create(input).pipe(Effect.flip);
619 assert.equal(failure._tag, "ProjectOperationError");
620 const leftovers = yield* sql<{ readonly count: number }>`
621 SELECT
622 (SELECT COUNT(*) FROM orchestration_events WHERE command_id = ${input.commandId}) +
623 (SELECT COUNT(*) FROM orchestration_command_receipts WHERE command_id = ${input.commandId}) +
624 (SELECT COUNT(*) FROM projection_projects WHERE project_id = ${projectId}) AS count
625 `;
626 assert.equal(leftovers[0]?.count, 0);
627 yield* sql`DROP TRIGGER fail_project_row`;
628 assert.equal((yield* service.create(input)).id, projectId);
629 }),
630 );
631});
632
633it.effect("keeps project snapshots available when optional metadata enrichment fails", () =>
634 Effect.gen(function* () {
635 const layerFailingMetadata = Layer.merge(
636 Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, {
637 resolve: () => Effect.succeed(null),
638 }),
639 Layer.succeed(ProjectFaviconResolver.ProjectFaviconResolver, {
640 invalidate: () => Effect.void,
641 resolvePath: (workspaceRoot) =>
642 Effect.fail(
643 new ProjectFaviconResolver.ProjectFaviconResolutionError({
644 operation: "stat-candidate",
645 workspaceRoot,
646 cause: "permission denied",
647 }),
648 ),
649 }),
650 );
651
652 yield* Effect.gen(function* () {
653 const service = yield* ProjectService.ProjectService;
654 const projectId = ProjectId.make("project:failed-enrichment");
655 yield* service.create({
656 commandId: CommandId.make("command:failed-enrichment:create"),
657 projectId,
658 title: "Still visible",
659 workspaceRoot: "/work/failed-enrichment",
660 });
661
662 const snapshot = yield* service.snapshot;
663 assert.equal(snapshot.projects.length, 1);
664 assert.equal(snapshot.projects[0]?.id, projectId);
665 assert.equal(snapshot.projects[0]?.title, "Still visible");
666 assert.isNull(snapshot.projects[0]?.repositoryIdentity ?? null);
667 assert.isNull(snapshot.projects[0]?.faviconPath ?? null);
668 }).pipe(Effect.provide(layerTestFor(layerFailingMetadata)));
669 }),
670);
671
672it.effect("invalidates workspace-derived metadata when a project moves", () =>
673 Effect.gen(function* () {
674 const metadataVersion = yield* Ref.make(1);
675 const layerVersionedMetadata = Layer.merge(
676 Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, {
677 resolve: (workspaceRoot) =>
678 Ref.get(metadataVersion).pipe(
679 Effect.map((version) => ({
680 canonicalKey: `example.test/v${version}${workspaceRoot}`,
681 locator: {
682 source: "git-remote" as const,
683 remoteName: "origin",
684 remoteUrl: `https://example.test/v${version}${workspaceRoot}.git`,
685 },
686 rootPath: workspaceRoot,
687 })),
688 ),
689 }),
690 Layer.succeed(ProjectFaviconResolver.ProjectFaviconResolver, {
691 invalidate: () => Effect.void,
692 resolvePath: (workspaceRoot) =>
693 Ref.get(metadataVersion).pipe(
694 Effect.map((version) => `${workspaceRoot}/favicon-v${version}.svg`),
695 ),
696 }),
697 );
698
699 yield* Effect.gen(function* () {
700 const service = yield* ProjectService.ProjectService;
701 const projectId = ProjectId.make("project:moved-enrichment");
702 yield* service.create({
703 commandId: CommandId.make("command:moved-enrichment:create"),
704 projectId,
705 title: "Moved",
706 workspaceRoot: "/work/original",
707 });
708 assert.equal(
709 (yield* waitForProject(
710 service,
711 projectId,
712 (project) => project.faviconPath === "/work/original/favicon-v1.svg",
713 )).faviconPath,
714 "/work/original/favicon-v1.svg",
715 );
716
717 yield* Ref.set(metadataVersion, 2);
718 yield* service.update({
719 commandId: CommandId.make("command:moved-enrichment:away"),
720 projectId,
721 workspaceRoot: "/work/temporary",
722 });
723 yield* service.snapshot;
724
725 yield* Ref.set(metadataVersion, 3);
726 yield* service.update({
727 commandId: CommandId.make("command:moved-enrichment:return"),
728 projectId,
729 workspaceRoot: "/work/original",
730 });
731 assert.equal(
732 (yield* waitForProject(
733 service,
734 projectId,
735 (project) => project.faviconPath === "/work/original/favicon-v3.svg",
736 )).faviconPath,
737 "/work/original/favicon-v3.svg",
738 );
739 }).pipe(Effect.provide(layerTestFor(layerVersionedMetadata)));
740 }),
741);
742
743it.effect("resolves a restored icon immediately after reselecting its saved path", () =>
744 Effect.gen(function* () {
745 const fileSystem = yield* FileSystem.FileSystem;
746 const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "project-icon-reselect-" });
747 const iconPath = `${cwd}/icon.svg`;
748 const metadata = Layer.merge(
749 Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, {
750 resolve: () => Effect.succeed(null),
751 }),
752 ProjectFaviconResolver.layer.pipe(
753 Layer.provide(layerWorkspacePaths),
754 Layer.provide(T3ProjectFileLoader.layer),
755 Layer.provide(NodeServices.layer),
756 ),
757 );
758 yield* Effect.gen(function* () {
759 const service = yield* ProjectService.ProjectService;
760 const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
761 const projectId = ProjectId.make("project:icon-reselect");
762 yield* service.create({
763 commandId: CommandId.make("command:icon-reselect:create"),
764 projectId,
765 title: "Icon reselection",
766 workspaceRoot: cwd,
767 });
768 yield* service.update({
769 commandId: CommandId.make("command:icon-reselect:missing"),
770 projectId,
771 faviconPath: "icon.svg",
772 });
773 assert.isNull(yield* resolver.resolvePath(cwd, "icon.svg"));
774 yield* fileSystem.writeFileString(iconPath, '<svg xmlns="http://www.w3.org/2000/svg"/>');
775 assert.isNull(yield* resolver.resolvePath(cwd, "icon.svg"));
776 yield* service.update({
777 commandId: CommandId.make("command:icon-reselect:restored"),
778 projectId,
779 faviconPath: "icon.svg",
780 });
781 assert.equal(yield* resolver.resolvePath(cwd, "icon.svg"), iconPath);
782 }).pipe(Effect.provide(layerTestFor(metadata)));
783 }).pipe(Effect.provide(NodeServices.layer)),
784);
785
786it.effect("serializes two projects claiming the same workspace root", () =>
787 Effect.gen(function* () {
788 const eventSink = yield* EventSink.EventSinkV2;
789 const firstReachedCommit = yield* Deferred.make<void>();
790 const releaseFirst = yield* Deferred.make<void>();
791 const gatedSink = EventSink.EventSinkV2.of({
792 ...eventSink,
793 commitProjectCommand: (input) =>
794 input.projectId === "project:race:first"
795 ? Deferred.succeed(firstReachedCommit, undefined).pipe(
796 Effect.andThen(Deferred.await(releaseFirst)),
797 Effect.andThen(eventSink.commitProjectCommand(input)),
798 )
799 : eventSink.commitProjectCommand(input),
800 });
801 const service = yield* ProjectService.make.pipe(
802 Effect.provideService(EventSink.EventSinkV2, gatedSink),
803 );
804 const claim = (name: string) =>
805 service.create({
806 commandId: CommandId.make(`command:race:${name}`),
807 projectId: ProjectId.make(`project:race:${name}`),
808 title: name,
809 workspaceRoot: "/work/race",
810 });
811 const first = yield* claim("first").pipe(Effect.forkChild({ startImmediately: true }));
812 yield* Deferred.await(firstReachedCommit);
813 const second = yield* claim("second").pipe(
814 Effect.flip,
815 Effect.forkChild({ startImmediately: true }),
816 );
817 yield* Effect.yieldNow;
818 assert.isUndefined(second.pollUnsafe());
819 yield* Deferred.succeed(releaseFirst, undefined);
820 assert.equal((yield* Fiber.join(first)).id, "project:race:first");
821 assert.equal((yield* Fiber.join(second))._tag, "ProjectConflictError");
822 }).pipe(Effect.provide(layerProjectServiceDependencies)),
823);
824
825it.effect("rejects an update that waited on the lock while its project was deleted", () =>
826 Effect.gen(function* () {
827 const eventSink = yield* EventSink.EventSinkV2;
828 const deleteReachedCommit = yield* Deferred.make<void>();
829 const releaseDelete = yield* Deferred.make<void>();
830 const gatedSink = EventSink.EventSinkV2.of({
831 ...eventSink,
832 commitProjectCommand: (input) =>
833 input.commandType === "project.delete"
834 ? Deferred.succeed(deleteReachedCommit, undefined).pipe(
835 Effect.andThen(Deferred.await(releaseDelete)),
836 Effect.andThen(eventSink.commitProjectCommand(input)),
837 )
838 : eventSink.commitProjectCommand(input),
839 });
840 const service = yield* ProjectService.make.pipe(
841 Effect.provideService(EventSink.EventSinkV2, gatedSink),
842 );
843 const sql = yield* SqlClient.SqlClient;
844 const projectId = ProjectId.make("project:update-race");
845 yield* service.create({
846 commandId: CommandId.make("command:update-race:create"),
847 projectId,
848 title: "Update race",
849 workspaceRoot: "/work/update-race",
850 });
851 const deletion = yield* service
852 .delete({ commandId: CommandId.make("command:update-race:delete"), projectId })
853 .pipe(Effect.forkChild({ startImmediately: true }));
854 yield* Deferred.await(deleteReachedCommit);
855 const updateCommandId = CommandId.make("command:update-race:update");
856 const update = yield* service
857 .update({ commandId: updateCommandId, projectId, title: "Too late" })
858 .pipe(Effect.flip, Effect.forkChild({ startImmediately: true }));
859 yield* Effect.yieldNow;
860 yield* Deferred.succeed(releaseDelete, undefined);
861 assert.isNotNull((yield* Fiber.join(deletion)).deletedAt);
862 assert.instanceOf(yield* Fiber.join(update), ProjectService.ProjectNotFoundError);
863 const receipts = yield* sql<{ readonly status: string }>`
864 SELECT status FROM orchestration_command_receipts WHERE command_id = ${updateCommandId}
865 `;
866 assert.deepEqual(receipts, [{ status: "rejected" }]);
867 const events = yield* sql<{ readonly event_type: string }>`
868 SELECT event_type FROM orchestration_events
869 WHERE stream_id = ${projectId} ORDER BY sequence ASC
870 `;
871 assert.deepEqual(
872 events.map((event) => event.event_type),
873 ["project.created", "project.deleted"],
874 );
875 assert.equal(
876 Option.getOrThrow(yield* service.getById(projectId, { includeDeleted: true })).title,
877 "Update race",
878 );
879 }).pipe(Effect.provide(layerProjectServiceDependencies)),
880);