Skip to content
Source
Sign inSign up
Open History to see the last commit

ProjectService.deletion.test.ts

530 lines22.0 KB

1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, it } from "@effect/vitest";
3import {
4 CommandId,
5 EventId,
6 type OrchestrationV2AppThread,
7 ProjectId,
8 ProviderInstanceId,
9 ThreadId,
10} from "@t3tools/contracts";
11import * as DateTime from "effect/DateTime";
12import * as Effect from "effect/Effect";
13import * as Layer from "effect/Layer";
14import * as Option from "effect/Option";
15import * as Schema from "effect/Schema";
16import { TestClock } from "effect/testing";
17import * as SqlClient from "effect/sql/SqlClient";
18​
19import * as ServerConfig from "../config.ts";
20import { OrchestrationEffectRequestV2 } from "../orchestration-v2/EffectOutbox.ts";
21import * as EventSink from "../orchestration-v2/EventSink.ts";
22import * as EventStore from "../orchestration-v2/EventStore.ts";
23import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
24import * as LegacyV1ThreadImporter from "../orchestration-v2/legacy/LegacyV1ThreadImporter.ts";
25import * as ProjectionReplay from "../orchestration-v2/testkit/ProjectionReplay.ts";
26import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts";
27import * as ProjectStore from "../orchestration-v2/ProjectStore.ts";
28import * as ThreadCommandExecutor from "../orchestration-v2/ThreadCommandExecutor.ts";
29import { planThreadDeletion } from "../orchestration-v2/ThreadDeletion.ts";
30import * as SqlitePersistence from "../persistence/Sqlite.ts";
31import * as ServerSettings from "../serverSettings.ts";
32import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
33import * as ProjectEnrichmentService from "./ProjectEnrichmentService.ts";
34import * as ProjectFaviconResolver from "./ProjectFaviconResolver.ts";
35import * as ProjectService from "./ProjectService.ts";
36import * as RepositoryIdentityResolver from "./RepositoryIdentityResolver.ts";
37​
38const layerEventPersistence = EventSink.layer.pipe(
39 Layer.provideMerge(Layer.merge(EventStore.layer, ProjectionStore.layer)),
40);
41const layerServices = Layer.mergeAll(
42 LegacyV1ThreadImporter.layer.pipe(Layer.provideMerge(layerEventPersistence)),
43 ProjectionReplay.layer.pipe(Layer.provide(layerEventPersistence)),
44 ProjectStore.layer,
45 IdAllocator.layer,
46 ThreadCommandExecutor.layer,
47 Layer.succeed(WorkspacePaths.WorkspacePaths, {
48 normalizeWorkspaceRoot: (workspaceRoot) => Effect.succeed(workspaceRoot),
49 resolveRelativePathWithinRoot: ({ workspaceRoot, relativePath }) =>
50 Effect.succeed({ absolutePath: `${workspaceRoot}/${relativePath}`, relativePath }),
51 }),
52).pipe(
53 Layer.provideMerge(
54 ProjectEnrichmentService.layer.pipe(
55 Layer.provide(
56 Layer.merge(
57 Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, {
58 resolve: () => Effect.succeed(null),
59 }),
60 Layer.succeed(ProjectFaviconResolver.ProjectFaviconResolver, {
61 invalidate: () => Effect.void,
62 resolvePath: () => Effect.succeed(null),
63 }),
64 ),
65 ),
66 ),
67 ),
68 Layer.provideMerge(ServerSettings.layerTest()),
69);
70const layerDatabase = SqlitePersistence.layerMemory.pipe(
71 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "project-deletion-test-" })),
72 Layer.provideMerge(NodeServices.layer),
73);
74const decodeEffectRequest = Schema.decodeUnknownEffect(
75 Schema.fromJsonString(OrchestrationEffectRequestV2),
76);
77​
78const seedProject = Effect.fn("ProjectDeletionTest.seedProject")(function* (projectId: ProjectId) {
79 const sql = yield* SqlClient.SqlClient;
80 yield* sql`
81 INSERT INTO projection_projects (
82 project_id, title, workspace_root, default_model_selection_json,
83 scripts_json, created_at, updated_at, deleted_at
84 ) VALUES (
85 ${projectId}, 'Deletion test', ${`/work/${projectId}`}, NULL,
86 '[]', '2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z', NULL
87 )
88 `;
89});
90​
91function nativeThreadCreated(projectId: ProjectId, threadId: ThreadId) {
92 const createdAt = DateTime.makeUnsafe("2026-01-01T00:00:00.000Z");
93 const providerInstanceId = ProviderInstanceId.make("codex");
94 const payload: OrchestrationV2AppThread = {
95 createdBy: "user",
96 creationSource: "web",
97 id: threadId,
98 projectId,
99 title: threadId,
100 providerInstanceId,
101 modelSelection: { instanceId: providerInstanceId, model: "gpt-5" },
102 runtimeMode: "full-access",
103 interactionMode: "default",
104 branch: null,
105 worktreePath: null,
106 activeProviderThreadId: null,
107 lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId },
108 forkedFrom: null,
109 createdAt,
110 updatedAt: createdAt,
111 archivedAt: null,
112 settledOverride: null,
113 settledAt: null,
114 lastVisitedAt: null,
115 deletedAt: null,
116 };
117 return {
118 id: EventId.make(`created:${threadId}`),
119 type: "thread.created" as const,
120 threadId,
121 providerInstanceId,
122 occurredAt: createdAt,
123 payload,
124 };
126​
127it.effect("retries a partial project deletion without repeating child events or cleanup", () =>
128 Effect.gen(function* () {
129 const sql = yield* SqlClient.SqlClient;
130 const projectId = ProjectId.make("project:partial-deletion");
131 const threadIds = [ThreadId.make("thread:delete-a"), ThreadId.make("thread:delete-b")] as const;
132 const commandId = CommandId.make("command:partial-project-delete");
133 yield* seedProject(projectId);
134 yield* TestClock.setTime(Date.parse("2026-09-04T12:00:00.000Z"));
135​
136 yield* Effect.gen(function* () {
137 const eventSink = yield* EventSink.EventSinkV2;
138 const projections = yield* ProjectionStore.ProjectionStoreV2;
139 yield* eventSink.write({
140 events: threadIds.map((threadId) => nativeThreadCreated(projectId, threadId)),
141 });
142 const attempts: ThreadId[] = [];
143 const failingEventSink = EventSink.EventSinkV2.of({
144 ...eventSink,
145 commitCommand: Effect.fn("ProjectDeletionTest.failSecondChild")(function* (
146 input: Parameters<EventSink.EventSinkV2Shape["commitCommand"]>[0],
147 ) {
148 attempts.push(input.threadId);
149 if (attempts.length === 2) {
150 return yield* new EventSink.EventSinkWriteError({
151 commandId: input.commandId,
152 eventCount: input.events.length,
153 cause: new Error("Injected failure for the second child"),
154 });
155 }
156 return yield* eventSink.commitCommand(input);
157 }),
158 });
159 const service = yield* ProjectService.make.pipe(
160 Effect.provideService(EventSink.EventSinkV2, failingEventSink),
161 );
162 const input = { commandId, projectId, force: true };
163 const failure = yield* service.delete(input).pipe(Effect.flip);
164 assert.instanceOf(failure, ProjectService.ProjectOperationError);
165 if (failure._tag !== "ProjectOperationError") return assert.fail("Expected a child failure");
166 assert.equal(failure.operation, "delete-thread");
167 assert.lengthOf(attempts, 2);
168 const firstThreadId = attempts[0] ?? assert.fail("The first child was not attempted");
169 const failedThreadId = attempts[1] ?? assert.fail("The second child was not attempted");
170 assert.notEqual(firstThreadId, failedThreadId);
171 assert.isTrue(Option.isSome(yield* service.getById(projectId)));
172 assert.isNotNull((yield* projections.getThreadProjection(firstThreadId)).thread.deletedAt);
173 assert.isNull((yield* projections.getThreadProjection(failedThreadId)).thread.deletedAt);
174​
175 const readDeletions = sql<{
176 readonly sequence: number;
177 readonly stream_id: string;
178 readonly command_id: string;
179 readonly event_type: string;
180 }>`
181 SELECT sequence, stream_id, command_id, event_type
182 FROM orchestration_events
183 WHERE event_type IN ('thread.deleted', 'project.deleted')
184 AND stream_id IN (${threadIds[0]}, ${threadIds[1]}, ${projectId})
185 ORDER BY sequence ASC
186 `;
187 const readCleanup = sql<{
188 readonly effect_id: string;
189 readonly thread_id: string;
190 readonly command_id: string;
191 readonly effect_type: string;
192 }>`
193 SELECT effect_id, thread_id, command_id, effect_type
194 FROM orchestration_v2_effect_outbox
195 WHERE thread_id IN (${threadIds[0]}, ${threadIds[1]})
196 ORDER BY effect_id ASC
197 `;
198 const partialEvents = yield* readDeletions;
199 const partialCleanup = yield* readCleanup;
200 assert.lengthOf(partialEvents, 1);
201 assert.equal(partialEvents[0]?.stream_id, firstThreadId);
202 assert.equal(partialEvents[0]?.event_type, "thread.deleted");
203 assert.deepEqual(
204 partialCleanup.map((effect) => [effect.thread_id, effect.effect_type]),
205 [
206 [firstThreadId, "preview.cleanup"],
207 [firstThreadId, "scheduled-tasks.cleanup"],
208 [firstThreadId, "terminal.cleanup"],
209 ],
210 );
211​
212 const deletedProject = yield* service.delete(input);
213 assert.isNotNull(deletedProject.deletedAt);
214 assert.isTrue(Option.isNone(yield* service.getById(projectId)));
215 assert.deepEqual(attempts, [firstThreadId, failedThreadId, failedThreadId]);
216 for (const threadId of threadIds) {
217 assert.isNotNull((yield* projections.getThreadProjection(threadId)).thread.deletedAt);
218 }
219 const finalEvents = yield* readDeletions;
220 assert.deepEqual(
221 finalEvents.map((event) => [event.stream_id, event.event_type]),
222 [
223 [firstThreadId, "thread.deleted"],
224 [failedThreadId, "thread.deleted"],
225 [projectId, "project.deleted"],
226 ],
227 );
228 assert.deepEqual(finalEvents[0], partialEvents[0]);
229 assert.equal(finalEvents[2]?.command_id, commandId);
230 const finalCleanup = yield* readCleanup;
231 assert.lengthOf(finalCleanup, 6);
232 assert.deepEqual(
233 finalCleanup.filter((effect) => effect.thread_id === firstThreadId),
234 partialCleanup,
235 );
236 for (const threadId of threadIds) {
237 const expectedCommandId = `${commandId}:delete-thread:${threadId}`;
238 assert.deepEqual(
239 finalCleanup.filter((effect) => effect.thread_id === threadId),
240 [
241 {
242 effect_id: `effect:${expectedCommandId}:preview.cleanup`,
243 thread_id: threadId,
244 command_id: expectedCommandId,
245 effect_type: "preview.cleanup",
246 },
247 {
248 effect_id: `effect:${expectedCommandId}:scheduled-tasks.cleanup`,
249 thread_id: threadId,
250 command_id: expectedCommandId,
251 effect_type: "scheduled-tasks.cleanup",
252 },
253 {
254 effect_id: `effect:${expectedCommandId}:terminal.cleanup`,
255 thread_id: threadId,
256 command_id: expectedCommandId,
257 effect_type: "terminal.cleanup",
258 },
259 ],
260 );
261 }
262 }).pipe(Effect.provide(layerServices));
263 }).pipe(Effect.provide(layerDatabase)),
264);
265​
266it.effect(
267 "hydrates a migrated transcript before deleting its project and cleaning attachments",
268 () =>
269 Effect.gen(function* () {
270 const sql = yield* SqlClient.SqlClient;
271 const projectId = ProjectId.make("project:legacy-deletion");
272 const threadId = ThreadId.make("thread:legacy-deletion");
273 const commandId = CommandId.make("command:legacy-project-delete");
274 yield* seedProject(projectId);
275 yield* sql`
276 INSERT INTO projection_threads (
277 thread_id, project_id, title, model_selection_json,
278 runtime_mode, interaction_mode, branch, worktree_path, latest_turn_id,
279 created_at, updated_at, archived_at, deleted_at
280 ) VALUES (
281 ${threadId}, ${projectId}, 'Legacy thread', '{"instanceId":"codex","model":"gpt-5.4"}',
282 'full-access', 'default', NULL, NULL, NULL,
283 '2026-01-01T00:00:00.000Z', '2026-01-04T00:00:00.000Z', NULL, NULL
284 )
285 `;
286 yield* sql`
287 INSERT INTO projection_thread_messages (
288 message_id, thread_id, turn_id, role, text, attachments_json,
289 is_streaming, created_at, updated_at
290 ) VALUES
291 (
292 'message:legacy-delete:1', ${threadId}, NULL, 'user', 'First question with screenshot',
293 '[{"type":"image","id":"legacy_screenshot","name":"screenshot.png","mimeType":"image/png","sizeBytes":128}]',
294 0, '2026-01-01T01:00:00.000Z', '2026-01-01T01:00:00.000Z'
295 ),
296 (
297 'message:legacy-delete:2', ${threadId}, NULL, 'assistant', 'First answer', '[]',
298 0, '2026-01-02T01:00:00.000Z', '2026-01-02T01:00:00.000Z'
299 ),
300 (
301 'message:legacy-delete:3', ${threadId}, NULL, 'user', 'Follow-up question', '[]',
302 0, '2026-01-03T01:00:00.000Z', '2026-01-03T01:00:00.000Z'
303 ),
304 (
305 'message:legacy-delete:4', ${threadId}, NULL, 'assistant', 'Follow-up answer', '[]',
306 0, '2026-01-04T01:00:00.000Z', '2026-01-04T01:00:00.000Z'
307 )
308 `;
309 yield* TestClock.setTime(Date.parse("2026-09-04T12:00:00.000Z"));
310​
311 yield* Effect.gen(function* () {
312 const importer = yield* LegacyV1ThreadImporter.LegacyV1ThreadImporter;
313 const maintenance = yield* ProjectionReplay.ProjectionReplay;
314 const projections = yield* ProjectionStore.ProjectionStoreV2;
315 const service = yield* ProjectService.make;
316 assert.deepEqual(yield* importer.reconcileShells, {
317 importedThreadCount: 1,
318 importedMessageCount: 2,
319 });
320 assert.equal(yield* importer.pendingThreadCount, 1);
321 assert.isTrue((yield* maintenance.rebuild).valid);
322 const shellProjection = yield* projections.getThreadProjection(threadId);
323 assert.deepEqual(
324 shellProjection.messages.map((message) => message.id),
325 ["message:legacy-delete:3", "message:legacy-delete:4"],
326 );
327 assert.deepEqual(
328 shellProjection.messages.flatMap((message) => message.attachments),
329 [],
330 );
331​
332 const deletedProject = yield* service.delete({ commandId, projectId, force: true });
333 assert.isNotNull(deletedProject.deletedAt);
334 assert.isTrue(Option.isNone(yield* service.getById(projectId)));
335 const projection = yield* projections.getThreadProjection(threadId);
336 assert.isNotNull(projection.thread.deletedAt);
337 assert.lengthOf(projection.messages, 4);
338 assert.equal(yield* importer.pendingThreadCount, 0);
339 const rows = yield* sql<{
340 readonly v2_deleted_at: string | null;
341 readonly project_deleted_at: string | null;
342 }>`
343 SELECT v2.deleted_at AS v2_deleted_at,
344 project.deleted_at AS project_deleted_at
345 FROM orchestration_v2_projection_threads AS v2
346 JOIN projection_projects AS project ON project.project_id = v2.project_id
347 WHERE v2.thread_id = ${threadId}
348 `;
349 assert.lengthOf(rows, 1);
350 assert.isNotNull(rows[0]?.v2_deleted_at);
351 assert.isNotNull(rows[0]?.project_deleted_at);
352 const legacyEvents = yield* sql`
353 SELECT sequence FROM orchestration_events
354 WHERE application_event_version = 1 AND stream_id = ${threadId}
355 `;
356 assert.deepEqual(legacyEvents, []);
357​
358 const cleanup = yield* sql<{
359 readonly command_id: string;
360 readonly payload_json: string;
361 readonly status: string;
362 }>`
363 SELECT command_id, payload_json, status
364 FROM orchestration_v2_effect_outbox
365 WHERE thread_id = ${threadId} AND effect_type = 'attachment.cleanup'
366 `;
367 assert.lengthOf(cleanup, 1);
368 assert.equal(cleanup[0]?.command_id, `${commandId}:delete-thread:${threadId}`);
369 assert.equal(cleanup[0]?.status, "pending");
370 const request = yield* decodeEffectRequest(cleanup[0]?.payload_json);
371 assert.deepEqual(request, {
372 type: "attachment.cleanup",
373 attachmentIds: ["legacy_screenshot"],
374 });
375 }).pipe(Effect.provide(layerServices));
376 }).pipe(Effect.provide(layerDatabase)),
377);
378​
379it.effect("rejects a child deletion command ID already accepted for an unrelated thread", () =>
380 Effect.gen(function* () {
381 const sql = yield* SqlClient.SqlClient;
382 const projectId = ProjectId.make("project:receipt-collision");
383 const otherProjectId = ProjectId.make("project:unrelated-receipt");
384 const threadId = ThreadId.make("thread:receipt-collision");
385 const otherThreadId = ThreadId.make("thread:unrelated-receipt");
386 const commandId = CommandId.make("command:collision-project-delete");
387 yield* seedProject(projectId);
388 yield* seedProject(otherProjectId);
389​
390 yield* Effect.gen(function* () {
391 const eventSink = yield* EventSink.EventSinkV2;
392 const projections = yield* ProjectionStore.ProjectionStoreV2;
393 const service = yield* ProjectService.make;
394 yield* eventSink.write({ events: [nativeThreadCreated(projectId, threadId)] });
395 const accepted = yield* eventSink.commitCommand({
396 commandId: CommandId.make(`${commandId}:delete-thread:${threadId}`),
397 commandType: "thread.create",
398 threadId: otherThreadId,
399 acceptedAt: DateTime.makeUnsafe("2026-01-01T00:00:00.000Z"),
400 events: [nativeThreadCreated(otherProjectId, otherThreadId)],
401 effects: [],
402 });
403 assert.equal(accepted.receipt.status, "accepted");
404​
405 const failure = yield* service
406 .delete({ commandId, projectId, force: true })
407 .pipe(Effect.flip);
408 assert.instanceOf(failure, ProjectService.ProjectOperationError);
409 if (failure._tag !== "ProjectOperationError") return assert.fail("Expected a child failure");
410 assert.equal(failure.operation, "delete-thread");
411 assert.isTrue(Option.isSome(yield* service.getById(projectId)));
412 assert.isNull((yield* projections.getThreadProjection(threadId)).thread.deletedAt);
413 assert.isNull((yield* projections.getThreadProjection(otherThreadId)).thread.deletedAt);
414 const deletions = yield* sql`
415 SELECT sequence FROM orchestration_events
416 WHERE event_type IN ('thread.deleted', 'project.deleted')
417 AND stream_id IN (${threadId}, ${otherThreadId}, ${projectId})
418 `;
419 assert.deepEqual(deletions, []);
420 const cleanup = yield* sql`
421 SELECT effect_id FROM orchestration_v2_effect_outbox
422 WHERE thread_id = ${threadId}
423 `;
424 assert.deepEqual(cleanup, []);
425 }).pipe(Effect.provide(layerServices));
426 }).pipe(Effect.provide(layerDatabase)),
427);
428​
429it.effect("deletes a project without force once its imported threads were deleted in V2", () =>
430 Effect.gen(function* () {
431 const sql = yield* SqlClient.SqlClient;
432 const projectId = ProjectId.make("project:imported-emptied");
433 const threadId = ThreadId.make("thread:imported-emptied");
434 yield* seedProject(projectId);
435 yield* sql`
436 INSERT INTO projection_threads (
437 thread_id, project_id, title, model_selection_json,
438 runtime_mode, interaction_mode, branch, worktree_path, latest_turn_id,
439 created_at, updated_at, archived_at, deleted_at
440 ) VALUES (
441 ${threadId}, ${projectId}, 'Imported thread', '{"instanceId":"codex","model":"gpt-5.4"}',
442 'full-access', 'default', NULL, NULL, NULL,
443 '2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z', NULL, NULL
444 )
445 `;
446 yield* TestClock.setTime(Date.parse("2026-09-04T12:00:00.000Z"));
447​
448 yield* Effect.gen(function* () {
449 const importer = yield* LegacyV1ThreadImporter.LegacyV1ThreadImporter;
450 const projections = yield* ProjectionStore.ProjectionStoreV2;
451 const eventSink = yield* EventSink.EventSinkV2;
452 const idAllocator = yield* IdAllocator.IdAllocatorV2;
453 const service = yield* ProjectService.make;
454 yield* importer.reconcileShells;
455 const early = yield* service
456 .delete({ commandId: CommandId.make("command:emptied:early"), projectId })
457 .pipe(Effect.flip);
458 assert.equal(early._tag, "ProjectNotEmptyError");
459​
460 yield* importer.ensureTranscript(threadId);
461 const command = {
462 type: "thread.delete" as const,
463 commandId: CommandId.make("command:emptied:thread-delete"),
464 threadId,
465 };
466 const now = yield* DateTime.now;
467 const plan = yield* planThreadDeletion({
468 command,
469 projection: yield* projections.getThreadRecords(threadId, [
470 "runs",
471 "attempts",
472 "nodes",
473 "runtimeRequests",
474 "subagents",
475 "providerSessions",
476 ]),
477 attachmentIds: [],
478 now,
479 idAllocator,
480 });
481 yield* eventSink.commitCommand({
482 commandId: command.commandId,
483 commandType: command.type,
484 threadId,
485 acceptedAt: now,
486 events: plan.events,
487 effects: plan.effects,
488 });
489 const legacy = yield* sql<{ readonly deleted_at: string | null }>`
490 SELECT deleted_at FROM projection_threads WHERE thread_id = ${threadId}
491 `;
492 assert.deepEqual(legacy, [{ deleted_at: null }]);
493​
494 const deleted = yield* service.delete({
495 commandId: CommandId.make("command:emptied:project-delete"),
496 projectId,
497 });
498 assert.isNotNull(deleted.deletedAt);
499 assert.isTrue(Option.isNone(yield* service.getById(projectId)));
500 }).pipe(Effect.provide(layerServices));
501 }).pipe(Effect.provide(layerDatabase)),
502);
503​
504it.effect(
505 "clears a deleted project's settings overrides so a machine-disabled instance cannot stay alive",
506 () =>
507 Effect.gen(function* () {
508 const projectId = ProjectId.make("project:overrides-cleanup");
509 const instanceId = ProviderInstanceId.make("codex_work");
510 yield* seedProject(projectId);
511​
512 yield* Effect.gen(function* () {
513 const service = yield* ProjectService.make;
514 const serverSettings = yield* ServerSettings.ServerSettingsService;
515 yield* serverSettings.updateSettings({
516 projectSettingsOverrides: {
517 [projectId]: { providerInstances: { [instanceId]: { enabled: true } } },
518 },
519 });
520​
521 yield* service.delete({
522 commandId: CommandId.make("command:overrides-cleanup:project-delete"),
523 projectId,
524 });
525​
526 const settingsAfterDelete = yield* serverSettings.getSettings;
527 assert.isUndefined(settingsAfterDelete.projectSettingsOverrides[projectId]);
528 }).pipe(Effect.provide(layerServices));
529 }).pipe(Effect.provide(layerDatabase)),
530);