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:
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user