stop handing the client a layout it cannot use

The layout columns defaulted to '[]' — an empty array for a column whose only legal contents are a
LayoutNode object — and every upsert that omitted a layout wrote it. Creating a dashboard from the
dashboard list is exactly that path, so the key came back present, the client's `key in state` check
preferred it over the caller's default, and normalizeLayout called .children.map on it and threw.

Three layers, because none of them was enforcing anything:

- the columns are nullable with no default: NULL means "none stored", which is the truth
- getAllDashboardState omits the key when what is stored is not an object, so rows written before this
  are repaired by the next write rather than crashing the read
- useDashboardState checks kind-compatibility before casting jsonb to T, and falls back to the caller's
  default when it does not match. Only object-shaped defaults are guarded — a wrong primitive is a
  cosmetic surprise, a wrong container is a crash.

Verified against the live DB: creating a dashboard with no layout no longer emits a ws-layout key, and
a row hand-set back to '[]' is omitted too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-07 09:08:15 +00:00
co-authored by Claude Opus 5
parent 64961d49f5
commit ef036dfcd5
3 changed files with 57 additions and 8 deletions
@@ -23,7 +23,7 @@ export async function getAllDashboardState(userId: number): Promise<Record<strin
result['workspaces'] = sorted.map((d) => ({ id: d.id, name: d.name, ...(d.config as object) }));
for (const d of dashRows) {
result[`ws-layout-${d.id}`] = d.layout;
if (isLayoutish(d.layout)) 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))) {
@@ -43,7 +43,7 @@ export async function getAllDashboardState(userId: number): Promise<Record<strin
// Screens
for (const s of screenRows) {
result[`screens/${s.name}`] = s.layout;
if (isLayoutish(s.layout)) result[`screens/${s.name}`] = s.layout;
}
return result;
@@ -52,6 +52,18 @@ export async function getAllDashboardState(userId: number): Promise<Record<strin
const asRecord = (value: unknown): Record<string, unknown> =>
value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
/**
* Omit a layout the client cannot use rather than hand it over.
*
* A `LayoutNode` is an object. The columns used to default to `'[]'`, and every path that upserts without
* one wrote it — so the key was present, the client's `key in state` check preferred it over the caller's
* default, and `normalizeLayout` called `.children.map` on an array of nothing and threw during render.
* Dropping the key instead lets the caller's default win and the next write repairs the row. The column is
* nullable now, so this only catches rows written before that; keep it anyway — it is the read-side half of
* a rule nothing else enforces.
*/
const isLayoutish = (value: unknown): boolean => !!value && typeof value === 'object' && !Array.isArray(value);
// ── Dashboard CRUD ──
type UpsertDashboardData = {
@@ -83,7 +95,7 @@ export async function upsertDashboard(userId: number, id: string, data: UpsertDa
userId,
name: data.name ?? id,
config: data.config ?? {},
layout: data.layout ?? [],
layout: data.layout ?? null,
terminals: data.terminals ?? {},
hostTerminals: data.hostTerminals ?? {},
sortOrder: data.sortOrder ?? 0,
@@ -158,7 +170,7 @@ export async function upsertScreen(userId: number, name: string, data: UpsertScr
.values({
userId,
name,
layout: (data.layout ?? []) as never,
layout: (data.layout ?? null) as never,
terminals: (data.terminals ?? {}) as never,
hostTerminals: (data.hostTerminals ?? {}) as never,
updatedAt: now,
@@ -10,7 +10,11 @@ export const dashboards = pgTable(
.references(() => users.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
config: jsonb('config').notNull().default({}),
layout: jsonb('layout').notNull().default([]),
// Nullable with NO default, deliberately. It held `'[]'` — an empty *array* for a column whose only
// legal contents are a `LayoutNode` object — so every upsert that omitted a layout wrote a value the
// client then preferred over its own default and crashed traversing. NULL says "none stored", which is
// the truth, and `getAllDashboardState` omits the key entirely so the caller's default wins.
layout: jsonb('layout'),
// `{}`, 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.
@@ -36,7 +40,8 @@ export const screens = pgTable(
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
layout: jsonb('layout').notNull().default([]),
// Same as `dashboards.layout` above — nullable, no default.
layout: jsonb('layout'),
terminals: jsonb('terminals').notNull().default({}),
hostTerminals: jsonb('host_terminals').notNull().default({}),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),