1import { describe, expect, it } from "@effect/vitest";
2import { CommandId, type Project, ProjectId } from "@t3tools/contracts";
3import * as Deferred from "effect/Deferred";
4import * as Effect from "effect/Effect";
5import * as Fiber from "effect/Fiber";
6
7import { ProjectNotEmptyError } from "./ProjectService.ts";
8import { mutateProjectWithClone } from "./ProjectMutation.ts";
9
10const projectId = ProjectId.make("project-1");
11const deletedProject: Project = {
12 id: projectId,
13 title: "kh-frontend",
14 workspaceRoot: "/workspace/kh-frontend",
15 repositoryIdentity: null,
16 faviconPath: null,
17 defaultModelSelection: null,
18 defaultThreadEnvMode: null,
19 autoPull: false,
20 projectIcon: null,
21 scripts: [],
22 createdAt: "2026-01-01T00:00:00.000Z",
23 updatedAt: "2026-01-01T00:00:00.000Z",
24 deletedAt: "2026-01-01T00:00:00.000Z",
25};
26
27describe("mutateProjectWithClone", () => {
28 it.effect("keeps the clone when deletion is rejected", () =>
29 Effect.gen(function* () {
30 const rejected = new ProjectNotEmptyError({ projectId });
31 const result = yield* mutateProjectWithClone(
32 {
33 create: () => Effect.die("unused"),
34 update: () => Effect.die("unused"),
35 delete: () => Effect.fail(rejected),
36 },
37 { discard: () => Effect.die("must not discard a project that still exists") },
38 { type: "project.delete", commandId: CommandId.make("rejected-delete"), projectId },
39 ).pipe(Effect.flip);
40
41 expect(result).toBe(rejected);
42 }),
43 );
44
45 it.effect("discards the clone record even when the caller disconnects mid-delete", () =>
46 Effect.gen(function* () {
47 const committed = yield* Deferred.make<void>();
48 const finishDelete = yield* Deferred.make<void>();
49 const discarded: Array<ProjectId> = [];
50 const projects = {
51 create: () => Effect.die("unused"),
52 update: () => Effect.die("unused"),
53 // The delete event is already durable while its post-commit cleanup runs.
54 delete: () =>
55 Deferred.succeed(committed, undefined).pipe(
56 Effect.andThen(Deferred.await(finishDelete)),
57 Effect.as(deletedProject),
58 ),
59 };
60 const clones = {
61 discard: (id: ProjectId) => Effect.sync(() => void discarded.push(id)),
62 };
63
64 const request = yield* mutateProjectWithClone(projects, clones, {
65 type: "project.delete",
66 commandId: CommandId.make("command-1"),
67 projectId,
68 }).pipe(Effect.forkChild);
69 yield* Deferred.await(committed);
70 // Delivered synchronously so it lands before the delete can finish.
71 request.interruptUnsafe();
72 yield* Deferred.succeed(finishDelete, undefined);
73 yield* Fiber.await(request);
74
75 expect(discarded).toEqual([projectId]);
76 }),
77 );
78});