1import { assert, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import * as SqlClient from "effect/sql/SqlClient";
5
6import { runMigrations } from "../Migrations.ts";
7import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
8
9const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layer({ filename: ":memory:" })));
10
11layer("025_CleanupInvalidProjectionPendingApprovals", (it) => {
12 it.effect("removes pending-approval rows that do not come from approval requests", () =>
13 Effect.gen(function* () {
14 const sql = yield* SqlClient.SqlClient;
15
16 yield* runMigrations({ toMigrationInclusive: 24 });
17
18 yield* sql`
19 INSERT INTO projection_threads (
20 thread_id,
21 project_id,
22 title,
23 model_selection_json,
24 runtime_mode,
25 interaction_mode,
26 branch,
27 worktree_path,
28 latest_turn_id,
29 created_at,
30 updated_at,
31 archived_at,
32 latest_user_message_at,
33 pending_approval_count,
34 pending_user_input_count,
35 has_actionable_proposed_plan,
36 deleted_at
37 )
38 VALUES
39 (
40 'thread-valid',
41 'project-1',
42 'Valid thread',
43 '{"provider":"codex","model":"gpt-5-codex"}',
44 'approval-required',
45 'default',
46 NULL,
47 NULL,
48 'turn-valid',
49 '2026-04-13T00:00:00.000Z',
50 '2026-04-13T00:00:00.000Z',
51 NULL,
52 NULL,
53 2,
54 0,
55 0,
56 NULL
57 ),
58 (
59 'thread-invalid',
60 'project-1',
61 'Invalid thread',
62 '{"provider":"codex","model":"gpt-5-codex"}',
63 'approval-required',
64 'default',
65 NULL,
66 NULL,
67 'turn-invalid',
68 '2026-04-13T00:00:00.000Z',
69 '2026-04-13T00:00:00.000Z',
70 NULL,
71 NULL,
72 1,
73 0,
74 0,
75 NULL
76 )
77 `;
78
79 yield* sql`
80 INSERT INTO projection_thread_activities (
81 activity_id,
82 thread_id,
83 turn_id,
84 tone,
85 kind,
86 summary,
87 payload_json,
88 sequence,
89 created_at
90 )
91 VALUES
92 (
93 'activity-approval-requested',
94 'thread-valid',
95 'turn-valid',
96 'approval',
97 'approval.requested',
98 'Command approval requested',
99 '{"requestId":"approval-valid","requestKind":"command"}',
100 NULL,
101 '2026-04-13T00:01:00.000Z'
102 ),
103 (
104 'activity-user-input-requested',
105 'thread-invalid',
106 'turn-invalid',
107 'info',
108 'user-input.requested',
109 'User input requested',
110 '{"requestId":"input-invalid","questions":[{"id":"scope","header":"Scope","question":"What should I inspect?","options":[{"label":"Server","description":"Inspect server code."}]}]}',
111 NULL,
112 '2026-04-13T00:02:00.000Z'
113 )
114 `;
115
116 yield* sql`
117 INSERT INTO projection_pending_approvals (
118 request_id,
119 thread_id,
120 turn_id,
121 status,
122 decision,
123 created_at,
124 resolved_at
125 )
126 VALUES
127 (
128 'approval-valid',
129 'thread-valid',
130 'turn-valid',
131 'pending',
132 NULL,
133 '2026-04-13T00:01:00.000Z',
134 NULL
135 ),
136 (
137 'input-invalid',
138 'thread-invalid',
139 'turn-invalid',
140 'pending',
141 NULL,
142 '2026-04-13T00:02:00.000Z',
143 NULL
144 ),
145 (
146 'input-invalid-resolved',
147 'thread-valid',
148 'turn-valid',
149 'resolved',
150 NULL,
151 '2026-04-13T00:03:00.000Z',
152 '2026-04-13T00:04:00.000Z'
153 )
154 `;
155
156 yield* runMigrations({ toMigrationInclusive: 25 });
157
158 const approvalRows = yield* sql<{
159 readonly requestId: string;
160 readonly status: string;
161 }>`
162 SELECT
163 request_id AS "requestId",
164 status
165 FROM projection_pending_approvals
166 ORDER BY request_id ASC
167 `;
168 assert.deepStrictEqual(approvalRows, [
169 {
170 requestId: "approval-valid",
171 status: "pending",
172 },
173 ]);
174
175 const threadCounts = yield* sql<{
176 readonly threadId: string;
177 readonly pendingApprovalCount: number;
178 }>`
179 SELECT
180 thread_id AS "threadId",
181 pending_approval_count AS "pendingApprovalCount"
182 FROM projection_threads
183 ORDER BY thread_id ASC
184 `;
185 assert.deepStrictEqual(threadCounts, [
186 {
187 threadId: "thread-invalid",
188 pendingApprovalCount: 0,
189 },
190 {
191 threadId: "thread-valid",
192 pendingApprovalCount: 1,
193 },
194 ]);
195 }),
196 );
197});