@morlay/session-rdb 0.1.9 → 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 +24 -3
- package/dist/artifact.d.mts +118 -3
- package/dist/artifact.mjs +5 -3
- package/dist/{log-C0q5svm7.d.mts → backend-BKtJ5sO5.d.mts} +189 -291
- 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 -3
- package/dist/deletion.mjs +2 -2
- package/dist/export-CfJQjQfr.mjs +123 -0
- package/dist/export.d.mts +1 -3
- package/dist/export.mjs +32 -57
- package/dist/import.d.mts +1 -3
- package/dist/import.mjs +32 -78
- package/dist/index-CUD2ZpTA.d.mts +71 -0
- package/dist/index.d.mts +20 -2
- package/dist/index.mjs +1562 -5
- 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 -50
- package/dist/storage.mjs +3 -1
- package/dist/testing.d.mts +9 -5
- package/dist/testing.mjs +27 -8
- package/dist/usage-row-BQQM-_t6.mjs +36 -0
- package/dist/usage.d.mts +1 -1
- package/dist/usage.mjs +8 -42
- package/dist/write-guard-CYrY58GM.mjs +174 -0
- package/package.json +32 -36
- 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} +125 -112
- 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 -1630
- 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 -272
- 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-query.ts +17 -4
- 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 +139 -77
- package/src/storage-takeover/read-model.ts +189 -0
- package/src/storage-takeover/repository.ts +238 -425
- package/src/storage-takeover/storage-backend.ts +47 -149
- package/src/storage-takeover/types.ts +45 -14
- 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-DbFW3rJX.mjs +0 -391
- package/dist/deletion-ODZFcbZm.mjs +0 -2218
- package/dist/gc.d.mts +0 -6
- package/dist/gc.mjs +0 -49
- package/dist/log-DYxdjJ-l.mjs +0 -562
- package/dist/routes-DGBlBE_I.mjs +0 -10
- package/dist/sqlite-DjOnMRh2.mjs +0 -1777
- 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 -1086
|
@@ -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
|
+
}
|
|
@@ -9,9 +9,22 @@ import type {
|
|
|
9
9
|
} from "@deepseek-ai/dsh-session";
|
|
10
10
|
import type {
|
|
11
11
|
ProjectionCheckpoint,
|
|
12
|
+
ProjectionKeySelection,
|
|
12
13
|
ProjectionSnapshot,
|
|
13
14
|
SessionProjectionMap,
|
|
14
15
|
} from "@deepseek-ai/dsh-session-projection";
|
|
16
|
+
|
|
17
|
+
// 上游的 keys 选择面既接受数组也接受 `ReadonlySet`(`sessionProjections.summaryKeys()` 给的是 Set)。
|
|
18
|
+
// 只看一个键时两种都要认——之前只按数组写了 `.includes`,Set 上直接抛 `keys.includes is not a function`,
|
|
19
|
+
// 列表行于是整列投影都没有(会话标题显示"未命名")。
|
|
20
|
+
function selects(
|
|
21
|
+
keys: ProjectionKeySelection | undefined,
|
|
22
|
+
key: Extract<keyof SessionProjectionMap, string>,
|
|
23
|
+
): boolean {
|
|
24
|
+
if (keys === undefined) return true;
|
|
25
|
+
if ("has" in keys) return keys.has(key);
|
|
26
|
+
return (keys as readonly string[]).includes(key);
|
|
27
|
+
}
|
|
15
28
|
import type {
|
|
16
29
|
StorageRepository,
|
|
17
30
|
StoredProjcacheEntry,
|
|
@@ -33,46 +46,37 @@ type LifecycleIdentity = CheckpointIdentity & {
|
|
|
33
46
|
isSeeded: boolean;
|
|
34
47
|
};
|
|
35
48
|
|
|
36
|
-
|
|
37
|
-
writeEveryEvents: number;
|
|
38
|
-
|
|
39
|
-
writeIntervalMs: number;
|
|
40
|
-
}
|
|
49
|
+
// 没有攒批参数:投影检查点按「与已落库那行相比是否有变化」决定写不写,与事件条数 / 时间无关。
|
|
41
50
|
|
|
42
|
-
export interface SessionProjectionCacheRdbOptions
|
|
43
|
-
|
|
51
|
+
export interface SessionProjectionCacheRdbOptions {
|
|
52
|
+
/** backend 打开后才有值(构造期装配、打开后读写)。 */
|
|
53
|
+
repository: () => Promise<StorageRepository>;
|
|
44
54
|
ready: Promise<unknown>;
|
|
45
55
|
}
|
|
46
56
|
|
|
47
|
-
interface DirtyState {
|
|
48
|
-
pending: number;
|
|
49
|
-
timer: ReturnType<typeof setTimeout> | undefined;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
57
|
const PREDECESSOR_TITLE_KEY = "title" as Extract<keyof SessionProjectionMap, string>;
|
|
53
58
|
|
|
54
59
|
export class SessionProjectionCacheRdb extends Service {
|
|
55
60
|
static inject = ["sessionProjections", "sessions"];
|
|
56
61
|
|
|
57
62
|
private readonly records = new Map<SessionId, StoredProjcacheEntry>();
|
|
58
|
-
|
|
63
|
+
// 同一 tick 内的事件合并成一次「落库 + 检查点比对」,这不是攒批:flush 之后事件已经在库里。
|
|
64
|
+
private readonly queued = new Set<SessionId>();
|
|
59
65
|
|
|
60
|
-
private
|
|
61
|
-
private readonly config: ProjectionCacheConfig;
|
|
62
|
-
private readonly repository: StorageRepository;
|
|
66
|
+
private repository!: StorageRepository;
|
|
63
67
|
private readonly ready: Promise<unknown>;
|
|
68
|
+
private readonly openRepository: () => Promise<StorageRepository>;
|
|
64
69
|
|
|
65
70
|
constructor(ctx: Context, options: SessionProjectionCacheRdbOptions) {
|
|
66
71
|
super(ctx, SESSION_PROJECTION_CACHE_SERVICE);
|
|
67
|
-
this.
|
|
68
|
-
this.repository = options.repository;
|
|
72
|
+
this.openRepository = options.repository;
|
|
69
73
|
this.ready = options.ready;
|
|
70
|
-
this.directReads = options.repository.readProjcacheDirect !== undefined;
|
|
71
74
|
}
|
|
72
75
|
|
|
73
76
|
protected async [Service.init](): Promise<void> {
|
|
74
77
|
this.installWritePath();
|
|
75
78
|
await this.ready;
|
|
79
|
+
this.repository = await this.openRepository();
|
|
76
80
|
|
|
77
81
|
const pruned = await this.repository.pruneStaleProjcache();
|
|
78
82
|
if (pruned > 0) {
|
|
@@ -80,15 +84,12 @@ export class SessionProjectionCacheRdb extends Service {
|
|
|
80
84
|
`session projection cache: pruned stale snapshot(s) for ${String(pruned)} session(s)`,
|
|
81
85
|
);
|
|
82
86
|
}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
this.records.set(entry.sessionId as SessionId, entry);
|
|
86
|
-
}
|
|
87
|
+
for (const entry of await this.repository.loadProjcache()) {
|
|
88
|
+
this.records.set(entry.sessionId as SessionId, entry);
|
|
87
89
|
}
|
|
88
90
|
}
|
|
89
91
|
|
|
90
92
|
private lookup(id: SessionId): StoredProjcacheEntry | undefined {
|
|
91
|
-
if (this.directReads) return this.repository.readProjcacheDirect?.(id);
|
|
92
93
|
return this.records.get(id);
|
|
93
94
|
}
|
|
94
95
|
|
|
@@ -105,7 +106,7 @@ export class SessionProjectionCacheRdb extends Service {
|
|
|
105
106
|
// inherited cut(那个只有写路径与 hydration 用得上)。
|
|
106
107
|
cachedSnapshot(
|
|
107
108
|
meta: SessionHeader,
|
|
108
|
-
keys?:
|
|
109
|
+
keys?: ProjectionKeySelection,
|
|
109
110
|
): ProjectionSnapshot | undefined {
|
|
110
111
|
const record = this.recordForLifecycle(meta);
|
|
111
112
|
const snapshot = record === undefined ? undefined : this.viewRecord(record, keys);
|
|
@@ -121,19 +122,16 @@ export class SessionProjectionCacheRdb extends Service {
|
|
|
121
122
|
|
|
122
123
|
private withDirectTitle(
|
|
123
124
|
id: SessionId,
|
|
124
|
-
keys:
|
|
125
|
+
keys: ProjectionKeySelection | undefined,
|
|
125
126
|
snapshot: ProjectionSnapshot | undefined,
|
|
126
127
|
): ProjectionSnapshot | undefined {
|
|
127
|
-
if (
|
|
128
|
-
return snapshot;
|
|
129
|
-
}
|
|
128
|
+
if (!selects(keys, PREDECESSOR_TITLE_KEY)) return snapshot;
|
|
130
129
|
if (snapshot?.values[PREDECESSOR_TITLE_KEY] !== undefined) return snapshot;
|
|
131
|
-
const
|
|
132
|
-
if (
|
|
133
|
-
const values = { ...snapshot?.values, [PREDECESSOR_TITLE_KEY]:
|
|
134
|
-
|
|
130
|
+
const title = this.records.get(id)?.title;
|
|
131
|
+
if (title === undefined) return snapshot;
|
|
132
|
+
const values = { ...snapshot?.values, [PREDECESSOR_TITLE_KEY]: title.title };
|
|
135
133
|
const asOfSeq =
|
|
136
|
-
snapshot === undefined ?
|
|
134
|
+
snapshot === undefined ? title.seq : Math.min(snapshot.asOfSeq as number, title.seq);
|
|
137
135
|
return { asOfSeq: asOfSeq as SessionSeqCursor, values };
|
|
138
136
|
}
|
|
139
137
|
|
|
@@ -150,7 +148,7 @@ export class SessionProjectionCacheRdb extends Service {
|
|
|
150
148
|
|
|
151
149
|
private viewRecord(
|
|
152
150
|
record: StoredProjcacheEntry,
|
|
153
|
-
keys?:
|
|
151
|
+
keys?: ProjectionKeySelection,
|
|
154
152
|
): ProjectionSnapshot | undefined {
|
|
155
153
|
const values = this.ctx.sessionProjections.viewCheckpoint(
|
|
156
154
|
record.rows as unknown as ProjectionCheckpoint,
|
|
@@ -189,17 +187,63 @@ export class SessionProjectionCacheRdb extends Service {
|
|
|
189
187
|
}
|
|
190
188
|
|
|
191
189
|
async write(session: Session): Promise<void> {
|
|
192
|
-
|
|
193
|
-
|
|
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
|
+
}
|
|
194
212
|
|
|
195
|
-
|
|
196
|
-
|
|
213
|
+
const changed = changedRows(previous, rows);
|
|
214
|
+
const removedKeys = Object.keys(previous ?? {}).filter((key) => rows[key] === undefined);
|
|
215
|
+
await this.patch(
|
|
197
216
|
session.id,
|
|
198
217
|
identityOf(session.header, session.inheritedEventCount),
|
|
199
|
-
|
|
218
|
+
changed,
|
|
219
|
+
removedKeys,
|
|
200
220
|
);
|
|
201
221
|
}
|
|
202
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
|
+
|
|
203
247
|
// rewind 后 head 回退:把水位超出新 head 的投影单元丢弃,保留仍在截断前缀内的行。
|
|
204
248
|
// 这是零 I/O 读的更新(不 fold 日志):下次零 I/O 读拿到的是截断后的水位,而不是旧值。
|
|
205
249
|
async truncateTo(
|
|
@@ -245,21 +289,8 @@ export class SessionProjectionCacheRdb extends Service {
|
|
|
245
289
|
}
|
|
246
290
|
|
|
247
291
|
private installWritePath(): void {
|
|
248
|
-
this.ctx.on("session/event", (session: Session
|
|
249
|
-
|
|
250
|
-
void this.flushSoft(session, "turn/end");
|
|
251
|
-
return;
|
|
252
|
-
}
|
|
253
|
-
const state = this.dirty.get(session) ?? { pending: 0, timer: undefined };
|
|
254
|
-
this.dirty.set(session, state);
|
|
255
|
-
state.pending += 1;
|
|
256
|
-
if (state.pending >= this.config.writeEveryEvents) {
|
|
257
|
-
void this.flushSoft(session, "count threshold");
|
|
258
|
-
return;
|
|
259
|
-
}
|
|
260
|
-
state.timer ??= setTimeout(() => {
|
|
261
|
-
void this.flushSoft(session, "interval");
|
|
262
|
-
}, this.config.writeIntervalMs);
|
|
292
|
+
this.ctx.on("session/event", (session: Session) => {
|
|
293
|
+
this.scheduleWrite(session, "event");
|
|
263
294
|
});
|
|
264
295
|
|
|
265
296
|
this.ctx.on("session/created", (session: Session) => {
|
|
@@ -268,19 +299,19 @@ export class SessionProjectionCacheRdb extends Service {
|
|
|
268
299
|
|
|
269
300
|
this.ctx.on("session/disposed", (session: Session) => {
|
|
270
301
|
void this.flushSoft(session, "detach");
|
|
271
|
-
this.
|
|
272
|
-
this.dirty.delete(session);
|
|
302
|
+
this.queued.delete(session.id);
|
|
273
303
|
});
|
|
304
|
+
}
|
|
274
305
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
);
|
|
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
|
+
});
|
|
284
315
|
}
|
|
285
316
|
|
|
286
317
|
private async flushSoft(session: Session, trigger: string): Promise<void> {
|
|
@@ -293,16 +324,6 @@ export class SessionProjectionCacheRdb extends Service {
|
|
|
293
324
|
}
|
|
294
325
|
}
|
|
295
326
|
|
|
296
|
-
private markClean(session: Session): void {
|
|
297
|
-
const state = this.dirty.get(session);
|
|
298
|
-
if (state === undefined) return;
|
|
299
|
-
state.pending = 0;
|
|
300
|
-
if (state.timer !== undefined) {
|
|
301
|
-
clearTimeout(state.timer);
|
|
302
|
-
state.timer = undefined;
|
|
303
|
-
}
|
|
304
|
-
}
|
|
305
|
-
|
|
306
327
|
private async put(
|
|
307
328
|
id: SessionId,
|
|
308
329
|
identity: CheckpointIdentity,
|
|
@@ -311,9 +332,50 @@ export class SessionProjectionCacheRdb extends Service {
|
|
|
311
332
|
const detached = detachJson(rows);
|
|
312
333
|
|
|
313
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
|
+
}
|
|
314
344
|
|
|
315
|
-
|
|
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
|
+
}
|
|
365
|
+
|
|
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;
|
|
316
377
|
}
|
|
378
|
+
return true;
|
|
317
379
|
}
|
|
318
380
|
|
|
319
381
|
function detachJson(
|