@runko/persist-mongo 0.1.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/README.md ADDED
@@ -0,0 +1,113 @@
1
+ # @runko/persist-mongo
2
+
3
+ runko 的持久化实现,**MongoDB**——也是第一个**非关系型**实现。
4
+
5
+ ```sh
6
+ pnpm add @runko/persist-mongo mongodb
7
+ ```
8
+
9
+ ```ts
10
+ import { createAgentRuntime } from "@runko/agent";
11
+ import { migrate, mongoPersistence } from "@runko/persist-mongo";
12
+ import { MongoClient } from "mongodb";
13
+
14
+ const client = new MongoClient(process.env.MONGO_URL);
15
+ await client.connect();
16
+ const db = client.db("myapp");
17
+
18
+ await migrate(db); // 建索引,幂等
19
+
20
+ const runtime = createAgentRuntime(agent, { persistence: mongoPersistence(db) });
21
+ ```
22
+
23
+ **吃 `Db` 而不是 `MongoClient`**:选哪个 database 是你的决定(多租户可能一租户一个 db),
24
+ 连接的生命周期也归你——本包不 connect、不 close。
25
+
26
+ ## 它不是薄壳
27
+
28
+ SQLite / PostgreSQL / MySQL 那三个包底下共用 `@runko/persist-kysely`。Kysely 是 SQL
29
+ 查询构建器,Mongo 用不上,所以**这个包直接实现三个领域接口**。
30
+
31
+ ## 它顺带证明了什么
32
+
33
+ 契约里有三条准则,当初就是为了**不把非关系型挡在门外**才那么写的。这是第一次真去验:
34
+
35
+ | 准则 | 结论 |
36
+ | --- | --- |
37
+ | 不假设事务能跨接口 | ✅ 需要原子的只有 `dequeue`,Mongo 的 `findOneAndDelete` 原生原子 |
38
+ | 不要求 CAS | ✅ 一次都没用上 |
39
+ | 不支持跨会话查询 | ✅ 每个查询都以 `conversationId` 打头,正好是索引前缀 |
40
+
41
+ 三条全成立——接口**没有漏掉关系型假设**。
42
+
43
+ 有一处 Mongo 反而更干净:契约要求 `dequeue`「取出即移除,一个方法内原子完成」,
44
+ SQL 那几家是「先 SELECT 排序取第一条、再 DELETE」两步,中间有窗口、得靠重试兜;
45
+ Mongo 的 `findOneAndDelete({...}, {sort})` **是数据库直接给的**。
46
+
47
+ ## 它存什么
48
+
49
+ 三个集合:`agent_ledger`(账本)· `agent_decisions`(人工裁决留底)· `agent_queue`(待发队列)。
50
+
51
+ 集合名固定,不提供前缀开关——**要隔离请用另一个 database**,那在 Mongo 里是一等公民,
52
+ 比集合名前缀干净得多。
53
+
54
+ `migrate()` 只建索引(Mongo 的集合是隐式创建的)。但它**不是可选的**:账本与裁决表的
55
+ 幂等写入靠唯一索引兜底,没有它并发写会写出两行。
56
+
57
+ **它不存你的东西。** runko 只认一个不透明的 `conversationId`,会话叫什么、属于谁,
58
+ 全归你自己存。
59
+
60
+ ## 要求 MongoDB 5.0+
61
+
62
+ 工具入参是**任意 JSON**,键里可能有 `.` 或 `$`——5.0 之前的 MongoDB 不接受这种字段名。
63
+ 实测 MongoDB 8 全放行。
64
+
65
+ ## 两个实测出来的坑
66
+
67
+ **① `settle` 必须看 `matchedCount`,不能看 `modifiedCount`。**
68
+ Mongo 在「匹配到但新值与旧值完全相同」时报 `matched=1, modified=0`。用 `modifiedCount`
69
+ 判断会把一次成功的结清误报成「没有这条」,调用方于是转 404。
70
+
71
+ > 有意思的是:契约文档曾经把这个坑安在 MySQL 头上,实测发现**在 MySQL 上不成立**
72
+ > (Kysely 的 `numUpdatedRows` 报的就是匹配数)。它的真身在这儿。
73
+
74
+ **② BSON 把 `undefined` 存成 `null`**,而 SQL 那几家走 `JSON.stringify`(直接丢掉这个键)。
75
+ 本包在写入前先过一遍 `JSON.parse(JSON.stringify(...))` 对齐它们——不然一致性套件的
76
+ 「原样往返」当场就红。
77
+
78
+ 没走「客户端开 `ignoreUndefined`」那条路,是因为**客户端是你建的**,我们只拿到 `Db`。
79
+
80
+ ## 测试
81
+
82
+ ```sh
83
+ RUNKO_TEST_MONGO_URL=mongodb://127.0.0.1:27018 pnpm --filter @runko/persist-mongo test
84
+ ```
85
+
86
+ 跟其余三个持久化包**跑同一套一致性用例**。不给连接串时整档跳过并说明原因——
87
+ Mongo 没有 pglite 那样的进程内替身(`mongodb-memory-server` 是下载一个真 mongod 来跑)。
88
+
89
+ ## 不是只有这一条路
90
+
91
+ **你的数据模型跟这三个集合对不上?那就自己实现那三个接口**——那是[头等路径,不是降级方案](../../docs/host/contract/features/persistence.md)。
92
+ 一共十来个方法。自己实现的话,装上 [`@runko/conformance`](../conformance/README.md) 自测:
93
+
94
+ ```ts
95
+ import { persistenceCases } from "@runko/conformance";
96
+
97
+ describe("我自己的实现", () => {
98
+ for (const testCase of persistenceCases) {
99
+ it(testCase.name, async () => {
100
+ await testCase.run({ persistence: myPersistence() });
101
+ });
102
+ }
103
+ });
104
+ ```
105
+
106
+ 套件**不依赖任何测试框架**——它只导出 `{ name, run }` 这样的用例数据,`describe`/`it`
107
+ 由你来接,vitest / jest / node:test / Workers 上都能跑。
108
+
109
+ ## 文档
110
+
111
+ [功能手册](../../docs/host/contract/features/persistence.md) ·
112
+ [技术方案](../../docs/host/contract/tech/persistence.md) ·
113
+ [施工进展](../../docs/host/contract/plans/persistence.md)
package/dist/index.cjs ADDED
@@ -0,0 +1,369 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ //#region src/collections.ts
3
+ const LEDGER_COLLECTION = "agent_ledger";
4
+ const DECISIONS_COLLECTION = "agent_decisions";
5
+ const QUEUE_COLLECTION = "agent_queue";
6
+ /**
7
+ * **写进 BSON 之前先过一遍 JSON。**
8
+ *
9
+ * 这一步不是多余的,它修的是一个实测出来的行为差异:BSON 会把 `undefined` **存成
10
+ * `null`**,而 SQL 那几家走的是 `JSON.stringify`(**直接把这个键丢掉**)。同一个
11
+ * `RunkoUIMessage` 存进去、读出来,Mongo 给 `{cachedInputTokens: null}`、SQLite 给
12
+ * `{}`——一致性套件的「原样往返」当场就会红。
13
+ *
14
+ * 修法有两条,选了这条:
15
+ *
16
+ * | | 怎么做 | 为什么不选 |
17
+ * |---|---|---|
18
+ * | 客户端开 `ignoreUndefined` | `new MongoClient(url, { ignoreUndefined: true })` | **客户端是宿主建的**,我们只拿到 `Db`,管不着它怎么建的 |
19
+ * | ✅ 自己先 JSON 归一 | `JSON.parse(JSON.stringify(v))` | —— |
20
+ *
21
+ * 选后者还有一个额外好处:它让 Mongo 这一档的语义**可证地**与 SQL 那几家一致
22
+ * ——因为它做的就是那几家在做的同一件事。
23
+ */
24
+ function toBson(value) {
25
+ return JSON.parse(JSON.stringify(value ?? null));
26
+ }
27
+ /**
28
+ * 读回来直接用——**不需要反向转换**。
29
+ *
30
+ * 存进去的已经是 JSON 归一过的纯数据,Mongo 原样给回来。数字、字符串、嵌套对象、
31
+ * 数组都实测过原样往返(含 `Number.MAX_SAFE_INTEGER` 与浮点)。
32
+ *
33
+ * 留这个函数是为了让「读」这一侧有个明确的边界名字,将来真要加什么(比如剥掉
34
+ * driver 塞进来的 `_id`)有地方放。
35
+ *
36
+ * ⚠️ **这是一处刻意的类型逃逸,范围仅限本函数。** 驱动把 BSON 文档的字段给成
37
+ * `unknown`(它无法知道我们存了什么形状),而这一层唯一能做的就是把它交还给调用方。
38
+ * 泛型 `as` 意味着**本边界上没有类型检查**——所以真正需要保证形状的地方必须自己校验:
39
+ * 队列的 `input` 走 `decodeTurnInput`(zod);账本的 `payload` 由 core 在
40
+ * `createSession({ resume })` 里跑 `validateUIMessages` 兜住。别把这个函数当成
41
+ * 「安全的转换」到处用。
42
+ */
43
+ function fromBson(value) {
44
+ return value;
45
+ }
46
+ //#endregion
47
+ //#region src/stores.ts
48
+ const OK = { ok: true };
49
+ /** 重复键。并发 upsert 撞同一个唯一索引时驱动抛这个,见 `upsertOnce`。 */
50
+ const DUPLICATE_KEY = 11e3;
51
+ function isDuplicateKeyError(error) {
52
+ return typeof error === "object" && error !== null && "code" in error && error.code === DUPLICATE_KEY;
53
+ }
54
+ /**
55
+ * 幂等插入的**兜底那一半**:吞掉重复键,其余错误照抛。
56
+ *
57
+ * 幂等本身靠 `upsert` + `$setOnInsert`(在调用点写),不靠异常——「先查再插」有竞态
58
+ * 窗口,「插了 catch」会把真正的写失败也一起吞掉。
59
+ *
60
+ * 但 `upsert` 在并发下**仍可能抛重复键**:两个请求同时发现不存在、同时插,唯一索引
61
+ * 挡下后一个。这是 MongoDB 明确记录的行为,不是 bug。那一支的结果照样是「已经有了
62
+ * 一行」——契约要的是「不得写出两行」,满足了,所以吞掉。
63
+ *
64
+ * **只包异常处理、不包 `updateOne` 本身**是刻意的:这样 `$setOnInsert` 的字段能在
65
+ * **具体的调用点**跟具体的文档类型对上,写错字段当场编译不过。包成一个泛型 helper
66
+ * 反而会把类型冲开、逼出一个 `as`。
67
+ */
68
+ async function ignoringDuplicateKey(write) {
69
+ try {
70
+ return await write();
71
+ } catch (error) {
72
+ if (!isDuplicateKeyError(error)) throw error;
73
+ return;
74
+ }
75
+ }
76
+ function createLedgerStore(db) {
77
+ const col = db.collection(LEDGER_COLLECTION);
78
+ return {
79
+ async append(entry) {
80
+ if ((await ignoringDuplicateKey(() => col.updateOne({
81
+ conversationId: entry.conversationId,
82
+ seq: entry.seq
83
+ }, { $setOnInsert: {
84
+ payload: toBson(entry.message),
85
+ ts: entry.ts
86
+ } }, { upsert: true })))?.upsertedCount === 1) return OK;
87
+ const existing = await col.findOne({
88
+ conversationId: entry.conversationId,
89
+ seq: entry.seq
90
+ }, { projection: { _id: 0 } });
91
+ if (existing !== null && fromBson(existing.payload).id === entry.message.id) return OK;
92
+ return {
93
+ ok: false,
94
+ reason: "rejected"
95
+ };
96
+ },
97
+ async read(conversationId, opts) {
98
+ const filter = opts?.afterSeq === void 0 ? { conversationId } : {
99
+ conversationId,
100
+ seq: { $gt: opts.afterSeq }
101
+ };
102
+ return (await col.find(filter, { projection: { _id: 0 } }).sort({ seq: 1 }).toArray()).map((doc) => ({
103
+ conversationId: doc.conversationId,
104
+ seq: doc.seq,
105
+ message: fromBson(doc.payload),
106
+ ts: doc.ts
107
+ }));
108
+ },
109
+ async maxSeq(conversationId) {
110
+ return (await col.findOne({ conversationId }, {
111
+ sort: { seq: -1 },
112
+ projection: {
113
+ seq: 1,
114
+ _id: 0
115
+ }
116
+ }))?.seq ?? 0;
117
+ }
118
+ };
119
+ }
120
+ function isOutcome(value) {
121
+ return value === "allow" || value === "deny" || value === "answered" || value === "timeout";
122
+ }
123
+ /** 可选字段一律「有才放进去」——`exactOptionalPropertyTypes` 下不能给 `undefined`。 */
124
+ function toRecord(doc) {
125
+ return {
126
+ conversationId: doc.conversationId,
127
+ toolCallId: doc.toolCallId,
128
+ kind: doc.kind === "question" ? "question" : "approval",
129
+ ...doc.toolName !== null ? { toolName: doc.toolName } : {},
130
+ ...doc.payload !== null ? { payload: fromBson(doc.payload) } : {},
131
+ ...isOutcome(doc.outcome) ? { outcome: doc.outcome } : {},
132
+ ...doc.scope === "once" || doc.scope === "conversation" ? { scope: doc.scope } : {},
133
+ ...doc.decidedBy !== null ? { decidedBy: doc.decidedBy } : {},
134
+ ...doc.message !== null ? { message: doc.message } : {},
135
+ requestedAt: doc.requestedAt,
136
+ ...doc.decidedAt !== null ? { decidedAt: doc.decidedAt } : {}
137
+ };
138
+ }
139
+ function createDecisionStore(db) {
140
+ const col = db.collection(DECISIONS_COLLECTION);
141
+ return {
142
+ async record(entry) {
143
+ await ignoringDuplicateKey(() => col.updateOne({
144
+ conversationId: entry.conversationId,
145
+ toolCallId: entry.toolCallId
146
+ }, { $setOnInsert: {
147
+ kind: entry.kind,
148
+ toolName: entry.toolName ?? null,
149
+ payload: entry.payload === void 0 ? null : toBson(entry.payload),
150
+ outcome: entry.outcome ?? null,
151
+ scope: entry.scope ?? null,
152
+ decidedBy: entry.decidedBy ?? null,
153
+ message: entry.message ?? null,
154
+ requestedAt: entry.requestedAt,
155
+ decidedAt: entry.decidedAt ?? null
156
+ } }, { upsert: true }));
157
+ return OK;
158
+ },
159
+ async settle(conversationId, toolCallId, settlement) {
160
+ return (await col.updateOne({
161
+ conversationId,
162
+ toolCallId,
163
+ decidedAt: null
164
+ }, { $set: {
165
+ outcome: settlement.outcome ?? null,
166
+ scope: settlement.scope ?? null,
167
+ decidedBy: settlement.decidedBy ?? null,
168
+ message: settlement.message ?? null,
169
+ decidedAt: settlement.decidedAt
170
+ } })).matchedCount > 0;
171
+ },
172
+ async listPending(conversationId) {
173
+ return (await col.find({
174
+ conversationId,
175
+ decidedAt: null
176
+ }, { projection: { _id: 0 } }).sort({ requestedAt: 1 }).toArray()).map(toRecord);
177
+ }
178
+ };
179
+ }
180
+ /** `enqueue` / `requeueFront` 撞号时的重试上限——够覆盖真实并发,又不会在病态负载下无限转。 */
181
+ const ENQUEUE_MAX_ATTEMPTS = 5;
182
+ function createQueueStore(db) {
183
+ const col = db.collection(QUEUE_COLLECTION);
184
+ const toItem = (doc) => ({
185
+ id: doc._id,
186
+ conversationId: doc.conversationId,
187
+ seq: doc.seq,
188
+ input: fromBson(doc.input),
189
+ createdAt: doc.createdAt
190
+ });
191
+ const list = async (conversationId) => {
192
+ return (await col.find({ conversationId }).sort({ seq: 1 }).toArray()).map(toItem);
193
+ };
194
+ /**
195
+ * 插一条。`(conversationId, seq)` 上有唯一索引,撞号会抛 11000(duplicate key)——
196
+ * 返回 false 让调用方换号重来,其余错误照常往外抛。
197
+ */
198
+ const insert = async (item) => {
199
+ try {
200
+ await col.insertOne({
201
+ _id: item.id,
202
+ conversationId: item.conversationId,
203
+ seq: item.seq,
204
+ input: toBson(item.input),
205
+ createdAt: item.createdAt
206
+ });
207
+ return true;
208
+ } catch (error) {
209
+ if (isDuplicateKeyError(error)) return false;
210
+ throw error;
211
+ }
212
+ };
213
+ return {
214
+ async enqueue(conversationId, input, opts) {
215
+ for (let attempt = 0; attempt < ENQUEUE_MAX_ATTEMPTS; attempt += 1) {
216
+ const current = await list(conversationId);
217
+ if (current.length >= opts.max) {
218
+ if (opts.onFull === "reject") return {
219
+ ok: false,
220
+ reason: "full",
221
+ queue: current
222
+ };
223
+ const oldest = current[0];
224
+ if (oldest !== void 0) await col.deleteOne({ _id: oldest.id });
225
+ }
226
+ const item = {
227
+ id: crypto.randomUUID(),
228
+ conversationId,
229
+ seq: (current.at(-1)?.seq ?? 0) + 1,
230
+ input,
231
+ createdAt: Date.now()
232
+ };
233
+ if (!await insert(item)) continue;
234
+ const after = await list(conversationId);
235
+ if (opts.onFull === "reject" && after.length > opts.max) {
236
+ await col.deleteOne({ _id: item.id });
237
+ return {
238
+ ok: false,
239
+ reason: "full",
240
+ queue: await list(conversationId)
241
+ };
242
+ }
243
+ return {
244
+ ok: true,
245
+ queued: item,
246
+ queue: after
247
+ };
248
+ }
249
+ return {
250
+ ok: false,
251
+ reason: "full",
252
+ queue: await list(conversationId)
253
+ };
254
+ },
255
+ async dequeue(conversationId) {
256
+ const doc = await col.findOneAndDelete({ conversationId }, { sort: { seq: 1 } });
257
+ if (doc === null) return {
258
+ item: void 0,
259
+ queue: []
260
+ };
261
+ return {
262
+ item: toItem(doc),
263
+ queue: await list(conversationId)
264
+ };
265
+ },
266
+ list,
267
+ async remove(conversationId, id) {
268
+ return {
269
+ removed: (await col.deleteOne({
270
+ conversationId,
271
+ _id: id
272
+ })).deletedCount > 0,
273
+ queue: await list(conversationId)
274
+ };
275
+ },
276
+ async clear(conversationId) {
277
+ await col.deleteMany({ conversationId });
278
+ return [];
279
+ },
280
+ async requeueFront(conversationId, item) {
281
+ for (let attempt = 0; attempt < ENQUEUE_MAX_ATTEMPTS; attempt += 1) {
282
+ const head = (await list(conversationId))[0];
283
+ const seq = head === void 0 ? item.seq : Math.min(item.seq, head.seq - 1) - attempt;
284
+ if (await insert({
285
+ ...item,
286
+ conversationId,
287
+ seq
288
+ })) break;
289
+ }
290
+ return await list(conversationId);
291
+ }
292
+ };
293
+ }
294
+ //#endregion
295
+ //#region src/migrate.ts
296
+ /** 同名索引已存在但选项不同时 Mongo 报的两个 code。 */
297
+ const INDEX_CONFLICT_CODES = /* @__PURE__ */ new Set([85, 86]);
298
+ function isIndexConflict(error) {
299
+ return typeof error === "object" && error !== null && "code" in error && typeof error.code === "number" && INDEX_CONFLICT_CODES.has(error.code);
300
+ }
301
+ /**
302
+ * 建一个具名索引,**同名不同选项时删了重建**。
303
+ *
304
+ * 这是 `migrate()` 唯一做的一点「演进」,范围限定在本包自己那三个具名索引上:不这么做
305
+ * 的话,任何一次索引选项的变更(比如队列索引从非唯一改成唯一)都会让老库上的
306
+ * `migrate()` 抛 `IndexOptionsConflict` 而启动失败——而 `migrate()` 对外承诺是幂等的。
307
+ *
308
+ * 重建可能失败(存量数据违反新的唯一性),那时照常抛——那种情况需要人介入,不该静默。
309
+ */
310
+ async function ensureIndex(db, collection, keys, options) {
311
+ try {
312
+ await db.collection(collection).createIndex(keys, options);
313
+ } catch (error) {
314
+ if (!isIndexConflict(error)) throw error;
315
+ await db.collection(collection).dropIndex(options.name);
316
+ await db.collection(collection).createIndex(keys, options);
317
+ }
318
+ }
319
+ async function migrate(db) {
320
+ await Promise.all([
321
+ ensureIndex(db, LEDGER_COLLECTION, {
322
+ conversationId: 1,
323
+ seq: 1
324
+ }, {
325
+ unique: true,
326
+ name: "agent_ledger_conversation_seq"
327
+ }),
328
+ ensureIndex(db, DECISIONS_COLLECTION, {
329
+ conversationId: 1,
330
+ toolCallId: 1
331
+ }, {
332
+ unique: true,
333
+ name: "agent_decisions_conversation_call"
334
+ }),
335
+ ensureIndex(db, QUEUE_COLLECTION, {
336
+ conversationId: 1,
337
+ seq: 1
338
+ }, {
339
+ unique: true,
340
+ name: "agent_queue_conversation_seq"
341
+ })
342
+ ]);
343
+ }
344
+ //#endregion
345
+ //#region src/index.ts
346
+ const RUNKO_PERSIST_MONGO_VERSION = "0.0.0";
347
+ /**
348
+ * 把一个 MongoDB `Db` 装成 `Persistence`。
349
+ *
350
+ * **吃 `Db` 而不是 `MongoClient`**:选哪个 database 是宿主的决定(多租户可能一租户
351
+ * 一个 db),连接的生命周期也归宿主管——本包不 connect、不 close。
352
+ *
353
+ * 三个集合固定叫 `agent_ledger` / `agent_decisions` / `agent_queue`,你自己的集合
354
+ * 照常在同一个 db 里,互不干扰。
355
+ */
356
+ function mongoPersistence(db) {
357
+ return {
358
+ ledger: createLedgerStore(db),
359
+ decisions: createDecisionStore(db),
360
+ queue: createQueueStore(db)
361
+ };
362
+ }
363
+ //#endregion
364
+ exports.DECISIONS_COLLECTION = DECISIONS_COLLECTION;
365
+ exports.LEDGER_COLLECTION = LEDGER_COLLECTION;
366
+ exports.QUEUE_COLLECTION = QUEUE_COLLECTION;
367
+ exports.RUNKO_PERSIST_MONGO_VERSION = RUNKO_PERSIST_MONGO_VERSION;
368
+ exports.migrate = migrate;
369
+ exports.mongoPersistence = mongoPersistence;
@@ -0,0 +1,63 @@
1
+ import { Persistence } from "@runko/agent";
2
+ import { Db } from "mongodb";
3
+ import { JsonValue } from "@runko/core";
4
+ //#region src/collections.d.ts
5
+ declare const LEDGER_COLLECTION = "agent_ledger";
6
+ declare const DECISIONS_COLLECTION = "agent_decisions";
7
+ declare const QUEUE_COLLECTION = "agent_queue";
8
+ /**
9
+ * [账本](../../../docs/terms.md)文档。
10
+ *
11
+ * **不用复合 `_id`**(`{_id: {conversationId, seq}}`)虽然那样能白捡唯一性——因为
12
+ * 嵌套文档的 `_id` 是**整体**建索引的,`{"_id.conversationId": x}` 这种查询用不上它。
13
+ * 改成显式字段 + 一个唯一复合索引(`migrate()` 建),查询才走得了索引。
14
+ */
15
+ interface LedgerDoc {
16
+ conversationId: string;
17
+ /** 每会话递增,由[归属仲裁](../../../docs/terms.md)分配。**允许有空洞**。 */
18
+ seq: number;
19
+ /** `RunkoUIMessage`。以 BSON 文档存(不是字符串)——Mongo 的原生形态,工具里看得见。 */
20
+ payload: JsonValue;
21
+ ts: number;
22
+ }
23
+ /** [裁决表](../../../docs/terms.md)文档。`decidedAt` 为 `null` 即「尚未 settle」。 */
24
+ interface DecisionDoc {
25
+ conversationId: string;
26
+ toolCallId: string;
27
+ kind: string;
28
+ toolName: string | null;
29
+ payload: JsonValue | null;
30
+ outcome: string | null;
31
+ scope: string | null;
32
+ decidedBy: string | null;
33
+ message: string | null;
34
+ requestedAt: number;
35
+ decidedAt: number | null;
36
+ }
37
+ /** [待发队列](../../../docs/terms.md)文档。`_id` 直接用队列条目自己的 id。 */
38
+ interface QueueDoc {
39
+ _id: string;
40
+ conversationId: string;
41
+ /** 入队顺序,**不与账本 seq 共享空间**。 */
42
+ seq: number;
43
+ input: JsonValue;
44
+ createdAt: number;
45
+ }
46
+ //#endregion
47
+ //#region src/migrate.d.ts
48
+ declare function migrate(db: Db): Promise<void>;
49
+ //#endregion
50
+ //#region src/index.d.ts
51
+ declare const RUNKO_PERSIST_MONGO_VERSION: "0.0.0";
52
+ /**
53
+ * 把一个 MongoDB `Db` 装成 `Persistence`。
54
+ *
55
+ * **吃 `Db` 而不是 `MongoClient`**:选哪个 database 是宿主的决定(多租户可能一租户
56
+ * 一个 db),连接的生命周期也归宿主管——本包不 connect、不 close。
57
+ *
58
+ * 三个集合固定叫 `agent_ledger` / `agent_decisions` / `agent_queue`,你自己的集合
59
+ * 照常在同一个 db 里,互不干扰。
60
+ */
61
+ declare function mongoPersistence(db: Db): Persistence;
62
+ //#endregion
63
+ export { DECISIONS_COLLECTION, type DecisionDoc, LEDGER_COLLECTION, type LedgerDoc, QUEUE_COLLECTION, type QueueDoc, RUNKO_PERSIST_MONGO_VERSION, migrate, mongoPersistence };
@@ -0,0 +1,63 @@
1
+ import { Persistence } from "@runko/agent";
2
+ import { Db } from "mongodb";
3
+ import { JsonValue } from "@runko/core";
4
+ //#region src/collections.d.ts
5
+ declare const LEDGER_COLLECTION = "agent_ledger";
6
+ declare const DECISIONS_COLLECTION = "agent_decisions";
7
+ declare const QUEUE_COLLECTION = "agent_queue";
8
+ /**
9
+ * [账本](../../../docs/terms.md)文档。
10
+ *
11
+ * **不用复合 `_id`**(`{_id: {conversationId, seq}}`)虽然那样能白捡唯一性——因为
12
+ * 嵌套文档的 `_id` 是**整体**建索引的,`{"_id.conversationId": x}` 这种查询用不上它。
13
+ * 改成显式字段 + 一个唯一复合索引(`migrate()` 建),查询才走得了索引。
14
+ */
15
+ interface LedgerDoc {
16
+ conversationId: string;
17
+ /** 每会话递增,由[归属仲裁](../../../docs/terms.md)分配。**允许有空洞**。 */
18
+ seq: number;
19
+ /** `RunkoUIMessage`。以 BSON 文档存(不是字符串)——Mongo 的原生形态,工具里看得见。 */
20
+ payload: JsonValue;
21
+ ts: number;
22
+ }
23
+ /** [裁决表](../../../docs/terms.md)文档。`decidedAt` 为 `null` 即「尚未 settle」。 */
24
+ interface DecisionDoc {
25
+ conversationId: string;
26
+ toolCallId: string;
27
+ kind: string;
28
+ toolName: string | null;
29
+ payload: JsonValue | null;
30
+ outcome: string | null;
31
+ scope: string | null;
32
+ decidedBy: string | null;
33
+ message: string | null;
34
+ requestedAt: number;
35
+ decidedAt: number | null;
36
+ }
37
+ /** [待发队列](../../../docs/terms.md)文档。`_id` 直接用队列条目自己的 id。 */
38
+ interface QueueDoc {
39
+ _id: string;
40
+ conversationId: string;
41
+ /** 入队顺序,**不与账本 seq 共享空间**。 */
42
+ seq: number;
43
+ input: JsonValue;
44
+ createdAt: number;
45
+ }
46
+ //#endregion
47
+ //#region src/migrate.d.ts
48
+ declare function migrate(db: Db): Promise<void>;
49
+ //#endregion
50
+ //#region src/index.d.ts
51
+ declare const RUNKO_PERSIST_MONGO_VERSION: "0.0.0";
52
+ /**
53
+ * 把一个 MongoDB `Db` 装成 `Persistence`。
54
+ *
55
+ * **吃 `Db` 而不是 `MongoClient`**:选哪个 database 是宿主的决定(多租户可能一租户
56
+ * 一个 db),连接的生命周期也归宿主管——本包不 connect、不 close。
57
+ *
58
+ * 三个集合固定叫 `agent_ledger` / `agent_decisions` / `agent_queue`,你自己的集合
59
+ * 照常在同一个 db 里,互不干扰。
60
+ */
61
+ declare function mongoPersistence(db: Db): Persistence;
62
+ //#endregion
63
+ export { DECISIONS_COLLECTION, type DecisionDoc, LEDGER_COLLECTION, type LedgerDoc, QUEUE_COLLECTION, type QueueDoc, RUNKO_PERSIST_MONGO_VERSION, migrate, mongoPersistence };
package/dist/index.js ADDED
@@ -0,0 +1,363 @@
1
+ //#region src/collections.ts
2
+ const LEDGER_COLLECTION = "agent_ledger";
3
+ const DECISIONS_COLLECTION = "agent_decisions";
4
+ const QUEUE_COLLECTION = "agent_queue";
5
+ /**
6
+ * **写进 BSON 之前先过一遍 JSON。**
7
+ *
8
+ * 这一步不是多余的,它修的是一个实测出来的行为差异:BSON 会把 `undefined` **存成
9
+ * `null`**,而 SQL 那几家走的是 `JSON.stringify`(**直接把这个键丢掉**)。同一个
10
+ * `RunkoUIMessage` 存进去、读出来,Mongo 给 `{cachedInputTokens: null}`、SQLite 给
11
+ * `{}`——一致性套件的「原样往返」当场就会红。
12
+ *
13
+ * 修法有两条,选了这条:
14
+ *
15
+ * | | 怎么做 | 为什么不选 |
16
+ * |---|---|---|
17
+ * | 客户端开 `ignoreUndefined` | `new MongoClient(url, { ignoreUndefined: true })` | **客户端是宿主建的**,我们只拿到 `Db`,管不着它怎么建的 |
18
+ * | ✅ 自己先 JSON 归一 | `JSON.parse(JSON.stringify(v))` | —— |
19
+ *
20
+ * 选后者还有一个额外好处:它让 Mongo 这一档的语义**可证地**与 SQL 那几家一致
21
+ * ——因为它做的就是那几家在做的同一件事。
22
+ */
23
+ function toBson(value) {
24
+ return JSON.parse(JSON.stringify(value ?? null));
25
+ }
26
+ /**
27
+ * 读回来直接用——**不需要反向转换**。
28
+ *
29
+ * 存进去的已经是 JSON 归一过的纯数据,Mongo 原样给回来。数字、字符串、嵌套对象、
30
+ * 数组都实测过原样往返(含 `Number.MAX_SAFE_INTEGER` 与浮点)。
31
+ *
32
+ * 留这个函数是为了让「读」这一侧有个明确的边界名字,将来真要加什么(比如剥掉
33
+ * driver 塞进来的 `_id`)有地方放。
34
+ *
35
+ * ⚠️ **这是一处刻意的类型逃逸,范围仅限本函数。** 驱动把 BSON 文档的字段给成
36
+ * `unknown`(它无法知道我们存了什么形状),而这一层唯一能做的就是把它交还给调用方。
37
+ * 泛型 `as` 意味着**本边界上没有类型检查**——所以真正需要保证形状的地方必须自己校验:
38
+ * 队列的 `input` 走 `decodeTurnInput`(zod);账本的 `payload` 由 core 在
39
+ * `createSession({ resume })` 里跑 `validateUIMessages` 兜住。别把这个函数当成
40
+ * 「安全的转换」到处用。
41
+ */
42
+ function fromBson(value) {
43
+ return value;
44
+ }
45
+ //#endregion
46
+ //#region src/stores.ts
47
+ const OK = { ok: true };
48
+ /** 重复键。并发 upsert 撞同一个唯一索引时驱动抛这个,见 `upsertOnce`。 */
49
+ const DUPLICATE_KEY = 11e3;
50
+ function isDuplicateKeyError(error) {
51
+ return typeof error === "object" && error !== null && "code" in error && error.code === DUPLICATE_KEY;
52
+ }
53
+ /**
54
+ * 幂等插入的**兜底那一半**:吞掉重复键,其余错误照抛。
55
+ *
56
+ * 幂等本身靠 `upsert` + `$setOnInsert`(在调用点写),不靠异常——「先查再插」有竞态
57
+ * 窗口,「插了 catch」会把真正的写失败也一起吞掉。
58
+ *
59
+ * 但 `upsert` 在并发下**仍可能抛重复键**:两个请求同时发现不存在、同时插,唯一索引
60
+ * 挡下后一个。这是 MongoDB 明确记录的行为,不是 bug。那一支的结果照样是「已经有了
61
+ * 一行」——契约要的是「不得写出两行」,满足了,所以吞掉。
62
+ *
63
+ * **只包异常处理、不包 `updateOne` 本身**是刻意的:这样 `$setOnInsert` 的字段能在
64
+ * **具体的调用点**跟具体的文档类型对上,写错字段当场编译不过。包成一个泛型 helper
65
+ * 反而会把类型冲开、逼出一个 `as`。
66
+ */
67
+ async function ignoringDuplicateKey(write) {
68
+ try {
69
+ return await write();
70
+ } catch (error) {
71
+ if (!isDuplicateKeyError(error)) throw error;
72
+ return;
73
+ }
74
+ }
75
+ function createLedgerStore(db) {
76
+ const col = db.collection(LEDGER_COLLECTION);
77
+ return {
78
+ async append(entry) {
79
+ if ((await ignoringDuplicateKey(() => col.updateOne({
80
+ conversationId: entry.conversationId,
81
+ seq: entry.seq
82
+ }, { $setOnInsert: {
83
+ payload: toBson(entry.message),
84
+ ts: entry.ts
85
+ } }, { upsert: true })))?.upsertedCount === 1) return OK;
86
+ const existing = await col.findOne({
87
+ conversationId: entry.conversationId,
88
+ seq: entry.seq
89
+ }, { projection: { _id: 0 } });
90
+ if (existing !== null && fromBson(existing.payload).id === entry.message.id) return OK;
91
+ return {
92
+ ok: false,
93
+ reason: "rejected"
94
+ };
95
+ },
96
+ async read(conversationId, opts) {
97
+ const filter = opts?.afterSeq === void 0 ? { conversationId } : {
98
+ conversationId,
99
+ seq: { $gt: opts.afterSeq }
100
+ };
101
+ return (await col.find(filter, { projection: { _id: 0 } }).sort({ seq: 1 }).toArray()).map((doc) => ({
102
+ conversationId: doc.conversationId,
103
+ seq: doc.seq,
104
+ message: fromBson(doc.payload),
105
+ ts: doc.ts
106
+ }));
107
+ },
108
+ async maxSeq(conversationId) {
109
+ return (await col.findOne({ conversationId }, {
110
+ sort: { seq: -1 },
111
+ projection: {
112
+ seq: 1,
113
+ _id: 0
114
+ }
115
+ }))?.seq ?? 0;
116
+ }
117
+ };
118
+ }
119
+ function isOutcome(value) {
120
+ return value === "allow" || value === "deny" || value === "answered" || value === "timeout";
121
+ }
122
+ /** 可选字段一律「有才放进去」——`exactOptionalPropertyTypes` 下不能给 `undefined`。 */
123
+ function toRecord(doc) {
124
+ return {
125
+ conversationId: doc.conversationId,
126
+ toolCallId: doc.toolCallId,
127
+ kind: doc.kind === "question" ? "question" : "approval",
128
+ ...doc.toolName !== null ? { toolName: doc.toolName } : {},
129
+ ...doc.payload !== null ? { payload: fromBson(doc.payload) } : {},
130
+ ...isOutcome(doc.outcome) ? { outcome: doc.outcome } : {},
131
+ ...doc.scope === "once" || doc.scope === "conversation" ? { scope: doc.scope } : {},
132
+ ...doc.decidedBy !== null ? { decidedBy: doc.decidedBy } : {},
133
+ ...doc.message !== null ? { message: doc.message } : {},
134
+ requestedAt: doc.requestedAt,
135
+ ...doc.decidedAt !== null ? { decidedAt: doc.decidedAt } : {}
136
+ };
137
+ }
138
+ function createDecisionStore(db) {
139
+ const col = db.collection(DECISIONS_COLLECTION);
140
+ return {
141
+ async record(entry) {
142
+ await ignoringDuplicateKey(() => col.updateOne({
143
+ conversationId: entry.conversationId,
144
+ toolCallId: entry.toolCallId
145
+ }, { $setOnInsert: {
146
+ kind: entry.kind,
147
+ toolName: entry.toolName ?? null,
148
+ payload: entry.payload === void 0 ? null : toBson(entry.payload),
149
+ outcome: entry.outcome ?? null,
150
+ scope: entry.scope ?? null,
151
+ decidedBy: entry.decidedBy ?? null,
152
+ message: entry.message ?? null,
153
+ requestedAt: entry.requestedAt,
154
+ decidedAt: entry.decidedAt ?? null
155
+ } }, { upsert: true }));
156
+ return OK;
157
+ },
158
+ async settle(conversationId, toolCallId, settlement) {
159
+ return (await col.updateOne({
160
+ conversationId,
161
+ toolCallId,
162
+ decidedAt: null
163
+ }, { $set: {
164
+ outcome: settlement.outcome ?? null,
165
+ scope: settlement.scope ?? null,
166
+ decidedBy: settlement.decidedBy ?? null,
167
+ message: settlement.message ?? null,
168
+ decidedAt: settlement.decidedAt
169
+ } })).matchedCount > 0;
170
+ },
171
+ async listPending(conversationId) {
172
+ return (await col.find({
173
+ conversationId,
174
+ decidedAt: null
175
+ }, { projection: { _id: 0 } }).sort({ requestedAt: 1 }).toArray()).map(toRecord);
176
+ }
177
+ };
178
+ }
179
+ /** `enqueue` / `requeueFront` 撞号时的重试上限——够覆盖真实并发,又不会在病态负载下无限转。 */
180
+ const ENQUEUE_MAX_ATTEMPTS = 5;
181
+ function createQueueStore(db) {
182
+ const col = db.collection(QUEUE_COLLECTION);
183
+ const toItem = (doc) => ({
184
+ id: doc._id,
185
+ conversationId: doc.conversationId,
186
+ seq: doc.seq,
187
+ input: fromBson(doc.input),
188
+ createdAt: doc.createdAt
189
+ });
190
+ const list = async (conversationId) => {
191
+ return (await col.find({ conversationId }).sort({ seq: 1 }).toArray()).map(toItem);
192
+ };
193
+ /**
194
+ * 插一条。`(conversationId, seq)` 上有唯一索引,撞号会抛 11000(duplicate key)——
195
+ * 返回 false 让调用方换号重来,其余错误照常往外抛。
196
+ */
197
+ const insert = async (item) => {
198
+ try {
199
+ await col.insertOne({
200
+ _id: item.id,
201
+ conversationId: item.conversationId,
202
+ seq: item.seq,
203
+ input: toBson(item.input),
204
+ createdAt: item.createdAt
205
+ });
206
+ return true;
207
+ } catch (error) {
208
+ if (isDuplicateKeyError(error)) return false;
209
+ throw error;
210
+ }
211
+ };
212
+ return {
213
+ async enqueue(conversationId, input, opts) {
214
+ for (let attempt = 0; attempt < ENQUEUE_MAX_ATTEMPTS; attempt += 1) {
215
+ const current = await list(conversationId);
216
+ if (current.length >= opts.max) {
217
+ if (opts.onFull === "reject") return {
218
+ ok: false,
219
+ reason: "full",
220
+ queue: current
221
+ };
222
+ const oldest = current[0];
223
+ if (oldest !== void 0) await col.deleteOne({ _id: oldest.id });
224
+ }
225
+ const item = {
226
+ id: crypto.randomUUID(),
227
+ conversationId,
228
+ seq: (current.at(-1)?.seq ?? 0) + 1,
229
+ input,
230
+ createdAt: Date.now()
231
+ };
232
+ if (!await insert(item)) continue;
233
+ const after = await list(conversationId);
234
+ if (opts.onFull === "reject" && after.length > opts.max) {
235
+ await col.deleteOne({ _id: item.id });
236
+ return {
237
+ ok: false,
238
+ reason: "full",
239
+ queue: await list(conversationId)
240
+ };
241
+ }
242
+ return {
243
+ ok: true,
244
+ queued: item,
245
+ queue: after
246
+ };
247
+ }
248
+ return {
249
+ ok: false,
250
+ reason: "full",
251
+ queue: await list(conversationId)
252
+ };
253
+ },
254
+ async dequeue(conversationId) {
255
+ const doc = await col.findOneAndDelete({ conversationId }, { sort: { seq: 1 } });
256
+ if (doc === null) return {
257
+ item: void 0,
258
+ queue: []
259
+ };
260
+ return {
261
+ item: toItem(doc),
262
+ queue: await list(conversationId)
263
+ };
264
+ },
265
+ list,
266
+ async remove(conversationId, id) {
267
+ return {
268
+ removed: (await col.deleteOne({
269
+ conversationId,
270
+ _id: id
271
+ })).deletedCount > 0,
272
+ queue: await list(conversationId)
273
+ };
274
+ },
275
+ async clear(conversationId) {
276
+ await col.deleteMany({ conversationId });
277
+ return [];
278
+ },
279
+ async requeueFront(conversationId, item) {
280
+ for (let attempt = 0; attempt < ENQUEUE_MAX_ATTEMPTS; attempt += 1) {
281
+ const head = (await list(conversationId))[0];
282
+ const seq = head === void 0 ? item.seq : Math.min(item.seq, head.seq - 1) - attempt;
283
+ if (await insert({
284
+ ...item,
285
+ conversationId,
286
+ seq
287
+ })) break;
288
+ }
289
+ return await list(conversationId);
290
+ }
291
+ };
292
+ }
293
+ //#endregion
294
+ //#region src/migrate.ts
295
+ /** 同名索引已存在但选项不同时 Mongo 报的两个 code。 */
296
+ const INDEX_CONFLICT_CODES = /* @__PURE__ */ new Set([85, 86]);
297
+ function isIndexConflict(error) {
298
+ return typeof error === "object" && error !== null && "code" in error && typeof error.code === "number" && INDEX_CONFLICT_CODES.has(error.code);
299
+ }
300
+ /**
301
+ * 建一个具名索引,**同名不同选项时删了重建**。
302
+ *
303
+ * 这是 `migrate()` 唯一做的一点「演进」,范围限定在本包自己那三个具名索引上:不这么做
304
+ * 的话,任何一次索引选项的变更(比如队列索引从非唯一改成唯一)都会让老库上的
305
+ * `migrate()` 抛 `IndexOptionsConflict` 而启动失败——而 `migrate()` 对外承诺是幂等的。
306
+ *
307
+ * 重建可能失败(存量数据违反新的唯一性),那时照常抛——那种情况需要人介入,不该静默。
308
+ */
309
+ async function ensureIndex(db, collection, keys, options) {
310
+ try {
311
+ await db.collection(collection).createIndex(keys, options);
312
+ } catch (error) {
313
+ if (!isIndexConflict(error)) throw error;
314
+ await db.collection(collection).dropIndex(options.name);
315
+ await db.collection(collection).createIndex(keys, options);
316
+ }
317
+ }
318
+ async function migrate(db) {
319
+ await Promise.all([
320
+ ensureIndex(db, LEDGER_COLLECTION, {
321
+ conversationId: 1,
322
+ seq: 1
323
+ }, {
324
+ unique: true,
325
+ name: "agent_ledger_conversation_seq"
326
+ }),
327
+ ensureIndex(db, DECISIONS_COLLECTION, {
328
+ conversationId: 1,
329
+ toolCallId: 1
330
+ }, {
331
+ unique: true,
332
+ name: "agent_decisions_conversation_call"
333
+ }),
334
+ ensureIndex(db, QUEUE_COLLECTION, {
335
+ conversationId: 1,
336
+ seq: 1
337
+ }, {
338
+ unique: true,
339
+ name: "agent_queue_conversation_seq"
340
+ })
341
+ ]);
342
+ }
343
+ //#endregion
344
+ //#region src/index.ts
345
+ const RUNKO_PERSIST_MONGO_VERSION = "0.0.0";
346
+ /**
347
+ * 把一个 MongoDB `Db` 装成 `Persistence`。
348
+ *
349
+ * **吃 `Db` 而不是 `MongoClient`**:选哪个 database 是宿主的决定(多租户可能一租户
350
+ * 一个 db),连接的生命周期也归宿主管——本包不 connect、不 close。
351
+ *
352
+ * 三个集合固定叫 `agent_ledger` / `agent_decisions` / `agent_queue`,你自己的集合
353
+ * 照常在同一个 db 里,互不干扰。
354
+ */
355
+ function mongoPersistence(db) {
356
+ return {
357
+ ledger: createLedgerStore(db),
358
+ decisions: createDecisionStore(db),
359
+ queue: createQueueStore(db)
360
+ };
361
+ }
362
+ //#endregion
363
+ export { DECISIONS_COLLECTION, LEDGER_COLLECTION, QUEUE_COLLECTION, RUNKO_PERSIST_MONGO_VERSION, migrate, mongoPersistence };
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "@runko/persist-mongo",
3
+ "version": "0.1.0",
4
+ "description": "持久化实现:MongoDB —— 第一个非关系型实现,直接落在三个领域接口上",
5
+ "type": "module",
6
+ "license": "ISC",
7
+ "publishConfig": {
8
+ "access": "public",
9
+ "registry": "https://registry.npmjs.org/"
10
+ },
11
+ "engines": {
12
+ "node": ">=24"
13
+ },
14
+ "main": "./dist/index.cjs",
15
+ "module": "./dist/index.js",
16
+ "types": "./dist/index.d.ts",
17
+ "exports": {
18
+ ".": {
19
+ "import": {
20
+ "types": "./dist/index.d.ts",
21
+ "default": "./dist/index.js"
22
+ },
23
+ "require": {
24
+ "types": "./dist/index.d.cts",
25
+ "default": "./dist/index.cjs"
26
+ }
27
+ },
28
+ "./package.json": "./package.json"
29
+ },
30
+ "files": [
31
+ "dist"
32
+ ],
33
+ "dependencies": {
34
+ "@runko/agent": "^0.1.0",
35
+ "@runko/core": "^0.1.0"
36
+ },
37
+ "peerDependencies": {
38
+ "mongodb": "^7"
39
+ },
40
+ "devDependencies": {
41
+ "@types/node": "^24.13.3",
42
+ "mongodb": "^7.5.0",
43
+ "tsdown": "^0.22.4",
44
+ "typescript": "^7.0.2",
45
+ "vitest": "^4.1.10",
46
+ "@runko/conformance": "^0.1.0"
47
+ },
48
+ "scripts": {
49
+ "build": "tsdown",
50
+ "typecheck": "tsc --noEmit",
51
+ "lint": "eslint . --report-unused-disable-directives --max-warnings 0",
52
+ "lint:fix": "eslint . --fix",
53
+ "test": "vitest run"
54
+ }
55
+ }