From 3acaea2e45088b4b6aa85ae8d47a45311dcb42d9 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Thu, 9 Apr 2026 00:30:00 +0200 Subject: [PATCH] refactor: replace 2100-line migration system with ensureDefaults + baseline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove all manual migrations — Drizzle-kit now manages schema DDL. Data-fix migrations have all completed on both instances. Startup initialization (settings row, worker ID, first admin) moves to idempotent ensureDefaults(). --- packages/server/src/db/migrate.ts | 2191 ++--------------------------- 1 file changed, 80 insertions(+), 2111 deletions(-) diff --git a/packages/server/src/db/migrate.ts b/packages/server/src/db/migrate.ts index 6724799c..1038b65c 100644 --- a/packages/server/src/db/migrate.ts +++ b/packages/server/src/db/migrate.ts @@ -1,2135 +1,104 @@ import Database from 'better-sqlite3'; import crypto from 'crypto'; -import path from 'path'; import fs from 'fs'; -import { DEFAULT_EVERYONE_PERMISSIONS, PermissionBits, ALL_PERMISSIONS, permissionsToString } from '@backspace/shared/src/permissions.js'; -import { generateThumbnail, isResizableImage, probeImageDimensions, probeMediaMeta, generateVideoThumbnail } from '../utils/thumbnail.js'; - -export function runMigrations(db: Database.Database): void { - console.log('Checking for database migrations...'); - - const tables = [ - { - name: 'messages', - columns: [ - { name: 'reply_to_id', type: 'TEXT REFERENCES messages(id) ON DELETE SET NULL' } - ] - }, - { - name: 'users', - columns: [ - { name: 'status', type: "TEXT DEFAULT 'offline'" }, - { name: 'custom_status', type: 'TEXT' } - ] - }, - { - name: 'roles', - columns: [ - { name: 'permissions', type: 'TEXT' } - ] - }, - { - name: 'dm_messages', - columns: [ - { name: 'edited_at', type: 'INTEGER' }, - { name: 'reply_to_id', type: 'TEXT' } - ] - }, - { - name: 'attachments', - columns: [ - { name: 'dm_message_id', type: 'TEXT' } - ] - }, - { - name: 'dm_members', - columns: [ - { name: 'closed', type: 'INTEGER DEFAULT 0' } - ] - }, - { - name: 'attachments', - columns: [ - { name: 'thumbnail_filename', type: 'TEXT' } - ] - }, - { - name: 'dm_channels', - columns: [ - { name: 'owner_id', type: 'TEXT' } - ] - }, - { - name: 'users', - columns: [ - { name: 'is_admin', type: 'INTEGER DEFAULT 0' } - ] - }, - { - name: 'users', - columns: [ - { name: 'home_instance', type: 'TEXT' }, - { name: 'replicated_instances', type: "TEXT DEFAULT '[]'" }, - { name: 'home_user_id', type: 'TEXT' } - ] - }, - { - name: 'instance_settings', - columns: [ - { name: 'instance_name', type: "TEXT DEFAULT 'Backspace'" }, - { name: 'worker_id', type: 'INTEGER' }, - { name: 'discovery_enabled', type: 'INTEGER NOT NULL DEFAULT 1' }, - { name: 'registration_open', type: 'INTEGER' }, - { name: 'bitrate_matrix_overrides', type: 'TEXT DEFAULT NULL' } - ] - }, - { - name: 'spaces', - columns: [ - { name: 'visibility', type: "TEXT DEFAULT 'private'" }, - { name: 'description', type: 'TEXT' } - ] - }, - { - name: 'spaces', - columns: [ - { name: 'banner', type: 'TEXT' } - ] - }, - { - name: 'users', - columns: [ - { name: 'banner', type: 'TEXT' }, - { name: 'accent_color', type: 'TEXT' }, - { name: 'bio', type: 'TEXT' }, - ] - }, - { - name: 'users', - columns: [ - { name: 'avatar_color', type: 'TEXT' }, - ] - }, - { - name: 'users', - columns: [ - { name: 'is_deleted', type: 'INTEGER DEFAULT 0' }, - ] - }, - { - name: 'spaces', - columns: [ - { name: 'avatar_color', type: 'TEXT' }, - ] - }, - { - name: 'channels', - columns: [ - { name: 'category_id', type: 'TEXT' }, - ] - }, - { - name: 'users', - columns: [ - { name: 'profile_updated_at', type: 'INTEGER' }, - ] - }, - { - name: 'users', - columns: [ - { name: 'discoverable', type: 'INTEGER DEFAULT 1' }, - ] - }, - { - name: 'attachments', - columns: [ - { name: 'uploader_id', type: 'TEXT' }, - ] - }, - { - name: 'users', - columns: [ - { name: 'password_changed_at', type: 'INTEGER' }, - ] - }, - { - name: 'users', - columns: [ - { name: 'show_activity', type: 'INTEGER NOT NULL DEFAULT 1' }, - ] - }, - { - name: 'attachments', - columns: [ - { name: 'width', type: 'INTEGER' }, - { name: 'height', type: 'INTEGER' }, - { name: 'duration', type: 'REAL' }, - ] - }, - { - name: 'instance_settings', - columns: [ - { name: 'max_upload_size_bytes', type: 'INTEGER' } - ] - }, - { - name: 'instance_settings', - columns: [ - { name: 'allow_custom_bitrate', type: 'INTEGER NOT NULL DEFAULT 1' } - ] - }, - // gif_api_key is handled by migrateRenameGifApiKey() — do NOT add it here - // or it will race with the tenor_api_key → gif_api_key rename migration - { - name: 'dm_channels', - columns: [ - { name: 'owner_home_user_id', type: 'TEXT' }, - { name: 'owner_home_instance', type: 'TEXT' }, - { name: 'deleted_at', type: 'INTEGER' }, - ] - }, - { - name: 'dm_messages', - columns: [ - { name: 'source_instance', type: 'TEXT' }, - { name: 'source_message_id', type: 'TEXT' }, - { name: 'encryption_version', type: 'INTEGER DEFAULT 0' }, - ] - }, - { - name: 'dm_messages', - columns: [ - { name: 'type', type: "TEXT NOT NULL DEFAULT 'user'" }, - ] - }, - { - name: 'attachments', - columns: [ - { name: 'source_url', type: 'TEXT' }, - ] - }, - { - name: 'instance_settings', - columns: [ - { name: 'federation_relay_enabled', type: 'INTEGER NOT NULL DEFAULT 1' }, - { name: 'federation_relay_ttl_days', type: 'INTEGER NOT NULL DEFAULT 30' }, - ] - }, - { - name: 'instance_settings', - columns: [ - { name: 'default_auto_rotate_interval_days', type: 'INTEGER NOT NULL DEFAULT 90' }, - ] - }, - { - name: 'federation_peers', - columns: [ - { name: 'pending_hmac_secret', type: 'TEXT' }, - { name: 'secret_rotation_at', type: 'INTEGER' }, - { name: 'secret_rotated_at', type: 'INTEGER' }, - { name: 'auto_rotate_interval_days', type: 'INTEGER NOT NULL DEFAULT 90' }, - ] - }, - { - name: 'users', - columns: [ - { name: 'federation_registry_updated_at', type: 'INTEGER DEFAULT 0' }, - ] - }, - ]; - - for (const table of tables) { - const tableInfo = db.pragma(`table_info(${table.name})`) as { name: string }[]; - const existingColumns = new Set(tableInfo.map(c => c.name)); - - for (const column of table.columns) { - if (!existingColumns.has(column.name)) { - console.log(`Migrating: Adding column ${column.name} to ${table.name}`); - try { - db.exec(`ALTER TABLE ${table.name} ADD COLUMN ${column.name} ${column.type}`); - } catch (error) { - console.error(`Failed to add column ${column.name} to ${table.name}:`, error); - } - } - } - } - - // Ensure channel_overrides table exists (idempotent) - db.exec(` - CREATE TABLE IF NOT EXISTS channel_overrides ( - channel_id TEXT NOT NULL REFERENCES channels(id) ON DELETE CASCADE, - target_type TEXT NOT NULL, - target_id TEXT NOT NULL, - allow TEXT NOT NULL DEFAULT '0', - deny TEXT NOT NULL DEFAULT '0', - PRIMARY KEY (channel_id, target_type, target_id) - ); - `); - - // Ensure bans table exists (idempotent) - db.exec(` - CREATE TABLE IF NOT EXISTS bans ( - space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE, - user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - reason TEXT, - banned_by TEXT REFERENCES users(id), - created_at INTEGER NOT NULL, - PRIMARY KEY (space_id, user_id) - ); - `); - - // Ensure join_requests table exists (idempotent) - db.exec(` - CREATE TABLE IF NOT EXISTS join_requests ( - id TEXT PRIMARY KEY, - space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE, - user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - message TEXT, - status TEXT NOT NULL DEFAULT 'pending', - decided_by TEXT REFERENCES users(id), - created_at INTEGER NOT NULL, - decided_at INTEGER - ); - `); - - // Ensure channel_categories table exists (idempotent) - db.exec(` - CREATE TABLE IF NOT EXISTS channel_categories ( - id TEXT PRIMARY KEY, - space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE, - name TEXT NOT NULL, - position INTEGER DEFAULT 0, - created_at INTEGER NOT NULL - ); - `); - - // Ensure voice_restrictions table exists (idempotent) - db.exec(` - CREATE TABLE IF NOT EXISTS voice_restrictions ( - space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE, - user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - restriction_type TEXT NOT NULL, - moderator_id TEXT REFERENCES users(id), - created_at INTEGER NOT NULL, - PRIMARY KEY (space_id, user_id, restriction_type) - ); - `); - - // Ensure embeds table exists (idempotent) - db.exec(` - CREATE TABLE IF NOT EXISTS embeds ( - id TEXT PRIMARY KEY, - message_id TEXT REFERENCES messages(id) ON DELETE CASCADE, - dm_message_id TEXT REFERENCES dm_messages(id) ON DELETE CASCADE, - url TEXT NOT NULL, - embed_type TEXT NOT NULL CHECK (embed_type IN ('generic', 'video', 'image', 'audio', 'rich')), - provider TEXT, - title TEXT, - description TEXT, - image TEXT, - embed_url TEXT, - width INTEGER, - height INTEGER, - color TEXT, - created_at INTEGER NOT NULL, - CHECK ( - (message_id IS NOT NULL AND dm_message_id IS NULL) OR - (message_id IS NULL AND dm_message_id IS NOT NULL) - ) - ); - `); - - // ─── Federation tables ─────────────────────────────────────────────────── - db.exec(` - CREATE TABLE IF NOT EXISTS federation_peers ( - id TEXT PRIMARY KEY, - origin TEXT NOT NULL UNIQUE, - instance_name TEXT, - hmac_secret TEXT NOT NULL, - status TEXT NOT NULL DEFAULT 'active', - last_seen_at INTEGER, - last_failure_at INTEGER, - consecutive_failures INTEGER DEFAULT 0, - last_synced_at INTEGER DEFAULT 0, - created_at INTEGER NOT NULL - ); - `); - - db.exec(` - CREATE TABLE IF NOT EXISTS federation_outbox ( - id TEXT PRIMARY KEY, - peer_id TEXT NOT NULL REFERENCES federation_peers(id) ON DELETE CASCADE, - context_id TEXT NOT NULL, - entity_id TEXT NOT NULL, - context_type TEXT NOT NULL DEFAULT 'dm', - event_type TEXT NOT NULL, - payload TEXT NOT NULL, - encryption_version INTEGER DEFAULT 0, - attempts INTEGER DEFAULT 0, - next_retry_at INTEGER NOT NULL, - expires_at INTEGER NOT NULL, - created_at INTEGER NOT NULL, - UNIQUE(peer_id, entity_id) - ); - `); - - db.exec(` - CREATE TABLE IF NOT EXISTS federation_file_queue ( - id TEXT PRIMARY KEY, - peer_origin TEXT NOT NULL, - dm_message_id TEXT NOT NULL, - source_url TEXT NOT NULL, - target_filename TEXT, - original_name TEXT NOT NULL, - mimetype TEXT NOT NULL, - size INTEGER NOT NULL, - status TEXT NOT NULL DEFAULT 'pending', - rejection_reason TEXT, - attempts INTEGER DEFAULT 0, - next_retry_at INTEGER NOT NULL, - expires_at INTEGER NOT NULL, - created_at INTEGER NOT NULL - ); - `); - - db.exec(` - CREATE TABLE IF NOT EXISTS federation_mutation_log ( - id TEXT PRIMARY KEY, - entity_id TEXT NOT NULL, - context_id TEXT NOT NULL, - context_type TEXT NOT NULL DEFAULT 'dm', - mutation_type TEXT NOT NULL, - mutated_at INTEGER NOT NULL, - payload TEXT - ); - `); - - // ─── User federation registry ─────────────────────────────────────────────── - db.exec(` - CREATE TABLE IF NOT EXISTS user_federation_registry ( - user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - origin TEXT NOT NULL, - label TEXT NOT NULL DEFAULT '', - username TEXT NOT NULL DEFAULT '', - remote_user_id TEXT NOT NULL DEFAULT '', - status TEXT NOT NULL DEFAULT 'connected', - added_at INTEGER NOT NULL, - last_connected_at INTEGER, - disconnected_at INTEGER, - error_message TEXT, - PRIMARY KEY (user_id, origin) - ); - `); - - // ─── Legacy permissions: convert JSON arrays to decimal strings ─────────── - migrateLegacyPermissions(db); - - // ─── RBAC Migration: Ensure @everyone roles exist for all spaces ───────── - migrateEveryoneRoles(db); - - // ─── Instance settings: ensure default row exists ────────────────────────── - migrateInstanceSettings(db); - - // ─── Worker ID: ensure a unique Snowflake worker ID is persisted ─────────── - migrateWorkerId(db); - - // ─── Namespace replicated users: ensure all federated users use user@domain ─ - migrateReplicatedUsernames(db); - - // ─── Admin flag: ensure at least one admin exists (first registered user) ── - migrateFirstAdmin(db); - - // ─── Remove USE_VOICE_ACTIVITY bit and shift STREAM/DISCONNECT_MEMBERS down ─ - migrateRemoveVoiceActivityBit(db); - - // ─── Clean up corrupted read_states (temp_ IDs leaked from optimistic messages) ─ - migrateCorruptedReadStates(db); - - // ─── Free usernames from already-tombstoned users ─────────────────────────── - migrateDeletedUsernames(db); - - // ─── Fix nullable moderator columns (bans.banned_by, voice_restrictions.moderator_id) ─ - migrateNullableModeratorColumns(db); - - // ─── Clean up orphaned data from deleted users and channels ──────────────── - migrateOrphanedData(db); - - // ─── Lowercase all existing usernames ──────────────────────────────────────── - migrateLowercaseUsernames(db); - - // ─── Convert video channels to voice (video type removed) ───────────────── - migrateVideoChannels(db); - - // ─── Backfill profile_updated_at from created_at ────────────────────────── - migrateProfileUpdatedAt(db); - - // ─── Ensure user_space_layout table exists ──────────────────────────────── - db.exec(` - CREATE TABLE IF NOT EXISTS user_space_layout ( - user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, - layout TEXT NOT NULL DEFAULT '[]', - updated_at INTEGER NOT NULL - ); - `); - - // ─── Add position column to space_folder_members ────────────────────────── - { - const sfmColumns = db.pragma('table_info(space_folder_members)') as { name: string }[]; - if (!sfmColumns.some(c => c.name === 'position')) { - db.exec('ALTER TABLE space_folder_members ADD COLUMN position INTEGER DEFAULT 0'); - console.log('Migrating: Added position column to space_folder_members'); - } - } - - // ─── Remove FK constraint from space_folder_members (federated spaces) ──── - { - const tableInfo = db.prepare( - "SELECT sql FROM sqlite_master WHERE type='table' AND name='space_folder_members'" - ).get() as { sql: string } | undefined; - - if (tableInfo && tableInfo.sql.includes('REFERENCES spaces')) { - console.log('Migrating: Removing FK constraint from space_folder_members...'); - db.exec(` - CREATE TABLE space_folder_members_new ( - folder_id TEXT NOT NULL REFERENCES space_folders(id) ON DELETE CASCADE, - space_id TEXT NOT NULL, - position INTEGER DEFAULT 0, - PRIMARY KEY (folder_id, space_id) - ); - INSERT INTO space_folder_members_new SELECT folder_id, space_id, position FROM space_folder_members; - DROP TABLE space_folder_members; - ALTER TABLE space_folder_members_new RENAME TO space_folder_members; - `); - } - } - - // ─── Add FK constraint to dm_messages.reply_to_id ──────────────────────── - migrateDmMessagesReplyToFk(db); - - // ─── Rename tenor_api_key → gif_api_key (Klipy pivot) ──────────────────── - migrateRenameGifApiKey(db); - - // ─── Add indexes on FK columns for query performance ───────────────────── - migrateAddIndexes(db); - - // ─── Embed indexes (outside fast-path guard so they run on existing DBs) ── - db.exec('CREATE INDEX IF NOT EXISTS idx_embeds_message_id ON embeds(message_id)'); - db.exec('CREATE INDEX IF NOT EXISTS idx_embeds_dm_message_id ON embeds(dm_message_id)'); - - // ─── Clean up stale attachment records for profile images ─────────────── - migrateCleanupProfileAttachmentRecords(db); - - // ─── Add category_overrides table ──────────────────────────────────────── - migrateCategoryOverrides(db); - - // ─── Add FK constraint to attachments.dm_message_id ───────────────────── - migrateAttachmentsDmMessageFk(db); - - // ─── Federation indexes ────────────────────────────────────────────────── - db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_dm_messages_source_unique ON dm_messages(source_instance, source_message_id) WHERE source_instance IS NOT NULL`); - // idx_dm_federated is created by migrateDmChannelsFederatedId (after column rename) - db.exec(`CREATE INDEX IF NOT EXISTS idx_outbox_retry ON federation_outbox(next_retry_at)`); - db.exec(`CREATE INDEX IF NOT EXISTS idx_mutation_log_time ON federation_mutation_log(mutated_at)`); - - // ─── Ensure federation relay is enabled (fix: was incorrectly defaulting to 0) ─ - try { - const relayState = db.prepare('SELECT federation_relay_enabled FROM instance_settings WHERE id = 1').get() as { federation_relay_enabled: number } | undefined; - if (relayState && relayState.federation_relay_enabled === 0) { - db.prepare('UPDATE instance_settings SET federation_relay_enabled = 1 WHERE id = 1').run(); - console.log('Federation: Enabled relay (was incorrectly defaulting to disabled)'); - } - } catch { /* column may not exist yet on first run */ } - - // ─── Rename canonical_pair_id → federated_id, add group DM columns ─────── - migrateDmChannelsFederatedId(db); - - // ─── Fix ownerId on 1-on-1 DMs (must run before federated_id backfill) ── - migrateFixOneOnOneOwnerIds(db); - - // ─── Backfill federated_id for existing 1-on-1 DM channels ────────────── - // (Runs after migrateDmChannelsFederatedId so the federated_id column is guaranteed to exist) - try { - // Find 1-on-1 DM channels that don't have a federated_id yet - const channelsNeedingPairId = db.prepare(` - SELECT dc.id, GROUP_CONCAT(COALESCE(u.home_user_id, u.id)) as home_ids - FROM dm_channels dc - JOIN dm_members dm ON dc.id = dm.dm_channel_id - JOIN users u ON dm.user_id = u.id - WHERE dc.federated_id IS NULL - AND dc.owner_id IS NULL - GROUP BY dc.id - HAVING COUNT(dm.user_id) = 2 - `).all() as { id: string; home_ids: string }[]; - - if (channelsNeedingPairId.length > 0) { - // crypto is already imported at the top of this file - const update = db.prepare('UPDATE dm_channels SET federated_id = ? WHERE id = ? AND federated_id IS NULL'); - - let backfilled = 0; - for (const channel of channelsNeedingPairId) { - // home_ids is like "homeUserId1,homeUserId2" (COALESCE handles NULL home_user_id) - const homeUserIds = channel.home_ids.split(','); - - if (homeUserIds.length === 2) { - const sorted = homeUserIds.sort(); - const pairId = crypto.createHash('sha256').update(sorted.join(':')).digest('hex').slice(0, 32); - // Check if this pairId already exists on a relay-created channel - const existing = db.prepare('SELECT id FROM dm_channels WHERE federated_id = ?').get(pairId) as { id: string } | undefined; - if (existing) { - // A relay-created duplicate exists — merge it into this (older) channel, then set the pair ID - db.prepare('UPDATE dm_messages SET dm_channel_id = ? WHERE dm_channel_id = ?').run(channel.id, existing.id); - db.prepare('INSERT OR IGNORE INTO dm_members (dm_channel_id, user_id, closed) SELECT ?, user_id, closed FROM dm_members WHERE dm_channel_id = ?').run(channel.id, existing.id); - db.prepare('DELETE FROM dm_members WHERE dm_channel_id = ?').run(existing.id); - db.prepare('DELETE FROM dm_channels WHERE id = ?').run(existing.id); - console.log(`Federation: Merged relay-duplicate channel ${existing.id} into original ${channel.id}`); - } - update.run(pairId, channel.id); - backfilled++; - } - } - - if (backfilled > 0) { - console.log(`Federation: Backfilled federated_id for ${backfilled} existing DM channel(s)`); - } - } - } catch (err) { - console.error('Federation federated_id backfill failed (non-fatal):', err); - } - - // ─── Merge duplicate DM channels with same federated_id ────────────────── - try { - // Find federated_ids that appear more than once - const duplicates = db.prepare(` - SELECT federated_id, GROUP_CONCAT(id) as channel_ids - FROM dm_channels - WHERE federated_id IS NOT NULL - GROUP BY federated_id - HAVING COUNT(*) > 1 - `).all() as { federated_id: string; channel_ids: string }[]; - - for (const dup of duplicates) { - const ids = dup.channel_ids.split(','); - // Keep the oldest channel (lowest snowflake ID = created first), move messages from newer ones - ids.sort(); - const keepId = ids[0]; - const removeIds = ids.slice(1); - - for (const removeId of removeIds) { - // Move messages from duplicate channel to the keeper - db.prepare('UPDATE dm_messages SET dm_channel_id = ? WHERE dm_channel_id = ?').run(keepId, removeId); - // Move read states from duplicate channel to the keeper (ignore conflicts) - db.prepare('INSERT OR IGNORE INTO read_states SELECT user_id, ?, last_read_message_id, updated_at FROM read_states WHERE channel_id = ?').run(keepId, removeId); - db.prepare('DELETE FROM read_states WHERE channel_id = ?').run(removeId); - // Move DM members (ignore conflicts where member already exists in keeper) - db.prepare('INSERT OR IGNORE INTO dm_members (dm_channel_id, user_id, closed) SELECT ?, user_id, closed FROM dm_members WHERE dm_channel_id = ?').run(keepId, removeId); - // Delete duplicate members and channel - db.prepare('DELETE FROM dm_members WHERE dm_channel_id = ?').run(removeId); - db.prepare('DELETE FROM dm_channels WHERE id = ?').run(removeId); - } - - if (removeIds.length > 0) { - console.log(`Federation: Merged ${removeIds.length} duplicate DM channel(s) for pair ${dup.federated_id} into ${keepId}`); - } - } - } catch (err) { - console.error('Federation DM channel merge failed (non-fatal):', err); - } - - // ─── Backfill federation mutation log for existing DM messages (idempotent) ─ - try { - const mutationLogExists = (db.pragma('table_info(federation_mutation_log)') as { name: string }[]).length > 0; - if (mutationLogExists) { - const count = (db.prepare('SELECT COUNT(*) as c FROM federation_mutation_log').get() as { c: number }).c; - if (count === 0) { - const result = db.prepare(` - INSERT OR IGNORE INTO federation_mutation_log (id, entity_id, context_id, context_type, mutation_type, mutated_at) - SELECT id, id, dm_channel_id, 'dm', 'create', created_at - FROM dm_messages - WHERE source_instance IS NULL - `).run(); - if (result.changes > 0) { - console.log(`Federation: Backfilled ${result.changes} mutation log entries for existing DM messages`); - } - } - } - } catch (err) { - console.error('Federation mutation log backfill failed (non-fatal):', err); - } - - migrateResetFederationSyncForLegacyDms(db); - - migrateGeneralizeOutbox(db); - - // ─── Federation upload size mismatch columns ───────────────────────────── - try { - db.exec(`ALTER TABLE attachments ADD COLUMN federation_status TEXT`); - } catch { /* column already exists */ } - - try { - db.exec(`ALTER TABLE attachments ADD COLUMN federation_meta TEXT`); - } catch { /* column already exists */ } - - try { - db.exec(`ALTER TABLE federation_peers ADD COLUMN remote_max_upload_size INTEGER`); - } catch { /* column already exists */ } - - // ─── Data integrity: repair group DMs with nulled-out owner_id ─────────── - // A bug in processOwnershipTransferEvent (fixed in cd7aff0) could set - // owner_id to NULL when resolveLocalUser failed, converting a group DM into - // a 1-on-1-looking channel. Detect these by finding dm_channels with a - // UUID-format federated_id (group DMs) but NULL owner_id, and restore the - // owner from the first remaining member. - const corruptedGroups = db.prepare(` - SELECT c.id, c.federated_id - FROM dm_channels c - WHERE c.owner_id IS NULL - AND c.federated_id IS NOT NULL - AND c.deleted_at IS NULL - AND length(c.federated_id) = 36 - AND c.federated_id LIKE '________-____-____-____-____________' - `).all() as Array<{ id: string; federated_id: string }>; - - for (const ch of corruptedGroups) { - const firstMember = db.prepare( - `SELECT user_id FROM dm_members WHERE dm_channel_id = ? LIMIT 1` - ).get(ch.id) as { user_id: string } | undefined; - - if (firstMember) { - db.prepare(`UPDATE dm_channels SET owner_id = ? WHERE id = ?`).run(firstMember.user_id, ch.id); - console.log(`[migration] Repaired group DM ${ch.id}: restored owner_id to ${firstMember.user_id}`); - } - } - - if (corruptedGroups.length > 0) { - console.log(`[migration] Repaired ${corruptedGroups.length} corrupted group DM(s).`); - } - - // ─── Data integrity: normalize homeInstance to bare domain ──────────────── - // resolveOrCreateReplicatedUser historically stored homeInstance as a full URL - // (e.g., "https://nova.ddns.net") while auth registration stored bare domains - // ("nova.ddns.net"). Normalize all to bare domain for consistent identity matching. - const fullUrlUsers = db.prepare(` - SELECT id, home_instance - FROM users - WHERE home_instance IS NOT NULL - AND (home_instance LIKE 'http://%' OR home_instance LIKE 'https://%') - AND is_deleted = 0 - `).all() as Array<{ id: string; home_instance: string }>; - - for (const u of fullUrlUsers) { - let domain: string; - try { - domain = new URL(u.home_instance).hostname; - } catch { - domain = u.home_instance.replace(/^https?:\/\//, '').split('/')[0] ?? u.home_instance; - } - db.prepare(`UPDATE users SET home_instance = ? WHERE id = ?`).run(domain, u.id); - } - - if (fullUrlUsers.length > 0) { - console.log(`[migration] Normalized ${fullUrlUsers.length} homeInstance value(s) from full URL to bare domain.`); - } - - // ─── Data integrity: merge duplicate federated user stubs ───────────────── - // The same remote user could accumulate multiple replicated records because - // resolveLocalUser matched on homeUserId but missed stubs created with a - // different homeUserId (e.g., auth registration vs S2S relay). Now that - // homeInstance is normalized to bare domain, we can detect and merge duplicates. - // - // Safety guard: at least one user in the pair must be a stub - // (passwordHash = '!federation-replicated'). Two real accounts from the same - // instance are different people who share a relayed DM, not duplicates. - // - // Detection criteria (at least one must match, PLUS same homeInstance domain): - // 1. Shared 1-on-1 DM membership (only when at least one is a stub) - // 2. Username cross-reference (one's homeUserId in the other's username base) - // 3. homeUserId cross-match (same homeUserId, missed due to old format mismatch) - // - // Winner selection: real account > stub, then most profile data, then lower ID. - - const federatedUsers = db.prepare(` - SELECT id, username, display_name, avatar, banner, bio, avatar_color, - home_instance, home_user_id, password_hash, is_deleted - FROM users - WHERE home_instance IS NOT NULL - AND is_deleted = 0 - `).all() as Array<{ - id: string; username: string; display_name: string | null; - avatar: string | null; banner: string | null; bio: string | null; - avatar_color: string | null; home_instance: string; home_user_id: string | null; - password_hash: string; is_deleted: number; - }>; - - // Group by normalized homeInstance domain - const domainGroups = new Map(); - for (const u of federatedUsers) { - const domain = u.home_instance.toLowerCase(); - const group = domainGroups.get(domain); - if (group) group.push(u); - else domainGroups.set(domain, [u]); - } - - type MergePair = { winner: typeof federatedUsers[0]; loser: typeof federatedUsers[0]; reason: string }; - const mergePairs: MergePair[] = []; - - for (const [domain, users] of domainGroups) { - if (users.length < 2) continue; - - // Check all pairs within this domain group - for (let i = 0; i < users.length; i++) { - for (let j = i + 1; j < users.length; j++) { - const a = users[i]!; - const b = users[j]!; - - // Safety guard: at least one must be a stub. Two real accounts from the - // same instance are different people (they share relayed DMs, not identities). - const aIsStub = a.password_hash === '!federation-replicated'; - const bIsStub = b.password_hash === '!federation-replicated'; - if (!aIsStub && !bIsStub) continue; - - let reason: string | null = null; - - // Criterion 1 removed: "shared 1-on-1 DM membership" was wrong — - // two users from the same domain sharing a DM channel doesn't mean - // they're duplicates, it means they're having a conversation. - - // Criterion 2: username cross-reference - if (!reason) { - const aBase = a.username.includes('@') ? a.username.split('@')[0]! : a.username; - const bBase = b.username.includes('@') ? b.username.split('@')[0]! : b.username; - if (a.home_user_id && bBase.toLowerCase() === a.home_user_id.toLowerCase()) { - reason = `b username base "${bBase}" matches a homeUserId "${a.home_user_id}"`; - } else if (b.home_user_id && aBase.toLowerCase() === b.home_user_id.toLowerCase()) { - reason = `a username base "${aBase}" matches b homeUserId "${b.home_user_id}"`; - } - } - - // Criterion 3: homeUserId cross-match - if (!reason) { - if (a.home_user_id && b.home_user_id && a.home_user_id === b.home_user_id) { - reason = `same homeUserId "${a.home_user_id}"`; - } - } - - if (!reason) continue; - - // Winner selection - const aReal = a.password_hash !== '!federation-replicated' ? 1 : 0; - const bReal = b.password_hash !== '!federation-replicated' ? 1 : 0; - - let winner: typeof a; - let loser: typeof a; - - if (aReal !== bReal) { - winner = aReal > bReal ? a : b; - loser = aReal > bReal ? b : a; - } else { - const profileCount = (u: typeof a) => - [u.display_name, u.avatar, u.banner, u.bio].filter(Boolean).length; - const aCount = profileCount(a); - const bCount = profileCount(b); - if (aCount !== bCount) { - winner = aCount > bCount ? a : b; - loser = aCount > bCount ? b : a; - } else { - winner = a.id < b.id ? a : b; - loser = a.id < b.id ? b : a; - } - } - - // Check we haven't already scheduled either user in a merge - const alreadyScheduled = mergePairs.some( - p => p.winner.id === winner.id || p.winner.id === loser.id || - p.loser.id === winner.id || p.loser.id === loser.id - ); - if (!alreadyScheduled) { - mergePairs.push({ winner, loser, reason }); - } - } - } - } - - // Execute merges — each pair in its own transaction for atomicity - const mergeOne = db.transaction((pair: MergePair) => { - const { winner, loser, reason } = pair; - let migratedDmMembers = 0; - let migratedDmMessages = 0; - let migratedDmReactions = 0; - let migratedFriends = 0; - let migratedFriendRequests = 0; - let migratedDmChannels = 0; - - // Step 1: Enrich winner with loser's non-null profile fields - const enrichUpdates: Record = {}; - if (loser.display_name && !winner.display_name) enrichUpdates.display_name = loser.display_name; - if (loser.avatar && !winner.avatar) enrichUpdates.avatar = loser.avatar; - if (loser.avatar_color && !winner.avatar_color) enrichUpdates.avatar_color = loser.avatar_color; - if (loser.banner && !winner.banner) enrichUpdates.banner = loser.banner; - if (loser.bio && !winner.bio) enrichUpdates.bio = loser.bio; - - // Prefer snowflake-format homeUserId (purely numeric) over username-format - const isNumeric = (s: string | null) => s !== null && /^\d+$/.test(s); - if (!winner.home_user_id && loser.home_user_id) { - enrichUpdates.home_user_id = loser.home_user_id; - } else if (winner.home_user_id && loser.home_user_id && - !isNumeric(winner.home_user_id) && isNumeric(loser.home_user_id)) { - enrichUpdates.home_user_id = loser.home_user_id; - } - - if (Object.keys(enrichUpdates).length > 0) { - const setClauses = Object.keys(enrichUpdates).map(k => `${k} = ?`).join(', '); - const values = [...Object.values(enrichUpdates), winner.id]; - db.prepare(`UPDATE users SET ${setClauses} WHERE id = ?`).run(...values); - } - - // Step 2: Re-point FK references — loser ID → winner ID - - // dm_members: check for conflicts (winner already in the same channel) - // When both have membership, keep winner's row but set closed = MIN (if either had it open, keep open) - const loserMemberships = db.prepare( - `SELECT dm_channel_id, closed FROM dm_members WHERE user_id = ?` - ).all(loser.id) as Array<{ dm_channel_id: string; closed: number }>; - - for (const m of loserMemberships) { - const winnerMembership = db.prepare( - `SELECT closed FROM dm_members WHERE dm_channel_id = ? AND user_id = ?` - ).get(m.dm_channel_id, winner.id) as { closed: number } | undefined; - if (winnerMembership) { - const mergedClosed = Math.min(winnerMembership.closed, m.closed); - if (mergedClosed !== winnerMembership.closed) { - db.prepare(`UPDATE dm_members SET closed = ? WHERE dm_channel_id = ? AND user_id = ?`).run(mergedClosed, m.dm_channel_id, winner.id); - } - db.prepare(`DELETE FROM dm_members WHERE dm_channel_id = ? AND user_id = ?`).run(m.dm_channel_id, loser.id); - } else { - db.prepare(`UPDATE dm_members SET user_id = ? WHERE dm_channel_id = ? AND user_id = ?`).run(winner.id, m.dm_channel_id, loser.id); - } - migratedDmMembers++; - } - - // dm_messages: no uniqueness constraint — safe to update all - const msgResult = db.prepare(`UPDATE dm_messages SET user_id = ? WHERE user_id = ?`).run(winner.id, loser.id); - migratedDmMessages = msgResult.changes; - - // dm_reactions: check for conflicts (winner already has same reaction on same message) - const loserReactions = db.prepare( - `SELECT id, dm_message_id, emoji FROM dm_reactions WHERE user_id = ?` - ).all(loser.id) as Array<{ id: string; dm_message_id: string; emoji: string }>; - - for (const r of loserReactions) { - const winnerAlreadyReacted = db.prepare( - `SELECT 1 FROM dm_reactions WHERE dm_message_id = ? AND user_id = ? AND emoji = ?` - ).get(r.dm_message_id, winner.id, r.emoji); - if (winnerAlreadyReacted) { - db.prepare(`DELETE FROM dm_reactions WHERE id = ?`).run(r.id); - } else { - db.prepare(`UPDATE dm_reactions SET user_id = ? WHERE id = ?`).run(winner.id, r.id); - } - migratedDmReactions++; - } - - // friends: check for conflicts - const loserFriendships = db.prepare( - `SELECT user_id, friend_id FROM friends WHERE user_id = ? OR friend_id = ?` - ).all(loser.id, loser.id) as Array<{ user_id: string; friend_id: string }>; - - for (const f of loserFriendships) { - const newUserId = f.user_id === loser.id ? winner.id : f.user_id; - const newFriendId = f.friend_id === loser.id ? winner.id : f.friend_id; - - // Skip self-friendships that would result from merge - if (newUserId === newFriendId) { - db.prepare(`DELETE FROM friends WHERE user_id = ? AND friend_id = ?`).run(f.user_id, f.friend_id); - migratedFriends++; - continue; - } - - const alreadyExists = db.prepare( - `SELECT 1 FROM friends WHERE (user_id = ? AND friend_id = ?) OR (user_id = ? AND friend_id = ?)` - ).get(newUserId, newFriendId, newFriendId, newUserId); - if (alreadyExists) { - db.prepare(`DELETE FROM friends WHERE user_id = ? AND friend_id = ?`).run(f.user_id, f.friend_id); - } else { - if (f.user_id === loser.id) { - db.prepare(`UPDATE friends SET user_id = ? WHERE user_id = ? AND friend_id = ?`).run(winner.id, loser.id, f.friend_id); - } else { - db.prepare(`UPDATE friends SET friend_id = ? WHERE user_id = ? AND friend_id = ?`).run(winner.id, f.user_id, loser.id); - } - } - migratedFriends++; - } - - // friend_requests: check for conflicts - const loserRequests = db.prepare( - `SELECT id, from_id, to_id FROM friend_requests WHERE from_id = ? OR to_id = ?` - ).all(loser.id, loser.id) as Array<{ id: string; from_id: string; to_id: string }>; - - for (const r of loserRequests) { - const newFromId = r.from_id === loser.id ? winner.id : r.from_id; - const newToId = r.to_id === loser.id ? winner.id : r.to_id; - - if (newFromId === newToId) { - db.prepare(`DELETE FROM friend_requests WHERE id = ?`).run(r.id); - migratedFriendRequests++; - continue; - } - - const alreadyExists = db.prepare( - `SELECT 1 FROM friend_requests WHERE from_id = ? AND to_id = ?` - ).get(newFromId, newToId); - if (alreadyExists) { - db.prepare(`DELETE FROM friend_requests WHERE id = ?`).run(r.id); - } else { - if (r.from_id === loser.id) { - db.prepare(`UPDATE friend_requests SET from_id = ? WHERE id = ?`).run(winner.id, r.id); - } - if (r.to_id === loser.id) { - db.prepare(`UPDATE friend_requests SET to_id = ? WHERE id = ?`).run(winner.id, r.id); - } - } - migratedFriendRequests++; - } - - // dm_channels: update owner_id - const ownerResult = db.prepare(`UPDATE dm_channels SET owner_id = ? WHERE owner_id = ?`).run(winner.id, loser.id); - migratedDmChannels = ownerResult.changes; - - // read_states: conflict handling — keep the row with more recent updated_at - let migratedReadStates = 0; - const loserReadStates = db.prepare( - `SELECT user_id, channel_id, last_read_message_id, updated_at FROM read_states WHERE user_id = ?` - ).all(loser.id) as Array<{ user_id: string; channel_id: string; last_read_message_id: string; updated_at: number }>; - - for (const rs of loserReadStates) { - const winnerRs = db.prepare( - `SELECT updated_at FROM read_states WHERE user_id = ? AND channel_id = ?` - ).get(winner.id, rs.channel_id) as { updated_at: number } | undefined; - if (winnerRs) { - if (rs.updated_at > winnerRs.updated_at) { - db.prepare(`UPDATE read_states SET last_read_message_id = ?, updated_at = ? WHERE user_id = ? AND channel_id = ?`) - .run(rs.last_read_message_id, rs.updated_at, winner.id, rs.channel_id); - } - db.prepare(`DELETE FROM read_states WHERE user_id = ? AND channel_id = ?`).run(loser.id, rs.channel_id); - } else { - db.prepare(`UPDATE read_states SET user_id = ? WHERE user_id = ? AND channel_id = ?`).run(winner.id, loser.id, rs.channel_id); - } - migratedReadStates++; - } - - // Step 4: Soft-delete loser - db.prepare(`UPDATE users SET is_deleted = 1 WHERE id = ?`).run(loser.id); - - console.log(`[migration] Merged duplicate user stubs for domain ${winner.home_instance}:`); - console.log(` Winner: ${winner.id} (${winner.username}) — kept`); - console.log(` Loser: ${loser.id} (${loser.username}) — soft-deleted`); - console.log(` Migrated: ${migratedDmMembers} dm_members, ${migratedDmMessages} dm_messages, ${migratedDmReactions} dm_reactions, ${migratedFriends} friends, ${migratedFriendRequests} friend_requests, ${migratedDmChannels} dm_channels, ${migratedReadStates} read_states`); - console.log(` Match reason: ${reason}`); - }); - - for (const pair of mergePairs) { - mergeOne(pair); - } - - if (mergePairs.length > 0) { - console.log(`[migration] Merged ${mergePairs.length} duplicate user stub pair(s).`); - } - - console.log('Migrations complete.'); -} - -/** Add FK constraint to dm_messages.reply_to_id (SQLite requires table recreation) */ -function migrateDmMessagesReplyToFk(db: Database.Database): void { - const tableInfo = db.prepare( - "SELECT sql FROM sqlite_master WHERE type='table' AND name='dm_messages'" - ).get() as { sql: string } | undefined; - - // Only migrate if reply_to_id exists but has no FK reference - if (!tableInfo) return; - if (!tableInfo.sql.includes('reply_to_id')) return; - if (tableInfo.sql.includes('REFERENCES dm_messages') || tableInfo.sql.includes('REFERENCES "dm_messages"')) return; - - console.log('Migrating: Adding FK constraint to dm_messages.reply_to_id...'); - - // Detect all current columns so we don't drop federation columns added before this migration runs - const columns = db.pragma('table_info(dm_messages)') as { name: string }[]; - const colNames = columns.map(c => c.name); - - // Build the new table with all existing columns, adding FK to reply_to_id - const colDefs: string[] = []; - for (const col of colNames) { - switch (col) { - case 'id': colDefs.push('id TEXT PRIMARY KEY'); break; - case 'dm_channel_id': colDefs.push('dm_channel_id TEXT NOT NULL REFERENCES dm_channels(id) ON DELETE CASCADE'); break; - case 'user_id': colDefs.push('user_id TEXT NOT NULL REFERENCES users(id)'); break; - case 'reply_to_id': colDefs.push('reply_to_id TEXT REFERENCES dm_messages_new(id) ON DELETE SET NULL'); break; - case 'content': colDefs.push('content TEXT'); break; - case 'edited_at': colDefs.push('edited_at INTEGER'); break; - case 'created_at': colDefs.push('created_at INTEGER NOT NULL'); break; - case 'source_instance': colDefs.push('source_instance TEXT'); break; - case 'source_message_id': colDefs.push('source_message_id TEXT'); break; - case 'encryption_version': colDefs.push('encryption_version INTEGER DEFAULT 0'); break; - default: colDefs.push(`${col} TEXT`); break; - } - } - - const colList = colNames.join(', '); - db.exec(` - CREATE TABLE dm_messages_new (${colDefs.join(', ')}); - INSERT INTO dm_messages_new SELECT ${colList} FROM dm_messages; - DROP TABLE dm_messages; - ALTER TABLE dm_messages_new RENAME TO dm_messages; - CREATE INDEX IF NOT EXISTS idx_dm_messages_dm_channel_id ON dm_messages(dm_channel_id); - CREATE INDEX IF NOT EXISTS idx_dm_messages_user_id ON dm_messages(user_id); - `); -} - -/** Ensure gif_api_key column exists in instance_settings, migrating from tenor_api_key if present */ -function migrateRenameGifApiKey(db: Database.Database): void { - const cols = db.pragma('table_info(instance_settings)') as { name: string }[]; - const hasTenor = cols.some(c => c.name === 'tenor_api_key'); - const hasGif = cols.some(c => c.name === 'gif_api_key'); - - if (hasTenor && !hasGif) { - // Clean case: rename the old column - console.log('Migrating: Renaming tenor_api_key → gif_api_key in instance_settings'); - db.exec('ALTER TABLE instance_settings RENAME COLUMN tenor_api_key TO gif_api_key'); - } else if (hasTenor && hasGif) { - // Race condition: column-add loop created empty gif_api_key before rename could run. - // Copy the real key from tenor_api_key if gif_api_key is still NULL/empty. - const row = db.prepare('SELECT tenor_api_key, gif_api_key FROM instance_settings WHERE id = 1').get() as - { tenor_api_key: string | null; gif_api_key: string | null } | undefined; - if (row && row.tenor_api_key && !row.gif_api_key) { - db.prepare('UPDATE instance_settings SET gif_api_key = ? WHERE id = 1').run(row.tenor_api_key); - console.log('Migrating: Copied API key from tenor_api_key → gif_api_key (fixing race condition)'); - } - } else if (!hasTenor && !hasGif) { - // Fresh install or never had Tenor — just add the column - console.log('Migrating: Adding gif_api_key column to instance_settings'); - db.exec('ALTER TABLE instance_settings ADD COLUMN gif_api_key TEXT'); - } - // !hasTenor && hasGif → already correct, no-op -} - -/** Add database indexes on FK columns to prevent full table scans */ -function migrateAddIndexes(db: Database.Database): void { - // Fast-path: skip if indexes already exist - const existing = db.prepare( - "SELECT name FROM sqlite_master WHERE type='index' AND name='idx_messages_channel_id'" +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +/** + * Baseline an existing install so Drizzle's migrate() skips the initial + * migration (tables already exist). Must be called BEFORE migrate(). + * + * Detects existing installs by checking: users table exists but + * __drizzle_migrations table does not. + * + * Drizzle's __drizzle_migrations table schema (verified SQLite DDL): + * "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL + * "hash" text NOT NULL -- SHA-256 hex of the migration SQL file (raw UTF-8) + * "created_at" numeric -- journalEntry.when (ms timestamp from journal) + * + * Hash must match Drizzle's exactly: read SQL file as raw UTF-8 string, + * hash it with SHA-256. Line endings matter — don't normalize \r\n vs \n. + */ +export function baselineExistingInstall(db: Database.Database): void { + const hasUsers = db.prepare( + "SELECT name FROM sqlite_master WHERE type='table' AND name='users'" + ).get(); + const hasJournal = db.prepare( + "SELECT name FROM sqlite_master WHERE type='table' AND name='__drizzle_migrations'" ).get(); - if (existing) return; - console.log('Migrating: Adding database indexes...'); + if (!hasUsers || hasJournal) return; // Fresh install or already baselined - const indexes = [ - // Hot paths: message listing, channel sidebar - 'CREATE INDEX IF NOT EXISTS idx_messages_channel_id ON messages(channel_id)', - 'CREATE INDEX IF NOT EXISTS idx_messages_user_id ON messages(user_id)', - 'CREATE INDEX IF NOT EXISTS idx_dm_messages_dm_channel_id ON dm_messages(dm_channel_id)', - 'CREATE INDEX IF NOT EXISTS idx_dm_messages_user_id ON dm_messages(user_id)', - 'CREATE INDEX IF NOT EXISTS idx_channels_space_id ON channels(space_id)', + console.log('[migrate] Existing install detected — baselining Drizzle migrations...'); - // Member lookups & permission checks - 'CREATE INDEX IF NOT EXISTS idx_space_members_user_id ON space_members(user_id)', - 'CREATE INDEX IF NOT EXISTS idx_member_roles_user_id_space_id ON member_roles(user_id, space_id)', - 'CREATE INDEX IF NOT EXISTS idx_roles_space_id ON roles(space_id)', - 'CREATE INDEX IF NOT EXISTS idx_channel_overrides_channel_id ON channel_overrides(channel_id)', - 'CREATE INDEX IF NOT EXISTS idx_dm_members_user_id ON dm_members(user_id)', + // Read the journal to get the initial migration metadata + const migrationsFolder = path.resolve(__dirname, '../../drizzle'); + const journalPath = path.join(migrationsFolder, 'meta', '_journal.json'); + const journal = JSON.parse(fs.readFileSync(journalPath, 'utf-8')); + const initialEntry = journal.entries[0]; - // Reactions - 'CREATE INDEX IF NOT EXISTS idx_reactions_message_id ON reactions(message_id)', - 'CREATE INDEX IF NOT EXISTS idx_dm_reactions_dm_message_id ON dm_reactions(dm_message_id)', - - // Attachments - 'CREATE INDEX IF NOT EXISTS idx_attachments_message_id ON attachments(message_id)', - 'CREATE INDEX IF NOT EXISTS idx_attachments_dm_message_id ON attachments(dm_message_id)', - - // Social - 'CREATE INDEX IF NOT EXISTS idx_friends_user_id ON friends(user_id)', - 'CREATE INDEX IF NOT EXISTS idx_friends_friend_id ON friends(friend_id)', - 'CREATE INDEX IF NOT EXISTS idx_friend_requests_to_id ON friend_requests(to_id)', - 'CREATE INDEX IF NOT EXISTS idx_friend_requests_from_id ON friend_requests(from_id)', - - // Moderation & discovery - 'CREATE INDEX IF NOT EXISTS idx_bans_space_id ON bans(space_id)', - 'CREATE INDEX IF NOT EXISTS idx_join_requests_space_id_status ON join_requests(space_id, status)', - 'CREATE INDEX IF NOT EXISTS idx_voice_restrictions_space_id ON voice_restrictions(space_id)', - - // Read states - 'CREATE INDEX IF NOT EXISTS idx_read_states_user_id ON read_states(user_id)', - - // Categories - 'CREATE INDEX IF NOT EXISTS idx_channel_categories_space_id ON channel_categories(space_id)', - - ]; - - db.exec(indexes.join(';\n')); -} - -/** Convert legacy JSON array permissions (e.g. '["VIEW_CHANNEL"]') to decimal strings */ -function migrateLegacyPermissions(db: Database.Database): void { - const roles = db.prepare('SELECT id, permissions FROM roles WHERE permissions IS NOT NULL').all() as { id: string; permissions: string }[]; - const update = db.prepare('UPDATE roles SET permissions = ? WHERE id = ?'); - - for (const role of roles) { - // Skip if already a valid decimal string - try { BigInt(role.permissions); continue; } catch {} - - // Try legacy JSON array - try { - const parsed = JSON.parse(role.permissions); - if (Array.isArray(parsed)) { - let result = 0n; - for (const key of parsed) { - const bit = PermissionBits[key as keyof typeof PermissionBits]; - if (bit !== undefined) result |= bit; - } - update.run(result.toString(), role.id); - console.log(`Migrating: Converted legacy permissions for role ${role.id}`); - continue; - } - } catch { /* not JSON either */ } - - // Unrecognized format — set to 0 - update.run('0', role.id); - console.log(`Migrating: Reset unrecognized permissions for role ${role.id}`); + if (!initialEntry) { + throw new Error('No entries found in drizzle migration journal'); } + + // Compute the hash the same way Drizzle does: SHA-256 of the SQL file content + const sqlPath = path.join(migrationsFolder, `${initialEntry.tag}.sql`); + const sqlContent = fs.readFileSync(sqlPath, 'utf-8'); + const hash = crypto.createHash('sha256').update(sqlContent).digest('hex'); + + // Create the journal table matching Drizzle's exact SQLite DDL + db.exec(` + CREATE TABLE IF NOT EXISTS "__drizzle_migrations" ( + "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, + "hash" text NOT NULL, + "created_at" numeric + ) + `); + + db.prepare( + 'INSERT INTO __drizzle_migrations (hash, created_at) VALUES (?, ?)' + ).run(hash, initialEntry.when); + + console.log(`[migrate] Baselined initial migration: ${initialEntry.tag} (hash: ${hash.slice(0, 12)}...)`); } -/** Ensure the single-row instance_settings row exists */ -function migrateInstanceSettings(db: Database.Database): void { +/** + * Ensure data invariants after schema migration. Idempotent — safe to run + * on every boot. Uses raw better-sqlite3 handle (not Drizzle ORM). + */ +export function ensureDefaults(db: Database.Database): void { + // 1. Ensure the single-row instance_settings row exists const row = db.prepare('SELECT id FROM instance_settings WHERE id = 1').get(); if (!row) { db.prepare( - 'INSERT OR IGNORE INTO instance_settings (id, max_bitrate_kbps, min_bitrate_kbps, bitrate_step_kbps, allowed_resolutions, allowed_framerates, max_resolution, max_framerate, updated_at) VALUES (1, 20000, 500, 500, ?, ?, 1080, 60, ?)' + `INSERT OR IGNORE INTO instance_settings + (id, max_bitrate_kbps, min_bitrate_kbps, bitrate_step_kbps, + allowed_resolutions, allowed_framerates, max_resolution, max_framerate, updated_at) + VALUES (1, 20000, 500, 500, ?, ?, 1080, 60, ?)` ).run('540,720,1080', '30,45,60', Date.now()); - console.log('Migrating: Inserted default instance_settings row'); + console.log('[defaults] Inserted default instance_settings row'); } -} -/** Ensure at least one user has is_admin = 1 (the earliest registered user) */ -function migrateFirstAdmin(db: Database.Database): void { + // 2. Ensure a unique Snowflake worker ID is persisted (0-1023) + const settings = db.prepare('SELECT worker_id FROM instance_settings WHERE id = 1').get() as + { worker_id: number | null } | undefined; + if (!settings || settings.worker_id === null) { + const workerId = crypto.randomInt(0, 1024); + db.prepare('UPDATE instance_settings SET worker_id = ? WHERE id = 1').run(workerId); + console.log(`[defaults] Generated Snowflake worker ID: ${workerId}`); + } + + // 3. Ensure at least one admin exists (promote earliest registered user) const anyAdmin = db.prepare('SELECT id FROM users WHERE is_admin = 1 LIMIT 1').get(); if (!anyAdmin) { - const firstUser = db.prepare('SELECT id FROM users ORDER BY created_at ASC LIMIT 1').get() as { id: string } | undefined; + const firstUser = db.prepare( + 'SELECT id FROM users ORDER BY created_at ASC LIMIT 1' + ).get() as { id: string } | undefined; if (firstUser) { db.prepare('UPDATE users SET is_admin = 1 WHERE id = ?').run(firstUser.id); - console.log(`Migrating: Set first user ${firstUser.id} as instance admin`); + console.log(`[defaults] Promoted first user ${firstUser.id} to admin`); } } } - -/** - * Ensure a unique Snowflake worker ID is persisted for this instance. - * Generated randomly on first boot (0-1023) and never changed. - * This prevents ID collisions between federated instances that would - * otherwise share worker_id = 1 when running as Docker PID 1. - */ -function migrateWorkerId(db: Database.Database): void { - const row = db.prepare('SELECT worker_id FROM instance_settings WHERE id = 1').get() as { worker_id: number | null } | undefined; - if (!row || row.worker_id === null) { - const workerId = crypto.randomInt(0, 1024); // 0-1023 (10-bit range) - db.prepare('UPDATE instance_settings SET worker_id = ? WHERE id = 1').run(workerId); - console.log(`Migrating: Generated Snowflake worker ID ${workerId} for this instance`); - } -} - -/** For each space, ensure an @everyone role exists with id === space.id */ -function migrateEveryoneRoles(db: Database.Database): void { - const spaces = db.prepare('SELECT id FROM spaces').all() as { id: string }[]; - const now = Date.now(); - const defaultPerms = permissionsToString(DEFAULT_EVERYONE_PERMISSIONS); - const adminPerms = permissionsToString(ALL_PERMISSIONS); - - const insertRole = db.prepare( - 'INSERT OR IGNORE INTO roles (id, space_id, name, color, position, permissions, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)' - ); - - for (const space of spaces) { - // Create @everyone role if it doesn't exist (id = space.id) - insertRole.run(space.id, space.id, '@everyone', '#b9bbbe', 0, defaultPerms, now); - } - - // Migrate existing admin members: ensure an Admin role exists and assign it - // Only run if the old `role` column still exists on space_members - const smColumns = db.pragma('table_info(space_members)') as { name: string }[]; - const hasRoleColumn = smColumns.some(c => c.name === 'role'); - const adminMembers = hasRoleColumn - ? db.prepare("SELECT space_id, user_id FROM space_members WHERE role = 'admin'").all() as { space_id: string; user_id: string }[] - : []; - - if (adminMembers.length > 0) { - // Group by space - const spaceAdmins = new Map(); - for (const row of adminMembers) { - let arr = spaceAdmins.get(row.space_id); - if (!arr) { arr = []; spaceAdmins.set(row.space_id, arr); } - arr.push(row.user_id); - } - - const checkAdminRole = db.prepare( - "SELECT id FROM roles WHERE space_id = ? AND name = 'Admin' AND permissions = ?" - ); - const insertMemberRole = db.prepare( - 'INSERT OR IGNORE INTO member_roles (space_id, user_id, role_id) VALUES (?, ?, ?)' - ); - - for (const [spaceId, userIds] of spaceAdmins) { - // Find or create Admin role for this space - let adminRole = checkAdminRole.get(spaceId, adminPerms) as { id: string } | undefined; - if (!adminRole) { - // Generate a simple unique ID for the admin role - const adminRoleId = `${spaceId}-admin`; - insertRole.run(adminRoleId, spaceId, 'Admin', '#e74c3c', 1, adminPerms, now); - adminRole = { id: adminRoleId }; - } - - for (const userId of userIds) { - insertMemberRole.run(spaceId, userId, adminRole.id); - } - } - } -} - -/** - * Remove the USE_VOICE_ACTIVITY bit (was bit 25) and shift STREAM (26→25) - * and DISCONNECT_MEMBERS (27→26) down. - * - * Gated behind a persistent `voice_bit_migrated` flag in instance_settings - * because the old and new bit positions overlap (STREAM moved into the same - * bit 25 that USE_VOICE_ACTIVITY occupied), making bit-inspection unreliable - * as an idempotency check. The previous version of this function had exactly - * that bug — it re-ran on every startup and silently stripped STREAM and - * DISCONNECT_MEMBERS from every role. - * - * On first run with the flag: repairs @everyone roles by re-adding STREAM, - * then sets the flag so it never runs again. - */ -function migrateRemoveVoiceActivityBit(db: Database.Database): void { - // Ensure the flag column exists - const cols = db.pragma('table_info(instance_settings)') as { name: string }[]; - if (!cols.some(c => c.name === 'voice_bit_migrated')) { - db.exec('ALTER TABLE instance_settings ADD COLUMN voice_bit_migrated INTEGER DEFAULT 0'); - } - - // Check if already migrated - const row = db.prepare('SELECT voice_bit_migrated FROM instance_settings WHERE id = 1').get() as - { voice_bit_migrated: number } | undefined; - if (row && row.voice_bit_migrated === 1) return; - - // The bit-shifting migration already ran (possibly many times) via the old - // broken code. All roles are already on the new layout (STREAM=25, - // DISCONNECT_MEMBERS=26). The damage is that repeated re-runs wiped those - // bits. Repair what we can: - - const STREAM_BIT = 1n << 25n; - const updateRole = db.prepare('UPDATE roles SET permissions = ? WHERE id = ?'); - - // Repair @everyone roles: re-add STREAM where it's missing. - // @everyone role id === space id, so join on that. - const spaces = db.prepare('SELECT id FROM spaces').all() as { id: string }[]; - for (const space of spaces) { - const role = db.prepare('SELECT id, permissions FROM roles WHERE id = ?').get(space.id) as - { id: string; permissions: string } | undefined; - if (!role?.permissions) continue; - try { - const perms = BigInt(role.permissions); - if ((perms & STREAM_BIT) === 0n) { - updateRole.run((perms | STREAM_BIT).toString(), role.id); - console.log(`Repair: Re-added STREAM to @everyone role for space ${space.id}`); - } - } catch { /* skip invalid */ } - } - - // For non-@everyone roles, warn about potentially lost bits so admins can - // manually re-enable STREAM / DISCONNECT_MEMBERS if needed. - const customRoles = db.prepare( - 'SELECT id, space_id, name, permissions FROM roles WHERE id NOT IN (SELECT id FROM spaces) AND permissions IS NOT NULL' - ).all() as { id: string; space_id: string; name: string; permissions: string }[]; - - let warnCount = 0; - for (const role of customRoles) { - try { - const perms = BigInt(role.permissions); - if ((perms & STREAM_BIT) === 0n) { - warnCount++; - } - } catch { /* skip invalid */ } - } - if (warnCount > 0) { - console.log( - `Repair: ${warnCount} custom role(s) may be missing STREAM/DISCONNECT_MEMBERS permissions ` + - `due to a previous migration bug. Admins can re-enable these in Space Settings → Roles.` - ); - } - - // Set flag so this never runs again - db.prepare('UPDATE instance_settings SET voice_bit_migrated = 1 WHERE id = 1').run(); - console.log('Migrating: Voice permission bit migration flagged as complete.'); -} - -/** Delete corrupted read_states rows where last_read_message_id is not a valid snowflake (numeric string) */ -function migrateCorruptedReadStates(db: Database.Database): void { - const deleted = db.prepare( - "DELETE FROM read_states WHERE last_read_message_id NOT GLOB '[0-9]*' OR last_read_message_id GLOB '*[^0-9]*'" - ).run(); - if (deleted.changes > 0) { - console.log(`Migrating: Cleaned up ${deleted.changes} corrupted read_states rows`); - } -} - -/** Rename already-tombstoned users so their original username can be reused */ -function migrateDeletedUsernames(db: Database.Database): void { - const rows = db.prepare( - "SELECT id, username FROM users WHERE is_deleted = 1 AND username NOT LIKE '!deleted:%'" - ).all() as { id: string; username: string }[]; - - if (rows.length === 0) return; - - const update = db.prepare('UPDATE users SET username = ? WHERE id = ?'); - for (const row of rows) { - update.run(`!deleted:${row.id}`, row.id); - console.log(`Migrating: Freed username "${row.username}" from deleted user ${row.id}`); - } -} - -/** - * Fix DDL for bans and voice_restrictions tables: make banned_by and moderator_id nullable. - * The original CREATE TABLE statements used NOT NULL, but these columns must be nullable - * to handle cases where the moderator account is later deleted. - */ -function migrateNullableModeratorColumns(db: Database.Database): void { - // Fix bans.banned_by: NOT NULL → nullable - { - const tableInfo = db.prepare( - "SELECT sql FROM sqlite_master WHERE type='table' AND name='bans'" - ).get() as { sql: string } | undefined; - - if (tableInfo && tableInfo.sql.includes('banned_by TEXT NOT NULL')) { - console.log('Migrating: Making bans.banned_by nullable...'); - db.exec(` - CREATE TABLE bans_new ( - space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE, - user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - reason TEXT, - banned_by TEXT REFERENCES users(id), - created_at INTEGER NOT NULL, - PRIMARY KEY (space_id, user_id) - ); - INSERT INTO bans_new SELECT space_id, user_id, reason, banned_by, created_at FROM bans; - DROP TABLE bans; - ALTER TABLE bans_new RENAME TO bans; - CREATE INDEX IF NOT EXISTS idx_bans_space_id ON bans(space_id); - `); - } - } - - // Fix voice_restrictions.moderator_id: NOT NULL → nullable - { - const tableInfo = db.prepare( - "SELECT sql FROM sqlite_master WHERE type='table' AND name='voice_restrictions'" - ).get() as { sql: string } | undefined; - - if (tableInfo && tableInfo.sql.includes('moderator_id TEXT NOT NULL')) { - console.log('Migrating: Making voice_restrictions.moderator_id nullable...'); - db.exec(` - CREATE TABLE voice_restrictions_new ( - space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE, - user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - restriction_type TEXT NOT NULL, - moderator_id TEXT REFERENCES users(id), - created_at INTEGER NOT NULL, - PRIMARY KEY (space_id, user_id, restriction_type) - ); - INSERT INTO voice_restrictions_new SELECT space_id, user_id, restriction_type, moderator_id, created_at FROM voice_restrictions; - DROP TABLE voice_restrictions; - ALTER TABLE voice_restrictions_new RENAME TO voice_restrictions; - CREATE INDEX IF NOT EXISTS idx_voice_restrictions_space_id ON voice_restrictions(space_id); - `); - } - } -} - -/** - * Clean up orphaned data left behind by user deletions and channel removals: - * 1. DM channels with zero members - * 2. DM attachments/reactions referencing non-existent dm_messages - * 3. Read states referencing non-existent channels - * 4. Stale moderator references (bans.banned_by, voice_restrictions.moderator_id, join_requests.decided_by) - */ -function migrateOrphanedData(db: Database.Database): void { - // 1. Delete DM channels with zero members (cascade cleans dm_messages) - const orphanedDms = db.prepare(` - SELECT dc.id FROM dm_channels dc - WHERE NOT EXISTS (SELECT 1 FROM dm_members dm WHERE dm.dm_channel_id = dc.id) - `).all() as { id: string }[]; - - if (orphanedDms.length > 0) { - const deleteAttachments = db.prepare( - 'DELETE FROM attachments WHERE dm_message_id IN (SELECT id FROM dm_messages WHERE dm_channel_id = ?)' - ); - const deleteReactions = db.prepare( - 'DELETE FROM dm_reactions WHERE dm_message_id IN (SELECT id FROM dm_messages WHERE dm_channel_id = ?)' - ); - const deleteDmChannel = db.prepare('DELETE FROM dm_channels WHERE id = ?'); - - for (const { id } of orphanedDms) { - deleteAttachments.run(id); - deleteReactions.run(id); - deleteDmChannel.run(id); - } - console.log(`Migrating: Cleaned up ${orphanedDms.length} orphaned DM channels`); - } - - // 2. Delete orphaned DM attachments referencing non-existent dm_messages - const orphanedAtts = db.prepare(` - DELETE FROM attachments - WHERE dm_message_id IS NOT NULL - AND NOT EXISTS (SELECT 1 FROM dm_messages WHERE dm_messages.id = attachments.dm_message_id) - `).run(); - if (orphanedAtts.changes > 0) { - console.log(`Migrating: Cleaned up ${orphanedAtts.changes} orphaned DM attachments`); - } - - // 3. Delete orphaned DM reactions referencing non-existent dm_messages - const orphanedReactions = db.prepare(` - DELETE FROM dm_reactions - WHERE NOT EXISTS (SELECT 1 FROM dm_messages WHERE dm_messages.id = dm_reactions.dm_message_id) - `).run(); - if (orphanedReactions.changes > 0) { - console.log(`Migrating: Cleaned up ${orphanedReactions.changes} orphaned DM reactions`); - } - - // 4. Delete orphaned read_states referencing non-existent channels (or DM channels) - const orphanedReadStates = db.prepare(` - DELETE FROM read_states - WHERE NOT EXISTS (SELECT 1 FROM channels WHERE channels.id = read_states.channel_id) - AND NOT EXISTS (SELECT 1 FROM dm_channels WHERE dm_channels.id = read_states.channel_id) - `).run(); - if (orphanedReadStates.changes > 0) { - console.log(`Migrating: Cleaned up ${orphanedReadStates.changes} orphaned read_states`); - } - - // 5. Nullify stale moderator references pointing to deleted users - try { - const staleBans = db.prepare(` - UPDATE bans SET banned_by = NULL - WHERE banned_by IS NOT NULL - AND NOT EXISTS (SELECT 1 FROM users WHERE users.id = bans.banned_by AND users.is_deleted = 0) - `).run(); - if (staleBans.changes > 0) { - console.log(`Migrating: Nullified ${staleBans.changes} stale bans.banned_by references`); - } - } catch { /* bans table may not exist yet */ } - - try { - const staleVoice = db.prepare(` - UPDATE voice_restrictions SET moderator_id = NULL - WHERE moderator_id IS NOT NULL - AND NOT EXISTS (SELECT 1 FROM users WHERE users.id = voice_restrictions.moderator_id AND users.is_deleted = 0) - `).run(); - if (staleVoice.changes > 0) { - console.log(`Migrating: Nullified ${staleVoice.changes} stale voice_restrictions.moderator_id references`); - } - } catch { /* voice_restrictions table may not exist yet */ } - - try { - const staleJoinReqs = db.prepare(` - UPDATE join_requests SET decided_by = NULL - WHERE decided_by IS NOT NULL - AND NOT EXISTS (SELECT 1 FROM users WHERE users.id = join_requests.decided_by AND users.is_deleted = 0) - `).run(); - if (staleJoinReqs.changes > 0) { - console.log(`Migrating: Nullified ${staleJoinReqs.changes} stale join_requests.decided_by references`); - } - } catch { /* join_requests table may not exist yet */ } -} - -/** - * Rename non-namespaced replicated users: e.g. "test" → "test@nova.ddns.net" - * Frees plain usernames for native user creation and makes all federated users - * visually consistent. Safe because JWTs validate by userId, not username. - */ -/** - * Lowercase all existing native usernames. Skips federated users (contain @) - * and tombstoned users (!deleted: prefix). If lowercasing would cause a collision, - * skip that user to avoid data loss. - */ -function migrateLowercaseUsernames(db: Database.Database): void { - const rows = db.prepare( - "SELECT id, username FROM users WHERE username NOT LIKE '%@%' AND username NOT LIKE '!deleted:%'" - ).all() as { id: string; username: string }[]; - - const needsUpdate = rows.filter(r => r.username !== r.username.toLowerCase()); - if (needsUpdate.length === 0) return; - - const checkExisting = db.prepare('SELECT id FROM users WHERE username = ?'); - const update = db.prepare('UPDATE users SET username = ? WHERE id = ?'); - - for (const row of needsUpdate) { - const lower = row.username.toLowerCase(); - // Check for collision (another user already has the lowercase version) - const existing = checkExisting.get(lower) as { id: string } | undefined; - if (existing && existing.id !== row.id) { - console.log(`Migrating: Skipping lowercase of "${row.username}" — "${lower}" already taken by user ${existing.id}`); - continue; - } - update.run(lower, row.id); - console.log(`Migrating: Lowercased username "${row.username}" → "${lower}"`); - } -} - -/** Convert any existing video channels to voice (video type removed — voice channels have full video capability) */ -function migrateVideoChannels(db: Database.Database): void { - const result = db.prepare("UPDATE channels SET type = 'voice' WHERE type = 'video'").run(); - if (result.changes > 0) { - console.log(`Migrating: Converted ${result.changes} video channel(s) to voice`); - } -} - -/** Backfill profile_updated_at from created_at for existing users */ -function migrateProfileUpdatedAt(db: Database.Database): void { - const result = db.prepare( - 'UPDATE users SET profile_updated_at = created_at WHERE profile_updated_at IS NULL' - ).run(); - if (result.changes > 0) { - console.log(`Migrating: Backfilled profile_updated_at for ${result.changes} user(s)`); - } -} - -function migrateReplicatedUsernames(db: Database.Database): void { - const rows = db.prepare( - "SELECT id, username, home_instance FROM users WHERE home_instance IS NOT NULL AND username NOT LIKE '%@%'" - ).all() as { id: string; username: string; home_instance: string }[]; - - if (rows.length === 0) return; - - const update = db.prepare('UPDATE users SET username = ? WHERE id = ?'); - for (const row of rows) { - const newUsername = `${row.username}@${row.home_instance}`; - update.run(newUsername, row.id); - console.log(`Migrating: Renamed replicated user "${row.username}" → "${newUsername}"`); - } -} - -/** - * Clean up stale attachment records left behind by profile image uploads. - * Profile images (avatars, banners, space icons) go through POST /api/uploads - * but are referenced by users/spaces columns, not by attachments.message_id. - * This leaves orphaned attachment records that inflate the "Unlinked Uploads" - * count in the storage panel. - * - * Gated by a persistent flag so it runs exactly once. - */ -function migrateCleanupProfileAttachmentRecords(db: Database.Database): void { - const cols = db.pragma('table_info(instance_settings)') as { name: string }[]; - if (!cols.some(c => c.name === 'profile_attachments_cleaned')) { - db.exec('ALTER TABLE instance_settings ADD COLUMN profile_attachments_cleaned INTEGER DEFAULT 0'); - } - - const row = db.prepare('SELECT profile_attachments_cleaned FROM instance_settings WHERE id = 1').get() as - { profile_attachments_cleaned: number } | undefined; - if (row && row.profile_attachments_cleaned === 1) return; - - // Collect all filenames currently referenced by profiles - const profileFilenames = new Set(); - - const avatarRows = db.prepare('SELECT avatar FROM users WHERE avatar IS NOT NULL').all() as { avatar: string }[]; - for (const r of avatarRows) profileFilenames.add(path.basename(r.avatar)); - - const bannerRows = db.prepare('SELECT banner FROM users WHERE banner IS NOT NULL').all() as { banner: string }[]; - for (const r of bannerRows) profileFilenames.add(path.basename(r.banner)); - - const iconRows = db.prepare('SELECT icon FROM spaces WHERE icon IS NOT NULL').all() as { icon: string }[]; - for (const r of iconRows) profileFilenames.add(path.basename(r.icon)); - - const spaceBannerRows = db.prepare('SELECT banner FROM spaces WHERE banner IS NOT NULL').all() as { banner: string }[]; - for (const r of spaceBannerRows) profileFilenames.add(path.basename(r.banner)); - - // Find unlinked attachment records (no message reference) - const unlinkedRows = db.prepare( - 'SELECT id, filename FROM attachments WHERE message_id IS NULL AND dm_message_id IS NULL' - ).all() as { id: string; filename: string }[]; - - const deleteStmt = db.prepare('DELETE FROM attachments WHERE id = ?'); - let cleaned = 0; - - for (const att of unlinkedRows) { - const basename = path.basename(att.filename); - // Delete if the file is a current profile image (record is unnecessary) - // or if the file no longer exists on disk (stale record from a replaced profile image) - if (profileFilenames.has(basename)) { - deleteStmt.run(att.id); - cleaned++; - } else { - // Check if the file still exists on disk — if not, this is a stale - // record from a previously-replaced profile image whose file was - // already deleted by the PATCH handler - try { - const uploadDir = process.env.UPLOAD_DIR || path.join(process.cwd(), 'data', 'uploads'); - const filePath = path.join(uploadDir, basename); - if (!fs.existsSync(filePath)) { - deleteStmt.run(att.id); - cleaned++; - } - } catch { - // Skip on error — the normal cleanup can handle it later - } - } - } - - if (cleaned > 0) { - console.log(`Migrating: Cleaned up ${cleaned} stale profile image attachment record(s)`); - } - - db.prepare('UPDATE instance_settings SET profile_attachments_cleaned = 1 WHERE id = 1').run(); -} - -function migrateCategoryOverrides(db: Database.Database): void { - const exists = db.prepare( - "SELECT name FROM sqlite_master WHERE type='table' AND name='category_overrides'" - ).get(); - if (exists) return; - - console.log('Migrating: Adding category_overrides table...'); - db.exec(` - CREATE TABLE IF NOT EXISTS category_overrides ( - category_id TEXT NOT NULL REFERENCES channel_categories(id) ON DELETE CASCADE, - target_type TEXT NOT NULL, - target_id TEXT NOT NULL, - allow TEXT NOT NULL DEFAULT '0', - deny TEXT NOT NULL DEFAULT '0', - PRIMARY KEY (category_id, target_type, target_id) - ); - CREATE INDEX IF NOT EXISTS idx_category_overrides_category_id ON category_overrides(category_id); - `); -} - -/** Add FK constraint to attachments.dm_message_id (SQLite requires table recreation) */ -function migrateAttachmentsDmMessageFk(db: Database.Database): void { - const tableInfo = db.prepare( - "SELECT sql FROM sqlite_master WHERE type='table' AND name='attachments'" - ).get() as { sql: string } | undefined; - - // Only migrate if dm_message_id exists but has no FK reference - if (!tableInfo) return; - if (!tableInfo.sql.includes('dm_message_id')) return; - if (tableInfo.sql.includes('REFERENCES dm_messages') || tableInfo.sql.includes('REFERENCES "dm_messages"')) return; - - console.log('Migrating: Adding FK constraint to attachments.dm_message_id...'); - - // Detect all current columns so we don't drop federation columns added before this migration runs - const columns = db.pragma('table_info(attachments)') as { name: string }[]; - const colNames = columns.map(c => c.name); - - const colDefs: string[] = []; - for (const col of colNames) { - switch (col) { - case 'id': colDefs.push('id TEXT PRIMARY KEY'); break; - case 'message_id': colDefs.push('message_id TEXT REFERENCES messages(id) ON DELETE CASCADE'); break; - case 'dm_message_id': colDefs.push('dm_message_id TEXT REFERENCES dm_messages(id) ON DELETE CASCADE'); break; - case 'uploader_id': colDefs.push('uploader_id TEXT'); break; - case 'filename': colDefs.push('filename TEXT NOT NULL'); break; - case 'original_name': colDefs.push('original_name TEXT NOT NULL'); break; - case 'mimetype': colDefs.push('mimetype TEXT NOT NULL'); break; - case 'size': colDefs.push('size INTEGER NOT NULL'); break; - case 'thumbnail_filename': colDefs.push('thumbnail_filename TEXT'); break; - case 'width': colDefs.push('width INTEGER'); break; - case 'height': colDefs.push('height INTEGER'); break; - case 'duration': colDefs.push('duration REAL'); break; - case 'source_url': colDefs.push('source_url TEXT'); break; - case 'created_at': colDefs.push('created_at INTEGER NOT NULL'); break; - default: colDefs.push(`${col} TEXT`); break; - } - } - - const colList = colNames.join(', '); - db.exec(` - CREATE TABLE attachments_new (${colDefs.join(', ')}); - INSERT INTO attachments_new - SELECT ${colList} - FROM attachments - WHERE dm_message_id IS NULL - OR dm_message_id IN (SELECT id FROM dm_messages); - DROP TABLE attachments; - ALTER TABLE attachments_new RENAME TO attachments; - CREATE INDEX IF NOT EXISTS idx_attachments_message_id ON attachments(message_id); - CREATE INDEX IF NOT EXISTS idx_attachments_dm_message_id ON attachments(dm_message_id); - `); -} - -/** - * Async backfill: generate thumbnails for all existing image attachments that - * don't have one yet. Runs once after server startup, gated by a persistent - * flag in instance_settings so it never re-runs. - * - * Call this AFTER the server is listening — it's fire-and-forget and doesn't - * block startup. - */ -export async function backfillThumbnails(db: Database.Database, uploadDir: string): Promise { - // Ensure the flag column exists - const cols = db.pragma('table_info(instance_settings)') as { name: string }[]; - if (!cols.some(c => c.name === 'thumbnails_backfilled')) { - db.exec('ALTER TABLE instance_settings ADD COLUMN thumbnails_backfilled INTEGER DEFAULT 0'); - } - - const row = db.prepare('SELECT thumbnails_backfilled FROM instance_settings WHERE id = 1').get() as - { thumbnails_backfilled: number } | undefined; - if (row && row.thumbnails_backfilled === 1) return; - - // Find all image attachments without a thumbnail - const rows = db.prepare( - "SELECT id, filename, mimetype FROM attachments WHERE thumbnail_filename IS NULL" - ).all() as { id: string; filename: string; mimetype: string }[]; - - const candidates = rows.filter(r => isResizableImage(r.mimetype)); - if (candidates.length === 0) { - db.prepare('UPDATE instance_settings SET thumbnails_backfilled = 1 WHERE id = 1').run(); - return; - } - - console.log(`Backfill: Generating thumbnails for ${candidates.length} existing image(s)...`); - - const update = db.prepare('UPDATE attachments SET thumbnail_filename = ? WHERE id = ?'); - let generated = 0; - let skipped = 0; - - for (const att of candidates) { - const originalPath = path.join(uploadDir, path.basename(att.filename)); - if (!fs.existsSync(originalPath)) { - skipped++; - continue; - } - - const thumbName = await generateThumbnail(originalPath, att.mimetype, uploadDir); - if (thumbName) { - update.run(thumbName, att.id); - generated++; - } else { - skipped++; - } - } - - console.log(`Backfill: Generated ${generated} thumbnail(s), skipped ${skipped} (small or missing)`); - db.prepare('UPDATE instance_settings SET thumbnails_backfilled = 1 WHERE id = 1').run(); -} - -/** - * Async backfill: extract dimensions and generate thumbnails for existing - * video and image attachments that don't have width/height yet. - * Runs once after server startup, gated by a persistent flag. - */ -export async function backfillMediaDimensions(db: Database.Database, uploadDir: string): Promise { - // Ensure the flag column exists - const cols = db.pragma('table_info(instance_settings)') as { name: string }[]; - if (!cols.some(c => c.name === 'media_dimensions_backfilled')) { - db.exec('ALTER TABLE instance_settings ADD COLUMN media_dimensions_backfilled INTEGER DEFAULT 0'); - } - - const row = db.prepare('SELECT media_dimensions_backfilled FROM instance_settings WHERE id = 1').get() as - { media_dimensions_backfilled: number } | undefined; - if (row && row.media_dimensions_backfilled === 1) return; - - // Find all image/video attachments without dimensions - const rows = db.prepare( - "SELECT id, filename, mimetype FROM attachments WHERE width IS NULL AND (mimetype LIKE 'video/%' OR mimetype LIKE 'image/%')" - ).all() as { id: string; filename: string; mimetype: string }[]; - - if (rows.length === 0) { - db.prepare('UPDATE instance_settings SET media_dimensions_backfilled = 1 WHERE id = 1').run(); - return; - } - - console.log(`Backfill: Extracting dimensions for ${rows.length} existing media attachment(s)...`); - - const update = db.prepare( - 'UPDATE attachments SET width = ?, height = ?, duration = ?, thumbnail_filename = COALESCE(?, thumbnail_filename) WHERE id = ?' - ); - let processed = 0; - let skipped = 0; - - for (const att of rows) { - const originalPath = path.join(uploadDir, path.basename(att.filename)); - if (!fs.existsSync(originalPath)) { - skipped++; - continue; - } - - try { - if (att.mimetype.startsWith('video/')) { - // Video: thumbnail + dimensions + duration - const videoThumb = await generateVideoThumbnail(originalPath, uploadDir); - const meta = await probeMediaMeta(originalPath, att.mimetype); - - const width = videoThumb?.width ?? meta?.width ?? null; - const height = videoThumb?.height ?? meta?.height ?? null; - const duration = meta?.duration ?? null; - const thumbName = videoThumb?.thumbnailFilename ?? null; - - update.run(width, height, duration, thumbName, att.id); - processed++; - } else { - // Image: dimensions only - const dims = await probeImageDimensions(originalPath); - if (dims) { - update.run(dims.width, dims.height, null, null, att.id); - processed++; - } else { - skipped++; - } - } - } catch (err) { - console.error(`Backfill: Failed to process ${att.filename} (non-fatal):`, err); - skipped++; - } - } - - console.log(`Backfill: Processed ${processed} media attachment(s), skipped ${skipped}`); - db.prepare('UPDATE instance_settings SET media_dimensions_backfilled = 1 WHERE id = 1').run(); -} - -/** - * Rename canonical_pair_id → federated_id in dm_channels and add the new - * group DM federation columns (owner_home_user_id, owner_home_instance, deleted_at). - * - * Handles three upgrade paths: - * 1. Existing install with canonical_pair_id column → full table rebuild to rename + add columns - * 2. Existing install without canonical_pair_id but missing new columns → ALTER TABLE adds them - * 3. Fresh install → DDL in index.ts already has the correct schema; this is a no-op - */ -function migrateDmChannelsFederatedId(db: Database.Database): void { - const cols = db.prepare(`PRAGMA table_info(dm_channels)`).all() as Array<{ name: string }>; - const hasOldCol = cols.some(c => c.name === 'canonical_pair_id'); - const hasNewCol = cols.some(c => c.name === 'federated_id'); - - if (!hasOldCol && hasNewCol) { - // Already migrated — ensure auxiliary columns exist (handles partial migration states) - const colNames = new Set(cols.map(c => c.name)); - if (!colNames.has('owner_home_user_id')) { - db.exec(`ALTER TABLE dm_channels ADD COLUMN owner_home_user_id TEXT`); - } - if (!colNames.has('owner_home_instance')) { - db.exec(`ALTER TABLE dm_channels ADD COLUMN owner_home_instance TEXT`); - } - if (!colNames.has('deleted_at')) { - db.exec(`ALTER TABLE dm_channels ADD COLUMN deleted_at INTEGER`); - } - // Rebuild index in case it was dropped - db.exec(`DROP INDEX IF EXISTS idx_dm_canonical_pair`); - db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_dm_federated ON dm_channels(federated_id) WHERE federated_id IS NOT NULL`); - return; - } - - if (hasOldCol && !hasNewCol) { - // Full table rebuild to rename column — disable FK enforcement during DROP - console.log('Migrating: Renaming canonical_pair_id → federated_id in dm_channels and adding group DM columns...'); - db.exec(`PRAGMA foreign_keys = OFF`); - try { - db.transaction(() => { - db.exec(` - CREATE TABLE dm_channels_new ( - id TEXT PRIMARY KEY, - owner_id TEXT, - federated_id TEXT, - owner_home_user_id TEXT, - owner_home_instance TEXT, - deleted_at INTEGER, - created_at INTEGER NOT NULL - ); - INSERT INTO dm_channels_new (id, owner_id, federated_id, created_at) - SELECT id, owner_id, canonical_pair_id, created_at FROM dm_channels; - DROP TABLE dm_channels; - ALTER TABLE dm_channels_new RENAME TO dm_channels; - `); - })(); - } finally { - db.exec(`PRAGMA foreign_keys = ON`); - } - db.exec(`DROP INDEX IF EXISTS idx_dm_canonical_pair`); - db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_dm_federated ON dm_channels(federated_id) WHERE federated_id IS NOT NULL`); - console.log('Migrating: dm_channels rename complete.'); - } else if (!hasOldCol && !hasNewCol) { - // Neither column exists — this is either a very old install or an odd state. - // Just add all the new columns via ALTER TABLE. - db.exec(`ALTER TABLE dm_channels ADD COLUMN federated_id TEXT`); - db.exec(`ALTER TABLE dm_channels ADD COLUMN owner_home_user_id TEXT`); - db.exec(`ALTER TABLE dm_channels ADD COLUMN owner_home_instance TEXT`); - db.exec(`ALTER TABLE dm_channels ADD COLUMN deleted_at INTEGER`); - db.exec(`DROP INDEX IF EXISTS idx_dm_canonical_pair`); - db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_dm_federated ON dm_channels(federated_id) WHERE federated_id IS NOT NULL`); - } - // hasOldCol && hasNewCol — both columns exist (shouldn't happen, but safe to skip) - - // Backfill owner_home_user_id / owner_home_instance from existing group DMs - try { - const groupDms = db.prepare(` - SELECT dc.id, dc.owner_id, u.home_user_id, u.home_instance - FROM dm_channels dc - JOIN users u ON dc.owner_id = u.id - WHERE dc.owner_id IS NOT NULL - AND dc.owner_home_user_id IS NULL - `).all() as Array<{ - id: string; - owner_id: string; - home_user_id: string | null; - home_instance: string | null; - }>; - - if (groupDms.length > 0) { - const updateStmt = db.prepare(` - UPDATE dm_channels - SET owner_home_user_id = ?, owner_home_instance = ? - WHERE id = ? - `); - - for (const gd of groupDms) { - updateStmt.run( - gd.home_user_id || gd.owner_id, - gd.home_instance || null, - gd.id, - ); - } - - console.log(`[migrate] Backfilled owner federation identity for ${groupDms.length} group DMs`); - } - } catch (err) { - console.error('migrateDmChannelsFederatedId: owner backfill failed (non-fatal):', err); - } -} - -/** Fix ownerId on 1-on-1 DMs: should be NULL, not the creator's ID */ -function migrateFixOneOnOneOwnerIds(db: Database.Database): void { - try { - const result = db.prepare(` - UPDATE dm_channels SET owner_id = NULL - WHERE id IN ( - SELECT dm_channel_id FROM dm_members - GROUP BY dm_channel_id - HAVING COUNT(*) = 2 - ) - AND owner_id IS NOT NULL - AND (federated_id IS NULL OR length(federated_id) = 32) - `).run(); - - if (result.changes > 0) { - console.log(`[migrate] Fixed ownerId on ${result.changes} 1-on-1 DM channel(s) (set to NULL)`); - } - } catch (err) { - console.error('migrateFixOneOnOneOwnerIds failed (non-fatal):', err); - } -} - -/** Reset federation sync checkpoint so legacy DMs get replicated via S2S */ -function migrateResetFederationSyncForLegacyDms(db: Database.Database): void { - try { - const peersTable = db.prepare(`PRAGMA table_info(federation_peers)`).all() as Array<{ name: string }>; - if (peersTable.length === 0) return; // No federation tables yet - - const hasSyncFlag = (db.pragma('table_info(instance_settings)') as Array<{ name: string }>) - .some(c => c.name === 'legacy_dm_sync_done'); - - if (!hasSyncFlag) { - db.exec(`ALTER TABLE instance_settings ADD COLUMN legacy_dm_sync_done INTEGER DEFAULT 0`); - } - - const settings = db.prepare('SELECT legacy_dm_sync_done FROM instance_settings WHERE id = 1').get() as { legacy_dm_sync_done: number } | undefined; - if (settings?.legacy_dm_sync_done) return; // Already ran - - const result = db.prepare(`UPDATE federation_peers SET last_synced_at = 0 WHERE status = 'active'`).run(); - - if (result.changes > 0) { - console.log(`[migrate] Reset sync checkpoint on ${result.changes} federation peer(s) for legacy DM replication`); - } - - db.prepare('UPDATE instance_settings SET legacy_dm_sync_done = 1 WHERE id = 1').run(); - } catch (err) { - console.error('migrateResetFederationSyncForLegacyDms failed (non-fatal):', err); - } -} - -/** - * Generalize federation_outbox and federation_mutation_log column names. - * Renames dm_channel_id → context_id, message_id → entity_id in federation_outbox, - * and dm_message_id → entity_id, dm_channel_id → context_id in federation_mutation_log. - * Adds context_type = 'dm' for all existing rows. - */ -function migrateGeneralizeOutbox(db: Database.Database): void { - // --- federation_outbox --- - const outboxCols = db.prepare(`PRAGMA table_info(federation_outbox)`).all() as Array<{ name: string }>; - const outboxColNames = new Set(outboxCols.map(c => c.name)); - - if (outboxColNames.has('context_id')) { - if (!outboxColNames.has('context_type')) { - db.exec(`ALTER TABLE federation_outbox ADD COLUMN context_type TEXT NOT NULL DEFAULT 'dm'`); - } - } else if (outboxColNames.has('dm_channel_id')) { - console.log('Migrating: Generalizing federation_outbox columns (dm_channel_id → context_id, message_id → entity_id)...'); - db.exec(`PRAGMA foreign_keys = OFF`); - try { - db.transaction(() => { - db.exec(` - CREATE TABLE federation_outbox_new ( - id TEXT PRIMARY KEY, - peer_id TEXT NOT NULL REFERENCES federation_peers(id) ON DELETE CASCADE, - context_id TEXT NOT NULL, - entity_id TEXT NOT NULL, - context_type TEXT NOT NULL DEFAULT 'dm', - event_type TEXT NOT NULL, - payload TEXT NOT NULL, - encryption_version INTEGER DEFAULT 0, - attempts INTEGER DEFAULT 0, - next_retry_at INTEGER NOT NULL, - expires_at INTEGER NOT NULL, - created_at INTEGER NOT NULL, - UNIQUE(peer_id, entity_id) - ); - INSERT INTO federation_outbox_new (id, peer_id, context_id, entity_id, context_type, event_type, payload, encryption_version, attempts, next_retry_at, expires_at, created_at) - SELECT id, peer_id, dm_channel_id, message_id, 'dm', event_type, payload, encryption_version, attempts, next_retry_at, expires_at, created_at - FROM federation_outbox; - DROP TABLE federation_outbox; - ALTER TABLE federation_outbox_new RENAME TO federation_outbox; - `); - })(); - } finally { - db.exec(`PRAGMA foreign_keys = ON`); - } - } - - // --- federation_mutation_log --- - const logCols = db.prepare(`PRAGMA table_info(federation_mutation_log)`).all() as Array<{ name: string }>; - const logColNames = new Set(logCols.map(c => c.name)); - - if (logColNames.has('entity_id')) { - if (!logColNames.has('context_type')) { - db.exec(`ALTER TABLE federation_mutation_log ADD COLUMN context_type TEXT NOT NULL DEFAULT 'dm'`); - } - } else if (logColNames.has('dm_message_id')) { - console.log('Migrating: Generalizing federation_mutation_log columns (dm_message_id → entity_id, dm_channel_id → context_id)...'); - db.exec(`PRAGMA foreign_keys = OFF`); - try { - db.transaction(() => { - db.exec(` - CREATE TABLE federation_mutation_log_new ( - id TEXT PRIMARY KEY, - entity_id TEXT NOT NULL, - context_id TEXT NOT NULL, - context_type TEXT NOT NULL DEFAULT 'dm', - mutation_type TEXT NOT NULL, - mutated_at INTEGER NOT NULL, - payload TEXT - ); - INSERT INTO federation_mutation_log_new (id, entity_id, context_id, context_type, mutation_type, mutated_at, payload) - SELECT id, dm_message_id, dm_channel_id, 'dm', mutation_type, mutated_at, payload - FROM federation_mutation_log; - DROP TABLE federation_mutation_log; - ALTER TABLE federation_mutation_log_new RENAME TO federation_mutation_log; - `); - })(); - } finally { - db.exec(`PRAGMA foreign_keys = ON`); - } - } - - // ─── FED-008: Add nonceSupported column for replay attack protection ─────── - try { - db.exec(`ALTER TABLE federation_peers ADD COLUMN nonce_supported INTEGER NOT NULL DEFAULT 0`); - } catch { /* column already exists */ } -}