diff --git a/src/db/index.js b/src/db/index.js index d0ee081..b76da9d 100644 --- a/src/db/index.js +++ b/src/db/index.js @@ -13,7 +13,7 @@ export function createDatabase(config) { const db = new Database(config.dbPath); initializeSchema(db); - runMigrations(db, config); initializeAttachments(db); + runMigrations(db, config); return db; } diff --git a/src/db/schema.js b/src/db/schema.js index 37cb887..e432f37 100644 --- a/src/db/schema.js +++ b/src/db/schema.js @@ -265,6 +265,59 @@ export function runMigrations(db, {dropLegacyChats = false} = {}) { ensureColumn("users", userColumns, "role", "ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'user' CHECK (role IN ('user', 'admin'))"); ensureColumn("users", userColumns, "session_version", "ALTER TABLE users ADD COLUMN session_version INTEGER DEFAULT 0"); db.prepare("UPDATE users SET session_version = 0 WHERE session_version IS NULL").run(); + + // Older databases allowed usernames that differed only by case. Repair those + // rows before adding the case-insensitive uniqueness guarantee. Keep the first + // account unchanged and give later accounts a stable, recoverable username; + // moving their owned rows prevents orphaned workspace data. + const conflictingUsers = db.prepare(` + SELECT LOWER(username) AS username_lower + FROM users + GROUP BY LOWER(username) + HAVING COUNT(*) > 1 + `).all(); + if (conflictingUsers.length > 0) { + const ownershipColumns = [ + ["chat_sessions", "username"], + ["chat_memories", "username"], + ["persona_chat_state", "username"], + ["personas", "username"], + ["personas", "source_creator_username"], + ["user_settings", "username"], + ["persona_market", "creator_username"], + ["persona_versions", "username"], + ["prompt_templates", "username"], + ["persona_market_favorites", "username"], + ["persona_market_ratings", "username"], + ["persona_market_reports", "reporter_username"], + ["attachments", "username"], + ["upload_leases", "username"], + ["workspace_operation_leases", "username"] + ]; + const existingTables = new Set(db.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all().map(({name}) => name)); + const updateStatements = ownershipColumns + .filter(([table]) => existingTables.has(table)) + .map(([table, column]) => [ + table, + column, + db.prepare(`UPDATE "${table}" SET "${column}" = ? WHERE "${column}" = ?`) + ]); + const repairConflicts = db.transaction(() => { + db.pragma("defer_foreign_keys = ON"); + for (const {username_lower: usernameLower} of conflictingUsers) { + const rows = db.prepare("SELECT rowid, username FROM users WHERE LOWER(username) = ? ORDER BY rowid").all(usernameLower); + for (const row of rows.slice(1)) { + const suffix = `-legacy-${row.rowid}`; + const nextUsername = `${row.username.slice(0, Math.max(1, 64 - suffix.length))}${suffix}`; + for (const [, , statement] of updateStatements) { + statement.run(nextUsername, row.username); + } + db.prepare("UPDATE users SET username = ? WHERE username = ?").run(nextUsername, row.username); + } + } + }); + repairConflicts(); + } db.exec("CREATE UNIQUE INDEX IF NOT EXISTS users_username_lower ON users (LOWER(username))"); const personaColumns = db.prepare("PRAGMA table_info(personas)").all();