@nightpainters/dsh-ling 1.6.2 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +130 -4
- package/DESIGN.md +89 -6
- package/PHILOSOPHY.md +14 -4
- package/README.md +102 -18
- package/cordis.patch.yml +32 -2
- package/lib/client.js +2520 -55
- package/lib/host/agent-tools.js +286 -0
- package/lib/host/api.js +592 -59
- package/lib/host/archive.js +3 -3
- 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/deepsummary.js +75 -2
- package/lib/host/dsweb-summary.js +6 -3
- package/lib/host/guard.js +53 -0
- package/lib/host/inject.js +12 -2
- package/lib/host/l1.js +13 -8
- 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 +73 -19
- package/lib/host/persona.js +13 -2
- package/lib/host/rawlog.js +1 -1
- package/lib/host/recall-search.js +45 -8
- package/lib/host/snapshot.js +2 -1
- package/lib/host/surgery.js +6 -4
- package/lib/host/tools.js +460 -19
- 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,199 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { LibraryStore, normalizeLibraryLimits, DEFAULT_LIBRARY_LIMITS } from './library-store.js';
|
|
3
|
+
import { LibraryChecker, validateLibraryCheckerConfig, normalizeRoots } from './library-checker.js';
|
|
4
|
+
import { analyzeImport, commitImport } from './library-import.js';
|
|
5
|
+
import { readSqliteSource } from './library-sqlite.js';
|
|
6
|
+
|
|
7
|
+
const plain = (value) => value !== null && typeof value === 'object'
|
|
8
|
+
&& (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null);
|
|
9
|
+
// F10(1.7):`configError` 是**公共配置形状**的第 5 个键(`config.js` 的 libraryFields 已声明它),
|
|
10
|
+
// 所以这里也必须收 —— 否则合法配置(空串)会在运行时被判成"未知配置"而把图书馆整块拒掉。
|
|
11
|
+
// 它只是"上一次校验失败的原因"的载体:非空时构造函数**先**进不可用态(见下),不会走到开库。
|
|
12
|
+
const CONFIG_KEYS = new Set(['enabled', 'codeRoots', 'officialUrls', 'limits', 'configError']);
|
|
13
|
+
|
|
14
|
+
/** Public config deliberately has no resolver, requester, or source override. */
|
|
15
|
+
export function normalizeLibraryConfig(raw = {}) {
|
|
16
|
+
if (!plain(raw)) throw new TypeError('library 配置必须是对象');
|
|
17
|
+
for (const key of Object.keys(raw)) {
|
|
18
|
+
if (!CONFIG_KEYS.has(key)) throw new RangeError(`library 未知配置: ${key}`);
|
|
19
|
+
}
|
|
20
|
+
if (raw.enabled !== undefined && typeof raw.enabled !== 'boolean') {
|
|
21
|
+
throw new TypeError('library.enabled 必须是布尔值');
|
|
22
|
+
}
|
|
23
|
+
const limits = raw.limits === undefined ? {} : raw.limits;
|
|
24
|
+
if (!plain(limits)) throw new TypeError('library.limits 必须是对象');
|
|
25
|
+
for (const key of Object.keys(limits)) {
|
|
26
|
+
if (!Object.hasOwn(DEFAULT_LIBRARY_LIMITS, key)) throw new RangeError(`library.limits 未知配置: ${key}`);
|
|
27
|
+
}
|
|
28
|
+
const normalized = normalizeLibraryLimits(limits);
|
|
29
|
+
const codeRoots = raw.codeRoots === undefined ? [] : raw.codeRoots;
|
|
30
|
+
const officialUrls = raw.officialUrls === undefined ? [] : raw.officialUrls;
|
|
31
|
+
validateLibraryCheckerConfig({ codeRoots, officialUrls }, normalized);
|
|
32
|
+
return Object.freeze({
|
|
33
|
+
enabled: raw.enabled ?? true,
|
|
34
|
+
codeRoots: Object.freeze([...codeRoots]), officialUrls: Object.freeze([...officialUrls]),
|
|
35
|
+
limits: Object.freeze(normalized),
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function unavailable(reason, message) {
|
|
40
|
+
return { ok: false, reason, message };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Independent library ownership: no memory import, migration, or injection. */
|
|
44
|
+
export class LibraryRuntime {
|
|
45
|
+
#config;
|
|
46
|
+
#store = null;
|
|
47
|
+
#checker = null;
|
|
48
|
+
#codeRoots = [];
|
|
49
|
+
#lockedPaths = [];
|
|
50
|
+
#failure = null;
|
|
51
|
+
#closed = false;
|
|
52
|
+
#closing = null;
|
|
53
|
+
|
|
54
|
+
constructor(dataDir, config = {}) {
|
|
55
|
+
// F10(1.7 修复批次):配置校验失败**不再把整插件带走**。`config.js` 已把原因带进来
|
|
56
|
+
// (`configError`),这里直接进不可用态 —— 响亮(理由词 + 出错的键名)、有界(只废图书馆)。
|
|
57
|
+
// **不试图用半套配置开库**:半套配置会把"配置写错"变成运行期看不懂的怪象。
|
|
58
|
+
// ⚠️ 直接构造(内部/测试)时的**严格性没变**:下面这行照旧对非法配置抛错 ——
|
|
59
|
+
// 严格在里、降级在外,两层不许互相污染。
|
|
60
|
+
const configError = plain(config) ? String(config.configError ?? '') : '';
|
|
61
|
+
if (configError) {
|
|
62
|
+
this.#config = normalizeLibraryConfig({}); // 合法形状,供 enabled / limits 读面
|
|
63
|
+
this.#failure = unavailable('library_config_invalid', `图书馆配置有误: ${configError.slice(0, 300)}`);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
// Configuration errors deliberately escape; DB availability errors do not.
|
|
67
|
+
this.#config = normalizeLibraryConfig(config);
|
|
68
|
+
if (!this.#config.enabled) return;
|
|
69
|
+
if (typeof dataDir !== 'string' || !dataDir) throw new TypeError('library dataDir 必须是非空路径');
|
|
70
|
+
try {
|
|
71
|
+
this.#store = new LibraryStore(join(dataDir, 'library.db'), this.#config.limits);
|
|
72
|
+
this.#checker = new LibraryChecker(this.#store, {
|
|
73
|
+
codeRoots: this.#config.codeRoots, officialUrls: this.#config.officialUrls,
|
|
74
|
+
});
|
|
75
|
+
// SQLite 通道(路 A,D14)要用的两件事,与 checker **同一套根校验**(导出复用,不复制第二份):
|
|
76
|
+
// - 白名单根:插件配置里的 `codeRoots`;
|
|
77
|
+
// - **不许自噬**:本插件自己的库 —— 源路径解析成真实路径后再比(大小写无关)。
|
|
78
|
+
this.#codeRoots = normalizeRoots(this.#config.codeRoots);
|
|
79
|
+
this.#lockedPaths = [join(dataDir, 'library.db'), join(dataDir, 'memory.db')];
|
|
80
|
+
} catch (error) {
|
|
81
|
+
try { this.#store?.close(); } catch { /* preserve the opening error */ }
|
|
82
|
+
this.#store = null;
|
|
83
|
+
this.#failure = unavailable('library_unavailable', `图书馆资料库不可用: ${String(error?.message ?? error).slice(0, 500)}`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
get limits() { return this.#config.limits; }
|
|
88
|
+
|
|
89
|
+
#unavailable() {
|
|
90
|
+
if (this.#closed) return unavailable('closed', '图书馆已关闭');
|
|
91
|
+
if (!this.#config.enabled) return unavailable('library_disabled', '图书馆已在插件配置中关闭');
|
|
92
|
+
if (this.#failure) return { ...this.#failure };
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
status() {
|
|
97
|
+
const failure = this.#unavailable();
|
|
98
|
+
if (failure) return { ...failure, enabled: this.#config.enabled, available: false, limits: { ...this.limits } };
|
|
99
|
+
try {
|
|
100
|
+
return {
|
|
101
|
+
ok: true, enabled: true, available: true,
|
|
102
|
+
store: this.#store.stats(), checker: this.#checker.status(), limits: { ...this.limits },
|
|
103
|
+
};
|
|
104
|
+
} catch (error) {
|
|
105
|
+
return {
|
|
106
|
+
...unavailable('library_unavailable', `图书馆状态不可读: ${String(error?.message ?? error).slice(0, 500)}`),
|
|
107
|
+
enabled: true, available: false, limits: { ...this.limits },
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
#call(method, ...args) {
|
|
113
|
+
const failure = this.#unavailable();
|
|
114
|
+
if (failure) return failure;
|
|
115
|
+
try { return this.#store[method](...args); }
|
|
116
|
+
catch (error) { return unavailable('library_unavailable', `图书馆操作失败: ${String(error?.message ?? error).slice(0, 500)}`); }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
search(query) {
|
|
120
|
+
if (!plain(query) || Object.keys(query).some((key) => !['q', 'offset', 'limit'].includes(key))) {
|
|
121
|
+
return unavailable('invalid_argument', '检索只接受 q、offset、limit');
|
|
122
|
+
}
|
|
123
|
+
return this.#call('search', query);
|
|
124
|
+
}
|
|
125
|
+
/** 浏览面 · **只读**:不搜也能翻(按 类目/来源 分组列条目,含留痕行)+ 分组计数表。
|
|
126
|
+
* 参数白名单在这里先收一道 —— 与 `search` 同款:未知键不落到 store 层才报。 */
|
|
127
|
+
browse(query) {
|
|
128
|
+
if (!plain(query) || Object.keys(query).some((key) => !['category', 'source_kind', 'offset', 'limit', 'fields'].includes(key))) {
|
|
129
|
+
return unavailable('invalid_argument', '浏览只接受 category、source_kind、offset、limit、fields');
|
|
130
|
+
}
|
|
131
|
+
return this.#call('browse', query);
|
|
132
|
+
}
|
|
133
|
+
get(id) { return this.#call('get', id); }
|
|
134
|
+
history(id, options) { return this.#call('history', id, options); }
|
|
135
|
+
checks(id, options) { return this.#call('checks', id, options); }
|
|
136
|
+
create(item) { return this.#call('create', item); }
|
|
137
|
+
revise(id, item, expectedHash) { return this.#call('revise', id, item, expectedHash); }
|
|
138
|
+
withdraw(id, reason, expectedHash) { return this.#call('withdraw', id, reason, expectedHash); }
|
|
139
|
+
|
|
140
|
+
/** 导入通道 · **分析段**(契约 §3)——
|
|
141
|
+
* `analyzeImport` 是纯函数式的"源 → 候选",**不写库**(§9.1),故这里**不经** `#call`
|
|
142
|
+
* (:89 那个门面只转发 store 方法)。限额由运行时**自己**注入:插件配置里改过的尺必须生效,
|
|
143
|
+
* 调用方不许自带一把(`opts.limits` 被本方法覆盖)。模型段由调用方注入 ⇒ 可换桩。
|
|
144
|
+
* ⚠️ 抛错也**不写库**:回 `library_unavailable`(503),不冒充"零候选"。 */
|
|
145
|
+
async analyzeImport(input, opts = {}) {
|
|
146
|
+
const failure = this.#unavailable();
|
|
147
|
+
if (failure) return failure;
|
|
148
|
+
try {
|
|
149
|
+
return await analyzeImport(input, {
|
|
150
|
+
...(plain(opts) ? opts : {}),
|
|
151
|
+
limits: this.limits,
|
|
152
|
+
// 取源**由运行时注入**(`analyzeImport` 是纯函数,不碰文件):只读打开、白名单根、拒自噬、表必须显式选。
|
|
153
|
+
readSqlite: (req) => readSqliteSource({
|
|
154
|
+
...req, roots: this.#codeRoots, lockedPaths: this.#lockedPaths,
|
|
155
|
+
}),
|
|
156
|
+
});
|
|
157
|
+
} catch (error) {
|
|
158
|
+
return unavailable('library_unavailable', `图书馆导入分析失败: ${String(error?.message ?? error).slice(0, 500)}`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** 导入通道 · **落库段**(契约 §3):只走既有 `store.create` 逐条入库(§9.2),
|
|
163
|
+
* 回执逐条 + 总数自洽(committed/skipped/rejected)。库不在场 ⇒ 503,不静默少给。 */
|
|
164
|
+
async commitImport(payload) {
|
|
165
|
+
const failure = this.#unavailable();
|
|
166
|
+
if (failure) return failure;
|
|
167
|
+
try {
|
|
168
|
+
return await commitImport(this.#store, payload);
|
|
169
|
+
} catch (error) {
|
|
170
|
+
return unavailable('library_unavailable', `图书馆导入落库失败: ${String(error?.message ?? error).slice(0, 500)}`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async check(id, options = {}) {
|
|
175
|
+
const failure = this.#unavailable();
|
|
176
|
+
if (failure) return { ...failure, recorded: false };
|
|
177
|
+
try {
|
|
178
|
+
const result = await this.#checker.check(id, options);
|
|
179
|
+
return {
|
|
180
|
+
...result,
|
|
181
|
+
adoption_reason: result.adopted === true ? 'adopted'
|
|
182
|
+
: result.record?.reason ?? (result.recorded ? 'not_adopted' : 'not_recorded'),
|
|
183
|
+
};
|
|
184
|
+
} catch (error) {
|
|
185
|
+
return { ...unavailable('library_unavailable', `图书馆核对失败: ${String(error?.message ?? error).slice(0, 500)}`), recorded: false };
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
close() {
|
|
190
|
+
if (this.#closing) return this.#closing;
|
|
191
|
+
this.#closed = true;
|
|
192
|
+
this.#closing = (async () => {
|
|
193
|
+
try { await this.#checker?.close(); }
|
|
194
|
+
finally { this.#store?.close(); }
|
|
195
|
+
return { ok: true, closed: true };
|
|
196
|
+
})();
|
|
197
|
+
return this.#closing;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 1.7 图书馆 · **SQLite 类接入通道**(选项文档 §11.3;2026-10-15 开工)
|
|
3
|
+
*
|
|
4
|
+
* 这一层只做一件事:**把一张表读成"内容单元"**,交给纯函数的 `analyzeImport`
|
|
5
|
+
* (`lib/host/library-import.js`)去走两段式。它**不写库、不改源库结构、不联网**。
|
|
6
|
+
*
|
|
7
|
+
* ## 为什么单独一层(不塞进 library-import.js)
|
|
8
|
+
* `analyzeImport` 的契约是"纯函数式:不碰库、不碰文件、不碰 network"(契约 §2.1)。
|
|
9
|
+
* 打开源库是**文件 I/O** ⇒ 必须留在外面,由运行时注入(`opts.readSqlite`),与模型段同款:
|
|
10
|
+
* 测试可以换桩,纯函数那一层不被污染。
|
|
11
|
+
*
|
|
12
|
+
* ## 动工前实测的六条事实(探针 `_probe/sqlite-probe*.mjs`,2026-10-15)
|
|
13
|
+
* 1. **打开是懒的**:`new DatabaseSync(垃圾文件,{readOnly:true})` **不抛**,第一条查询才抛
|
|
14
|
+
* `file is not a database` ⇒ 判"是不是库"**必须自己读魔数**,不能靠 open。
|
|
15
|
+
* 2. **大整数默认就抛**:不传 `readBigInts` 时,任何超过 2^53 的 INTEGER 会让**整条查询**抛
|
|
16
|
+
* `ERR_OUT_OF_RANGE` ⇒ **一行坏数据能毙掉整张表** ⇒ 一律 `readBigInts: true`,自己转字符串。
|
|
17
|
+
* 3. **`rowid` 会被同名列遮蔽**:表里真有一个叫 `rowid` 的列时,`SELECT rowid` 拿到的是**那一列**
|
|
18
|
+
* (实测 `__rid: "r1"` 而非 1/2)⇒ 无主键表必须在 `rowid`/`_rowid_`/`oid` 里挑一个**没被列名占用**的。
|
|
19
|
+
* 4. **`PRAGMA busy_timeout` 在只读连接上也能设、且可回读**(实测 `{timeout:0}` → `{timeout:2000}`);
|
|
20
|
+
* 而构造函数里那个 `{ timeout: 2000 }` 选项"没抛"**不等于生效** ⇒ 用 PRAGMA 设,并**回读当证据**。
|
|
21
|
+
* 5. **`WITHOUT ROWID` 的表查 `rowid` 直接报 `no such column: rowid`** ⇒ rowid 回退只在**无主键**时用
|
|
22
|
+
* (而 WITHOUT ROWID 必有主键,两条口径正好不打架)。
|
|
23
|
+
* 6. **标识符转义** = `"` 包 + 内部 `""`:`PRAGMA table_info("怪 名")` 与 `SELECT "列""引"` 都实测可用。
|
|
24
|
+
*
|
|
25
|
+
* ## 五条安全硬约束(§11.3:缺一条都不许上)
|
|
26
|
+
* ① 只读打开(实测只读连接上写会抛 `attempt to write a readonly database`);
|
|
27
|
+
* ② **拒绝打开本插件自己的库**(`library.db` / `memory.db`)⇒ `source_locked` —— 自噬 + 锁风险;
|
|
28
|
+
* ③ 表**必须显式选**,**不猜"最大的表"**(猜错 = 把不相干的数据当条目灌进馆);
|
|
29
|
+
* ④ 大表**不数总数**:不跑 `COUNT(*)`,分页靠 `LIMIT ?+1 OFFSET ?` **只探一格**;
|
|
30
|
+
* ⑤ 打不开的东西**如实说**(`not_sqlite` / `source_unreadable` / …),**绝不静默产出 0 条**。
|
|
31
|
+
*
|
|
32
|
+
* ## 定位与保真(与 §11.6 B.1 同口径)
|
|
33
|
+
* locator = `表名#主键值`(复合主键按 `pk` 次序用 `|` 连;无主键 ⇒ 用没被占用的 rowid 别名):
|
|
34
|
+
* 它是**地址**,不是内容 —— 幂等键 = `sha256(源身份)[0:16] + ':' + locator`。
|
|
35
|
+
* `m`(单元文本)= 该行 `列=值` 逐行拼成的文本,它**同时**是默认正文与 evidence 的回查区间
|
|
36
|
+
* (`verifyWithIndex` 的 `span.includes(ev)`)—— 两边**只能由这一个函数产出**,另拼一遍就会假红。
|
|
37
|
+
*/
|
|
38
|
+
import { openSync, closeSync, readSync, statSync, realpathSync, existsSync } from 'node:fs';
|
|
39
|
+
import { resolve as resolvePath } from 'node:path';
|
|
40
|
+
import { createHash } from 'node:crypto';
|
|
41
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
42
|
+
import { contained, localPath } from './library-checker.js';
|
|
43
|
+
import { utf8Bytes } from './library-store.js';
|
|
44
|
+
|
|
45
|
+
/** SQLite 文件头(16 字节,ASCII)。**唯一的**"这是不是 SQLite 库"判据 —— open 是懒的。 */
|
|
46
|
+
export const SQLITE_MAGIC = 'SQLite format 3\0';
|
|
47
|
+
/** 无主键表挑 rowid 别名的次序(白名单常量 ⇒ 拼进 SQL 时不加引号,见 `selectList`)。 */
|
|
48
|
+
export const SQLITE_ROWID_ALIASES = Object.freeze(['rowid', '_rowid_', 'oid']);
|
|
49
|
+
/** locator 的字节预算(幂等键里 locator 那半截;store 侧 `import_key` 上限 512 字节)。 */
|
|
50
|
+
export const SQLITE_LOCATOR_MAX_BYTES = 300;
|
|
51
|
+
/** 回执里最多列出几张表(超出只计数,不静默丢)。 */
|
|
52
|
+
export const SQLITE_TABLES_LISTED = 100;
|
|
53
|
+
|
|
54
|
+
const bad = (reason, message) => ({ ok: false, reason, message });
|
|
55
|
+
const msgOf = (e) => String((e && e.message) || e || '未知错误').slice(0, 300);
|
|
56
|
+
|
|
57
|
+
/** `"名""字"` —— SQLite 的标识符转义(实测可用于 SELECT 与 PRAGMA)。 */
|
|
58
|
+
export function quoteIdent(name) {
|
|
59
|
+
return `"${String(name).replace(/"/g, '""')}"`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* D15 的**源身份串**:`<真实路径>|<字节数>|`mtime。<br>
|
|
64
|
+
* 为什么不拿内容哈希:① 读整库求哈希与"大表不数总数"同一类浪费;② path 接入时源文本为空,
|
|
65
|
+
* 内容哈希会退化成常量、让**跨库同表同主键撞键**(这条是被异源复核证伪后改的口径,见 §11.6 C)。
|
|
66
|
+
*/
|
|
67
|
+
export function sqliteIdentityString(identity) {
|
|
68
|
+
return `${identity.real}|${identity.size}|${identity.mtime_ms}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** 单元格值 → 文本(保真优先:`NULL` / 大整数按原样 / 二进制写成 `x'..'`,不猜、不改)。 */
|
|
72
|
+
export function sqlValueText(v) {
|
|
73
|
+
if (v === null || v === undefined) return 'NULL';
|
|
74
|
+
if (typeof v === 'bigint') return v.toString();
|
|
75
|
+
if (typeof v === 'string') return v;
|
|
76
|
+
if (typeof v === 'number' || typeof v === 'boolean') return String(v);
|
|
77
|
+
if (v instanceof Uint8Array) {
|
|
78
|
+
const hex = Buffer.from(v).toString('hex');
|
|
79
|
+
return hex.length <= 64 ? `x'${hex}'` : `x'${hex.slice(0, 64)}…'(${v.length} 字节)`;
|
|
80
|
+
}
|
|
81
|
+
try { return JSON.stringify(v); } catch { return String(v); }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* 行 → 单元文本(每列一行 `列=值`)。
|
|
86
|
+
* ⚠️ 这个函数的输出**就是** `m`,也是 evidence 的回查区间 —— **不许在别处再拼一遍**。
|
|
87
|
+
*/
|
|
88
|
+
export function sqlRowText(columns, row) {
|
|
89
|
+
return columns.map((c) => `${c.name}=${sqlValueText(row[c.name])}`).join('\n');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** 主键值超预算时的**确定性**收短:截到预算内(不劈开多字节字符)+ 挂原文摘要前 8 位。 */
|
|
93
|
+
export function sqliteLocatorOf(table, keyText) {
|
|
94
|
+
const raw = `${table}#${keyText}`;
|
|
95
|
+
if (utf8Bytes(raw) <= SQLITE_LOCATOR_MAX_BYTES) return { locator: raw, capped: false };
|
|
96
|
+
const digest = createHash('sha256').update(raw, 'utf8').digest('hex').slice(0, 8);
|
|
97
|
+
const room = SQLITE_LOCATOR_MAX_BYTES - digest.length - 2; // 1 个 `…` 与 1 个 `#` 的余量
|
|
98
|
+
const head = Buffer.from(raw, 'utf8').subarray(0, room).toString('utf8').replace(/\uFFFD+$/u, '');
|
|
99
|
+
return { locator: `${head}…#${digest}`, capped: true };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** 读前 16 字节判魔数(open 靠不住:fopen 一个垃圾文件是成功的)。 */
|
|
103
|
+
function isSqliteFile(real) {
|
|
104
|
+
let fd = null;
|
|
105
|
+
try {
|
|
106
|
+
fd = openSync(real, 'r');
|
|
107
|
+
const buf = Buffer.alloc(16);
|
|
108
|
+
const read = readSync(fd, buf, 0, 16, 0);
|
|
109
|
+
return read === 16 && buf.toString('latin1') === SQLITE_MAGIC;
|
|
110
|
+
} catch {
|
|
111
|
+
return false;
|
|
112
|
+
} finally {
|
|
113
|
+
if (fd !== null) { try { closeSync(fd); } catch { /* 只读句柄,关不上也不影响结论 */ } }
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const realOrResolved = (p) => { try { return realpathSync.native(p); } catch { return resolvePath(p); } };
|
|
118
|
+
/** 源库旁边那两个 SQLite 侧文件:`-wal` / `-shm`(存在与**新增**都要如实报出去)。 */
|
|
119
|
+
const sidecarState = (dbPath) => ['-wal', '-shm'].map((suf) => dbPath + suf).filter((p) => existsSync(p));
|
|
120
|
+
|
|
121
|
+
/** 路径比对的归一:Windows 大小写不敏感,两边都走真实路径。 */
|
|
122
|
+
const samePath = (a, b) => {
|
|
123
|
+
const na = realOrResolved(a);
|
|
124
|
+
const nb = realOrResolved(b);
|
|
125
|
+
return process.platform === 'win32' ? na.toLowerCase() === nb.toLowerCase() : na === nb;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* 打开源库(只读)并读出一页内容单元。<br>
|
|
130
|
+
* `options`:`path` · `table` · `offset` · `limit`(一页几张) · `limits` · `roots`(登记根,
|
|
131
|
+
* `[{path, real}]`,由 `normalizeRoots` 产出) · `lockedPaths`(拒开的自有库绝对路径)。
|
|
132
|
+
*/
|
|
133
|
+
export async function readSqliteSource(options = {}) {
|
|
134
|
+
const o = options && typeof options === 'object' ? options : {};
|
|
135
|
+
const limits = o.limits && typeof o.limits === 'object' ? o.limits : {};
|
|
136
|
+
|
|
137
|
+
// ── ① 路径闸(复用 library-checker 的同一套根校验,不自创)────────────────────────────
|
|
138
|
+
const rawPath = typeof o.path === 'string' ? o.path.trim() : '';
|
|
139
|
+
if (rawPath === '') {
|
|
140
|
+
return bad('source_path_invalid', 'path 必填:SQLite 走"路径接入",要的是源库在本机上的**绝对**路径');
|
|
141
|
+
}
|
|
142
|
+
if (!localPath(rawPath)) {
|
|
143
|
+
return bad('source_path_invalid', `path 必须是本机绝对路径且不含 .. 穿越(实际 ${JSON.stringify(rawPath)})`);
|
|
144
|
+
}
|
|
145
|
+
const roots = Array.isArray(o.roots) ? o.roots.filter((r) => r && typeof r.path === 'string' && typeof r.real === 'string') : [];
|
|
146
|
+
const lexical = resolvePath(rawPath);
|
|
147
|
+
if (roots.length === 0) {
|
|
148
|
+
return bad('source_path_not_allowed', '还没有登记取源根(library.codeRoots 是空的)⇒ 路径接入读不了任何源库');
|
|
149
|
+
}
|
|
150
|
+
if (!roots.some((r) => contained(r.path, lexical) || contained(r.real, lexical))) {
|
|
151
|
+
return bad('source_path_not_allowed', `这个路径不在登记的取源根里(library.codeRoots)。要读它,先把它的目录加进 codeRoots:${lexical}`);
|
|
152
|
+
}
|
|
153
|
+
let real = '';
|
|
154
|
+
try { real = realpathSync.native(lexical); }
|
|
155
|
+
catch (e) { return bad('source_unreadable', `打不开这个路径:${msgOf(e)}`); }
|
|
156
|
+
if (!roots.some((r) => contained(r.real, real))) {
|
|
157
|
+
return bad('source_symlink_escape', `真实路径 ${real} 跳出了登记根(符号链接?)—— 根白名单挡不住这层,所以这里再核一次`);
|
|
158
|
+
}
|
|
159
|
+
let st = null;
|
|
160
|
+
try { st = statSync(real); }
|
|
161
|
+
catch (e) { return bad('source_unreadable', `读不到文件信息:${msgOf(e)}`); }
|
|
162
|
+
if (!st.isFile()) return bad('source_unreadable', '这不是普通文件(目录 / 设备 / 管道都不接)');
|
|
163
|
+
|
|
164
|
+
// ── ② 自噬闸:根白名单**挡不住**它(数据目录完全可以被登记进 codeRoots)────────────────
|
|
165
|
+
const locked = Array.isArray(o.lockedPaths) ? o.lockedPaths.filter((p) => typeof p === 'string' && p !== '') : [];
|
|
166
|
+
for (const p of locked) {
|
|
167
|
+
// ⚠️ **只比路径串不够**:`realpath` 不会把**硬链接**归一到同一个路径 ⇒ 在登记根里放一个指向
|
|
168
|
+
// `memory.db` 的硬链接,路径串不同、inode 相同,这道闸就被绕过去了(2026-10-15 实测复现:
|
|
169
|
+
// `ok:true` 且读出了 `memory-机密`)。所以除了路径串,还要比 **dev+ino**
|
|
170
|
+
// —— 与 library-checker 的 `sameFile` 同一口径,不另立第二套。
|
|
171
|
+
let same = samePath(real, p);
|
|
172
|
+
if (!same) {
|
|
173
|
+
try {
|
|
174
|
+
const other = statSync(p);
|
|
175
|
+
same = other.dev === st.dev && other.ino === st.ino;
|
|
176
|
+
} catch { /* 对方不在 / 读不到 ⇒ 这一路比不了,不算命中 */ }
|
|
177
|
+
}
|
|
178
|
+
if (same) {
|
|
179
|
+
return bad('source_locked', `这个文件是本插件自己的库(${p})—— 拒绝打开:自噬 + 锁风险`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ── ③ 魔数闸(open 是懒的:垃圾文件也能 open 成功,必须自己看头)──────────────────────
|
|
184
|
+
if (!isSqliteFile(real)) {
|
|
185
|
+
return bad('not_sqlite', `前 16 字节不是 SQLite 魔数(${JSON.stringify(SQLITE_MAGIC)})—— `
|
|
186
|
+
+ '它不是一个 SQLite 库。若这是 SQLCipher 之类的**加密库**,本通道不支持(请先解密或导出成普通库)。');
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const identity = { real, size: st.size, mtime_ms: Math.floor(st.mtimeMs) };
|
|
190
|
+
const sourceIdentity = sqliteIdentityString(identity);
|
|
191
|
+
|
|
192
|
+
// ── ④ 只读打开 ────────────────────────────────────────────────────────────────────
|
|
193
|
+
// 打开前先记下源库旁边的侧文件(WAL 库在 `-shm` 缺失、目录可写时,SQLite 自己会建一个 —— 那是源目录的一次写入)
|
|
194
|
+
const sideBefore = sidecarState(real);
|
|
195
|
+
let db = null;
|
|
196
|
+
try { db = new DatabaseSync(real, { readOnly: true, readBigInts: true }); }
|
|
197
|
+
catch (e) { return bad('source_unreadable', `只读打开失败:${msgOf(e)}`); }
|
|
198
|
+
try {
|
|
199
|
+
const timeout = Math.floor(Number(limits.sqliteBusyTimeoutMs ?? 0));
|
|
200
|
+
let busy = null;
|
|
201
|
+
if (Number.isFinite(timeout) && timeout > 0) {
|
|
202
|
+
db.exec(`PRAGMA busy_timeout = ${timeout}`);
|
|
203
|
+
const back = db.prepare('PRAGMA busy_timeout').get();
|
|
204
|
+
busy = { want: timeout, got: Number(back?.timeout ?? 0) };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ── ⑤ 表枚举(表必须显式选;不猜)──────────────────────────────────────────────────
|
|
208
|
+
let all = [];
|
|
209
|
+
try {
|
|
210
|
+
all = db.prepare("SELECT type, name FROM sqlite_master WHERE type IN ('table','view') AND name NOT LIKE 'sqlite_%' ORDER BY name").all();
|
|
211
|
+
} catch (e) {
|
|
212
|
+
return bad('sqlite_read_failed', `读不了表清单(这不像是普通 SQLite 库):${msgOf(e)}`);
|
|
213
|
+
}
|
|
214
|
+
const allNames = all.map((r) => ({ type: String(r.type), name: String(r.name) }));
|
|
215
|
+
const names = allNames.filter((x) => x.type === 'table').map((x) => x.name);
|
|
216
|
+
const viewNames = allNames.filter((x) => x.type === 'view').map((x) => x.name);
|
|
217
|
+
const tables = names.slice(0, SQLITE_TABLES_LISTED);
|
|
218
|
+
const tablesCapped = names.length > tables.length;
|
|
219
|
+
// 视图**不接**,而且要在这里**拦住**(不是放进去再撞错):`PRAGMA table_info` 对视图一律 pk=0(实测),
|
|
220
|
+
// 视图又没有 rowid ⇒ 生成不出稳定定位 —— 拿"行号"当键的话,源一变就会悄悄指到别的行(那是假幂等)。
|
|
221
|
+
const viewNote = viewNames.length === 0 ? '' : `(另有 ${viewNames.length} 个视图不接:${viewNames.slice(0, 6).join('、')}${viewNames.length > 6 ? ' 等' : ''})`;
|
|
222
|
+
const hint = tables.length === 0
|
|
223
|
+
? `(这个库里**一张表都没有**)${viewNote}`
|
|
224
|
+
: `库里的表:${tables.join('、')}${tablesCapped ? ` 等 ${names.length} 张` : ''}${viewNote}`;
|
|
225
|
+
|
|
226
|
+
const table = typeof o.table === 'string' ? o.table.trim() : '';
|
|
227
|
+
if (table === '') {
|
|
228
|
+
return bad('table_required', `表必须**显式选**(不替你猜最大的表 —— 猜错就把不相干的数据灌进馆了)。${hint}`);
|
|
229
|
+
}
|
|
230
|
+
if (viewNames.includes(table)) {
|
|
231
|
+
return bad('table_not_found', `${JSON.stringify(table)} 是个**视图**,本通道不接:视图没有稳定主键(拿行号当键 = 假幂等),请改用它底下的那张表。`);
|
|
232
|
+
}
|
|
233
|
+
if (!names.includes(table)) {
|
|
234
|
+
return bad('table_not_found', `这张表不在库里:${JSON.stringify(table)}。${hint}`);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ── ⑥ 列与定位键 ──────────────────────────────────────────────────────────────────
|
|
238
|
+
let cols = [];
|
|
239
|
+
try { cols = db.prepare(`PRAGMA table_info(${quoteIdent(table)})`).all(); }
|
|
240
|
+
catch (e) { return bad('sqlite_read_failed', `读不了表结构:${msgOf(e)}`); }
|
|
241
|
+
if (!Array.isArray(cols) || cols.length === 0) {
|
|
242
|
+
return bad('table_not_found', `读不到 ${JSON.stringify(table)} 的列(它是视图或空壳?)`);
|
|
243
|
+
}
|
|
244
|
+
const columns = cols.map((c) => ({ name: String(c.name), type: String(c.type ?? '') }));
|
|
245
|
+
const colNames = columns.map((c) => c.name);
|
|
246
|
+
const pk = cols.filter((c) => Number(c.pk) > 0)
|
|
247
|
+
.sort((a, b) => Number(a.pk) - Number(b.pk)) // pk 是**主键里的位次**(复合主键按它排序)
|
|
248
|
+
.map((c) => String(c.name));
|
|
249
|
+
let ridAlias = '';
|
|
250
|
+
if (pk.length === 0) {
|
|
251
|
+
ridAlias = SQLITE_ROWID_ALIASES.find((a) => !colNames.includes(a)) || '';
|
|
252
|
+
if (ridAlias === '') {
|
|
253
|
+
return bad('no_stable_key', `${JSON.stringify(table)} 没有主键,而 rowid / _rowid_ / oid 三个名字都被列名占用了 ⇒ 拿不到稳定定位`);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ── ⑦ 取值(只 SELECT;分页在 SQL 层做,大表不整表读进内存)────────────────────────────
|
|
258
|
+
const pageMaxRaw = Math.floor(Number(o.limit ?? 0));
|
|
259
|
+
const pageMax = Number.isFinite(pageMaxRaw) && pageMaxRaw > 0 ? pageMaxRaw : Number(limits.importBatchMaxItems ?? 200);
|
|
260
|
+
const offsetRaw = Math.floor(Number(o.offset ?? 0));
|
|
261
|
+
const offset = Number.isFinite(offsetRaw) && offsetRaw > 0 ? offsetRaw : 0;
|
|
262
|
+
// 别名一律来自白名单常量(不是用户输入)⇒ 不加引号:`SELECT "rowid"` 在某些上下文会回落成字符串字面量。
|
|
263
|
+
const selectList = [...colNames.map(quoteIdent), ...(ridAlias ? [`${ridAlias} AS __rid`] : [])].join(', ');
|
|
264
|
+
let rows = [];
|
|
265
|
+
// ⚠️ **必须显式 ORDER BY**:`LIMIT ? OFFSET ?` 不排序时"第几页是哪些行"是**未定义**的 ——
|
|
266
|
+
// 源库被别的进程写动(VACUUM / REINDEX / 新行)后两次扫描的窗口会漂 ⇒ 翻页**重条目或漏条目**;
|
|
267
|
+
// 同一份源两次读也可能给出不同顺序。有主键 ⇒ 按主键(升序、唯一 ⇒ 页序稳定);
|
|
268
|
+
// 无主键 ⇒ 按**没被列名占用的** rowid 别名(它天然唯一)。
|
|
269
|
+
const orderBy = pk.length > 0 ? pk.map((c) => quoteIdent(c)).join(', ') : ridAlias;
|
|
270
|
+
try {
|
|
271
|
+
rows = db.prepare(`SELECT ${selectList} FROM ${quoteIdent(table)} ORDER BY ${orderBy} LIMIT ? OFFSET ?`).all(pageMax + 1, offset);
|
|
272
|
+
} catch (e) {
|
|
273
|
+
return bad('sqlite_read_failed', `读表失败:${msgOf(e)}`);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const batchMax = Number(limits.importBatchMaxBytes ?? 0);
|
|
277
|
+
const byteBudget = Number.isFinite(batchMax) && batchMax > 0 ? batchMax : Infinity;
|
|
278
|
+
const units = [];
|
|
279
|
+
let bytes = 0;
|
|
280
|
+
let capped = 0;
|
|
281
|
+
for (let i = 0; i < rows.length; i += 1) {
|
|
282
|
+
if (units.length >= pageMax) break; // 多取的那一格就是"还有更多"的证据
|
|
283
|
+
const row = rows[i];
|
|
284
|
+
const text = sqlRowText(columns, row);
|
|
285
|
+
const size = utf8Bytes(text);
|
|
286
|
+
// 字节闸:第一条永远收(否则 next_offset 会原地打转),之后超预算就留给下一页
|
|
287
|
+
if (units.length > 0 && bytes + size > byteBudget) break;
|
|
288
|
+
bytes += size;
|
|
289
|
+
const keyText = pk.length > 0 ? pk.map((n) => sqlValueText(row[n])).join('|') : sqlValueText(row.__rid);
|
|
290
|
+
const { locator, capped: wasCapped } = sqliteLocatorOf(table, keyText);
|
|
291
|
+
if (wasCapped) capped += 1;
|
|
292
|
+
const value = {};
|
|
293
|
+
for (const n of colNames) value[n] = row[n]; // `__rid` 不进单元值:它不该出现在"其他字段"正文里
|
|
294
|
+
units.push({ locator, ordinal: offset + i + 1, raw: text, value, key: keyText, pk });
|
|
295
|
+
}
|
|
296
|
+
const hasMore = rows.length > units.length;
|
|
297
|
+
|
|
298
|
+
// ── ⑧ 读完之后两件如实上报的事 ────────────────────────────────────────────────────
|
|
299
|
+
// ① 身份复验(**缩小** TOCTOU 窗口的办法):`node:sqlite` 只收路径、拿不到 fd,所以给不了
|
|
300
|
+
// `O_NOFOLLOW` 那层保证;这里至少能发现"读的过程中这个路径被换走/文件被改过"。
|
|
301
|
+
// 发现 ≠ 硬拒(别人 checkpoint 一下也会改 mtime),但**绝不许装作没发生**:如实进回执。
|
|
302
|
+
let identityChanged = false;
|
|
303
|
+
try {
|
|
304
|
+
const st2 = statSync(real);
|
|
305
|
+
identityChanged = st2.size !== st.size
|
|
306
|
+
|| Math.floor(st2.mtimeMs) !== Math.floor(st.mtimeMs)
|
|
307
|
+
|| st2.dev !== st.dev || st2.ino !== st.ino
|
|
308
|
+
|| !samePath(real, realpathSync.native(lexical));
|
|
309
|
+
} catch { identityChanged = true; }
|
|
310
|
+
// ② 源目录侧写:实测复现 —— 有 `-wal`、没 `-shm` 时读一次,源目录就会多出 `<库名>-shm`。
|
|
311
|
+
// 这不是"我们写源库"(主库一个字节没动),但确实是**源目录的一次写入** ⇒ 报出来。
|
|
312
|
+
const sidecarCreated = sidecarState(real).filter((p) => sideBefore.indexOf(p) < 0);
|
|
313
|
+
|
|
314
|
+
return {
|
|
315
|
+
ok: true,
|
|
316
|
+
source_identity: sourceIdentity,
|
|
317
|
+
identity,
|
|
318
|
+
table,
|
|
319
|
+
columns: colNames,
|
|
320
|
+
column_types: columns.map((c) => ({ name: c.name, type: c.type })),
|
|
321
|
+
pk,
|
|
322
|
+
rowid_alias: ridAlias,
|
|
323
|
+
tables,
|
|
324
|
+
tables_total: names.length,
|
|
325
|
+
tables_capped: tablesCapped,
|
|
326
|
+
views_total: viewNames.length,
|
|
327
|
+
unit_kind: pk.length > 0 ? 'pk' : 'rowid',
|
|
328
|
+
offset,
|
|
329
|
+
page_max: pageMax,
|
|
330
|
+
order_by: orderBy,
|
|
331
|
+
rows_fetched: rows.length,
|
|
332
|
+
units,
|
|
333
|
+
has_more: hasMore,
|
|
334
|
+
locators_capped: capped,
|
|
335
|
+
busy_timeout: busy,
|
|
336
|
+
sidecars_created: sidecarCreated,
|
|
337
|
+
identity_changed: identityChanged,
|
|
338
|
+
};
|
|
339
|
+
} catch (e) {
|
|
340
|
+
return bad('sqlite_read_failed', `读源库时出错:${msgOf(e)}`);
|
|
341
|
+
} finally {
|
|
342
|
+
try { db.close(); } catch { /* 只读句柄;关不掉也不该盖住真正的错 */ }
|
|
343
|
+
}
|
|
344
|
+
}
|