1277 lines
62 KiB
TypeScript
1277 lines
62 KiB
TypeScript
// 笔记服务:内存索引 + chokidar 监听 + writeLock 串行化。
|
||
// 与 favorites 同款「内部权威 + push event」模式。
|
||
|
||
import { EventEmitter } from 'events';
|
||
import chokidar, { FSWatcher } from 'chokidar';
|
||
import { promises as fs } from 'fs';
|
||
import { join, relative } from 'path';
|
||
import type { FolderMoveResult, StandaloneNote, StandaloneNoteSummary } from '@shared/types';
|
||
import { WORKSPACE_DATA_LAYOUT, toNoteSummary } from '@shared/types';
|
||
import { toPosix, toNotePath } from './path-mapping';
|
||
import {
|
||
scanNotes,
|
||
writeNote,
|
||
createNote,
|
||
renameNote,
|
||
moveNoteAcrossFolders,
|
||
moveNoteDirAcrossFolders,
|
||
renameNoteDir,
|
||
repinNoteId,
|
||
readNoteMtime,
|
||
parseNoteFile,
|
||
collectNotePaths,
|
||
readNote,
|
||
NOTE_FILE_EXT_RE,
|
||
} from './standalone';
|
||
// r175:导入走 service(旧实现 IPC handler 直调 helper → 导入的笔记不进
|
||
// byId / resources 表,见 importFile 注释)
|
||
import { importNoteFromFile } from './import';
|
||
import type { NotesImportResult } from '@shared/api-types';
|
||
import { getRootPath, safeTrash, sweepOrphanTmpFiles } from '../utils/safe-write';
|
||
import { closeWatcherQuietly, withWatcherReleased } from '../utils/watcher-release';
|
||
import { LibraryError, errMsg } from '../utils/error';
|
||
import { isReallyGone } from '../utils/file-check';
|
||
import { assertNoConflict } from '../utils/optimistic-concurrency';
|
||
import { tr } from '../i18n';
|
||
import { createLogger } from '../utils/logger';
|
||
import { WriteLock } from '../utils/write-lock';
|
||
import { SelfWriteEchoSuppressor, fileFingerprint } from '../utils/self-write-echo';
|
||
import type { FileFingerprint } from '../utils/self-write-echo';
|
||
import {
|
||
upsertResource,
|
||
deleteResource,
|
||
listResources,
|
||
listResourceRelPaths,
|
||
listStaleIndexIds,
|
||
renameResource,
|
||
} from '../db/resources-repo';
|
||
import { noteResourceId, parseResourceId } from '@shared/resource-id';
|
||
import { stripExt } from '@shared/path-utils';
|
||
import type { NoteResource } from '@shared/types';
|
||
import { computeFileId } from './path-mapping';
|
||
|
||
const log = createLogger('notes-service');
|
||
|
||
/**
|
||
* perf r255:取一篇笔记 body 的 UTF-8 字节数。
|
||
*
|
||
* 口径 = `Buffer.byteLength(body,'utf8')`(与 noteResourceOf 写库的 size
|
||
* 列同算法)—— **唯一**区别是值优先取解析缓存(见 standalone.ts 的
|
||
* NoteParseCacheEntry.bodyBytes):缓存命中时零计算,未命中(理论上
|
||
* 不该发生 —— 缓存与 notes 同源)才现场算一次。
|
||
*
|
||
* 为什么值得:byteLength 是一次**全量 UTF-8 扫描**,实测 2000 篇 × 8KB
|
||
* = 4.3ms/遍 的**主线程纯 CPU**(不是 I/O 等待 ⇒ 直接推迟同期的其它
|
||
* IPC 响应 ⇒ 用户侧「卡一下」)。旧实现每轮 reload 付两遍(emit 指纹
|
||
* 一遍 + DB 判定一遍)= 4000 次调用 ≈ 8.6ms,稳态下这就是白烧。
|
||
*/
|
||
function bodyBytesOf(note: StandaloneNote, cache: Map<string, number> | undefined): number {
|
||
const cached = cache?.get(note.relPath);
|
||
return cached === undefined ? Buffer.byteLength(note.body, 'utf8') : cached;
|
||
}
|
||
|
||
/**
|
||
* perf r255:两轮扫描的 per-note 状态是否**完全相同**(emit 幂等判定)。
|
||
*
|
||
* 判定语义与旧的「`${id}|${mtime}|${bytes}` 排序 join 后比字符串」**一字
|
||
* 不差**:集合相同(条数一致)且每条的 (mtime, bytes) 一致 ⇒ 「没变」。
|
||
* 差别只在代价:旧实现每次 reload 要拼 2000 个字符串 + 排序 + join
|
||
* (实测 ≈ 5.7ms/2000 篇),这里只做 O(n) 的逐项比对(≈ 0.3ms)。
|
||
*
|
||
* 「同长不同文 + mtime 保留」判成「没变」—— 这是 r211/r220 双因子口径
|
||
* 一直接受的盲区(备份还原 / cp -p / 同步盘按内容去重),不是本函数引入的。
|
||
*/
|
||
function sameNoteState(
|
||
a: Map<string, { mtime: number; bytes: number }> | null,
|
||
b: Map<string, { mtime: number; bytes: number }>,
|
||
): boolean {
|
||
if (!a) return false; // 首轮:必 emit
|
||
if (a.size !== b.size) return false;
|
||
for (const [id, v] of b) {
|
||
const prev = a.get(id);
|
||
if (prev === undefined) return false;
|
||
if (prev.mtime !== v.mtime || prev.bytes !== v.bytes) return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
const RELOAD_DEBOUNCE_MS = 100;
|
||
/**
|
||
* perf r239(2026-10-21):**自写回声抑制** —— 「我刚写过的文件,watcher 的回声
|
||
* 不再触发全量重扫」。
|
||
*
|
||
* 判据 = **文件指纹**(mtime + size + ino),不是时间窗口:
|
||
* 写盘后记下指纹;回声事件到达时现 stat 一次比对 —— 一样 ⇒ 文件还是
|
||
* 我们写的那份 ⇒ 纯回声,抑制;不一样 ⇒ 文件被别人动过 ⇒ 照常重扫。
|
||
* 早期实现用「1 秒时间窗口」,结果**窗口内的外部改动被一起吞掉**
|
||
* (用户 autosave 后 1 秒内又用 Obsidian / 同步盘改了同一个文件 ⇒ 那次
|
||
* 改动永远不可见)—— 指纹判据没有这个洞。
|
||
*
|
||
* 下面的 TTL 只用于「回声迟到太久 / 平台丢事件」时让记录过期(宁可多重扫,
|
||
* 不可漏扫),不是「判定是不是自写」的主判据。
|
||
*
|
||
* **不要**跟 renderer 的 SELF_WRITE_SUPPRESS_WINDOW_MS(500ms)「统一」——
|
||
* 两者管的是不同的东西(那边管 IPC 往返,这边管磁盘 IO),强行对齐会让
|
||
* 其中一个的余量被吃掉。
|
||
*/
|
||
const NOTES_SELF_WRITE_ECHO_TTL_MS = 30_000;
|
||
/**
|
||
* perf r239:自写回声抑制后的**延迟复核**间隔(见 scheduleEchoVerify)。
|
||
* 400ms:足够让「写盘 → 外部改动」这条链上的 watcher 事件都落地;
|
||
* 又不至于让复核拖到用户已经忘了这次保存。
|
||
*/
|
||
const ECHO_VERIFY_DELAY_MS = 400;
|
||
// notes chokidar depth cap:对齐 import.ts,防深层目录 DoS(handle alloc);
|
||
// chokidar depth 0-based,顶层 + 16 层 = depth: 16
|
||
const MAX_NOTES_WATCH_DEPTH = 16;
|
||
// watcher 连续重启上限:防「库/数据目录被删 / 权限翻转 / 网络盘长期掉线」
|
||
// 等永久坏路径触发 2s 一轮 log 风暴,超过触发 'watcher-failed' 让用户介入
|
||
const MAX_NOTES_AUTO_RESTARTS = 5;
|
||
/** watcher 重启退避基数 */
|
||
const NOTES_WATCHER_RESTART_DELAY_MS = 2000;
|
||
/** watcher「健康期」窗口:重启后这段时间内不再报错 ⇒ 上一次失败是孤立事件,
|
||
* 连续失败计数可以清零(见 scheduleWatcherRestart 的 r197 段)。 */
|
||
const NOTES_WATCHER_HEALTHY_MS = 30_000;
|
||
|
||
// r219:isReallyGone(「只有 ENOENT 才算没了」)已上收到 utils/file-check.ts
|
||
// 单一真相源 —— notes(r209 两处)与 literature(r219 两处)共用同一份,
|
||
// 不再各自维护孪生实现。
|
||
|
||
// 按 id 索引(id = SHA-256(relPath),重命名时 id 会变,但 user 「我编辑的是同一个笔记」
|
||
// 基于 relPath;为「按 id 查找」语义,内存用 id 索引)
|
||
export class NotesService extends EventEmitter {
|
||
private byId = new Map<string, StandaloneNote>();
|
||
private watcher: FSWatcher | null = null;
|
||
private reloadTimer: NodeJS.Timeout | null = null;
|
||
private started = false;
|
||
// watcher 自动重启状态机(对齐 library 同款):旧实现 chokidar 失败就永久死亡 ——
|
||
// 同步抛没有重启路径,异步 'error' 事件 handler 只 log.warn,this.watcher 仍
|
||
// 指向死实例,事件流静默断。修法:2s 退避重启 + 连续失败上限防永久坏路径打
|
||
// log 风暴 + start() 重置计数。restartTimer 必须在 stop() 清,否则拖 event loop
|
||
// 阻退出
|
||
private watcherRestartTimer: NodeJS.Timeout | null = null;
|
||
private watcherRestartAttempts = 0;
|
||
// r197:健康期计时器 —— 重启后 NOTES_WATCHER_HEALTHY_MS 内不再报错 ⇒
|
||
// 判定「这次真的活了」⇒ 连续失败计数清零(详见 scheduleWatcherRestart)
|
||
private watcherHealthTimer: NodeJS.Timeout | null = null;
|
||
// 写操作并发锁,所有 mutation 串行化(抽到 utils/write-lock.ts 共享)
|
||
private writeLock = new WriteLock();
|
||
// audit-r223:reload 的 emit 幂等指纹(镜像 literature r136 M1 / r211 /
|
||
// r215)—— null = 首次 reload 必 emit(首推)。
|
||
//
|
||
// perf r255:指纹的**表示**换了(判定语义一字未改):旧实现把
|
||
// `${id}|${mtime}|${bytes}` 拼成 2000 个字符串再 sort + join 成一个大
|
||
// 字符串(实测 2000 篇 ≈ 5.7ms/轮:2000 次拼接 + 一次 O(n log n) 排序
|
||
// + 一次 join)。而判定本身只需要「逐条比一比」⇒ 改成
|
||
// Map<id, {mtime, bytes}> 逐项比对:同样的判定,零排序零 join
|
||
// (实测 ≈ 0.7ms/轮)。
|
||
private lastEmitNotes: Map<string, { mtime: number; bytes: number }> | null = null;
|
||
// 目录维度的指纹(空文件夹的增/删/改名不改变任何**文件行** —— 见下)。
|
||
private lastEmitDirs = '';
|
||
// perf r239:自写回声抑制器 —— 详见 utils/self-write-echo.ts 的头注。
|
||
// autosave 每 1.5s 落一次盘 → watcher 回声 → 全量重扫(实测 2000 篇
|
||
// 410ms),而这次重扫是纯浪费(write() 自己已经更新了 byId + DB + FTS)。
|
||
private selfWrites = new SelfWriteEchoSuppressor(NOTES_SELF_WRITE_ECHO_TTL_MS);
|
||
// perf r239:自写回声抑制后的延迟复核 timer(路径 → timer)。
|
||
// 必须清:未清的 timer 会拖住 event loop 阻退出(与 reloadTimer 同款理由)。
|
||
private echoVerifyTimers = new Map<string, NodeJS.Timeout>();
|
||
|
||
async start(opts: { skipInitialLoad?: boolean } = {}): Promise<void> {
|
||
if (this.started) return;
|
||
try {
|
||
// 三层 mkdir 防御:startup 顶层 + scanNotes + createNote,保证 notes/ 在任何代码路径
|
||
// 上不会因外部因素(OneDrive sync / 用户手动删 / AV 隔离)突然消失
|
||
const notesDir = join(getRootPath(), WORKSPACE_DATA_LAYOUT.NOTES_DIR);
|
||
await fs.mkdir(notesDir, { recursive: true });
|
||
// 清扫原子写 tmp 残留:<name>.md.<rand>.tmp(safeWriteFileAtomic 在 close→rename
|
||
// 之间被杀留下)。scanNotes 只认 .md,看不见也不自愈 —— 反复断电持续堆积
|
||
void sweepOrphanTmpFiles(notesDir).catch((sweepErr: unknown) => {
|
||
log.warn('Orphan tmp sweep failed', errMsg(sweepErr));
|
||
});
|
||
} catch (err: unknown) {
|
||
// 不再 swallow + emit('error'):旧实现 started 永远 false,后续任何 mutation
|
||
// 因目录不存在抛 ENOENT,IPC 看似「启动成功」实则半残。throw 让 bootstrap
|
||
// allSettled 透传错误,弹 dialog 提示用户检查磁盘权限
|
||
log.error('Failed to ensure notes dir', errMsg(err));
|
||
throw err;
|
||
}
|
||
// started=true 必须先置再调 reload() —— reload 守卫本意是阻 timer fire-after-stop
|
||
// 重建 byId,若 started 在 reload 之后才 true,首次 reload 也会被自己守卫拦下,
|
||
// list() 永远空
|
||
this.started = true;
|
||
if (!opts.skipInitialLoad) {
|
||
await this.reload();
|
||
}
|
||
// 显式 start 重置计数,否则一次失败耗尽的计数让「重启应用 / 显式 retry」也被阈值锁死
|
||
this.watcherRestartAttempts = 0;
|
||
this.startWatcher();
|
||
}
|
||
|
||
/** 建立 notes 目录的 chokidar watcher — start() 与自动重启共用同一代码路径 */
|
||
private startWatcher(): void {
|
||
try {
|
||
const notesDir = join(getRootPath(), WORKSPACE_DATA_LAYOUT.NOTES_DIR);
|
||
const w = chokidar.watch(notesDir, {
|
||
ignoreInitial: true,
|
||
awaitWriteFinish: { stabilityThreshold: 50, pollInterval: 25 },
|
||
depth: MAX_NOTES_WATCH_DEPTH,
|
||
});
|
||
this.watcher = w;
|
||
// perf r239:文件级事件带路径走 scheduleReload(path) —— 判「是不是
|
||
// 我自己写出来的回声」需要路径(chokidar 的 file 事件带 path,
|
||
// dir 事件不带)。目录事件仍旧无路径地走全量 reload。
|
||
const schedule = (p: string) => this.scheduleReload(p);
|
||
// perf r239:只对**笔记文件**(与 collectNotePaths 的收集口径逐字对齐)
|
||
// 的事件触发 reload。旧实现对目录里**任何**文件都触发 ⇒ 用户往
|
||
// notes/ 里丢一张图片、粘一个 PDF、放进 .gitignore 之类的杂文件,
|
||
// 每个都会命中 chokidar 'add' ⇒ **白跑一次全库重扫**
|
||
// (实测 2000 篇一次 410ms,而这些文件根本不是笔记)。
|
||
//
|
||
// 注(实测纠正,2026-10-21):早先以为「原子写的 tmp 文件
|
||
// (`<target>.rkms-a<rand>.tmp`)也会命中 'add',于是每次 autosave
|
||
// 都多触发一次全量重扫」—— **实测不成立**:chokidar 的
|
||
// awaitWriteFinish(stabilityThreshold 50ms)要求文件大小稳定
|
||
// 50ms 才上报,而 tmp 文件在几毫秒内就被 rename 走 ⇒ 永远
|
||
// 达不到稳定阈值 ⇒ chokidar 根本不上报它。变异实证:去掉本过滤
|
||
// 后,「应用自己写盘」用例的重扫次数仍是 0。
|
||
// 过滤器的真实收益在**杂文件**(图片 / PDF / 其它后缀)——
|
||
// 守卫用例 D 锁住这一条(变异:去掉过滤 ⇒ D 红)。
|
||
//
|
||
// 口径与 collectNotePaths 一致 ⇒ 不改变「哪些文件算笔记」的语义,
|
||
// 只是不再为非笔记文件白跑一趟全库重扫。
|
||
const onNoteFile = (p: string) => {
|
||
if (!NOTE_FILE_EXT_RE.test(p)) return;
|
||
schedule(p);
|
||
};
|
||
w.on('add', onNoteFile);
|
||
w.on('change', onNoteFile);
|
||
w.on('unlink', onNoteFile);
|
||
// r184:目录**结构**变化也要触发 reload。
|
||
// 旧实现只监听文件级 add/change/unlink ⇒ 用户在资源管理器里
|
||
// **建了一个空文件夹**(或删了一个文件夹)时,主进程连 'changed'
|
||
// 都不发 ⇒ renderer 完全不知道磁盘的目录结构变了 ⇒ 左侧栏的
|
||
// 文件夹树与磁盘永久对不上(外部建的夹不出现、外部删的夹还在)。
|
||
// 文件级事件覆盖不到「只有目录变、没有文件变」这一类。
|
||
w.on('addDir', () => this.scheduleReload());
|
||
w.on('unlinkDir', () => this.scheduleReload());
|
||
w.on('error', (err: unknown) => {
|
||
// 不再裸 emit('error'):无 listener 时抛 unhandledError 同步崩主进程
|
||
log.warn('Notes watcher error', errMsg(err));
|
||
if (this.listenerCount('error') > 0) {
|
||
this.emit('error', err);
|
||
}
|
||
// 最隐蔽失败形态:chokidar 内部已卡死/unwatch 掉,this.watcher 仍指向死
|
||
// 实例,事件流静默断。闭包 w(不是 this.watcher)判断「自己是否已被替换」,
|
||
// 避免旧实例迟到 error 误伤新 watcher
|
||
if (this.watcher === w) {
|
||
// 重启前必须先关掉+摘监听 —— 旧实现只置 null 不关,旧 chokidar 仍持有
|
||
// fs.watch 句柄,add/change/unlink 监听还绑着 scheduleReload(一次外部改动
|
||
// 触发多次 reload);stop() 只关 this.watcher(= null)关不掉它,退出期拖 event loop
|
||
w.removeAllListeners();
|
||
// closeWatcherQuietly(r216):裸 close 后晚到的 awaitWriteFinish
|
||
// 轮询 stat 报错(Windows EPERM)⇒ emit('error') 无监听者 ⇒ 同步 throw
|
||
void closeWatcherQuietly(w).catch((closeErr: unknown) =>
|
||
log.warn('Notes watcher close during restart threw', errMsg(closeErr)),
|
||
);
|
||
this.watcher = null;
|
||
this.scheduleWatcherRestart(err);
|
||
}
|
||
});
|
||
} catch (err: unknown) {
|
||
log.warn('Failed to start notes watcher (scheduling restart)', errMsg(err));
|
||
this.watcher = null;
|
||
this.scheduleWatcherRestart(err);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* r188:物理搬动**目录**前临时释放 chokidar 的目录句柄,搬完重建。
|
||
*
|
||
* 根因见 utils/watcher-release.ts 的头注:chokidar 对每个被监听的目录
|
||
* 持一个 OS 句柄,而 Windows 拒绝重命名「内部存在打开句柄」的目录
|
||
* ⇒ 含子目录的文件夹**改名 / 搬移必 EPERM**(notes 与 literature
|
||
* 两侧同病)。退避重试救不了(句柄是长期的,不是瞬时占用)。
|
||
*/
|
||
private async withWatcherReleased<T>(fn: () => Promise<T>): Promise<T> {
|
||
return withWatcherReleased(
|
||
{
|
||
get: () => this.watcher,
|
||
clear: () => {
|
||
this.watcher = null;
|
||
},
|
||
// 服务已 stop(退出期)时不重建 —— 否则 stop 之后又拉起一个 watcher,
|
||
// 拖住 event loop 不退出
|
||
restart: () => {
|
||
if (this.started) this.startWatcher();
|
||
},
|
||
},
|
||
fn,
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 排一次 watcher 重启(2s 退避),超上限放弃并 emit 'watcher-failed'。
|
||
* 重启成功后 reload() 补回死亡窗口漏的外部增删改(目录级 scan,轻)
|
||
*/
|
||
private scheduleWatcherRestart(cause: unknown): void {
|
||
// started=false 是退出期 —— 不排重启(留悬挂 timer)
|
||
if (!this.started) return;
|
||
// 已有 pending 不重复排,避免错误风暴排一串 timer
|
||
if (this.watcherRestartTimer) return;
|
||
// r197:刚又报错 ⇒ 上一次的「健康期」判定作废(健康期计时器必须撤,
|
||
// 否则它会在 30s 后把计数清零,而 watcher 其实一直坏着 —— 计数清零
|
||
// ⇒ 2s 退避变成无限重试,正是本上限要防的 log 风暴)。
|
||
if (this.watcherHealthTimer) {
|
||
clearTimeout(this.watcherHealthTimer);
|
||
this.watcherHealthTimer = null;
|
||
}
|
||
if (this.watcherRestartAttempts >= MAX_NOTES_AUTO_RESTARTS) {
|
||
log.error('Notes watcher restart limit reached — giving up until next app start', {
|
||
attempts: this.watcherRestartAttempts,
|
||
});
|
||
this.emit('watcher-failed', cause);
|
||
return;
|
||
}
|
||
this.watcherRestartAttempts++;
|
||
this.watcherRestartTimer = setTimeout(() => {
|
||
this.watcherRestartTimer = null;
|
||
if (!this.started) return;
|
||
log.info('Restarting notes watcher', { attempt: this.watcherRestartAttempts });
|
||
this.startWatcher();
|
||
// r197:重启成功 ⇒ 排一个「健康期」计时器:NOTES_WATCHER_HEALTHY_MS 内
|
||
// 不再报错 ⇒ 计数清零。
|
||
// 旧实现只在 start()/stop() 清计数,于是「连续失败上限」实际是**终身**
|
||
// 预算:一次长会话里攒够 5 次瞬时 watcher 错误(网络盘抖一下 /
|
||
// OneDrive 同步冲突 / AV 扫一下 / 目录被临时改名)就把 watcher 永久
|
||
// 放弃 —— 之后所有外部改动都不再被感知,用户看到「我改了文件,应用
|
||
// 不更新」,且没有任何报错提示(只在日志里)。
|
||
// 计数清零必须**延后**到健康期结束,不能在 startWatcher() 之后立刻
|
||
// 清:watcher 起来后马上再 error 的话,计数归零 ⇒ 无限重试。
|
||
this.watcherHealthTimer = setTimeout(() => {
|
||
this.watcherHealthTimer = null;
|
||
if (!this.started) return;
|
||
this.watcherRestartAttempts = 0;
|
||
}, NOTES_WATCHER_HEALTHY_MS);
|
||
void this.reload().catch((err: unknown) => {
|
||
log.warn('Notes reload after watcher restart failed', errMsg(err));
|
||
});
|
||
}, NOTES_WATCHER_RESTART_DELAY_MS);
|
||
}
|
||
|
||
/** 显式触发一次全文重扫(推迟扫描到窗口出现之后的场景) */
|
||
async load(): Promise<void> {
|
||
await this.reload();
|
||
}
|
||
|
||
async stop(): Promise<void> {
|
||
// 先标 started=false:防止 close 期间 fire 事件触发 scheduleReload 创建新 timer,
|
||
// 在 byId.clear() 之后又 populate 回数据(stale 状态泄漏到下次 start)。
|
||
// scheduleReload 双重保护 if (!started)
|
||
this.started = false;
|
||
if (this.reloadTimer) {
|
||
clearTimeout(this.reloadTimer);
|
||
this.reloadTimer = null;
|
||
}
|
||
// 必须清 watcher 重启 timer:started 已置 false,回调守卫会 bail,但 timer 句柄
|
||
// 本身仍持 2s 拖 event loop 阻退出。顺带重置计数,下次 start() 干净起步
|
||
if (this.watcherRestartTimer) {
|
||
clearTimeout(this.watcherRestartTimer);
|
||
this.watcherRestartTimer = null;
|
||
}
|
||
// r197:健康期计时器也要清 —— 它持 30s 句柄,拖 event loop 阻退出
|
||
if (this.watcherHealthTimer) {
|
||
clearTimeout(this.watcherHealthTimer);
|
||
this.watcherHealthTimer = null;
|
||
}
|
||
this.watcherRestartAttempts = 0;
|
||
if (this.watcher) {
|
||
try {
|
||
// closeWatcherQuietly(r216):退出期被监听树里的文件正被外部删/搬时,
|
||
// 晚到的 awaitWriteFinish 轮询错误不能变成 uncaught exception
|
||
await closeWatcherQuietly(this.watcher);
|
||
} catch (err: unknown) {
|
||
// shutdown 路径不该因 watcher 关不上 throw 阻断其他清理,降级 warn
|
||
log.warn('Notes watcher close failed during shutdown', errMsg(err));
|
||
}
|
||
this.watcher = null;
|
||
}
|
||
// 先等 in-flight writeLock 结束再清 byId —— 避免 stop() 之后的写入落到内存被清空
|
||
// WriteLock 非 thenable,await 实例本身绕过 FIFO chain;drain() 在链尾排 no-op,
|
||
// resolve 即说明前面所有 run() 都完成
|
||
await this.writeLock.drain();
|
||
this.byId.clear();
|
||
}
|
||
|
||
/** 全量列表(mtime 倒序,含 body)—— 调用方显式决定"我要 body",
|
||
* 默认走 listSummaries() 零 body 开销 */
|
||
list(): StandaloneNote[] {
|
||
return Array.from(this.byId.values()).sort((a, b) => b.mtime - a.mtime);
|
||
}
|
||
|
||
/** 精简列表(mtime 倒序,无 body) —— IPC 默认走这条 */
|
||
listSummaries(): StandaloneNoteSummary[] {
|
||
return Array.from(this.byId.values())
|
||
.sort((a, b) => b.mtime - a.mtime)
|
||
.map(toNoteSummary);
|
||
}
|
||
|
||
findById(id: string): StandaloneNote | null {
|
||
return this.byId.get(id) ?? null;
|
||
}
|
||
|
||
// abs_path 不入 Resource(纯派生量),upsert 时由 caller 单传
|
||
private noteResourceOf(n: StandaloneNote): NoteResource {
|
||
return {
|
||
id: noteResourceId(n.id),
|
||
kind: 'note',
|
||
// audit-r222:无标题兜底必须与 scan 路径(standalone.ts readNoteHeader)
|
||
// 同口径 = stripExt(filename)(**去扩展名**)。旧实现 ?? n.filename 带
|
||
// .md 后缀,且只在「行来自 readNote/writeNote(title 可为 null)」时
|
||
// 触发 ⇒ 无标题笔记每次保存后 DB title 在 'foo' 与 'foo.md' 之间
|
||
// 翻转,FTS SearchHit.title 跟着翻。read 路径的 null 不能删 ——
|
||
// 它是编辑器标题框「空 ⇒ 显示占位符、不写回 fm.title」的语义载体。
|
||
title: n.title ?? stripExt(n.filename),
|
||
relPath: n.relPath,
|
||
tags: n.tags ?? [],
|
||
mtime: n.mtime,
|
||
size: Buffer.byteLength(n.body, 'utf8'),
|
||
indexedAt: Date.now(),
|
||
};
|
||
}
|
||
|
||
/** 磁盘写成功后的 resources / resources_fts 同步。失败 log + 不否决磁盘写
|
||
* (磁盘是 source of truth)—— 失败行 disk-only 由下次 resync 插入,或
|
||
* indexed_at=0 由下次启动 stale-index 回填,两条自愈路径都在本类里
|
||
*
|
||
* perf r239:返回**是否成功** —— 自写回声抑制依赖它:
|
||
* DB 同步失败时**不能**抑制 watcher 回声(否则这条笔记的 DB 行永远
|
||
* 停在旧正文,只能等下次外部改动或重启才自愈)。失败 ⇒ 不打自写标记
|
||
* ⇒ 回声照常触发全量 reload ⇒ syncDbWithDisk 把这行补上。
|
||
*/
|
||
private syncResource(n: StandaloneNote): boolean {
|
||
try {
|
||
upsertResource(
|
||
this.noteResourceOf(n),
|
||
n.body,
|
||
toNotePath(getRootPath(), n.relPath),
|
||
);
|
||
return true;
|
||
} catch (err: unknown) {
|
||
log.error('resource sync failed for note', n.relPath, errMsg(err));
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// CRUD
|
||
async create(filename: string): Promise<StandaloneNote> {
|
||
return this.writeLock.run(async () => {
|
||
const n = await createNote(getRootPath(), filename);
|
||
this.byId.set(n.id, n);
|
||
this.syncResource(n);
|
||
this.emit('changed');
|
||
return n;
|
||
});
|
||
}
|
||
|
||
/**
|
||
* audit-r175:OS 拖入 .md 的导入 —— **必须走 service**,不能裸调
|
||
* standalone helper。
|
||
*
|
||
* 旧实现(IPC handler 直调 notes/import.ts 的 importNoteFromFile)完全
|
||
* 绕开本类,后果有两条,都是用户可见的:
|
||
*
|
||
* 1. **导入的笔记不进 byId** —— chokidar 的 'add' 事件要等
|
||
* awaitWriteFinish(50ms)+ reload 去抖(100ms)才把笔记塞进 byId,
|
||
* 而 renderer 拿到 import 结果后**立刻**调 moveToFolder(把笔记
|
||
* 移进当前文件夹)→ byId.get(id) 必 miss → NOT_FOUND →
|
||
* 「导入成功」toast 与「移动失败」toast 同时弹,笔记停在 notes 根。
|
||
* 2. **导入的笔记不进 resources 表** —— notes 域的 reload() 只重建
|
||
* byId、不做 disk↔DB 差集(文献域有),所以导入的笔记在本次会话内
|
||
* 永远没有 FTS 行 → 全局搜索搜不到它。
|
||
*
|
||
* 现在导入收进 service:文件落盘后立刻 byId.set + syncResource(DB/FTS),
|
||
* 跟 create() 同一形态。落点仍是 notes 根(导入不带目录语义),要进子目录
|
||
* 由 caller 紧接着调 moveFile —— 那时 byId 已经有行了。
|
||
*/
|
||
async importFile(sourcePath: string): Promise<NotesImportResult> {
|
||
return this.writeLock.run(async () => {
|
||
const result = await importNoteFromFile(sourcePath, getRootPath());
|
||
const relPaths = result.createdRelPaths ?? [];
|
||
for (const relPath of relPaths) {
|
||
const note = await readNote(getRootPath(), relPath);
|
||
if (!note) continue;
|
||
this.byId.set(note.id, note);
|
||
this.syncResource(note);
|
||
}
|
||
if (relPaths.length > 0) this.emit('changed');
|
||
return result;
|
||
});
|
||
}
|
||
|
||
async write(
|
||
id: string,
|
||
body: string,
|
||
title?: string,
|
||
// expectedMtime:renderer 在 loadFullNote 时记下 activeNoteFull.mtime,save 时回传。
|
||
// 比对磁盘 mtime,不一致抛 CONFLICT(防 OneDrive/iCloud sync、Vim、git pull 等
|
||
// 外部修改被静默覆盖)。无 expectedMtime 保持 last-writer-wins(向后兼容老 caller)
|
||
expectedMtime?: number,
|
||
): Promise<StandaloneNote> {
|
||
return this.writeLock.run(async () => {
|
||
const existing = this.byId.get(id);
|
||
if (!existing) {
|
||
throw new LibraryError('NOT_FOUND', tr('notes.notFound', { id }));
|
||
}
|
||
// expectedMtime 判定抽到 utils/optimistic-concurrency.assertNoConflict 共享
|
||
// 走严格 ===,文件系统 fractional-second 容差(NTFS 100ns/APFS ns)由 caller
|
||
// Math.round 兜底
|
||
if (expectedMtime !== undefined) {
|
||
const diskMtime = await readNoteMtime(getRootPath(), existing.relPath);
|
||
if (diskMtime === null) {
|
||
// 外部删了/搬走 → NOT_FOUND 与 byId 找不到对称
|
||
throw new LibraryError('NOT_FOUND', tr('notes.notFoundPath', { path: existing.relPath }));
|
||
}
|
||
try {
|
||
assertNoConflict(Math.round(diskMtime), Math.round(expectedMtime));
|
||
} catch {
|
||
// 抛 AppError('CONFLICT') — 用 i18n 字符串重包装让 toast 走用户语言
|
||
// actual = 磁盘 mtime,expected = renderer 读时快照
|
||
throw new LibraryError(
|
||
'CONFLICT',
|
||
tr('notes.modifiedExternally', {
|
||
actual: String(Math.round(diskMtime)),
|
||
expected: String(Math.round(expectedMtime)),
|
||
}),
|
||
);
|
||
}
|
||
}
|
||
const updated = await writeNote(
|
||
getRootPath(),
|
||
existing.relPath,
|
||
body,
|
||
{ title: title !== undefined ? title.trim() || undefined : undefined },
|
||
{ preserveExisting: true },
|
||
);
|
||
// audit-r124:writeNote 会把 frontmatter id 重钉为 computeFileId(relPath)
|
||
// (standalone.ts「副本重映射」规则)。若文件曾带外部 fm.id(手改 /
|
||
// 拷贝导入 / 旧版本遗留),updated.id ≠ existing.id —— 旧实现只
|
||
// set 新键 + upsert 新行,不删旧键旧行:byId 留陈旧快照,resources
|
||
// 表同 rel_path 出现双行(搜索命中死行 → 打开 NOT_FOUND)。收敛成
|
||
// renameFile 同款「先删旧再加新」,且 DB 旧行一并删。
|
||
// audit-r155:DB 旧行 delete **不再 best-effort swallow** —— 失败
|
||
// 时 byId 保持原状(chokidar 'change' 在 writeLock 释放后排队 →
|
||
// scheduleReload → scanNotes 重建 byId 把新 id 按新 relPath 入
|
||
// byId,自愈),但 throw 提示用户本次 write 失败,避免「磁盘已
|
||
// 写 + byId 双键 + DB 双行 + 搜索命中死行」三态漂移。
|
||
// 旧实现的 best-effort 注释自承「观测靠本条 error log」——
|
||
// 实际上没有自愈路径(resync rel_path 差集不收此情况),error
|
||
// log 用户看不到,等于 silent corruption。
|
||
if (updated.id !== existing.id) {
|
||
try {
|
||
deleteResource(noteResourceId(existing.id));
|
||
} catch (err: unknown) {
|
||
log.error('write id-repin: old row delete failed (chokidar will self-heal byId)', {
|
||
relPath: existing.relPath,
|
||
oldId: existing.id,
|
||
newId: updated.id,
|
||
err: errMsg(err),
|
||
});
|
||
throw new LibraryError(
|
||
'IO_ERROR',
|
||
tr('notes.idRepinDbSyncFailed', {
|
||
oldId: existing.id,
|
||
newId: updated.id,
|
||
reason: errMsg(err),
|
||
}),
|
||
);
|
||
}
|
||
this.byId.delete(existing.id);
|
||
}
|
||
this.byId.set(updated.id, updated);
|
||
const dbOk = this.syncResource(updated);
|
||
// perf r239:打「自写」标记 —— watcher 的回声不再触发全量重扫
|
||
// (autosave 每 1.5s 一次,2000 篇实测每次白跑 410ms)。
|
||
// 记的是**写完那一刻的文件指纹**(mtime+size+ino):回声事件到达时
|
||
// 现 stat 一次比对,一样 ⇒ 还是我们写的那份 ⇒ 纯回声,抑制;
|
||
// 不一样 ⇒ 文件被别人动过 ⇒ 照常重扫(外部改动永远可见)。
|
||
// **DB 同步失败时不打标记**:那条笔记的 resources 行还停在旧正文,
|
||
// 必须靠 watcher 回声触发的全量 reload 来补(否则搜索永远搜不到
|
||
// 新内容,直到下次外部改动 / 重启)。「DB 写失败」是罕见路径,
|
||
// 为它多跑一次全量重扫是划算的。
|
||
if (dbOk) {
|
||
const abs = toNotePath(getRootPath(), updated.relPath);
|
||
const fp = await this.fingerprintOf(abs);
|
||
if (fp) this.selfWrites.mark(abs, fp);
|
||
}
|
||
// perf r239:写盘后的延迟复核 —— 「我写完之后这个文件有没有被别人动过?」
|
||
// (watcher 可能整个吞掉一次外部改动,见 scheduleWriteVerify 注释)
|
||
this.scheduleWriteVerify(toNotePath(getRootPath(), updated.relPath));
|
||
this.emit('changed');
|
||
return updated;
|
||
});
|
||
}
|
||
|
||
async renameFile(id: string, newFilename: string): Promise<StandaloneNote> {
|
||
return this.writeLock.run(async () => {
|
||
const existing = this.byId.get(id);
|
||
if (!existing) {
|
||
throw new LibraryError('NOT_FOUND', tr('notes.notFound', { id }));
|
||
}
|
||
const updated = await renameNote(getRootPath(), existing.relPath, newFilename);
|
||
// audit-r151:DB tx 在 byId mutation 之前 —— 原实现在 IPC handler 跑
|
||
// renameResource(此时 writeLock 已释放),DB tx 失败时 byId 已更新,
|
||
// 出现 disk 新文件 + byId 新 id + DB 旧 row + FTS 旧行的三态漂移。
|
||
// 现在 renameResource 与 renameNote 同 writeLock 内,DB 失败 → byId 不变;
|
||
// chokidar 'unlink' (old) + 'add' (new) 在 writeLock 释放后排队 →
|
||
// scheduleReload → scanNotes 重建 byId,把 disk 上的新文件按新
|
||
// relPath 入 byId。Self-healing,无需 user 介入。
|
||
// 列形态走 noteResourceOf(同 syncResource 共用一套资源字段推导)。
|
||
//
|
||
// r196:DB tx + byId 换键抽成 applyNoteIdSwap —— 原来 renameFile /
|
||
// moveFile 两处逐字重复(只有 log 文案与 i18n key 不同),改一边
|
||
// 漏另一边 = 「重命名同步了 DB,搬移没同步」这类漂移。
|
||
await this.applyNoteIdSwap(id, updated, 'DB rename failed for note', 'notes.renameDbSyncFailed');
|
||
this.emit('changed');
|
||
return updated;
|
||
});
|
||
}
|
||
|
||
// r161+ disk-folder Phase 2b:跨文件夹移动笔记。结构与 renameFile 同款
|
||
// (writeLock + standalone + renameResource in same lock + byId mutation
|
||
// + emit),差异:targetRelPath 是「目标目录」相对路径('' = 根 / 'foo' /
|
||
// 'foo/bar'),newRelPath = ${targetRelPath}/${basename(oldRelPath)}。
|
||
//
|
||
// 时序不变量(承 r155 H1 / r151 同款):disk rename → DB tx → byId mutation
|
||
// 三步全在 writeLock 内。DB 失败 throw IO_ERROR + byId 旧键保留 +
|
||
// chokidar 'unlink'(old) + 'add'(new) 在 writeLock 释放后排队 →
|
||
// scheduleReload → scanNotes 重建 byId 把新 id 按新 relPath 入 byId
|
||
// 自愈。失败路径无三态漂移(disk 新文件 + byId 新 id + DB 旧 row 三态
|
||
// 错位 不会发生,因为 DB tx 在 byId mutation 之前抛)。
|
||
async moveFile(
|
||
id: string,
|
||
targetRelPath: string,
|
||
): Promise<StandaloneNote> {
|
||
return this.writeLock.run(async () => {
|
||
const existing = this.byId.get(id);
|
||
if (!existing) {
|
||
throw new LibraryError('NOT_FOUND', tr('notes.notFound', { id }));
|
||
}
|
||
const updated = await moveNoteAcrossFolders(
|
||
getRootPath(),
|
||
existing.relPath,
|
||
targetRelPath,
|
||
);
|
||
// r196:同 renameFile —— 走同一份 applyNoteIdSwap(时序不变量单点化)。
|
||
await this.applyNoteIdSwap(id, updated, 'DB move failed for note', 'notes.idRepinDbSyncFailed');
|
||
this.emit('changed');
|
||
return updated;
|
||
});
|
||
}
|
||
|
||
/**
|
||
* r173:跨父目录搬移整个笔记文件夹(子树)。
|
||
*
|
||
* 场景:侧栏「剪切文件夹 → 粘贴到别的目录」「把文件夹拖到别的目录」
|
||
* 「移到根目录」—— 三条入口此前全是 throw「not yet supported」的
|
||
* 占位(UI 菜单项可见、点了必报错)。
|
||
*
|
||
* 与 moveFile(单文件)的差异:
|
||
* - 搬的是整棵子树:fs.rename 一次(目录里的非 .md 文件也带走,
|
||
* 不静默丢弃用户文件);
|
||
* - 子树里**每个** .md 的 id 都要重钉(笔记 id = SHA-256(relPath),
|
||
* 路径变 → id 变)⇒ 逐个 repinNoteId + renameResource + byId 换键;
|
||
* - 返回 idRemap(oldId → newId)给 renderer 把选中态跟到新 id。
|
||
*
|
||
* 时序不变量(同 moveFile):disk rename → DB tx → byId mutation 三步
|
||
* 全在 writeLock 内;DB 失败 throw(byId 保持旧键),后续 chokidar
|
||
* 'unlink'+'add' 触发 reload 自愈。
|
||
*/
|
||
async moveDir(
|
||
oldRelPath: string,
|
||
targetRelPath: string,
|
||
): Promise<FolderMoveResult> {
|
||
return this.writeLock.run(async () => {
|
||
// 子树里的笔记(byId 索引)—— 必须在 fs 搬家**之前**取快照:
|
||
// 搬完之后 byId 里的 relPath 已是新路径,没法再算「旧前缀」。
|
||
const affected = this.subtreeNotes(oldRelPath);
|
||
const newRelPath = await this.withWatcherReleased(() =>
|
||
moveNoteDirAcrossFolders(getRootPath(), oldRelPath, targetRelPath),
|
||
);
|
||
if (newRelPath === oldRelPath) {
|
||
// 同位置 → no-op,没有 id 变化
|
||
return { relPath: newRelPath, idRemap: [] };
|
||
}
|
||
const idRemap = await this.repinSubtree(
|
||
affected,
|
||
oldRelPath,
|
||
newRelPath,
|
||
'DB dir-move failed for note',
|
||
);
|
||
this.emit('changed');
|
||
return { relPath: newRelPath, idRemap };
|
||
});
|
||
}
|
||
|
||
/**
|
||
* r175:重命名笔记文件夹(子树)。
|
||
*
|
||
* 场景:侧栏右键「重命名」文件夹。
|
||
*
|
||
* 为什么必须走 service(而不是 IPC 层裸 fs.rename):
|
||
* 笔记 id 与路径强绑定(fm.id = SHA-256(relPath) 前 16 位)。重命名
|
||
* 目录 ⇒ 子树里每个 .md 的 relPath 都变 ⇒ id 全变。旧实现只搬目录:
|
||
* - 文件里的 fm.id 还是旧路径的哈希。下一次 writeNote 无条件
|
||
* 重烧 `id: computeFileId(relPath)` ⇒ id **突变** ⇒ renderer 手里
|
||
* 的 id 变死 id ⇒ 之后每次保存都 NOT_FOUND,用户新敲的字困在
|
||
* 死 id 草稿里(重启即丢)—— 真实数据丢失路径;
|
||
* - DB 的 rel_path 停在旧路径,搜索命中的路径信息是旧的。
|
||
* 本方法把「物理搬家 + 逐个重钉 id + DB rename + byId 换键」收进
|
||
* writeLock,与 moveDir 同款时序不变量。
|
||
*
|
||
* 返回 idRemap(oldId → newId)给 renderer 把选中态跟到新 id。
|
||
*/
|
||
async renameDir(
|
||
oldRelPath: string,
|
||
newRelPath: string,
|
||
): Promise<FolderMoveResult> {
|
||
return this.writeLock.run(async () => {
|
||
// 同 moveDir:先取子树快照,再动磁盘。
|
||
const affected = this.subtreeNotes(oldRelPath);
|
||
await this.withWatcherReleased(() =>
|
||
renameNoteDir(getRootPath(), oldRelPath, newRelPath),
|
||
);
|
||
if (newRelPath === oldRelPath) {
|
||
// 同名 → no-op,没有 id 变化
|
||
return { relPath: newRelPath, idRemap: [] };
|
||
}
|
||
const idRemap = await this.repinSubtree(
|
||
affected,
|
||
oldRelPath,
|
||
newRelPath,
|
||
'DB dir-rename failed for note',
|
||
);
|
||
this.emit('changed');
|
||
return { relPath: newRelPath, idRemap };
|
||
});
|
||
}
|
||
|
||
/**
|
||
* r196:单篇笔记的「DB 同步 + byId 换键」—— renameFile / moveFile 共用。
|
||
*
|
||
* 时序不变量(audit-r151 起,两条路径**必须**一致):
|
||
* 1. disk 操作已在 writeLock 内完成;
|
||
* 2. DB tx(renameResource)**先**于 byId mutation —— DB 失败就抛,
|
||
* byId 保持旧键,不会出现「disk 新文件 + byId 新 id + DB 旧 row」
|
||
* 的三态漂移(chokidar 的 unlink+add 会在锁释放后触发 reload 自愈);
|
||
* 3. byId 换键:relPath 变 → id 变 ⇒ 先删旧 id 再加新 id。
|
||
*
|
||
* @param logLabel log.error 的首参(区分是 rename 还是 move)
|
||
* @param errKey 抛给 IPC 的 i18n key(reason 由 errMsg 填)
|
||
*/
|
||
private async applyNoteIdSwap(
|
||
oldId: string,
|
||
updated: StandaloneNote,
|
||
logLabel: string,
|
||
errKey: string,
|
||
): Promise<void> {
|
||
try {
|
||
renameResource(
|
||
noteResourceId(oldId),
|
||
this.noteResourceOf(updated),
|
||
updated.body,
|
||
toNotePath(getRootPath(), updated.relPath),
|
||
);
|
||
} catch (err: unknown) {
|
||
log.error(logLabel, updated.relPath, errMsg(err));
|
||
throw new LibraryError('IO_ERROR', tr(errKey, { reason: errMsg(err) }));
|
||
}
|
||
this.byId.delete(oldId);
|
||
this.byId.set(updated.id, updated);
|
||
}
|
||
|
||
/**
|
||
* r196:子树里的笔记(byId 索引)—— relPath 以 `oldRelPath/` 开头。
|
||
* 目录内相对路径 = relPath 去掉 oldRelPath 前缀(见 repinSubtree)。
|
||
*/
|
||
private subtreeNotes(oldRelPath: string): StandaloneNote[] {
|
||
const prefix = `${oldRelPath}/`;
|
||
return Array.from(this.byId.values()).filter((n) => n.relPath.startsWith(prefix));
|
||
}
|
||
|
||
/**
|
||
* r196:子树 id 重钉 —— 目录搬移 / 改名后,子树里每个 .md 的 relPath 变了
|
||
* ⇒ 笔记 id(= SHA-256(relPath) 前 16 位)全变 ⇒ 逐个:
|
||
* repinNoteId(重写 frontmatter id)→ DB rename → byId 换键。
|
||
*
|
||
* moveDir / renameDir 共用这一份 —— 原来两个方法把这 20 行逐字抄了两遍
|
||
* (只差 log 文案),改一边漏另一边 = 「搬移重钉了 id、改名没重钉」⇒
|
||
* 子树里的笔记下次保存必 id 漂移(r192 同款数据丢失路径)。
|
||
*
|
||
* @param affected 搬家**之前**取好的子树笔记快照(旧 relPath)
|
||
*/
|
||
private async repinSubtree(
|
||
affected: StandaloneNote[],
|
||
oldRelPath: string,
|
||
newRelPath: string,
|
||
logLabel: string,
|
||
): Promise<FolderMoveResult['idRemap']> {
|
||
const idRemap: FolderMoveResult['idRemap'] = [];
|
||
for (const n of affected) {
|
||
// 'a/b/n.md' 搬到 'x/a' → 'x/a/b/n.md'(保留目录内层级)
|
||
const newNoteRelPath = newRelPath + n.relPath.slice(oldRelPath.length);
|
||
const updated = await repinNoteId(getRootPath(), newNoteRelPath);
|
||
await this.applyNoteIdSwap(n.id, updated, logLabel, 'notes.idRepinDbSyncFailed');
|
||
idRemap.push({ oldId: n.id, newId: updated.id, relPath: updated.relPath });
|
||
}
|
||
return idRemap;
|
||
}
|
||
|
||
/** 删除改走系统回收站(electron shell.trashItem,经 safeTrash),用户可恢复。
|
||
* 时序不变量:findById → onBeforeTrash → trashItem → byId.delete → onAfterTrash → emit */
|
||
async trash(
|
||
id: string,
|
||
// hook 拆成 beforeTrash + afterTrash — DB sync 需「删除之前/之后」做事。
|
||
// DB delete 走 onAfterTrash:之前 onBeforeTrash 顺序在删除失败(EPERM / OneDrive
|
||
// sync lock / AV 扫描)时留「DB 永久 stale, 文件还在」鬼影 —— 改后删除失败时
|
||
// byId/DB/文件三态一致,user 重试即可;成功但 DB delete 失败是罕见 stale row,
|
||
// 下次 chokidar reload 或重命名时刷新
|
||
onBeforeTrash?: (note: StandaloneNote) => void | Promise<void>,
|
||
onAfterTrash?: (note: StandaloneNote) => void | Promise<void>,
|
||
): Promise<void> {
|
||
return this.writeLock.run(async () => {
|
||
const existing = this.byId.get(id);
|
||
if (!existing) {
|
||
throw new LibraryError('NOT_FOUND', tr('notes.notFound', { id }));
|
||
}
|
||
if (onBeforeTrash) await onBeforeTrash(existing);
|
||
const absPath = join(getRootPath(), WORKSPACE_DATA_LAYOUT.NOTES_DIR, existing.relPath);
|
||
// safeTrash 与旧 safeUnlink 同款契约:文件已不存在吞 ENOENT 当作成功,
|
||
// 其他错误冒泡 —— 差异是删除进系统回收站
|
||
await safeTrash(absPath);
|
||
this.byId.delete(id);
|
||
if (onAfterTrash) await onAfterTrash(existing);
|
||
this.emit('changed');
|
||
});
|
||
}
|
||
|
||
// findBySource / readNoteForSource / writeNoteForSource / removeBySource 已下线
|
||
// —— per-file notes 退役后 NotesService 不维护 source 桥接;AI 模块 V1.6 下线后
|
||
// 只剩独立 notes/*.md 一条路径
|
||
|
||
// 「删单条 db-only 行」的独立 API 已下线(audit-r124):唯一消费链路(用户
|
||
// 决策 modal)从未接线,现由 resyncIfNeeded 就地清理取代。未来若真要做
|
||
// 「保留磁盘文件、只删索引行」的 UI 功能,参照 trash / write 的
|
||
// byId+DB 双清形态重建即可。
|
||
|
||
/** 启动期 disk ↔ DB 差集:disk-only 走 parseNoteFile + upsertResource 补齐;
|
||
* db-only(磁盘文件已外部消失)audit-r124 起**就地清理** DB 行 + byId。
|
||
* 文件已不在、行只是镜像,删除属状态同步而非数据销毁。
|
||
* 走 writeLock 与 write/rename/trash/reload 同队列串行;started 守卫对齐 reload
|
||
*
|
||
* audit-r156:旧「写 settings.notesResyncPending 让 renderer modal 决定」链路
|
||
* 从未接线(settings 字段、preload 推听、renderer 组件全 0 caller),整链路
|
||
* 一并删除;ghost 行清理自 audit-r124 起即由本方法就地完成,与 settings
|
||
* 字段存在与否无关。 */
|
||
async resyncIfNeeded(): Promise<{ inserted: number; dbOnlyIds: string[] }> {
|
||
if (!this.started) return { inserted: 0, dbOnlyIds: [] };
|
||
return this.writeLock.run(async () => {
|
||
if (!this.started) return { inserted: 0, dbOnlyIds: [] };
|
||
const root = getRootPath();
|
||
const notesDir = join(root, WORKSPACE_DATA_LAYOUT.NOTES_DIR);
|
||
await fs.mkdir(notesDir, { recursive: true });
|
||
|
||
// 收集 disk rel_path(只扫 readdir,不 parse)
|
||
const filePaths: string[] = [];
|
||
await collectNotePaths(notesDir, notesDir, filePaths);
|
||
const diskRelSet = new Set(filePaths.map((full) => toPosix(relative(notesDir, full))));
|
||
const dbRelSet = new Set(listResourceRelPaths('note'));
|
||
|
||
// disk-only → parse + upsertResource + 加 byId(resources_fts 索引 body,
|
||
// 插入天然带全文)
|
||
const diskOnly = [...diskRelSet].filter((p) => !dbRelSet.has(p));
|
||
let inserted = 0;
|
||
for (const rel of diskOnly) {
|
||
const full = join(notesDir, rel);
|
||
try {
|
||
const note = await parseNoteFile(notesDir, full);
|
||
if (!note) continue;
|
||
upsertResource(this.noteResourceOf(note), note.body, full);
|
||
this.byId.set(note.id, note);
|
||
inserted++;
|
||
} catch (err) {
|
||
log.warn('resync insert failed', rel, errMsg(err));
|
||
}
|
||
}
|
||
if (inserted > 0) this.emit('changed');
|
||
|
||
// 0005 迁移标记消费:indexed_at=0 的 note 行(老库升级 FTS 只有 title 种子)
|
||
// 重读磁盘回填 body。回填走 syncResource → upsertResource 一次性;
|
||
// db-only 行(byId 里没有)跳过 —— 它们在下方 ghost 清理里整行删除
|
||
let reindexed = 0;
|
||
for (const rid of listStaleIndexIds('note')) {
|
||
const note = this.byId.get(parseResourceId(rid).raw);
|
||
if (!note) continue;
|
||
this.syncResource(note);
|
||
reindexed++;
|
||
}
|
||
if (reindexed > 0) log.info('stale-index backfill done', { reindexed });
|
||
|
||
// db-only = 磁盘文件已外部消失 → ghost 行就地清理(audit-r124)。
|
||
// 逐行按 rel_path 命中删除 —— 行的 raw id 可能是 fm.id /
|
||
// computeFileId / 历史形态,id 不可靠,rel_path 才是全形态覆盖的
|
||
// 判定键。
|
||
// ⚠ r209:**不能只信 diskRelSet**。collectNotePaths 对单个子目录的
|
||
// readdir 失败是「log warn 后只丢这一支」的(standalone.ts 的
|
||
// per-branch try/catch),失败时 diskRelSet 缺的**是一整棵子树**,
|
||
// 而不是「这些文件真被删了」。旧注释写的「扫描 throw 会让整个
|
||
// resync reject,不会带着残缺 diskRelSet 进删除分支」**不成立**
|
||
// (collectNotePaths 从不 throw)⇒ 子树里的笔记会被整片判成 ghost
|
||
// 删掉:文件还在磁盘上,索引行却没了 ⇒ 列表 / 搜索里消失,而且
|
||
// 本轮不会补回(插入阶段先于删除阶段跑,且它也看不见这些文件)。
|
||
// 修法 = 与同文件运行期路径 syncDbWithDisk(r180)的**双重守卫**对齐:
|
||
// diskRelSet 之外再用「文件是不是真的没了」兜一次。
|
||
// 路径用**当前 root** 重建而不是 DB 里的 abs_path(ADR-0003:
|
||
// rootPath 迁移后 abs_path 会过期,照它判会误删仍存在的笔记)。
|
||
const dbOnlyRel = [...dbRelSet].filter((p) => !diskRelSet.has(p));
|
||
let dropped = 0;
|
||
if (dbOnlyRel.length > 0) {
|
||
const dbOnlySet = new Set(dbOnlyRel);
|
||
for (const r of listResources('note')) {
|
||
// listResources 签名返回 Resource[] 联合 —— note 行必带 relPath、
|
||
// literature 行不带,TS 需 kind 窄化(audit-r124:tsc 抓到裸 r.relPath)
|
||
if (r.kind !== 'note' || !dbOnlySet.has(r.relPath)) continue;
|
||
// 兜底:文件其实还在磁盘上 —— collectNotePaths 的盲区(子目录
|
||
// readdir 失败 / 深度 > 16 / symlink 目标)会让 diskRelSet 缺一块,
|
||
// 没有这道守卫就会把「还在的笔记」判成 ghost 删掉。
|
||
if (!(await isReallyGone(join(notesDir, r.relPath)))) continue;
|
||
deleteResource(r.id);
|
||
dropped++;
|
||
const raw = parseResourceId(r.id).raw;
|
||
if (this.byId.delete(raw)) this.emit('changed');
|
||
}
|
||
log.info('Notes resync: dropped ghost rows of externally deleted notes', { dropped });
|
||
}
|
||
// 返回形状保留 { inserted, dbOnlyIds }:唯一消费方是 startup 观测日志;
|
||
// r124 起它不是「待决定」清单,而是本轮已清理行的 id 快照(legacy id
|
||
// 推导保留仅为形状兼容,数值不再代表待处理项)
|
||
const dbOnlyIds = dbOnlyRel.map((rel) => computeFileId(rel));
|
||
return { inserted, dbOnlyIds };
|
||
});
|
||
}
|
||
|
||
/**
|
||
* perf r239(2026-10-21):带路径的 reload 调度。
|
||
*
|
||
* @param path 触发本次调度的事件路径(chokidar 的 file 事件带 path)。
|
||
* undefined = 目录级事件(addDir / unlinkDir)或显式 reload —— 一律走
|
||
* 全量重扫。
|
||
*
|
||
* **自写回声短路**:文件级事件先 stat 一次拿指纹,与「我写完时记下的
|
||
* 指纹」比对 —— 一样 ⇒ 这个事件只是我自己的写盘回声 ⇒ 直接返回,
|
||
* 不排 reload。理由(详见 utils/self-write-echo.ts):
|
||
* write() 自己已经做完 byId.set + syncResource(DB/FTS) + emit('changed'),
|
||
* watcher 的回声只是把同样的状态**重新从磁盘读一遍**(2000 篇实测 410ms,
|
||
* 而触发条件只是「用户停下来打字 1.5 秒」)。
|
||
*
|
||
* 指纹不同 ⇒ 文件**被别人动过** ⇒ 照常重扫。早期实现用「1 秒时间
|
||
* 窗口」,结果窗口内的外部改动被一起吞掉(用户 autosave 后 1 秒内
|
||
* 又用 Obsidian 改了同一个文件 ⇒ 那次改动永远不可见)—— 指纹判据
|
||
* 没有这个洞。
|
||
*/
|
||
private scheduleReload(path?: string): void {
|
||
// stop 期间 started=false bail:防 watcher close 期间 fire 事件创建新 timer,
|
||
// 绕过 stop() 的 clearTimeout,在 byId.clear() 之后又 populate 回数据
|
||
// 重新计时而不早返回:chokidar 一次写盘会触发 add/change 多事件,
|
||
// 「已有 timer 就 return」会把窗口内的增量信号吞掉
|
||
if (!this.started) return;
|
||
if (path === undefined) {
|
||
this.armReload();
|
||
return;
|
||
}
|
||
// 文件级事件:先 stat 再判(异步)。stat 失败(文件已被删 = unlink 事件)
|
||
// ⇒ 不是自写回声 ⇒ 照常重扫。
|
||
void this.maybeSelfEcho(path).then((isEcho) => {
|
||
if (isEcho) {
|
||
log.debug('skip reload for self-write echo', { path });
|
||
return;
|
||
}
|
||
this.armReload();
|
||
});
|
||
}
|
||
|
||
/**
|
||
* perf r239:写盘后的**延迟复核** —— 「我写完之后,这个文件有没有被别人动过?」
|
||
*
|
||
* 为什么需要:自写回声抑制只在「事件到达时文件指纹 == 我们写完时的指纹」
|
||
* 时成立。但 watcher 的事件流**可能整个吞掉**一次外部改动 —— 最典型的
|
||
* 就是「app 写盘后 1ms 内外部把文件删了」:chokidar 的 awaitWriteFinish
|
||
* 还在等这个文件「大小稳定 50ms」,文件却先没了 ⇒ chokidar 认为
|
||
* 「那个待上报的写没了」,**既不发 'change' 也不发 'unlink'** ⇒
|
||
* 外部删除彻底不可见(实测:机器繁忙时必现,空闲时偶现)。
|
||
*
|
||
* 复核 = 写盘后 ECHO_VERIFY_DELAY_MS 再 stat 一次,重跑一次「是不是
|
||
* 自写回声」的判断:
|
||
* 指纹仍相同 ⇒ 文件确实还是我们写的那份 ⇒ 真的没有外部改动。
|
||
* 指纹变了 / 文件没了 ⇒ 外部动过 ⇒ 照常重扫。
|
||
* ⇒ 抑制从「赌事件不会丢」变成「丢了也能补回来」。
|
||
*
|
||
* 为什么挂在**写盘**上而不是挂在「收到回声事件」上:事件可能根本不来
|
||
* (上面那个吞事件的场景)⇒ 挂在事件上的复核永远不会触发。
|
||
*
|
||
* 代价:每次 autosave 多 1 个 timer + 1 次 stat(约 0.1ms)——
|
||
* 相对省下的 410ms 全量重扫可以忽略。
|
||
*/
|
||
private scheduleWriteVerify(path: string): void {
|
||
const existing = this.echoVerifyTimers.get(path);
|
||
if (existing) clearTimeout(existing);
|
||
const timer = setTimeout(() => {
|
||
this.echoVerifyTimers.delete(path);
|
||
if (!this.started) return;
|
||
void this.maybeSelfEcho(path).then((stillEcho) => {
|
||
if (stillEcho) return; // 文件还是我们写的那份 ⇒ 确实没有外部改动
|
||
log.info('External change detected after self-write', { path });
|
||
this.armReload();
|
||
});
|
||
}, ECHO_VERIFY_DELAY_MS);
|
||
this.echoVerifyTimers.set(path, timer);
|
||
}
|
||
|
||
/**
|
||
* 判断某个文件级 watcher 事件是不是「我自己写出来的回声」。
|
||
* 判据 = 当前文件指纹 vs 写盘时记下的指纹(见 utils/self-write-echo.ts)。
|
||
*/
|
||
private async maybeSelfEcho(path: string): Promise<boolean> {
|
||
const fp = await this.fingerprintOf(path);
|
||
if (!fp) return false;
|
||
return this.selfWrites.isSelfEcho(path, fp);
|
||
}
|
||
|
||
/** 拿一个路径的文件指纹;文件不存在 / 读不到 ⇒ null。 */
|
||
private async fingerprintOf(absPath: string): Promise<FileFingerprint | null> {
|
||
try {
|
||
const st = await fs.stat(absPath);
|
||
return fileFingerprint(st);
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/** 真正排一次防抖 reload(RELOAD_DEBOUNCE_MS)。 */
|
||
private armReload(): void {
|
||
if (!this.started) return;
|
||
if (this.reloadTimer) clearTimeout(this.reloadTimer);
|
||
this.reloadTimer = setTimeout(() => {
|
||
this.reloadTimer = null;
|
||
if (!this.started) return;
|
||
this.reload().catch((err: unknown) => {
|
||
log.warn('Reload failed', errMsg(err));
|
||
if (this.listenerCount('reload-failed') > 0) {
|
||
this.emit('reload-failed', err);
|
||
}
|
||
});
|
||
}, RELOAD_DEBOUNCE_MS);
|
||
}
|
||
|
||
private async reload(): Promise<void> {
|
||
// reload 必须走 writeLock:之前直接换 this.byId 与并发 write/rename/trash
|
||
// 产生 race(T0 reload 启动 scanNotes → T1 write 进入 writeLock → T2 write
|
||
// 完成 set(old byId) → T3 reload 完成 next 不含 write → T4 next 覆盖 write),
|
||
// fix:reload 走 writeLock 串行化,与 write/rename/trash 同队列
|
||
// 加 started 守卫:timer fire 与 stop() 之间的 race(timer 入队时 started 还是
|
||
// true,stop() clearTimeout 但 writeLock 已 queue reload,reload 完成 byId = next
|
||
// 后 stop() 清 byId 已被覆盖),reload 内部再次检 started 拒绝 rebuild byId
|
||
if (!this.started) return;
|
||
return this.writeLock.run(async () => {
|
||
if (!this.started) return;
|
||
// audit-r223:dirsOut —— emit 指纹的目录维度(见下方指纹块)。
|
||
const diskDirs: string[] = [];
|
||
// perf r255:body 字节数由解析缓存给出(稳态零计算)—— 见
|
||
// standalone.ts 的 NoteParseCacheEntry.bodyBytes 头注。
|
||
const bodyBytes = new Map<string, number>();
|
||
// perf r255:本轮的磁盘路径集 —— syncDbWithDisk 原先**又走一遍**
|
||
// 目录树来算同一个东西(实测 2000 篇 / 20 子目录 ≈ 4.4ms 一遍)。
|
||
const diskPaths: string[] = [];
|
||
const notes = await scanNotes(getRootPath(), {
|
||
dirsOut: diskDirs,
|
||
bodyBytesOut: bodyBytes,
|
||
pathsOut: diskPaths,
|
||
});
|
||
const next = new Map<string, StandaloneNote>();
|
||
for (const n of notes) {
|
||
next.set(n.id, n);
|
||
}
|
||
this.byId = next;
|
||
// audit-r223:emit 幂等指纹,镜像 literature r136 M1 —— 旧实现**无条件**
|
||
// emit('changed'):chokidar 对 no-op 触碰(同步盘周期 touch mtime 后
|
||
// 又改回 / 元数据抖动)也触发 scheduleReload → emit ⇒ renderer 每次
|
||
// 都全量 listNotes + setState(100+ 行重渲),内容其实一个字没变。
|
||
// 指纹 = 每篇的 (id, mtime, size)(size = body UTF-8 字节数,与
|
||
// noteResourceOf 同算法;r211/r220 的双因子口径:「内容变但 mtime
|
||
// 保留」只看 mtime 会判「没变」)+ **目录集**(r215 的教训:空文件夹
|
||
// 增/删/改名不改变任何文件行,只看文件指纹会把目录结构变化判成
|
||
// 「没变」⇒ 左侧栏文件夹树与磁盘对不上;r184 起 notes 的 watcher
|
||
// 就监听 addDir/unlinkDir,指纹必须接住这一类)。目录用相对
|
||
// notesDir 的 POSIX relPath(换根 / ADR-0003 后绝对路径全变,会刷
|
||
// 出一次假指纹差),排序保证跨 reload 稳定。
|
||
// 「同长不同文 + mtime 保留」是双因子已接受的盲区(与 literature
|
||
// 同款)。
|
||
// perf r255:表示换成 Map 逐项比对(判定语义不变,见 lastEmitNotes
|
||
// 头注)—— 旧实现的「2000 次字符串拼接 + 排序 + join」是纯开销。
|
||
// 注意:指纹只管 emit;下方 syncDbWithDisk **无条件跑** —— DB 自愈
|
||
// (stale 回填 / ghost 清理)不依赖 renderer 是否需要刷新。
|
||
const root = getRootPath();
|
||
const notesDir = join(root, WORKSPACE_DATA_LAYOUT.NOTES_DIR);
|
||
const dirSig = diskDirs
|
||
.map((d) => toPosix(relative(notesDir, d)))
|
||
.sort()
|
||
.join(',');
|
||
const noteState = new Map<string, { mtime: number; bytes: number }>();
|
||
for (const n of notes) {
|
||
noteState.set(n.id, { mtime: n.mtime, bytes: bodyBytesOf(n, bodyBytes) });
|
||
}
|
||
const unchanged =
|
||
sameNoteState(this.lastEmitNotes, noteState) && dirSig === this.lastEmitDirs;
|
||
this.lastEmitNotes = noteState;
|
||
this.lastEmitDirs = dirSig;
|
||
if (!unchanged) {
|
||
this.emit('changed');
|
||
}
|
||
// r180:内存索引重建完还要把 DB 拉齐 —— 见 syncDbWithDisk 头注
|
||
await this.syncDbWithDisk(root, notes, diskPaths, bodyBytes);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* r180:reload 期 disk ↔ DB 差集(**与 literature 域 r136 对齐**)。
|
||
*
|
||
* 旧实现:reload() 只重建内存 byId,**从不碰 DB** —— 于是会话运行期间
|
||
* 三件事让「侧栏列表」与「磁盘文件夹」对不上:
|
||
* 1. 用户从 OS 拖进 <rootPath>/notes/ 的 .md:侧栏能看见(列表跟磁盘
|
||
* 一致),但 resources 表里**没有这一行** ⇒ 全局搜索 / FTS 永远搜不到;
|
||
* 2. 用户用外部编辑器改过的 .md:侧栏标题是新的,DB 里的 body 还是旧的
|
||
* ⇒ 搜索命中旧正文,正文与文件内容不一致;
|
||
* 3. 用户在 OS 删掉的 .md:DB 留下 ghost 行 ⇒ 搜索还能搜到一篇磁盘上
|
||
* 已经不存在的东西(点进去 NOT_FOUND)。
|
||
* literature 域每次 reload 都做这个差集(r136),两个域行为相反 —— 这正是
|
||
* 用户报「文献和笔记列表应该和对应的文件夹完全一致」里最刺眼的一条。
|
||
*
|
||
* 差集键用 rel_path(不是 id):id 会随目录改名(r179)/去重消解(r80)变,
|
||
* rel_path 才是覆盖全生命周期的判定键(与 resyncIfNeeded 同口径)。
|
||
*
|
||
* 差集方向:
|
||
* - disk-only / 内容变 / id 变 → upsert(带 body ⇒ FTS 同步刷新);
|
||
* - db-only(磁盘已无此文件) → ghost 行清理(行只是镜像,文件才是数据;
|
||
* 删除属状态同步,不是数据销毁 —— 与 r124 启动期口径一致)。
|
||
*/
|
||
private async syncDbWithDisk(
|
||
root: string,
|
||
notes: StandaloneNote[],
|
||
diskPaths: string[],
|
||
bodyBytes: Map<string, number>,
|
||
): Promise<void> {
|
||
const notesDir = join(root, WORKSPACE_DATA_LAYOUT.NOTES_DIR);
|
||
// disk 侧权威路径集:**文件在磁盘上就算数** —— 解析失败的笔记(超大 /
|
||
// 坏 frontmatter)也必须留在集合里,否则它的 DB 行会被误判成 ghost
|
||
// 删掉(文件还在,只是读不出来)。
|
||
//
|
||
// perf r255:路径集由 scanNotes **顺手**带出来 —— 旧实现在这里**又走
|
||
// 一遍** collectNotePaths(与扫描期那次逐字相同的调用),同一轮 reload
|
||
// 走两遍同样的树(实测 2000 篇 / 20 子目录 ≈ 4.4ms 一遍)。
|
||
// 语义差异只有一处,且是**更安全**的方向:现在两个口径来自同一次
|
||
// walk ⇒ 「本轮解析出来的笔记」与「本轮看到的文件」严格一致(旧实现
|
||
// 两次 walk 之间磁盘若变了,两边会各说各话)。唯一可见差别:walk 之后
|
||
// 才被删掉的文件,它的 DB 行本轮不清(下一轮 reload 清)—— 下一轮
|
||
// reload 由该次 unlink 事件触发,不会久留。
|
||
const diskRelSet = new Set(diskPaths.map((full) => toPosix(relative(notesDir, full))));
|
||
|
||
const dbRows = listResources('note');
|
||
const rowsByRel = new Map<string, (typeof dbRows)[number]>();
|
||
for (const r of dbRows) {
|
||
// listResources 签名返回 Resource 联合 —— note 行必带 relPath,
|
||
// literature 行不带,TS 需 kind 窄化(audit-r124 同款坑)
|
||
if (r.kind !== 'note') continue;
|
||
if (!rowsByRel.has(r.relPath)) rowsByRel.set(r.relPath, r);
|
||
}
|
||
|
||
// 本轮所有笔记的「应有 id」集合 —— 用于「旧行是否已被别人占用」的守卫
|
||
const wantIds = new Set<string>();
|
||
for (const n of notes) wantIds.add(noteResourceId(n.id));
|
||
|
||
let upserted = 0;
|
||
for (const n of notes) {
|
||
const row = rowsByRel.get(n.relPath);
|
||
const wantId = noteResourceId(n.id);
|
||
// 无变化(id 同 + mtime 同 + size 同 + FTS body 已填)→ 跳过:reload
|
||
// 是 100ms 防抖的高频路径,不能每次都全量重写 DB。
|
||
// r220:mtime + size 双因子(对齐 literature r212 口径)—— 「内容变
|
||
// 但 mtime 保留」(备份还原 / cp -p / 同步盘按内容去重)只看 mtime
|
||
// 会判「没变」⇒ FTS 永久停在旧正文,全文搜索搜不到新内容。
|
||
// size = body 的 UTF-8 字节数(与 noteResourceOf 同一算法),body
|
||
// 本来就在内存里,成本可忽略;「同长不同文 + mtime 保留」仍是
|
||
// 双因子已接受的盲区(与 literature 同款,要兜只能全文 hash)。
|
||
// perf r255:字节数取自解析缓存(稳态零计算)—— 口径与上面完全
|
||
// 相同(同一个 Buffer.byteLength(body,'utf8')),只是算一次、复用。
|
||
if (
|
||
row &&
|
||
row.id === wantId &&
|
||
row.mtime === n.mtime &&
|
||
row.size === bodyBytesOf(n, bodyBytes) &&
|
||
row.indexedAt !== 0
|
||
) continue;
|
||
try {
|
||
upsertResource(this.noteResourceOf(n), n.body, toNotePath(root, n.relPath));
|
||
// 同 relPath 但 id 变了(外部改写 fm.id / r80 去重消解):旧行是陈旧
|
||
// 镜像,删掉 —— 否则搜索里同一篇笔记出两行。旧 id 若已被本轮别的
|
||
// 笔记占用(两个文件同 id 的去重场景)则不删,那是 upsert 冲突解决
|
||
// 的活,交给 upsert 的 ON CONFLICT(rel_path 覆盖)收口。
|
||
if (row && row.id !== wantId && !wantIds.has(row.id)) {
|
||
deleteResource(row.id);
|
||
}
|
||
upserted++;
|
||
} catch (err: unknown) {
|
||
log.warn('reload db upsert failed', n.relPath, errMsg(err));
|
||
}
|
||
}
|
||
|
||
// db-only:磁盘上已没有这个文件 → ghost 行清理。
|
||
// 双重守卫(缺一不可):
|
||
// a) diskRelSet —— 常规判定;
|
||
// b) isReallyGone(当前 root 下的真实路径)—— 兜住 collectNotePaths
|
||
// 的盲区(深度 > 16 的深层笔记、symlink 目标等)。路径用
|
||
// **当前 root 重建**而不是 DB 里的 abs_path —— ADR-0003
|
||
// rootPath 迁移后 abs_path 会过期,照它判会把仍存在的笔记
|
||
// 误删(索引丢了,文件还在,但搜索再也搜不到)。
|
||
// r209:判据从 existsSync 换成 isReallyGone —— 前者把「读不到」
|
||
// 也当成「没了」(EACCES / EBUSY 时会误删),后者只在 ENOENT 时
|
||
// 判「没了」,其余错误 fail-safe 当作「还在」(与启动期路径同口径)。
|
||
let dropped = 0;
|
||
// perf r239:复用上面那份 dbRows,不再第二次 listResources('note') ——
|
||
// 旧实现每次 reload 把**整表 note 行**物化两遍(2000 篇 = 2×2000 行
|
||
// 的对象构造 + JSON 字段拷贝)。两次结果等价:中间发生的 upsert 只
|
||
// **新增**行,而新增行必然在 diskRelSet 里(它们来自本轮 notes),
|
||
// ghost 循环按 diskRelSet 判 ⇒ 不受新增行影响。
|
||
for (const r of dbRows) {
|
||
if (r.kind !== 'note') continue;
|
||
if (diskRelSet.has(r.relPath)) continue;
|
||
if (!(await isReallyGone(join(notesDir, r.relPath)))) continue;
|
||
try {
|
||
deleteResource(r.id);
|
||
dropped++;
|
||
} catch (err: unknown) {
|
||
log.warn('reload db ghost delete failed', r.id, errMsg(err));
|
||
}
|
||
}
|
||
|
||
if (upserted > 0 || dropped > 0) {
|
||
log.info('Notes reload: db synced with disk', { upserted, dropped });
|
||
}
|
||
}
|
||
}
|
||
|
||
// 模块级单例
|
||
let _instance: NotesService | null = null;
|
||
export function getNotesService(): NotesService {
|
||
if (!_instance) _instance = new NotesService();
|
||
return _instance;
|
||
}
|