@ai-zen/agents-host 0.0.0-stage → 0.1.0-alpha.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 +39 -0
- package/LICENSE +21 -0
- package/README.md +759 -2
- package/dist/app.d.ts +384 -0
- package/dist/app.js +1148 -0
- package/dist/cli.d.ts +21 -0
- package/dist/cli.js +353 -0
- package/dist/config.d.ts +97 -0
- package/dist/config.js +171 -0
- package/dist/handlers/agentHandlers.d.ts +18 -0
- package/dist/handlers/agentHandlers.js +20 -0
- package/dist/handlers/assetHandlers.d.ts +29 -0
- package/dist/handlers/assetHandlers.js +35 -0
- package/dist/handlers/capabilities.d.ts +26 -0
- package/dist/handlers/capabilities.js +138 -0
- package/dist/handlers/completionHandlers.d.ts +18 -0
- package/dist/handlers/completionHandlers.js +13 -0
- package/dist/handlers/configHandlers.d.ts +31 -0
- package/dist/handlers/configHandlers.js +59 -0
- package/dist/handlers/conversationHandlers.d.ts +118 -0
- package/dist/handlers/conversationHandlers.js +314 -0
- package/dist/handlers/handoffHandlers.d.ts +17 -0
- package/dist/handlers/handoffHandlers.js +14 -0
- package/dist/handlers/hostHandlers.d.ts +39 -0
- package/dist/handlers/hostHandlers.js +56 -0
- package/dist/handlers/index.d.ts +128 -0
- package/dist/handlers/index.js +92 -0
- package/dist/handlers/messageHandlers.d.ts +11 -0
- package/dist/handlers/messageHandlers.js +10 -0
- package/dist/handlers/modelHandlers.d.ts +17 -0
- package/dist/handlers/modelHandlers.js +19 -0
- package/dist/handlers/noticeHandlers.d.ts +24 -0
- package/dist/handlers/noticeHandlers.js +24 -0
- package/dist/handlers/questionHandlers.d.ts +16 -0
- package/dist/handlers/questionHandlers.js +16 -0
- package/dist/handlers/searchHandlers.d.ts +12 -0
- package/dist/handlers/searchHandlers.js +12 -0
- package/dist/handlers/workspaceHandlers.d.ts +29 -0
- package/dist/handlers/workspaceHandlers.js +47 -0
- package/dist/i18n/en.d.ts +243 -0
- package/dist/i18n/en.js +270 -0
- package/dist/i18n/index.d.ts +43 -0
- package/dist/i18n/index.js +72 -0
- package/dist/i18n/zh.d.ts +240 -0
- package/dist/i18n/zh.js +257 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +15 -0
- package/dist/net/UploadReceiver.d.ts +186 -0
- package/dist/net/UploadReceiver.js +494 -0
- package/dist/net/WsHub.d.ts +231 -0
- package/dist/net/WsHub.js +689 -0
- package/dist/net/auth.d.ts +73 -0
- package/dist/net/auth.js +74 -0
- package/dist/net/discovery.d.ts +72 -0
- package/dist/net/discovery.js +171 -0
- package/dist/net/index.d.ts +14 -0
- package/dist/net/index.js +10 -0
- package/dist/runtime/ChatRunner.d.ts +374 -0
- package/dist/runtime/ChatRunner.js +1204 -0
- package/dist/runtime/ConversationDrafts.d.ts +43 -0
- package/dist/runtime/ConversationDrafts.js +47 -0
- package/dist/runtime/RunRegistry.d.ts +79 -0
- package/dist/runtime/RunRegistry.js +108 -0
- package/dist/runtime/ScopeRegistry.d.ts +153 -0
- package/dist/runtime/ScopeRegistry.js +259 -0
- package/dist/runtime/TransientRunner.d.ts +90 -0
- package/dist/runtime/TransientRunner.js +179 -0
- package/dist/runtime/index.d.ts +14 -0
- package/dist/runtime/index.js +10 -0
- package/dist/services/AgentService.d.ts +150 -0
- package/dist/services/AgentService.js +718 -0
- package/dist/services/AssetService.d.ts +338 -0
- package/dist/services/AssetService.js +739 -0
- package/dist/services/AutoNaming.d.ts +92 -0
- package/dist/services/AutoNaming.js +235 -0
- package/dist/services/CompletionService.d.ts +122 -0
- package/dist/services/CompletionService.js +442 -0
- package/dist/services/ConfigService.d.ts +171 -0
- package/dist/services/ConfigService.js +695 -0
- package/dist/services/ConversationService.d.ts +274 -0
- package/dist/services/ConversationService.js +902 -0
- package/dist/services/CredentialService.d.ts +33 -0
- package/dist/services/CredentialService.js +86 -0
- package/dist/services/DiscoveryService.d.ts +182 -0
- package/dist/services/DiscoveryService.js +998 -0
- package/dist/services/HandoffService.d.ts +189 -0
- package/dist/services/HandoffService.js +414 -0
- package/dist/services/Logger.d.ts +130 -0
- package/dist/services/Logger.js +256 -0
- package/dist/services/MessageService.d.ts +88 -0
- package/dist/services/MessageService.js +280 -0
- package/dist/services/ModelService.d.ts +89 -0
- package/dist/services/ModelService.js +489 -0
- package/dist/services/NoticeService.d.ts +93 -0
- package/dist/services/NoticeService.js +217 -0
- package/dist/services/QuestionService.d.ts +184 -0
- package/dist/services/QuestionService.js +457 -0
- package/dist/services/SearchService.d.ts +54 -0
- package/dist/services/SearchService.js +117 -0
- package/dist/services/WorkspaceService.d.ts +96 -0
- package/dist/services/WorkspaceService.js +213 -0
- package/dist/services/imageSize.d.ts +41 -0
- package/dist/services/imageSize.js +146 -0
- package/dist/services/index.d.ts +20 -0
- package/dist/services/index.js +15 -0
- package/dist/services/secretMask.d.ts +24 -0
- package/dist/services/secretMask.js +34 -0
- package/dist/storage/AssetRepository.d.ts +87 -0
- package/dist/storage/AssetRepository.js +180 -0
- package/dist/storage/ConversationRepository.d.ts +55 -0
- package/dist/storage/ConversationRepository.js +128 -0
- package/dist/storage/CredentialRepository.d.ts +28 -0
- package/dist/storage/CredentialRepository.js +47 -0
- package/dist/storage/Database.d.ts +95 -0
- package/dist/storage/Database.js +180 -0
- package/dist/storage/MessageRepository.d.ts +104 -0
- package/dist/storage/MessageRepository.js +323 -0
- package/dist/storage/MetaRepository.d.ts +21 -0
- package/dist/storage/MetaRepository.js +33 -0
- package/dist/storage/NoticeRepository.d.ts +31 -0
- package/dist/storage/NoticeRepository.js +89 -0
- package/dist/storage/QuestionRepository.d.ts +34 -0
- package/dist/storage/QuestionRepository.js +96 -0
- package/dist/storage/WorkspaceRepository.d.ts +25 -0
- package/dist/storage/WorkspaceRepository.js +44 -0
- package/dist/storage/dbWorker.d.ts +10 -0
- package/dist/storage/dbWorker.js +132 -0
- package/dist/storage/index.d.ts +20 -0
- package/dist/storage/index.js +16 -0
- package/dist/storage/migrations.d.ts +26 -0
- package/dist/storage/migrations.js +207 -0
- package/dist/storage/types.d.ts +298 -0
- package/dist/storage/types.js +14 -0
- package/dist/wire/errors.d.ts +105 -0
- package/dist/wire/errors.js +143 -0
- package/dist/wire/events.d.ts +102 -0
- package/dist/wire/events.js +13 -0
- package/dist/wire/frames.d.ts +106 -0
- package/dist/wire/frames.js +156 -0
- package/dist/wire/index.d.ts +31 -0
- package/dist/wire/index.js +31 -0
- package/dist/wire/methods.d.ts +1896 -0
- package/dist/wire/methods.js +99 -0
- package/dist/wire/states.d.ts +487 -0
- package/dist/wire/states.js +61 -0
- package/package.json +63 -3
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 数据库门面:对外只有 `all` / `get` / `run` / `tx` / `close` 五个异步方法,内部把每条
|
|
3
|
+
* 请求变成一条 worker 消息,交给 `dbWorker.ts` 在独立线程里用同步的 `DatabaseSync` 执行。
|
|
4
|
+
*
|
|
5
|
+
* 主线程因此完全不碰 SQL 执行:不会阻塞事件循环,也不会有「一个慢查询卡住所有 WebSocket
|
|
6
|
+
* 连接」这种事。
|
|
7
|
+
*
|
|
8
|
+
* 实例由创建者持有并注入给各 Repository(依赖从单根出发),本模块不导出任何模块级单例。
|
|
9
|
+
*/
|
|
10
|
+
import { Worker } from "node:worker_threads";
|
|
11
|
+
/**
|
|
12
|
+
* worker 入口的文件名:源码直接跑(vitest / 类型剥离)时同目录是 `.ts`,产物里是 `.js`。
|
|
13
|
+
* 按当前模块自身的扩展名推导,两种运行方式都能找到同一个 worker。
|
|
14
|
+
*/
|
|
15
|
+
function resolveWorkerUrl() {
|
|
16
|
+
const self = new URL(import.meta.url);
|
|
17
|
+
const extension = self.pathname.endsWith(".ts") ? ".ts" : ".js";
|
|
18
|
+
return new URL(`./dbWorker${extension}`, self);
|
|
19
|
+
}
|
|
20
|
+
function toError(value) {
|
|
21
|
+
return value instanceof Error ? value : new Error(String(value));
|
|
22
|
+
}
|
|
23
|
+
function isStatementList(value) {
|
|
24
|
+
return Array.isArray(value);
|
|
25
|
+
}
|
|
26
|
+
/** 按凭据里的下标把结果对回调用方;下标越界说明凭据不是这一批登记的,直接报错 */
|
|
27
|
+
function pickResult(statement, results) {
|
|
28
|
+
if (statement.index >= results.length) {
|
|
29
|
+
throw new Error(`tx 结果缺少第 ${statement.index} 条语句(本批共 ${results.length} 条)`);
|
|
30
|
+
}
|
|
31
|
+
return results[statement.index];
|
|
32
|
+
}
|
|
33
|
+
function pickOutcome(plan, results) {
|
|
34
|
+
if (plan === undefined)
|
|
35
|
+
return undefined;
|
|
36
|
+
if (isStatementList(plan))
|
|
37
|
+
return plan.map((statement) => pickResult(statement, results));
|
|
38
|
+
return pickResult(plan, results);
|
|
39
|
+
}
|
|
40
|
+
export class Database {
|
|
41
|
+
dbPath;
|
|
42
|
+
worker = null;
|
|
43
|
+
nextId = 1;
|
|
44
|
+
pending = new Map();
|
|
45
|
+
closed = false;
|
|
46
|
+
closing = null;
|
|
47
|
+
/** @param dbPath SQLite 数据库文件路径 */
|
|
48
|
+
constructor(dbPath) {
|
|
49
|
+
this.dbPath = dbPath;
|
|
50
|
+
}
|
|
51
|
+
/** 多行查询 */
|
|
52
|
+
all(sql, params = []) {
|
|
53
|
+
return this.dispatch({ kind: "all", sql, params });
|
|
54
|
+
}
|
|
55
|
+
/** 单行查询;没有命中的行时返回 `undefined` */
|
|
56
|
+
get(sql, params = []) {
|
|
57
|
+
return this.dispatch({ kind: "get", sql, params });
|
|
58
|
+
}
|
|
59
|
+
/** 写语句(INSERT / UPDATE / DELETE) */
|
|
60
|
+
run(sql, params = []) {
|
|
61
|
+
return this.dispatch({ kind: "run", sql, params });
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* 事务:回调里用 `client.all` / `get` / `run` 登记语句,回调返回后整批在 worker 里
|
|
65
|
+
* `BEGIN IMMEDIATE` → 按登记顺序逐条执行 → `COMMIT`,任一条失败就整体 ROLLBACK。
|
|
66
|
+
*
|
|
67
|
+
* 回调返回的凭据决定拿回哪些结果,所以「先读后写」是一次往返、一个事务:
|
|
68
|
+
*
|
|
69
|
+
* ```ts
|
|
70
|
+
* const [found, written] = await db.tx((q) => [
|
|
71
|
+
* q.get<{ n: number }>("SELECT n FROM t WHERE id = ?", [id]),
|
|
72
|
+
* q.run("UPDATE t SET n = n + 1 WHERE id = ?", [id]),
|
|
73
|
+
* ]);
|
|
74
|
+
* ```
|
|
75
|
+
*
|
|
76
|
+
* 回调抛错时还什么都没发出去,等于什么都没写,直接向上抛。
|
|
77
|
+
*/
|
|
78
|
+
async tx(fn) {
|
|
79
|
+
this.assertOpen();
|
|
80
|
+
const batch = [];
|
|
81
|
+
const register = (kind, sql, params) => {
|
|
82
|
+
const index = batch.length;
|
|
83
|
+
batch.push({ kind, sql, params });
|
|
84
|
+
return { index };
|
|
85
|
+
};
|
|
86
|
+
const client = {
|
|
87
|
+
all: (sql, params = []) => register("all", sql, params),
|
|
88
|
+
get: (sql, params = []) => register("get", sql, params),
|
|
89
|
+
run: (sql, params = []) => register("run", sql, params),
|
|
90
|
+
};
|
|
91
|
+
const plan = await fn(client);
|
|
92
|
+
const results = batch.length === 0
|
|
93
|
+
? []
|
|
94
|
+
: (await this.dispatch({ kind: "batch", statements: batch }));
|
|
95
|
+
return pickOutcome(plan, results);
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* 关闭:先拒绝所有挂起的请求、把实例标记为已关闭(之后任何调用都直接 reject),
|
|
99
|
+
* 再请 worker 关库退出;worker 没在超时内退出就强制终止,避免 `close()` 自己悬挂。
|
|
100
|
+
*/
|
|
101
|
+
close() {
|
|
102
|
+
this.closing ??= this.doClose();
|
|
103
|
+
return this.closing;
|
|
104
|
+
}
|
|
105
|
+
async doClose() {
|
|
106
|
+
this.closed = true;
|
|
107
|
+
const worker = this.worker;
|
|
108
|
+
this.worker = null;
|
|
109
|
+
// 关库时仍在排队的请求一律以「已关闭」失败,不让调用方拿到永远不 resolve 的 Promise
|
|
110
|
+
this.failAll(new Error("Database 已关闭,不再接受调用"));
|
|
111
|
+
if (!worker)
|
|
112
|
+
return;
|
|
113
|
+
await new Promise((resolve) => {
|
|
114
|
+
const timer = setTimeout(() => {
|
|
115
|
+
void worker.terminate().then(() => resolve(), () => resolve());
|
|
116
|
+
}, 2000);
|
|
117
|
+
worker.once("exit", () => {
|
|
118
|
+
clearTimeout(timer);
|
|
119
|
+
resolve();
|
|
120
|
+
});
|
|
121
|
+
try {
|
|
122
|
+
worker.postMessage({ id: this.nextId++, kind: "close" });
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
void worker.terminate().then(() => resolve(), () => resolve());
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
assertOpen() {
|
|
130
|
+
if (this.closed)
|
|
131
|
+
throw new Error("Database 已关闭,不再接受调用");
|
|
132
|
+
}
|
|
133
|
+
/** 统一执行通道:分配请求 id、挂起 Promise、把消息交给 worker */
|
|
134
|
+
dispatch(payload) {
|
|
135
|
+
if (this.closed)
|
|
136
|
+
return Promise.reject(new Error("Database 已关闭,不再接受调用"));
|
|
137
|
+
const id = this.nextId++;
|
|
138
|
+
return new Promise((resolve, reject) => {
|
|
139
|
+
this.pending.set(id, { resolve, reject });
|
|
140
|
+
try {
|
|
141
|
+
this.workerHandle().postMessage({ ...payload, id });
|
|
142
|
+
}
|
|
143
|
+
catch (error) {
|
|
144
|
+
// 消息本身没发出去(worker 已终止等),立刻失败,不留悬挂项
|
|
145
|
+
this.pending.delete(id);
|
|
146
|
+
reject(toError(error));
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
/** 懒启动:第一次真正要碰数据库时才起 worker */
|
|
151
|
+
workerHandle() {
|
|
152
|
+
if (this.worker)
|
|
153
|
+
return this.worker;
|
|
154
|
+
const worker = new Worker(resolveWorkerUrl(), {
|
|
155
|
+
workerData: { dbPath: this.dbPath },
|
|
156
|
+
});
|
|
157
|
+
worker.on("message", (response) => this.settle(response));
|
|
158
|
+
// worker 崩溃 / 退出:把所有挂起请求 reject 掉,否则调用方会一直等下去
|
|
159
|
+
worker.on("error", (error) => this.failAll(toError(error)));
|
|
160
|
+
worker.on("exit", () => this.failAll(new Error("SQLite worker 已退出")));
|
|
161
|
+
this.worker = worker;
|
|
162
|
+
return worker;
|
|
163
|
+
}
|
|
164
|
+
settle(response) {
|
|
165
|
+
const pending = this.pending.get(response.id);
|
|
166
|
+
if (!pending)
|
|
167
|
+
return; // 关闭流程里的回执等,没有对应的等待者
|
|
168
|
+
this.pending.delete(response.id);
|
|
169
|
+
if (response.ok)
|
|
170
|
+
pending.resolve(response.result);
|
|
171
|
+
else
|
|
172
|
+
pending.reject(new Error(response.error));
|
|
173
|
+
}
|
|
174
|
+
failAll(error) {
|
|
175
|
+
for (const pending of this.pending.values())
|
|
176
|
+
pending.reject(error);
|
|
177
|
+
this.pending.clear();
|
|
178
|
+
this.worker = null;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `messages` 表仓储:消息读写 + 全文检索。
|
|
3
|
+
*
|
|
4
|
+
* 三处与别处不同的地方:
|
|
5
|
+
*
|
|
6
|
+
* 1. **边流边写**:一轮生成过程中同一条助手消息会被反复写(PRD D15)。`upsert` 按 id
|
|
7
|
+
* 覆盖正文 / 状态 / omit,但 `seq` 与 `createdAt` 保留第一次写入时的值 —— 否则消息
|
|
8
|
+
* 会在流式过程中不断「往后跳」。
|
|
9
|
+
*
|
|
10
|
+
* 2. **全文检索的分词现实**:FTS5 表用 `tokenize='trigram'`(见 migrations.ts),
|
|
11
|
+
* 中文子串才搜得到;代价是查询词至少要 3 个字符,少于 3 个字符(典型是两字中文)
|
|
12
|
+
* 在 trigram 下必然搜不到,这里退化到 `LIKE` 子串匹配。
|
|
13
|
+
*
|
|
14
|
+
* 3. **子 Agent 的归属**:`parentToolCallId` 记下这条消息是被哪次工具调用派出去产生的
|
|
15
|
+
* (R37)。怎么写入由上层决定,本文件提供按它取回的一段读取路径。
|
|
16
|
+
*
|
|
17
|
+
* 4. **`hidden` 的两条读数**:`hidden` 是 core 的 `Message.hidden`,落在 `data` 列的 JSON 里
|
|
18
|
+
* (没有单独的列)。给用户看的读取路径(`message.list`、侧栏条数、删除代价)默认把它
|
|
19
|
+
* 排除;要连它一起取的地方(模型的历史、排查问题)显式传 `includeHidden`。
|
|
20
|
+
*
|
|
21
|
+
* FTS 索引的同步交给 `messages` 上的触发器(同样在 migrations.ts 里建),所以本文件
|
|
22
|
+
* 写消息时只写 `messages` 表,不需要额外维护索引。
|
|
23
|
+
*/
|
|
24
|
+
import type { Database } from "./Database.js";
|
|
25
|
+
import type { MessageSearchHit, MessageSearchScope, MessageUpsertInput, StoredMessage } from "./types.js";
|
|
26
|
+
/** 读消息时的选项 */
|
|
27
|
+
export interface MessageListOptions {
|
|
28
|
+
/** 连 `hidden` 的消息一起取(默认不取:`hidden` 表示这条不给人看) */
|
|
29
|
+
includeHidden?: boolean;
|
|
30
|
+
}
|
|
31
|
+
export declare class MessageRepository {
|
|
32
|
+
private readonly db;
|
|
33
|
+
constructor(db: Database);
|
|
34
|
+
/**
|
|
35
|
+
* 写入或覆盖一条消息。
|
|
36
|
+
*
|
|
37
|
+
* 同一条消息(同 id)重复调用即「覆盖最新内容 + 状态」,`seq` / `createdAt` 不变。
|
|
38
|
+
*/
|
|
39
|
+
upsert(input: MessageUpsertInput): Promise<void>;
|
|
40
|
+
/**
|
|
41
|
+
* 批量写入(一轮结束时的「写一次」):整批放在一个事务里,避免中途断开留下半轮历史。
|
|
42
|
+
*/
|
|
43
|
+
upsertMany(inputs: MessageUpsertInput[]): Promise<void>;
|
|
44
|
+
get(id: string): Promise<StoredMessage | undefined>;
|
|
45
|
+
/**
|
|
46
|
+
* 按对话取消息(architecture.md A12 的 `message.list` 口径)。
|
|
47
|
+
*
|
|
48
|
+
* - 不给 `after`:整段历史,按 `seq` 升序。
|
|
49
|
+
* - 给了 `after` 且这个 id 存在:**从它本身开始**(含它,内容是最新的)+ 之后全部。
|
|
50
|
+
* 故意不从它的下一条开始 —— 重连的客户端上次拿到的最后一条可能正是流式中那条,
|
|
51
|
+
* 它的内容已经变了,得重新拿最新的。
|
|
52
|
+
* - 给了 `after` 但库里没有这个 id(已删除、或换了库):退回整段历史。
|
|
53
|
+
*
|
|
54
|
+
* 三条都只覆盖**主线**消息:子 Agent 的消息虽然落在同一张表,但它们属于另一次对话
|
|
55
|
+
* 与另一步执行(`parentToolCallId` 非空),回看走 {@link listByParentToolCallId},
|
|
56
|
+
* 不能混进主线历史(否则模型下一轮会把别人的对话当成自己的上下文)。
|
|
57
|
+
*
|
|
58
|
+
* 三种情况收敛成一条 SQL:找不到时 `COALESCE` 把下界降成 -1,条件恒真。
|
|
59
|
+
*
|
|
60
|
+
* 这里数出来的是**主线**的条数(`parentToolCallId IS NULL` 那一半):子 Agent 的消息落在同一张
|
|
61
|
+
* 表、但有非空的 `parentToolCallId`,不在本方法的结果里。同样是「这段对话有多少条消息」,
|
|
62
|
+
* {@link countByConversation} 数的是表里的全部行(不筛那一列,只筛 `hidden`),所以一段跑过
|
|
63
|
+
* 子 Agent 的对话,`listByConversation().length` 会小于 `countByConversation()`。
|
|
64
|
+
*
|
|
65
|
+
* `hidden` 的消息默认不在结果里(侧栏条数、`message.list` 都用这条默认)。要看的那一侧
|
|
66
|
+
* (模型的历史、排查)传 `includeHidden`。
|
|
67
|
+
*/
|
|
68
|
+
listByConversation(conversationId: string, after?: string, options?: MessageListOptions): Promise<StoredMessage[]>;
|
|
69
|
+
/**
|
|
70
|
+
* 按父工具调用取子 Agent 的消息(R37 提供的存储能力)。
|
|
71
|
+
*
|
|
72
|
+
* 按对话限定范围:工具调用 id 只在它所属的那段对话里有意义。
|
|
73
|
+
*/
|
|
74
|
+
listByParentToolCallId(conversationId: string, parentToolCallId: string, options?: MessageListOptions): Promise<StoredMessage[]>;
|
|
75
|
+
/**
|
|
76
|
+
* 全文搜索(PRD D16:按聊天内容、可只搜一个 Workspace)。
|
|
77
|
+
*
|
|
78
|
+
* @param scope 一段对话内 / 一个 Workspace 下 / 不限(`undefined` 由调用方显式传入
|
|
79
|
+
* 表示不限;本层不替调用方决定「不给条件就搜全库」是不是合理)
|
|
80
|
+
* @param keyword 查询词:长度 >= 3 走 FTS5 trigram,< 3 走 LIKE 子串
|
|
81
|
+
* @param limit 上限
|
|
82
|
+
*/
|
|
83
|
+
search(scope: MessageSearchScope, keyword: string, limit: number): Promise<MessageSearchHit[]>;
|
|
84
|
+
/**
|
|
85
|
+
* 硬删一段对话里排在指定消息之后的消息(PRD D24 的撤回)。
|
|
86
|
+
* `includeSelf` 为真时连那条消息一起删。
|
|
87
|
+
*
|
|
88
|
+
* 锚点不在该对话里时 `seq` 子查询为 NULL,条件不成立,一条也不会删。
|
|
89
|
+
*/
|
|
90
|
+
deleteAfter(conversationId: string, messageId: string, options?: {
|
|
91
|
+
includeSelf?: boolean;
|
|
92
|
+
}): Promise<number>;
|
|
93
|
+
/**
|
|
94
|
+
* 数一段对话在 `messages` 表里的全部行数(PRD D17 / F-F9 的代价一侧用它)。
|
|
95
|
+
*
|
|
96
|
+
* 与 {@link listByConversation} 的差别就在 `parentToolCallId` 这一列:本方法不筛它,所以子 Agent
|
|
97
|
+
* 的消息也算在内。同一个问题(「这段对话有多少条消息」)于是有两个不同的数,两处的用途不同:
|
|
98
|
+
* 这里要的是「删掉会让库里少多少行」,那边要的是「主线里能回看多少条」。
|
|
99
|
+
*
|
|
100
|
+
* `hidden` 的消息两边都不算:它在界面上从来没出现过,删掉它数进代价里会让用户看到
|
|
101
|
+
* 一个对不上的数字。
|
|
102
|
+
*/
|
|
103
|
+
countByConversation(conversationId: string): Promise<number>;
|
|
104
|
+
}
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `messages` 表仓储:消息读写 + 全文检索。
|
|
3
|
+
*
|
|
4
|
+
* 三处与别处不同的地方:
|
|
5
|
+
*
|
|
6
|
+
* 1. **边流边写**:一轮生成过程中同一条助手消息会被反复写(PRD D15)。`upsert` 按 id
|
|
7
|
+
* 覆盖正文 / 状态 / omit,但 `seq` 与 `createdAt` 保留第一次写入时的值 —— 否则消息
|
|
8
|
+
* 会在流式过程中不断「往后跳」。
|
|
9
|
+
*
|
|
10
|
+
* 2. **全文检索的分词现实**:FTS5 表用 `tokenize='trigram'`(见 migrations.ts),
|
|
11
|
+
* 中文子串才搜得到;代价是查询词至少要 3 个字符,少于 3 个字符(典型是两字中文)
|
|
12
|
+
* 在 trigram 下必然搜不到,这里退化到 `LIKE` 子串匹配。
|
|
13
|
+
*
|
|
14
|
+
* 3. **子 Agent 的归属**:`parentToolCallId` 记下这条消息是被哪次工具调用派出去产生的
|
|
15
|
+
* (R37)。怎么写入由上层决定,本文件提供按它取回的一段读取路径。
|
|
16
|
+
*
|
|
17
|
+
* 4. **`hidden` 的两条读数**:`hidden` 是 core 的 `Message.hidden`,落在 `data` 列的 JSON 里
|
|
18
|
+
* (没有单独的列)。给用户看的读取路径(`message.list`、侧栏条数、删除代价)默认把它
|
|
19
|
+
* 排除;要连它一起取的地方(模型的历史、排查问题)显式传 `includeHidden`。
|
|
20
|
+
*
|
|
21
|
+
* FTS 索引的同步交给 `messages` 上的触发器(同样在 migrations.ts 里建),所以本文件
|
|
22
|
+
* 写消息时只写 `messages` 表,不需要额外维护索引。
|
|
23
|
+
*/
|
|
24
|
+
/** trigram 分词器能匹配的最小查询长度(按字符算,不是字节) */
|
|
25
|
+
const TRIGRAM_MIN_LENGTH = 3;
|
|
26
|
+
/** 搜索结果的片段长度上限 */
|
|
27
|
+
const SNIPPET_LENGTH = 160;
|
|
28
|
+
const COLUMNS = "id, conversationId, seq, role, status, omit, content, data, searchText, parentToolCallId, createdAt, updatedAt";
|
|
29
|
+
/**
|
|
30
|
+
* 「这条消息给用户看」的条件:`data.hidden` 不为真。
|
|
31
|
+
*
|
|
32
|
+
* `hidden` 在 `data` 这列 JSON 里,所以判断要读一次 JSON。`data` 为空或不是合法 JSON 时
|
|
33
|
+
* 按「不是隐藏」算 —— 一条坏数据不该让整条消息在界面上凭空消失。
|
|
34
|
+
*/
|
|
35
|
+
const VISIBLE_ONLY = `(CASE WHEN json_valid(data) THEN json_extract(data, '$.hidden') ELSE 0 END) IS NOT 1`;
|
|
36
|
+
/**
|
|
37
|
+
* 写入语句:`seq` 取同对话当前最大值 + 1;同一 id 再写时只覆盖内容类字段。
|
|
38
|
+
*
|
|
39
|
+
* `MAX(seq)` 与插入在同一条语句里完成,worker 又是单线程串行执行,所以不会出现两条
|
|
40
|
+
* 消息抢到同一个 seq(`UNIQUE (conversationId, seq)` 是最后一道防线)。
|
|
41
|
+
*
|
|
42
|
+
* `parentToolCallId` 在冲突分支里用 `COALESCE`:流式过程里同一条消息会被反复写,
|
|
43
|
+
* 不带上这一列的那些写不会把已经记下的归属抹掉。
|
|
44
|
+
*/
|
|
45
|
+
const UPSERT_SQL = `
|
|
46
|
+
INSERT INTO messages (${COLUMNS})
|
|
47
|
+
VALUES (
|
|
48
|
+
?,
|
|
49
|
+
?,
|
|
50
|
+
(SELECT COALESCE(MAX(seq), 0) + 1 FROM messages WHERE conversationId = ?),
|
|
51
|
+
?, ?, ?, ?, ?, ?, ?,
|
|
52
|
+
?, ?
|
|
53
|
+
)
|
|
54
|
+
ON CONFLICT (id) DO UPDATE SET
|
|
55
|
+
role = excluded.role,
|
|
56
|
+
status = excluded.status,
|
|
57
|
+
omit = excluded.omit,
|
|
58
|
+
content = excluded.content,
|
|
59
|
+
data = excluded.data,
|
|
60
|
+
searchText = excluded.searchText,
|
|
61
|
+
parentToolCallId = COALESCE(excluded.parentToolCallId, messages.parentToolCallId),
|
|
62
|
+
updatedAt = excluded.updatedAt
|
|
63
|
+
`;
|
|
64
|
+
/**
|
|
65
|
+
* 从消息正文里抽出可检索的纯文本。
|
|
66
|
+
*
|
|
67
|
+
* 只取文本段:JSON 结构(工具调用的参数、用量等)不进索引,否则搜 "id"、"type"
|
|
68
|
+
* 这类词会把所有工具调用消息都捞出来,反而搜不准。
|
|
69
|
+
*/
|
|
70
|
+
function extractSearchText(content) {
|
|
71
|
+
if (content === null)
|
|
72
|
+
return "";
|
|
73
|
+
if (typeof content === "string")
|
|
74
|
+
return content;
|
|
75
|
+
const parts = [];
|
|
76
|
+
for (const section of content) {
|
|
77
|
+
// 库里的内容是外部写入的 JSON,形状不保证合 core 的类型:非文本段不参与索引
|
|
78
|
+
if (section.type === "text" && typeof section.text === "string")
|
|
79
|
+
parts.push(section.text);
|
|
80
|
+
}
|
|
81
|
+
return parts.join("\n");
|
|
82
|
+
}
|
|
83
|
+
function toSearchText(content) {
|
|
84
|
+
return extractSearchText(content).replace(/\s+/g, " ").trim();
|
|
85
|
+
}
|
|
86
|
+
function parseJson(value) {
|
|
87
|
+
if (value === null)
|
|
88
|
+
return null;
|
|
89
|
+
try {
|
|
90
|
+
return JSON.parse(value);
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
// 库里出现坏 JSON 时不让整条记录读不出来,原样当字符串返回
|
|
94
|
+
return value;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* `content` 列的反序列化:core 的 `MessageContent` 就是「字符串,或内容段数组」。
|
|
99
|
+
*
|
|
100
|
+
* 两种形状原样返回(R30:不在仓储层重建内容段);其它情况(空列、坏数据)归为「没有正文」。
|
|
101
|
+
*/
|
|
102
|
+
function toContent(value) {
|
|
103
|
+
if (typeof value === "string")
|
|
104
|
+
return value;
|
|
105
|
+
if (Array.isArray(value))
|
|
106
|
+
return value;
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
function toStoredMessage(row) {
|
|
110
|
+
const data = parseJson(row.data);
|
|
111
|
+
return {
|
|
112
|
+
id: row.id,
|
|
113
|
+
conversationId: row.conversationId,
|
|
114
|
+
parentToolCallId: row.parentToolCallId,
|
|
115
|
+
seq: row.seq,
|
|
116
|
+
role: row.role,
|
|
117
|
+
status: row.status,
|
|
118
|
+
omit: row.omit !== 0,
|
|
119
|
+
content: toContent(parseJson(row.content)),
|
|
120
|
+
data: typeof data === "object" && data !== null && !Array.isArray(data)
|
|
121
|
+
? data
|
|
122
|
+
: null,
|
|
123
|
+
createdAt: row.createdAt,
|
|
124
|
+
updatedAt: row.updatedAt,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
/** 建 upsert 的绑定参数数组(顺序与 UPSERT_SQL 的占位符一一对应) */
|
|
128
|
+
function upsertParams(input) {
|
|
129
|
+
const searchText = toSearchText(input.content ?? null);
|
|
130
|
+
const contentJson = JSON.stringify(input.content ?? null);
|
|
131
|
+
const dataJson = input.data === undefined || input.data === null ? null : JSON.stringify(input.data);
|
|
132
|
+
return [
|
|
133
|
+
input.id,
|
|
134
|
+
input.conversationId,
|
|
135
|
+
input.conversationId,
|
|
136
|
+
input.role,
|
|
137
|
+
input.status,
|
|
138
|
+
input.omit ? 1 : 0,
|
|
139
|
+
contentJson,
|
|
140
|
+
dataJson,
|
|
141
|
+
searchText,
|
|
142
|
+
input.parentToolCallId ?? null,
|
|
143
|
+
input.createdAt,
|
|
144
|
+
input.createdAt,
|
|
145
|
+
];
|
|
146
|
+
}
|
|
147
|
+
/** FTS5 的查询串:整体加引号当作短语,用户输入里的特殊符号就不会被当成查询语法 */
|
|
148
|
+
function toMatchPhrase(keyword) {
|
|
149
|
+
return `"${keyword.replace(/"/g, '""')}"`;
|
|
150
|
+
}
|
|
151
|
+
/** LIKE 的通配符转义 */
|
|
152
|
+
function escapeLike(keyword) {
|
|
153
|
+
return keyword.replace(/[\\%_]/g, (match) => `\\${match}`);
|
|
154
|
+
}
|
|
155
|
+
export class MessageRepository {
|
|
156
|
+
db;
|
|
157
|
+
constructor(db) {
|
|
158
|
+
this.db = db;
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* 写入或覆盖一条消息。
|
|
162
|
+
*
|
|
163
|
+
* 同一条消息(同 id)重复调用即「覆盖最新内容 + 状态」,`seq` / `createdAt` 不变。
|
|
164
|
+
*/
|
|
165
|
+
async upsert(input) {
|
|
166
|
+
await this.db.run(UPSERT_SQL, upsertParams(input));
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* 批量写入(一轮结束时的「写一次」):整批放在一个事务里,避免中途断开留下半轮历史。
|
|
170
|
+
*/
|
|
171
|
+
async upsertMany(inputs) {
|
|
172
|
+
if (inputs.length === 0)
|
|
173
|
+
return;
|
|
174
|
+
await this.db.tx((tx) => {
|
|
175
|
+
for (const input of inputs)
|
|
176
|
+
tx.run(UPSERT_SQL, upsertParams(input));
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
async get(id) {
|
|
180
|
+
const row = await this.db.get(`SELECT ${COLUMNS} FROM messages WHERE id = ?`, [id]);
|
|
181
|
+
return row ? toStoredMessage(row) : undefined;
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* 按对话取消息(architecture.md A12 的 `message.list` 口径)。
|
|
185
|
+
*
|
|
186
|
+
* - 不给 `after`:整段历史,按 `seq` 升序。
|
|
187
|
+
* - 给了 `after` 且这个 id 存在:**从它本身开始**(含它,内容是最新的)+ 之后全部。
|
|
188
|
+
* 故意不从它的下一条开始 —— 重连的客户端上次拿到的最后一条可能正是流式中那条,
|
|
189
|
+
* 它的内容已经变了,得重新拿最新的。
|
|
190
|
+
* - 给了 `after` 但库里没有这个 id(已删除、或换了库):退回整段历史。
|
|
191
|
+
*
|
|
192
|
+
* 三条都只覆盖**主线**消息:子 Agent 的消息虽然落在同一张表,但它们属于另一次对话
|
|
193
|
+
* 与另一步执行(`parentToolCallId` 非空),回看走 {@link listByParentToolCallId},
|
|
194
|
+
* 不能混进主线历史(否则模型下一轮会把别人的对话当成自己的上下文)。
|
|
195
|
+
*
|
|
196
|
+
* 三种情况收敛成一条 SQL:找不到时 `COALESCE` 把下界降成 -1,条件恒真。
|
|
197
|
+
*
|
|
198
|
+
* 这里数出来的是**主线**的条数(`parentToolCallId IS NULL` 那一半):子 Agent 的消息落在同一张
|
|
199
|
+
* 表、但有非空的 `parentToolCallId`,不在本方法的结果里。同样是「这段对话有多少条消息」,
|
|
200
|
+
* {@link countByConversation} 数的是表里的全部行(不筛那一列,只筛 `hidden`),所以一段跑过
|
|
201
|
+
* 子 Agent 的对话,`listByConversation().length` 会小于 `countByConversation()`。
|
|
202
|
+
*
|
|
203
|
+
* `hidden` 的消息默认不在结果里(侧栏条数、`message.list` 都用这条默认)。要看的那一侧
|
|
204
|
+
* (模型的历史、排查)传 `includeHidden`。
|
|
205
|
+
*/
|
|
206
|
+
async listByConversation(conversationId, after, options = {}) {
|
|
207
|
+
const rows = await this.db.all(`SELECT ${COLUMNS} FROM messages
|
|
208
|
+
WHERE conversationId = ?
|
|
209
|
+
AND parentToolCallId IS NULL
|
|
210
|
+
AND (? = 1 OR ${VISIBLE_ONLY})
|
|
211
|
+
AND (? IS NULL
|
|
212
|
+
OR seq >= COALESCE(
|
|
213
|
+
(SELECT seq FROM messages WHERE id = ? AND conversationId = ?),
|
|
214
|
+
-1))
|
|
215
|
+
ORDER BY seq ASC`, [
|
|
216
|
+
conversationId,
|
|
217
|
+
options.includeHidden === true ? 1 : 0,
|
|
218
|
+
after ?? null,
|
|
219
|
+
after ?? null,
|
|
220
|
+
conversationId,
|
|
221
|
+
]);
|
|
222
|
+
return rows.map(toStoredMessage);
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* 按父工具调用取子 Agent 的消息(R37 提供的存储能力)。
|
|
226
|
+
*
|
|
227
|
+
* 按对话限定范围:工具调用 id 只在它所属的那段对话里有意义。
|
|
228
|
+
*/
|
|
229
|
+
async listByParentToolCallId(conversationId, parentToolCallId, options = {}) {
|
|
230
|
+
const rows = await this.db.all(`SELECT ${COLUMNS} FROM messages
|
|
231
|
+
WHERE conversationId = ? AND parentToolCallId = ?
|
|
232
|
+
AND (? = 1 OR ${VISIBLE_ONLY})
|
|
233
|
+
ORDER BY seq ASC`, [conversationId, parentToolCallId, options.includeHidden === true ? 1 : 0]);
|
|
234
|
+
return rows.map(toStoredMessage);
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* 全文搜索(PRD D16:按聊天内容、可只搜一个 Workspace)。
|
|
238
|
+
*
|
|
239
|
+
* @param scope 一段对话内 / 一个 Workspace 下 / 不限(`undefined` 由调用方显式传入
|
|
240
|
+
* 表示不限;本层不替调用方决定「不给条件就搜全库」是不是合理)
|
|
241
|
+
* @param keyword 查询词:长度 >= 3 走 FTS5 trigram,< 3 走 LIKE 子串
|
|
242
|
+
* @param limit 上限
|
|
243
|
+
*/
|
|
244
|
+
async search(scope, keyword, limit) {
|
|
245
|
+
const trimmed = keyword.trim();
|
|
246
|
+
if (trimmed.length === 0)
|
|
247
|
+
return [];
|
|
248
|
+
// 片段取命中位置前面 40 个字符开始的一小段;没命中(FTS 命中但整词不在、或极端长词)
|
|
249
|
+
// 就退回头部一小段。两处都写成 `m.searchText`:FTS 分支里两张表都有 searchText 列,
|
|
250
|
+
// 不限定会直接报 ambiguous column(歧义列)
|
|
251
|
+
const snippet = `CASE
|
|
252
|
+
WHEN instr(m.searchText, ?) > 0
|
|
253
|
+
THEN substr(m.searchText, max(1, instr(m.searchText, ?) - 40), ${SNIPPET_LENGTH})
|
|
254
|
+
ELSE substr(m.searchText, 1, ${SNIPPET_LENGTH})
|
|
255
|
+
END AS snippet`;
|
|
256
|
+
// trigram 至少要 3 个字符,短查询用它只会得到空结果,直接换 LIKE
|
|
257
|
+
const useFts = [...trimmed].length >= TRIGRAM_MIN_LENGTH;
|
|
258
|
+
const conditions = [];
|
|
259
|
+
const params = [];
|
|
260
|
+
if (useFts) {
|
|
261
|
+
conditions.push("messages_fts MATCH ?");
|
|
262
|
+
params.push(toMatchPhrase(trimmed));
|
|
263
|
+
}
|
|
264
|
+
else {
|
|
265
|
+
conditions.push("m.searchText LIKE ? ESCAPE '\\'");
|
|
266
|
+
params.push(`%${escapeLike(trimmed)}%`);
|
|
267
|
+
}
|
|
268
|
+
if (scope && "conversationId" in scope) {
|
|
269
|
+
conditions.push("m.conversationId = ?");
|
|
270
|
+
params.push(scope.conversationId);
|
|
271
|
+
}
|
|
272
|
+
else if (scope && "workspaceId" in scope) {
|
|
273
|
+
conditions.push("c.workspaceId = ?");
|
|
274
|
+
params.push(scope.workspaceId);
|
|
275
|
+
}
|
|
276
|
+
const sql = useFts
|
|
277
|
+
? `SELECT m.id, m.conversationId, c.name AS conversationName, c.workspaceId AS workspaceId,
|
|
278
|
+
m.role, m.createdAt, ${snippet}
|
|
279
|
+
FROM messages_fts
|
|
280
|
+
JOIN messages m ON m.rowid = messages_fts.rowid
|
|
281
|
+
JOIN conversations c ON c.id = m.conversationId
|
|
282
|
+
WHERE ${conditions.join(" AND ")}
|
|
283
|
+
ORDER BY m.createdAt DESC, m.seq DESC
|
|
284
|
+
LIMIT ?`
|
|
285
|
+
: `SELECT m.id, m.conversationId, c.name AS conversationName, c.workspaceId AS workspaceId,
|
|
286
|
+
m.role, m.createdAt, ${snippet}
|
|
287
|
+
FROM messages m
|
|
288
|
+
JOIN conversations c ON c.id = m.conversationId
|
|
289
|
+
WHERE ${conditions.join(" AND ")}
|
|
290
|
+
ORDER BY m.createdAt DESC, m.seq DESC
|
|
291
|
+
LIMIT ?`;
|
|
292
|
+
return this.db.all(sql, [trimmed, trimmed, ...params, limit]);
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* 硬删一段对话里排在指定消息之后的消息(PRD D24 的撤回)。
|
|
296
|
+
* `includeSelf` 为真时连那条消息一起删。
|
|
297
|
+
*
|
|
298
|
+
* 锚点不在该对话里时 `seq` 子查询为 NULL,条件不成立,一条也不会删。
|
|
299
|
+
*/
|
|
300
|
+
async deleteAfter(conversationId, messageId, options = {}) {
|
|
301
|
+
const comparison = options.includeSelf ? ">=" : ">";
|
|
302
|
+
const result = await this.db.run(`DELETE FROM messages
|
|
303
|
+
WHERE conversationId = ?
|
|
304
|
+
AND seq ${comparison} (
|
|
305
|
+
SELECT seq FROM messages WHERE id = ? AND conversationId = ?
|
|
306
|
+
)`, [conversationId, messageId, conversationId]);
|
|
307
|
+
return result.changes;
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* 数一段对话在 `messages` 表里的全部行数(PRD D17 / F-F9 的代价一侧用它)。
|
|
311
|
+
*
|
|
312
|
+
* 与 {@link listByConversation} 的差别就在 `parentToolCallId` 这一列:本方法不筛它,所以子 Agent
|
|
313
|
+
* 的消息也算在内。同一个问题(「这段对话有多少条消息」)于是有两个不同的数,两处的用途不同:
|
|
314
|
+
* 这里要的是「删掉会让库里少多少行」,那边要的是「主线里能回看多少条」。
|
|
315
|
+
*
|
|
316
|
+
* `hidden` 的消息两边都不算:它在界面上从来没出现过,删掉它数进代价里会让用户看到
|
|
317
|
+
* 一个对不上的数字。
|
|
318
|
+
*/
|
|
319
|
+
async countByConversation(conversationId) {
|
|
320
|
+
const row = await this.db.get(`SELECT count(*) AS n FROM messages WHERE conversationId = ? AND ${VISIBLE_ONLY}`, [conversationId]);
|
|
321
|
+
return row?.n ?? 0;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `meta` 表仓储:库自身的元信息(schema 版本、建库时间)。
|
|
3
|
+
*
|
|
4
|
+
* 表是单行表(`id` 固定 1)。版本号的推进只能由 `migrations.migrate` 在事务里完成,
|
|
5
|
+
* 这里只提供读取 —— 否则「结构没改、版本号先动了」这种状态很容易被别的代码造出来。
|
|
6
|
+
*/
|
|
7
|
+
import type { Database } from "./Database.js";
|
|
8
|
+
import type { MetaRow } from "./types.js";
|
|
9
|
+
export declare class MetaRepository {
|
|
10
|
+
private readonly db;
|
|
11
|
+
constructor(db: Database);
|
|
12
|
+
/**
|
|
13
|
+
* 当前 schema 版本。库里还没有 `meta` 表(全新库)时返回 0,
|
|
14
|
+
* 让 `migrate` 从第一级开始跑,而不是先抛一个「表不存在」。
|
|
15
|
+
*/
|
|
16
|
+
getSchemaVersion(): Promise<number>;
|
|
17
|
+
/** 元信息整行;全新库(表或行还不存在)返回 `undefined` */
|
|
18
|
+
getState(): Promise<MetaRow | undefined>;
|
|
19
|
+
/** 表存不存在:全新库还没跑过任何迁移,直接查 `meta` 会报「no such table」 */
|
|
20
|
+
private hasMetaTable;
|
|
21
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `meta` 表仓储:库自身的元信息(schema 版本、建库时间)。
|
|
3
|
+
*
|
|
4
|
+
* 表是单行表(`id` 固定 1)。版本号的推进只能由 `migrations.migrate` 在事务里完成,
|
|
5
|
+
* 这里只提供读取 —— 否则「结构没改、版本号先动了」这种状态很容易被别的代码造出来。
|
|
6
|
+
*/
|
|
7
|
+
export class MetaRepository {
|
|
8
|
+
db;
|
|
9
|
+
constructor(db) {
|
|
10
|
+
this.db = db;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* 当前 schema 版本。库里还没有 `meta` 表(全新库)时返回 0,
|
|
14
|
+
* 让 `migrate` 从第一级开始跑,而不是先抛一个「表不存在」。
|
|
15
|
+
*/
|
|
16
|
+
async getSchemaVersion() {
|
|
17
|
+
if (!(await this.hasMetaTable()))
|
|
18
|
+
return 0;
|
|
19
|
+
const row = await this.db.get("SELECT id, schemaVersion, createdAt FROM meta WHERE id = 1");
|
|
20
|
+
return row?.schemaVersion ?? 0;
|
|
21
|
+
}
|
|
22
|
+
/** 元信息整行;全新库(表或行还不存在)返回 `undefined` */
|
|
23
|
+
async getState() {
|
|
24
|
+
if (!(await this.hasMetaTable()))
|
|
25
|
+
return undefined;
|
|
26
|
+
return this.db.get("SELECT id, schemaVersion, createdAt FROM meta WHERE id = 1");
|
|
27
|
+
}
|
|
28
|
+
/** 表存不存在:全新库还没跑过任何迁移,直接查 `meta` 会报「no such table」 */
|
|
29
|
+
async hasMetaTable() {
|
|
30
|
+
const row = await this.db.get("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'meta'");
|
|
31
|
+
return row !== undefined;
|
|
32
|
+
}
|
|
33
|
+
}
|