Files
boocode/apps/server/src/routes/panes.ts
indifferentketchup 82180bc135 batch3 T2 review fixes: movePane sentinel + count race + PATCH atomicity
- Move sentinel from -1 to -100 (outside the negate range) so moves from
  position 0 no longer collide with negated row at -1
- Pull count check + position validation inside sql.begin in POST so two
  concurrent inserts can't both pass the max-5 guard
- Wrap movePane + state UPDATE in a single transaction in PATCH so partial
  failures roll back consistently
2026-05-15 14:58:49 +00:00

209 lines
7.2 KiB
TypeScript

import type { FastifyInstance } from 'fastify';
import type { TransactionSql } from 'postgres';
import type { Sql } from '../db.js';
import type { Pane, PaneCreateRequest, PaneUpdateRequest } from '../types/api.js';
const VALID_KINDS = new Set(['chat', 'file_browser']);
const MAX_PANES = 5;
async function movePane(
tx: TransactionSql,
paneId: string,
sid: string,
oldPos: number,
newPos: number
): Promise<void> {
if (oldPos === newPos) return;
// Move target pane to a sentinel well outside the negate range [-MAX_PANES, -1]
// so it never collides with negated rows during the shift steps.
await tx`UPDATE session_panes SET position = -100 WHERE id = ${paneId}`;
if (newPos > oldPos) {
await tx`UPDATE session_panes SET position = -position
WHERE session_id = ${sid} AND position > ${oldPos} AND position <= ${newPos}`;
await tx`UPDATE session_panes SET position = -position - 1
WHERE session_id = ${sid} AND position < 0 AND id != ${paneId}`;
} else {
await tx`UPDATE session_panes SET position = -position - 2
WHERE session_id = ${sid} AND position >= ${newPos} AND position < ${oldPos}`;
await tx`UPDATE session_panes SET position = -position - 1
WHERE session_id = ${sid} AND position < 0 AND id != ${paneId}`;
}
await tx`UPDATE session_panes SET position = ${newPos} WHERE id = ${paneId}`;
}
export function registerPaneRoutes(app: FastifyInstance, sql: Sql): void {
// GET /api/sessions/:id/panes — list panes ordered by position ASC
app.get<{ Params: { id: string } }>(
'/api/sessions/:id/panes',
async (req, reply) => {
const sessionRows = await sql`SELECT id FROM sessions WHERE id = ${req.params.id}`;
if (sessionRows.length === 0) {
reply.code(404);
return { error: 'session not found' };
}
const panes = await sql<Pane[]>`
SELECT id, session_id, position, kind, state, created_at
FROM session_panes
WHERE session_id = ${req.params.id}
ORDER BY position ASC
`;
return { panes };
}
);
// POST /api/sessions/:id/panes — create a new pane
app.post<{ Params: { id: string } }>(
'/api/sessions/:id/panes',
async (req, reply) => {
const body = (req.body ?? {}) as PaneCreateRequest;
const { kind, position } = body;
if (!kind || !VALID_KINDS.has(kind)) {
reply.code(400);
return { error: 'kind must be "chat" or "file_browser"' };
}
const sessionRows = await sql`SELECT id FROM sessions WHERE id = ${req.params.id}`;
if (sessionRows.length === 0) {
reply.code(404);
return { error: 'session not found' };
}
const sid = req.params.id;
const state = {};
let insertError: string | null = null;
const inserted = await sql.begin(async (tx) => {
const countResult = await tx<{ n: number }[]>`
SELECT COUNT(*)::int AS n FROM session_panes WHERE session_id = ${sid}
`;
const n = countResult[0]!.n;
if (n >= MAX_PANES) {
throw new Error('MAX_PANES_EXCEEDED');
}
let insertPos: number;
if (position === undefined || position === null) {
insertPos = n;
} else {
if (position < 0 || position > n) {
throw new Error('OUT_OF_BOUNDS');
}
insertPos = position;
}
await tx`UPDATE session_panes SET position = -position - 1
WHERE session_id = ${sid} AND position >= ${insertPos}`;
const [row] = await tx<Pane[]>`
INSERT INTO session_panes (session_id, position, kind, state)
VALUES (${sid}, ${insertPos}, ${kind}, ${JSON.stringify(state)}::jsonb)
RETURNING id, session_id, position, kind, state, created_at
`;
await tx`UPDATE session_panes SET position = -position
WHERE session_id = ${sid} AND position < 0`;
return row;
}).catch((err: Error) => {
insertError = err.message;
return null;
});
if (insertError === 'MAX_PANES_EXCEEDED') {
reply.code(400);
return { error: `session already has ${MAX_PANES} panes (maximum)` };
}
if (insertError === 'OUT_OF_BOUNDS') {
reply.code(400);
return { error: `position out of bounds` };
}
if (insertError) {
reply.code(500);
return { error: 'internal error' };
}
reply.code(201);
return inserted as Pane;
}
);
// PATCH /api/panes/:id — update state and/or position
app.patch<{ Params: { id: string } }>(
'/api/panes/:id',
async (req, reply) => {
const body = (req.body ?? {}) as PaneUpdateRequest;
const { state, position } = body;
if (state === undefined && position === undefined) {
reply.code(400);
return { error: 'must provide at least one of: state, position' };
}
const paneRows = await sql<Pane[]>`
SELECT id, session_id, position, kind, state, created_at
FROM session_panes WHERE id = ${req.params.id}
`;
if (paneRows.length === 0) {
reply.code(404);
return { error: 'pane not found' };
}
const pane = paneRows[0]!;
const sid = pane.session_id;
const oldPos = pane.position;
// Validate position if provided
if (position !== undefined) {
const countRows = await sql<{ n: number }[]>`
SELECT COUNT(*)::int AS n FROM session_panes WHERE session_id = ${sid}
`;
const count = countRows[0]?.n ?? 0;
if (position < 0 || position >= count) {
reply.code(400);
return { error: `position must be between 0 and ${count - 1}` };
}
}
// Apply position and/or state changes atomically
await sql.begin(async (tx) => {
if (position !== undefined && position !== oldPos) {
await movePane(tx, req.params.id, sid, oldPos, position);
}
if (state !== undefined) {
await tx`
UPDATE session_panes SET state = ${JSON.stringify(state)}::jsonb
WHERE id = ${req.params.id}
`;
}
});
const [updated] = await sql<Pane[]>`
SELECT id, session_id, position, kind, state, created_at
FROM session_panes WHERE id = ${req.params.id}
`;
return updated as Pane;
}
);
// DELETE /api/panes/:id — delete a pane, shift remaining down
app.delete<{ Params: { id: string } }>(
'/api/panes/:id',
async (req, reply) => {
const paneRows = await sql<{ id: string; session_id: string; position: number }[]>`
SELECT id, session_id, position FROM session_panes WHERE id = ${req.params.id}
`;
if (paneRows.length === 0) {
reply.code(404);
return { error: 'pane not found' };
}
const { session_id: sid, position: P } = paneRows[0]!;
await sql.begin(async (tx) => {
await tx`DELETE FROM session_panes WHERE id = ${req.params.id}`;
await tx`UPDATE session_panes SET position = -position
WHERE session_id = ${sid} AND position > ${P}`;
await tx`UPDATE session_panes SET position = -position - 1
WHERE session_id = ${sid} AND position < 0`;
});
reply.code(204);
return null;
}
);
}