'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 };