@morlay/session-rdb 0.0.11 → 0.0.13

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.
Files changed (56) hide show
  1. package/README.md +0 -16
  2. package/dist/artifact.d.mts +64 -0
  3. package/dist/artifact.mjs +3 -0
  4. package/dist/import-D2vUnUZe.mjs +438 -0
  5. package/dist/import.d.mts +24 -0
  6. package/dist/import.mjs +2 -0
  7. package/dist/index-uUvn6gYp.d.mts +111 -0
  8. package/dist/index.d.mts +3 -0
  9. package/dist/index.mjs +466 -0
  10. package/dist/invariant.d.mts +7 -0
  11. package/dist/invariant.mjs +8 -0
  12. package/dist/magic-string.es-BWtG0AyA.mjs +1017 -0
  13. package/dist/schema-B-8G4lWc.mjs +852 -0
  14. package/dist/schema-CFXZURX7.d.mts +116 -0
  15. package/dist/sqlite-DIXY-dFZ.mjs +356 -0
  16. package/dist/storage.d.mts +44 -0
  17. package/dist/storage.mjs +3 -0
  18. package/dist/testing.d.mts +31 -0
  19. package/dist/testing.mjs +16051 -0
  20. package/package.json +39 -22
  21. package/src/adapters/ddl.ts +77 -0
  22. package/src/adapters/index.ts +4 -0
  23. package/src/adapters/to-postgres.ts +91 -0
  24. package/src/adapters/to-sqlite.ts +79 -0
  25. package/src/artifact.ts +4 -0
  26. package/src/backend.ts +112 -0
  27. package/src/branch.ts +508 -0
  28. package/src/entities/events.ts +27 -0
  29. package/src/entities/index.ts +30 -0
  30. package/src/entities/persistence-state.ts +10 -0
  31. package/src/entities/schema-meta.ts +9 -0
  32. package/src/entities/session-events.ts +29 -0
  33. package/src/entities/sessions.ts +20 -0
  34. package/src/entities/types.ts +48 -0
  35. package/src/import.ts +270 -0
  36. package/src/index.ts +547 -0
  37. package/src/invariant.ts +15 -0
  38. package/src/log.ts +456 -0
  39. package/src/migrate.ts +137 -0
  40. package/src/postgres.ts +369 -0
  41. package/src/schema.ts +234 -0
  42. package/src/sqlite.ts +412 -0
  43. package/src/storage.ts +5 -0
  44. package/src/testing/contract.ts +562 -0
  45. package/src/testing/coordinator-contract.ts +1723 -0
  46. package/src/testing/helpers.ts +20 -0
  47. package/src/testing.ts +12 -0
  48. package/src/write-guard.ts +27 -0
  49. package/lib/index.d.mts +0 -558
  50. package/lib/index.d.mts.map +0 -1
  51. package/lib/index.mjs +0 -2176
  52. package/lib/index.mjs.map +0 -1
  53. package/lib/invariant.d.mts +0 -15
  54. package/lib/invariant.d.mts.map +0 -1
  55. package/lib/invariant.mjs +0 -21
  56. package/lib/invariant.mjs.map +0 -1
