diff --git a/packages/server/src/db/backfillDmMembership.test.ts b/packages/server/src/db/backfillDmMembership.test.ts new file mode 100644 index 00000000..b269ca04 --- /dev/null +++ b/packages/server/src/db/backfillDmMembership.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import Database from 'better-sqlite3'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { backfillOneOnOneDmMembership } from './migrate.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +let db: Database.Database; + +function applyMigrations(d: Database.Database): void { + const dir = path.resolve(__dirname, '../../drizzle'); + for (const f of fs.readdirSync(dir).filter(x => x.endsWith('.sql')).sort()) + for (const stmt of fs.readFileSync(path.join(dir, f), 'utf8').split(/-->\s*statement-breakpoint/)) { + const c = stmt.trim(); if (c) d.exec(c); + } +} +const now = Date.now(); +const insUser = (id: string, isDeleted = 0) => db.prepare('INSERT INTO users (id, username, password_hash, created_at, is_deleted) VALUES (?,?,?,?,?)').run(id, id, 'x', now, isDeleted); +const insDm = (id: string) => db.prepare('INSERT INTO dm_channels (id, owner_id, created_at) VALUES (?,NULL,?)').run(id, now); +const insMember = (dm: string, u: string) => db.prepare('INSERT INTO dm_members (dm_channel_id, user_id, closed) VALUES (?,?,0)').run(dm, u); +const insMsg = (id: string, dm: string, u: string) => db.prepare('INSERT INTO dm_messages (id, dm_channel_id, user_id, content, created_at) VALUES (?,?,?,?,?)').run(id, dm, u, 'hi', now); +const memberCount = (dm: string) => (db.prepare('SELECT COUNT(*) AS c FROM dm_members WHERE dm_channel_id = ?').get(dm) as { c: number }).c; + +describe('backfillOneOnOneDmMembership', () => { + beforeEach(() => { db = new Database(':memory:'); applyMigrations(db); }); + afterEach(() => db.close()); + + it('re-inserts the missing (deleted) partner membership from message authorship', () => { + insUser('dead', 1); insUser('survivor'); + insDm('dm1'); insMember('dm1', 'survivor'); // partner membership missing (old bug) + insMsg('m1', 'dm1', 'dead'); insMsg('m2', 'dm1', 'survivor'); + backfillOneOnOneDmMembership(db); + expect(memberCount('dm1')).toBe(2); + expect(db.prepare('SELECT 1 FROM dm_members WHERE dm_channel_id=? AND user_id=?').get('dm1', 'dead')).toBeTruthy(); + }); + + it('is idempotent and leaves healthy / unrecoverable channels untouched', () => { + insUser('a'); insUser('b'); + insDm('healthy'); insMember('healthy', 'a'); insMember('healthy', 'b'); // 2 members → skip + insDm('unrecoverable'); insMember('unrecoverable', 'a'); // partner never messaged / gone + backfillOneOnOneDmMembership(db); + backfillOneOnOneDmMembership(db); // second run must be a no-op + expect(memberCount('healthy')).toBe(2); + expect(memberCount('unrecoverable')).toBe(1); + }); +}); diff --git a/packages/server/src/db/index.ts b/packages/server/src/db/index.ts index 4ebcbdb7..1e0dc932 100644 --- a/packages/server/src/db/index.ts +++ b/packages/server/src/db/index.ts @@ -3,7 +3,7 @@ import { drizzle } from 'drizzle-orm/better-sqlite3'; import { migrate } from 'drizzle-orm/better-sqlite3/migrator'; import { config } from '../config.js'; import * as schema from './schema.js'; -import { ensureDefaults } from './migrate.js'; +import { ensureDefaults, backfillOneOnOneDmMembership } from './migrate.js'; import { setWorkerId } from '../utils/snowflake.js'; import { createSnapshot } from '../utils/backup.js'; import { hasPendingMigrations } from './pendingMigrations.js'; @@ -50,6 +50,9 @@ export function initDatabase() { // Ensure data invariants (settings row, worker ID, first admin) ensureDefaults(sqlite); + // Recover pre-fix broken 1-on-1 DM threads (deleted partner's membership row + // lost before the tombstone fix). Idempotent — safe no-op on every later boot. + backfillOneOnOneDmMembership(sqlite); // Initialize Snowflake worker ID from persisted value const settings = sqlite.prepare('SELECT worker_id FROM instance_settings WHERE id = 1').get() as { worker_id: number } | undefined; diff --git a/packages/server/src/db/migrate.ts b/packages/server/src/db/migrate.ts index 7375d409..2b122b33 100644 --- a/packages/server/src/db/migrate.ts +++ b/packages/server/src/db/migrate.ts @@ -51,3 +51,39 @@ export function ensureDefaults(db: Database.Database): void { } } } + +/** + * One-time, idempotent recovery of 1-on-1 DM threads broken before the DM + * tombstone fix: those had the deleted partner's dm_members row removed, making + * the thread UI-unreachable. For each ownerId-NULL channel with exactly one + * member, re-insert membership for any distinct dm_messages author that is + * missing from dm_members and still exists in users. Safe to run every boot. + */ +export function backfillOneOnOneDmMembership(db: Database.Database): void { + const broken = db.prepare(` + SELECT dc.id AS channelId + FROM dm_channels dc + WHERE dc.owner_id IS NULL + AND (SELECT COUNT(*) FROM dm_members dm WHERE dm.dm_channel_id = dc.id) = 1 + `).all() as { channelId: string }[]; + if (broken.length === 0) return; + + const missingAuthors = db.prepare(` + SELECT DISTINCT msg.user_id AS userId + FROM dm_messages msg + JOIN users u ON u.id = msg.user_id + WHERE msg.dm_channel_id = ? + AND msg.user_id NOT IN (SELECT user_id FROM dm_members WHERE dm_channel_id = ?) + `); + const insertMember = db.prepare('INSERT INTO dm_members (dm_channel_id, user_id, closed) VALUES (?, ?, 0)'); + + let restored = 0; + const run = db.transaction(() => { + for (const { channelId } of broken) { + const authors = missingAuthors.all(channelId, channelId) as { userId: string }[]; + for (const { userId } of authors) { insertMember.run(channelId, userId); restored++; } + } + }); + run(); + if (restored > 0) console.log(`[backfill] restored ${restored} deleted-partner DM membership row(s)`); +}