This commit is contained in:
2026-09-12 13:59:12 +08:00
commit 941ce4baab
71 changed files with 13037 additions and 0 deletions

98
lib/migrate.js Normal file
View File

@@ -0,0 +1,98 @@
'use strict';
const { devWarn } = require('./log');
// clips -> clips_fts 的同步触发器。v2 / v3 共用。
const FTS_TRIGGERS = `
CREATE TRIGGER IF NOT EXISTS clips_ai AFTER INSERT ON clips BEGIN
INSERT INTO clips_fts(rowid, text) VALUES (new.id, new.text);
END;
CREATE TRIGGER IF NOT EXISTS clips_ad AFTER DELETE ON clips BEGIN
INSERT INTO clips_fts(clips_fts, rowid, text) VALUES('delete', old.id, old.text);
END;
-- 只在 text 真的变了才重建索引。没有这个 WHEN 的话,每次
-- "UPDATE clips SET top_at = ?"(也就是每次复制/置顶)都会白白
-- 把该行从 FTS 删掉再插回去。
CREATE TRIGGER IF NOT EXISTS clips_au AFTER UPDATE ON clips
WHEN old.text IS NOT new.text BEGIN
INSERT INTO clips_fts(clips_fts, rowid, text) VALUES('delete', old.id, old.text);
INSERT INTO clips_fts(rowid, text) VALUES (new.id, new.text);
END;
`;
// STEPS[i] upgrades the schema from version (i + 1) to (i + 2).
// STEPS[0] therefore is the v1 -> v2 step.
const STEPS = [
// v1 -> v2: add FTS5 table + triggers (idempotent).
function v1_to_v2(db) {
db.exec(`
CREATE VIRTUAL TABLE IF NOT EXISTS clips_fts USING fts5(text, content='clips', content_rowid='id');
${FTS_TRIGGERS}
INSERT INTO clips_fts(rowid, text) SELECT id, text FROM clips WHERE text IS NOT NULL;
`);
},
// v2 -> v3: 换成 trigram 分词器。
//
// v2 用的是默认的 unicode61 分词器,它把一整串连续的中文当成**一个** token
// 而 FTS5 的 MATCH 是整 token 匹配、不做子串匹配。结果:搜"公园"在
// "今天去公园散步"里一条都搜不到 —— 对一个中文剪贴板工具来说等于搜索全废。
// v2 这一步之前因为索引下标算错从没真正执行过,所以线上一直悄悄退回 LIKE
// 反而"正好"是对的;把 v2 修好之后这个坑才会暴露出来。)
//
// trigram 分词器按 3 字符滑窗切分,支持子串匹配,中文可用。
// 代价≤2 字符的查询它匹配不到,这部分由调用方退回 LIKE 处理。
function v2_to_v3(db) {
db.exec(`
DROP TRIGGER IF EXISTS clips_ai;
DROP TRIGGER IF EXISTS clips_ad;
DROP TRIGGER IF EXISTS clips_au;
DROP TABLE IF EXISTS clips_fts;
CREATE VIRTUAL TABLE clips_fts USING fts5(
text, content='clips', content_rowid='id', tokenize='trigram'
);
${FTS_TRIGGERS}
INSERT INTO clips_fts(rowid, text) SELECT id, text FROM clips WHERE text IS NOT NULL;
`);
},
];
/**
* Applies each pending schema step in order.
*
* The DB's own `PRAGMA user_version` is the source of truth for what has already
* been applied — not the config file, which only records the version the app
* *wants*. Passing a config-derived `fromVersion` would re-run every step on
* every launch (re-indexing the whole FTS table each time the app starts).
*
* Returns the version actually reached, so callers can tell whether a step was
* skipped because of an error.
*/
function migrate(db, fromVersion, toVersion) {
let current = fromVersion;
try {
const row = db.pragma('user_version', { simple: true });
if (typeof row === 'number' && row > 0) current = row;
} catch (e) {
// Non-sqlite stub (tests) — fall back to the caller-supplied version.
}
for (let v = current + 1; v <= toVersion; v++) {
const step = STEPS[v - 2];
if (!step) continue;
try {
step(db);
current = v;
try { db.pragma(`user_version = ${v}`); } catch (e) {}
} catch (e) {
// Most likely FTS5 is not compiled into this SQLite build. Leave the
// version marker where it is so a later launch can retry, and let the
// caller fall back to LIKE-based search.
devWarn('[migrate] step', v, 'failed, leaving schema at v' + current + ':', e.message);
break;
}
}
return current;
}
module.exports = { migrate, STEPS };