@aiao/rxdb-adapter-electron 0.0.26 → 0.0.28

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 (59) hide show
  1. package/README.md +122 -21
  2. package/dist/RxDBAdapterElectron.d.ts +27 -1
  3. package/dist/RxDBAdapterElectron.d.ts.map +1 -1
  4. package/dist/electron-sqlite-host.d.ts.map +1 -1
  5. package/dist/host.d.ts +1 -1
  6. package/dist/host.d.ts.map +1 -1
  7. package/dist/host.js +46 -40
  8. package/dist/index.d.ts +1 -1
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +20 -6
  11. package/dist/node-sqlite-engine.d.ts +10 -0
  12. package/dist/node-sqlite-engine.d.ts.map +1 -1
  13. package/dist/pglite/RxDBAdapterElectronPGlite.d.ts +53 -1
  14. package/dist/pglite/RxDBAdapterElectronPGlite.d.ts.map +1 -1
  15. package/dist/pglite/desktop-pglite-client.d.ts +27 -1
  16. package/dist/pglite/desktop-pglite-client.d.ts.map +1 -1
  17. package/dist/pglite/desktop-pglite-results.d.ts +14 -0
  18. package/dist/pglite/desktop-pglite-results.d.ts.map +1 -0
  19. package/dist/pglite/electron-pglite-backup-error.d.ts +32 -0
  20. package/dist/pglite/electron-pglite-backup-error.d.ts.map +1 -0
  21. package/dist/pglite/pglite-adapter.interface.d.ts +12 -0
  22. package/dist/pglite/pglite-adapter.interface.d.ts.map +1 -1
  23. package/dist/pglite/restore-electron-pglite-database.d.ts +68 -0
  24. package/dist/pglite/restore-electron-pglite-database.d.ts.map +1 -0
  25. package/dist/pglite-host/electron-pglite-host.d.ts +47 -32
  26. package/dist/pglite-host/electron-pglite-host.d.ts.map +1 -1
  27. package/dist/pglite-host/pglite-host-data-dir.d.ts +75 -0
  28. package/dist/pglite-host/pglite-host-data-dir.d.ts.map +1 -0
  29. package/dist/pglite-host/pglite-host-lock.d.ts +48 -0
  30. package/dist/pglite-host/pglite-host-lock.d.ts.map +1 -0
  31. package/dist/pglite-host/pglite-host-restore.d.ts +38 -0
  32. package/dist/pglite-host/pglite-host-restore.d.ts.map +1 -0
  33. package/dist/pglite-host/pglite-host-runtime.d.ts +90 -0
  34. package/dist/pglite-host/pglite-host-runtime.d.ts.map +1 -0
  35. package/dist/pglite-host.d.ts +2 -2
  36. package/dist/pglite-host.d.ts.map +1 -1
  37. package/dist/pglite-host.js +643 -120
  38. package/dist/pglite.d.ts +2 -0
  39. package/dist/pglite.d.ts.map +1 -1
  40. package/dist/pglite.js +341 -65
  41. package/package.json +13 -10
  42. package/src/RxDBAdapterElectron.ts +40 -1
  43. package/src/electron-sqlite-host.ts +3 -0
  44. package/src/host.ts +1 -0
  45. package/src/index.ts +1 -0
  46. package/src/node-sqlite-engine.ts +20 -1
  47. package/src/pglite/RxDBAdapterElectronPGlite.ts +80 -5
  48. package/src/pglite/desktop-pglite-client.ts +111 -17
  49. package/src/pglite/desktop-pglite-results.ts +24 -0
  50. package/src/pglite/electron-pglite-backup-error.ts +128 -0
  51. package/src/pglite/pglite-adapter.interface.ts +14 -0
  52. package/src/pglite/restore-electron-pglite-database.ts +429 -0
  53. package/src/pglite-host/electron-pglite-host.ts +409 -160
  54. package/src/pglite-host/pglite-host-data-dir.ts +224 -0
  55. package/src/pglite-host/pglite-host-lock.ts +154 -0
  56. package/src/pglite-host/pglite-host-restore.ts +460 -0
  57. package/src/pglite-host/pglite-host-runtime.ts +166 -0
  58. package/src/pglite-host.ts +7 -0
  59. package/src/pglite.ts +9 -0
@@ -19,12 +19,38 @@ import {
19
19
  DESKTOP_PGLITE_PROTOCOL_VERSION,
20
20
  parseDesktopPgliteRequest,
21
21
  RxDBAdapterDesktopError,
22
+ type DesktopPgliteBackupBeginRequest,
23
+ type DesktopPgliteBackupCursorRequest,
24
+ type DesktopPgliteDataDirItem,
25
+ type DesktopPgliteEngineRequest,
26
+ type DesktopPgliteEngineResult,
22
27
  type DesktopPgliteNotifyMessage,
23
- type DesktopPgliteQueryResult,
24
28
  type DesktopPgliteRequest,
25
29
  type DesktopPgliteResponse
26
30
  } from '@aiao/rxdb-adapter-sqlite-core/desktop-host';
27
31
  import { randomUUID } from 'node:crypto';
32
+ import { hasPgVersion, walkPgliteDataDirectory } from './pglite-host-data-dir.js';
33
+ import { acquirePgliteDirectoryLock, type PgliteDirectoryLock } from './pglite-host-lock.js';
34
+ import {
35
+ createPgliteRestoreCoordinator,
36
+ type DesktopPgliteRestoreRequest,
37
+ type PgliteRestoreCoordinator
38
+ } from './pglite-host-restore.js';
39
+ import {
40
+ runStatement,
41
+ suspendTransaction,
42
+ toWireResult,
43
+ type ElectronPgliteProbeRuntime,
44
+ type ElectronPgliteRuntime,
45
+ type SuspendedTransaction
46
+ } from './pglite-host-runtime.js';
47
+
48
+ export type {
49
+ ElectronPgliteProbeRuntime,
50
+ ElectronPgliteRuntime,
51
+ ElectronPgliteRuntimeResult,
52
+ ElectronPgliteTransaction
53
+ } from './pglite-host-runtime.js';
28
54
 
