@modusensus/dsh-mneme 0.9.4 → 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.
Files changed (126) hide show
  1. package/README.md +57 -10
  2. package/bin/cli.mjs +215 -2
  3. package/docs/CONFIGURATION.md +25 -2
  4. package/docs/DAEMON.md +13 -2
  5. package/docs/DISTILL.md +150 -4
  6. package/docs/SHARE.md +552 -0
  7. package/docs/SLEEP.md +21 -7
  8. package/docs/STORAGE.md +2 -1
  9. package/lib/api-standalone.js +294 -8
  10. package/lib/api.js +217 -3
  11. package/lib/client.js +919 -39
  12. package/lib/config.js +33 -1
  13. package/lib/continuity.js +11 -3
  14. package/lib/document.js +15 -2
  15. package/lib/dream/decisions.js +176 -30
  16. package/lib/dream/sleep.js +530 -94
  17. package/lib/dream.js +174 -21
  18. package/lib/fence.js +24 -0
  19. package/lib/index.js +15 -6
  20. package/lib/inject.js +9 -4
  21. package/lib/lang.js +38 -16
  22. package/lib/memory-source.js +9 -0
  23. package/lib/organize.js +5 -1
  24. package/lib/pipeline-nudge-auto.js +3 -0
  25. package/lib/pipeline-nudge.js +41 -8
  26. package/lib/pipeline-runner.js +15 -0
  27. package/lib/pipeline.js +62 -1
  28. package/lib/pre-inject-gate.js +14 -0
  29. package/lib/profile-extract.js +4 -1
  30. package/lib/provenance-http.js +251 -0
  31. package/lib/serve.js +13 -0
  32. package/lib/service.js +97 -4
  33. package/lib/session-gate.js +91 -0
  34. package/lib/settings.js +13 -1
  35. package/lib/share-http.js +281 -0
  36. package/lib/share-import.js +234 -0
  37. package/lib/share.js +335 -0
  38. package/lib/store.js +797 -28
  39. package/lib/summarize.js +209 -23
  40. package/lib/tools.js +26 -6
  41. package/package.json +4 -2
  42. package/src/api-standalone.js +294 -8
  43. package/src/api.js +217 -3
  44. package/src/config.js +33 -1
  45. package/src/continuity.js +11 -3
  46. package/src/document.js +15 -2
  47. package/src/dream/decisions.js +176 -30
  48. package/src/dream/sleep.js +530 -94
  49. package/src/dream.js +174 -21
  50. package/src/fence.js +24 -0
  51. package/src/index.js +15 -6
  52. package/src/inject.js +9 -4
  53. package/src/lang.js +38 -16
  54. package/src/memory-source.js +9 -0
  55. package/src/organize.js +5 -1
  56. package/src/pipeline-nudge-auto.js +3 -0
  57. package/src/pipeline-nudge.js +41 -8
  58. package/src/pipeline-runner.js +15 -0
  59. package/src/pipeline.js +62 -1
  60. package/src/pre-inject-gate.js +14 -0
  61. package/src/profile-extract.js +4 -1
  62. package/src/provenance-http.js +251 -0
  63. package/src/serve.js +13 -0
  64. package/src/service.js +97 -4
  65. package/src/session-gate.js +91 -0
  66. package/src/settings.js +13 -1
  67. package/src/share-http.js +281 -0
  68. package/src/share-import.js +234 -0
  69. package/src/share.js +335 -0
  70. package/src/store.js +797 -28
  71. package/src/summarize.js +209 -23
  72. package/src/tools.js +26 -6
  73. package/test/api.test.js +523 -6
  74. package/test/audit.test.js +2 -2
  75. package/test/cli-receive.test.js +390 -0
  76. package/test/cli-share.test.js +437 -0
  77. package/test/client-share.test.js +225 -0
  78. package/test/client.test.js +354 -0
  79. package/test/config.test.js +8 -0
  80. package/test/conflict-reviewed.test.js +79 -0
  81. package/test/continuity.test.js +29 -1
  82. package/test/dedupe-candidate-window.test.js +180 -0
  83. package/test/dependency-overrides.test.js +78 -0
  84. package/test/differentiate-feedback.test.js +248 -0
  85. package/test/dream-candidate-hybrid.test.js +2 -2
  86. package/test/dream-decision-evidence.test.js +314 -0
  87. package/test/dream-fence.test.js +207 -0
  88. package/test/dream.test.js +87 -5
  89. package/test/helpers/dream-mock.js +6 -2
  90. package/test/inject-pools.test.js +2 -1
  91. package/test/llm-message-source.test.js +1 -1
  92. package/test/mint-source.test.js +84 -1
  93. package/test/pipeline-nudge-auto.test.js +26 -2
  94. package/test/pipeline-nudge.test.js +87 -0
  95. package/test/pipeline-r1r2.test.js +129 -0
  96. package/test/pipeline-runner.test.js +26 -0
  97. package/test/policy-epoch.test.js +2 -2
  98. package/test/prompt-stance.test.js +128 -0
  99. package/test/provenance-http.test.js +367 -0
  100. package/test/readme-claims.test.js +45 -0
  101. package/test/reasoning-effort.test.js +10 -10
  102. package/test/release-docs.test.js +7 -1
  103. package/test/scope-candidates.test.js +7 -4
  104. package/test/scope-state.test.js +130 -1
  105. package/test/security-doc.test.js +43 -4
  106. package/test/serve.test.js +62 -0
  107. package/test/session-gate.test.js +264 -0
  108. package/test/settings.test.js +4 -0
  109. package/test/share-foreign-rows.test.js +144 -0
  110. package/test/share-http.test.js +144 -0
  111. package/test/share-import.test.js +577 -0
  112. package/test/share-imported-guard.test.js +311 -0
  113. package/test/share-imported-identity.test.js +111 -0
  114. package/test/share-pack.test.js +511 -0
  115. package/test/share-receive-http.test.js +756 -0
  116. package/test/share-routes.test.js +337 -0
  117. package/test/share-store.test.js +133 -0
  118. package/test/share-validate.test.js +101 -0
  119. package/test/sleep-action-set.test.js +16 -11
  120. package/test/sleep-catchup.test.js +315 -0
  121. package/test/sleep-failure-backoff.test.js +156 -0
  122. package/test/sleep.test.js +293 -0
  123. package/test/standalone-api.test.js +574 -5
  124. package/test/store.test.js +220 -0
  125. package/test/summarize-parse-diagnostics.test.js +75 -0
  126. package/test/summarize.test.js +187 -2
