From d9dc33bd36248efcc2e0ace7ec6a112ae3d4e465 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Thu, 26 Mar 2026 19:59:44 +0100 Subject: [PATCH] feat(schema): rename canonical_pair_id to federated_id, add group DM columns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renames dm_channels.canonical_pair_id → federated_id and adds owner_home_user_id, owner_home_instance, deleted_at columns to support federated group DMs. Migration handles upgrade from existing installs (full table rebuild to rename), partial states (ALTER TABLE), and fresh installs (correct DDL from the start). Backfills federated_id for existing 1-on-1 DM channels and owner federation identity for group DMs. --- packages/server/src/db/index.ts | 5 ++ packages/server/src/db/migrate.ts | 142 ++++++++++++++++++++++++++---- packages/server/src/db/schema.ts | 5 +- 3 files changed, 135 insertions(+), 17 deletions(-) diff --git a/packages/server/src/db/index.ts b/packages/server/src/db/index.ts index 46e02dc8..7bd57013 100644 --- a/packages/server/src/db/index.ts +++ b/packages/server/src/db/index.ts @@ -92,8 +92,13 @@ function createTables(db: Database.Database): void { CREATE TABLE IF NOT EXISTS dm_channels ( 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 ); + CREATE UNIQUE INDEX IF NOT EXISTS idx_dm_federated ON dm_channels(federated_id) WHERE federated_id IS NOT NULL; CREATE TABLE IF NOT EXISTS dm_members ( dm_channel_id TEXT NOT NULL REFERENCES dm_channels(id) ON DELETE CASCADE, diff --git a/packages/server/src/db/migrate.ts b/packages/server/src/db/migrate.ts index 22dd05d0..eadcf160 100644 --- a/packages/server/src/db/migrate.ts +++ b/packages/server/src/db/migrate.ts @@ -183,7 +183,10 @@ export function runMigrations(db: Database.Database): void { { name: 'dm_channels', columns: [ - { name: 'canonical_pair_id', type: 'TEXT' }, + { name: 'federated_id', type: 'TEXT' }, + { name: 'owner_home_user_id', type: 'TEXT' }, + { name: 'owner_home_instance', type: 'TEXT' }, + { name: 'deleted_at', type: 'INTEGER' }, ] }, { @@ -479,7 +482,7 @@ export function runMigrations(db: Database.Database): void { // ─── 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`); - db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_dm_canonical_pair ON dm_channels(canonical_pair_id) WHERE canonical_pair_id IS NOT NULL`); + db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_dm_federated ON dm_channels(federated_id) WHERE federated_id IS NOT NULL`); 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)`); @@ -492,22 +495,27 @@ export function runMigrations(db: Database.Database): void { } } catch { /* column may not exist yet on first run */ } - // ─── Backfill canonical_pair_id for existing 1-on-1 DM channels ───────── + // ─── Rename canonical_pair_id → federated_id, add group DM columns ─────── + migrateDmChannelsFederatedId(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 canonical_pair_id yet + // 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.canonical_pair_id IS NULL + 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 canonical_pair_id = ? WHERE id = ? AND canonical_pair_id IS NULL'); + 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) { @@ -518,7 +526,7 @@ export function runMigrations(db: Database.Database): void { 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 canonical_pair_id = ?').get(pairId) as { id: string } | undefined; + 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); @@ -533,23 +541,23 @@ export function runMigrations(db: Database.Database): void { } if (backfilled > 0) { - console.log(`Federation: Backfilled canonical_pair_id for ${backfilled} existing DM channel(s)`); + console.log(`Federation: Backfilled federated_id for ${backfilled} existing DM channel(s)`); } } } catch (err) { - console.error('Federation canonical_pair_id backfill failed (non-fatal):', err); + console.error('Federation federated_id backfill failed (non-fatal):', err); } - // ─── Merge duplicate DM channels with same canonical_pair_id ───────────── + // ─── Merge duplicate DM channels with same federated_id ────────────────── try { - // Find canonical_pair_ids that appear more than once + // Find federated_ids that appear more than once const duplicates = db.prepare(` - SELECT canonical_pair_id, GROUP_CONCAT(id) as channel_ids + SELECT federated_id, GROUP_CONCAT(id) as channel_ids FROM dm_channels - WHERE canonical_pair_id IS NOT NULL - GROUP BY canonical_pair_id + WHERE federated_id IS NOT NULL + GROUP BY federated_id HAVING COUNT(*) > 1 - `).all() as { canonical_pair_id: string; channel_ids: string }[]; + `).all() as { federated_id: string; channel_ids: string }[]; for (const dup of duplicates) { const ids = dup.channel_ids.split(','); @@ -572,7 +580,7 @@ export function runMigrations(db: Database.Database): void { } if (removeIds.length > 0) { - console.log(`Federation: Merged ${removeIds.length} duplicate DM channel(s) for pair ${dup.canonical_pair_id} into ${keepId}`); + console.log(`Federation: Merged ${removeIds.length} duplicate DM channel(s) for pair ${dup.federated_id} into ${keepId}`); } } } catch (err) { @@ -1452,3 +1460,105 @@ export async function backfillMediaDimensions(db: Database.Database, uploadDir: 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 and add new columns in one atomic step + console.log('Migrating: Renaming canonical_pair_id → federated_id in dm_channels and adding group DM columns...'); + 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; + `); + 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); + } +} diff --git a/packages/server/src/db/schema.ts b/packages/server/src/db/schema.ts index c7c13730..e704fca9 100644 --- a/packages/server/src/db/schema.ts +++ b/packages/server/src/db/schema.ts @@ -117,7 +117,10 @@ export const embeds = sqliteTable('embeds', { export const dmChannels = sqliteTable('dm_channels', { id: text('id').primaryKey(), ownerId: text('owner_id'), - canonicalPairId: text('canonical_pair_id'), + federatedId: text('federated_id'), + ownerHomeUserId: text('owner_home_user_id'), + ownerHomeInstance: text('owner_home_instance'), + deletedAt: integer('deleted_at'), createdAt: integer('created_at').notNull(), });