29
55
  /**
30
56
  * 逻辑位置的 scheme。
@@ -50,34 +76,41 @@ export const DESKTOP_PGLITE_WATCH_CHANNELS: readonly string[] = Object.freeze([
50
76
  'rxdb_migration_notify'
51
77
  ]);
52
78
 
53
- /** 一条语句的结果;`ElectronPgliteRuntime` 与 PGlite 的 `Results` 在此结构相容。 */
54
- export interface ElectronPgliteRuntimeResult {
55
- readonly rows: Record<string, unknown>[];
56
- readonly fields: { name: string; dataTypeID: number }[];
57
- readonly affectedRows?: number;
58
- }
59
-
60
- /** 事务句柄;对应 PGlite 的 `Transaction`,只声明 host 真正用到的两个操作。 */
61
- export interface ElectronPgliteTransaction {
62
- query(sql: string, params?: unknown[]): Promise<ElectronPgliteRuntimeResult>;
63
- exec(sql: string): Promise<ElectronPgliteRuntimeResult[]>;
64
- }
65
-
66
79
  /**
67
- * host 需要的 PGlite 能力子集。
80
+ * 备份与恢复所需的宿主配置(US-217)。
68
81
  *
69
82
  * @remarks
70
- * 刻意不写成 `PGliteInterface`:宿主应用极可能传进来的不是裸 `PGlite`,而是跑在 worker
71
- * 里的 `PGliteWorker`——PGlite 的 WASM 在主进程 JS 线程上是**同步**执行的,一条重查询会
72
- * 把整个窗口的 IPC 一起卡住(故事「冻结带来的三条实现约束」第 3 条)。声明成结构类型后,
73
- * 两者都能直接传入,而这份清单也顺便说清了 host 只需要这五个操作。
83
+ * 不传时 host 只提供连接:`pg.engine`、`pg.backup.*`、`pg.restore.*` 一律报 `unsupported_operation`,
84
+ * `pg.open` 也不取目录锁,与引入备份之前的行为逐项一致。传了之后每个打开的数据目录旁边都有一把
85
+ * 跨进程的目录锁:同一目录上的恢复与连接互斥,另一个进程里的 host 也不例外(AC#12)。
74
86
  */
