@morlay/session-rdb 0.1.8 → 0.1.10-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/README.md +6 -0
- package/dist/artifact.d.mts +1 -2
- package/dist/artifact.mjs +2 -2
- package/dist/{branch-DbFW3rJX.mjs → branch-CoPCRKer.mjs} +18 -8
- package/dist/{deletion-ODZFcbZm.mjs → deletion-CWl89TIR.mjs} +60 -29
- package/dist/deletion.d.mts +1 -3
- package/dist/deletion.mjs +2 -2
- package/dist/export.d.mts +1 -3
- package/dist/export.mjs +2 -3
- package/dist/gc.d.mts +1 -3
- package/dist/gc.mjs +2 -3
- package/dist/import.d.mts +1 -3
- package/dist/import.mjs +3 -4
- package/dist/index.d.mts +2 -2
- package/dist/index.mjs +5 -5
- package/dist/{log-DYxdjJ-l.mjs → log-CcWy46VE.mjs} +22 -26
- package/dist/{log-C0q5svm7.d.mts → log-TYgd8Xkr.d.mts} +21 -22
- package/dist/{routes-DGBlBE_I.mjs → routes-hZjTcX_R.mjs} +1 -2
- package/dist/{sqlite-DjOnMRh2.mjs → sqlite-BNRd1aqR.mjs} +47 -46
- package/dist/storage.d.mts +1 -3
- package/dist/storage.mjs +1 -1
- package/dist/testing.d.mts +1 -5
- package/dist/testing.mjs +3 -5
- package/dist/usage.d.mts +1 -1
- package/dist/usage.mjs +2 -3
- package/package.json +25 -25
- package/src/branch.ts +18 -4
- package/src/index.ts +41 -5
- package/src/log.ts +21 -25
- package/src/session-query.ts +17 -4
- package/src/sqlite.ts +18 -8
- package/src/storage-takeover/projection-cache.ts +17 -6
- package/src/storage-takeover/repository.ts +19 -0
- package/src/storage-takeover/storage-backend.ts +13 -0
- package/src/storage-takeover/types.ts +5 -0
package/src/branch.ts
CHANGED
|
@@ -153,7 +153,19 @@ function resetSurfaceManager(surfaceManager: SurfaceManagerLike): void {
|
|
|
153
153
|
surfaceManager._pendingPlan = undefined;
|
|
154
154
|
}
|
|
155
155
|
|
|
156
|
-
|
|
156
|
+
/**
|
|
157
|
+
* 会话**当前内存 log** 的快照(复制一份)。用于"需要完整序列"的操作(首次落库的 seed、rewind / fork 的历史),
|
|
158
|
+
* 不走已弃用的同步历史读 `snapshotEvents()`——那三个读口上游已声明「新调用禁止」,而这里要的是当前内存里
|
|
159
|
+
* 那份完整日志,不是按需分页的历史。
|
|
160
|
+
* @param session - 会话。
|
|
161
|
+
* @returns 事件副本(调用方可以自由遍历)。
|
|
162
|
+
*/
|
|
163
|
+
export function liveEventsOf(session: Session): readonly SessionEvent[] {
|
|
164
|
+
return [...(session as unknown as { log: readonly SessionEvent[] }).log];
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** @returns 截断后日志里最后一个 `turn/start` 的 turn 号(没有则 0)。 */
|
|
168
|
+
export function truncateLiveSession(session: Session, newLength: number): number {
|
|
157
169
|
const s = session as unknown as {
|
|
158
170
|
log: SessionEvent[];
|
|
159
171
|
eventsSnapshot?: unknown;
|
|
@@ -166,6 +178,9 @@ export function truncateLiveSession(session: Session, newLength: number): void {
|
|
|
166
178
|
derivedGeneration: number;
|
|
167
179
|
surfaceManager: SurfaceManagerLike;
|
|
168
180
|
};
|
|
181
|
+
// 截断后 agent 的 turn 游标要跟着落在保留前缀的最后一个 turn 上:这里直接读**要保留的那段**(同一个数组,
|
|
182
|
+
// 不经过已弃用的同步历史读 `snapshotEvents`,也不依赖 agent 侧的投影)。
|
|
183
|
+
const lastTurn = s.log.slice(0, newLength).findLast((event) => event.type === "turn/start")?.data.turn ?? 0;
|
|
169
184
|
s.log.length = newLength;
|
|
170
185
|
s.eventsSnapshot = undefined;
|
|
171
186
|
s.headerFold = undefined;
|
|
@@ -176,6 +191,7 @@ export function truncateLiveSession(session: Session, newLength: number): void {
|
|
|
176
191
|
s.derivedNodes = 0;
|
|
177
192
|
s.derivedGeneration = 0;
|
|
178
193
|
resetSurfaceManager(s.surfaceManager);
|
|
194
|
+
return lastTurn;
|
|
179
195
|
}
|
|
180
196
|
|
|
181
197
|
export function replaceLiveSessionLog(session: Session, events: readonly SessionEvent[]): void {
|
|
@@ -407,7 +423,7 @@ export class SessionBranchRdbProvider implements SessionBranchProvider {
|
|
|
407
423
|
internals.writeGuard.confirmHead(id, denseBoundary);
|
|
408
424
|
|
|
409
425
|
if (live !== undefined) {
|
|
410
|
-
truncateLiveSession(live, keepLength);
|
|
426
|
+
const lastTurn = truncateLiveSession(live, keepLength);
|
|
411
427
|
|
|
412
428
|
this.live.resetProjections?.(live);
|
|
413
429
|
|
|
@@ -416,8 +432,6 @@ export class SessionBranchRdbProvider implements SessionBranchProvider {
|
|
|
416
432
|
if (agent !== undefined) {
|
|
417
433
|
agent.requestHeaderLogged = false;
|
|
418
434
|
|
|
419
|
-
const lastTurn =
|
|
420
|
-
live.snapshotEvents().findLast((e) => e.type === "turn/start")?.data.turn ?? 0;
|
|
421
435
|
const phase = (agent as unknown as { phase?: { lastTurn?: number } }).phase;
|
|
422
436
|
if (phase !== undefined) phase.lastTurn = lastTurn;
|
|
423
437
|
}
|
package/src/index.ts
CHANGED
|
@@ -57,7 +57,7 @@ import {
|
|
|
57
57
|
} from "./schema.ts";
|
|
58
58
|
import { SqliteBackend } from "./sqlite.ts";
|
|
59
59
|
import { PostgresBackend } from "./postgres.ts";
|
|
60
|
-
import { SessionBranchRdb } from "./branch.ts";
|
|
60
|
+
import { SessionBranchRdb, liveEventsOf } from "./branch.ts";
|
|
61
61
|
import { balanceRewindPrefix } from "@morlay/session-branch";
|
|
62
62
|
import { registerSessionImport } from "./import.ts";
|
|
63
63
|
import { registerSessionDeletion } from "./deletion.ts";
|
|
@@ -93,7 +93,7 @@ const DEFAULT_PROJECTION_WRITE_INTERVAL_MS = 5000;
|
|
|
93
93
|
export { SCHEMA_VERSION } from "./schema.ts";
|
|
94
94
|
export { SESSION_ROWS_PATH } from "./rows.ts";
|
|
95
95
|
export type { SessionRowsItem, SessionRowsValue } from "./rows.ts";
|
|
96
|
-
export { SessionBranchRdb, SessionBranchRdbProvider, locateTurnEnd } from "./branch.ts";
|
|
96
|
+
export { SessionBranchRdb, SessionBranchRdbProvider, liveEventsOf, locateTurnEnd } from "./branch.ts";
|
|
97
97
|
|
|
98
98
|
export interface SessionPersistenceRdbInternals {
|
|
99
99
|
readonly backend: Backend;
|
|
@@ -134,12 +134,14 @@ export type Config =
|
|
|
134
134
|
journalMode?: JournalMode | undefined;
|
|
135
135
|
busyTimeout?: number | undefined;
|
|
136
136
|
projectionCache?: ProjectionCacheOptions | undefined;
|
|
137
|
+
preparedSessionCacheSize?: number | undefined;
|
|
137
138
|
}
|
|
138
139
|
| {
|
|
139
140
|
type: "postgres";
|
|
140
141
|
connectionString: string;
|
|
141
142
|
schema?: string | undefined;
|
|
142
143
|
projectionCache?: ProjectionCacheOptions | undefined;
|
|
144
|
+
preparedSessionCacheSize?: number | undefined;
|
|
143
145
|
};
|
|
144
146
|
|
|
145
147
|
// schema 解析之后的形状:整段 Config 是 **volatile 稳定引用**(页面可编辑),读它要过 `.get()`。
|
|
@@ -493,6 +495,22 @@ class RdbSessionHandle implements SessionHandle {
|
|
|
493
495
|
const localized = (text: { zh: string; en: string }): string => text as unknown as string;
|
|
494
496
|
|
|
495
497
|
// 投影 checkpoint 的写回节流:两个分支共用。
|
|
498
|
+
// 上游 session-query 的**冷会话观察缓存**容量(`preparedSessionCacheSize`):缓存按**条目数**持有完整
|
|
499
|
+
// `Session`(含整份 append-only log),被活跃观察租约钉住的条目不淘汰;一次读取中途转成 live 时会重试 live
|
|
500
|
+
// 路径,但**不会顺手清掉已缓存的 cold 条目**——所以同一个 id 可能同时有两份 log 驻留。上游默认 5;本部署把它
|
|
501
|
+
// 收到最小的 1:本地单用户、rdb 后端,冷读复用的收益不值这份内存放大。上游校验是 >= 1(不能设 0)。
|
|
502
|
+
const preparedSessionCacheSchema = z
|
|
503
|
+
.number()
|
|
504
|
+
.step(1)
|
|
505
|
+
.min(1)
|
|
506
|
+
.default(1)
|
|
507
|
+
.description(
|
|
508
|
+
localized({
|
|
509
|
+
zh: "冷会话观察缓存容量(条数,最小 1):每个条目持一份完整会话日志;本部署默认 1,把同 id 两份日志的驻留压到最小。",
|
|
510
|
+
en: "Cold prepared-Session observation cache size (entries, minimum 1): each entry holds one complete session log. This deployment defaults to 1, minimizing duplicate per-id residency.",
|
|
511
|
+
}),
|
|
512
|
+
);
|
|
513
|
+
|
|
496
514
|
const projectionCacheSchema = z
|
|
497
515
|
.object({
|
|
498
516
|
writeEveryEvents: z
|
|
@@ -560,6 +578,7 @@ export class SessionPersistenceRdb extends SessionPersistence {
|
|
|
560
578
|
}),
|
|
561
579
|
),
|
|
562
580
|
projectionCache: projectionCacheSchema,
|
|
581
|
+
preparedSessionCacheSize: preparedSessionCacheSchema,
|
|
563
582
|
}),
|
|
564
583
|
z.object({
|
|
565
584
|
type: z.const("postgres"),
|
|
@@ -582,6 +601,7 @@ export class SessionPersistenceRdb extends SessionPersistence {
|
|
|
582
601
|
}),
|
|
583
602
|
),
|
|
584
603
|
projectionCache: projectionCacheSchema,
|
|
604
|
+
preparedSessionCacheSize: preparedSessionCacheSchema,
|
|
585
605
|
}),
|
|
586
606
|
])
|
|
587
607
|
.description(
|
|
@@ -628,7 +648,12 @@ export class SessionPersistenceRdb extends SessionPersistence {
|
|
|
628
648
|
|
|
629
649
|
new SessionBranchRdb(this.ctx);
|
|
630
650
|
|
|
631
|
-
|
|
651
|
+
// 冷会话观察缓存的容量在这一行配置里(默认见 `preparedSessionCacheSchema`);session-query 自己不会再读它。
|
|
652
|
+
const queryConfig =
|
|
653
|
+
this.config.preparedSessionCacheSize === undefined
|
|
654
|
+
? {}
|
|
655
|
+
: { preparedSessionCacheSize: this.config.preparedSessionCacheSize };
|
|
656
|
+
this.ctx.plugin(SessionQueryRdb, queryConfig);
|
|
632
657
|
|
|
633
658
|
registerSessionImport(this.ctx, this);
|
|
634
659
|
|
|
@@ -875,6 +900,17 @@ export class SessionPersistenceRdb extends SessionPersistence {
|
|
|
875
900
|
await this.backend.vacuum();
|
|
876
901
|
}
|
|
877
902
|
|
|
903
|
+
/**
|
|
904
|
+
* 已归档的会话 id(`t_sessions.f_archived_at` 非空)。`session/list` 默认过滤掉它们。
|
|
905
|
+
* 读的是持久化里的归档标记,不依赖 `workspaceRegistry` 服务的就绪时机。
|
|
906
|
+
* @returns 归档过的 Session id 集。
|
|
907
|
+
*/
|
|
908
|
+
async archivedSessionIds(): Promise<ReadonlySet<string>> {
|
|
909
|
+
await this.ready;
|
|
910
|
+
const state = await this.backend.storage.readWorkspaceState();
|
|
911
|
+
return new Set((state?.archivedSessionIds ?? []).map(String));
|
|
912
|
+
}
|
|
913
|
+
|
|
878
914
|
private async unarchiveBeforeDeletion(id: SessionId): Promise<void> {
|
|
879
915
|
const registry = this.ctx.get("workspaceRegistry") as unknown as
|
|
880
916
|
| {
|
|
@@ -1440,7 +1476,7 @@ export class SessionPersistenceRdb extends SessionPersistence {
|
|
|
1440
1476
|
handle = (await this.create(session.header, {
|
|
1441
1477
|
inheritedEventCount: session.inheritedEventCount,
|
|
1442
1478
|
})) as RdbSessionHandle;
|
|
1443
|
-
const seed = session
|
|
1479
|
+
const seed = liveEventsOf(session);
|
|
1444
1480
|
if (seed.length > 0) await handle.append(seed);
|
|
1445
1481
|
} else {
|
|
1446
1482
|
if (stored.meta.cwd !== session.header.cwd) {
|
|
@@ -1456,7 +1492,7 @@ export class SessionPersistenceRdb extends SessionPersistence {
|
|
|
1456
1492
|
assertVersion(stored.meta);
|
|
1457
1493
|
|
|
1458
1494
|
repairReadView(stored.events);
|
|
1459
|
-
const seed = session
|
|
1495
|
+
const seed = liveEventsOf(session);
|
|
1460
1496
|
if (!seedCoversPrefix(seed, stored.events)) {
|
|
1461
1497
|
throw new Error(
|
|
1462
1498
|
`session "${id}" already has a persisted log on disk that does not match this live session (id collision)`,
|
package/src/log.ts
CHANGED
|
@@ -108,12 +108,12 @@ export function rowToEvent(row: EventRow): SessionEvent {
|
|
|
108
108
|
typeof (record as Record<string, unknown>)["time"] === "number" &&
|
|
109
109
|
"data" in (record as Record<string, unknown>);
|
|
110
110
|
if (full) {
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
111
|
+
// 直接改写出参而不是 `{...record}`:冷读一页就是几万次,spread 在这里是纯开销。
|
|
112
|
+
const event = record as SessionEvent & { seq: number; time: number; surfaceOp?: unknown };
|
|
113
|
+
event.seq = row.fSequence as SessionEvent["seq"];
|
|
114
|
+
event.time = row.fCreatedAt as SessionEvent["time"];
|
|
115
|
+
if (surfaceOp !== undefined) event.surfaceOp = surfaceOp;
|
|
116
|
+
return event as SessionEvent;
|
|
117
117
|
}
|
|
118
118
|
return {
|
|
119
119
|
type: row.fType as SessionEvent["type"],
|
|
@@ -648,44 +648,40 @@ export function scanRows(
|
|
|
648
648
|
rows: readonly EventRow[],
|
|
649
649
|
base = 0,
|
|
650
650
|
): { preserved: SessionEvent[]; tornFrom?: number } {
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
event?: SessionEvent;
|
|
654
|
-
}
|
|
655
|
-
const parsed: Parsed[] = rows.map((row) => {
|
|
656
|
-
try {
|
|
657
|
-
return { ok: true, event: rowToEvent(row) };
|
|
658
|
-
} catch {
|
|
659
|
-
return { ok: false };
|
|
660
|
-
}
|
|
661
|
-
});
|
|
662
|
-
|
|
651
|
+
// 已提交区(最后一个 `turn/end` 及之前)必须完整:坏行/断号只有在它之后才允许当作撕裂尾部丢掉。
|
|
652
|
+
// 倒序只为找到这个边界,先按行类型筛(绝大多数行直接跳过),再解析候选行确认它本身可读。
|
|
663
653
|
let lastTurnEnd = -1;
|
|
664
|
-
for (let i =
|
|
665
|
-
if (
|
|
654
|
+
for (let i = rows.length - 1; i >= 0; i--) {
|
|
655
|
+
if (rows[i]?.fType !== "turn/end") continue;
|
|
656
|
+
try {
|
|
657
|
+
rowToEvent(rows[i] as EventRow);
|
|
666
658
|
lastTurnEnd = i;
|
|
667
659
|
break;
|
|
660
|
+
} catch {
|
|
661
|
+
/* 这个 turn/end 自身坏掉:继续往前找上一个可读的边界。 */
|
|
668
662
|
}
|
|
669
663
|
}
|
|
670
664
|
|
|
671
665
|
const preserved: SessionEvent[] = [];
|
|
672
666
|
for (let i = 0; i < rows.length; i++) {
|
|
673
|
-
|
|
674
|
-
|
|
667
|
+
let event: SessionEvent;
|
|
668
|
+
try {
|
|
669
|
+
event = rowToEvent(rows[i] as EventRow);
|
|
670
|
+
} catch {
|
|
675
671
|
if (i <= lastTurnEnd)
|
|
676
672
|
throw new Error(
|
|
677
673
|
`corrupt session log: unparsable committed event at seq ${rows[i]?.fSequence}`,
|
|
678
674
|
);
|
|
679
675
|
break;
|
|
680
676
|
}
|
|
681
|
-
if (
|
|
677
|
+
if (event.seq !== base + i) {
|
|
682
678
|
if (i <= lastTurnEnd)
|
|
683
679
|
throw new Error(
|
|
684
|
-
`corrupt session log: seq gap in committed region (expected ${base + i}, got ${
|
|
680
|
+
`corrupt session log: seq gap in committed region (expected ${base + i}, got ${event.seq})`,
|
|
685
681
|
);
|
|
686
682
|
break;
|
|
687
683
|
}
|
|
688
|
-
preserved.push(
|
|
684
|
+
preserved.push(event);
|
|
689
685
|
}
|
|
690
686
|
|
|
691
687
|
return preserved.length < rows.length
|
package/src/session-query.ts
CHANGED
|
@@ -7,25 +7,38 @@ import {
|
|
|
7
7
|
} from "@deepseek-ai/dsh-session-query";
|
|
8
8
|
|
|
9
9
|
export class SessionQueryRdb extends SessionQueryEngine {
|
|
10
|
+
/**
|
|
11
|
+
* 本次装配用的冷会话观察缓存容量(由 `session-rdb` 行的 `preparedSessionCacheSize` 透传;undefined 表示沿用
|
|
12
|
+
* 上游默认)。它是**按条目数**持有完整 Session 的缓存:条目多一条就多一份完整 append-only log 驻留。
|
|
13
|
+
*/
|
|
14
|
+
readonly preparedSessionCacheSize: number | undefined;
|
|
15
|
+
|
|
10
16
|
constructor(ctx: Context, config: Config = {}) {
|
|
11
17
|
super(ctx, config);
|
|
18
|
+
this.preparedSessionCacheSize = config.preparedSessionCacheSize;
|
|
12
19
|
}
|
|
13
20
|
|
|
14
21
|
// 官方列表(`session/list`)**默认不含已归档**:归档是「收起来」的会话,上游 client 用 workspace registry
|
|
15
22
|
// 的归档集做「只看归档」的派生;完整语料走管理面路由 `SESSION_ROWS_PATH`。
|
|
16
23
|
override async listSessions(signal?: AbortSignal): Promise<SessionRecord[]> {
|
|
17
24
|
const records = await super.listSessions(signal);
|
|
18
|
-
const archived = this.archivedSessionIds();
|
|
25
|
+
const archived = await this.archivedSessionIds();
|
|
19
26
|
if (archived.size === 0) return records;
|
|
20
27
|
return records.filter((record) => !archived.has(String(record.header.id)));
|
|
21
28
|
}
|
|
22
29
|
|
|
23
|
-
//
|
|
24
|
-
|
|
30
|
+
// 归档集合:优先读 workspace registry(它和页面上的归档动作同源);registry 不在(headless / 启动早期 /
|
|
31
|
+
// 测试装配)就退回持久化里的 `f_archived_at`——`session/list` 默认**不含**已归档是这一层的语义,不能因为
|
|
32
|
+
// 一个可选服务没到就把归档会话又列出来。
|
|
33
|
+
private async archivedSessionIds(): Promise<ReadonlySet<string>> {
|
|
25
34
|
const registry = this.ctx.get("workspaceRegistry" as never) as unknown as
|
|
26
35
|
| { archivedSessionIds?: readonly string[] }
|
|
27
36
|
| undefined;
|
|
28
|
-
return new Set<string>(registry
|
|
37
|
+
if (registry !== undefined) return new Set<string>(registry.archivedSessionIds ?? []);
|
|
38
|
+
const persistence = this.ctx.get("sessionPersistence") as unknown as
|
|
39
|
+
| { archivedSessionIds?: () => Promise<ReadonlySet<string>> }
|
|
40
|
+
| undefined;
|
|
41
|
+
return (await persistence?.archivedSessionIds?.()) ?? new Set<string>();
|
|
29
42
|
}
|
|
30
43
|
|
|
31
44
|
override async searchSessions(): Promise<never> {
|
package/src/sqlite.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { mkdir, open, readdir, stat } from "node:fs/promises";
|
|
|
3
3
|
import { dirname, resolve } from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { DatabaseSync } from "node:sqlite";
|
|
6
|
-
import { and, desc, eq,
|
|
6
|
+
import { and, desc, eq, inArray, lt, notInArray, sql } from "drizzle-orm";
|
|
7
7
|
import { drizzle, type NodeSQLiteDatabase } from "drizzle-orm/node-sqlite";
|
|
8
8
|
import { migrate } from "drizzle-orm/node-sqlite/migrator";
|
|
9
9
|
import type { SessionId } from "@deepseek-ai/dsh-session";
|
|
@@ -341,6 +341,10 @@ function configureDatabase(
|
|
|
341
341
|
db.exec("PRAGMA foreign_keys = ON");
|
|
342
342
|
|
|
343
343
|
db.exec(`PRAGMA busy_timeout = ${busyTimeout}`);
|
|
344
|
+
// 冷读一次要顺序扫整份日志(大会话 30k+ 事件、几十 MiB 的 `f_data`):把页缓存与 mmap 窗口调大,
|
|
345
|
+
// 省掉的是 SQLite 与内核页缓存之间的往返。`:memory:` 上这两个 PRAGMA 无害。
|
|
346
|
+
db.exec("PRAGMA cache_size = -65536");
|
|
347
|
+
db.exec("PRAGMA mmap_size = 268435456");
|
|
344
348
|
const dbx = drizzle({ client: db });
|
|
345
349
|
|
|
346
350
|
dbx.transaction(
|
|
@@ -500,13 +504,19 @@ export class SqliteBackend implements Backend {
|
|
|
500
504
|
}
|
|
501
505
|
|
|
502
506
|
async getEventRows(id: SessionId, fromSequence?: number): Promise<EventRow[]> {
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
507
|
+
// 裸 prepared 语句而不是 drizzle 的 `.select().all()`:冷读要一次拉整份日志(大会话 30k+ 行、几十 MiB 的
|
|
508
|
+
// `f_data`),drizzle 的逐行对象映射在这条路径上是纯开销。列集合与 `eventRows()` 完全一致。
|
|
509
|
+
const sql =
|
|
510
|
+
`select se.f_event_id as fEventId, se.f_sequence as fSequence, e.f_type as fType,
|
|
511
|
+
e.f_kind as fKind, e.f_role as fRole, e.f_name as fName, e.f_action_id as fActionId,
|
|
512
|
+
e.f_created_at as fCreatedAt, e.f_data as fData, se.f_surface_op as fSurfaceOp
|
|
513
|
+
from t_session_events se join t_events e on se.f_event_id = e.f_event_id
|
|
514
|
+
where se.f_session_id = ?${fromSequence === undefined ? "" : " and se.f_sequence >= ?"}
|
|
515
|
+
order by se.f_sequence`;
|
|
516
|
+
const statement = this.db.$client.prepare(sql);
|
|
517
|
+
const rows =
|
|
518
|
+
fromSequence === undefined ? statement.all(id) : statement.all(id, fromSequence);
|
|
519
|
+
return rows as unknown as EventRow[];
|
|
510
520
|
}
|
|
511
521
|
|
|
512
522
|
// 只取类型:rewind 的边界 / 窗口探测不该把整个事件 JSON(f_data)拖回来。
|
|
@@ -9,9 +9,22 @@ import type {
|
|
|
9
9
|
} from "@deepseek-ai/dsh-session";
|
|
10
10
|
import type {
|
|
11
11
|
ProjectionCheckpoint,
|
|
12
|
+
ProjectionKeySelection,
|
|
12
13
|
ProjectionSnapshot,
|
|
13
14
|
SessionProjectionMap,
|
|
14
15
|
} from "@deepseek-ai/dsh-session-projection";
|
|
16
|
+
|
|
17
|
+
// 上游的 keys 选择面既接受数组也接受 `ReadonlySet`(`sessionProjections.summaryKeys()` 给的是 Set)。
|
|
18
|
+
// 只看一个键时两种都要认——之前只按数组写了 `.includes`,Set 上直接抛 `keys.includes is not a function`,
|
|
19
|
+
// 列表行于是整列投影都没有(会话标题显示"未命名")。
|
|
20
|
+
function selects(
|
|
21
|
+
keys: ProjectionKeySelection | undefined,
|
|
22
|
+
key: Extract<keyof SessionProjectionMap, string>,
|
|
23
|
+
): boolean {
|
|
24
|
+
if (keys === undefined) return true;
|
|
25
|
+
if ("has" in keys) return keys.has(key);
|
|
26
|
+
return (keys as readonly string[]).includes(key);
|
|
27
|
+
}
|
|
15
28
|
import type {
|
|
16
29
|
StorageRepository,
|
|
17
30
|
StoredProjcacheEntry,
|
|
@@ -105,7 +118,7 @@ export class SessionProjectionCacheRdb extends Service {
|
|
|
105
118
|
// inherited cut(那个只有写路径与 hydration 用得上)。
|
|
106
119
|
cachedSnapshot(
|
|
107
120
|
meta: SessionHeader,
|
|
108
|
-
keys?:
|
|
121
|
+
keys?: ProjectionKeySelection,
|
|
109
122
|
): ProjectionSnapshot | undefined {
|
|
110
123
|
const record = this.recordForLifecycle(meta);
|
|
111
124
|
const snapshot = record === undefined ? undefined : this.viewRecord(record, keys);
|
|
@@ -121,12 +134,10 @@ export class SessionProjectionCacheRdb extends Service {
|
|
|
121
134
|
|
|
122
135
|
private withDirectTitle(
|
|
123
136
|
id: SessionId,
|
|
124
|
-
keys:
|
|
137
|
+
keys: ProjectionKeySelection | undefined,
|
|
125
138
|
snapshot: ProjectionSnapshot | undefined,
|
|
126
139
|
): ProjectionSnapshot | undefined {
|
|
127
|
-
if (
|
|
128
|
-
return snapshot;
|
|
129
|
-
}
|
|
140
|
+
if (!selects(keys, PREDECESSOR_TITLE_KEY)) return snapshot;
|
|
130
141
|
if (snapshot?.values[PREDECESSOR_TITLE_KEY] !== undefined) return snapshot;
|
|
131
142
|
const direct = this.repository.readSessionTitleDirect?.(id);
|
|
132
143
|
if (direct === undefined) return snapshot;
|
|
@@ -150,7 +161,7 @@ export class SessionProjectionCacheRdb extends Service {
|
|
|
150
161
|
|
|
151
162
|
private viewRecord(
|
|
152
163
|
record: StoredProjcacheEntry,
|
|
153
|
-
keys?:
|
|
164
|
+
keys?: ProjectionKeySelection,
|
|
154
165
|
): ProjectionSnapshot | undefined {
|
|
155
166
|
const values = this.ctx.sessionProjections.viewCheckpoint(
|
|
156
167
|
record.rows as unknown as ProjectionCheckpoint,
|
|
@@ -232,6 +232,17 @@ export function createStorageRepository(host: StorageRepositoryHost): StorageRep
|
|
|
232
232
|
});
|
|
233
233
|
},
|
|
234
234
|
|
|
235
|
+
async deleteWorkspaces(ids: readonly string[]): Promise<void> {
|
|
236
|
+
if (ids.length === 0) return;
|
|
237
|
+
await host.writeAtomically(async () => {
|
|
238
|
+
const db = await dbx();
|
|
239
|
+
await runQuery(
|
|
240
|
+
db.delete(tWorkspaceSessions).where(inArray(tWorkspaceSessions.fWorkspaceId, [...ids])),
|
|
241
|
+
);
|
|
242
|
+
await runQuery(db.delete(tWorkspaces).where(inArray(tWorkspaces.fWorkspaceId, [...ids])));
|
|
243
|
+
});
|
|
244
|
+
},
|
|
245
|
+
|
|
235
246
|
async readWorkspaceState(): Promise<WorkspaceDomainState | null> {
|
|
236
247
|
const db = await dbx();
|
|
237
248
|
const row = await oneRow<{
|
|
@@ -399,6 +410,14 @@ export function createStorageRepository(host: StorageRepositoryHost): StorageRep
|
|
|
399
410
|
});
|
|
400
411
|
},
|
|
401
412
|
|
|
413
|
+
async deleteScheduleTasks(ids: readonly string[]): Promise<void> {
|
|
414
|
+
if (ids.length === 0) return;
|
|
415
|
+
await host.writeAtomically(async () => {
|
|
416
|
+
const db = await dbx();
|
|
417
|
+
await runQuery(db.delete(tScheduleTasks).where(inArray(tScheduleTasks.fId, [...ids])));
|
|
418
|
+
});
|
|
419
|
+
},
|
|
420
|
+
|
|
402
421
|
async loadProjcache(): Promise<StoredProjcacheEntry[]> {
|
|
403
422
|
const db = await dbx();
|
|
404
423
|
const rows = await allRows<ProjcacheRowRecord>(db.select().from(tProjcacheRows));
|
|
@@ -177,6 +177,13 @@ class WorkspaceKvUnit extends TrackedKvUnit {
|
|
|
177
177
|
await this.track(this.repository.deleteWorkspace(key));
|
|
178
178
|
}
|
|
179
179
|
|
|
180
|
+
// 上游 `KvUnit.deleteRecords`:行存储一次批量做完(不让调用方逐条重试)。
|
|
181
|
+
async deleteRecords(table: string, keys: readonly string[]): Promise<void> {
|
|
182
|
+
this.assertOpen();
|
|
183
|
+
this.assertTable(table);
|
|
184
|
+
await this.track(this.repository.deleteWorkspaces([...new Set(keys)]));
|
|
185
|
+
}
|
|
186
|
+
|
|
180
187
|
async setGlobal(value: unknown): Promise<void> {
|
|
181
188
|
this.assertOpen();
|
|
182
189
|
await this.track(this.repository.writeWorkspaceState(workspaceStateOf(value)));
|
|
@@ -208,6 +215,12 @@ class ScheduleKvUnit extends TrackedKvUnit {
|
|
|
208
215
|
await this.track(this.repository.deleteScheduleTask(key));
|
|
209
216
|
}
|
|
210
217
|
|
|
218
|
+
async deleteRecords(table: string, keys: readonly string[]): Promise<void> {
|
|
219
|
+
this.assertOpen();
|
|
220
|
+
this.assertTable(table);
|
|
221
|
+
await this.track(this.repository.deleteScheduleTasks([...new Set(keys)]));
|
|
222
|
+
}
|
|
223
|
+
|
|
211
224
|
setGlobal(_value: unknown): Promise<void> {
|
|
212
225
|
throw new Error(`unit '${this.descriptor.name}' declares no global slot`);
|
|
213
226
|
}
|
|
@@ -33,6 +33,9 @@ export interface StorageRepository {
|
|
|
33
33
|
|
|
34
34
|
deleteWorkspace(id: string): Promise<void>;
|
|
35
35
|
|
|
36
|
+
// 一次批量(上游 `KvUnit.deleteRecords` 的契约:行存储要么一次做完、要么不做)。
|
|
37
|
+
deleteWorkspaces(ids: readonly string[]): Promise<void>;
|
|
38
|
+
|
|
36
39
|
readWorkspaceState(): Promise<WorkspaceDomainState | null>;
|
|
37
40
|
|
|
38
41
|
writeWorkspaceState(state: WorkspaceDomainState): Promise<void>;
|
|
@@ -44,6 +47,8 @@ export interface StorageRepository {
|
|
|
44
47
|
|
|
45
48
|
deleteScheduleTask(id: string): Promise<void>;
|
|
46
49
|
|
|
50
|
+
deleteScheduleTasks(ids: readonly string[]): Promise<void>;
|
|
51
|
+
|
|
47
52
|
loadProjcache(): Promise<StoredProjcacheEntry[]>;
|
|
48
53
|
|
|
49
54
|
// 不经 await、直接读的路径(供同步的 `cachedSnapshot` 消费);宿主提供不了则缺省。
|