Skip to content

Commit a10175f

Browse files
saulmcclaude
andauthored
feat(agent-templates): write the featured gallery's order in one transaction (#363)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 791f278 commit a10175f

5 files changed

Lines changed: 549 additions & 4 deletions

File tree

src/api/v2/agent-templates/agent-templates.router.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { buildAttachmentPresignedHandler } from "./handlers/build-attachment-pre
99
import { createHandler } from "./handlers/create";
1010
import { deleteHandler } from "./handlers/delete";
1111
import { detailHandler } from "./handlers/detail";
12+
import { featuredOrderHandler } from "./handlers/featured-order";
1213
import { generationsEphemeralPostHandler } from "./handlers/generations-ephemeral-post";
1314
import { generationsGetHandler } from "./handlers/generations-get";
1415
import { generationsPostHandler } from "./handlers/generations-post";
@@ -76,6 +77,15 @@ agentTemplatesRouter.get(
7677
listCountsHandler,
7778
);
7879

80+
// The featured gallery's order, written whole and in one transaction. Mounted
81+
// before /:id so the wildcard doesn't capture "featured-order" as a template id.
82+
agentTemplatesRouter.put(
83+
"/featured-order",
84+
authOrAgentApiKeyAuth,
85+
requireAccount,
86+
featuredOrderHandler,
87+
);
88+
7989
agentTemplatesRouter.patch(
8090
"/:id",
8191
authOrAgentApiKeyAuth,
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
import { Prisma } from "@prisma/client";
2+
import type { Request, Response } from "express";
3+
import { z } from "zod";
4+
import { prisma } from "@/utils/prisma";
5+
6+
// The gallery holds far fewer than this; the cap just bounds the transaction.
7+
const MAX_GALLERY = 200;
8+
9+
const bodySchema = z.object({
10+
// The whole gallery, top slot first.
11+
templateIds: z.array(z.string().uuid()).min(1).max(MAX_GALLERY),
12+
});
13+
14+
const sendError = (
15+
res: Response,
16+
status: number,
17+
error: { code: string; message: string },
18+
) => {
19+
res.status(status).json({ error });
20+
};
21+
22+
/**
23+
* Did Postgres refuse this transaction because another one moved the gallery
24+
* under it?
25+
*
26+
* Two shapes, because the transaction mixes query styles: Prisma maps a
27+
* serialization failure in its OWN queries to `P2034`, while a raw query
28+
* surfaces the driver's code instead — `40001`, serialization_failure — wrapped
29+
* as `P2010`. Matching only the first lets a genuine, expected conflict escape
30+
* as a 500.
31+
*/
32+
export const isSerializationFailure = (error: unknown): boolean => {
33+
if (!(error instanceof Prisma.PrismaClientKnownRequestError)) {
34+
return false;
35+
}
36+
if (error.code === "P2034") {
37+
return true;
38+
}
39+
const meta = error.meta as { code?: string } | undefined;
40+
return error.code === "P2010" && meta?.code === "40001";
41+
};
42+
43+
/**
44+
* Write the featured gallery's order — the whole thing, in one transaction.
45+
*
46+
* A reorder used to be one PATCH per row, and convos.org renders those as they
47+
* land: a failure halfway through left the homepage on a blend of the old order
48+
* and the new one, which no amount of client-side undo can rule out (the undo
49+
* is itself a sequence of writes that can fail). The order is a single value,
50+
* so it moves once or not at all.
51+
*
52+
* The body must be the *entire* gallery. A partial list would leave the rows it
53+
* omits holding weights that collide with the ones it sets, which is how you
54+
* get two templates claiming one slot. If the gallery has changed since the
55+
* caller read it — something published, featured, or dropped out — the ids
56+
* won't match and the write is refused rather than clobbering a set the caller
57+
* never saw.
58+
*/
59+
export async function featuredOrderHandler(req: Request, res: Response) {
60+
// Curation is the dashboard's, not an owner's — same rule the rank field
61+
// itself carries in PATCH.
62+
const isApiKeyListener = res.locals.isApiKeyListener ?? false;
63+
if (!isApiKeyListener) {
64+
req.log.warn(
65+
{ callerAccountId: res.locals.accountId, action: "featured-order" },
66+
"Unauthorized agent-template curation attempt",
67+
);
68+
sendError(res, 403, {
69+
code: "FORBIDDEN",
70+
message: "Not authorized to set the featured gallery order",
71+
});
72+
return;
73+
}
74+
75+
const parsed = bodySchema.safeParse(req.body);
76+
if (!parsed.success) {
77+
res.status(400).json({
78+
error: "Invalid request body",
79+
details: parsed.error.issues,
80+
});
81+
return;
82+
}
83+
84+
const { templateIds } = parsed.data;
85+
const unique = new Set(templateIds);
86+
if (unique.size !== templateIds.length) {
87+
sendError(res, 400, {
88+
code: "DUPLICATE_TEMPLATE",
89+
message: "templateIds contains the same template more than once",
90+
});
91+
return;
92+
}
93+
94+
try {
95+
const total = templateIds.length;
96+
97+
// The membership check and the writes are one transaction, at SERIALIZABLE.
98+
//
99+
// Both halves are needed. Reading the gallery outside the write would make
100+
// this a check-then-act across two round trips; and moving the read inside a
101+
// default (READ COMMITTED) transaction wouldn't close it either, since every
102+
// statement there takes a fresh snapshot — a template featured or dropped
103+
// between the check and the writes would still slip past. The damaging case
104+
// is a row LEAVING the gallery mid-write: it stays in `templateIds`, takes a
105+
// weight it's no longer entitled to, and — because a weight is a slot —
106+
// silently reclaims that slot when it comes back. Exactly the invariant the
107+
// patch handler enforces, walked around from the side.
108+
//
109+
// SERIALIZABLE makes the read and the writes see one snapshot and aborts the
110+
// loser of a conflicting pair, which surfaces below as the same 409 a stale
111+
// set gets: re-read the gallery and try again.
112+
const updated = await prisma.$transaction(
113+
async (tx) => {
114+
const gallery = await tx.agentTemplate.findMany({
115+
where: { featured: true, status: "published" },
116+
select: { id: true },
117+
});
118+
119+
const galleryIds = new Set(gallery.map((template) => template.id));
120+
const matchesGallery =
121+
galleryIds.size === unique.size &&
122+
templateIds.every((id) => galleryIds.has(id));
123+
if (!matchesGallery) {
124+
return null;
125+
}
126+
127+
// Heaviest leads, so the first id gets the largest weight. One statement
128+
// for the whole gallery, rather than an update per row: it keeps the
129+
// SERIALIZABLE window to a single round trip (fewer aborts under a
130+
// concurrent write), and it leaves `updatedAt` alone — Prisma's `update`
131+
// would touch it on every row, so a reorder would stamp the entire
132+
// gallery as freshly edited, and `updatedAt` is a sort the list API
133+
// offers. Ranking a template is not editing it.
134+
const written = await tx.$executeRaw`
135+
UPDATE "AgentTemplate" AS t
136+
SET "featuredRank" = v.rank
137+
FROM (VALUES ${Prisma.join(
138+
templateIds.map(
139+
(id, index) => Prisma.sql`(${id}::uuid, ${total - index}::int)`,
140+
),
141+
)}) AS v(id, rank)
142+
WHERE t."id" = v.id
143+
`;
144+
// Membership was checked in this same snapshot, so every id must land.
145+
// If one didn't, the order we'd be storing isn't the one we validated.
146+
if (written !== total) {
147+
throw new Error(
148+
`featured-order wrote ${written} of ${total} templates`,
149+
);
150+
}
151+
return total;
152+
},
153+
{ isolationLevel: Prisma.TransactionIsolationLevel.Serializable },
154+
);
155+
156+
if (updated === null) {
157+
sendError(res, 409, {
158+
code: "GALLERY_CHANGED",
159+
message:
160+
"templateIds must be exactly the featured, published templates — the gallery changed, so re-read it and try again",
161+
});
162+
return;
163+
}
164+
165+
res.status(200).json({ updated });
166+
} catch (error) {
167+
// A serialization failure means the gallery moved under this write. The
168+
// caller's answer is the same as for a set that was already stale.
169+
if (isSerializationFailure(error)) {
170+
sendError(res, 409, {
171+
code: "GALLERY_CHANGED",
172+
message:
173+
"the gallery changed while this order was being written — re-read it and try again",
174+
});
175+
return;
176+
}
177+
req.log.error(
178+
{ error, stack: error instanceof Error ? error.stack : undefined },
179+
"Failed to set featured gallery order",
180+
);
181+
res.status(500).json({ error: "Failed to set featured gallery order" });
182+
}
183+
}

0 commit comments

Comments
 (0)