@morlay/session-rdb 0.0.17 → 0.0.19
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/dist/artifact.d.mts +1 -1
- package/dist/artifact.mjs +1 -1
- package/dist/{import-DZIAjOUr.mjs → import-Bc2QQa5G.mjs} +14 -5
- package/dist/import.d.mts +3 -2
- package/dist/import.mjs +1 -1
- package/dist/{index-BEKoV1Uy.d.mts → index-CCcK9tia.d.mts} +2 -2
- package/dist/index.d.mts +2 -2
- package/dist/index.mjs +93 -39
- package/dist/{magic-string.es-BWtG0AyA.mjs → magic-string.es-BgJoa-3K.mjs} +2 -2
- package/dist/{schema-Bo6Hy5gW.d.mts → schema-DPcuEh_a.d.mts} +36 -47
- package/dist/{sqlite-BIPdMNQb.mjs → sqlite-DYExtbLo.mjs} +27 -6
- package/dist/storage.d.mts +1 -1
- package/dist/storage.mjs +1 -1
- package/dist/testing.mjs +20 -20
- package/drizzle/postgres/20260910120001_v3_storage_tables/snapshot.json +25 -78
- package/drizzle/postgres/20260910130001_v3_session_title_backfill/snapshot.json +25 -78
- package/drizzle/sqlite/20260910120000_v3_storage_tables/snapshot.json +25 -78
- package/drizzle/sqlite/20260910130000_v3_session_title_backfill/snapshot.json +25 -78
- package/package.json +20 -19
- package/src/entities/v3/workspace-state.ts +1 -1
- package/src/import-storages.ts +8 -3
- package/src/import.ts +41 -4
- package/src/index.ts +10 -2
- package/src/postgres.ts +15 -9
- package/src/session-query.ts +27 -0
- package/src/storage-takeover/index.ts +8 -8
- package/src/storage-takeover/projection-cache.ts +50 -24
- package/src/storage-takeover/repository.ts +66 -20
- package/src/storage-takeover/storage-backend.ts +65 -21
- package/src/storage-takeover/types.ts +11 -3
package/src/index.ts
CHANGED
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
type SessionPersistenceStatOptions,
|
|
31
31
|
} from "@deepseek-ai/dsh-session-persistence";
|
|
32
32
|
import {
|
|
33
|
+
SESSION_FORMAT_VERSION,
|
|
33
34
|
SessionLogOffset,
|
|
34
35
|
type Session,
|
|
35
36
|
type SessionEvent,
|
|
@@ -37,6 +38,7 @@ import {
|
|
|
37
38
|
type SessionId,
|
|
38
39
|
type SurfaceEventType,
|
|
39
40
|
} from "@deepseek-ai/dsh-session";
|
|
41
|
+
import { sessionFormatLogFilename } from "@deepseek-ai/dsh-session-format";
|
|
40
42
|
import { type Backend, type BackendTx, type EventInsert } from "./backend.ts";
|
|
41
43
|
import { WriteGuard } from "./write-guard.ts";
|
|
42
44
|
import { repairReadView, rowToMeta, scanRows, toJsonlArtifact } from "./log.ts";
|
|
@@ -50,6 +52,7 @@ import { SqliteBackend } from "./sqlite.ts";
|
|
|
50
52
|
import { PostgresBackend } from "./postgres.ts";
|
|
51
53
|
import { SessionBranchRdb } from "./branch.ts";
|
|
52
54
|
import { registerSessionImport } from "./import.ts";
|
|
55
|
+
import { SessionQueryRdb } from "./session-query.ts";
|
|
53
56
|
import { adoptLegacyRows, convertLegacyRows, isLegacyVersion } from "./legacy.ts";
|
|
54
57
|
import { installStorageTakeover } from "./storage-takeover/index.ts";
|
|
55
58
|
|
|
@@ -532,6 +535,10 @@ export class SessionPersistenceRdb extends SessionPersistence {
|
|
|
532
535
|
this.installLiveRouting(ctx);
|
|
533
536
|
// 分支 provider 服务(rewind / forkFrom / timeline),随 fiber 卸载自动回滚。
|
|
534
537
|
new SessionBranchRdb(this.ctx);
|
|
538
|
+
// 会话查询服务:接管官方 session-query-sqlite(同名 provide 会 fail loud,
|
|
539
|
+
// 其装配行由 better-session patch 禁用);class-plugin 装载以复用基类的
|
|
540
|
+
// 精确读 / 过滤 / 血缘实现。
|
|
541
|
+
this.ctx.plugin(SessionQueryRdb, {});
|
|
535
542
|
// 导入端点:webServer + connection 就绪后注册 `/api/session.import`。
|
|
536
543
|
registerSessionImport(this.ctx, this);
|
|
537
544
|
// storages 接管:storage hub 的 `rdb` 后端(workspace 域)与
|
|
@@ -706,7 +713,7 @@ export class SessionPersistenceRdb extends SessionPersistence {
|
|
|
706
713
|
|
|
707
714
|
// --- RDB 特有能力(rewind / fork / 导出 / 测试支撑) ---
|
|
708
715
|
|
|
709
|
-
/**
|
|
716
|
+
/** 导出当前世代的 jsonl artifact,视图只读不落库。 */
|
|
710
717
|
async readRaw(
|
|
711
718
|
id: SessionId,
|
|
712
719
|
signal?: AbortSignal,
|
|
@@ -724,7 +731,8 @@ export class SessionPersistenceRdb extends SessionPersistence {
|
|
|
724
731
|
return {
|
|
725
732
|
meta: log.meta,
|
|
726
733
|
inheritedEventCount,
|
|
727
|
-
|
|
734
|
+
// 内容按当前世代编码,文件名必须声明同一世代(上游导出用 session.vN.jsonl)。
|
|
735
|
+
filename: sessionFormatLogFilename(SESSION_FORMAT_VERSION),
|
|
728
736
|
content: toJsonlArtifact(log.meta, inheritedEventCount, log.events),
|
|
729
737
|
};
|
|
730
738
|
}
|
package/src/postgres.ts
CHANGED
|
@@ -60,16 +60,22 @@ export class PostgresBackend implements Backend {
|
|
|
60
60
|
this.opened.catch(() => {});
|
|
61
61
|
this.storage = createStorageRepository({
|
|
62
62
|
db: () => this.opened.then(() => this.txOverride ?? this.db),
|
|
63
|
+
// storages 写事务用 serializable:workspace 域是整记录替换,多实例并发写
|
|
64
|
+
// 同一记录在 read committed 下是「最后提交者赢」的静默覆盖;提升隔离级别
|
|
65
|
+
// 后冲突以序列化失败暴露(与「并发写入 fail loud」一致),调用方重试即可。
|
|
63
66
|
writeAtomically: (fn) =>
|
|
64
|
-
this.db.transaction(
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
67
|
+
this.db.transaction(
|
|
68
|
+
async (tx) => {
|
|
69
|
+
const previous = this.txOverride;
|
|
70
|
+
this.txOverride = tx;
|
|
71
|
+
try {
|
|
72
|
+
return await fn();
|
|
73
|
+
} finally {
|
|
74
|
+
this.txOverride = previous;
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
{ isolationLevel: "serializable" },
|
|
78
|
+
),
|
|
73
79
|
tables: this.tables,
|
|
74
80
|
});
|
|
75
81
|
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { Context } from "@deepseek-ai/cordis";
|
|
2
|
+
import { SessionQueryEngine, SessionQueryError, type Config } from "@deepseek-ai/dsh-session-query";
|
|
3
|
+
|
|
4
|
+
// 会话查询服务的 rdb 实现:精确读 / 过滤 / 血缘全部复用上游基类(数据面走
|
|
5
|
+
// ctx.sessions 与我们的 ctx.sessionPersistence),这里只接管服务所有权。
|
|
6
|
+
// 全文检索与官方 `openAt: never` 装配保持一致——直接拒绝,不引入 FTS 索引、
|
|
7
|
+
// node:sqlite 或派生库,搜索语义留待显式实现(见 ADR 0006)。
|
|
8
|
+
export class SessionQueryRdb extends SessionQueryEngine {
|
|
9
|
+
constructor(ctx: Context, config: Config = {}) {
|
|
10
|
+
super(ctx, config);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
override async searchSessions(): Promise<never> {
|
|
14
|
+
throw searchDisabled();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
override async searchEvents(): Promise<never> {
|
|
18
|
+
throw searchDisabled();
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function searchDisabled(): SessionQueryError {
|
|
23
|
+
return new SessionQueryError(
|
|
24
|
+
"session search is disabled: this deployment serves session queries from the rdb backend without a full-text index",
|
|
25
|
+
"SESSION_QUERY_SEARCH_DISABLED",
|
|
26
|
+
);
|
|
27
|
+
}
|
|
@@ -48,13 +48,13 @@ export function installStorageTakeover(ctx: Context, options: StorageTakeoverOpt
|
|
|
48
48
|
storageCtx.provide(storageBackendServiceKey(RDB_STORAGE_BACKEND), backend);
|
|
49
49
|
});
|
|
50
50
|
|
|
51
|
-
// 投影 checkpoint:上游插件被禁用后由本服务提供同名 API
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
51
|
+
// 投影 checkpoint:上游插件被禁用后由本服务提供同名 API。按 cordis
|
|
52
|
+
// class plugin 装载(而不是直接 new):`static inject` 负责等依赖就绪,
|
|
53
|
+
// 框架负责调用 `[Service.init]` 安装写入路径——直接 new 会静默跳过 init,
|
|
54
|
+
// 导致 session/created、turn/end、计数与定时器的 checkpoint 写入全部失效。
|
|
55
|
+
ctx.plugin(SessionProjectionCacheRdb, {
|
|
56
|
+
...options.projectionCache,
|
|
57
|
+
repository: options.repository,
|
|
58
|
+
ready: options.ready,
|
|
59
59
|
});
|
|
60
60
|
}
|
|
@@ -49,6 +49,12 @@ export interface ProjectionCacheConfig {
|
|
|
49
49
|
writeIntervalMs: number;
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
/** class-plugin 装载参数:写节流 + storages 接管访问层 + 介质就绪信号。 */
|
|
53
|
+
export interface SessionProjectionCacheRdbOptions extends ProjectionCacheConfig {
|
|
54
|
+
repository: StorageRepository;
|
|
55
|
+
ready: Promise<unknown>;
|
|
56
|
+
}
|
|
57
|
+
|
|
52
58
|
/** 每会话写回节流簿记(只对 live session)。 */
|
|
53
59
|
interface DirtyState {
|
|
54
60
|
pending: number;
|
|
@@ -67,35 +73,48 @@ export class SessionProjectionCacheRdb extends Service {
|
|
|
67
73
|
private readonly dirty = new Map<Session, DirtyState>();
|
|
68
74
|
/** 读路径是否直读介质(`readProjcacheSync` 存在即同步驱动)。 */
|
|
69
75
|
private readonly directReads: boolean;
|
|
76
|
+
private readonly config: ProjectionCacheConfig;
|
|
77
|
+
private readonly repository: StorageRepository;
|
|
78
|
+
private readonly ready: Promise<unknown>;
|
|
70
79
|
|
|
71
80
|
/**
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
* @param
|
|
81
|
+
* 本类按 cordis class plugin 装载(`ctx.plugin(SessionProjectionCacheRdb, options)`):
|
|
82
|
+
* `static inject` 等依赖就绪后构造,构造后框架调用 `[Service.init]` 安装写入
|
|
83
|
+
* 路径——直接 `new` 不会触发 init,写入路径会静默缺失。
|
|
84
|
+
* @param ctx - 插件上下文(已注入 sessionProjections 与 sessions)。
|
|
85
|
+
* @param options - 写节流 + storages 接管访问层 + 介质就绪信号。
|
|
76
86
|
*/
|
|
77
|
-
constructor(
|
|
78
|
-
ctx: Context,
|
|
79
|
-
private readonly config: ProjectionCacheConfig,
|
|
80
|
-
private readonly repository: StorageRepository,
|
|
81
|
-
private readonly ready: Promise<unknown>,
|
|
82
|
-
) {
|
|
87
|
+
constructor(ctx: Context, options: SessionProjectionCacheRdbOptions) {
|
|
83
88
|
super(ctx, SESSION_PROJECTION_CACHE_SERVICE);
|
|
84
|
-
this.
|
|
89
|
+
this.config = options;
|
|
90
|
+
this.repository = options.repository;
|
|
91
|
+
this.ready = options.ready;
|
|
92
|
+
this.directReads = options.repository.readProjcacheSync !== undefined;
|
|
85
93
|
}
|
|
86
94
|
|
|
87
95
|
/**
|
|
88
|
-
*
|
|
89
|
-
*
|
|
96
|
+
* Install the write path before the async medium wait: listeners must exist
|
|
97
|
+
* from plugin activation on, or sessions created while the medium settles
|
|
98
|
+
* lose their mandatory checkpoints. Only the async driver needs the startup
|
|
99
|
+
* mirror; the sync driver serves every read from the table.
|
|
90
100
|
*/
|
|
91
101
|
protected async [Service.init](): Promise<void> {
|
|
102
|
+
this.installWritePath();
|
|
92
103
|
await this.ready;
|
|
104
|
+
// 一次性清理旧版写入路径留下的陈旧行(水位为负却已有事件):这些行会把
|
|
105
|
+
// 有对话的会话在 cold 列表里误判为空白。缓存是派生数据,删除让会话回到
|
|
106
|
+
// 「未知即可见」,等待下一次强制点重写。
|
|
107
|
+
const pruned = await this.repository.pruneStaleProjcache();
|
|
108
|
+
if (pruned > 0) {
|
|
109
|
+
this.ctx.logger.info(
|
|
110
|
+
`session projection cache: pruned stale snapshot(s) for ${String(pruned)} session(s)`,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
93
113
|
if (!this.directReads) {
|
|
94
114
|
for (const entry of await this.repository.loadProjcache()) {
|
|
95
115
|
this.records.set(entry.sessionId as SessionId, entry);
|
|
96
116
|
}
|
|
97
117
|
}
|
|
98
|
-
this.installWritePath();
|
|
99
118
|
}
|
|
100
119
|
|
|
101
120
|
/** Read one session's stored record: straight from the medium, or from the async mirror. */
|
|
@@ -113,7 +132,10 @@ export class SessionProjectionCacheRdb extends Service {
|
|
|
113
132
|
* @param expected - the log identity the caller holds (live or stored header).
|
|
114
133
|
* @returns the identity-matching record, or `undefined` (absent or unrelated).
|
|
115
134
|
*/
|
|
116
|
-
private recordFor(
|
|
135
|
+
private recordFor(
|
|
136
|
+
id: SessionId,
|
|
137
|
+
expected: CurrentCheckpointIdentity,
|
|
138
|
+
): StoredProjcacheEntry | undefined {
|
|
117
139
|
const record = this.lookup(id);
|
|
118
140
|
if (record === undefined) return undefined;
|
|
119
141
|
return identityMatches(record.identity, expected) ? record : undefined;
|
|
@@ -329,12 +351,15 @@ export class SessionProjectionCacheRdb extends Service {
|
|
|
329
351
|
this.dirty.delete(session);
|
|
330
352
|
});
|
|
331
353
|
|
|
332
|
-
this.ctx.effect(
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
354
|
+
this.ctx.effect(
|
|
355
|
+
() => () => {
|
|
356
|
+
for (const state of this.dirty.values()) {
|
|
357
|
+
if (state.timer !== undefined) clearTimeout(state.timer);
|
|
358
|
+
}
|
|
359
|
+
this.dirty.clear();
|
|
360
|
+
},
|
|
361
|
+
"sessionProjectionCacheRdb.timers",
|
|
362
|
+
);
|
|
338
363
|
}
|
|
339
364
|
|
|
340
365
|
/** One fail-soft durable checkpoint. */
|
|
@@ -374,7 +399,9 @@ export class SessionProjectionCacheRdb extends Service {
|
|
|
374
399
|
}
|
|
375
400
|
|
|
376
401
|
/** Detach one checkpoint from live unit state, refusing non-lossless JSON. */
|
|
377
|
-
function detachJson(
|
|
402
|
+
function detachJson(
|
|
403
|
+
rows: Record<string, ProjectionCheckpointRow>,
|
|
404
|
+
): Record<string, ProjectionCheckpointRow> {
|
|
378
405
|
let text: string | undefined;
|
|
379
406
|
try {
|
|
380
407
|
text = JSON.stringify(rows);
|
|
@@ -416,8 +443,7 @@ function identityOf(
|
|
|
416
443
|
*/
|
|
417
444
|
function identityMatches(stored: CheckpointIdentity, expected: CurrentCheckpointIdentity): boolean {
|
|
418
445
|
return (
|
|
419
|
-
stored.formatVersion === expected.formatVersion &&
|
|
420
|
-
lifecycleIdentityMatches(stored, expected)
|
|
446
|
+
stored.formatVersion === expected.formatVersion && lifecycleIdentityMatches(stored, expected)
|
|
421
447
|
);
|
|
422
448
|
}
|
|
423
449
|
|
|
@@ -7,8 +7,14 @@
|
|
|
7
7
|
* 在介质事务内完成(`host.writeAtomically`),不留部分应用的中间态。
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
10
|
+
import { randomUUID } from "node:crypto";
|
|
11
|
+
import { and, eq, gte, inArray, isNotNull, lt, notInArray, sql } from "drizzle-orm";
|
|
12
|
+
import {
|
|
13
|
+
SESSION_FORMAT_VERSION,
|
|
14
|
+
SessionId,
|
|
15
|
+
SessionLogOffset,
|
|
16
|
+
SessionSeq,
|
|
17
|
+
} from "@deepseek-ai/dsh-session";
|
|
12
18
|
import type { SessionSeqCursor } from "@deepseek-ai/dsh-session";
|
|
13
19
|
import type { WorkspaceId } from "@deepseek-ai/dsh-workspace";
|
|
14
20
|
import type {
|
|
@@ -92,9 +98,7 @@ function identityOfSession(row: SessionIdentityRow): CheckpointIdentity {
|
|
|
92
98
|
createdAt: row.fCreatedAt,
|
|
93
99
|
...(row.fCwd === null ? {} : { cwd: row.fCwd }),
|
|
94
100
|
isSeeded: row.fSeedLength !== null,
|
|
95
|
-
...(row.fSeedLength === null
|
|
96
|
-
? {}
|
|
97
|
-
: { inheritedEventCount: SessionLogOffset(row.fSeedLength) }),
|
|
101
|
+
...(row.fSeedLength === null ? {} : { inheritedEventCount: SessionLogOffset(row.fSeedLength) }),
|
|
98
102
|
};
|
|
99
103
|
}
|
|
100
104
|
|
|
@@ -305,20 +309,38 @@ export function createStorageRepository(host: StorageRepositoryHost): StorageRep
|
|
|
305
309
|
.update(tSessions)
|
|
306
310
|
.set({ fArchivedAt: null })
|
|
307
311
|
.where(
|
|
308
|
-
and(
|
|
309
|
-
isNotNull(tSessions.fArchivedAt),
|
|
310
|
-
notInArray(tSessions.fSessionId, archived),
|
|
311
|
-
),
|
|
312
|
+
and(isNotNull(tSessions.fArchivedAt), notInArray(tSessions.fSessionId, archived)),
|
|
312
313
|
),
|
|
313
314
|
);
|
|
314
315
|
const stamp = Date.now();
|
|
315
316
|
for (const sessionId of archived) {
|
|
316
|
-
//
|
|
317
|
+
// 归档标记落在会话行上(f_archived_at);live 但从未物化的会话还没有
|
|
318
|
+
// 行——补一行骨架(head=-1、无 cwd/seed),否则标记无处可写,重启后
|
|
319
|
+
// 归档集由介质重算时会静默丢失。真正物化走 upsertSession,其冲突列
|
|
320
|
+
// 不含 f_archived_at,所以标记在物化后仍保留。
|
|
317
321
|
await runQuery(
|
|
318
322
|
db
|
|
319
|
-
.
|
|
320
|
-
.
|
|
321
|
-
|
|
323
|
+
.insert(tSessions)
|
|
324
|
+
.values({
|
|
325
|
+
fSessionId: sessionId,
|
|
326
|
+
fHeadEventId: "",
|
|
327
|
+
fHeadSequence: -1,
|
|
328
|
+
fVersion: SESSION_FORMAT_VERSION,
|
|
329
|
+
fCreatedAt: stamp,
|
|
330
|
+
fCwd: null,
|
|
331
|
+
fParentSession: null,
|
|
332
|
+
fSeedLength: null,
|
|
333
|
+
fOrigin: null,
|
|
334
|
+
fDelegationDepth: null,
|
|
335
|
+
fIncarnation: randomUUID(),
|
|
336
|
+
fRevision: 0,
|
|
337
|
+
fArchivedAt: stamp,
|
|
338
|
+
})
|
|
339
|
+
.onConflictDoUpdate({
|
|
340
|
+
target: tSessions.fSessionId,
|
|
341
|
+
// 首次归档时间保留:重复写入同一集合不刷新标记。
|
|
342
|
+
set: { fArchivedAt: sql`COALESCE(${tSessions.fArchivedAt}, ${stamp})` },
|
|
343
|
+
}),
|
|
322
344
|
);
|
|
323
345
|
}
|
|
324
346
|
});
|
|
@@ -353,10 +375,7 @@ export function createStorageRepository(host: StorageRepositoryHost): StorageRep
|
|
|
353
375
|
return [...entries.values()].filter((entry) => Object.keys(entry.rows).length > 0);
|
|
354
376
|
},
|
|
355
377
|
|
|
356
|
-
async putProjcache(
|
|
357
|
-
sessionId: string,
|
|
358
|
-
rows: ProjectionCheckpoint,
|
|
359
|
-
): Promise<void> {
|
|
378
|
+
async putProjcache(sessionId: string, rows: ProjectionCheckpoint): Promise<void> {
|
|
360
379
|
await host.writeAtomically(async () => {
|
|
361
380
|
const db = await dbx();
|
|
362
381
|
await runQuery(db.delete(tProjcacheRows).where(eq(tProjcacheRows.fSessionId, sessionId)));
|
|
@@ -371,9 +390,35 @@ export function createStorageRepository(host: StorageRepositoryHost): StorageRep
|
|
|
371
390
|
});
|
|
372
391
|
},
|
|
373
392
|
|
|
374
|
-
async
|
|
393
|
+
async pruneStaleProjcache(): Promise<number> {
|
|
375
394
|
const db = await dbx();
|
|
376
|
-
await
|
|
395
|
+
const stale = await allRows<{ fSessionId: string }>(
|
|
396
|
+
db
|
|
397
|
+
.select({ fSessionId: tProjcacheRows.fSessionId })
|
|
398
|
+
.from(tProjcacheRows)
|
|
399
|
+
.where(
|
|
400
|
+
and(
|
|
401
|
+
lt(tProjcacheRows.fSeq, 0),
|
|
402
|
+
inArray(
|
|
403
|
+
tProjcacheRows.fSessionId,
|
|
404
|
+
db
|
|
405
|
+
.select({ fSessionId: tSessions.fSessionId })
|
|
406
|
+
.from(tSessions)
|
|
407
|
+
.where(gte(tSessions.fHeadSequence, 0)),
|
|
408
|
+
),
|
|
409
|
+
),
|
|
410
|
+
),
|
|
411
|
+
);
|
|
412
|
+
if (stale.length === 0) return 0;
|
|
413
|
+
await runQuery(
|
|
414
|
+
db.delete(tProjcacheRows).where(
|
|
415
|
+
inArray(
|
|
416
|
+
tProjcacheRows.fSessionId,
|
|
417
|
+
stale.map((row) => row.fSessionId),
|
|
418
|
+
),
|
|
419
|
+
),
|
|
420
|
+
);
|
|
421
|
+
return stale.length;
|
|
377
422
|
},
|
|
378
423
|
|
|
379
424
|
// 同步驱动(SQLite)才有:读路径直接查介质,进程内不维护 checkpoint 镜像。
|
|
@@ -412,7 +457,8 @@ export function createStorageRepository(host: StorageRepositoryHost): StorageRep
|
|
|
412
457
|
.from(tSessions)
|
|
413
458
|
.where(eq(tSessions.fSessionId, sessionId))
|
|
414
459
|
.get() as { fTitle: string | null; fTitleSeq: number | null } | undefined;
|
|
415
|
-
if (row === undefined || row.fTitle === null || row.fTitleSeq === null)
|
|
460
|
+
if (row === undefined || row.fTitle === null || row.fTitleSeq === null)
|
|
461
|
+
return undefined;
|
|
416
462
|
return { title: row.fTitle, seq: row.fTitleSeq };
|
|
417
463
|
},
|
|
418
464
|
}),
|
|
@@ -25,8 +25,8 @@ const WORKSPACE_TABLE = "workspaces";
|
|
|
25
25
|
export class RdbStorageBackend implements StorageBackend {
|
|
26
26
|
readonly kv: KvFacet = { open: (descriptor) => this.openUnit(descriptor) };
|
|
27
27
|
|
|
28
|
-
/** 已打开(或打开中)的 unit
|
|
29
|
-
private readonly open = new
|
|
28
|
+
/** 已打开(或打开中)的 unit;重复 open 是调用方 bug。 */
|
|
29
|
+
private readonly open = new Map<string, WorkspaceKvUnit>();
|
|
30
30
|
private closed = false;
|
|
31
31
|
|
|
32
32
|
/**
|
|
@@ -47,46 +47,73 @@ export class RdbStorageBackend implements StorageBackend {
|
|
|
47
47
|
`(table '${WORKSPACE_TABLE}' plus a global slot)`,
|
|
48
48
|
);
|
|
49
49
|
}
|
|
50
|
+
// 本后端是 single 布局的专用表实现:per-record 布局(及其 compatibleVersions
|
|
51
|
+
// 读语义)无法表达,宁可 fail loud 也不静默按 single 读。
|
|
52
|
+
if (descriptor.layout !== undefined && descriptor.layout !== "single") {
|
|
53
|
+
throw new Error(
|
|
54
|
+
`rdb storage backend serves only the 'single' layout (domain '${descriptor.name}' ` +
|
|
55
|
+
`declares '${descriptor.layout}')`,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
50
58
|
if (this.open.has(descriptor.name)) {
|
|
51
59
|
throw new Error(`kv unit '${descriptor.name}' is already open (double-open is a caller bug)`);
|
|
52
60
|
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
61
|
+
// 名字在同步段预留:并发第二个 open 必须在任何 await 之前被拒。
|
|
62
|
+
const unit = new WorkspaceKvUnit(this.repository, descriptor);
|
|
63
|
+
this.open.set(descriptor.name, unit);
|
|
64
|
+
try {
|
|
65
|
+
const stored = await this.repository.readUnitVersion(descriptor.name);
|
|
66
|
+
if (stored === undefined) {
|
|
67
|
+
await this.repository.insertUnitVersion(descriptor.name, descriptor.version);
|
|
68
|
+
} else if (stored !== descriptor.version) {
|
|
69
|
+
throw new StorageError(
|
|
70
|
+
"version-mismatch",
|
|
71
|
+
`kv unit '${descriptor.name}' is stamped version ${stored} on the medium, ` +
|
|
72
|
+
`incompatible with descriptor version ${descriptor.version}`,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
} catch (error) {
|
|
76
|
+
this.open.delete(descriptor.name);
|
|
77
|
+
throw error;
|
|
62
78
|
}
|
|
63
|
-
|
|
64
|
-
return new WorkspaceKvUnit(this.repository, descriptor, () => {
|
|
79
|
+
unit.onClose(() => {
|
|
65
80
|
this.open.delete(descriptor.name);
|
|
66
81
|
});
|
|
82
|
+
return unit;
|
|
67
83
|
}
|
|
68
84
|
|
|
69
85
|
/**
|
|
70
|
-
* Release the backend.
|
|
71
|
-
*
|
|
72
|
-
*
|
|
86
|
+
* Release the backend. New writes are rejected immediately; already-queued
|
|
87
|
+
* unit writes drain first (upstream backend contract). The medium (the
|
|
88
|
+
* session database) belongs to the owning session-rdb plugin, so closing
|
|
89
|
+
* open units here does not close it.
|
|
90
|
+
* @returns resolution after every open unit drained.
|
|
73
91
|
*/
|
|
74
92
|
async close(): Promise<void> {
|
|
75
93
|
this.closed = true;
|
|
94
|
+
const units = [...this.open.values()];
|
|
76
95
|
this.open.clear();
|
|
96
|
+
await Promise.all(units.map((unit) => unit.close()));
|
|
77
97
|
}
|
|
78
98
|
}
|
|
79
99
|
|
|
80
100
|
/** workspace 域的 KV unit:每个原语一条 SQL 语句,值形状与上游记录一致。 */
|
|
81
101
|
class WorkspaceKvUnit implements KvUnit {
|
|
82
102
|
private closed = false;
|
|
103
|
+
/** 在途写操作:close 先拒绝新写,再 drain 它们(不丢已受理的写)。 */
|
|
104
|
+
private readonly inflight = new Set<Promise<void>>();
|
|
105
|
+
private onClosed: (() => void) | undefined;
|
|
83
106
|
|
|
84
107
|
constructor(
|
|
85
108
|
private readonly repository: StorageRepository,
|
|
86
109
|
private readonly descriptor: KvUnitDescriptor,
|
|
87
|
-
private readonly onClose: () => void,
|
|
88
110
|
) {}
|
|
89
111
|
|
|
112
|
+
/** Install the name-release callback after the backend registered this unit. */
|
|
113
|
+
onClose(release: () => void): void {
|
|
114
|
+
this.onClosed = release;
|
|
115
|
+
}
|
|
116
|
+
|
|
90
117
|
async loadAll(): Promise<{ tables: Record<string, Record<string, unknown>>; global: unknown }> {
|
|
91
118
|
this.assertOpen();
|
|
92
119
|
const records: Record<string, unknown> = Object.create(null) as Record<string, unknown>;
|
|
@@ -100,24 +127,41 @@ class WorkspaceKvUnit implements KvUnit {
|
|
|
100
127
|
async putRecord(table: string, key: string, value: unknown): Promise<void> {
|
|
101
128
|
this.assertOpen();
|
|
102
129
|
this.assertTable(table);
|
|
103
|
-
await this.repository.putWorkspace(key, workspaceRecordOf(value));
|
|
130
|
+
await this.track(this.repository.putWorkspace(key, workspaceRecordOf(value)));
|
|
104
131
|
}
|
|
105
132
|
|
|
106
133
|
async deleteRecord(table: string, key: string): Promise<void> {
|
|
107
134
|
this.assertOpen();
|
|
108
135
|
this.assertTable(table);
|
|
109
|
-
await this.repository.deleteWorkspace(key);
|
|
136
|
+
await this.track(this.repository.deleteWorkspace(key));
|
|
110
137
|
}
|
|
111
138
|
|
|
112
139
|
async setGlobal(value: unknown): Promise<void> {
|
|
113
140
|
this.assertOpen();
|
|
114
|
-
await this.repository.writeWorkspaceState(workspaceStateOf(value));
|
|
141
|
+
await this.track(this.repository.writeWorkspaceState(workspaceStateOf(value)));
|
|
115
142
|
}
|
|
116
143
|
|
|
117
144
|
async close(): Promise<void> {
|
|
118
145
|
if (this.closed) return;
|
|
119
146
|
this.closed = true;
|
|
120
|
-
this.
|
|
147
|
+
while (this.inflight.size > 0) {
|
|
148
|
+
await Promise.allSettled(this.inflight);
|
|
149
|
+
}
|
|
150
|
+
this.onClosed?.();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Register one accepted write so close can drain it. */
|
|
154
|
+
private async track(operation: Promise<unknown>): Promise<void> {
|
|
155
|
+
const settled = operation.then(
|
|
156
|
+
() => undefined,
|
|
157
|
+
() => undefined,
|
|
158
|
+
);
|
|
159
|
+
this.inflight.add(settled);
|
|
160
|
+
try {
|
|
161
|
+
await operation;
|
|
162
|
+
} finally {
|
|
163
|
+
this.inflight.delete(settled);
|
|
164
|
+
}
|
|
121
165
|
}
|
|
122
166
|
|
|
123
167
|
private assertOpen(): void {
|
|
@@ -11,7 +11,10 @@
|
|
|
11
11
|
|
|
12
12
|
import type { SessionId } from "@deepseek-ai/dsh-session";
|
|
13
13
|
import type { CheckpointIdentity } from "@deepseek-ai/dsh-session-projection-cache";
|
|
14
|
-
import type {
|
|
14
|
+
import type {
|
|
15
|
+
ProjectionCheckpoint,
|
|
16
|
+
ProjectionCheckpointRow,
|
|
17
|
+
} from "@deepseek-ai/dsh-session-projection";
|
|
15
18
|
import type { WorkspaceDomainState, WorkspaceRecord } from "@deepseek-ai/dsh-workspace";
|
|
16
19
|
|
|
17
20
|
export type {
|
|
@@ -77,6 +80,11 @@ export interface StorageRepository {
|
|
|
77
80
|
* @param rows - 每个投影 key 一行。
|
|
78
81
|
*/
|
|
79
82
|
putProjcache(sessionId: string, rows: ProjectionCheckpoint): Promise<void>;
|
|
80
|
-
/**
|
|
81
|
-
|
|
83
|
+
/**
|
|
84
|
+
* 清理陈旧 checkpoint 行:水位为负却已有事件的会话(旧版写入路径的产物,
|
|
85
|
+
* 会把有对话的会话误判为空白)。缓存是派生数据,删除只让该会话回到
|
|
86
|
+
* 「未知即可见」并等待下一次强制点重写。
|
|
87
|
+
* @returns 删除的行数。
|
|
88
|
+
*/
|
|
89
|
+
pruneStaleProjcache(): Promise<number>;
|
|
82
90
|
}
|