1
0
Fork 0

Compare commits

...

3 commits

Author SHA1 Message Date
e05befbcbe v0.3.1 - fix tech tree auto-arrange overloading the server 2026-10-03 04:08:35 -04:00
98cc860299 docs: document bulk tech tree position writes 2026-10-03 04:08:29 -04:00
80ad24580a fix(tech-tree): save canvas positions in one request so auto-arrange cannot overload the server
Every canvas position write (Auto-arrange, Save positions, drags, freeze pins)
used to send one PATCH per technology, all at once. On a large tree that was
dozens of concurrent full document updates, which exhausted the database pool
and could crash the server.

- Add POST /api/tech-tree/positions: validates `{ positions: [{ id, x, y }] }`
  (null x/y clears a pin, capped at 2000) and applies every position in a
  single UPDATE on the tree position columns. Access mirrors the technologies
  update rule (technologies:update or Intelligence membership).
- Route all canvas position writes through it; failures now surface in the
  toolbar instead of being swallowed.
- Auto-arrange is optimistic: the canvas re-lays out on click and the pins
  are restored if the save fails. A spinner status chip shows while a layout
  save runs, and layout controls and dragging are locked until it finishes.
- Integration tests for validation and the bulk write; the e2e checks the busy
  indicator and that no per-node PATCHes are sent.
2026-10-03 04:08:29 -04:00
7 changed files with 374 additions and 48 deletions

View file