@@ -0,0 +1,337 @@
1
+ // 分享包的**两张皮**路由锁。盯四件事:
2
+ // a) policy 归对(token —— 它是全库级读取,与 /export 同门,见 2026-10 审计 P0);
3
+ // b) token 配上时**不带 token 必须 401**,不是静默放行;
4
+ // c) readBody 返回 null(超 4 MiB,它自己已答 413)时 handler 必须直接 return,
5
+ // 不能再答一次 —— 双写响应是 #410 那一类故障;
6
+ // d) 500 必须留下日志:shareReply 把抛错兜成 500,不接 warn 回调时那条 500 是静默的
7
+ // (现场只剩「内部错误」,查不出是哪一次打包炸的)。同款契约见 reclaim 的 500 用例。
8
+ // 装配台照 test/api-routes-gaps.test.js 的 reclaimSetup,**必须落文件库**:`:memory:`
9
+ // 与文件库在 store.all() 的读侧口径上没差别,但分享包吃的正是「全库含归档/已遗忘」,
10
+ // 用内存库时归档位的字节口径与体积水位都摸不到真实数字(同 reclaim 报告的理由)。
11
+ //
12
+ // 后半截是 Bearer 数据面(src/api-standalone.js)的真 HTTP 装配,起服务与读 token 的
13
+ // 形状照 test/standalone-api.test.js。放同一份文件里是有意的:#411 说两张皮只许差在
14
+ // 鉴权与路径,那么「同一份答复」就是两侧共同的不变量——分开两个文件时,改一侧的人
15
+ // 看不见另一侧锁着什么。
16
+ import test from "node:test";
17
+ import assert from "node:assert/strict";
18
+ import { EventEmitter } from "node:events";
19
+ import { mkdtempSync, rmSync } from "node:fs";
20
+ import { tmpdir } from "node:os";
21
+ import { join } from "node:path";
22
+ import { createStore } from "../src/store.js";
23
+ import { createService } from "../src/service.js";
24
+ import { createSettings } from "../src/settings.js";
25
+ import { createApi } from "../src/api.js";
26
+ import { createStandaloneApi } from "../src/api-standalone.js";
27
+
28
+ const SHARE_PATH = "/api/dsh-mneme/share";
29
+ const MAX_BODY_BYTES = 4 * 1024 * 1024;
30
+
31
+ class FakeRes extends EventEmitter {
32
+ constructor({ failFirstHead = false } = {}) {
33
+ super();
34
+ this.statusCode = 0;
35
+ this.body = "";
36
+ this.headers = null;
37
+ // writeHead 计数:双写响应的故障形态是「第二次 writeHead 覆盖掉第一次的状态码」,
38
+ // 只看最终 statusCode 抓不住(413 之后又被 200 盖掉时 body 也换了),要数次数。
39
+ this.heads = 0;
40
+ // 一次性坏掉的 sink:真 socket 上「一个响应两写」的现场形态就是第二次 writeHead 抛
41
+ // ERR_HTTP_HEADERS_SENT(Node 的 writeHead 当场把头发出去)。这里让第一次抛、第二次
42
+ // 正常,好把「promise 链上的错误到底有没有人接」单独问出来;抛掉的那一次不计数,
43
+ // 因为它一个字节都没写出去。
44
+ this.failFirstHead = failFirstHead;
45
+ // 数据面的 handler 不返回 promise(那条链是 `void readBody(...)`),所以等答复只能靠
46
+ // 这一声 end:配超时竞速,缺 .catch 时用例红得干脆,而不是把文件挂死在无限超时上。
47
+ this.settled = new Promise((resolve) => { this.settle = resolve; });
48
+ }
49
+ writeHead(code, headers) {
50
+ if (this.failFirstHead) {
51
+ this.failFirstHead = false;
52
+ throw new Error("sink on fire");
53
+ }
54
+ this.statusCode = code;
55
+ this.headers = headers;
56
+ this.heads += 1;
57
+ return this;
58
+ }
59
+ end(text) {
60
+ this.body = text ?? "";
61
+ this.emit("end");
62
+ this.settle?.("ended");
63
+ return this;
64
+ }
65
+ }
66
+
67
+ function shareSetup(apiToken = "secret-token", { logger = null } = {}) {
68
+ const dir = mkdtempSync(join(tmpdir(), "dsh-mneme-share-api-"));
69
+ const store = createStore(join(dir, "memory.db"));
70
+ const service = createService({ store, mirror: null, config: {} });
71
+ const settings = createSettings(store.db);
72
+ const commands = { add: () => {}, remove: () => {}, list: () => [] };
73
+ const routes = [];
74
+ const ctx = { logger, webServer: { register(route) { routes.push(route); return () => {}; } } };
75
+ createApi(ctx, service, settings, commands, null, undefined, apiToken, null, null);
76
+ return {
77
+ store, service, routes,
78
+ // 闭包拿 store,不用 this:裸函数调用下 this 是 undefined,异常被吞掉就会变成
79
+ // 「库没关就删目录」的 EPERM(清理失败还说不清原因)。
80
+ close() {
81
+ try { store.close(); } catch { /* already closed */ }
82
+ rmSync(dir, { recursive: true, force: true });
83
+ }
84
+ };
85
+ }
86
+
87
+ /**
88
+ * 字节必须在 listener 挂上之后再 emit:EventEmitter 不排队,在 `route.handler` 里
89
+ * readBody 注册 data/end 之前 emit 出去等于什么都没发,readBody 的 promise 永不
90
+ * resolve,整个用例会挂到超时——失败原因还完全看不出跟分享有关。
91
+ */
92
+ function shareReq({ method = "POST", url = SHARE_PATH, body = "", authorized = true } = {}) {
93
+ const req = new EventEmitter();
94
+ req.method = method;
95
+ req.url = url;
96
+ req.headers = authorized ? { authorization: "Bearer secret-token" } : {};
97
+ process.nextTick(() => {
98
+ req.emit("data", Buffer.from(body));
99
+ req.emit("end");
100
+ });
101
+ return req;
102
+ }
103
+
104
+ async function callShare(route, opts = {}) {
105
+ const res = new FakeRes({ failFirstHead: opts.failFirstHead === true });
106
+ let rejection = null;
107
+ try {
108
+ await route.handler(shareReq(opts), res);
109
+ } catch (error) {
110
+ // 把 rejection 收成读数而不是让它冒出去:少了 .catch 时这里就是 #410 的形状——
111
+ // 断言「rejection 必须为 null」比让用例以一个看不懂的异常挂掉说得清得多。
112
+ rejection = String(error);
113
+ }
114
+ return {
115
+ status: res.statusCode,
116
+ body: res.body ? JSON.parse(res.body) : null,
117
+ headers: res.headers,
118
+ heads: res.heads,
119
+ rejection
120
+ };
121
+ }
122
+
123
+ function shareRoute(setup) {
124
+ const route = setup.routes.find((r) => r.path === SHARE_PATH);
125
+ assert.ok(route, "route must exist");
126
+ return route;
127
+ }
128
+
129
+ test("share route is registered with policy token", () => {
130
+ const s = shareSetup();
131
+ try {
132
+ const route = shareRoute(s);
133
+ assert.equal(route.policy, "token");
134
+ assert.equal(route.kind, "exact");
135
+ } finally { s.close(); }
136
+ });
137
+
138
+ test("a configured token gates the share route", async () => {
139
+ const s = shareSetup();
140
+ try {
141
+ const route = shareRoute(s);
142
+ s.store.save({ type: "decision", title: "选 SQLite", content: "正文", tags: [] });
143
+ const denied = await callShare(route, { authorized: false, body: "" });
144
+ assert.equal(denied.status, 401);
145
+ const allowed = await callShare(route, { authorized: true, body: "" });
146
+ assert.equal(allowed.status, 200);
147
+ const pack = allowed.body;
148
+ assert.equal(pack.kind, "mneme.share/v1");
149
+ assert.equal(pack.report.included, 1);
150
+ assert.ok(!("id" in pack.memories[0]));
151
+ // 本侧 sendJson 有 headers 第四参(数据面没有):Content-Disposition 是面板这张脸
152
+ // 独有的那半,共用层只回 {status, body},文件名由这里拼——锁它,防止哪天把
153
+ // shareFilename 的连接也一起「统一」掉。
154
+ assert.match(allowed.headers["Content-Disposition"], /^attachment; filename="dsh-mneme-share-\d{8}-[0-9a-f]{8}\.json"$/);
155
+ // 空 body 与「一个字节都不发」同口径:分享的主用法就是按整库口径打。
156
+ const alias = await callShare(route, { body: JSON.stringify({ alias: "桉桉的库" }) });
157
+ assert.equal(alias.status, 200);
158
+ assert.equal(alias.body.alias, "桉桉的库");
159
+ assert.match(alias.body.source_version, /\d/, "包要记下发送方版本,对面按它判兼容");
160
+ assert.ok(Number.isFinite(Date.parse(alias.body.generated_at)));
161
+ } finally { s.close(); }
162
+ });
163
+
164
+ test("GET on the share route is not an open read", async () => {
165
+ const s = shareSetup();
166
+ try {
167
+ const route = shareRoute(s);
168
+ s.store.save({ type: "decision", title: "选 SQLite", content: "正文", tags: [] });
169
+ const out = await callShare(route, { method: "GET", body: "" });
170
+ // 状态码锁死 405,不留 404 这个后门:#411 说两张皮只许差在鉴权与路径,
171
+ // 而数据面那半(本文件末尾)已经只认 405。这里容忍 404 就等于让「面板哪天
172
+ // 掉回末尾 404」也算通过——两张皮悄悄多出一处差别,没人报警。
173
+ assert.equal(out.status, 405, `GET 必须回 405,got ${out.status}`);
174
+ assert.ok(!out.body || !("memories" in out.body), "GET 不许把包交出去");
175
+ } finally { s.close(); }
176
+ });
177
+
178
+ test("a body this layer cannot read is a 400 with one code, not a pack", async () => {
179
+ const s = shareSetup();
180
+ try {
181
+ const route = shareRoute(s);
182
+ s.store.save({ type: "decision", title: "选 SQLite", content: "正文", tags: [] });
183
+ for (const raw of ["{oops", "[1,2]", "null"]) {
184
+ const out = await callShare(route, { body: raw });
185
+ assert.equal(out.status, 400, `${raw} 必须被拒`);
186
+ assert.equal(out.headers?.["Content-Type"], "application/json; charset=utf-8");
187
+ assert.ok(!("memories" in out.body), "拒答里绝不带包体");
188
+ }
189
+ } finally { s.close(); }
190
+ });
191
+
192
+ test("an oversize body answers 413 exactly once", async () => {
193
+ const s = shareSetup();
194
+ try {
195
+ const route = shareRoute(s);
196
+ s.store.save({ type: "decision", title: "选 SQLite", content: "正文", tags: [] });
197
+ // readBody 超限时会自己答 413 并把 text 解析成 null。handler 若没有
198
+ // `if (text === null) return`,它会拿 null 继续往下走:parseShareBody(null) 是
199
+ // 合法的「空 body」,于是整库进包、再答一次 200 —— 一个响应两写,客户端按
200
+ // 状态码分支时看到的是最后那一次(#410 修过的同一类故障)。
201
+ const out = await callShare(route, { body: "x".repeat(MAX_BODY_BYTES + 1) });
202
+ assert.equal(out.heads, 1, "超限时只许有一次响应写");
203
+ assert.equal(out.status, 413);
204
+ assert.equal(out.body.error, "payload-too-large");
205
+ } finally { s.close(); }
206
+ });
207
+
208
+ test("a throwing pack build answers 500 and leaves a log line", async () => {
209
+ // 不带 logger 也照答 500(shareReply 的 warn 是可选的),但带时必须真的写日志:
210
+ // 否则「500 且无迹可查」这条现场就无从下手。
211
+ const warnings = [];
212
+ const s = shareSetup("secret-token", { logger: { warn: (m) => warnings.push(String(m)) } });
213
+ try {
214
+ const route = shareRoute(s);
215
+ s.service.all = () => { throw new Error("database is locked"); };
216
+ const out = await callShare(route, { body: "" });
217
+ assert.equal(out.status, 500);
218
+ assert.deepEqual(out.body, { error: "internal" }, "对外只有一个码,不带原始错误文本");
219
+ assert.match(warnings.join(" "), /share pack failed/);
220
+ assert.match(warnings.join(" "), /database is locked/, "原始错误文本要留在日志里");
221
+ } finally { s.close(); }
222
+ });
223
+
224
+ test("the panel /share chain answers once when the sink fails mid-write", async () => {
225
+ // 车道 A 的这条路由只有外层 try,而外层 try 在 .then 的回调里早已退栈:回调里抛出的错
226
+ // (这里是 sendJson 的 writeHead 抛——真 socket 上「一个响应两写」的第二次就是这个形状)
227
+ // 没人接,就是一个 unhandled rejection 加「一个字节都没回」(#410)。数据面的同名路由
228
+ // (本文件末尾)一直带着那一声 .catch,两条皮在这里本该只差鉴权与路径(#411),
229
+ // 所以补的是面板这一侧。C6 之后 warn 已被共用层守卫包住,日志坏掉那条路走不到这里,
230
+ // 这个 sink 形状是 .catch 唯一还能被独立问到的入口。
231
+ const s = shareSetup();
232
+ try {
233
+ const route = shareRoute(s);
234
+ s.store.save({ type: "decision", title: "选 SQLite", content: "正文 here", tags: [] });
235
+ const out = await callShare(route, { body: "", failFirstHead: true });
236
+ assert.equal(out.rejection, null, "回调里抛出的错必须由链上的 .catch 接住");
237
+ assert.equal(out.status, 500, "sink 坏了也要把答复交出去");
238
+ assert.deepEqual(out.body, { error: "internal" });
239
+ assert.equal(out.heads, 1, "抛掉的那一次没写出去,成功的只许有一次");
240
+ } finally { s.close(); }
241
+ });
242
+
243
+ // --- Bearer 数据面(src/api-standalone.js)-----------------------------------
244
+
245
+ function standaloneSetup() {
246
+ const dir = mkdtempSync(join(tmpdir(), "dsh-mneme-share-std-"));
247
+ const store = createStore(join(dir, "memory.db"));
248
+ const service = createService({ store, mirror: null, config: {} });
249
+ const settings = createSettings(store.db);
250
+ // 起法照 test/standalone-api.test.js:这个模块没有 start()/stop(),监听在构造里
251
+ // 发起、由 ready 兑现,端口是 OS 给的(port: 0),token 挂在 api 上。
252
+ const api = createStandaloneApi({ service, store, config: {}, settings, logger: null, port: 0 });
253
+ return api.ready.then(() => {
254
+ const base = `http://127.0.0.1:${api.port}`;
255
+ return {
256
+ api, store, service, base, auth: { authorization: `Bearer ${api.token}` },
257
+ close() {
258
+ // 拆掉 keep-alive 连接,否则 node --test 的循环排不干净(同那份文件)。
259
+ api.server.closeIdleConnections?.();
260
+ api.server.close();
261
+ try { store.close(); } catch { /* already closed */ }
262
+ rmSync(dir, { recursive: true, force: true });
263
+ }
264
+ };
265
+ });
266
+ }
267
+
268
+ test("standalone /share answers the pack for a Bearer caller and 401 otherwise", async () => {
269
+ const h = await standaloneSetup();
270
+ try {
271
+ h.store.save({ type: "decision", title: "选 SQLite", content: "正文 here", tags: [] });
272
+ const anon = await fetch(`${h.base}/share`, { method: "POST", body: "{}" });
273
+ assert.equal(anon.status, 401, "这一面除 GET /health 外一律要 token,没有 policy 名可绕");
274
+ assert.deepEqual(await anon.json(), { error: "unauthorized" });
275
+
276
+ const ok = await fetch(`${h.base}/share`, {
277
+ method: "POST", headers: h.auth, body: JSON.stringify({ alias: "桉" })
278
+ });
279
+ assert.equal(ok.status, 200);
280
+ const pack = await ok.json();
281
+ assert.equal(pack.kind, "mneme.share/v1", "两张皮交出去的是同一个包形状");
282
+ assert.equal(pack.alias, "桉");
283
+ assert.equal(pack.report.included, 1);
284
+ // 本面**没有** Content-Disposition:它的 sendJson 连 headers 参数都没有,CLI 拿
285
+ // JSON 自己落盘。锁「不许冒出来」,防止哪天有人为了对齐面板而改那个签名。
286
+ assert.equal(ok.headers.get("content-disposition"), null);
287
+
288
+ // 读不懂的 body 在这侧同样是 400 一个码,而不是掉回末尾的 404。
289
+ for (const raw of ["{oops", "[1,2]", "null"]) {
290
+ const bad = await fetch(`${h.base}/share`, { method: "POST", headers: h.auth, body: raw });
291
+ assert.equal(bad.status, 400, `${raw} 必须被拒`);
292
+ assert.ok(!("memories" in await bad.json()), "拒答里绝不带包体");
293
+ }
294
+ } finally { await h.close(); }
295
+ });
296
+
297
+ test("standalone GET /share answers 405, matching the panel", async () => {
298
+ // 这一条**故意**与 /maintenance/reclaim 不同(那边 GET 掉到末尾 404)。#411 的口径
299
+ // 是「两张皮只许差在鉴权与路径」,状态码差一次就是第三处差别,所以这里跟着面板回
300
+ // 405 —— 一致性优先于照抄这份文件里的邻居。
301
+ const h = await standaloneSetup();
302
+ try {
303
+ h.store.save({ type: "decision", title: "选 SQLite", content: "正文 here", tags: [] });
304
+ const out = await fetch(`${h.base}/share`, { headers: h.auth });
305
+ assert.equal(out.status, 405);
306
+ const body = await out.json();
307
+ assert.ok(!("memories" in body), "GET 不许把包交出去");
308
+ } finally { await h.close(); }
309
+ });
310
+
311
+ test("the standalone /share chain answers once when the sink fails mid-write", async () => {
312
+ // 本面这条链一直带着 .catch,但**从来没有任何用例走到得过它**:共用层从不抛、日志也
313
+ // 被守卫吞掉之后,剩下的入口只有面自己写答复这一步(真 socket 上「一个响应两写」的
314
+ // 第二次 writeHead 就是这里的形状)。与上面面板那条同名用例是一对——两张皮的 .catch
315
+ // 各锁各的,少一边就只有一边会红(#411)。假 sink 第一次抛、第二次正常;等得到答复
316
+ // 就说明链上有主,等不到(输给超时)就是 #410 的「一个字节都没回」。
317
+ const h = await standaloneSetup();
318
+ try {
319
+ h.store.save({ type: "decision", title: "选 SQLite", content: "正文 here", tags: [] });
320
+ const handlers = h.api.server.listeners("request");
321
+ assert.equal(handlers.length, 1, "这台面上只许挂着一个 request handler,否则不知道在驱动谁");
322
+ const req = new EventEmitter();
323
+ req.method = "POST";
324
+ req.url = "/share";
325
+ req.headers = { authorization: `Bearer ${h.api.token}` };
326
+ const res = new FakeRes({ failFirstHead: true });
327
+ handlers[0](req, res);
328
+ process.nextTick(() => {
329
+ req.emit("data", Buffer.from("{}"));
330
+ req.emit("end");
331
+ });
332
+ const settled = await Promise.race([res.settled, new Promise((r) => setTimeout(() => r("timeout"), 3000))]);
333
+ assert.equal(settled, "ended", "缺了 .catch 就永远等不到答复");
334
+ assert.equal(res.statusCode, 500);
335
+ assert.equal(res.heads, 1, "抛掉的那一次没写出去,成功的只许有一次");
336
+ } finally { await h.close(); }
337
+ });
@@ -0,0 +1,133 @@
1
+ // 车道 C 的存储层。这里盯的不是「列在不在」,而是三件容易写错的事:
2
+ // a) 存量库重开即建列,且存量行的新列是 NULL 而不是被填成某个假值;
3
+ // b) UNIQUE 索引只对外来行生效——普通本地行必须能无限多条共享 NULL;
4
+ // c) 批删清 side table 且 generation 前移,否则镜像恢复会把删掉的行当「没跟上」。
5
+ import test from "node:test";
6
+ import assert from "node:assert/strict";
7
+ import { mkdtempSync, rmSync } from "node:fs";
8
+ import { tmpdir } from "node:os";
9
+ import { join } from "node:path";
10
+ import { DatabaseSync } from "node:sqlite";
11
+ import { createStore } from "../src/store.js";
12
+ import { IMPORTED_SOURCE } from "../src/memory-source.js";
13
+
14
+ const temp = () => mkdtempSync(join(tmpdir(), "dsh-mneme-share-store-"));
15
+
16
+ test("an existing library gains both columns on reopen, legacy rows stay NULL", () => {
17
+ const dir = temp(), path = join(dir, "memory.db");
18
+ const raw = new DatabaseSync(path);
19
+ raw.exec(`CREATE TABLE memories (
20
+ id TEXT PRIMARY KEY, type TEXT NOT NULL, title TEXT NOT NULL, content TEXT NOT NULL,
21
+ tags TEXT NOT NULL DEFAULT '[]', importance INTEGER NOT NULL DEFAULT 3,
22
+ forgotten INTEGER NOT NULL DEFAULT 0, archived INTEGER NOT NULL DEFAULT 0,
23
+ archived_at TEXT, source TEXT, content_history TEXT, content_hash TEXT, embedding TEXT,
24
+ epistemic_status TEXT NOT NULL DEFAULT 'subjective', last_accessed_at TEXT,
25
+ _full_content TEXT, evidence TEXT, doc_path TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL);`);
26
+ raw.prepare(`INSERT INTO memories (id, type, title, content, created_at, updated_at)
27
+ VALUES ('legacy-1','decision','旧条目','正文','2026-01-01T00:00:00.000Z','2026-01-01T00:00:00.000Z')`).run();
28
+ raw.close();
29
+
30
+ const store = createStore(path);
31
+ try {
32
+ const row = store.getById("legacy-1");
33
+ assert.equal(row.share_pack_id, undefined, "legacy row must not be stamped as imported");
34
+ assert.equal(row.share_external_id, undefined);
35
+ // toRow 读不到列时也返回 undefined,所以「列真的建了」「建的是 NULL 而不是 ''」
36
+ // 这两件事只能在库里看——否则本用例对迁移毫无判别力。
37
+ const cols = store.db.prepare("PRAGMA table_info(memories)").all().map((c) => c.name);
38
+ assert.ok(cols.includes("share_pack_id") && cols.includes("share_external_id"),
39
+ "reopen must add both columns to a legacy library");
40
+ const stored = store.db.prepare("SELECT share_pack_id, share_external_id AS e FROM memories WHERE id = 'legacy-1'").get();
41
+ assert.equal(stored.share_pack_id, null, "legacy cells are NULL, not '' (empty string would collide in the UNIQUE index)");
42
+ assert.equal(stored.e, null);
43
+ } finally { store.close(); }
44
+ // 幂等重开:第二个连接开在同一条路径上,列已在,迁移不许再动它一根手指。
45
+ const again = createStore(path);
46
+ try {
47
+ assert.ok(again.getById("legacy-1"));
48
+ assert.equal(again.getById("legacy-1").share_pack_id, undefined);
49
+ } finally { again.close(); rmSync(dir, { recursive: true, force: true }); }
50
+ });
51
+
52
+ test("foreign rows are unique per external_id while local rows share NULL freely", () => {
53
+ const dir = temp(), store = createStore(join(dir, "memory.db"));
54
+ try {
55
+ store.save({ type: "decision", title: "a", content: "x", source: IMPORTED_SOURCE, share_external_id: "deadbeef00000001" });
56
+ assert.throws(() => store.save({ type: "decision", title: "b", content: "y", source: IMPORTED_SOURCE, share_external_id: "deadbeef00000001" }),
57
+ /UNIQUE constraint failed/);
58
+ for (const t of ["p1", "p2", "p3"]) store.save({ type: "decision", title: t, content: "x" });
59
+ assert.equal(store.list({ type: "decision", limit: null }).length, 4, "NULL must not collide");
60
+ } finally { store.close(); rmSync(dir, { recursive: true, force: true }); }
61
+ });
62
+
63
+ test("share columns round-trip through toRow and are queryable", () => {
64
+ const dir = temp(), store = createStore(join(dir, "memory.db"));
65
+ try {
66
+ const row = store.save({ type: "preference", title: "t", content: "c", source: IMPORTED_SOURCE,
67
+ share_pack_id: "packA", share_external_id: "deadbeef00000002" });
68
+ assert.equal(row.share_pack_id, "packA");
69
+ assert.equal(row.share_external_id, "deadbeef00000002");
70
+ assert.equal(store.getByShareExternalId("deadbeef00000002").id, row.id);
71
+ assert.equal(store.getByShareExternalId("nope"), undefined);
72
+ store.save({ type: "preference", title: "t2", content: "c2", source: IMPORTED_SOURCE, share_pack_id: "packA", share_external_id: "deadbeef00000003" });
73
+ assert.deepEqual(store.listBySharePackId("packA").map((m) => m.share_external_id).sort(),
74
+ ["deadbeef00000002", "deadbeef00000003"]);
75
+ } finally { store.close(); rmSync(dir, { recursive: true, force: true }); }
76
+ });
77
+
78
+ test("deleteBySharePackId removes the whole pack, cleans provenance, and bumps generation", () => {
79
+ const dir = temp(), store = createStore(join(dir, "memory.db"));
80
+ try {
81
+ const a = store.save({ type: "preference", title: "t1", content: "c", source: IMPORTED_SOURCE, share_pack_id: "packA", share_external_id: "deadbeef00000011" });
82
+ const b = store.save({ type: "preference", title: "t2", content: "c", source: IMPORTED_SOURCE, share_pack_id: "packA", share_external_id: "deadbeef00000012" });
83
+ store.save({ type: "preference", title: "keep", content: "c", source: IMPORTED_SOURCE, share_pack_id: "packB", share_external_id: "deadbeef00000013" });
84
+ // 归档面也要一起收走:整包退出之后不许留下「来自一个已经收起的包」的行。
85
+ // 行数用库级 count 而不是 store.count()——后者按活跃面过滤,归档行不在账上,
86
+ // 拿它当「删了几行」的锚会把断言变成过滤器口径的探针(那不是本用例要锁的东西)。
87
+ store.setArchived(b.id, true);
88
+ const totalRows = () => store.db.prepare("SELECT count(*) AS c FROM memories").get().c;
89
+ // 真实 schema 的必填列是 (memory_id, session_id, seq_start, seq_end, created_at) 全部
90
+ // NOT NULL,走 store 自己的写入口 saveMemoryProvenance,不走手搓 INSERT。
91
+ //
92
+ // 这里先把 foreign_keys 关掉,不是随手配置:memory_provenance 挂着
93
+ // ON DELETE CASCADE,而 node:sqlite 的 DatabaseSync 默认就把 PRAGMA foreign_keys 开成
94
+ // 1(实测:新库上 `PRAGMA foreign_keys` 返回 1)。FK 开着时删记忆由级联代劳,本用例
95
+ // 断言的「批删自己清链接」就永远测不到那行代码——变异检验证实:摘掉 deleteBySharePackId
96
+ // 里那条 DELETE FROM memory_provenance,五条用例照绿。关掉之后级联不再代劳,
97
+ // 断言测的才是显式清理本身。(`src/store.js` 的 `remove()` 注释在 C1 修复轮已经改成
98
+ // 真口径:node:sqlite 默认就把外键开着、级联是活的,显式清理是为了不把清理押在一份没人
99
+ // 钉过的驱动默认上——本用例反过来**临时关掉**它,恰恰就是为了让那句「不押注」可测。)
100
+ store.db.exec("PRAGMA foreign_keys = OFF");
101
+ store.saveMemoryProvenance({ memoryId: a.id, sessionId: "s-1", seqStart: 1, seqEnd: 3, createdAt: "2026-01-01T00:00:00.000Z" });
102
+ assert.equal(store.provenanceFor(a.id).length, 1, "the link must exist before the delete or the assertion below is empty");
103
+ const before = totalRows();
104
+ const genBefore = store.getMirrorState().generation;
105
+ assert.equal(store.listBySharePackId("packA").length, 2, "the withdrawal inventory must see archived rows too");
106
+ const deleted = store.deleteBySharePackId("packA");
107
+ assert.equal(deleted, 2);
108
+ assert.ok(store.getMirrorState().generation > genBefore,
109
+ "batch delete must register mirror debt like remove() does, else recoverMirror reads the pack as 「not yet synced」");
110
+ assert.equal(totalRows(), before - 2);
111
+ assert.equal(store.provenanceFor(a.id).length, 0, "provenance links must not dangle after a batch delete");
112
+ assert.equal(store.getByShareExternalId("deadbeef00000011"), undefined, "UNIQUE slot must be freed for a clean re-import");
113
+ assert.equal(store.listBySharePackId("packA").length, 0);
114
+ assert.equal(store.listBySharePackId("packB").length, 1);
115
+ const genAfterDelete = store.getMirrorState().generation;
116
+ assert.equal(store.deleteBySharePackId("packA"), 0, "idempotent");
117
+ assert.equal(store.getMirrorState().generation, genAfterDelete,
118
+ "a no-op withdrawal deletes nothing, so it must not register mirror debt either");
119
+ } finally { store.close(); rmSync(dir, { recursive: true, force: true }); }
120
+ });
121
+
122
+ test("a UNIQUE collision aborts the whole transaction — nothing lands partially", () => {
123
+ const dir = temp(), store = createStore(join(dir, "memory.db"));
124
+ try {
125
+ store.save({ type: "decision", title: "seed", content: "x", source: IMPORTED_SOURCE, share_external_id: "cafe000000000001" });
126
+ assert.throws(() => store.runAtomically(() => {
127
+ store.save({ type: "decision", title: "dup", content: "y", source: IMPORTED_SOURCE, share_external_id: "cafe000000000001" });
128
+ store.save({ type: "decision", title: "later", content: "z", source: IMPORTED_SOURCE, share_external_id: "cafe000000000002" });
129
+ }), /UNIQUE constraint failed/);
130
+ assert.equal(store.getByShareExternalId("cafe000000000002"), undefined, "the row after the collision must not have landed");
131
+ assert.equal(store.list({ limit: null }).length, 1, "the batch left nothing behind");
132
+ } finally { store.close(); rmSync(dir, { recursive: true, force: true }); }
133
+ });
@@ -0,0 +1,101 @@
1
+ // 接收侧的格式闸门。锁三件事:
2
+ // a) 一个不合规的包**整包**不合格(车道 C 不许半途入库);
3
+ // b) 键必须可重算——包是别人发来的,重算对不上就是被人改过,幂等键失效;
4
+ // c) 允许的类型集合不许偷偷跟着 mirror 扩:这里刻意重述一遍,并由静态锁比对
5
+ // TYPE_FILE 的键集减去两个排除项,将来加第 9 类时它会转红提醒人来决定。
6
+ import test from "node:test";
7
+ import assert from "node:assert/strict";
8
+ // 静态锁用真仪器而不是源码正则:test/mirror-types.test.js 已经是 import TYPE_FILE +
9
+ // Object.keys 的先例。在**测试**里 import 它不花钱——src/share.js 的重述字面集照旧,
10
+ // 那是接收侧的兼容契约,不是漂移。
11
+ import { TYPE_FILE } from "../src/mirror.js";
12
+ import {
13
+ PACK_ID_RE, SHARE_ALLOWED_TYPES, SHARE_READONLY_TYPES, SHARE_EXCLUDED_TYPES,
14
+ importedTagFor, validateSharePack, deriveExternalId, SHARE_KIND
15
+ } from "../src/share.js";
16
+
17
+ const entry = (over = {}) => {
18
+ const base = { type: "decision", title: "选 SQLite", content: "正文正文", tags: ["x"], importance: 4,
19
+ epistemic_status: "subjective", created_at: "2026-10-01T00:00:00.000Z", updated_at: "2026-10-01T00:00:00.000Z", content_hash: "ab" };
20
+ return { ...base, external_id: deriveExternalId("f0f0f0f0f0f0f0f0", base.type, base.title, base.content_hash), ...over };
21
+ };
22
+ const packOf = (memories, over = {}) => ({
23
+ kind: SHARE_KIND, pack_id: "f0f0f0f0f0f0f0f0", alias: "桉", generated_at: "2026-10-22T00:00:00.000Z",
24
+ source_version: "0.9.4", report: { included: memories.length, skipped: {}, skip_list: [], deduped: 0 },
25
+ memories, ...over
26
+ });
27
+
28
+ test("pack_id is exactly 16 lowercase hex", () => {
29
+ assert.match("f0f0f0f0f0f0f0f0", PACK_ID_RE);
30
+ assert.doesNotMatch("f0f0f0f0f0f0f0", PACK_ID_RE); // 车道 A 铸的是 16 位,不是 12
31
+ assert.doesNotMatch("F0F0F0F0F0F0F0F0", PACK_ID_RE);
32
+ // 后两条只验 {16} 那个量词,验不到两个锚:锚一掉,「16 位以内」就成了「正文里含 16 位」,
33
+ // 于是 18 位的串和带空白的串都能过——而这两种恰好是手改包与编码包的形状。
34
+ assert.doesNotMatch("f0f0f0f0f0f0f0f0ff", PACK_ID_RE);
35
+ assert.doesNotMatch(" f0f0f0f0f0f0f0f0", PACK_ID_RE);
36
+ });
37
+
38
+ test("a well-formed pack validates and reports its count", () => {
39
+ const out = validateSharePack(packOf([entry()]));
40
+ assert.equal(out.ok, true);
41
+ assert.equal(out.count, 1);
42
+ });
43
+
44
+ test("every rejection names a code and never a partial success", () => {
45
+ assert.equal(validateSharePack({}).error, "invalid-kind");
46
+ assert.equal(validateSharePack(packOf([entry()], { kind: "other" })).error, "invalid-kind");
47
+ assert.equal(validateSharePack(packOf([entry()], { pack_id: "nope" })).error, "invalid-pack-id");
48
+ assert.equal(validateSharePack(packOf([])).error, "empty-pack");
49
+ assert.equal(validateSharePack({ ...packOf([entry()]), memories: "x" }).error, "invalid-memories");
50
+ const badType = validateSharePack(packOf([entry({ type: "document" })]));
51
+ assert.equal(badType.error, "invalid-type");
52
+ assert.equal(badType.index, 0);
53
+ assert.equal(validateSharePack(packOf([entry({ title: " " })])).error, "invalid-entry");
54
+ assert.equal(validateSharePack(packOf([entry({ content: "" })])).error, "invalid-entry");
55
+ assert.equal(validateSharePack(packOf([entry({ importance: "high" })])).error, "invalid-entry");
56
+ });
57
+
58
+ test("a recomputed key that disagrees means the pack was edited by hand", () => {
59
+ const out = validateSharePack(packOf([entry({ external_id: "0000000000000000" })]));
60
+ assert.equal(out.ok, false);
61
+ assert.equal(out.error, "external-id-mismatch");
62
+ assert.equal(out.index, 0);
63
+ });
64
+
65
+ test("duplicate external_id inside one pack is rejected, not folded here", () => {
66
+ // 折叠是出包侧的义务(§3 的 deduped);收到重复键说明包被人改过,按不合格处置。
67
+ const dup = packOf([entry(), entry({ content: "另一条" })]);
68
+ assert.equal(validateSharePack(dup).error, "duplicate-external-id");
69
+ });
70
+
71
+ test("the tag is derived in one place and never from the sharer's identity", () => {
72
+ assert.equal(importedTagFor("桉"), "from-桉");
73
+ assert.equal(importedTagFor(""), "from-shared");
74
+ assert.equal(importedTagFor(undefined), "from-shared");
75
+ assert.equal(importedTagFor(" "), "from-shared"); // 全空白别名走中性值,不许剩 `from--`
76
+ assert.equal(importedTagFor("a\nb c"), "from-a-b-c"); // 空白游程一律折成一个 -,标签不许带换行
77
+ assert.equal(importedTagFor("x".repeat(80)).length, 32); // 上限:别人的字符串不许无限长
78
+ });
79
+
80
+ test("folding keeps the word boundary: a newline alias is not the squashed spelling", () => {
81
+ // 上一版对同一类字符开两条规则:换行/制表符**删除**、其余空白折成 -。删除那一侧
82
+ // 恰恰制造了它自己测试要防的撞名——"a\nb" 与 "ab" 读出同一个标签,而对面看到的
83
+ // 来源读数还丢了词缝。裁定:整类空白(含 \r\n\t)折成一个 -(mirror.js 的
84
+ // oneLine() 同是先例:只折不删)。单行的要求折叠同样满足。
85
+ assert.equal(importedTagFor("a\nb"), "from-a-b");
86
+ assert.equal(importedTagFor("ab"), "from-ab");
87
+ assert.notEqual(importedTagFor("a\nb"), importedTagFor("ab"));
88
+ assert.equal(importedTagFor("a\tb"), "from-a-b"); // 制表符与换行同一条规则
89
+ assert.equal(importedTagFor("a\r\nb"), "from-a-b"); // \r\n 游程折成**一个** -
90
+ });
91
+
92
+ test("the allowed type set is the mirror's minus the two exclusions", () => {
93
+ // 真仪器读真数据:TYPE_FILE import 进来直接 Object.keys()。源码正则有两类静默失明——
94
+ // 不匹配 `[a-z_]+: "….md"` 形状的键(大写、连字符、带引号的键名)会被无声丢掉,
95
+ // 而缩进/换行这类纯排版改动同样能让它一条都不解析。断言本体不变:新键进 TYPE_FILE
96
+ // 而没在这里被决定过,这条必须转红。
97
+ const keys = Object.keys(TYPE_FILE);
98
+ assert.ok(keys.length >= 8, `TYPE_FILE should expose its keys to the lock, got ${keys.length}`);
99
+ const minus = new Set(keys.filter((k) => !SHARE_READONLY_TYPES.has(k) && !SHARE_EXCLUDED_TYPES.has(k)));
100
+ assert.deepEqual([...SHARE_ALLOWED_TYPES].sort(), [...minus].sort());
101
+ });
@@ -74,10 +74,12 @@ test("issue#126 supersede: replay is idempotent (archived loser, single note)",
74
74
  store.close();
75
75
  });
