@nightpainters/dsh-ling 1.6.2 → 1.7.1
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/CHANGELOG.md +176 -9
- package/DESIGN.md +89 -6
- package/PHILOSOPHY.md +14 -4
- package/README.md +105 -19
- package/cordis.patch.yml +32 -2
- package/lib/client.js +2544 -61
- package/lib/host/agent-tools.js +286 -0
- package/lib/host/api.js +592 -59
- package/lib/host/archive.js +7 -7
- package/lib/host/autotree.js +2 -2
- package/lib/host/clock-skill.js +1 -1
- package/lib/host/config.js +38 -0
- package/lib/host/deep-index-skill.js +3 -3
- package/lib/host/deepsummary.js +80 -4
- package/lib/host/dsweb-summary.js +11 -5
- package/lib/host/genesis.js +5 -3
- package/lib/host/guard.js +53 -0
- package/lib/host/inject.js +18 -5
- package/lib/host/l1.js +17 -11
- package/lib/host/library-checker.js +552 -0
- package/lib/host/library-import-engine.js +211 -0
- package/lib/host/library-import.js +911 -0
- package/lib/host/library-runtime.js +199 -0
- package/lib/host/library-sqlite.js +344 -0
- package/lib/host/library-store.js +1714 -0
- package/lib/host/memory.js +119 -29
- package/lib/host/persona.js +13 -2
- package/lib/host/rawlog.js +67 -4
- package/lib/host/recall-search.js +60 -12
- package/lib/host/retitle.js +5 -5
- package/lib/host/session-line.js +7 -2
- package/lib/host/snapshot.js +9 -5
- package/lib/host/surgery.js +6 -4
- package/lib/host/tools.js +473 -27
- package/lib/host/util.js +1 -1
- package/lib/index.js +120 -81
- package/package.json +10 -5
- package/tools/deep-import-docs.mjs +1210 -0
|
@@ -0,0 +1,1714 @@
|
|
|
1
|
+
// 1.7 图书馆 · **存储层**(L1:自建 SQLite,与 memory.db 同款 DatabaseSync)。
|
|
2
|
+
//
|
|
3
|
+
// 为什么是**独立一套**而不是 MemoryStore 的新职责:
|
|
4
|
+
// ① 图书馆的判据是"有源可对账"(交接 §1),条目形态 = 值 + 出处 + 取数时间 + 核对规格,
|
|
5
|
+
// 与记忆条目(会话/结论/血缘)没有公共列;
|
|
6
|
+
// ② 零注入(交接 P3)⇒ 它不参与开场装配,不该让记忆库的构造路径依赖它;
|
|
7
|
+
// ③ 契约 §Permanent Boundary:存进记忆库的数据**永不**迁进图书馆,库之间也不该有隐式通道。
|
|
8
|
+
// 所以本文件**只**依赖 node: 内置模块,不 import 本插件任何其他文件(留旧形状参考 memory.js
|
|
9
|
+
// 的 deep_item,LIKE 口径参考 recall-search.js,但那些是形状参考,不是依赖)。
|
|
10
|
+
//
|
|
11
|
+
// 本层负责:表结构 / 条目校验 / 新建-修订-撤下生命周期 / 检索 / 核对历史。**不负责**:
|
|
12
|
+
// HTTP 端点、工具注册、界面、裁判员取源(那些归主线)。store 的公开操作**不对模型开放**。
|
|
13
|
+
//
|
|
14
|
+
// 四条"谁改都不许违反"的语义位(契约 §Item Contract,交接 §6):
|
|
15
|
+
// ① superseded_by **只追不替**,且只指向"同事务里新建的下一版" ⇒ 结构上形不成环;
|
|
16
|
+
// ② source_ref 与 as_of **必填非空**,as_of 不许用 created_at 偷代;
|
|
17
|
+
// ③ checked_state/checked_at **只由 recordCheck 写** —— create/revise 显式拒绝这两个字段,
|
|
18
|
+
// 即使调用方把自己的核对结果塞进 item 也进不去;
|
|
19
|
+
// ④ 撤下/取代都**留痕不物理删**,默认检索只看 active 最新版。
|
|
20
|
+
import { mkdirSync, statSync } from 'node:fs';
|
|
21
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
22
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
23
|
+
|
|
24
|
+
/** 可复核的源类型(契约 §Item Contract,受控枚举)。 */
|
|
25
|
+
export const LIBRARY_SOURCE_KINDS = Object.freeze(['standard', 'official-doc', 'manual', 'our-code', 'web']);
|
|
26
|
+
/** 条目类目(受控枚举)。 */
|
|
27
|
+
export const LIBRARY_CATEGORIES = Object.freeze(['standard-value', 'boundary', 'project-spec', 'external-api']);
|
|
28
|
+
/** 核对三态 —— **只有**这三个值 + 未核空串,别的值(如 verified)一律拒绝。 */
|
|
29
|
+
export const LIBRARY_CHECK_STATES = Object.freeze(['ok', 'wrong', 'unknown']);
|
|
30
|
+
/**
|
|
31
|
+
* 比较器语义(实现归裁判员 library-checker.js,这里只是**受控枚举**与口径说明):
|
|
32
|
+
* - `exact`:**类型严格的 JSON 结构化深比**(数字/字符串/布尔/null/数组/对象逐位同形,
|
|
33
|
+
* 等价于 `isDeepStrictEqual`;`1` 与 `'1'`、`0` 与 `false` 都**不**相等)——
|
|
34
|
+
* **不是**"`JSON.stringify` 后逐字节比",那会把键序差异也算成不等。
|
|
35
|
+
* - `text-lf`:只把 CRLF/CR 归一成 LF(不 trim、不折叠大小写、不模糊),两端都必须是文本。
|
|
36
|
+
*/
|
|
37
|
+
export const LIBRARY_COMPARISONS = Object.freeze(['exact', 'text-lf']);
|
|
38
|
+
|
|
39
|
+
/** `id` 前缀;条目 id 形如 `lib:<uuid>`,lineage_id 取初版 id(同一谱系恒等)。 */
|
|
40
|
+
export const LIBRARY_ID_PREFIX = 'lib:';
|
|
41
|
+
/** 库文件 schema 版本,存在 library_meta 里(不是 PRAGMA user_version,便于同时读多行元数据)。 */
|
|
42
|
+
/**
|
|
43
|
+
* 图书馆库的结构版本。**v2:library_item.import_key**(1.7 导入通道的幂等键,2026-10-12)。
|
|
44
|
+
* 契约 §6:`import_key = sha256(sourceBytes).hex.slice(0,16) + ':' + locator`,空串 = "无键"。
|
|
45
|
+
* 三条口径(测试 §5/§8 逐条钉住):
|
|
46
|
+
* - 它是**身份**,不是内容 ⇒ 既不进 `libraryContentHash`,也不进 `byte_size`
|
|
47
|
+
* ("同内容换 import_key 照常入库"就是这条:去重认的是键,不是"内容一样就拒");
|
|
48
|
+
* - **只在 create 时给出**(revise 出现即拒):修订不继承导入键,否则新版本会被自己旧版占着的键挡住;
|
|
49
|
+
* - 唯一性**含撤回/被取代的行** ⇒ 重复导入的代价是永久的(撤下是留痕不删,同源同定位再导入仍 skipped)。
|
|
50
|
+
*/
|
|
51
|
+
export const LIBRARY_STORE_SCHEMA_VERSION = 2;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* 限额默认值(契约 §Limits)。字段名就是**可配置的名字** —— 主线按配置覆盖时用同一组键,
|
|
55
|
+
* 单位在键名里写明,免得"64"到底是 KiB 还是字节要回头猜。
|
|
56
|
+
*/
|
|
57
|
+
export const DEFAULT_LIBRARY_LIMITS = Object.freeze({
|
|
58
|
+
itemMaxBytes: 65536, // 条目(正文 + JSON 元数据)UTF-8 字节上限 = 64 KiB
|
|
59
|
+
resultMaxBytes: 262144, // 一次响应序列化后的字节上限 = 256 KiB
|
|
60
|
+
queryMaxChars: 512, // 检索词字符上限
|
|
61
|
+
searchDefaultLimit: 20, // 检索默认条数
|
|
62
|
+
searchMaxLimit: 100, // 检索条数上限(契约 §Limits)
|
|
63
|
+
// 浏览面(2026-10-13 主人定):人往下翻就续页;但某组总量到这个数(1000)就切**分页**,
|
|
64
|
+
// 免得渐进累积把会话缓存撑爆 —— 两把尺是同一件事的两半,故放在一起。
|
|
65
|
+
browseDefaultLimit: 200, // 浏览面一页默认 200 条
|
|
66
|
+
browseMaxLimit: 1000, // 浏览面单页上限 = 切分页的阈值
|
|
67
|
+
sourceMaxBytes: 2097152, // 单次取源字节上限 = 2 MiB(裁判员用,本层只回执)
|
|
68
|
+
selectedTextMaxBytes: 16384, // 定位选中的文本上限 = 16 KiB
|
|
69
|
+
selectedTextMaxLines: 200, // 定位选中的行数上限 = 200
|
|
70
|
+
checkTimeoutMs: 10000, // 单次核对超时 = 10 s
|
|
71
|
+
checkConcurrency: 2, // 并发核对 = 2
|
|
72
|
+
sqliteBusyTimeoutMs: 2000, // SQLite busy timeout = 2 s
|
|
73
|
+
// ── 1.7 导入通道(契约 §7:全部可配、**规范键名**,双限是"条数 × 字节"两把尺)────────────
|
|
74
|
+
// 这五个键同时是**配置面**的键名(config.js 从本表自动派生 library.limits 的 schema),
|
|
75
|
+
// 所以改名等于改配置接口 —— 改这里要同批改契约 §7。
|
|
76
|
+
importBatchMaxItems: 200, // 单批条数上限
|
|
77
|
+
importBatchMaxBytes: 3145728, // 单批总字节上限 = 3 MiB
|
|
78
|
+
importTextMaxBytes: 1048576, // 单次粘贴/上传文本上限 = 1 MiB
|
|
79
|
+
importCandidateMaxBytes: 65536, // 单条正文上限(= itemMaxBytes 同值)
|
|
80
|
+
importModelMaxItems: 60, // 单批**送模型**提候选的条数上限(成本闸)
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
/** 各限额的**有效区间**(整数闭区间)。越界 ⇒ 构造时抛错(配置错误要响亮,不静默夹取)。 */
|
|
84
|
+
const LIMIT_BOUNDS = Object.freeze({
|
|
85
|
+
itemMaxBytes: [1024, 64 * 1024 * 1024],
|
|
86
|
+
resultMaxBytes: [1024, 256 * 1024 * 1024],
|
|
87
|
+
queryMaxChars: [1, 65536],
|
|
88
|
+
searchDefaultLimit: [1, 1000],
|
|
89
|
+
searchMaxLimit: [1, 1000],
|
|
90
|
+
browseDefaultLimit: [1, 5000],
|
|
91
|
+
browseMaxLimit: [1, 5000],
|
|
92
|
+
sourceMaxBytes: [1024, 1024 * 1024 * 1024],
|
|
93
|
+
selectedTextMaxBytes: [64, 16 * 1024 * 1024],
|
|
94
|
+
selectedTextMaxLines: [1, 100000],
|
|
95
|
+
checkTimeoutMs: [1, 600000],
|
|
96
|
+
checkConcurrency: [1, 64],
|
|
97
|
+
sqliteBusyTimeoutMs: [0, 600000],
|
|
98
|
+
// 导入通道(契约 §7):上限留宽、下限守住"至少能放进一条";0 是 importModelMaxItems 的合法值
|
|
99
|
+
// ——"一个都不送模型"是**正当逃生开关**(规则段仍照跑),不是配置错误。
|
|
100
|
+
importBatchMaxItems: [1, 5000],
|
|
101
|
+
importBatchMaxBytes: [1024, 256 * 1024 * 1024],
|
|
102
|
+
importTextMaxBytes: [256, 64 * 1024 * 1024],
|
|
103
|
+
importCandidateMaxBytes: [1, 64 * 1024 * 1024],
|
|
104
|
+
importModelMaxItems: [0, 1000],
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
/** 读接口的分页上限(与检索同一个 100 的口径)。 */
|
|
108
|
+
const PAGE_MAX_LIMIT = 100;
|
|
109
|
+
const PAGE_DEFAULT_LIMIT = 20;
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* 限额键的**兼容别名**(主线集成时用短名):别名与正名指向同一个内部字段。
|
|
113
|
+
* ⚠️ 同一个字段**只要正名与别名同时出现就抛错** —— 不比较取值:两个名字都给本身就是
|
|
114
|
+
* 语义重复,而"取值相同就放行"会让配置读起来有两个来源(下次只改一处就静默失效)。
|
|
115
|
+
*/
|
|
116
|
+
const LIMIT_ALIASES = Object.freeze({
|
|
117
|
+
selectionMaxBytes: 'selectedTextMaxBytes',
|
|
118
|
+
selectionMaxLines: 'selectedTextMaxLines',
|
|
119
|
+
concurrency: 'checkConcurrency',
|
|
120
|
+
busyTimeoutMs: 'sqliteBusyTimeoutMs',
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
/** 文本字段的字节上限(元数据自身的护栏;条目总量另受 itemMaxBytes 约束)。 */
|
|
124
|
+
const TITLE_MAX_BYTES = 2048;
|
|
125
|
+
const SOURCE_REF_MAX_BYTES = 4096;
|
|
126
|
+
const VERSION_MAX_BYTES = 512;
|
|
127
|
+
const AS_OF_MAX_BYTES = 64;
|
|
128
|
+
const WITHDRAW_REASON_MAX_BYTES = 4096;
|
|
129
|
+
const EVIDENCE_STRING_MAX_BYTES = 4096;
|
|
130
|
+
|
|
131
|
+
/** item 载荷里允许出现的字段(可编辑);此外一律拒绝 —— 不做静默剥离(契约 §Item Contract)。 */
|
|
132
|
+
const ITEM_INPUT_KEYS = Object.freeze([
|
|
133
|
+
'title', 'body', 'category', 'source_kind', 'source_ref', 'as_of',
|
|
134
|
+
'version', 'conditions', 'check_spec',
|
|
135
|
+
]);
|
|
136
|
+
/**
|
|
137
|
+
* 导入身份键:只允许在 **create** 时出现(revise 出现即拒 —— 见 `normalizeItemInput` 的注释)。
|
|
138
|
+
* 单独一栏而不并进 `ITEM_INPUT_KEYS`:并进去就等于 revise 也收,而 revise 的 `partial` 回落
|
|
139
|
+
* 会把旧版的键**继承到新版本**,新版本当场被旧版占着的键挡住 ⇒ 导入过的条目将永远无法修订。
|
|
140
|
+
*/
|
|
141
|
+
const IMPORT_INPUT_KEYS = Object.freeze(['import_key']);
|
|
142
|
+
/** 导入键的字节上限(形状另判;它不进条目字节预算,但也不许无限长)。 */
|
|
143
|
+
const IMPORT_KEY_MAX_BYTES = 512;
|
|
144
|
+
/** 契约 §6 的键形:`<源摘要前 16 hex>:<定位>`。 */
|
|
145
|
+
const IMPORT_KEY_RE = /^[0-9a-f]{16}:.+$/;
|
|
146
|
+
|
|
147
|
+
/** 只读字段:出现即拒绝。`checked_state/checked_at` 在这里被**点名**(防外部伪造认证)。 */
|
|
148
|
+
const READONLY_ITEM_KEYS = Object.freeze([
|
|
149
|
+
'id', 'lineage_id', 'revision', 'previous_id', 'content_hash', 'state', 'superseded_by',
|
|
150
|
+
'created_at', 'updated_at', 'checked_state', 'checked_at', 'withdrawn_at', 'withdrawn_reason',
|
|
151
|
+
]);
|
|
152
|
+
const LOCATOR_KEYS = Object.freeze(['kind', 'start', 'end', 'pointer']);
|
|
153
|
+
const CHECK_SPEC_KEYS = Object.freeze(['locator', 'expected', 'comparison']);
|
|
154
|
+
/**
|
|
155
|
+
* evidence 允许出现的键。**没有 `hash`**:核对对象的摘要已经有两个正门 ——
|
|
156
|
+
* `recordCheck(itemId, hash, evidence)` 的**第二个参数**(写库时落 `library_check.item_hash`),
|
|
157
|
+
* 以及回执/历史里的 `check.item_hash`。再收一个 `evidence.hash` 只会多出一份**不校验、不落库**
|
|
158
|
+
* 的影子字段(加固前它就在这张表里,却从没人读),所以整条删掉:显式传 `hash` ⇒ `unknown_field`。
|
|
159
|
+
*/
|
|
160
|
+
const EVIDENCE_KEYS = Object.freeze([
|
|
161
|
+
'state', 'requested_source', 'actual_source', 'source_hash', 'locator',
|
|
162
|
+
'expected', 'observed', 'comparison', 'checked_at', 'reason',
|
|
163
|
+
]);
|
|
164
|
+
|
|
165
|
+
const HASH_RE = /^sha256:[0-9a-f]{64}$/;
|
|
166
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
167
|
+
const AS_OF_DATE_RE = /^(\d{4})-(\d{2})-(\d{2})$/;
|
|
168
|
+
const AS_OF_TS_RE = /^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2}):(\d{2})(\.\d{1,9})?Z$/;
|
|
169
|
+
|
|
170
|
+
const SCHEMA_DDL = `CREATE TABLE IF NOT EXISTS library_meta (
|
|
171
|
+
key TEXT PRIMARY KEY,
|
|
172
|
+
value TEXT NOT NULL
|
|
173
|
+
);
|
|
174
|
+
CREATE TABLE IF NOT EXISTS library_item (
|
|
175
|
+
id TEXT PRIMARY KEY,
|
|
176
|
+
lineage_id TEXT NOT NULL,
|
|
177
|
+
revision INTEGER NOT NULL,
|
|
178
|
+
previous_id TEXT NOT NULL DEFAULT '',
|
|
179
|
+
title TEXT NOT NULL,
|
|
180
|
+
body TEXT NOT NULL,
|
|
181
|
+
category TEXT NOT NULL,
|
|
182
|
+
source_kind TEXT NOT NULL,
|
|
183
|
+
source_ref TEXT NOT NULL,
|
|
184
|
+
as_of TEXT NOT NULL,
|
|
185
|
+
version TEXT,
|
|
186
|
+
conditions TEXT,
|
|
187
|
+
check_spec TEXT,
|
|
188
|
+
content_hash TEXT NOT NULL,
|
|
189
|
+
checked_state TEXT NOT NULL DEFAULT '',
|
|
190
|
+
checked_at TEXT,
|
|
191
|
+
superseded_by TEXT NOT NULL DEFAULT '',
|
|
192
|
+
withdrawn_at TEXT,
|
|
193
|
+
withdrawn_reason TEXT,
|
|
194
|
+
created_at TEXT NOT NULL,
|
|
195
|
+
updated_at TEXT NOT NULL,
|
|
196
|
+
import_key TEXT NOT NULL DEFAULT ''
|
|
197
|
+
);
|
|
198
|
+
-- ⚠️ import_key 的索引**故意不写在这段 DDL 里**:SCHEMA_DDL 在迁移之前执行,而 v1 老表上
|
|
199
|
+
-- 还没有这一列 ⇒ CREATE INDEX ... ON library_item(import_key) 会在迁移有机会跑之前就抛
|
|
200
|
+
-- "no such column"。索引由 _initializeSchema() 在**确认列存在之后**建(见那里的注释)。
|
|
201
|
+
CREATE TABLE IF NOT EXISTS library_check (
|
|
202
|
+
check_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
203
|
+
item_id TEXT NOT NULL,
|
|
204
|
+
check_sequence INTEGER NOT NULL,
|
|
205
|
+
item_hash TEXT NOT NULL,
|
|
206
|
+
state TEXT NOT NULL,
|
|
207
|
+
requested_source TEXT,
|
|
208
|
+
actual_source TEXT,
|
|
209
|
+
source_hash TEXT,
|
|
210
|
+
locator TEXT,
|
|
211
|
+
expected TEXT,
|
|
212
|
+
observed TEXT,
|
|
213
|
+
comparison TEXT,
|
|
214
|
+
reason TEXT,
|
|
215
|
+
checked_at TEXT NOT NULL,
|
|
216
|
+
adopted INTEGER NOT NULL DEFAULT 0,
|
|
217
|
+
UNIQUE(item_id, check_sequence)
|
|
218
|
+
);
|
|
219
|
+
CREATE INDEX IF NOT EXISTS library_item_lineage ON library_item(lineage_id, revision);
|
|
220
|
+
CREATE INDEX IF NOT EXISTS library_item_state ON library_item(superseded_by, withdrawn_at);
|
|
221
|
+
CREATE INDEX IF NOT EXISTS library_check_item ON library_check(item_id, check_sequence);
|
|
222
|
+
CREATE INDEX IF NOT EXISTS library_check_hash ON library_check(item_id, item_hash);`;
|
|
223
|
+
|
|
224
|
+
/** 库不可用(打不开 / 文件不是 SQLite 库 / 目录建不出来)—— 区别于"零命中"。 */
|
|
225
|
+
export class LibraryUnavailableError extends Error {
|
|
226
|
+
constructor(message, cause) {
|
|
227
|
+
super(message);
|
|
228
|
+
this.name = 'LibraryUnavailableError';
|
|
229
|
+
this.code = 'library-unavailable';
|
|
230
|
+
if (cause !== undefined) this.cause = cause;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** UTF-8 字节数(`Buffer.byteLength` 对非字符串会抛,故先要求 string)。 */
|
|
235
|
+
export function utf8Bytes(s) {
|
|
236
|
+
return Buffer.byteLength(String(s), 'utf8');
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** 规范化摘要:对**规范字段集**做确定性 JSON(键序固定)后 sha256 ⇒ `sha256:<hex>`。 */
|
|
240
|
+
export function libraryContentHash(item) {
|
|
241
|
+
const canonical = {
|
|
242
|
+
title: item.title,
|
|
243
|
+
body: item.body,
|
|
244
|
+
category: item.category,
|
|
245
|
+
source_kind: item.source_kind,
|
|
246
|
+
source_ref: item.source_ref,
|
|
247
|
+
as_of: item.as_of,
|
|
248
|
+
version: item.version ?? null,
|
|
249
|
+
conditions: item.conditions ?? null,
|
|
250
|
+
check_spec: item.check_spec ?? null,
|
|
251
|
+
};
|
|
252
|
+
return 'sha256:' + createHash('sha256').update(JSON.stringify(canonical), 'utf8').digest('hex');
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** 两枚摘要是否指同一内容(与陈旧判据同一处实现,免得两处判据漂移)。 */
|
|
256
|
+
export function hashEqual(a, b) {
|
|
257
|
+
if (typeof a !== 'string' || typeof b !== 'string') return false;
|
|
258
|
+
const x = a.trim();
|
|
259
|
+
const y = b.trim();
|
|
260
|
+
return x.length > 0 && x === y;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** text-lf:只把 CRLF/CR 归一成 LF —— 不 trim、不折叠大小写、不模糊匹配(契约 §Item Contract)。 */
|
|
264
|
+
export function normalizeTextLf(s) {
|
|
265
|
+
return String(s).replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* RFC 6901 JSON pointer 求值。返回 `{found, value}`:`found:false` 表示**位置不存在**
|
|
270
|
+
* (契约:missing positions are unknown, not wrong),`value:undefined` 才是"存在且就是 undefined"。
|
|
271
|
+
* 非法指针(不以 `/` 开头且非空串、`~` 后不是 0/1)返回 `{found:false, value:undefined, invalid:true}`。
|
|
272
|
+
*/
|
|
273
|
+
export function resolveJsonPointer(doc, pointer) {
|
|
274
|
+
if (typeof pointer !== 'string') return { found: false, value: undefined, invalid: true };
|
|
275
|
+
if (pointer === '') return { found: true, value: doc };
|
|
276
|
+
if (!pointer.startsWith('/')) return { found: false, value: undefined, invalid: true };
|
|
277
|
+
const tokens = pointer.slice(1).split('/').map((t) => {
|
|
278
|
+
if (/~(?![01])/.test(t)) return null;
|
|
279
|
+
return t.replace(/~1/g, '/').replace(/~0/g, '~');
|
|
280
|
+
});
|
|
281
|
+
if (tokens.some((t) => t === null)) return { found: false, value: undefined, invalid: true };
|
|
282
|
+
let cur = doc;
|
|
283
|
+
for (const token of tokens) {
|
|
284
|
+
if (Array.isArray(cur)) {
|
|
285
|
+
if (!/^(0|[1-9]\d*)$/.test(token)) return { found: false, value: undefined };
|
|
286
|
+
const idx = Number(token);
|
|
287
|
+
if (idx >= cur.length) return { found: false, value: undefined };
|
|
288
|
+
cur = cur[idx];
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
if (cur !== null && typeof cur === 'object') {
|
|
292
|
+
if (!Object.prototype.hasOwnProperty.call(cur, token)) return { found: false, value: undefined };
|
|
293
|
+
cur = cur[token];
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
return { found: false, value: undefined };
|
|
297
|
+
}
|
|
298
|
+
return { found: true, value: cur };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** LIKE 用:`%` `_` `\` 一律转义成字面量(转义符固定 `\`)。 */
|
|
302
|
+
function escapeLike(s) {
|
|
303
|
+
return s.replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_');
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function isPlainObject(v) {
|
|
307
|
+
return v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function nowIso() {
|
|
311
|
+
return new Date().toISOString();
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function fail(reason, status, extra) {
|
|
315
|
+
return Object.assign({ ok: false, reason, ...(status ? { status } : {}) }, extra || {});
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* 失败回执的**规范形状**:`reason` 永远是机器可判定的**短码**(调用方按它分流),
|
|
320
|
+
* 人话进 `message`。两者混用一个字段的话回执就没法机械判定了 —— 而"红/绿可判定"
|
|
321
|
+
* 正是这套回执存在的理由(验收要能点名 `invalid_check_state`,而不是去正则匹配一句中文)。
|
|
322
|
+
* `refuse(code, message)` 是给"内部小函数要先返回、由调用方统一做成回执"的场合用的形状。
|
|
323
|
+
*/
|
|
324
|
+
function refuse(code, message) {
|
|
325
|
+
return { refused: true, response: fail(code, null, { message }) };
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function bytesOf(value) {
|
|
329
|
+
try {
|
|
330
|
+
return utf8Bytes(JSON.stringify(value));
|
|
331
|
+
} catch {
|
|
332
|
+
return Infinity;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* **严格 JSON 值判定**:只接受能**无损**穿过 `JSON.stringify` 的数据 ——
|
|
338
|
+
* null / 布尔 / 有限数字 / 字符串 / 纯数据对象(原型为 `Object.prototype` 或 `null`)/ 数组,
|
|
339
|
+
* 且不含环。合法返回 `null`,否则返回一句带出错路径的人话。
|
|
340
|
+
*
|
|
341
|
+
* 为什么非严不可:这些值落库前都要过 `JSON.stringify`,而它**会悄悄改值** ——
|
|
342
|
+
* `Infinity`/`NaN` ⇒ `null`、值为 `undefined`/函数/Symbol 的键 ⇒ **整个键被丢掉**、
|
|
343
|
+
* 数组空位 ⇒ `null`、`-0` ⇒ `0`、`BigInt` ⇒ 直接抛。任何一处发生,写进库的就不再是调用方
|
|
344
|
+
* 给的那份数据(内容摘要也跟着漂移),所以必须在**入库前**拒掉,而不是靠 `bytesOf` 的
|
|
345
|
+
* try/catch 兜成一句"超出字节上限"的误导回执 —— 实测:自引用环加固前正是以那个理由被拒的,
|
|
346
|
+
* 而 `Infinity` 这类"能序列化但会变值"的则**直接混进库**(见 §13 回归断言)。
|
|
347
|
+
*/
|
|
348
|
+
function jsonValueProblem(value, label) {
|
|
349
|
+
return scanJsonValue(value, label, []);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function scanJsonValue(value, path, ancestors) {
|
|
353
|
+
const at = path.length > 120 ? `${path.slice(0, 117)}…` : path;
|
|
354
|
+
if (value === null) return null;
|
|
355
|
+
const kind = typeof value;
|
|
356
|
+
if (kind === 'string' || kind === 'boolean') return null;
|
|
357
|
+
if (kind === 'number') {
|
|
358
|
+
if (!Number.isFinite(value)) return `${at} 不能是非有限数字(${String(value)}):JSON 会把它悄悄变成 null`;
|
|
359
|
+
if (Object.is(value, -0)) return `${at} 不能是 -0:JSON 会把它悄悄变成 0`;
|
|
360
|
+
return null;
|
|
361
|
+
}
|
|
362
|
+
if (kind === 'undefined') return `${at} 不能是 undefined:JSON 会把这个键整个丢掉`;
|
|
363
|
+
if (kind === 'bigint') return `${at} 不能是 BigInt:JSON.stringify 会直接抛错`;
|
|
364
|
+
if (kind === 'function') return `${at} 不能是函数:JSON 会把这个键整个丢掉`;
|
|
365
|
+
if (kind === 'symbol') return `${at} 不能是 Symbol:JSON 会把这个键整个丢掉`;
|
|
366
|
+
if (ancestors.includes(value)) return `${at} 出现环(对象/数组自引用):JSON.stringify 会直接抛错`;
|
|
367
|
+
ancestors.push(value);
|
|
368
|
+
let problem = null;
|
|
369
|
+
if (Array.isArray(value)) {
|
|
370
|
+
for (let i = 0; i < value.length; i += 1) {
|
|
371
|
+
if (!Object.prototype.hasOwnProperty.call(value, i)) {
|
|
372
|
+
problem = `${at}[${i}] 是数组空位:JSON 会把它变成 null`;
|
|
373
|
+
break;
|
|
374
|
+
}
|
|
375
|
+
problem = scanJsonValue(value[i], `${at}[${i}]`, ancestors);
|
|
376
|
+
if (problem) break;
|
|
377
|
+
}
|
|
378
|
+
} else if (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null) {
|
|
379
|
+
for (const key of Object.keys(value)) {
|
|
380
|
+
problem = scanJsonValue(value[key], `${at}.${key}`, ancestors);
|
|
381
|
+
if (problem) break;
|
|
382
|
+
}
|
|
383
|
+
} else {
|
|
384
|
+
problem = `${at} 不是纯数据对象(原型既不是 Object.prototype 也不是 null):JSON 序列化会改它的形状`;
|
|
385
|
+
}
|
|
386
|
+
ancestors.pop();
|
|
387
|
+
return problem;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* 显式 `checked_at`:必须是**合法 UTC ISO 时刻**(带 `Z`,如 `2026-10-06T12:00:00.000Z`),
|
|
392
|
+
* 且日历与时分秒字段都能回读 —— `2026-02-30T00:00:00Z` / `2026-10-06T25:00:00Z` / 月份 13 一律拒。
|
|
393
|
+
* 形状复用 `AS_OF_TS_RE`(与 `as_of` 的 UTC 时刻**同一条正则** ⇒ 两处口径不会各自漂移)。
|
|
394
|
+
*
|
|
395
|
+
* **只有完全缺省(`undefined`)才允许取当前时刻** —— 内部调用可以不传该字段;
|
|
396
|
+
* 一旦显式给了值,就绝不静默改写成 `now()`。加固前这里对任何非字符串(含 `null`、数字、数组)
|
|
397
|
+
* 和空串都 `.trim()` 后回落 `now()`(见旧 `checkedAtOrNow`),于是"证据写着 12:00、落库却是此刻"
|
|
398
|
+
* 这种**账实不符**不会被任何断言发现 —— 而 `checked_at` 恰恰是认证的观测时间。
|
|
399
|
+
*/
|
|
400
|
+
function checkCheckedAt(v) {
|
|
401
|
+
if (v === undefined) return { ok: true, value: nowIso() };
|
|
402
|
+
if (typeof v !== 'string') {
|
|
403
|
+
return fail('invalid_evidence', null, {
|
|
404
|
+
message: `evidence.checked_at 必须是 UTC ISO 时刻字符串(收到 ${v === null ? 'null' : typeof v});`
|
|
405
|
+
+ '要取当前时刻就整个字段不传,不许用别的类型顶替',
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
const t = v.trim();
|
|
409
|
+
if (t === '' || t !== v) {
|
|
410
|
+
return fail('invalid_evidence', null, {
|
|
411
|
+
message: 'evidence.checked_at 必须是显式的 UTC ISO 时刻(不许留空、不许带首尾空白);'
|
|
412
|
+
+ '要取当前时刻就整个字段不传',
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
const m = AS_OF_TS_RE.exec(t);
|
|
416
|
+
if (!m) {
|
|
417
|
+
return fail('invalid_evidence', null, {
|
|
418
|
+
message: 'evidence.checked_at 必须是带 Z 的 UTC ISO 时刻(如 2026-10-06T12:00:00.000Z);'
|
|
419
|
+
+ '无 Z 的本地时间、带 +08:00 偏移量、纯日期一律拒绝',
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
const [y, mo, d, hh, mi, ss] = [Number(m[1]), Number(m[2]), Number(m[3]),
|
|
423
|
+
Number(m[4]), Number(m[5]), Number(m[6])];
|
|
424
|
+
const dt = new Date(Date.UTC(y, mo - 1, d));
|
|
425
|
+
const realDay = dt.getUTCFullYear() === y && dt.getUTCMonth() === mo - 1 && dt.getUTCDate() === d;
|
|
426
|
+
if (!realDay || hh > 23 || mi > 59 || ss > 59) {
|
|
427
|
+
return fail('invalid_evidence', null, {
|
|
428
|
+
message: `evidence.checked_at 不是真实存在的 UTC 时刻:${t}(形状对但日历/时刻越界)`,
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
return { ok: true, value: t };
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/** 严格整数解析:数字必须是整数;"12"/"12 " 可接受(表单来的字符串),其余一律 undefined。 */
|
|
435
|
+
function toInt(v) {
|
|
436
|
+
if (typeof v === 'number') return Number.isInteger(v) ? v : undefined;
|
|
437
|
+
if (typeof v === 'string' && /^\s*-?\d+\s*$/.test(v)) return Number(v);
|
|
438
|
+
return undefined;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* 限额规范化(契约 §Limits:Configuration validated at activation; invalid values fail loudly)。
|
|
443
|
+
* 主线可复用本函数做激活期校验;`base` 允许传部分覆盖。
|
|
444
|
+
*/
|
|
445
|
+
export function normalizeLibraryLimits(base) {
|
|
446
|
+
if (base === undefined || base === null) return Object.freeze({ ...DEFAULT_LIBRARY_LIMITS });
|
|
447
|
+
if (!isPlainObject(base)) throw new TypeError('limits 必须是普通对象');
|
|
448
|
+
const out = { ...DEFAULT_LIBRARY_LIMITS };
|
|
449
|
+
const seen = new Map();
|
|
450
|
+
for (const [rawKey, raw] of Object.entries(base)) {
|
|
451
|
+
const key = LIMIT_ALIASES[rawKey] ?? rawKey;
|
|
452
|
+
if (!Object.prototype.hasOwnProperty.call(DEFAULT_LIBRARY_LIMITS, key)) {
|
|
453
|
+
throw new RangeError(`limits 未知字段 ${rawKey}(可用:${Object.keys(DEFAULT_LIBRARY_LIMITS).join(', ')}`
|
|
454
|
+
+ ` 或别名 ${Object.keys(LIMIT_ALIASES).join(', ')})`);
|
|
455
|
+
}
|
|
456
|
+
if (typeof raw !== 'number' || !Number.isInteger(raw)) {
|
|
457
|
+
throw new TypeError(`limits.${rawKey} 必须是整数(收到 ${JSON.stringify(raw)})`);
|
|
458
|
+
}
|
|
459
|
+
if (seen.has(key)) {
|
|
460
|
+
throw new RangeError(`limits 同一个字段给了两个名字(${seen.get(key)} 与 ${rawKey}),`
|
|
461
|
+
+ '给两个名字本身就是语义重复(不比较取值,取值相同也拒)');
|
|
462
|
+
}
|
|
463
|
+
seen.set(key, rawKey);
|
|
464
|
+
const [lo, hi] = LIMIT_BOUNDS[key];
|
|
465
|
+
if (raw < lo || raw > hi) {
|
|
466
|
+
throw new RangeError(`limits.${rawKey} 越界:${raw} 不在 [${lo}, ${hi}]`);
|
|
467
|
+
}
|
|
468
|
+
out[key] = raw;
|
|
469
|
+
}
|
|
470
|
+
if (out.browseDefaultLimit > out.browseMaxLimit) {
|
|
471
|
+
throw new RangeError(`limits.browseDefaultLimit(${out.browseDefaultLimit})`
|
|
472
|
+
+ ` 不能大于 limits.browseMaxLimit(${out.browseMaxLimit})`);
|
|
473
|
+
}
|
|
474
|
+
if (out.searchDefaultLimit > out.searchMaxLimit) {
|
|
475
|
+
throw new RangeError(`limits.searchDefaultLimit(${out.searchDefaultLimit})`
|
|
476
|
+
+ ` 不能大于 limits.searchMaxLimit(${out.searchMaxLimit})`);
|
|
477
|
+
}
|
|
478
|
+
return Object.freeze(out);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function checkIsoAsOf(text) {
|
|
482
|
+
const t = String(text).trim();
|
|
483
|
+
if (t !== text || t === '') return fail('invalid_as_of', null, { message: 'as_of 必须是显式的 ISO 日期(YYYY-MM-DD)或 UTC 时刻(…Z),不许留空' });
|
|
484
|
+
if (utf8Bytes(t) > AS_OF_MAX_BYTES) return fail('invalid_as_of', null, { message: `as_of 长度超过 ${AS_OF_MAX_BYTES} 字节` });
|
|
485
|
+
const dateMatch = AS_OF_DATE_RE.exec(t);
|
|
486
|
+
const tsMatch = dateMatch ? null : AS_OF_TS_RE.exec(t);
|
|
487
|
+
if (!dateMatch && !tsMatch) {
|
|
488
|
+
return fail('invalid_as_of', null, { message: 'as_of 必须是 YYYY-MM-DD 或带 Z 的 UTC ISO 时刻(如 2026-10-06T00:00:00Z)' });
|
|
489
|
+
}
|
|
490
|
+
const m = dateMatch || tsMatch;
|
|
491
|
+
const [y, mo, d] = [Number(m[1]), Number(m[2]), Number(m[3])];
|
|
492
|
+
const dt = new Date(Date.UTC(y, mo - 1, d));
|
|
493
|
+
if (dt.getUTCFullYear() !== y || dt.getUTCMonth() !== mo - 1 || dt.getUTCDate() !== d) {
|
|
494
|
+
return fail('invalid_as_of', null, { message: 'as_of 的日期部分不是真实日历日' });
|
|
495
|
+
}
|
|
496
|
+
if (tsMatch) {
|
|
497
|
+
const [hh, mi, ss] = [Number(m[4]), Number(m[5]), Number(m[6])];
|
|
498
|
+
if (hh > 23 || mi > 59 || ss > 59) return fail('invalid_as_of', null, { message: 'as_of 的时刻部分越界' });
|
|
499
|
+
}
|
|
500
|
+
return { ok: true, value: t };
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function checkTitle(text) {
|
|
504
|
+
if (utf8Bytes(text) > TITLE_MAX_BYTES) return fail('invalid_item', null, { message: `title 超过 ${TITLE_MAX_BYTES} 字节` });
|
|
505
|
+
return { ok: true };
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function checkSourceRef(text) {
|
|
509
|
+
if (utf8Bytes(text) > SOURCE_REF_MAX_BYTES) return fail('invalid_source_ref', null, { message: `source_ref 超过 ${SOURCE_REF_MAX_BYTES} 字节` });
|
|
510
|
+
return { ok: true };
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function checkVersion(v, limits) {
|
|
514
|
+
if (v === undefined || v === null) return { ok: true, value: null };
|
|
515
|
+
let text;
|
|
516
|
+
if (typeof v === 'string') text = v.trim();
|
|
517
|
+
else if (typeof v === 'number' && Number.isFinite(v)) text = String(v);
|
|
518
|
+
// ⚠️ `Object.keys(v).length === v.length` 是**空位判据**:every()/map() 都会跳过数组空位,
|
|
519
|
+
// 于是 `['1', , '2']` 能混过 every,再由 join('+') 悄悄变成 `'1+'`(键名被改)。空位一律拒。
|
|
520
|
+
else if (Array.isArray(v) && v.length > 0 && Object.keys(v).length === v.length
|
|
521
|
+
&& v.every((x) => typeof x === 'string' && x.trim() !== '')) {
|
|
522
|
+
text = v.map((x) => x.trim()).join('+');
|
|
523
|
+
} else return fail('invalid_version', null, { message: 'version 必须是字符串、有限数字或非空字符串数组' });
|
|
524
|
+
if (text === '') return fail('invalid_version', null, { message: 'version 给定了就不能是空串(要么省略)' });
|
|
525
|
+
if (utf8Bytes(text) > Math.min(VERSION_MAX_BYTES, limits.itemMaxBytes)) {
|
|
526
|
+
return fail('invalid_version', null, { message: `version 超过 ${VERSION_MAX_BYTES} 字节` });
|
|
527
|
+
}
|
|
528
|
+
return { ok: true, value: text };
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function checkConditions(c, limits) {
|
|
532
|
+
if (c === undefined || c === null) return { ok: true, value: null };
|
|
533
|
+
if (typeof c === 'string') {
|
|
534
|
+
const t = c.trim();
|
|
535
|
+
if (t === '') return fail('invalid_conditions', null, { message: 'conditions 给定了就不能是空串(要么省略)' });
|
|
536
|
+
if (utf8Bytes(t) > limits.itemMaxBytes) return fail('invalid_conditions', null, { message: 'conditions 超出条目字节上限' });
|
|
537
|
+
return { ok: true, value: t };
|
|
538
|
+
}
|
|
539
|
+
if (Array.isArray(c)) {
|
|
540
|
+
if (c.length === 0) return fail('invalid_conditions', null, { message: 'conditions 数组必须非空且每项是非空字符串' });
|
|
541
|
+
// ⚠️ 逐项检查而不是 `c.every(...)`:every()/map() **会跳过数组空位**,`['a', , 'b']`
|
|
542
|
+
// 因此能混过旧写法,再被 JSON 序列化成 null(数据被悄悄改)。
|
|
543
|
+
for (let i = 0; i < c.length; i += 1) {
|
|
544
|
+
if (!Object.prototype.hasOwnProperty.call(c, i)) {
|
|
545
|
+
return fail('invalid_conditions', null, { message: `conditions[${i}] 是数组空位:JSON 会把它变成 null` });
|
|
546
|
+
}
|
|
547
|
+
const x = c[i];
|
|
548
|
+
if (typeof x !== 'string' || x.trim() === '') {
|
|
549
|
+
return fail('invalid_conditions', null, { message: 'conditions 数组必须非空且每项是非空字符串' });
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
const arr = c.map((x) => x.trim());
|
|
553
|
+
if (bytesOf(arr) > limits.itemMaxBytes) return fail('invalid_conditions', null, { message: 'conditions 超出条目字节上限' });
|
|
554
|
+
return { ok: true, value: arr };
|
|
555
|
+
}
|
|
556
|
+
if (isPlainObject(c)) {
|
|
557
|
+
const keys = Object.keys(c);
|
|
558
|
+
if (keys.length === 0) return fail('invalid_conditions', null, { message: 'conditions 对象必须非空' });
|
|
559
|
+
// ⚠️ 拷贝**不能用** `o[k] = v`:键名 `__proto__` 会撞上 Object.prototype 上的访问器,
|
|
560
|
+
// 赋值被静默忽略 —— 实测:自有键 `__proto__` 的 conditions 落库后变成 `{}`(键丢了)。
|
|
561
|
+
// Object.fromEntries 走 CreateDataProperty,键原样成为自有数据属性,不碰原型链。
|
|
562
|
+
const entries = [];
|
|
563
|
+
let total = 0;
|
|
564
|
+
for (const k of keys) {
|
|
565
|
+
const v = c[k];
|
|
566
|
+
if (typeof v !== 'string' && typeof v !== 'number' && typeof v !== 'boolean' && v !== null) {
|
|
567
|
+
return fail('invalid_conditions', null, { message: `conditions.${k} 只能是字符串/数字/布尔/null(显式适用条件不许塞嵌套结构)` });
|
|
568
|
+
}
|
|
569
|
+
// 数字这一档还要挡 NaN/Infinity/-0:它们"类型合法"却会被 JSON 悄悄改成 null/0。
|
|
570
|
+
const scalarBad = jsonValueProblem(v, `conditions.${k}`);
|
|
571
|
+
if (scalarBad) return fail('invalid_conditions', null, { message: scalarBad });
|
|
572
|
+
entries.push([k, v]);
|
|
573
|
+
total += utf8Bytes(k) + utf8Bytes(String(v));
|
|
574
|
+
if (total > limits.itemMaxBytes) return fail('invalid_conditions', null, { message: 'conditions 超出条目字节上限' });
|
|
575
|
+
}
|
|
576
|
+
return { ok: true, value: Object.fromEntries(entries) };
|
|
577
|
+
}
|
|
578
|
+
return fail('invalid_conditions', null, { message: 'conditions 只能是字符串、非空字符串数组或扁平对象' });
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// ⚠️ 行段/指针两个校验器**只看 locator 自己的键与值**(调用方传进来的就是 locator):
|
|
582
|
+
// 它们不该去审 `spec.expected` —— 早先版本在这里多写了一条"只允许 kind/start/end",
|
|
583
|
+
// 结果把 `spec.expected` 这个合法字段当成未知字段,连最普通的 lines 规格都建不进去
|
|
584
|
+
// (测试第一次跑就抓到;这类"校验器越权审别人的字段"是自伤型缺陷)。
|
|
585
|
+
function checkLinesLocator(locator, expected, limits) {
|
|
586
|
+
const keys = Object.keys(locator);
|
|
587
|
+
if (keys.some((k) => k !== 'kind' && k !== 'start' && k !== 'end')) {
|
|
588
|
+
return fail('invalid_check_spec', null, { message: 'lines 定位只允许 kind/start/end' });
|
|
589
|
+
}
|
|
590
|
+
const start = toInt(locator.start);
|
|
591
|
+
const end = toInt(locator.end);
|
|
592
|
+
if (start === undefined || end === undefined || start < 1 || end < start) {
|
|
593
|
+
return fail('invalid_check_spec', null, { message: 'lines 定位要求 1 <= start <= end 的整数行号' });
|
|
594
|
+
}
|
|
595
|
+
if (end - start + 1 > limits.selectedTextMaxLines) {
|
|
596
|
+
return fail('invalid_check_spec', null, { message: `lines 定位选中的行数(${end - start + 1})超过 ${limits.selectedTextMaxLines} 行上限` });
|
|
597
|
+
}
|
|
598
|
+
if (typeof expected !== 'string') {
|
|
599
|
+
return fail('invalid_check_spec', null, { message: 'lines 定位的 expected 必须是文本' });
|
|
600
|
+
}
|
|
601
|
+
if (utf8Bytes(expected) > limits.selectedTextMaxBytes) {
|
|
602
|
+
return fail('invalid_check_spec', null, { message: `lines 定位的 expected 超过 ${limits.selectedTextMaxBytes} 字节(选中文本上限)` });
|
|
603
|
+
}
|
|
604
|
+
return { ok: true, locator: { kind: 'lines', start, end } };
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
function checkJsonPointerLocator(locator, expected, limits) {
|
|
608
|
+
const keys = Object.keys(locator);
|
|
609
|
+
if (keys.some((k) => k !== 'kind' && k !== 'pointer')) {
|
|
610
|
+
return fail('invalid_check_spec', null, { message: 'json-pointer 定位只允许 kind/pointer' });
|
|
611
|
+
}
|
|
612
|
+
const pointer = locator.pointer;
|
|
613
|
+
if (typeof pointer !== 'string') return fail('invalid_check_spec', null, { message: 'json-pointer 定位的 pointer 必须是字符串' });
|
|
614
|
+
if (pointer !== '') {
|
|
615
|
+
if (pointer[0] !== '/') return fail('invalid_check_spec', null, { message: 'RFC 6901 指针必须以 / 开头(空串表示整篇)' });
|
|
616
|
+
if (/~(?![01])/.test(pointer)) return fail('invalid_check_spec', null, { message: 'RFC 6901 指针里 ~ 只能出现在 ~0/~1 中' });
|
|
617
|
+
}
|
|
618
|
+
if (expected === undefined) return fail('invalid_check_spec', null, { message: 'json-pointer 定位必须显式给 expected(JSON 值;null 也算显式)' });
|
|
619
|
+
// expected 是**任意 JSON 值**,但必须是能无损序列化的那种:加固前 `Infinity`/`NaN`/
|
|
620
|
+
// 嵌套非有限数/undefined/函数/空位/环都能混进来,落库时被 JSON 悄悄改成 null 或丢键
|
|
621
|
+
// (实测:`expected: Infinity` 建得进去,读回却是 `null`,规格与摘要一起被改写)。
|
|
622
|
+
const expectedBad = jsonValueProblem(expected, 'check_spec.expected');
|
|
623
|
+
if (expectedBad) return fail('invalid_check_spec', null, { message: expectedBad });
|
|
624
|
+
if (bytesOf(expected) > limits.itemMaxBytes) return fail('invalid_check_spec', null, { message: 'expected 超出条目字节上限' });
|
|
625
|
+
return { ok: true, locator: { kind: 'json-pointer', pointer } };
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/** check_spec:null(不可核)或 {locator, expected, comparison} —— 其余一律拒绝。 */
|
|
629
|
+
function checkCheckSpec(raw, limits) {
|
|
630
|
+
if (raw === undefined || raw === null) return { ok: true, value: null };
|
|
631
|
+
if (!isPlainObject(raw)) return fail('invalid_check_spec', null, { message: 'check_spec 只能是 null 或 {locator, expected, comparison}' });
|
|
632
|
+
const keys = Object.keys(raw);
|
|
633
|
+
const unknown = keys.filter((k) => !CHECK_SPEC_KEYS.includes(k));
|
|
634
|
+
if (unknown.length) return fail('invalid_check_spec', null, { message: `check_spec 未知字段:${unknown.join(', ')}` });
|
|
635
|
+
const missing = CHECK_SPEC_KEYS.filter((k) => !keys.includes(k));
|
|
636
|
+
if (missing.length) return fail('invalid_check_spec', null, { message: `check_spec 缺字段:${missing.join(', ')}` });
|
|
637
|
+
if (!Object.prototype.hasOwnProperty.call(raw, 'expected')) {
|
|
638
|
+
return fail('invalid_check_spec', null, { message: 'check_spec.expected 必须显式给出' });
|
|
639
|
+
}
|
|
640
|
+
if (typeof raw.comparison !== 'string' || !LIBRARY_COMPARISONS.includes(raw.comparison)) {
|
|
641
|
+
return fail('invalid_check_spec', null, { message: `comparison 只能是 ${LIBRARY_COMPARISONS.join(' | ')}` });
|
|
642
|
+
}
|
|
643
|
+
// text-lf 的语义就是"把换行归一后比文本":expected 不是文本时这条比较器根本没法执行,
|
|
644
|
+
// 加固前它会一路建进库,直到裁判员那边才变成必然的 mismatch —— 建不出来的规格要当场拒。
|
|
645
|
+
if (raw.comparison === 'text-lf' && typeof raw.expected !== 'string') {
|
|
646
|
+
return fail('invalid_check_spec', null, {
|
|
647
|
+
message: 'comparison=text-lf 时 expected 必须是文本(换行归一只对文本有意义;要核 JSON 值请用 exact)',
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
const loc = raw.locator;
|
|
651
|
+
if (!isPlainObject(loc)) return fail('invalid_check_spec', null, { message: 'locator 必须是对象' });
|
|
652
|
+
// ⚠️ locator 自己的**未知字段**必须在这里先判(lunknown),不能丢给下面的类型校验器 ——
|
|
653
|
+
// 它们只认自己那一类的键,拿不到"这个键本来就不该存在"的语义(测试里 `kind:'lines'` 还多带
|
|
654
|
+
// 一个 `lines:3` 就是靠这一条被拒的)。
|
|
655
|
+
const lunknown = Object.keys(loc).filter((k) => !LOCATOR_KEYS.includes(k));
|
|
656
|
+
if (lunknown.length) return fail('invalid_check_spec', null, { message: `locator 未知字段:${lunknown.join(', ')}` });
|
|
657
|
+
if (loc.kind !== 'lines' && loc.kind !== 'json-pointer') {
|
|
658
|
+
return fail('invalid_check_spec', null, { message: "locator.kind 只能是 'lines' 或 'json-pointer'" });
|
|
659
|
+
}
|
|
660
|
+
const res = loc.kind === 'lines'
|
|
661
|
+
? checkLinesLocator(loc, raw.expected, limits)
|
|
662
|
+
: checkJsonPointerLocator(loc, raw.expected, limits);
|
|
663
|
+
if (!res.ok) return res;
|
|
664
|
+
const value = {
|
|
665
|
+
locator: res.locator,
|
|
666
|
+
expected: raw.expected,
|
|
667
|
+
comparison: raw.comparison,
|
|
668
|
+
};
|
|
669
|
+
if (bytesOf(value) > limits.itemMaxBytes) return fail('invalid_check_spec', null, { message: 'check_spec 超出条目字节上限' });
|
|
670
|
+
return { ok: true, value };
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/**
|
|
674
|
+
* 导入键校验:`空串即无`(与全库其余可选文本同一惯例;显式 null 也当"无")。
|
|
675
|
+
* 形状不对**当场拒** —— 一条永远匹配不上的键躺在库里,唯一的下场就是让"重复导入"漏过去。
|
|
676
|
+
*/
|
|
677
|
+
function checkImportKey(raw) {
|
|
678
|
+
if (raw === undefined || raw === null) return { ok: true, value: '' };
|
|
679
|
+
if (typeof raw !== 'string') return fail('invalid_import_key', null, { message: 'import_key 必须是字符串' });
|
|
680
|
+
const s = raw.trim();
|
|
681
|
+
if (s === '') return { ok: true, value: '' };
|
|
682
|
+
if (utf8Bytes(s) > IMPORT_KEY_MAX_BYTES) {
|
|
683
|
+
return fail('invalid_import_key', null, { message: `import_key 超过 ${IMPORT_KEY_MAX_BYTES} 字节上限` });
|
|
684
|
+
}
|
|
685
|
+
if (!IMPORT_KEY_RE.test(s)) {
|
|
686
|
+
return fail('invalid_import_key', null, {
|
|
687
|
+
message: "import_key 形状非法(期望 `<源摘要前 16 hex>:<定位>`,见契约 §6)",
|
|
688
|
+
});
|
|
689
|
+
}
|
|
690
|
+
return { ok: true, value: s };
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/**
|
|
694
|
+
* 条目规范化。`partial:true`(revise)时缺省字段回落到 `fallback`(当前版),显式 null 表示
|
|
695
|
+
* **清空该可选字段**;`partial:false`(create)时缺省即错误。任何模式下未知字段/只读字段都拒绝。
|
|
696
|
+
*/
|
|
697
|
+
function normalizeItemInput(raw, limits, opts) {
|
|
698
|
+
const fallback = opts?.fallback ?? null;
|
|
699
|
+
const partial = opts?.partial === true;
|
|
700
|
+
if (!isPlainObject(raw)) return fail('invalid_type', null, { message: '条目必须是普通对象' });
|
|
701
|
+
const unknown = Object.keys(raw).filter((k) => !ITEM_INPUT_KEYS.includes(k)
|
|
702
|
+
&& !READONLY_ITEM_KEYS.includes(k) && !IMPORT_INPUT_KEYS.includes(k));
|
|
703
|
+
if (unknown.length) return fail('unknown_field', null, { message: `条目含未知字段:${unknown.join(', ')}` });
|
|
704
|
+
const readonly = Object.keys(raw).filter((k) => READONLY_ITEM_KEYS.includes(k));
|
|
705
|
+
if (readonly.length) {
|
|
706
|
+
return fail('readonly_field', null, { message: `条目含只读字段:${readonly.join(', ')}` });
|
|
707
|
+
}
|
|
708
|
+
// 导入键只在 create(`partial:false`)时收。revise 的缺省回落是"继承当前版",若把旧版的
|
|
709
|
+
// import_key 继承到新版本,新版本会被旧版占着的键挡住(导入过的条目就此无法修订)。
|
|
710
|
+
if (partial && Object.keys(raw).some((k) => IMPORT_INPUT_KEYS.includes(k))) {
|
|
711
|
+
return fail('readonly_field', null, {
|
|
712
|
+
message: 'import_key 只在 create 时给出(修订不继承导入键:重复导入认的是键,不是修订)',
|
|
713
|
+
});
|
|
714
|
+
}
|
|
715
|
+
const importKeyBad = partial ? { ok: true, value: '' } : checkImportKey(raw.import_key);
|
|
716
|
+
if (!importKeyBad.ok) return importKeyBad;
|
|
717
|
+
|
|
718
|
+
let title;
|
|
719
|
+
if (Object.prototype.hasOwnProperty.call(raw, 'title')) title = raw.title;
|
|
720
|
+
else if (partial && fallback) title = fallback.title;
|
|
721
|
+
if (typeof title !== 'string' || title.trim() === '') return fail('invalid_title', null, { message: 'title 必填且非空' });
|
|
722
|
+
title = title.trim();
|
|
723
|
+
const titleBad = checkTitle(title);
|
|
724
|
+
if (!titleBad.ok) return titleBad;
|
|
725
|
+
|
|
726
|
+
let body;
|
|
727
|
+
if (Object.prototype.hasOwnProperty.call(raw, 'body')) body = raw.body;
|
|
728
|
+
else if (partial && fallback) body = fallback.body;
|
|
729
|
+
if (typeof body !== 'string' || body === '') return fail('invalid_body', null, { message: 'body 必填且非空' });
|
|
730
|
+
|
|
731
|
+
let category;
|
|
732
|
+
if (Object.prototype.hasOwnProperty.call(raw, 'category')) category = raw.category;
|
|
733
|
+
else if (partial && fallback) category = fallback.category;
|
|
734
|
+
if (typeof category !== 'string' || !LIBRARY_CATEGORIES.includes(category.trim())) {
|
|
735
|
+
return fail('invalid_category', null, { message: `category 只能是 ${LIBRARY_CATEGORIES.join(' | ')}` });
|
|
736
|
+
}
|
|
737
|
+
category = category.trim();
|
|
738
|
+
|
|
739
|
+
let sourceKind;
|
|
740
|
+
if (Object.prototype.hasOwnProperty.call(raw, 'source_kind')) sourceKind = raw.source_kind;
|
|
741
|
+
else if (partial && fallback) sourceKind = fallback.source_kind;
|
|
742
|
+
if (typeof sourceKind !== 'string' || !LIBRARY_SOURCE_KINDS.includes(sourceKind.trim())) {
|
|
743
|
+
return fail('invalid_source_kind', null, { message: `source_kind 只能是 ${LIBRARY_SOURCE_KINDS.join(' | ')}` });
|
|
744
|
+
}
|
|
745
|
+
sourceKind = sourceKind.trim();
|
|
746
|
+
|
|
747
|
+
let sourceRef;
|
|
748
|
+
if (Object.prototype.hasOwnProperty.call(raw, 'source_ref')) sourceRef = raw.source_ref;
|
|
749
|
+
else if (partial && fallback) sourceRef = fallback.source_ref;
|
|
750
|
+
if (typeof sourceRef !== 'string' || sourceRef.trim() === '') {
|
|
751
|
+
return fail('invalid_source_ref', null, { message: 'source_ref 必填非空(没有出处就不许进图书馆)' });
|
|
752
|
+
}
|
|
753
|
+
sourceRef = sourceRef.trim();
|
|
754
|
+
const refBad = checkSourceRef(sourceRef);
|
|
755
|
+
if (!refBad.ok) return refBad;
|
|
756
|
+
|
|
757
|
+
let asOf;
|
|
758
|
+
if (Object.prototype.hasOwnProperty.call(raw, 'as_of')) asOf = raw.as_of;
|
|
759
|
+
else if (partial && fallback) asOf = fallback.as_of;
|
|
760
|
+
if (typeof asOf !== 'string') return fail('invalid_as_of', null, { message: 'as_of 必填(显式 ISO 日期或 UTC 时刻,不由创建时间推断)' });
|
|
761
|
+
const asOfBad = checkIsoAsOf(asOf);
|
|
762
|
+
if (!asOfBad.ok) return asOfBad;
|
|
763
|
+
asOf = asOfBad.value;
|
|
764
|
+
|
|
765
|
+
let version;
|
|
766
|
+
if (Object.prototype.hasOwnProperty.call(raw, 'version')) version = raw.version;
|
|
767
|
+
else if (partial && fallback) version = fallback.version;
|
|
768
|
+
const verBad = checkVersion(version, limits);
|
|
769
|
+
if (!verBad.ok) return verBad;
|
|
770
|
+
|
|
771
|
+
let conditions;
|
|
772
|
+
if (Object.prototype.hasOwnProperty.call(raw, 'conditions')) conditions = raw.conditions;
|
|
773
|
+
else if (partial && fallback) conditions = fallback.conditions;
|
|
774
|
+
const condBad = checkConditions(conditions, limits);
|
|
775
|
+
if (!condBad.ok) return condBad;
|
|
776
|
+
|
|
777
|
+
let checkSpec;
|
|
778
|
+
if (Object.prototype.hasOwnProperty.call(raw, 'check_spec')) checkSpec = raw.check_spec;
|
|
779
|
+
else if (partial && fallback) checkSpec = fallback.check_spec;
|
|
780
|
+
const specBad = checkCheckSpec(checkSpec, limits);
|
|
781
|
+
if (!specBad.ok) return specBad;
|
|
782
|
+
|
|
783
|
+
const value = {
|
|
784
|
+
title,
|
|
785
|
+
body,
|
|
786
|
+
category,
|
|
787
|
+
source_kind: sourceKind,
|
|
788
|
+
source_ref: sourceRef,
|
|
789
|
+
as_of: asOf,
|
|
790
|
+
version: verBad.value,
|
|
791
|
+
conditions: condBad.value,
|
|
792
|
+
check_spec: specBad.value,
|
|
793
|
+
};
|
|
794
|
+
// 契约:条目 UTF-8 字节上限 **包含 JSON 元数据**(不是只看 body 长度)。
|
|
795
|
+
const total = bytesOf(value);
|
|
796
|
+
if (total > limits.itemMaxBytes) {
|
|
797
|
+
return fail('item_too_large', null, { message: `条目总计 ${total} 字节,超过 ${limits.itemMaxBytes} 字节上限(含 JSON 元数据)` });
|
|
798
|
+
}
|
|
799
|
+
value.byte_size = total;
|
|
800
|
+
// 导入键在**字节判定之后**才挂上去:它是身份不是内容 —— 既不该占条目字节预算,
|
|
801
|
+
// 也不该进 libraryContentHash(那两个判据都只看"内容字段":换键不换内容)。
|
|
802
|
+
if (!partial) value.import_key = importKeyBad.value;
|
|
803
|
+
return { ok: true, value };
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
function parseJsonColumn(raw, field, id) {
|
|
807
|
+
if (raw === null || raw === undefined) return null;
|
|
808
|
+
try {
|
|
809
|
+
return JSON.parse(raw);
|
|
810
|
+
} catch (e) {
|
|
811
|
+
throw new Error(`library_item.check_spec/conditions 列解析失败(id=${id}, field=${field}):${e?.message ?? e}`);
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
function rowToRecord(row) {
|
|
816
|
+
return {
|
|
817
|
+
id: row.id,
|
|
818
|
+
lineage_id: row.lineage_id,
|
|
819
|
+
revision: Number(row.revision),
|
|
820
|
+
previous_id: row.previous_id || null,
|
|
821
|
+
title: row.title,
|
|
822
|
+
body: row.body,
|
|
823
|
+
category: row.category,
|
|
824
|
+
source_kind: row.source_kind,
|
|
825
|
+
source_ref: row.source_ref,
|
|
826
|
+
as_of: row.as_of,
|
|
827
|
+
version: row.version === null || row.version === undefined ? null : row.version,
|
|
828
|
+
conditions: row.conditions === null || row.conditions === undefined
|
|
829
|
+
? null
|
|
830
|
+
: parseJsonColumn(row.conditions, 'conditions', row.id),
|
|
831
|
+
check_spec: row.check_spec === null || row.check_spec === undefined
|
|
832
|
+
? null
|
|
833
|
+
: parseJsonColumn(row.check_spec, 'check_spec', row.id),
|
|
834
|
+
content_hash: row.content_hash,
|
|
835
|
+
state: row.withdrawn_at ? 'withdrawn' : (row.superseded_by ? 'superseded' : 'active'),
|
|
836
|
+
superseded_by: row.superseded_by || null,
|
|
837
|
+
checked_state: row.checked_state || '',
|
|
838
|
+
checked_at: row.checked_at ?? null,
|
|
839
|
+
withdrawn_at: row.withdrawn_at ?? null,
|
|
840
|
+
withdrawn_reason: row.withdrawn_reason ?? null,
|
|
841
|
+
created_at: row.created_at,
|
|
842
|
+
updated_at: row.updated_at,
|
|
843
|
+
// 空串即无(与库里的存储形状同一口径):回执里 null = "这条不是从导入通道进来的"。
|
|
844
|
+
import_key: row.import_key ? row.import_key : null,
|
|
845
|
+
};
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
function checkRowToRecord(row) {
|
|
849
|
+
return {
|
|
850
|
+
check_sequence: Number(row.check_sequence),
|
|
851
|
+
item_id: row.item_id,
|
|
852
|
+
item_hash: row.item_hash,
|
|
853
|
+
state: row.state,
|
|
854
|
+
requested_source: row.requested_source ?? null,
|
|
855
|
+
actual_source: row.actual_source ?? null,
|
|
856
|
+
source_hash: row.source_hash ?? null,
|
|
857
|
+
locator: row.locator === null || row.locator === undefined
|
|
858
|
+
? null
|
|
859
|
+
: parseJsonColumn(row.locator, 'locator', row.item_id),
|
|
860
|
+
expected: row.expected === null || row.expected === undefined
|
|
861
|
+
? null
|
|
862
|
+
: parseJsonColumn(row.expected, 'expected', row.item_id),
|
|
863
|
+
observed: row.observed === null || row.observed === undefined
|
|
864
|
+
? null
|
|
865
|
+
: parseJsonColumn(row.observed, 'observed', row.item_id),
|
|
866
|
+
comparison: row.comparison ?? null,
|
|
867
|
+
reason: row.reason ?? null,
|
|
868
|
+
checked_at: row.checked_at,
|
|
869
|
+
adopted: Number(row.adopted) === 1,
|
|
870
|
+
};
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
/**
|
|
874
|
+
* 条目 id 只接受本库自产的形状 `lib:<uuid>` —— 拒绝乱七八糟的 id 是**减少攻击面**,
|
|
875
|
+
* 不是洁癖:它同时挡住把外部字符串拼进 SQL 的冲动。
|
|
876
|
+
*/
|
|
877
|
+
function parseLibraryId(value) {
|
|
878
|
+
if (typeof value !== 'string') return null;
|
|
879
|
+
const text = value.trim();
|
|
880
|
+
if (!text.startsWith(LIBRARY_ID_PREFIX)) return null;
|
|
881
|
+
const uuid = text.slice(LIBRARY_ID_PREFIX.length).toLowerCase();
|
|
882
|
+
if (!UUID_RE.test(uuid)) return null;
|
|
883
|
+
return LIBRARY_ID_PREFIX + uuid;
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
/**
|
|
887
|
+
* 证据里的 locator:允许"直接给定位对象"或"给一句源内定位描述(有界字符串)"。
|
|
888
|
+
* 返回值**形状统一**为 `{locator}` —— 修前它在"没有定位"时返回裸 null,调用方却按
|
|
889
|
+
* `locator.refused` 读 ⇒ 直接用 null 去取属性会抛 TypeError(测试当场抓到)。形状不统一
|
|
890
|
+
* 就是这种"平时不报、边界就炸"的根源,故这里只回一种形状。
|
|
891
|
+
*/
|
|
892
|
+
function canonicalizeEvidenceLocator(raw, spec, limits) {
|
|
893
|
+
if (raw === undefined || raw === null) return { locator: spec === null ? null : spec.locator };
|
|
894
|
+
if (isPlainObject(raw)) {
|
|
895
|
+
const loc = raw.kind === 'lines'
|
|
896
|
+
? checkLinesLocator(raw, '', limits)
|
|
897
|
+
: (raw.kind === 'json-pointer' ? checkJsonPointerLocator(raw, null, limits) : null);
|
|
898
|
+
if (!loc) return refuse('invalid_locator', "evidence.locator.kind 只能是 'lines' 或 'json-pointer'");
|
|
899
|
+
if (loc.ok) return { locator: loc.locator };
|
|
900
|
+
return refuse('invalid_locator', `evidence.locator 非法:${loc.message ?? loc.reason}`);
|
|
901
|
+
}
|
|
902
|
+
if (typeof raw === 'string') {
|
|
903
|
+
const t = raw.trim();
|
|
904
|
+
if (t === '') return { locator: spec === null ? null : spec.locator };
|
|
905
|
+
if (utf8Bytes(t) > EVIDENCE_STRING_MAX_BYTES) {
|
|
906
|
+
return refuse('invalid_locator', `evidence.locator 超过 ${EVIDENCE_STRING_MAX_BYTES} 字节`);
|
|
907
|
+
}
|
|
908
|
+
return { locator: t };
|
|
909
|
+
}
|
|
910
|
+
return refuse('invalid_locator', 'evidence.locator 只能是对象或字符串');
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
function checkEvidenceValue(value, label, limits) {
|
|
914
|
+
if (value === undefined) return { ok: true, value: null };
|
|
915
|
+
// ⚠️ 不能只靠 `bytesOf` 判大小:它的 try/catch 把"序列化不了"兜成 Infinity,而
|
|
916
|
+
// `Infinity`/`NaN`/`undefined` 这类**能序列化但会变值**的它根本发现不了
|
|
917
|
+
// (`JSON.stringify(Infinity) === 'null'`,于是按 4 字节通过、落库成了 null)。
|
|
918
|
+
const problem = jsonValueProblem(value, `evidence.${label}`);
|
|
919
|
+
if (problem) return fail('invalid_evidence', null, { message: problem });
|
|
920
|
+
const size = bytesOf(value);
|
|
921
|
+
if (!Number.isFinite(size)) return fail('invalid_evidence', null, { message: `evidence.${label} 无法序列化成 JSON` });
|
|
922
|
+
if (size > limits.itemMaxBytes) {
|
|
923
|
+
return fail('invalid_evidence', null, { message: `evidence.${label} 超过 ${limits.itemMaxBytes} 字节上限` });
|
|
924
|
+
}
|
|
925
|
+
return { ok: true, value };
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
function checkEvidenceText(value, label, required) {
|
|
929
|
+
if (value === undefined || value === null) {
|
|
930
|
+
if (required) return fail('invalid_evidence', null, { message: `evidence.${label} 必填(核对凭据必须写明它查的是哪个源)` });
|
|
931
|
+
return { ok: true, value: null };
|
|
932
|
+
}
|
|
933
|
+
if (typeof value !== 'string') return fail('invalid_evidence', null, { message: `evidence.${label} 必须是字符串` });
|
|
934
|
+
const t = value.trim();
|
|
935
|
+
if (required && t === '') return fail('invalid_evidence', null, { message: `evidence.${label} 不能为空` });
|
|
936
|
+
if (utf8Bytes(value) > EVIDENCE_STRING_MAX_BYTES) {
|
|
937
|
+
return fail('invalid_evidence', null, { message: `evidence.${label} 超过 ${EVIDENCE_STRING_MAX_BYTES} 字节` });
|
|
938
|
+
}
|
|
939
|
+
return { ok: true, value: t === '' ? null : value };
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
/** 核对凭据校验 + 规范化(契约 §Checker Surface 的 evidence 字段清单)。 */
|
|
943
|
+
function normalizeEvidence(raw, limits, spec) {
|
|
944
|
+
if (!isPlainObject(raw)) return fail('invalid_type', null, { message: 'evidence 必须是普通对象' });
|
|
945
|
+
const unknown = Object.keys(raw).filter((k) => !EVIDENCE_KEYS.includes(k));
|
|
946
|
+
if (unknown.length) return fail('unknown_field', null, { message: `evidence 未知字段:${unknown.join(', ')}` });
|
|
947
|
+
|
|
948
|
+
if (typeof raw.state !== 'string' || !LIBRARY_CHECK_STATES.includes(raw.state.trim())) {
|
|
949
|
+
return fail('invalid_check_state', null, {
|
|
950
|
+
message: `evidence.state 只能是 ${LIBRARY_CHECK_STATES.join(' | ')}(伪造的 verified/ok-ish 状态一律拒绝)`,
|
|
951
|
+
});
|
|
952
|
+
}
|
|
953
|
+
const state = raw.state.trim();
|
|
954
|
+
if (state !== 'ok' && !(typeof raw.reason === 'string' && raw.reason.trim() !== '')) {
|
|
955
|
+
return fail('invalid_evidence', null, { message: `evidence.state=${state} 必须给出具体 reason(查不到就不是对/错)` });
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
const requested = checkEvidenceText(raw.requested_source, 'requested_source', true);
|
|
959
|
+
if (!requested.ok) return requested;
|
|
960
|
+
const actual = checkEvidenceText(raw.actual_source, 'actual_source', false);
|
|
961
|
+
if (!actual.ok) return actual;
|
|
962
|
+
const sourceHash = checkEvidenceText(raw.source_hash, 'source_hash', false);
|
|
963
|
+
if (!sourceHash.ok) return sourceHash;
|
|
964
|
+
if (sourceHash.value !== null && !HASH_RE.test(sourceHash.value)) {
|
|
965
|
+
return fail('invalid_evidence', null, { message: 'evidence.source_hash 必须是 sha256:<64 位小写十六进制>' });
|
|
966
|
+
}
|
|
967
|
+
const reason = checkEvidenceText(raw.reason, 'reason', false);
|
|
968
|
+
if (!reason.ok) return reason;
|
|
969
|
+
const comparison = checkEvidenceText(raw.comparison, 'comparison', false);
|
|
970
|
+
if (!comparison.ok) return comparison;
|
|
971
|
+
if (comparison.value !== null && !LIBRARY_COMPARISONS.includes(comparison.value)) {
|
|
972
|
+
return fail('invalid_evidence', null, { message: `evidence.comparison 只能是 ${LIBRARY_COMPARISONS.join(' | ')}` });
|
|
973
|
+
}
|
|
974
|
+
const locator = canonicalizeEvidenceLocator(raw.locator, spec, limits);
|
|
975
|
+
if (locator.refused) return locator.response;
|
|
976
|
+
const expected = checkEvidenceValue(raw.expected, 'expected', limits);
|
|
977
|
+
if (!expected.ok) return expected;
|
|
978
|
+
const observed = checkEvidenceValue(raw.observed, 'observed', limits);
|
|
979
|
+
if (!observed.ok) return observed;
|
|
980
|
+
const checkedAt = checkCheckedAt(raw.checked_at);
|
|
981
|
+
if (!checkedAt.ok) return checkedAt;
|
|
982
|
+
|
|
983
|
+
// 字节数按**规范化后的证据**算,不按调用方给的原样对象:原样对象里可能夹着非 JSON 值,
|
|
984
|
+
// `bytesOf` 会兜成 Infinity(加固前这里就是这么来的 —— 一个夹带 BigInt 的 checked_at
|
|
985
|
+
// 会让回执自报 Infinity 字节)。现在每个字段都已单独验过,这一份一定可序列化;
|
|
986
|
+
// 仍按规范化后的对象算,是为了让"落库的那份"和"计字节的那份"**始终是同一个对象**。
|
|
987
|
+
const value = {
|
|
988
|
+
state,
|
|
989
|
+
requested_source: requested.value,
|
|
990
|
+
actual_source: actual.value,
|
|
991
|
+
source_hash: sourceHash.value,
|
|
992
|
+
locator: locator.locator,
|
|
993
|
+
expected: expected.value,
|
|
994
|
+
observed: observed.value,
|
|
995
|
+
comparison: comparison.value,
|
|
996
|
+
reason: reason.value,
|
|
997
|
+
checked_at: checkedAt.value,
|
|
998
|
+
};
|
|
999
|
+
value.byte_size = bytesOf(value);
|
|
1000
|
+
return { ok: true, value };
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
/**
|
|
1004
|
+
* 给回执加一个**不可枚举**的兼容别名。为什么不可枚举:它不出现在 `Object.keys`/`JSON.stringify`
|
|
1005
|
+
* 里 ⇒ 不占响应字节(256 KiB 上限是按序列化后算的)、也不会被当成两个字段各自渲染;
|
|
1006
|
+
* 但 `res.items` 这种点读法照旧可用(与 memory.js 的 countView 同一手法)。
|
|
1007
|
+
* ⚠️ `search()` **不再**挂任何不可枚举的 `total` 别名:主线已把那个伪总数彻底删掉,
|
|
1008
|
+
* 检索回执只有 `count`(本页)/`nextOffset`/`truncated`(回归断言见测试 §8)。
|
|
1009
|
+
*/
|
|
1010
|
+
function alias(target, name, value) {
|
|
1011
|
+
Object.defineProperty(target, name, { value, enumerable: false, configurable: true, writable: false });
|
|
1012
|
+
return target;
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
/** 条目载荷里出现的"绝不允许从外部写入"的认证字段 —— 报错文案单列,便于验收点名。 */
|
|
1016
|
+
const FORGED_STATE_KEYS = Object.freeze(['checked_state', 'checked_at']);
|
|
1017
|
+
|
|
1018
|
+
/**
|
|
1019
|
+
* LibraryStore —— 图书馆存储层。所有公开操作**同步**返回规范 `{ok,...}` 记录。
|
|
1020
|
+
*
|
|
1021
|
+
* 构造失败(打不开 / 不是 SQLite 库 / 目录不可建)抛 `LibraryUnavailableError`:
|
|
1022
|
+
* "库不可用"与"零命中"必须是可区分的两件事(契约 §Store Surface: unavailable != zero results)。
|
|
1023
|
+
* 构造之后的**读写异常一律上抛**(不 catch-return-empty)—— 静默吞掉数据库错误会把故障
|
|
1024
|
+
* 伪装成"没有数据",那正是本次要避免的语义。
|
|
1025
|
+
*/
|
|
1026
|
+
export class LibraryStore {
|
|
1027
|
+
constructor(dbPath, limits) {
|
|
1028
|
+
if (typeof dbPath !== 'string' || dbPath.trim() === '') {
|
|
1029
|
+
throw new TypeError('LibraryStore(dbPath, limits):dbPath 必须是非空字符串');
|
|
1030
|
+
}
|
|
1031
|
+
this.path = dbPath;
|
|
1032
|
+
// ⚠️ 顺序照 memory.js 的实测教训:busy_timeout 必须在任何可能撞锁的语句**之前**
|
|
1033
|
+
// (DDL 之前、journal_mode 之前);否则非 WAL 库遇到写锁时会在毫秒级抛 database is locked。
|
|
1034
|
+
this.limits = normalizeLibraryLimits(limits);
|
|
1035
|
+
this.closed = false;
|
|
1036
|
+
try {
|
|
1037
|
+
if (dbPath !== ':memory:') mkdirFor(dbPath);
|
|
1038
|
+
this.db = new DatabaseSync(dbPath);
|
|
1039
|
+
} catch (e) {
|
|
1040
|
+
throw new LibraryUnavailableError(
|
|
1041
|
+
`图书馆库打不开(${dbPath}):${e?.message ?? e}`, e);
|
|
1042
|
+
}
|
|
1043
|
+
try {
|
|
1044
|
+
this.db.exec(`PRAGMA busy_timeout=${this.limits.sqliteBusyTimeoutMs};`);
|
|
1045
|
+
this.db.exec('PRAGMA foreign_keys=ON;');
|
|
1046
|
+
this.db.exec('PRAGMA journal_mode=WAL;');
|
|
1047
|
+
this.db.exec(SCHEMA_DDL);
|
|
1048
|
+
this._initializeSchema();
|
|
1049
|
+
this._prepare();
|
|
1050
|
+
} catch (e) {
|
|
1051
|
+
try { this.db.close(); } catch { /* 初始化没成功,关不关得掉都不改变结论 */ }
|
|
1052
|
+
this.closed = true;
|
|
1053
|
+
if (e instanceof LibraryUnavailableError) throw e;
|
|
1054
|
+
throw new LibraryUnavailableError(`图书馆库初始化失败(${dbPath}):${e?.message ?? e}`, e);
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
/** 建表 + 元数据自证(迁移后回读一次,失败即抛,不留"静默列缺失"的路)。 */
|
|
1059
|
+
_initializeSchema() {
|
|
1060
|
+
const cur = this.db.prepare('SELECT value FROM library_meta WHERE key=?').get('schema_version');
|
|
1061
|
+
if (cur === undefined) {
|
|
1062
|
+
this.db.prepare('INSERT INTO library_meta(key,value) VALUES(?,?)')
|
|
1063
|
+
.run('schema_version', String(LIBRARY_STORE_SCHEMA_VERSION));
|
|
1064
|
+
} else if (Number(cur.value) === 1 && LIBRARY_STORE_SCHEMA_VERSION === 2) {
|
|
1065
|
+
// v1 → v2:唯一的迁移路。显式 ALTER + 回读自证 + 成功后才前进版本;
|
|
1066
|
+
// 任何一步不成 ⇒ 抛(绝不带着"旧结构 + 新版本号"跑起来)。
|
|
1067
|
+
this._migrateV1toV2();
|
|
1068
|
+
} else if (Number(cur.value) !== LIBRARY_STORE_SCHEMA_VERSION) {
|
|
1069
|
+
throw new Error(`library 库 schema_version=${cur.value},本版期望 ${LIBRARY_STORE_SCHEMA_VERSION}`
|
|
1070
|
+
+ '(不认识的库不许就地改写,请另行处置)');
|
|
1071
|
+
}
|
|
1072
|
+
const cols = this.db.prepare('PRAGMA table_info(library_item)').all().map((c) => c.name);
|
|
1073
|
+
const want = ['id', 'lineage_id', 'revision', 'previous_id', 'title', 'body', 'category',
|
|
1074
|
+
'source_kind', 'source_ref', 'as_of', 'version', 'conditions', 'check_spec', 'content_hash',
|
|
1075
|
+
'checked_state', 'checked_at', 'superseded_by', 'withdrawn_at', 'withdrawn_reason',
|
|
1076
|
+
'created_at', 'updated_at', 'import_key'];
|
|
1077
|
+
const missing = want.filter((c) => !cols.includes(c));
|
|
1078
|
+
if (missing.length) throw new Error(`library_item 缺列:${missing.join(', ')}`);
|
|
1079
|
+
// 导入键的唯一性**由库自己保证**:部分唯一索引 —— `空串即无`,没有键的行不受约束。
|
|
1080
|
+
// 放在这里(而不是 SCHEMA_DDL)是因为 DDL 先于迁移执行,而 v1 老表上还没有这一列。
|
|
1081
|
+
this.db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS library_item_import_key
|
|
1082
|
+
ON library_item(import_key) WHERE import_key<>'';`);
|
|
1083
|
+
const ccols = this.db.prepare('PRAGMA table_info(library_check)').all().map((c) => c.name);
|
|
1084
|
+
const cwant = ['check_id', 'item_id', 'check_sequence', 'item_hash', 'state', 'requested_source',
|
|
1085
|
+
'actual_source', 'source_hash', 'locator', 'expected', 'observed', 'comparison', 'reason',
|
|
1086
|
+
'checked_at', 'adopted'];
|
|
1087
|
+
const cmissing = cwant.filter((c) => !ccols.includes(c));
|
|
1088
|
+
if (cmissing.length) throw new Error(`library_check 缺列:${cmissing.join(', ')}`);
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
/**
|
|
1092
|
+
* v1 → v2:`library_item` 加 `import_key`(纯增列)。
|
|
1093
|
+
* 幂等(先探列再 ALTER,重复跑不报错)+ 自证(回读不到该列 ⇒ 抛,版本不前进);
|
|
1094
|
+
* ALTER 与版本前进**同一个事务**,中途失败不留"半迁移却已前进"的组合。
|
|
1095
|
+
*/
|
|
1096
|
+
_migrateV1toV2() {
|
|
1097
|
+
this._tx(() => {
|
|
1098
|
+
const cols = this.db.prepare('PRAGMA table_info(library_item)').all().map((c) => c.name);
|
|
1099
|
+
if (!cols.includes('import_key')) {
|
|
1100
|
+
this.db.exec("ALTER TABLE library_item ADD COLUMN import_key TEXT NOT NULL DEFAULT ''");
|
|
1101
|
+
}
|
|
1102
|
+
const back = this.db.prepare('PRAGMA table_info(library_item)').all().map((c) => c.name);
|
|
1103
|
+
if (!back.includes('import_key')) {
|
|
1104
|
+
throw new Error('library 库 v1→v2 迁移未生效:library_item.import_key 仍缺(schema_version 不前进)');
|
|
1105
|
+
}
|
|
1106
|
+
this.db.prepare('UPDATE library_meta SET value=? WHERE key=?')
|
|
1107
|
+
.run(String(LIBRARY_STORE_SCHEMA_VERSION), 'schema_version');
|
|
1108
|
+
});
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
_prepare() {
|
|
1112
|
+
this.st = {
|
|
1113
|
+
insertItem: this.db.prepare(`INSERT INTO library_item
|
|
1114
|
+
(id, lineage_id, revision, previous_id, title, body, category, source_kind, source_ref, as_of,
|
|
1115
|
+
version, conditions, check_spec, content_hash, checked_state, checked_at, superseded_by,
|
|
1116
|
+
withdrawn_at, withdrawn_reason, created_at, updated_at, import_key)
|
|
1117
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,'',NULL,'',NULL,NULL,?,?,?)`),
|
|
1118
|
+
// 幂等查重:空串是"无键"(不受部分唯一索引约束),故显式排掉。
|
|
1119
|
+
byImportKey: this.db.prepare(
|
|
1120
|
+
"SELECT id FROM library_item WHERE import_key=? AND import_key<>'' LIMIT 1"),
|
|
1121
|
+
getItem: this.db.prepare('SELECT * FROM library_item WHERE id=?'),
|
|
1122
|
+
byLineage: this.db.prepare('SELECT * FROM library_item WHERE lineage_id=? ORDER BY revision ASC'),
|
|
1123
|
+
lineageHead: this.db.prepare(
|
|
1124
|
+
'SELECT * FROM library_item WHERE lineage_id=? ORDER BY revision DESC LIMIT 1'),
|
|
1125
|
+
sameContent: this.db.prepare(
|
|
1126
|
+
'SELECT id FROM library_item WHERE lineage_id=? AND content_hash=? AND revision<>? ORDER BY revision ASC LIMIT 1'),
|
|
1127
|
+
nextRevision: this.db.prepare(
|
|
1128
|
+
'SELECT COALESCE(MAX(revision),0)+1 AS rev FROM library_item WHERE lineage_id=?'),
|
|
1129
|
+
setSuperseded: this.db.prepare(
|
|
1130
|
+
"UPDATE library_item SET superseded_by=? WHERE id=? AND content_hash=? AND superseded_by='' AND withdrawn_at IS NULL"),
|
|
1131
|
+
withdraw: this.db.prepare(
|
|
1132
|
+
"UPDATE library_item SET withdrawn_at=?, withdrawn_reason=?, updated_at=? WHERE id=? AND content_hash=? AND superseded_by='' AND withdrawn_at IS NULL"),
|
|
1133
|
+
adoptCheck: this.db.prepare(
|
|
1134
|
+
"UPDATE library_item SET checked_state=?, checked_at=?, updated_at=? WHERE id=? AND content_hash=? AND superseded_by='' AND withdrawn_at IS NULL"),
|
|
1135
|
+
insertCheck: this.db.prepare(`INSERT INTO library_check
|
|
1136
|
+
(item_id, check_sequence, item_hash, state, requested_source, actual_source, source_hash,
|
|
1137
|
+
locator, expected, observed, comparison, reason, checked_at, adopted)
|
|
1138
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)`),
|
|
1139
|
+
nextSeq: this.db.prepare(
|
|
1140
|
+
'SELECT COALESCE(MAX(check_sequence),0)+1 AS seq FROM library_check WHERE item_id=?'),
|
|
1141
|
+
// ⚠️ 回读**必须按 (item_id, check_sequence) 两把钥匙**:check_sequence 是**每条目独立**的
|
|
1142
|
+
// 序号(契约:"Each attempt has an ordered sequence"),不是全局唯一 —— 只按它去 SELECT *
|
|
1143
|
+
// 会撞到**别的条目**的同号行(实测:第二个条目的"第 1 次核对"回读到第一个条目的行)。
|
|
1144
|
+
// 全局唯一那把钥匙是 check_id(AUTOINCREMENT),不对外暴露。
|
|
1145
|
+
seqRow: this.db.prepare('SELECT * FROM library_check WHERE item_id=? AND check_sequence=?'),
|
|
1146
|
+
listChecks: this.db.prepare(
|
|
1147
|
+
'SELECT * FROM library_check WHERE item_id=? ORDER BY check_sequence ASC LIMIT ? OFFSET ?'),
|
|
1148
|
+
countChecks: this.db.prepare('SELECT COUNT(*) AS n FROM library_check WHERE item_id=?'),
|
|
1149
|
+
allOfLineageForState: this.db.prepare(
|
|
1150
|
+
'SELECT id, superseded_by, withdrawn_at FROM library_item WHERE lineage_id=?'),
|
|
1151
|
+
stateCounts: this.db.prepare(`SELECT
|
|
1152
|
+
COALESCE(SUM(CASE WHEN superseded_by='' AND withdrawn_at IS NULL THEN 1 ELSE 0 END),0) AS active,
|
|
1153
|
+
COALESCE(SUM(CASE WHEN superseded_by<>'' THEN 1 ELSE 0 END),0) AS superseded,
|
|
1154
|
+
COALESCE(SUM(CASE WHEN superseded_by='' AND withdrawn_at IS NOT NULL THEN 1 ELSE 0 END),0) AS withdrawn,
|
|
1155
|
+
COALESCE(MAX(revision),0) AS max_revision
|
|
1156
|
+
FROM library_item`),
|
|
1157
|
+
countsForLineage: this.db.prepare(`SELECT
|
|
1158
|
+
COALESCE(SUM(CASE WHEN superseded_by='' AND withdrawn_at IS NULL THEN 1 ELSE 0 END),0) AS active,
|
|
1159
|
+
COALESCE(SUM(CASE WHEN superseded_by<>'' THEN 1 ELSE 0 END),0) AS superseded,
|
|
1160
|
+
COALESCE(SUM(CASE WHEN superseded_by='' AND withdrawn_at IS NOT NULL THEN 1 ELSE 0 END),0) AS withdrawn
|
|
1161
|
+
FROM library_item WHERE lineage_id=?`),
|
|
1162
|
+
countChecksForLineage: this.db.prepare(
|
|
1163
|
+
'SELECT COUNT(*) AS n FROM library_check WHERE item_id IN (SELECT id FROM library_item WHERE lineage_id=?)'),
|
|
1164
|
+
checkCountsByItem: this.db.prepare(
|
|
1165
|
+
'SELECT item_id, COUNT(*) AS n FROM library_check WHERE item_id IN'
|
|
1166
|
+
+ ' (SELECT id FROM library_item WHERE lineage_id=?) GROUP BY item_id'),
|
|
1167
|
+
};
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
/** 事务:先获取跨连接写锁,再读状态/写入;异常回滚后上抛,不吞失败。 */
|
|
1171
|
+
_tx(fn) {
|
|
1172
|
+
this.db.exec('BEGIN IMMEDIATE');
|
|
1173
|
+
try {
|
|
1174
|
+
const out = fn();
|
|
1175
|
+
this.db.exec('COMMIT');
|
|
1176
|
+
return out;
|
|
1177
|
+
} catch (e) {
|
|
1178
|
+
try { this.db.exec('ROLLBACK'); } catch { /* 回滚失败不掩盖原始异常 */ }
|
|
1179
|
+
throw e;
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
/** 关闭后的统一回执:`reason` 是机器短码 `closed`,人话进 `message`,并保留 `code:'closed'`。 */
|
|
1184
|
+
_closedResponse() {
|
|
1185
|
+
return fail('closed', null, { message: '图书馆库已关闭', code: 'closed' });
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
_assertOpen() {
|
|
1189
|
+
if (this.closed) throw new LibraryUnavailableError('图书馆库已关闭');
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
/** 新建条目:新建版本号=1、lineage=自身 id,认证状态一律为空(只能由 recordCheck 写)。 */
|
|
1193
|
+
create(item) {
|
|
1194
|
+
if (this.closed) return this._closedResponse();
|
|
1195
|
+
const bad = forgeryGuard(item);
|
|
1196
|
+
if (bad) return bad;
|
|
1197
|
+
const norm = normalizeItemInput(item, this.limits, { partial: false });
|
|
1198
|
+
if (!norm.ok) return norm;
|
|
1199
|
+
const value = norm.value;
|
|
1200
|
+
const id = LIBRARY_ID_PREFIX + randomUUID();
|
|
1201
|
+
const at = nowIso();
|
|
1202
|
+
// 查重与写入**必须在同一个写事务里**(BEGIN IMMEDIATE):先查后写之间若有第二个连接提交,
|
|
1203
|
+
// 两笔都会过预检 —— 那样"幂等"就只是"大多数时候成立"。
|
|
1204
|
+
const out = this._tx(() => {
|
|
1205
|
+
if (value.import_key) {
|
|
1206
|
+
const dup = this.st.byImportKey.get(value.import_key);
|
|
1207
|
+
if (dup) return { dup: dup.id };
|
|
1208
|
+
}
|
|
1209
|
+
try {
|
|
1210
|
+
this.st.insertItem.run(
|
|
1211
|
+
id, id, 1, '', value.title, value.body, value.category, value.source_kind, value.source_ref,
|
|
1212
|
+
value.as_of, value.version, jsonOrNull(value.conditions), jsonOrNull(value.check_spec),
|
|
1213
|
+
libraryContentHash(value), at, at, value.import_key ?? '',
|
|
1214
|
+
);
|
|
1215
|
+
} catch (e) {
|
|
1216
|
+
// 库里的部分唯一索引是最后一道闸(并发下两笔同时过预检才撞到这里):
|
|
1217
|
+
// 归成同一个 reason,不外泄 SQLite 错误码 —— 调用方只需知道"这个键已经有了"。
|
|
1218
|
+
if (value.import_key && /UNIQUE|library_item_import_key/i.test(String(e?.message))) {
|
|
1219
|
+
return { dup: this.st.byImportKey.get(value.import_key)?.id ?? '' };
|
|
1220
|
+
}
|
|
1221
|
+
throw e;
|
|
1222
|
+
}
|
|
1223
|
+
return { record: rowToRecord(this.st.getItem.get(id)) };
|
|
1224
|
+
});
|
|
1225
|
+
if (out.dup !== undefined) {
|
|
1226
|
+
return fail('duplicate_import', null, {
|
|
1227
|
+
message: `这个导入键已经有条目了(幂等:同源同定位只进一次;撤下/被取代的行也算占用):${value.import_key}`,
|
|
1228
|
+
duplicate_of: out.dup || null,
|
|
1229
|
+
});
|
|
1230
|
+
}
|
|
1231
|
+
return { ok: true, item: out.record, byte_size: value.byte_size };
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
/**
|
|
1235
|
+
* 修订:必须拿"当前 active 最新版 + 它观测到的 content_hash"。新版本**换新 id**、
|
|
1236
|
+
* previous_id 指向被取代的旧版,旧版 superseded_by 只追不替 —— 两笔写在同一事务里,
|
|
1237
|
+
* 所以 superseded_by 结构上不可能指向"不是刚建出来的下一版",也就形不成环。
|
|
1238
|
+
* 认证状态**重置**(新内容不该继承旧认证)。
|
|
1239
|
+
*/
|
|
1240
|
+
revise(id, item, expectedHash) {
|
|
1241
|
+
if (this.closed) return this._closedResponse();
|
|
1242
|
+
const canonicalId = parseLibraryId(id);
|
|
1243
|
+
if (!canonicalId) return fail('invalid_argument', null, { message: `id 形状非法(期望 ${LIBRARY_ID_PREFIX}<uuid>):${JSON.stringify(id)}` });
|
|
1244
|
+
if (typeof expectedHash !== 'string' || expectedHash.trim() === '') {
|
|
1245
|
+
return fail('invalid_argument', null, { message: 'expectedHash 必填(修订必须带上观测到的 content_hash,防陈旧覆盖)' });
|
|
1246
|
+
}
|
|
1247
|
+
const bad = forgeryGuard(item);
|
|
1248
|
+
if (bad) return bad;
|
|
1249
|
+
// Read state/hash only after acquiring the writer lock. A second connection
|
|
1250
|
+
// may commit between a preflight read and BEGIN; synchronous JS is no lock.
|
|
1251
|
+
return this._tx(() => {
|
|
1252
|
+
const current = this.st.getItem.get(canonicalId);
|
|
1253
|
+
if (!current) return fail('not_found', null, { message: `条目不存在:${canonicalId}` });
|
|
1254
|
+
if (current.withdrawn_at) return fail('not_active', null, { message: '条目已撤下,不能修订(撤下是终态留痕)' });
|
|
1255
|
+
if (current.superseded_by) return fail('not_active', null, { message: '条目已被新版取代,只能修订当前最新版' });
|
|
1256
|
+
if (!hashEqual(expectedHash, current.content_hash)) {
|
|
1257
|
+
return fail('hash_mismatch', 'stale', {
|
|
1258
|
+
message: `expectedHash 与当前 content_hash 不一致(陈旧观测):期望 ${current.content_hash},`
|
|
1259
|
+
+ `收到 ${String(expectedHash).trim()}`,
|
|
1260
|
+
current_hash: current.content_hash,
|
|
1261
|
+
});
|
|
1262
|
+
}
|
|
1263
|
+
const currentRecord = rowToRecord(current);
|
|
1264
|
+
const norm = normalizeItemInput(item, this.limits, { partial: true, fallback: currentRecord });
|
|
1265
|
+
if (!norm.ok) return norm;
|
|
1266
|
+
const value = norm.value;
|
|
1267
|
+
const newHash = libraryContentHash(value);
|
|
1268
|
+
if (newHash === current.content_hash) {
|
|
1269
|
+
return fail('no_change', null, {
|
|
1270
|
+
message: '内容与当前版完全一致,没有产生新修订(避免同一事务里造出重复版本)',
|
|
1271
|
+
content_hash: current.content_hash,
|
|
1272
|
+
});
|
|
1273
|
+
}
|
|
1274
|
+
const twin = this.st.sameContent.get(current.lineage_id, newHash, current.revision);
|
|
1275
|
+
if (twin) {
|
|
1276
|
+
return fail('duplicate_content', null, { message: `该内容已是本谱系的另一版(${twin.id}),不需要重复修订` });
|
|
1277
|
+
}
|
|
1278
|
+
const newId = LIBRARY_ID_PREFIX + randomUUID();
|
|
1279
|
+
const at = nowIso();
|
|
1280
|
+
const rev = Number(this.st.nextRevision.get(current.lineage_id).rev);
|
|
1281
|
+
this.st.insertItem.run(
|
|
1282
|
+
newId, current.lineage_id, rev, canonicalId, value.title, value.body, value.category,
|
|
1283
|
+
value.source_kind, value.source_ref, value.as_of, value.version, jsonOrNull(value.conditions),
|
|
1284
|
+
jsonOrNull(value.check_spec), newHash, at, at,
|
|
1285
|
+
// 修订**不继承**导入键(空串 = 无键):键标识的是"这一次导入",不是谱系;
|
|
1286
|
+
// 继承的话新版本会被旧版占着的同一个键挡住(而且那是唯一索引,库层面也过不去)。
|
|
1287
|
+
'',
|
|
1288
|
+
);
|
|
1289
|
+
const changed = this.st.setSuperseded.run(newId, canonicalId, current.content_hash);
|
|
1290
|
+
if (Number(changed.changes) !== 1) throw new Error('修订目标在事务内失去 active/hash 条件,已回滚');
|
|
1291
|
+
return {
|
|
1292
|
+
ok: true,
|
|
1293
|
+
item: rowToRecord(this.st.getItem.get(newId)),
|
|
1294
|
+
previous: rowToRecord(this.st.getItem.get(canonicalId)),
|
|
1295
|
+
byte_size: value.byte_size,
|
|
1296
|
+
};
|
|
1297
|
+
});
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
/** 撤下:reason 与 expectedHash **都必填**;只改状态留痕,不物理删除(历史与核对记录都留着)。 */
|
|
1301
|
+
withdraw(id, reason, expectedHash) {
|
|
1302
|
+
if (this.closed) return this._closedResponse();
|
|
1303
|
+
const canonicalId = parseLibraryId(id);
|
|
1304
|
+
if (!canonicalId) return fail('invalid_argument', null, { message: `id 形状非法(期望 ${LIBRARY_ID_PREFIX}<uuid>):${JSON.stringify(id)}` });
|
|
1305
|
+
if (typeof reason !== 'string' || reason.trim() === '') {
|
|
1306
|
+
return fail('invalid_argument', null, { message: 'withdraw 必须给出非空 reason(撤下不是静默删除)' });
|
|
1307
|
+
}
|
|
1308
|
+
if (utf8Bytes(reason) > WITHDRAW_REASON_MAX_BYTES) {
|
|
1309
|
+
return fail('invalid_argument', null, { message: `reason 超过 ${WITHDRAW_REASON_MAX_BYTES} 字节` });
|
|
1310
|
+
}
|
|
1311
|
+
if (typeof expectedHash !== 'string' || expectedHash.trim() === '') {
|
|
1312
|
+
return fail('invalid_argument', null, { message: 'withdraw 必须带上观测到的 content_hash' });
|
|
1313
|
+
}
|
|
1314
|
+
return this._tx(() => {
|
|
1315
|
+
const current = this.st.getItem.get(canonicalId);
|
|
1316
|
+
if (!current) return fail('not_found', null, { message: `条目不存在:${canonicalId}` });
|
|
1317
|
+
if (current.withdrawn_at) return fail('not_active', null, { message: '条目已经撤下过了(撤下不重复执行)' });
|
|
1318
|
+
if (current.superseded_by) return fail('not_active', null, { message: '条目已被新版取代;要撤下请撤下当前最新版' });
|
|
1319
|
+
if (!hashEqual(expectedHash, current.content_hash)) {
|
|
1320
|
+
return fail('hash_mismatch', 'stale', {
|
|
1321
|
+
message: `expectedHash 与当前 content_hash 不一致(陈旧观测):期望 ${current.content_hash},`
|
|
1322
|
+
+ `收到 ${String(expectedHash).trim()}`,
|
|
1323
|
+
current_hash: current.content_hash,
|
|
1324
|
+
});
|
|
1325
|
+
}
|
|
1326
|
+
const at = nowIso();
|
|
1327
|
+
const changed = this.st.withdraw.run(at, reason.trim(), at, canonicalId, current.content_hash);
|
|
1328
|
+
if (Number(changed.changes) !== 1) throw new Error('撤下目标在事务内失去 active/hash 条件,已回滚');
|
|
1329
|
+
return { ok: true, item: rowToRecord(this.st.getItem.get(canonicalId)) };
|
|
1330
|
+
});
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
/** 单条读:任何状态都读得到(撤下/被取代是留痕,不是消失);不存在 ⇒ not_found 且 found:false。 */
|
|
1334
|
+
get(id) {
|
|
1335
|
+
if (this.closed) return this._closedResponse();
|
|
1336
|
+
const canonicalId = parseLibraryId(id);
|
|
1337
|
+
if (!canonicalId) return fail('invalid_argument', null, { message: `id 形状非法(期望 ${LIBRARY_ID_PREFIX}<uuid>):${JSON.stringify(id)}` });
|
|
1338
|
+
const row = this.st.getItem.get(canonicalId);
|
|
1339
|
+
if (!row) return fail('not_found', null, { message: `条目不存在:${canonicalId}`, found: false });
|
|
1340
|
+
return { ok: true, found: true, item: rowToRecord(row) };
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
/** 谱系历史:全部版本(含被取代/已撤下)+ 每条核对数 + 有界分页。 */
|
|
1344
|
+
history(id, opts) {
|
|
1345
|
+
if (this.closed) return this._closedResponse();
|
|
1346
|
+
const canonicalId = parseLibraryId(id);
|
|
1347
|
+
if (!canonicalId) return fail('invalid_argument', null, { message: `id 形状非法(期望 ${LIBRARY_ID_PREFIX}<uuid>):${JSON.stringify(id)}` });
|
|
1348
|
+
const paging = resolvePaging(opts, this.limits);
|
|
1349
|
+
if (!paging.ok) return paging.response;
|
|
1350
|
+
const row = this.st.getItem.get(canonicalId);
|
|
1351
|
+
if (!row) return fail('not_found', null, { message: `条目不存在:${canonicalId}`, found: false });
|
|
1352
|
+
const all = this.st.byLineage.all(row.lineage_id).map(rowToRecord);
|
|
1353
|
+
const counts = this.st.countsForLineage.get(row.lineage_id);
|
|
1354
|
+
const checksTotal = Number(this.st.countChecksForLineage.get(row.lineage_id).n);
|
|
1355
|
+
const currentId = currentOf(all)?.id ?? null;
|
|
1356
|
+
const checkCount = new Map();
|
|
1357
|
+
for (const c of this.st.checkCountsByItem.all(row.lineage_id)) {
|
|
1358
|
+
checkCount.set(c.item_id, Number(c.n));
|
|
1359
|
+
}
|
|
1360
|
+
const revisions = all.map((it) => ({
|
|
1361
|
+
id: it.id,
|
|
1362
|
+
revision: it.revision,
|
|
1363
|
+
previous_id: it.previous_id,
|
|
1364
|
+
state: it.state,
|
|
1365
|
+
superseded_by: it.superseded_by,
|
|
1366
|
+
withdrawn_at: it.withdrawn_at,
|
|
1367
|
+
withdrawn_reason: it.withdrawn_reason,
|
|
1368
|
+
content_hash: it.content_hash,
|
|
1369
|
+
title: it.title,
|
|
1370
|
+
checked_state: it.checked_state,
|
|
1371
|
+
checked_at: it.checked_at,
|
|
1372
|
+
created_at: it.created_at,
|
|
1373
|
+
updated_at: it.updated_at,
|
|
1374
|
+
check_count: checkCount.get(it.id) ?? 0,
|
|
1375
|
+
is_current: it.id === currentId,
|
|
1376
|
+
}));
|
|
1377
|
+
const slice = revisions.slice(paging.offset, paging.offset + paging.limit);
|
|
1378
|
+
const hasMore = paging.offset + slice.length < revisions.length;
|
|
1379
|
+
const { kept, capped } = capByBytes(slice, this.limits.resultMaxBytes, (k, wasCapped) => ({
|
|
1380
|
+
ok: true,
|
|
1381
|
+
found: true,
|
|
1382
|
+
item_id: canonicalId,
|
|
1383
|
+
lineage_id: row.lineage_id,
|
|
1384
|
+
revision_count: revisions.length,
|
|
1385
|
+
check_total: checksTotal,
|
|
1386
|
+
counts: { active: Number(counts.active), superseded: Number(counts.superseded), withdrawn: Number(counts.withdrawn) },
|
|
1387
|
+
offset: paging.offset,
|
|
1388
|
+
limit: paging.limit,
|
|
1389
|
+
count: k.length,
|
|
1390
|
+
nextOffset: (wasCapped || hasMore) && k.length > 0 ? paging.offset + k.length : null,
|
|
1391
|
+
truncated: wasCapped || hasMore,
|
|
1392
|
+
revisions: k,
|
|
1393
|
+
}));
|
|
1394
|
+
// 兼容别名(不可枚举):主线读 `history().items` 可用,但序列化里只有 `revisions` 一份数据。
|
|
1395
|
+
const out = { ...kept, byte_size: bytesOf(kept) };
|
|
1396
|
+
return alias(out, 'items', out.revisions);
|
|
1397
|
+
}
|
|
1398
|
+
/**
|
|
1399
|
+
* 检索:默认**只看 active 最新版**;多词 = 空白切分后 AND(title/body/category 任一中出现即可);
|
|
1400
|
+
* `% _ \` 一律按字面量(`LIKE ? ESCAPE '\'`);分页有界,响应受 resultMaxBytes 约束。
|
|
1401
|
+
* 空查询是**参数错误**(不是"零命中")—— 把用错参数伪装成没数据是最误导的一类回执。
|
|
1402
|
+
*/
|
|
1403
|
+
search(query) {
|
|
1404
|
+
if (this.closed) return this._closedResponse();
|
|
1405
|
+
const raw = query === undefined || query === null ? {} : query;
|
|
1406
|
+
if (!isPlainObject(raw)) return fail('invalid_type', null, { message: 'search 参数必须是对象:{q, limit?, offset?, includeInactive?}' });
|
|
1407
|
+
const unknown = Object.keys(raw).filter((k) => !['q', 'limit', 'offset', 'includeInactive'].includes(k));
|
|
1408
|
+
if (unknown.length) return fail('unknown_field', null, { message: `search 未知参数:${unknown.join(', ')}` });
|
|
1409
|
+
if (typeof raw.q !== 'string') return fail('invalid_query', null, { message: 'q 必填(字符串)' });
|
|
1410
|
+
const q = raw.q.trim();
|
|
1411
|
+
if (q === '') return fail('invalid_query', null, { message: 'q 不能为空(空查询会返回全部条目,这不是检索)' });
|
|
1412
|
+
if (q.length > this.limits.queryMaxChars) {
|
|
1413
|
+
return fail('query_too_long', null, { message: `q 长度 ${q.length} 字符,超过 ${this.limits.queryMaxChars} 字符上限` });
|
|
1414
|
+
}
|
|
1415
|
+
const limit = raw.limit === undefined ? this.limits.searchDefaultLimit : toInt(raw.limit);
|
|
1416
|
+
if (limit === undefined || limit < 1 || limit > this.limits.searchMaxLimit) {
|
|
1417
|
+
return fail('invalid_limit', null, { message: `limit 必须是 1..${this.limits.searchMaxLimit} 的整数(默认 ${this.limits.searchDefaultLimit})` });
|
|
1418
|
+
}
|
|
1419
|
+
const offset = raw.offset === undefined ? 0 : toInt(raw.offset);
|
|
1420
|
+
if (offset === undefined || offset < 0) return fail('invalid_offset', null, { message: 'offset 必须是 >= 0 的整数' });
|
|
1421
|
+
if (raw.includeInactive !== undefined && typeof raw.includeInactive !== 'boolean') {
|
|
1422
|
+
return fail('invalid_argument', null, { message: 'includeInactive 只能是布尔值' });
|
|
1423
|
+
}
|
|
1424
|
+
const includeInactive = raw.includeInactive === true;
|
|
1425
|
+
const words = q.split(/\s+/).filter(Boolean);
|
|
1426
|
+
if (words.length === 0) return fail('invalid_query', null, { message: 'q 里没有可检索的词' });
|
|
1427
|
+
const clauses = words.map(() => "(title LIKE ? ESCAPE '\\' OR body LIKE ? ESCAPE '\\' OR category LIKE ? ESCAPE '\\')");
|
|
1428
|
+
const where = [includeInactive ? '1=1' : "superseded_by='' AND withdrawn_at IS NULL", ...clauses].join(' AND ');
|
|
1429
|
+
const args = [];
|
|
1430
|
+
for (const w of words) {
|
|
1431
|
+
const pat = `%${escapeLike(w)}%`;
|
|
1432
|
+
args.push(pat, pat, pat);
|
|
1433
|
+
}
|
|
1434
|
+
// 多取 1 条只为判定"后面还有没有",不对外暴露 "总数" —— 契约禁止误导性 total。
|
|
1435
|
+
const rows = this.db.prepare(
|
|
1436
|
+
`SELECT * FROM library_item WHERE ${where} ORDER BY revision DESC, created_at DESC, id ASC LIMIT ? OFFSET ?`,
|
|
1437
|
+
).all(...args, limit + 1, offset);
|
|
1438
|
+
const moreInDb = rows.length > limit;
|
|
1439
|
+
const items = rows.slice(0, limit).map(rowToRecord);
|
|
1440
|
+
const hasMore = moreInDb;
|
|
1441
|
+
const { kept, capped } = capByBytes(items, this.limits.resultMaxBytes, (k, wasCapped) => ({
|
|
1442
|
+
ok: true,
|
|
1443
|
+
query: q,
|
|
1444
|
+
words,
|
|
1445
|
+
includeInactive,
|
|
1446
|
+
items: k,
|
|
1447
|
+
count: k.length,
|
|
1448
|
+
offset,
|
|
1449
|
+
limit,
|
|
1450
|
+
nextOffset: (wasCapped || hasMore) && k.length > 0 ? offset + k.length : null,
|
|
1451
|
+
truncated: wasCapped || hasMore,
|
|
1452
|
+
}));
|
|
1453
|
+
// 单条就超上限时 kept 为空:这时必须让 nextOffset 指回同一个 offset(而不是 null),
|
|
1454
|
+
// 否则"换个更小的 limit 重试"这条出路被回执自己堵死。
|
|
1455
|
+
if (kept.count === 0 && rows.length > 0) kept.nextOffset = offset;
|
|
1456
|
+
const out = { ...kept, byte_size: bytesOf(kept) };
|
|
1457
|
+
return out;
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
/**
|
|
1461
|
+
* 浏览:**不搜也能翻** —— 按 `category` / `source_kind` 两个分组维度列条目。
|
|
1462
|
+
* 与 `search` 的分工(这不是它的重复实现):
|
|
1463
|
+
* · `search` 要关键词,且**默认只看 active 最新版**(`includeInactive` 是存储层内部扩展,面不外露);
|
|
1464
|
+
* · `browse` 不要关键词,且**看全部** —— 撤下/被取代的行走的是留痕不是消失,它们也是馆藏
|
|
1465
|
+
* (条目自带 `state`,要区分由调用方自己看)。
|
|
1466
|
+
* 回执**不给 total**(契约禁止误导性 total):只给本页 + `nextOffset` + `truncated`;
|
|
1467
|
+
* 另附一张 `groups` 分组计数表(**与筛选无关,恒定全量**)—— 浏览树的组节点靠它,
|
|
1468
|
+
* 否则每翻一页骨架都会跟着变。
|
|
1469
|
+
*/
|
|
1470
|
+
browse(query) {
|
|
1471
|
+
if (this.closed) return this._closedResponse();
|
|
1472
|
+
const raw = query === undefined || query === null ? {} : query;
|
|
1473
|
+
if (!isPlainObject(raw)) return fail('invalid_type', null, { message: 'browse 参数必须是对象:{category?, source_kind?, offset?, limit?}' });
|
|
1474
|
+
const unknown = Object.keys(raw).filter((k) => !['category', 'source_kind', 'offset', 'limit', 'fields'].includes(k));
|
|
1475
|
+
if (unknown.length) return fail('unknown_field', null, { message: `browse 未知参数:${unknown.join(', ')}` });
|
|
1476
|
+
if (raw.category !== undefined && !LIBRARY_CATEGORIES.includes(raw.category)) {
|
|
1477
|
+
return fail('invalid_argument', null, { message: `category 只能是 ${LIBRARY_CATEGORIES.join(' / ')}(实际 ${JSON.stringify(raw.category)})` });
|
|
1478
|
+
}
|
|
1479
|
+
if (raw.source_kind !== undefined && !LIBRARY_SOURCE_KINDS.includes(raw.source_kind)) {
|
|
1480
|
+
return fail('invalid_argument', null, { message: `source_kind 只能是 ${LIBRARY_SOURCE_KINDS.join(' / ')}(实际 ${JSON.stringify(raw.source_kind)})` });
|
|
1481
|
+
}
|
|
1482
|
+
// `fields`:'full'(默认,整条含正文)/ 'row'(列表行,**不带正文**)。列表行存在的理由是字节预算:
|
|
1483
|
+
// 单条正文上限 64 KiB,而一次响应总共只有 resultMaxBytes(256 KiB) ⇒ 200 条带正文必然被截,
|
|
1484
|
+
// 于是"翻到底再加载"会变成"翻到底只拿到几条"。列表只要标题与标记,正文由详情面按 id 单取。
|
|
1485
|
+
const fields = raw.fields === undefined ? 'full' : raw.fields;
|
|
1486
|
+
if (fields !== 'full' && fields !== 'row') {
|
|
1487
|
+
return fail('invalid_argument', null, { message: `fields 只能是 full / row(实际 ${JSON.stringify(raw.fields)})` });
|
|
1488
|
+
}
|
|
1489
|
+
const limit = raw.limit === undefined ? this.limits.browseDefaultLimit : toInt(raw.limit);
|
|
1490
|
+
if (limit === undefined || limit < 1 || limit > this.limits.browseMaxLimit) {
|
|
1491
|
+
return fail('invalid_limit', null, { message: `limit 必须是 1..${this.limits.browseMaxLimit} 的整数(默认 ${this.limits.browseDefaultLimit})` });
|
|
1492
|
+
}
|
|
1493
|
+
const offset = raw.offset === undefined ? 0 : toInt(raw.offset);
|
|
1494
|
+
if (offset === undefined || offset < 0) return fail('invalid_offset', null, { message: 'offset 必须是 >= 0 的整数' });
|
|
1495
|
+
const where = [];
|
|
1496
|
+
const args = [];
|
|
1497
|
+
if (raw.category !== undefined) { where.push('category=?'); args.push(raw.category); }
|
|
1498
|
+
if (raw.source_kind !== undefined) { where.push('source_kind=?'); args.push(raw.source_kind); }
|
|
1499
|
+
// 多取 1 条只为判定"后面还有没有"(与 search 同一手法):total 从缺,分页靠 nextOffset。
|
|
1500
|
+
const rows = this.db.prepare(
|
|
1501
|
+
`SELECT * FROM library_item${where.length ? ` WHERE ${where.join(' AND ')}` : ''}`
|
|
1502
|
+
+ ' ORDER BY category ASC, source_kind ASC, revision DESC, created_at DESC, id ASC LIMIT ? OFFSET ?',
|
|
1503
|
+
).all(...args, limit + 1, offset);
|
|
1504
|
+
const hasMore = rows.length > limit;
|
|
1505
|
+
const items = rows.slice(0, limit).map((row) => {
|
|
1506
|
+
const rec = rowToRecord(row);
|
|
1507
|
+
if (fields !== 'row') return rec;
|
|
1508
|
+
const body = rec.body;
|
|
1509
|
+
const out = { ...rec };
|
|
1510
|
+
delete out.body;
|
|
1511
|
+
out.body_chars = typeof body === 'string' ? body.length : 0;
|
|
1512
|
+
return out;
|
|
1513
|
+
});
|
|
1514
|
+
const groups = this.db.prepare(
|
|
1515
|
+
'SELECT category, source_kind, COUNT(*) AS n,'
|
|
1516
|
+
+ " SUM(CASE WHEN superseded_by='' AND withdrawn_at IS NULL THEN 1 ELSE 0 END) AS active_n"
|
|
1517
|
+
+ ' FROM library_item GROUP BY category, source_kind ORDER BY category ASC, source_kind ASC',
|
|
1518
|
+
).all().map((g) => ({ category: g.category, source_kind: g.source_kind, count: Number(g.n), active: Number(g.active_n) }));
|
|
1519
|
+
const { kept, capped } = capByBytes(items, this.limits.resultMaxBytes, (k, wasCapped) => ({
|
|
1520
|
+
ok: true,
|
|
1521
|
+
category: raw.category === undefined ? null : raw.category,
|
|
1522
|
+
source_kind: raw.source_kind === undefined ? null : raw.source_kind,
|
|
1523
|
+
fields,
|
|
1524
|
+
groups,
|
|
1525
|
+
items: k,
|
|
1526
|
+
count: k.length,
|
|
1527
|
+
offset,
|
|
1528
|
+
limit,
|
|
1529
|
+
nextOffset: (wasCapped || hasMore) && k.length > 0 ? offset + k.length : null,
|
|
1530
|
+
truncated: wasCapped || hasMore,
|
|
1531
|
+
}));
|
|
1532
|
+
// 同 search:单条就超上限时"换个更小的 limit 重试"这条出路不许被回执自己堵死。
|
|
1533
|
+
if (kept.count === 0 && rows.length > 0) kept.nextOffset = offset;
|
|
1534
|
+
const out = { ...kept, byte_size: bytesOf(kept) };
|
|
1535
|
+
return out;
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
/** 库级画像:各状态条数 + 核对数 + 限额(供 /library/status 用)。**不报 total 用于检索分页**。 */
|
|
1539
|
+
stats() {
|
|
1540
|
+
if (this.closed) return this._closedResponse();
|
|
1541
|
+
const c = this.st.stateCounts.get();
|
|
1542
|
+
const checks = Number(this.db.prepare('SELECT COUNT(*) AS n FROM library_check').get().n);
|
|
1543
|
+
const lineages = Number(this.db.prepare('SELECT COUNT(*) AS n FROM (SELECT DISTINCT lineage_id FROM library_item)').get().n);
|
|
1544
|
+
const adopted = Number(this.db.prepare('SELECT COUNT(*) AS n FROM library_check WHERE adopted=1').get().n);
|
|
1545
|
+
return {
|
|
1546
|
+
ok: true,
|
|
1547
|
+
items: {
|
|
1548
|
+
total: Number(c.active) + Number(c.superseded) + Number(c.withdrawn),
|
|
1549
|
+
active: Number(c.active),
|
|
1550
|
+
superseded: Number(c.superseded),
|
|
1551
|
+
withdrawn: Number(c.withdrawn),
|
|
1552
|
+
lineages,
|
|
1553
|
+
max_revision: Number(c.max_revision),
|
|
1554
|
+
},
|
|
1555
|
+
checks: { total: checks, adopted },
|
|
1556
|
+
limits: { ...this.limits },
|
|
1557
|
+
schema_version: LIBRARY_STORE_SCHEMA_VERSION,
|
|
1558
|
+
};
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
/**
|
|
1562
|
+
* 记录一次**已完成**的核对(裁判员调用;本方法不对模型开放)。
|
|
1563
|
+
* - 每一次完成都留档(check_sequence 单调递增,按条目独立编排);
|
|
1564
|
+
* - 只有"条目仍是 active 最新版"且"哈希与本次核对对象一致"才**采纳**为最新认证;
|
|
1565
|
+
* - 陈旧证据返回 adopted:false(不报错)—— 它仍然是历史证据,只是不再代表当前内容;
|
|
1566
|
+
* - 条目不存在:证据照留(审计用),adopted:false。绝不吞掉写库异常。
|
|
1567
|
+
*/
|
|
1568
|
+
recordCheck(itemId, hash, evidence) {
|
|
1569
|
+
if (this.closed) return this._closedResponse();
|
|
1570
|
+
const canonicalId = parseLibraryId(itemId);
|
|
1571
|
+
if (!canonicalId) return fail('invalid_argument', null, { message: `itemId 形状非法(期望 ${LIBRARY_ID_PREFIX}<uuid>):${JSON.stringify(itemId)}` });
|
|
1572
|
+
if (typeof hash !== 'string' || !HASH_RE.test(hash.trim())) {
|
|
1573
|
+
return fail('invalid_argument', null, { message: 'hash 必须是 sha256:<64 位小写十六进制>(核对对象的内容摘要)' });
|
|
1574
|
+
}
|
|
1575
|
+
const wantHash = hash.trim();
|
|
1576
|
+
return this._tx(() => {
|
|
1577
|
+
const row = this.st.getItem.get(canonicalId);
|
|
1578
|
+
const spec = row ? (row.check_spec === null || row.check_spec === undefined
|
|
1579
|
+
? null : parseJsonColumn(row.check_spec, 'check_spec', canonicalId)) : null;
|
|
1580
|
+
const norm = normalizeEvidence(evidence, this.limits, spec);
|
|
1581
|
+
if (!norm.ok) return norm;
|
|
1582
|
+
const ev = norm.value;
|
|
1583
|
+
const eligible = Boolean(row) && !row.superseded_by && !row.withdrawn_at && hashEqual(wantHash, row.content_hash);
|
|
1584
|
+
const at = ev.checked_at;
|
|
1585
|
+
// Adoption and history share the same transaction. An inactive/stale row
|
|
1586
|
+
// still gets its completed evidence; a failed CAS never erases that record.
|
|
1587
|
+
const adopted = eligible && Number(this.st.adoptCheck.run(ev.state, at, at, canonicalId, wantHash).changes) === 1;
|
|
1588
|
+
const next = Number(this.st.nextSeq.get(canonicalId).seq);
|
|
1589
|
+
this.st.insertCheck.run(
|
|
1590
|
+
canonicalId, next, wantHash, ev.state, ev.requested_source, ev.actual_source, ev.source_hash,
|
|
1591
|
+
jsonOrNull(ev.locator), jsonOrNull(ev.expected), jsonOrNull(ev.observed), ev.comparison,
|
|
1592
|
+
ev.reason, at, adopted ? 1 : 0,
|
|
1593
|
+
);
|
|
1594
|
+
// Receipt is read under the same writer lock, never from a later state.
|
|
1595
|
+
const inserted = checkRowToRecord(this.st.seqRow.get(canonicalId, next));
|
|
1596
|
+
return {
|
|
1597
|
+
ok: true,
|
|
1598
|
+
adopted,
|
|
1599
|
+
reason: adopted ? null : (row ? 'stale_or_inactive' : 'item_missing'),
|
|
1600
|
+
item_id: canonicalId,
|
|
1601
|
+
check_sequence: next,
|
|
1602
|
+
state: ev.state,
|
|
1603
|
+
checked_at: at,
|
|
1604
|
+
checked_state: adopted ? ev.state : (row ? row.checked_state || '' : ''),
|
|
1605
|
+
check: inserted,
|
|
1606
|
+
};
|
|
1607
|
+
});
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
/** 核对历史(按 check_sequence 升序 = 时间单调),有界分页 + 字节上限。 */
|
|
1611
|
+
checks(id, opts) {
|
|
1612
|
+
if (this.closed) return this._closedResponse();
|
|
1613
|
+
const canonicalId = parseLibraryId(id);
|
|
1614
|
+
if (!canonicalId) return fail('invalid_argument', null, { message: `id 形状非法(期望 ${LIBRARY_ID_PREFIX}<uuid>):${JSON.stringify(id)}` });
|
|
1615
|
+
const paging = resolvePaging(opts, this.limits);
|
|
1616
|
+
if (!paging.ok) return paging.response;
|
|
1617
|
+
const total = Number(this.st.countChecks.get(canonicalId).n);
|
|
1618
|
+
const rows = this.st.listChecks.all(canonicalId, paging.limit, paging.offset).map(checkRowToRecord);
|
|
1619
|
+
const hasMore = paging.offset + rows.length < total;
|
|
1620
|
+
const { kept, capped } = capByBytes(rows, this.limits.resultMaxBytes, (k, wasCapped) => ({
|
|
1621
|
+
ok: true,
|
|
1622
|
+
item_id: canonicalId,
|
|
1623
|
+
checks: k,
|
|
1624
|
+
count: k.length,
|
|
1625
|
+
offset: paging.offset,
|
|
1626
|
+
limit: paging.limit,
|
|
1627
|
+
total,
|
|
1628
|
+
nextOffset: (wasCapped || hasMore) && k.length > 0 ? paging.offset + k.length : null,
|
|
1629
|
+
truncated: wasCapped || hasMore,
|
|
1630
|
+
}));
|
|
1631
|
+
const out = { ...kept, byte_size: bytesOf(kept) };
|
|
1632
|
+
return alias(out, 'items', out.checks);
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
/** 关闭:幂等;关掉后的操作返回 `{ok:false, reason:'closed', message:'图书馆库已关闭', code:'closed'}`(不是抛错)。 */
|
|
1636
|
+
close() {
|
|
1637
|
+
if (this.closed) return { ok: true, closed: true, already: true };
|
|
1638
|
+
this.closed = true;
|
|
1639
|
+
try {
|
|
1640
|
+
this.db.close();
|
|
1641
|
+
} catch (e) {
|
|
1642
|
+
throw new Error(`图书馆库关闭失败:${e?.message ?? e}`);
|
|
1643
|
+
}
|
|
1644
|
+
return { ok: true, closed: true, already: false };
|
|
1645
|
+
}
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
/** 载荷里出现伪造认证字段(checked_state/checked_at)时的**点名**回执。 */
|
|
1649
|
+
function forgeryGuard(item) {
|
|
1650
|
+
if (!isPlainObject(item)) return null;
|
|
1651
|
+
const forged = FORGED_STATE_KEYS.filter((k) => Object.prototype.hasOwnProperty.call(item, k));
|
|
1652
|
+
if (!forged.length) return null;
|
|
1653
|
+
return fail('readonly_field', null, {
|
|
1654
|
+
message: `条目载荷含只读认证字段:${forged.join(', ')}(checked_state/checked_at 只能由 recordCheck 写)`,
|
|
1655
|
+
readonly: forged,
|
|
1656
|
+
});
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
function mkdirFor(filePath) {
|
|
1660
|
+
const idx = Math.max(filePath.lastIndexOf('/'), filePath.lastIndexOf('\\'));
|
|
1661
|
+
const dir = idx > 0 ? filePath.slice(0, idx) : '.';
|
|
1662
|
+
try {
|
|
1663
|
+
mkdirSync(dir, { recursive: true });
|
|
1664
|
+
} catch (e) {
|
|
1665
|
+
throw new LibraryUnavailableError(`图书馆库目录建不出来(${dir}):${e?.message ?? e}`, e);
|
|
1666
|
+
}
|
|
1667
|
+
try {
|
|
1668
|
+
const st = statSync(dir);
|
|
1669
|
+
if (!st.isDirectory()) throw new LibraryUnavailableError(`图书馆库路径的父级不是目录:${dir}`);
|
|
1670
|
+
} catch (e) {
|
|
1671
|
+
if (e instanceof LibraryUnavailableError) throw e;
|
|
1672
|
+
throw new LibraryUnavailableError(`图书馆库目录不可用(${dir}):${e?.message ?? e}`, e);
|
|
1673
|
+
}
|
|
1674
|
+
}
|
|
1675
|
+
|
|
1676
|
+
function jsonOrNull(v) {
|
|
1677
|
+
return v === undefined || v === null ? null : JSON.stringify(v);
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
function resolvePaging(opts, limits) {
|
|
1681
|
+
const raw = opts === undefined || opts === null ? {} : opts;
|
|
1682
|
+
if (!isPlainObject(raw)) return refuse('invalid_type', '分页参数必须是对象:{offset?, limit?}');
|
|
1683
|
+
const unknown = Object.keys(raw).filter((k) => k !== 'offset' && k !== 'limit');
|
|
1684
|
+
if (unknown.length) return refuse('unknown_field', `分页参数未知字段:${unknown.join(', ')}`);
|
|
1685
|
+
const offset = raw.offset === undefined ? 0 : toInt(raw.offset);
|
|
1686
|
+
if (offset === undefined || offset < 0) return refuse('invalid_offset', 'offset 必须是 >= 0 的整数');
|
|
1687
|
+
const defaultLimit = Math.min(PAGE_DEFAULT_LIMIT, limits.searchMaxLimit);
|
|
1688
|
+
const limit = raw.limit === undefined ? defaultLimit : toInt(raw.limit);
|
|
1689
|
+
if (limit === undefined || limit < 1 || limit > PAGE_MAX_LIMIT) {
|
|
1690
|
+
return refuse('invalid_limit', `limit 必须是 1..${PAGE_MAX_LIMIT} 的整数(默认 ${defaultLimit})`);
|
|
1691
|
+
}
|
|
1692
|
+
return { ok: true, offset, limit };
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
function currentOf(items) {
|
|
1696
|
+
return items.find((it) => it.superseded_by === '' && !it.withdrawn_at) ?? items[items.length - 1] ?? null;
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1699
|
+
/**
|
|
1700
|
+
* 序列化结果超上限时从**尾部**丢条目(不静默截断 JSON),并如实回 truncated。
|
|
1701
|
+
* ⚠️ 截断标志由这里**当参数传进去**给 build —— 修前 build 回调直接读外层的 `capped`,
|
|
1702
|
+
* 而那个 `capped` 是 `const {kept, capped} = capByBytes(...)` 的**同名绑定**,回调执行时
|
|
1703
|
+
* 它还在 TDZ 里 ⇒ `Cannot access 'capped' before initialization`(第一次跑 history 就炸)。
|
|
1704
|
+
* 闭包与环境同名是这类"平时不报、一进分支就炸"的经典来源,故这里不靠闭包。
|
|
1705
|
+
*/
|
|
1706
|
+
function capByBytes(items, maxBytes, build) {
|
|
1707
|
+
let kept = items.slice();
|
|
1708
|
+
let capped = false;
|
|
1709
|
+
while (kept.length > 0 && bytesOf(build(kept, capped)) > maxBytes) {
|
|
1710
|
+
kept = kept.slice(0, -1);
|
|
1711
|
+
capped = true;
|
|
1712
|
+
}
|
|
1713
|
+
return { kept: build(kept, capped), capped };
|
|
1714
|
+
}
|