@morlay/session-rdb 0.1.10-alpha.0 → 0.1.10-alpha.2
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 +18 -3
- package/dist/artifact.d.mts +118 -2
- package/dist/artifact.mjs +5 -3
- package/dist/{log-TYgd8Xkr.d.mts → backend-BKtJ5sO5.d.mts} +183 -284
- package/dist/backend-rdb-BDCntVDy.mjs +1341 -0
- package/dist/branch-CG1PUlO6.mjs +833 -0
- package/dist/deletion-SSX7IqU8.mjs +35 -0
- package/dist/deletion.d.mts +1 -1
- package/dist/deletion.mjs +2 -2
- package/dist/export-CfJQjQfr.mjs +123 -0
- package/dist/export.d.mts +1 -1
- package/dist/export.mjs +31 -55
- package/dist/import.d.mts +1 -1
- package/dist/import.mjs +31 -76
- package/dist/index-CUD2ZpTA.d.mts +71 -0
- package/dist/index.d.mts +20 -2
- package/dist/index.mjs +1561 -4
- package/dist/open-YoyD_kah.mjs +561 -0
- package/dist/organize.d.mts +9 -0
- package/dist/organize.mjs +30 -0
- package/dist/route-DpfY0zq-.mjs +114 -0
- package/dist/storage.d.mts +41 -48
- package/dist/storage.mjs +3 -1
- package/dist/testing.d.mts +9 -1
- package/dist/testing.mjs +25 -4
- package/dist/usage-row-BQQM-_t6.mjs +36 -0
- package/dist/usage.d.mts +1 -1
- package/dist/usage.mjs +7 -40
- package/dist/write-guard-CYrY58GM.mjs +174 -0
- package/package.json +10 -14
- package/src/artifact.ts +4 -1
- package/src/backend-rdb.ts +331 -0
- package/src/backend.ts +32 -15
- package/src/{branch.ts → branch/index.ts} +109 -110
- package/src/branch/port.ts +62 -0
- package/src/branch/semantics.ts +69 -0
- package/src/db/open.ts +180 -0
- package/src/db/stats.ts +449 -0
- package/src/deletion-error.ts +17 -0
- package/src/deletion.ts +19 -72
- package/src/entities/index.ts +16 -1
- package/src/entities/v3/event-usage.ts +3 -3
- package/src/entities/v3/events.ts +3 -3
- package/src/entities/v3/index.ts +4 -16
- package/src/entities/v3/persistence-state.ts +3 -3
- package/src/entities/v3/schema-meta.ts +3 -3
- package/src/entities/v3/session-counts.ts +3 -3
- package/src/entities/v3/session-events.ts +3 -3
- package/src/entities/v3/session-projcache-rows.ts +3 -3
- package/src/entities/v3/session-usage.ts +3 -3
- package/src/entities/v3/sessions.ts +3 -3
- package/src/entities/v3/storage-records.ts +17 -0
- package/src/entities/v3/storage-units.ts +3 -3
- package/src/{schema.ts → event-shape.ts} +0 -46
- package/src/export.ts +32 -78
- package/src/handles.ts +349 -0
- package/src/http/route.ts +178 -0
- package/src/import-storages.ts +5 -2
- package/src/import.ts +63 -125
- package/src/index.ts +15 -1666
- package/src/legacy.ts +3 -1
- package/src/live-routing.ts +205 -0
- package/src/log/export.ts +84 -0
- package/src/{log.ts → log/repair.ts} +1 -268
- package/src/log/rows.ts +107 -0
- package/src/log/usage-row.ts +64 -0
- package/src/migrations.ts +196 -0
- package/src/organize.ts +38 -0
- package/src/read-path.ts +190 -0
- package/src/routes.ts +2 -1
- package/src/rows.ts +25 -77
- package/src/session-ops.ts +444 -0
- package/src/session-store.ts +675 -0
- package/src/stats-fold.ts +44 -0
- package/src/storage-takeover/index.ts +2 -5
- package/src/storage-takeover/legacy-domain-migration.ts +118 -0
- package/src/storage-takeover/projection-cache.ts +122 -71
- package/src/storage-takeover/read-model.ts +189 -0
- package/src/storage-takeover/repository.ts +236 -442
- package/src/storage-takeover/storage-backend.ts +44 -159
- package/src/storage-takeover/types.ts +45 -19
- package/src/storage-takeover/workspace-documents.ts +65 -0
- package/src/storage.ts +4 -2
- package/src/testing.ts +21 -1
- package/src/usage.ts +11 -65
- package/src/version.ts +13 -0
- package/src/write-path.ts +286 -0
- package/dist/branch-CoPCRKer.mjs +0 -401
- package/dist/deletion-CWl89TIR.mjs +0 -2249
- package/dist/gc.d.mts +0 -4
- package/dist/gc.mjs +0 -48
- package/dist/log-CcWy46VE.mjs +0 -558
- package/dist/routes-hZjTcX_R.mjs +0 -9
- package/dist/sqlite-BNRd1aqR.mjs +0 -1778
- package/drizzle/postgres/20260908072805_v2_initial/migration.sql +0 -58
- package/drizzle/postgres/20260908072805_v2_initial/snapshot.json +0 -686
- package/drizzle/postgres/20260908072806_v3_drop_original_seq/migration.sql +0 -1
- package/drizzle/postgres/20260908072806_v3_drop_original_seq/snapshot.json +0 -673
- package/drizzle/postgres/20260910120001_v3_storage_tables/migration.sql +0 -65
- package/drizzle/postgres/20260910120001_v3_storage_tables/snapshot.json +0 -1183
- package/drizzle/postgres/20260910130001_v3_session_title_backfill/migration.sql +0 -10
- package/drizzle/postgres/20260910130001_v3_session_title_backfill/snapshot.json +0 -1183
- package/drizzle/postgres/20260918120000_v3_event_usage/migration.sql +0 -14
- package/drizzle/postgres/20260918120000_v3_event_usage/snapshot.json +0 -1309
- package/drizzle/postgres/20260921075900_v3_agent_preset/migration.sql +0 -1
- package/drizzle/postgres/20260922090001_v3_pinned_seq/migration.sql +0 -2
- package/drizzle/postgres/20260922093001_v3_last_event_at/migration.sql +0 -10
- package/drizzle/postgres/20260923120000_v3_usage_materialized/migration.sql +0 -48
- package/drizzle/postgres/20261008103001_v3_schedule_tasks/migration.sql +0 -11
- package/drizzle/sqlite/20260908072751_v2_initial/migration.sql +0 -53
- package/drizzle/sqlite/20260908072751_v2_initial/snapshot.json +0 -492
- package/drizzle/sqlite/20260908072752_v3_drop_original_seq/migration.sql +0 -6
- package/drizzle/sqlite/20260908072752_v3_drop_original_seq/snapshot.json +0 -513
- package/drizzle/sqlite/20260910120000_v3_storage_tables/migration.sql +0 -66
- package/drizzle/sqlite/20260910120000_v3_storage_tables/snapshot.json +0 -905
- package/drizzle/sqlite/20260910130000_v3_session_title_backfill/migration.sql +0 -13
- package/drizzle/sqlite/20260910130000_v3_session_title_backfill/snapshot.json +0 -905
- package/drizzle/sqlite/20260918120000_v3_event_usage/migration.sql +0 -14
- package/drizzle/sqlite/20260918120000_v3_event_usage/snapshot.json +0 -1031
- package/drizzle/sqlite/20260921075855_v3_agent_preset/migration.sql +0 -7
- package/drizzle/sqlite/20260921075855_v3_agent_preset/snapshot.json +0 -1041
- package/drizzle/sqlite/20260922090000_v3_pinned_seq/migration.sql +0 -4
- package/drizzle/sqlite/20260922093000_v3_last_event_at/migration.sql +0 -13
- package/drizzle/sqlite/20260923120000_v3_usage_materialized/migration.sql +0 -48
- package/drizzle/sqlite/20261008103000_v3_schedule_tasks/migration.sql +0 -14
- package/src/adapters/index.ts +0 -12
- package/src/adapters/to-postgres.ts +0 -93
- package/src/adapters/to-sqlite.ts +0 -83
- package/src/adapters/types.ts +0 -44
- package/src/drizzle/postgres-v2.ts +0 -10
- package/src/drizzle/postgres-v3.ts +0 -18
- package/src/drizzle/sqlite-v2.ts +0 -9
- package/src/drizzle/sqlite-v3.ts +0 -18
- package/src/entities/v2/index.ts +0 -20
- package/src/entities/v2/session-events.ts +0 -10
- package/src/entities/v3/schedule-tasks.ts +0 -16
- package/src/entities/v3/workspace-sessions.ts +0 -19
- package/src/entities/v3/workspace-state.ts +0 -12
- package/src/entities/v3/workspaces.ts +0 -14
- package/src/gc.ts +0 -77
- package/src/postgres.ts +0 -1015
- package/src/sqlite.ts +0 -1096
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { SessionEvent } from "@deepseek-ai/dsh-session";
|
|
2
|
+
import type { SessionCountBucket, SessionUsageBucket } from "./backend.ts";
|
|
3
|
+
import type { EventUsageRow } from "./log/usage-row.ts";
|
|
4
|
+
import { COUNTED_EVENT_TYPES, addActivityCount, localDayKey } from "./usage.ts";
|
|
5
|
+
|
|
6
|
+
// 一批事件折成活动计数的桶:只数四种类型,按本地日聚合成四项。
|
|
7
|
+
export function sessionCountBuckets(events: readonly SessionEvent[]): SessionCountBucket[] {
|
|
8
|
+
const counted = new Map<string, SessionCountBucket>();
|
|
9
|
+
for (const event of events) {
|
|
10
|
+
if (!(COUNTED_EVENT_TYPES as readonly string[]).includes(event.type)) continue;
|
|
11
|
+
const day = localDayKey(event.time);
|
|
12
|
+
const bucket = counted.get(day) ?? { day, turns: 0, steps: 0, userInputs: 0, toolCalls: 0 };
|
|
13
|
+
addActivityCount(bucket, event.type, 1);
|
|
14
|
+
counted.set(day, bucket);
|
|
15
|
+
}
|
|
16
|
+
return [...counted.values()];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// 一批用量行折成 token 桶:按「本地日 × 模型」聚合(模型未知落空串,与表口径一致)。
|
|
20
|
+
export function sessionUsageBuckets(usageRows: readonly EventUsageRow[]): SessionUsageBucket[] {
|
|
21
|
+
const counted = new Map<string, SessionUsageBucket>();
|
|
22
|
+
for (const row of usageRows) {
|
|
23
|
+
const provider = row.fProvider ?? "";
|
|
24
|
+
const model = row.fModel ?? "";
|
|
25
|
+
const key = `${row.fDay}#${provider}#${model}`;
|
|
26
|
+
const bucket = counted.get(key) ?? {
|
|
27
|
+
day: row.fDay,
|
|
28
|
+
provider,
|
|
29
|
+
model,
|
|
30
|
+
inputTokens: 0,
|
|
31
|
+
outputTokens: 0,
|
|
32
|
+
cacheReadTokens: 0,
|
|
33
|
+
reasoningTokens: 0,
|
|
34
|
+
totalTokens: 0,
|
|
35
|
+
};
|
|
36
|
+
bucket.inputTokens += row.fInputTokens;
|
|
37
|
+
bucket.outputTokens += row.fOutputTokens;
|
|
38
|
+
bucket.cacheReadTokens += row.fCacheReadTokens;
|
|
39
|
+
bucket.reasoningTokens += row.fReasoningTokens;
|
|
40
|
+
bucket.totalTokens += row.fTotalTokens;
|
|
41
|
+
counted.set(key, bucket);
|
|
42
|
+
}
|
|
43
|
+
return [...counted.values()];
|
|
44
|
+
}
|
|
@@ -1,14 +1,12 @@
|
|
|
1
1
|
import type { Context } from "@deepseek-ai/cordis";
|
|
2
2
|
import { storageBackendServiceKey } from "@deepseek-ai/dsh-storage";
|
|
3
|
-
import type { ProjectionCacheConfig } from "./projection-cache.ts";
|
|
4
3
|
import { SessionProjectionCacheRdb } from "./projection-cache.ts";
|
|
5
4
|
import { RDB_STORAGE_BACKEND, RdbStorageBackend } from "./storage-backend.ts";
|
|
6
5
|
import type { StorageRepository } from "./types.ts";
|
|
7
6
|
|
|
8
7
|
export interface StorageTakeoverOptions {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
projectionCache: ProjectionCacheConfig;
|
|
8
|
+
/** backend 打开后才有值:接管在构造期完成,真正的读写在打开后发生。 */
|
|
9
|
+
repository: () => Promise<StorageRepository>;
|
|
12
10
|
|
|
13
11
|
ready: Promise<unknown>;
|
|
14
12
|
}
|
|
@@ -27,7 +25,6 @@ export function installStorageTakeover(ctx: Context, options: StorageTakeoverOpt
|
|
|
27
25
|
});
|
|
28
26
|
|
|
29
27
|
ctx.plugin(SessionProjectionCacheRdb, {
|
|
30
|
-
...options.projectionCache,
|
|
31
28
|
repository: options.repository,
|
|
32
29
|
ready: options.ready,
|
|
33
30
|
});
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// 一次性搬运:旧版把 workspace / schedule / cua 授权域拆成各自的语义表,现在所有域只落
|
|
2
|
+
// `t_storage_records`。旧表保留不删(可回退),这里把数据读成上游的文档形状写进新表。
|
|
3
|
+
//
|
|
4
|
+
// 幂等由调用方用 `t_schema_meta` 的标记控制:搬过一次就不再搬,否则用户清空工作区后重启会把旧数据搬回来。
|
|
5
|
+
import type { SessionId } from "@deepseek-ai/dsh-session";
|
|
6
|
+
import type { WorkspaceId } from "@deepseek-ai/dsh-workspace";
|
|
7
|
+
import type { StorageRepository } from "./types.ts";
|
|
8
|
+
|
|
9
|
+
export interface LegacyWorkspaceRow {
|
|
10
|
+
id: string;
|
|
11
|
+
path: string;
|
|
12
|
+
title: string;
|
|
13
|
+
createdAt: string;
|
|
14
|
+
updatedAt: string;
|
|
15
|
+
position: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface LegacyLinkRow {
|
|
19
|
+
workspaceId: string;
|
|
20
|
+
sessionId: string;
|
|
21
|
+
position: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface LegacyWorkspaceStateRow {
|
|
25
|
+
initialized: number | boolean;
|
|
26
|
+
pendingOperation: string | null;
|
|
27
|
+
pendingWorkspaceId: string | null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface LegacyScheduleRow {
|
|
31
|
+
id: string;
|
|
32
|
+
sessionId: string;
|
|
33
|
+
status: string;
|
|
34
|
+
record: string;
|
|
35
|
+
lastDelivery: string | null;
|
|
36
|
+
deliveryHistory: string | null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface LegacyGlobalRow {
|
|
40
|
+
domain: string;
|
|
41
|
+
value: unknown;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface LegacyStorageRows {
|
|
45
|
+
workspaces: LegacyWorkspaceRow[];
|
|
46
|
+
links: LegacyLinkRow[];
|
|
47
|
+
state: LegacyWorkspaceStateRow | undefined;
|
|
48
|
+
archived: string[];
|
|
49
|
+
pinned: Array<{ id: string; seq: number }>;
|
|
50
|
+
tasks: LegacyScheduleRow[];
|
|
51
|
+
globals: LegacyGlobalRow[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const LEGACY_STORAGE_MIGRATION_KEY = "legacy_domain_storage_migrated";
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 把旧语义表的内容写进通用域存储(`workspace` / `schedule` / 上一版 `t_storage_globals` 的域)。
|
|
58
|
+
* @param rows - 旧表读出来的行。
|
|
59
|
+
* @param repository - 通用域存储。
|
|
60
|
+
*/
|
|
61
|
+
export async function migrateLegacyDomainStorage(
|
|
62
|
+
rows: LegacyStorageRows,
|
|
63
|
+
repository: StorageRepository,
|
|
64
|
+
): Promise<void> {
|
|
65
|
+
if (rows.workspaces.length > 0 || rows.state !== undefined) {
|
|
66
|
+
for (const row of rows.workspaces) {
|
|
67
|
+
const sessionIds = rows.links
|
|
68
|
+
.filter((link) => link.workspaceId === row.id)
|
|
69
|
+
.sort((left, right) => left.position - right.position)
|
|
70
|
+
.map((link) => link.sessionId as SessionId);
|
|
71
|
+
await repository.putDomainRecord("workspace", "workspaces", row.id, {
|
|
72
|
+
path: row.path,
|
|
73
|
+
title: row.title,
|
|
74
|
+
sessionIds,
|
|
75
|
+
createdAt: row.createdAt,
|
|
76
|
+
updatedAt: row.updatedAt,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
const pending = rows.state?.pendingOperation;
|
|
80
|
+
const pendingMutation =
|
|
81
|
+
pending === "create" || pending === "delete"
|
|
82
|
+
? {
|
|
83
|
+
operation: pending,
|
|
84
|
+
workspaceId: (rows.state?.pendingWorkspaceId ?? "") as WorkspaceId,
|
|
85
|
+
}
|
|
86
|
+
: undefined;
|
|
87
|
+
await repository.writeDomainGlobal("workspace", {
|
|
88
|
+
initialized: rows.state === undefined ? true : Boolean(rows.state.initialized),
|
|
89
|
+
workspaceIds: rows.workspaces
|
|
90
|
+
.filter((row) => row.position >= 0)
|
|
91
|
+
.sort((left, right) => left.position - right.position)
|
|
92
|
+
.map((row) => row.id as WorkspaceId),
|
|
93
|
+
archivedSessionIds: rows.archived.map((id) => id as SessionId),
|
|
94
|
+
pinnedSessionIds: rows.pinned
|
|
95
|
+
.sort((left, right) => left.seq - right.seq)
|
|
96
|
+
.map((row) => row.id as SessionId),
|
|
97
|
+
...(pendingMutation === undefined ? {} : { pendingMutation }),
|
|
98
|
+
});
|
|
99
|
+
await repository.insertUnitVersion("workspace", 2);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
for (const row of rows.tasks) {
|
|
103
|
+
await repository.putDomainRecord("schedule", "tasks", row.id, {
|
|
104
|
+
sessionId: row.sessionId as SessionId,
|
|
105
|
+
record: JSON.parse(row.record),
|
|
106
|
+
status: row.status === "inactive" ? "inactive" : "active",
|
|
107
|
+
...(row.lastDelivery === null ? {} : { lastDelivery: JSON.parse(row.lastDelivery) }),
|
|
108
|
+
...(row.deliveryHistory === null ? {} : { deliveryHistory: JSON.parse(row.deliveryHistory) }),
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
if (rows.tasks.length > 0) await repository.insertUnitVersion("schedule", 1);
|
|
112
|
+
|
|
113
|
+
// 上一版只有一个域一张的 `t_storage_globals`(cua 授权域),搬进同一张通用表。
|
|
114
|
+
for (const row of rows.globals) {
|
|
115
|
+
await repository.writeDomainGlobal(row.domain, row.value);
|
|
116
|
+
await repository.insertUnitVersion(row.domain, 1);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -46,46 +46,37 @@ type LifecycleIdentity = CheckpointIdentity & {
|
|
|
46
46
|
isSeeded: boolean;
|
|
47
47
|
};
|
|
48
48
|
|
|
49
|
-
|
|
50
|
-
writeEveryEvents: number;
|
|
49
|
+
// 没有攒批参数:投影检查点按「与已落库那行相比是否有变化」决定写不写,与事件条数 / 时间无关。
|
|
51
50
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
export interface SessionProjectionCacheRdbOptions extends ProjectionCacheConfig {
|
|
56
|
-
repository: StorageRepository;
|
|
51
|
+
export interface SessionProjectionCacheRdbOptions {
|
|
52
|
+
/** backend 打开后才有值(构造期装配、打开后读写)。 */
|
|
53
|
+
repository: () => Promise<StorageRepository>;
|
|
57
54
|
ready: Promise<unknown>;
|
|
58
55
|
}
|
|
59
56
|
|
|
60
|
-
interface DirtyState {
|
|
61
|
-
pending: number;
|
|
62
|
-
timer: ReturnType<typeof setTimeout> | undefined;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
57
|
const PREDECESSOR_TITLE_KEY = "title" as Extract<keyof SessionProjectionMap, string>;
|
|
66
58
|
|
|
67
59
|
export class SessionProjectionCacheRdb extends Service {
|
|
68
60
|
static inject = ["sessionProjections", "sessions"];
|
|
69
61
|
|
|
70
62
|
private readonly records = new Map<SessionId, StoredProjcacheEntry>();
|
|
71
|
-
|
|
63
|
+
// 同一 tick 内的事件合并成一次「落库 + 检查点比对」,这不是攒批:flush 之后事件已经在库里。
|
|
64
|
+
private readonly queued = new Set<SessionId>();
|
|
72
65
|
|
|
73
|
-
private
|
|
74
|
-
private readonly config: ProjectionCacheConfig;
|
|
75
|
-
private readonly repository: StorageRepository;
|
|
66
|
+
private repository!: StorageRepository;
|
|
76
67
|
private readonly ready: Promise<unknown>;
|
|
68
|
+
private readonly openRepository: () => Promise<StorageRepository>;
|
|
77
69
|
|
|
78
70
|
constructor(ctx: Context, options: SessionProjectionCacheRdbOptions) {
|
|
79
71
|
super(ctx, SESSION_PROJECTION_CACHE_SERVICE);
|
|
80
|
-
this.
|
|
81
|
-
this.repository = options.repository;
|
|
72
|
+
this.openRepository = options.repository;
|
|
82
73
|
this.ready = options.ready;
|
|
83
|
-
this.directReads = options.repository.readProjcacheDirect !== undefined;
|
|
84
74
|
}
|
|
85
75
|
|
|
86
76
|
protected async [Service.init](): Promise<void> {
|
|
87
77
|
this.installWritePath();
|
|
88
78
|
await this.ready;
|
|
79
|
+
this.repository = await this.openRepository();
|
|
89
80
|
|
|
90
81
|
const pruned = await this.repository.pruneStaleProjcache();
|
|
91
82
|
if (pruned > 0) {
|
|
@@ -93,15 +84,12 @@ export class SessionProjectionCacheRdb extends Service {
|
|
|
93
84
|
`session projection cache: pruned stale snapshot(s) for ${String(pruned)} session(s)`,
|
|
94
85
|
);
|
|
95
86
|
}
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
this.records.set(entry.sessionId as SessionId, entry);
|
|
99
|
-
}
|
|
87
|
+
for (const entry of await this.repository.loadProjcache()) {
|
|
88
|
+
this.records.set(entry.sessionId as SessionId, entry);
|
|
100
89
|
}
|
|
101
90
|
}
|
|
102
91
|
|
|
103
92
|
private lookup(id: SessionId): StoredProjcacheEntry | undefined {
|
|
104
|
-
if (this.directReads) return this.repository.readProjcacheDirect?.(id);
|
|
105
93
|
return this.records.get(id);
|
|
106
94
|
}
|
|
107
95
|
|
|
@@ -139,12 +127,11 @@ export class SessionProjectionCacheRdb extends Service {
|
|
|
139
127
|
): ProjectionSnapshot | undefined {
|
|
140
128
|
if (!selects(keys, PREDECESSOR_TITLE_KEY)) return snapshot;
|
|
141
129
|
if (snapshot?.values[PREDECESSOR_TITLE_KEY] !== undefined) return snapshot;
|
|
142
|
-
const
|
|
143
|
-
if (
|
|
144
|
-
const values = { ...snapshot?.values, [PREDECESSOR_TITLE_KEY]:
|
|
145
|
-
|
|
130
|
+
const title = this.records.get(id)?.title;
|
|
131
|
+
if (title === undefined) return snapshot;
|
|
132
|
+
const values = { ...snapshot?.values, [PREDECESSOR_TITLE_KEY]: title.title };
|
|
146
133
|
const asOfSeq =
|
|
147
|
-
snapshot === undefined ?
|
|
134
|
+
snapshot === undefined ? title.seq : Math.min(snapshot.asOfSeq as number, title.seq);
|
|
148
135
|
return { asOfSeq: asOfSeq as SessionSeqCursor, values };
|
|
149
136
|
}
|
|
150
137
|
|
|
@@ -200,17 +187,63 @@ export class SessionProjectionCacheRdb extends Service {
|
|
|
200
187
|
}
|
|
201
188
|
|
|
202
189
|
async write(session: Session): Promise<void> {
|
|
203
|
-
|
|
204
|
-
|
|
190
|
+
// 先比差异,**有变化才碰 SQLite**:没变化直接返回,既不写检查点、也不推上游 flush
|
|
191
|
+
//(事件落库的主节奏是上游 handle 的批量窗口,逐条 flush 会把写放大到每秒数十次——
|
|
192
|
+
// 见 [设计 写路径](../designs/20260917-写路径.md)「否掉的选项」)。
|
|
193
|
+
const rows = this.ctx.sessionProjections.checkpoint(session) as unknown as Record<
|
|
194
|
+
string,
|
|
195
|
+
ProjectionCheckpointRow
|
|
196
|
+
>;
|
|
197
|
+
const previous = this.lookup(session.id)?.rows;
|
|
198
|
+
if (previous !== undefined && sameCheckpoint(previous, rows)) return;
|
|
199
|
+
|
|
200
|
+
if (this.ctx.sessions.get(session.id) === session) {
|
|
201
|
+
// 检查点是内存 log 的快照:先把 live 事件落库再写检查点,但 **flush 失败不该阻断检查点**——
|
|
202
|
+
// 否则撤回后缓存会停在旧水位,读路径一直显示撤回前的数据(要重启实例才从库里重新加载)。
|
|
203
|
+
// flush 失败本身由上游记账,这里只补一条 warn。
|
|
204
|
+
try {
|
|
205
|
+
await this.ctx.sessions.flush(session);
|
|
206
|
+
} catch (error: unknown) {
|
|
207
|
+
this.ctx.logger.warn(
|
|
208
|
+
`session projection cache: flush before checkpoint for "${session.id}" failed (checkpoint still written from the in-memory log): ${error instanceof Error ? error.message : String(error)}`,
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
205
212
|
|
|
206
|
-
|
|
207
|
-
|
|
213
|
+
const changed = changedRows(previous, rows);
|
|
214
|
+
const removedKeys = Object.keys(previous ?? {}).filter((key) => rows[key] === undefined);
|
|
215
|
+
await this.patch(
|
|
208
216
|
session.id,
|
|
209
217
|
identityOf(session.header, session.inheritedEventCount),
|
|
210
|
-
|
|
218
|
+
changed,
|
|
219
|
+
removedKeys,
|
|
211
220
|
);
|
|
212
221
|
}
|
|
213
222
|
|
|
223
|
+
// 增量写回:只 upsert 变化的行、删掉消失的 key,其余行不碰(整份删重插是纯写放大)。
|
|
224
|
+
private async patch(
|
|
225
|
+
id: SessionId,
|
|
226
|
+
identity: CheckpointIdentity,
|
|
227
|
+
changed: Record<string, ProjectionCheckpointRow>,
|
|
228
|
+
removedKeys: readonly string[],
|
|
229
|
+
): Promise<void> {
|
|
230
|
+
const detachedChanged = detachJson(changed);
|
|
231
|
+
await this.repository.patchProjcache(id, detachedChanged, removedKeys);
|
|
232
|
+
|
|
233
|
+
const merged: Record<string, ProjectionCheckpointRow> = {};
|
|
234
|
+
const previous = this.records.get(id)?.rows;
|
|
235
|
+
if (previous !== undefined) Object.assign(merged, previous);
|
|
236
|
+
Object.assign(merged, detachedChanged);
|
|
237
|
+
for (const key of removedKeys) delete merged[key];
|
|
238
|
+
const title = await this.repository.readSessionTitle(id);
|
|
239
|
+
this.records.set(id, {
|
|
240
|
+
sessionId: id,
|
|
241
|
+
identity,
|
|
242
|
+
rows: merged,
|
|
243
|
+
...(title === undefined ? {} : { title }),
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
|
|
214
247
|
// rewind 后 head 回退:把水位超出新 head 的投影单元丢弃,保留仍在截断前缀内的行。
|
|
215
248
|
// 这是零 I/O 读的更新(不 fold 日志):下次零 I/O 读拿到的是截断后的水位,而不是旧值。
|
|
216
249
|
async truncateTo(
|
|
@@ -256,21 +289,8 @@ export class SessionProjectionCacheRdb extends Service {
|
|
|
256
289
|
}
|
|
257
290
|
|
|
258
291
|
private installWritePath(): void {
|
|
259
|
-
this.ctx.on("session/event", (session: Session
|
|
260
|
-
|
|
261
|
-
void this.flushSoft(session, "turn/end");
|
|
262
|
-
return;
|
|
263
|
-
}
|
|
264
|
-
const state = this.dirty.get(session) ?? { pending: 0, timer: undefined };
|
|
265
|
-
this.dirty.set(session, state);
|
|
266
|
-
state.pending += 1;
|
|
267
|
-
if (state.pending >= this.config.writeEveryEvents) {
|
|
268
|
-
void this.flushSoft(session, "count threshold");
|
|
269
|
-
return;
|
|
270
|
-
}
|
|
271
|
-
state.timer ??= setTimeout(() => {
|
|
272
|
-
void this.flushSoft(session, "interval");
|
|
273
|
-
}, this.config.writeIntervalMs);
|
|
292
|
+
this.ctx.on("session/event", (session: Session) => {
|
|
293
|
+
this.scheduleWrite(session, "event");
|
|
274
294
|
});
|
|
275
295
|
|
|
276
296
|
this.ctx.on("session/created", (session: Session) => {
|
|
@@ -279,19 +299,19 @@ export class SessionProjectionCacheRdb extends Service {
|
|
|
279
299
|
|
|
280
300
|
this.ctx.on("session/disposed", (session: Session) => {
|
|
281
301
|
void this.flushSoft(session, "detach");
|
|
282
|
-
this.
|
|
283
|
-
this.dirty.delete(session);
|
|
302
|
+
this.queued.delete(session.id);
|
|
284
303
|
});
|
|
304
|
+
}
|
|
285
305
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
);
|
|
306
|
+
// 同一 tick 的多条事件合成一次:flush 一次、checkpoint 取一次、diff 一次。
|
|
307
|
+
// 落库时机不再取决于事件条数或定时器——事件一到就写库,检查点则只在真的变了才写。
|
|
308
|
+
private scheduleWrite(session: Session, trigger: string): void {
|
|
309
|
+
if (this.queued.has(session.id)) return;
|
|
310
|
+
this.queued.add(session.id);
|
|
311
|
+
queueMicrotask(() => {
|
|
312
|
+
this.queued.delete(session.id);
|
|
313
|
+
void this.flushSoft(session, trigger);
|
|
314
|
+
});
|
|
295
315
|
}
|
|
296
316
|
|
|
297
317
|
private async flushSoft(session: Session, trigger: string): Promise<void> {
|
|
@@ -304,16 +324,6 @@ export class SessionProjectionCacheRdb extends Service {
|
|
|
304
324
|
}
|
|
305
325
|
}
|
|
306
326
|
|
|
307
|
-
private markClean(session: Session): void {
|
|
308
|
-
const state = this.dirty.get(session);
|
|
309
|
-
if (state === undefined) return;
|
|
310
|
-
state.pending = 0;
|
|
311
|
-
if (state.timer !== undefined) {
|
|
312
|
-
clearTimeout(state.timer);
|
|
313
|
-
state.timer = undefined;
|
|
314
|
-
}
|
|
315
|
-
}
|
|
316
|
-
|
|
317
327
|
private async put(
|
|
318
328
|
id: SessionId,
|
|
319
329
|
identity: CheckpointIdentity,
|
|
@@ -322,9 +332,50 @@ export class SessionProjectionCacheRdb extends Service {
|
|
|
322
332
|
const detached = detachJson(rows);
|
|
323
333
|
|
|
324
334
|
await this.repository.putProjcache(id, detached);
|
|
335
|
+
const title = await this.repository.readSessionTitle(id);
|
|
336
|
+
this.records.set(id, {
|
|
337
|
+
sessionId: id,
|
|
338
|
+
identity,
|
|
339
|
+
rows: detached,
|
|
340
|
+
...(title === undefined ? {} : { title }),
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// 「有没有变化」的判据:逐行比 `ver` 与 `val`,**不比 `seq`**。`seq` 是「最后折入的事件」水位,
|
|
346
|
+
// 上游投影对每个事件都会推进它(哪怕 apply 返回同一份 state),拿它当判据等于每个事件都写一次。
|
|
347
|
+
// 值没变就跳过写回:读侧仍会从旧水位重放那几条事件,得到同样的值——缓存要的是「值对不对」,
|
|
348
|
+
// 不是「水位最新」。`val` 是纯 JSON(写入前已 detach),逐键序列化比较即可。
|
|
349
|
+
function sameRow(before: ProjectionCheckpointRow, after: ProjectionCheckpointRow): boolean {
|
|
350
|
+
return before.ver === after.ver && JSON.stringify(before.val) === JSON.stringify(after.val);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// 只挑出真的变了的行:写库只碰这些行,其余行原样留在库里(不删不重插)。
|
|
354
|
+
function changedRows(
|
|
355
|
+
previous: Record<string, ProjectionCheckpointRow> | undefined,
|
|
356
|
+
next: Record<string, ProjectionCheckpointRow>,
|
|
357
|
+
): Record<string, ProjectionCheckpointRow> {
|
|
358
|
+
const changed: Record<string, ProjectionCheckpointRow> = {};
|
|
359
|
+
for (const [key, row] of Object.entries(next)) {
|
|
360
|
+
const before = previous?.[key];
|
|
361
|
+
if (before === undefined || !sameRow(before, row)) changed[key] = row;
|
|
362
|
+
}
|
|
363
|
+
return changed;
|
|
364
|
+
}
|
|
325
365
|
|
|
326
|
-
|
|
366
|
+
function sameCheckpoint(
|
|
367
|
+
previous: Record<string, ProjectionCheckpointRow>,
|
|
368
|
+
next: Record<string, ProjectionCheckpointRow>,
|
|
369
|
+
): boolean {
|
|
370
|
+
const keys = Object.keys(next);
|
|
371
|
+
if (keys.length !== Object.keys(previous).length) return false;
|
|
372
|
+
for (const key of keys) {
|
|
373
|
+
const before = previous[key];
|
|
374
|
+
const after = next[key];
|
|
375
|
+
if (before === undefined || after === undefined) return false;
|
|
376
|
+
if (!sameRow(before, after)) return false;
|
|
327
377
|
}
|
|
378
|
+
return true;
|
|
328
379
|
}
|
|
329
380
|
|
|
330
381
|
function detachJson(
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import type { Database } from "@morlay/rdbkit";
|
|
2
|
+
import {
|
|
3
|
+
and,
|
|
4
|
+
E,
|
|
5
|
+
eq,
|
|
6
|
+
gte,
|
|
7
|
+
isNull,
|
|
8
|
+
lt,
|
|
9
|
+
notInSubquery,
|
|
10
|
+
or,
|
|
11
|
+
type Condition,
|
|
12
|
+
type ExprOperand,
|
|
13
|
+
} from "@morlay/rdbkit";
|
|
14
|
+
import type { EventRow, ReadModel, SessionRow } from "../backend.ts";
|
|
15
|
+
import { events, sessionEvents, sessions } from "../entities/index.ts";
|
|
16
|
+
|
|
17
|
+
export type { ReadModel };
|
|
18
|
+
|
|
19
|
+
export function createReadModel(database: Database): ReadModel {
|
|
20
|
+
const sessionsTable = sessions.table(database);
|
|
21
|
+
const eventsTable = events.table(database);
|
|
22
|
+
const links = sessionEvents.table(database);
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
async getSession(id) {
|
|
26
|
+
for await (const row of sessionsTable.select().where(eq(sessionsTable.f_session_id, id))) {
|
|
27
|
+
return sessionRowOf(row);
|
|
28
|
+
}
|
|
29
|
+
return undefined;
|
|
30
|
+
},
|
|
31
|
+
|
|
32
|
+
async getEventRows(id, fromSequence) {
|
|
33
|
+
const rows: EventRow[] = [];
|
|
34
|
+
const condition =
|
|
35
|
+
fromSequence === undefined
|
|
36
|
+
? eq(links.f_session_id, id)
|
|
37
|
+
: and(eq(links.f_session_id, id), gte(links.f_sequence, fromSequence));
|
|
38
|
+
for await (const row of links
|
|
39
|
+
.select({
|
|
40
|
+
fEventId: links.f_event_id,
|
|
41
|
+
fSequence: links.f_sequence,
|
|
42
|
+
fType: eventsTable.f_type,
|
|
43
|
+
fKind: eventsTable.f_kind,
|
|
44
|
+
fRole: eventsTable.f_role,
|
|
45
|
+
fName: eventsTable.f_name,
|
|
46
|
+
fActionId: eventsTable.f_action_id,
|
|
47
|
+
fCreatedAt: eventsTable.f_created_at,
|
|
48
|
+
fData: eventsTable.f_data,
|
|
49
|
+
fSurfaceOp: links.f_surface_op,
|
|
50
|
+
})
|
|
51
|
+
.join(events, eq(eventsTable.f_event_id, links.f_event_id))
|
|
52
|
+
.where(condition)
|
|
53
|
+
.orderBy("f_sequence")) {
|
|
54
|
+
rows.push(eventRowOf(row));
|
|
55
|
+
}
|
|
56
|
+
return rows;
|
|
57
|
+
},
|
|
58
|
+
|
|
59
|
+
async getEventTypeAt(id, sequence) {
|
|
60
|
+
for await (const row of links
|
|
61
|
+
.select({ fType: eventsTable.f_type })
|
|
62
|
+
.join(events, eq(eventsTable.f_event_id, links.f_event_id))
|
|
63
|
+
.where(and(eq(links.f_session_id, id), eq(links.f_sequence, sequence)))
|
|
64
|
+
.limit(1)) {
|
|
65
|
+
return row.fType;
|
|
66
|
+
}
|
|
67
|
+
return undefined;
|
|
68
|
+
},
|
|
69
|
+
|
|
70
|
+
async getEventTypesBefore(id, beforeSequence, limit) {
|
|
71
|
+
const rows: Array<Pick<EventRow, "fSequence" | "fType">> = [];
|
|
72
|
+
for await (const row of links
|
|
73
|
+
.select({ fSequence: links.f_sequence, fType: eventsTable.f_type })
|
|
74
|
+
.join(events, eq(eventsTable.f_event_id, links.f_event_id))
|
|
75
|
+
.where(and(eq(links.f_session_id, id), lt(links.f_sequence, beforeSequence)))
|
|
76
|
+
.orderBy("f_sequence", "desc")
|
|
77
|
+
.limit(limit)) {
|
|
78
|
+
rows.push({ fSequence: Number(row.fSequence), fType: row.fType });
|
|
79
|
+
}
|
|
80
|
+
return rows;
|
|
81
|
+
},
|
|
82
|
+
|
|
83
|
+
async listSessions() {
|
|
84
|
+
const rows: SessionRow[] = [];
|
|
85
|
+
for await (const row of sessionsTable.select()) rows.push(sessionRowOf(row));
|
|
86
|
+
return rows;
|
|
87
|
+
},
|
|
88
|
+
|
|
89
|
+
async latestTitleEvent(id) {
|
|
90
|
+
for await (const row of links
|
|
91
|
+
.select({ fSequence: links.f_sequence, fData: eventsTable.f_data })
|
|
92
|
+
.join(events, eq(eventsTable.f_event_id, links.f_event_id))
|
|
93
|
+
.where(and(eq(links.f_session_id, id), eq(eventsTable.f_type, "session/title")))
|
|
94
|
+
.orderBy("f_sequence", "desc")
|
|
95
|
+
.limit(1)) {
|
|
96
|
+
return { fSequence: Number(row.fSequence), fData: row.fData };
|
|
97
|
+
}
|
|
98
|
+
return undefined;
|
|
99
|
+
},
|
|
100
|
+
|
|
101
|
+
async listOrphanSubagentSessions() {
|
|
102
|
+
const ids: string[] = [];
|
|
103
|
+
for await (const row of sessionsTable
|
|
104
|
+
.select({ id: sessionsTable.f_session_id })
|
|
105
|
+
.where(
|
|
106
|
+
and(
|
|
107
|
+
eq(sessionsTable.f_origin, "subagent"),
|
|
108
|
+
or(
|
|
109
|
+
isNull(sessionsTable.f_parent_session),
|
|
110
|
+
notInSubquery(
|
|
111
|
+
sessionsTable.f_parent_session,
|
|
112
|
+
sessionsTable.select({ id: sessionsTable.f_session_id }).toFragment(),
|
|
113
|
+
),
|
|
114
|
+
),
|
|
115
|
+
),
|
|
116
|
+
)) {
|
|
117
|
+
ids.push(row.id);
|
|
118
|
+
}
|
|
119
|
+
return ids;
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** `f_last_event_at` 的「只增不减」(两方言同一份表达式,值走绑定)。 */
|
|
125
|
+
export function lastEventAtExpr(value: number): ExprOperand<number> {
|
|
126
|
+
return E.maxOf(E.column("f_last_event_at"), value);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** header 的「变了才写」条件:逐列 `IS DISTINCT FROM` 的 OR(值全部走绑定参数)。 */
|
|
130
|
+
export function headerChangedCondition(
|
|
131
|
+
header: Readonly<Record<string, string | number | null>>,
|
|
132
|
+
): Condition {
|
|
133
|
+
return E.or(
|
|
134
|
+
...Object.entries(header).map(([name, value]) =>
|
|
135
|
+
E.isDistinctFrom(E.column(name, "t_sessions"), value),
|
|
136
|
+
),
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* 事件行的数值列统一收回 number:`f_created_at` 在表定义里是 `bigint`(rdbkit 读回 BigInt),
|
|
142
|
+
* 事件坐标与毫秒时间都在安全整数范围内。
|
|
143
|
+
*/
|
|
144
|
+
function eventRowOf(row: {
|
|
145
|
+
fEventId: string;
|
|
146
|
+
fSequence: number | bigint;
|
|
147
|
+
fType: string;
|
|
148
|
+
fKind: string;
|
|
149
|
+
fRole: string;
|
|
150
|
+
fName: string;
|
|
151
|
+
fActionId: string;
|
|
152
|
+
fCreatedAt: number | bigint;
|
|
153
|
+
fData: string;
|
|
154
|
+
fSurfaceOp: string | null;
|
|
155
|
+
}): EventRow {
|
|
156
|
+
return {
|
|
157
|
+
fEventId: row.fEventId,
|
|
158
|
+
fSequence: Number(row.fSequence),
|
|
159
|
+
fType: row.fType,
|
|
160
|
+
fKind: row.fKind,
|
|
161
|
+
fRole: row.fRole,
|
|
162
|
+
fName: row.fName,
|
|
163
|
+
fActionId: row.fActionId,
|
|
164
|
+
fCreatedAt: Number(row.fCreatedAt),
|
|
165
|
+
fData: row.fData,
|
|
166
|
+
fSurfaceOp: row.fSurfaceOp,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** 列名原样 → 会话行的 camelCase(列名由表定义固定,这里是唯一一处映射)。 */
|
|
171
|
+
export function sessionRowOf(row: Record<string, unknown>): SessionRow { return {
|
|
172
|
+
fSessionId: String(row["f_session_id"]),
|
|
173
|
+
fHeadEventId: String(row["f_head_event_id"]),
|
|
174
|
+
fHeadSequence: Number(row["f_head_sequence"]),
|
|
175
|
+
fVersion: Number(row["f_version"]),
|
|
176
|
+
fCreatedAt: Number(row["f_created_at"]),
|
|
177
|
+
fCwd: (row["f_cwd"] as string | null) ?? null,
|
|
178
|
+
fParentSession: (row["f_parent_session"] as string | null) ?? null,
|
|
179
|
+
fSeedLength: row["f_seed_length"] === null ? null : Number(row["f_seed_length"]),
|
|
180
|
+
fOrigin: (row["f_origin"] as string | null) ?? null,
|
|
181
|
+
fDelegationDepth: row["f_delegation_depth"] === null ? null : Number(row["f_delegation_depth"]),
|
|
182
|
+
fAgentPreset: (row["f_agent_preset"] as string | null) ?? null,
|
|
183
|
+
fIncarnation: String(row["f_incarnation"]),
|
|
184
|
+
fRevision: Number(row["f_revision"]),
|
|
185
|
+
fArchivedAt: row["f_archived_at"] === null ? null : Number(row["f_archived_at"]),
|
|
186
|
+
fPinnedSeq: row["f_pinned_seq"] === null ? null : Number(row["f_pinned_seq"]),
|
|
187
|
+
fLastEventAt: row["f_last_event_at"] === null ? null : Number(row["f_last_event_at"]),
|
|
188
|
+
};
|
|
189
|
+
}
|