fix(dm): idempotent backfill restores pre-existing broken Deleted-User 1-on-1 threads (S5)
This commit is contained in:
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,7 +3,7 @@ import { drizzle } from 'drizzle-orm/better-sqlite3';
|
|||||||
import { migrate } from 'drizzle-orm/better-sqlite3/migrator';
|
import { migrate } from 'drizzle-orm/better-sqlite3/migrator';
|
||||||
import { config } from '../config.js';
|
import { config } from '../config.js';
|
||||||
import * as schema from './schema.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 { setWorkerId } from '../utils/snowflake.js';
|
||||||
import { createSnapshot } from '../utils/backup.js';
|
import { createSnapshot } from '../utils/backup.js';
|
||||||
import { hasPendingMigrations } from './pendingMigrations.js';
|
import { hasPendingMigrations } from './pendingMigrations.js';
|
||||||
@@ -50,6 +50,9 @@ export function initDatabase() {
|
|||||||
|
|
||||||
// Ensure data invariants (settings row, worker ID, first admin)
|
// Ensure data invariants (settings row, worker ID, first admin)
|
||||||
ensureDefaults(sqlite);
|
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
|
// 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;
|
const settings = sqlite.prepare('SELECT worker_id FROM instance_settings WHERE id = 1').get() as { worker_id: number } | undefined;
|
||||||
|
|||||||
@@ -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)`);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user