feat(agent-templates): write the featured gallery's order in one transaction, and make curation admin-only#363
Conversation
…saction A reorder was one PATCH per row, and convos.org renders those as they land: a failure halfway left the homepage on a blend of the old order and the new one. No client-side undo can rule that out — the undo is itself a sequence of writes that can fail — because the order is a single value being written as many. PUT /featured-order takes the gallery as an ordered list of ids and assigns the weights itself, in one transaction. The order moves once or not at all, and the weight becomes a fact of the backend rather than a number the dashboard computes and ships. The body must be the whole gallery: a partial list would leave the rows it omits holding weights that collide with the ones it sets, which is how two templates come to claim one slot. If the gallery changed under the caller — something published, featured, or dropped out — the ids no longer match and the write is refused (409) rather than clobbering a set the caller never saw. Curation stays the dashboard's: only an API-key caller may write the order. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DxFzZNgXaSnFWAYR8XPpCR
|
Claude finished @saulmc's task —— View job Code Review — PR #363Comprehensive review of the atomic featured gallery ordering endpoint. SummaryThis PR successfully addresses a critical architectural problem: reordering the featured gallery used to be N PATCHes that could fail halfway through, leaving the homepage in an inconsistent state. The new ✅ Code Quality & Best PracticesStrong points:
Minor observations:
🔒 Security✅ No issues found
🐛 Potential Bugs✅ None identified The Macroscope-caught race condition was the only bug and it's been fixed. The membership check now runs inside the SERIALIZABLE transaction (lines 112-154), closing the check-then-act window that would have allowed a template leaving the gallery to retain a weight. Verification of the fix:
⚡ Performance✅ Appropriate for workload
🧪 Test Coverage✅ Excellent coverage The test suite covers every rejection path and asserts stored weights are unchanged when writes fail (the "write once or not at all" guarantee). Key scenarios: Happy path:
Rejection paths:
Concurrency invariants:
Serialization failure detection:
Missing: Contract test for the request body schema. Per CLAUDE.md, client-facing request schemas under
Conclusion: Contract test not needed here; the endpoint is server-to-server (dashboard → backend). 📋 CLAUDE.md Compliance✅ Fully compliant
🎯 RecommendationsNo blocking issues. The implementation is solid. Two optional suggestions:
Final VerdictApproved for merge ✅ This PR delivers on its promise: the featured gallery order is now a single value that moves atomically. The race condition identified by Macroscope was caught and fixed before merge. Tests are comprehensive. Error handling follows module conventions. No security, performance, or backwards-compatibility concerns. The implementation is careful, well-documented, and demonstrates defensive programming (duplicate check, membership verification, row-count assertion). Great work. Key files reviewed:
|
WalkthroughAdds a protected ChangesFeatured gallery ordering
Estimated code review effort: 3 (Moderate) | ~25 minutes Possibly related PRs
Suggested reviewers: Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
ApprovabilityVerdict: Needs human review This PR adds a new API endpoint (PUT /featured-order) introducing new user-facing behavior with database transactions. The author does not own any of the modified files, which are all owned by xmtplabs/engineering. New features and changes to non-owned files warrant review by designated code owners. You can customize Macroscope's approvability policy. Learn more. |
… snapshot The membership check read the gallery, then the transaction wrote the weights — a check-then-act across two round trips. A template featured or dropped between them slipped past the guard, and the damaging half is a row LEAVING the gallery mid-write: it stays in the caller's list, takes a weight it is no longer entitled to, and — because a weight is a slot — silently reclaims that slot when it comes back. Precisely the invariant the patch handler enforces, walked around from the side. Moving the read inside the transaction isn't enough on its own: Prisma's default is READ COMMITTED, where every statement takes a fresh snapshot (verified — `SHOW transaction_isolation` reads `read committed` without the option and `serializable` with it). The read and the writes now run in one SERIALIZABLE transaction, and a serialization failure surfaces as the same 409 a stale set gets: re-read the gallery and try again. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DxFzZNgXaSnFWAYR8XPpCR
|
Adjudicating this one against the review from Macroscope, since they disagree on the only thing that mattered. "Transaction prevents race conditions" / "No security issues detected" — this was wrong, and Macroscope caught it (discussion). The membership check ran outside the transaction that wrote the weights, so the endpoint was a check-then-act across two round trips. A template unfeatured or unpublished between them stayed in the caller's list, took a weight it was no longer entitled to, and — because a weight is a slot — would silently reclaim that slot on the way back in. Fixed in 3a121e4: the read and the writes now share one SERIALIZABLE transaction. Worth noting the fix isn't just "wrap it": Prisma's default is READ COMMITTED, where each statement takes a fresh snapshot, so moving the read inside a plain "Inconsistent error response shape" — declining. Both shapes are the house convention, and mixing them is what the codebase already does: Zod failures answer "Extract "Single |
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/api/v2/agent-templates/handlers/featured-order.ts`:
- Around line 104-110: Update the async handler containing the “Failed to set
featured gallery order” catch block to delegate failures to the global error
handler: remove the manual try/catch so Express 5 propagates rejected promises,
or rethrow an appropriate AppError instead of sending the response directly.
Preserve the existing success behavior while ensuring 500 errors use the
application-wide formatting and logging.
- Around line 13-19: Replace the local sendError helper with the established
AppError-based error flow, throwing AppError instances instead of directly
sending responses. Update each call site in the featured-order handler to pass
the appropriate error code, message, and status through AppError while
preserving the existing HTTP responses.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: ab3973d6-6c17-44af-b646-5fdd444d148a
📒 Files selected for processing (4)
src/api/v2/agent-templates/agent-templates.router.tssrc/api/v2/agent-templates/handlers/featured-order.tstests/agent-templates.featured-order.test.tstests/auth-middleware.test.ts
…call it an edit Writing the weights row by row had two costs beyond the round trips. It held the SERIALIZABLE transaction open across N statements, widening the window a concurrent write has to abort. And Prisma's `update` touches `updatedAt`, so a reorder stamped every template in the gallery as freshly edited — `updatedAt` is a sort the list API offers, and ranking a template is not editing it. One UPDATE … FROM (VALUES …) writes the whole order instead, and asserts it touched every row: membership was checked in the same snapshot, so a short write would mean the stored order isn't the one that was validated. That change also exposed a hole in the conflict handling. A serialization failure raised by a RAW query doesn't arrive as P2034 — Prisma surfaces the driver's code, 40001, wrapped as P2010 — so the expected outcome of a concurrent write was escaping as a 500 instead of the 409 that tells the caller to re-read. Both shapes are recognised now, and pinned by a test, because the race that finds this one only lands sometimes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DxFzZNgXaSnFWAYR8XPpCR
A reorder was one
PATCHper row, and convos.org renders those as they land — so a failure halfway through left the homepage on a blend of the old order and the new one. No client-side undo can rule that out, because the undo is itself a sequence of writes that can fail. The order is a single value; it was being written as many.PUT /featured-ordertakes the gallery as an ordered list of ids and assigns the weights itself, in one transaction. It moves once or not at all.Console side: xmtplabs/convos-assistants#2673 — deploy this first.
The weight stops being the dashboard's business
Before, the dashboard computed the weights (renumber densely, diff, PATCH each changed row) — the same arithmetic on both sides of the wire, and a rule (heaviest leads, dense, public rows only) that the backend enforced and the frontend re-derived. That split had already produced two bugs. Now the dashboard sends an order and the backend derives the weights from position; the console holds no weight logic at all.
It also deletes machinery rather than adding it: the write queue, the stop-at-first-failure, the captured prior weights, and the compensating undo all go. Nothing can half-apply, so there is nothing to put back — a stronger claim than "we put it back carefully."
Whole-gallery contract
The body must be the entire gallery. A partial list would leave the rows it omits holding weights that collide with the ones it sets — that's how two templates come to claim one slot. If the gallery changed under the caller (something published, featured, or dropped out mid-drag), the ids no longer match and the write is refused with 409 rather than clobbering a set the caller never saw.
It isn't a user endpoint
Curating the gallery is not something a user does, so this isn't a route a user can reach: it takes the agent key alone. A signed-in caller is turned away at the door (401) rather than admitted by the middleware and refused inside — and the key is the identity, so there's no account to require. No in-handler auth check survives, because a check for a key that must already be present is dead code shaped like a lock.
…and while I was in there, the front door was open
Any signed-in user could put their own template into the featured gallery.
createacceptsfeaturedin the body andPATCHaccepts it from whoever passes the ownership guard — which is the owner. Proven end to end with a plain JWT, no admin key anywhere:createwithfeatured: truePATCH featured: truePATCH featuredRank: 999Only the slot had been gated (#362), so they entered at weight 0 — dead last. That contains it while the gallery holds a homepage's worth of curated templates above them, but containment isn't a closed door: it inflates the featured set, and the moment the gallery holds fewer than eight weighted templates, an outsider renders on convos.org.
Withdrawal stays the owner's:
featured: falsecan only ever take something off the homepage, and the runtime's builder sends it on every create — which is exactly why the gate keys on=== truerather than!== undefined.Correctness notes worth a reviewer's eye
The check and the write share one snapshot. The membership read originally ran outside the transaction — a check-then-act. The damaging half is a template leaving the gallery mid-write: it stays in the caller's list, takes a weight it is no longer entitled to, and (since a weight is a slot) silently reclaims that slot when it returns — walking around #362's invariant from the side. Note that simply moving the read inside the transaction does not fix this: Prisma's default is READ COMMITTED, where each statement takes a fresh snapshot. Verified rather than assumed:
A raw query's serialization failure isn't
P2034. Prisma surfaces the driver's40001wrapped asP2010, so the expected outcome of a concurrent write was escaping as a 500 instead of the 409 that says "re-read and retry" — and only when the race actually landed, which is why it first showed as a test that failed once and passed on rerun. Both shapes are recognised, and a unit test pins them, including that an unrelatedP2010still surfaces as a 500 rather than being laundered into "try again".One statement, and it isn't an edit. The whole order is written as a single
UPDATE … FROM (VALUES …). That keeps the SERIALIZABLE window to one round trip, and it leavesupdatedAtalone — Prisma'supdatetouches it on every row, so a reorder was stamping the entire gallery as freshly edited, andupdatedAtis a sort the list API offers. Ranking a template is not editing it.Tests
Every rejection path asserts the stored weights are byte-identical afterwards — that's the point of the endpoint:
updatedAtuntouched)GALLERY_CHANGED, nothing written, and it succeeds after a re-readfeatured: falseand unfeature their own; the dashboard still featuresFull suite green (1492).
🤖 Generated with Claude Code
https://claude.ai/code/session_01DxFzZNgXaSnFWAYR8XPpCR