Skip to content

Commit d0060d3

Browse files
committed
Merge feat/delete-conversations-branches: DELETE conversations + branches endpoints
2 parents e64d2b9 + 9300e11 commit d0060d3

1 file changed

Lines changed: 165 additions & 0 deletions

File tree

src/routes/drift/index.ts

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,171 @@ const driftRoutes: FastifyPluginAsyncTypebox = async (fastify) => {
218218
}
219219
);
220220

221+
// Delete a conversation and everything belonging to it
222+
fastify.delete(
223+
'/conversations/:conversationId',
224+
{
225+
schema: {
226+
description:
227+
'Delete a conversation and all of its branches, messages, and facts. Use this to implement client "Clear history" flows. Returns 404 when the conversation does not exist (or belongs to another user).',
228+
tags: ['Drift'],
229+
params: Type.Object({
230+
conversationId: Type.String(),
231+
}),
232+
response: {
233+
200: Type.Object({
234+
success: Type.Literal(true),
235+
data: Type.Object({
236+
deletedBranches: Type.Number(),
237+
deletedMessages: Type.Number(),
238+
deletedFacts: Type.Number(),
239+
}),
240+
}),
241+
404: Type.Object({
242+
success: Type.Literal(false),
243+
error: Type.Object({ message: Type.String() }),
244+
}),
245+
},
246+
},
247+
},
248+
async (request, reply) => {
249+
const { conversationId } = request.params;
250+
const userId = request.userId ?? '';
251+
252+
const conversation = await fastify.prisma.conversation.findUnique({
253+
where: { userId_id: { userId, id: conversationId } },
254+
select: { id: true },
255+
});
256+
257+
if (!conversation) {
258+
return reply.status(404).send({
259+
success: false,
260+
error: { message: 'Conversation not found' },
261+
});
262+
}
263+
264+
// Count what's about to go so we can report it back
265+
const [deletedBranches, deletedMessages, deletedFacts] = await Promise.all([
266+
fastify.prisma.branch.count({ where: { userId, conversationId } }),
267+
fastify.prisma.message.count({ where: { userId, conversationId } }),
268+
fastify.prisma.fact.count({
269+
where: { branch: { userId, conversationId } },
270+
}),
271+
]);
272+
273+
// Atomic cascade. Prisma cascades Conversation -> Branches/Clusters/Messages
274+
// and Branch -> Facts/ClusterMemberships/TailRoutes/MergeEdges. TailRoutes
275+
// anchored to these messages (anchorMessageId has no cascade) are cleared
276+
// explicitly; DriftLog has no FK and is cleaned by conversationId.
277+
await fastify.prisma.$transaction(async (tx) => {
278+
const msgs = await tx.message.findMany({
279+
where: { userId, conversationId },
280+
select: { id: true },
281+
});
282+
const messageIds = msgs.map((m) => m.id);
283+
if (messageIds.length > 0) {
284+
await tx.tailRoute.deleteMany({
285+
where: { anchorMessageId: { in: messageIds } },
286+
});
287+
}
288+
await tx.driftLog.deleteMany({ where: { conversationId } });
289+
await tx.conversation.delete({
290+
where: { userId_id: { userId, id: conversationId } },
291+
});
292+
});
293+
294+
return reply.send({
295+
success: true,
296+
data: { deletedBranches, deletedMessages, deletedFacts },
297+
});
298+
}
299+
);
300+
301+
// Delete a single branch. Children cascade: deleting a branch also deletes
302+
// every descendant branch plus their messages and facts. Document this
303+
// behaviour in the SDK / API reference.
304+
fastify.delete(
305+
'/branches/:branchId',
306+
{
307+
schema: {
308+
description:
309+
'Delete a branch and every descendant branch (cascading), including their messages and facts. Returns 404 when the branch does not exist or belongs to another user.',
310+
tags: ['Drift'],
311+
params: Type.Object({
312+
branchId: Type.String(),
313+
}),
314+
response: {
315+
200: Type.Object({
316+
success: Type.Literal(true),
317+
data: Type.Object({
318+
deletedMessages: Type.Number(),
319+
deletedFacts: Type.Number(),
320+
}),
321+
}),
322+
404: Type.Object({
323+
success: Type.Literal(false),
324+
error: Type.Object({ message: Type.String() }),
325+
}),
326+
},
327+
},
328+
},
329+
async (request, reply) => {
330+
const { branchId } = request.params;
331+
const userId = request.userId ?? '';
332+
333+
const branch = await fastify.prisma.branch.findUnique({
334+
where: { id: branchId },
335+
select: { userId: true },
336+
});
337+
338+
// 404 covers both "does not exist" and "belongs to someone else" to avoid
339+
// leaking branch ID existence across tenants.
340+
if (!branch || branch.userId !== userId) {
341+
return reply.status(404).send({
342+
success: false,
343+
error: { message: 'Branch not found' },
344+
});
345+
}
346+
347+
// Walk the subtree rooted at branchId via recursive CTE, scoped to user.
348+
const rows = await fastify.prisma.$queryRaw<{ id: string }[]>`
349+
WITH RECURSIVE branch_tree AS (
350+
SELECT id FROM branches WHERE id = ${branchId} AND "userId" = ${userId}
351+
UNION
352+
SELECT b.id FROM branches b
353+
INNER JOIN branch_tree bt ON b."parentId" = bt.id
354+
WHERE b."userId" = ${userId}
355+
)
356+
SELECT id FROM branch_tree;
357+
`;
358+
const branchIds = rows.map((r) => r.id);
359+
360+
const [deletedMessages, deletedFacts] = await Promise.all([
361+
fastify.prisma.message.count({ where: { branchId: { in: branchIds } } }),
362+
fastify.prisma.fact.count({ where: { branchId: { in: branchIds } } }),
363+
]);
364+
365+
await fastify.prisma.$transaction(async (tx) => {
366+
const msgs = await tx.message.findMany({
367+
where: { branchId: { in: branchIds } },
368+
select: { id: true },
369+
});
370+
const messageIds = msgs.map((m) => m.id);
371+
if (messageIds.length > 0) {
372+
await tx.tailRoute.deleteMany({
373+
where: { anchorMessageId: { in: messageIds } },
374+
});
375+
}
376+
await tx.branch.deleteMany({ where: { id: { in: branchIds } } });
377+
});
378+
379+
return reply.send({
380+
success: true,
381+
data: { deletedMessages, deletedFacts },
382+
});
383+
}
384+
);
385+
221386
// Health check
222387
fastify.get(
223388
'/health',

0 commit comments

Comments
 (0)