75
- export interface ElectronPgliteRuntime {
76
- query(sql: string, params?: unknown[]): Promise<ElectronPgliteRuntimeResult>;
77
- exec(sql: string): Promise<ElectronPgliteRuntimeResult[]>;
78
- transaction<T>(callback: (tx: ElectronPgliteTransaction) => Promise<T>): Promise<T>;
79
- listen(channel: string, callback: (payload: string) => void): Promise<unknown>;
80
- close(): Promise<void>;
87
+ export interface ElectronPgliteBackupOptions {
88
+ /**
89
+ * 逻辑数据目录名到物理路径。
90
+ *
91
+ * @remarks
92
+ * **必须**就是 `createRuntime` 为同一个名字打开的目录:备份从这里读文件,恢复往这里写文件,
93
+ * 目录锁与恢复标记也建在它旁边。两者不一致时备份报 `host_internal_error`,而不是悄悄读出别的目录。
94
+ * 传进来的名字已过白名单校验,不含任何路径分隔符。
95
+ */
96
+ readonly resolveDataDirectory: (dataDirectoryName: string) => string;
97
+ /**
98
+ * 创建一个不落盘的探针运行时,用来回答 `pg.engine`。
99
+ *
100
+ * @remarks
101
+ * 例如 `new PGlite()`(内存实例)。必须与 `createRuntime` 用同一份 PGlite、带同一组扩展,
102
+ * 否则回答的引擎版本对真实数据目录不成立。探针用完即关,结果在 host 生命周期内缓存;
103
+ * 起不来时不缓存,下一次 `pg.engine` 重试。
104
+ */
105
+ readonly createProbeRuntime: () => Promise<ElectronPgliteProbeRuntime>;
106
+ /**
107
+ * `createRuntime` 为每个实例装载的扩展名。
108
+ *
109
+ * @remarks
110
+ * 只列影响数据兼容性的扩展,不含 `live`(它不改变数据目录);没有扩展时传空数组。
111
+ * 归档据此声明所需扩展,恢复端不满足时在写入目标之前拒绝(AC#5)。
112
+ */
113
+ readonly extensions: readonly string[];
81
114
  }
82
115
 
83
116
  /** {@link createElectronPgliteHost} 的入参。 */
@@ -91,7 +124,7 @@ export interface ElectronPgliteHostOptions {
91
124
  * 数据会静默落在谁也没打算用的位置,而症状是「重启后数据没了」。
92
125
  *
93
126
  * 传进来的名字已过白名单校验,不含任何路径分隔符,因此 `join(root, name)` 不会越出 `root`。
94
- * 同一个名字在 host 生命周期内只会被调用一次(AC#7)。
127
+ * 同一个名字同一时刻至多只有一个活着的运行时(AC#7);恢复校验同样经它打开目标目录。
95
128
  */
96
129
  readonly createRuntime: (dataDirectoryName: string) => Promise<ElectronPgliteRuntime>;
97
130
  /** 把裸 NOTIFY 送达对应会话的 renderer,例如 `webContents.send`。 */
@@ -104,6 +137,8 @@ export interface ElectronPgliteHostOptions {
104
137
  * 把送达失败当成写失败回给调用方只会诱发一次重复写入。不传则丢弃。
105
138
  */
106
139
  readonly onDeliveryError?: (error: unknown) => void;
140
+ /** 备份与恢复支持;不传则不提供(见 {@link ElectronPgliteBackupOptions})。 */
141
+ readonly backup?: ElectronPgliteBackupOptions;
107
142
  }
108
143
 
109
144
  /** 桌面 PGlite host 实例。 */
@@ -122,31 +157,43 @@ export interface ElectronPgliteHost {
122
157
  handle(request: unknown, ownerId: number): Promise<DesktopPgliteResponse>;
123
158
  /** 当前打开的会话数。 */
124
159
  readonly openSessionCount: number;
125
- /** 当前活着的 PGlite 实例数;同一个数据目录上的多个会话只算一个。 */
160
+ /** 当前活着的 PGlite 实例数;同一个数据目录上的多个会话只算一个,恢复校验用的私有实例不计入。 */
126
161
  readonly openInstanceCount: number;
127
162
  /** 当前挂起的事务数;正常静止时应为 0。 */
128
163
  readonly openTransactionCount: number;
164
+ /** 当前进行中的备份快照数;快照同样占着连接,但不计入 `openTransactionCount`。 */
165
+ readonly openBackupCount: number;
166
+ /** 当前进行中的恢复数。 */
167
+ readonly openRestoreCount: number;
129
168
  /**
130
- * 回收某个窗口名下的全部会话与事务。
169
+ * 回收某个窗口名下的全部会话、事务、快照与恢复。
131
170
  *
132
171
  * @remarks
133
172
  * 这是本方案的**前提而非收尾**:挂起的 callback 独占 PGlite 的连接锁,渲染进程崩在
134
173
  * 事务中间而没人回收的话,之后任何查询都会永远排队——表征是「数据库不响应」,
135
174
  * 与那次崩溃毫无关联线索。调用方必须把它挂到 `render-process-gone` 与 `destroyed`
136
- * 两个事件上(AC#3)。
175
+ * 两个事件上(AC#3)。被打断的恢复保留恢复标记,之后的连接据此被拒绝(AC#11)。
137
176
  *
138
177
  * @param ownerId - 已崩溃或已销毁的 `webContents.id`
139
178
  * @returns 本次回滚掉的事务条数
140
179
  */
141
180
  releaseOwner(ownerId: number): Promise<number>;
142
- /** 关闭全部会话与实例,通常在应用退出前调用。 */
181
+ /** 关闭全部会话与实例、打断全部恢复,通常在应用退出前调用。 */
143
182
  closeAll(): Promise<void>;
144
183
  }
145
184
 
185
+ /** 一个已经起来的运行时,以及启用备份时它在数据目录上持有的锁。 */
186
+ interface StartedRuntime {
187
+ readonly runtime: ElectronPgliteRuntime;
188
+ readonly lock: PgliteDirectoryLock | undefined;
189
+ }
190
+
146
191
  /** 一个数据目录上的运行时及其会话。 */
147
192
  interface InstanceEntry {
148
193
  readonly sessions: Set<string>;
149
- readonly ready: Promise<ElectronPgliteRuntime>;
194
+ /** 正在等 `ready` 的 `pg.open` 数;不为 0 时最后一条会话关掉也不释放实例。 */
195
+ pending: number;
196
+ readonly ready: Promise<StartedRuntime>;
150
197
  }
151
198
 
152
199
  /** 一条会话。 */
@@ -156,46 +203,30 @@ interface SessionEntry {
156
203
  }
157
204
 
158
205
  /** 一条挂起中的事务。 */
159
- interface TransactionEntry {
206
+ type TransactionEntry = SuspendedTransaction & { readonly sessionId: string };
207
+
208
+ /** 一次进行中的备份快照。 */
209
+ interface BackupEntry {
160
210
  readonly sessionId: string;
161
- readonly tx: ElectronPgliteTransaction;
162
- readonly settle: { readonly resolve: () => void; readonly reject: (error: Error) => void };
163
- /** `transaction(...)` 本身;等它落地才算真的提交/回滚完。 */
164
- readonly finished: Promise<unknown>;
211
+ readonly iterator: AsyncGenerator<DesktopPgliteDataDirItem>;
212
+ /** 读快照期间占住连接的空事务:它不结束,任何语句都改不了文件(AC#16)。 */
213
+ readonly held: SuspendedTransaction;
165
214
  }
166
215
 
167
- /** `pg.begin` 等到超时的哨兵;用 Symbol 是因为任何合法的 `tx` 都不可能与它相等。 */
168
- const TIMED_OUT = Symbol('pg.begin timed out');
169
-
170
- const raceTimeout = async <T>(promise: Promise<T>, ms: number): Promise<T | typeof TIMED_OUT> => {
171
- let timer: ReturnType<typeof setTimeout> | undefined;
172
- try {
173
- return await Promise.race([
174
- promise,
175
- new Promise<typeof TIMED_OUT>(resolve => {
176
- timer = setTimeout(() => resolve(TIMED_OUT), ms);
177
- })
178
- ]);
179
- } finally {
180
- // 不清掉的话,一次成功的 begin 也会让 Node 的事件循环多活 `ms` 毫秒,
181
- // 于是应用退出被推迟——在测试里表现为「跑完了但进程不退」。
182
- clearTimeout(timer);
183
- }
184
- };
216
+ /** 启用备份时的配置与状态。 */
217
+ interface BackupSupport {
218
+ readonly options: ElectronPgliteBackupOptions;
219
+ /** 去重并排序后的扩展名。 */
220
+ readonly extensions: readonly string[];
221
+ readonly restores: PgliteRestoreCoordinator;
222
+ }
185
223
 
186
- /**
187
- * 把 PGlite 的结果收窄成能过结构化克隆的形状。
188
- *
189
- * @remarks
190
- * `fields` 上除了 `name` / `dataTypeID` 还挂着 PGlite 自己的解析器,整个丢给
191
- * `ipcRenderer.invoke` 会以 DataCloneError 失败——而报错点在 IPC 层,看上去与 SQL 毫无关系。
192
- * `rows` 里的 bigint / Uint8Array / Date 都是结构化克隆原生支持的,原样带走(AC#1)。
193
- */
194
- const toWireResult = (result: ElectronPgliteRuntimeResult): DesktopPgliteQueryResult => ({
195
- rows: result.rows,
196
- fields: result.fields.map(field => ({ name: field.name, dataTypeID: field.dataTypeID })),
197
- affectedRows: result.affectedRows
198
- });
224
+ /** 协议 v2 新增、只在启用备份时可用的请求。 */
225
+ type DesktopPgliteBackupRequest =
226
+ | DesktopPgliteEngineRequest
227
+ | DesktopPgliteBackupBeginRequest
228
+ | DesktopPgliteBackupCursorRequest
229
+ | DesktopPgliteRestoreRequest;
199
230
 
200
231
  const toErrorResponse = (error: unknown): DesktopPgliteResponse => {
201
232
  if (error instanceof RxDBAdapterDesktopError) {
@@ -208,31 +239,23 @@ const toErrorResponse = (error: unknown): DesktopPgliteResponse => {
208
239
  };
209
240
  };
210
241
 
211
- /**
212
- * 跑一条语句,把引擎抛出的错误归一成 `statement_failed`。
213
- *
214
- * @remarks
215
- * 不归一的话,一条写错的 SQL 会以 `host_internal_error` 回到 renderer——那个码的含义是
216
- * 「host 有缺陷」,会把调用方的排查引向完全错误的方向。
217
- */
218
- const runStatement = async <T>(run: () => Promise<T>): Promise<T> => {
219
- try {
220
- return await run();
221
- } catch (error) {
222
- if (error instanceof RxDBAdapterDesktopError) throw error;
223
- throw new RxDBAdapterDesktopError('statement_failed', error instanceof Error ? error.message : String(error), {
224
- cause: error
225
- });
226
- }
227
- };
242
+ const openFailed = (dataDirectoryName: string, cause: unknown): RxDBAdapterDesktopError =>
243
+ new RxDBAdapterDesktopError(
244
+ 'open_failed',
245
+ `the application could not open a PGlite runtime for ${dataDirectoryName}`,
246
+ {
247
+ cause
248
+ }
249
+ );
228
250
 
229
251
  /**
230
252
  * 创建一个桌面 PGlite host。
231
253
  *
232
254
  * @remarks
233
255
  * 与 SQLite host 相反,同一个数据目录上的多个会话**共享一个** PGlite 实例(AC#7):
234
- * PGlite 是嵌入式单写者,同一份数据目录被两个实例同时打开会直接损坏它。跨窗口的并发
235
- * 因此由这一条连接的排队来串行化,而不是交给文件锁。
256
+ * PGlite 是嵌入式单写者,同一份数据目录被两个实例同时打开会直接损坏它。同一 host 内跨窗口的
257
+ * 并发因此由这一条连接的排队来串行化;启用备份时每个实例还在数据目录旁持有一把跨进程的
258
+ * 目录锁,恢复据此确认目标没有被任何进程连着(AC#12)。
236
259
  *
237
260
  * @param options - host 配置
238
261
  * @returns host 实例
@@ -241,6 +264,30 @@ export function createElectronPgliteHost(options: ElectronPgliteHostOptions): El
241
264
  const instances = new Map<string, InstanceEntry>();
242
265
  const sessions = new Map<string, SessionEntry>();
243
266
  const transactions = new Map<string, TransactionEntry>();
267
+ const backups = new Map<string, BackupEntry>();
268
+ /** 正在关闭的实例:关完之前,同名的新连接与恢复都要等它交还目录锁。 */
269
+ const closing = new Map<string, Promise<void>>();
270
+ let engineProbe: Promise<DesktopPgliteEngineResult> | undefined;
271
+
272
+ /** 本 host 是否正连着该目录;正在关闭的实例先等它关完,免得恢复撞上自己 host 里的旧锁。 */
273
+ const isInUse = async (dataDirectoryName: string): Promise<boolean> => {
274
+ while (closing.has(dataDirectoryName)) await closing.get(dataDirectoryName);
275
+ return instances.has(dataDirectoryName);
276
+ };
277
+
278
+ const backupOptions = options.backup;
279
+ const support: BackupSupport | undefined =
280
+ backupOptions === undefined ? undefined : (
281
+ {
282
+ options: backupOptions,
283
+ extensions: [...new Set(backupOptions.extensions)].sort(),
284
+ restores: createPgliteRestoreCoordinator({
285
+ createRuntime: name => options.createRuntime(name),
286
+ resolveDataDirectory: name => backupOptions.resolveDataDirectory(name),
287
+ isInUse
288
+ })
289
+ }
290
+ );
244
291
 
245
292
  const deliver = (sessionId: string, channel: string, payload: string): void => {
246
293
  try {
@@ -250,20 +297,101 @@ export function createElectronPgliteHost(options: ElectronPgliteHostOptions): El
250
297
  }
251
298
  };
252
299
 
300
+ /**
301
+ * 取数据目录锁,并确认目录上没有未完成的恢复。
302
+ *
303
+ * @remarks
304
+ * 同步完成:`pg.open` 因此在第一个 tick 里就占住目录,之后才开始的恢复必然撞锁,
305
+ * 不存在「恢复查完空状态、连接随即打开」的窗口(AC#12)。未启用备份时不取锁。
306
+ */
307
+ const lockDirectory = (dataDirectoryName: string): PgliteDirectoryLock | undefined => {
308
+ if (support === undefined) return undefined;
309
+ const lock = acquirePgliteDirectoryLock(support.options.resolveDataDirectory(dataDirectoryName));
310
+ let marked: boolean;
311
+ try {
312
+ marked = lock.hasMarker();
313
+ } catch (error) {
314
+ lock.release();
315
+ throw error;
316
+ }
317
+ if (!marked) return lock;
318
+ lock.release();
319
+ // 半恢复的目录一旦被 PGlite 打开,缺的文件会被当成「本来就没有」,于是连上一个看似正常的坏库(AC#11)。
320
+ throw new RxDBAdapterDesktopError(
321
+ 'restore_incomplete',
322
+ `a restore of "${dataDirectoryName}" did not finish; run pg.restore.cleanup before connecting`
323
+ );
324
+ };
325
+
326
+ const subscribeChannels = async (runtime: ElectronPgliteRuntime, sessionIds: ReadonlySet<string>): Promise<void> => {
327
+ // 订阅在这里一次性建好,而不是每开一个会话建一次:LISTEN 是连接级的,
328
+ // 重复订阅只会让同一条 NOTIFY 被回调多次,进而让 renderer 侧的批量窗口收到重复事件。
329
+ for (const channel of DESKTOP_PGLITE_WATCH_CHANNELS) {
330
+ await runtime.listen(channel, payload => {
331
+ for (const sessionId of sessionIds) deliver(sessionId, channel, payload);
332
+ });
333
+ }
334
+ };
335
+
336
+ const launchRuntime = async (
337
+ dataDirectoryName: string,
338
+ sessionIds: ReadonlySet<string>
339
+ ): Promise<ElectronPgliteRuntime> => {
340
+ let runtime: ElectronPgliteRuntime;
341
+ try {
342
+ runtime = await options.createRuntime(dataDirectoryName);
343
+ } catch (error) {
344
+ throw openFailed(dataDirectoryName, error);
345
+ }
346
+ try {
347
+ await subscribeChannels(runtime, sessionIds);
348
+ } catch (error) {
349
+ // 订阅失败的运行时必须关掉:它开着数据目录,重试时起的新实例会与它争同一份文件。
350
+ await runtime.close().catch(() => undefined);
351
+ throw openFailed(dataDirectoryName, error);
352
+ }
353
+ return runtime;
354
+ };
355
+
356
+ const startRuntime = async (dataDirectoryName: string, sessionIds: ReadonlySet<string>): Promise<StartedRuntime> => {
357
+ const lock = lockDirectory(dataDirectoryName);
358
+ try {
359
+ return { runtime: await launchRuntime(dataDirectoryName, sessionIds), lock };
360
+ } catch (error) {
361
+ lock?.release();
362
+ throw error;
363
+ }
364
+ };
365
+
253
366
  const createInstance = (dataDirectoryName: string): InstanceEntry => {
254
367
  const sessionIds = new Set<string>();
255
- const ready = (async (): Promise<ElectronPgliteRuntime> => {
256
- const runtime = await options.createRuntime(dataDirectoryName);
257
- // 订阅在这里一次性建好,而不是每开一个会话建一次:LISTEN 是连接级的,
258
- // 重复订阅只会让同一条 NOTIFY 被回调多次,进而让 renderer 侧的批量窗口收到重复事件。
259
- for (const channel of DESKTOP_PGLITE_WATCH_CHANNELS) {
260
- await runtime.listen(channel, payload => {
261
- for (const sessionId of sessionIds) deliver(sessionId, channel, payload);
262
- });
263
- }
264
- return runtime;
265
- })();
266
- return { sessions: sessionIds, ready };
368
+ return { sessions: sessionIds, pending: 0, ready: startRuntime(dataDirectoryName, sessionIds) };
369
+ };
370
+
371
+ /** 关掉一个实例的运行时并交还目录锁;只有起来过、挂过会话的实例才会走到这里。 */
372
+ const shutdownInstance = async (instance: InstanceEntry): Promise<void> => {
373
+ const { runtime, lock } = await instance.ready;
374
+ try {
375
+ await runtime.close();
376
+ } finally {
377
+ lock?.release();
378
+ }
379
+ };
380
+
381
+ /** 从表里摘掉实例并关掉它;摘表与登记「正在关闭」之间没有 await,同名的请求不会漏看。 */
382
+ const releaseInstance = async (dataDirectoryName: string, instance: InstanceEntry): Promise<void> => {
383
+ instances.delete(dataDirectoryName);
384
+ const done = shutdownInstance(instance);
385
+ const settled = done.then(
386
+ () => undefined,
387
+ () => undefined
388
+ );
389
+ closing.set(dataDirectoryName, settled);
390
+ try {
391
+ await done;
392
+ } finally {
393
+ if (closing.get(dataDirectoryName) === settled) closing.delete(dataDirectoryName);
394
+ }
267
395
  };
268
396
 
269
397
  const requireSession = (sessionId: string, ownerId: number): SessionEntry => {
@@ -285,7 +413,7 @@ export function createElectronPgliteHost(options: ElectronPgliteHostOptions): El
285
413
  `session references a released instance for ${session.dataDirectoryName}`
286
414
  );
287
415
  }
288
- return instance.ready;
416
+ return (await instance.ready).runtime;
289
417
  };
290
418
 
291
419
  /**
@@ -343,19 +471,23 @@ export function createElectronPgliteHost(options: ElectronPgliteHostOptions): El
343
471
  ownerId: number
344
472
  ): Promise<DesktopPgliteResponse> => {
345
473
  const { dataDirectoryName } = request.storage;
474
+ while (closing.has(dataDirectoryName)) await closing.get(dataDirectoryName);
475
+ if (support?.restores.isReserved(dataDirectoryName)) {
476
+ throw new RxDBAdapterDesktopError('restore_in_progress', `"${dataDirectoryName}" is being restored`);
477
+ }
478
+ // 从上面的检查到登记实例之间没有 await:并发的恢复要么已经占了名字,要么会看见这个实例。
346
479
  const instance = instances.get(dataDirectoryName) ?? createInstance(dataDirectoryName);
347
480
  instances.set(dataDirectoryName, instance);
481
+ instance.pending += 1;
348
482
  try {
349
483
  await instance.ready;
350
484
  } catch (error) {
351
485
  // 起不来的实例不能留在表里:留着的话下一次 `pg.open` 会拿到同一个已经 reject 的
352
486
  // promise,于是「修好配置再试一次」永远不可能成功。
353
- if (instance.sessions.size === 0) instances.delete(dataDirectoryName);
354
- throw new RxDBAdapterDesktopError(
355
- 'open_failed',
356
- `the application could not open a PGlite runtime for ${dataDirectoryName}`,
357
- { cause: error }
358
- );
487
+ if (instances.get(dataDirectoryName) === instance) instances.delete(dataDirectoryName);
488
+ throw error;
489
+ } finally {
490
+ instance.pending -= 1;
359
491
  }
360
492
  const sessionId = randomUUID();
361
493
  instance.sessions.add(sessionId);
@@ -370,7 +502,7 @@ export function createElectronPgliteHost(options: ElectronPgliteHostOptions): El
370
502
  };
371
503
  };
372
504
 
373
- /** 关掉一条会话;数据目录上最后一条会话消失时连实例一起释放。 */
505
+ /** 关掉一条会话;数据目录上最后一条会话消失、且没有正在加入的 `pg.open` 时连实例一起释放。 */
374
506
  const closeSession = async (sessionId: string): Promise<void> => {
375
507
  const session = sessions.get(sessionId);
376
508
  if (!session) return;
@@ -378,10 +510,8 @@ export function createElectronPgliteHost(options: ElectronPgliteHostOptions): El
378
510
  const instance = instances.get(session.dataDirectoryName);
379
511
  if (!instance) return;
380
512
  instance.sessions.delete(sessionId);
381
- if (instance.sessions.size > 0) return;
382
- instances.delete(session.dataDirectoryName);
383
- const runtime = await instance.ready.catch(() => undefined);
384
- await runtime?.close();
513
+ if (instance.sessions.size > 0 || instance.pending > 0) return;
514
+ await releaseInstance(session.dataDirectoryName, instance);
385
515
  };
386
516
 
387
517
  /** 回滚一条会话名下全部挂起的事务,返回条数。 */
@@ -398,42 +528,9 @@ export function createElectronPgliteHost(options: ElectronPgliteHostOptions): El
398
528
  ownerId: number
399
529
  ): Promise<DesktopPgliteResponse> => {
400
530
  const runtime = await requireRuntime(requireSession(request.sessionId, ownerId));
401
-
402
- let settleHandles!: TransactionEntry['settle'];
403
- const closed = new Promise<void>((resolve, reject) => {
404
- settleHandles = { resolve, reject };
405
- });
406
- // 超时路径会在任何人 await 之前就 reject 它;先接住,免得变成未处理拒绝而打死进程。
407
- closed.catch(() => undefined);
408
-
409
- let markStarted!: (tx: ElectronPgliteTransaction) => void;
410
- const started = new Promise<ElectronPgliteTransaction>(resolve => {
411
- markStarted = resolve;
412
- });
413
- const finished = runtime.transaction(async tx => {
414
- markStarted(tx);
415
- // 事务从这里一直开着,直到 commit / rollback / 回收把 `closed` 结掉。
416
- await closed;
417
- });
418
- finished.catch(() => undefined);
419
-
420
- const tx = await raceTimeout(started, request.timeout);
421
- if (tx === TIMED_OUT) {
422
- // 关键在于 reject 而不是简单丢弃:被丢弃的 callback 迟早会在连接空出来的一瞬间
423
- // 启动,然后永远挂在 `await closed` 上——于是「超时之后再也开不了事务」,
424
- // 而现场看起来只是「数据库不响应」。reject 让它一进 callback 就抛,
425
- // PGlite 随即回滚这条空事务并交还连接。
426
- settleHandles.reject(
427
- new RxDBAdapterDesktopError('transaction_unavailable', 'the begin that opened this transaction timed out')
428
- );
429
- throw new RxDBAdapterDesktopError(
430
- 'transaction_unavailable',
431
- `waited ${request.timeout}ms for the PGlite connection but another transaction still holds it`
432
- );
433
- }
434
-
531
+ const held = await suspendTransaction(runtime, request.timeout);
435
532
  const transactionId = randomUUID();
436
- transactions.set(transactionId, { sessionId: request.sessionId, tx, settle: settleHandles, finished });
533
+ transactions.set(transactionId, { ...held, sessionId: request.sessionId });
437
534
  return { kind: 'pg.begin', result: { transactionId } };
438
535
  };
439
536
 
@@ -492,13 +589,163 @@ export function createElectronPgliteHost(options: ElectronPgliteHostOptions): El
492
589
  return { kind: 'pg.version', result: value };
493
590
  };
494
591
 
592
+ const requireBackupSupport = (): BackupSupport => {
593
+ if (support === undefined) {
594
+ throw new RxDBAdapterDesktopError('unsupported_operation', 'this host was created without backup support');
595
+ }
596
+ return support;
597
+ };
598
+
599
+ /** 起一个探针问出引擎版本;探针不碰任何数据目录,也不占任何会话的连接。 */
600
+ const probeEngine = async (backup: BackupSupport): Promise<DesktopPgliteEngineResult> => {
601
+ let probe: ElectronPgliteProbeRuntime;
602
+ try {
603
+ probe = await backup.options.createProbeRuntime();
604
+ } catch (error) {
605
+ throw new RxDBAdapterDesktopError('open_failed', 'the application could not start the PGlite probe runtime', {
606
+ cause: error
607
+ });
608
+ }
609
+ try {
610
+ const result = await runStatement(() => probe.query('SHOW server_version'));
611
+ const serverVersion = result.rows[0]?.['server_version'];
612
+ if (typeof serverVersion !== 'string' || serverVersion === '') {
613
+ throw new RxDBAdapterDesktopError('host_internal_error', 'PostgreSQL did not report server_version');
614
+ }
615
+ return { serverVersion, extensions: backup.extensions };
616
+ } finally {
617
+ await probe.close().catch(() => undefined);
618
+ }
619
+ };
620
+
621
+ const engine = async (backup: BackupSupport): Promise<DesktopPgliteResponse> => {
622
+ const probe = (engineProbe ??= probeEngine(backup));
623
+ try {
624
+ return { kind: 'pg.engine', result: await probe };
625
+ } catch (error) {
626
+ // 失败不缓存:探针起不来多半是环境问题(内存、WASM 加载),修好之后应当能重试。
627
+ if (engineProbe === probe) engineProbe = undefined;
628
+ throw error;
629
+ }
630
+ };
631
+
632
+ const backupBegin = async (
633
+ backup: BackupSupport,
634
+ request: DesktopPgliteBackupBeginRequest,
635
+ ownerId: number
636
+ ): Promise<DesktopPgliteResponse> => {
637
+ const session = requireSession(request.sessionId, ownerId);
638
+ // 先解析路径再占连接:解析抛错时不会留下一条没人收拾的挂起事务。
639
+ const directory = backup.options.resolveDataDirectory(session.dataDirectoryName);
640
+ const runtime = await requireRuntime(session);
641
+ const held = await suspendTransaction(runtime, request.timeout);
642
+ try {
643
+ // 挂起的空事务占住唯一的连接,此后没有语句能改动文件;CHECKPOINT 再把已提交的数据刷进
644
+ // 数据文件。目录树因此就是提交边界上的一致快照:在途事务要么已提交,要么还在排队(AC#16)。
645
+ await runStatement(() => held.tx.query('CHECKPOINT'));
646
+ if (!(await hasPgVersion(directory))) {
647
+ throw new RxDBAdapterDesktopError(
648
+ 'host_internal_error',
649
+ `resolveDataDirectory does not point at the data directory of "${session.dataDirectoryName}"`
650
+ );
651
+ }
652
+ } catch (error) {
653
+ held.settle.reject(new RxDBAdapterDesktopError('write_aborted', 'the snapshot was aborted'));
654
+ await held.finished.catch(() => undefined);
655
+ throw error;
656
+ }
657
+ const backupId = randomUUID();
658
+ backups.set(backupId, { sessionId: request.sessionId, iterator: walkPgliteDataDirectory(directory), held });
659
+ return { kind: 'pg.backup.begin', result: { backupId } };
660
+ };
661
+
662
+ /** 取一次进行中的快照;核对会话的理由与 {@link requireTransaction} 相同。 */
663
+ const requireBackup = (backupId: string, sessionId: string): BackupEntry => {
664
+ const entry = backups.get(backupId);
665
+ if (!entry || entry.sessionId !== sessionId) {
666
+ throw new RxDBAdapterDesktopError(
667
+ 'transaction_not_found',
668
+ `backup ${backupId} is unknown, already finished, or not owned by session ${sessionId}`
669
+ );
670
+ }
671
+ return entry;
672
+ };
673
+
674
+ /** 结束一次快照:先关遍历器(连同它开着的文件句柄),无论成败都放开连接。 */
675
+ const finishBackup = async (backupId: string): Promise<void> => {
676
+ const entry = backups.get(backupId);
677
+ if (!entry) return;
678
+ backups.delete(backupId);
679
+ try {
680
+ await entry.iterator.return(undefined);
681
+ } finally {
682
+ entry.held.settle.resolve();
683
+ await entry.held.finished.catch(() => undefined);
684
+ }
685
+ };
686
+
687
+ const backupNext = async (
688
+ request: DesktopPgliteBackupCursorRequest,
689
+ ownerId: number
690
+ ): Promise<DesktopPgliteResponse> => {
691
+ requireSession(request.sessionId, ownerId);
692
+ const entry = requireBackup(request.backupId, request.sessionId);
693
+ let next: IteratorResult<DesktopPgliteDataDirItem>;
694
+ try {
695
+ next = await entry.iterator.next();
696
+ } catch (error) {
697
+ // 读失败的快照续不下去:连接必须立刻交还,否则排在后面的写入会一直等。
698
+ await finishBackup(request.backupId);
699
+ throw error;
700
+ }
701
+ return { kind: 'pg.backup.next', result: next.done ? { type: 'end' } : next.value };
702
+ };
703
+
704
+ const backupEnd = async (
705
+ request: DesktopPgliteBackupCursorRequest,
706
+ ownerId: number
707
+ ): Promise<DesktopPgliteResponse> => {
708
+ requireSession(request.sessionId, ownerId);
709
+ requireBackup(request.backupId, request.sessionId);
710
+ await finishBackup(request.backupId);
711
+ return { kind: 'pg.backup.end' };
712
+ };
713
+
714
+ const dispatchBackup = async (
715
+ request: DesktopPgliteBackupRequest,
716
+ ownerId: number
717
+ ): Promise<DesktopPgliteResponse> => {
718
+ const backup = requireBackupSupport();
719
+ switch (request.kind) {
720
+ case 'pg.engine':
721
+ return engine(backup);
722
+ case 'pg.backup.begin':
723
+ return backupBegin(backup, request, ownerId);
724
+ case 'pg.backup.next':
725
+ return backupNext(request, ownerId);
726
+ case 'pg.backup.end':
727
+ return backupEnd(request, ownerId);
728
+ default:
729
+ return backup.restores.handle(request, ownerId);
730
+ }
731
+ };
732
+
733
+ /** 收掉一条会话:先结束它的快照,再回滚它的事务,最后关会话;返回回滚掉的事务条数。 */
734
+ const teardownSession = async (sessionId: string, reason: string): Promise<number> => {
735
+ const doomed = [...backups.entries()].filter(([, entry]) => entry.sessionId === sessionId).map(([id]) => id);
736
+ // 关遍历器失败只会漏一个只读句柄,连接在 finishBackup 里总会交还;不能因此把会话留着。
737
+ for (const backupId of doomed) await finishBackup(backupId).catch(() => undefined);
738
+ const rolledBack = await rollbackSessionTransactions(sessionId, reason);
739
+ await closeSession(sessionId);
740
+ return rolledBack;
741
+ };
742
+
495
743
  const close = async (
496
744
  request: Extract<DesktopPgliteRequest, { kind: 'pg.close' }>,
497
745
  ownerId: number
498
746
  ): Promise<DesktopPgliteResponse> => {
499
747
  requireSession(request.sessionId, ownerId);
500
- await rollbackSessionTransactions(request.sessionId, `session ${request.sessionId} was closed`);
501
- await closeSession(request.sessionId);
748
+ await teardownSession(request.sessionId, `session ${request.sessionId} was closed`);
502
749
  return { kind: 'pg.close' };
503
750
  };
504
751
 
@@ -506,8 +753,9 @@ export function createElectronPgliteHost(options: ElectronPgliteHostOptions): El
506
753
  * 按种类派发一条已通过协议校验的请求。
507
754
  *
508
755
  * @remarks
509
- * 穷尽 `switch` 的理由与 SQLite host 完全一致:协议加了新种类而这里忘记补分支时,
510
- * `_exhaustive: never` 让它在 `tsc` 阶段就红,而不是在运行期变成怪异失败。
756
+ * 协议加了新种类而这里忘记补分支时,`default` 把它交给 `dispatchBackup`,后者的参数类型
757
+ * 不接受它,于是在 `tsc` 阶段就红,而不是在运行期变成怪异失败——与 SQLite host 的穷尽
758
+ * `switch` 同一个目的。
511
759
  */
512
760
  const dispatch = (request: DesktopPgliteRequest, ownerId: number): Promise<DesktopPgliteResponse> => {
513
761
  switch (request.kind) {
@@ -534,13 +782,8 @@ export function createElectronPgliteHost(options: ElectronPgliteHostOptions): El
534
782
  return version(request, ownerId);
535
783
  case 'pg.close':
536
784
  return close(request, ownerId);
537
- default: {
538
- const _exhaustive: never = request;
539
- throw new RxDBAdapterDesktopError(
540
- 'protocol_violation',
541
- `unsupported pglite request kind: ${String((_exhaustive as { kind?: unknown }).kind)}`
542
- );
543
- }
785
+ default:
786
+ return dispatchBackup(request, ownerId);
544
787
  }
545
788
  };
546
789
 
@@ -561,20 +804,26 @@ export function createElectronPgliteHost(options: ElectronPgliteHostOptions): El
561
804
  get openTransactionCount(): number {
562
805
  return transactions.size;
563
806
  },
807
+ get openBackupCount(): number {
808
+ return backups.size;
809
+ },
810
+ get openRestoreCount(): number {
811
+ return support === undefined ? 0 : support.restores.openCount;
812
+ },
564
813
  releaseOwner: async (ownerId: number): Promise<number> => {
565
814
  const doomed = [...sessions.entries()].filter(([, session]) => session.owner === ownerId).map(([id]) => id);
566
815
  let rolledBack = 0;
567
816
  for (const sessionId of doomed) {
568
- rolledBack += await rollbackSessionTransactions(sessionId, `owner ${ownerId} is gone`);
569
- await closeSession(sessionId);
817
+ rolledBack += await teardownSession(sessionId, `owner ${ownerId} is gone`);
570
818
  }
819
+ await support?.restores.releaseOwner(ownerId);
571
820
  return rolledBack;
572
821
  },
573
822
  closeAll: async (): Promise<void> => {
574
823
  for (const sessionId of [...sessions.keys()]) {
575
- await rollbackSessionTransactions(sessionId, 'the host is shutting down');
576
- await closeSession(sessionId);
824
+ await teardownSession(sessionId, 'the host is shutting down');
577
825
  }
826
+ await support?.restores.closeAll();
578
827
  }
579
828
  };
580
829
  }