@ -312,9 +312,9 @@ Leaflet world map at `/map` (top-level sidebar entry, whole unit views it). Full
- **View wiring**: `payload.config.ts` `admin.components.views.techTree` (path `/tech-tree`, component `src/components/admin/tech-tree/TechTreeView.tsx`) and `admin.components.Nav` → `TechTreeNav.tsx`. Custom root views are public by default, so the view gates itself (logged-in + `technologies:read` OR Intelligence qualification). The nav replica injects the entry into the group containing `technologies` and reuses public exports (`NavWrapper`/`NavHamburger`/`DefaultNavClient` from `@payloadcms/next/client`, `groupNavItems` from `@payloadcms/ui/shared`); registered admin components must be **default exports** (importMap convention in this repo).
- **Layout** (`layout.ts`, pure): a **propagating left-to-right tree**. A technology WITH prerequisites is placed one column to the right of its parents, at their average row, so linking A -> B drops B immediately beside A and B's dependents continue further right; nothing is teleported to a distant column when its depth changes. Technologies in a category stay pinned to that category's column (the category contract); uncategorized roots fill a catalog grid whose slots come from each tech's rank among ALL uncategorized technologies (`MAX_ROWS_PER_COLUMN` = 10 rows, spilling into more columns) so promoting a tech into the tree leaves an empty slot rather than re-shuffling the grid; uncategorized dependents follow their parents inside the uncategorized zone. Collisions search rows outward from the desired row (dependents may also grow upward, and their columns are not height-capped, so a link from a full column still lands beside its parent rather than far right). Category bands are the bounding box of their members' final positions. Prerequisites placed to the RIGHT of their dependent surface as amber warnings (toolbar chip + node icon + preview panel section).
- **Edges**: AND prerequisites solid slate, OR prerequisites dashed cyan with an OR label. Connections are editable on the canvas: drag from a node's right handle onto another node (the target card OR its handle) to add it as an AND prerequisite; the drop-on-card case is resolved by an `onConnectEnd` fallback because React Flow only resolves drops onto a handle. Select an edge and press Backspace to remove the prerequisite. Duplicates, self-references, and cycles (direct or transitive) are rejected by the pure `prereqEdits.ts` helpers with an inline error in the toolbar. Lines are also manually routable: selecting an edge reveals a "+" handle at each segment's midpoint (click to add a bend), bends drag to move, and double-clicking a bend removes it (`RoutedEdge.tsx` + `edgeRoutes.ts`). Routes are stored per browser in `localStorage`; making them shared would need a persisted field on the target technology. Edges also route around technologies they are not connected to: `edgeRouting.ts` detours each segment around the node rectangles it would cross (inflated by a clearance margin) and `buildRoutePath` rounds every corner. An unrouted edge keeps its automatic out/across/in shape unless that path would cut through a node, in which case it falls back to a direct line the router can bend around.
- **Interactions**: nodes are draggable; dropping one PATCHes its `treePosition` (persisted, survives reload) and the toolbar shows an "Auto-arrange" button (technologies:update gated) that clears every manual position and restores the ordered layout. A **Save positions** button (technologies:update gated), next to Auto-arrange, commits the current on-screen position of every node to `treePosition` in one pass (matching what React Flow rendered, not just dragged/pinned cards) so the exact arrangement survives a reload; Auto-arrange then clears them. Dragging one card of a multi-selection moves and persists the whole selection (React Flow hands the full dragged set to `onNodeDragStop`), and a category band is draggable by its header (`dragHandle`), which moves every technology in that band. A **Snap: On/Off** toggle (default on, remembered per browser, 46px grid that divides `COLUMN_STEP`/`ROW_STEP`) snaps dragged cards to the grid. **Connecting does not move anything**: if the ordered layout would relocate the connected technology, the canvas pins it to its current spot (`treePosition` written in the same PATCH as the prerequisite) so the edge appears without the card jumping; the catalog grid also uses stable slots (each tech's rank among all uncategorized technologies, so a tech promoted into the tree leaves its slot empty instead of re-shuffling every other root). Pressing "Auto-arrange" clears those pins and moves everything, including the connected node, to its ordered position. Node click opens the side panel, which is a **lite editor** over the fields it shows (name, type, approval status, summary, category with inline create, prerequisites with AND/OR grouping and add/remove, research costs with resource rows and min/max durations): dirty tracking, Save/Reset, and an "Edit in full view" jump to the standard admin edit route. Final approval states stay superuser-only (`canSetFinalApproval` = `system:admin-access`), mirroring the field access. Quick-create dialog (name, type, summary, category, research durations) creates via REST; the category picker is a native input + suggestion list (existing values selectable, new values creatable inline) because Radix Popover inside the admin Dialog proved layer-unstable. New categories get `position = max + 1`. The canvas uses React Flow's `<Background variant="lines">` so the grid scales with zoom (a CSS-background grid does not, and looked broken at low zoom), and `fitView` is capped at `maxZoom: 0.85`. Panel and toolbar controls carry dark tactical chrome (the admin panel's light shadcn defaults would render as white slabs on the dark surface; bare `<button>` elements in the admin additionally inherit a global light background AND border, so list rows need explicit `bg-transparent` + `border-0`), and the panel's dropdowns use the in-house `PanelSelect` listbox rather than native `<select>`: a native popup is browser/OS chrome that ignores dark styling (only `color-scheme` influences it) and cannot be screenshotted, whereas `PanelSelect` renders its list through a portal as a fixed-position overlay above the panel (so it never pushes the panel's content down), flips upward when there is more room above, closes on scroll/resize so it cannot detach from its trigger, and offers a search field for long lists. Both it and the panel's category picker use a mono header strip, hairline row separators, a cyan left accent on the selected row, and the `.tech-tree-scroll` dark scrollbar rules from `custom.scss`.
- **Interactions**: nodes are draggable; dropping one PATCHes its `treePosition` (persisted, survives reload) and the toolbar shows an "Auto-arrange" button (technologies:update gated) that clears every manual position and restores the ordered layout. A **Save positions** button (technologies:update gated), next to Auto-arrange, commits the current on-screen position of every node to `treePosition` in one pass (matching what React Flow rendered, not just dragged/pinned cards) so the exact arrangement survives a reload; Auto-arrange then clears them. Dragging one card of a multi-selection moves and persists the whole selection (React Flow hands the full dragged set to `onNodeDragStop`), and a category band is draggable by its header (`dragHandle`), which moves every technology in that band. A **Snap: On/Off** toggle (default on, remembered per browser, 46px grid that divides `COLUMN_STEP`/`ROW_STEP`) snaps dragged cards to the grid. **Every canvas position write is one bulk request**: drags, Save positions, Auto-arrange, and freeze pins all POST `{ positions: [{ id, x, y }] }` to `/api/tech-tree/positions` (null x/y clears a pin), which `writeTreePositions` (`src/lib/tech-tree/positions.ts`) applies as a single `UPDATE ... FROM (VALUES ...)` on the `tree_position_x/y` columns (access = `technologies:update` or Intelligence membership, the collection's own update rule; batches capped at 2000). Never persist positions with a PATCH per node: firing dozens of concurrent document updates exhausted the database pool and could crash the server on Auto-arrange. Auto-arrange is optimistic (the canvas re-lays out on click, pins are restored if the save fails) and a spinner status chip plus disabled layout controls and node dragging cover the save. **Connecting does not move anything**: if the ordered layout would relocate the connected technology, the canvas pins it to its current spot (`treePosition` written in the same PATCH as the prerequisite) so the edge appears without the card jumping; the catalog grid also uses stable slots (each tech's rank among all uncategorized technologies, so a tech promoted into the tree leaves its slot empty instead of re-shuffling every other root). Pressing "Auto-arrange" clears those pins and moves everything, including the connected node, to its ordered position. Node click opens the side panel, which is a **lite editor** over the fields it shows (name, type, approval status, summary, category with inline create, prerequisites with AND/OR grouping and add/remove, research costs with resource rows and min/max durations): dirty tracking, Save/Reset, and an "Edit in full view" jump to the standard admin edit route. Final approval states stay superuser-only (`canSetFinalApproval` = `system:admin-access`), mirroring the field access. Quick-create dialog (name, type, summary, category, research durations) creates via REST; the category picker is a native input + suggestion list (existing values selectable, new values creatable inline) because Radix Popover inside the admin Dialog proved layer-unstable. New categories get `position = max + 1`. The canvas uses React Flow's `<Background variant="lines">` so the grid scales with zoom (a CSS-background grid does not, and looked broken at low zoom), and `fitView` is capped at `maxZoom: 0.85`. Panel and toolbar controls carry dark tactical chrome (the admin panel's light shadcn defaults would render as white slabs on the dark surface; bare `<button>` elements in the admin additionally inherit a global light background AND border, so list rows need explicit `bg-transparent` + `border-0`), and the panel's dropdowns use the in-house `PanelSelect` listbox rather than native `<select>`: a native popup is browser/OS chrome that ignores dark styling (only `color-scheme` influences it) and cannot be screenshotted, whereas `PanelSelect` renders its list through a portal as a fixed-position overlay above the panel (so it never pushes the panel's content down), flips upward when there is more room above, closes on scroll/resize so it cannot detach from its trigger, and offers a search field for long lists. Both it and the panel's category picker use a mono header strip, hairline row separators, a cyan left accent on the selected row, and the `.tech-tree-scroll` dark scrollbar rules from `custom.scss`.
- **Styling**: tactical dossier (dark `#05070a` canvas + 28px grid via inline styles, squared corners, classification strips, mono readouts); shadcn form controls in the dialog stay conventional. React Flow controls/attribution are darkened via `.tech-tree-canvas` rules in `src/app/(payload)/custom.scss`.
- **Gotchas**: React Flow in controlled mode needs `useNodesState` AND `useEdgesState` with change handlers that forward everything except `remove` (a no-op edge handler silently breaks edge selection, and a passed-through remove lets Backspace delete nodes that only exist in the data layer); category band nodes must be `pointerEvents: "none"` or they swallow edge/pane clicks; node handles must live OUTSIDE the card's `overflow-hidden` box or they become un-draggable; a second `next dev` for the same directory is impossible (Next 16 lock) so e2e verification either frees port 3000 or uses a copied repo (whose file watcher is unreliable: restart the copied server after every source change). technologies create requires `approvalStatus` + `researchCosts.minimumResearchDuration` (the field's `defaultValue: 0` violates its own `min: 1`, so creates must pass it explicitly); clearing `treePosition` means sending `{ x: null, y: null }` (a group cannot be nulled wholesale). The e2e seeds via cookie-authed REST (`page.request`) after frontend login, so it MUST run against the test DB: a dev server left on port 3000 is silently reused (`reuseExistingServer: true`) and its DEV database 403s every seed (the dev user has no create permission). Free the port first. Tests: `tests/int/tech-tree-layout.int.spec.ts` (pure layout: wrapping, overrides, band bboxes, warnings, cycles), `tests/int/tech-tree-prereq-edits.int.spec.ts` (pure add/remove/switch with cycle rejection), `tests/int/tech-categories.int.spec.ts` (CRUD, RBAC, division gating, FK set-null, position round-trip), `tests/int/tech-tree-edge-routes.int.spec.ts` (rounded route path + obstacle avoidance) + `tests/e2e/tech-tree.e2e.spec.ts` (nav, render, panel, edit nav, both quick-create paths, drag persistence + Auto-arrange, save positions + reload survival, multi-select drag, category-band drag, snap-to-grid, edge routing, connect/disconnect + lite-editor saves, wrapping at scale). Vivaldi crashes under automation in this suite; run with `E2E_BROWSER_PATH=<playwright chromium>`.
- **Gotchas**: React Flow in controlled mode needs `useNodesState` AND `useEdgesState` with change handlers that forward everything except `remove` (a no-op edge handler silently breaks edge selection, and a passed-through remove lets Backspace delete nodes that only exist in the data layer); category band nodes must be `pointerEvents: "none"` or they swallow edge/pane clicks; node handles must live OUTSIDE the card's `overflow-hidden` box or they become un-draggable; a second `next dev` for the same directory is impossible (Next 16 lock) so e2e verification either frees port 3000 or uses a copied repo (whose file watcher is unreliable: restart the copied server after every source change). technologies create requires `approvalStatus` + `researchCosts.minimumResearchDuration` (the field's `defaultValue: 0` violates its own `min: 1`, so creates must pass it explicitly); clearing `treePosition` means sending `{ x: null, y: null }` (a group cannot be nulled wholesale). The e2e seeds via cookie-authed REST (`page.request`) after frontend login, so it MUST run against the test DB: a dev server left on port 3000 is silently reused (`reuseExistingServer: true`) and its DEV database 403s every seed (the dev user has no create permission). Free the port first. Tests: `tests/int/tech-tree-layout.int.spec.ts` (pure layout: wrapping, overrides, band bboxes, warnings, cycles), `tests/int/tech-tree-prereq-edits.int.spec.ts` (pure add/remove/switch with cycle rejection), `tests/int/tech-categories.int.spec.ts` (CRUD, RBAC, division gating, FK set-null, position round-trip), `tests/int/tech-tree-edge-routes.int.spec.ts` (rounded route path + obstacle avoidance), `tests/int/tech-tree-positions.int.spec.ts` (bulk position validation + single-statement write/clear) + `tests/e2e/tech-tree.e2e.spec.ts` (nav, render, panel, edit nav, both quick-create paths, drag persistence + Auto-arrange, save positions + reload survival, multi-select drag, category-band drag, snap-to-grid, edge routing, connect/disconnect + lite-editor saves, wrapping at scale). Vivaldi crashes under automation in this suite; run with `E2E_BROWSER_PATH=<playwright chromium>`.
## Wiki

