stop the dashboard PATCH dispatcher losing writes
Four defects, one shape: a write that returns 200 and lands nowhere.
- Unknown keys were dropped by a chain of `if (match) continue` with no `else`. The three prefixes
CommandTerminalWrapper actually writes — tmux, nvim, claude-code — were among them, so those panel
maps lived in the React Query cache only: every reload minted a fresh uuid and abandoned a running
pty. They now live in a `panel_state` bag on the dashboard row, and an unmatched key 400s.
- `ws-terminals-{id}: null` fell through to an upsert, writing NULL into a NOT NULL column on a live
dashboard and re-INSERTing a deleted one. Renaming a dashboard sends exactly that, paired with
`ws-layout-{id}: null`, so the old slug came back as a zombie row in the dashboards list.
- HostTerminalWrapper and CommandTerminalWrapper built their state key straight from `dashboardId`,
which is a workspace *key* (`ws-layout-<id>`), while TerminalWrapper stripped the prefix. The server
read the un-stripped form back as a dashboard id and created it. One rule now, in state-key.ts.
Verified against the live server: unknown key 400s, the three prefixes round-trip, a null on a live
dashboard is a no-op, and the rename sequence leaves no zombie.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,14 +2,23 @@ import { createRouter } from '@@/create-router';
|
||||
import {
|
||||
getAllDashboardState,
|
||||
upsertDashboard,
|
||||
updateDashboard,
|
||||
deleteDashboard,
|
||||
upsertScreen,
|
||||
deleteScreen,
|
||||
upsertDefaults,
|
||||
setDashboardPanelState,
|
||||
setDefaultsPanelState,
|
||||
} from 'officerdb';
|
||||
|
||||
export const dashboardsRouter = createRouter();
|
||||
|
||||
// Panel-scoped session maps with no column of their own, written by `CommandTerminalWrapper` as
|
||||
// `ws-<statePrefix>-<dashboardId>`. This is an allow-list because `ws-<prefix>-<id>` cannot be split
|
||||
// without one — both halves may contain dashes. Keep it in step with the `statePrefix` props in
|
||||
// `apps/Terminal/index.tsx`; the 400 at the bottom of the PATCH loop is what tells you when it is not.
|
||||
const PANEL_STATE_PREFIXES = ['tmux', 'nvim', 'claude-code'];
|
||||
|
||||
// GET /dashboards
|
||||
dashboardsRouter.get('/', async (ctx) => {
|
||||
const userId = ctx.get('user').id;
|
||||
@@ -57,11 +66,18 @@ dashboardsRouter.patch('/', async (ctx) => {
|
||||
continue;
|
||||
}
|
||||
|
||||
// ws-terminals-{id}
|
||||
// ws-terminals-{id} / ws-host-terminals-{id}
|
||||
//
|
||||
// A null means "forget this key". It arrives paired with `ws-layout-{id}: null` on a rename, by which
|
||||
// point the row is already gone — so dropping it silently is the whole job. These branches used to
|
||||
// fall through to an upsert, which wrote NULL into a NOT NULL column on a live dashboard (23502) and
|
||||
// re-INSERTed a deleted one as a zombie named after its own slug.
|
||||
const wsTerminalsMatch = key.match(/^ws-terminals-(.+)$/);
|
||||
if (wsTerminalsMatch) {
|
||||
const id = wsTerminalsMatch[1]!;
|
||||
await upsertDashboard(userId, id, { terminals: value });
|
||||
if (value !== null && !(await updateDashboard(userId, id, { terminals: value }))) {
|
||||
return ctx.json({ error: `no dashboard "${id}" to write "${key}" to` }, 404);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -69,7 +85,21 @@ dashboardsRouter.patch('/', async (ctx) => {
|
||||
const wsHostTerminalsMatch = key.match(/^ws-host-terminals-(.+)$/);
|
||||
if (wsHostTerminalsMatch) {
|
||||
const id = wsHostTerminalsMatch[1]!;
|
||||
await upsertDashboard(userId, id, { hostTerminals: value });
|
||||
if (value !== null && !(await updateDashboard(userId, id, { hostTerminals: value }))) {
|
||||
return ctx.json({ error: `no dashboard "${id}" to write "${key}" to` }, 404);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// ws-{prefix}-{id} for the prefixes that have no column of their own
|
||||
const prefix = PANEL_STATE_PREFIXES.find((p) => key.startsWith(`ws-${p}-`));
|
||||
if (prefix) {
|
||||
const id = key.slice(`ws-${prefix}-`.length);
|
||||
if (id === 'default') {
|
||||
await setDefaultsPanelState(userId, prefix, value);
|
||||
} else if (!(await setDashboardPanelState(userId, id, prefix, value))) {
|
||||
return ctx.json({ error: `no dashboard "${id}" to write "${key}" to` }, 404);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -85,6 +115,10 @@ dashboardsRouter.patch('/', async (ctx) => {
|
||||
continue;
|
||||
}
|
||||
|
||||
// No silent drops. An unmatched key used to return 200 with a fresh state blob, so a whole key family
|
||||
// in active use could persist nowhere at all and look like it had — which is exactly what happened to
|
||||
// the three prefixes above.
|
||||
return ctx.json({ error: `unknown dashboard-state key "${key}"` }, 400);
|
||||
}
|
||||
|
||||
const state = await getAllDashboardState(userId);
|
||||
|
||||
Reference in New Issue
Block a user