@morlay/session-rdb 0.0.16-alpha.4 → 0.0.17
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 +44 -10
- package/dist/artifact.d.mts +9 -2
- package/dist/artifact.mjs +3 -3
- package/dist/{import-BPEHfHNk.mjs → import-DZIAjOUr.mjs} +2 -1
- package/dist/import.d.mts +1 -1
- package/dist/import.mjs +1 -1
- package/dist/{index-CgAqCQAb.d.mts → index-BEKoV1Uy.d.mts} +11 -2
- package/dist/index.d.mts +3 -3
- package/dist/index.mjs +509 -6
- package/dist/{log-BIuXv09P.mjs → log-DO69NQnn.mjs} +16 -1
- package/dist/schema-Bo6Hy5gW.d.mts +3476 -0
- package/dist/{sqlite-IH5I48aL.mjs → sqlite-BIPdMNQb.mjs} +504 -6
- package/dist/storage.d.mts +10 -2
- package/dist/storage.mjs +2 -2
- package/dist/testing.d.mts +1 -1
- package/dist/testing.mjs +1 -1
- package/drizzle/postgres/20260910120001_v3_storage_tables/migration.sql +65 -0
- package/drizzle/postgres/20260910120001_v3_storage_tables/snapshot.json +1236 -0
- package/drizzle/postgres/20260910130001_v3_session_title_backfill/migration.sql +10 -0
- package/drizzle/postgres/20260910130001_v3_session_title_backfill/snapshot.json +1236 -0
- package/drizzle/sqlite/20260910120000_v3_storage_tables/migration.sql +66 -0
- package/drizzle/sqlite/20260910120000_v3_storage_tables/snapshot.json +958 -0
- package/drizzle/sqlite/20260910130000_v3_session_title_backfill/migration.sql +13 -0
- package/drizzle/sqlite/20260910130000_v3_session_title_backfill/snapshot.json +958 -0
- package/package.json +20 -18
- package/src/backend.ts +7 -0
- package/src/branch.ts +2 -0
- package/src/drizzle/postgres-v3.ts +5 -0
- package/src/drizzle/sqlite-v3.ts +5 -0
- package/src/entities/v3/index.ts +20 -0
- package/src/entities/v3/session-projcache-rows.ts +27 -0
- package/src/entities/v3/sessions.ts +6 -0
- package/src/entities/v3/storage-units.ts +10 -0
- package/src/entities/v3/workspace-sessions.ts +24 -0
- package/src/entities/v3/workspace-state.ts +18 -0
- package/src/entities/v3/workspaces.ts +19 -0
- package/src/import-storages.ts +173 -0
- package/src/index.ts +53 -0
- package/src/log.ts +19 -0
- package/src/postgres.ts +70 -2
- package/src/schema.ts +16 -2
- package/src/sqlite.ts +87 -4
- package/src/storage-takeover/index.ts +60 -0
- package/src/storage-takeover/projection-cache.ts +445 -0
- package/src/storage-takeover/repository.ts +420 -0
- package/src/storage-takeover/storage-backend.ts +177 -0
- package/src/storage-takeover/types.ts +82 -0
- package/dist/schema-COK7-wRV.d.mts +0 -104
|
@@ -0,0 +1,445 @@
|
|
|
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
|
+
import { Context, Service } from "@deepseek-ai/cordis";
|
|
14
|
+
import { SessionLogOffset } from "@deepseek-ai/dsh-session";
|
|
15
|
+
import type {
|
|
16
|
+
Session,
|
|
17
|
+
SessionEvent,
|
|
18
|
+
SessionHeader,
|
|
19
|
+
SessionId,
|
|
20
|
+
SessionSeqCursor,
|
|
21
|
+
} from "@deepseek-ai/dsh-session";
|
|
22
|
+
import type {
|
|
23
|
+
ProjectionCheckpoint,
|
|
24
|
+
ProjectionSnapshot,
|
|
25
|
+
SessionProjectionMap,
|
|
26
|
+
} from "@deepseek-ai/dsh-session-projection";
|
|
27
|
+
import type {
|
|
28
|
+
StorageRepository,
|
|
29
|
+
StoredProjcacheEntry,
|
|
30
|
+
CheckpointIdentity,
|
|
31
|
+
ProjectionCheckpointRow,
|
|
32
|
+
} from "./types.ts";
|
|
33
|
+
|
|
34
|
+
/** 服务注册名(与上游插件一致,消费者经 `ctx.get` 解析)。 */
|
|
35
|
+
export const SESSION_PROJECTION_CACHE_SERVICE = "sessionProjectionCache";
|
|
36
|
+
|
|
37
|
+
/** 完整身份:当前世代写入的字段都必需。 */
|
|
38
|
+
type CurrentCheckpointIdentity = CheckpointIdentity & {
|
|
39
|
+
formatVersion: number;
|
|
40
|
+
isSeeded: boolean;
|
|
41
|
+
inheritedEventCount: number;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** 缓存写节流参数(上游 Config 的部署值)。 */
|
|
45
|
+
export interface ProjectionCacheConfig {
|
|
46
|
+
/** 两次强制点之间,累积多少个已提交事件强制落一次盘。 */
|
|
47
|
+
writeEveryEvents: number;
|
|
48
|
+
/** 脏 checkpoint 在强制点之间允许滞留的最长毫秒数。 */
|
|
49
|
+
writeIntervalMs: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** 每会话写回节流簿记(只对 live session)。 */
|
|
53
|
+
interface DirtyState {
|
|
54
|
+
pending: number;
|
|
55
|
+
timer: ReturnType<typeof setTimeout> | undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** 只认 title 的前代提示(与上游一致)。 */
|
|
59
|
+
const PREDECESSOR_TITLE_KEY = "title" as Extract<keyof SessionProjectionMap, string>;
|
|
60
|
+
|
|
61
|
+
/** rdb 持久化的投影 checkpoint 服务:同步驱动直读介质,异步驱动用写穿镜像支撑同步签名。 */
|
|
62
|
+
export class SessionProjectionCacheRdb extends Service {
|
|
63
|
+
static inject = ["sessionProjections", "sessions"];
|
|
64
|
+
|
|
65
|
+
/** 异步驱动(PostgreSQL)的同步读镜像:同步驱动(SQLite)直读介质,这里恒为空。 */
|
|
66
|
+
private readonly records = new Map<SessionId, StoredProjcacheEntry>();
|
|
67
|
+
private readonly dirty = new Map<Session, DirtyState>();
|
|
68
|
+
/** 读路径是否直读介质(`readProjcacheSync` 存在即同步驱动)。 */
|
|
69
|
+
private readonly directReads: boolean;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* @param ctx - 插件上下文(须已注入 sessionProjections 与 sessions)。
|
|
73
|
+
* @param config - 写节流参数。
|
|
74
|
+
* @param repository - storages 接管表访问层。
|
|
75
|
+
* @param ready - 介质就绪信号(直读介质前必须等到)。
|
|
76
|
+
*/
|
|
77
|
+
constructor(
|
|
78
|
+
ctx: Context,
|
|
79
|
+
private readonly config: ProjectionCacheConfig,
|
|
80
|
+
private readonly repository: StorageRepository,
|
|
81
|
+
private readonly ready: Promise<unknown>,
|
|
82
|
+
) {
|
|
83
|
+
super(ctx, SESSION_PROJECTION_CACHE_SERVICE);
|
|
84
|
+
this.directReads = repository.readProjcacheSync !== undefined;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Wait for the medium, then install the write path. Only the async driver
|
|
89
|
+
* needs a startup mirror: the sync driver serves every read from the table.
|
|
90
|
+
*/
|
|
91
|
+
protected async [Service.init](): Promise<void> {
|
|
92
|
+
await this.ready;
|
|
93
|
+
if (!this.directReads) {
|
|
94
|
+
for (const entry of await this.repository.loadProjcache()) {
|
|
95
|
+
this.records.set(entry.sessionId as SessionId, entry);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
this.installWritePath();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Read one session's stored record: straight from the medium, or from the async mirror. */
|
|
102
|
+
private lookup(id: SessionId): StoredProjcacheEntry | undefined {
|
|
103
|
+
if (this.directReads) return this.repository.readProjcacheSync?.(id);
|
|
104
|
+
return this.records.get(id);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The stored record for one session, accepted only when its bound log
|
|
109
|
+
* identity matches `expected`. A session id names a slot, not a lifecycle:
|
|
110
|
+
* a recreated id or a persistence store swapped under a surviving cache
|
|
111
|
+
* must not let an old record seed state folded from an unrelated log.
|
|
112
|
+
* @param id - the session whose record is read.
|
|
113
|
+
* @param expected - the log identity the caller holds (live or stored header).
|
|
114
|
+
* @returns the identity-matching record, or `undefined` (absent or unrelated).
|
|
115
|
+
*/
|
|
116
|
+
private recordFor(id: SessionId, expected: CurrentCheckpointIdentity): StoredProjcacheEntry | undefined {
|
|
117
|
+
const record = this.lookup(id);
|
|
118
|
+
if (record === undefined) return undefined;
|
|
119
|
+
return identityMatches(record.identity, expected) ? record : undefined;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The cached projection cut for one stored (cold) or live header.
|
|
124
|
+
* @param meta - authoritative Session header.
|
|
125
|
+
* @param inheritedEventCount - exact inherited cut completing the lifecycle identity.
|
|
126
|
+
* @param keys - optional projection keys required by the caller's audience.
|
|
127
|
+
* @returns the cut (`asOfSeq` = lowest served-row watermark), or `undefined`
|
|
128
|
+
* when no usable row exists for this lifecycle.
|
|
129
|
+
*/
|
|
130
|
+
cachedSnapshot(
|
|
131
|
+
meta: SessionHeader,
|
|
132
|
+
inheritedEventCount: SessionLogOffset,
|
|
133
|
+
keys?: readonly Extract<keyof SessionProjectionMap, string>[],
|
|
134
|
+
): ProjectionSnapshot | undefined {
|
|
135
|
+
const record = this.recordFor(meta.id, identityOf(meta, inheritedEventCount));
|
|
136
|
+
const snapshot = record === undefined ? undefined : this.viewRecord(record, keys);
|
|
137
|
+
return this.withDirectTitle(meta.id, keys, snapshot);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* 标题是会话数据本身(`t_sessions.f_title`,由 rdb 写路径与 rewind 维护):
|
|
142
|
+
* checkpoint 行里没有 title 时直接取该列,列表消费不依赖缓存行是否存在。
|
|
143
|
+
*/
|
|
144
|
+
private withDirectTitle(
|
|
145
|
+
id: SessionId,
|
|
146
|
+
keys: readonly Extract<keyof SessionProjectionMap, string>[] | undefined,
|
|
147
|
+
snapshot: ProjectionSnapshot | undefined,
|
|
148
|
+
): ProjectionSnapshot | undefined {
|
|
149
|
+
if (keys !== undefined && !(keys as readonly string[]).includes(PREDECESSOR_TITLE_KEY)) {
|
|
150
|
+
return snapshot;
|
|
151
|
+
}
|
|
152
|
+
if (snapshot?.values[PREDECESSOR_TITLE_KEY] !== undefined) return snapshot;
|
|
153
|
+
const direct = this.repository.readSessionTitleSync?.(id);
|
|
154
|
+
if (direct === undefined) return snapshot;
|
|
155
|
+
const values = { ...snapshot?.values, [PREDECESSOR_TITLE_KEY]: direct.title };
|
|
156
|
+
// 水位取最低(under-claim 安全):已有块的水位与直取行取小。
|
|
157
|
+
const asOfSeq =
|
|
158
|
+
snapshot === undefined ? direct.seq : Math.min(snapshot.asOfSeq as number, direct.seq);
|
|
159
|
+
return { asOfSeq: asOfSeq as SessionSeqCursor, values };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Read only a predecessor checkpoint's title as a zero-I/O listing hint.
|
|
164
|
+
* @param meta - authoritative listed Session header.
|
|
165
|
+
* @param inheritedEventCount - exact inherited cut completing the lifecycle identity.
|
|
166
|
+
* @returns a title-only checkpoint view with `asOfSeq: -1`, or `undefined`
|
|
167
|
+
* when the record is current, newer, unrelated, missing, or incompatible
|
|
168
|
+
* with the title unit.
|
|
169
|
+
*/
|
|
170
|
+
cachedPredecessorTitle(
|
|
171
|
+
meta: SessionHeader,
|
|
172
|
+
inheritedEventCount: SessionLogOffset,
|
|
173
|
+
): ProjectionSnapshot | undefined {
|
|
174
|
+
const expected = identityOf(meta, inheritedEventCount);
|
|
175
|
+
const record = this.lookup(meta.id);
|
|
176
|
+
if (record === undefined || !predecessorIdentityMatches(record.identity, expected)) {
|
|
177
|
+
return undefined;
|
|
178
|
+
}
|
|
179
|
+
const title = this.viewRecord(record, [PREDECESSOR_TITLE_KEY]);
|
|
180
|
+
return title === undefined ? undefined : { ...title, asOfSeq: -1 };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** View selected wire rows and bind them to their lowest served watermark. */
|
|
184
|
+
private viewRecord(
|
|
185
|
+
record: StoredProjcacheEntry,
|
|
186
|
+
keys?: readonly Extract<keyof SessionProjectionMap, string>[],
|
|
187
|
+
): ProjectionSnapshot | undefined {
|
|
188
|
+
const values = this.ctx.sessionProjections.viewCheckpoint(
|
|
189
|
+
record.rows as unknown as ProjectionCheckpoint,
|
|
190
|
+
keys,
|
|
191
|
+
);
|
|
192
|
+
const servedKeys = Object.keys(values);
|
|
193
|
+
if (servedKeys.length === 0) return undefined;
|
|
194
|
+
// The block carries ONE cut: the lowest served watermark is the seq every
|
|
195
|
+
// value is at least current as of (under-claiming is safe under
|
|
196
|
+
// higher-seq-wins; over-claiming would let a stale value outrank pushes).
|
|
197
|
+
const firstKey = servedKeys[0] as string;
|
|
198
|
+
let asOfSeq = (record.rows[firstKey] as ProjectionCheckpointRow).seq as SessionSeqCursor;
|
|
199
|
+
for (const key of servedKeys.slice(1)) {
|
|
200
|
+
const row = record.rows[key] as ProjectionCheckpointRow;
|
|
201
|
+
if ((row.seq as number) < (asOfSeq as number)) asOfSeq = row.seq as SessionSeqCursor;
|
|
202
|
+
}
|
|
203
|
+
return { asOfSeq, values };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Hydrate projection cells for an already-prepared Session without another
|
|
208
|
+
* persistence read. The cache seeds matching rows; the supplied exact log
|
|
209
|
+
* advances every unit to the observation cut. No checkpoint is written
|
|
210
|
+
* because the logical observation may contain recovery events not yet durable.
|
|
211
|
+
* @param session - exact unpublished Session retained by persistence.
|
|
212
|
+
* @param events - exact logical event prefix represented by the observation.
|
|
213
|
+
* @returns all projection values at the event cut.
|
|
214
|
+
*/
|
|
215
|
+
hydratePrepared(session: Session, events: readonly SessionEvent[]): ProjectionSnapshot {
|
|
216
|
+
const record = this.recordFor(
|
|
217
|
+
session.id,
|
|
218
|
+
identityOf(session.header, session.inheritedEventCount),
|
|
219
|
+
);
|
|
220
|
+
if (record === undefined) {
|
|
221
|
+
return this.ctx.sessionProjections.hydrate(session, {}, events, SessionLogOffset(0));
|
|
222
|
+
}
|
|
223
|
+
try {
|
|
224
|
+
return this.ctx.sessionProjections.hydrate(
|
|
225
|
+
session,
|
|
226
|
+
record.rows as unknown as ProjectionCheckpoint,
|
|
227
|
+
events,
|
|
228
|
+
SessionLogOffset(0),
|
|
229
|
+
);
|
|
230
|
+
} catch {
|
|
231
|
+
// Cached rows are disposable derived data. Retry from the exact log so a
|
|
232
|
+
// stale schema cannot make a valid Session unreadable.
|
|
233
|
+
return this.ctx.sessionProjections.hydrate(session, {}, events, SessionLogOffset(0));
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Durably checkpoint one live session NOW (all mandatory points call this).
|
|
239
|
+
* NOT fail-soft — callers on the fail-soft paths contain it.
|
|
240
|
+
* @param session - the live session to checkpoint.
|
|
241
|
+
* @returns resolution after durability.
|
|
242
|
+
*/
|
|
243
|
+
async write(session: Session): Promise<void> {
|
|
244
|
+
const rows = this.ctx.sessionProjections.checkpoint(session);
|
|
245
|
+
this.markClean(session);
|
|
246
|
+
// Durability barrier: the checkpoint cut was taken above, so flushing
|
|
247
|
+
// AFTER it guarantees every event inside the cut is durably logged
|
|
248
|
+
// before the cache row lands — a crash can leave the cache behind the
|
|
249
|
+
// log (longer tail replay) but never ahead of it (phantom values folded
|
|
250
|
+
// from events no stored log contains).
|
|
251
|
+
if (this.ctx.sessions.get(session.id) === session) await this.ctx.sessions.flush(session);
|
|
252
|
+
await this.put(
|
|
253
|
+
session.id,
|
|
254
|
+
identityOf(session.header, session.inheritedEventCount),
|
|
255
|
+
rows as unknown as Record<string, ProjectionCheckpointRow>,
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Cold-read one session's projections from its complete log. Each unit is
|
|
261
|
+
* seeded from the identity-checked cached rows and the refreshed checkpoint
|
|
262
|
+
* is written back (fail-soft, fire-and-forget).
|
|
263
|
+
* @param meta - the stored session header (identity witness).
|
|
264
|
+
* @param inheritedEventCount - exact inherited prefix length for projection initialization and identity.
|
|
265
|
+
* @param events - the session's complete log, in seq order.
|
|
266
|
+
* @returns the projection cut at the log end.
|
|
267
|
+
*/
|
|
268
|
+
coldSnapshot(
|
|
269
|
+
meta: SessionHeader,
|
|
270
|
+
inheritedEventCount: SessionLogOffset,
|
|
271
|
+
events: readonly SessionEvent[],
|
|
272
|
+
): ProjectionSnapshot {
|
|
273
|
+
const identity = identityOf(meta, inheritedEventCount);
|
|
274
|
+
const restored = this.ctx.sessionProjections.restore(
|
|
275
|
+
(this.recordFor(meta.id, identity)?.rows ?? {}) as unknown as ProjectionCheckpoint,
|
|
276
|
+
events,
|
|
277
|
+
SessionLogOffset(0),
|
|
278
|
+
meta,
|
|
279
|
+
inheritedEventCount,
|
|
280
|
+
);
|
|
281
|
+
// Refresh the row so the next cold read seeds from it; fail-soft and
|
|
282
|
+
// fire-and-forget — a failed write-back only costs a longer tail replay.
|
|
283
|
+
void this.put(
|
|
284
|
+
meta.id,
|
|
285
|
+
identity,
|
|
286
|
+
restored.checkpoint as unknown as Record<string, ProjectionCheckpointRow>,
|
|
287
|
+
).catch((error: unknown) => {
|
|
288
|
+
this.ctx.logger.warn(
|
|
289
|
+
`session projection cache: cold-read write-back for "${meta.id}" failed (cache stays stale): ${String(error)}`,
|
|
290
|
+
);
|
|
291
|
+
});
|
|
292
|
+
return restored.snapshot;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// --- write-behind (throttle + mandatory points) ---
|
|
296
|
+
|
|
297
|
+
private installWritePath(): void {
|
|
298
|
+
// Every committed event advances the dirty counter; turn/end is a
|
|
299
|
+
// mandatory point (the durable value most reads want is the turn-final
|
|
300
|
+
// one), count/interval throttle the in-turn stream.
|
|
301
|
+
this.ctx.on("session/event", (session: Session, event: SessionEvent) => {
|
|
302
|
+
if (event.type === "turn/end") {
|
|
303
|
+
void this.flushSoft(session, "turn/end");
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
const state = this.dirty.get(session) ?? { pending: 0, timer: undefined };
|
|
307
|
+
this.dirty.set(session, state);
|
|
308
|
+
state.pending += 1;
|
|
309
|
+
if (state.pending >= this.config.writeEveryEvents) {
|
|
310
|
+
void this.flushSoft(session, "count threshold");
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
state.timer ??= setTimeout(() => {
|
|
314
|
+
void this.flushSoft(session, "interval");
|
|
315
|
+
}, this.config.writeIntervalMs);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
// Creation is the FIRST mandatory point: a session that never talks (a
|
|
319
|
+
// forked child seeded with its ancestor's title, say) would otherwise
|
|
320
|
+
// get its first row only at detach.
|
|
321
|
+
this.ctx.on("session/created", (session: Session) => {
|
|
322
|
+
void this.flushSoft(session, "create");
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
// Detach (the live-to-cold moment): the final mandatory point.
|
|
326
|
+
this.ctx.on("session/disposed", (session: Session) => {
|
|
327
|
+
void this.flushSoft(session, "detach");
|
|
328
|
+
this.markClean(session);
|
|
329
|
+
this.dirty.delete(session);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
this.ctx.effect(() => () => {
|
|
333
|
+
for (const state of this.dirty.values()) {
|
|
334
|
+
if (state.timer !== undefined) clearTimeout(state.timer);
|
|
335
|
+
}
|
|
336
|
+
this.dirty.clear();
|
|
337
|
+
}, "sessionProjectionCacheRdb.timers");
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** One fail-soft durable checkpoint. */
|
|
341
|
+
private async flushSoft(session: Session, trigger: string): Promise<void> {
|
|
342
|
+
try {
|
|
343
|
+
await this.write(session);
|
|
344
|
+
} catch (error) {
|
|
345
|
+
this.ctx.logger.warn(
|
|
346
|
+
`session projection cache: ${trigger} write for "${session.id}" failed (cache stays stale): ${String(error)}`,
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** Reset one session's dirty bookkeeping (its checkpoint is being written). */
|
|
352
|
+
private markClean(session: Session): void {
|
|
353
|
+
const state = this.dirty.get(session);
|
|
354
|
+
if (state === undefined) return;
|
|
355
|
+
state.pending = 0;
|
|
356
|
+
if (state.timer !== undefined) {
|
|
357
|
+
clearTimeout(state.timer);
|
|
358
|
+
state.timer = undefined;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** Replace one session's stored record with its log identity and a detached snapshot of `rows`. */
|
|
363
|
+
private async put(
|
|
364
|
+
id: SessionId,
|
|
365
|
+
identity: CheckpointIdentity,
|
|
366
|
+
rows: Record<string, ProjectionCheckpointRow>,
|
|
367
|
+
): Promise<void> {
|
|
368
|
+
const detached = detachJson(rows);
|
|
369
|
+
// 介质只存行:checkpoint 的 identity 由会话行承载(直读时现取)。
|
|
370
|
+
await this.repository.putProjcache(id, detached);
|
|
371
|
+
// 异步驱动的镜像要 identity 才能做校验,随写入一起带上。
|
|
372
|
+
if (!this.directReads) this.records.set(id, { sessionId: id, identity, rows: detached });
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** Detach one checkpoint from live unit state, refusing non-lossless JSON. */
|
|
377
|
+
function detachJson(rows: Record<string, ProjectionCheckpointRow>): Record<string, ProjectionCheckpointRow> {
|
|
378
|
+
let text: string | undefined;
|
|
379
|
+
try {
|
|
380
|
+
text = JSON.stringify(rows);
|
|
381
|
+
} catch (error) {
|
|
382
|
+
throw new TypeError(
|
|
383
|
+
`projection checkpoint is not losslessly JSON-serializable: ${String(error)}`,
|
|
384
|
+
{ cause: error },
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
if (text === undefined) {
|
|
388
|
+
throw new TypeError("projection checkpoint is not losslessly JSON-serializable");
|
|
389
|
+
}
|
|
390
|
+
return JSON.parse(text) as Record<string, ProjectionCheckpointRow>;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** Project a header onto the identity fields a record is bound to. */
|
|
394
|
+
function identityOf(
|
|
395
|
+
header: SessionHeader,
|
|
396
|
+
inheritedEventCount: SessionLogOffset,
|
|
397
|
+
): CurrentCheckpointIdentity {
|
|
398
|
+
const cut = SessionLogOffset(inheritedEventCount);
|
|
399
|
+
if (!header.isSeeded && cut !== 0) {
|
|
400
|
+
throw new Error("unseeded projection-cache identity inherited event count must be 0");
|
|
401
|
+
}
|
|
402
|
+
return {
|
|
403
|
+
formatVersion: header.version,
|
|
404
|
+
createdAt: header.createdAt,
|
|
405
|
+
...(header.cwd === undefined ? {} : { cwd: header.cwd }),
|
|
406
|
+
isSeeded: header.isSeeded,
|
|
407
|
+
inheritedEventCount: cut,
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Whether a stored record's bound identity names the caller's lifecycle.
|
|
413
|
+
* An absent format generation cannot prove the fold semantics and never
|
|
414
|
+
* matches. Once the format matches, absent lineage fields (records admitted
|
|
415
|
+
* via compatible versions predate them) read as the unseeded lineage.
|
|
416
|
+
*/
|
|
417
|
+
function identityMatches(stored: CheckpointIdentity, expected: CurrentCheckpointIdentity): boolean {
|
|
418
|
+
return (
|
|
419
|
+
stored.formatVersion === expected.formatVersion &&
|
|
420
|
+
lifecycleIdentityMatches(stored, expected)
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/** Match one predecessor cache record to the authoritative listed lifecycle. */
|
|
425
|
+
function predecessorIdentityMatches(
|
|
426
|
+
stored: CheckpointIdentity,
|
|
427
|
+
expected: CurrentCheckpointIdentity,
|
|
428
|
+
): boolean {
|
|
429
|
+
const predecessor =
|
|
430
|
+
stored.formatVersion === undefined || stored.formatVersion < expected.formatVersion;
|
|
431
|
+
return predecessor && lifecycleIdentityMatches(stored, expected);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/** Match the format-independent fields that distinguish one Session lifecycle. */
|
|
435
|
+
function lifecycleIdentityMatches(
|
|
436
|
+
stored: CheckpointIdentity,
|
|
437
|
+
expected: CurrentCheckpointIdentity,
|
|
438
|
+
): boolean {
|
|
439
|
+
return (
|
|
440
|
+
stored.createdAt === expected.createdAt &&
|
|
441
|
+
stored.cwd === expected.cwd &&
|
|
442
|
+
(stored.isSeeded ?? false) === expected.isSeeded &&
|
|
443
|
+
(stored.inheritedEventCount ?? 0) === expected.inheritedEventCount
|
|
444
|
+
);
|
|
445
|
+
}
|