@modusensus/dsh-mneme 0.14.0-beta.1 → 0.14.0-beta.2

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 (543) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +830 -830
  3. package/assets/icon.svg +3 -3
  4. package/bin/cli.mjs +964 -964
  5. package/bin/dsh-mneme-choro.mjs +262 -262
  6. package/bin/dsh-mneme-mcp.mjs +510 -510
  7. package/bin/dsh-mneme-serve.mjs +151 -151
  8. package/cordis.patch.yml +21 -21
  9. package/docs/CONFIGURATION.md +407 -404
  10. package/docs/CONTINUITY.md +139 -139
  11. package/docs/DAEMON.md +57 -57
  12. package/docs/DIRECTORIES.md +114 -114
  13. package/docs/DISTILL.md +397 -371
  14. package/docs/DSH-GO.md +97 -97
  15. package/docs/ENTITIES.md +247 -247
  16. package/docs/FORGET.md +77 -77
  17. package/docs/KB.md +110 -110
  18. package/docs/LOCAL_MODEL.md +222 -222
  19. package/docs/SEMANTIC.md +256 -256
  20. package/docs/SHARE.md +552 -552
  21. package/docs/SLEEP.md +202 -202
  22. package/docs/STORAGE.md +122 -122
  23. package/docs/WORKFLOWS.md +366 -366
  24. package/lib/api-standalone.js +1007 -1007
  25. package/lib/api.js +3600 -3600
  26. package/lib/attachment-text.js +122 -122
  27. package/lib/bootstrap-http.js +105 -105
  28. package/lib/bootstrap.js +215 -215
  29. package/lib/candidates.js +454 -454
  30. package/lib/capabilities.js +819 -819
  31. package/lib/choro-command.js +111 -111
  32. package/lib/client.js +14420 -14322
  33. package/lib/commands.js +95 -95
  34. package/lib/config-keys.js +144 -144
  35. package/lib/config.js +1361 -1344
  36. package/lib/content-hash.js +54 -54
  37. package/lib/continuity.js +258 -258
  38. package/lib/date-hint.js +32 -0
  39. package/lib/diary/auto.js +190 -190
  40. package/lib/diary/compose.js +440 -440
  41. package/lib/diary/dir.js +59 -59
  42. package/lib/diary/export.js +97 -97
  43. package/lib/diary/index.js +26 -26
  44. package/lib/diary/library.js +189 -189
  45. package/lib/diary/qq-inbox.js +142 -142
  46. package/lib/diary/rollover.js +61 -61
  47. package/lib/diary/session-provider.js +122 -122
  48. package/lib/document-index.js +94 -94
  49. package/lib/document.js +322 -322
  50. package/lib/dream/clustering.js +123 -123
  51. package/lib/dream/decisions.js +945 -945
  52. package/lib/dream/narratives.js +44 -44
  53. package/lib/dream/sleep.js +2027 -2027
  54. package/lib/dream/triage.js +139 -139
  55. package/lib/dream.js +1879 -1879
  56. package/lib/dsh-go/host.js +162 -162
  57. package/lib/dsh-go/panel.js +2091 -2091
  58. package/lib/dsh-go/text.js +342 -342
  59. package/lib/dsh-go/views/common.js +68 -68
  60. package/lib/dsh-go/views/entity.js +48 -48
  61. package/lib/dsh-go/views/memory.js +198 -198
  62. package/lib/dsh-go/views/root.js +30 -30
  63. package/lib/dsh-go/views/settings.js +398 -398
  64. package/lib/dsh-go/views/status.js +51 -51
  65. package/lib/embedding.js +342 -342
  66. package/lib/entities/extractor.js +285 -285
  67. package/lib/fence.js +24 -24
  68. package/lib/graph/anchoring.js +184 -184
  69. package/lib/guide.js +74 -74
  70. package/lib/heat.js +144 -144
  71. package/lib/hot-memory.js +119 -119
  72. package/lib/image-describe.js +158 -0
  73. package/lib/index.js +852 -852
  74. package/lib/inject.js +656 -651
  75. package/lib/kb/index.js +309 -309
  76. package/lib/kb/routes.js +219 -219
  77. package/lib/kb/tools.js +148 -148
  78. package/lib/lang.js +607 -595
  79. package/lib/local-embedder.js +358 -358
  80. package/lib/maintenance.js +162 -162
  81. package/lib/memory-source.js +9 -9
  82. package/lib/mirror.js +330 -330
  83. package/lib/organize.js +456 -456
  84. package/lib/peak-hours.js +172 -172
  85. package/lib/pipeline-activities.js +178 -178
  86. package/lib/pipeline-attribution.js +165 -165
  87. package/lib/pipeline-command.js +279 -279
  88. package/lib/pipeline-confirm.js +142 -142
  89. package/lib/pipeline-exec.js +174 -174
  90. package/lib/pipeline-nudge-auto.js +129 -129
  91. package/lib/pipeline-nudge.js +1623 -1623
  92. package/lib/pipeline-observations.js +181 -181
  93. package/lib/pipeline-observe.js +133 -133
  94. package/lib/pipeline-runner.js +361 -361
  95. package/lib/pipeline-runs.js +442 -442
  96. package/lib/pipeline.js +716 -716
  97. package/lib/placement.js +13 -13
  98. package/lib/pre-inject-gate.js +354 -354
  99. package/lib/profile-extract.js +164 -164
  100. package/lib/profile-facts.js +356 -356
  101. package/lib/provenance-http.js +251 -251
  102. package/lib/quality-filter.js +162 -162
  103. package/lib/recall-stats.js +190 -190
  104. package/lib/reclaim-http.js +150 -150
  105. package/lib/reranker.js +269 -269
  106. package/lib/runtime/adopt.js +176 -176
  107. package/lib/runtime/closure.js +172 -172
  108. package/lib/runtime/download.js +247 -247
  109. package/lib/runtime/layout.js +265 -265
  110. package/lib/runtime/loader.js +252 -252
  111. package/lib/runtime/model-fetch.js +361 -361
  112. package/lib/runtime/provision.js +237 -237
  113. package/lib/runtime/tarball.js +130 -130
  114. package/lib/runtime/verify.js +223 -223
  115. package/lib/safe-slice.js +18 -18
  116. package/lib/scope-state.js +277 -277
  117. package/lib/scope.js +187 -187
  118. package/lib/search/adaptive.js +60 -60
  119. package/lib/search/bm25.js +96 -96
  120. package/lib/semantic.js +572 -572
  121. package/lib/sensitive-scan.js +209 -209
  122. package/lib/serve.js +228 -228
  123. package/lib/service.js +3268 -3268
  124. package/lib/session-gate.js +91 -91
  125. package/lib/session-usage.js +350 -350
  126. package/lib/settings.js +827 -817
  127. package/lib/share-http.js +281 -281
  128. package/lib/share-import.js +234 -234
  129. package/lib/share.js +335 -335
  130. package/lib/store.js +5895 -5895
  131. package/lib/summarize.js +1388 -1343
  132. package/lib/tools.js +1370 -1356
  133. package/lib/user-settings.js +230 -230
  134. package/lib/vector-index.js +180 -180
  135. package/lib/version-check.js +122 -122
  136. package/lib/wiki/claims.js +267 -267
  137. package/lib/wiki/cli-hint.js +90 -90
  138. package/lib/wiki/inject.js +164 -164
  139. package/lib/wiki/partition.js +200 -200
  140. package/lib/wiki/plan.js +214 -214
  141. package/lib/wiki/queries.js +640 -640
  142. package/lib/wiki/render.js +261 -261
  143. package/lib/wiki/scan.js +371 -371
  144. package/lib/wiki/t0.js +500 -500
  145. package/lib/wiki/t1-jobs.js +232 -232
  146. package/lib/wiki/t1.js +609 -609
  147. package/lib/write-admission.js +404 -404
  148. package/locale/en.json +6 -6
  149. package/locale/zh.json +6 -6
  150. package/package.json +1 -1
  151. package/runtime-manifest.json +526 -526
  152. package/scripts/benchmark-embed.js +201 -201
  153. package/scripts/benchmark-recall.js +249 -249
  154. package/scripts/benchmark-rerank.js +166 -166
  155. package/scripts/build-runtime-manifest.mjs +224 -224
  156. package/scripts/changelog-prep.mjs +91 -91
  157. package/scripts/check-sync.js +44 -44
  158. package/scripts/e2e-dsh.js +220 -220
  159. package/scripts/mneme-runtime.mjs +277 -277
  160. package/scripts/publish-gate.mjs +197 -197
  161. package/scripts/release-docs.mjs +210 -210
  162. package/scripts/release-notes.mjs +164 -164
  163. package/scripts/stress-dsh.js +255 -255
  164. package/scripts/sync-lib.js +47 -47
  165. package/scripts/sync-test-badge.mjs +69 -69
  166. package/scripts/test-count-sync.mjs +48 -48
  167. package/src/api-standalone.js +1007 -1007
  168. package/src/api.js +3600 -3600
  169. package/src/attachment-text.js +122 -122
  170. package/src/bootstrap-http.js +105 -105
  171. package/src/bootstrap.js +215 -215
  172. package/src/candidates.js +454 -454
  173. package/src/capabilities.js +819 -819
  174. package/src/choro-command.js +111 -111
  175. package/src/commands.js +95 -95
  176. package/src/config-keys.js +144 -144
  177. package/src/config.js +1361 -1344
  178. package/src/content-hash.js +54 -54
  179. package/src/continuity.js +258 -258
  180. package/src/date-hint.js +32 -0
  181. package/src/diary/auto.js +190 -190
  182. package/src/diary/compose.js +440 -440
  183. package/src/diary/dir.js +59 -59
  184. package/src/diary/export.js +97 -97
  185. package/src/diary/index.js +26 -26
  186. package/src/diary/library.js +189 -189
  187. package/src/diary/qq-inbox.js +142 -142
  188. package/src/diary/rollover.js +61 -61
  189. package/src/diary/session-provider.js +122 -122
  190. package/src/document-index.js +94 -94
  191. package/src/document.js +322 -322
  192. package/src/dream/clustering.js +123 -123
  193. package/src/dream/decisions.js +945 -945
  194. package/src/dream/narratives.js +44 -44
  195. package/src/dream/sleep.js +2027 -2027
  196. package/src/dream/triage.js +139 -139
  197. package/src/dream.js +1879 -1879
  198. package/src/dsh-go/host.js +162 -162
  199. package/src/dsh-go/panel.js +2091 -2091
  200. package/src/dsh-go/text.js +342 -342
  201. package/src/dsh-go/views/common.js +68 -68
  202. package/src/dsh-go/views/entity.js +48 -48
  203. package/src/dsh-go/views/memory.js +198 -198
  204. package/src/dsh-go/views/root.js +30 -30
  205. package/src/dsh-go/views/settings.js +398 -398
  206. package/src/dsh-go/views/status.js +51 -51
  207. package/src/embedding.js +342 -342
  208. package/src/entities/extractor.js +285 -285
  209. package/src/fence.js +24 -24
  210. package/src/graph/anchoring.js +184 -184
  211. package/src/guide.js +74 -74
  212. package/src/heat.js +144 -144
  213. package/src/hot-memory.js +119 -119
  214. package/src/image-describe.js +158 -0
  215. package/src/index.js +852 -852
  216. package/src/inject.js +656 -651
  217. package/src/kb/index.js +309 -309
  218. package/src/kb/routes.js +219 -219
  219. package/src/kb/tools.js +148 -148
  220. package/src/lang.js +607 -595
  221. package/src/local-embedder.js +358 -358
  222. package/src/maintenance.js +162 -162
  223. package/src/memory-source.js +9 -9
  224. package/src/mirror.js +330 -330
  225. package/src/organize.js +456 -456
  226. package/src/peak-hours.js +172 -172
  227. package/src/pipeline-activities.js +178 -178
  228. package/src/pipeline-attribution.js +165 -165
  229. package/src/pipeline-command.js +279 -279
  230. package/src/pipeline-confirm.js +142 -142
  231. package/src/pipeline-exec.js +174 -174
  232. package/src/pipeline-nudge-auto.js +129 -129
  233. package/src/pipeline-nudge.js +1623 -1623
  234. package/src/pipeline-observations.js +181 -181
  235. package/src/pipeline-observe.js +133 -133
  236. package/src/pipeline-runner.js +361 -361
  237. package/src/pipeline-runs.js +442 -442
  238. package/src/pipeline.js +716 -716
  239. package/src/placement.js +13 -13
  240. package/src/pre-inject-gate.js +354 -354
  241. package/src/profile-extract.js +164 -164
  242. package/src/profile-facts.js +356 -356
  243. package/src/provenance-http.js +251 -251
  244. package/src/quality-filter.js +162 -162
  245. package/src/recall-stats.js +190 -190
  246. package/src/reclaim-http.js +150 -150
  247. package/src/reranker.js +269 -269
  248. package/src/runtime/adopt.js +176 -176
  249. package/src/runtime/closure.js +172 -172
  250. package/src/runtime/download.js +247 -247
  251. package/src/runtime/layout.js +265 -265
  252. package/src/runtime/loader.js +252 -252
  253. package/src/runtime/model-fetch.js +361 -361
  254. package/src/runtime/provision.js +237 -237
  255. package/src/runtime/tarball.js +130 -130
  256. package/src/runtime/verify.js +223 -223
  257. package/src/safe-slice.js +18 -18
  258. package/src/scope-state.js +277 -277
  259. package/src/scope.js +187 -187
  260. package/src/search/adaptive.js +60 -60
  261. package/src/search/bm25.js +96 -96
  262. package/src/semantic.js +572 -572
  263. package/src/sensitive-scan.js +209 -209
  264. package/src/serve.js +228 -228
  265. package/src/service.js +3268 -3268
  266. package/src/session-gate.js +91 -91
  267. package/src/session-usage.js +350 -350
  268. package/src/settings.js +827 -817
  269. package/src/share-http.js +281 -281
  270. package/src/share-import.js +234 -234
  271. package/src/share.js +335 -335
  272. package/src/store.js +5895 -5895
  273. package/src/summarize.js +1388 -1343
  274. package/src/tools.js +1370 -1356
  275. package/src/user-settings.js +230 -230
  276. package/src/vector-index.js +180 -180
  277. package/src/version-check.js +122 -122
  278. package/src/wiki/claims.js +267 -267
  279. package/src/wiki/cli-hint.js +90 -90
  280. package/src/wiki/inject.js +164 -164
  281. package/src/wiki/partition.js +200 -200
  282. package/src/wiki/plan.js +214 -214
  283. package/src/wiki/queries.js +640 -640
  284. package/src/wiki/render.js +261 -261
  285. package/src/wiki/scan.js +371 -371
  286. package/src/wiki/t0.js +500 -500
  287. package/src/wiki/t1-jobs.js +232 -232
  288. package/src/wiki/t1.js +609 -609
  289. package/src/write-admission.js +404 -404
  290. package/test/adaptive-inject-budget.test.js +85 -85
  291. package/test/api-hardening.test.js +173 -173
  292. package/test/api-nudge-route.test.js +259 -259
  293. package/test/api-routes-gaps.test.js +1092 -1092
  294. package/test/api.test.js +3007 -2998
  295. package/test/attachment-text.test.js +139 -139
  296. package/test/audit.test.js +448 -448
  297. package/test/baseline-persist.test.js +248 -248
  298. package/test/benchmark.test.js +94 -94
  299. package/test/bm25-corpus-cache.test.js +145 -145
  300. package/test/bootstrap.test.js +228 -228
  301. package/test/candidates.test.js +345 -345
  302. package/test/capabilities.test.js +577 -577
  303. package/test/capacity-warning.test.js +262 -262
  304. package/test/changelog-prep.test.js +125 -125
  305. package/test/choro-command.test.js +218 -218
  306. package/test/cli-enums.test.js +37 -37
  307. package/test/cli-flag-value.test.js +86 -86
  308. package/test/cli-receive.test.js +390 -390
  309. package/test/cli-share.test.js +437 -437
  310. package/test/client-share.test.js +225 -225
  311. package/test/client.test.js +4595 -4425
  312. package/test/clustering.test.js +100 -100
  313. package/test/commands.test.js +106 -106
  314. package/test/config-keys-routes.test.js +233 -233
  315. package/test/config.test.js +153 -153
  316. package/test/conflict-freeze.test.js +344 -344
  317. package/test/conflict-ledger-enforce.test.js +452 -452
  318. package/test/conflict-ledger.test.js +242 -242
  319. package/test/conflict-reviewed.test.js +79 -79
  320. package/test/content-hash.test.js +204 -204
  321. package/test/continuity.test.js +452 -452
  322. package/test/contrast.test.js +432 -432
  323. package/test/date-hint.test.js +156 -0
  324. package/test/dedupe-candidate-window.test.js +180 -180
  325. package/test/dependency-overrides.test.js +78 -78
  326. package/test/diary-library.test.js +187 -187
  327. package/test/diary.test.js +576 -576
  328. package/test/differentiate-feedback.test.js +248 -248
  329. package/test/distill-tx-embed-gap.test.js +110 -110
  330. package/test/docs-config-keys.test.js +65 -65
  331. package/test/document-dir.test.js +233 -233
  332. package/test/document.test.js +703 -703
  333. package/test/dream-archive-guardrail.test.js +116 -116
  334. package/test/dream-baseline-clamp.test.js +174 -174
  335. package/test/dream-candidate-hybrid.test.js +281 -281
  336. package/test/dream-coverage-degrade.test.js +86 -86
  337. package/test/dream-decision-evidence.test.js +314 -314
  338. package/test/dream-failure-backoff.test.js +173 -173
  339. package/test/dream-fence.test.js +207 -207
  340. package/test/dream-maxtokens-fallback.test.js +132 -132
  341. package/test/dream-merge-guardrail.test.js +219 -219
  342. package/test/dream-peak-hours.test.js +194 -194
  343. package/test/dream.test.js +1528 -1528
  344. package/test/dsh-go-host.test.js +134 -134
  345. package/test/dsh-go-panel-actions.test.js +854 -854
  346. package/test/dsh-go-panel.test.js +251 -251
  347. package/test/embed-ready-poll.test.js +94 -94
  348. package/test/embed-text-reasons.test.js +112 -112
  349. package/test/embedding-cache-f32.test.js +276 -276
  350. package/test/embedding-ready.test.js +226 -226
  351. package/test/entities.test.js +711 -711
  352. package/test/entity-recall.test.js +179 -179
  353. package/test/epistemic.test.js +325 -325
  354. package/test/failure-query.test.js +147 -147
  355. package/test/failure-retention.test.js +105 -105
  356. package/test/fnew-0112.test.js +311 -311
  357. package/test/fnew-03.test.js +422 -422
  358. package/test/forget-corrections.test.js +70 -70
  359. package/test/graph-anchoring.test.js +184 -184
  360. package/test/graph-api.test.js +333 -333
  361. package/test/graph-boosted-relations.test.js +189 -189
  362. package/test/graph-inject-budget.test.js +103 -103
  363. package/test/graph-multipath.test.js +269 -269
  364. package/test/graph-passive-confirm.test.js +91 -91
  365. package/test/graph-weight-evolution.test.js +104 -104
  366. package/test/heat.test.js +175 -175
  367. package/test/helpers/dream-mock.js +86 -86
  368. package/test/helpers/peer-worker.mjs +25 -25
  369. package/test/helpers/runtime-source.js +60 -60
  370. package/test/helpers/tar-builder.js +80 -80
  371. package/test/helpers/write-admission-samples.js +312 -312
  372. package/test/hot-memory.test.js +259 -259
  373. package/test/image-description.test.js +271 -0
  374. package/test/image-memory.test.js +170 -170
  375. package/test/inject-content-cap.test.js +91 -91
  376. package/test/inject-first-round-bm25.test.js +89 -89
  377. package/test/inject-heat.test.js +77 -77
  378. package/test/inject-parent-gate.test.js +134 -109
  379. package/test/inject-pin-explicit.test.js +300 -300
  380. package/test/inject-pin-vector-crowd.test.js +225 -225
  381. package/test/inject-pools.test.js +195 -195
  382. package/test/inject-recall.test.js +172 -172
  383. package/test/inject-rotation.test.js +161 -161
  384. package/test/inject.test.js +553 -553
  385. package/test/issue-617-step1-query-and-fusion.test.js +361 -361
  386. package/test/kb-api-wiring.test.js +128 -128
  387. package/test/kb-collections.test.js +272 -272
  388. package/test/kb-routes.test.js +379 -379
  389. package/test/lang.test.js +98 -98
  390. package/test/lastrunat-persist.test.js +101 -101
  391. package/test/lib-smoke.test.js +116 -116
  392. package/test/llm-audit.test.js +815 -815
  393. package/test/llm-message-source.test.js +207 -207
  394. package/test/local-embedder.test.js +292 -292
  395. package/test/maintenance.test.js +501 -501
  396. package/test/mcp-stdio.test.js +385 -385
  397. package/test/mint-source.test.js +311 -311
  398. package/test/mirror-dirty.test.js +424 -424
  399. package/test/mirror-edit-digest.test.js +187 -187
  400. package/test/mirror-generation.test.js +499 -499
  401. package/test/mirror-types.test.js +208 -208
  402. package/test/mirror.test.js +249 -249
  403. package/test/narrative-bars.test.js +162 -162
  404. package/test/organize-tool.test.js +232 -232
  405. package/test/organize.test.js +418 -418
  406. package/test/panel-chart-scale.test.js +90 -90
  407. package/test/panel-tokens.test.js +245 -245
  408. package/test/peak-hours-observability.test.js +70 -70
  409. package/test/peer-blockers.test.js +193 -193
  410. package/test/persona-addressing.test.js +83 -83
  411. package/test/persona-api.test.js +172 -172
  412. package/test/persona-by-session.test.js +568 -568
  413. package/test/persona-flag.test.js +36 -36
  414. package/test/persona-inject.test.js +145 -145
  415. package/test/persona-settings.test.js +198 -198
  416. package/test/pipeline-activities.test.js +155 -155
  417. package/test/pipeline-attribution.test.js +175 -175
  418. package/test/pipeline-command.test.js +243 -243
  419. package/test/pipeline-confirm.test.js +148 -148
  420. package/test/pipeline-exec.test.js +172 -172
  421. package/test/pipeline-nudge-auto.test.js +248 -248
  422. package/test/pipeline-nudge.test.js +1278 -1278
  423. package/test/pipeline-observations.test.js +157 -157
  424. package/test/pipeline-observe.test.js +127 -127
  425. package/test/pipeline-r1r2.test.js +278 -278
  426. package/test/pipeline-runner.test.js +468 -468
  427. package/test/pipeline-runs.test.js +767 -767
  428. package/test/pipeline.test.js +636 -636
  429. package/test/placement.test.js +36 -36
  430. package/test/policy-epoch.test.js +259 -259
  431. package/test/pre-inject-gate.test.js +444 -444
  432. package/test/profile-extract.test.js +269 -269
  433. package/test/profile-facts.test.js +492 -492
  434. package/test/prompt-stance.test.js +128 -128
  435. package/test/provenance-http.test.js +367 -367
  436. package/test/publish-gate.test.js +272 -272
  437. package/test/quality-backfill.test.js +290 -290
  438. package/test/quality-filter.test.js +295 -295
  439. package/test/readme-claims.test.js +113 -113
  440. package/test/reasoning-effort.test.js +752 -752
  441. package/test/recall-anti-update.test.js +82 -82
  442. package/test/recall-evals.test.js +253 -253
  443. package/test/recall-layer.test.js +326 -326
  444. package/test/recall-retention.test.js +128 -128
  445. package/test/recall-stats.test.js +274 -274
  446. package/test/receipt-chain.test.js +451 -451
  447. package/test/reclaim-http.test.js +209 -209
  448. package/test/reflection-update-gate.test.js +129 -129
  449. package/test/reflection.test.js +226 -226
  450. package/test/reindex-backfill.test.js +475 -475
  451. package/test/reindex-scheduler.test.js +369 -369
  452. package/test/release-docs.test.js +156 -156
  453. package/test/release-notes.test.js +265 -265
  454. package/test/release-workflow.test.js +93 -93
  455. package/test/reranker-single-logit.test.js +73 -73
  456. package/test/reranker.test.js +197 -197
  457. package/test/resident-status.test.js +83 -83
  458. package/test/route-policy.test.js +113 -113
  459. package/test/runtime-adopt.test.js +206 -206
  460. package/test/runtime-cli.test.js +89 -89
  461. package/test/runtime-closure.test.js +193 -193
  462. package/test/runtime-download.test.js +302 -302
  463. package/test/runtime-layout.test.js +163 -163
  464. package/test/runtime-loader.test.js +255 -255
  465. package/test/runtime-manifest.test.js +159 -159
  466. package/test/runtime-model-fetch.test.js +348 -348
  467. package/test/runtime-provision.test.js +101 -101
  468. package/test/runtime-tarball.test.js +81 -81
  469. package/test/runtime-verify.test.js +319 -319
  470. package/test/scope-candidates.test.js +491 -491
  471. package/test/scope-distill-stamp.test.js +134 -134
  472. package/test/scope-explicit.test.js +452 -452
  473. package/test/scope-inject-soft.test.js +106 -106
  474. package/test/scope-optional-service.test.js +110 -110
  475. package/test/scope-retrieval.test.js +197 -197
  476. package/test/scope-state.test.js +610 -610
  477. package/test/scope-storage.test.js +255 -255
  478. package/test/scope-strict.test.js +367 -367
  479. package/test/search-fusion.test.js +90 -90
  480. package/test/security-doc.test.js +188 -188
  481. package/test/semantic.test.js +124 -124
  482. package/test/sensitive-scan.test.js +251 -251
  483. package/test/serve-bin.test.js +121 -121
  484. package/test/serve.test.js +258 -258
  485. package/test/service-search.test.js +174 -174
  486. package/test/service.test.js +410 -410
  487. package/test/session-gate.test.js +264 -264
  488. package/test/session-usage.test.js +317 -317
  489. package/test/settings.test.js +273 -273
  490. package/test/share-foreign-rows.test.js +144 -144
  491. package/test/share-http.test.js +144 -144
  492. package/test/share-import.test.js +577 -577
  493. package/test/share-imported-guard.test.js +311 -311
  494. package/test/share-imported-identity.test.js +111 -111
  495. package/test/share-pack.test.js +511 -511
  496. package/test/share-receive-http.test.js +756 -756
  497. package/test/share-routes.test.js +337 -337
  498. package/test/share-store.test.js +133 -133
  499. package/test/share-validate.test.js +101 -101
  500. package/test/skip-invalid-restore.test.js +262 -262
  501. package/test/sleep-action-set.test.js +441 -441
  502. package/test/sleep-catchup.test.js +315 -315
  503. package/test/sleep-failure-backoff.test.js +156 -156
  504. package/test/sleep-heat.test.js +174 -174
  505. package/test/sleep-scheduler-rearm.test.js +89 -89
  506. package/test/sleep.test.js +1455 -1455
  507. package/test/standalone-api.test.js +1344 -1344
  508. package/test/store-vector-cache.test.js +98 -98
  509. package/test/store-write-path-columns.test.js +191 -191
  510. package/test/store.test.js +744 -744
  511. package/test/stress.test.js +209 -209
  512. package/test/summarize-occurred-at.test.js +127 -127
  513. package/test/summarize-parse-diagnostics.test.js +75 -75
  514. package/test/summarize-reasoning-effort.test.js +189 -189
  515. package/test/summarize.test.js +2251 -2251
  516. package/test/supersede-state-tracking.test.js +394 -394
  517. package/test/test-count-sync.test.js +71 -71
  518. package/test/tool-state-ignore.test.js +65 -65
  519. package/test/tools-dto-schema-matrix.test.js +398 -398
  520. package/test/tools-memory-get-schema.test.js +65 -65
  521. package/test/tools.test.js +585 -585
  522. package/test/updated-at-semantics.test.js +113 -113
  523. package/test/user-settings-preview.test.js +127 -127
  524. package/test/user-settings-segments.test.js +527 -527
  525. package/test/vector-index.test.js +334 -334
  526. package/test/version-check.test.js +152 -152
  527. package/test/webServer-optional.test.js +82 -82
  528. package/test/wiki-api.test.js +734 -734
  529. package/test/wiki-claims.test.js +194 -194
  530. package/test/wiki-cli-hint.test.js +118 -118
  531. package/test/wiki-inject.test.js +265 -265
  532. package/test/wiki-partition.test.js +112 -112
  533. package/test/wiki-plan.test.js +80 -80
  534. package/test/wiki-provenance.test.js +202 -202
  535. package/test/wiki-queries.test.js +398 -398
  536. package/test/wiki-scan.test.js +208 -208
  537. package/test/wiki-store.test.js +190 -190
  538. package/test/wiki-t0.test.js +456 -456
  539. package/test/wiki-t1-jobs.test.js +125 -125
  540. package/test/wiki-t1.test.js +460 -460
  541. package/test/wiki-tools.test.js +176 -176
  542. package/test/workflow-guards.test.js +155 -155
  543. package/test/write-admission.test.js +668 -668
