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,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();
|
|
||||||
|
|
|
||||||
|
|
@ -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