@morlay/session-rdb 0.0.16 → 0.0.18
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
package/dist/index.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { a as
|
|
2
|
-
import { c as repairRequestHeaders, d as rowToMeta, f as scanRows, g as
|
|
3
|
-
import { c as SessionBranchRdbProvider, l as locateTurnEnd, o as registerSessionImport, s as SessionBranchRdb } from "./import-
|
|
1
|
+
import { S as WriteGuard, a as EVENT_ENCODING, b as postgresTableDefs, c as eventDimensions, i as DEFAULT_BUSY_TIMEOUT_MS, o as SCHEMA_VERSION, r as createStorageRepository, t as SqliteBackend, x as toPostgresSchema } from "./sqlite-BIPdMNQb.mjs";
|
|
2
|
+
import { _ as toJsonlArtifact, c as repairRequestHeaders, d as rowToMeta, f as scanRows, g as titleOfEventData, m as sessionInsertRow, p as sessionConflictRow, s as repairReadView } from "./log-DO69NQnn.mjs";
|
|
3
|
+
import { c as SessionBranchRdbProvider, l as locateTurnEnd, o as registerSessionImport, s as SessionBranchRdb } from "./import-DZIAjOUr.mjs";
|
|
4
4
|
import z from "@deepseek-ai/schemastery";
|
|
5
5
|
import { randomUUID } from "node:crypto";
|
|
6
6
|
import { Pool } from "pg";
|
|
@@ -8,10 +8,12 @@ import { drizzle } from "drizzle-orm/node-postgres";
|
|
|
8
8
|
import { SessionAlreadyExistsError, SessionAlreadyOwnedError, SessionHandleClosedError, SessionPersistence, SessionPersistenceNotFoundError, SessionPersistenceRevision, SessionReadOnlyError, assertContiguous, assertVersion, materializeAppendBatch, materializeCreateHeader, validateStoredEvents } from "@deepseek-ai/dsh-session-persistence";
|
|
9
9
|
import { SESSION_FORMAT_VERSION, SessionLogOffset } from "@deepseek-ai/dsh-session";
|
|
10
10
|
import { sessionFormatCatalog } from "@deepseek-ai/dsh-session-format-catalog";
|
|
11
|
-
import { and, eq, gte, sql } from "drizzle-orm";
|
|
11
|
+
import { and, desc, eq, gte, sql } from "drizzle-orm";
|
|
12
12
|
import { readdirSync } from "node:fs";
|
|
13
13
|
import { fileURLToPath } from "node:url";
|
|
14
14
|
import { migrate } from "drizzle-orm/node-postgres/migrator";
|
|
15
|
+
import { StorageError, storageBackendServiceKey } from "@deepseek-ai/dsh-storage";
|
|
16
|
+
import { Service } from "@deepseek-ai/cordis";
|
|
15
17
|
//#region src/postgres.ts
|
|
16
18
|
/** drizzle-kit 生成的迁移目录(随包根 drizzle/ 发布;src/dist 形态经相对 URL 统一解析)。 */
|
|
17
19
|
const postgresMigrationsDir = fileURLToPath(new URL("../drizzle/postgres/", import.meta.url));
|
|
@@ -21,12 +23,46 @@ var PostgresBackend = class PostgresBackend {
|
|
|
21
23
|
kind = "postgres";
|
|
22
24
|
storeIdentity;
|
|
23
25
|
tables;
|
|
26
|
+
/** storages 接管表访问层;句柄在 open()(含迁移)完成后解析。 */
|
|
27
|
+
storage;
|
|
28
|
+
opened;
|
|
29
|
+
resolveOpened;
|
|
30
|
+
rejectOpened;
|
|
31
|
+
/** 事务期间的连接覆盖:storage 写方法经它加入当前事务。 */
|
|
32
|
+
txOverride;
|
|
24
33
|
constructor(db, options) {
|
|
25
34
|
this.db = db;
|
|
26
35
|
this.options = options;
|
|
27
36
|
this.tables = toPostgresSchema(postgresTableDefs, this.options.schema ?? "public");
|
|
37
|
+
this.opened = new Promise((resolve, reject) => {
|
|
38
|
+
this.resolveOpened = resolve;
|
|
39
|
+
this.rejectOpened = reject;
|
|
40
|
+
});
|
|
41
|
+
this.opened.catch(() => {});
|
|
42
|
+
this.storage = createStorageRepository({
|
|
43
|
+
db: () => this.opened.then(() => this.txOverride ?? this.db),
|
|
44
|
+
writeAtomically: (fn) => this.db.transaction(async (tx) => {
|
|
45
|
+
const previous = this.txOverride;
|
|
46
|
+
this.txOverride = tx;
|
|
47
|
+
try {
|
|
48
|
+
return await fn();
|
|
49
|
+
} finally {
|
|
50
|
+
this.txOverride = previous;
|
|
51
|
+
}
|
|
52
|
+
}),
|
|
53
|
+
tables: this.tables
|
|
54
|
+
});
|
|
28
55
|
}
|
|
29
56
|
async open() {
|
|
57
|
+
try {
|
|
58
|
+
await this.doOpen();
|
|
59
|
+
this.resolveOpened();
|
|
60
|
+
} catch (error) {
|
|
61
|
+
this.rejectOpened(error);
|
|
62
|
+
throw error;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
async doOpen() {
|
|
30
66
|
const schema = this.options.schema ?? "public";
|
|
31
67
|
const qualifiedMeta = schema === "public" ? "t_schema_meta" : `"${schema}".t_schema_meta`;
|
|
32
68
|
if ((await this.db.execute(sql`SELECT to_regclass(${qualifiedMeta}) IS NOT NULL AS exists`)).rows[0]?.exists === true) {
|
|
@@ -87,6 +123,7 @@ var PostgresBackend = class PostgresBackend {
|
|
|
87
123
|
insertBridges: (rows) => this.insertBridges(tx, rows),
|
|
88
124
|
updateHead: (id, headEventId, headSequence) => this.updateHead(tx, id, headEventId, headSequence),
|
|
89
125
|
bumpRevision: (id) => this.bumpRevision(tx, id),
|
|
126
|
+
refreshTitle: (id) => this.refreshTitle(tx, id),
|
|
90
127
|
deleteBridgeTail: (id, fromSequence) => this.deleteBridgeTail(tx, id, fromSequence),
|
|
91
128
|
getPrevBridge: (id, sequence) => this.getPrevBridge(tx, id, sequence)
|
|
92
129
|
};
|
|
@@ -136,6 +173,20 @@ var PostgresBackend = class PostgresBackend {
|
|
|
136
173
|
async bumpRevision(exec, id) {
|
|
137
174
|
await exec.update(this.tables["t_sessions"]).set({ fRevision: sql`${this.tables["t_sessions"].fRevision} + 1` }).where(eq(this.tables["t_sessions"].fSessionId, id)).execute();
|
|
138
175
|
}
|
|
176
|
+
/** 从事件表重算标题(最后一条 `session/title`),写回会话行的 f_title 列。 */
|
|
177
|
+
async refreshTitle(exec, id) {
|
|
178
|
+
const bridges = this.tables["t_session_events"];
|
|
179
|
+
const entities = this.tables["t_events"];
|
|
180
|
+
const row = (await exec.select({
|
|
181
|
+
fSequence: bridges.fSequence,
|
|
182
|
+
fData: entities.fData
|
|
183
|
+
}).from(bridges).innerJoin(entities, eq(entities.fEventId, bridges.fEventId)).where(and(eq(bridges.fSessionId, id), eq(entities.fType, "session/title"))).orderBy(desc(bridges.fSequence)).limit(1).execute())[0];
|
|
184
|
+
const title = row === void 0 ? void 0 : titleOfEventData(row.fData);
|
|
185
|
+
await exec.update(this.tables["t_sessions"]).set({
|
|
186
|
+
fTitle: title ?? null,
|
|
187
|
+
fTitleSeq: title === void 0 ? null : row.fSequence
|
|
188
|
+
}).where(eq(this.tables["t_sessions"].fSessionId, id)).execute();
|
|
189
|
+
}
|
|
139
190
|
async deleteBridgeTail(exec, id, fromSequence) {
|
|
140
191
|
await exec.delete(this.tables["t_session_events"]).where(and(eq(this.tables["t_session_events"].fSessionId, id), gte(this.tables["t_session_events"].fSequence, fromSequence))).execute();
|
|
141
192
|
}
|
|
@@ -250,7 +301,436 @@ function adoptLegacyRows(row, eventRows) {
|
|
|
250
301
|
};
|
|
251
302
|
}
|
|
252
303
|
//#endregion
|
|
304
|
+
//#region src/storage-takeover/projection-cache.ts
|
|
305
|
+
/**
|
|
306
|
+
* 投影 checkpoint 服务(`ctx.sessionProjectionCache`)的 rdb 实现:替换上游
|
|
307
|
+
* `@deepseek-ai/dsh-session-projection-cache` 插件,公开面与语义逐一对齐
|
|
308
|
+
* (cachedSnapshot / cachedPredecessorTitle / hydratePrepared / write /
|
|
309
|
+
* coldSnapshot,以及三个强制写点与计数/定时节流),持久层换成 session-rdb
|
|
310
|
+
* 的语义专用表 `t_session_projcache` / `t_session_projcache_row`。
|
|
311
|
+
*
|
|
312
|
+
* 读方法是同步签名(session 列表在请求路径上直接调用),所以服务持有启动时
|
|
313
|
+
* 从表中加载的内存 checkpoint 表;写先落库、后更新内存,保证读到的内存值
|
|
314
|
+
* 磁盘上一定存在。
|
|
315
|
+
*/
|
|
316
|
+
/** 服务注册名(与上游插件一致,消费者经 `ctx.get` 解析)。 */
|
|
317
|
+
const SESSION_PROJECTION_CACHE_SERVICE = "sessionProjectionCache";
|
|
318
|
+
/** 只认 title 的前代提示(与上游一致)。 */
|
|
319
|
+
const PREDECESSOR_TITLE_KEY = "title";
|
|
320
|
+
/** rdb 持久化的投影 checkpoint 服务:同步驱动直读介质,异步驱动用写穿镜像支撑同步签名。 */
|
|
321
|
+
var SessionProjectionCacheRdb = class extends Service {
|
|
322
|
+
config;
|
|
323
|
+
repository;
|
|
324
|
+
ready;
|
|
325
|
+
static inject = ["sessionProjections", "sessions"];
|
|
326
|
+
/** 异步驱动(PostgreSQL)的同步读镜像:同步驱动(SQLite)直读介质,这里恒为空。 */
|
|
327
|
+
records = /* @__PURE__ */ new Map();
|
|
328
|
+
dirty = /* @__PURE__ */ new Map();
|
|
329
|
+
/** 读路径是否直读介质(`readProjcacheSync` 存在即同步驱动)。 */
|
|
330
|
+
directReads;
|
|
331
|
+
/**
|
|
332
|
+
* @param ctx - 插件上下文(须已注入 sessionProjections 与 sessions)。
|
|
333
|
+
* @param config - 写节流参数。
|
|
334
|
+
* @param repository - storages 接管表访问层。
|
|
335
|
+
* @param ready - 介质就绪信号(直读介质前必须等到)。
|
|
336
|
+
*/
|
|
337
|
+
constructor(ctx, config, repository, ready) {
|
|
338
|
+
super(ctx, SESSION_PROJECTION_CACHE_SERVICE);
|
|
339
|
+
this.config = config;
|
|
340
|
+
this.repository = repository;
|
|
341
|
+
this.ready = ready;
|
|
342
|
+
this.directReads = repository.readProjcacheSync !== void 0;
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* Wait for the medium, then install the write path. Only the async driver
|
|
346
|
+
* needs a startup mirror: the sync driver serves every read from the table.
|
|
347
|
+
*/
|
|
348
|
+
async [Service.init]() {
|
|
349
|
+
await this.ready;
|
|
350
|
+
if (!this.directReads) for (const entry of await this.repository.loadProjcache()) this.records.set(entry.sessionId, entry);
|
|
351
|
+
this.installWritePath();
|
|
352
|
+
}
|
|
353
|
+
/** Read one session's stored record: straight from the medium, or from the async mirror. */
|
|
354
|
+
lookup(id) {
|
|
355
|
+
if (this.directReads) return this.repository.readProjcacheSync?.(id);
|
|
356
|
+
return this.records.get(id);
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* The stored record for one session, accepted only when its bound log
|
|
360
|
+
* identity matches `expected`. A session id names a slot, not a lifecycle:
|
|
361
|
+
* a recreated id or a persistence store swapped under a surviving cache
|
|
362
|
+
* must not let an old record seed state folded from an unrelated log.
|
|
363
|
+
* @param id - the session whose record is read.
|
|
364
|
+
* @param expected - the log identity the caller holds (live or stored header).
|
|
365
|
+
* @returns the identity-matching record, or `undefined` (absent or unrelated).
|
|
366
|
+
*/
|
|
367
|
+
recordFor(id, expected) {
|
|
368
|
+
const record = this.lookup(id);
|
|
369
|
+
if (record === void 0) return void 0;
|
|
370
|
+
return identityMatches(record.identity, expected) ? record : void 0;
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* The cached projection cut for one stored (cold) or live header.
|
|
374
|
+
* @param meta - authoritative Session header.
|
|
375
|
+
* @param inheritedEventCount - exact inherited cut completing the lifecycle identity.
|
|
376
|
+
* @param keys - optional projection keys required by the caller's audience.
|
|
377
|
+
* @returns the cut (`asOfSeq` = lowest served-row watermark), or `undefined`
|
|
378
|
+
* when no usable row exists for this lifecycle.
|
|
379
|
+
*/
|
|
380
|
+
cachedSnapshot(meta, inheritedEventCount, keys) {
|
|
381
|
+
const record = this.recordFor(meta.id, identityOf(meta, inheritedEventCount));
|
|
382
|
+
const snapshot = record === void 0 ? void 0 : this.viewRecord(record, keys);
|
|
383
|
+
return this.withDirectTitle(meta.id, keys, snapshot);
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* 标题是会话数据本身(`t_sessions.f_title`,由 rdb 写路径与 rewind 维护):
|
|
387
|
+
* checkpoint 行里没有 title 时直接取该列,列表消费不依赖缓存行是否存在。
|
|
388
|
+
*/
|
|
389
|
+
withDirectTitle(id, keys, snapshot) {
|
|
390
|
+
if (keys !== void 0 && !keys.includes(PREDECESSOR_TITLE_KEY)) return snapshot;
|
|
391
|
+
if (snapshot?.values[PREDECESSOR_TITLE_KEY] !== void 0) return snapshot;
|
|
392
|
+
const direct = this.repository.readSessionTitleSync?.(id);
|
|
393
|
+
if (direct === void 0) return snapshot;
|
|
394
|
+
const values = {
|
|
395
|
+
...snapshot?.values,
|
|
396
|
+
[PREDECESSOR_TITLE_KEY]: direct.title
|
|
397
|
+
};
|
|
398
|
+
return {
|
|
399
|
+
asOfSeq: snapshot === void 0 ? direct.seq : Math.min(snapshot.asOfSeq, direct.seq),
|
|
400
|
+
values
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* Read only a predecessor checkpoint's title as a zero-I/O listing hint.
|
|
405
|
+
* @param meta - authoritative listed Session header.
|
|
406
|
+
* @param inheritedEventCount - exact inherited cut completing the lifecycle identity.
|
|
407
|
+
* @returns a title-only checkpoint view with `asOfSeq: -1`, or `undefined`
|
|
408
|
+
* when the record is current, newer, unrelated, missing, or incompatible
|
|
409
|
+
* with the title unit.
|
|
410
|
+
*/
|
|
411
|
+
cachedPredecessorTitle(meta, inheritedEventCount) {
|
|
412
|
+
const expected = identityOf(meta, inheritedEventCount);
|
|
413
|
+
const record = this.lookup(meta.id);
|
|
414
|
+
if (record === void 0 || !predecessorIdentityMatches(record.identity, expected)) return;
|
|
415
|
+
const title = this.viewRecord(record, [PREDECESSOR_TITLE_KEY]);
|
|
416
|
+
return title === void 0 ? void 0 : {
|
|
417
|
+
...title,
|
|
418
|
+
asOfSeq: -1
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
/** View selected wire rows and bind them to their lowest served watermark. */
|
|
422
|
+
viewRecord(record, keys) {
|
|
423
|
+
const values = this.ctx.sessionProjections.viewCheckpoint(record.rows, keys);
|
|
424
|
+
const servedKeys = Object.keys(values);
|
|
425
|
+
if (servedKeys.length === 0) return void 0;
|
|
426
|
+
const firstKey = servedKeys[0];
|
|
427
|
+
let asOfSeq = record.rows[firstKey].seq;
|
|
428
|
+
for (const key of servedKeys.slice(1)) {
|
|
429
|
+
const row = record.rows[key];
|
|
430
|
+
if (row.seq < asOfSeq) asOfSeq = row.seq;
|
|
431
|
+
}
|
|
432
|
+
return {
|
|
433
|
+
asOfSeq,
|
|
434
|
+
values
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
/**
|
|
438
|
+
* Hydrate projection cells for an already-prepared Session without another
|
|
439
|
+
* persistence read. The cache seeds matching rows; the supplied exact log
|
|
440
|
+
* advances every unit to the observation cut. No checkpoint is written
|
|
441
|
+
* because the logical observation may contain recovery events not yet durable.
|
|
442
|
+
* @param session - exact unpublished Session retained by persistence.
|
|
443
|
+
* @param events - exact logical event prefix represented by the observation.
|
|
444
|
+
* @returns all projection values at the event cut.
|
|
445
|
+
*/
|
|
446
|
+
hydratePrepared(session, events) {
|
|
447
|
+
const record = this.recordFor(session.id, identityOf(session.header, session.inheritedEventCount));
|
|
448
|
+
if (record === void 0) return this.ctx.sessionProjections.hydrate(session, {}, events, SessionLogOffset(0));
|
|
449
|
+
try {
|
|
450
|
+
return this.ctx.sessionProjections.hydrate(session, record.rows, events, SessionLogOffset(0));
|
|
451
|
+
} catch {
|
|
452
|
+
return this.ctx.sessionProjections.hydrate(session, {}, events, SessionLogOffset(0));
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
/**
|
|
456
|
+
* Durably checkpoint one live session NOW (all mandatory points call this).
|
|
457
|
+
* NOT fail-soft — callers on the fail-soft paths contain it.
|
|
458
|
+
* @param session - the live session to checkpoint.
|
|
459
|
+
* @returns resolution after durability.
|
|
460
|
+
*/
|
|
461
|
+
async write(session) {
|
|
462
|
+
const rows = this.ctx.sessionProjections.checkpoint(session);
|
|
463
|
+
this.markClean(session);
|
|
464
|
+
if (this.ctx.sessions.get(session.id) === session) await this.ctx.sessions.flush(session);
|
|
465
|
+
await this.put(session.id, identityOf(session.header, session.inheritedEventCount), rows);
|
|
466
|
+
}
|
|
467
|
+
/**
|
|
468
|
+
* Cold-read one session's projections from its complete log. Each unit is
|
|
469
|
+
* seeded from the identity-checked cached rows and the refreshed checkpoint
|
|
470
|
+
* is written back (fail-soft, fire-and-forget).
|
|
471
|
+
* @param meta - the stored session header (identity witness).
|
|
472
|
+
* @param inheritedEventCount - exact inherited prefix length for projection initialization and identity.
|
|
473
|
+
* @param events - the session's complete log, in seq order.
|
|
474
|
+
* @returns the projection cut at the log end.
|
|
475
|
+
*/
|
|
476
|
+
coldSnapshot(meta, inheritedEventCount, events) {
|
|
477
|
+
const identity = identityOf(meta, inheritedEventCount);
|
|
478
|
+
const restored = this.ctx.sessionProjections.restore(this.recordFor(meta.id, identity)?.rows ?? {}, events, SessionLogOffset(0), meta, inheritedEventCount);
|
|
479
|
+
this.put(meta.id, identity, restored.checkpoint).catch((error) => {
|
|
480
|
+
this.ctx.logger.warn(`session projection cache: cold-read write-back for "${meta.id}" failed (cache stays stale): ${String(error)}`);
|
|
481
|
+
});
|
|
482
|
+
return restored.snapshot;
|
|
483
|
+
}
|
|
484
|
+
installWritePath() {
|
|
485
|
+
this.ctx.on("session/event", (session, event) => {
|
|
486
|
+
if (event.type === "turn/end") {
|
|
487
|
+
this.flushSoft(session, "turn/end");
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
const state = this.dirty.get(session) ?? {
|
|
491
|
+
pending: 0,
|
|
492
|
+
timer: void 0
|
|
493
|
+
};
|
|
494
|
+
this.dirty.set(session, state);
|
|
495
|
+
state.pending += 1;
|
|
496
|
+
if (state.pending >= this.config.writeEveryEvents) {
|
|
497
|
+
this.flushSoft(session, "count threshold");
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
state.timer ??= setTimeout(() => {
|
|
501
|
+
this.flushSoft(session, "interval");
|
|
502
|
+
}, this.config.writeIntervalMs);
|
|
503
|
+
});
|
|
504
|
+
this.ctx.on("session/created", (session) => {
|
|
505
|
+
this.flushSoft(session, "create");
|
|
506
|
+
});
|
|
507
|
+
this.ctx.on("session/disposed", (session) => {
|
|
508
|
+
this.flushSoft(session, "detach");
|
|
509
|
+
this.markClean(session);
|
|
510
|
+
this.dirty.delete(session);
|
|
511
|
+
});
|
|
512
|
+
this.ctx.effect(() => () => {
|
|
513
|
+
for (const state of this.dirty.values()) if (state.timer !== void 0) clearTimeout(state.timer);
|
|
514
|
+
this.dirty.clear();
|
|
515
|
+
}, "sessionProjectionCacheRdb.timers");
|
|
516
|
+
}
|
|
517
|
+
/** One fail-soft durable checkpoint. */
|
|
518
|
+
async flushSoft(session, trigger) {
|
|
519
|
+
try {
|
|
520
|
+
await this.write(session);
|
|
521
|
+
} catch (error) {
|
|
522
|
+
this.ctx.logger.warn(`session projection cache: ${trigger} write for "${session.id}" failed (cache stays stale): ${String(error)}`);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
/** Reset one session's dirty bookkeeping (its checkpoint is being written). */
|
|
526
|
+
markClean(session) {
|
|
527
|
+
const state = this.dirty.get(session);
|
|
528
|
+
if (state === void 0) return;
|
|
529
|
+
state.pending = 0;
|
|
530
|
+
if (state.timer !== void 0) {
|
|
531
|
+
clearTimeout(state.timer);
|
|
532
|
+
state.timer = void 0;
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
/** Replace one session's stored record with its log identity and a detached snapshot of `rows`. */
|
|
536
|
+
async put(id, identity, rows) {
|
|
537
|
+
const detached = detachJson(rows);
|
|
538
|
+
await this.repository.putProjcache(id, detached);
|
|
539
|
+
if (!this.directReads) this.records.set(id, {
|
|
540
|
+
sessionId: id,
|
|
541
|
+
identity,
|
|
542
|
+
rows: detached
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
};
|
|
546
|
+
/** Detach one checkpoint from live unit state, refusing non-lossless JSON. */
|
|
547
|
+
function detachJson(rows) {
|
|
548
|
+
let text;
|
|
549
|
+
try {
|
|
550
|
+
text = JSON.stringify(rows);
|
|
551
|
+
} catch (error) {
|
|
552
|
+
throw new TypeError(`projection checkpoint is not losslessly JSON-serializable: ${String(error)}`, { cause: error });
|
|
553
|
+
}
|
|
554
|
+
if (text === void 0) throw new TypeError("projection checkpoint is not losslessly JSON-serializable");
|
|
555
|
+
return JSON.parse(text);
|
|
556
|
+
}
|
|
557
|
+
/** Project a header onto the identity fields a record is bound to. */
|
|
558
|
+
function identityOf(header, inheritedEventCount) {
|
|
559
|
+
const cut = SessionLogOffset(inheritedEventCount);
|
|
560
|
+
if (!header.isSeeded && cut !== 0) throw new Error("unseeded projection-cache identity inherited event count must be 0");
|
|
561
|
+
return {
|
|
562
|
+
formatVersion: header.version,
|
|
563
|
+
createdAt: header.createdAt,
|
|
564
|
+
...header.cwd === void 0 ? {} : { cwd: header.cwd },
|
|
565
|
+
isSeeded: header.isSeeded,
|
|
566
|
+
inheritedEventCount: cut
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
/**
|
|
570
|
+
* Whether a stored record's bound identity names the caller's lifecycle.
|
|
571
|
+
* An absent format generation cannot prove the fold semantics and never
|
|
572
|
+
* matches. Once the format matches, absent lineage fields (records admitted
|
|
573
|
+
* via compatible versions predate them) read as the unseeded lineage.
|
|
574
|
+
*/
|
|
575
|
+
function identityMatches(stored, expected) {
|
|
576
|
+
return stored.formatVersion === expected.formatVersion && lifecycleIdentityMatches(stored, expected);
|
|
577
|
+
}
|
|
578
|
+
/** Match one predecessor cache record to the authoritative listed lifecycle. */
|
|
579
|
+
function predecessorIdentityMatches(stored, expected) {
|
|
580
|
+
return (stored.formatVersion === void 0 || stored.formatVersion < expected.formatVersion) && lifecycleIdentityMatches(stored, expected);
|
|
581
|
+
}
|
|
582
|
+
/** Match the format-independent fields that distinguish one Session lifecycle. */
|
|
583
|
+
function lifecycleIdentityMatches(stored, expected) {
|
|
584
|
+
return stored.createdAt === expected.createdAt && stored.cwd === expected.cwd && (stored.isSeeded ?? false) === expected.isSeeded && (stored.inheritedEventCount ?? 0) === expected.inheritedEventCount;
|
|
585
|
+
}
|
|
586
|
+
//#endregion
|
|
587
|
+
//#region src/storage-takeover/storage-backend.ts
|
|
588
|
+
/**
|
|
589
|
+
* storages 接管:注册在 storage hub 上的 `rdb` KV 后端。它把 workspace 域
|
|
590
|
+
* (上游 single 布局:一张 `workspaces` 表 + global 单例)映射到 session-rdb
|
|
591
|
+
* 的语义专用表,与事件日志同库同连接;域层(`ctx.storageDomain`)通过
|
|
592
|
+
* `routes` 把 `workspace` 域路由到本后端。
|
|
593
|
+
*
|
|
594
|
+
* 本后端只服务已知域的专用表:未知 unit 名 fail loud,而不是静默落到通用
|
|
595
|
+
* 存储——表结构是显式维护的(见 docs/schema.md)。
|
|
596
|
+
*/
|
|
597
|
+
/** 本后端唯一服务的域:上游 `workspaceDomainSpec`(v2,single 布局)。 */
|
|
598
|
+
const WORKSPACE_UNIT = "workspace";
|
|
599
|
+
const WORKSPACE_TABLE = "workspaces";
|
|
600
|
+
/** 只服务 workspace 域的 KV 后端。 */
|
|
601
|
+
var RdbStorageBackend = class {
|
|
602
|
+
repository;
|
|
603
|
+
kv = { open: (descriptor) => this.openUnit(descriptor) };
|
|
604
|
+
/** 已打开(或打开中)的 unit 名;重复 open 是调用方 bug。 */
|
|
605
|
+
open = /* @__PURE__ */ new Set();
|
|
606
|
+
closed = false;
|
|
607
|
+
/**
|
|
608
|
+
* @param repository - storages 接管表访问层(与事件日志同介质)。
|
|
609
|
+
*/
|
|
610
|
+
constructor(repository) {
|
|
611
|
+
this.repository = repository;
|
|
612
|
+
}
|
|
613
|
+
async openUnit(descriptor) {
|
|
614
|
+
if (this.closed) throw new StorageError("closed", "rdb storage backend is closed");
|
|
615
|
+
if (descriptor.name !== WORKSPACE_UNIT) throw new Error(`rdb storage backend serves only the '${WORKSPACE_UNIT}' domain (requested '${descriptor.name}')`);
|
|
616
|
+
if (!descriptor.tables.includes(WORKSPACE_TABLE) || descriptor.hasGlobal !== true) throw new Error(`rdb storage backend expects the '${WORKSPACE_UNIT}' domain shape (table '${WORKSPACE_TABLE}' plus a global slot)`);
|
|
617
|
+
if (this.open.has(descriptor.name)) throw new Error(`kv unit '${descriptor.name}' is already open (double-open is a caller bug)`);
|
|
618
|
+
const stored = await this.repository.readUnitVersion(descriptor.name);
|
|
619
|
+
if (stored === void 0) await this.repository.insertUnitVersion(descriptor.name, descriptor.version);
|
|
620
|
+
else if (stored !== descriptor.version) throw new StorageError("version-mismatch", `kv unit '${descriptor.name}' is stamped version ${stored} on the medium, incompatible with descriptor version ${descriptor.version}`);
|
|
621
|
+
this.open.add(descriptor.name);
|
|
622
|
+
return new WorkspaceKvUnit(this.repository, descriptor, () => {
|
|
623
|
+
this.open.delete(descriptor.name);
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
/**
|
|
627
|
+
* Release the backend. The medium (the session database) belongs to the
|
|
628
|
+
* owning session-rdb plugin, so closing open units here does not close it.
|
|
629
|
+
* @returns resolution after the name table is cleared.
|
|
630
|
+
*/
|
|
631
|
+
async close() {
|
|
632
|
+
this.closed = true;
|
|
633
|
+
this.open.clear();
|
|
634
|
+
}
|
|
635
|
+
};
|
|
636
|
+
/** workspace 域的 KV unit:每个原语一条 SQL 语句,值形状与上游记录一致。 */
|
|
637
|
+
var WorkspaceKvUnit = class {
|
|
638
|
+
repository;
|
|
639
|
+
descriptor;
|
|
640
|
+
onClose;
|
|
641
|
+
closed = false;
|
|
642
|
+
constructor(repository, descriptor, onClose) {
|
|
643
|
+
this.repository = repository;
|
|
644
|
+
this.descriptor = descriptor;
|
|
645
|
+
this.onClose = onClose;
|
|
646
|
+
}
|
|
647
|
+
async loadAll() {
|
|
648
|
+
this.assertOpen();
|
|
649
|
+
const records = Object.create(null);
|
|
650
|
+
for (const { id, record } of await this.repository.listWorkspaces()) records[id] = record;
|
|
651
|
+
const state = await this.repository.readWorkspaceState();
|
|
652
|
+
return {
|
|
653
|
+
tables: { [WORKSPACE_TABLE]: records },
|
|
654
|
+
global: state
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
async putRecord(table, key, value) {
|
|
658
|
+
this.assertOpen();
|
|
659
|
+
this.assertTable(table);
|
|
660
|
+
await this.repository.putWorkspace(key, workspaceRecordOf(value));
|
|
661
|
+
}
|
|
662
|
+
async deleteRecord(table, key) {
|
|
663
|
+
this.assertOpen();
|
|
664
|
+
this.assertTable(table);
|
|
665
|
+
await this.repository.deleteWorkspace(key);
|
|
666
|
+
}
|
|
667
|
+
async setGlobal(value) {
|
|
668
|
+
this.assertOpen();
|
|
669
|
+
await this.repository.writeWorkspaceState(workspaceStateOf(value));
|
|
670
|
+
}
|
|
671
|
+
async close() {
|
|
672
|
+
if (this.closed) return;
|
|
673
|
+
this.closed = true;
|
|
674
|
+
this.onClose();
|
|
675
|
+
}
|
|
676
|
+
assertOpen() {
|
|
677
|
+
if (this.closed) throw new StorageError("closed", `kv unit '${this.descriptor.name}' is closed`);
|
|
678
|
+
}
|
|
679
|
+
assertTable(table) {
|
|
680
|
+
if (table !== WORKSPACE_TABLE) throw new Error(`unit '${this.descriptor.name}' does not declare table '${table}'`);
|
|
681
|
+
}
|
|
682
|
+
};
|
|
683
|
+
/** Narrow one opaque KV record to the workspace record the domain shipped. */
|
|
684
|
+
function workspaceRecordOf(value) {
|
|
685
|
+
const record = value;
|
|
686
|
+
if (typeof record !== "object" || record === null || typeof record.path !== "string" || typeof record.title !== "string" || !Array.isArray(record.sessionIds) || typeof record.createdAt !== "string" || typeof record.updatedAt !== "string") throw new TypeError("workspace record does not match the stored shape");
|
|
687
|
+
return {
|
|
688
|
+
path: record.path,
|
|
689
|
+
title: record.title,
|
|
690
|
+
sessionIds: record.sessionIds.map((id) => id),
|
|
691
|
+
createdAt: record.createdAt,
|
|
692
|
+
updatedAt: record.updatedAt
|
|
693
|
+
};
|
|
694
|
+
}
|
|
695
|
+
/** Narrow one opaque KV global to the workspace registry state the domain shipped. */
|
|
696
|
+
function workspaceStateOf(value) {
|
|
697
|
+
const state = value;
|
|
698
|
+
if (typeof state !== "object" || state === null || typeof state.initialized !== "boolean" || !Array.isArray(state.workspaceIds) || !Array.isArray(state.archivedSessionIds)) throw new TypeError("workspace registry state does not match the stored shape");
|
|
699
|
+
return {
|
|
700
|
+
initialized: state.initialized,
|
|
701
|
+
workspaceIds: state.workspaceIds.map((id) => id),
|
|
702
|
+
archivedSessionIds: state.archivedSessionIds.map((id) => id),
|
|
703
|
+
...state.pendingMutation === void 0 ? {} : { pendingMutation: state.pendingMutation }
|
|
704
|
+
};
|
|
705
|
+
}
|
|
706
|
+
//#endregion
|
|
707
|
+
//#region src/storage-takeover/index.ts
|
|
708
|
+
/**
|
|
709
|
+
* Install the storages takeover on the session-rdb plugin context.
|
|
710
|
+
* @param ctx - session-rdb plugin context.
|
|
711
|
+
* @param options - repository and projection-cache throttle parameters.
|
|
712
|
+
*/
|
|
713
|
+
function installStorageTakeover(ctx, options) {
|
|
714
|
+
ctx.inject(["storage"], (storageCtx) => {
|
|
715
|
+
const backend = new RdbStorageBackend(options.repository);
|
|
716
|
+
storageCtx.effect(() => {
|
|
717
|
+
const unregister = storageCtx.storage.backend.register("rdb", backend);
|
|
718
|
+
return async () => {
|
|
719
|
+
unregister();
|
|
720
|
+
await backend.close();
|
|
721
|
+
};
|
|
722
|
+
}, "session-rdb.storageBackend");
|
|
723
|
+
storageCtx.provide(storageBackendServiceKey("rdb"), backend);
|
|
724
|
+
});
|
|
725
|
+
ctx.inject(["sessionProjections"], (projectionCtx) => {
|
|
726
|
+
new SessionProjectionCacheRdb(projectionCtx, options.projectionCache, options.repository, options.ready);
|
|
727
|
+
});
|
|
728
|
+
}
|
|
729
|
+
//#endregion
|
|
253
730
|
//#region src/index.ts
|
|
731
|
+
/** 投影缓存默认写节流(与上游 base 装配的部署值一致)。 */
|
|
732
|
+
const DEFAULT_PROJECTION_WRITE_EVERY_EVENTS = 200;
|
|
733
|
+
const DEFAULT_PROJECTION_WRITE_INTERVAL_MS = 5e3;
|
|
254
734
|
/** 会话级写所有权与 live 路由簿记。 */
|
|
255
735
|
var RdbBackendTracker = class {
|
|
256
736
|
name;
|
|
@@ -519,11 +999,25 @@ var SessionPersistenceRdb = class SessionPersistenceRdb extends SessionPersisten
|
|
|
519
999
|
"truncate",
|
|
520
1000
|
"persist"
|
|
521
1001
|
]).default("wal"),
|
|
522
|
-
busyTimeout: z.number().step(1).min(0).default(DEFAULT_BUSY_TIMEOUT_MS)
|
|
1002
|
+
busyTimeout: z.number().step(1).min(0).default(DEFAULT_BUSY_TIMEOUT_MS),
|
|
1003
|
+
projectionCache: z.object({
|
|
1004
|
+
writeEveryEvents: z.natural().min(1).default(DEFAULT_PROJECTION_WRITE_EVERY_EVENTS),
|
|
1005
|
+
writeIntervalMs: z.natural().min(1).default(DEFAULT_PROJECTION_WRITE_INTERVAL_MS)
|
|
1006
|
+
}).default({
|
|
1007
|
+
writeEveryEvents: DEFAULT_PROJECTION_WRITE_EVERY_EVENTS,
|
|
1008
|
+
writeIntervalMs: DEFAULT_PROJECTION_WRITE_INTERVAL_MS
|
|
1009
|
+
})
|
|
523
1010
|
}), z.object({
|
|
524
1011
|
type: z.const("postgres"),
|
|
525
1012
|
connectionString: z.string().required(),
|
|
526
|
-
schema: z.string().default("public")
|
|
1013
|
+
schema: z.string().default("public"),
|
|
1014
|
+
projectionCache: z.object({
|
|
1015
|
+
writeEveryEvents: z.natural().min(1).default(DEFAULT_PROJECTION_WRITE_EVERY_EVENTS),
|
|
1016
|
+
writeIntervalMs: z.natural().min(1).default(DEFAULT_PROJECTION_WRITE_INTERVAL_MS)
|
|
1017
|
+
}).default({
|
|
1018
|
+
writeEveryEvents: DEFAULT_PROJECTION_WRITE_EVERY_EVENTS,
|
|
1019
|
+
writeIntervalMs: DEFAULT_PROJECTION_WRITE_INTERVAL_MS
|
|
1020
|
+
})
|
|
527
1021
|
})]);
|
|
528
1022
|
static settingsNs = "session-rdb";
|
|
529
1023
|
name = "session-rdb";
|
|
@@ -554,6 +1048,14 @@ var SessionPersistenceRdb = class SessionPersistenceRdb extends SessionPersisten
|
|
|
554
1048
|
this.installLiveRouting(ctx);
|
|
555
1049
|
new SessionBranchRdb(this.ctx);
|
|
556
1050
|
registerSessionImport(this.ctx, this);
|
|
1051
|
+
installStorageTakeover(this.ctx, {
|
|
1052
|
+
repository: this.backend.storage,
|
|
1053
|
+
ready: this.ready,
|
|
1054
|
+
projectionCache: {
|
|
1055
|
+
writeEveryEvents: this.config.projectionCache?.writeEveryEvents ?? DEFAULT_PROJECTION_WRITE_EVERY_EVENTS,
|
|
1056
|
+
writeIntervalMs: this.config.projectionCache?.writeIntervalMs ?? DEFAULT_PROJECTION_WRITE_INTERVAL_MS
|
|
1057
|
+
}
|
|
1058
|
+
});
|
|
557
1059
|
}
|
|
558
1060
|
async init() {
|
|
559
1061
|
await this.backend.open();
|
|
@@ -1064,6 +1566,7 @@ async function appendEventTail(tx, meta, events, anchor, reuse) {
|
|
|
1064
1566
|
}
|
|
1065
1567
|
if (eventRows.length > 0) await tx.insertEvents(eventRows);
|
|
1066
1568
|
await tx.insertBridges(bridgeRows);
|
|
1569
|
+
if (events.some((event) => event.type === "session/title")) await tx.refreshTitle(meta.id);
|
|
1067
1570
|
return {
|
|
1068
1571
|
headEventId: parentId,
|
|
1069
1572
|
headSequence: nextSeq - 1
|
|
@@ -513,5 +513,20 @@ function toJsonlArtifact(meta, inheritedEventCount, events) {
|
|
|
513
513
|
for (const event of events) lines.push(JSON.stringify(sessionFormatCatalog.encodeCurrentEvent(event)));
|
|
514
514
|
return lines.join("\n");
|
|
515
515
|
}
|
|
516
|
+
/**
|
|
517
|
+
* 事件行 JSON → `session/title` 的标题文本。存储层写路径保证 `f_data` 是完整
|
|
518
|
+
* 事件 JSON;形状不符(或损坏)按「无标题」处理——标题是派生显示数据。
|
|
519
|
+
* @param data - `t_events.f_data` 原文。
|
|
520
|
+
* @returns 标题文本,或 `undefined`。
|
|
521
|
+
*/
|
|
522
|
+
function titleOfEventData(data) {
|
|
523
|
+
try {
|
|
524
|
+
const value = JSON.parse(data);
|
|
525
|
+
const title = value.data?.title ?? value.title;
|
|
526
|
+
return typeof title === "string" ? title : void 0;
|
|
527
|
+
} catch {
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
}
|
|
516
531
|
//#endregion
|
|
517
|
-
export { repairAssistantSettlement as a, repairRequestHeaders as c, rowToMeta as d, scanRows as f,
|
|
532
|
+
export { toJsonlArtifact as _, repairAssistantSettlement as a, repairRequestHeaders as c, rowToMeta as d, scanRows as f, titleOfEventData as g, syncMeteringRanges as h, renameLegacyPtcEvents as i, repairSurfaceOps as l, sessionInsertRow as m, orphanInboxSpliceSeqs as n, repairOrphanInboxSplices as o, sessionConflictRow as p, recomputeReplaceProvenance as r, repairReadView as s, findSurfaceRepairs as t, rowToEvent as u };
|