@morlay/session-rdb 0.0.20 → 0.0.21-alpha.0
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 +59 -31
- package/dist/artifact.d.mts +25 -65
- package/dist/artifact.mjs +2 -2
- package/dist/{schema-BkQN5GyT.d.mts → backend-DpdtxYpz.d.mts} +346 -93
- package/dist/{branch-Co6xlbi0.mjs → branch-5JzX9rUq.mjs} +163 -52
- package/dist/deletion.d.mts +7 -0
- package/dist/deletion.mjs +2 -0
- package/dist/dist-vIVO6bA-.mjs +1524 -0
- package/dist/import.d.mts +4 -4
- package/dist/import.mjs +28 -11
- package/dist/index.d.mts +2 -3
- package/dist/index.mjs +4 -1631
- package/dist/{log-DO69NQnn.mjs → log-CnYct2Dv.mjs} +37 -82
- package/dist/{sqlite-DYExtbLo.mjs → sqlite-fpvm5Dzs.mjs} +205 -75
- package/dist/src-CWTWV7vx.mjs +1904 -0
- package/dist/storage.d.mts +14 -5
- package/dist/storage.mjs +2 -2
- package/dist/testing.d.mts +1 -26
- package/dist/testing.mjs +10503 -9614
- package/drizzle/postgres/20260918120000_v3_event_usage/migration.sql +14 -0
- package/drizzle/postgres/20260918120000_v3_event_usage/snapshot.json +1309 -0
- package/drizzle/sqlite/20260918120000_v3_event_usage/migration.sql +14 -0
- package/drizzle/sqlite/20260918120000_v3_event_usage/snapshot.json +1031 -0
- package/package.json +28 -29
- package/src/adapters/to-postgres.ts +1 -3
- package/src/adapters/to-sqlite.ts +0 -2
- package/src/adapters/types.ts +0 -3
- package/src/artifact.ts +0 -2
- package/src/backend.ts +31 -2
- package/src/branch.ts +223 -135
- package/src/deletion.ts +87 -0
- package/src/drizzle/postgres-v2.ts +0 -1
- package/src/drizzle/postgres-v3.ts +0 -1
- package/src/drizzle/sqlite-v2.ts +0 -1
- package/src/drizzle/sqlite-v3.ts +0 -1
- package/src/entities/v2/session-events.ts +0 -1
- package/src/entities/v3/event-usage.ts +25 -0
- package/src/entities/v3/events.ts +1 -3
- package/src/entities/v3/index.ts +4 -0
- package/src/entities/v3/session-events.ts +1 -2
- package/src/entities/v3/session-projcache-rows.ts +0 -8
- package/src/entities/v3/sessions.ts +3 -3
- package/src/entities/v3/storage-units.ts +0 -1
- package/src/entities/v3/workspace-sessions.ts +0 -5
- package/src/entities/v3/workspace-state.ts +0 -6
- package/src/entities/v3/workspaces.ts +0 -5
- package/src/export.ts +103 -0
- package/src/gc.ts +76 -0
- package/src/import-storages.ts +4 -37
- package/src/import.ts +54 -31
- package/src/index.ts +150 -114
- package/src/legacy.ts +4 -42
- package/src/log.ts +63 -106
- package/src/postgres.ts +249 -22
- package/src/schema.ts +6 -6
- package/src/session-query.ts +0 -4
- package/src/sqlite.ts +231 -55
- package/src/storage-takeover/index.ts +2 -28
- package/src/storage-takeover/projection-cache.ts +30 -134
- package/src/storage-takeover/repository.ts +20 -59
- package/src/storage-takeover/storage-backend.ts +3 -33
- package/src/storage-takeover/types.ts +14 -56
- package/src/storage.ts +0 -2
- package/src/testing/contract.ts +19 -50
- package/src/testing/coordinator-contract.ts +10 -25
- package/src/testing.ts +1 -4
- package/src/usage.ts +191 -0
- package/dist/index-D55G9n4k.d.mts +0 -271
- package/dist/magic-string.es-BgJoa-3K.mjs +0 -1017
|
@@ -1,37 +1,20 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* storages 接管:注册在 storage hub 上的 `rdb` KV 后端。它把 workspace 域
|
|
3
|
-
* (上游 single 布局:一张 `workspaces` 表 + global 单例)映射到 session-rdb
|
|
4
|
-
* 的语义专用表,与事件日志同库同连接;域层(`ctx.storageDomain`)通过
|
|
5
|
-
* `routes` 把 `workspace` 域路由到本后端。
|
|
6
|
-
*
|
|
7
|
-
* 本后端只服务已知域的专用表:未知 unit 名 fail loud,而不是静默落到通用
|
|
8
|
-
* 存储——表结构是显式维护的(见 docs/schema.md)。
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
1
|
import { StorageError } from "@deepseek-ai/dsh-storage";
|
|
12
2
|
import type { SessionId } from "@deepseek-ai/dsh-session";
|
|
13
3
|
import type { WorkspaceId } from "@deepseek-ai/dsh-workspace";
|
|
14
4
|
import type { KvFacet, KvUnit, KvUnitDescriptor, StorageBackend } from "@deepseek-ai/dsh-storage";
|
|
15
5
|
import type { StorageRepository, WorkspaceRecord, WorkspaceDomainState } from "./types.ts";
|
|
16
6
|
|
|
17
|
-
/** 注册到 storage hub 的后端名(storage-domain 的 routes 引用它)。 */
|
|
18
7
|
export const RDB_STORAGE_BACKEND = "rdb";
|
|
19
8
|
|
|
20
|
-
/** 本后端唯一服务的域:上游 `workspaceDomainSpec`(v2,single 布局)。 */
|
|
21
9
|
const WORKSPACE_UNIT = "workspace";
|
|
22
10
|
const WORKSPACE_TABLE = "workspaces";
|
|
23
11
|
|
|
24
|
-
/** 只服务 workspace 域的 KV 后端。 */
|
|
25
12
|
export class RdbStorageBackend implements StorageBackend {
|
|
26
13
|
readonly kv: KvFacet = { open: (descriptor) => this.openUnit(descriptor) };
|
|
27
14
|
|
|
28
|
-
/** 已打开(或打开中)的 unit;重复 open 是调用方 bug。 */
|
|
29
15
|
private readonly open = new Map<string, WorkspaceKvUnit>();
|
|
30
16
|
private closed = false;
|
|
31
17
|
|
|
32
|
-
/**
|
|
33
|
-
* @param repository - storages 接管表访问层(与事件日志同介质)。
|
|
34
|
-
*/
|
|
35
18
|
constructor(private readonly repository: StorageRepository) {}
|
|
36
19
|
|
|
37
20
|
private async openUnit(descriptor: KvUnitDescriptor): Promise<KvUnit> {
|
|
@@ -47,8 +30,7 @@ export class RdbStorageBackend implements StorageBackend {
|
|
|
47
30
|
`(table '${WORKSPACE_TABLE}' plus a global slot)`,
|
|
48
31
|
);
|
|
49
32
|
}
|
|
50
|
-
|
|
51
|
-
// 读语义)无法表达,宁可 fail loud 也不静默按 single 读。
|
|
33
|
+
|
|
52
34
|
if (descriptor.layout !== undefined && descriptor.layout !== "single") {
|
|
53
35
|
throw new Error(
|
|
54
36
|
`rdb storage backend serves only the 'single' layout (domain '${descriptor.name}' ` +
|
|
@@ -58,7 +40,7 @@ export class RdbStorageBackend implements StorageBackend {
|
|
|
58
40
|
if (this.open.has(descriptor.name)) {
|
|
59
41
|
throw new Error(`kv unit '${descriptor.name}' is already open (double-open is a caller bug)`);
|
|
60
42
|
}
|
|
61
|
-
|
|
43
|
+
|
|
62
44
|
const unit = new WorkspaceKvUnit(this.repository, descriptor);
|
|
63
45
|
this.open.set(descriptor.name, unit);
|
|
64
46
|
try {
|
|
@@ -82,13 +64,6 @@ export class RdbStorageBackend implements StorageBackend {
|
|
|
82
64
|
return unit;
|
|
83
65
|
}
|
|
84
66
|
|
|
85
|
-
/**
|
|
86
|
-
* Release the backend. New writes are rejected immediately; already-queued
|
|
87
|
-
* unit writes drain first (upstream backend contract). The medium (the
|
|
88
|
-
* session database) belongs to the owning session-rdb plugin, so closing
|
|
89
|
-
* open units here does not close it.
|
|
90
|
-
* @returns resolution after every open unit drained.
|
|
91
|
-
*/
|
|
92
67
|
async close(): Promise<void> {
|
|
93
68
|
this.closed = true;
|
|
94
69
|
const units = [...this.open.values()];
|
|
@@ -97,10 +72,9 @@ export class RdbStorageBackend implements StorageBackend {
|
|
|
97
72
|
}
|
|
98
73
|
}
|
|
99
74
|
|
|
100
|
-
/** workspace 域的 KV unit:每个原语一条 SQL 语句,值形状与上游记录一致。 */
|
|
101
75
|
class WorkspaceKvUnit implements KvUnit {
|
|
102
76
|
private closed = false;
|
|
103
|
-
|
|
77
|
+
|
|
104
78
|
private readonly inflight = new Set<Promise<void>>();
|
|
105
79
|
private onClosed: (() => void) | undefined;
|
|
106
80
|
|
|
@@ -109,7 +83,6 @@ class WorkspaceKvUnit implements KvUnit {
|
|
|
109
83
|
private readonly descriptor: KvUnitDescriptor,
|
|
110
84
|
) {}
|
|
111
85
|
|
|
112
|
-
/** Install the name-release callback after the backend registered this unit. */
|
|
113
86
|
onClose(release: () => void): void {
|
|
114
87
|
this.onClosed = release;
|
|
115
88
|
}
|
|
@@ -150,7 +123,6 @@ class WorkspaceKvUnit implements KvUnit {
|
|
|
150
123
|
this.onClosed?.();
|
|
151
124
|
}
|
|
152
125
|
|
|
153
|
-
/** Register one accepted write so close can drain it. */
|
|
154
126
|
private async track(operation: Promise<unknown>): Promise<void> {
|
|
155
127
|
const settled = operation.then(
|
|
156
128
|
() => undefined,
|
|
@@ -177,7 +149,6 @@ class WorkspaceKvUnit implements KvUnit {
|
|
|
177
149
|
}
|
|
178
150
|
}
|
|
179
151
|
|
|
180
|
-
/** Narrow one opaque KV record to the workspace record the domain shipped. */
|
|
181
152
|
function workspaceRecordOf(value: unknown): WorkspaceRecord {
|
|
182
153
|
const record = value as Partial<WorkspaceRecord> | null;
|
|
183
154
|
if (
|
|
@@ -200,7 +171,6 @@ function workspaceRecordOf(value: unknown): WorkspaceRecord {
|
|
|
200
171
|
};
|
|
201
172
|
}
|
|
202
173
|
|
|
203
|
-
/** Narrow one opaque KV global to the workspace registry state the domain shipped. */
|
|
204
174
|
function workspaceStateOf(value: unknown): WorkspaceDomainState {
|
|
205
175
|
const state = value as Partial<WorkspaceDomainState> | null;
|
|
206
176
|
if (
|
|
@@ -1,14 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* storages 接管的类型:**能复用上游契约的一律复用**——记录形状来自上游
|
|
3
|
-
* domain/projection 的 `z.infer` 产物(`WorkspaceRecord` / `WorkspaceDomainState`
|
|
4
|
-
* / `CheckpointIdentity` / `ProjectionCheckpoint`),本文件只声明存储层的访问
|
|
5
|
-
* 接口与"上游类型 + 会话键"的组合。上游 spec 变化时这里直接编译报错,不会
|
|
6
|
-
* 静默偏移。
|
|
7
|
-
*
|
|
8
|
-
* 这些 import 全是类型导入(无运行时依赖):rdb 是通用插件,不能硬依赖
|
|
9
|
-
* web 层的 workspace 包或已被替换的 projcache 包。
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
1
|
import type { SessionId } from "@deepseek-ai/dsh-session";
|
|
13
2
|
import type { CheckpointIdentity } from "@deepseek-ai/dsh-session-projection-cache";
|
|
14
3
|
import type {
|
|
@@ -25,66 +14,35 @@ export type {
|
|
|
25
14
|
WorkspaceRecord,
|
|
26
15
|
};
|
|
27
16
|
|
|
28
|
-
/**
|
|
29
|
-
* 一个会话的完整 checkpoint:上游 `CheckpointIdentity`(日志身份,唯一权威定义
|
|
30
|
-
* 在被替换的 projcache spec 里)+ 上游 `ProjectionCheckpoint`(逐 key 行)+ 会话键。
|
|
31
|
-
*/
|
|
32
17
|
export interface StoredProjcacheEntry {
|
|
33
18
|
sessionId: SessionId;
|
|
34
19
|
identity: CheckpointIdentity;
|
|
35
20
|
rows: ProjectionCheckpoint;
|
|
36
21
|
}
|
|
37
22
|
|
|
38
|
-
/**
|
|
39
|
-
* storages 接管表的方言无关访问层:SQLite 与 PostgreSQL 两个 Backend 各提供
|
|
40
|
-
* 同一实现,调用方(KV 后端与投影缓存服务)不感知介质差异。读方法返回持久
|
|
41
|
-
* 值的语义视图,workspace 的写入在介质事务内整体替换。
|
|
42
|
-
*/
|
|
43
23
|
export interface StorageRepository {
|
|
44
|
-
/** 读一个域的版本戳;域从未打开时 undefined。 */
|
|
45
24
|
readUnitVersion(name: string): Promise<number | undefined>;
|
|
46
|
-
|
|
25
|
+
|
|
47
26
|
insertUnitVersion(name: string, version: number): Promise<void>;
|
|
48
|
-
|
|
27
|
+
|
|
49
28
|
listWorkspaces(): Promise<Array<{ id: string; record: WorkspaceRecord }>>;
|
|
50
|
-
|
|
29
|
+
|
|
51
30
|
putWorkspace(id: string, record: WorkspaceRecord): Promise<void>;
|
|
52
|
-
|
|
31
|
+
|
|
53
32
|
deleteWorkspace(id: string): Promise<void>;
|
|
54
|
-
|
|
33
|
+
|
|
55
34
|
readWorkspaceState(): Promise<WorkspaceDomainState | null>;
|
|
56
|
-
|
|
35
|
+
|
|
57
36
|
writeWorkspaceState(state: WorkspaceDomainState): Promise<void>;
|
|
58
|
-
|
|
37
|
+
|
|
59
38
|
loadProjcache(): Promise<StoredProjcacheEntry[]>;
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
*/
|
|
67
|
-
readProjcacheSync?(sessionId: string): StoredProjcacheEntry | undefined;
|
|
68
|
-
/**
|
|
69
|
-
* 同步读一个会话的标题列(`t_sessions.f_title` / `f_title_seq`,rdb 写路径与
|
|
70
|
-
* rewind 维护):只有同步驱动提供,列表消费直接取会话数据,不依赖 checkpoint
|
|
71
|
-
* 行是否存在。
|
|
72
|
-
* @param sessionId - 会话 id。
|
|
73
|
-
* @returns 标题与其事件 seq,未写过标题时 `undefined`。
|
|
74
|
-
*/
|
|
75
|
-
readSessionTitleSync?(sessionId: string): { title: string; seq: number } | undefined;
|
|
76
|
-
/**
|
|
77
|
-
* 覆盖写一个会话的 checkpoint 行(先清旧行再写新行,同一事务)。记录头
|
|
78
|
-
* 不在这里:它与 `t_sessions` 行 1:1,identity 直接复用该行的列。
|
|
79
|
-
* @param sessionId - 会话 id。
|
|
80
|
-
* @param rows - 每个投影 key 一行。
|
|
81
|
-
*/
|
|
39
|
+
|
|
40
|
+
/** 不经 await、直接读的路径(供同步的 `cachedSnapshot` 消费);宿主提供不了则缺省。 */
|
|
41
|
+
readProjcacheDirect?(sessionId: string): StoredProjcacheEntry | undefined;
|
|
42
|
+
|
|
43
|
+
readSessionTitleDirect?(sessionId: string): { title: string; seq: number } | undefined;
|
|
44
|
+
|
|
82
45
|
putProjcache(sessionId: string, rows: ProjectionCheckpoint): Promise<void>;
|
|
83
|
-
|
|
84
|
-
* 清理陈旧 checkpoint 行:水位为负却已有事件的会话(旧版写入路径的产物,
|
|
85
|
-
* 会把有对话的会话误判为空白)。缓存是派生数据,删除只让该会话回到
|
|
86
|
-
* 「未知即可见」并等待下一次强制点重写。
|
|
87
|
-
* @returns 删除的行数。
|
|
88
|
-
*/
|
|
46
|
+
|
|
89
47
|
pruneStaleProjcache(): Promise<number>;
|
|
90
48
|
}
|
package/src/storage.ts
CHANGED
package/src/testing/contract.ts
CHANGED
|
@@ -18,29 +18,17 @@ import {
|
|
|
18
18
|
type SessionPersistence,
|
|
19
19
|
} from "@deepseek-ai/dsh-session-persistence";
|
|
20
20
|
|
|
21
|
-
/** One backend service instance under test plus its teardown. */
|
|
22
21
|
interface ContractBackendInstance {
|
|
23
22
|
persistence: SessionPersistence;
|
|
24
23
|
dispose: () => Promise<void>;
|
|
25
24
|
}
|
|
26
25
|
|
|
27
|
-
/** A backend under test: the primary instance plus optional storage-level capabilities. */
|
|
28
26
|
export interface ContractBackend extends ContractBackendInstance {
|
|
29
|
-
/**
|
|
30
|
-
* Open a FRESH backend instance over the SAME storage, as another process
|
|
31
|
-
* would after this one exits. Enables the cross-instance visibility and
|
|
32
|
-
* reopen-continuation tests; a backend without shared storage omits it and
|
|
33
|
-
* those tests self-skip.
|
|
34
|
-
*/
|
|
35
27
|
reopen?: () => Promise<ContractBackendInstance>;
|
|
36
|
-
|
|
37
|
-
* Inject a torn physical tail after the committed log of one stored session,
|
|
38
|
-
* simulating a crash mid-write. Enables the torn-tail tests.
|
|
39
|
-
*/
|
|
28
|
+
|
|
40
29
|
corruptTail?: (id: SessionId, cwd: string | undefined) => Promise<void>;
|
|
41
30
|
}
|
|
42
31
|
|
|
43
|
-
/** Build a minimal {@link SessionHeader} for a session id. */
|
|
44
32
|
export function meta(id: string, cwd?: string): SessionHeader {
|
|
45
33
|
return {
|
|
46
34
|
version: SESSION_FORMAT_VERSION,
|
|
@@ -51,7 +39,6 @@ export function meta(id: string, cwd?: string): SessionHeader {
|
|
|
51
39
|
};
|
|
52
40
|
}
|
|
53
41
|
|
|
54
|
-
/** A well-formed one-turn event log (contiguous seqs from 0). */
|
|
55
42
|
export function oneTurnLog(): SessionEvent[] {
|
|
56
43
|
return [
|
|
57
44
|
{ type: "turn/start", seq: SessionSeq(0), time: 1, data: { turn: 1 } },
|
|
@@ -108,7 +95,6 @@ export function oneTurnLog(): SessionEvent[] {
|
|
|
108
95
|
];
|
|
109
96
|
}
|
|
110
97
|
|
|
111
|
-
/** A contiguous second-turn batch continuing {@link oneTurnLog}. */
|
|
112
98
|
function secondTurn(startSeq = 6): SessionEvent[] {
|
|
113
99
|
return [
|
|
114
100
|
{ type: "turn/start", seq: SessionSeq(startSeq), time: 9, data: { turn: 2 } },
|
|
@@ -136,12 +122,6 @@ export function appendLog(session: Session, events: readonly SessionEvent[]): vo
|
|
|
136
122
|
}
|
|
137
123
|
}
|
|
138
124
|
|
|
139
|
-
/**
|
|
140
|
-
* Run the backend-agnostic handle contract suite. `make()` MUST return a
|
|
141
|
-
* fresh backend over fresh, empty storage each call.
|
|
142
|
-
* @param name - suite label, e.g. `jsonl-none` / `sqlite`.
|
|
143
|
-
* @param make - factory producing one fresh {@link ContractBackend} per test.
|
|
144
|
-
*/
|
|
145
125
|
export function runPersistenceContract(name: string, make: () => Promise<ContractBackend>): void {
|
|
146
126
|
describe(`SessionPersistence contract: ${name}`, () => {
|
|
147
127
|
it("round-trips through one write handle: append, self-read, offset/length defaults", async () => {
|
|
@@ -155,18 +135,18 @@ export function runPersistenceContract(name: string, make: () => Promise<Contrac
|
|
|
155
135
|
expect(handle.header).toMatchObject(m);
|
|
156
136
|
|
|
157
137
|
await handle.append(log);
|
|
158
|
-
|
|
138
|
+
|
|
159
139
|
await handle.append([]);
|
|
160
|
-
|
|
140
|
+
|
|
161
141
|
const full = await handle.read();
|
|
162
142
|
expect(full.events).toEqual(log);
|
|
163
143
|
expect((await handle.read(3)).events).toEqual(log.slice(3));
|
|
164
144
|
expect((await handle.read(0, 2)).events).toEqual(log.slice(0, 2));
|
|
165
145
|
expect((await handle.read(1, 3)).events).toEqual(log.slice(1, 4));
|
|
166
|
-
|
|
146
|
+
|
|
167
147
|
expect((await handle.read(log.length)).events).toEqual([]);
|
|
168
148
|
expect((await handle.read(log.length + 100)).events).toEqual([]);
|
|
169
|
-
|
|
149
|
+
|
|
170
150
|
await handle.flush();
|
|
171
151
|
await handle.close();
|
|
172
152
|
} finally {
|
|
@@ -194,8 +174,7 @@ export function runPersistenceContract(name: string, make: () => Promise<Contrac
|
|
|
194
174
|
await expect(persistence.create(meta("dup-pending"))).rejects.toBeInstanceOf(
|
|
195
175
|
SessionAlreadyExistsError,
|
|
196
176
|
);
|
|
197
|
-
|
|
198
|
-
// the id is free again.
|
|
177
|
+
|
|
199
178
|
await first.close();
|
|
200
179
|
const second = await persistence.create(meta("dup-pending"));
|
|
201
180
|
await second.close();
|
|
@@ -263,7 +242,7 @@ export function runPersistenceContract(name: string, make: () => Promise<Contrac
|
|
|
263
242
|
try {
|
|
264
243
|
const m = meta("owned");
|
|
265
244
|
const creator = await persistence.create(m);
|
|
266
|
-
|
|
245
|
+
|
|
267
246
|
await expect(persistence.open(m.id, "write")).rejects.toBeInstanceOf(
|
|
268
247
|
SessionAlreadyOwnedError,
|
|
269
248
|
);
|
|
@@ -273,7 +252,6 @@ export function runPersistenceContract(name: string, make: () => Promise<Contrac
|
|
|
273
252
|
);
|
|
274
253
|
await creator.close();
|
|
275
254
|
|
|
276
|
-
// After close, a new write handle continues at the stored next-seq.
|
|
277
255
|
const writer = await persistence.open(m.id, "write");
|
|
278
256
|
await writer.append(secondTurn());
|
|
279
257
|
expect((await writer.read()).events.map((e) => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7]);
|
|
@@ -295,7 +273,7 @@ export function runPersistenceContract(name: string, make: () => Promise<Contrac
|
|
|
295
273
|
expect(reader.access).toBe("read");
|
|
296
274
|
await expect(reader.append(secondTurn())).rejects.toBeInstanceOf(SessionReadOnlyError);
|
|
297
275
|
await expect(reader.flush()).rejects.toBeInstanceOf(SessionReadOnlyError);
|
|
298
|
-
|
|
276
|
+
|
|
299
277
|
expect((await reader.read()).events).toEqual(oneTurnLog());
|
|
300
278
|
await reader.close();
|
|
301
279
|
} finally {
|
|
@@ -319,7 +297,7 @@ export function runPersistenceContract(name: string, make: () => Promise<Contrac
|
|
|
319
297
|
await using writer = await persistence.open(m.id, "write");
|
|
320
298
|
await writer.append(secondTurn());
|
|
321
299
|
}
|
|
322
|
-
|
|
300
|
+
|
|
323
301
|
const reopened = await persistence.open(m.id, "write");
|
|
324
302
|
await reopened.close();
|
|
325
303
|
} finally {
|
|
@@ -332,8 +310,7 @@ export function runPersistenceContract(name: string, make: () => Promise<Contrac
|
|
|
332
310
|
try {
|
|
333
311
|
const materialized = await backend.persistence.create(meta("flush-all"));
|
|
334
312
|
const abandoned = await backend.persistence.create(meta("flush-all-closing"));
|
|
335
|
-
|
|
336
|
-
// which counts as flushed — close itself drained durably.
|
|
313
|
+
|
|
337
314
|
const closing = abandoned.close();
|
|
338
315
|
await backend.persistence.flush();
|
|
339
316
|
await closing;
|
|
@@ -341,9 +318,8 @@ export function runPersistenceContract(name: string, make: () => Promise<Contrac
|
|
|
341
318
|
if (backend.reopen !== undefined) {
|
|
342
319
|
const reopened = await backend.reopen();
|
|
343
320
|
try {
|
|
344
|
-
// The barrier materialized the empty session durably...
|
|
345
321
|
expect(await reopened.persistence.stat(SessionId("flush-all"))).toBeDefined();
|
|
346
|
-
|
|
322
|
+
|
|
347
323
|
expect(await reopened.persistence.stat(SessionId("flush-all-closing"))).toBeUndefined();
|
|
348
324
|
} finally {
|
|
349
325
|
await reopened.dispose();
|
|
@@ -360,7 +336,7 @@ export function runPersistenceContract(name: string, make: () => Promise<Contrac
|
|
|
360
336
|
try {
|
|
361
337
|
const m = meta("lazy", "/work");
|
|
362
338
|
const creator = await backend.persistence.create(m);
|
|
363
|
-
|
|
339
|
+
|
|
364
340
|
expect((await creator.read()).events).toEqual([]);
|
|
365
341
|
|
|
366
342
|
const snapshot = await backend.persistence.stat(m.id);
|
|
@@ -440,8 +416,7 @@ export function runPersistenceContract(name: string, make: () => Promise<Contrac
|
|
|
440
416
|
expect((await before.read()).events).toEqual(oneTurnLog());
|
|
441
417
|
|
|
442
418
|
await writer.append(secondTurn());
|
|
443
|
-
|
|
444
|
-
// append once it has resolved.
|
|
419
|
+
|
|
445
420
|
expect((await before.read()).events.map((e) => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7]);
|
|
446
421
|
const after = await persistence.open(m.id, "read");
|
|
447
422
|
expect((await after.read()).events.map((e) => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7]);
|
|
@@ -481,10 +456,10 @@ export function runPersistenceContract(name: string, make: () => Promise<Contrac
|
|
|
481
456
|
try {
|
|
482
457
|
const m = meta("contiguity");
|
|
483
458
|
const handle = await persistence.create(m);
|
|
484
|
-
await handle.append(oneTurnLog());
|
|
485
|
-
|
|
459
|
+
await handle.append(oneTurnLog());
|
|
460
|
+
|
|
486
461
|
await expect(handle.append(oneTurnLog())).rejects.toThrow(/expected 6/);
|
|
487
|
-
|
|
462
|
+
|
|
488
463
|
const gapped: SessionEvent[] = [
|
|
489
464
|
{ type: "turn/start", seq: SessionSeq(6), time: 9, data: { turn: 2 } },
|
|
490
465
|
{
|
|
@@ -495,7 +470,7 @@ export function runPersistenceContract(name: string, make: () => Promise<Contrac
|
|
|
495
470
|
},
|
|
496
471
|
];
|
|
497
472
|
await expect(handle.append(gapped)).rejects.toThrow(/expected 7/);
|
|
498
|
-
|
|
473
|
+
|
|
499
474
|
expect((await handle.read()).events.map((e) => e.seq)).toEqual([0, 1, 2, 3, 4, 5]);
|
|
500
475
|
await handle.close();
|
|
501
476
|
} finally {
|
|
@@ -520,7 +495,7 @@ export function runPersistenceContract(name: string, make: () => Promise<Contrac
|
|
|
520
495
|
await expect(handle.append(bad(1n))).rejects.toThrow(TypeError);
|
|
521
496
|
await expect(handle.append(bad(1n))).rejects.toThrow(/losslessly JSON-serializable/);
|
|
522
497
|
await expect(handle.append(bad(undefined))).rejects.toThrow(/losslessly JSON-serializable/);
|
|
523
|
-
|
|
498
|
+
|
|
524
499
|
await handle.append(oneTurnLog());
|
|
525
500
|
expect((await handle.read()).events).toEqual(oneTurnLog());
|
|
526
501
|
await handle.close();
|
|
@@ -535,7 +510,7 @@ export function runPersistenceContract(name: string, make: () => Promise<Contrac
|
|
|
535
510
|
const m = meta("foreign-vocabulary");
|
|
536
511
|
const handle = await persistence.create(m);
|
|
537
512
|
await handle.append(oneTurnLog());
|
|
538
|
-
|
|
513
|
+
|
|
539
514
|
await expect(
|
|
540
515
|
handle.append([
|
|
541
516
|
{ type: "mystery/event", seq: SessionSeq(6), time: 7, data: { payload: true } },
|
|
@@ -543,7 +518,6 @@ export function runPersistenceContract(name: string, make: () => Promise<Contrac
|
|
|
543
518
|
).rejects.toBeInstanceOf(SessionFormatUnsupportedError);
|
|
544
519
|
await handle.close();
|
|
545
520
|
|
|
546
|
-
// 拒绝的批次未落库:log 只有 oneTurnLog,可正常读写。
|
|
547
521
|
const reader = await persistence.open(m.id, "read");
|
|
548
522
|
try {
|
|
549
523
|
const { events } = await reader.read();
|
|
@@ -579,15 +553,12 @@ export function runPersistenceContract(name: string, make: () => Promise<Contrac
|
|
|
579
553
|
await creator.close();
|
|
580
554
|
await backend.corruptTail(m.id, m.cwd);
|
|
581
555
|
|
|
582
|
-
// A reader over the corrupted artifact serves only the committed prefix.
|
|
583
556
|
const readerInstance = await backend.reopen();
|
|
584
557
|
try {
|
|
585
558
|
const reader = await readerInstance.persistence.open(m.id, "read");
|
|
586
559
|
expect((await reader.read()).events).toEqual(oneTurnLog());
|
|
587
560
|
await reader.close();
|
|
588
561
|
|
|
589
|
-
// A write open + first append durably truncates the torn tail and
|
|
590
|
-
// continues at the committed next-seq.
|
|
591
562
|
const writer = await readerInstance.persistence.open(m.id, "write");
|
|
592
563
|
await writer.append(secondTurn());
|
|
593
564
|
expect((await writer.read()).events.map((e) => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7]);
|
|
@@ -596,7 +567,6 @@ export function runPersistenceContract(name: string, make: () => Promise<Contrac
|
|
|
596
567
|
await readerInstance.dispose();
|
|
597
568
|
}
|
|
598
569
|
|
|
599
|
-
// The repaired log is intact for the next instance.
|
|
600
570
|
const verifyInstance = await backend.reopen();
|
|
601
571
|
try {
|
|
602
572
|
const verify = await verifyInstance.persistence.open(m.id, "read");
|
|
@@ -630,7 +600,6 @@ export function runPersistenceContract(name: string, make: () => Promise<Contrac
|
|
|
630
600
|
const listChanged = (await persistence.list()).find((s) => s.header.id === m.id);
|
|
631
601
|
expect(listChanged?.revision).toBe(statChanged?.revision);
|
|
632
602
|
|
|
633
|
-
// Snapshot headers carry the stored header, identically everywhere.
|
|
634
603
|
const reader = await persistence.open(m.id, "read");
|
|
635
604
|
expect(statChanged?.header).toEqual(reader.header);
|
|
636
605
|
expect(listChanged?.header).toEqual(reader.header);
|
|
@@ -25,11 +25,6 @@ function send(session: Session, events: readonly SessionEvent[]): void {
|
|
|
25
25
|
appendLog(session, events);
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
/**
|
|
29
|
-
* RDB 特有行为契约:live 会话驱动持久化、seed 边界、fork/resume、HMR
|
|
30
|
-
* dispose drain、冲突拒绝、torn-tail。coordinator 内部状态(ownerless /
|
|
31
|
-
* per-id chain / legacy 迁移)已由新版 handle 模型承担,不再单独测试。
|
|
32
|
-
*/
|
|
33
28
|
export function runCoordinatorContract(
|
|
34
29
|
name: string,
|
|
35
30
|
makeFixture: () => Promise<CoordinatorFixture>,
|
|
@@ -169,15 +164,14 @@ export function runCoordinatorContract(
|
|
|
169
164
|
const { ctx, fiber } = await freshCtx(fix);
|
|
170
165
|
try {
|
|
171
166
|
const seed = oneTurnLog();
|
|
172
|
-
|
|
167
|
+
|
|
173
168
|
const forked = ctx.sessions.create(SessionId("forked"), { seed, meta: { cwd: WORK } });
|
|
174
|
-
await ctx.sessions.flush(forked);
|
|
169
|
+
await ctx.sessions.flush(forked);
|
|
175
170
|
const loaded = await readAll(ctx, SessionId("forked"));
|
|
176
|
-
|
|
177
|
-
// carry a bracket the still-running parent owns.
|
|
171
|
+
|
|
178
172
|
expect(loaded.events.slice(0, seed.length)).toEqual(seed);
|
|
179
173
|
expect(loaded.events.at(-1)).toMatchObject({ type: "session/end-seed", seq: seed.length });
|
|
180
|
-
|
|
174
|
+
|
|
181
175
|
await ctx.sessions.flush(forked);
|
|
182
176
|
const reloaded = await readAll(ctx, SessionId("forked"));
|
|
183
177
|
expect(reloaded.events).toEqual(loaded.events);
|
|
@@ -205,13 +199,13 @@ export function runCoordinatorContract(
|
|
|
205
199
|
seed: loaded.events,
|
|
206
200
|
meta: { cwd: WORK },
|
|
207
201
|
});
|
|
208
|
-
await second.ctx.sessions.flush(s2);
|
|
202
|
+
await second.ctx.sessions.flush(s2);
|
|
209
203
|
s2.append("turn/start", { turn: 2 });
|
|
210
204
|
s2.append("turn/end", { turn: 2, reason: { kind: "completed" } });
|
|
211
205
|
await second.ctx.sessions.flush(s2);
|
|
212
206
|
|
|
213
207
|
const reloaded = await readAll(second.ctx, SessionId("resumed"));
|
|
214
|
-
|
|
208
|
+
|
|
215
209
|
expect(reloaded.events.map((e) => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8]);
|
|
216
210
|
expect(reloaded.events[6]).toMatchObject({ type: "session/end-seed" });
|
|
217
211
|
} finally {
|
|
@@ -224,7 +218,7 @@ export function runCoordinatorContract(
|
|
|
224
218
|
const fix = await makeFixture();
|
|
225
219
|
const ctx = new Context();
|
|
226
220
|
await ctx.plugin(SessionStore);
|
|
227
|
-
|
|
221
|
+
|
|
228
222
|
const session = ctx.sessions.create(SessionId("pre-existing"), { meta: { cwd: WORK } });
|
|
229
223
|
session.append("turn/start", { turn: 1 });
|
|
230
224
|
session.append(
|
|
@@ -239,7 +233,6 @@ export function runCoordinatorContract(
|
|
|
239
233
|
|
|
240
234
|
const fiber = await fix.mount(ctx);
|
|
241
235
|
try {
|
|
242
|
-
// The plugin seeded it on apply; a subsequent flush persists its events.
|
|
243
236
|
await ctx.sessions.flush(session);
|
|
244
237
|
const loaded = await readAll(ctx, SessionId("pre-existing"));
|
|
245
238
|
expect(loaded.events.length).toBeGreaterThanOrEqual(2);
|
|
@@ -265,10 +258,9 @@ export function runCoordinatorContract(
|
|
|
265
258
|
{ surfaceOp: "append" },
|
|
266
259
|
);
|
|
267
260
|
session.append("turn/end", { turn: 1, reason: { kind: "completed" } });
|
|
268
|
-
|
|
261
|
+
|
|
269
262
|
await fiber.dispose();
|
|
270
263
|
|
|
271
|
-
// A fresh backend instance reads what the disposed one drained.
|
|
272
264
|
const second = await freshCtx(fix);
|
|
273
265
|
try {
|
|
274
266
|
const loaded = await readAll(second.ctx, SessionId("drain"));
|
|
@@ -283,7 +275,6 @@ export function runCoordinatorContract(
|
|
|
283
275
|
const fix = await makeFixture();
|
|
284
276
|
const { ctx, fiber } = await freshCtx(fix);
|
|
285
277
|
try {
|
|
286
|
-
// Persist a session with cwd WORK.
|
|
287
278
|
let first!: Session;
|
|
288
279
|
const firstFiber = await ctx.plugin(
|
|
289
280
|
Object.assign(
|
|
@@ -297,7 +288,6 @@ export function runCoordinatorContract(
|
|
|
297
288
|
await ctx.sessions.flush(first);
|
|
298
289
|
await firstFiber.dispose();
|
|
299
290
|
|
|
300
|
-
// A NEW live session with the same id but a DIFFERENT cwd must be rejected.
|
|
301
291
|
let second!: Session;
|
|
302
292
|
await ctx.plugin(
|
|
303
293
|
Object.assign(
|
|
@@ -318,8 +308,6 @@ export function runCoordinatorContract(
|
|
|
318
308
|
const fix = await makeFixture();
|
|
319
309
|
const { ctx, fiber } = await freshCtx(fix);
|
|
320
310
|
try {
|
|
321
|
-
// A live session created then disposed BEFORE its first append: never
|
|
322
|
-
// materialized. A new live session reusing the id must reclaim it.
|
|
323
311
|
let firstSession!: Session;
|
|
324
312
|
const firstFiber = await ctx.plugin(
|
|
325
313
|
Object.assign(
|
|
@@ -329,8 +317,8 @@ export function runCoordinatorContract(
|
|
|
329
317
|
{ inject: ["sessions"] },
|
|
330
318
|
),
|
|
331
319
|
);
|
|
332
|
-
await ctx.sessions.flush(firstSession);
|
|
333
|
-
await firstFiber.dispose();
|
|
320
|
+
await ctx.sessions.flush(firstSession);
|
|
321
|
+
await firstFiber.dispose();
|
|
334
322
|
|
|
335
323
|
let reuse!: Session;
|
|
336
324
|
await ctx.plugin(
|
|
@@ -362,7 +350,6 @@ export function runCoordinatorContract(
|
|
|
362
350
|
await handle.append(oneTurnLog());
|
|
363
351
|
await handle.close();
|
|
364
352
|
|
|
365
|
-
// A fresh backend instance appends through a write handle.
|
|
366
353
|
const writer = await ctx.sessionPersistence.open(SessionId("storage-only"), "write");
|
|
367
354
|
await writer.append([
|
|
368
355
|
{ type: "turn/start", seq: SessionSeq(6), time: 7, data: { turn: 2 } },
|
|
@@ -430,12 +417,10 @@ export function runCoordinatorContract(
|
|
|
430
417
|
await creator.close();
|
|
431
418
|
await fix.corruptTail(SessionId("torn"), WORK);
|
|
432
419
|
|
|
433
|
-
// A reader serves only the committed prefix.
|
|
434
420
|
const reader = await ctx.sessionPersistence.open(SessionId("torn"), "read");
|
|
435
421
|
expect((await reader.read()).events).toEqual(oneTurnLog());
|
|
436
422
|
await reader.close();
|
|
437
423
|
|
|
438
|
-
// A write open + first append durably truncates the torn tail.
|
|
439
424
|
const writer = await ctx.sessionPersistence.open(SessionId("torn"), "write");
|
|
440
425
|
await writer.append([
|
|
441
426
|
{ type: "turn/start", seq: SessionSeq(6), time: 7, data: { turn: 2 } },
|
package/src/testing.ts
CHANGED
|
@@ -1,10 +1,7 @@
|
|
|
1
|
-
// 测试支撑(跨包测试共享):helpers / contract / coordinator-contract。
|
|
2
|
-
// 仅供 vitest 测试引用;不进入运行时 bundle。
|
|
3
1
|
export { EmptySettings } from "./testing/helpers.ts";
|
|
4
2
|
export type { ContractBackend } from "./testing/contract.ts";
|
|
5
3
|
export { appendLog, meta, oneTurnLog, runPersistenceContract } from "./testing/contract.ts";
|
|
6
4
|
export type { CoordinatorFixture } from "./testing/coordinator-contract.ts";
|
|
7
5
|
export { runCoordinatorContract } from "./testing/coordinator-contract.ts";
|
|
8
|
-
|
|
9
|
-
// 缓存。跨包测试要构造「rewind 之后」的会话态时用它,不必拉起 RDB 装配。
|
|
6
|
+
|
|
10
7
|
export { truncateLiveSession } from "./branch.ts";
|