@modusensus/dsh-mneme 0.9.4 → 0.9.5
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.
- package/README.md +53 -10
- package/bin/cli.mjs +215 -2
- package/docs/CONFIGURATION.md +4 -2
- package/docs/DAEMON.md +9 -2
- package/docs/DISTILL.md +66 -4
- package/docs/SHARE.md +552 -0
- package/docs/SLEEP.md +1 -1
- package/docs/STORAGE.md +1 -1
- package/lib/api-standalone.js +286 -8
- package/lib/api.js +195 -1
- package/lib/client.js +783 -30
- package/lib/document.js +15 -2
- package/lib/dream/decisions.js +88 -3
- package/lib/dream/sleep.js +63 -13
- package/lib/dream.js +65 -9
- package/lib/lang.js +23 -6
- package/lib/memory-source.js +9 -0
- package/lib/organize.js +5 -1
- package/lib/pipeline.js +62 -1
- package/lib/provenance-http.js +126 -0
- package/lib/serve.js +13 -0
- package/lib/service.js +67 -4
- package/lib/share-http.js +281 -0
- package/lib/share-import.js +234 -0
- package/lib/share.js +335 -0
- package/lib/store.js +272 -11
- package/lib/summarize.js +60 -11
- package/package.json +1 -1
- package/src/api-standalone.js +286 -8
- package/src/api.js +195 -1
- package/src/document.js +15 -2
- package/src/dream/decisions.js +88 -3
- package/src/dream/sleep.js +63 -13
- package/src/dream.js +65 -9
- package/src/lang.js +23 -6
- package/src/memory-source.js +9 -0
- package/src/organize.js +5 -1
- package/src/pipeline.js +62 -1
- package/src/provenance-http.js +126 -0
- package/src/serve.js +13 -0
- package/src/service.js +67 -4
- package/src/share-http.js +281 -0
- package/src/share-import.js +234 -0
- package/src/share.js +335 -0
- package/src/store.js +272 -11
- package/src/summarize.js +60 -11
- package/test/api.test.js +370 -2
- package/test/audit.test.js +2 -2
- package/test/cli-receive.test.js +390 -0
- package/test/cli-share.test.js +437 -0
- package/test/client-share.test.js +225 -0
- package/test/client.test.js +139 -0
- package/test/dedupe-candidate-window.test.js +180 -0
- package/test/dream-candidate-hybrid.test.js +2 -2
- package/test/dream-fence.test.js +164 -0
- package/test/dream.test.js +87 -5
- package/test/helpers/dream-mock.js +6 -2
- package/test/llm-message-source.test.js +1 -1
- package/test/mint-source.test.js +1 -1
- package/test/pipeline-r1r2.test.js +129 -0
- package/test/policy-epoch.test.js +2 -2
- package/test/provenance-http.test.js +184 -0
- package/test/readme-claims.test.js +45 -0
- package/test/reasoning-effort.test.js +10 -10
- package/test/scope-candidates.test.js +7 -4
- package/test/scope-state.test.js +130 -1
- package/test/serve.test.js +62 -0
- package/test/share-foreign-rows.test.js +144 -0
- package/test/share-http.test.js +144 -0
- package/test/share-import.test.js +577 -0
- package/test/share-imported-guard.test.js +311 -0
- package/test/share-imported-identity.test.js +111 -0
- package/test/share-pack.test.js +511 -0
- package/test/share-receive-http.test.js +756 -0
- package/test/share-routes.test.js +337 -0
- package/test/share-store.test.js +133 -0
- package/test/share-validate.test.js +101 -0
- package/test/standalone-api.test.js +524 -5
- package/test/summarize-parse-diagnostics.test.js +75 -0
- package/test/summarize.test.js +15 -2
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
// 分享包在两张皮上共用的解析与答复(形状照 src/reclaim-http.js,#411)。
|
|
2
|
+
// 只返回 {status, body},绝不自己写 res —— 两侧的 sendJson 签名不同(api.js 有
|
|
3
|
+
// headers 第四参、api-standalone.js 没有),把写 res 留在各侧 handler 里是唯一
|
|
4
|
+
// 能让一份实现喂两张脸的做法。
|
|
5
|
+
//
|
|
6
|
+
// 哪些行进包、以什么原因不进,一条判据都不在这里重复:那是 src/share.js 的事。本模块
|
|
7
|
+
// 只做协议层的事——读 body、把结果包成答复、给一个能落盘的文件名。
|
|
8
|
+
import { buildSharePack } from "./share.js";
|
|
9
|
+
|
|
10
|
+
/** 拒发时的下一步动作。它是给调用方看的,出口只许有一处措辞(同 RECLAIM_CONFIRM_HINT)。 */
|
|
11
|
+
const SHARE_ERROR_HINT = "包体超过分享水位:用 --type 或 --tag 分批,别整库外发。";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* 记日志这一手,只许经这里。
|
|
15
|
+
*
|
|
16
|
+
* 「这一层从不抛」是**层的契约**,不是每个调用面的运气(C5 评审裁定):warn 裸调时,宿主
|
|
17
|
+
* logger 自己坏掉就会从 catch 块里再抛出去,而两侧 handler 那个外层 try 在 .then 的回调里
|
|
18
|
+
* 早已退栈——现场就是「一个字节都没回」(#410)。守卫住在面上一处一个,等于每接一张新脸
|
|
19
|
+
* 都要记得再兜一次;住在层里,两侧、以及后面的 CLI(C7)拿到的都是同一个脾气。
|
|
20
|
+
*
|
|
21
|
+
* 三个构造器(shareReply / receiveReply / undoReply)都走这里,**对称本身就是要点**:
|
|
22
|
+
* 只给新加的两个上守卫、出包那侧留一处裸调,下一个读者就会照那处裸的抄。
|
|
23
|
+
* 日志环坏掉时答复照旧送出——对面拿到的仍是那一个状态码,只是少了一条日志迹。
|
|
24
|
+
*/
|
|
25
|
+
function warnSafely(warn, message) {
|
|
26
|
+
try {
|
|
27
|
+
warn?.(message);
|
|
28
|
+
} catch { /* 记日志是自己的事,坏了的日志不许把已经算好的答复一起带走 */ }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 解析 POST body(已由调用方读成文本)→ buildSharePack 的入参集合。
|
|
33
|
+
*
|
|
34
|
+
* 空 body 等价于 `{}`:分享的主用法就是「一个字节都不发,按整库口径打」。坏 JSON 与
|
|
35
|
+
* 发成数组/null 是两个不同的错码(前者是调用方发坏了,后者是发错了顶层形状),
|
|
36
|
+
* 与 reclaim 侧同款,出口要能分支。
|
|
37
|
+
*
|
|
38
|
+
* 只认这三个键,多余的键丢掉:面板与 CLI 各写各的选项时,静默接受一个拼错的键等于
|
|
39
|
+
* 让她以为筛过了。
|
|
40
|
+
*
|
|
41
|
+
* 这里**没有** dryRun:share 是纯读、不改库,spec §4 明确它不套 reclaim 的
|
|
42
|
+
* dry-run/confirm 纪律,干跑由 CLI 侧的 `--dry-run` 决定(它压根不发这个键)。上一版
|
|
43
|
+
* 把它「登记但不参与答复构造」留着,等于凭空多出一个没人消费的公开字段:今天发
|
|
44
|
+
* `dryRun: true` 的人拿到的是真包,而哪天要给它真语义、或把它删掉,两侧都是破坏性改动。
|
|
45
|
+
* 删掉后它和别的多余键走同一条规则——丢掉,不再是一个特例。
|
|
46
|
+
*
|
|
47
|
+
* @returns {{ok: true, options: {alias?: string, type?: unknown, tag?: unknown}}
|
|
48
|
+
* | {ok: false, error: string}}
|
|
49
|
+
*/
|
|
50
|
+
export function parseShareBody(text) {
|
|
51
|
+
if (text === undefined || text === null || String(text).trim() === "") return { ok: true, options: {} };
|
|
52
|
+
let body;
|
|
53
|
+
try {
|
|
54
|
+
body = JSON.parse(String(text));
|
|
55
|
+
} catch {
|
|
56
|
+
return { ok: false, error: "invalid-json" };
|
|
57
|
+
}
|
|
58
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) return { ok: false, error: "invalid-body" };
|
|
59
|
+
const options = {};
|
|
60
|
+
if (typeof body.alias === "string") options.alias = body.alias;
|
|
61
|
+
// type / tag 在这里只判「填没填」,不判形状。收窄口径是 share.js 的 shareFilterSet,
|
|
62
|
+
// 它钉死的是「读不懂的形状只许收窄到 0 条,不许当没填」(错的方向是少发,当场看得见)。
|
|
63
|
+
// 本层若再校验一次形状、把裸字符串或混进非字符串条目的数组丢成「没填」,就等于在 HTTP
|
|
64
|
+
// 面上把那条洞反着挖开——她以为只发了一类,实际整库进包,而 report 看不出异样。
|
|
65
|
+
if (body.type !== undefined && body.type !== null) options.type = body.type;
|
|
66
|
+
if (body.tag !== undefined && body.tag !== null) options.tag = body.tag;
|
|
67
|
+
return { ok: true, options };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** 包文件名:日期 + pack_id 前 8 位。Content-Disposition 与 CLI 落盘共用这一个拼法。 */
|
|
71
|
+
export function shareFilename(packId, now = () => new Date()) {
|
|
72
|
+
const stamp = now().toISOString().slice(0, 10).replace(/-/g, "");
|
|
73
|
+
return `dsh-mneme-share-${stamp}-${String(packId).slice(0, 8)}.json`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* 组包并给出答复。`service.all()` 是「全库含归档/已遗忘」的现成通道(store.js 的 all()
|
|
78
|
+
* 不带 WHERE),口径判据全在 share.js 里判,本模块不重复任何一条。
|
|
79
|
+
*
|
|
80
|
+
* filters 两个键只在填了时才挂上去:buildSharePack 的默认值兜的是 `filters` 整个参数为
|
|
81
|
+
* undefined,构造一个 `filters: null` 会直接在对端解引用时抛——没填的键就让它不存在。
|
|
82
|
+
*
|
|
83
|
+
* 413 那条绝不回 pack:这条答复存在的原因恰恰是包太大,把 memories 序列化回去等于把
|
|
84
|
+
* 刚拒绝掉的那几 MB 再发一遍。report + 量到的字节 + 水位 + 提示够出口说清「大了多少、
|
|
85
|
+
* 怎么分批」(share.js 超限时也把完整 pack 交回,本层只用它的 report)。
|
|
86
|
+
*
|
|
87
|
+
* 抛错在这里兜住并回 500,不留给调用方的 try:面板侧 handler 的形状是
|
|
88
|
+
* `try { return readBody(...).then(...) } catch`,外层 try 在 .then 的回调里已经退栈,
|
|
89
|
+
* 从这里抛出去就是一个没人接的 rejection,客户端拿到的不是 500 而是「一个字节都没回」
|
|
90
|
+
* (#410,reclaim-http.js 里 reclaimReply 那段注释记的就是这一次)。共用层兜一次,两张
|
|
91
|
+
* 皮才不会因为各自接线不同而有一种会漏。warn 可选:数据面的 logger 未必接得上,少一个
|
|
92
|
+
* 回调也不许把异常漏出去。
|
|
93
|
+
*
|
|
94
|
+
* @param {{service: {all: () => object[]}, packageVersion: string, homeDir: string,
|
|
95
|
+
* now?: () => Date, options?: object, warn?: ((msg: string) => void)|null}} input
|
|
96
|
+
* @returns {{status: number, body: object}}
|
|
97
|
+
*/
|
|
98
|
+
export function shareReply({ service, packageVersion, homeDir, now = () => new Date(), options = {}, warn = null }) {
|
|
99
|
+
try {
|
|
100
|
+
const rows = typeof service?.all === "function" ? service.all() : [];
|
|
101
|
+
const filters = {};
|
|
102
|
+
if (options.type !== undefined) filters.type = options.type;
|
|
103
|
+
if (options.tag !== undefined) filters.tag = options.tag;
|
|
104
|
+
const out = buildSharePack({
|
|
105
|
+
rows, alias: options.alias, homeDir, packageVersion,
|
|
106
|
+
filters,
|
|
107
|
+
generatedAt: now().toISOString()
|
|
108
|
+
});
|
|
109
|
+
if (!out.ok) {
|
|
110
|
+
return {
|
|
111
|
+
status: 413,
|
|
112
|
+
body: { error: out.error, bytes: out.bytes, limitBytes: out.limitBytes, hint: SHARE_ERROR_HINT, report: out.pack.report }
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
return { status: 200, body: out.pack };
|
|
116
|
+
} catch (error) {
|
|
117
|
+
// 日志里留住原始文本(现场最难下手的就是「只回了 internal」那一类),对外只有一个码。
|
|
118
|
+
warnSafely(warn, `[dsh-mneme] share pack failed: ${String(error)}`);
|
|
119
|
+
return { status: 500, body: { error: "internal" } };
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// --- 接收侧(车道 C,spec §5)--------------------------------------------------
|
|
124
|
+
// 入库与整包退出同属「两张皮只许差在鉴权与路径」那条纪律(#411),所以这两组解析/答复
|
|
125
|
+
// 也住在本模块,与出包侧同一个形状:只回 {status, body},绝不写 res。
|
|
126
|
+
//
|
|
127
|
+
// 这里刻意**不**重跑 validateSharePack:整包闸门住在 share.js、由 service 的
|
|
128
|
+
// importSharePack 自己调(src/share-import.js)。本层再判一次就是第二套口径——两份实现
|
|
129
|
+
// 各自演进时最坏情况是「路由放行、service 拒」或反过来,而一个包有没有合格从此没有准信。
|
|
130
|
+
// 失败靠 err.code 鸭子类型映射成状态码:ShareImportError 是刻意不从 service 再导出的
|
|
131
|
+
// (类跨模块之后,instanceof 就退化成「只在同一个实例内成立」),码才是契约。
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* 导入错误码 → HTTP 状态。一张表喂两张皮(数据面那半也从这儿取),码集由 share.js 的
|
|
135
|
+
* validateSharePack 定:闸门新增一格必须在这里登记。认不出的码走「500 + 日志」那一条,
|
|
136
|
+
* 绝不静默降级成 400 冒充「你的包不合格」——那会把存储故障报成对方的错。
|
|
137
|
+
*/
|
|
138
|
+
export const SHARE_IMPORT_ERRORS = Object.freeze({
|
|
139
|
+
"invalid-kind": 400,
|
|
140
|
+
"invalid-pack-id": 400,
|
|
141
|
+
"invalid-memories": 400,
|
|
142
|
+
"empty-pack": 400,
|
|
143
|
+
"invalid-type": 400,
|
|
144
|
+
"invalid-entry": 400,
|
|
145
|
+
"external-id-mismatch": 400,
|
|
146
|
+
"duplicate-external-id": 400
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* 「收侧没接线」的码(headless 或旧装配里没有那两个方法)。与 RECLAIM_UNAVAILABLE 同一
|
|
151
|
+
* 处置:字面量只许住在本模块,两张皮都从这一个导出取,否则各写一种拼法就没法按码分支。
|
|
152
|
+
*/
|
|
153
|
+
export const SHARE_IMPORT_UNAVAILABLE = "share-import-unavailable";
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* 缺省即干跑:没有 confirm 就是预览。脾气照 readReclaimFields 的 confirm(只认真布尔),
|
|
157
|
+
* 但这一面的方向更硬——写的是**收件人的库**,所以唯一的入口就是 confirm,`dryRun` 之类
|
|
158
|
+
* 的第二个旋钮一概不认(reclaim 的 body 形状里就带 dryRun,手熟的人会顺手发来,把它读成
|
|
159
|
+
* 「要写」等于替她点头)。
|
|
160
|
+
*/
|
|
161
|
+
function readDryRun(body) {
|
|
162
|
+
return body?.confirm !== true;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* 答复壳里唯一由本层加的一格。service 那一侧收的是 opts.dryRun、不认识 dry_run,对面读的
|
|
167
|
+
* 却是这一格,所以放在本层加:两张皮拿到的键才完全相同。
|
|
168
|
+
*
|
|
169
|
+
* `dryRun !== false` 而不是 `=== true`:认不出的值(字符串、对象、undefined)一律落回**干跑**,
|
|
170
|
+
* 与解析侧同一个失败方向;而传下去的那个布尔值与写进答复的那一格永远同一个数——「其实没写、
|
|
171
|
+
* 却说写了」和「其实写了、却说没写」都是说谎,这一格不许和实参分家。
|
|
172
|
+
*/
|
|
173
|
+
function withDryRun(reply, dryRun) {
|
|
174
|
+
return { ...reply, dry_run: dryRun !== false };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* 解析 POST /receive 的 body(已读成文本)→ { pack, dryRun }。
|
|
179
|
+
*
|
|
180
|
+
* 三个码分开是有原因的:invalid-json(发坏了)、invalid-body(顶层就不是对象)、
|
|
181
|
+
* missing-pack(对象里没有包)——对面据此知道改哪一头,而它们全是 400。空 body 是
|
|
182
|
+
* **错**的,与出包侧相反:那边「一个字节都不发」是整库口径,这边压根没东西可写。
|
|
183
|
+
*
|
|
184
|
+
* pack 那一格只判「是不是个对象」,不判包:数组或 `{kind:"nope"}` 都往下交,由闸门整包拒
|
|
185
|
+
* (invalid-kind)。判据只许有一处。
|
|
186
|
+
*
|
|
187
|
+
* @returns {{ok: true, pack: object, dryRun: boolean} | {ok: false, error: string}}
|
|
188
|
+
*/
|
|
189
|
+
export function parseReceiveBody(text) {
|
|
190
|
+
if (text === undefined || text === null || String(text).trim() === "") return { ok: false, error: "invalid-body" };
|
|
191
|
+
let body;
|
|
192
|
+
try {
|
|
193
|
+
body = JSON.parse(String(text));
|
|
194
|
+
} catch {
|
|
195
|
+
return { ok: false, error: "invalid-json" };
|
|
196
|
+
}
|
|
197
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) return { ok: false, error: "invalid-body" };
|
|
198
|
+
if (!body.pack || typeof body.pack !== "object") return { ok: false, error: "missing-pack" };
|
|
199
|
+
return { ok: true, pack: body.pack, dryRun: readDryRun(body) };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* 解析 POST /receive/undo 的 body → { packId, dryRun }。顶层形状与「有没有 id」是两个错,
|
|
204
|
+
* 与 receive 侧同一口径(发坏了 / 发错了形状 / 没填)。
|
|
205
|
+
*
|
|
206
|
+
* id 在这里 trim,与 deleteSharePack 首行同一把尺;非字符串与空白一律不进删除那条路——
|
|
207
|
+
* 让空白去 service 那儿换回一个 `pack_id: ""` 的 0 删除,读数上看不出是「没填」还是
|
|
208
|
+
* 「那个包本来就没导入过」,而这两种在出口是完全不同的事。
|
|
209
|
+
*
|
|
210
|
+
* @returns {{ok: true, packId: string, dryRun: boolean} | {ok: false, error: string}}
|
|
211
|
+
*/
|
|
212
|
+
export function parseUndoBody(text) {
|
|
213
|
+
if (text === undefined || text === null || String(text).trim() === "") return { ok: false, error: "invalid-body" };
|
|
214
|
+
let body;
|
|
215
|
+
try {
|
|
216
|
+
body = JSON.parse(String(text));
|
|
217
|
+
} catch {
|
|
218
|
+
return { ok: false, error: "invalid-json" };
|
|
219
|
+
}
|
|
220
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) return { ok: false, error: "invalid-body" };
|
|
221
|
+
const packId = typeof body.packId === "string" ? body.packId.trim() : "";
|
|
222
|
+
if (!packId) return { ok: false, error: "missing-pack-id" };
|
|
223
|
+
return { ok: true, packId, dryRun: readDryRun(body) };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* 入库的答复。`pack` 原样交给 service,校验在那儿跑(见本段开头那句「判据只许有一处」)。
|
|
228
|
+
*
|
|
229
|
+
* 抛错在这儿兜住并回 500,不留给调用方的 try:两侧 handler 的形状都是
|
|
230
|
+
* `try { return readBody(...).then(...) } catch`,外层 try 在 .then 的回调里已经退栈,
|
|
231
|
+
* 从这里抛出去就是一个没人接的 rejection(#410)。warn 可选——数据面的 logger 未必接得上,
|
|
232
|
+
* 少一个回调不许把异常漏出去。
|
|
233
|
+
*
|
|
234
|
+
* @param {{service: {importSharePack?: Function}, pack: object, dryRun?: boolean,
|
|
235
|
+
* warn?: ((msg: string) => void)|null}} input
|
|
236
|
+
* @returns {{status: number, body: object}}
|
|
237
|
+
*/
|
|
238
|
+
export function receiveReply({ service, pack, dryRun, warn = null }) {
|
|
239
|
+
if (typeof service?.importSharePack !== "function") {
|
|
240
|
+
return { status: 503, body: { error: SHARE_IMPORT_UNAVAILABLE } };
|
|
241
|
+
}
|
|
242
|
+
const dry = dryRun !== false;
|
|
243
|
+
try {
|
|
244
|
+
return { status: 200, body: withDryRun(service.importSharePack(pack, { dryRun: dry }), dry) };
|
|
245
|
+
} catch (error) {
|
|
246
|
+
const code = typeof error?.code === "string" ? error.code : null;
|
|
247
|
+
const status = code === null ? undefined : SHARE_IMPORT_ERRORS[code];
|
|
248
|
+
if (status) {
|
|
249
|
+
// detail 是闸门给的那一格(哪个字段、哪个坏类型),只转发不加工;没有就不造这一格。
|
|
250
|
+
const detail = typeof error.detail === "string" && error.detail ? { detail: error.detail } : {};
|
|
251
|
+
return { status, body: { error: code, ...detail } };
|
|
252
|
+
}
|
|
253
|
+
warnSafely(warn, `[dsh-mneme] share import failed: ${String(error)}`);
|
|
254
|
+
return { status: 500, body: { error: "internal" } };
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* 整包退出的答复。这一面**不查** SHARE_IMPORT_ERRORS:packId 的形状闸门在 parseUndoBody,
|
|
260
|
+
* service 那侧不抛带码的错(非字符串/空白它回一个 0 删除,不是异常),所以真抛出来就是
|
|
261
|
+
* 存储故障。别在这儿补第二张表——那张表只会成为 SHARE_IMPORT_ERRORS 的影子。
|
|
262
|
+
*
|
|
263
|
+
* 答复里的 pack_id 是 service 的读数而不是请求的回显(非字符串/空白那一支它回 ""),
|
|
264
|
+
* 本层不改写、不兜底;dry_run 由 withDryRun 加,与入库那侧同一格键。
|
|
265
|
+
*
|
|
266
|
+
* @param {{service: {deleteSharePack?: Function}, packId: string, dryRun?: boolean,
|
|
267
|
+
* warn?: ((msg: string) => void)|null}} input
|
|
268
|
+
* @returns {{status: number, body: object}}
|
|
269
|
+
*/
|
|
270
|
+
export function undoReply({ service, packId, dryRun, warn = null }) {
|
|
271
|
+
if (typeof service?.deleteSharePack !== "function") {
|
|
272
|
+
return { status: 503, body: { error: SHARE_IMPORT_UNAVAILABLE } };
|
|
273
|
+
}
|
|
274
|
+
const dry = dryRun !== false;
|
|
275
|
+
try {
|
|
276
|
+
return { status: 200, body: withDryRun(service.deleteSharePack(packId, { dryRun: dry }), dry) };
|
|
277
|
+
} catch (error) {
|
|
278
|
+
warnSafely(warn, `[dsh-mneme] share undo failed: ${String(error)}`);
|
|
279
|
+
return { status: 500, body: { error: "internal" } };
|
|
280
|
+
}
|
|
281
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
// dsh-mneme/src/share-import.js
|
|
2
|
+
// 车道 C 收侧的入库与退出:一份校验过的分享包 → 本库的外来行(整批一个事务),以及按
|
|
3
|
+
// pack_id 把那一批整包收回(spec §5)。
|
|
4
|
+
//
|
|
5
|
+
// 独立成模块(AGENTS.md 尺寸约定,同 organize.js / document.js 先例):service.js 只做
|
|
6
|
+
// 依赖注入 + barrel 出口,调用方零改动。依赖由 createService 的闭包注入(store /
|
|
7
|
+
// transaction / scheduleEmbed),形状照 src/organize.js 的 createOrganizer——本模块不
|
|
8
|
+
// 认识 service 的内部接线,也不自带事务助手。
|
|
9
|
+
//
|
|
10
|
+
// C3b 是纯搬移:`ShareImportError` 与 `importSharePack` 的定义连同注释、错误码、答复
|
|
11
|
+
// 形状从 src/service.js 逐字移来,一行行为都没改。搬完那三把变异锁照样咬(见
|
|
12
|
+
// test/share-import.test.js 的「最外层 COMMIT 才排空嵌入队列」那组)。C4 是搬移之后
|
|
13
|
+
// 第一刀真的碰这块的改动,所以那两个「本文件」之类的口吻也一并改对了。
|
|
14
|
+
//
|
|
15
|
+
// ESM 环的位置随之挪了一格:原先是 service → share → write-admission → service,现在
|
|
16
|
+
// 那条 service → share 的直接边换成了 service → share-import → share,环还是那个环、
|
|
17
|
+
// 求值期仍然没有任何一侧调用另一侧。下面 share.js 那条 import 上的注释讲的就是它。
|
|
18
|
+
|
|
19
|
+
import { scopeKeyOf } from "./scope.js";
|
|
20
|
+
import { isImportedRow, IMPORTED_SOURCE } from "./memory-source.js";
|
|
21
|
+
// 车道 C 收侧的两个纯件:整包闸门与来源标签。import 它们而不是重抄一份——判据有两份
|
|
22
|
+
// 的话,出包侧改了收侧不会红,最坏情况是「对面按旧口径铸的键被我们整包拒掉」。
|
|
23
|
+
// 代价是一条**已知并被接受**的 ESM 环:service → share → write-admission → service
|
|
24
|
+
// (share.js 只借 write-admission 的空白/噪声判据,见它模块头)。环上三处都是函数
|
|
25
|
+
// (`PINNED_MEMORY_TYPES` 是 service 顶层的 const,但 write-admission 只在函数体里读它;
|
|
26
|
+
// share.js 用的 informationlessReason 是函数声明,提升后即刻可用),没有任何一侧在
|
|
27
|
+
// 模块求值期调用另一侧——所以求值顺序炸不出 TDZ。这条不是「应该没事」:C3 的报告里
|
|
28
|
+
// 有一道全新进程的 `import("./src/service.js")` 探针,本文件的测试也全走真实链路。
|
|
29
|
+
import { validateSharePack, importedTagFor } from "./share.js";
|
|
30
|
+
|
|
31
|
+
// 退出答复里给人核对的清单封顶 20 条。这个数管的是**答复的字节数**不是删除的完整性:
|
|
32
|
+
// deleted 恒是全集,sample 只是前缀。两张皮(面板路由 / Bearer 数据面)都把答复
|
|
33
|
+
// JSON.stringify 出去,一个几千条的包如果整包塞进答复,响应体会比请求还大,而人只需要
|
|
34
|
+
// 认出最前面那几条是不是他要收的那批。同 SHARE_SKIP_LIST_MAX(车道 A,50)的口径:
|
|
35
|
+
// 截的是清单不是账。
|
|
36
|
+
const DELETE_SAMPLE_MAX = 20;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 导入的错误:带 code,让两张皮(面板路由与 standalone 数据面)映射成同一个 400 码
|
|
40
|
+
* 而不是各写一份文案。类放在模块顶层而不是 createService 的闭包里——闭包每实例造一
|
|
41
|
+
* 个新类,`instanceof` 就退化成「只在同一个 service 实例内部成立」的关系,而消费方
|
|
42
|
+
* 判的是 `err.code`(spec §5:码集由 share.js 的 validateSharePack 定,这里只转发)。
|
|
43
|
+
*/
|
|
44
|
+
export class ShareImportError extends Error {
|
|
45
|
+
constructor(code, detail) {
|
|
46
|
+
super(`share import: ${code}`);
|
|
47
|
+
this.name = "ShareImportError";
|
|
48
|
+
this.code = code;
|
|
49
|
+
this.detail = detail;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* 构造收侧的入库与退出。注入的三个依赖都是 service 闭包成员:`store` 是存储层,
|
|
55
|
+
* `transaction` 必须是 service 那一个而不是 `store.runAtomically`——嵌入队列只在最外层
|
|
56
|
+
* COMMIT 排空、回滚时按层截断,这套队列语义存储层的助手不认识(退出那侧用它是为了同一
|
|
57
|
+
* 件事:镜像重渲染与写通知都在它的 finally 里)。
|
|
58
|
+
*
|
|
59
|
+
* @returns {{ importSharePack: (pack: object, opts?: { dryRun?: boolean }) => object,
|
|
60
|
+
* deleteSharePack: (packId: string, opts?: { dryRun?: boolean }) => object }}
|
|
61
|
+
*/
|
|
62
|
+
export function createShareImport({ store, transaction, scheduleEmbed }) {
|
|
63
|
+
/**
|
|
64
|
+
* 车道 C 的入库(spec §5)。与 service.js 里的 mergeHumanEdits 同属「外部文本进库」
|
|
65
|
+
* 那一族(C3b 之后它在隔壁文件,别再按同文件口吻读这句),但方向相反:那一族改的是
|
|
66
|
+
* 本机的行,这一族**已有的行一条都不许碰**。
|
|
67
|
+
*
|
|
68
|
+
* 真导入整包裹在注入的 `transaction()` 里而不是 `store.runAtomically()`:scheduleEmbed
|
|
69
|
+
* 在 txDepth>0 时只入队,队列由那个 transaction()(住在 service.js,不是本文件)的
|
|
70
|
+
* 最外层 COMMIT 排空(入队在 scheduleEmbed 首行,排空在 transaction 的 finally),
|
|
71
|
+
* 存储层那个助手不认识这套队列——用它等于静默不嵌入,而这批行对收件人的向量检索就
|
|
72
|
+
* 永远隐形(车道 C 预检点名的那条)。回滚一侧由 transaction() 截断 pendingEmbed,
|
|
73
|
+
* 绝不给已回滚的行做向量。
|
|
74
|
+
*
|
|
75
|
+
* 幂等的主路径是事务内先查 `share_external_id`,命中即跳过。那把部分 UNIQUE 索引只是
|
|
76
|
+
* 双保险,管两个宿主共用同一 memoryDir 时的竞态;它从这里穿出时是**整笔回滚**——不
|
|
77
|
+
* catch、不续跑。「约束报错就跳过这一条」看着宽容,实际是把「这个包还没吃过」报成
|
|
78
|
+
* 「吃过了」,而整包回滚后重跑一次本来就成了 created=0 的正常答复。
|
|
79
|
+
*
|
|
80
|
+
* 走 `store.save` 而不是 `saveWithDedupe`:后者命中即 appendContent 并入既有行,而
|
|
81
|
+
* 「把别人的正文并进我的条目」正是决策 2 要禁的动作;汇流口那侧也已把外来行剔出候选集
|
|
82
|
+
* (saveWithDedupe 里的 isImportedRow),两条路合起来才是「谁也不改谁」。
|
|
83
|
+
*
|
|
84
|
+
* @param {object} pack 一份分享包(kind `mneme.share/v1`)
|
|
85
|
+
* @param {{dryRun?: boolean}} [opts] dryRun 只算账不写库,答复同形状
|
|
86
|
+
* @returns {{pack_id: string, alias: string, created: number, skipped_existing: number,
|
|
87
|
+
* unchanged: number, title_conflicts: Array<{type: string, title: string, existing_id: string}>,
|
|
88
|
+
* conflicts_checked: number}}
|
|
89
|
+
* @throws {ShareImportError} 校验不过时 err.code 属于 validateSharePack 的码集,且一行未写
|
|
90
|
+
*/
|
|
91
|
+
function importSharePack(pack, { dryRun = false } = {}) {
|
|
92
|
+
const v = validateSharePack(pack);
|
|
93
|
+
if (!v.ok) throw new ShareImportError(v.error, v.detail);
|
|
94
|
+
const tag = importedTagFor(pack.alias);
|
|
95
|
+
// 撞名清单的判据按「收件人会不会真的看不见这条」来定,三个条件各挡一类假报告:
|
|
96
|
+
// · 已归档 / 已遗忘(store.list 的默认档已经排掉,这里再写一遍是把契约留在调用处,
|
|
97
|
+
// 哪天有人给这个调用加上 includeArchived,报出来的就是他自己收起来的行);
|
|
98
|
+
// · 带 scope 标注:外来行落的是 NULL scope,两条在他的视图里根本不在同一格——
|
|
99
|
+
// scope 这一半与 saveWithDedupe 的去重键同口径(scopeKeyOf 把 NULL 归一成「未标注」),
|
|
100
|
+
// 但这里**不比 sensitivity**:去重键是五格(type / title / 两个 scope / sensitivity),
|
|
101
|
+
// 这里只有四格,少了 sensitivity 那一格,见下面「刻意更宽」那条;
|
|
102
|
+
// · 外来行(isImportedRow):这一格最容易被「顺手简化」掉。少了它,第二次导入别的
|
|
103
|
+
// 包时会把上一包别人的原文报成「你库里已经有一条」,existing_id 指向一行他既不能
|
|
104
|
+
// 改写、在 C4 之前也删不掉的行——撞名读数从「要不要处理我这条」变成「去处理别人
|
|
105
|
+
// 那条」。
|
|
106
|
+
// 同键取 list() 默认序(importance DESC, updated_at DESC, id)的第一条:那一行落在去重
|
|
107
|
+
// 窗口里时,就是 saveWithDedupe 会挑中的并入目标,报出来的 id 因此是「假如允许并入会被
|
|
108
|
+
// 吃掉的那一条」,不是随便一条同名行。但**这不是与去重等价的集合**,是刻意更宽的报法,
|
|
109
|
+
// 两处差别都要写明免得日后被读成等价:去重只扫 `store.list({type, limit: 100})` 那个窗口
|
|
110
|
+
// (第 101 条以后的同名行它根本看不见,我们扫全库),并且多比一格 sensitivity(同名同
|
|
111
|
+
// scope 而敏感度不同的两条,去重会各建一行、我们照样报撞名)。方向是安全的:宽的一侧只
|
|
112
|
+
// 会多报,漏报才是「收件人看不见这条」那种要命的形状。读的时候按「这里报的都是真同名
|
|
113
|
+
// 行」,别按「dedupe 会吃的就是这些行」。
|
|
114
|
+
// 标题两侧 trim,与去重那侧的 title.trim() 同一把尺。最后还有一处对照根本不成立,记下
|
|
115
|
+
// 免得日后又被拼回等价:dream summary 那一支的键是 source、narrative 那一支(src/service.js:1405-1410)
|
|
116
|
+
// 的键是 tags[0],两种行的「假如允许并入」目标跟这里的「同名第一条」不是同一格东西。
|
|
117
|
+
const ownByKey = new Map();
|
|
118
|
+
for (const m of store.list({ limit: null })) {
|
|
119
|
+
if (m.archived || isImportedRow(m)) continue;
|
|
120
|
+
if (scopeKeyOf(m.agent_scope) || scopeKeyOf(m.workspace_scope)) continue;
|
|
121
|
+
const key = `${m.type}\u0000${String(m.title).trim()}`;
|
|
122
|
+
if (!ownByKey.has(key)) ownByKey.set(key, m.id);
|
|
123
|
+
}
|
|
124
|
+
const importBatch = () => {
|
|
125
|
+
let created = 0;
|
|
126
|
+
let skippedExisting = 0;
|
|
127
|
+
let unchanged = 0;
|
|
128
|
+
const conflicts = [];
|
|
129
|
+
for (const entry of pack.memories) {
|
|
130
|
+
const existing = store.getByShareExternalId(entry.external_id);
|
|
131
|
+
if (existing) {
|
|
132
|
+
skippedExisting++;
|
|
133
|
+
// unchanged 比的是「同一把尺的两端」,而这两端本来就可能合法地不同:包里的锚是
|
|
134
|
+
// 发送方**遮之前**的归一化哈希(决策 4),库里这一格是收件人按自己收到的字节
|
|
135
|
+
// 重算的 store 哈希,两侧各自遮自己那台机器的 home 前缀。所以这个数**只是给人
|
|
136
|
+
// 看的读数,绝不许当动作依据**——拿它决定「这条不用导入」就是个 bug;要不要建
|
|
137
|
+
// 这条行,只看上面那格 share_external_id。
|
|
138
|
+
if (typeof existing.content_hash === "string" && existing.content_hash === entry.content_hash) unchanged++;
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
const key = `${entry.type}\u0000${String(entry.title).trim()}`;
|
|
142
|
+
if (ownByKey.has(key)) conflicts.push({ type: entry.type, title: entry.title, existing_id: ownByKey.get(key) });
|
|
143
|
+
if (dryRun) { created++; continue; }
|
|
144
|
+
// created_at / content_hash 不在传参里:这两格由 store 铸造(spec §5 记的三个事实
|
|
145
|
+
// 前两条),包里那两个值只作给人看的时间感与发件侧自证的锚。
|
|
146
|
+
const row = store.save({
|
|
147
|
+
type: entry.type,
|
|
148
|
+
title: entry.title,
|
|
149
|
+
content: entry.content,
|
|
150
|
+
// 只追加一个来源标签,包里的标签原样带过来;Set 去重是防发件人本来就带了一个
|
|
151
|
+
// 一模一样的标签(那样面板上会出现两条同名标签,读起来像系统写了两次)。
|
|
152
|
+
tags: [...new Set([...(Array.isArray(entry.tags) ? entry.tags.map(String) : []), tag])],
|
|
153
|
+
importance: Number.isInteger(entry.importance) ? entry.importance : 3,
|
|
154
|
+
epistemic_status: entry.epistemic_status,
|
|
155
|
+
source: IMPORTED_SOURCE,
|
|
156
|
+
agent_scope: null,
|
|
157
|
+
workspace_scope: null,
|
|
158
|
+
share_pack_id: pack.pack_id,
|
|
159
|
+
share_external_id: entry.external_id
|
|
160
|
+
});
|
|
161
|
+
// 排队而不是当场嵌:这里在事务里,txDepth>0,scheduleEmbed 入队、由 transaction()
|
|
162
|
+
// 提交后 drain。别改成「事务外再补一遍」——那是 #372 与 distill-tx-embed-gap 修过
|
|
163
|
+
// 两次的形状。
|
|
164
|
+
scheduleEmbed(row);
|
|
165
|
+
created++;
|
|
166
|
+
}
|
|
167
|
+
return { created, skippedExisting, unchanged, conflicts };
|
|
168
|
+
};
|
|
169
|
+
// 干跑不裹 transaction():那一层的 finally 会 syncMirror() + notifyWrite()。一次没写
|
|
170
|
+
// 任何东西的预览,不该把整面镜像重渲染一遍,更不该告诉睡眠调度器「库刚变过」——那
|
|
171
|
+
// 一下会把空闲计时器重置,压掉她本来该做的一轮巩固。剩下的全是只读查询,不需要 BEGIN。
|
|
172
|
+
const result = dryRun ? importBatch() : transaction(importBatch);
|
|
173
|
+
return {
|
|
174
|
+
pack_id: pack.pack_id,
|
|
175
|
+
// alias 对面自报,可以是空串;缺格(手改包)时补空串而不是 undefined——这两张皮的
|
|
176
|
+
// 答复会被 JSON.stringify,undefined 的键直接消失,面板就分不清「匿名」与「没读到」。
|
|
177
|
+
alias: typeof pack.alias === "string" ? pack.alias : "",
|
|
178
|
+
created: result.created,
|
|
179
|
+
skipped_existing: result.skippedExisting,
|
|
180
|
+
unchanged: result.unchanged,
|
|
181
|
+
// title_conflicts 是 advisory 读数,**不是「没有撞名」的证明**:撞名只对本轮真正会新建
|
|
182
|
+
// 的那些条目判(skipped_existing 那一支直接 continue,不再比),所以空数组只说明「这一批
|
|
183
|
+
// 里没有新的撞名要报」。conflicts_checked 同样是包里的条数而不是比过的条数——第二次导入
|
|
184
|
+
// 同一个包时这一格恒空,而撞名在库里物理存在。对面文案别写成「现在没有冲突」。
|
|
185
|
+
title_conflicts: result.conflicts,
|
|
186
|
+
conflicts_checked: pack.memories.length
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* 整包退出(spec §5)。**物删不归档**(裁定与理由都记在这儿,免得日后被读成手滑):
|
|
192
|
+
* 归档行仍占着 share_external_id 的 UNIQUE 位,会造成「撤了却导不回来」的死角——那个
|
|
193
|
+
* 形态比多删更坏,而原始包还在分享者手里,随时能重导。
|
|
194
|
+
* 本方法与 reclaim 的「一条记忆都不删」哲学相反:那是自动回收面(没人点头,删了就找不
|
|
195
|
+
* 回来),这是人显式按下的一次撤销——他刚说过「这批不是我的」,留着才是替他做决定。
|
|
196
|
+
*
|
|
197
|
+
* 寻址键只有 `share_pack_id` 一个:外来行由本模块铸造(source='imported' + 两列),
|
|
198
|
+
* 全仓别处都不写这两列,所以「按包删」与「只删外来行」今天等价。哪天有人给本地行也
|
|
199
|
+
* 盖上 share_pack_id,这条等价就破了——那时要收窄的是 store 那一侧的 WHERE,不是这里
|
|
200
|
+
* 补一层 filter(补了会让清点与实删分家,见下面 deleted 为什么取 store 的数)。
|
|
201
|
+
*
|
|
202
|
+
* 裹 `transaction()` 而不是裸调 `store.deleteBySharePackId`:后者内部有 runAtomically,
|
|
203
|
+
* 而它在 db.isTransaction 为真时直接跑回调(src/store.js 的 runAtomically 首行),所以
|
|
204
|
+
* 嵌套安全;换来的是 transaction 的 finally 把 syncMirror + notifyWrite 各做一次——
|
|
205
|
+
* 退出必须让面板、镜像与做梦调度器同时知道这批行没了。什么都没删时**不进**事务:
|
|
206
|
+
* 一笔空删除既不该重渲染镜像也不该把空闲计时器顶回去(与 importSharePack 的干跑同一条
|
|
207
|
+
* 理由)。
|
|
208
|
+
*
|
|
209
|
+
* @param {string} packId 要收起的包;非字符串 / 空白 / 没导入过都答 0,不抛
|
|
210
|
+
* @param {{dryRun?: boolean}} [opts] dryRun 只清点不删,答复同形状
|
|
211
|
+
* @returns {{pack_id: string, deleted: number, sample: Array<{type: string, title: string}>}}
|
|
212
|
+
*/
|
|
213
|
+
function deleteSharePack(packId, { dryRun = false } = {}) {
|
|
214
|
+
const id = typeof packId === "string" ? packId.trim() : "";
|
|
215
|
+
if (!id) return { pack_id: "", deleted: 0, sample: [] };
|
|
216
|
+
// 清点与实删读的是同一个 store 方法(listBySharePackId 不过滤 archived/forgotten:
|
|
217
|
+
// 那两个面也要一起收走,否则库里留下「来自一个已经收起的包」的行)。sample 取前
|
|
218
|
+
// DELETE_SAMPLE_MAX 条给人核对「我要收起的到底是哪一批」——listBySharePackId 的序是
|
|
219
|
+
// updated_at DESC, id DESC,所以报出去的是他最近看过的那几条。
|
|
220
|
+
const rows = store.listBySharePackId(id);
|
|
221
|
+
const sample = rows.slice(0, DELETE_SAMPLE_MAX).map((m) => ({ type: m.type, title: m.title }));
|
|
222
|
+
if (dryRun || !rows.length) return { pack_id: id, deleted: rows.length, sample };
|
|
223
|
+
// deleted 取 store 的 changes 而不是 rows.length:上面那次清点是只读快照,而
|
|
224
|
+
// deleteBySharePackId 在**它自己的事务里重新读一遍 id**——两个宿主共用同一 memoryDir
|
|
225
|
+
// 时(AGENTS.md 的那台机器两条腿),别人在我们清点之后往同一个包里补写的行会一起被删
|
|
226
|
+
// (它不在 sample 里),我们清点到的行也可能已被别人删掉(它数不进 changes)。报实删的
|
|
227
|
+
// 数才是诚实的那一个:sample 是清点时的预览,deleted 是这一笔真的带走了几行,并发下
|
|
228
|
+
// 两者可以差一条,谁也不许拿对方的数当「漏删/多删」的依据。
|
|
229
|
+
const deleted = transaction(() => store.deleteBySharePackId(id));
|
|
230
|
+
return { pack_id: id, deleted, sample };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return { importSharePack, deleteSharePack };
|
|
234
|
+
}
|