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