memory-eternal 0.9.17 → 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.
@@ -0,0 +1,90 @@
1
+ // 配置同步(共享文件协议):让「独立 Web 页」也能保存配置。
2
+ //
3
+ // 背景:独立 web server 进程里没有 DSH 的 settings 服务,`ctx.settings.update` 不存在,
4
+ // 所以它的 /config 过去只能只读 —— 用户点保存永远没反应(issue #12 的另一半)。
5
+ // 方案:独立端把改动写进 `memory-eternal-config.pending.json`,DSH 宿主激活时与运行期
6
+ // 轮询该文件,用 settings.update 应用后删除。DSH 没运行时改动留在文件里,下次启动生效。
7
+ import fs from 'node:fs'
8
+ import path from 'node:path'
9
+ import { configFilePath } from './capture-run.js'
10
+
11
+ /** 待应用配置文件的路径(与共享配置文件同目录)。 */
12
+ export function pendingConfigPath(env = process.env) {
13
+ return String(configFilePath(env)).replace(/\.json$/i, '.pending.json')
14
+ }
15
+
16
+ /** 读待应用改动;文件不存在/损坏都返回 null(不影响主流程)。 */
17
+ export function readPendingConfig(env = process.env) {
18
+ try {
19
+ const data = JSON.parse(fs.readFileSync(pendingConfigPath(env), 'utf8'))
20
+ if (!data || typeof data.patch !== 'object' || data.patch === null || Array.isArray(data.patch)) return null
21
+ return { at: Number(data.at) || 0, tries: Number(data.tries) || 0, patch: data.patch }
22
+ } catch { return null }
23
+ }
24
+
25
+ /** 原子写入待应用改动(tmp + rename),返回写入内容。 */
26
+ export function writePendingConfig(env, patch, now = Date.now(), tries = 0) {
27
+ const file = pendingConfigPath(env)
28
+ const data = tries > 0 ? { at: now, tries, patch } : { at: now, patch }
29
+ const tmp = file + '.' + process.pid + '.tmp'
30
+ fs.writeFileSync(tmp, JSON.stringify(data, null, 2), 'utf8')
31
+ fs.renameSync(tmp, file)
32
+ return data
33
+ }
34
+
35
+ /** 删除待应用文件;不存在也算成功。 */
36
+ export function clearPendingConfig(env = process.env) {
37
+ try { fs.unlinkSync(pendingConfigPath(env)); return true } catch { return false }
38
+ }
39
+
40
+ /**
41
+ * 监听待应用文件的变化(毫秒级触发),用于把「独立页保存 → DSH 应用」从 5 秒轮询
42
+ * 提升到准即时。监听失败(平台/权限)时退化为不监听 —— 仍有 5 秒轮询兜底。
43
+ * @returns {() => void} 停止监听
44
+ */
45
+ export function watchPendingConfig(env, onChange, { debounceMs = 60 } = {}) {
46
+ const file = pendingConfigPath(env)
47
+ let timer = null
48
+ let watcher = null
49
+ const fire = () => {
50
+ if (timer) clearTimeout(timer)
51
+ timer = setTimeout(() => { try { onChange() } catch { /* 回调异常不影响监听 */ } }, debounceMs)
52
+ }
53
+ try {
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())
59
+ if (typeof watcher.unref === 'function') watcher.unref()
60
+ } catch { return () => {} }
61
+ return () => {
62
+ if (timer) clearTimeout(timer)
63
+ try { watcher.close() } catch { /* 已关闭 */ }
64
+ }
65
+ }
66
+
67
+ /**
68
+ * 取出并应用一次待应用改动:apply(patch) 成功后删除文件;失败保留(下轮重试)。
69
+ * @returns {Promise<object|null>} 应用成功的 patch,没有待应用项时返回 null
70
+ */
71
+ export async function drainPendingConfig(env, apply, maxTries = 5) {
72
+ const pending = readPendingConfig(env)
73
+ if (!pending) return null
74
+ try {
75
+ await apply(pending.patch)
76
+ } catch (error) {
77
+ const tries = (pending.tries || 0) + 1
78
+ if (tries >= maxTries) {
79
+ // 连续失败(多半是值不合法):放弃并删掉,否则会每 5 秒无限重试
80
+ clearPendingConfig(env)
81
+ const dropped = new Error('待应用配置连续失败 ' + tries + ' 次已放弃:' + String((error && error.message) || error))
82
+ dropped.dropped = true
83
+ throw dropped
84
+ }
85
+ writePendingConfig(env, pending.patch, pending.at, tries)
86
+ throw error
87
+ }
88
+ clearPendingConfig(env)
89
+ return pending.patch
90
+ }
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
  /**
package/lib/sse.js ADDED
@@ -0,0 +1,51 @@
1
+ // SSE 推送中心:把「配置/卡片变更」即时推给所有已打开的页面(不必手动刷新)。
2
+ // 两个宿主(DSH 内嵌 + 独立 web)共用同一套实现;纯逻辑,便于单测(注入假 res)。
3
+ export function createHub({ heartbeatMs = 25000 } = {}) {
4
+ const clients = new Set()
5
+ let timer = null
6
+ const send = (res, chunk) => {
7
+ try { res.write(chunk); return true } catch { return false }
8
+ }
9
+ const drop = (res) => {
10
+ clients.delete(res)
11
+ try { res.end() } catch { /* 已断开 */ }
12
+ }
13
+ const ensureTimer = () => {
14
+ if (timer || !(heartbeatMs > 0)) return
15
+ timer = setInterval(() => {
16
+ for (const res of [...clients]) if (!send(res, ': ping\n\n')) drop(res)
17
+ }, heartbeatMs)
18
+ if (typeof timer.unref === 'function') timer.unref()
19
+ }
20
+ return {
21
+ /** 接入一个连接(保持长连接,不要 end)。 */
22
+ add(res) {
23
+ try {
24
+ res.writeHead(200, {
25
+ 'Content-Type': 'text/event-stream; charset=utf-8',
26
+ 'Cache-Control': 'no-store',
27
+ Connection: 'keep-alive',
28
+ 'X-Accel-Buffering': 'no',
29
+ })
30
+ } catch { return false }
31
+ if (!send(res, 'retry: 3000\n\n')) return false
32
+ clients.add(res)
33
+ ensureTimer()
34
+ const onClose = () => drop(res)
35
+ try { res.on('close', onClose); res.on('error', onClose) } catch { /* 某些响应对象没有 on */ }
36
+ return true
37
+ },
38
+ /** 广播事件(event: <name>,data: JSON)。返回当前连接数。 */
39
+ broadcast(event, data) {
40
+ const chunk = 'event: ' + event + '\ndata: ' + JSON.stringify(data === undefined ? {} : data) + '\n\n'
41
+ for (const res of [...clients]) if (!send(res, chunk)) drop(res)
42
+ return clients.size
43
+ },
44
+ get size() { return clients.size },
45
+ close() {
46
+ if (timer) clearInterval(timer)
47
+ timer = null
48
+ for (const res of [...clients]) drop(res)
49
+ },
50
+ }
51
+ }