memory-eternal 0.9.23 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -51,10 +51,11 @@ export function watchPendingConfig(env, onChange, { debounceMs = 60 } = {}) {
51
51
  timer = setTimeout(() => { try { onChange() } catch { /* 回调异常不影响监听 */ } }, debounceMs)
52
52
  }
53
53
  try {
54
- watcher = fs.watch(path.dirname(file), (_event, name) => {
55
- if (name && !String(name).startsWith('memory-eternal-config.pending')) return
56
- fire()
57
- })
54
+ // 监听**文件本身**而不是父目录:
55
+ // 父目录被删除 / 重命名时(临时 HOME、清理脚本、测试收尾),Windows 上 libuv 会断言崩溃
56
+ // (`Assertion failed: !_wcsnicmp(filename, dir, dirlen), src\win\fs-event.c`),
57
+ // 而监听文件时目录消失只会得到 ENOENT,不会带崩进程。也顺带免掉了按文件名过滤。
58
+ watcher = fs.watch(file, () => fire())
58
59
  if (typeof watcher.unref === 'function') watcher.unref()
59
60
  } catch { return () => {} }
60
61
  return () => {
package/lib/db.js CHANGED
@@ -26,6 +26,19 @@ export function getDb(root) {
26
26
  return db
27
27
  }
28
28
 
29
+ /**
30
+ * 建表。
31
+ *
32
+ * 存储模型(v0.10「两套存储体系」):
33
+ * cards = **主库 / 正常区**:物理上只存 approved。召回、检索、图谱、去重、
34
+ * 导出这些读路径**不再需要写 `WHERE status='approved'`** —— 查主库即安全。
35
+ * quarantine = **隔离区 / 异常区**:pending(待审)/ rejected(驳回)/ deleted(回收站)。
36
+ * 审核中心与回收站的唯一数据源;除审核操作外无人读它。
37
+ *
38
+ * 这样做的原因:v0.9 及以前是「一张表 + status 列」,安全性依赖每一处 SQL 都记得加
39
+ * 状态条件。审计发现三处漏了(去重池把新知识追加进待审卡、readCard 直读未审核正文、
40
+ * /card 接口无状态校验)—— 只要有人再漏一处就会重演。物理分表后,漏写 `WHERE` 也不泄漏。
41
+ */
29
42
  function initTables(db) {
30
43
  db.exec(`
31
44
  CREATE TABLE IF NOT EXISTS config (
@@ -35,14 +48,14 @@ function initTables(db) {
35
48
  );
36
49
 
37
50
  CREATE TABLE IF NOT EXISTS cards (
38
- id INTEGER PRIMARY KEY AUTOINCREMENT,
51
+ id INTEGER PRIMARY KEY,
39
52
  path TEXT UNIQUE NOT NULL,
40
53
  kind TEXT NOT NULL DEFAULT 'knowledge',
41
54
  title TEXT NOT NULL DEFAULT '',
42
55
  tags TEXT DEFAULT '[]',
43
56
  body TEXT NOT NULL DEFAULT '',
44
57
  summary TEXT DEFAULT '',
45
- status TEXT NOT NULL DEFAULT 'pending',
58
+ status TEXT NOT NULL DEFAULT 'approved',
46
59
  source TEXT DEFAULT '',
47
60
  submitted_by TEXT DEFAULT '',
48
61
  severity TEXT DEFAULT 'info',
@@ -56,13 +69,43 @@ function initTables(db) {
56
69
  CREATE INDEX IF NOT EXISTS idx_cards_kind ON cards(kind);
57
70
  CREATE INDEX IF NOT EXISTS idx_cards_deleted ON cards(deleted_at);
58
71
 
72
+ -- 隔离区:结构与主库一致,另加 quarantined_at(进隔离区的时间)与 quarantine_reason
73
+ CREATE TABLE IF NOT EXISTS quarantine (
74
+ id INTEGER PRIMARY KEY,
75
+ path TEXT UNIQUE NOT NULL,
76
+ kind TEXT NOT NULL DEFAULT 'knowledge',
77
+ title TEXT NOT NULL DEFAULT '',
78
+ tags TEXT DEFAULT '[]',
79
+ body TEXT NOT NULL DEFAULT '',
80
+ summary TEXT DEFAULT '',
81
+ status TEXT NOT NULL DEFAULT 'pending',
82
+ source TEXT DEFAULT '',
83
+ submitted_by TEXT DEFAULT '',
84
+ severity TEXT DEFAULT 'info',
85
+ reason TEXT DEFAULT '',
86
+ created_at TEXT DEFAULT (datetime('now')),
87
+ updated_at TEXT DEFAULT (datetime('now')),
88
+ deleted_at TEXT,
89
+ quarantined_at TEXT DEFAULT (datetime('now')),
90
+ quarantine_reason TEXT DEFAULT ''
91
+ );
92
+
93
+ CREATE INDEX IF NOT EXISTS idx_quarantine_status ON quarantine(status);
94
+ CREATE INDEX IF NOT EXISTS idx_quarantine_kind ON quarantine(kind);
95
+ CREATE INDEX IF NOT EXISTS idx_quarantine_deleted ON quarantine(deleted_at);
96
+
97
+ -- 更新记录:card_id 指向 cards.id **或** quarantine.id(两表共享 id 空间)。
98
+ -- 因此这里**不能**建外键:SQLite 的外键只认一张父表,卡被搬进隔离区时 FK 校验会直接失败
99
+ -- (实测 FOREIGN KEY constraint failed)。永久删除卡片时由 deleteCard 手工清理更新记录。
59
100
  CREATE TABLE IF NOT EXISTS card_updates (
60
101
  id INTEGER PRIMARY KEY AUTOINCREMENT,
61
- card_id INTEGER NOT NULL REFERENCES cards(id) ON DELETE CASCADE,
102
+ card_id INTEGER NOT NULL,
62
103
  content TEXT NOT NULL,
63
104
  created_at TEXT DEFAULT (datetime('now'))
64
105
  );
65
106
 
107
+ CREATE INDEX IF NOT EXISTS idx_card_updates_card ON card_updates(card_id);
108
+
66
109
  CREATE TABLE IF NOT EXISTS feedback (
67
110
  id INTEGER PRIMARY KEY AUTOINCREMENT,
68
111
  query TEXT NOT NULL,
@@ -81,7 +124,111 @@ function initTables(db) {
81
124
  reason TEXT DEFAULT '',
82
125
  created_at TEXT DEFAULT (datetime('now'))
83
126
  );
127
+
128
+ -- 卡片 id 发号器(两张表**共用**)。
129
+ --
130
+ -- 为什么不用 AUTOINCREMENT:cards 与 quarantine 是两张 AUTOINCREMENT 表,各有一份
131
+ -- sqlite_sequence 计数器,且都从 1 开始 —— 于是两张表会同时出现 id=1,而卡需要在
132
+ -- 两表间搬家并保留 id,必然撞 UNIQUE constraint failed。实测确认:
133
+ -- 共享 id 空间 + 双 AUTOINCREMENT 在 SQLite 里无法自洽。
134
+ -- 改为「显式发号 + 单一持久计数器」:id 全库唯一、单调递增、删除后也不复用,
135
+ -- 而且任何一次 renumber 都是显式 error,不会静默串记录。
136
+ CREATE TABLE IF NOT EXISTS card_sequence (
137
+ name TEXT PRIMARY KEY,
138
+ seq INTEGER NOT NULL
139
+ );
84
140
  `)
141
+ // 种子:取两表最大 id(老库升级时把已有卡都覆盖进去);没有则从 0 起
142
+ db.prepare(`INSERT INTO card_sequence (name, seq) VALUES ('card', 0) ON CONFLICT(name) DO NOTHING`).run()
143
+ db.prepare(`UPDATE card_sequence SET seq = (
144
+ SELECT MAX(m) FROM (SELECT MAX(id) m FROM cards UNION ALL SELECT MAX(id) FROM quarantine)
145
+ ) WHERE name = 'card' AND seq < (
146
+ SELECT MAX(m) FROM (SELECT MAX(id) m FROM cards UNION ALL SELECT MAX(id) FROM quarantine)
147
+ )`).run()
148
+ migrateToQuarantine(db)
149
+ }
150
+
151
+ /** 分配一个新的卡片 id(跨两张表唯一、单调递增)。 */
152
+ export function nextCardId(db) {
153
+ db.prepare("UPDATE card_sequence SET seq = seq + 1 WHERE name = 'card'").run()
154
+ const row = db.prepare("SELECT seq FROM card_sequence WHERE name = 'card'").get()
155
+ if (!row) throw new Error('card_sequence 未初始化')
156
+ return row.seq
157
+ }
158
+
159
+ /** 主库里的每一个 status 常量:只有它算「正常区」。 */
160
+ export const MAIN_STATUS = 'approved'
161
+ /** 隔离区里的三个 status 常量。 */
162
+ export const QUARANTINE_STATUSES = ['pending', 'rejected', 'deleted']
163
+ /** 状态 → 它应该待在哪张表。 */
164
+ export function tableForStatus(status) {
165
+ return String(status) === MAIN_STATUS ? 'cards' : 'quarantine'
166
+ }
167
+
168
+ /**
169
+ * 迁移到两套存储(幂等,可重复执行,每次 getDb 都会跑一遍):
170
+ * 把主库里所有非 approved 的行搬进隔离区,并带上它们的 card_updates。
171
+ *
172
+ * 关键性质:**绝不删除任何内容**,只换表。老版本代码回滚后看不到隔离区(审核队列会
173
+ * 显示为空),但数据还在同一个 .db 文件里,升级回来即恢复。
174
+ */
175
+ export function migrateToQuarantine(db) {
176
+ let pending = []
177
+ try {
178
+ pending = db.prepare("SELECT id, path FROM cards WHERE status <> ?").all(MAIN_STATUS)
179
+ } catch { return 0 }
180
+ if (!pending.length) return 0
181
+
182
+ // 老库的 card_updates 有指向 cards(id) 的外键:把卡搬走时 FK 会拒绝删除主库行。
183
+ // 迁移期间**临时关掉外键校验**(SQLite 允许 PRAGMA foreign_keys 在无事务时切换),
184
+ // 搬完立刻恢复 —— 之后建表/写入仍受外键保护。
185
+ let fkWasOn = false
186
+ try {
187
+ fkWasOn = db.prepare('PRAGMA foreign_keys').get().foreign_keys === 1
188
+ if (fkWasOn) db.exec('PRAGMA foreign_keys = OFF')
189
+ } catch { fkWasOn = false }
190
+
191
+ const insertQuarantine = db.prepare(`
192
+ INSERT OR IGNORE INTO quarantine
193
+ (id, path, kind, title, tags, body, summary, status, source, submitted_by, severity, reason, created_at, updated_at, deleted_at, quarantined_at, quarantine_reason)
194
+ SELECT id, path, kind, title, tags, body, summary, status, source, submitted_by, severity, reason, created_at, updated_at, deleted_at, datetime('now'), '迁移:主库只保留 approved'
195
+ FROM cards WHERE id = ?
196
+ `)
197
+ const dropFromMain = db.prepare('DELETE FROM cards WHERE id = ?')
198
+
199
+ let moved = 0
200
+ for (const row of pending) {
201
+ try {
202
+ db.exec('BEGIN')
203
+ const info = insertQuarantine.run(row.id)
204
+ // card_updates 按 card_id 关联,id 不变 → 更新记录跟着卡一起走,无需改写
205
+ if (info.changes) moved++
206
+ dropFromMain.run(row.id)
207
+ db.exec('COMMIT')
208
+ } catch (e) {
209
+ try { db.exec('ROLLBACK') } catch { /* 已回滚 */ }
210
+ process.stderr.write(`[memory-eternal] 隔离迁移失败(${row.path}):${e?.message || e}\n`)
211
+ }
212
+ }
213
+ if (fkWasOn) { try { db.exec('PRAGMA foreign_keys = ON') } catch { /* 恢复失败:下次 getDb 会再设一次 */ } }
214
+ if (moved) process.stderr.write(`[memory-eternal] 已把 ${moved} 张未审核卡搬进隔离区(主库只留 approved)\n`)
215
+ return moved
216
+ }
217
+
218
+ /**
219
+ * 一致性不变量:主库只能有 approved。
220
+ * 供测试与「用量/今日」体检使用 —— 正常永远是 0,非 0 说明有代码绕过写入闸门。
221
+ */
222
+ export function checkMainStoreInvariant(root) {
223
+ const db = getDb(root)
224
+ try {
225
+ const bad = db.prepare('SELECT status, COUNT(*) n FROM cards WHERE status <> ? GROUP BY status').all(MAIN_STATUS)
226
+ const total = db.prepare('SELECT COUNT(*) n FROM cards').get().n
227
+ const quarantined = db.prepare('SELECT status, COUNT(*) n FROM quarantine GROUP BY status').all()
228
+ return { ok: bad.length === 0, violations: bad, mainTotal: total, quarantine: quarantined }
229
+ } catch (e) {
230
+ return { ok: false, violations: [], mainTotal: 0, quarantine: [], error: String(e?.message || e) }
231
+ }
85
232
  }
86
233
 
87
234
  /**