@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
package/src/db/open.ts
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { chmod, mkdir, open as openFile, stat } from "node:fs/promises";
|
|
3
|
+
import { dirname, resolve } from "node:path";
|
|
4
|
+
import { ensureSchema, openDatabase as openRdbkitDatabase, runMigrations, type Database } from "@morlay/rdbkit";
|
|
5
|
+
import type { Backend } from "../backend.ts";
|
|
6
|
+
import { catalog, persistenceState } from "../entities/index.ts";
|
|
7
|
+
import { sessionMigrations } from "../migrations.ts";
|
|
8
|
+
import { createReadModel } from "../storage-takeover/read-model.ts";
|
|
9
|
+
import { createStorageRepository } from "../storage-takeover/repository.ts";
|
|
10
|
+
import {
|
|
11
|
+
DEFAULT_BUSY_TIMEOUT_MS,
|
|
12
|
+
SCHEMA_VERSION,
|
|
13
|
+
SESSION_PERSISTENCE_SQLITE_APPLICATION_ID,
|
|
14
|
+
type JournalMode,
|
|
15
|
+
} from "../version.ts";
|
|
16
|
+
import { backfillUsageTables } from "./stats.ts";
|
|
17
|
+
import { RdbBackend } from "../backend-rdb.ts";
|
|
18
|
+
|
|
19
|
+
export type BackendConfig =
|
|
20
|
+
| { type: "sqlite"; path: string; journalMode?: JournalMode; busyTimeout?: number }
|
|
21
|
+
| { type: "postgres"; connectionString: string; schema?: string };
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 写事务按介质串行:SQLite 的 `BEGIN IMMEDIATE` 并发会直接 busy,PG 的 `serializable` 并发会冲突;
|
|
26
|
+
* 同一个库(进程内多个实例)共用一条队列。
|
|
27
|
+
*/
|
|
28
|
+
const writeQueues = new Map<string, Promise<void>>();
|
|
29
|
+
|
|
30
|
+
function enqueueWrite<T>(key: string, fn: () => Promise<T>): Promise<T> {
|
|
31
|
+
const tail = writeQueues.get(key) ?? Promise.resolve();
|
|
32
|
+
const run = tail.then(fn);
|
|
33
|
+
writeQueues.set(
|
|
34
|
+
key,
|
|
35
|
+
run.then(
|
|
36
|
+
() => undefined,
|
|
37
|
+
() => undefined,
|
|
38
|
+
),
|
|
39
|
+
);
|
|
40
|
+
return run;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** 打开数据库、对齐结构、跑迁移、落 identity(连接、库身份、回填都在这一步)。 */
|
|
44
|
+
export async function openRdbBackend(config: BackendConfig): Promise<Backend> {
|
|
45
|
+
const schema = config.type === "postgres" ? (config.schema ?? "public") : "";
|
|
46
|
+
const actualPath =
|
|
47
|
+
config.type === "sqlite" && config.path !== ":memory:" ? resolve(config.path) : undefined;
|
|
48
|
+
if (actualPath !== undefined) {
|
|
49
|
+
// 库文件与目录的权限:目录 0700(不改变已有目录的 mode),文件 0600(WAL / SHM 继承)。
|
|
50
|
+
await mkdir(dirname(actualPath), { recursive: true, mode: 0o700 });
|
|
51
|
+
try {
|
|
52
|
+
// 独占创建:新建的文件固定 0600;已存在的文件保留它自己的 mode。
|
|
53
|
+
const handle = await openFile(actualPath, "wx", 0o600);
|
|
54
|
+
await handle.close();
|
|
55
|
+
await chmod(actualPath, 0o600);
|
|
56
|
+
} catch (error) {
|
|
57
|
+
if ((error as { code?: string }).code !== "EEXIST") throw error;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const database = await openRdbkitDatabase(
|
|
61
|
+
config.type === "sqlite"
|
|
62
|
+
? {
|
|
63
|
+
dialect: "sqlite",
|
|
64
|
+
path: actualPath ?? config.path,
|
|
65
|
+
...(config.journalMode === undefined ? {} : { journalMode: config.journalMode }),
|
|
66
|
+
// journal_mode 等校验通过后再切(拒绝旧库时不能改动它)。
|
|
67
|
+
applyJournalMode: false,
|
|
68
|
+
busyTimeout: config.busyTimeout ?? DEFAULT_BUSY_TIMEOUT_MS,
|
|
69
|
+
cacheSizeKb: 65536,
|
|
70
|
+
mmapSize: 268435456,
|
|
71
|
+
}
|
|
72
|
+
: {
|
|
73
|
+
dialect: "postgres",
|
|
74
|
+
connectionString: config.connectionString,
|
|
75
|
+
...(config.schema === undefined ? {} : { schema: config.schema }),
|
|
76
|
+
isolationLevel: "serializable",
|
|
77
|
+
},
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
let onDisk = 0;
|
|
82
|
+
if (config.type === "sqlite") {
|
|
83
|
+
onDisk = Number((await database.sqlitePragma("user_version")) ?? 0);
|
|
84
|
+
const applicationId = Number((await database.sqlitePragma("application_id")) ?? 0);
|
|
85
|
+
if (onDisk !== 0 && onDisk !== SCHEMA_VERSION) {
|
|
86
|
+
throw new Error(
|
|
87
|
+
`session database at "${config.path}" has schema version ${onDisk}, incompatible with this build (${SCHEMA_VERSION})`,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
if (onDisk >= 1 && applicationId !== SESSION_PERSISTENCE_SQLITE_APPLICATION_ID) {
|
|
91
|
+
throw new Error(
|
|
92
|
+
`session database at "${config.path}" has application id ${applicationId}, expected ${SESSION_PERSISTENCE_SQLITE_APPLICATION_ID}`,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
if (onDisk === 0) {
|
|
96
|
+
// 有表但没版本标记:旧库或别的程序的库,拒绝并要求显式迁移(空文件才允许初始化)。
|
|
97
|
+
const objects = await database.objectNames();
|
|
98
|
+
const foreignApplication =
|
|
99
|
+
applicationId !== 0 && applicationId !== SESSION_PERSISTENCE_SQLITE_APPLICATION_ID;
|
|
100
|
+
if (foreignApplication || objects.length > 0) {
|
|
101
|
+
throw new Error(
|
|
102
|
+
`session database at "${config.path}" has an unversioned schema or application identity`,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const writeKey =
|
|
109
|
+
config.type === "sqlite"
|
|
110
|
+
? `sqlite:${config.path}`
|
|
111
|
+
: `postgres:${config.connectionString}:${schema}`;
|
|
112
|
+
const inWrite = <T>(fn: (scoped: Database) => Promise<T>): Promise<T> =>
|
|
113
|
+
enqueueWrite(writeKey, () => database.transaction(fn));
|
|
114
|
+
|
|
115
|
+
if (actualPath !== undefined) {
|
|
116
|
+
// SQLite 自己创建 WAL / SHM / 回滚日志时用默认 mode(受 umask 影响),统一收成 0600。
|
|
117
|
+
for (const suffix of ["-wal", "-shm", "-journal"]) {
|
|
118
|
+
try {
|
|
119
|
+
await chmod(`${actualPath}${suffix}`, 0o600);
|
|
120
|
+
} catch {
|
|
121
|
+
// 文件还没创建(未进入 WAL / 没有回滚日志)就跳过。
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// 打标记、建表、迁移、回填、identity 是**一个事务**:任一步失败都不在库里留痕
|
|
127
|
+
//(旧版本 / 别人的库被拒时,连表与版本戳都不该出现)。
|
|
128
|
+
let storeId: string | undefined;
|
|
129
|
+
await inWrite(async (scoped) => {
|
|
130
|
+
if (config.type === "sqlite" && onDisk === 0) {
|
|
131
|
+
await scoped.setSqlitePragma("application_id", SESSION_PERSISTENCE_SQLITE_APPLICATION_ID);
|
|
132
|
+
await scoped.setSqlitePragma("user_version", SCHEMA_VERSION);
|
|
133
|
+
}
|
|
134
|
+
await ensureSchema(scoped, catalog);
|
|
135
|
+
await runMigrations(scoped, {
|
|
136
|
+
catalog,
|
|
137
|
+
migrations: sessionMigrations(),
|
|
138
|
+
});
|
|
139
|
+
await backfillUsageTables(scoped);
|
|
140
|
+
|
|
141
|
+
const state = persistenceState.table(scoped);
|
|
142
|
+
await state.insertIgnore({ f_singleton: 1, f_store_id: randomUUID() }, ["f_singleton"]);
|
|
143
|
+
for await (const row of state.select({ storeId: state.f_store_id })) storeId = row.storeId;
|
|
144
|
+
});
|
|
145
|
+
if (storeId === undefined || storeId.length === 0) {
|
|
146
|
+
throw new Error("session database has no valid store identity");
|
|
147
|
+
}
|
|
148
|
+
if (config.type === "sqlite") {
|
|
149
|
+
// 结构对齐成功后才切 journal_mode:被拒的库不能被改动。
|
|
150
|
+
await database.setSqlitePragma("journal_mode", (config.journalMode ?? "wal").toLowerCase());
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const storage = createStorageRepository({
|
|
154
|
+
database,
|
|
155
|
+
writeAtomically: (fn) => inWrite((scoped) => fn(scoped)),
|
|
156
|
+
});
|
|
157
|
+
const storeIdentity =
|
|
158
|
+
config.type === "sqlite"
|
|
159
|
+
? actualPath === undefined
|
|
160
|
+
? `memory:store:${storeId}`
|
|
161
|
+
: await fileIdentity(actualPath, storeId)
|
|
162
|
+
: `${config.type}:${config.connectionString}:${schema}:store:${storeId}`;
|
|
163
|
+
return new RdbBackend({
|
|
164
|
+
kind: config.type,
|
|
165
|
+
database,
|
|
166
|
+
storage,
|
|
167
|
+
readModel: createReadModel(database),
|
|
168
|
+
storeIdentity,
|
|
169
|
+
});
|
|
170
|
+
} catch (error) {
|
|
171
|
+
await database.close();
|
|
172
|
+
throw error;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** 库文件身份:`file:dev:ino:birthtimeNs:store:<storeId>`(符号链接指向同一 inode 时身份相同)。 */
|
|
177
|
+
async function fileIdentity(path: string, storeId: string): Promise<string> {
|
|
178
|
+
const identity = await stat(path, { bigint: true });
|
|
179
|
+
return `file:${identity.dev}:${identity.ino}:${identity.birthtimeNs}:store:${storeId}`;
|
|
180
|
+
}
|
package/src/db/stats.ts
ADDED
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
import {
|
|
2
|
+
E,
|
|
3
|
+
count,
|
|
4
|
+
sum,
|
|
5
|
+
type Database,
|
|
6
|
+
type OperandInput,
|
|
7
|
+
type Table,
|
|
8
|
+
type TableDef,
|
|
9
|
+
} from "@morlay/rdbkit";
|
|
10
|
+
import type {
|
|
11
|
+
SessionCountBucket,
|
|
12
|
+
SessionListRowsPage,
|
|
13
|
+
SessionListRowsQuery,
|
|
14
|
+
SessionUsageBucket,
|
|
15
|
+
} from "../backend.ts";
|
|
16
|
+
import {
|
|
17
|
+
eventUsage,
|
|
18
|
+
events,
|
|
19
|
+
sessionCounts,
|
|
20
|
+
sessionEvents,
|
|
21
|
+
sessions,
|
|
22
|
+
sessionUsage,
|
|
23
|
+
} from "../entities/index.ts";
|
|
24
|
+
import type { StorageRepository } from "../storage-takeover/types.ts";
|
|
25
|
+
import {
|
|
26
|
+
deleteSessionStats as deleteSessionStatsRows,
|
|
27
|
+
incrementSessionCounts as incrementSessionCountsRows,
|
|
28
|
+
incrementSessionUsage as incrementSessionUsageRows,
|
|
29
|
+
listSessionRows as listSessionRowsQuery,
|
|
30
|
+
rebuildSessionStats as rebuildSessionStatsRows,
|
|
31
|
+
refreshEventUsageFlags as refreshEventUsageFlagsRows,
|
|
32
|
+
usageActivityScope,
|
|
33
|
+
usageBuckets,
|
|
34
|
+
usageSessionCounts,
|
|
35
|
+
usageSessionTokens,
|
|
36
|
+
usageTokenScope,
|
|
37
|
+
} from "../session-ops.ts";
|
|
38
|
+
import type { UsageAggregate } from "../usage.ts";
|
|
39
|
+
import { COUNTED_EVENT_TYPES } from "../usage.ts";
|
|
40
|
+
|
|
41
|
+
function asText(value: unknown): string | null {
|
|
42
|
+
return typeof value === "string" ? value : null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function usageReport(database: Database, sinceMs?: number): Promise<UsageAggregate> {
|
|
46
|
+
const buckets = await usageBuckets(database, sinceMs);
|
|
47
|
+
const tokenScope = await usageTokenScope(database, sinceMs);
|
|
48
|
+
const activityScope = await usageActivityScope(database, sinceMs);
|
|
49
|
+
const tokenSessions = await usageSessionTokens(database, sinceMs);
|
|
50
|
+
const activitySessions = await usageSessionCounts(database, sinceMs);
|
|
51
|
+
|
|
52
|
+
const toToken = (row: Record<string, unknown>) => ({
|
|
53
|
+
inputTokens: Number(row["input_tokens"] ?? 0),
|
|
54
|
+
outputTokens: Number(row["output_tokens"] ?? 0),
|
|
55
|
+
cacheReadTokens: Number(row["cache_read_tokens"] ?? 0),
|
|
56
|
+
reasoningTokens: Number(row["reasoning_tokens"] ?? 0),
|
|
57
|
+
totalTokens: Number(row["total_tokens"] ?? 0),
|
|
58
|
+
});
|
|
59
|
+
const toActivity = (row: Record<string, unknown>) => ({
|
|
60
|
+
turns: Number(row["turns"] ?? 0),
|
|
61
|
+
steps: Number(row["steps"] ?? 0),
|
|
62
|
+
userInputs: Number(row["user_inputs"] ?? 0),
|
|
63
|
+
toolCalls: Number(row["tool_calls"] ?? 0),
|
|
64
|
+
});
|
|
65
|
+
const emptyToken = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, reasoningTokens: 0, totalTokens: 0 };
|
|
66
|
+
const emptyActivity = { turns: 0, steps: 0, userInputs: 0, toolCalls: 0 };
|
|
67
|
+
const addToken = (target: ReturnType<typeof toToken>, row: Record<string, unknown>) => {
|
|
68
|
+
const token = toToken(row);
|
|
69
|
+
target.inputTokens += token.inputTokens;
|
|
70
|
+
target.outputTokens += token.outputTokens;
|
|
71
|
+
target.cacheReadTokens += token.cacheReadTokens;
|
|
72
|
+
target.reasoningTokens += token.reasoningTokens;
|
|
73
|
+
target.totalTokens += token.totalTokens;
|
|
74
|
+
};
|
|
75
|
+
const addActivity = (target: ReturnType<typeof toActivity>, row: Record<string, unknown>) => {
|
|
76
|
+
const activity = toActivity(row);
|
|
77
|
+
target.turns += activity.turns;
|
|
78
|
+
target.steps += activity.steps;
|
|
79
|
+
target.userInputs += activity.userInputs;
|
|
80
|
+
target.toolCalls += activity.toolCalls;
|
|
81
|
+
};
|
|
82
|
+
const sum = (left: typeof emptyToken & typeof emptyActivity, right: typeof emptyToken & typeof emptyActivity) => ({
|
|
83
|
+
inputTokens: left.inputTokens + right.inputTokens,
|
|
84
|
+
outputTokens: left.outputTokens + right.outputTokens,
|
|
85
|
+
cacheReadTokens: left.cacheReadTokens + right.cacheReadTokens,
|
|
86
|
+
reasoningTokens: left.reasoningTokens + right.reasoningTokens,
|
|
87
|
+
totalTokens: left.totalTokens + right.totalTokens,
|
|
88
|
+
turns: left.turns + right.turns,
|
|
89
|
+
steps: left.steps + right.steps,
|
|
90
|
+
userInputs: left.userInputs + right.userInputs,
|
|
91
|
+
toolCalls: left.toolCalls + right.toolCalls,
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const human = { ...emptyToken, ...emptyActivity };
|
|
95
|
+
const subagent = { ...emptyToken, ...emptyActivity };
|
|
96
|
+
for (const row of tokenScope) {
|
|
97
|
+
addToken(Number(row.subagent) === 1 ? subagent : human, row);
|
|
98
|
+
}
|
|
99
|
+
for (const row of activityScope) {
|
|
100
|
+
addActivity(Number(row["subagent"]) === 1 ? subagent : human, row);
|
|
101
|
+
}
|
|
102
|
+
const activityBySession = new Map<string, ReturnType<typeof toActivity>>();
|
|
103
|
+
for (const row of activitySessions) {
|
|
104
|
+
activityBySession.set(String(row["session_id"]), toActivity(row));
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
totals: sum(sum({ ...emptyToken, ...emptyActivity }, human), subagent),
|
|
108
|
+
subagent,
|
|
109
|
+
human,
|
|
110
|
+
buckets: buckets.map((row) => ({
|
|
111
|
+
day: String(row.day),
|
|
112
|
+
provider: row.provider,
|
|
113
|
+
model: row.model,
|
|
114
|
+
subagent: Number(row.subagent) === 1,
|
|
115
|
+
...toToken(row as unknown as Record<string, unknown>),
|
|
116
|
+
})),
|
|
117
|
+
sessions: tokenSessions.map((row) => ({
|
|
118
|
+
sessionId: String(row["session_id"]),
|
|
119
|
+
title: asText(row["title"]),
|
|
120
|
+
subagent: Number(row["subagent"]) === 1 || row["subagent"] === true,
|
|
121
|
+
archived: Number(row["archived"]) === 1 || row["archived"] === true,
|
|
122
|
+
...toToken(row),
|
|
123
|
+
...(activityBySession.get(String(row["session_id"])) ?? emptyActivity),
|
|
124
|
+
})),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export async function listSessionRows(
|
|
129
|
+
database: Database,
|
|
130
|
+
storage: StorageRepository,
|
|
131
|
+
query?: SessionListRowsQuery,
|
|
132
|
+
): Promise<SessionListRowsPage> {
|
|
133
|
+
const needle = query?.query?.trim().toLowerCase() ?? "";
|
|
134
|
+
const index = await storage.listWorkspaceIndex();
|
|
135
|
+
const titleMatches =
|
|
136
|
+
needle === ""
|
|
137
|
+
? []
|
|
138
|
+
: [
|
|
139
|
+
...new Set(
|
|
140
|
+
index
|
|
141
|
+
.filter((entry) => entry.title.toLowerCase().includes(needle))
|
|
142
|
+
.flatMap((entry) => entry.sessionIds),
|
|
143
|
+
),
|
|
144
|
+
];
|
|
145
|
+
const { items, total } = await listSessionRowsQuery(database, {
|
|
146
|
+
needle,
|
|
147
|
+
titleMatches,
|
|
148
|
+
includeSubagents: query?.includeSubagents === true,
|
|
149
|
+
limit: query?.limit ?? 20,
|
|
150
|
+
offset: query?.offset ?? 0,
|
|
151
|
+
});
|
|
152
|
+
const workspaceOf = new Map<string, string>();
|
|
153
|
+
for (const entry of index) {
|
|
154
|
+
for (const sessionId of entry.sessionIds) {
|
|
155
|
+
if (!workspaceOf.has(sessionId)) workspaceOf.set(sessionId, entry.title);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return {
|
|
159
|
+
items: items.map((row) => ({
|
|
160
|
+
sessionId: String(row["session_id"]),
|
|
161
|
+
title: asText(row["title"]),
|
|
162
|
+
origin: asText(row["origin"]),
|
|
163
|
+
cwd: asText(row["cwd"]),
|
|
164
|
+
createdAt: Number(row["created_at"]),
|
|
165
|
+
updatedAt: Number(row["updated_at"]),
|
|
166
|
+
archived: Number(row["archived"]) === 1 || row["archived"] === true,
|
|
167
|
+
subagent: row["origin"] === "subagent",
|
|
168
|
+
workspace: workspaceOf.get(String(row["session_id"])) ?? null,
|
|
169
|
+
})),
|
|
170
|
+
total,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export async function incrementSessionUsage(
|
|
175
|
+
database: Database,
|
|
176
|
+
id: string,
|
|
177
|
+
buckets: readonly SessionUsageBucket[],
|
|
178
|
+
): Promise<void> {
|
|
179
|
+
await incrementSessionUsageRows(database, id, buckets);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export async function incrementSessionCounts(
|
|
183
|
+
database: Database,
|
|
184
|
+
id: string,
|
|
185
|
+
buckets: readonly SessionCountBucket[],
|
|
186
|
+
): Promise<void> {
|
|
187
|
+
await incrementSessionCountsRows(database, id, buckets);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export async function deleteSessionStats(database: Database, id: string): Promise<void> {
|
|
191
|
+
await deleteSessionStatsRows(database, id);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export async function rebuildSessionStats(database: Database, id: string): Promise<void> {
|
|
195
|
+
const links = sessionEvents.table(database);
|
|
196
|
+
const eventsTable = events.table(database);
|
|
197
|
+
const usageTable = eventUsage.table(database);
|
|
198
|
+
|
|
199
|
+
// 用量:本会话桥接行 × 用量行,按 (本地日, provider, model) 聚合。
|
|
200
|
+
const usage = links
|
|
201
|
+
.select({
|
|
202
|
+
f_session_id: links.f_session_id,
|
|
203
|
+
f_day: usageTable.f_day,
|
|
204
|
+
f_provider: E.coalesce(usageTable.f_provider, ""),
|
|
205
|
+
f_model: E.coalesce(usageTable.f_model, ""),
|
|
206
|
+
f_input_tokens: sum("f_input_tokens", "t_event_usage"),
|
|
207
|
+
f_output_tokens: sum("f_output_tokens", "t_event_usage"),
|
|
208
|
+
f_cache_read_tokens: sum("f_cache_read_tokens", "t_event_usage"),
|
|
209
|
+
f_reasoning_tokens: sum("f_reasoning_tokens", "t_event_usage"),
|
|
210
|
+
f_total_tokens: sum("f_total_tokens", "t_event_usage"),
|
|
211
|
+
})
|
|
212
|
+
.join(eventUsage, E.eq(usageTable.f_event_id, links.f_event_id))
|
|
213
|
+
.where(E.eq(links.f_session_id, id))
|
|
214
|
+
// 按列分组而不是按 `COALESCE` 表达式:PostgreSQL 的 GROUP BY 匹配按语法树,参数化的表达式
|
|
215
|
+
// 在 SELECT 与 GROUP BY 里是两个不同的 Param 节点,会被判成「不在 GROUP BY 里」。
|
|
216
|
+
.groupBy(links.f_session_id, usageTable.f_day, usageTable.f_provider, usageTable.f_model)
|
|
217
|
+
.toFragment();
|
|
218
|
+
|
|
219
|
+
// 活动计数:本会话桥接行 × 事件行,按本地日聚合四项。
|
|
220
|
+
const counts = links
|
|
221
|
+
.select({
|
|
222
|
+
f_session_id: links.f_session_id,
|
|
223
|
+
f_day: E.localDay(eventsTable.f_created_at),
|
|
224
|
+
f_turns: E.countIf(E.eq(eventsTable.f_type, "turn/start")),
|
|
225
|
+
f_steps: E.countIf(E.eq(eventsTable.f_type, "step/start")),
|
|
226
|
+
f_user_inputs: E.countIf(E.eq(eventsTable.f_type, "user/message")),
|
|
227
|
+
f_tool_calls: E.countIf(E.eq(eventsTable.f_type, "tool/call")),
|
|
228
|
+
})
|
|
229
|
+
.join(events, E.eq(eventsTable.f_event_id, links.f_event_id))
|
|
230
|
+
.where(
|
|
231
|
+
E.and(
|
|
232
|
+
E.eq(links.f_session_id, id),
|
|
233
|
+
E.inList(eventsTable.f_type, [...COUNTED_EVENT_TYPES]),
|
|
234
|
+
),
|
|
235
|
+
)
|
|
236
|
+
.groupBy(links.f_session_id, E.localDay(eventsTable.f_created_at))
|
|
237
|
+
.toFragment();
|
|
238
|
+
|
|
239
|
+
await rebuildSessionStatsRows(database, id, { usage, counts });
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export async function refreshEventUsageFlags(
|
|
243
|
+
database: Database,
|
|
244
|
+
eventIds: readonly string[],
|
|
245
|
+
): Promise<void> {
|
|
246
|
+
const links = sessionEvents.table(database);
|
|
247
|
+
const sessionsTable = sessions.table(database);
|
|
248
|
+
// 相关子查询:外层是被更新的用量行的 `f_event_id`(`UPDATE` 的目标表没有别名,用表名限定)。
|
|
249
|
+
const refersTo = (eventId: OperandInput) => E.eq(links.f_event_id, eventId);
|
|
250
|
+
await refreshEventUsageFlagsRows(
|
|
251
|
+
database,
|
|
252
|
+
eventIds,
|
|
253
|
+
{
|
|
254
|
+
referenced: E.toInt(
|
|
255
|
+
E.exists(
|
|
256
|
+
links
|
|
257
|
+
.select({ one: links.f_event_id })
|
|
258
|
+
.where(refersTo(E.column("f_event_id", "t_event_usage")))
|
|
259
|
+
.toFragment(),
|
|
260
|
+
),
|
|
261
|
+
),
|
|
262
|
+
subagent: E.toInt(
|
|
263
|
+
E.exists(
|
|
264
|
+
links
|
|
265
|
+
.select({ one: links.f_event_id })
|
|
266
|
+
.join(sessions, E.eq(sessionsTable.f_session_id, links.f_session_id))
|
|
267
|
+
.where(
|
|
268
|
+
E.and(
|
|
269
|
+
refersTo(E.column("f_event_id", "t_event_usage")),
|
|
270
|
+
E.eq(sessionsTable.f_origin, "subagent"),
|
|
271
|
+
),
|
|
272
|
+
)
|
|
273
|
+
.toFragment(),
|
|
274
|
+
),
|
|
275
|
+
),
|
|
276
|
+
},
|
|
277
|
+
400,
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** 统计表为空时按事件表全量回填(幂等;迁移删表重建、手动清空后自愈)。 */
|
|
282
|
+
export async function backfillUsageTables(database: Database): Promise<void> {
|
|
283
|
+
const links = sessionEvents.table(database);
|
|
284
|
+
const eventsTable = events.table(database);
|
|
285
|
+
const sessionsTable = sessions.table(database);
|
|
286
|
+
const usageTable = eventUsage.table(database);
|
|
287
|
+
|
|
288
|
+
if (await isEmpty(database, eventUsage)) {
|
|
289
|
+
const hasUsage = (path: string) => E.isNotNull(E.jsonText(eventsTable.f_data, path));
|
|
290
|
+
const text = (path: string) => E.jsonText(eventsTable.f_data, path);
|
|
291
|
+
const int = (path: string) => E.coalesce(E.jsonInt(eventsTable.f_data, path), 0);
|
|
292
|
+
const usagePaths = (field: string) => [
|
|
293
|
+
`$.data.usage.${field}`,
|
|
294
|
+
`$.usage.${field}`,
|
|
295
|
+
] as const;
|
|
296
|
+
const input = usagePaths("inputTokens");
|
|
297
|
+
const output = usagePaths("outputTokens");
|
|
298
|
+
const cacheRead = usagePaths("cacheReadTokens");
|
|
299
|
+
const reasoning = usagePaths("reasoningTokens");
|
|
300
|
+
const total = usagePaths("totalTokens");
|
|
301
|
+
const referenced = (eventId: OperandInput) =>
|
|
302
|
+
E.toInt(
|
|
303
|
+
E.exists(
|
|
304
|
+
links
|
|
305
|
+
.select({ one: links.f_event_id })
|
|
306
|
+
.where(E.eq(links.f_event_id, eventId))
|
|
307
|
+
.toFragment(),
|
|
308
|
+
),
|
|
309
|
+
);
|
|
310
|
+
const subagent = (eventId: OperandInput) =>
|
|
311
|
+
E.toInt(
|
|
312
|
+
E.exists(
|
|
313
|
+
links
|
|
314
|
+
.select({ one: links.f_event_id })
|
|
315
|
+
.join(sessions, E.eq(sessionsTable.f_session_id, links.f_session_id))
|
|
316
|
+
.where(E.and(E.eq(links.f_event_id, eventId), E.eq(sessionsTable.f_origin, "subagent")))
|
|
317
|
+
.toFragment(),
|
|
318
|
+
),
|
|
319
|
+
);
|
|
320
|
+
|
|
321
|
+
const source = eventsTable
|
|
322
|
+
.select({
|
|
323
|
+
f_event_id: eventsTable.f_event_id,
|
|
324
|
+
f_created_at: eventsTable.f_created_at,
|
|
325
|
+
f_day: E.localDay(eventsTable.f_created_at),
|
|
326
|
+
f_provider: E.coalesce(
|
|
327
|
+
text("$.data.message.source.provider"),
|
|
328
|
+
text("$.message.source.provider"),
|
|
329
|
+
),
|
|
330
|
+
f_model: E.coalesce(text("$.data.message.source.model"), text("$.message.source.model")),
|
|
331
|
+
f_referenced: referenced(E.column("f_event_id", "t_events")),
|
|
332
|
+
f_subagent: subagent(E.column("f_event_id", "t_events")),
|
|
333
|
+
f_input_tokens: E.coalesce(int(input[0]), int(input[1]), 0),
|
|
334
|
+
f_output_tokens: E.coalesce(int(output[0]), int(output[1]), 0),
|
|
335
|
+
f_cache_read_tokens: E.coalesce(int(cacheRead[0]), int(cacheRead[1]), 0),
|
|
336
|
+
f_reasoning_tokens: E.coalesce(int(reasoning[0]), int(reasoning[1]), 0),
|
|
337
|
+
f_total_tokens: E.coalesce(int(total[0]), int(total[1]), 0),
|
|
338
|
+
})
|
|
339
|
+
.where(
|
|
340
|
+
E.and(
|
|
341
|
+
E.eq(eventsTable.f_type, "assistant/message"),
|
|
342
|
+
E.or(hasUsage("$.data.usage"), hasUsage("$.usage")),
|
|
343
|
+
),
|
|
344
|
+
)
|
|
345
|
+
.toFragment();
|
|
346
|
+
|
|
347
|
+
await usageTable.insertFromSelect(
|
|
348
|
+
[
|
|
349
|
+
"f_event_id",
|
|
350
|
+
"f_created_at",
|
|
351
|
+
"f_day",
|
|
352
|
+
"f_provider",
|
|
353
|
+
"f_model",
|
|
354
|
+
"f_referenced",
|
|
355
|
+
"f_subagent",
|
|
356
|
+
"f_input_tokens",
|
|
357
|
+
"f_output_tokens",
|
|
358
|
+
"f_cache_read_tokens",
|
|
359
|
+
"f_reasoning_tokens",
|
|
360
|
+
"f_total_tokens",
|
|
361
|
+
],
|
|
362
|
+
source,
|
|
363
|
+
{ conflict: ["f_event_id"] },
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
if (await isEmpty(database, sessionUsage)) {
|
|
368
|
+
const source = links
|
|
369
|
+
.select({
|
|
370
|
+
f_session_id: links.f_session_id,
|
|
371
|
+
f_day: usageTable.f_day,
|
|
372
|
+
f_provider: E.coalesce(usageTable.f_provider, ""),
|
|
373
|
+
f_model: E.coalesce(usageTable.f_model, ""),
|
|
374
|
+
f_input_tokens: sum("f_input_tokens", "t_event_usage"),
|
|
375
|
+
f_output_tokens: sum("f_output_tokens", "t_event_usage"),
|
|
376
|
+
f_cache_read_tokens: sum("f_cache_read_tokens", "t_event_usage"),
|
|
377
|
+
f_reasoning_tokens: sum("f_reasoning_tokens", "t_event_usage"),
|
|
378
|
+
f_total_tokens: sum("f_total_tokens", "t_event_usage"),
|
|
379
|
+
})
|
|
380
|
+
.join(eventUsage, E.eq(usageTable.f_event_id, links.f_event_id))
|
|
381
|
+
// 同上:分组用列,`COALESCE` 只留在投影里。
|
|
382
|
+
.groupBy(links.f_session_id, usageTable.f_day, usageTable.f_provider, usageTable.f_model)
|
|
383
|
+
.toFragment();
|
|
384
|
+
await sessionUsage.table(database).insertFromSelect(
|
|
385
|
+
[
|
|
386
|
+
"f_session_id",
|
|
387
|
+
"f_day",
|
|
388
|
+
"f_provider",
|
|
389
|
+
"f_model",
|
|
390
|
+
"f_input_tokens",
|
|
391
|
+
"f_output_tokens",
|
|
392
|
+
"f_cache_read_tokens",
|
|
393
|
+
"f_reasoning_tokens",
|
|
394
|
+
"f_total_tokens",
|
|
395
|
+
],
|
|
396
|
+
source,
|
|
397
|
+
{ conflict: ["f_session_id", "f_day", "f_provider", "f_model"] },
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
if (await isEmpty(database, sessionCounts)) {
|
|
402
|
+
const source = links
|
|
403
|
+
.select({
|
|
404
|
+
f_session_id: links.f_session_id,
|
|
405
|
+
f_day: E.localDay(eventsTable.f_created_at),
|
|
406
|
+
f_turns: E.countIf(E.eq(eventsTable.f_type, "turn/start")),
|
|
407
|
+
f_steps: E.countIf(E.eq(eventsTable.f_type, "step/start")),
|
|
408
|
+
f_user_inputs: E.countIf(E.eq(eventsTable.f_type, "user/message")),
|
|
409
|
+
f_tool_calls: E.countIf(E.eq(eventsTable.f_type, "tool/call")),
|
|
410
|
+
})
|
|
411
|
+
.join(events, E.eq(eventsTable.f_event_id, links.f_event_id))
|
|
412
|
+
.where(E.inList(eventsTable.f_type, [...COUNTED_EVENT_TYPES]))
|
|
413
|
+
.groupBy(links.f_session_id, E.localDay(eventsTable.f_created_at))
|
|
414
|
+
.toFragment();
|
|
415
|
+
await sessionCounts.table(database).insertFromSelect(
|
|
416
|
+
["f_session_id", "f_day", "f_turns", "f_steps", "f_user_inputs", "f_tool_calls"],
|
|
417
|
+
source,
|
|
418
|
+
{ conflict: ["f_session_id", "f_day"] },
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
async function isEmpty<T extends TableDef>(database: Database, definition: Table<T>): Promise<boolean> {
|
|
424
|
+
for await (const row of definition.table(database).select({ n: count() })) {
|
|
425
|
+
// PostgreSQL 的 COUNT 返回 int8 → BigInt,`0n === 0` 是 false,必须显式转。
|
|
426
|
+
return Number(row.n) === 0;
|
|
427
|
+
}
|
|
428
|
+
return true;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* 整理数据的一步:三张派生统计表**清空后全量重算**。
|
|
433
|
+
*
|
|
434
|
+
* 这三张表本来就是可销毁重建的派生数据(见 ADR-统计衍生表物化归属与汇总),所以重算走「删了再回填」
|
|
435
|
+
* 这条与打开时自愈相同的路径,不逐会话补差。
|
|
436
|
+
* @param database - 执行面。
|
|
437
|
+
* @returns 重建涉及的会话数。
|
|
438
|
+
*/
|
|
439
|
+
export async function rebuildAllStats(database: Database): Promise<number> {
|
|
440
|
+
await sessionUsage.table(database).delete();
|
|
441
|
+
await sessionCounts.table(database).delete();
|
|
442
|
+
await eventUsage.table(database).delete();
|
|
443
|
+
await backfillUsageTables(database);
|
|
444
|
+
let total = 0;
|
|
445
|
+
for await (const row of sessions.table(database).select({ n: count() })) {
|
|
446
|
+
total = Number(row.n);
|
|
447
|
+
}
|
|
448
|
+
return total;
|
|
449
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 会话删除的失败语义(叶子模块)。
|
|
3
|
+
*
|
|
4
|
+
* 放成叶子是为了断开 `session-store ↔ deletion` 的运行时环:HTTP 路由只依赖这个错误类型,不再值引入整个服务。
|
|
5
|
+
现在 `deletion.ts` 对它只剩 `import type`,运行时环为 0。
|
|
6
|
+
*/
|
|
7
|
+
export type SessionDeletionErrorCode = "SESSION_NOT_FOUND" | "SESSION_NOT_ARCHIVED" | "SESSION_LIVE";
|
|
8
|
+
|
|
9
|
+
export class SessionDeletionError extends Error {
|
|
10
|
+
constructor(
|
|
11
|
+
message: string,
|
|
12
|
+
readonly code: SessionDeletionErrorCode,
|
|
13
|
+
) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.name = "SessionDeletionError";
|
|
16
|
+
}
|
|
17
|
+
}
|