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:
2026-08-07 08:55:01 +00:00
co-authored by Claude Opus 5
parent 70c2f0811d
commit f4ed7401da
8 changed files with 159 additions and 36 deletions
+3
View File
@@ -56,11 +56,14 @@ export {
export {
getAllDashboardState,
upsertDashboard,
updateDashboard,
deleteDashboard,
setDashboardPanelState,
upsertScreen,
deleteScreen,
getDefaults,
upsertDefaults,
setDefaultsPanelState,
} from './queries/dashboards';
export {
@@ -26,6 +26,9 @@ export async function getAllDashboardState(userId: number): Promise<Record<strin
result[`ws-layout-${d.id}`] = d.layout;
result[`ws-terminals-${d.id}`] = d.terminals;
result[`ws-host-terminals-${d.id}`] = d.hostTerminals;
for (const [prefix, value] of Object.entries(asRecord(d.panelState))) {
result[`ws-${prefix}-${d.id}`] = value;
}
}
}
@@ -33,6 +36,9 @@ export async function getAllDashboardState(userId: number): Promise<Record<strin
if (defaultsRow) {
result['ws-terminals-default'] = defaultsRow.terminals;
result['ws-host-terminals-default'] = defaultsRow.hostTerminals;
for (const [prefix, value] of Object.entries(asRecord(defaultsRow.panelState))) {
result[`ws-${prefix}-default`] = value;
}
}
// Screens
@@ -43,6 +49,9 @@ export async function getAllDashboardState(userId: number): Promise<Record<strin
return result;
}
const asRecord = (value: unknown): Record<string, unknown> =>
value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
// ── Dashboard CRUD ──
type UpsertDashboardData = {
@@ -54,37 +63,75 @@ type UpsertDashboardData = {
sortOrder?: number;
};
function buildDashboardSet(data: UpsertDashboardData, now: Date): Record<string, unknown> {
const set: Record<string, unknown> = { updatedAt: now };
if (data.name !== undefined) set.name = data.name;
if (data.config !== undefined) set.config = data.config;
if (data.layout !== undefined) set.layout = data.layout;
if (data.terminals !== undefined) set.terminals = data.terminals;
if (data.hostTerminals !== undefined) set.hostTerminals = data.hostTerminals;
if (data.sortOrder !== undefined) set.sortOrder = data.sortOrder;
return set;
}
export async function upsertDashboard(userId: number, id: string, data: UpsertDashboardData): Promise<void> {
const now = new Date();
if (await updateDashboard(userId, id, data)) return;
await db.insert(dashboards).values({
id,
userId,
name: data.name ?? id,
config: data.config ?? {},
layout: data.layout ?? [],
terminals: data.terminals ?? {},
hostTerminals: data.hostTerminals ?? {},
sortOrder: data.sortOrder ?? 0,
createdAt: now,
updatedAt: now,
});
}
/**
* Update a dashboard only if it already exists, and report whether it did.
*
* `upsertDashboard` INSERTs on an unknown id, which is right for the two create paths and wrong for every
* other key: renaming a dashboard PATCHes `ws-layout-old: null` and `ws-terminals-old: null` together, and
* the second key used to re-INSERT the row the first had just deleted — named after its own slug, so the
* old name reappeared in the dashboard list as a zombie.
*/
export async function updateDashboard(userId: number, id: string, data: UpsertDashboardData): Promise<boolean> {
const updated = await db
.update(dashboards)
.set(buildDashboardSet(data, new Date()))
.where(and(eq(dashboards.userId, userId), eq(dashboards.id, id)))
.returning({ id: dashboards.id });
return updated.length > 0;
}
/** Write one prefix of a dashboard's `panelState` bag, leaving its siblings alone. */
export async function setDashboardPanelState(
userId: number,
id: string,
prefix: string,
value: unknown,
): Promise<boolean> {
const existing = await db
.select()
.select({ panelState: dashboards.panelState })
.from(dashboards)
.where(and(eq(dashboards.userId, userId), eq(dashboards.id, id)))
.then((rows) => rows[0]);
if (!existing) return false;
if (existing) {
const set: Record<string, unknown> = { updatedAt: now };
if (data.name !== undefined) set.name = data.name;
if (data.config !== undefined) set.config = data.config;
if (data.layout !== undefined) set.layout = data.layout;
if (data.terminals !== undefined) set.terminals = data.terminals;
if (data.hostTerminals !== undefined) set.hostTerminals = data.hostTerminals;
if (data.sortOrder !== undefined) set.sortOrder = data.sortOrder;
await db.update(dashboards).set(set).where(eq(dashboards.id, id));
} else {
await db.insert(dashboards).values({
id,
userId,
name: data.name ?? id,
config: data.config ?? {},
layout: data.layout ?? [],
terminals: data.terminals ?? [],
hostTerminals: data.hostTerminals ?? [],
sortOrder: data.sortOrder ?? 0,
createdAt: now,
updatedAt: now,
});
}
const next = { ...asRecord(existing.panelState) };
if (value === null) delete next[prefix];
else next[prefix] = value;
await db
.update(dashboards)
.set({ panelState: next, updatedAt: new Date() })
.where(and(eq(dashboards.userId, userId), eq(dashboards.id, id)));
return true;
}
export async function deleteDashboard(userId: number, id: string): Promise<void> {
@@ -157,3 +204,18 @@ export async function upsertDefaults(userId: number, data: UpsertDefaultsData):
set,
});
}
/** The `panelState` equivalent of `upsertDefaults` — for panels living on a screen rather than a dashboard. */
export async function setDefaultsPanelState(userId: number, prefix: string, value: unknown): Promise<void> {
const now = new Date();
const [row] = await db.select().from(dashboardDefaults).where(eq(dashboardDefaults.userId, userId));
const next = { ...asRecord(row?.panelState) };
if (value === null) delete next[prefix];
else next[prefix] = value;
await db
.insert(dashboardDefaults)
.values({ userId, panelState: next as never, updatedAt: now })
.onConflictDoUpdate({ target: dashboardDefaults.userId, set: { panelState: next, updatedAt: now } });
}
@@ -11,8 +11,16 @@ export const dashboards = pgTable(
name: text('name').notNull(),
config: jsonb('config').notNull().default({}),
layout: jsonb('layout').notNull().default([]),
terminals: jsonb('terminals').notNull().default([]),
hostTerminals: jsonb('host_terminals').notNull().default([]),
// `{}`, not `[]` — every writer treats these as a panelId → sessionId map, and `screens` and
// `dashboard_defaults` already default them correctly. The array default only ever produced a shape
// the client had to spread away on first write.
terminals: jsonb('terminals').notNull().default({}),
hostTerminals: jsonb('host_terminals').notNull().default({}),
// Panel-scoped session maps that have no column of their own, keyed by the app's state prefix:
// `{ tmux: { <panelId>: <sessionId> }, nvim: {…}, 'claude-code': {…} }`. They used to have no home at
// all — the PATCH dispatcher dropped them and returned 200, so every reload minted a fresh uuid and
// abandoned the running pty. Living on the dashboard row means they are deleted with it.
panelState: jsonb('panel_state').notNull().default({}),
sortOrder: integer('sort_order').notNull().default(0),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
@@ -44,5 +52,6 @@ export const dashboardDefaults = pgTable('dashboard_defaults', {
.unique(),
terminals: jsonb('terminals').notNull().default({}),
hostTerminals: jsonb('host_terminals').notNull().default({}),
panelState: jsonb('panel_state').notNull().default({}),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
});