View file

@ -1,6 +1,6 @@
{
"name": "polaris-task-force",
"version": "0.3.0",
"version": "0.3.1",
"description": "Web app for the Arma 3 unit Polaris Task Force. Uses Next.js for client- and server-side rendering, with a Postgres database and some other neat libraries.",
"license": "MIT",
"type": "module",

View file

@ -0,0 +1,52 @@
import { NextRequest, NextResponse } from "next/server";
import config from "@payload-config";
import { getPayload } from "payload";
import { parsePositionUpdates, writeTreePositions } from "@/lib/tech-tree/positions";
import { hasIntelligenceQualification } from "@/utils/access-control/hasIntelligenceQualification";
import { hasPermission } from "@/utils/access-control/hasPermission";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
/**
* Bulk Tech Tree position write: `{ positions: [{ id, x, y }] }`, where a
* null x/y returns a technology to the automatic layout. Used by every canvas
* position write (drags, Save positions, Auto-arrange, freeze pins) so they
* cost one request and one SQL statement instead of a PATCH per node.
*
* Access mirrors the technologies collection's update rule: the
* `technologies:update` permission OR Intelligence division membership.
*/
export async function POST(req: NextRequest) {
const payload = await getPayload({ config: await config });
const { user } = await payload.auth({ headers: req.headers, canSetHeaders: false });
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const allowed =
(await hasPermission(payload, user, "technologies:update")) ||
(await hasIntelligenceQualification(payload, user));
if (!allowed) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
let body: unknown;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}
const parsed = parsePositionUpdates(body);
if (!parsed.ok) {
return NextResponse.json({ error: parsed.error }, { status: 400 });
}
try {
const updated = await writeTreePositions(payload, parsed.updates);
return NextResponse.json({ ok: true, updated });
} catch (err) {
payload.logger.error(`[TechTree] Bulk position write failed: ${err}`);
return NextResponse.json({ error: "Could not save positions." }, { status: 500 });
}
}

