@morlay/session-rdb 0.0.21-alpha.7 → 0.0.21-alpha.9
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 +31 -12
- package/dist/artifact.d.mts +2 -101
- package/dist/artifact.mjs +2 -2
- package/dist/{branch-BjUR6wfe.mjs → branch-D4nVzQQM.mjs} +3 -3
- package/dist/deletion.d.mts +1 -1
- package/dist/deletion.mjs +1 -1
- package/dist/{dist-vIVO6bA-.mjs → dist-DHer1cql.mjs} +14 -7
- package/dist/export.d.mts +8 -0
- package/dist/export.mjs +76 -0
- package/dist/gc.d.mts +8 -0
- package/dist/gc.mjs +51 -0
- package/dist/import.d.mts +1 -1
- package/dist/import.mjs +1 -1
- package/dist/index.d.mts +2 -2
- package/dist/index.mjs +3 -3
- package/dist/{log-qCNkDjh5.mjs → log-BWVFYAh5.mjs} +39 -12
- package/dist/{backend-E0bmTXpz.d.mts → log-CUjKQbOR.d.mts} +222 -39
- package/dist/{sqlite-zcXVyMxr.mjs → sqlite-SCiSnwaz.mjs} +321 -259
- package/dist/{src-B9X8BKyh.mjs → src-BZ1tffd5.mjs} +342 -310
- package/dist/storage.d.mts +20 -7
- package/dist/storage.mjs +2 -2
- package/dist/testing.d.mts +1 -1
- package/dist/testing.mjs +48 -29
- package/dist/usage.d.mts +2 -0
- package/dist/usage.mjs +152 -0
- package/drizzle/postgres/20260923120000_v3_usage_materialized/migration.sql +48 -0
- package/drizzle/sqlite/20260923120000_v3_usage_materialized/migration.sql +48 -0
- package/package.json +25 -30
- package/src/backend.ts +41 -17
- package/src/branch.ts +4 -4
- package/src/drizzle/postgres-v3.ts +2 -0
- package/src/drizzle/sqlite-v3.ts +2 -0
- package/src/entities/v3/event-usage.ts +8 -1
- package/src/entities/v3/index.ts +8 -0
- package/src/entities/v3/session-counts.ts +23 -0
- package/src/entities/v3/session-usage.ts +29 -0
- package/src/index.ts +230 -88
- package/src/log.ts +58 -17
- package/src/postgres.ts +248 -149
- package/src/rows.ts +2 -1
- package/src/schema.ts +4 -0
- package/src/sqlite.ts +263 -124
- package/src/storage-takeover/repository.ts +1 -4
- package/src/usage.ts +10 -6
- package/cordis.patch.yml +0 -13
package/src/index.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Context } from "@deepseek-ai/cordis";
|
|
2
|
+
import type { Volatile } from "@deepseek-ai/cosmokit";
|
|
2
3
|
import z from "@deepseek-ai/schemastery";
|
|
3
4
|
import { randomUUID } from "node:crypto";
|
|
4
5
|
import { Pool } from "pg";
|
|
@@ -41,11 +42,13 @@ import { sessionFormatLogFilename } from "@deepseek-ai/dsh-session-format";
|
|
|
41
42
|
import {
|
|
42
43
|
type Backend,
|
|
43
44
|
type BackendTx,
|
|
44
|
-
type EventCountBucket,
|
|
45
45
|
type EventInsert,
|
|
46
|
+
type SessionCountBucket,
|
|
47
|
+
type SessionUsageBucket,
|
|
46
48
|
} from "./backend.ts";
|
|
47
49
|
import { WriteGuard } from "./write-guard.ts";
|
|
48
|
-
import { repairReadView, rowToMeta, scanRows, toJsonlArtifact } from "./log.ts";
|
|
50
|
+
import { repairReadView, rowToMeta, scanRows, toJsonlArtifact, usageRowOf } from "./log.ts";
|
|
51
|
+
import type { EventUsageRow } from "./log.ts";
|
|
49
52
|
import {
|
|
50
53
|
DEFAULT_BUSY_TIMEOUT_MS,
|
|
51
54
|
eventDimensions,
|
|
@@ -61,11 +64,16 @@ import { registerSessionDeletion } from "./deletion.ts";
|
|
|
61
64
|
import { registerSessionExport } from "./export.ts";
|
|
62
65
|
import { registerSessionGc } from "./gc.ts";
|
|
63
66
|
import { registerSessionRows } from "./rows.ts";
|
|
64
|
-
import {
|
|
67
|
+
import {
|
|
68
|
+
COUNTED_EVENT_TYPES,
|
|
69
|
+
addActivityCount,
|
|
70
|
+
localDayKey,
|
|
71
|
+
registerSessionUsage,
|
|
72
|
+
} from "./usage.ts";
|
|
65
73
|
import type { UsageAggregate } from "./usage.ts";
|
|
66
74
|
import { SessionQueryRdb } from "./session-query.ts";
|
|
67
75
|
import { adoptLegacyRows, convertLegacyRows, isLegacyVersion } from "./legacy.ts";
|
|
68
|
-
import {
|
|
76
|
+
import { needsShapeAdoption, sealOwnEvents } from "./log.ts";
|
|
69
77
|
import { installStorageTakeover } from "./storage-takeover/index.ts";
|
|
70
78
|
|
|
71
79
|
/** 一批事件里的最大时间:写路径据此推进会话行的「最后活动时间」。 */
|
|
@@ -122,25 +130,21 @@ export interface ProjectionCacheOptions {
|
|
|
122
130
|
export type Config =
|
|
123
131
|
| {
|
|
124
132
|
type: "sqlite";
|
|
125
|
-
|
|
126
133
|
path: string;
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
busyTimeout?: number;
|
|
131
|
-
|
|
132
|
-
projectionCache?: ProjectionCacheOptions;
|
|
134
|
+
journalMode?: JournalMode | undefined;
|
|
135
|
+
busyTimeout?: number | undefined;
|
|
136
|
+
projectionCache?: ProjectionCacheOptions | undefined;
|
|
133
137
|
}
|
|
134
138
|
| {
|
|
135
139
|
type: "postgres";
|
|
136
|
-
|
|
137
140
|
connectionString: string;
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
projectionCache?: ProjectionCacheOptions;
|
|
141
|
+
schema?: string | undefined;
|
|
142
|
+
projectionCache?: ProjectionCacheOptions | undefined;
|
|
142
143
|
};
|
|
143
144
|
|
|
145
|
+
/** schema 解析之后的形状:整段 Config 是 **volatile 稳定引用**(页面可编辑),读它要过 `.get()`。 */
|
|
146
|
+
export type ResolvedConfig = Volatile<Config>;
|
|
147
|
+
|
|
144
148
|
export type SessionDeletionErrorCode =
|
|
145
149
|
| "SESSION_NOT_FOUND"
|
|
146
150
|
| "SESSION_NOT_ARCHIVED"
|
|
@@ -484,40 +488,111 @@ class RdbSessionHandle implements SessionHandle {
|
|
|
484
488
|
static readonly LIVE_WRITE_BATCH_MAX_DELAY_MS = 200;
|
|
485
489
|
}
|
|
486
490
|
|
|
491
|
+
/**
|
|
492
|
+
* 本地化说明:`description()` 的类型签名只声明 `string`,而 meta 本身接受 `Dict<string>`
|
|
493
|
+
* (`vendor/schemastery/src/index.ts` 的 `mergeDesc` 就是按字典合并的),所以这里只做一次类型放行。
|
|
494
|
+
*/
|
|
495
|
+
const localized = (text: { zh: string; en: string }): string => text as unknown as string;
|
|
496
|
+
|
|
497
|
+
/** 投影 checkpoint 的写回节流:两个分支共用。 */
|
|
498
|
+
const projectionCacheSchema = z
|
|
499
|
+
.object({
|
|
500
|
+
writeEveryEvents: z
|
|
501
|
+
.natural()
|
|
502
|
+
.min(1)
|
|
503
|
+
.default(DEFAULT_PROJECTION_WRITE_EVERY_EVENTS)
|
|
504
|
+
.description(
|
|
505
|
+
localized({
|
|
506
|
+
zh: "每累计这么多事件写回一次投影 checkpoint。",
|
|
507
|
+
en: "Write the projection checkpoint back after this many events.",
|
|
508
|
+
}),
|
|
509
|
+
),
|
|
510
|
+
writeIntervalMs: z
|
|
511
|
+
.natural()
|
|
512
|
+
.min(1)
|
|
513
|
+
.default(DEFAULT_PROJECTION_WRITE_INTERVAL_MS)
|
|
514
|
+
.description(
|
|
515
|
+
localized({
|
|
516
|
+
zh: "距离上次写回超过这个间隔(毫秒)时也写一次。",
|
|
517
|
+
en: "Also write back when this many ms passed since the last one.",
|
|
518
|
+
}),
|
|
519
|
+
),
|
|
520
|
+
})
|
|
521
|
+
.default({
|
|
522
|
+
writeEveryEvents: DEFAULT_PROJECTION_WRITE_EVERY_EVENTS,
|
|
523
|
+
writeIntervalMs: DEFAULT_PROJECTION_WRITE_INTERVAL_MS,
|
|
524
|
+
});
|
|
525
|
+
|
|
487
526
|
export class SessionPersistenceRdb extends SessionPersistence {
|
|
488
527
|
static inject = ["sessions"];
|
|
489
528
|
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
529
|
+
// 显式声明「输入 → 解析后」的形状:整段是 volatile,读它要过 `.get()`。union + volatile 的推断形状与手写类型
|
|
530
|
+
// 在可选性 / 索引签名上不完全对齐,所以这里断言一次,类型面由此固定。
|
|
531
|
+
static Config = z
|
|
532
|
+
.union([
|
|
533
|
+
z.object({
|
|
534
|
+
type: z.const("sqlite"),
|
|
535
|
+
path: z
|
|
536
|
+
.string()
|
|
537
|
+
.required()
|
|
538
|
+
.description(
|
|
539
|
+
localized({
|
|
540
|
+
zh: "SQLite 数据库文件路径;`:memory:` 是内存库(进程结束即丢)。",
|
|
541
|
+
en: "SQLite database file path; `:memory:` keeps it in memory and loses it with the process.",
|
|
542
|
+
}),
|
|
543
|
+
),
|
|
544
|
+
journalMode: z
|
|
545
|
+
.union(["wal", "delete", "truncate", "persist"] as const)
|
|
546
|
+
.default("wal")
|
|
547
|
+
.description(
|
|
548
|
+
localized({
|
|
549
|
+
zh: "SQLite 日志模式;默认 wal(并发读最好)。",
|
|
550
|
+
en: "SQLite journal mode; `wal` by default (best concurrent reads).",
|
|
551
|
+
}),
|
|
552
|
+
),
|
|
553
|
+
busyTimeout: z
|
|
554
|
+
.number()
|
|
555
|
+
.step(1)
|
|
556
|
+
.min(0)
|
|
557
|
+
.default(DEFAULT_BUSY_TIMEOUT_MS)
|
|
558
|
+
.description(
|
|
559
|
+
localized({
|
|
560
|
+
zh: "写锁冲突时的忙等超时(毫秒)。",
|
|
561
|
+
en: "Busy timeout in ms while a write lock is contended.",
|
|
562
|
+
}),
|
|
563
|
+
),
|
|
564
|
+
projectionCache: projectionCacheSchema,
|
|
565
|
+
}),
|
|
566
|
+
z.object({
|
|
567
|
+
type: z.const("postgres"),
|
|
568
|
+
connectionString: z
|
|
569
|
+
.string()
|
|
570
|
+
.required()
|
|
571
|
+
.description(
|
|
572
|
+
localized({
|
|
573
|
+
zh: "PostgreSQL 连接串(含库名与凭证)。",
|
|
574
|
+
en: "PostgreSQL connection string (database and credentials).",
|
|
575
|
+
}),
|
|
576
|
+
),
|
|
577
|
+
schema: z
|
|
578
|
+
.string()
|
|
579
|
+
.default("public")
|
|
580
|
+
.description(
|
|
581
|
+
localized({
|
|
582
|
+
zh: "库内的 schema 名;默认 public。",
|
|
583
|
+
en: "Schema name inside the database; `public` by default.",
|
|
584
|
+
}),
|
|
585
|
+
),
|
|
586
|
+
projectionCache: projectionCacheSchema,
|
|
587
|
+
}),
|
|
588
|
+
])
|
|
589
|
+
.description(
|
|
590
|
+
localized({
|
|
591
|
+
zh: "会话持久化后端:改这里的连接参数会在这一行重挂时生效(Loader 会重装被改的行)。",
|
|
592
|
+
en: "Session persistence backend: edits apply when this row is remounted (the Loader re-installs changed rows).",
|
|
593
|
+
}),
|
|
594
|
+
)
|
|
595
|
+
.volatile() as unknown as z<Config, ResolvedConfig>;
|
|
521
596
|
|
|
522
597
|
override readonly name = "session-rdb";
|
|
523
598
|
|
|
@@ -534,9 +609,12 @@ export class SessionPersistenceRdb extends SessionPersistence {
|
|
|
534
609
|
private readonly liveBuffers = new Map<SessionId, SessionEvent[]>();
|
|
535
610
|
private readonly liveReady = new Map<SessionId, Promise<void>>();
|
|
536
611
|
|
|
612
|
+
/** 本次装配用的配置快照(整段 Config 是 volatile,重挂会给新的)。 */
|
|
613
|
+
readonly config: Config;
|
|
614
|
+
|
|
537
615
|
constructor(
|
|
538
616
|
ctx: Context,
|
|
539
|
-
|
|
617
|
+
config: ResolvedConfig,
|
|
540
618
|
|
|
541
619
|
injectedBackend?: Backend,
|
|
542
620
|
) {
|
|
@@ -544,7 +622,12 @@ export class SessionPersistenceRdb extends SessionPersistence {
|
|
|
544
622
|
|
|
545
623
|
// 配置就是这一行的 config(cordis.patch.yml / profile patch,或设置页改它);上游 0.1.7 的 settings
|
|
546
624
|
// 不再提供 namespace section 覆盖,旧 `settings.yaml` 的 `session-rdb` 段由上游一次性导进同 id 的行。
|
|
547
|
-
|
|
625
|
+
//
|
|
626
|
+
// 整段 Config 标了 volatile(页面可编辑),而生效靠 **Loader 重挂这一行**(settings 写完经
|
|
627
|
+
// `reconcileProfilePatches` 让 Loader 重装受影响的 entry):重挂会构造新实例、拿到新快照,所以这里取一次
|
|
628
|
+
// 就够,运行期不必反复读引用。
|
|
629
|
+
this.config = config.get();
|
|
630
|
+
this.backend = injectedBackend ?? createBackend(this.config);
|
|
548
631
|
this.ready = this.init();
|
|
549
632
|
this.installLiveRouting(ctx);
|
|
550
633
|
|
|
@@ -754,7 +837,7 @@ export class SessionPersistenceRdb extends SessionPersistence {
|
|
|
754
837
|
this.liveFailures.delete(id);
|
|
755
838
|
this.liveDropWarned.delete(id);
|
|
756
839
|
this.reuseEventIds.delete(id);
|
|
757
|
-
await this.
|
|
840
|
+
await this.dropSessionStats(id);
|
|
758
841
|
}
|
|
759
842
|
|
|
760
843
|
/** GC 通道:回收已无桥接行引用的事件行(孤儿),返回删除行数。 */
|
|
@@ -778,7 +861,7 @@ export class SessionPersistenceRdb extends SessionPersistence {
|
|
|
778
861
|
this.liveFailures.delete(id);
|
|
779
862
|
this.liveDropWarned.delete(id);
|
|
780
863
|
this.reuseEventIds.delete(id);
|
|
781
|
-
await this.
|
|
864
|
+
await this.dropSessionStats(id);
|
|
782
865
|
}
|
|
783
866
|
return deleted;
|
|
784
867
|
}
|
|
@@ -854,10 +937,14 @@ export class SessionPersistenceRdb extends SessionPersistence {
|
|
|
854
937
|
await this.ready;
|
|
855
938
|
if (events.length === 0) return false;
|
|
856
939
|
|
|
940
|
+
// 本仓库自造的事件类型落库前补上 `ignorable` 信封:不补的话上游那道校验连写入都拒。
|
|
941
|
+
sealOwnEvents(events);
|
|
857
942
|
validateStoredEvents(meta, [...events]);
|
|
858
943
|
|
|
859
944
|
const reuse = this.reuseEventIds.get(meta.id);
|
|
860
945
|
let confirmedHead = -1;
|
|
946
|
+
// 本批用量行由 `appendEventTail` 折好;事务成功后旁路据此累加 token 汇总表。
|
|
947
|
+
let usageRows: EventUsageRow[] = [];
|
|
861
948
|
await this.backend.transaction(async (tx) => {
|
|
862
949
|
if (tornTruncateTo !== undefined) {
|
|
863
950
|
await tx.deleteBridgeTail(meta.id, tornTruncateTo);
|
|
@@ -872,16 +959,17 @@ export class SessionPersistenceRdb extends SessionPersistence {
|
|
|
872
959
|
const head = await tx.getHead(meta.id);
|
|
873
960
|
|
|
874
961
|
this.writeGuard.assertNoConcurrentWriter(meta.id, head.fHeadSequence);
|
|
875
|
-
const
|
|
962
|
+
const appended = await appendEventTail(
|
|
876
963
|
tx,
|
|
877
964
|
meta,
|
|
878
965
|
events,
|
|
879
966
|
{ parentId: head.fHeadEventId, nextSeq: head.fHeadSequence + 1 },
|
|
880
967
|
reuse,
|
|
881
968
|
);
|
|
882
|
-
|
|
969
|
+
usageRows = appended.usageRows;
|
|
970
|
+
await tx.updateHead(meta.id, appended.headEventId, appended.headSequence);
|
|
883
971
|
await tx.bumpRevision(meta.id, maxEventTime(events));
|
|
884
|
-
confirmedHead = headSequence;
|
|
972
|
+
confirmedHead = appended.headSequence;
|
|
885
973
|
});
|
|
886
974
|
// 事务**成功之后**才丢掉复用映射:失败(并发写者校验、唯一键冲突等)时这份映射还没被消费,
|
|
887
975
|
// 重试同一个 append 还要靠它复用父会话的事件行——提前删掉会让前缀事件行被重新插入一遍。
|
|
@@ -889,7 +977,7 @@ export class SessionPersistenceRdb extends SessionPersistence {
|
|
|
889
977
|
|
|
890
978
|
this.writeGuard.confirmHead(meta.id, confirmedHead);
|
|
891
979
|
this.tracker.materialized(meta.id);
|
|
892
|
-
await this.
|
|
980
|
+
await this.recordSessionStats(meta.id, events, usageRows);
|
|
893
981
|
return true;
|
|
894
982
|
}
|
|
895
983
|
|
|
@@ -959,7 +1047,7 @@ export class SessionPersistenceRdb extends SessionPersistence {
|
|
|
959
1047
|
const { preserved, tornFrom } = scanRows(eventRows, options.fromSeq ?? 0);
|
|
960
1048
|
// 版本号不完全可信:写路径曾把回退视图的结果以当前版本号落库,于是库里存在「v4 标记 + 旧代形状」
|
|
961
1049
|
// 的行——在扫干净的边界之内再按内容判一次(撕裂尾部与坏行已经被 `scanRows` 丢掉了)。
|
|
962
|
-
if (
|
|
1050
|
+
if (needsShapeAdoption(preserved)) {
|
|
963
1051
|
const adopted = adoptLegacyRows(row, eventRows);
|
|
964
1052
|
return {
|
|
965
1053
|
meta: adopted.meta,
|
|
@@ -985,39 +1073,60 @@ export class SessionPersistenceRdb extends SessionPersistence {
|
|
|
985
1073
|
}
|
|
986
1074
|
|
|
987
1075
|
/**
|
|
988
|
-
*
|
|
989
|
-
*
|
|
1076
|
+
* 统计衍生表的**旁路累加**:不在写事务里、失败只 warn——两张会话汇总表都是可销毁重建的
|
|
1077
|
+
* 派生表,丢几次累加不影响可用性(需要时用 `rebuildSessionStats` 重算)。
|
|
1078
|
+
* token 走写路径已折好的用量行,活动计数走本批事件类型。
|
|
990
1079
|
*/
|
|
991
|
-
private async
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1080
|
+
private async recordSessionStats(
|
|
1081
|
+
id: SessionId,
|
|
1082
|
+
events: readonly SessionEvent[],
|
|
1083
|
+
usageRows: readonly EventUsageRow[],
|
|
1084
|
+
): Promise<void> {
|
|
1085
|
+
const usageBuckets = sessionUsageBuckets(usageRows);
|
|
1086
|
+
if (usageBuckets.length > 0) {
|
|
1087
|
+
try {
|
|
1088
|
+
await this.backend.incrementSessionUsage(id, usageBuckets);
|
|
1089
|
+
} catch (error: unknown) {
|
|
1090
|
+
this.ctx.logger.warn(
|
|
1091
|
+
`session-rdb: token usage for "${id}" not updated (${describeError(error)})`,
|
|
1092
|
+
);
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
const countBuckets = sessionCountBuckets(events);
|
|
1096
|
+
if (countBuckets.length > 0) {
|
|
1097
|
+
try {
|
|
1098
|
+
await this.backend.incrementSessionCounts(id, countBuckets);
|
|
1099
|
+
} catch (error: unknown) {
|
|
1100
|
+
this.ctx.logger.warn(
|
|
1101
|
+
`session-rdb: activity counts for "${id}" not updated (${describeError(error)})`,
|
|
1102
|
+
);
|
|
1103
|
+
}
|
|
1000
1104
|
}
|
|
1001
1105
|
}
|
|
1002
1106
|
|
|
1003
|
-
/**
|
|
1004
|
-
|
|
1107
|
+
/**
|
|
1108
|
+
* 重算一个会话的统计(rewind / fork 之后;best-effort,同 `recordSessionStats`):
|
|
1109
|
+
* 两张汇总表按会话重算,用量行的引用标记全量重算(引用可能跨会话消失)。
|
|
1110
|
+
*/
|
|
1111
|
+
async rebuildSessionStats(id: SessionId): Promise<void> {
|
|
1005
1112
|
try {
|
|
1006
|
-
await this.backend.
|
|
1113
|
+
await this.backend.rebuildSessionStats(id);
|
|
1114
|
+
await this.backend.refreshEventUsageFlags();
|
|
1007
1115
|
} catch (error: unknown) {
|
|
1008
1116
|
this.ctx.logger.warn(
|
|
1009
|
-
`session-rdb:
|
|
1117
|
+
`session-rdb: session stats for "${id}" not rebuilt (${describeError(error)})`,
|
|
1010
1118
|
);
|
|
1011
1119
|
}
|
|
1012
1120
|
}
|
|
1013
1121
|
|
|
1014
|
-
/**
|
|
1015
|
-
private async
|
|
1122
|
+
/** 会话删除时清掉它的汇总行,并重算用量行的引用标记(被删会话引用的行要立刻退出统计)。 */
|
|
1123
|
+
private async dropSessionStats(id: SessionId): Promise<void> {
|
|
1016
1124
|
try {
|
|
1017
|
-
await this.backend.
|
|
1125
|
+
await this.backend.deleteSessionStats(id);
|
|
1126
|
+
await this.backend.refreshEventUsageFlags();
|
|
1018
1127
|
} catch (error: unknown) {
|
|
1019
1128
|
this.ctx.logger.warn(
|
|
1020
|
-
`session-rdb:
|
|
1129
|
+
`session-rdb: session stats for "${id}" not deleted (${describeError(error)})`,
|
|
1021
1130
|
);
|
|
1022
1131
|
}
|
|
1023
1132
|
}
|
|
@@ -1039,13 +1148,15 @@ export class SessionPersistenceRdb extends SessionPersistence {
|
|
|
1039
1148
|
{ meta: log.meta, inheritedEventCount: SessionLogOffset(log.inheritedEventCount) },
|
|
1040
1149
|
randomUUID(),
|
|
1041
1150
|
);
|
|
1042
|
-
const
|
|
1151
|
+
const appended = await appendEventTail(tx, log.meta, log.events, {
|
|
1043
1152
|
parentId: "",
|
|
1044
1153
|
nextSeq: 0,
|
|
1045
1154
|
});
|
|
1046
|
-
await tx.updateHead(id, headEventId, headSequence);
|
|
1155
|
+
await tx.updateHead(id, appended.headEventId, appended.headSequence);
|
|
1047
1156
|
await tx.bumpRevision(id, maxEventTime(log.events));
|
|
1048
1157
|
});
|
|
1158
|
+
// 重写换了事件行:被删掉的那批成了孤儿(引用标记要落回 0),本会话汇总按新事件重算。
|
|
1159
|
+
await this.rebuildSessionStats(id);
|
|
1049
1160
|
}
|
|
1050
1161
|
|
|
1051
1162
|
async listSnapshots(
|
|
@@ -1374,15 +1485,41 @@ function seedCoversPrefix(seed: readonly SessionEvent[], prefix: readonly Sessio
|
|
|
1374
1485
|
);
|
|
1375
1486
|
}
|
|
1376
1487
|
|
|
1377
|
-
/**
|
|
1378
|
-
function
|
|
1379
|
-
const counted = new Map<string,
|
|
1488
|
+
/** 一批事件折成活动计数的桶:只数四种类型,按本地日聚合成四项。 */
|
|
1489
|
+
function sessionCountBuckets(events: readonly SessionEvent[]): SessionCountBucket[] {
|
|
1490
|
+
const counted = new Map<string, SessionCountBucket>();
|
|
1380
1491
|
for (const event of events) {
|
|
1381
1492
|
if (!(COUNTED_EVENT_TYPES as readonly string[]).includes(event.type)) continue;
|
|
1382
1493
|
const day = localDayKey(event.time);
|
|
1383
|
-
const
|
|
1384
|
-
|
|
1385
|
-
|
|
1494
|
+
const bucket = counted.get(day) ?? { day, turns: 0, steps: 0, userInputs: 0, toolCalls: 0 };
|
|
1495
|
+
addActivityCount(bucket, event.type, 1);
|
|
1496
|
+
counted.set(day, bucket);
|
|
1497
|
+
}
|
|
1498
|
+
return [...counted.values()];
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
/** 一批用量行折成 token 桶:按「本地日 × 模型」聚合(模型未知落空串,与表口径一致)。 */
|
|
1502
|
+
function sessionUsageBuckets(usageRows: readonly EventUsageRow[]): SessionUsageBucket[] {
|
|
1503
|
+
const counted = new Map<string, SessionUsageBucket>();
|
|
1504
|
+
for (const row of usageRows) {
|
|
1505
|
+
const provider = row.fProvider ?? "";
|
|
1506
|
+
const model = row.fModel ?? "";
|
|
1507
|
+
const key = `${row.fDay}#${provider}#${model}`;
|
|
1508
|
+
const bucket = counted.get(key) ?? {
|
|
1509
|
+
day: row.fDay,
|
|
1510
|
+
provider,
|
|
1511
|
+
model,
|
|
1512
|
+
inputTokens: 0,
|
|
1513
|
+
outputTokens: 0,
|
|
1514
|
+
cacheReadTokens: 0,
|
|
1515
|
+
reasoningTokens: 0,
|
|
1516
|
+
totalTokens: 0,
|
|
1517
|
+
};
|
|
1518
|
+
bucket.inputTokens += row.fInputTokens;
|
|
1519
|
+
bucket.outputTokens += row.fOutputTokens;
|
|
1520
|
+
bucket.cacheReadTokens += row.fCacheReadTokens;
|
|
1521
|
+
bucket.reasoningTokens += row.fReasoningTokens;
|
|
1522
|
+
bucket.totalTokens += row.fTotalTokens;
|
|
1386
1523
|
counted.set(key, bucket);
|
|
1387
1524
|
}
|
|
1388
1525
|
return [...counted.values()];
|
|
@@ -1424,11 +1561,12 @@ async function appendEventTail(
|
|
|
1424
1561
|
events: readonly SessionEvent[],
|
|
1425
1562
|
anchor: { parentId: string; nextSeq: number },
|
|
1426
1563
|
reuse?: ReadonlyMap<number, string>,
|
|
1427
|
-
): Promise<{ headEventId: string; headSequence: number }> {
|
|
1564
|
+
): Promise<{ headEventId: string; headSequence: number; usageRows: EventUsageRow[] }> {
|
|
1428
1565
|
let parentId = anchor.parentId;
|
|
1429
1566
|
let nextSeq = anchor.nextSeq;
|
|
1430
1567
|
|
|
1431
1568
|
const eventRows: EventInsert[] = [];
|
|
1569
|
+
const usageRows: EventUsageRow[] = [];
|
|
1432
1570
|
const bridgeRows: Array<{
|
|
1433
1571
|
fSessionId: SessionId;
|
|
1434
1572
|
fEventId: string;
|
|
@@ -1447,7 +1585,7 @@ async function appendEventTail(
|
|
|
1447
1585
|
sourceEventSeqs?: unknown;
|
|
1448
1586
|
};
|
|
1449
1587
|
const { data, surfaceOp: _surfaceOp, sourceEventSeqs: _sourceEventSeqs, ...envelope } = raw;
|
|
1450
|
-
|
|
1588
|
+
const row: EventInsert = {
|
|
1451
1589
|
fEventId: eventId,
|
|
1452
1590
|
fParentId: parentId,
|
|
1453
1591
|
fType: event.type,
|
|
@@ -1458,7 +1596,11 @@ async function appendEventTail(
|
|
|
1458
1596
|
fEncoding: EVENT_ENCODING,
|
|
1459
1597
|
fData: JSON.stringify({ ...envelope, data }),
|
|
1460
1598
|
fCreatedAt: event.time,
|
|
1461
|
-
}
|
|
1599
|
+
};
|
|
1600
|
+
eventRows.push(row);
|
|
1601
|
+
// 用量行在这里一次折好(读侧维度一起物化),后端只负责插入,事务后旁路再据此累加汇总表。
|
|
1602
|
+
const usageRow = usageRowOf(row, meta.origin === "subagent");
|
|
1603
|
+
if (usageRow !== undefined) usageRows.push(usageRow);
|
|
1462
1604
|
}
|
|
1463
1605
|
const surfaceOp =
|
|
1464
1606
|
(event as SessionEvent<SurfaceEventType>).surfaceOp === undefined
|
|
@@ -1473,13 +1615,13 @@ async function appendEventTail(
|
|
|
1473
1615
|
parentId = eventId;
|
|
1474
1616
|
nextSeq++;
|
|
1475
1617
|
}
|
|
1476
|
-
if (eventRows.length > 0) await tx.insertEvents(eventRows);
|
|
1618
|
+
if (eventRows.length > 0) await tx.insertEvents(eventRows, usageRows);
|
|
1477
1619
|
await tx.insertBridges(bridgeRows);
|
|
1478
1620
|
|
|
1479
1621
|
if (events.some((event) => (event.type as string) === "session/title")) {
|
|
1480
1622
|
await tx.refreshTitle(meta.id);
|
|
1481
1623
|
}
|
|
1482
|
-
return { headEventId: parentId, headSequence: nextSeq - 1 };
|
|
1624
|
+
return { headEventId: parentId, headSequence: nextSeq - 1, usageRows };
|
|
1483
1625
|
}
|
|
1484
1626
|
|
|
1485
1627
|
export default SessionPersistenceRdb;
|
package/src/log.ts
CHANGED
|
@@ -4,6 +4,7 @@ import type { SessionFormatEvent, SessionFormatHeader } from "@deepseek-ai/dsh-s
|
|
|
4
4
|
import { sessionFormatCatalog } from "@deepseek-ai/dsh-session-format-catalog";
|
|
5
5
|
import type { SessionStorageMetadata } from "@deepseek-ai/dsh-session-persistence";
|
|
6
6
|
import type { EventRow, SessionRow } from "./backend.ts";
|
|
7
|
+
import { localDayKey } from "./usage.ts";
|
|
7
8
|
|
|
8
9
|
function normalizeSurfaceOp(value: unknown): SurfaceOp {
|
|
9
10
|
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
@@ -469,15 +470,15 @@ export function renameLegacyPtcEvents(events: SessionEvent[]): void {
|
|
|
469
470
|
* (v4 起 system 消息只认这个 kind);
|
|
470
471
|
* - `tool/result` 的消息:v3 是 user 角色、结果块包在 `content[0]`;v4 是 `role: 'tool'` + 顶层
|
|
471
472
|
* `toolCallId` + `content` 就是结果块;
|
|
472
|
-
* -
|
|
473
|
-
*
|
|
473
|
+
* - **本仓库自造、上游不认识的事件类型**(见 `OWN_EVENT_TYPES`:历史遗留的与我们当前在写的)标
|
|
474
|
+
* `ignorable: true`——v4 的校验对未知类型只接受可忽略信封。
|
|
474
475
|
*
|
|
475
476
|
* 只认这张白名单:**其它未知类型保持 fail loud**(那是「数据来自更新的 harness」的信号,不能吞掉)。
|
|
476
477
|
*/
|
|
477
478
|
export function normalizeToCurrentShape(events: SessionEvent[]): void {
|
|
478
479
|
for (const event of events) {
|
|
479
480
|
const type = (event as { type: string }).type;
|
|
480
|
-
if (
|
|
481
|
+
if (OWN_EVENT_TYPES.has(type)) {
|
|
481
482
|
(event as { ignorable?: true }).ignorable = true;
|
|
482
483
|
continue;
|
|
483
484
|
}
|
|
@@ -520,17 +521,45 @@ function liftToolResultMessage(message: unknown): void {
|
|
|
520
521
|
}
|
|
521
522
|
|
|
522
523
|
/**
|
|
523
|
-
*
|
|
524
|
-
*
|
|
524
|
+
* 落库前给**本仓库自造的事件类型**补上 `ignorable: true` 信封。
|
|
525
|
+
*
|
|
526
|
+
* 上游的持久化校验(`validateStoredEvents`)只放行「已知类型」或「带可忽略信封的未知类型」,我们的类型
|
|
527
|
+
* 按构造就在已知表之外——不补的话连**写入**都会被拒(`appendBatch` 里那道校验)。上游 `Session.append`
|
|
528
|
+
* 没有承载这个信封的口子(第三个参数只服务 surface 事件),所以这一笔由持久化层补:canonical log 里的
|
|
529
|
+
* 形状不变,落库的那一份带上信封。
|
|
530
|
+
* @param events - 即将落库的批次(`materializeAppendBatch` 的 JSON 快照副本,可写)。
|
|
531
|
+
*/
|
|
532
|
+
export function sealOwnEvents(events: readonly SessionEvent[]): void {
|
|
533
|
+
for (const event of events) {
|
|
534
|
+
if (OWN_EVENT_TYPES.has((event as { type: string }).type)) {
|
|
535
|
+
(event as { ignorable?: true }).ignorable = true;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* 本仓库自造、上游不认识的事件类型:读的时候标 `ignorable: true`,v4 的持久化校验才肯跳过它们
|
|
542
|
+
* (`validateStoredEvents` 只放行「已知类型」或「带可忽略信封的未知类型」)。
|
|
543
|
+
*
|
|
544
|
+
* 两类都在这张表里:
|
|
545
|
+
* - **历史遗留**:写过、后来连同机制一起删除的(`session-branch/version`);
|
|
546
|
+
* - **当前在写**:本仓库的插件自己声明的事件类型(`session-mode/selected`)——上游的
|
|
547
|
+
* `KNOWN_SESSION_EVENT_TYPES` 由上游仓库的声明生成,外部插件的事件按构造就在它之外。
|
|
548
|
+
*
|
|
549
|
+
* 新增自造事件类型时**必须**登记到这里,否则读路径 fail loud(真回归:`session-mode/selected` 漏登记,
|
|
550
|
+
* 会话打不开)。**不要**用「所有未知类型」代替(那会掩盖更新版本的未知事件——那种必须拒读)。
|
|
525
551
|
*/
|
|
526
|
-
export const
|
|
552
|
+
export const OWN_EVENT_TYPES: ReadonlySet<string> = new Set([
|
|
553
|
+
"session-branch/version",
|
|
554
|
+
"session-mode/selected",
|
|
555
|
+
]);
|
|
527
556
|
|
|
528
|
-
/**
|
|
529
|
-
*
|
|
530
|
-
export function
|
|
557
|
+
/** 这批事件是否需要按当前形状归一(旧代消息形状,或尚未标 `ignorable` 的自造事件类型)——写路径曾把回退
|
|
558
|
+
* 视图的结果以当前版本号落库,所以**版本号不可信**,读之前要按内容判一次。 */
|
|
559
|
+
export function needsShapeAdoption(events: readonly SessionEvent[]): boolean {
|
|
531
560
|
for (const event of events) {
|
|
532
561
|
const type = (event as { type: string }).type;
|
|
533
|
-
if (
|
|
562
|
+
if (OWN_EVENT_TYPES.has(type)) return true;
|
|
534
563
|
if (type === "system/message") {
|
|
535
564
|
const message = (event.data as unknown as Record<string, unknown>)["message"] as
|
|
536
565
|
| Record<string, unknown>
|
|
@@ -716,12 +745,15 @@ export function titleOfEventData(data: string): string | undefined {
|
|
|
716
745
|
}
|
|
717
746
|
}
|
|
718
747
|
|
|
719
|
-
/** 用量行的列(`t_event_usage
|
|
748
|
+
/** 用量行的列(`t_event_usage`):读侧维度(本地日与两个归属标记)在写入时一起物化。 */
|
|
720
749
|
export interface EventUsageRow {
|
|
721
750
|
fEventId: string;
|
|
722
751
|
fCreatedAt: number;
|
|
752
|
+
fDay: string;
|
|
723
753
|
fProvider: string | null;
|
|
724
754
|
fModel: string | null;
|
|
755
|
+
fReferenced: number;
|
|
756
|
+
fSubagent: number;
|
|
725
757
|
fInputTokens: number;
|
|
726
758
|
fOutputTokens: number;
|
|
727
759
|
fCacheReadTokens: number;
|
|
@@ -740,15 +772,21 @@ function textField(value: unknown): string | null {
|
|
|
740
772
|
/**
|
|
741
773
|
* 一条事件行的用量(只有带 `usage` 的 `assistant/message` 才有):写路径据此记录
|
|
742
774
|
* `t_event_usage`,统计因此不必逐行解析 JSON。两种 `f_data` 结构(带信封 / 老格式)都认。
|
|
775
|
+
* 读侧维度一起物化:本地日由事件时间算,`f_referenced` 直接 1(这一批事件行与桥接行同事务,
|
|
776
|
+
* 提交即被引用),`f_subagent` 取写入它的会话是否子代理。
|
|
743
777
|
* @param event - 待写入的事件行。
|
|
778
|
+
* @param subagent - 写入它的会话是否 `origin === 'subagent'`。
|
|
744
779
|
* @returns 用量行,或该事件没有用量时的 undefined。
|
|
745
780
|
*/
|
|
746
|
-
export function usageRowOf(
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
781
|
+
export function usageRowOf(
|
|
782
|
+
event: {
|
|
783
|
+
fEventId: string;
|
|
784
|
+
fCreatedAt: number;
|
|
785
|
+
fType: string;
|
|
786
|
+
fData: string;
|
|
787
|
+
},
|
|
788
|
+
subagent: boolean,
|
|
789
|
+
): EventUsageRow | undefined {
|
|
752
790
|
if (event.fType !== "assistant/message") return undefined;
|
|
753
791
|
let parsed: Record<string, unknown>;
|
|
754
792
|
try {
|
|
@@ -763,8 +801,11 @@ export function usageRowOf(event: {
|
|
|
763
801
|
const row: EventUsageRow = {
|
|
764
802
|
fEventId: event.fEventId,
|
|
765
803
|
fCreatedAt: event.fCreatedAt,
|
|
804
|
+
fDay: localDayKey(event.fCreatedAt),
|
|
766
805
|
fProvider: textField(source?.["provider"]),
|
|
767
806
|
fModel: textField(source?.["model"]),
|
|
807
|
+
fReferenced: 1,
|
|
808
|
+
fSubagent: subagent ? 1 : 0,
|
|
768
809
|
fInputTokens: numericField((usage as Record<string, unknown>)["inputTokens"]),
|
|
769
810
|
fOutputTokens: numericField((usage as Record<string, unknown>)["outputTokens"]),
|
|
770
811
|
fCacheReadTokens: numericField((usage as Record<string, unknown>)["cacheReadTokens"]),
|