76
76
 
77
- test("issue#126 differentiate: both entries survive and each carries the distinctions note", () => {
77
+ test("issue#533 differentiate: both entries survive, bodies untouched, the pair lands in the ledger", () => {
78
78
  const { service, store } = setup();
79
79
  const a = makeMemory(service, "内网 SSH 端口", "内网走 22");
80
80
  const b = makeMemory(service, "外网 SSH 端口", "外网映射 2222");
81
+ // 两侧各自追加注记会让共享后缀把向量拉近——先记下原始正文,事后逐字节比对。
82
+ const before = { [a.id]: service.getById(a.id).content, [b.id]: service.getById(b.id).content };
81
83
  const snapshot = snapshotOf(store, a, b);
82
84
  const { applied } = applyDecisions(
83
85
  [{ action: "differentiate", ids: [a.id, b.id], distinctions: ["内网直连", "外网映射"] }],
@@ -87,10 +89,12 @@ test("issue#126 differentiate: both entries survive and each carries the distinc
87
89
  for (const m of [a, b]) {
88
90
  const after = service.getById(m.id);
89
91
  assert.equal(after.archived, false, "differentiate never archives");
90
- assert.ok(after.content.includes("差异注记"), "each side gets the note");
92
+ assert.equal(after.content, before[m.id], "the body is left byte-identical");
93
+ assert.ok(!after.content.includes("差异注记"), "no note is written into the body");
91
94
  }
92
- assert.ok(service.getById(a.id).content.includes("内网直连"), "note carries the distinctions");
93
- assert.ok(service.getById(b.id).content.includes("外网映射"));
95
+ const record = service.findDifferentiatedPair(a.id, b.id);
96
+ assert.ok(record, "the verdict is recorded as a pair, not as body text");
97
+ assert.deepEqual(record.distinctions, ["内网直连", "外网映射"], "the model's distinctions survive for audit");
94
98
  store.close();
95
99
  });
96
100
 
@@ -369,20 +373,20 @@ test("issue#126r: buildOutcome records a disposition for the new actions", () =>
369
373
  assert.equal(outcome.byId.d2, "differentiated");
370
374
  });
371
375
 
372
- test("issue#126r: differentiate refreshes the cached vectors so the note reaches the index", async () => {
376
+ test("issue#533r: differentiate leaves the cached vectors alone (nothing changed to re-embed)", async () => {
373
377
  const { service, store, vectorIndex } = setup();
374
378
  const { a, b } = seedPair(service, vectorIndex, "向量甲", "向量乙");
375
- const before = vectorIndex.getEmbedding(a.id);
376
- // 让嵌入器产出与旧向量不同的结果,便于确认"确实重嵌了"。
379
+ const beforeA = vectorIndex.getEmbedding(a.id);
380
+ const beforeB = vectorIndex.getEmbedding(b.id);
381
+ // 让嵌入器产出与旧向量不同的结果:若源码又悄悄重嵌,这里立刻会变。
377
382
  const fresh = { ...embedder, embedSingle: async () => [0, 1, 0] };
378
383
  service.setEmbedder(fresh);
379
384
  const ctx = captureCtx(JSON.stringify([
380
385
  { action: "differentiate", ids: [a.id, b.id], distinctions: ["甲面", "乙面"] }
381
386
  ]));
382
387
  await runSleep(ctx, service, baseConfig({ sleepActionSet: "full" }), ctx.logger, { embedder: fresh, vectorIndex }, null);
383
- const after = vectorIndex.getEmbedding(a.id);
384
- assert.notDeepEqual(after, before, "the cached vector was recomputed after the content note");
385
- assert.deepEqual(after, [0, 1, 0], "it carries the fresh embedding");
388
+ assert.deepEqual(vectorIndex.getEmbedding(a.id), beforeA, "side A keeps its vector");
389
+ assert.deepEqual(vectorIndex.getEmbedding(b.id), beforeB, "side B keeps its vector");
386
390
  store.close();
387
391
  });
388
392
 
@@ -397,7 +401,8 @@ test("issue#126r: the full tier discovers cross-type pairs (differentiate would
397
401
  ]));
398
402
  const result = await runSleep(ctx, service, baseConfig({ sleepActionSet: "full" }), ctx.logger, { embedder, vectorIndex }, null);
399
403
  assert.equal(result.phases.conflicts.applied, 1, "the cross-type pair was adjudicated");
400
- assert.ok(service.getById(proj.id).content.includes("差异注记"), "differentiate reached the cross-type pair");
404
+ assert.ok(service.findDifferentiatedPair(proj.id, pref.id), "differentiate reached the cross-type pair");
405
+ assert.equal(service.getById(proj.id).content, "项目用 8080", "the project body is untouched");
401
406
  store.close();
402
407
  });
403
408