fix: drop legacy layouts.template_id/display_id columns via table rebuild

- Migration v0.5: rebuild layouts table without template_id, display_id,
  regions, grid_cols, grid_rows, is_default
- Migration v0.6: rebuild layout_cells table without region_name
- Migration v0.7: drop layout_templates table entirely (concept removed)
- createLayout simplified to clean column set (no sentinel values)
- createLayoutCell simplified (no region_name placeholder)
- Removed all layout_template repo methods (dead code)

Answers user question "why template_id/display_id in template": SQLite
can't drop columns without rebuilding the table. Now done properly.
This commit is contained in:
Mitchell R 2026-05-10 22:03:32 +02:00
parent b8f934b2be
commit 2398be6853
2 changed files with 73 additions and 82 deletions

View file

@ -366,4 +366,70 @@ export const MIGRATIONS: readonly MigrationEntry[] = [
} }
} }
}, },
// ---- v0.5: rebuild layouts table to drop legacy columns
// SQLite can't drop columns, so rebuild: create new schema → copy data →
// drop old → rename. Removes template_id, display_id, regions, grid_cols,
// grid_rows, is_default — cells own position now, displays attach via join.
(db: DatabaseSync) => {
const cols = db.prepare(`PRAGMA table_info("layouts")`).all() as Array<{ name: string }>;
const hasTemplateId = cols.some((c) => c.name === "template_id");
if (!hasTemplateId) return; // already migrated
db.exec("PRAGMA foreign_keys = OFF");
db.exec(`
CREATE TABLE layouts_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
description TEXT,
priority TEXT NOT NULL DEFAULT 'normal' CHECK(priority IN ('hot', 'normal', 'cold')),
cooling_timeout_seconds INTEGER,
preload_camera_ids TEXT NOT NULL DEFAULT '[]',
resets_idle_timer INTEGER NOT NULL DEFAULT 1
) STRICT;
INSERT INTO layouts_new (id, name, description, priority, cooling_timeout_seconds, preload_camera_ids, resets_idle_timer)
SELECT id, name, description, priority, cooling_timeout_seconds, preload_camera_ids, resets_idle_timer FROM layouts;
DROP TABLE layouts;
ALTER TABLE layouts_new RENAME TO layouts;
`);
db.exec("PRAGMA foreign_keys = ON");
},
// Same cleanup for layout_cells — drop region_name, layout_id FK stays
(db: DatabaseSync) => {
const cols = db.prepare(`PRAGMA table_info("layout_cells")`).all() as Array<{ name: string }>;
const hasRegionName = cols.some((c) => c.name === "region_name");
if (!hasRegionName) return;
db.exec("PRAGMA foreign_keys = OFF");
db.exec(`
CREATE TABLE layout_cells_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
layout_id INTEGER NOT NULL REFERENCES layouts(id) ON DELETE CASCADE,
row INTEGER NOT NULL DEFAULT 0,
col INTEGER NOT NULL DEFAULT 0,
row_span INTEGER NOT NULL DEFAULT 1,
col_span INTEGER NOT NULL DEFAULT 1,
content_type TEXT NOT NULL CHECK(content_type IN ('camera', 'web', 'html')),
camera_id INTEGER REFERENCES cameras(id) ON DELETE SET NULL,
stream_selector TEXT,
web_url TEXT,
html_content TEXT,
cooling_timeout_seconds INTEGER,
options TEXT NOT NULL DEFAULT '{}'
) STRICT;
INSERT INTO layout_cells_new (id, layout_id, row, col, row_span, col_span, content_type, camera_id, stream_selector, web_url, html_content, cooling_timeout_seconds, options)
SELECT id, layout_id, row, col, row_span, col_span, content_type, camera_id, stream_selector, web_url, html_content, cooling_timeout_seconds, options FROM layout_cells;
DROP TABLE layout_cells;
ALTER TABLE layout_cells_new RENAME TO layout_cells;
`);
db.exec("PRAGMA foreign_keys = ON");
},
// Drop layout_templates entirely — concept removed
`DROP TABLE IF EXISTS layout_templates`,
]; ];

View file

