@aiao/rxdb-adapter-electron 0.0.0-stage → 0.0.26

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 (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +293 -2
  3. package/dist/RxDBAdapterElectron.d.ts +35 -0
  4. package/dist/RxDBAdapterElectron.d.ts.map +1 -0
  5. package/dist/electron-adapter.interface.d.ts +23 -0
  6. package/dist/electron-adapter.interface.d.ts.map +1 -0
  7. package/dist/electron-file-host.d.ts +52 -0
  8. package/dist/electron-file-host.d.ts.map +1 -0
  9. package/dist/electron-sqlite-host.d.ts +65 -0
  10. package/dist/electron-sqlite-host.d.ts.map +1 -0
  11. package/dist/host.d.ts +19 -0
  12. package/dist/host.d.ts.map +1 -0
  13. package/dist/host.js +762 -0
  14. package/dist/index.d.ts +22 -0
  15. package/dist/index.d.ts.map +1 -0
  16. package/dist/index.js +22 -0
  17. package/dist/node-sqlite-engine.d.ts +87 -0
  18. package/dist/node-sqlite-engine.d.ts.map +1 -0
  19. package/dist/pglite/RxDBAdapterElectronPGlite.d.ts +44 -0
  20. package/dist/pglite/RxDBAdapterElectronPGlite.d.ts.map +1 -0
  21. package/dist/pglite/desktop-pglite-client.d.ts +118 -0
  22. package/dist/pglite/desktop-pglite-client.d.ts.map +1 -0
  23. package/dist/pglite/pglite-adapter.interface.d.ts +56 -0
  24. package/dist/pglite/pglite-adapter.interface.d.ts.map +1 -0
  25. package/dist/pglite-host/electron-pglite-host.d.ts +114 -0
  26. package/dist/pglite-host/electron-pglite-host.d.ts.map +1 -0
  27. package/dist/pglite-host.d.ts +17 -0
  28. package/dist/pglite-host.d.ts.map +1 -0
  29. package/dist/pglite-host.js +215 -0
  30. package/dist/pglite.d.ts +20 -0
  31. package/dist/pglite.d.ts.map +1 -0
  32. package/dist/pglite.js +217 -0
  33. package/dist/sqlite-script.d.ts +26 -0
  34. package/dist/sqlite-script.d.ts.map +1 -0
  35. package/package.json +91 -3
  36. package/src/RxDBAdapterElectron.ts +66 -0
  37. package/src/electron-adapter.interface.ts +25 -0
  38. package/src/electron-file-host.ts +758 -0
  39. package/src/electron-sqlite-host.ts +298 -0
  40. package/src/host.ts +63 -0
  41. package/src/index.ts +70 -0
  42. package/src/node-sqlite-engine.ts +612 -0
  43. package/src/pglite/RxDBAdapterElectronPGlite.ts +77 -0
  44. package/src/pglite/desktop-pglite-client.ts +520 -0
  45. package/src/pglite/pglite-adapter.interface.ts +64 -0
  46. package/src/pglite-host/electron-pglite-host.ts +580 -0
  47. package/src/pglite-host.ts +43 -0
  48. package/src/pglite.ts +42 -0
  49. package/src/sqlite-script.ts +134 -0
@@ -0,0 +1,520 @@
1
+ /**
2
+ * renderer 侧的桌面 PGlite 客户端,实现 `@aiao/rxdb-adapter-pglite` 的 `IPGliteClient` 契约。
3
+ *
4
+ * @remarks
5
+ * 它把 query / exec / transaction 转成 `pg.*` 请求发给主进程持有的唯一 PGlite 实例,
6
+ * 因此 `RxDBAdapterPGlite` 里的全部 SQL、迁移、变更管线原样复用——桌面路径不是另一套
7
+ * 实现,只是同一份契约换了条传输通道。
8
+ *
9
+ * 事务用的是 US-208 线 G 冻结的「IPC 事务 ID 协议」:`pg.begin` 换回一个 host 签发的
10
+ * 事务 ID,随后的语句带着它走,最后 `pg.commit` / `pg.rollback` 结清。
11
+ *
12
+ * @module pglite/desktop-pglite-client
13
+ */
14
+
15
+ import {
16
+ DEFAULT_NOTIFY_BATCH_TIMEOUT_MS,
17
+ PGliteNotificationBatcher,
18
+ type IPGliteClient,
19
+ type PGliteClientEvents,
20
+ type PGliteClientOptions
21
+ } from '@aiao/rxdb-adapter-pglite';
22
+ import {
23
+ RxDBAdapterDesktopError,
24
+ assertDesktopPgliteResponse,
25
+ parseDesktopPgliteHandshakeResult,
26
+ parseDesktopPgliteNotifyMessage,
27
+ parseDesktopPgliteOpenResult,
28
+ type DesktopHostTransport,
29
+ type DesktopPgliteParam,
30
+ type DesktopPgliteQueryResult,
31
+ type DesktopPgliteRequest
32
+ } from '@aiao/rxdb-adapter-sqlite-core/desktop-host';
33
+ import { EventDispatcher } from '@aiao/utils';
34
+ import type { QueryOptions, Results, Transaction } from '@electric-sql/pglite';
35
+ // 模板编译走 PGlite 自己的实现,而不是本地再拼一遍 `$1、$2`:`identifier` / `raw` / 嵌套
36
+ // `sql` 这些辅助器的语义只有原实现说了算,照抄一份的分叉表征是「同一个模板在桌面下
37
+ // 少转义了一个标识符」。`/template` 是个独立子路径(约 2 KB),不带任何 WASM 进 renderer。
38
+ import { query as compileTemplate } from '@electric-sql/pglite/template';
39
+
40
+ /** {@link DesktopPGliteClient} 的构造参数。 */
41
+ export interface DesktopPGliteClientOptions {
42
+ /** preload 或 worker 桥接提供的传输层。 */
43
+ readonly transport: DesktopHostTransport;
44
+ /** 应用作用域内的逻辑数据目录名;host 在自己的应用数据目录里解析它。 */
45
+ readonly dataDirectoryName: string;
46
+ /** `pg.begin` 的等待上限(毫秒);省略时由 host 用协议默认值。 */
47
+ readonly beginTimeout?: number;
48
+ /** NOTIFY 批量窗口(毫秒);省略时用 {@link DEFAULT_NOTIFY_BATCH_TIMEOUT_MS}。 */
49
+ readonly batchTimeout?: number;
50
+ }
51
+
52
+ /**
53
+ * 把 host 的应答还原成 PGlite 的 `Results`。
54
+ *
55
+ * @remarks
56
+ * `rows` 里的值已经是 PG 的原生 JS 表示(bigint / `Uint8Array` / `Date` / 普通对象),
57
+ * 结构化克隆逐值搬过来,这里不做任何再解析——多一层转换就多一处能悄悄丢精度的地方。
58
+ */
59
+ const toResults = <T>(result: DesktopPgliteQueryResult): Results<T> => ({
60
+ rows: result.rows as unknown as T[],
61
+ fields: result.fields.map(field => ({ name: field.name, dataTypeID: field.dataTypeID })),
62
+ affectedRows: result.affectedRows
63
+ });
64
+
65
+ /**
66
+ * 拒绝本代理无法转发的 `QueryOptions`。
67
+ *
68
+ * @remarks
69
+ * `parsers` / `serializers` / `onNotice` 都是函数,`blob` 是流——没有一个过得了结构化克隆。
70
+ * 静默忽略它们的后果是调用方以为自己换了解析器,而拿到的还是默认解析结果,
71
+ * 且**没有任何报错**。这里明确失败,比装作支持诚实。
72
+ */
73
+ const assertNoQueryOptions = (options: QueryOptions | undefined): void => {
74
+ if (options === undefined) return;
75
+ throw new RxDBAdapterDesktopError(
76
+ 'protocol_violation',
77
+ 'the desktop PGlite protocol carries no QueryOptions; parsers, serializers, onNotice and blob ' +
78
+ 'cannot cross the process boundary'
79
+ );
80
+ };
81
+
82
+ /**
83
+ * 拒绝由 renderer 指定的落盘位置。
84
+ *
85
+ * @remarks
86
+ * 存储归主进程独占(AC#5):默默忽略 `store` / `dataDir` 会让调用方以为数据落在它指定的
87
+ * 位置,而实际在别处——症状是「重启后数据没了」,排查时根本不会怀疑到这两个被忽略的字段。
88
+ */
89
+ const assertHostOwnedStorage = (options: PGliteClientOptions): void => {
90
+ if (options.store === undefined && options.dataDir === undefined) return;
91
+ throw new RxDBAdapterDesktopError(
92
+ 'protocol_violation',
93
+ 'the desktop PGlite host owns storage; store and dataDir must not be set on the renderer side'
94
+ );
95
+ };
96
+
97
+ const unsupportedTransactionOperation = (operation: string): RxDBAdapterDesktopError =>
98
+ new RxDBAdapterDesktopError(
99
+ 'protocol_violation',
100
+ `${operation}() cannot be proxied to the desktop PGlite host; use query() or exec() instead`
101
+ );
102
+
103
+ /**
104
+ * 把通知回调里的异常挪出传输层的调用栈。
105
+ *
106
+ * @remarks
107
+ * 与 `DesktopSqliteClient` 的同名手法一字不差,因为病因一字不差:NOTIFY 通道是全 renderer
108
+ * 共享的,`ipcRenderer.on` 又是**同步扇出**,异常一路抛回 Electron 自己的 IPC 派发循环,
109
+ * 排在后面的监听者(通常是另一个库的客户端)就收不到这条消息——表现为「某个库的响应式
110
+ * 查询不刷新」,与那条畸形消息毫无关联线索。
111
+ *
112
+ * 不吞:丢进微任务原样重抛,落成一条 uncaught error。传输层不该替调用方决定
113
+ * 「这个错误不重要」,它只是不该由传输层来接。
114
+ */
115
+ const reportOutOfBand = (error: unknown): void => {
116
+ queueMicrotask(() => {
117
+ throw error;
118
+ });
119
+ };
120
+
121
+ /**
122
+ * 通过桌面 host 访问主进程 PGlite 实例的客户端。
123
+ *
124
+ * @remarks
125
+ * 两条串行化链各管一件事:
126
+ * - {@link DesktopPGliteClient.#tail} 是**会话内 FIFO**,保证发出顺序即执行顺序;
127
+ * - {@link DesktopPGliteClient.#lock} 是**本地互斥**,让事务独占整段时间。
128
+ *
129
+ * 后者不是可有可无的谨慎:主进程只有一条 PGlite 连接,一条事务开着时它被挂起的
130
+ * callback 攥着。没有本地互斥的话,第二条事务的 `pg.begin`(或一条自动提交语句)会排在
131
+ * 会话队列里等那条连接,而连接又要等第一条事务把剩下的语句发完——双方互相等,
132
+ * 表现为几秒后一句 `transaction_unavailable`,看上去像 host 有 bug。浏览器路径上
133
+ * PGlite 自己的 mutex 做的正是同一件事,这里只是把它挪到了进程边界的这一侧。
134
+ *
135
+ * @public
136
+ */
137
+ export class DesktopPGliteClient extends EventDispatcher<PGliteClientEvents> implements IPGliteClient {
138
+ readonly #options: DesktopPGliteClientOptions;
139
+ readonly #batcher: PGliteNotificationBatcher;
140
+ #dbName = '';
141
+ #sessionId?: string;
142
+ #resolvedLocation?: string;
143
+ #unsubscribe?: () => void;
144
+ #closed = false;
145
+ #closePromise?: Promise<void>;
146
+ /** 会话内 FIFO 队尾;恒不 reject,否则一条查询出错会把整个会话废掉。 */
147
+ #tail: Promise<unknown> = Promise.resolve();
148
+ /** 本地互斥队尾;同样恒不 reject。 */
149
+ #lock: Promise<unknown> = Promise.resolve();
150
+
151
+ /** 本客户端在 host 上的会话 ID,用于把推送过来的 NOTIFY 对号入座。 */
152
+ get sessionId(): string {
153
+ if (!this.#sessionId) {
154
+ throw new RxDBAdapterDesktopError('session_closed', 'the desktop PGlite client has not been initialised yet');
155
+ }
156
+ return this.#sessionId;
157
+ }
158
+
159
+ /** host 解析出的逻辑位置,仅供诊断(AC#5)。 */
160
+ get resolvedLocation(): string {
161
+ if (!this.#resolvedLocation) {
162
+ throw new RxDBAdapterDesktopError('session_closed', 'the desktop PGlite client has not been initialised yet');
163
+ }
164
+ return this.#resolvedLocation;
165
+ }
166
+
167
+ /** 尚未分发的 NOTIFY 行事件数量。 */
168
+ get pendingNotificationCount(): number {
169
+ return this.#batcher.pendingCount;
170
+ }
171
+
172
+ constructor(options: DesktopPGliteClientOptions) {
173
+ super();
174
+ this.#options = options;
175
+ this.#batcher = new PGliteNotificationBatcher({
176
+ resolveDbName: () => this.#dbName,
177
+ emit: event => this.dispatchEvent(event.type, event),
178
+ batchTimeout: options.batchTimeout ?? DEFAULT_NOTIFY_BATCH_TIMEOUT_MS,
179
+ onParseError: error => console.error('Failed to parse desktop NOTIFY payload:', error)
180
+ });
181
+ }
182
+
183
+ /**
184
+ * 握手、打开会话并订阅变更推送。
185
+ *
186
+ * @remarks
187
+ * 握手排在 `pg.open` 之前是 AC#11 的全部内容:`pg.open` 会 `mkdir` 出数据目录并在里面
188
+ * 跑 initdb,等 renderer 从 open 应答里读出版本不匹配时,磁盘上已经多了一整棵目录树,
189
+ * 而调用方拿不到 client,也就没有把手去收拾它。
190
+ *
191
+ * @param dbName - RxDB 逻辑库名;只用于变更事件的 `dbName` 字段,不参与落盘位置解析
192
+ * @param options - PGlite 选项;**不得**含 `store` 或 `dataDir`
193
+ * @throws {@link RxDBAdapterDesktopError} 指定了落盘位置、协议版本不匹配或 host 打不开数据目录时
194
+ */
195
+ async init(dbName: string, options: PGliteClientOptions): Promise<void> {
196
+ assertHostOwnedStorage(options);
197
+ if (this.#sessionId) {
198
+ throw new RxDBAdapterDesktopError(
199
+ 'protocol_violation',
200
+ `the desktop PGlite client is already bound to session ${this.#sessionId}`
201
+ );
202
+ }
203
+ this.#dbName = dbName;
204
+
205
+ const handshake = assertDesktopPgliteResponse(
206
+ 'pg.handshake',
207
+ await this.#options.transport.request({ kind: 'pg.handshake' })
208
+ );
209
+ parseDesktopPgliteHandshakeResult(handshake.result);
210
+
211
+ const opened = assertDesktopPgliteResponse(
212
+ 'pg.open',
213
+ await this.#options.transport.request({
214
+ kind: 'pg.open',
215
+ storage: { engine: 'pglite', dataDirectoryName: this.#options.dataDirectoryName }
216
+ })
217
+ );
218
+ const result = await this.#parseOpenResultOrClose(opened.result);
219
+ this.#sessionId = result.sessionId;
220
+ this.#resolvedLocation = result.resolvedLocation;
221
+ this.#unsubscribe = this.#options.transport.subscribe(message => this.#onMessage(message));
222
+ await this.#awaitSubscription();
223
+ }
224
+
225
+ /**
226
+ * 执行一条带参数的语句(扩展查询协议)。
227
+ *
228
+ * @param query - SQL
229
+ * @param params - 位置参数
230
+ * @param options - **必须省略**,见 {@link assertNoQueryOptions}
231
+ * @returns 与浏览器路径同形状的结果
232
+ * @throws {@link RxDBAdapterDesktopError} 会话已关闭、传了 `options`,或 host 报告执行失败时
233
+ */
234
+ async query<T>(query: string, params?: unknown[], options?: QueryOptions): Promise<Results<T>> {
235
+ this.#assertOpen();
236
+ assertNoQueryOptions(options);
237
+ return this.#exclusive(async () => this.#queryOn(undefined, query, params));
238
+ }
239
+
240
+ /**
241
+ * 以标签模板执行一条语句,参数自动参数化。
242
+ *
243
+ * @param sqlStrings - 模板字面量片段
244
+ * @param params - 插值;`identifier` / `raw` / 嵌套 `sql` 辅助器按原语义处理
245
+ * @returns 与浏览器路径同形状的结果
246
+ * @throws {@link RxDBAdapterDesktopError} 会话已关闭,或 host 报告执行失败时
247
+ */
248
+ async sql<T>(sqlStrings: TemplateStringsArray, ...params: unknown[]): Promise<Results<T>> {
249
+ this.#assertOpen();
250
+ const compiled = compileTemplate(sqlStrings, ...params);
251
+ return this.#exclusive(async () => this.#queryOn(undefined, compiled.query, compiled.params));
252
+ }
253
+
254
+ /**
255
+ * 执行一段多语句脚本(简单查询协议)。
256
+ *
257
+ * @param query - 一条或多条以 `;` 分隔的语句
258
+ * @param options - **必须省略**,见 {@link assertNoQueryOptions}
259
+ * @returns 每条语句一个结果
260
+ * @throws {@link RxDBAdapterDesktopError} 会话已关闭、传了 `options`,或 host 报告执行失败时
261
+ */
262
+ async exec(query: string, options?: QueryOptions): Promise<Results[]> {
263
+ this.#assertOpen();
264
+ assertNoQueryOptions(options);
265
+ return this.#exclusive(async () => this.#execOn(undefined, query));
266
+ }
267
+
268
+ /**
269
+ * 在主进程那条唯一连接上开一条真事务。
270
+ *
271
+ * @remarks
272
+ * 回调拿到的 `tx` 是个代理:它的每条语句都带着 host 签发的事务 ID 过去,因此确实落在
273
+ * 同一条 PostgreSQL 事务里,而不是被包装成「看起来像事务」的独立请求(AC#2)。
274
+ * 回调正常返回即 `pg.commit`,抛出即 `pg.rollback`——与 PGlite 自己的语义一致。
275
+ *
276
+ * @param callback - 事务体
277
+ * @returns 回调的返回值
278
+ * @throws 回调抛出的原始异常(事务已回滚),或 {@link RxDBAdapterDesktopError}
279
+ */
280
+ async transaction<T>(callback: (tx: Transaction) => Promise<T>): Promise<T> {
281
+ this.#assertOpen();
282
+ return this.#exclusive(() => this.#runTransaction(callback));
283
+ }
284
+
285
+ /**
286
+ * 报告 host 侧 PostgreSQL 的版本串。
287
+ *
288
+ * @returns 形如 `PostgreSQL 17.x ...` 的版本串
289
+ */
290
+ async version(): Promise<string> {
291
+ this.#assertOpen();
292
+ return this.#exclusive(
293
+ async () =>
294
+ assertDesktopPgliteResponse('pg.version', await this.#send({ kind: 'pg.version', sessionId: this.sessionId }))
295
+ .result
296
+ );
297
+ }
298
+
299
+ /**
300
+ * 断开会话。
301
+ *
302
+ * @remarks
303
+ * 等在途请求与开着的事务跑完再关,因此调用方返回时 host 上确实已经没有本会话的
304
+ * 任何残留。并发调用共享同一个关闭流程。
305
+ */
306
+ async disconnect(): Promise<void> {
307
+ this.#closePromise ??= this.#runClose(true);
308
+ await this.#closePromise;
309
+ }
310
+
311
+ /**
312
+ * 不等在途请求,直接让 host 释放本会话。
313
+ *
314
+ * @remarks
315
+ * host 收到 `pg.close` 会把本会话仍开着的事务全部回滚,因此「不等」丢掉的只是那些
316
+ * 未提交的写——这正是 `forceClose` 与 {@link DesktopPGliteClient.disconnect} 的区别。
317
+ */
318
+ async forceClose(): Promise<void> {
319
+ this.#closePromise ??= this.#runClose(false);
320
+ await this.#closePromise;
321
+ }
322
+
323
+ /**
324
+ * 解析 `pg.open` 结果;解析不过就先把 host 上刚开出来的会话关掉,再抛原始错误。
325
+ *
326
+ * @remarks
327
+ * 走到这里说明 host 已经建好数据目录、起了实例、登记了会话,而 renderer 才发现结果
328
+ * 形状不对。此时调用方拿不到 client,也就永远没有关掉那条会话的把手。
329
+ */
330
+ async #parseOpenResultOrClose(result: unknown): Promise<{ sessionId: string; resolvedLocation: string }> {
331
+ try {
332
+ return parseDesktopPgliteOpenResult(result);
333
+ } catch (error) {
334
+ const record = typeof result === 'object' && result !== null ? (result as Record<string, unknown>) : undefined;
335
+ const sessionId = record?.['sessionId'];
336
+ if (typeof sessionId === 'string') {
337
+ // 收摊失败不上报:连接已经带着准确的原因失败了,把它盖掉只会让诊断更远。
338
+ await this.#options.transport.request({ kind: 'pg.close', sessionId }).catch(() => undefined);
339
+ }
340
+ throw error;
341
+ }
342
+ }
343
+
344
+ /**
345
+ * 等变更推送通道真正建好,失败就回滚刚打开的会话。
346
+ *
347
+ * @remarks
348
+ * 订阅建不起来意味着响应式查询永远不刷新。返回一个「能查、但永不刷新」的客户端是所有
349
+ * 故障形态里最难查的一种,所以这里让 `init()` 直接失败。
350
+ */
351
+ async #awaitSubscription(): Promise<void> {
352
+ try {
353
+ await this.#options.transport.subscriptionReady?.();
354
+ } catch (error) {
355
+ const sessionId = this.#sessionId;
356
+ this.#closed = true;
357
+ this.#unsubscribe?.();
358
+ await this.#send({ kind: 'pg.close', sessionId: sessionId! }).catch(() => undefined);
359
+ throw new RxDBAdapterDesktopError(
360
+ 'host_unavailable',
361
+ `desktop PGlite session ${String(sessionId)} could not subscribe to host notifications`,
362
+ { cause: error }
363
+ );
364
+ }
365
+ }
366
+
367
+ async #runClose(drain: boolean): Promise<void> {
368
+ if (drain) await this.#lock.catch(() => undefined);
369
+ this.#closed = true;
370
+ if (drain) await this.#tail.catch(() => undefined);
371
+ const sessionId = this.#sessionId;
372
+ this.#unsubscribe?.();
373
+ this.#batcher.clear();
374
+ this.removeAllEventListeners();
375
+ if (!sessionId) return;
376
+ assertDesktopPgliteResponse('pg.close', await this.#options.transport.request({ kind: 'pg.close', sessionId }));
377
+ }
378
+
379
+ async #runTransaction<T>(callback: (tx: Transaction) => Promise<T>): Promise<T> {
380
+ const begun = assertDesktopPgliteResponse(
381
+ 'pg.begin',
382
+ await this.#send({
383
+ kind: 'pg.begin',
384
+ sessionId: this.sessionId,
385
+ ...(this.#options.beginTimeout === undefined ? {} : { timeout: this.#options.beginTimeout })
386
+ } as DesktopPgliteRequest)
387
+ );
388
+ const transactionId = begun.result.transactionId;
389
+ let settled = false;
390
+
391
+ const end = async (kind: 'pg.commit' | 'pg.rollback'): Promise<void> => {
392
+ settled = true;
393
+ assertDesktopPgliteResponse(kind, await this.#send({ kind, sessionId: this.sessionId, transactionId }));
394
+ };
395
+ const assertLive = (): void => {
396
+ if (settled) {
397
+ throw new RxDBAdapterDesktopError('transaction_not_found', `transaction ${transactionId} is already closed`);
398
+ }
399
+ };
400
+
401
+ const tx: Transaction = {
402
+ query: async <R>(sql: string, params?: unknown[], options?: QueryOptions): Promise<Results<R>> => {
403
+ assertLive();
404
+ assertNoQueryOptions(options);
405
+ return this.#queryOn(transactionId, sql, params);
406
+ },
407
+ exec: async (sql: string, options?: QueryOptions): Promise<Results[]> => {
408
+ assertLive();
409
+ assertNoQueryOptions(options);
410
+ return this.#execOn(transactionId, sql);
411
+ },
412
+ rollback: async (): Promise<void> => {
413
+ assertLive();
414
+ await end('pg.rollback');
415
+ },
416
+ sql: async <R>(sqlStrings: TemplateStringsArray, ...values: unknown[]): Promise<Results<R>> => {
417
+ assertLive();
418
+ const compiled = compileTemplate(sqlStrings, ...values);
419
+ return this.#queryOn(transactionId, compiled.query, compiled.params);
420
+ },
421
+ // `listen` 代理不了:它要在**这条连接**上挂一个回调,而回调过不了进程边界。
422
+ // 静默降级成「订阅了但永远收不到」是最难查的一种故障,所以当场炸。
423
+ // 事务内的通知请改用 renderer 侧的 NOTIFY 批量器(本客户端已经在监听)。
424
+ listen: (): Promise<(tx?: Transaction) => Promise<void>> => {
425
+ throw unsupportedTransactionOperation('listen');
426
+ },
427
+ get closed(): boolean {
428
+ return settled;
429
+ }
430
+ };
431
+
432
+ try {
433
+ const result = await callback(tx);
434
+ if (!settled) await end('pg.commit');
435
+ return result;
436
+ } catch (error) {
437
+ // 回滚失败不覆盖原始错误:调用方要看的是业务那条,而不是收摊那条。
438
+ if (!settled) await end('pg.rollback').catch(() => undefined);
439
+ throw error;
440
+ }
441
+ }
442
+
443
+ async #queryOn<T>(transactionId: string | undefined, sql: string, params?: unknown[]): Promise<Results<T>> {
444
+ const request: DesktopPgliteRequest = {
445
+ kind: 'pg.query',
446
+ sessionId: this.sessionId,
447
+ sql,
448
+ params: (params ?? []) as readonly DesktopPgliteParam[],
449
+ ...(transactionId === undefined ? {} : { transactionId })
450
+ };
451
+ return toResults<T>(assertDesktopPgliteResponse('pg.query', await this.#send(request)).result);
452
+ }
453
+
454
+ async #execOn(transactionId: string | undefined, sql: string): Promise<Results[]> {
455
+ const request: DesktopPgliteRequest = {
456
+ kind: 'pg.exec',
457
+ sessionId: this.sessionId,
458
+ sql,
459
+ ...(transactionId === undefined ? {} : { transactionId })
460
+ };
461
+ return assertDesktopPgliteResponse('pg.exec', await this.#send(request)).result.map(result => toResults(result));
462
+ }
463
+
464
+ /**
465
+ * 独占执行:事务整段、单条自动提交语句各算一次。
466
+ *
467
+ * @remarks
468
+ * 必须在任何 `await` 之前**同步**接上队尾,否则两个并发调用会抢到同一个队尾,
469
+ * 互斥退化成没有。
470
+ */
471
+ #exclusive<T>(run: () => Promise<T>): Promise<T> {
472
+ const settled = this.#lock.then(run);
473
+ this.#lock = settled.then(undefined, () => undefined);
474
+ return settled;
475
+ }
476
+
477
+ /**
478
+ * 把一个请求排进会话队列,队列空了才发上传输层。
479
+ *
480
+ * @remarks
481
+ * 一个会话同一时刻只允许有一个在途请求:会话在 host 侧就是同一条 PGlite 连接上的
482
+ * 一串调用,允许交错的话「先发的先执行」不再成立,一条 `SELECT` 可能挤到 `DELETE`
483
+ * 前面跑,上层看到的是刚删掉的行还在。
484
+ */
485
+ #send(payload: DesktopPgliteRequest): Promise<unknown> {
486
+ const settled = this.#tail.then(() => this.#options.transport.request(payload));
487
+ this.#tail = settled.then(undefined, () => undefined);
488
+ return settled;
489
+ }
490
+
491
+ #assertOpen(): void {
492
+ if (this.#closed || !this.#sessionId) {
493
+ throw new RxDBAdapterDesktopError(
494
+ 'session_closed',
495
+ `desktop PGlite session ${String(this.#sessionId)} is not open`
496
+ );
497
+ }
498
+ }
499
+
500
+ /**
501
+ * 收下一条 host 推送。
502
+ *
503
+ * @remarks
504
+ * 变更通道是全 renderer 共享的:SQLite host 的 `change` 消息、别的会话的 NOTIFY 都会
505
+ * 从这里过。先按 `kind` 与 `sessionId` 筛掉不属于自己的,再解析——顺序反过来的话,
506
+ * 一条本来就不该由自己处理的消息会因为形状不符而抛错。
507
+ */
508
+ #onMessage(message: unknown): void {
509
+ if (this.#closed) return;
510
+ if (typeof message !== 'object' || message === null) return;
511
+ if ((message as { kind?: unknown }).kind !== 'pg.notify') return;
512
+ if ((message as { sessionId?: unknown }).sessionId !== this.#sessionId) return;
513
+ try {
514
+ const notification = parseDesktopPgliteNotifyMessage(message);
515
+ this.#batcher.accept(notification.channel, notification.payload);
516
+ } catch (error) {
517
+ reportOutOfBand(error);
518
+ }
519
+ }
520
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Electron PGlite 适配器在本包内的身份与选项。
3
+ *
4
+ * @module pglite/pglite-adapter.interface
5
+ */
6
+
7
+ import type { DesktopHostTransport } from '@aiao/rxdb-adapter-sqlite-core/desktop-host';
8
+
9
+ /**
10
+ * 适配器在 `RxDBAdapters` 注册表中的名字。
11
+ *
12
+ * @remarks
13
+ * 与 `sqlite-electron` 并列,同一个 Electron 运行时上两个适配器可以同时注册
14
+ * (US-207 已为此把 SQLite 侧的名字从 `electron` 改成了 `sqlite-electron`)。
15
+ *
16
+ * 这里**刻意不写** `satisfies DesktopHostAdapterName`:那份登记表是「走 SQLite host
17
+ * 协议的适配器」的清单,PGlite 走的是另一族协议(`pg.*`)。硬套上去会让共享层的
18
+ * 存储联合把 `pglite-electron` 也当成能接受 `DesktopSqliteFileStorage` 的适配器。
19
+ */
20
+ export const ADAPTER_NAME = 'pglite-electron' as const;
21
+
22
+ /**
23
+ * 逻辑数据目录名的默认后缀。
24
+ *
25
+ * @remarks
26
+ * 与 SQLite 侧的 `DEFAULT_DATABASE_SUFFIX` 同构,但后缀不同:PGlite 落盘的是一棵
27
+ * initdb 生成的**目录树**而不是单个文件,用 `.db` 之类的文件后缀命名只会误导运维。
28
+ */
29
+ export const DEFAULT_DATA_DIRECTORY_SUFFIX = '-pgdata';
30
+
31
+ /** {@link RxDBAdapterElectronPGlite} 的构造选项。 */
32
+ export interface ElectronPGliteOptions {
33
+ /**
34
+ * 传输层;省略时从 `globalThis` 上读取 preload 注入的那一份。
35
+ *
36
+ * @remarks
37
+ * 显式传入主要供测试与非 Electron 宿主使用,见
38
+ * {@link @aiao/rxdb-adapter-sqlite-core/desktop-host#resolveDesktopHostTransport}。
39
+ */
40
+ readonly transport?: DesktopHostTransport;
41
+ /**
42
+ * 应用作用域内的逻辑数据目录名;省略时为 `<dbName>-pgdata`。
43
+ *
44
+ * @remarks
45
+ * 只能是名字,不能是路径:物理根目录由主进程决定,renderer 既拿不到也不需要
46
+ * (AC#5)。含路径分隔符时在构造期就抛 `invalid_database_name`。
47
+ */
48
+ readonly dataDirectoryName?: string;
49
+ /**
50
+ * `pg.begin` 等待主进程那条唯一连接的上限(毫秒)。
51
+ *
52
+ * @remarks
53
+ * 省略时用协议默认值 `DESKTOP_PGLITE_DEFAULT_BEGIN_TIMEOUT_MS`。它约束的是**跨窗口**
54
+ * 的竞争:同一个客户端内部的事务由本地互斥串行化,根本不会互相超时。
55
+ */
56
+ readonly beginTimeout?: number;
57
+ /**
58
+ * NOTIFY 批量窗口(毫秒);省略时用 `DEFAULT_NOTIFY_BATCH_TIMEOUT_MS`。
59
+ *
60
+ * @remarks
61
+ * 与浏览器路径同义、同实现——批量发生在渲染进程,主进程只转发裸 NOTIFY。
62
+ */
63
+ readonly batchTimeout?: number;
64
+ }