From 4fe4ac5d19cddc7db3d05563e8977d0c70d963f2 Mon Sep 17 00:00:00 2001 From: Anso Date: Fri, 3 Apr 2026 02:03:40 -0400 Subject: [PATCH] fix(db): recreate stack_update_status table with composite primary key (#356) The original table used stack_name as the sole PRIMARY KEY. A later migration added node_id and a composite unique index, but the old single-column PK was never removed (SQLite doesn't support DROP CONSTRAINT). This caused UNIQUE constraint failures when two nodes shared the same stack name. Recreate the table with PRIMARY KEY (node_id, stack_name) for existing databases, and fix the CREATE TABLE for new databases. --- backend/src/services/DatabaseService.ts | 31 ++++++++++++++++++------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index a1be366e..0b1df9c7 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -297,9 +297,11 @@ export class DatabaseService { CREATE INDEX IF NOT EXISTS idx_metrics_container ON container_metrics(container_id); CREATE TABLE IF NOT EXISTS stack_update_status ( - stack_name TEXT PRIMARY KEY, + node_id INTEGER NOT NULL DEFAULT 0, + stack_name TEXT NOT NULL, has_update INTEGER DEFAULT 0, - checked_at INTEGER NOT NULL + checked_at INTEGER NOT NULL, + PRIMARY KEY (node_id, stack_name) ); CREATE TABLE IF NOT EXISTS nodes ( @@ -471,12 +473,25 @@ export class DatabaseService { maybeAddCol('scheduled_tasks', 'target_services', 'TEXT DEFAULT NULL'); maybeAddCol('scheduled_tasks', 'prune_label_filter', 'TEXT DEFAULT NULL'); - // Per-node scoping for stack update status (pre-0.10 had stack_name as sole PK) - maybeAddCol('stack_update_status', 'node_id', 'INTEGER NOT NULL DEFAULT 0'); - this.db.exec(` - CREATE UNIQUE INDEX IF NOT EXISTS idx_stack_update_status_node_stack - ON stack_update_status(node_id, stack_name); - `); + // Recreate stack_update_status with composite PK (node_id, stack_name). + // Original table had stack_name as sole PK which breaks when multiple nodes share stack names. + const susInfo = this.db.pragma('table_info(stack_update_status)') as Array<{ name: string; pk: number }>; + const needsRecreate = susInfo.some(c => c.name === 'stack_name' && c.pk === 1) && !susInfo.some(c => c.name === 'node_id' && c.pk > 0); + if (needsRecreate) { + this.db.exec(` + CREATE TABLE stack_update_status_new ( + node_id INTEGER NOT NULL DEFAULT 0, + stack_name TEXT NOT NULL, + has_update INTEGER DEFAULT 0, + checked_at INTEGER NOT NULL, + PRIMARY KEY (node_id, stack_name) + ); + INSERT OR IGNORE INTO stack_update_status_new (node_id, stack_name, has_update, checked_at) + SELECT COALESCE(node_id, 0), stack_name, has_update, checked_at FROM stack_update_status; + DROP TABLE stack_update_status; + ALTER TABLE stack_update_status_new RENAME TO stack_update_status; + `); + } // Drop legacy SSH/TLS columns from pre-0.7 databases (no longer read or written) const legacyCols = ['host', 'port', 'ssh_port', 'ssh_user', 'ssh_password', 'ssh_key', 'tls_ca', 'tls_cert', 'tls_key'];