@morlay/session-rdb 0.0.20 → 0.0.21-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 +59 -31
- package/dist/artifact.d.mts +25 -65
- package/dist/artifact.mjs +2 -2
- package/dist/{schema-BkQN5GyT.d.mts → backend-DpdtxYpz.d.mts} +346 -93
- package/dist/{branch-Co6xlbi0.mjs → branch-5JzX9rUq.mjs} +163 -52
- package/dist/deletion.d.mts +7 -0
- package/dist/deletion.mjs +2 -0
- package/dist/dist-vIVO6bA-.mjs +1524 -0
- package/dist/import.d.mts +4 -4
- package/dist/import.mjs +28 -11
- package/dist/index.d.mts +2 -3
- package/dist/index.mjs +4 -1631
- package/dist/{log-DO69NQnn.mjs → log-CnYct2Dv.mjs} +37 -82
- package/dist/{sqlite-DYExtbLo.mjs → sqlite-fpvm5Dzs.mjs} +205 -75
- package/dist/src-CWTWV7vx.mjs +1904 -0
- package/dist/storage.d.mts +14 -5
- package/dist/storage.mjs +2 -2
- package/dist/testing.d.mts +1 -26
- package/dist/testing.mjs +10503 -9614
- package/drizzle/postgres/20260918120000_v3_event_usage/migration.sql +14 -0
- package/drizzle/postgres/20260918120000_v3_event_usage/snapshot.json +1309 -0
- package/drizzle/sqlite/20260918120000_v3_event_usage/migration.sql +14 -0
- package/drizzle/sqlite/20260918120000_v3_event_usage/snapshot.json +1031 -0
- package/package.json +28 -29
- package/src/adapters/to-postgres.ts +1 -3
- package/src/adapters/to-sqlite.ts +0 -2
- package/src/adapters/types.ts +0 -3
- package/src/artifact.ts +0 -2
- package/src/backend.ts +31 -2
- package/src/branch.ts +223 -135
- package/src/deletion.ts +87 -0
- package/src/drizzle/postgres-v2.ts +0 -1
- package/src/drizzle/postgres-v3.ts +0 -1
- package/src/drizzle/sqlite-v2.ts +0 -1
- package/src/drizzle/sqlite-v3.ts +0 -1
- package/src/entities/v2/session-events.ts +0 -1
- package/src/entities/v3/event-usage.ts +25 -0
- package/src/entities/v3/events.ts +1 -3
- package/src/entities/v3/index.ts +4 -0
- package/src/entities/v3/session-events.ts +1 -2
- package/src/entities/v3/session-projcache-rows.ts +0 -8
- package/src/entities/v3/sessions.ts +3 -3
- package/src/entities/v3/storage-units.ts +0 -1
- package/src/entities/v3/workspace-sessions.ts +0 -5
- package/src/entities/v3/workspace-state.ts +0 -6
- package/src/entities/v3/workspaces.ts +0 -5
- package/src/export.ts +103 -0
- package/src/gc.ts +76 -0
- package/src/import-storages.ts +4 -37
- package/src/import.ts +54 -31
- package/src/index.ts +150 -114
- package/src/legacy.ts +4 -42
- package/src/log.ts +63 -106
- package/src/postgres.ts +249 -22
- package/src/schema.ts +6 -6
- package/src/session-query.ts +0 -4
- package/src/sqlite.ts +231 -55
- package/src/storage-takeover/index.ts +2 -28
- package/src/storage-takeover/projection-cache.ts +30 -134
- package/src/storage-takeover/repository.ts +20 -59
- package/src/storage-takeover/storage-backend.ts +3 -33
- package/src/storage-takeover/types.ts +14 -56
- package/src/storage.ts +0 -2
- package/src/testing/contract.ts +19 -50
- package/src/testing/coordinator-contract.ts +10 -25
- package/src/testing.ts +1 -4
- package/src/usage.ts +191 -0
- package/dist/index-D55G9n4k.d.mts +0 -271
- package/dist/magic-string.es-BgJoa-3K.mjs +0 -1017
|
@@ -1,15 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 投影 checkpoint 服务(`ctx.sessionProjectionCache`)的 rdb 实现:替换上游
|
|
3
|
-
* `@deepseek-ai/dsh-session-projection-cache` 插件,公开面与语义逐一对齐
|
|
4
|
-
* (cachedSnapshot / cachedPredecessorTitle / hydratePrepared / write /
|
|
5
|
-
* coldSnapshot,以及三个强制写点与计数/定时节流),持久层换成 session-rdb
|
|
6
|
-
* 的语义专用表 `t_session_projcache` / `t_session_projcache_row`。
|
|
7
|
-
*
|
|
8
|
-
* 读方法是同步签名(session 列表在请求路径上直接调用),所以服务持有启动时
|
|
9
|
-
* 从表中加载的内存 checkpoint 表;写先落库、后更新内存,保证读到的内存值
|
|
10
|
-
* 磁盘上一定存在。
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
1
|
import { Context, Service } from "@deepseek-ai/cordis";
|
|
14
2
|
import { SessionLogOffset } from "@deepseek-ai/dsh-session";
|
|
15
3
|
import type {
|
|
@@ -31,79 +19,55 @@ import type {
|
|
|
31
19
|
ProjectionCheckpointRow,
|
|
32
20
|
} from "./types.ts";
|
|
33
21
|
|
|
34
|
-
/** 服务注册名(与上游插件一致,消费者经 `ctx.get` 解析)。 */
|
|
35
22
|
export const SESSION_PROJECTION_CACHE_SERVICE = "sessionProjectionCache";
|
|
36
23
|
|
|
37
|
-
/** 完整身份:当前世代写入的字段都必需。 */
|
|
38
24
|
type CurrentCheckpointIdentity = CheckpointIdentity & {
|
|
39
25
|
formatVersion: number;
|
|
40
26
|
isSeeded: boolean;
|
|
41
27
|
inheritedEventCount: number;
|
|
42
28
|
};
|
|
43
29
|
|
|
44
|
-
/** 缓存写节流参数(上游 Config 的部署值)。 */
|
|
45
30
|
export interface ProjectionCacheConfig {
|
|
46
|
-
/** 两次强制点之间,累积多少个已提交事件强制落一次盘。 */
|
|
47
31
|
writeEveryEvents: number;
|
|
48
|
-
|
|
32
|
+
|
|
49
33
|
writeIntervalMs: number;
|
|
50
34
|
}
|
|
51
35
|
|
|
52
|
-
/** class-plugin 装载参数:写节流 + storages 接管访问层 + 介质就绪信号。 */
|
|
53
36
|
export interface SessionProjectionCacheRdbOptions extends ProjectionCacheConfig {
|
|
54
37
|
repository: StorageRepository;
|
|
55
38
|
ready: Promise<unknown>;
|
|
56
39
|
}
|
|
57
40
|
|
|
58
|
-
/** 每会话写回节流簿记(只对 live session)。 */
|
|
59
41
|
interface DirtyState {
|
|
60
42
|
pending: number;
|
|
61
43
|
timer: ReturnType<typeof setTimeout> | undefined;
|
|
62
44
|
}
|
|
63
45
|
|
|
64
|
-
/** 只认 title 的前代提示(与上游一致)。 */
|
|
65
46
|
const PREDECESSOR_TITLE_KEY = "title" as Extract<keyof SessionProjectionMap, string>;
|
|
66
47
|
|
|
67
|
-
/** rdb 持久化的投影 checkpoint 服务:同步驱动直读介质,异步驱动用写穿镜像支撑同步签名。 */
|
|
68
48
|
export class SessionProjectionCacheRdb extends Service {
|
|
69
49
|
static inject = ["sessionProjections", "sessions"];
|
|
70
50
|
|
|
71
|
-
/** 异步驱动(PostgreSQL)的同步读镜像:同步驱动(SQLite)直读介质,这里恒为空。 */
|
|
72
51
|
private readonly records = new Map<SessionId, StoredProjcacheEntry>();
|
|
73
52
|
private readonly dirty = new Map<Session, DirtyState>();
|
|
74
|
-
|
|
53
|
+
|
|
75
54
|
private readonly directReads: boolean;
|
|
76
55
|
private readonly config: ProjectionCacheConfig;
|
|
77
56
|
private readonly repository: StorageRepository;
|
|
78
57
|
private readonly ready: Promise<unknown>;
|
|
79
58
|
|
|
80
|
-
/**
|
|
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 接管访问层 + 介质就绪信号。
|
|
86
|
-
*/
|
|
87
59
|
constructor(ctx: Context, options: SessionProjectionCacheRdbOptions) {
|
|
88
60
|
super(ctx, SESSION_PROJECTION_CACHE_SERVICE);
|
|
89
61
|
this.config = options;
|
|
90
62
|
this.repository = options.repository;
|
|
91
63
|
this.ready = options.ready;
|
|
92
|
-
this.directReads = options.repository.
|
|
64
|
+
this.directReads = options.repository.readProjcacheDirect !== undefined;
|
|
93
65
|
}
|
|
94
66
|
|
|
95
|
-
/**
|
|
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.
|
|
100
|
-
*/
|
|
101
67
|
protected async [Service.init](): Promise<void> {
|
|
102
68
|
this.installWritePath();
|
|
103
69
|
await this.ready;
|
|
104
|
-
|
|
105
|
-
// 有对话的会话在 cold 列表里误判为空白。缓存是派生数据,删除让会话回到
|
|
106
|
-
// 「未知即可见」,等待下一次强制点重写。
|
|
70
|
+
|
|
107
71
|
const pruned = await this.repository.pruneStaleProjcache();
|
|
108
72
|
if (pruned > 0) {
|
|
109
73
|
this.ctx.logger.info(
|
|
@@ -117,21 +81,11 @@ export class SessionProjectionCacheRdb extends Service {
|
|
|
117
81
|
}
|
|
118
82
|
}
|
|
119
83
|
|
|
120
|
-
/** Read one session's stored record: straight from the medium, or from the async mirror. */
|
|
121
84
|
private lookup(id: SessionId): StoredProjcacheEntry | undefined {
|
|
122
|
-
if (this.directReads) return this.repository.
|
|
85
|
+
if (this.directReads) return this.repository.readProjcacheDirect?.(id);
|
|
123
86
|
return this.records.get(id);
|
|
124
87
|
}
|
|
125
88
|
|
|
126
|
-
/**
|
|
127
|
-
* The stored record for one session, accepted only when its bound log
|
|
128
|
-
* identity matches `expected`. A session id names a slot, not a lifecycle:
|
|
129
|
-
* a recreated id or a persistence store swapped under a surviving cache
|
|
130
|
-
* must not let an old record seed state folded from an unrelated log.
|
|
131
|
-
* @param id - the session whose record is read.
|
|
132
|
-
* @param expected - the log identity the caller holds (live or stored header).
|
|
133
|
-
* @returns the identity-matching record, or `undefined` (absent or unrelated).
|
|
134
|
-
*/
|
|
135
89
|
private recordFor(
|
|
136
90
|
id: SessionId,
|
|
137
91
|
expected: CurrentCheckpointIdentity,
|
|
@@ -141,14 +95,6 @@ export class SessionProjectionCacheRdb extends Service {
|
|
|
141
95
|
return identityMatches(record.identity, expected) ? record : undefined;
|
|
142
96
|
}
|
|
143
97
|
|
|
144
|
-
/**
|
|
145
|
-
* The cached projection cut for one stored (cold) or live header.
|
|
146
|
-
* @param meta - authoritative Session header.
|
|
147
|
-
* @param inheritedEventCount - exact inherited cut completing the lifecycle identity.
|
|
148
|
-
* @param keys - optional projection keys required by the caller's audience.
|
|
149
|
-
* @returns the cut (`asOfSeq` = lowest served-row watermark), or `undefined`
|
|
150
|
-
* when no usable row exists for this lifecycle.
|
|
151
|
-
*/
|
|
152
98
|
cachedSnapshot(
|
|
153
99
|
meta: SessionHeader,
|
|
154
100
|
inheritedEventCount: SessionLogOffset,
|
|
@@ -159,10 +105,6 @@ export class SessionProjectionCacheRdb extends Service {
|
|
|
159
105
|
return this.withDirectTitle(meta.id, keys, snapshot);
|
|
160
106
|
}
|
|
161
107
|
|
|
162
|
-
/**
|
|
163
|
-
* 标题是会话数据本身(`t_sessions.f_title`,由 rdb 写路径与 rewind 维护):
|
|
164
|
-
* checkpoint 行里没有 title 时直接取该列,列表消费不依赖缓存行是否存在。
|
|
165
|
-
*/
|
|
166
108
|
private withDirectTitle(
|
|
167
109
|
id: SessionId,
|
|
168
110
|
keys: readonly Extract<keyof SessionProjectionMap, string>[] | undefined,
|
|
@@ -172,23 +114,15 @@ export class SessionProjectionCacheRdb extends Service {
|
|
|
172
114
|
return snapshot;
|
|
173
115
|
}
|
|
174
116
|
if (snapshot?.values[PREDECESSOR_TITLE_KEY] !== undefined) return snapshot;
|
|
175
|
-
const direct = this.repository.
|
|
117
|
+
const direct = this.repository.readSessionTitleDirect?.(id);
|
|
176
118
|
if (direct === undefined) return snapshot;
|
|
177
119
|
const values = { ...snapshot?.values, [PREDECESSOR_TITLE_KEY]: direct.title };
|
|
178
|
-
|
|
120
|
+
|
|
179
121
|
const asOfSeq =
|
|
180
122
|
snapshot === undefined ? direct.seq : Math.min(snapshot.asOfSeq as number, direct.seq);
|
|
181
123
|
return { asOfSeq: asOfSeq as SessionSeqCursor, values };
|
|
182
124
|
}
|
|
183
125
|
|
|
184
|
-
/**
|
|
185
|
-
* Read only a predecessor checkpoint's title as a zero-I/O listing hint.
|
|
186
|
-
* @param meta - authoritative listed Session header.
|
|
187
|
-
* @param inheritedEventCount - exact inherited cut completing the lifecycle identity.
|
|
188
|
-
* @returns a title-only checkpoint view with `asOfSeq: -1`, or `undefined`
|
|
189
|
-
* when the record is current, newer, unrelated, missing, or incompatible
|
|
190
|
-
* with the title unit.
|
|
191
|
-
*/
|
|
192
126
|
cachedPredecessorTitle(
|
|
193
127
|
meta: SessionHeader,
|
|
194
128
|
inheritedEventCount: SessionLogOffset,
|
|
@@ -202,7 +136,6 @@ export class SessionProjectionCacheRdb extends Service {
|
|
|
202
136
|
return title === undefined ? undefined : { ...title, asOfSeq: -1 };
|
|
203
137
|
}
|
|
204
138
|
|
|
205
|
-
/** View selected wire rows and bind them to their lowest served watermark. */
|
|
206
139
|
private viewRecord(
|
|
207
140
|
record: StoredProjcacheEntry,
|
|
208
141
|
keys?: readonly Extract<keyof SessionProjectionMap, string>[],
|
|
@@ -213,9 +146,7 @@ export class SessionProjectionCacheRdb extends Service {
|
|
|
213
146
|
);
|
|
214
147
|
const servedKeys = Object.keys(values);
|
|
215
148
|
if (servedKeys.length === 0) return undefined;
|
|
216
|
-
|
|
217
|
-
// value is at least current as of (under-claiming is safe under
|
|
218
|
-
// higher-seq-wins; over-claiming would let a stale value outrank pushes).
|
|
149
|
+
|
|
219
150
|
const firstKey = servedKeys[0] as string;
|
|
220
151
|
let asOfSeq = (record.rows[firstKey] as ProjectionCheckpointRow).seq as SessionSeqCursor;
|
|
221
152
|
for (const key of servedKeys.slice(1)) {
|
|
@@ -225,15 +156,6 @@ export class SessionProjectionCacheRdb extends Service {
|
|
|
225
156
|
return { asOfSeq, values };
|
|
226
157
|
}
|
|
227
158
|
|
|
228
|
-
/**
|
|
229
|
-
* Hydrate projection cells for an already-prepared Session without another
|
|
230
|
-
* persistence read. The cache seeds matching rows; the supplied exact log
|
|
231
|
-
* advances every unit to the observation cut. No checkpoint is written
|
|
232
|
-
* because the logical observation may contain recovery events not yet durable.
|
|
233
|
-
* @param session - exact unpublished Session retained by persistence.
|
|
234
|
-
* @param events - exact logical event prefix represented by the observation.
|
|
235
|
-
* @returns all projection values at the event cut.
|
|
236
|
-
*/
|
|
237
159
|
hydratePrepared(session: Session, events: readonly SessionEvent[]): ProjectionSnapshot {
|
|
238
160
|
const record = this.recordFor(
|
|
239
161
|
session.id,
|
|
@@ -250,26 +172,14 @@ export class SessionProjectionCacheRdb extends Service {
|
|
|
250
172
|
SessionLogOffset(0),
|
|
251
173
|
);
|
|
252
174
|
} catch {
|
|
253
|
-
// Cached rows are disposable derived data. Retry from the exact log so a
|
|
254
|
-
// stale schema cannot make a valid Session unreadable.
|
|
255
175
|
return this.ctx.sessionProjections.hydrate(session, {}, events, SessionLogOffset(0));
|
|
256
176
|
}
|
|
257
177
|
}
|
|
258
178
|
|
|
259
|
-
/**
|
|
260
|
-
* Durably checkpoint one live session NOW (all mandatory points call this).
|
|
261
|
-
* NOT fail-soft — callers on the fail-soft paths contain it.
|
|
262
|
-
* @param session - the live session to checkpoint.
|
|
263
|
-
* @returns resolution after durability.
|
|
264
|
-
*/
|
|
265
179
|
async write(session: Session): Promise<void> {
|
|
266
180
|
const rows = this.ctx.sessionProjections.checkpoint(session);
|
|
267
181
|
this.markClean(session);
|
|
268
|
-
|
|
269
|
-
// AFTER it guarantees every event inside the cut is durably logged
|
|
270
|
-
// before the cache row lands — a crash can leave the cache behind the
|
|
271
|
-
// log (longer tail replay) but never ahead of it (phantom values folded
|
|
272
|
-
// from events no stored log contains).
|
|
182
|
+
|
|
273
183
|
if (this.ctx.sessions.get(session.id) === session) await this.ctx.sessions.flush(session);
|
|
274
184
|
await this.put(
|
|
275
185
|
session.id,
|
|
@@ -278,15 +188,24 @@ export class SessionProjectionCacheRdb extends Service {
|
|
|
278
188
|
);
|
|
279
189
|
}
|
|
280
190
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
191
|
+
// rewind 后 head 回退:把水位超出新 head 的投影单元丢弃,保留仍在截断前缀内的行。
|
|
192
|
+
// 这是零 I/O 读的更新(不 fold 日志):下次零 I/O 读拿到的是截断后的水位,而不是旧值。
|
|
193
|
+
async truncateTo(
|
|
194
|
+
meta: SessionHeader,
|
|
195
|
+
inheritedEventCount: SessionLogOffset,
|
|
196
|
+
headSeq: number,
|
|
197
|
+
): Promise<void> {
|
|
198
|
+
const identity = identityOf(meta, inheritedEventCount);
|
|
199
|
+
const record = this.recordFor(meta.id, identity);
|
|
200
|
+
if (record === undefined) return;
|
|
201
|
+
const rows = Object.fromEntries(
|
|
202
|
+
Object.entries(record.rows).filter(
|
|
203
|
+
([, row]) => ((row as ProjectionCheckpointRow).seq as number) <= headSeq,
|
|
204
|
+
),
|
|
205
|
+
);
|
|
206
|
+
await this.put(meta.id, identity, rows as unknown as Record<string, ProjectionCheckpointRow>);
|
|
207
|
+
}
|
|
208
|
+
|
|
290
209
|
coldSnapshot(
|
|
291
210
|
meta: SessionHeader,
|
|
292
211
|
inheritedEventCount: SessionLogOffset,
|
|
@@ -300,8 +219,7 @@ export class SessionProjectionCacheRdb extends Service {
|
|
|
300
219
|
meta,
|
|
301
220
|
inheritedEventCount,
|
|
302
221
|
);
|
|
303
|
-
|
|
304
|
-
// fire-and-forget — a failed write-back only costs a longer tail replay.
|
|
222
|
+
|
|
305
223
|
void this.put(
|
|
306
224
|
meta.id,
|
|
307
225
|
identity,
|
|
@@ -314,12 +232,7 @@ export class SessionProjectionCacheRdb extends Service {
|
|
|
314
232
|
return restored.snapshot;
|
|
315
233
|
}
|
|
316
234
|
|
|
317
|
-
// --- write-behind (throttle + mandatory points) ---
|
|
318
|
-
|
|
319
235
|
private installWritePath(): void {
|
|
320
|
-
// Every committed event advances the dirty counter; turn/end is a
|
|
321
|
-
// mandatory point (the durable value most reads want is the turn-final
|
|
322
|
-
// one), count/interval throttle the in-turn stream.
|
|
323
236
|
this.ctx.on("session/event", (session: Session, event: SessionEvent) => {
|
|
324
237
|
if (event.type === "turn/end") {
|
|
325
238
|
void this.flushSoft(session, "turn/end");
|
|
@@ -337,14 +250,10 @@ export class SessionProjectionCacheRdb extends Service {
|
|
|
337
250
|
}, this.config.writeIntervalMs);
|
|
338
251
|
});
|
|
339
252
|
|
|
340
|
-
// Creation is the FIRST mandatory point: a session that never talks (a
|
|
341
|
-
// forked child seeded with its ancestor's title, say) would otherwise
|
|
342
|
-
// get its first row only at detach.
|
|
343
253
|
this.ctx.on("session/created", (session: Session) => {
|
|
344
254
|
void this.flushSoft(session, "create");
|
|
345
255
|
});
|
|
346
256
|
|
|
347
|
-
// Detach (the live-to-cold moment): the final mandatory point.
|
|
348
257
|
this.ctx.on("session/disposed", (session: Session) => {
|
|
349
258
|
void this.flushSoft(session, "detach");
|
|
350
259
|
this.markClean(session);
|
|
@@ -362,7 +271,6 @@ export class SessionProjectionCacheRdb extends Service {
|
|
|
362
271
|
);
|
|
363
272
|
}
|
|
364
273
|
|
|
365
|
-
/** One fail-soft durable checkpoint. */
|
|
366
274
|
private async flushSoft(session: Session, trigger: string): Promise<void> {
|
|
367
275
|
try {
|
|
368
276
|
await this.write(session);
|
|
@@ -373,7 +281,6 @@ export class SessionProjectionCacheRdb extends Service {
|
|
|
373
281
|
}
|
|
374
282
|
}
|
|
375
283
|
|
|
376
|
-
/** Reset one session's dirty bookkeeping (its checkpoint is being written). */
|
|
377
284
|
private markClean(session: Session): void {
|
|
378
285
|
const state = this.dirty.get(session);
|
|
379
286
|
if (state === undefined) return;
|
|
@@ -384,21 +291,19 @@ export class SessionProjectionCacheRdb extends Service {
|
|
|
384
291
|
}
|
|
385
292
|
}
|
|
386
293
|
|
|
387
|
-
/** Replace one session's stored record with its log identity and a detached snapshot of `rows`. */
|
|
388
294
|
private async put(
|
|
389
295
|
id: SessionId,
|
|
390
296
|
identity: CheckpointIdentity,
|
|
391
297
|
rows: Record<string, ProjectionCheckpointRow>,
|
|
392
298
|
): Promise<void> {
|
|
393
299
|
const detached = detachJson(rows);
|
|
394
|
-
|
|
300
|
+
|
|
395
301
|
await this.repository.putProjcache(id, detached);
|
|
396
|
-
|
|
302
|
+
|
|
397
303
|
if (!this.directReads) this.records.set(id, { sessionId: id, identity, rows: detached });
|
|
398
304
|
}
|
|
399
305
|
}
|
|
400
306
|
|
|
401
|
-
/** Detach one checkpoint from live unit state, refusing non-lossless JSON. */
|
|
402
307
|
function detachJson(
|
|
403
308
|
rows: Record<string, ProjectionCheckpointRow>,
|
|
404
309
|
): Record<string, ProjectionCheckpointRow> {
|
|
@@ -417,7 +322,6 @@ function detachJson(
|
|
|
417
322
|
return JSON.parse(text) as Record<string, ProjectionCheckpointRow>;
|
|
418
323
|
}
|
|
419
324
|
|
|
420
|
-
/** Project a header onto the identity fields a record is bound to. */
|
|
421
325
|
function identityOf(
|
|
422
326
|
header: SessionHeader,
|
|
423
327
|
inheritedEventCount: SessionLogOffset,
|
|
@@ -435,19 +339,12 @@ function identityOf(
|
|
|
435
339
|
};
|
|
436
340
|
}
|
|
437
341
|
|
|
438
|
-
/**
|
|
439
|
-
* Whether a stored record's bound identity names the caller's lifecycle.
|
|
440
|
-
* An absent format generation cannot prove the fold semantics and never
|
|
441
|
-
* matches. Once the format matches, absent lineage fields (records admitted
|
|
442
|
-
* via compatible versions predate them) read as the unseeded lineage.
|
|
443
|
-
*/
|
|
444
342
|
function identityMatches(stored: CheckpointIdentity, expected: CurrentCheckpointIdentity): boolean {
|
|
445
343
|
return (
|
|
446
344
|
stored.formatVersion === expected.formatVersion && lifecycleIdentityMatches(stored, expected)
|
|
447
345
|
);
|
|
448
346
|
}
|
|
449
347
|
|
|
450
|
-
/** Match one predecessor cache record to the authoritative listed lifecycle. */
|
|
451
348
|
function predecessorIdentityMatches(
|
|
452
349
|
stored: CheckpointIdentity,
|
|
453
350
|
expected: CurrentCheckpointIdentity,
|
|
@@ -457,7 +354,6 @@ function predecessorIdentityMatches(
|
|
|
457
354
|
return predecessor && lifecycleIdentityMatches(stored, expected);
|
|
458
355
|
}
|
|
459
356
|
|
|
460
|
-
/** Match the format-independent fields that distinguish one Session lifecycle. */
|
|
461
357
|
function lifecycleIdentityMatches(
|
|
462
358
|
stored: CheckpointIdentity,
|
|
463
359
|
expected: CurrentCheckpointIdentity,
|
|
@@ -1,12 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* storages 接管表的方言无关实现:SQLite 与 PostgreSQL 的 drizzle 查询构建器
|
|
3
|
-
* 共享同一套调用形状(SQLite 走同步 `.all()`/`.get()`/`.run()`,PostgreSQL
|
|
4
|
-
* 走 thenable),差别由本模块的执行 helper 吸收。
|
|
5
|
-
*
|
|
6
|
-
* workspace 域是权威数据:记录、归属、显示顺序与归档集都拆成语义表,每次写入
|
|
7
|
-
* 在介质事务内完成(`host.writeAtomically`),不留部分应用的中间态。
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
1
|
import { randomUUID } from "node:crypto";
|
|
11
2
|
import { and, eq, gte, inArray, isNotNull, lt, notInArray, sql } from "drizzle-orm";
|
|
12
3
|
import {
|
|
@@ -27,36 +18,23 @@ import type {
|
|
|
27
18
|
WorkspaceRecord,
|
|
28
19
|
} from "./types.ts";
|
|
29
20
|
|
|
30
|
-
/** 注入口:介质句柄 + 方言表对象 + 事务。 */
|
|
31
21
|
export interface StorageRepositoryHost {
|
|
32
|
-
/** 解析 drizzle 句柄;介质未就绪时调用方等待其 open 完成。 */
|
|
33
22
|
db: () => Promise<unknown>;
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
*/
|
|
39
|
-
dbSync?: () => unknown;
|
|
40
|
-
/** `toSqliteSchema` / `toPostgresSchema` 产出的方言表对象。 */
|
|
23
|
+
|
|
24
|
+
/** 不经 await、直接拿到的数据库句柄;未开放时只走异步的 `db`。 */
|
|
25
|
+
dbDirect?: () => unknown;
|
|
26
|
+
|
|
41
27
|
tables: Record<string, unknown>;
|
|
42
|
-
|
|
43
|
-
* Run `fn` inside one medium transaction: every statement issued through
|
|
44
|
-
* {@link StorageRepositoryHost.db} while it runs joins that transaction, and
|
|
45
|
-
* a failure rolls the whole write back.
|
|
46
|
-
* @param fn - statements to run atomically.
|
|
47
|
-
* @returns the callback's result.
|
|
48
|
-
*/
|
|
28
|
+
|
|
49
29
|
writeAtomically: <T>(fn: () => Promise<T>) => Promise<T>;
|
|
50
30
|
}
|
|
51
31
|
|
|
52
|
-
/** 多行查询:SQLite 同步 `.all()`,PostgreSQL await。 */
|
|
53
32
|
async function allRows<T>(query: unknown): Promise<T[]> {
|
|
54
33
|
const q = query as { all?: () => T[] };
|
|
55
34
|
if (typeof q.all === "function") return q.all();
|
|
56
35
|
return (await query) as T[];
|
|
57
36
|
}
|
|
58
37
|
|
|
59
|
-
/** 单行查询:SQLite 同步 `.get()`,PostgreSQL await 后取首行。 */
|
|
60
38
|
async function oneRow<T>(query: unknown): Promise<T | undefined> {
|
|
61
39
|
const q = query as { get?: () => T | undefined };
|
|
62
40
|
if (typeof q.get === "function") return q.get();
|
|
@@ -64,7 +42,6 @@ async function oneRow<T>(query: unknown): Promise<T | undefined> {
|
|
|
64
42
|
return rows[0];
|
|
65
43
|
}
|
|
66
44
|
|
|
67
|
-
/** 写入语句:SQLite 同步 `.run()`,PostgreSQL await。 */
|
|
68
45
|
async function runQuery(query: unknown): Promise<void> {
|
|
69
46
|
const q = query as { run?: () => unknown };
|
|
70
47
|
if (typeof q.run === "function") {
|
|
@@ -74,7 +51,6 @@ async function runQuery(query: unknown): Promise<void> {
|
|
|
74
51
|
await query;
|
|
75
52
|
}
|
|
76
53
|
|
|
77
|
-
/** `t_sessions` 上承载 checkpoint identity 的列(与投影行 1:1,不另存一份)。 */
|
|
78
54
|
interface SessionIdentityRow {
|
|
79
55
|
fVersion: number;
|
|
80
56
|
fCreatedAt: number;
|
|
@@ -82,7 +58,6 @@ interface SessionIdentityRow {
|
|
|
82
58
|
fSeedLength: number | null;
|
|
83
59
|
}
|
|
84
60
|
|
|
85
|
-
/** `t_session_projcache_row` 的行形状(存储层视角)。 */
|
|
86
61
|
interface ProjcacheRowRecord {
|
|
87
62
|
fSessionId: string;
|
|
88
63
|
fKey: string;
|
|
@@ -91,7 +66,6 @@ interface ProjcacheRowRecord {
|
|
|
91
66
|
fVal: string;
|
|
92
67
|
}
|
|
93
68
|
|
|
94
|
-
/** 一行会话元数据 → 上游 `CheckpointIdentity`。 */
|
|
95
69
|
function identityOfSession(row: SessionIdentityRow): CheckpointIdentity {
|
|
96
70
|
return {
|
|
97
71
|
formatVersion: row.fVersion,
|
|
@@ -102,12 +76,10 @@ function identityOfSession(row: SessionIdentityRow): CheckpointIdentity {
|
|
|
102
76
|
};
|
|
103
77
|
}
|
|
104
78
|
|
|
105
|
-
/** 存储的整数水位 → 上游 seq cursor(-1 是空日志哨兵)。 */
|
|
106
79
|
function seqCursor(seq: number): SessionSeqCursor {
|
|
107
80
|
return seq === -1 ? -1 : SessionSeq(seq);
|
|
108
81
|
}
|
|
109
82
|
|
|
110
|
-
/** 两写标记的两列 → 上游 `pendingMutation`(未知操作按缺失处理)。 */
|
|
111
83
|
function pendingMutationOf(row: {
|
|
112
84
|
fPendingOperation: string | null;
|
|
113
85
|
fPendingWorkspaceId: string | null;
|
|
@@ -117,13 +89,7 @@ function pendingMutationOf(row: {
|
|
|
117
89
|
return { operation, workspaceId: (row.fPendingWorkspaceId ?? "") as WorkspaceId };
|
|
118
90
|
}
|
|
119
91
|
|
|
120
|
-
/**
|
|
121
|
-
* 构建一个介质的 storages 接管访问层。
|
|
122
|
-
* @param host - 介质句柄、方言表对象与事务执行器。
|
|
123
|
-
* @returns 方言无关的访问层。
|
|
124
|
-
*/
|
|
125
92
|
export function createStorageRepository(host: StorageRepositoryHost): StorageRepository {
|
|
126
|
-
/* eslint-disable @typescript-eslint/no-explicit-any -- drizzle 双方言表对象只在运行期共享查询形状 */
|
|
127
93
|
const tables = host.tables as Record<string, any>;
|
|
128
94
|
const tUnits = tables["t_storage_units"]!;
|
|
129
95
|
const tSessions = tables["t_sessions"]!;
|
|
@@ -131,7 +97,7 @@ export function createStorageRepository(host: StorageRepositoryHost): StorageRep
|
|
|
131
97
|
const tWorkspaceSessions = tables["t_workspace_sessions"]!;
|
|
132
98
|
const tWorkspaceState = tables["t_workspace_state"]!;
|
|
133
99
|
const tProjcacheRows = tables["t_session_projcache_row"]!;
|
|
134
|
-
|
|
100
|
+
|
|
135
101
|
const sessionIdentity = {
|
|
136
102
|
fSessionId: tSessions.fSessionId,
|
|
137
103
|
fVersion: tSessions.fVersion,
|
|
@@ -139,9 +105,8 @@ export function createStorageRepository(host: StorageRepositoryHost): StorageRep
|
|
|
139
105
|
fCwd: tSessions.fCwd,
|
|
140
106
|
fSeedLength: tSessions.fSeedLength,
|
|
141
107
|
};
|
|
142
|
-
/* eslint-enable @typescript-eslint/no-explicit-any */
|
|
143
108
|
|
|
144
|
-
const dbx = async (): Promise<any> => (await host.db()) as any;
|
|
109
|
+
const dbx = async (): Promise<any> => (await host.db()) as any;
|
|
145
110
|
|
|
146
111
|
return {
|
|
147
112
|
async readUnitVersion(name: string): Promise<number | undefined> {
|
|
@@ -209,7 +174,7 @@ export function createStorageRepository(host: StorageRepositoryHost): StorageRep
|
|
|
209
174
|
.values(values)
|
|
210
175
|
.onConflictDoUpdate({ target: tWorkspaces.fWorkspaceId, set: values }),
|
|
211
176
|
);
|
|
212
|
-
|
|
177
|
+
|
|
213
178
|
await runQuery(
|
|
214
179
|
db.delete(tWorkspaceSessions).where(eq(tWorkspaceSessions.fWorkspaceId, id)),
|
|
215
180
|
);
|
|
@@ -287,7 +252,7 @@ export function createStorageRepository(host: StorageRepositoryHost): StorageRep
|
|
|
287
252
|
.values(values)
|
|
288
253
|
.onConflictDoUpdate({ target: tWorkspaceState.fSingleton, set: values }),
|
|
289
254
|
);
|
|
290
|
-
|
|
255
|
+
|
|
291
256
|
await runQuery(db.update(tWorkspaces).set({ fPosition: -1 }));
|
|
292
257
|
for (const [position, workspaceId] of state.workspaceIds.entries()) {
|
|
293
258
|
await runQuery(
|
|
@@ -297,7 +262,7 @@ export function createStorageRepository(host: StorageRepositoryHost): StorageRep
|
|
|
297
262
|
.where(eq(tWorkspaces.fWorkspaceId, workspaceId)),
|
|
298
263
|
);
|
|
299
264
|
}
|
|
300
|
-
|
|
265
|
+
|
|
301
266
|
const archived = state.archivedSessionIds;
|
|
302
267
|
await runQuery(
|
|
303
268
|
archived.length === 0
|
|
@@ -314,10 +279,6 @@ export function createStorageRepository(host: StorageRepositoryHost): StorageRep
|
|
|
314
279
|
);
|
|
315
280
|
const stamp = Date.now();
|
|
316
281
|
for (const sessionId of archived) {
|
|
317
|
-
// 归档标记落在会话行上(f_archived_at);live 但从未物化的会话还没有
|
|
318
|
-
// 行——补一行骨架(head=-1、无 cwd/seed),否则标记无处可写,重启后
|
|
319
|
-
// 归档集由介质重算时会静默丢失。真正物化走 upsertSession,其冲突列
|
|
320
|
-
// 不含 f_archived_at,所以标记在物化后仍保留。
|
|
321
282
|
await runQuery(
|
|
322
283
|
db
|
|
323
284
|
.insert(tSessions)
|
|
@@ -338,7 +299,7 @@ export function createStorageRepository(host: StorageRepositoryHost): StorageRep
|
|
|
338
299
|
})
|
|
339
300
|
.onConflictDoUpdate({
|
|
340
301
|
target: tSessions.fSessionId,
|
|
341
|
-
|
|
302
|
+
|
|
342
303
|
set: { fArchivedAt: sql`COALESCE(${tSessions.fArchivedAt}, ${stamp})` },
|
|
343
304
|
}),
|
|
344
305
|
);
|
|
@@ -362,7 +323,6 @@ export function createStorageRepository(host: StorageRepositoryHost): StorageRep
|
|
|
362
323
|
});
|
|
363
324
|
}
|
|
364
325
|
for (const row of rows) {
|
|
365
|
-
// 没有会话行的行读作缺失:checkpoint 的 identity 由会话行承载。
|
|
366
326
|
const entry = entries.get(row.fSessionId);
|
|
367
327
|
if (entry === undefined) continue;
|
|
368
328
|
entry.rows[row.fKey] = {
|
|
@@ -371,7 +331,7 @@ export function createStorageRepository(host: StorageRepositoryHost): StorageRep
|
|
|
371
331
|
val: JSON.parse(row.fVal),
|
|
372
332
|
} satisfies ProjectionCheckpointRow;
|
|
373
333
|
}
|
|
374
|
-
|
|
334
|
+
|
|
375
335
|
return [...entries.values()].filter((entry) => Object.keys(entry.rows).length > 0);
|
|
376
336
|
},
|
|
377
337
|
|
|
@@ -421,12 +381,11 @@ export function createStorageRepository(host: StorageRepositoryHost): StorageRep
|
|
|
421
381
|
return stale.length;
|
|
422
382
|
},
|
|
423
383
|
|
|
424
|
-
|
|
425
|
-
...(host.dbSync === undefined
|
|
384
|
+
...(host.dbDirect === undefined
|
|
426
385
|
? {}
|
|
427
386
|
: {
|
|
428
|
-
|
|
429
|
-
const db = host.
|
|
387
|
+
readProjcacheDirect: (sessionId: string): StoredProjcacheEntry | undefined => {
|
|
388
|
+
const db = host.dbDirect!() as any;
|
|
430
389
|
const stored = db
|
|
431
390
|
.select()
|
|
432
391
|
.from(tProjcacheRows)
|
|
@@ -438,7 +397,7 @@ export function createStorageRepository(host: StorageRepositoryHost): StorageRep
|
|
|
438
397
|
.from(tSessions)
|
|
439
398
|
.where(eq(tSessions.fSessionId, sessionId))
|
|
440
399
|
.get() as (SessionIdentityRow & { fSessionId: string }) | undefined;
|
|
441
|
-
|
|
400
|
+
|
|
442
401
|
if (session === undefined) return undefined;
|
|
443
402
|
const rows: ProjectionCheckpoint = {};
|
|
444
403
|
for (const row of stored) {
|
|
@@ -450,8 +409,10 @@ export function createStorageRepository(host: StorageRepositoryHost): StorageRep
|
|
|
450
409
|
}
|
|
451
410
|
return { sessionId: SessionId(sessionId), identity: identityOfSession(session), rows };
|
|
452
411
|
},
|
|
453
|
-
|
|
454
|
-
|
|
412
|
+
readSessionTitleDirect: (
|
|
413
|
+
sessionId: string,
|
|
414
|
+
): { title: string; seq: number } | undefined => {
|
|
415
|
+
const db = host.dbDirect!() as any;
|
|
455
416
|
const row = db
|
|
456
417
|
.select({ fTitle: tSessions.fTitle, fTitleSeq: tSessions.fTitleSeq })
|
|
457
418
|
.from(tSessions)
|