'use strict'; /** * 列表排序:验证 main.js 里 orderBy() 的实现。 * * 排序契约:列表按「最近活跃」倒序,即 created_at DESC。 * * - 外部新复制:插入时 created_at = now → 自然排到顶 * - 用户点过「复制」:movetop 把 created_at 同步更新到 now → 同样排到顶 * - 其它项按 created_at DESC 自然排列 * * 早期版本用过 `ORDER BY top_at DESC, created_at DESC`,但它让「外部新复制」 * 排在「会话内置顶项」之后 —— 即使新复制才是当前剪贴板内容,列表顶上 * 显示的还是上一会话里被点过复制的旧项。这违反「第一个就是当前剪贴板 * 内容」的契约。改成 created_at DESC 后,无论外部复制还是点过复制, * 都按最近活跃时间排序,问题自然消失。 * * 用 id DESC 作 tiebreaker:同 ms 内多次变更给一个稳定次序。 */ const assert = require('node:assert'); const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); const Database = require('better-sqlite3'); const NEW = 'ORDER BY created_at DESC, id DESC'; let passed = 0; function check(name, fn) { try { fn(); console.log(' ok -', name); passed++; } catch (e) { console.error(' FAIL -', name, e.message); process.exitCode = 1; } } function makeDb() { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'clip-ob-')); const db = new Database(path.join(tmp, 'h.db')); db.exec(`CREATE TABLE clips( id INTEGER PRIMARY KEY AUTOINCREMENT, type TEXT, text TEXT, image BLOB, preview TEXT, top_at REAL, created_at REAL NOT NULL)`); // 复合索引:created_at DESC, id DESC —— 完整覆盖 ORDER BY 的两列, // 不需要「TEMP B-TREE FOR LAST TERM OF ORDER BY」给 id DESC 兜底。 db.exec(`CREATE INDEX idx_clips_created_id ON clips(created_at DESC, id DESC)`); return db; } const ids = (db, order, limit = 1000) => db.prepare(`SELECT id FROM clips ${order} LIMIT ?`).all(limit).map((r) => r.id); check('外部新复制天然排到顶:created_at 最大的在最前', () => { const db = makeDb(); const ins = db.prepare('INSERT INTO clips(type,text,top_at,created_at) VALUES(?,?,?,?)'); ins.run('text', 'a', null, 100); // id=1, created_at=100 ins.run('text', 'b', null, 300); // id=2, created_at=300 ins.run('text', 'c', 500, 200); // id=3, created_at=200(之前被点过复制) ins.run('text', 'd', null, 200); // id=4, created_at=200 ins.run('text', 'e', 900, 100); // id=5, created_at=100(之前被点过复制) ins.run('text', 'f', null, 400); // id=6, created_at=400 const got = db.prepare(`SELECT text FROM clips ${NEW}`).all().map((r) => r.text); // created_at DESC:f(400), b(300), d(200), c(200), e(100), a(100) // created_at 相同的 (d/c=200, e/a=100) 之间用 id DESC 决出次序: // - 同为 200:d(id=4) > c(id=3) → d 在前 // - 同为 100:e(id=5) > a(id=1) → e 在前 assert.deepStrictEqual(got, ['f', 'b', 'd', 'c', 'e', 'a']); db.close(); }); check('点过复制的项也排到顶:movetop 把 created_at 同步更新到 now', () => { const db = makeDb(); const ins = db.prepare('INSERT INTO clips(type,text,top_at,created_at) VALUES(?,?,?,?)'); ins.run('text', 'a', null, 100); ins.run('text', 'b', null, 200); ins.run('text', 'c', null, 300); // 模拟点过复制 c —— movetop 把 top_at 和 created_at 都更新到 now db.prepare('UPDATE clips SET top_at = ?, created_at = ? WHERE id = (SELECT id FROM clips WHERE text = ?)').run(1000, 1000, 'a'); const got = db.prepare(`SELECT text FROM clips ${NEW}`).all().map((r) => r.text); // a(1000) > c(300) > b(200) assert.deepStrictEqual(got, ['a', 'c', 'b']); db.close(); }); check('外部新复制总会超过任何会话内置顶项(契约)', () => { // 这个 case 就是用户报的 bug:会话内点了 c 的复制 → c: top_at=500, created_at=200 // 接着又从外面复制了 f → f: top_at=NULL, created_at=400 // 老排序 (top_at DESC) 会把 c 排到 f 前面,错。 const db = makeDb(); const ins = db.prepare('INSERT INTO clips(type,text,top_at,created_at) VALUES(?,?,?,?)'); ins.run('text', 'c', 500, 200); ins.run('text', 'f', null, 400); const got = db.prepare(`SELECT text FROM clips ${NEW}`).all().map((r) => r.text); assert.deepStrictEqual(got, ['f', 'c'], '最新复制 (f) 必须在被点过复制的 (c) 前面'); db.close(); }); check('全部 created_at 相同时按 id DESC 给出稳定次序', () => { const db = makeDb(); const ins = db.prepare('INSERT INTO clips(type,text,top_at,created_at) VALUES(?,?,?,?)'); ins.run('text', 'a', null, 100); ins.run('text', 'b', null, 100); ins.run('text', 'c', null, 100); const got = db.prepare(`SELECT text FROM clips ${NEW}`).all().map((r) => r.text); // 同一 created_at,id DESC → c, b, a assert.deepStrictEqual(got, ['c', 'b', 'a']); db.close(); }); check('1 万行随机数据:按 created_at DESC + id DESC 排序稳定', () => { const db = makeDb(); const ins = db.prepare('INSERT INTO clips(type,text,top_at,created_at) VALUES(?,?,?,?)'); db.transaction(() => { for (let i = 0; i < 10000; i++) { ins.run('text', 't' + i, i % 7 === 0 ? (i * 13) % 5000 : null, (i * 31) % 9999); } })(); // 不论 top_at 是什么值,ORDER BY created_at DESC, id DESC 永远稳定 const rows = db.prepare(`SELECT id FROM clips ORDER BY created_at DESC, id DESC LIMIT 500`).all().map(r => r.id); // 跑两次,结果必须完全一致(id DESC tiebreaker 起作用) const again = db.prepare(`SELECT id FROM clips ORDER BY created_at DESC, id DESC LIMIT 500`).all().map(r => r.id); assert.deepStrictEqual(rows, again, 'id DESC 兜底让排序可重复'); // 行数对上 assert.strictEqual(rows.length, 500); db.close(); }); check('新 ORDER BY 能走 idx_clips_created_id 复合索引(不再全表排序)', () => { const db = makeDb(); const ins = db.prepare('INSERT INTO clips(type,text,top_at,created_at) VALUES(?,?,?,?)'); db.transaction(() => { for (let i = 0; i < 2000; i++) ins.run('text', 't' + i, null, i); })(); db.exec('ANALYZE'); const plan = db .prepare(`EXPLAIN QUERY PLAN SELECT id, top_at, created_at FROM clips ${NEW} LIMIT 200`) .all().map((r) => r.detail).join(' | '); // 视 SELECT 的列能否被索引完全覆盖,SQLite 会报 "USING INDEX" 或 // "USING COVERING INDEX" —— 两者都算走上了索引。 assert.match(plan, /USING (COVERING )?INDEX idx_clips_created_id/, `应走 idx_clips_created_id,实际: ${plan}`); assert.doesNotMatch(plan, /TEMP B-TREE/, `不应再有临时排序,实际: ${plan}`); db.close(); }); check('带完整列的真实列表查询同样不会退回全表排序', () => { const db = makeDb(); const ins = db.prepare('INSERT INTO clips(type,text,image,preview,top_at,created_at) VALUES(?,?,?,?,?,?)'); db.transaction(() => { for (let i = 0; i < 2000; i++) { ins.run('text', 't' + i, null, 'p' + i, i % 7 === 0 ? (i * 13) % 5000 : null, i); } })(); const cols = 'id, type, text, preview, top_at, created_at, (image IS NOT NULL) AS has_image'; const detail = db .prepare(`EXPLAIN QUERY PLAN SELECT ${cols} FROM clips ${NEW} LIMIT 200`) .all().map((r) => r.detail).join(' | '); assert.match(detail, /idx_clips_created_id/, `应走 idx_clips_created_id,实际: ${detail}`); assert.doesNotMatch(detail, /TEMP B-TREE/, `不应临时排序,实际: ${detail}`); db.close(); }); check('main.js 的 orderBy 返回 created_at DESC, id DESC(不再用 top_at 排序)', () => { const src = fs.readFileSync(path.join(__dirname, '..', 'main.js'), 'utf8'); const orderByMatch = src.match(/const\s+orderBy\s*=\s*\([^)]*\)\s*=>\s*`([^`]+)`/); assert.ok(orderByMatch, '找不到 orderBy 定义'); const expr = orderByMatch[1]; assert.ok( /created_at\s+DESC/.test(expr), `orderBy 必须按 created_at DESC 排序,实际: ${expr}` ); assert.ok( !/top_at\s+DESC/.test(expr), `orderBy 不应再用 top_at 排序,实际: ${expr}` ); }); check('main.js 不再残留 (top_at IS NULL) 排序表达式', () => { // 早期版本写过 `(top_at IS NULL), top_at DESC, created_at DESC`, // 那个 (top_at IS NULL) 是表达式,索引satisfy不了,会强制全表排序。 // 现在新排序不用 top_at 了,这个坑不应该再出现。 const src = fs.readFileSync(path.join(__dirname, '..', 'main.js'), 'utf8'); const orderByLines = src.split('\n').filter((l) => /ORDER BY|orderBy =/.test(l)); for (const line of orderByLines) { assert.ok( !/top_at\s+IS\s+NULL\s*\)\s*,/.test(line), `排序表达式又回来了,会导致全表排序: ${line.trim()}` ); } }); console.log(`\n${passed} checks passed`);