@ -415,61 +415,6 @@ export class Repository {
// layout templates // layout templates
// =========================================================================== // ===========================================================================
listLayoutTemplates(): LayoutTemplate[] {
const rs = this.prep("SELECT * FROM layout_templates ORDER BY name").all();
return rs.map((r) => rowToLayoutTemplate(r as Record<string, unknown>));
}
getLayoutTemplateById(id: number): LayoutTemplate | null {
const r = this.prep("SELECT * FROM layout_templates WHERE id = ?").get(id);
return r ? rowToLayoutTemplate(r as Record<string, unknown>) : null;
}
createLayoutTemplate(input: {
name: string;
description?: string | null;
regions: unknown;
grid_cols?: number;
grid_rows?: number;
is_builtin?: boolean;
}): LayoutTemplate {
const result = this.prep(
`INSERT INTO layout_templates (name, description, regions, grid_cols, grid_rows, is_builtin)
VALUES (?, ?, ?, ?, ?, ?)`,
).run(
input.name,
input.description ?? null,
J(input.regions),
input.grid_cols ?? 12,
input.grid_rows ?? 12,
B(input.is_builtin ?? false),
);
const id = Number(result.lastInsertRowid);
void this.notify("layout_templates", "create", id);
const r = this.getLayoutTemplateById(id);
if (!r) throw new Error("layout_template vanished after insert");
return r;
}
updateLayoutTemplate(id: number, patch: { name?: string; description?: string | null; regions?: unknown; grid_cols?: number; grid_rows?: number }): void {
const sets: string[] = [];
const vals: unknown[] = [];
for (const [k, v] of Object.entries(patch)) {
if (k === "id") continue;
sets.push(`${k} = ?`);
vals.push(k === "regions" ? J(v) : (v === undefined ? null : v));
}
if (sets.length === 0) return;
vals.push(id);
this.db.prepare(`UPDATE layout_templates SET ${sets.join(", ")} WHERE id = ?`).run(...vals as any[]);
void this.notify("layout_templates", "update", id);
}
deleteLayoutTemplate(id: number): void {
this.db.prepare(`DELETE FROM layout_templates WHERE id = ?`).run(id);
void this.notify("layout_templates", "delete", id);
}
// =========================================================================== // ===========================================================================
// layouts // layouts
// =========================================================================== // ===========================================================================
@ -546,25 +491,15 @@ export class Repository {
preload_camera_ids?: number[]; preload_camera_ids?: number[];
resets_idle_timer?: boolean; resets_idle_timer?: boolean;
}): Layout { }): Layout {
// Legacy NOT NULL columns (template_id, display_id, regions, grid_*) are
// populated with sentinel values: cells own their position now and the
// grid is computed at read time. The columns will be dropped by a future
// migration — until then they're inert.
const result = this.prep( const result = this.prep(
`INSERT INTO layouts (name, description, template_id, regions, grid_cols, grid_rows, display_id, priority, cooling_timeout_seconds, preload_camera_ids, is_default, resets_idle_timer) `INSERT INTO layouts (name, description, priority, cooling_timeout_seconds, preload_camera_ids, resets_idle_timer)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?)`,
).run( ).run(
input.name, input.name,
input.description ?? null, input.description ?? null,
0,
J([]),
1,
1,
0,
input.priority ?? "normal", input.priority ?? "normal",
input.cooling_timeout_seconds ?? null, input.cooling_timeout_seconds ?? null,
J(input.preload_camera_ids ?? []), J(input.preload_camera_ids ?? []),
B(false),
B(input.resets_idle_timer ?? true), B(input.resets_idle_timer ?? true),
); );
const id = Number(result.lastInsertRowid); const id = Number(result.lastInsertRowid);
@ -618,15 +553,11 @@ export class Repository {
cooling_timeout_seconds?: number | null; cooling_timeout_seconds?: number | null;
options?: Record<string, unknown>; options?: Record<string, unknown>;
}): LayoutCell { }): LayoutCell {
// region_name column is legacy NOT NULL — synthesize a unique placeholder
// until the column is dropped by a future migration. Nothing reads it.
const placeholder = `cell_${input.layout_id}_${input.row}_${input.col}_${Date.now()}`;
const result = this.prep( const result = this.prep(
`INSERT INTO layout_cells (layout_id, region_name, "row", col, row_span, col_span, content_type, camera_id, stream_selector, web_url, html_content, cooling_timeout_seconds, options) `INSERT INTO layout_cells (layout_id, "row", col, row_span, col_span, content_type, camera_id, stream_selector, web_url, html_content, cooling_timeout_seconds, options)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
).run( ).run(
input.layout_id, input.layout_id,
placeholder,
input.row, input.row,
input.col, input.col,
input.row_span ?? 1, input.row_span ?? 1,
@ -1156,15 +1087,9 @@ export class Repository {
return this.listLayoutCells(layoutId); return this.listLayoutCells(layoutId);
} }
layoutTemplates(ids: number[]): LayoutTemplate[] { // Deprecated — layout_templates dropped in v0.5
if (ids.length === 0) return []; layoutTemplates(_ids: number[]): LayoutTemplate[] {
const placeholders = ids.map(() => "?").join(","); return [];
const rs = this.db
.prepare(
`SELECT * FROM layout_templates WHERE id IN (${placeholders})`,
)
.all(...(ids as never[]));
return rs.map((r) => rowToLayoutTemplate(r as Record<string, unknown>));
} }
cameraLabelNames(cameraId: number): string[] { cameraLabelNames(cameraId: number): string[] {