@@ -1,577 +1,577 @@
1
- // 导入的动作面。四条锁,一条比一条容易被「顺手重构」拆掉:
2
- // a) 不改写别人——既有非 imported 行的 updated_at 逐字节不动(决策 2 的直接锁);
3
- // b) 幂等——同包第二次导入 created = 0 且不删任何东西;
4
- // c) 外来行带着守卫要的身份落地(source / 两个 scope),否则车道 B 的九个池子白挡;
5
- // d) 真的排进了嵌入队列、并在提交后落进 embedding 列(在 transaction 里),否则对面的
6
- // 向量检索吃不到这批。
7
- // 另有三条同样会被「顺手重构」拆掉:撞名清单里的既有那一行必须是收件人自己的行(外来行
8
- // 永远不许当 existing_id)、校验不过时整库无痕、干跑不许冒充「库刚变过」。这三条测的都是
9
- // 收侧要对外讲的读数与其余读侧的可见性——报得出去但没人能看出错,比不测更糟。
10
- // 最后三条盯事务那一层的形状:嵌入队列只在**最外层 COMMIT** 排空(不当场发、也不在提交前
11
- // 排空)、对面宿主抢先把同一个 share_external_id 落库时整笔中止、以及「已经写下的行」随
12
- // 回滚一起消失且一行都不嵌。它们各自防的替换品:store.runAtomically(txDepth 恒 0,队列
13
- // 当场排空)、hand-rolled BEGIN/COMMIT(原子性一样,但 drain 与 notifyWrite 的位置不对)、
14
- // 「撞一条就跳过这一条」的宽容写法(前几行留在库里)。
15
- //
16
- // C4 之后本文件管的是收侧的**进与出**两面。退出那五条锁防的是另一族错:删多(伤到收件人
17
- // 自己的行)、删少(留下半个包,身份还挂在那个已经收起的包上)、没删干净幂等键(归档版实现
18
- // 的死结:撤了却导不回来)、没删成东西却冒充「库刚变过」(重渲染整面镜像 + 顶回空闲计时器)、
19
- // 以及嵌入次数与「真的写进库的行数」脱钩。最后一条是整包 IN 列表的分块:SQLite 一条语句的
20
- // 参数上限是 32766,而包的水位按字节不按条数,所以「小正文大条数」的包能逼近它。
21
- import test from "node:test";
22
- import assert from "node:assert/strict";
23
- import { mkdtempSync, rmSync } from "node:fs";
24
- import { tmpdir } from "node:os";
25
- import { join } from "node:path";
26
- import { createStore } from "../src/store.js";
27
- import { createService } from "../src/service.js";
28
- import { deriveExternalId, importedTagFor, SHARE_KIND } from "../src/share.js";
29
- import { IMPORTED_SOURCE } from "../src/memory-source.js";
30
-
31
- const PACK_ID = "f0f0f0f0f0f0f0f0";
32
- // packId 可换是第三个补的用例要的:external_id 的四格里含 pack_id,同 pack_id 的第二份
33
- // 包会被幂等那格吃掉(skipped_existing),那条路径问不出「撞名报给谁」这件事。
34
- const PACK_ID_2 = "a1a1a1a1a1a1a1a1";
35
- const pack = (entries, alias = "桉", packId = PACK_ID) => ({
36
- kind: SHARE_KIND, pack_id: packId, alias, generated_at: "2026-10-22T00:00:00.000Z",
37
- source_version: "0.9.4", report: { included: entries.length, skipped: {}, skip_list: [], deduped: 0 },
38
- memories: entries.map((e) => ({ ...e, external_id: deriveExternalId(packId, e.type, e.title, e.content_hash) }))
39
- });
40
- const entry = (type, title, content) => ({
41
- type, title, content, tags: ["t"], importance: 4, epistemic_status: "verifiable",
42
- created_at: "2026-10-01T00:00:00.000Z", updated_at: "2026-10-01T00:00:00.000Z",
43
- content_hash: "anchor", external_id: deriveExternalId(PACK_ID, type, title, "anchor")
44
- });
45
-
46
- // scheduleEmbed 的写回是 embedSingle 的 .then(),异步且不进任何可 await 的出口
47
- // (fire-and-forget 是设计,不是漏的)。所以这里等的是**落库**:轮询到 embedding
48
- // 列真有值为止,等不到就让断言自己红。轮询而不是 await 一个 tick,是因为桩返回的
49
- // promise 与真实 embedder 的调度深度不同,写死 tick 数的用例会在某天多一层 await
50
- // 时静默转红——那类红查起来比这条用例本身还贵。
51
- async function waitFor(pred, tries = 100) {
52
- for (let i = 0; i < tries; i++) {
53
- if (pred()) return true;
54
- await new Promise((r) => setTimeout(r, 5));
55
- }
56
- return false;
57
- }
58
-
59
- function setup() {
60
- const dir = mkdtempSync(join(tmpdir(), "dsh-mneme-share-import-"));
61
- const dbPath = join(dir, "memory.db");
62
- const raw = createStore(dbPath);
63
- const embedded = [];
64
- // 事务形状的两个观测点,都由 store 包装记下来,不改任何返回值:
65
- // · saves:每次 store.save **返回之后**那一刻——库还开着事务吗(inTx)、嵌入已经被调用
66
- // 几次(fired)。这一格问的是「事务体内有没有当场发嵌入」。
67
- // · fires :每次 embedSingle 被调用那一刻——事务还在开着吗、库里能看到几行、写通知跑了
68
- // 没有。这一格问的是「排空是在 COMMIT 之前还是之后」。
69
- // 用 Proxy 而不是替换方法:service 拿到的是同一个 store 对象,所有方法照原样委托,
70
- // 观测只在调用后加一笔,绝不留一份「测试专用」的第二实现。
71
- const saves = [];
72
- const fires = [];
73
- const inject = { precheck: null };
74
- let writes = 0;
75
- let second = null;
76
- const store = new Proxy(raw, {
77
- get(t, key) {
78
- const v = Reflect.get(t, key);
79
- if (typeof v !== "function") return v;
80
- return (...args) => {
81
- const result = v.apply(t, args);
82
- if (key === "save") saves.push({ title: args[0].title, inTx: raw.db.isTransaction, fired: embedded.length });
83
- // 注入点:先查刚返回 undefined,写入还没发生——这正是「另一个宿主在我读与写之间
84
- // 提交」的那个窗口,用例在这里让对面的行落地。
85
- if (key === "getByShareExternalId" && inject.precheck) inject.precheck(args[0], result);
86
- return result;
87
- };
88
- }
89
- });
90
- const rowsOf = (s) => s.list({ limit: null, includeArchived: true, includeForgotten: true });
91
- const rows = () => rowsOf(raw);
92
- const service = createService({
93
- store, mirror: null, config: { embeddingModel: "stub" },
94
- onWrite: () => { writes++; }
95
- });
96
- // 方法名照 src/service.js 探测的那两个(schedule ?? embedSingle):猜错的话 lock d 那条
97
- // 用例会「绿得没意义」——嵌入根本没被调用也测不出来。这里刻意不给 schedule(),
98
- // 走的是 embedSingle → store.setEmbedding 那一支,正是导入路径要的那一条。
99
- // 探针记录(已跑过、不留在文件里):把这一整套 setEmbedder 摘掉、或把实现里的
100
- // scheduleEmbed(row) 删掉,下面 lock d 那条用例都会转红——它等的是 embedding 列,
101
- // 不是「函数被叫过」。
102
- // embedSingle 的函数体在第一个 await 之前同步跑完,所以 fires 记的就是「排空那一刻」:
103
- // 事务开没开、库里几行、写通知跑过没有,都是当下的读数而不是事后回看。
104
- service.setEmbedder({
105
- embedSingle: async (t) => {
106
- fires.push({ inTx: raw.db.isTransaction, rows: rowsOf(raw).length, writesAtFire: writes });
107
- embedded.push(t);
108
- return [0, t.length];
109
- },
110
- embed: async (xs) => xs.map(() => [0, 1])
111
- });
112
- return {
113
- store: raw, service, dir, rows, embedded, saves, fires, inject,
114
- writes: () => writes,
115
- // 第二个连接指同一个文件——AGENTS.md 说的「两个宿主共用一个 memoryDir」在单机上的
116
- // 等价物(WAL + busy_timeout 本就为多连接就绪,test/peer-blockers.test.js 同法)。
117
- openSecond() { second = createStore(dbPath); return second; },
118
- close() {
119
- for (const s of [second, raw]) { try { s.close(); } catch { /* closed */ } }
120
- // Windows:连接句柄延迟释放时 rm 偶发 EPERM,maxRetries 兜一下;清理只是收尾。
121
- try { rmSync(dir, { recursive: true, force: true, maxRetries: 30, retryDelay: 100 }); }
122
- catch (err) { if (!["EPERM", "EBUSY", "EACCES"].includes(err.code)) throw err; }
123
- }
124
- };
125
- }
126
-
127
- test("a foreign pack lands as new rows with the guard's identity", () => {
128
- const h = setup();
129
- try {
130
- const out = h.service.importSharePack(pack([entry("decision", "选 SQLite", "正文 A")]), {});
131
- assert.equal(out.created, 1);
132
- assert.equal(out.pack_id, PACK_ID);
133
- const row = h.rows().find((m) => m.title === "选 SQLite");
134
- assert.equal(row.source, IMPORTED_SOURCE);
135
- assert.equal(row.agent_scope, undefined);
136
- assert.equal(row.workspace_scope, undefined);
137
- assert.equal(row.share_pack_id, PACK_ID);
138
- assert.ok(row.tags.includes(importedTagFor("桉")));
139
- assert.notEqual(row.created_at, "2026-10-01T00:00:00.000Z", "created_at is the store's, not the pack's");
140
- // content_hash 与 created_at 同一族:两格都由 store 铸造,包里的值传不进去(spec §5
141
- // 记的三个事实前两条)。这条不是防呆——是「unchanged 只许当读数」的前提:接收侧一旦
142
- // 能吃下发件侧的锚,那一格就从给人看的数变成动作依据,同 title 同锚但正文不同的两条
143
- // 就会被判成「已经吃过」而静默不建。库里这条行带的必须是收件人自己算的哈希。
144
- // 先锁形状再锁不等:只写 notEqual 的话 content_hash 读成 undefined 也算「不等于 anchor」,
145
- // 那条断言就成了永远绿的空锁。
146
- assert.match(row.content_hash, /^[0-9a-f]{64}$/, "the stored anchor is the recipient's own sha256");
147
- assert.notEqual(row.content_hash, "anchor", "content_hash is the store's, not the pack's");
148
- } finally { h.close(); }
149
- });
150
-
151
- test("re-importing the same pack creates nothing and deletes nothing", () => {
152
- const h = setup();
153
- try {
154
- h.service.importSharePack(pack([entry("decision", "选 SQLite", "正文 A"), entry("pitfall", "别用表连接", "正文 B")]), {});
155
- const before = h.rows().length;
156
- const again = h.service.importSharePack(pack([entry("decision", "选 SQLite", "正文 A"), entry("pitfall", "别用表连接", "正文 B")]), {});
157
- assert.equal(again.created, 0);
158
- assert.equal(again.skipped_existing, 2);
159
- // unchanged 在这里是 0,不是 bug:包里的锚是发送方**遮之前**的归一化哈希,库里这一格
160
- // 是收件人按收到的字节重算的(上一条用例锁的就是这件事)。所以这条计数只能回答
161
- // 「两侧的尺看起来一样吗」,永远不能回答「这条要不要建」——后者只看 share_external_id。
162
- assert.equal(again.unchanged, 0, "the two anchors legitimately differ; unchanged is a reading, not a decision");
163
- assert.equal(h.rows().length, before, "idempotent means nothing removed either");
164
- } finally { h.close(); }
165
- });
166
-
167
- test("a name clash with the host's own memory is reported, never merged into", () => {
168
- const h = setup();
169
- try {
170
- const mine = h.store.save({ type: "decision", title: "选 SQLite", content: "我自己的结论", tags: [] });
171
- const before = h.store.getById(mine.id);
172
- const out = h.service.importSharePack(pack([entry("decision", "选 SQLite", "正文 A")]), {});
173
- assert.equal(out.created, 1);
174
- assert.equal(out.title_conflicts.length, 1);
175
- assert.equal(out.title_conflicts[0].existing_id, mine.id);
176
- const after = h.store.getById(mine.id);
177
- assert.equal(after.content, before.content);
178
- assert.equal(after.updated_at, before.updated_at);
179
- assert.equal(after.archived, before.archived);
180
- } finally { h.close(); }
181
- });
182
-
183
- test("dry-run answers the same shape and writes nothing", () => {
184
- const h = setup();
185
- try {
186
- const out = h.service.importSharePack(pack([entry("decision", "选 SQLite", "正文 A")]), { dryRun: true });
187
- assert.equal(out.created, 1);
188
- assert.equal(h.rows().length, 0, "a dry run must leave the library byte-identical");
189
- const real = h.service.importSharePack(pack([entry("decision", "选 SQLite", "正文 A")]), {});
190
- assert.equal(real.created, 1);
191
- } finally { h.close(); }
192
- });
193
-
194
- test("an invalid pack throws before any row is written", () => {
195
- const h = setup();
196
- try {
197
- const bad = pack([entry("decision", "选 SQLite", "正文 A"), entry("decision", "第二条", "x")]);
198
- bad.memories[1].external_id = "0000000000000000";
199
- assert.throws(() => h.service.importSharePack(bad, {}), (e) => e.code === "external-id-mismatch");
200
- assert.equal(h.rows().length, 0);
201
- } finally { h.close(); }
202
- });
203
-
204
- // 车道 B 的九个池子把外来行报成「不可改写」,收侧的撞名读数得说的是同一件事:
205
- // 撞的是收件人自己的行。上一包的 foreign 行同名不是撞名——它是别人的原文,
206
- // 报出去只会让人去处理一条他既不能改也不能删(C4 之前)的行。
207
- test("a clash is reported against my own row, never against another foreign row", () => {
208
- const h = setup();
209
- try {
210
- const first = h.service.importSharePack(pack([entry("pitfall", "别用表连接", "正文 B")], "桉"), {});
211
- assert.equal(first.title_conflicts.length, 0, "库里没有同名自己的行时不该凭空报撞名");
212
- const foreign = h.rows().find((m) => m.title === "别用表连接");
213
- assert.equal(foreign.source, IMPORTED_SOURCE);
214
- const second = h.service.importSharePack(pack([entry("pitfall", "别用表连接", "正文 B")], "杉", PACK_ID_2), {});
215
- assert.equal(second.created, 1, "别的 pack_id 铸出别的幂等键,这一条是真新建");
216
- assert.deepEqual(second.title_conflicts, [], "上一包的外来行不是「你库里已经有一条」");
217
- } finally { h.close(); }
218
- });
219
-
220
- // 第四条锁(d)。断言的是 embedding **列**:只数 embedSingle 被调了几次的话,
221
- // 一个「调完就把向量丢掉」的实现照样绿,而这条契约要的是对面搜得到这批行。
222
- test("the pack reaches the embed queue and lands a vector after the commit", async () => {
223
- const h = setup();
224
- try {
225
- const out = h.service.importSharePack(pack([entry("decision", "选 SQLite", "正文 A"), entry("pitfall", "别用表连接", "正文 B")]), {});
226
- assert.equal(out.created, 2);
227
- assert.equal(await waitFor(() => h.service.embeddedCount() === 2), true,
228
- "both imported rows must end up with a vector, not just a scheduled call");
229
- assert.equal(h.embedded.length, 2, "exactly one embed per imported row");
230
- // 干跑一条都不许嵌:它一行都没写,排进队列的就是给不存在的行做向量。
231
- const dry = h.service.importSharePack(pack([entry("constraint", "别动镜像", "正文 C")], "桉"), { dryRun: true });
232
- assert.equal(dry.created, 1);
233
- assert.equal(h.embedded.length, 2, "a dry run schedules nothing");
234
- assert.equal(h.service.embeddedCount(), 2);
235
- } finally { h.close(); }
236
- });
237
-
238
- // 车道 C 预检点名的那条裁定:真导入必须裹在 service.transaction() 里,不是
239
- // store.runAtomically()。两个助手都能让整包原子落库,所以「行在不在」测不出差别,
240
- // 差别在事务那一层的 finally:syncMirror() + notifyWrite()——镜像与写通知才是收件人
241
- // 其余读侧(面板、巩固调度、另一台宿主)看见这批行的地方。换成 runAtomically 会写下
242
- // 整包却一次都不通知;干跑则两次都不该通知(它没改任何东西,却会把空闲计时器顶回去、
243
- // 压掉本来该做的一轮巩固)。
244
- test("the committed batch fires the transaction's write side effects exactly once", () => {
245
- const h = setup();
246
- try {
247
- h.service.importSharePack(pack([entry("decision", "选 SQLite", "正文 A")]), { dryRun: true });
248
- assert.equal(h.writes(), 0, "a dry run must not claim the library just changed");
249
- h.service.importSharePack(pack([entry("decision", "选 SQLite", "正文 A")]), {});
250
- assert.equal(h.writes(), 1, "one notifyWrite per committed batch, from transaction()'s finally");
251
- } finally { h.close(); }
252
- });
253
-
254
- // 裁定 1 的正身。上一条例子数的是 notifyWrite(换了 runAtomically 会红),但它是**代理**
255
- // 读数:真正要钉的是嵌入队列的排空位置——scheduleEmbed 在事务里只入队,队列由 transaction()
256
- // 的 finally 在**最外层 COMMIT 之后**排空。三种「看起来一样对」的写法各被哪一格抓住:
257
- // · store.runAtomically 导入:txDepth 恒 0,嵌入在事务体内当场发起 → saves 里的 fired 立刻 >0;
258
- // · hand-rolled BEGIN/COMMIT + 提交前排空:行是原子的、嵌入也发了,但发的时候事务还开着
259
- // (fires 的 inTx 为真)、库里的行数还不是全批(rows < N)、finally 那层的 notifyWrite 根本没跑;
260
- // · 事务外再补一遍嵌入:同 fired > 0 那一类。
261
- // 「事务体内 fired === 0」与「排空时 inTx === 0」这两格是上一轮唯一缺的锁,notifyWrite 从此
262
- // 只是第二信号,不再是唯一挡着「重构后静默不嵌入」的东西。
263
- test("the embed queue drains at the transaction's outermost COMMIT, never inside the batch", () => {
264
- const h = setup();
265
- try {
266
- const p = pack([entry("decision", "选 SQLite", "正文 A"), entry("pitfall", "别用表连接", "正文 B")]);
267
- const out = h.service.importSharePack(p, {});
268
- assert.equal(out.created, 2);
269
- // 事务体内:两次写入都确实在开着的写事务里(inTx 为真,否则「一行都没发」只是因为
270
- // 根本没有事务——那格就成空锁了),且 embedSingle 一次都没被调用。
271
- assert.equal(h.saves.length, 2, "both rows are written through the store inside the batch");
272
- for (const s of h.saves) {
273
- assert.equal(s.inTx, true, `the write of 「${s.title}」 must happen inside an open transaction`);
274
- assert.equal(s.fired, 0, `nothing may be embedded while the batch is still open (saw 「${s.title}」)`);
275
- }
276
- // 排空时刻:COMMIT 已经发生(isTransaction 归 false),整批两行都已可见,写通知也已经
277
- // 跑过——这三格一起说的是「drain 在 finally 里、在提交之后」,不是「在事务里补的」。
278
- assert.equal(h.fires.length, 2, "exactly one embed per committed row");
279
- for (const f of h.fires) {
280
- assert.equal(f.inTx, false, "the queue must drain after the COMMIT, not while the transaction is open");
281
- assert.equal(f.rows, 2, "the drain sees the whole committed batch, not a prefix of it");
282
- assert.equal(f.writesAtFire, 1, "notifyWrite runs in the same finally, before the drain");
283
- }
284
- assert.equal(h.writes(), 1, "and exactly once for the batch");
285
- } finally { h.close(); }
286
- });
287
-
288
- // 两个连接、同一个文件(AGENTS.md 的多进程 WAL 场景在单进程里的等价物):对面宿主在我
289
- // 「先查读到 undefined」与「我这行 INSERT」之间把同一个 share_external_id 落了库。
290
- // 这条锁能证与不能证,写清楚,别当成跨进程证明:
291
- // · 证到:整笔中止(导入抛错)、一包一行都不落(连不冲突的那条也不在库里)、对面那行原样
292
- // 活着、且**两个连接读到的都是这个结果**(不是某条连接的私有回滚视图)、嵌入一次都没发。
293
- // · 没证到:报错的具体种类。WAL 下这条路径必然是 SQLITE_BUSY_SNAPSHOT(我们的事务先读过、
294
- // 想升级成写事务时对端已提交,store 的 busy_timeout 不重试这一类)——实测消息
295
- // 「database is locked」。索引那一层(UNIQUE)由下一条用例在**同一笔事务里**命中,那条
296
- // 才锁得住「已经写下的行也一起回滚」。两种表面都是「双保险生效」,所以断言按行为不按消息。
297
- // · 注入点只能在**我们第一次写之前**:WAL 同一时刻只有一个写入者,我们的 INSERT 一落地,
298
- // 对面那条就只会被 busy_timeout 挡死(那是另一件事,且要等 5 秒)。所以「对端抢先提交」
299
- // 这一族里,本用例是唯一能成立的时间序,「已落半包再回滚」只能交给下一条用例。
300
- test("a second host landing the same key mid-import aborts the whole batch on both connections", () => {
301
- const h = setup();
302
- const other = h.openSecond();
303
- try {
304
- const p = pack([entry("decision", "选 SQLite", "正文 A"), entry("pitfall", "别用表连接", "正文 B")]);
305
- const stolen = p.memories[0].external_id;
306
- let landed = false;
307
- h.inject.precheck = (key) => {
308
- if (landed || key !== stolen) return;
309
- landed = true;
310
- // 走另一个连接、另一个事务:它的 COMMIT 落在我们读与写之间。
311
- other.save({ type: "decision", title: "选 SQLite", content: "对面先落的原文",
312
- source: IMPORTED_SOURCE, share_pack_id: "另一个包", share_external_id: key });
313
- };
314
- assert.throws(() => h.service.importSharePack(p, {}), /UNIQUE constraint failed|database is locked/i);
315
- assert.equal(landed, true, "the injection must have run or the whole case is empty");
316
- assert.equal(h.saves.length, 0, "the abort must not leave any write of ours behind");
317
- // 收件人这条库里:只有对面那一条,包里两条一条都没落(幂等键不是 ours,也不算 skipped_existing)。
318
- assert.deepEqual(h.rows().map((m) => m.title), ["选 SQLite"]);
319
- assert.equal(h.rows()[0].content, "对面先落的原文", "the other host's row survives untouched");
320
- assert.equal(h.rows()[0].source, IMPORTED_SOURCE);
321
- // 对面连接读到的同一件事:我们的 INSERT 没有以「覆盖」「并存」的形态偷偷落进去。
322
- assert.deepEqual(other.list({ limit: null, includeArchived: true, includeForgotten: true }).map((m) => m.title),
323
- ["选 SQLite"], "the two connections must agree that nothing of ours landed");
324
- assert.equal(other.getByShareExternalId(stolen).content, "对面先落的原文");
325
- assert.equal(other.getByShareExternalId(p.memories[1].external_id), undefined);
326
- assert.equal(h.embedded.length, 0, "an aborted batch embeds nothing");
327
- assert.equal(h.service.embeddedCount(), 0);
328
- } finally { h.close(); }
329
- });
330
-
331
- // 「撞一条就跳过这一条继续」是这条契约最容易招来的宽容写法:看上去也返回了一个数,实际把
332
- // 「这个包还没吃过」报成「吃过了」,而库里留下半包。上一条例子挡不住它(我们的第一行就没
333
- // 写下去),所以这里让冲突发生在**第 3 行**:前两行的 INSERT 已经真的落进这笔事务,索引
334
- // 在第三次写入时把它撞回去。断言三格:前两行随整笔回滚一起消失(不是「留下半包」)、已入队
335
- // 的两次嵌入一个都没发(transaction() 把 pendingEmbed 截回基线)、错误原样穿出而不是被吞掉。
336
- // 「外层回滚」这件事在这里只能这么测:transaction() 是无条件 `BEGIN`,所以
337
- // `service.transaction(() => service.transaction(...))` 这种嵌套在实测里直接以
338
- // 「cannot start a transaction within a transaction」失败(外层因此连累回滚)——文件里
339
- // 真正的嵌套只有 store.runAtomically 那侧认 db.isTransaction 早退,而它不认识队列。
340
- // 回滚一侧的锁因此落在**本笔事务自己失败**这条路上:截断 pendingEmbed 的代码就是那一格,
341
- // 外层回滚走的也是同一格,形状等价。
342
- // 注入走 h.store(同一个连接、同一笔已开着的事务):这里要的是「写快照里这个键已被占用」
343
- // 这一件事,对端提交在 WAL 下只会拿到 BUSY(见上一条例子),拿不到 UNIQUE。代价是注入的
344
- // 那行也同属这笔事务,会跟着一起回滚——所以「对面的行活着」由上一条例子负责证。
345
- test("a collision on the index rolls back the rows that already landed and discards their embeds", () => {
346
- const h = setup();
347
- try {
348
- const p = pack([entry("decision", "选 SQLite", "正文 A"), entry("pitfall", "别用表连接", "正文 B"),
349
- entry("constraint", "别动镜像", "正文 C")]);
350
- const stolen = p.memories[2].external_id;
351
- let injected = false;
352
- h.inject.precheck = (key) => {
353
- if (injected || key !== stolen) return;
354
- injected = true;
355
- h.store.save({ type: "constraint", title: "别动镜像", content: "已经被占的原文",
356
- source: IMPORTED_SOURCE, share_pack_id: "另一个包", share_external_id: key });
357
- };
358
- assert.throws(() => h.service.importSharePack(p, {}),
359
- /UNIQUE constraint failed: memories\.share_external_id/, "the index, not the pre-check, must be what aborts us");
360
- assert.equal(injected, true, "the injection must have run or the whole case is empty");
361
- assert.equal(h.saves.length, 2, "two of our INSERTs did land inside the transaction before the collision");
362
- assert.equal(h.rows().length, 0, "a prefix must never survive: the whole batch rolls back, nothing lands");
363
- assert.equal(h.embedded.length, 0, "the queued embeds are truncated on rollback — no vector for a rolled-back row");
364
- assert.equal(h.service.embeddedCount(), 0);
365
- assert.equal(h.store.getByShareExternalId(p.memories[0].external_id), undefined);
366
- } finally { h.close(); }
367
- });
368
-
369
- // ============================ C4:整包退出 ============================
370
- // 退出是这条车道唯一会从用户库里**删掉东西**的动作,所以下面六条锁各自防一类「看着也对」
371
- // 的替换品:
372
- // · 删成归档 —— 行还在,UNIQUE 位还占着,包撤不掉也导不回(spec §5 定物删的那条理由);
373
- // · 只删可见面 / 只删第一块 —— deleted 报得满、库里却留着半包,那半包的来源标签指向一个
374
- // 已经撤走的分享者,而面板只认答复里的数;
375
- // · 顺手清库 —— 收件人自己的行被一起带走或被改一行,本文件按 C3 的口径逐字节钉住那一行;
376
- // · 干跑走真删那半 / 没删成也进事务 —— 答复同形状,但它重渲染了整面镜像、顶回了做梦的
377
- // 空闲计时器,还记下一笔没有对象的镜像债;
378
- // · 嵌入次数与真的写进库的行数脱钩(重复排空、给没写成的行做向量、退出后重嵌全库);
379
- // · 整包 IN 列表不分块 —— SQLite 一条语句 32766 个参数的上限,撞上了就是整笔回滚。
380
- test("undo removes exactly one pack's rows and frees the idempotency keys", () => {
381
- const h = setup();
382
- try {
383
- h.service.importSharePack(pack([entry("decision", "选 SQLite", "正文 A"), entry("pitfall", "别用表连接", "正文 B")]), {});
384
- const kept = h.store.save({ type: "decision", title: "我自己的", content: "正文", tags: [] });
385
- const keptBefore = h.store.getById(kept.id);
386
- const preview = h.service.deleteSharePack(PACK_ID, { dryRun: true });
387
- assert.equal(preview.deleted, 2);
388
- assert.equal(preview.pack_id, PACK_ID);
389
- assert.equal(h.rows().length, 3, "dry-run removes nothing");
390
- assert.equal(h.writes(), 1, "the preview must not claim the library just changed (the 1 is the import's own notify)");
391
- const out = h.service.deleteSharePack(PACK_ID, {});
392
- assert.equal(out.deleted, 2);
393
- assert.deepEqual(out.sample, preview.sample, "the destructive answer carries the same inventory the preview showed");
394
- assert.equal(h.rows().length, 1, "only the withdrawn pack is gone, the host's row is still there");
395
- assert.ok(h.store.getById(kept.id), "the host's own rows must survive");
396
- const keptAfter = h.store.getById(kept.id);
397
- assert.equal(keptAfter.updated_at, keptBefore.updated_at, "a withdrawal must not touch the recipient's own row");
398
- assert.equal(keptAfter.content, keptBefore.content);
399
- assert.equal(h.writes(), 2, "one notifyWrite for the committed withdrawal, from transaction()'s finally");
400
- assert.equal(h.service.deleteSharePack(PACK_ID, {}).deleted, 0, "undo is idempotent");
401
- assert.equal(h.writes(), 2, "a withdrawal that deletes nothing must not notify a second time");
402
- const again = h.service.importSharePack(pack([entry("decision", "选 SQLite", "正文 A"), entry("pitfall", "别用表连接", "正文 B")]), {});
403
- assert.equal(again.created, 2, "the UNIQUE slots were freed, so the pack can land again");
404
- } finally { h.close(); }
405
- });
406
-
407
- // 「留下半个包」是这一族里最难看见的错:deleted 报 2、sample 报 2、库里还剩 1 条带
408
- // share_pack_id 的行——面板上那个包显示已收起,向量检索与关键词检索却还能搜到它,而它
409
- // 的来源标签指向一个已经撤走的分享者。所以这里数的是**库级**行数(SELECT count(*) FROM
410
- // memories),不是答复里的数、也不是 h.rows() 那个可见面:归档与遗忘那两张脸必须一起
411
- // 收走。spec §5 明确不要的归档版实现在这里当场转红(变异检验:把实现换成逐行
412
- // setArchived,本文件六条退出用例里有五条一起红,见 C4 报告的 M2)。
413
- test("undo takes the whole pack with it, including the archived and forgotten faces", () => {
414
- const h = setup();
415
- try {
416
- h.service.importSharePack(pack([entry("decision", "选 SQLite", "正文 A"), entry("pitfall", "别用表连接", "正文 B"),
417
- entry("constraint", "别动镜像", "正文 C")]), {});
418
- const rowsIn = h.rows();
419
- h.store.setArchived(rowsIn.find((m) => m.title === "选 SQLite").id, true);
420
- h.store.setForget(rowsIn.find((m) => m.title === "别用表连接").id, true);
421
- const total = () => h.store.db.prepare("SELECT count(*) AS c FROM memories").get().c;
422
- assert.equal(total(), 3, "three foreign rows landed before the withdrawal");
423
- const out = h.service.deleteSharePack(PACK_ID, {});
424
- assert.equal(out.deleted, 3, "the count is the store's, not the visible face's: archived/forgotten rows are part of the pack");
425
- assert.equal(total(), 0, "nothing of that pack may survive in any face");
426
- assert.deepEqual(h.store.listBySharePackId(PACK_ID), []);
427
- // 每一格的幂等键都释放了:剩一条就够把下一个同名条目卡成 skipped_existing,而它挂在
428
- // 一个已经报成「收起了」的包上——所以 re-import 必须整包新建,一条都不许跳过。
429
- const again = h.service.importSharePack(pack([entry("decision", "选 SQLite", "正文 A"),
430
- entry("pitfall", "别用表连接", "正文 B"), entry("constraint", "别动镜像", "正文 C")]), {});
431
- assert.equal(again.created, 3, "every share_external_id slot the pack held is free again");
432
- assert.equal(again.skipped_existing, 0, "a withdrawn row must not read as 「already imported」");
433
- assert.deepEqual(again.title_conflicts, [], "the withdrawn pack left no row behind to clash with");
434
- assert.equal(h.rows().length, 3);
435
- } finally { h.close(); }
436
- });
437
-
438
- test("undo of an unknown pack answers zero, not an error", () => {
439
- const h = setup();
440
- try {
441
- h.service.importSharePack(pack([entry("decision", "选 SQLite", "正文 A")]), {});
442
- const genBefore = h.store.getMirrorState().generation;
443
- const out = h.service.deleteSharePack("0000000000000000", {});
444
- assert.equal(out.deleted, 0);
445
- assert.deepEqual(out.sample, []);
446
- assert.equal(out.pack_id, "0000000000000000");
447
- assert.equal(h.rows().length, 1, "an id that matches nothing must not delete the pack that is actually in the library");
448
- assert.equal(h.store.getMirrorState().generation, genBefore,
449
- "deleting nothing registers no mirror debt — recoverMirror would rewrite the whole mirror for zero rows");
450
- // 两张皮把对面的输入原样递进来:空串、空格、非字符串都不该抛,也不该摸 store。
451
- assert.deepEqual(h.service.deleteSharePack("", {}), { pack_id: "", deleted: 0, sample: [] });
452
- assert.deepEqual(h.service.deleteSharePack(" ", {}), { pack_id: "", deleted: 0, sample: [] });
453
- assert.deepEqual(h.service.deleteSharePack(undefined, {}), { pack_id: "", deleted: 0, sample: [] });
454
- assert.deepEqual(h.service.deleteSharePack(42, {}), { pack_id: "", deleted: 0, sample: [] });
455
- assert.equal(h.writes(), 1, "the four no-op answers above never reach transaction(), so they never notify");
456
- } finally { h.close(); }
457
- });
458
-
459
- // 上限管的是**答复的字节数**不是删除的完整性:这一格要走两张皮(面板 JSON 与 Bearer 数据面
460
- // JSON),一个几千条的包如果把全部标题塞进答复,响应体就比请求还大,而人只需要认前面几条。
461
- // 所以删多少与报多少是两格:deleted 恒为全集,sample 恒为 20 条封顶的前缀。
462
- test("the sample is capped so a huge pack still answers in bounded bytes", () => {
463
- const h = setup();
464
- try {
465
- const many = Array.from({ length: 30 }, (_, i) => entry("pattern", `模式 ${i}`, `正文 ${i}`));
466
- h.service.importSharePack(pack(many), {});
467
- const out = h.service.deleteSharePack(PACK_ID, { dryRun: true });
468
- assert.equal(out.deleted, 30);
469
- assert.equal(out.sample.length, 20);
470
- assert.ok(out.sample.every((s) => typeof s.type === "string" && typeof s.title === "string"));
471
- assert.deepEqual(Object.keys(out.sample[0]).sort(), ["title", "type"],
472
- "the sample carries only what a human needs to recognise the batch — no ids, no content bytes");
473
- assert.equal(h.rows().length, 30, "the preview deletes nothing");
474
- const real = h.service.deleteSharePack(PACK_ID, {});
475
- assert.equal(real.deleted, 30, "the cap never caps the delete, only the report");
476
- assert.equal(real.sample.length, 20);
477
- assert.equal(h.rows().length, 0);
478
- } finally { h.close(); }
479
- });
480
-
481
- // 上一轮评审点名的缺口:现有三把事务锁(saves 里的 fired、排空时刻的 inTx/rows/writesAtFire、
482
- // notifyWrite 次数)数的是「排空那一眼」的形状,所以「在 transaction() 返回之后再补一遍嵌入」
483
- // 那种写法能同时满足它们。这条把锚换成一个等式:**发射次数 == 本次真的写进库的行数**,
484
- // 不多不少,且退出一次都不许发。它能抓住的形状:重复排空(双份嵌入)、给没写成的行做向量(干跑/回滚/
485
- // 未知包)、以及「退出后把剩下的库重嵌一遍」(那既烧收件人的模型额度,也把外来行的向量重新
486
- // 写进已经删掉的行——后者由 searchVector 那一格兜住:向量集合永远只以库为准)。
487
- // 诚实记下它抓不到的那一半:如果实现把入队换成「事务返回后一次性补」,次数与向量列都对得上,
488
- // 本文件所有探针都读不到差别——差别只在 pendingEmbed 有没有被用过,那是 service 闭包内部的
489
- // 事,钉它要动生产代码的可测面,这一刀不换(见报告 leftover)。
490
- test("embeds fire exactly once per row a batch writes, and never for a withdrawal", async () => {
491
- const h = setup();
492
- try {
493
- const first = h.service.importSharePack(pack([entry("decision", "选 SQLite", "正文 A"), entry("pitfall", "别用表连接", "正文 B")]), {});
494
- assert.equal(h.fires.length, first.created, "one embed fire per written row, no more");
495
- const preview = h.service.importSharePack(pack([entry("constraint", "别动镜像", "正文 C")], "桉"), { dryRun: true });
496
- assert.equal(preview.created, 1);
497
- assert.equal(h.fires.length, first.created, "a dry run writes no rows, so it must fire no embed");
498
- const second = h.service.importSharePack(pack([entry("constraint", "别动镜像", "正文 C")], "杉", PACK_ID_2), {});
499
- assert.equal(h.fires.length, first.created + second.created, "the second pack adds exactly its own rows");
500
- assert.equal(h.embedded.length, h.fires.length, "no fire is dropped and none is invented");
501
- assert.equal(await waitFor(() => h.service.embeddedCount() === 3), true, "all three live rows end up vectored");
502
- const withdrawn = h.store.listBySharePackId(PACK_ID).map((m) => m.id);
503
- const firesBefore = h.fires.length;
504
- const out = h.service.deleteSharePack(PACK_ID, {});
505
- assert.equal(out.deleted, 2);
506
- assert.equal(h.fires.length, firesBefore,
507
- "a withdrawal embeds nothing: it writes no row, and re-embedding the survivor would burn the recipient's model quota");
508
- assert.equal(await waitFor(() => h.service.embeddedCount() === 1), true,
509
- "the embedding column travels with the row: the two withdrawn rows take their vectors to the grave");
510
- // 真正的读路径:向量检索的候选集合每次由 SQL 现取(store.searchVector),所以退出的行
511
- // 不可能以「有向量的僵尸」身份回来。桩向量的余弦对任何 [0, n] 都是 1,这里要的就是
512
- // 「全都入选」的宽口径,好让行数差直接显形。
513
- const hits = h.service.searchVector([0, 1], { limit: 10, threshold: 0 });
514
- assert.equal(hits.length, 1, "the withdrawn rows are invisible to vector recall");
515
- assert.equal(hits[0].share_pack_id, PACK_ID_2);
516
- for (const id of withdrawn) assert.equal(h.store.getById(id), undefined);
517
- } finally { h.close(); }
518
- });
519
-
520
- // 分块锁(SQLite 一条语句最多绑 32766 个参数,SQLITE_MAX_VARIABLE_NUMBER)。车道 A 的水位
521
- // 按字节不按条数(MAX_PACK_BYTES = 3 MiB),所以「正文很短、条数很多」的包理论上能逼近那一格,
522
- // 而一条语句撞上限是整笔回滚 = 这个包永远撤不掉。这里不测 32766 本身(那要 32767 行真数据),
523
- // 测的是分块这件事真的发生了:600 行的包由**多条** DELETE 承担,每条绑的 `?` 严格少于全集,
524
- // 而绑数之和正好是 600(没有漏删的块、没有重复的块)。
525
- // 它防的回归:把 deleteBySharePackId 换回单条 IN(deletes.length 变 1、那条的绑数变 600);
526
- // 以及「只删了第一块」那种半吊子分块(deleted 变 500、绑数之和变 500)。
527
- // 诚实记录这块观测的边界:它只数 DELETE FROM memories 的语句形状,不改任何返回值;chunk 尺寸
528
- // 若哪天被调到 ≥600,本用例会转红,那时把包加大而不是把断言调松——断言的意思是「一条语句
529
- // 绑不下整个包」,不是「chunk 等于 500」。
530
- test("a pack larger than one IN chunk is withdrawn in chunks that each bind less than the whole set", () => {
531
- const h = setup();
532
- try {
533
- // 600 行由 store.save 直接落,不走 importSharePack:这条锁的是**删**那一侧的语句形状,
534
- // 而本文件的嵌入探针每次发射都要全表数一遍行数(见 setup 里的 fires.rows),600 行走完整
535
- // 导入会把用例变成 600×600 的读、测的还是导入侧(30 行那条已经覆盖)。落库带的那几列
536
- // (source / share_pack_id / share_external_id / epistemic_status)与导入路径逐字同一套,
537
- // 所以删除面读到的形状不是假的。
538
- const many = Array.from({ length: 600 }, (_, i) => entry("pattern", `模式 ${i}`, `正文 ${i}`));
539
- for (const e of many) {
540
- h.store.save({ type: e.type, title: e.title, content: e.content, tags: e.tags, importance: e.importance,
541
- epistemic_status: e.epistemic_status, source: IMPORTED_SOURCE, agent_scope: null, workspace_scope: null,
542
- share_pack_id: PACK_ID, share_external_id: e.external_id });
543
- }
544
- assert.equal(h.rows().length, 600, "the multi-chunk path needs a pack bigger than one chunk to exist at all");
545
- // 观测点:临时给这条连接的 prepare 加一层记账(取完立刻还原,见 finally)。store 内部
546
- // 调的是 db.prepare(...),属性查找在调用那一刻发生,所以自己的这一层会先被命中;
547
- // delete 之后原型上的方法照原样回来。不改任何返回值,也不留第二份实现。
548
- const db = h.store.db;
549
- const originalPrepare = db.prepare;
550
- const sqls = [];
551
- db.prepare = function (sql) { sqls.push(sql); return originalPrepare.call(this, sql); };
552
- let out;
553
- try {
554
- out = h.service.deleteSharePack(PACK_ID, {});
555
- } finally {
556
- delete db.prepare;
557
- }
558
- assert.equal(db.prepare, originalPrepare, "the observation must be taken back off the connection");
559
- assert.equal(out.deleted, 600, "chunking must not lose a row, and must not report a chunk's count as the total");
560
- assert.equal(h.rows().length, 0, "every chunk is really deleted, not just the first one");
561
- // 两条 IN 都得分块:memories 与 memory_provenance 绑的是同一份 id 快照,只分其中一条
562
- // 等于另一半还挂着那个上限。
563
- for (const prefix of ["DELETE FROM memories WHERE id IN (", "DELETE FROM memory_provenance WHERE memory_id IN ("]) {
564
- const statements = sqls.filter((sql) => sql.startsWith(prefix));
565
- const bound = statements.map((sql) => (sql.match(/\?/g) || []).length);
566
- assert.ok(bound.length >= 2, `${prefix}… must run as more than one statement for a 600-row pack (saw ${bound.length})`);
567
- assert.equal(bound.reduce((sum, n) => sum + n, 0), 600,
568
- `${prefix}… chunks must partition the id set: nothing deleted twice, nothing left out`);
569
- for (const n of bound) assert.ok(n < 600, `one statement bound all ${n} ids — that is the unbounded IN list this lock exists to stop`);
570
- }
571
- // 整包的 UNIQUE 槽位随分块一起释放(最后一块最容易漏:它绑的是余数)。
572
- h.store.save({ type: "pattern", title: "模式 0", content: "撤完还能再落回同一格",
573
- source: IMPORTED_SOURCE, share_pack_id: PACK_ID, share_external_id: many[0].external_id });
574
- assert.equal(h.store.getByShareExternalId(many[0].external_id).content, "撤完还能再落回同一格");
575
- assert.equal(h.rows().length, 1);
576
- } finally { h.close(); }
577
- });
1
+ // 导入的动作面。四条锁,一条比一条容易被「顺手重构」拆掉:
2
+ // a) 不改写别人——既有非 imported 行的 updated_at 逐字节不动(决策 2 的直接锁);
3
+ // b) 幂等——同包第二次导入 created = 0 且不删任何东西;
4
+ // c) 外来行带着守卫要的身份落地(source / 两个 scope),否则车道 B 的九个池子白挡;
5
+ // d) 真的排进了嵌入队列、并在提交后落进 embedding 列(在 transaction 里),否则对面的
6
+ // 向量检索吃不到这批。
7
+ // 另有三条同样会被「顺手重构」拆掉:撞名清单里的既有那一行必须是收件人自己的行(外来行
8
+ // 永远不许当 existing_id)、校验不过时整库无痕、干跑不许冒充「库刚变过」。这三条测的都是
9
+ // 收侧要对外讲的读数与其余读侧的可见性——报得出去但没人能看出错,比不测更糟。
10
+ // 最后三条盯事务那一层的形状:嵌入队列只在**最外层 COMMIT** 排空(不当场发、也不在提交前
11
+ // 排空)、对面宿主抢先把同一个 share_external_id 落库时整笔中止、以及「已经写下的行」随
12
+ // 回滚一起消失且一行都不嵌。它们各自防的替换品:store.runAtomically(txDepth 恒 0,队列
13
+ // 当场排空)、hand-rolled BEGIN/COMMIT(原子性一样,但 drain 与 notifyWrite 的位置不对)、
14
+ // 「撞一条就跳过这一条」的宽容写法(前几行留在库里)。
15
+ //
16
+ // C4 之后本文件管的是收侧的**进与出**两面。退出那五条锁防的是另一族错:删多(伤到收件人
17
+ // 自己的行)、删少(留下半个包,身份还挂在那个已经收起的包上)、没删干净幂等键(归档版实现
18
+ // 的死结:撤了却导不回来)、没删成东西却冒充「库刚变过」(重渲染整面镜像 + 顶回空闲计时器)、
19
+ // 以及嵌入次数与「真的写进库的行数」脱钩。最后一条是整包 IN 列表的分块:SQLite 一条语句的
20
+ // 参数上限是 32766,而包的水位按字节不按条数,所以「小正文大条数」的包能逼近它。
21
+ import test from "node:test";
22
+ import assert from "node:assert/strict";
23
+ import { mkdtempSync, rmSync } from "node:fs";
24
+ import { tmpdir } from "node:os";
25
+ import { join } from "node:path";
26
+ import { createStore } from "../src/store.js";
27
+ import { createService } from "../src/service.js";
28
+ import { deriveExternalId, importedTagFor, SHARE_KIND } from "../src/share.js";
29
+ import { IMPORTED_SOURCE } from "../src/memory-source.js";
30
+
31
+ const PACK_ID = "f0f0f0f0f0f0f0f0";
32
+ // packId 可换是第三个补的用例要的:external_id 的四格里含 pack_id,同 pack_id 的第二份
33
+ // 包会被幂等那格吃掉(skipped_existing),那条路径问不出「撞名报给谁」这件事。
34
+ const PACK_ID_2 = "a1a1a1a1a1a1a1a1";
35
+ const pack = (entries, alias = "桉", packId = PACK_ID) => ({
36
+ kind: SHARE_KIND, pack_id: packId, alias, generated_at: "2026-10-22T00:00:00.000Z",
37
+ source_version: "0.9.4", report: { included: entries.length, skipped: {}, skip_list: [], deduped: 0 },
38
+ memories: entries.map((e) => ({ ...e, external_id: deriveExternalId(packId, e.type, e.title, e.content_hash) }))
39
+ });
40
+ const entry = (type, title, content) => ({
41
+ type, title, content, tags: ["t"], importance: 4, epistemic_status: "verifiable",
42
+ created_at: "2026-10-01T00:00:00.000Z", updated_at: "2026-10-01T00:00:00.000Z",
43
+ content_hash: "anchor", external_id: deriveExternalId(PACK_ID, type, title, "anchor")
44
+ });
45
+
46
+ // scheduleEmbed 的写回是 embedSingle 的 .then(),异步且不进任何可 await 的出口
47
+ // (fire-and-forget 是设计,不是漏的)。所以这里等的是**落库**:轮询到 embedding
48
+ // 列真有值为止,等不到就让断言自己红。轮询而不是 await 一个 tick,是因为桩返回的
49
+ // promise 与真实 embedder 的调度深度不同,写死 tick 数的用例会在某天多一层 await
50
+ // 时静默转红——那类红查起来比这条用例本身还贵。
51
+ async function waitFor(pred, tries = 100) {
52
+ for (let i = 0; i < tries; i++) {
53
+ if (pred()) return true;
54
+ await new Promise((r) => setTimeout(r, 5));
55
+ }
56
+ return false;
57
+ }
58
+
59
+ function setup() {
60
+ const dir = mkdtempSync(join(tmpdir(), "dsh-mneme-share-import-"));
61
+ const dbPath = join(dir, "memory.db");
62
+ const raw = createStore(dbPath);
63
+ const embedded = [];
64
+ // 事务形状的两个观测点,都由 store 包装记下来,不改任何返回值:
65
+ // · saves:每次 store.save **返回之后**那一刻——库还开着事务吗(inTx)、嵌入已经被调用
66
+ // 几次(fired)。这一格问的是「事务体内有没有当场发嵌入」。
67
+ // · fires :每次 embedSingle 被调用那一刻——事务还在开着吗、库里能看到几行、写通知跑了
68
+ // 没有。这一格问的是「排空是在 COMMIT 之前还是之后」。
69
+ // 用 Proxy 而不是替换方法:service 拿到的是同一个 store 对象,所有方法照原样委托,
70
+ // 观测只在调用后加一笔,绝不留一份「测试专用」的第二实现。
71
+ const saves = [];
72
+ const fires = [];
73
+ const inject = { precheck: null };
74
+ let writes = 0;
75
+ let second = null;
76
+ const store = new Proxy(raw, {
77
+ get(t, key) {
78
+ const v = Reflect.get(t, key);
79
+ if (typeof v !== "function") return v;
80
+ return (...args) => {
81
+ const result = v.apply(t, args);
82
+ if (key === "save") saves.push({ title: args[0].title, inTx: raw.db.isTransaction, fired: embedded.length });
83
+ // 注入点:先查刚返回 undefined,写入还没发生——这正是「另一个宿主在我读与写之间
84
+ // 提交」的那个窗口,用例在这里让对面的行落地。
85
+ if (key === "getByShareExternalId" && inject.precheck) inject.precheck(args[0], result);
86
+ return result;
87
+ };
88
+ }
89
+ });
90
+ const rowsOf = (s) => s.list({ limit: null, includeArchived: true, includeForgotten: true });
91
+ const rows = () => rowsOf(raw);
92
+ const service = createService({
93
+ store, mirror: null, config: { embeddingModel: "stub" },
94
+ onWrite: () => { writes++; }
95
+ });
96
+ // 方法名照 src/service.js 探测的那两个(schedule ?? embedSingle):猜错的话 lock d 那条
97
+ // 用例会「绿得没意义」——嵌入根本没被调用也测不出来。这里刻意不给 schedule(),
98
+ // 走的是 embedSingle → store.setEmbedding 那一支,正是导入路径要的那一条。
99
+ // 探针记录(已跑过、不留在文件里):把这一整套 setEmbedder 摘掉、或把实现里的
100
+ // scheduleEmbed(row) 删掉,下面 lock d 那条用例都会转红——它等的是 embedding 列,
101
+ // 不是「函数被叫过」。
102
+ // embedSingle 的函数体在第一个 await 之前同步跑完,所以 fires 记的就是「排空那一刻」:
103
+ // 事务开没开、库里几行、写通知跑过没有,都是当下的读数而不是事后回看。
104
+ service.setEmbedder({
105
+ embedSingle: async (t) => {
106
+ fires.push({ inTx: raw.db.isTransaction, rows: rowsOf(raw).length, writesAtFire: writes });
107
+ embedded.push(t);
108
+ return [0, t.length];
109
+ },
110
+ embed: async (xs) => xs.map(() => [0, 1])
111
+ });
112
+ return {
113
+ store: raw, service, dir, rows, embedded, saves, fires, inject,
114
+ writes: () => writes,
115
+ // 第二个连接指同一个文件——AGENTS.md 说的「两个宿主共用一个 memoryDir」在单机上的
116
+ // 等价物(WAL + busy_timeout 本就为多连接就绪,test/peer-blockers.test.js 同法)。
117
+ openSecond() { second = createStore(dbPath); return second; },
118
+ close() {
119
+ for (const s of [second, raw]) { try { s.close(); } catch { /* closed */ } }
120
+ // Windows:连接句柄延迟释放时 rm 偶发 EPERM,maxRetries 兜一下;清理只是收尾。
121
+ try { rmSync(dir, { recursive: true, force: true, maxRetries: 30, retryDelay: 100 }); }
122
+ catch (err) { if (!["EPERM", "EBUSY", "EACCES"].includes(err.code)) throw err; }
123
+ }
124
+ };
125
+ }
126
+
127
+ test("a foreign pack lands as new rows with the guard's identity", () => {
128
+ const h = setup();
129
+ try {
130
+ const out = h.service.importSharePack(pack([entry("decision", "选 SQLite", "正文 A")]), {});
131
+ assert.equal(out.created, 1);
132
+ assert.equal(out.pack_id, PACK_ID);
133
+ const row = h.rows().find((m) => m.title === "选 SQLite");
134
+ assert.equal(row.source, IMPORTED_SOURCE);
135
+ assert.equal(row.agent_scope, undefined);
136
+ assert.equal(row.workspace_scope, undefined);
137
+ assert.equal(row.share_pack_id, PACK_ID);
138
+ assert.ok(row.tags.includes(importedTagFor("桉")));
139
+ assert.notEqual(row.created_at, "2026-10-01T00:00:00.000Z", "created_at is the store's, not the pack's");
140
+ // content_hash 与 created_at 同一族:两格都由 store 铸造,包里的值传不进去(spec §5
141
+ // 记的三个事实前两条)。这条不是防呆——是「unchanged 只许当读数」的前提:接收侧一旦
142
+ // 能吃下发件侧的锚,那一格就从给人看的数变成动作依据,同 title 同锚但正文不同的两条
143
+ // 就会被判成「已经吃过」而静默不建。库里这条行带的必须是收件人自己算的哈希。
144
+ // 先锁形状再锁不等:只写 notEqual 的话 content_hash 读成 undefined 也算「不等于 anchor」,
145
+ // 那条断言就成了永远绿的空锁。
146
+ assert.match(row.content_hash, /^[0-9a-f]{64}$/, "the stored anchor is the recipient's own sha256");
147
+ assert.notEqual(row.content_hash, "anchor", "content_hash is the store's, not the pack's");
148
+ } finally { h.close(); }
149
+ });
150
+
151
+ test("re-importing the same pack creates nothing and deletes nothing", () => {
152
+ const h = setup();
153
+ try {
154
+ h.service.importSharePack(pack([entry("decision", "选 SQLite", "正文 A"), entry("pitfall", "别用表连接", "正文 B")]), {});
155
+ const before = h.rows().length;
156
+ const again = h.service.importSharePack(pack([entry("decision", "选 SQLite", "正文 A"), entry("pitfall", "别用表连接", "正文 B")]), {});
157
+ assert.equal(again.created, 0);
158
+ assert.equal(again.skipped_existing, 2);
159
+ // unchanged 在这里是 0,不是 bug:包里的锚是发送方**遮之前**的归一化哈希,库里这一格
160
+ // 是收件人按收到的字节重算的(上一条用例锁的就是这件事)。所以这条计数只能回答
161
+ // 「两侧的尺看起来一样吗」,永远不能回答「这条要不要建」——后者只看 share_external_id。
162
+ assert.equal(again.unchanged, 0, "the two anchors legitimately differ; unchanged is a reading, not a decision");
163
+ assert.equal(h.rows().length, before, "idempotent means nothing removed either");
164
+ } finally { h.close(); }
165
+ });
166
+
167
+ test("a name clash with the host's own memory is reported, never merged into", () => {
168
+ const h = setup();
169
+ try {
170
+ const mine = h.store.save({ type: "decision", title: "选 SQLite", content: "我自己的结论", tags: [] });
171
+ const before = h.store.getById(mine.id);
172
+ const out = h.service.importSharePack(pack([entry("decision", "选 SQLite", "正文 A")]), {});
173
+ assert.equal(out.created, 1);
174
+ assert.equal(out.title_conflicts.length, 1);
175
+ assert.equal(out.title_conflicts[0].existing_id, mine.id);
176
+ const after = h.store.getById(mine.id);
177
+ assert.equal(after.content, before.content);
178
+ assert.equal(after.updated_at, before.updated_at);
179
+ assert.equal(after.archived, before.archived);
180
+ } finally { h.close(); }
181
+ });
182
+
183
+ test("dry-run answers the same shape and writes nothing", () => {
184
+ const h = setup();
185
+ try {
186
+ const out = h.service.importSharePack(pack([entry("decision", "选 SQLite", "正文 A")]), { dryRun: true });
187
+ assert.equal(out.created, 1);
188
+ assert.equal(h.rows().length, 0, "a dry run must leave the library byte-identical");
189
+ const real = h.service.importSharePack(pack([entry("decision", "选 SQLite", "正文 A")]), {});
190
+ assert.equal(real.created, 1);
191
+ } finally { h.close(); }
192
+ });
193
+
194
+ test("an invalid pack throws before any row is written", () => {
195
+ const h = setup();
196
+ try {
197
+ const bad = pack([entry("decision", "选 SQLite", "正文 A"), entry("decision", "第二条", "x")]);
198
+ bad.memories[1].external_id = "0000000000000000";
199
+ assert.throws(() => h.service.importSharePack(bad, {}), (e) => e.code === "external-id-mismatch");
200
+ assert.equal(h.rows().length, 0);
201
+ } finally { h.close(); }
202
+ });
203
+
204
+ // 车道 B 的九个池子把外来行报成「不可改写」,收侧的撞名读数得说的是同一件事:
205
+ // 撞的是收件人自己的行。上一包的 foreign 行同名不是撞名——它是别人的原文,
206
+ // 报出去只会让人去处理一条他既不能改也不能删(C4 之前)的行。
207
+ test("a clash is reported against my own row, never against another foreign row", () => {
208
+ const h = setup();
209
+ try {
210
+ const first = h.service.importSharePack(pack([entry("pitfall", "别用表连接", "正文 B")], "桉"), {});
211
+ assert.equal(first.title_conflicts.length, 0, "库里没有同名自己的行时不该凭空报撞名");
212
+ const foreign = h.rows().find((m) => m.title === "别用表连接");
213
+ assert.equal(foreign.source, IMPORTED_SOURCE);
214
+ const second = h.service.importSharePack(pack([entry("pitfall", "别用表连接", "正文 B")], "杉", PACK_ID_2), {});
215
+ assert.equal(second.created, 1, "别的 pack_id 铸出别的幂等键,这一条是真新建");
216
+ assert.deepEqual(second.title_conflicts, [], "上一包的外来行不是「你库里已经有一条」");
217
+ } finally { h.close(); }
218
+ });
219
+
220
+ // 第四条锁(d)。断言的是 embedding **列**:只数 embedSingle 被调了几次的话,
221
+ // 一个「调完就把向量丢掉」的实现照样绿,而这条契约要的是对面搜得到这批行。
222
+ test("the pack reaches the embed queue and lands a vector after the commit", async () => {
223
+ const h = setup();
224
+ try {
225
+ const out = h.service.importSharePack(pack([entry("decision", "选 SQLite", "正文 A"), entry("pitfall", "别用表连接", "正文 B")]), {});
226
+ assert.equal(out.created, 2);
227
+ assert.equal(await waitFor(() => h.service.embeddedCount() === 2), true,
228
+ "both imported rows must end up with a vector, not just a scheduled call");
229
+ assert.equal(h.embedded.length, 2, "exactly one embed per imported row");
230
+ // 干跑一条都不许嵌:它一行都没写,排进队列的就是给不存在的行做向量。
231
+ const dry = h.service.importSharePack(pack([entry("constraint", "别动镜像", "正文 C")], "桉"), { dryRun: true });
232
+ assert.equal(dry.created, 1);
233
+ assert.equal(h.embedded.length, 2, "a dry run schedules nothing");
234
+ assert.equal(h.service.embeddedCount(), 2);
235
+ } finally { h.close(); }
236
+ });
237
+
238
+ // 车道 C 预检点名的那条裁定:真导入必须裹在 service.transaction() 里,不是
239
+ // store.runAtomically()。两个助手都能让整包原子落库,所以「行在不在」测不出差别,
240
+ // 差别在事务那一层的 finally:syncMirror() + notifyWrite()——镜像与写通知才是收件人
241
+ // 其余读侧(面板、巩固调度、另一台宿主)看见这批行的地方。换成 runAtomically 会写下
242
+ // 整包却一次都不通知;干跑则两次都不该通知(它没改任何东西,却会把空闲计时器顶回去、
243
+ // 压掉本来该做的一轮巩固)。
244
+ test("the committed batch fires the transaction's write side effects exactly once", () => {
245
+ const h = setup();
246
+ try {
247
+ h.service.importSharePack(pack([entry("decision", "选 SQLite", "正文 A")]), { dryRun: true });
248
+ assert.equal(h.writes(), 0, "a dry run must not claim the library just changed");
249
+ h.service.importSharePack(pack([entry("decision", "选 SQLite", "正文 A")]), {});
250
+ assert.equal(h.writes(), 1, "one notifyWrite per committed batch, from transaction()'s finally");
251
+ } finally { h.close(); }
252
+ });
253
+
254
+ // 裁定 1 的正身。上一条例子数的是 notifyWrite(换了 runAtomically 会红),但它是**代理**
255
+ // 读数:真正要钉的是嵌入队列的排空位置——scheduleEmbed 在事务里只入队,队列由 transaction()
256
+ // 的 finally 在**最外层 COMMIT 之后**排空。三种「看起来一样对」的写法各被哪一格抓住:
257
+ // · store.runAtomically 导入:txDepth 恒 0,嵌入在事务体内当场发起 → saves 里的 fired 立刻 >0;
258
+ // · hand-rolled BEGIN/COMMIT + 提交前排空:行是原子的、嵌入也发了,但发的时候事务还开着
259
+ // (fires 的 inTx 为真)、库里的行数还不是全批(rows < N)、finally 那层的 notifyWrite 根本没跑;
260
+ // · 事务外再补一遍嵌入:同 fired > 0 那一类。
261
+ // 「事务体内 fired === 0」与「排空时 inTx === 0」这两格是上一轮唯一缺的锁,notifyWrite 从此
262
+ // 只是第二信号,不再是唯一挡着「重构后静默不嵌入」的东西。
263
+ test("the embed queue drains at the transaction's outermost COMMIT, never inside the batch", () => {
264
+ const h = setup();
265
+ try {
266
+ const p = pack([entry("decision", "选 SQLite", "正文 A"), entry("pitfall", "别用表连接", "正文 B")]);
267
+ const out = h.service.importSharePack(p, {});
268
+ assert.equal(out.created, 2);
269
+ // 事务体内:两次写入都确实在开着的写事务里(inTx 为真,否则「一行都没发」只是因为
270
+ // 根本没有事务——那格就成空锁了),且 embedSingle 一次都没被调用。
271
+ assert.equal(h.saves.length, 2, "both rows are written through the store inside the batch");
272
+ for (const s of h.saves) {
273
+ assert.equal(s.inTx, true, `the write of 「${s.title}」 must happen inside an open transaction`);
274
+ assert.equal(s.fired, 0, `nothing may be embedded while the batch is still open (saw 「${s.title}」)`);
275
+ }
276
+ // 排空时刻:COMMIT 已经发生(isTransaction 归 false),整批两行都已可见,写通知也已经
277
+ // 跑过——这三格一起说的是「drain 在 finally 里、在提交之后」,不是「在事务里补的」。
278
+ assert.equal(h.fires.length, 2, "exactly one embed per committed row");
279
+ for (const f of h.fires) {
280
+ assert.equal(f.inTx, false, "the queue must drain after the COMMIT, not while the transaction is open");
281
+ assert.equal(f.rows, 2, "the drain sees the whole committed batch, not a prefix of it");
282
+ assert.equal(f.writesAtFire, 1, "notifyWrite runs in the same finally, before the drain");
283
+ }
284
+ assert.equal(h.writes(), 1, "and exactly once for the batch");
285
+ } finally { h.close(); }
286
+ });
287
+
288
+ // 两个连接、同一个文件(AGENTS.md 的多进程 WAL 场景在单进程里的等价物):对面宿主在我
289
+ // 「先查读到 undefined」与「我这行 INSERT」之间把同一个 share_external_id 落了库。
290
+ // 这条锁能证与不能证,写清楚,别当成跨进程证明:
291
+ // · 证到:整笔中止(导入抛错)、一包一行都不落(连不冲突的那条也不在库里)、对面那行原样
292
+ // 活着、且**两个连接读到的都是这个结果**(不是某条连接的私有回滚视图)、嵌入一次都没发。
293
+ // · 没证到:报错的具体种类。WAL 下这条路径必然是 SQLITE_BUSY_SNAPSHOT(我们的事务先读过、
294
+ // 想升级成写事务时对端已提交,store 的 busy_timeout 不重试这一类)——实测消息
295
+ // 「database is locked」。索引那一层(UNIQUE)由下一条用例在**同一笔事务里**命中,那条
296
+ // 才锁得住「已经写下的行也一起回滚」。两种表面都是「双保险生效」,所以断言按行为不按消息。
297
+ // · 注入点只能在**我们第一次写之前**:WAL 同一时刻只有一个写入者,我们的 INSERT 一落地,
298
+ // 对面那条就只会被 busy_timeout 挡死(那是另一件事,且要等 5 秒)。所以「对端抢先提交」
299
+ // 这一族里,本用例是唯一能成立的时间序,「已落半包再回滚」只能交给下一条用例。
300
+ test("a second host landing the same key mid-import aborts the whole batch on both connections", () => {
301
+ const h = setup();
302
+ const other = h.openSecond();
303
+ try {
304
+ const p = pack([entry("decision", "选 SQLite", "正文 A"), entry("pitfall", "别用表连接", "正文 B")]);
305
+ const stolen = p.memories[0].external_id;
306
+ let landed = false;
307
+ h.inject.precheck = (key) => {
308
+ if (landed || key !== stolen) return;
309
+ landed = true;
310
+ // 走另一个连接、另一个事务:它的 COMMIT 落在我们读与写之间。
311
+ other.save({ type: "decision", title: "选 SQLite", content: "对面先落的原文",
312
+ source: IMPORTED_SOURCE, share_pack_id: "另一个包", share_external_id: key });
313
+ };
314
+ assert.throws(() => h.service.importSharePack(p, {}), /UNIQUE constraint failed|database is locked/i);
315
+ assert.equal(landed, true, "the injection must have run or the whole case is empty");
316
+ assert.equal(h.saves.length, 0, "the abort must not leave any write of ours behind");
317
+ // 收件人这条库里:只有对面那一条,包里两条一条都没落(幂等键不是 ours,也不算 skipped_existing)。
318
+ assert.deepEqual(h.rows().map((m) => m.title), ["选 SQLite"]);
319
+ assert.equal(h.rows()[0].content, "对面先落的原文", "the other host's row survives untouched");
320
+ assert.equal(h.rows()[0].source, IMPORTED_SOURCE);
321
+ // 对面连接读到的同一件事:我们的 INSERT 没有以「覆盖」「并存」的形态偷偷落进去。
322
+ assert.deepEqual(other.list({ limit: null, includeArchived: true, includeForgotten: true }).map((m) => m.title),
323
+ ["选 SQLite"], "the two connections must agree that nothing of ours landed");
324
+ assert.equal(other.getByShareExternalId(stolen).content, "对面先落的原文");
325
+ assert.equal(other.getByShareExternalId(p.memories[1].external_id), undefined);
326
+ assert.equal(h.embedded.length, 0, "an aborted batch embeds nothing");
327
+ assert.equal(h.service.embeddedCount(), 0);
328
+ } finally { h.close(); }
329
+ });
330
+
331
+ // 「撞一条就跳过这一条继续」是这条契约最容易招来的宽容写法:看上去也返回了一个数,实际把
332
+ // 「这个包还没吃过」报成「吃过了」,而库里留下半包。上一条例子挡不住它(我们的第一行就没
333
+ // 写下去),所以这里让冲突发生在**第 3 行**:前两行的 INSERT 已经真的落进这笔事务,索引
334
+ // 在第三次写入时把它撞回去。断言三格:前两行随整笔回滚一起消失(不是「留下半包」)、已入队
335
+ // 的两次嵌入一个都没发(transaction() 把 pendingEmbed 截回基线)、错误原样穿出而不是被吞掉。
336
+ // 「外层回滚」这件事在这里只能这么测:transaction() 是无条件 `BEGIN`,所以
337
+ // `service.transaction(() => service.transaction(...))` 这种嵌套在实测里直接以
338
+ // 「cannot start a transaction within a transaction」失败(外层因此连累回滚)——文件里
339
+ // 真正的嵌套只有 store.runAtomically 那侧认 db.isTransaction 早退,而它不认识队列。
340
+ // 回滚一侧的锁因此落在**本笔事务自己失败**这条路上:截断 pendingEmbed 的代码就是那一格,
341
+ // 外层回滚走的也是同一格,形状等价。
342
+ // 注入走 h.store(同一个连接、同一笔已开着的事务):这里要的是「写快照里这个键已被占用」
343
+ // 这一件事,对端提交在 WAL 下只会拿到 BUSY(见上一条例子),拿不到 UNIQUE。代价是注入的
344
+ // 那行也同属这笔事务,会跟着一起回滚——所以「对面的行活着」由上一条例子负责证。
345
+ test("a collision on the index rolls back the rows that already landed and discards their embeds", () => {
346
+ const h = setup();
347
+ try {
348
+ const p = pack([entry("decision", "选 SQLite", "正文 A"), entry("pitfall", "别用表连接", "正文 B"),
349
+ entry("constraint", "别动镜像", "正文 C")]);
350
+ const stolen = p.memories[2].external_id;
351
+ let injected = false;
352
+ h.inject.precheck = (key) => {
353
+ if (injected || key !== stolen) return;
354
+ injected = true;
355
+ h.store.save({ type: "constraint", title: "别动镜像", content: "已经被占的原文",
356
+ source: IMPORTED_SOURCE, share_pack_id: "另一个包", share_external_id: key });
357
+ };
358
+ assert.throws(() => h.service.importSharePack(p, {}),
359
+ /UNIQUE constraint failed: memories\.share_external_id/, "the index, not the pre-check, must be what aborts us");
360
+ assert.equal(injected, true, "the injection must have run or the whole case is empty");
361
+ assert.equal(h.saves.length, 2, "two of our INSERTs did land inside the transaction before the collision");
362
+ assert.equal(h.rows().length, 0, "a prefix must never survive: the whole batch rolls back, nothing lands");
363
+ assert.equal(h.embedded.length, 0, "the queued embeds are truncated on rollback — no vector for a rolled-back row");
364
+ assert.equal(h.service.embeddedCount(), 0);
365
+ assert.equal(h.store.getByShareExternalId(p.memories[0].external_id), undefined);
366
+ } finally { h.close(); }
367
+ });
368
+
369
+ // ============================ C4:整包退出 ============================
370
+ // 退出是这条车道唯一会从用户库里**删掉东西**的动作,所以下面六条锁各自防一类「看着也对」
371
+ // 的替换品:
372
+ // · 删成归档 —— 行还在,UNIQUE 位还占着,包撤不掉也导不回(spec §5 定物删的那条理由);
373
+ // · 只删可见面 / 只删第一块 —— deleted 报得满、库里却留着半包,那半包的来源标签指向一个
374
+ // 已经撤走的分享者,而面板只认答复里的数;
375
+ // · 顺手清库 —— 收件人自己的行被一起带走或被改一行,本文件按 C3 的口径逐字节钉住那一行;
376
+ // · 干跑走真删那半 / 没删成也进事务 —— 答复同形状,但它重渲染了整面镜像、顶回了做梦的
377
+ // 空闲计时器,还记下一笔没有对象的镜像债;
378
+ // · 嵌入次数与真的写进库的行数脱钩(重复排空、给没写成的行做向量、退出后重嵌全库);
379
+ // · 整包 IN 列表不分块 —— SQLite 一条语句 32766 个参数的上限,撞上了就是整笔回滚。
380
+ test("undo removes exactly one pack's rows and frees the idempotency keys", () => {
381
+ const h = setup();
382
+ try {
383
+ h.service.importSharePack(pack([entry("decision", "选 SQLite", "正文 A"), entry("pitfall", "别用表连接", "正文 B")]), {});
384
+ const kept = h.store.save({ type: "decision", title: "我自己的", content: "正文", tags: [] });
385
+ const keptBefore = h.store.getById(kept.id);
386
+ const preview = h.service.deleteSharePack(PACK_ID, { dryRun: true });
387
+ assert.equal(preview.deleted, 2);
388
+ assert.equal(preview.pack_id, PACK_ID);
389
+ assert.equal(h.rows().length, 3, "dry-run removes nothing");
390
+ assert.equal(h.writes(), 1, "the preview must not claim the library just changed (the 1 is the import's own notify)");
391
+ const out = h.service.deleteSharePack(PACK_ID, {});
392
+ assert.equal(out.deleted, 2);
393
+ assert.deepEqual(out.sample, preview.sample, "the destructive answer carries the same inventory the preview showed");
394
+ assert.equal(h.rows().length, 1, "only the withdrawn pack is gone, the host's row is still there");
395
+ assert.ok(h.store.getById(kept.id), "the host's own rows must survive");
396
+ const keptAfter = h.store.getById(kept.id);
397
+ assert.equal(keptAfter.updated_at, keptBefore.updated_at, "a withdrawal must not touch the recipient's own row");
398
+ assert.equal(keptAfter.content, keptBefore.content);
399
+ assert.equal(h.writes(), 2, "one notifyWrite for the committed withdrawal, from transaction()'s finally");
400
+ assert.equal(h.service.deleteSharePack(PACK_ID, {}).deleted, 0, "undo is idempotent");
401
+ assert.equal(h.writes(), 2, "a withdrawal that deletes nothing must not notify a second time");
402
+ const again = h.service.importSharePack(pack([entry("decision", "选 SQLite", "正文 A"), entry("pitfall", "别用表连接", "正文 B")]), {});
403
+ assert.equal(again.created, 2, "the UNIQUE slots were freed, so the pack can land again");
404
+ } finally { h.close(); }
405
+ });
406
+
407
+ // 「留下半个包」是这一族里最难看见的错:deleted 报 2、sample 报 2、库里还剩 1 条带
408
+ // share_pack_id 的行——面板上那个包显示已收起,向量检索与关键词检索却还能搜到它,而它
409
+ // 的来源标签指向一个已经撤走的分享者。所以这里数的是**库级**行数(SELECT count(*) FROM
410
+ // memories),不是答复里的数、也不是 h.rows() 那个可见面:归档与遗忘那两张脸必须一起
411
+ // 收走。spec §5 明确不要的归档版实现在这里当场转红(变异检验:把实现换成逐行
412
+ // setArchived,本文件六条退出用例里有五条一起红,见 C4 报告的 M2)。
413
+ test("undo takes the whole pack with it, including the archived and forgotten faces", () => {
414
+ const h = setup();
415
+ try {
416
+ h.service.importSharePack(pack([entry("decision", "选 SQLite", "正文 A"), entry("pitfall", "别用表连接", "正文 B"),
417
+ entry("constraint", "别动镜像", "正文 C")]), {});
418
+ const rowsIn = h.rows();
419
+ h.store.setArchived(rowsIn.find((m) => m.title === "选 SQLite").id, true);
420
+ h.store.setForget(rowsIn.find((m) => m.title === "别用表连接").id, true);
421
+ const total = () => h.store.db.prepare("SELECT count(*) AS c FROM memories").get().c;
422
+ assert.equal(total(), 3, "three foreign rows landed before the withdrawal");
423
+ const out = h.service.deleteSharePack(PACK_ID, {});
424
+ assert.equal(out.deleted, 3, "the count is the store's, not the visible face's: archived/forgotten rows are part of the pack");
425
+ assert.equal(total(), 0, "nothing of that pack may survive in any face");
426
+ assert.deepEqual(h.store.listBySharePackId(PACK_ID), []);
427
+ // 每一格的幂等键都释放了:剩一条就够把下一个同名条目卡成 skipped_existing,而它挂在
428
+ // 一个已经报成「收起了」的包上——所以 re-import 必须整包新建,一条都不许跳过。
429
+ const again = h.service.importSharePack(pack([entry("decision", "选 SQLite", "正文 A"),
430
+ entry("pitfall", "别用表连接", "正文 B"), entry("constraint", "别动镜像", "正文 C")]), {});
431
+ assert.equal(again.created, 3, "every share_external_id slot the pack held is free again");
432
+ assert.equal(again.skipped_existing, 0, "a withdrawn row must not read as 「already imported」");
433
+ assert.deepEqual(again.title_conflicts, [], "the withdrawn pack left no row behind to clash with");
434
+ assert.equal(h.rows().length, 3);
435
+ } finally { h.close(); }
436
+ });
437
+
438
+ test("undo of an unknown pack answers zero, not an error", () => {
439
+ const h = setup();
440
+ try {
441
+ h.service.importSharePack(pack([entry("decision", "选 SQLite", "正文 A")]), {});
442
+ const genBefore = h.store.getMirrorState().generation;
443
+ const out = h.service.deleteSharePack("0000000000000000", {});
444
+ assert.equal(out.deleted, 0);
445
+ assert.deepEqual(out.sample, []);
446
+ assert.equal(out.pack_id, "0000000000000000");
447
+ assert.equal(h.rows().length, 1, "an id that matches nothing must not delete the pack that is actually in the library");
448
+ assert.equal(h.store.getMirrorState().generation, genBefore,
449
+ "deleting nothing registers no mirror debt — recoverMirror would rewrite the whole mirror for zero rows");
450
+ // 两张皮把对面的输入原样递进来:空串、空格、非字符串都不该抛,也不该摸 store。
451
+ assert.deepEqual(h.service.deleteSharePack("", {}), { pack_id: "", deleted: 0, sample: [] });
452
+ assert.deepEqual(h.service.deleteSharePack(" ", {}), { pack_id: "", deleted: 0, sample: [] });
453
+ assert.deepEqual(h.service.deleteSharePack(undefined, {}), { pack_id: "", deleted: 0, sample: [] });
454
+ assert.deepEqual(h.service.deleteSharePack(42, {}), { pack_id: "", deleted: 0, sample: [] });
455
+ assert.equal(h.writes(), 1, "the four no-op answers above never reach transaction(), so they never notify");
456
+ } finally { h.close(); }
457
+ });
458
+
459
+ // 上限管的是**答复的字节数**不是删除的完整性:这一格要走两张皮(面板 JSON 与 Bearer 数据面
460
+ // JSON),一个几千条的包如果把全部标题塞进答复,响应体就比请求还大,而人只需要认前面几条。
461
+ // 所以删多少与报多少是两格:deleted 恒为全集,sample 恒为 20 条封顶的前缀。
462
+ test("the sample is capped so a huge pack still answers in bounded bytes", () => {
463
+ const h = setup();
464
+ try {
465
+ const many = Array.from({ length: 30 }, (_, i) => entry("pattern", `模式 ${i}`, `正文 ${i}`));
466
+ h.service.importSharePack(pack(many), {});
467
+ const out = h.service.deleteSharePack(PACK_ID, { dryRun: true });
468
+ assert.equal(out.deleted, 30);
469
+ assert.equal(out.sample.length, 20);
470
+ assert.ok(out.sample.every((s) => typeof s.type === "string" && typeof s.title === "string"));
471
+ assert.deepEqual(Object.keys(out.sample[0]).sort(), ["title", "type"],
472
+ "the sample carries only what a human needs to recognise the batch — no ids, no content bytes");
473
+ assert.equal(h.rows().length, 30, "the preview deletes nothing");
474
+ const real = h.service.deleteSharePack(PACK_ID, {});
475
+ assert.equal(real.deleted, 30, "the cap never caps the delete, only the report");
476
+ assert.equal(real.sample.length, 20);
477
+ assert.equal(h.rows().length, 0);
478
+ } finally { h.close(); }
479
+ });
480
+
481
+ // 上一轮评审点名的缺口:现有三把事务锁(saves 里的 fired、排空时刻的 inTx/rows/writesAtFire、
482
+ // notifyWrite 次数)数的是「排空那一眼」的形状,所以「在 transaction() 返回之后再补一遍嵌入」
483
+ // 那种写法能同时满足它们。这条把锚换成一个等式:**发射次数 == 本次真的写进库的行数**,
484
+ // 不多不少,且退出一次都不许发。它能抓住的形状:重复排空(双份嵌入)、给没写成的行做向量(干跑/回滚/
485
+ // 未知包)、以及「退出后把剩下的库重嵌一遍」(那既烧收件人的模型额度,也把外来行的向量重新
486
+ // 写进已经删掉的行——后者由 searchVector 那一格兜住:向量集合永远只以库为准)。
487
+ // 诚实记下它抓不到的那一半:如果实现把入队换成「事务返回后一次性补」,次数与向量列都对得上,
488
+ // 本文件所有探针都读不到差别——差别只在 pendingEmbed 有没有被用过,那是 service 闭包内部的
489
+ // 事,钉它要动生产代码的可测面,这一刀不换(见报告 leftover)。
490
+ test("embeds fire exactly once per row a batch writes, and never for a withdrawal", async () => {
491
+ const h = setup();
492
+ try {
493
+ const first = h.service.importSharePack(pack([entry("decision", "选 SQLite", "正文 A"), entry("pitfall", "别用表连接", "正文 B")]), {});
494
+ assert.equal(h.fires.length, first.created, "one embed fire per written row, no more");
495
+ const preview = h.service.importSharePack(pack([entry("constraint", "别动镜像", "正文 C")], "桉"), { dryRun: true });
496
+ assert.equal(preview.created, 1);
497
+ assert.equal(h.fires.length, first.created, "a dry run writes no rows, so it must fire no embed");
498
+ const second = h.service.importSharePack(pack([entry("constraint", "别动镜像", "正文 C")], "杉", PACK_ID_2), {});
499
+ assert.equal(h.fires.length, first.created + second.created, "the second pack adds exactly its own rows");
500
+ assert.equal(h.embedded.length, h.fires.length, "no fire is dropped and none is invented");
501
+ assert.equal(await waitFor(() => h.service.embeddedCount() === 3), true, "all three live rows end up vectored");
502
+ const withdrawn = h.store.listBySharePackId(PACK_ID).map((m) => m.id);
503
+ const firesBefore = h.fires.length;
504
+ const out = h.service.deleteSharePack(PACK_ID, {});
505
+ assert.equal(out.deleted, 2);
506
+ assert.equal(h.fires.length, firesBefore,
507
+ "a withdrawal embeds nothing: it writes no row, and re-embedding the survivor would burn the recipient's model quota");
508
+ assert.equal(await waitFor(() => h.service.embeddedCount() === 1), true,
509
+ "the embedding column travels with the row: the two withdrawn rows take their vectors to the grave");
510
+ // 真正的读路径:向量检索的候选集合每次由 SQL 现取(store.searchVector),所以退出的行
511
+ // 不可能以「有向量的僵尸」身份回来。桩向量的余弦对任何 [0, n] 都是 1,这里要的就是
512
+ // 「全都入选」的宽口径,好让行数差直接显形。
513
+ const hits = h.service.searchVector([0, 1], { limit: 10, threshold: 0 });
514
+ assert.equal(hits.length, 1, "the withdrawn rows are invisible to vector recall");
515
+ assert.equal(hits[0].share_pack_id, PACK_ID_2);
516
+ for (const id of withdrawn) assert.equal(h.store.getById(id), undefined);
517
+ } finally { h.close(); }
518
+ });
519
+
520
+ // 分块锁(SQLite 一条语句最多绑 32766 个参数,SQLITE_MAX_VARIABLE_NUMBER)。车道 A 的水位
521
+ // 按字节不按条数(MAX_PACK_BYTES = 3 MiB),所以「正文很短、条数很多」的包理论上能逼近那一格,
522
+ // 而一条语句撞上限是整笔回滚 = 这个包永远撤不掉。这里不测 32766 本身(那要 32767 行真数据),
523
+ // 测的是分块这件事真的发生了:600 行的包由**多条** DELETE 承担,每条绑的 `?` 严格少于全集,
524
+ // 而绑数之和正好是 600(没有漏删的块、没有重复的块)。
525
+ // 它防的回归:把 deleteBySharePackId 换回单条 IN(deletes.length 变 1、那条的绑数变 600);
526
+ // 以及「只删了第一块」那种半吊子分块(deleted 变 500、绑数之和变 500)。
527
+ // 诚实记录这块观测的边界:它只数 DELETE FROM memories 的语句形状,不改任何返回值;chunk 尺寸
528
+ // 若哪天被调到 ≥600,本用例会转红,那时把包加大而不是把断言调松——断言的意思是「一条语句
529
+ // 绑不下整个包」,不是「chunk 等于 500」。
530
+ test("a pack larger than one IN chunk is withdrawn in chunks that each bind less than the whole set", () => {
531
+ const h = setup();
532
+ try {
533
+ // 600 行由 store.save 直接落,不走 importSharePack:这条锁的是**删**那一侧的语句形状,
534
+ // 而本文件的嵌入探针每次发射都要全表数一遍行数(见 setup 里的 fires.rows),600 行走完整
535
+ // 导入会把用例变成 600×600 的读、测的还是导入侧(30 行那条已经覆盖)。落库带的那几列
536
+ // (source / share_pack_id / share_external_id / epistemic_status)与导入路径逐字同一套,
537
+ // 所以删除面读到的形状不是假的。
538
+ const many = Array.from({ length: 600 }, (_, i) => entry("pattern", `模式 ${i}`, `正文 ${i}`));
539
+ for (const e of many) {
540
+ h.store.save({ type: e.type, title: e.title, content: e.content, tags: e.tags, importance: e.importance,
541
+ epistemic_status: e.epistemic_status, source: IMPORTED_SOURCE, agent_scope: null, workspace_scope: null,
542
+ share_pack_id: PACK_ID, share_external_id: e.external_id });
543
+ }
544
+ assert.equal(h.rows().length, 600, "the multi-chunk path needs a pack bigger than one chunk to exist at all");
545
+ // 观测点:临时给这条连接的 prepare 加一层记账(取完立刻还原,见 finally)。store 内部
546
+ // 调的是 db.prepare(...),属性查找在调用那一刻发生,所以自己的这一层会先被命中;
547
+ // delete 之后原型上的方法照原样回来。不改任何返回值,也不留第二份实现。
548
+ const db = h.store.db;
549
+ const originalPrepare = db.prepare;
550
+ const sqls = [];
551
+ db.prepare = function (sql) { sqls.push(sql); return originalPrepare.call(this, sql); };
552
+ let out;
553
+ try {
554
+ out = h.service.deleteSharePack(PACK_ID, {});
555
+ } finally {
556
+ delete db.prepare;
557
+ }
558
+ assert.equal(db.prepare, originalPrepare, "the observation must be taken back off the connection");
559
+ assert.equal(out.deleted, 600, "chunking must not lose a row, and must not report a chunk's count as the total");
560
+ assert.equal(h.rows().length, 0, "every chunk is really deleted, not just the first one");
561
+ // 两条 IN 都得分块:memories 与 memory_provenance 绑的是同一份 id 快照,只分其中一条
562
+ // 等于另一半还挂着那个上限。
563
+ for (const prefix of ["DELETE FROM memories WHERE id IN (", "DELETE FROM memory_provenance WHERE memory_id IN ("]) {
564
+ const statements = sqls.filter((sql) => sql.startsWith(prefix));
565
+ const bound = statements.map((sql) => (sql.match(/\?/g) || []).length);
566
+ assert.ok(bound.length >= 2, `${prefix}… must run as more than one statement for a 600-row pack (saw ${bound.length})`);
567
+ assert.equal(bound.reduce((sum, n) => sum + n, 0), 600,
568
+ `${prefix}… chunks must partition the id set: nothing deleted twice, nothing left out`);
569
+ for (const n of bound) assert.ok(n < 600, `one statement bound all ${n} ids — that is the unbounded IN list this lock exists to stop`);
570
+ }
571
+ // 整包的 UNIQUE 槽位随分块一起释放(最后一块最容易漏:它绑的是余数)。
572
+ h.store.save({ type: "pattern", title: "模式 0", content: "撤完还能再落回同一格",
573
+ source: IMPORTED_SOURCE, share_pack_id: PACK_ID, share_external_id: many[0].external_id });
574
+ assert.equal(h.store.getByShareExternalId(many[0].external_id).content, "撤完还能再落回同一格");
575
+ assert.equal(h.rows().length, 1);
576
+ } finally { h.close(); }
577
+ });