fix(boot): auto-apply Phase 1 multi-tenancy migration on startup if not yet applied; refactor scripts/migrate-multitenancy.js to expose runMigration() with CLI wrapper preserved; pre-migration snapshot to db/remote_display.pre-migration-<timestamp>.db; belt-and-suspenders guards on migrateFolderWorkspaceIds + backfillActivityLogWorkspace so the inline backfills skip cleanly if workspaces table absent. Fixes startup crash on pre-multi-tenancy installs (semetra22 / Discord report) where 'npm start' after pulling latest hit migrateFolderWorkspaceIds and crashed with 'no such table: workspaces'. Self-hosters now get an automatic upgrade path without needing to run 'node scripts/migrate-multitenancy.js' manually.

This commit is contained in:
ScreenTinker 2026-05-12 08:22:47 -05:00
parent 92e26aafcb
commit bc445a0a7c
2 changed files with 327 additions and 228 deletions

View file

@ -14,276 +14,305 @@
// Idempotent: tracked by schema_migrations row 'phase5_multitenancy_backfill'. // Idempotent: tracked by schema_migrations row 'phase5_multitenancy_backfill'.
// Re-running is a no-op. // Re-running is a no-op.
// //
// Usage: // Two invocation modes:
// node scripts/migrate-multitenancy.js [--dry-run] // 1. CLI: node scripts/migrate-multitenancy.js [--dry-run]
// 2. In-process: require('./scripts/migrate-multitenancy').runMigration({ db })
// //
// --dry-run prints what would happen and rolls the transaction back, so the // In-process mode is used by server/db/database.js on startup so self-hosters
// DB is unchanged on disk. Without the flag the migration commits. // who pull latest and restart don't have to remember to run the script
// manually.
'use strict'; 'use strict';
const path = require('path'); const path = require('path');
const SERVER_DIR = path.resolve(__dirname, '..', 'server'); const SERVER_DIR = path.resolve(__dirname, '..', 'server');
process.chdir(SERVER_DIR); // Resolve modules relative to server/ where the deps live, not relative to
// Resolve modules relative to server/ where the deps live, not relative to this script's dir. // this script's dir. Works both for CLI invocation and when require'd from
// database.js - Node resolves modules relative to the required file's own
// __dirname, not the caller's.
const resolveFromServer = (name) => require.resolve(name, { paths: [SERVER_DIR] }); const resolveFromServer = (name) => require.resolve(name, { paths: [SERVER_DIR] });
const Database = require(resolveFromServer('better-sqlite3')); const Database = require(resolveFromServer('better-sqlite3'));
const { v4: uuidv4 } = require(resolveFromServer('uuid')); const { v4: uuidv4 } = require(resolveFromServer('uuid'));
const config = require(path.join(SERVER_DIR, 'config')); const config = require(path.join(SERVER_DIR, 'config'));
const dryRun = process.argv.includes('--dry-run');
const MIGRATION_ID = 'phase5_multitenancy_backfill'; const MIGRATION_ID = 'phase5_multitenancy_backfill';
const db = new Database(config.dbPath); function alreadyApplied(db) {
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
function alreadyApplied() {
try { try {
return !!db.prepare('SELECT 1 FROM schema_migrations WHERE id = ?').get(MIGRATION_ID); return !!db.prepare('SELECT 1 FROM schema_migrations WHERE id = ?').get(MIGRATION_ID);
} catch { return false; } } catch { return false; }
} }
if (alreadyApplied()) { function runMigration({ db: existingDb = null, dryRun = false, logger = console } = {}) {
console.log('[migrate] already applied - nothing to do'); const db = existingDb || (() => {
process.exit(0); const d = new Database(config.dbPath);
} d.pragma('journal_mode = WAL');
d.pragma('foreign_keys = ON');
return d;
})();
const ownDb = !existingDb;
console.log(`[migrate] mode=${dryRun ? 'DRY RUN' : 'COMMIT'}`); try {
console.log(`[migrate] db=${config.dbPath}`); if (alreadyApplied(db)) {
logger.log('[migrate] already applied - nothing to do');
return { skipped: true };
}
// 1. New tables (idempotent). logger.log(`[migrate] mode=${dryRun ? 'DRY RUN' : 'COMMIT'}`);
db.exec(` logger.log(`[migrate] db=${config.dbPath}`);
CREATE TABLE IF NOT EXISTS organizations (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
slug TEXT UNIQUE,
owner_user_id TEXT NOT NULL REFERENCES users(id),
plan_id TEXT DEFAULT 'free' REFERENCES plans(id),
stripe_customer_id TEXT,
stripe_subscription_id TEXT,
subscription_status TEXT DEFAULT 'active',
subscription_ends INTEGER,
grace_period_ends INTEGER,
locked_at INTEGER,
default_brand_name TEXT,
default_logo_url TEXT,
default_primary_color TEXT,
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
);
CREATE TABLE IF NOT EXISTS organization_members ( // 1. New tables (idempotent).
id INTEGER PRIMARY KEY AUTOINCREMENT, db.exec(`
organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, CREATE TABLE IF NOT EXISTS organizations (
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, id TEXT PRIMARY KEY,
role TEXT NOT NULL DEFAULT 'org_admin', name TEXT NOT NULL,
invited_by TEXT REFERENCES users(id), slug TEXT UNIQUE,
joined_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), owner_user_id TEXT NOT NULL REFERENCES users(id),
UNIQUE(organization_id, user_id) plan_id TEXT DEFAULT 'free' REFERENCES plans(id),
); stripe_customer_id TEXT,
stripe_subscription_id TEXT,
subscription_status TEXT DEFAULT 'active',
subscription_ends INTEGER,
grace_period_ends INTEGER,
locked_at INTEGER,
default_brand_name TEXT,
default_logo_url TEXT,
default_primary_color TEXT,
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
);
CREATE TABLE IF NOT EXISTS workspaces ( CREATE TABLE IF NOT EXISTS organization_members (
id TEXT PRIMARY KEY, id INTEGER PRIMARY KEY AUTOINCREMENT,
organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
name TEXT NOT NULL, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
slug TEXT, role TEXT NOT NULL DEFAULT 'org_admin',
created_by TEXT REFERENCES users(id), invited_by TEXT REFERENCES users(id),
billing_type TEXT DEFAULT 'client_billable', joined_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
billing_notes TEXT, UNIQUE(organization_id, user_id)
billing_contact_email TEXT, );
billing_contract_ref TEXT,
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
UNIQUE(organization_id, slug)
);
CREATE TABLE IF NOT EXISTS workspace_members ( CREATE TABLE IF NOT EXISTS workspaces (
id INTEGER PRIMARY KEY AUTOINCREMENT, id TEXT PRIMARY KEY,
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, name TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'workspace_viewer', slug TEXT,
invited_by TEXT REFERENCES users(id), created_by TEXT REFERENCES users(id),
joined_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), billing_type TEXT DEFAULT 'client_billable',
UNIQUE(workspace_id, user_id) billing_notes TEXT,
); billing_contact_email TEXT,
billing_contract_ref TEXT,
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
UNIQUE(organization_id, slug)
);
CREATE TABLE IF NOT EXISTS workspace_invites ( CREATE TABLE IF NOT EXISTS workspace_members (
id TEXT PRIMARY KEY, id INTEGER PRIMARY KEY AUTOINCREMENT,
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
email TEXT NOT NULL, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role TEXT NOT NULL DEFAULT 'workspace_viewer', role TEXT NOT NULL DEFAULT 'workspace_viewer',
invited_by TEXT NOT NULL REFERENCES users(id), invited_by TEXT REFERENCES users(id),
expires_at INTEGER NOT NULL, joined_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')) UNIQUE(workspace_id, user_id)
); );
`);
// 2. Additive columns (idempotent: ignore 'duplicate column' errors). CREATE TABLE IF NOT EXISTS workspace_invites (
const alters = [ id TEXT PRIMARY KEY,
'ALTER TABLE devices ADD COLUMN workspace_id TEXT REFERENCES workspaces(id)', workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
'ALTER TABLE content ADD COLUMN workspace_id TEXT REFERENCES workspaces(id)', email TEXT NOT NULL,
'ALTER TABLE playlists ADD COLUMN workspace_id TEXT REFERENCES workspaces(id)', role TEXT NOT NULL DEFAULT 'workspace_viewer',
'ALTER TABLE layouts ADD COLUMN workspace_id TEXT REFERENCES workspaces(id)', invited_by TEXT NOT NULL REFERENCES users(id),
'ALTER TABLE widgets ADD COLUMN workspace_id TEXT REFERENCES workspaces(id)', expires_at INTEGER NOT NULL,
'ALTER TABLE schedules ADD COLUMN workspace_id TEXT REFERENCES workspaces(id)', created_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
'ALTER TABLE video_walls ADD COLUMN workspace_id TEXT REFERENCES workspaces(id)', );
'ALTER TABLE device_groups ADD COLUMN workspace_id TEXT REFERENCES workspaces(id)', `);
'ALTER TABLE white_labels ADD COLUMN workspace_id TEXT REFERENCES workspaces(id)',
'ALTER TABLE kiosk_pages ADD COLUMN workspace_id TEXT REFERENCES workspaces(id)',
'ALTER TABLE alert_configs ADD COLUMN workspace_id TEXT REFERENCES workspaces(id)',
'ALTER TABLE activity_log ADD COLUMN workspace_id TEXT REFERENCES workspaces(id)',
'ALTER TABLE activity_log ADD COLUMN organization_id TEXT REFERENCES organizations(id)',
'ALTER TABLE activity_log ADD COLUMN acting_user_id TEXT REFERENCES users(id)',
'ALTER TABLE activity_log ADD COLUMN was_acting_as INTEGER DEFAULT 0',
];
for (const sql of alters) {
try { db.exec(sql); } catch (e) { /* column exists */ }
}
// 3. Indexes. // 2. Additive columns (idempotent: ignore 'duplicate column' errors).
db.exec(` const alters = [
CREATE INDEX IF NOT EXISTS idx_devices_workspace ON devices(workspace_id); 'ALTER TABLE devices ADD COLUMN workspace_id TEXT REFERENCES workspaces(id)',
CREATE INDEX IF NOT EXISTS idx_content_workspace ON content(workspace_id); 'ALTER TABLE content ADD COLUMN workspace_id TEXT REFERENCES workspaces(id)',
CREATE INDEX IF NOT EXISTS idx_playlists_workspace ON playlists(workspace_id); 'ALTER TABLE playlists ADD COLUMN workspace_id TEXT REFERENCES workspaces(id)',
CREATE INDEX IF NOT EXISTS idx_video_walls_workspace ON video_walls(workspace_id); 'ALTER TABLE layouts ADD COLUMN workspace_id TEXT REFERENCES workspaces(id)',
CREATE INDEX IF NOT EXISTS idx_workspaces_organization ON workspaces(organization_id); 'ALTER TABLE widgets ADD COLUMN workspace_id TEXT REFERENCES workspaces(id)',
CREATE INDEX IF NOT EXISTS idx_workspace_members_user ON workspace_members(user_id); 'ALTER TABLE schedules ADD COLUMN workspace_id TEXT REFERENCES workspaces(id)',
CREATE INDEX IF NOT EXISTS idx_organization_members_user ON organization_members(user_id); 'ALTER TABLE video_walls ADD COLUMN workspace_id TEXT REFERENCES workspaces(id)',
`); 'ALTER TABLE device_groups ADD COLUMN workspace_id TEXT REFERENCES workspaces(id)',
'ALTER TABLE white_labels ADD COLUMN workspace_id TEXT REFERENCES workspaces(id)',
'ALTER TABLE kiosk_pages ADD COLUMN workspace_id TEXT REFERENCES workspaces(id)',
'ALTER TABLE alert_configs ADD COLUMN workspace_id TEXT REFERENCES workspaces(id)',
'ALTER TABLE activity_log ADD COLUMN workspace_id TEXT REFERENCES workspaces(id)',
'ALTER TABLE activity_log ADD COLUMN organization_id TEXT REFERENCES organizations(id)',
'ALTER TABLE activity_log ADD COLUMN acting_user_id TEXT REFERENCES users(id)',
'ALTER TABLE activity_log ADD COLUMN was_acting_as INTEGER DEFAULT 0',
];
for (const sql of alters) {
try { db.exec(sql); } catch (e) { /* column exists */ }
}
// 4. Backfill (single transaction). // 3. Indexes.
const users = db.prepare('SELECT * FROM users').all(); db.exec(`
const teams = db.prepare('SELECT * FROM teams').all(); CREATE INDEX IF NOT EXISTS idx_devices_workspace ON devices(workspace_id);
const teamMembers = db.prepare('SELECT * FROM team_members').all(); CREATE INDEX IF NOT EXISTS idx_content_workspace ON content(workspace_id);
CREATE INDEX IF NOT EXISTS idx_playlists_workspace ON playlists(workspace_id);
CREATE INDEX IF NOT EXISTS idx_video_walls_workspace ON video_walls(workspace_id);
CREATE INDEX IF NOT EXISTS idx_workspaces_organization ON workspaces(organization_id);
CREATE INDEX IF NOT EXISTS idx_workspace_members_user ON workspace_members(user_id);
CREATE INDEX IF NOT EXISTS idx_organization_members_user ON organization_members(user_id);
`);
const userDefaultWs = new Map(); // user_id -> workspace_id // 4. Backfill (single transaction).
const userToOrg = new Map(); // user_id -> organization_id const users = db.prepare('SELECT * FROM users').all();
const teams = db.prepare('SELECT * FROM teams').all();
const teamMembers = db.prepare('SELECT * FROM team_members').all();
const RESOURCE_TABLES_WITH_TEAM_ID = ['devices', 'content', 'layouts', 'widgets', 'video_walls']; const userDefaultWs = new Map(); // user_id -> workspace_id
const RESOURCE_TABLES_NO_TEAM_ID = ['playlists', 'schedules', 'device_groups', 'white_labels', 'kiosk_pages', 'alert_configs']; const userToOrg = new Map(); // user_id -> organization_id
function table_has_col(t, c) { const RESOURCE_TABLES_WITH_TEAM_ID = ['devices', 'content', 'layouts', 'widgets', 'video_walls'];
return db.prepare(`PRAGMA table_info(${t})`).all().some(x => x.name === c); const RESOURCE_TABLES_NO_TEAM_ID = ['playlists', 'schedules', 'device_groups', 'white_labels', 'kiosk_pages', 'alert_configs'];
}
const stats = { orgs: 0, workspaces: 0, org_members: 0, ws_members: 0, role_changes: { sa: 0, adm: 0 }, backfill: {} }; function table_has_col(t, c) {
return db.prepare(`PRAGMA table_info(${t})`).all().some(x => x.name === c);
}
const backfill = db.transaction(() => { const stats = { orgs: 0, workspaces: 0, org_members: 0, ws_members: 0, role_changes: { sa: 0, adm: 0 }, backfill: {} };
for (const u of users) {
const orgId = uuidv4();
const orgName = (u.name && u.name.trim()) ? `${u.name}'s organization` : `${u.email}'s organization`;
db.prepare(`INSERT INTO organizations (
id, name, owner_user_id, plan_id,
stripe_customer_id, stripe_subscription_id,
subscription_status, subscription_ends
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(
orgId, orgName, u.id, u.plan_id || 'free',
u.stripe_customer_id || null, u.stripe_subscription_id || null,
u.subscription_status || 'active', u.subscription_ends || null
);
stats.orgs++;
db.prepare(`INSERT INTO organization_members (organization_id, user_id, role) VALUES (?, ?, 'org_owner')`).run(orgId, u.id);
stats.org_members++;
userToOrg.set(u.id, orgId);
const ownedTeams = teams.filter(t => t.owner_id === u.id); const backfill = db.transaction(() => {
let defaultWsId; for (const u of users) {
if (ownedTeams.length === 0) { const orgId = uuidv4();
defaultWsId = uuidv4(); const orgName = (u.name && u.name.trim()) ? `${u.name}'s organization` : `${u.email}'s organization`;
db.prepare(`INSERT INTO workspaces (id, organization_id, name, created_by) VALUES (?, ?, 'Default', ?)`).run(defaultWsId, orgId, u.id); db.prepare(`INSERT INTO organizations (
stats.workspaces++; id, name, owner_user_id, plan_id,
db.prepare(`INSERT INTO workspace_members (workspace_id, user_id, role) VALUES (?, ?, 'workspace_admin')`).run(defaultWsId, u.id); stripe_customer_id, stripe_subscription_id,
stats.ws_members++; subscription_status, subscription_ends
} else { ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(
// Re-use each team's id as the workspace id so existing FKs and bookmarks survive. orgId, orgName, u.id, u.plan_id || 'free',
for (const t of ownedTeams) { u.stripe_customer_id || null, u.stripe_subscription_id || null,
db.prepare(`INSERT INTO workspaces (id, organization_id, name, created_by) VALUES (?, ?, ?, ?)`).run(t.id, orgId, t.name, t.owner_id); u.subscription_status || 'active', u.subscription_ends || null
stats.workspaces++; );
const tms = teamMembers.filter(m => m.team_id === t.id); stats.orgs++;
let ownerSeen = false; db.prepare(`INSERT INTO organization_members (organization_id, user_id, role) VALUES (?, ?, 'org_owner')`).run(orgId, u.id);
for (const m of tms) { stats.org_members++;
if (m.user_id === t.owner_id) ownerSeen = true; userToOrg.set(u.id, orgId);
const wsRole =
m.role === 'owner' ? 'workspace_admin' : const ownedTeams = teams.filter(t => t.owner_id === u.id);
m.role === 'editor' ? 'workspace_editor' : let defaultWsId;
'workspace_viewer'; if (ownedTeams.length === 0) {
db.prepare(`INSERT INTO workspace_members (workspace_id, user_id, role, invited_by, joined_at) VALUES (?, ?, ?, ?, ?)`) defaultWsId = uuidv4();
.run(t.id, m.user_id, wsRole, m.invited_by || null, m.joined_at || Math.floor(Date.now() / 1000)); db.prepare(`INSERT INTO workspaces (id, organization_id, name, created_by) VALUES (?, ?, 'Default', ?)`).run(defaultWsId, orgId, u.id);
stats.ws_members++; stats.workspaces++;
} db.prepare(`INSERT INTO workspace_members (workspace_id, user_id, role) VALUES (?, ?, 'workspace_admin')`).run(defaultWsId, u.id);
if (!ownerSeen) {
db.prepare(`INSERT INTO workspace_members (workspace_id, user_id, role) VALUES (?, ?, 'workspace_admin')`).run(t.id, t.owner_id);
stats.ws_members++; stats.ws_members++;
} else {
// Re-use each team's id as the workspace id so existing FKs and bookmarks survive.
for (const t of ownedTeams) {
db.prepare(`INSERT INTO workspaces (id, organization_id, name, created_by) VALUES (?, ?, ?, ?)`).run(t.id, orgId, t.name, t.owner_id);
stats.workspaces++;
const tms = teamMembers.filter(m => m.team_id === t.id);
let ownerSeen = false;
for (const m of tms) {
if (m.user_id === t.owner_id) ownerSeen = true;
const wsRole =
m.role === 'owner' ? 'workspace_admin' :
m.role === 'editor' ? 'workspace_editor' :
'workspace_viewer';
db.prepare(`INSERT INTO workspace_members (workspace_id, user_id, role, invited_by, joined_at) VALUES (?, ?, ?, ?, ?)`)
.run(t.id, m.user_id, wsRole, m.invited_by || null, m.joined_at || Math.floor(Date.now() / 1000));
stats.ws_members++;
}
if (!ownerSeen) {
db.prepare(`INSERT INTO workspace_members (workspace_id, user_id, role) VALUES (?, ?, 'workspace_admin')`).run(t.id, t.owner_id);
stats.ws_members++;
}
}
defaultWsId = ownedTeams
.slice()
.sort((a, b) => (a.created_at || 0) - (b.created_at || 0))[0].id;
} }
userDefaultWs.set(u.id, defaultWsId);
} }
defaultWsId = ownedTeams
.slice() for (const t of RESOURCE_TABLES_WITH_TEAM_ID) {
.sort((a, b) => (a.created_at || 0) - (b.created_at || 0))[0].id; if (!table_has_col(t, 'workspace_id')) { stats.backfill[t] = 'skipped (no workspace_id col)'; continue; }
const rows = db.prepare(`SELECT id, user_id, team_id FROM ${t} WHERE workspace_id IS NULL`).all();
const upd = db.prepare(`UPDATE ${t} SET workspace_id = ? WHERE id = ?`);
let filled = 0;
for (const r of rows) {
const wsId = r.team_id || userDefaultWs.get(r.user_id);
if (wsId) { upd.run(wsId, r.id); filled++; }
}
stats.backfill[t] = `${filled}/${rows.length}`;
}
for (const t of RESOURCE_TABLES_NO_TEAM_ID) {
if (!table_has_col(t, 'workspace_id')) { stats.backfill[t] = 'skipped (no workspace_id col)'; continue; }
const rows = db.prepare(`SELECT id, user_id FROM ${t} WHERE workspace_id IS NULL`).all();
const upd = db.prepare(`UPDATE ${t} SET workspace_id = ? WHERE id = ?`);
let filled = 0;
for (const r of rows) {
const wsId = userDefaultWs.get(r.user_id);
if (wsId) { upd.run(wsId, r.id); filled++; }
}
stats.backfill[t] = `${filled}/${rows.length}`;
}
const aRows = db.prepare(`SELECT id, user_id FROM activity_log WHERE workspace_id IS NULL OR organization_id IS NULL`).all();
const aUpd = db.prepare(`UPDATE activity_log SET workspace_id = ?, organization_id = ? WHERE id = ?`);
let aFilled = 0;
for (const r of aRows) {
const wsId = r.user_id ? (userDefaultWs.get(r.user_id) || null) : null;
const orgId = r.user_id ? (userToOrg.get(r.user_id) || null) : null;
aUpd.run(wsId, orgId, r.id);
if (wsId || orgId) aFilled++;
}
stats.backfill.activity_log = `${aFilled}/${aRows.length} (NULLs are anonymous platform events)`;
// Role migration.
stats.role_changes.sa = db.prepare(`SELECT COUNT(*) AS n FROM users WHERE role = 'superadmin'`).get().n;
stats.role_changes.adm = db.prepare(`SELECT COUNT(*) AS n FROM users WHERE role = 'admin'`).get().n;
db.prepare(`UPDATE users SET role = 'platform_admin' WHERE role = 'superadmin'`).run();
db.prepare(`UPDATE users SET role = 'user' WHERE role = 'admin'`).run();
db.prepare('INSERT OR IGNORE INTO schema_migrations (id) VALUES (?)').run(MIGRATION_ID);
if (dryRun) {
// Force rollback by throwing - better-sqlite3 db.transaction() reverts everything.
throw new Error('__DRY_RUN_ROLLBACK__');
}
});
try {
backfill();
logger.log('[migrate] committed');
return { ok: true, stats };
} catch (e) {
if (e.message === '__DRY_RUN_ROLLBACK__') {
logger.log('[migrate] rolled back (dry run)');
return { ok: true, dryRun: true, stats };
}
throw e;
} }
userDefaultWs.set(u.id, defaultWsId); } finally {
if (ownDb) db.close();
} }
}
for (const t of RESOURCE_TABLES_WITH_TEAM_ID) { // CLI wrapper - only fires when invoked as 'node scripts/migrate-multitenancy.js'.
if (!table_has_col(t, 'workspace_id')) { stats.backfill[t] = 'skipped (no workspace_id col)'; continue; } // When required from server/db/database.js the CLI block is skipped.
const rows = db.prepare(`SELECT id, user_id, team_id FROM ${t} WHERE workspace_id IS NULL`).all(); if (require.main === module) {
const upd = db.prepare(`UPDATE ${t} SET workspace_id = ? WHERE id = ?`); process.chdir(SERVER_DIR);
let filled = 0; const dryRun = process.argv.includes('--dry-run');
for (const r of rows) { try {
const wsId = r.team_id || userDefaultWs.get(r.user_id); const result = runMigration({ dryRun });
if (wsId) { upd.run(wsId, r.id); filled++; } if (result.stats) {
console.log('---summary---');
console.log(JSON.stringify(result.stats, null, 2));
} }
stats.backfill[t] = `${filled}/${rows.length}`; process.exit(0);
} } catch (e) {
for (const t of RESOURCE_TABLES_NO_TEAM_ID) {
if (!table_has_col(t, 'workspace_id')) { stats.backfill[t] = 'skipped (no workspace_id col)'; continue; }
const rows = db.prepare(`SELECT id, user_id FROM ${t} WHERE workspace_id IS NULL`).all();
const upd = db.prepare(`UPDATE ${t} SET workspace_id = ? WHERE id = ?`);
let filled = 0;
for (const r of rows) {
const wsId = userDefaultWs.get(r.user_id);
if (wsId) { upd.run(wsId, r.id); filled++; }
}
stats.backfill[t] = `${filled}/${rows.length}`;
}
const aRows = db.prepare(`SELECT id, user_id FROM activity_log WHERE workspace_id IS NULL OR organization_id IS NULL`).all();
const aUpd = db.prepare(`UPDATE activity_log SET workspace_id = ?, organization_id = ? WHERE id = ?`);
let aFilled = 0;
for (const r of aRows) {
const wsId = r.user_id ? (userDefaultWs.get(r.user_id) || null) : null;
const orgId = r.user_id ? (userToOrg.get(r.user_id) || null) : null;
aUpd.run(wsId, orgId, r.id);
if (wsId || orgId) aFilled++;
}
stats.backfill.activity_log = `${aFilled}/${aRows.length} (NULLs are anonymous platform events)`;
// Role migration.
stats.role_changes.sa = db.prepare(`SELECT COUNT(*) AS n FROM users WHERE role = 'superadmin'`).get().n;
stats.role_changes.adm = db.prepare(`SELECT COUNT(*) AS n FROM users WHERE role = 'admin'`).get().n;
db.prepare(`UPDATE users SET role = 'platform_admin' WHERE role = 'superadmin'`).run();
db.prepare(`UPDATE users SET role = 'user' WHERE role = 'admin'`).run();
db.prepare('INSERT OR IGNORE INTO schema_migrations (id) VALUES (?)').run(MIGRATION_ID);
if (dryRun) {
// Force rollback by throwing - better-sqlite3 db.transaction() reverts everything.
throw new Error('__DRY_RUN_ROLLBACK__');
}
});
try {
backfill();
console.log('[migrate] committed');
} catch (e) {
if (e.message === '__DRY_RUN_ROLLBACK__') {
console.log('[migrate] rolled back (dry run)');
} else {
console.error('[migrate] FAILED:', e.message); console.error('[migrate] FAILED:', e.message);
process.exit(1); process.exit(1);
} }
} }
console.log('---summary---'); module.exports = { runMigration };
console.log(JSON.stringify(stats, null, 2));
db.close();

