mirror of
https://github.com/screentinker/screentinker.git
synced 2026-05-15 07:32:23 -06:00
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:
parent
92e26aafcb
commit
bc445a0a7c
|
|
@ -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();
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue