@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
|
@@ -1,19 +1,12 @@
|
|
|
1
1
|
import { StorageError } from "@deepseek-ai/dsh-storage";
|
|
2
|
-
import type { SessionId } from "@deepseek-ai/dsh-session";
|
|
3
|
-
import type { ScheduleTask } from "@deepseek-ai/dsh-schedule";
|
|
4
|
-
import type { WorkspaceId } from "@deepseek-ai/dsh-workspace";
|
|
5
2
|
import type { KvFacet, KvUnit, KvUnitDescriptor, StorageBackend } from "@deepseek-ai/dsh-storage";
|
|
6
|
-
import type { StorageRepository
|
|
3
|
+
import type { StorageRepository } from "./types.ts";
|
|
7
4
|
|
|
8
5
|
export const RDB_STORAGE_BACKEND = "rdb";
|
|
9
6
|
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
const WORKSPACE_UNIT = "workspace";
|
|
14
|
-
const WORKSPACE_TABLE = "workspaces";
|
|
15
|
-
const SCHEDULE_UNIT = "schedule";
|
|
16
|
-
const SCHEDULE_TABLE = "tasks";
|
|
7
|
+
// 本后端服务**所有** storage 域:`(域, 表, 键)` 一行落 `t_storage_records`,global 单例用空表名与空键。
|
|
8
|
+
// 上游 `KvUnit` 的契约只有「按表名读写记录 + 一个 global」这几种操作,行存储直接承载它,所以再加一个域
|
|
9
|
+
// (不论它自带几张表、还是只有 global)都不需要新表或新映射——域名就是行的一部分。
|
|
17
10
|
|
|
18
11
|
// 本后端对 unit 的内部约定:关闭时把域名让回后端(上游 `KvUnit` 没有这个入口,它归"谁能再开一次"这件事)。
|
|
19
12
|
interface ReleasableKvUnit extends KvUnit {
|
|
@@ -24,29 +17,31 @@ export class RdbStorageBackend implements StorageBackend {
|
|
|
24
17
|
readonly kv: KvFacet = { open: (descriptor) => this.openUnit(descriptor) };
|
|
25
18
|
|
|
26
19
|
private readonly open = new Map<string, ReleasableKvUnit>();
|
|
20
|
+
// 正在打开的名字:`open()` 要先等介质,占位必须在 await 之前,否则并发 open 会双双通过检查。
|
|
21
|
+
private readonly opening = new Set<string>();
|
|
27
22
|
private closed = false;
|
|
28
23
|
|
|
29
|
-
constructor(private readonly repository: StorageRepository) {}
|
|
24
|
+
constructor(private readonly repository: () => Promise<StorageRepository>) {}
|
|
30
25
|
|
|
31
26
|
private async openUnit(descriptor: KvUnitDescriptor): Promise<KvUnit> {
|
|
32
27
|
if (this.closed) throw new StorageError("closed", "rdb storage backend is closed");
|
|
33
|
-
if (descriptor.
|
|
34
|
-
throw new Error(
|
|
35
|
-
`rdb storage backend serves only the 'single' layout (domain '${descriptor.name}' ` +
|
|
36
|
-
`declares '${descriptor.layout}')`,
|
|
37
|
-
);
|
|
38
|
-
}
|
|
39
|
-
if (this.open.has(descriptor.name)) {
|
|
28
|
+
if (this.open.has(descriptor.name) || this.opening.has(descriptor.name)) {
|
|
40
29
|
throw new Error(`kv unit '${descriptor.name}' is already open (double-open is a caller bug)`);
|
|
41
30
|
}
|
|
31
|
+
this.opening.add(descriptor.name);
|
|
42
32
|
|
|
43
|
-
const
|
|
33
|
+
const repository = await this.repository();
|
|
34
|
+
const unit = new RdbKvUnit(repository, descriptor);
|
|
44
35
|
this.open.set(descriptor.name, unit);
|
|
36
|
+
this.opening.delete(descriptor.name);
|
|
45
37
|
try {
|
|
46
|
-
const stored = await
|
|
38
|
+
const stored = await repository.readUnitVersion(descriptor.name);
|
|
39
|
+
const accepted =
|
|
40
|
+
stored === descriptor.version ||
|
|
41
|
+
(descriptor.compatibleVersions ?? []).includes(stored ?? -1);
|
|
47
42
|
if (stored === undefined) {
|
|
48
|
-
await
|
|
49
|
-
} else if (
|
|
43
|
+
await repository.insertUnitVersion(descriptor.name, descriptor.version);
|
|
44
|
+
} else if (!accepted) {
|
|
50
45
|
throw new StorageError(
|
|
51
46
|
"version-mismatch",
|
|
52
47
|
`kv unit '${descriptor.name}' is stamped version ${stored} on the medium, ` +
|
|
@@ -55,6 +50,7 @@ export class RdbStorageBackend implements StorageBackend {
|
|
|
55
50
|
}
|
|
56
51
|
} catch (error) {
|
|
57
52
|
this.open.delete(descriptor.name);
|
|
53
|
+
this.opening.delete(descriptor.name);
|
|
58
54
|
throw error;
|
|
59
55
|
}
|
|
60
56
|
unit.onClose(() => {
|
|
@@ -63,31 +59,6 @@ export class RdbStorageBackend implements StorageBackend {
|
|
|
63
59
|
return unit;
|
|
64
60
|
}
|
|
65
61
|
|
|
66
|
-
private unitFor(descriptor: KvUnitDescriptor): ReleasableKvUnit {
|
|
67
|
-
if (descriptor.name === WORKSPACE_UNIT) {
|
|
68
|
-
if (!descriptor.tables.includes(WORKSPACE_TABLE) || descriptor.hasGlobal !== true) {
|
|
69
|
-
throw new Error(
|
|
70
|
-
`rdb storage backend expects the '${WORKSPACE_UNIT}' domain shape ` +
|
|
71
|
-
`(table '${WORKSPACE_TABLE}' plus a global slot)`,
|
|
72
|
-
);
|
|
73
|
-
}
|
|
74
|
-
return new WorkspaceKvUnit(this.repository, descriptor);
|
|
75
|
-
}
|
|
76
|
-
if (descriptor.name === SCHEDULE_UNIT) {
|
|
77
|
-
if (!descriptor.tables.includes(SCHEDULE_TABLE) || descriptor.hasGlobal !== false) {
|
|
78
|
-
throw new Error(
|
|
79
|
-
`rdb storage backend expects the '${SCHEDULE_UNIT}' domain shape ` +
|
|
80
|
-
`(table '${SCHEDULE_TABLE}' without a global slot)`,
|
|
81
|
-
);
|
|
82
|
-
}
|
|
83
|
-
return new ScheduleKvUnit(this.repository, descriptor);
|
|
84
|
-
}
|
|
85
|
-
throw new Error(
|
|
86
|
-
`rdb storage backend serves only the '${WORKSPACE_UNIT}' and '${SCHEDULE_UNIT}' domains ` +
|
|
87
|
-
`(requested '${descriptor.name}')`,
|
|
88
|
-
);
|
|
89
|
-
}
|
|
90
|
-
|
|
91
62
|
async close(): Promise<void> {
|
|
92
63
|
this.closed = true;
|
|
93
64
|
const units = [...this.open.values()];
|
|
@@ -96,8 +67,7 @@ export class RdbStorageBackend implements StorageBackend {
|
|
|
96
67
|
}
|
|
97
68
|
}
|
|
98
69
|
|
|
99
|
-
// 一个
|
|
100
|
-
// 域自己的读写在子类里(`unitFor` 的返回类型就地校验它们合得上 `KvUnit`)。
|
|
70
|
+
// 一个 unit 的公共形态:关闭后拒写、关闭前把已接受的写排干、关闭回调把名字让回给后端。
|
|
101
71
|
abstract class TrackedKvUnit {
|
|
102
72
|
private closed = false;
|
|
103
73
|
|
|
@@ -110,9 +80,6 @@ abstract class TrackedKvUnit {
|
|
|
110
80
|
protected readonly descriptor: KvUnitDescriptor,
|
|
111
81
|
) {}
|
|
112
82
|
|
|
113
|
-
// 这个 unit 认的表名(由各自的域决定)。
|
|
114
|
-
protected abstract readonly tables: readonly string[];
|
|
115
|
-
|
|
116
83
|
onClose(release: () => void): void {
|
|
117
84
|
this.onClosed = release;
|
|
118
85
|
}
|
|
@@ -146,126 +113,57 @@ abstract class TrackedKvUnit {
|
|
|
146
113
|
}
|
|
147
114
|
|
|
148
115
|
protected assertTable(table: string): void {
|
|
149
|
-
if (!this.tables.includes(table)) {
|
|
116
|
+
if (!this.descriptor.tables.includes(table)) {
|
|
150
117
|
throw new Error(`unit '${this.descriptor.name}' does not declare table '${table}'`);
|
|
151
118
|
}
|
|
152
119
|
}
|
|
153
120
|
}
|
|
154
121
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
122
|
+
// 行存储:记录按 `(域, 表, 键)` 一行,global 单例是空表名的那一行。`single` 与 `per-record` 两种布局
|
|
123
|
+
// 在这里同构(一行一条记录),所以布局不再拒绝——版本仍按域戳。
|
|
124
|
+
class RdbKvUnit extends TrackedKvUnit {
|
|
158
125
|
async loadAll(): Promise<{ tables: Record<string, Record<string, unknown>>; global: unknown }> {
|
|
159
126
|
this.assertOpen();
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
-
|
|
127
|
+
const tables: Record<string, Record<string, unknown>> = Object.create(null) as Record<
|
|
128
|
+
string,
|
|
129
|
+
Record<string, unknown>
|
|
130
|
+
>;
|
|
131
|
+
for (const record of await this.repository.listDomainRecords(this.descriptor.name)) {
|
|
132
|
+
(tables[record.table] ??= {})[record.key] = record.value;
|
|
163
133
|
}
|
|
164
|
-
|
|
165
|
-
|
|
134
|
+
// 声明了但从未写过的表也要出现在快照里(域层按 spec.tables 逐表读)。
|
|
135
|
+
for (const table of this.descriptor.tables) tables[table] ??= {};
|
|
136
|
+
return {
|
|
137
|
+
tables,
|
|
138
|
+
global: this.descriptor.hasGlobal
|
|
139
|
+
? await this.repository.readDomainGlobal(this.descriptor.name)
|
|
140
|
+
: null,
|
|
141
|
+
};
|
|
166
142
|
}
|
|
167
143
|
|
|
168
144
|
async putRecord(table: string, key: string, value: unknown): Promise<void> {
|
|
169
145
|
this.assertOpen();
|
|
170
146
|
this.assertTable(table);
|
|
171
|
-
await this.track(this.repository.
|
|
147
|
+
await this.track(this.repository.putDomainRecord(this.descriptor.name, table, key, value));
|
|
172
148
|
}
|
|
173
149
|
|
|
174
150
|
async deleteRecord(table: string, key: string): Promise<void> {
|
|
175
151
|
this.assertOpen();
|
|
176
152
|
this.assertTable(table);
|
|
177
|
-
await this.track(this.repository.
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
async setGlobal(value: unknown): Promise<void> {
|
|
181
|
-
this.assertOpen();
|
|
182
|
-
await this.track(this.repository.writeWorkspaceState(workspaceStateOf(value)));
|
|
153
|
+
await this.track(this.repository.deleteDomainRecord(this.descriptor.name, table, key));
|
|
183
154
|
}
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
class ScheduleKvUnit extends TrackedKvUnit {
|
|
187
|
-
protected readonly tables = [SCHEDULE_TABLE];
|
|
188
155
|
|
|
189
|
-
async
|
|
190
|
-
this.assertOpen();
|
|
191
|
-
const records: Record<string, unknown> = Object.create(null) as Record<string, unknown>;
|
|
192
|
-
for (const { id, task } of await this.repository.listScheduleTasks()) {
|
|
193
|
-
records[id] = task;
|
|
194
|
-
}
|
|
195
|
-
// 这个域没有 global 槽(`scheduleDomain` 不声明):`null` = "从未写过",域层不会读它。
|
|
196
|
-
return { tables: { [SCHEDULE_TABLE]: records }, global: null };
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
async putRecord(table: string, key: string, value: unknown): Promise<void> {
|
|
156
|
+
async deleteRecords(table: string, keys: readonly string[]): Promise<void> {
|
|
200
157
|
this.assertOpen();
|
|
201
158
|
this.assertTable(table);
|
|
202
|
-
await this.track(this.repository.
|
|
159
|
+
await this.track(this.repository.deleteDomainRecords(this.descriptor.name, table, keys));
|
|
203
160
|
}
|
|
204
161
|
|
|
205
|
-
async
|
|
162
|
+
async setGlobal(value: unknown): Promise<void> {
|
|
206
163
|
this.assertOpen();
|
|
207
|
-
this.
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
setGlobal(_value: unknown): Promise<void> {
|
|
212
|
-
throw new Error(`unit '${this.descriptor.name}' declares no global slot`);
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
function workspaceRecordOf(value: unknown): WorkspaceRecord {
|
|
217
|
-
const record = value as Partial<WorkspaceRecord> | null;
|
|
218
|
-
if (
|
|
219
|
-
typeof record !== "object" ||
|
|
220
|
-
record === null ||
|
|
221
|
-
typeof record.path !== "string" ||
|
|
222
|
-
typeof record.title !== "string" ||
|
|
223
|
-
!Array.isArray(record.sessionIds) ||
|
|
224
|
-
typeof record.createdAt !== "string" ||
|
|
225
|
-
typeof record.updatedAt !== "string"
|
|
226
|
-
) {
|
|
227
|
-
throw new TypeError("workspace record does not match the stored shape");
|
|
228
|
-
}
|
|
229
|
-
return {
|
|
230
|
-
path: record.path,
|
|
231
|
-
title: record.title,
|
|
232
|
-
sessionIds: record.sessionIds.map((id) => id as SessionId),
|
|
233
|
-
createdAt: record.createdAt,
|
|
234
|
-
updatedAt: record.updatedAt,
|
|
235
|
-
};
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
function workspaceStateOf(value: unknown): WorkspaceDomainState {
|
|
239
|
-
const state = value as Partial<WorkspaceDomainState> | null;
|
|
240
|
-
if (
|
|
241
|
-
typeof state !== "object" ||
|
|
242
|
-
state === null ||
|
|
243
|
-
typeof state.initialized !== "boolean" ||
|
|
244
|
-
!Array.isArray(state.workspaceIds) ||
|
|
245
|
-
!Array.isArray(state.archivedSessionIds) ||
|
|
246
|
-
!Array.isArray(state.pinnedSessionIds)
|
|
247
|
-
) {
|
|
248
|
-
throw new TypeError("workspace registry state does not match the stored shape");
|
|
249
|
-
}
|
|
250
|
-
return {
|
|
251
|
-
initialized: state.initialized,
|
|
252
|
-
workspaceIds: state.workspaceIds.map((id) => id as WorkspaceId),
|
|
253
|
-
archivedSessionIds: state.archivedSessionIds.map((id) => id as SessionId),
|
|
254
|
-
pinnedSessionIds: state.pinnedSessionIds.map((id) => id as SessionId),
|
|
255
|
-
...(state.pendingMutation === undefined ? {} : { pendingMutation: state.pendingMutation }),
|
|
256
|
-
};
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
function scheduleTaskOf(value: unknown): ScheduleTask {
|
|
260
|
-
const task = value as Partial<ScheduleTask> | null;
|
|
261
|
-
if (
|
|
262
|
-
typeof task !== "object" ||
|
|
263
|
-
task === null ||
|
|
264
|
-
typeof task.sessionId !== "string" ||
|
|
265
|
-
typeof task.record !== "object" ||
|
|
266
|
-
task.record === null
|
|
267
|
-
) {
|
|
268
|
-
throw new TypeError("schedule task does not match the stored shape");
|
|
164
|
+
if (!this.descriptor.hasGlobal) {
|
|
165
|
+
throw new Error(`unit '${this.descriptor.name}' declares no global slot`);
|
|
166
|
+
}
|
|
167
|
+
await this.track(this.repository.writeDomainGlobal(this.descriptor.name, value));
|
|
269
168
|
}
|
|
270
|
-
return task as ScheduleTask;
|
|
271
169
|
}
|
|
@@ -6,6 +6,7 @@ import type {
|
|
|
6
6
|
ProjectionCheckpointRow,
|
|
7
7
|
} from "@deepseek-ai/dsh-session-projection";
|
|
8
8
|
import type { WorkspaceDomainState, WorkspaceRecord } from "@deepseek-ai/dsh-workspace";
|
|
9
|
+
import type { Database } from "@morlay/rdbkit";
|
|
9
10
|
|
|
10
11
|
export type {
|
|
11
12
|
CheckpointIdentity,
|
|
@@ -16,10 +17,32 @@ export type {
|
|
|
16
17
|
WorkspaceRecord,
|
|
17
18
|
};
|
|
18
19
|
|
|
20
|
+
export interface DomainRecord {
|
|
21
|
+
table: string;
|
|
22
|
+
key: string;
|
|
23
|
+
value: unknown;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface WorkspaceIndexEntry {
|
|
27
|
+
id: string;
|
|
28
|
+
title: string;
|
|
29
|
+
sessionIds: string[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface StorageRepositoryHost {
|
|
33
|
+
/** 统一的查询面(结构、迁移与域读写都走它)。 */
|
|
34
|
+
database: Database;
|
|
35
|
+
|
|
36
|
+
/** 把一组写做成一次事务(宿主管连接与隔离级别)。 */
|
|
37
|
+
writeAtomically: <T>(fn: (database: Database) => Promise<T>) => Promise<T>;
|
|
38
|
+
}
|
|
39
|
+
|
|
19
40
|
export interface StoredProjcacheEntry {
|
|
20
41
|
sessionId: SessionId;
|
|
21
42
|
identity: CheckpointIdentity;
|
|
22
43
|
rows: ProjectionCheckpoint;
|
|
44
|
+
/** 会话标题(随快照一起加载 / 写路径刷新;`cachedSnapshot` 缺标题时用它补,不回库直读)。 */
|
|
45
|
+
title?: { title: string; seq: number };
|
|
23
46
|
}
|
|
24
47
|
|
|
25
48
|
export interface StorageRepository {
|
|
@@ -27,31 +50,39 @@ export interface StorageRepository {
|
|
|
27
50
|
|
|
28
51
|
insertUnitVersion(name: string, version: number): Promise<void>;
|
|
29
52
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
53
|
+
// 所有 storage 域共用:`(domain, table, key)` 一行,值是整份记录文档。global 单例用
|
|
54
|
+
// `table = ''`、`key = ''`;读不到返回 `null`(域层据此改用 spec 的 initial,与上游
|
|
55
|
+
// `KvUnit.loadAll` 的哨兵语义一致)。
|
|
56
|
+
listDomainRecords(domain: string): Promise<DomainRecord[]>;
|
|
33
57
|
|
|
34
|
-
|
|
58
|
+
readDomainGlobal(domain: string): Promise<unknown>;
|
|
35
59
|
|
|
36
|
-
|
|
60
|
+
putDomainRecord(domain: string, table: string, key: string, value: unknown): Promise<void>;
|
|
37
61
|
|
|
38
|
-
|
|
62
|
+
deleteDomainRecord(domain: string, table: string, key: string): Promise<void>;
|
|
39
63
|
|
|
40
|
-
//
|
|
41
|
-
|
|
64
|
+
// 一次批量(上游 `KvUnit.deleteRecords` 的契约:行存储要么一次做完、要么不做)。
|
|
65
|
+
deleteDomainRecords(domain: string, table: string, keys: readonly string[]): Promise<void>;
|
|
42
66
|
|
|
43
|
-
|
|
67
|
+
writeDomainGlobal(domain: string, value: unknown): Promise<void>;
|
|
44
68
|
|
|
45
|
-
|
|
69
|
+
// 会话面读 workspace 文档:列表的标题与搜索、删除会话时清理归属。数据只有 `t_storage_records`
|
|
70
|
+
// 一份,所以这两条走文档(workspace 数量级小,整份读没问题)。
|
|
71
|
+
listWorkspaceIndex(): Promise<WorkspaceIndexEntry[]>;
|
|
46
72
|
|
|
47
73
|
loadProjcache(): Promise<StoredProjcacheEntry[]>;
|
|
48
74
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
readSessionTitleDirect?(sessionId: string): { title: string; seq: number } | undefined;
|
|
75
|
+
/** 会话标题(投影缓存的镜像刷新与 `cachedSnapshot` 补标题用)。 */
|
|
76
|
+
readSessionTitle(sessionId: string): Promise<{ title: string; seq: number } | undefined>;
|
|
53
77
|
|
|
54
78
|
putProjcache(sessionId: string, rows: ProjectionCheckpoint): Promise<void>;
|
|
55
79
|
|
|
80
|
+
/** 增量写回:只 upsert `changed` 里的行、删掉 `removedKeys`,其余行原样不动。 */
|
|
81
|
+
patchProjcache(
|
|
82
|
+
sessionId: string,
|
|
83
|
+
changed: ProjectionCheckpoint,
|
|
84
|
+
removedKeys: readonly string[],
|
|
85
|
+
): Promise<void>;
|
|
86
|
+
|
|
56
87
|
pruneStaleProjcache(): Promise<number>;
|
|
57
88
|
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { E, type Database } from "@morlay/rdbkit";
|
|
2
|
+
import { storageRecords } from "../entities/index.ts";
|
|
3
|
+
import type { WorkspaceDomainState, WorkspaceRecord } from "./types.ts";
|
|
4
|
+
|
|
5
|
+
const WORKSPACE_DOMAIN = "workspace";
|
|
6
|
+
const WORKSPACE_TABLE = "workspaces";
|
|
7
|
+
const GLOBAL_TABLE = "";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* 删除会话后清掉 workspace 文档里的归属与归档 / 钉住集合。
|
|
11
|
+
*
|
|
12
|
+
* 域存储只有 `t_storage_records` 一份,没有再 join 的归属表,所以删除路径得读改文档。调用方保证在
|
|
13
|
+
* 写事务内:SQLite 用同一连接上的 `Database`,PG 用当前事务的 scoped `Database`。
|
|
14
|
+
* @param database - 当前执行面(事务内就是 scoped 实例)。
|
|
15
|
+
* @param ids - 刚删除的会话 id。
|
|
16
|
+
*/
|
|
17
|
+
export async function pruneWorkspaceDocuments(
|
|
18
|
+
database: Database,
|
|
19
|
+
ids: readonly string[],
|
|
20
|
+
): Promise<void> {
|
|
21
|
+
if (ids.length === 0) return;
|
|
22
|
+
const records = database.table(storageRecords);
|
|
23
|
+
const scope = E.and(E.eq(records.f_domain, WORKSPACE_DOMAIN), E.eq(records.f_table, WORKSPACE_TABLE));
|
|
24
|
+
const drop = new Set<string>(ids);
|
|
25
|
+
|
|
26
|
+
for await (const row of records
|
|
27
|
+
.select({ key: records.f_key, value: records.f_value })
|
|
28
|
+
.where(scope)) {
|
|
29
|
+
const record = JSON.parse(row.value) as WorkspaceRecord;
|
|
30
|
+
const kept = record.sessionIds.filter((sessionId) => !drop.has(sessionId));
|
|
31
|
+
if (kept.length === record.sessionIds.length) continue;
|
|
32
|
+
await records.update(
|
|
33
|
+
{ f_value: JSON.stringify({ ...record, sessionIds: kept }) },
|
|
34
|
+
E.and(
|
|
35
|
+
E.eq(records.f_domain, WORKSPACE_DOMAIN),
|
|
36
|
+
E.eq(records.f_table, WORKSPACE_TABLE),
|
|
37
|
+
E.eq(records.f_key, row.key),
|
|
38
|
+
),
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
let globalValue: string | undefined;
|
|
43
|
+
for await (const row of records
|
|
44
|
+
.select({ value: records.f_value })
|
|
45
|
+
.where(E.and(E.eq(records.f_domain, WORKSPACE_DOMAIN), E.eq(records.f_table, GLOBAL_TABLE)))) {
|
|
46
|
+
globalValue = row.value;
|
|
47
|
+
}
|
|
48
|
+
if (globalValue === undefined) return;
|
|
49
|
+
const state = JSON.parse(globalValue) as WorkspaceDomainState;
|
|
50
|
+
const archivedSessionIds = state.archivedSessionIds.filter((sessionId) => !drop.has(sessionId));
|
|
51
|
+
const pinnedSessionIds = (state.pinnedSessionIds ?? []).filter(
|
|
52
|
+
(sessionId) => !drop.has(sessionId),
|
|
53
|
+
);
|
|
54
|
+
if (
|
|
55
|
+
archivedSessionIds.length === state.archivedSessionIds.length &&
|
|
56
|
+
pinnedSessionIds.length === (state.pinnedSessionIds ?? []).length
|
|
57
|
+
) {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const next: WorkspaceDomainState = { ...state, archivedSessionIds, pinnedSessionIds };
|
|
61
|
+
await records.update(
|
|
62
|
+
{ f_value: JSON.stringify(next) },
|
|
63
|
+
E.and(E.eq(records.f_domain, WORKSPACE_DOMAIN), E.eq(records.f_table, GLOBAL_TABLE)),
|
|
64
|
+
);
|
|
65
|
+
}
|
package/src/storage.ts
CHANGED
package/src/testing.ts
CHANGED
|
@@ -1,6 +1,26 @@
|
|
|
1
|
+
import type { DatabaseSync } from "node:sqlite";
|
|
2
|
+
import { openSqliteHandle } from "@morlay/rdbkit/testing";
|
|
3
|
+
import { openRdbBackend } from "./db/open.ts";
|
|
4
|
+
import { DEFAULT_BUSY_TIMEOUT_MS, type JournalMode } from "./version.ts";
|
|
5
|
+
|
|
1
6
|
export type { ContractBackend } from "./testing/contract.ts";
|
|
2
7
|
export { appendLog, meta, oneTurnLog, runPersistenceContract } from "./testing/contract.ts";
|
|
3
8
|
export type { CoordinatorFixture } from "./testing/coordinator-contract.ts";
|
|
4
9
|
export { runCoordinatorContract } from "./testing/coordinator-contract.ts";
|
|
5
10
|
|
|
6
|
-
export { truncateLiveSession } from "./branch.ts";
|
|
11
|
+
export { truncateLiveSession } from "./branch/index.ts";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* 测试辅助:先把库初始化(建表 / 迁移 / 身份),再给一个同步句柄——造损坏数据、读 `PRAGMA` 用。
|
|
15
|
+
*
|
|
16
|
+
* 同步口只在测试里存在:生产路径拿到的永远是 `Database`。
|
|
17
|
+
*/
|
|
18
|
+
export async function openInitializedSqlite(
|
|
19
|
+
path: string,
|
|
20
|
+
journalMode: JournalMode = "wal",
|
|
21
|
+
busyTimeout = DEFAULT_BUSY_TIMEOUT_MS,
|
|
22
|
+
): Promise<DatabaseSync> {
|
|
23
|
+
const backend = await openRdbBackend({ type: "sqlite", path, journalMode, busyTimeout });
|
|
24
|
+
await backend.close();
|
|
25
|
+
return openSqliteHandle(path, { journalMode, busyTimeout });
|
|
26
|
+
}
|
package/src/usage.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Context } from "@deepseek-ai/cordis";
|
|
2
2
|
import { SESSION_USAGE_PATH } from "./routes.ts";
|
|
3
|
-
import
|
|
3
|
+
import { mountJsonRoute } from "./http/route.ts";
|
|
4
|
+
import type { SessionPersistenceRdb } from "./session-store.ts";
|
|
4
5
|
|
|
5
6
|
export { SESSION_USAGE_PATH };
|
|
6
7
|
|
|
@@ -179,69 +180,14 @@ export function addTotals(target: UsageTotals, row: UsageTotals): UsageTotals {
|
|
|
179
180
|
// 用量统计通道:一次请求回报总览(含 subagent 拆分)、按天 × 模型的桶与按会话的行。
|
|
180
181
|
// 聚合只算被会话引用的事件行——fork 共享行因此只计一次,已删会话留下的孤儿行不计。
|
|
181
182
|
export function registerSessionUsage(ctx: Context, persistence: SessionPersistenceRdb): void {
|
|
182
|
-
ctx
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
}): () => void;
|
|
192
|
-
};
|
|
193
|
-
const connection = webCtx.get("connection") as unknown as {
|
|
194
|
-
requestRejection(request: {
|
|
195
|
-
headers: import("node:http").IncomingHttpHeaders;
|
|
196
|
-
}): number | undefined;
|
|
197
|
-
};
|
|
198
|
-
return webCtx.effect(
|
|
199
|
-
() =>
|
|
200
|
-
webServer.register({
|
|
201
|
-
kind: "exact",
|
|
202
|
-
path: SESSION_USAGE_PATH,
|
|
203
|
-
handler: async (req, res) => {
|
|
204
|
-
const rejection = connection.requestRejection(req);
|
|
205
|
-
if (rejection !== undefined) {
|
|
206
|
-
res.writeHead(rejection);
|
|
207
|
-
res.end(rejection === 401 ? "unauthorized" : "forbidden");
|
|
208
|
-
return;
|
|
209
|
-
}
|
|
210
|
-
if (req.method !== "POST") {
|
|
211
|
-
res.writeHead(405, { "content-type": "application/json" });
|
|
212
|
-
res.end(JSON.stringify({ error: "method not allowed" }));
|
|
213
|
-
return;
|
|
214
|
-
}
|
|
215
|
-
const chunks: Buffer[] = [];
|
|
216
|
-
for await (const chunk of req) chunks.push(chunk as Buffer);
|
|
217
|
-
const raw = Buffer.concat(chunks).toString("utf8");
|
|
218
|
-
let envelope: { range?: unknown } = {};
|
|
219
|
-
if (raw !== "") {
|
|
220
|
-
try {
|
|
221
|
-
envelope = JSON.parse(raw) as { range?: unknown };
|
|
222
|
-
} catch {
|
|
223
|
-
res.writeHead(400, { "content-type": "application/json" });
|
|
224
|
-
res.end(JSON.stringify({ error: "request body is not JSON" }));
|
|
225
|
-
return;
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
try {
|
|
229
|
-
const report = await persistence.usageReport(
|
|
230
|
-
resolveUsageSince(parseUsageRange(envelope.range), Date.now()),
|
|
231
|
-
);
|
|
232
|
-
res.writeHead(200, { "content-type": "application/json" });
|
|
233
|
-
res.end(JSON.stringify(report));
|
|
234
|
-
} catch (error: unknown) {
|
|
235
|
-
res.writeHead(500, { "content-type": "application/json" });
|
|
236
|
-
res.end(
|
|
237
|
-
JSON.stringify({
|
|
238
|
-
error: error instanceof Error ? error.message : "usage report failed",
|
|
239
|
-
}),
|
|
240
|
-
);
|
|
241
|
-
}
|
|
242
|
-
},
|
|
243
|
-
}),
|
|
244
|
-
`session-rdb: ${SESSION_USAGE_PATH} route`,
|
|
245
|
-
);
|
|
183
|
+
mountJsonRoute(ctx, {
|
|
184
|
+
path: SESSION_USAGE_PATH,
|
|
185
|
+
handle: async (body) => {
|
|
186
|
+
const { range } = body as { range?: unknown };
|
|
187
|
+
const report = await persistence.usageReport(
|
|
188
|
+
resolveUsageSince(parseUsageRange(range), Date.now()),
|
|
189
|
+
);
|
|
190
|
+
return { body: report };
|
|
191
|
+
},
|
|
246
192
|
});
|
|
247
193
|
}
|
package/src/version.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** 库结构版本(写入 SQLite 的 `user_version`,不匹配即拒绝打开)。 */
|
|
2
|
+
export const SCHEMA_VERSION = 3;
|
|
3
|
+
|
|
4
|
+
/** SQLite 文件头的 application id(`0x44534850` = "DSHP"),防止误开别的库。 */
|
|
5
|
+
export const SESSION_PERSISTENCE_SQLITE_APPLICATION_ID = 0x44534850;
|
|
6
|
+
|
|
7
|
+
/** 事件 `f_data` 的编码(目前只有 JSON)。 */
|
|
8
|
+
export const EVENT_ENCODING = "json";
|
|
9
|
+
|
|
10
|
+
export type JournalMode = "wal" | "delete" | "truncate" | "persist";
|
|
11
|
+
|
|
12
|
+
/** SQLite 的默认 busy timeout(毫秒)。 */
|
|
13
|
+
export const DEFAULT_BUSY_TIMEOUT_MS = 5000;
|