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,43 +14,52 @@
// 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;
try {
if (alreadyApplied(db)) {
logger.log('[migrate] already applied - nothing to do');
return { skipped: true };
} }
console.log(`[migrate] mode=${dryRun ? 'DRY RUN' : 'COMMIT'}`); logger.log(`[migrate] mode=${dryRun ? 'DRY RUN' : 'COMMIT'}`);
console.log(`[migrate] db=${config.dbPath}`); logger.log(`[migrate] db=${config.dbPath}`);
// 1. New tables (idempotent). // 1. New tables (idempotent).
db.exec(` db.exec(`
@ -274,16 +283,36 @@ const backfill = db.transaction(() => {
try { try {
backfill(); backfill();
console.log('[migrate] committed'); logger.log('[migrate] committed');
return { ok: true, stats };
} catch (e) { } catch (e) {
if (e.message === '__DRY_RUN_ROLLBACK__') { if (e.message === '__DRY_RUN_ROLLBACK__') {
console.log('[migrate] rolled back (dry run)'); logger.log('[migrate] rolled back (dry run)');
} else { return { ok: true, dryRun: true, stats };
}
throw e;
}
} finally {
if (ownDb) db.close();
}
}
// CLI wrapper - only fires when invoked as 'node scripts/migrate-multitenancy.js'.
// When required from server/db/database.js the CLI block is skipped.
if (require.main === module) {
process.chdir(SERVER_DIR);
const dryRun = process.argv.includes('--dry-run');
try {
const result = runMigration({ dryRun });
if (result.stats) {
console.log('---summary---');
console.log(JSON.stringify(result.stats, null, 2));
}
process.exit(0);
} catch (e) {
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