package/src/sqlite.ts ADDED
@@ -0,0 +1,412 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { statSync } from "node:fs";
3
+ import { mkdir, open } from "node:fs/promises";
4
+ import { dirname, resolve } from "node:path";
5
+ import { DatabaseSync } from "node:sqlite";
6
+ import { and, desc, eq, gte, sql } from "drizzle-orm";
7
+ import { drizzle, type NodeSQLiteDatabase } from "drizzle-orm/node-sqlite";
8
+ import type { SessionId } from "@deepseek-ai/dsh-session";
9
+ import type { SessionStorageMetadata } from "@deepseek-ai/dsh-session-persistence";
10
+ import {
11
+ type Backend,
12
+ type BackendTx,
13
+ type EventInsert,
14
+ type EventRow,
15
+ type SessionRow,
16
+ } from "./backend.ts";
17
+ import { createTablesSql } from "./adapters/index.ts";
18
+ import { sqliteTableDefs } from "./entities/index.ts";
19
+ import { sessionConflictRow, sessionInsertRow } from "./log.ts";
20
+ import { migrateSqliteV1ToV2 } from "./migrate.ts";
21
+ import {
22
+ DEFAULT_BUSY_TIMEOUT_MS,
23
+ SCHEMA_VERSION,
24
+ SESSION_PERSISTENCE_SQLITE_APPLICATION_ID,
25
+ tEvents,
26
+ tPersistenceState,
27
+ tSessionEvents,
28
+ tSessions,
29
+ type JournalMode,
30
+ } from "./schema.ts";
31
+
32
+ type SqliteDb = NodeSQLiteDatabase & { $client: DatabaseSync };
33
+
34
+ const sqliteTxQueues = new Map<string, Promise<void>>();
35
+
36
+ function enqueueSqliteTx<T>(path: string, fn: () => Promise<T>): Promise<T> {
37
+ const tail = sqliteTxQueues.get(path) ?? Promise.resolve();
38
+ const run = tail.then(fn);
39
+ // 失败的事务不得毒化后续队列。
40
+ sqliteTxQueues.set(
41
+ path,
42
+ run.then(
43
+ () => undefined,
44
+ () => undefined,
45
+ ),
46
+ );
47
+ return run;
48
+ }
49
+
50
+ async function createDatabaseFile(path: string): Promise<void> {
51
+ try {
52
+ const handle = await open(path, "wx", 0o600);
53
+ await handle.close();
54
+ } catch (error) {
55
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
56
+ }
57
+ }
58
+
59
+ export function openDatabase(
60
+ path: string,
61
+ journalMode: JournalMode,
62
+ busyTimeout = DEFAULT_BUSY_TIMEOUT_MS,
63
+ ): DatabaseSync {
64
+ const db = new DatabaseSync(path);
65
+ try {
66
+ configureDatabase(db, path, journalMode, busyTimeout);
67
+ return db;
68
+ } catch (error: unknown) {
69
+ db.close();
70
+ throw error;
71
+ }
72
+ }
73
+
74
+ function configureDatabase(
75
+ db: DatabaseSync,
76
+ path: string,
77
+ journalMode: JournalMode,
78
+ busyTimeout: number,
79
+ ): void {
80
+ // 其余是驱动级 SQLite 操作(无 drizzle API):连接 pragma 与 sqlite_schema 探测。
81
+ db.exec("PRAGMA foreign_keys = ON");
82
+ // busy_timeout 必须先于一切锁获取(初始化事务与每次写事务)。
83
+ db.exec(`PRAGMA busy_timeout = ${busyTimeout}`);
84
+ const dbx = drizzle({ client: db });
85
+ // 初始化在单个 `BEGIN IMMEDIATE` 事务内完成,持写锁校验 schema 归属。
86
+ dbx.transaction(
87
+ (tx) => {
88
+ const { user_version: onDisk } = tx.get(sql`PRAGMA user_version`) as {
89
+ user_version: number;
90
+ };
91
+ const { application_id: applicationId } = tx.get(sql`PRAGMA application_id`) as {
92
+ application_id: number;
93
+ };
94
+ const { count: userObjectCount } = tx.get(
95
+ sql`SELECT COUNT(*) AS count FROM sqlite_schema WHERE name NOT GLOB 'sqlite_*'`,
96
+ ) as { count: number };
97
+ if (onDisk === 0 && (applicationId !== 0 || userObjectCount > 0)) {
98
+ throw new Error(
99
+ `session database at "${path}" has an unversioned schema or application identity`,
100
+ );
101
+ }
102
+ if (onDisk === 1) {
103
+ // 启动时自动迁移 v1 → v2(同一写锁事务内,失败整体回滚)。
104
+ migrateSqliteV1ToV2(db);
105
+ } else if (onDisk !== 0 && onDisk !== SCHEMA_VERSION) {
106
+ throw new Error(
107
+ `session database at "${path}" has schema version ${onDisk}, incompatible with this build (${SCHEMA_VERSION})`,
108
+ );
109
+ }
110
+ if (
111
+ (onDisk === SCHEMA_VERSION || onDisk === 1) &&
112
+ applicationId !== SESSION_PERSISTENCE_SQLITE_APPLICATION_ID
113
+ ) {
114
+ throw new Error(
115
+ `session database at "${path}" has application id ${applicationId}, expected ${SESSION_PERSISTENCE_SQLITE_APPLICATION_ID}`,
116
+ );
117
+ }
118
+ for (const statement of createTablesSql("sqlite", sqliteTableDefs)) {
119
+ tx.run(sql.raw(statement));
120
+ }
121
+ tx.insert(tPersistenceState)
122
+ .values({ fSingleton: 1, fStoreId: randomUUID() })
123
+ .onConflictDoNothing()
124
+ .run();
125
+ if (onDisk === 0) {
126
+ tx.run(sql.raw(`PRAGMA application_id = ${SESSION_PERSISTENCE_SQLITE_APPLICATION_ID}`));
127
+ tx.run(sql.raw(`PRAGMA user_version = ${SCHEMA_VERSION}`));
128
+ }
129
+ },
130
+ { behavior: "immediate" },
131
+ );
132
+ // journal_mode 不可绑定,经校验后的联合可直接插值;在归属校验与初始化
133
+ // 提交之后应用。
134
+ db.exec(`PRAGMA journal_mode = ${journalMode.toUpperCase()}`);
135
+ }
136
+
137
+ export interface SqliteBackendOptions {
138
+ path: string;
139
+ journalMode: JournalMode;
140
+ busyTimeout: number;
141
+ }
142
+
143
+ export class SqliteBackend implements Backend {
144
+ readonly kind = "sqlite" as const;
145
+ storeIdentity!: string;
146
+
147
+ private dbPath = "";
148
+ private db!: SqliteDb;
149
+
150
+ constructor(private readonly options: SqliteBackendOptions) {}
151
+
152
+ async open(): Promise<void> {
153
+ const actual =
154
+ this.options.path === ":memory:" ? this.options.path : resolve(this.options.path);
155
+ this.dbPath = actual;
156
+ if (actual !== ":memory:") {
157
+ await mkdir(dirname(actual), { recursive: true, mode: 0o700 });
158
+ await createDatabaseFile(actual);
159
+ }
160
+ // openDatabase 内的初始化事务是同步 `BEGIN IMMEDIATE`,须排进同一
161
+ // per-path 写队列——否则与进行中的写事务竞争会在持有锁的异步回调
162
+ // 让步期间忙等,冻结事件循环(死锁直到 busy_timeout)。
163
+ await enqueueSqliteTx(actual, async () => {
164
+ this.db = drizzle({
165
+ client: openDatabase(actual, this.options.journalMode, this.options.busyTimeout),
166
+ });
167
+ });
168
+ try {
169
+ const row = this.db
170
+ .select({ fStoreId: tPersistenceState.fStoreId })
171
+ .from(tPersistenceState)
172
+ .where(eq(tPersistenceState.fSingleton, 1))
173
+ .get() as { fStoreId: string } | undefined;
174
+ /* v8 ignore next -- openDatabase inserts the singleton before returning. */
175
+ if (row === undefined) {
176
+ throw new Error(`session database at "${actual}" has no store identity`);
177
+ }
178
+ if (row.fStoreId.length === 0) {
179
+ throw new Error(`session database at "${actual}" has no valid store identity`);
180
+ }
181
+ if (actual !== ":memory:") {
182
+ const identity = statSync(actual, { bigint: true });
183
+ this.storeIdentity = `file:${identity.dev}:${identity.ino}:${identity.birthtimeNs}:store:${row.fStoreId}`;
184
+ } else {
185
+ this.storeIdentity = `memory:store:${row.fStoreId}`;
186
+ }
187
+ } catch (error: unknown) {
188
+ this.db.$client.close();
189
+ throw error;
190
+ }
191
+ }
192
+
193
+ async close(): Promise<void> {
194
+ // open 可能未及赋值 db 就失败(队列内初始化抛错);close 不得在
195
+ // coordinator 的 dispose 之上再崩。
196
+ if (this.db === undefined) return;
197
+ this.db.$client.close();
198
+ }
199
+
200
+ async getSession(id: SessionId): Promise<SessionRow | undefined> {
201
+ return this.db.select().from(tSessions).where(eq(tSessions.fSessionId, id)).get() as
202
+ | SessionRow
203
+ | undefined;
204
+ }
205
+
206
+ async getSeqMapRows(id: SessionId): Promise<Array<{ fSequence: number; fOriginalSeq: number }>> {
207
+ return this.db
208
+ .select({ fSequence: tSessionEvents.fSequence, fOriginalSeq: tSessionEvents.fOriginalSeq })
209
+ .from(tSessionEvents)
210
+ .where(eq(tSessionEvents.fSessionId, id))
211
+ .all();
212
+ }
213
+
214
+ async getEventRows(id: SessionId, fromSequence?: number): Promise<EventRow[]> {
215
+ const scoped =
216
+ fromSequence === undefined
217
+ ? this.eventRows().where(eq(tSessionEvents.fSessionId, id))
218
+ : this.eventRows().where(
219
+ and(eq(tSessionEvents.fSessionId, id), gte(tSessionEvents.fSequence, fromSequence)),
220
+ );
221
+ return scoped.orderBy(tSessionEvents.fSequence).all() as unknown as EventRow[];
222
+ }
223
+
224
+ async listSessions(): Promise<SessionRow[]> {
225
+ return this.db.select().from(tSessions).all() as SessionRow[];
226
+ }
227
+
228
+ async transaction<T>(fn: (tx: BackendTx) => Promise<T>): Promise<T> {
229
+ // drizzle 的 SQLite 驱动只支持同步事务回调,而共享 BackendTx 接口因
230
+ // PostgreSQL 是异步的——BEGIN/COMMIT/ROLLBACK 语句因此走驱动层。
231
+ // 异步回调在持写锁期间让出微任务间隙:本进程内第二个连接此时同步
232
+ // `BEGIN IMMEDIATE` 会忙等并冻结事件循环(锁持有者无法提交,死锁直到
233
+ // busy_timeout)。per-path 写队列串行化消除该间隙——SQLite 本就单写者;
234
+ // 跨进程竞争仍经 busy_timeout 解决,不同数据库文件互不串行。
235
+ return enqueueSqliteTx(this.dbPath, async () => {
236
+ this.db.$client.exec("BEGIN IMMEDIATE");
237
+ try {
238
+ const result = await fn(this.tx);
239
+ this.db.$client.exec("COMMIT");
240
+ return result;
241
+ } catch (error: unknown) {
242
+ // DELETE+INSERT 不会冲突;这里回滚 DB 级失败(磁盘满等),测试不可达。
243
+ /* v8 ignore start */
244
+ try {
245
+ this.db.$client.exec("ROLLBACK");
246
+ } catch {
247
+ // 原始 SQLite 失败仍是可操作的根因。
248
+ }
249
+ throw error;
250
+ /* v8 ignore stop */
251
+ }
252
+ });
253
+ }
254
+
255
+ private readonly tx: BackendTx = {
256
+ upsertSession: (storage, incarnation) => this.upsertSession(storage, incarnation),
257
+ getHead: (id) => this.getHead(id),
258
+ getSeedLength: (id) => this.getSeedLength(id),
259
+ updateSeedLength: (id, seedLength) => this.updateSeedLength(id, seedLength),
260
+ insertEvents: (events) => this.insertEvents(events),
261
+ insertBridges: (rows) => this.insertBridges(rows),
262
+ updateHead: (id, headEventId, headSequence) => this.updateHead(id, headEventId, headSequence),
263
+ bumpRevision: (id) => this.bumpRevision(id),
264
+ deleteBridgeTail: (id, fromSequence) => this.deleteBridgeTail(id, fromSequence),
265
+ getPrevBridge: (id, sequence) => this.getPrevBridge(id, sequence),
266
+ getLastBridge: (id) => this.getLastBridge(id),
267
+ };
268
+
269
+ // --- row primitives (transaction-internal or standalone) ---
270
+
271
+ private async upsertSession(storage: SessionStorageMetadata, incarnation: string): Promise<void> {
272
+ this.db
273
+ .insert(tSessions)
274
+ .values(sessionInsertRow(storage, incarnation))
275
+ .onConflictDoUpdate({
276
+ target: tSessions.fSessionId,
277
+ set: sessionConflictRow(storage),
278
+ })
279
+ .run();
280
+ }
281
+
282
+ private async getHead(
283
+ id: SessionId,
284
+ ): Promise<Pick<SessionRow, "fHeadEventId" | "fHeadSequence">> {
285
+ const head = this.db
286
+ .select({ fHeadEventId: tSessions.fHeadEventId, fHeadSequence: tSessions.fHeadSequence })
287
+ .from(tSessions)
288
+ .where(eq(tSessions.fSessionId, id))
289
+ .get() as Pick<SessionRow, "fHeadEventId" | "fHeadSequence"> | undefined;
290
+ /* v8 ignore next -- appendBatch/commitRepair always materialize the row before reading the head */
291
+ if (head === undefined) throw new Error(`session "${id}" has no materialized row`);
292
+ return head;
293
+ }
294
+
295
+ private async getSeedLength(id: SessionId): Promise<number | null> {
296
+ const row = this.db
297
+ .select({ fSeedLength: tSessions.fSeedLength })
298
+ .from(tSessions)
299
+ .where(eq(tSessions.fSessionId, id))
300
+ .get() as { fSeedLength: number | null } | undefined;
301
+ /* v8 ignore next -- rewind always materializes the row before reading the seed length */
302
+ if (row === undefined) throw new Error(`session "${id}" has no materialized row`);
303
+ return row.fSeedLength;
304
+ }
305
+
306
+ private async updateSeedLength(id: SessionId, seedLength: number): Promise<void> {
307
+ this.db
308
+ .update(tSessions)
309
+ .set({ fSeedLength: seedLength })
310
+ .where(eq(tSessions.fSessionId, id))
311
+ .run();
312
+ }
313
+
314
+ private static readonly INSERT_BATCH_ROWS = 1000;
315
+
316
+ private async insertEvents(events: EventInsert[]): Promise<void> {
317
+ if (events.length === 0) return;
318
+ for (let i = 0; i < events.length; i += SqliteBackend.INSERT_BATCH_ROWS) {
319
+ this.db
320
+ .insert(tEvents)
321
+ .values(events.slice(i, i + SqliteBackend.INSERT_BATCH_ROWS).map((event) => ({ ...event })))
322
+ .run();
323
+ }
324
+ }
325
+
326
+ private async insertBridges(
327
+ rows: Array<{
328
+ fSessionId: SessionId;
329
+ fEventId: string;
330
+ fSequence: number;
331
+ fOriginalSeq: number;
332
+ fSurfaceOp: string | null;
333
+ }>,
334
+ ): Promise<void> {
335
+ if (rows.length === 0) return;
336
+ for (let i = 0; i < rows.length; i += SqliteBackend.INSERT_BATCH_ROWS) {
337
+ this.db
338
+ .insert(tSessionEvents)
339
+ .values(rows.slice(i, i + SqliteBackend.INSERT_BATCH_ROWS).map((row) => ({ ...row })))
340
+ .run();
341
+ }
342
+ }
343
+
344
+ private async updateHead(
345
+ id: SessionId,
346
+ headEventId: string,
347
+ headSequence: number,
348
+ ): Promise<void> {
349
+ this.db
350
+ .update(tSessions)
351
+ .set({ fHeadEventId: headEventId, fHeadSequence: headSequence })
352
+ .where(eq(tSessions.fSessionId, id))
353
+ .run();
354
+ }
355
+
356
+ private async bumpRevision(id: SessionId): Promise<void> {
357
+ this.db
358
+ .update(tSessions)
359
+ .set({ fRevision: sql`${tSessions.fRevision} + 1` })
360
+ .where(eq(tSessions.fSessionId, id))
361
+ .run();
362
+ }
363
+
364
+ private async deleteBridgeTail(id: SessionId, fromSequence: number): Promise<void> {
365
+ this.db
366
+ .delete(tSessionEvents)
367
+ .where(and(eq(tSessionEvents.fSessionId, id), gte(tSessionEvents.fSequence, fromSequence)))
368
+ .run();
369
+ }
370
+
371
+ private async getPrevBridge(
372
+ id: SessionId,
373
+ sequence: number,
374
+ ): Promise<{ fEventId: string; fSequence: number } | undefined> {
375
+ return this.db
376
+ .select({ fEventId: tSessionEvents.fEventId, fSequence: tSessionEvents.fSequence })
377
+ .from(tSessionEvents)
378
+ .where(and(eq(tSessionEvents.fSessionId, id), eq(tSessionEvents.fSequence, sequence)))
379
+ .get() as { fEventId: string; fSequence: number } | undefined;
380
+ }
381
+
382
+ private async getLastBridge(
383
+ id: SessionId,
384
+ ): Promise<{ fEventId: string; fSequence: number } | undefined> {
385
+ return this.db
386
+ .select({ fEventId: tSessionEvents.fEventId, fSequence: tSessionEvents.fSequence })
387
+ .from(tSessionEvents)
388
+ .where(eq(tSessionEvents.fSessionId, id))
389
+ .orderBy(desc(tSessionEvents.fSequence))
390
+ .limit(1)
391
+ .get() as { fEventId: string; fSequence: number } | undefined;
392
+ }
393
+
394
+ private eventRows() {
395
+ return this.db
396
+ .select({
397
+ fEventId: tSessionEvents.fEventId,
398
+ fSequence: tSessionEvents.fSequence,
399
+ fOriginalSeq: tSessionEvents.fOriginalSeq,
400
+ fType: tEvents.fType,
401
+ fKind: tEvents.fKind,
402
+ fRole: tEvents.fRole,
403
+ fName: tEvents.fName,
404
+ fActionId: tEvents.fActionId,
405
+ fCreatedAt: tEvents.fCreatedAt,
406
+ fData: tEvents.fData,
407
+ fSurfaceOp: tSessionEvents.fSurfaceOp,
408
+ })
409
+ .from(tSessionEvents)
410
+ .innerJoin(tEvents, eq(tSessionEvents.fEventId, tEvents.fEventId));
411
+ }
412
+ }
package/src/storage.ts ADDED
@@ -0,0 +1,5 @@
1
+ // 存储引擎层:schema 常量/表定义、sqlite 后端(openDatabase / SqliteBackend)与
2
+ // 并发写保护(WriteGuard)。文件物理分开,经本入口聚合导出。
3
+ export * from "./schema.ts";
4
+ export * from "./sqlite.ts";
5
+ export * from "./write-guard.ts";