View file

@ -16,6 +16,50 @@ db.pragma('foreign_keys = ON');
const schema = fs.readFileSync(path.join(__dirname, 'schema.sql'), 'utf8'); const schema = fs.readFileSync(path.join(__dirname, 'schema.sql'), 'utf8');
db.exec(schema); db.exec(schema);
// Auto-apply Phase 1 multi-tenancy migration if not yet applied. Without this
// a self-hoster who pulls latest and restarts hits a crash in
// migrateFolderWorkspaceIds (queries workspaces table that doesn't exist).
// Pre-existing data is snapshotted to db/remote_display.pre-migration-<ts>.db
// before the migration runs - clear restore path on failure. Fresh installs
// run against empty data (creates tables, no rows to backfill).
function ensureMultitenancyMigration() {
let applied = false;
try {
applied = !!db.prepare(
"SELECT 1 FROM schema_migrations WHERE id = 'phase5_multitenancy_backfill'"
).get();
} catch { /* schema_migrations may not exist yet; treat as not applied */ }
if (applied) return;
console.warn('[boot] Multi-tenancy schema not present - applying migration...');
const ts = new Date().toISOString().replace(/[:.]/g, '-');
const snapshotPath = path.join(dbDir, `remote_display.pre-migration-${ts}.db`);
try {
db.pragma('wal_checkpoint(TRUNCATE)');
fs.copyFileSync(config.dbPath, snapshotPath);
console.warn(`[boot] Pre-migration snapshot: ${snapshotPath}`);
} catch (e) {
console.error(`[boot] Snapshot failed: ${e.message}`);
process.exit(1);
}
try {
const { runMigration } = require('../../scripts/migrate-multitenancy');
runMigration({ db });
console.warn('[boot] Migration complete, continuing startup');
} catch (e) {
console.error(`[boot] Migration FAILED: ${e.message}`);
console.error(`[boot] Restore with: cp ${snapshotPath} ${config.dbPath}`);
process.exit(1);
}
}
// Note: ensureMultitenancyMigration() is called LATER, after the inline
// migrations array has added team_id and workspace_id columns. The Phase 1
// migration script reads team_id from resource tables during its backfill
// loop, so those columns must exist first. Definition kept here near the
// top so the auto-migration logic is easy to find when reading the file.
// Migrations for existing databases // Migrations for existing databases
const migrations = [ const migrations = [
'ALTER TABLE content ADD COLUMN remote_url TEXT', 'ALTER TABLE content ADD COLUMN remote_url TEXT',
@ -330,6 +374,12 @@ function migrateGroupSchedules() {
migrateGroupSchedules(); migrateGroupSchedules();
// Phase 1 multi-tenancy migration (auto-applies if not yet run). Must come
// AFTER the inline migrations above so that team_id / workspace_id columns
// exist on resource tables - the Phase 1 backfill loop reads team_id and
// updates workspace_id.
ensureMultitenancyMigration();
// Phase 2.2c migration: backfill content_folders.workspace_id from owner's // Phase 2.2c migration: backfill content_folders.workspace_id from owner's
// default workspace. The ALTER lives in the migrations array above; this // default workspace. The ALTER lives in the migrations array above; this
// one-shot populates the column for any rows that pre-date it. // one-shot populates the column for any rows that pre-date it.
@ -339,6 +389,16 @@ function migrateFolderWorkspaceIds() {
const already = db.prepare('SELECT 1 FROM schema_migrations WHERE id = ?').get(PHASE6_MIGRATION_ID); const already = db.prepare('SELECT 1 FROM schema_migrations WHERE id = ?').get(PHASE6_MIGRATION_ID);
if (already) return; if (already) return;
// Belt-and-suspenders: if multi-tenancy tables aren't present (auto-runner
// somehow skipped), skip cleanly instead of crashing on the JOIN below.
const hasWorkspaces = db.prepare(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='workspaces'"
).get();
if (!hasWorkspaces) {
console.warn('migrateFolderWorkspaceIds: workspaces table missing, skipping');
return;
}
// Check the column exists before trying to backfill. (Defensive: on a fresh // Check the column exists before trying to backfill. (Defensive: on a fresh
// install the schema.sql defines content_folders without the column, the // install the schema.sql defines content_folders without the column, the
// ALTER above adds it, and we proceed; but if anything went sideways we // ALTER above adds it, and we proceed; but if anything went sideways we
@ -385,6 +445,16 @@ function backfillActivityLogWorkspace() {
const already = db.prepare('SELECT 1 FROM schema_migrations WHERE id = ?').get(PHASE_2_2_ACTIVITY_STOP_ID); const already = db.prepare('SELECT 1 FROM schema_migrations WHERE id = ?').get(PHASE_2_2_ACTIVITY_STOP_ID);
if (already) return; if (already) return;
// Belt-and-suspenders: if multi-tenancy tables aren't present (auto-runner
// somehow skipped), skip cleanly instead of crashing on workspace_members.
const hasMembers = db.prepare(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='workspace_members'"
).get();
if (!hasMembers) {
console.warn('backfillActivityLogWorkspace: workspace_members table missing, skipping');
return;
}
const viaDevice = db.prepare(` const viaDevice = db.prepare(`
UPDATE activity_log SET workspace_id = ( UPDATE activity_log SET workspace_id = (
SELECT workspace_id FROM devices WHERE devices.id = activity_log.device_id SELECT workspace_id FROM devices WHERE devices.id = activity_log.device_id