View file

@ -17,7 +17,14 @@ import {
ReactFlowProvider,
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import { LayoutGridIcon, MagnetIcon, PlusIcon, SaveIcon, TriangleAlertIcon } from "lucide-react";
import {
LayoutGridIcon,
Loader2Icon,
MagnetIcon,
PlusIcon,
SaveIcon,
TriangleAlertIcon,
} from "lucide-react";
import { useRouter } from "next/navigation";
import React from "react";
@ -239,6 +246,27 @@ function buildFlowEdges(
});
}
/**
* Persist canvas positions in ONE request (one SQL statement server-side).
* Never PATCH per node: dozens of concurrent document updates exhausted the
* database pool and could take the server down. Null x/y clears a pin.
*/
async function saveTreePositions(
updates: { id: number; x: number | null; y: number | null }[],
): Promise<void> {
if (updates.length === 0) return;
const res = await fetch("/api/tech-tree/positions", {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ positions: updates }),
});
if (!res.ok) {
const body = await res.json().catch(() => null);
throw new Error(body?.error || `Could not save positions (HTTP ${res.status}).`);
}
}
async function fetchCollection<T>(slug: string): Promise<T[]> {
const res = await fetch(`/api/${slug}?limit=0&depth=0&sort=name`, {
credentials: "same-origin",
@ -255,7 +283,9 @@ function CanvasInner({ data }: { data: TechTreeData }) {
const [resources, setResources] = React.useState(data.resources);
const [selectedId, setSelectedId] = React.useState<string | null>(null);
const [createOpen, setCreateOpen] = React.useState(false);
const [arranging, setArranging] = React.useState(false);
// Label of the layout write in flight ("Arranging...", "Saving positions...");
// null when idle. Drives the toolbar status chip and locks layout controls.
const [layoutBusy, setLayoutBusy] = React.useState<string | null>(null);
// Manual edge routing, loaded from this browser's storage on mount. Empty =
// the edge draws its automatic path.
@ -314,16 +344,8 @@ function CanvasInner({ data }: { data: TechTreeData }) {
/** Persist freeze pins (skipping ids whose own PATCH already carries one). */
const persistPins = React.useCallback(
async (pins: Map<number, CanvasPosition>, skip: Set<number> = new Set()) => {
await Promise.all(
[...pins]
.filter(([id]) => !skip.has(id))
.map(([id, pin]) =>
fetch(`/api/technologies/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ treePosition: pin }),
}),
),
await saveTreePositions(
[...pins].filter(([id]) => !skip.has(id)).map(([id, pin]) => ({ id, x: pin.x, y: pin.y })),
);
},
[],
@ -476,7 +498,7 @@ function CanvasInner({ data }: { data: TechTreeData }) {
/**
* Persist one or more card positions to `treePosition` (optimistic local
* update first, then PATCH each). Used by card drags (a multi-selection
* update first, then one bulk write). Used by card drags (a multi-selection
* moves several cards at once) and by category-band drags (every member).
*/
const persistPositions = React.useCallback(
@ -489,17 +511,12 @@ function CanvasInner({ data }: { data: TechTreeData }) {
return p ? { ...t, treePosition: { x: p.x, y: p.y } } : t;
}),
);
await Promise.all(
positions.map((p) =>
fetch(`/api/technologies/${p.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ treePosition: { x: p.x, y: p.y } }),
}).catch(() => {
// Optimistic position stays locally; a reload restores the stored value.
}),
),
);
try {
await saveTreePositions(positions);
} catch (e) {
// The optimistic position stays on screen; a reload shows what is stored.
setConnectError(e instanceof Error ? e.message : "Could not save positions.");
}
},
[],
);
@ -599,39 +616,48 @@ function CanvasInner({ data }: { data: TechTreeData }) {
setSaveStatus("Nothing to save.");
return;
}
if (layoutBusy) return;
setTechs((prev) =>
prev.map((t) => {
const pin = pins.get(t.id);
return pin ? { ...t, treePosition: pin } : t;
}),
);
setLayoutBusy("Saving positions...");
setConnectError(null);
try {
await persistPins(pins);
setSaveStatus(`Positions saved. ${pins.size} node(s).`);
} catch (e) {
setSaveStatus("Could not save positions.");
setConnectError(e instanceof Error ? e.message : "Could not save positions.");
} finally {
setLayoutBusy(null);
}
}, [techs, currentPositions, persistPins]);
}, [techs, currentPositions, persistPins, layoutBusy]);
const handleAutoArrange = React.useCallback(async () => {
const withPositions = techs.filter((t) => t.treePosition !== null);
if (withPositions.length === 0) return;
setArranging(true);
const pinned = techs.filter((t) => t.treePosition !== null);
if (pinned.length === 0 || layoutBusy) return;
const previous = new Map(pinned.map((t) => [t.id, t.treePosition]));
// Optimistic: re-lay the canvas out at once so the click visibly does
// something, then persist every cleared pin in a single request.
setLayoutBusy("Arranging...");
setConnectError(null);
setTechs((prev) => prev.map((t) => (t.treePosition ? { ...t, treePosition: null } : t)));
try {
await Promise.all(
withPositions.map((t) =>
fetch(`/api/technologies/${t.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ treePosition: { x: null, y: null } }),
}),
),
await saveTreePositions(pinned.map((t) => ({ id: t.id, x: null, y: null })));
setSaveStatus(`Layout reset. ${pinned.length} node(s) re-arranged.`);
} catch (e) {
// Put the pins back so the canvas matches what is actually stored.
setTechs((prev) =>
prev.map((t) => (previous.has(t.id) ? { ...t, treePosition: previous.get(t.id) ?? null } : t)),
);
setTechs((prev) => prev.map((t) => ({ ...t, treePosition: null })));
setConnectError(e instanceof Error ? e.message : "Could not reset the layout.");
} finally {
setArranging(false);
setLayoutBusy(null);
}
}, [techs]);
}, [techs, layoutBusy]);
const techById = React.useMemo(
() => new Map(techs.map((t) => [String(t.id), t])),
@ -656,7 +682,9 @@ function CanvasInner({ data }: { data: TechTreeData }) {
if (options.freeze) {
const pins = pinsToFreeze(currentPositions(), nextTechs, nextCategories);
nextTechs = applyPins(nextTechs, pins);
void persistPins(pins);
persistPins(pins).catch((e: unknown) =>
setConnectError(e instanceof Error ? e.message : "Could not save positions."),
);
}
setTechs(nextTechs);
setCategories(nextCategories);
@ -701,6 +729,9 @@ function CanvasInner({ data }: { data: TechTreeData }) {
fitView
fitViewOptions={{ padding: 0.15, maxZoom: 0.85 }}
minZoom={0.08}
// Freeze dragging while a layout write is in flight so a drag cannot
// race (and be overwritten by) the bulk save.
nodesDraggable={layoutBusy === null}
snapToGrid={snapToGrid}
snapGrid={[SNAP_GRID, SNAP_GRID]}
nodesConnectable={data.canUpdate}
@ -771,7 +802,17 @@ function CanvasInner({ data }: { data: TechTreeData }) {
{connectError}
</span>
) : null}
{saveStatus ? (
{layoutBusy ? (
<span
role="status"
aria-live="polite"
className="flex items-center gap-1.5 font-mono text-[10px] tracking-widest text-[#67e8f9] uppercase"
data-testid="tech-tree-layout-busy"
>
<Loader2Icon className="size-3 animate-spin" />
{layoutBusy}
</span>
) : saveStatus ? (
<span
className="font-mono text-[10px] tracking-widest text-emerald-400 uppercase"
data-testid="tech-tree-save-status"
@ -802,6 +843,7 @@ function CanvasInner({ data }: { data: TechTreeData }) {
size="sm"
variant="ghost"
onClick={handleSavePositions}
disabled={layoutBusy !== null}
title="Save the current positions of every node so the layout stays the same on your next visit"
data-testid="tech-tree-save-positions"
className={TACTICAL_BUTTON}
@ -815,13 +857,13 @@ function CanvasInner({ data }: { data: TechTreeData }) {
size="sm"
variant="ghost"
onClick={handleAutoArrange}
disabled={arranging}
disabled={layoutBusy !== null}
title="Clear manual node positions and restore the automatic layout"
data-testid="tech-tree-auto-arrange"
className={TACTICAL_BUTTON}
>
<LayoutGridIcon className="size-3.5" />
{arranging ? "Arranging..." : "Auto-arrange"}
Auto-arrange
</Button>
) : null}
{data.canCreate ? (

View file

@ -0,0 +1,81 @@
import { sql } from "@payloadcms/db-postgres";
import type { Payload } from "payload";
/**
* Bulk Tech Tree position writes.
*
* The canvas used to persist positions with one REST PATCH per technology, all
* fired at once. Auto-arrange or Save positions on a 60+ node tree therefore
* hit the server with 60+ concurrent full document updates (auth, permission
* lookups, populated responses each), which exhausted the database pool and
* could take the server down. Every canvas position write now goes through
* `writeTreePositions`: one request, one SQL statement.
*
* Only the `tree_position_x` / `tree_position_y` columns are touched. The
* technologies collection has no hooks, so bypassing the Local API loses
* nothing; `updated_at` is bumped to keep the documents honest.
*/
/** Hard cap per request so a malformed client cannot build a giant statement. */
export const MAX_POSITION_UPDATES = 2000;
export type TreePositionUpdate = {
id: number;
/** Both numbers to pin a node, both null to return it to the automatic layout. */
x: number | null;
y: number | null;
};
export type ParseResult =
| { ok: true; updates: TreePositionUpdate[] }
| { ok: false; error: string };
/** Validate a `{ positions: [...] }` request body. Pure. */
export function parsePositionUpdates(body: unknown): ParseResult {
const positions = (body as { positions?: unknown } | null)?.positions;
if (!Array.isArray(positions)) return { ok: false, error: "positions must be an array." };
if (positions.length > MAX_POSITION_UPDATES) {
return { ok: false, error: `At most ${MAX_POSITION_UPDATES} positions per request.` };
}
const byId = new Map<number, TreePositionUpdate>();
for (const entry of positions) {
const { id, x, y } = (entry ?? {}) as { id?: unknown; x?: unknown; y?: unknown };
if (typeof id !== "number" || !Number.isInteger(id) || id <= 0) {
return { ok: false, error: "Each position needs a positive integer id." };
}
const cleared = x === null && y === null;
const pinned =
typeof x === "number" && Number.isFinite(x) && typeof y === "number" && Number.isFinite(y);
if (!cleared && !pinned) {
return { ok: false, error: `Position for ${id} must have numeric x and y, or both null.` };
}
// Last write wins for a repeated id.
byId.set(
id,
cleared
? { id, x: null, y: null }
: { id, x: Math.round(x as number), y: Math.round(y as number) },
);
}
return { ok: true, updates: [...byId.values()] };
}
/** Write every position in a single UPDATE. Returns the number of rows touched. */
export async function writeTreePositions(
payload: Payload,
updates: TreePositionUpdate[],
): Promise<number> {
if (updates.length === 0) return 0;
const values = sql.join(
updates.map((u) => sql`(${u.id}::integer, ${u.x}::numeric, ${u.y}::numeric)`),
sql`, `,
);
const result = await payload.db.drizzle.execute(sql`
UPDATE technologies AS t
SET tree_position_x = v.x, tree_position_y = v.y, updated_at = now()
FROM (VALUES ${values}) AS v(id, x, y)
WHERE t.id = v.id
`);
return (result as { rowCount?: number | null }).rowCount ?? 0;
}

View file

@ -238,8 +238,22 @@ test.describe("Tech Tree admin view", () => {
new RegExp(`translate\\(${stored.x}px,\\s*${stored.y}px\\)`),
);
// Auto-arrange clears the manual position.
// Auto-arrange clears the manual position with ONE bulk request (never a
// PATCH per node) and shows a busy indicator while it saves. The save is
// held briefly so the indicator is observable.
let perNodePatches = 0;
page.on("request", (req) => {
if (req.method() === "PATCH" && req.url().includes("/api/technologies/")) perNodePatches += 1;
});
await page.route("**/api/tech-tree/positions", async (route) => {
await new Promise((resolve) => setTimeout(resolve, 800));
await route.continue();
});
await page.getByTestId("tech-tree-auto-arrange").click();
await expect(page.getByTestId("tech-tree-layout-busy")).toBeVisible();
await expect(page.getByTestId("tech-tree-layout-busy")).toHaveCount(0, { timeout: 15_000 });
await page.unroute("**/api/tech-tree/positions");
expect(perNodePatches).toBe(0);
await expect
.poll(
async () => {

View file

@ -0,0 +1,137 @@
import { getPayload, Payload } from "payload";
import config from "@/payload.config";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
MAX_POSITION_UPDATES,
parsePositionUpdates,
writeTreePositions,
} from "@/lib/tech-tree/positions";
const RUN = `ttpos-${Date.now().toString(36)}`;
const TIMEOUT = 30_000;
describe("tech tree position bulk-write validation", () => {
it("accepts pins and clears", () => {
const result = parsePositionUpdates({
positions: [
{ id: 1, x: 10.4, y: 20.6 },
{ id: 2, x: null, y: null },
],
});
expect(result).toEqual({
ok: true,
updates: [
{ id: 1, x: 10, y: 21 },
{ id: 2, x: null, y: null },
],
});
});
it("keeps the last write for a repeated id", () => {
const result = parsePositionUpdates({
positions: [
{ id: 3, x: 1, y: 1 },
{ id: 3, x: null, y: null },
],
});
expect(result).toEqual({ ok: true, updates: [{ id: 3, x: null, y: null }] });
});
it("rejects malformed bodies", () => {
expect(parsePositionUpdates(null).ok).toBe(false);
expect(parsePositionUpdates({ positions: "nope" }).ok).toBe(false);
expect(parsePositionUpdates({ positions: [{ id: 0, x: 1, y: 1 }] }).ok).toBe(false);
expect(parsePositionUpdates({ positions: [{ id: 1.5, x: 1, y: 1 }] }).ok).toBe(false);
// Half-cleared and non-finite coordinates are both invalid.
expect(parsePositionUpdates({ positions: [{ id: 1, x: 5, y: null }] }).ok).toBe(false);
expect(parsePositionUpdates({ positions: [{ id: 1, x: Number.NaN, y: 1 }] }).ok).toBe(false);
});
it("caps the batch size", () => {
const positions = Array.from({ length: MAX_POSITION_UPDATES + 1 }, (_, i) => ({
id: i + 1,
x: 0,
y: 0,
}));
expect(parsePositionUpdates({ positions }).ok).toBe(false);
});
});
describe("tech tree position bulk write (database)", () => {
let payload: Payload;
const ids: number[] = [];
beforeAll(async () => {
payload = await getPayload({ config });
for (const label of ["a", "b", "c"]) {
const doc = await payload.create({
collection: "technologies",
data: {
name: `${RUN}-${label}`,
summary: "Bulk position write fixture.",
type: "upgrade",
approvalStatus: "in_progress",
researchCosts: { minimumResearchDuration: 1 },
},
overrideAccess: true,
depth: 0,
});
ids.push(doc.id);
}
}, TIMEOUT);
afterAll(async () => {
for (const id of ids) {
await payload.delete({ collection: "technologies", id, overrideAccess: true });
}
}, TIMEOUT);
const positionOf = async (id: number) => {
const doc = await payload.findByID({
collection: "technologies",
id,
overrideAccess: true,
depth: 0,
});
return doc.treePosition ?? null;
};
it(
"pins many technologies in one write and clears them again",
async () => {
const pinned = await writeTreePositions(payload, [
{ id: ids[0], x: 46, y: 92 },
{ id: ids[1], x: 368, y: 184 },
{ id: ids[2], x: 0, y: 0 },
]);
expect(pinned).toBe(3);
expect(await positionOf(ids[0])).toMatchObject({ x: 46, y: 92 });
expect(await positionOf(ids[1])).toMatchObject({ x: 368, y: 184 });
expect(await positionOf(ids[2])).toMatchObject({ x: 0, y: 0 });
// Auto-arrange path: clear every pin in a single statement.
const cleared = await writeTreePositions(
payload,
ids.map((id) => ({ id, x: null, y: null })),
);
expect(cleared).toBe(3);
for (const id of ids) {
const position = await positionOf(id);
expect(position?.x ?? null).toBeNull();
expect(position?.y ?? null).toBeNull();
}
},
TIMEOUT,
);
it(
"ignores ids that do not exist",
async () => {
const touched = await writeTreePositions(payload, [{ id: 999_999_999, x: 1, y: 1 }]);
expect(touched).toBe(0);
},
TIMEOUT,
);
});