update
This commit is contained in:
128
test/migrate-idempotent.test.js
Normal file
128
test/migrate-idempotent.test.js
Normal file
@@ -0,0 +1,128 @@
|
||||
'use strict';
|
||||
/**
|
||||
* 回归测试:v1 -> v2 迁移
|
||||
*
|
||||
* 曾经的 bug:migrate() 用 STEPS[v - 1] 取步骤,但 STEPS[0] 就是 v1->v2 的步骤,
|
||||
* 所以 v=2 时取到的是 STEPS[1] === undefined,整个 FTS5 迁移从来没跑过。
|
||||
* 表现:clips_fts 永远不存在,搜索一直静默退回 LIKE 全表扫描。
|
||||
*/
|
||||
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 { migrate, STEPS } = require('../lib/migrate.js');
|
||||
|
||||
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 freshDb() {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'clip-mig2-'));
|
||||
const db = new Database(path.join(tmp, 'h.db'));
|
||||
db.exec(`CREATE TABLE clips (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, type TEXT, text TEXT,
|
||||
top_at REAL, created_at REAL NOT NULL DEFAULT 0)`);
|
||||
return db;
|
||||
}
|
||||
|
||||
check('STEPS[0] is the v1->v2 step (index maps version-2, not version-1)', () => {
|
||||
assert.strictEqual(typeof STEPS[0], 'function');
|
||||
assert.match(STEPS[0].name, /v1_to_v2/);
|
||||
});
|
||||
|
||||
// 探测这个 SQLite 构建是否带 FTS5;没有的话下面的断言换成"优雅降级"
|
||||
let fts5 = true;
|
||||
{
|
||||
const db = freshDb();
|
||||
try { db.exec(`CREATE VIRTUAL TABLE t USING fts5(x)`); } catch (_) { fts5 = false; }
|
||||
db.close();
|
||||
}
|
||||
|
||||
check('migrate actually reaches v2 and reports it', () => {
|
||||
const db = freshDb();
|
||||
const reached = migrate(db, 1, 2);
|
||||
if (fts5) {
|
||||
assert.strictEqual(reached, 2, '迁移应到达 v2');
|
||||
assert.strictEqual(db.pragma('user_version', { simple: true }), 2);
|
||||
} else {
|
||||
assert.strictEqual(reached, 1, '没有 FTS5 时应停在 v1,而不是谎报成功');
|
||||
}
|
||||
db.close();
|
||||
});
|
||||
|
||||
if (fts5) {
|
||||
check('clips_fts exists and is searchable after migrate', () => {
|
||||
const db = freshDb();
|
||||
db.prepare('INSERT INTO clips(type,text) VALUES(?,?)').run('text', 'hello world');
|
||||
migrate(db, 1, 2);
|
||||
const rows = db.prepare(`SELECT rowid FROM clips_fts WHERE clips_fts MATCH 'hello'`).all();
|
||||
assert.strictEqual(rows.length, 1, '已有数据应被回填进 FTS 索引');
|
||||
db.close();
|
||||
});
|
||||
|
||||
check('migrate is idempotent across restarts (no duplicate index entries)', () => {
|
||||
const db = freshDb();
|
||||
db.prepare('INSERT INTO clips(type,text) VALUES(?,?)').run('text', 'hello world');
|
||||
for (let launch = 0; launch < 3; launch++) migrate(db, 1, 2);
|
||||
const n = db.prepare(`SELECT count(*) c FROM clips_fts WHERE clips_fts MATCH 'hello'`).get().c;
|
||||
assert.strictEqual(n, 1, '重复启动不应重复回填');
|
||||
db.exec(`INSERT INTO clips_fts(clips_fts) VALUES('integrity-check')`);
|
||||
db.close();
|
||||
});
|
||||
|
||||
check('user_version short-circuits the backfill on subsequent runs', () => {
|
||||
const db = freshDb();
|
||||
migrate(db, 1, 2);
|
||||
// 直接往 clips 插一行但绕开触发器是做不到的,所以改为验证:
|
||||
// 第二次 migrate 不再执行回填(否则这行会被索引两次)
|
||||
db.prepare('INSERT INTO clips(type,text) VALUES(?,?)').run('text', 'unique token zzz');
|
||||
migrate(db, 1, 2);
|
||||
const n = db.prepare(`SELECT count(*) c FROM clips_fts WHERE clips_fts MATCH 'zzz'`).get().c;
|
||||
assert.strictEqual(n, 1);
|
||||
db.close();
|
||||
});
|
||||
|
||||
check('AFTER UPDATE trigger skips FTS churn when only top_at changes', () => {
|
||||
const db = freshDb();
|
||||
db.prepare('INSERT INTO clips(type,text) VALUES(?,?)').run('text', 'hello world');
|
||||
migrate(db, 1, 2);
|
||||
db.prepare('UPDATE clips SET top_at = ? WHERE id = 1').run(123);
|
||||
assert.strictEqual(
|
||||
db.prepare(`SELECT count(*) c FROM clips_fts WHERE clips_fts MATCH 'hello'`).get().c, 1,
|
||||
'置顶不应影响可搜索性');
|
||||
db.exec(`INSERT INTO clips_fts(clips_fts) VALUES('integrity-check')`);
|
||||
db.close();
|
||||
});
|
||||
|
||||
check('AFTER UPDATE trigger still re-indexes when text changes', () => {
|
||||
const db = freshDb();
|
||||
db.prepare('INSERT INTO clips(type,text) VALUES(?,?)').run('text', 'hello world');
|
||||
migrate(db, 1, 2);
|
||||
db.prepare('UPDATE clips SET text = ? WHERE id = 1').run('goodbye moon');
|
||||
assert.strictEqual(db.prepare(`SELECT count(*) c FROM clips_fts WHERE clips_fts MATCH 'hello'`).get().c, 0);
|
||||
assert.strictEqual(db.prepare(`SELECT count(*) c FROM clips_fts WHERE clips_fts MATCH 'goodbye'`).get().c, 1);
|
||||
db.close();
|
||||
});
|
||||
|
||||
check('DELETE keeps the FTS index consistent', () => {
|
||||
const db = freshDb();
|
||||
db.prepare('INSERT INTO clips(type,text) VALUES(?,?)').run('text', 'hello world');
|
||||
migrate(db, 1, 2);
|
||||
db.prepare('DELETE FROM clips WHERE id = 1').run();
|
||||
assert.strictEqual(db.prepare(`SELECT count(*) c FROM clips_fts WHERE clips_fts MATCH 'hello'`).get().c, 0);
|
||||
db.exec(`INSERT INTO clips_fts(clips_fts) VALUES('integrity-check')`);
|
||||
db.close();
|
||||
});
|
||||
} else {
|
||||
console.log(' SKIP - FTS5 unavailable in this SQLite build');
|
||||
}
|
||||
|
||||
check('migrate on a broken db degrades instead of throwing', () => {
|
||||
const bad = { pragma() { throw new Error('nope'); }, exec() { throw new Error('nope'); } };
|
||||
assert.strictEqual(migrate(bad, 1, 2), 1);
|
||||
});
|
||||
|
||||
console.log(`\n${passed} checks passed`);
|
||||
Reference in New Issue
Block a user