@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,580 @@
1
+ /**
2
+ * 桌面 PGlite host:把 renderer 送来的 `pg.*` 请求派发到主进程持有的 PGlite 实例。
3
+ *
4
+ * @remarks
5
+ * 本模块运行在**特权侧**,是唯一接触 PGlite 实例的地方。形状由 US-208 线 G 冻结为
6
+ * 「IPC 事务 ID 协议」:主进程持连接,renderer 用 host 签发的事务 ID 把多次 IPC 调用
7
+ * 串成一条真事务。
8
+ *
9
+ * PGlite 只提供 callback 形态的事务(`transaction(cb)`,返回即 COMMIT、抛出即 ROLLBACK),
10
+ * 而 `cb` 跨不了 IPC。本模块的做法是**把 callback 挂起**:进入 `cb` 后立刻 `await` 一个
11
+ * 由 host 持有的 promise,事务因此在 PostgreSQL 侧一直开着,`tx` 句柄留在表里供后续请求
12
+ * 复用。这不是绕开 PGlite 的事务语义,恰恰是照它的语义用——每条语句都真的走同一个 `tx`,
13
+ * 没有任何一条被拆进隐式事务(AC#2 明令禁止把多条独立请求包装成假事务)。
14
+ *
15
+ * @module electron-pglite-host
16
+ */
17
+
18
+ import {
19
+ DESKTOP_PGLITE_PROTOCOL_VERSION,
20
+ parseDesktopPgliteRequest,
21
+ RxDBAdapterDesktopError,
22
+ type DesktopPgliteNotifyMessage,
23
+ type DesktopPgliteQueryResult,
24
+ type DesktopPgliteRequest,
25
+ type DesktopPgliteResponse
26
+ } from '@aiao/rxdb-adapter-sqlite-core/desktop-host';
27
+ import { randomUUID } from 'node:crypto';
28
+
29
+ /**
30
+ * 逻辑位置的 scheme。
31
+ *
32
+ * @remarks
33
+ * `pg.open` 回给 renderer 的 `resolvedLocation` 用它拼装,只表达「应用作用域内的某个数据
34
+ * 目录」,不含物理根目录(AC#5)。与 SQLite 侧的 `desktop-sqlite://app-scope` 刻意不同名:
35
+ * 两者的名字空间互不相干,共用一个 scheme 会让日志里的两类位置看起来可以互相替换。
36
+ */
37
+ const LOGICAL_LOCATION_SCHEME = 'desktop-pglite://app-scope';
38
+
39
+ /**
40
+ * host 代 renderer 订阅的 NOTIFY 频道。
41
+ *
42
+ * @remarks
43
+ * 与 `@aiao/rxdb-adapter-pglite` 里 `PGliteClient` 监听的三张系统表一一对应。
44
+ * PostgreSQL 只把 `LISTEN` 过的频道推给连接,因此这份清单漏一项就等于对应的响应式
45
+ * 查询在桌面下永远不刷新——而浏览器下一切正常,排查时几乎不会怀疑到订阅清单上。
46
+ */
47
+ export const DESKTOP_PGLITE_WATCH_CHANNELS: readonly string[] = Object.freeze([
48
+ 'rxdb_change_notify',
49
+ 'rxdb_branch_notify',
50
+ 'rxdb_migration_notify'
51
+ ]);
52
+
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
+ /**
67
+ * host 需要的 PGlite 能力子集。
68
+ *
69
+ * @remarks
70
+ * 刻意不写成 `PGliteInterface`:宿主应用极可能传进来的不是裸 `PGlite`,而是跑在 worker
71
+ * 里的 `PGliteWorker`——PGlite 的 WASM 在主进程 JS 线程上是**同步**执行的,一条重查询会
72
+ * 把整个窗口的 IPC 一起卡住(故事「冻结带来的三条实现约束」第 3 条)。声明成结构类型后,
73
+ * 两者都能直接传入,而这份清单也顺便说清了 host 只需要这五个操作。
74
+ */
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>;
81
+ }
82
+
83
+ /** {@link createElectronPgliteHost} 的入参。 */
84
+ export interface ElectronPgliteHostOptions {
85
+ /**
86
+ * 按逻辑数据目录名创建一个 PGlite 运行时。
87
+ *
88
+ * @remarks
89
+ * **必填,没有默认实现**:默认值只能是「在某处建一个 PGlite」,而那个「某处」只有宿主
90
+ * 应用知道(Electron 下通常在 `app.getPath('userData')` 之下)。给一个兜底目录意味着
91
+ * 数据会静默落在谁也没打算用的位置,而症状是「重启后数据没了」。
92
+ *
93
+ * 传进来的名字已过白名单校验,不含任何路径分隔符,因此 `join(root, name)` 不会越出 `root`。
94
+ * 同一个名字在 host 生命周期内只会被调用一次(AC#7)。
95
+ */
96
+ readonly createRuntime: (dataDirectoryName: string) => Promise<ElectronPgliteRuntime>;
97
+ /** 把裸 NOTIFY 送达对应会话的 renderer,例如 `webContents.send`。 */
98
+ readonly postNotify: (message: DesktopPgliteNotifyMessage) => void;
99
+ /**
100
+ * NOTIFY 送达失败时的上报口。
101
+ *
102
+ * @remarks
103
+ * 与 SQLite host 同理:窗口在通知投递途中被销毁是常规竞态,此时写入早已落库,
104
+ * 把送达失败当成写失败回给调用方只会诱发一次重复写入。不传则丢弃。
105
+ */
106
+ readonly onDeliveryError?: (error: unknown) => void;
107
+ }
108
+
109
+ /** 桌面 PGlite host 实例。 */
110
+ export interface ElectronPgliteHost {
111
+ /**
112
+ * 处理一条来自 renderer 的请求。
113
+ *
114
+ * @remarks
115
+ * **永不 reject**:失败以 `kind: 'error'` 的应答返回,理由与 SQLite host 一致
116
+ * (`ipcRenderer.invoke` 在 reject 时会把错误压平成字符串,错误码随之丢失)。
117
+ *
118
+ * @param request - 未经校验的请求负载
119
+ * @param ownerId - 发起方的 `webContents.id`;会话与事务都按它归属
120
+ * @returns 协议应答
121
+ */
122
+ handle(request: unknown, ownerId: number): Promise<DesktopPgliteResponse>;
123
+ /** 当前打开的会话数。 */
124
+ readonly openSessionCount: number;
125
+ /** 当前活着的 PGlite 实例数;同一个数据目录上的多个会话只算一个。 */
126
+ readonly openInstanceCount: number;
127
+ /** 当前挂起的事务数;正常静止时应为 0。 */
128
+ readonly openTransactionCount: number;
129
+ /**
130
+ * 回收某个窗口名下的全部会话与事务。
131
+ *
132
+ * @remarks
133
+ * 这是本方案的**前提而非收尾**:挂起的 callback 独占 PGlite 的连接锁,渲染进程崩在
134
+ * 事务中间而没人回收的话,之后任何查询都会永远排队——表征是「数据库不响应」,
135
+ * 与那次崩溃毫无关联线索。调用方必须把它挂到 `render-process-gone` 与 `destroyed`
136
+ * 两个事件上(AC#3)。
137
+ *
138
+ * @param ownerId - 已崩溃或已销毁的 `webContents.id`
139
+ * @returns 本次回滚掉的事务条数
140
+ */
141
+ releaseOwner(ownerId: number): Promise<number>;
142
+ /** 关闭全部会话与实例,通常在应用退出前调用。 */
143
+ closeAll(): Promise<void>;
144
+ }
145
+
146
+ /** 一个数据目录上的运行时及其会话。 */
147
+ interface InstanceEntry {
148
+ readonly sessions: Set<string>;
149
+ readonly ready: Promise<ElectronPgliteRuntime>;
150
+ }
151
+
152
+ /** 一条会话。 */
153
+ interface SessionEntry {
154
+ readonly dataDirectoryName: string;
155
+ readonly owner: number;
156
+ }
157
+
158
+ /** 一条挂起中的事务。 */
159
+ interface TransactionEntry {
160
+ 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>;
165
+ }
166
+
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
+ };
185
+
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
+ });
199
+
200
+ const toErrorResponse = (error: unknown): DesktopPgliteResponse => {
201
+ if (error instanceof RxDBAdapterDesktopError) {
202
+ return { kind: 'error', code: error.code, message: error.detail };
203
+ }
204
+ return {
205
+ kind: 'error',
206
+ code: 'host_internal_error',
207
+ message: error instanceof Error ? error.message : String(error)
208
+ };
209
+ };
210
+
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
+ };
228
+
229
+ /**
230
+ * 创建一个桌面 PGlite host。
231
+ *
232
+ * @remarks
233
+ * 与 SQLite host 相反,同一个数据目录上的多个会话**共享一个** PGlite 实例(AC#7):
234
+ * PGlite 是嵌入式单写者,同一份数据目录被两个实例同时打开会直接损坏它。跨窗口的并发
235
+ * 因此由这一条连接的排队来串行化,而不是交给文件锁。
236
+ *
237
+ * @param options - host 配置
238
+ * @returns host 实例
239
+ */
240
+ export function createElectronPgliteHost(options: ElectronPgliteHostOptions): ElectronPgliteHost {
241
+ const instances = new Map<string, InstanceEntry>();
242
+ const sessions = new Map<string, SessionEntry>();
243
+ const transactions = new Map<string, TransactionEntry>();
244
+
245
+ const deliver = (sessionId: string, channel: string, payload: string): void => {
246
+ try {
247
+ options.postNotify({ kind: 'pg.notify', sessionId, channel, payload });
248
+ } catch (error) {
249
+ options.onDeliveryError?.(error);
250
+ }
251
+ };
252
+
253
+ const createInstance = (dataDirectoryName: string): InstanceEntry => {
254
+ 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 };
267
+ };
268
+
269
+ const requireSession = (sessionId: string, ownerId: number): SessionEntry => {
270
+ const session = sessions.get(sessionId);
271
+ if (!session) {
272
+ throw new RxDBAdapterDesktopError('session_closed', `session ${sessionId} is not open on this host`);
273
+ }
274
+ if (session.owner !== ownerId) {
275
+ throw new RxDBAdapterDesktopError('permission_denied', `session ${sessionId} belongs to another window`);
276
+ }
277
+ return session;
278
+ };
279
+
280
+ const requireRuntime = async (session: SessionEntry): Promise<ElectronPgliteRuntime> => {
281
+ const instance = instances.get(session.dataDirectoryName);
282
+ if (!instance) {
283
+ throw new RxDBAdapterDesktopError(
284
+ 'host_internal_error',
285
+ `session references a released instance for ${session.dataDirectoryName}`
286
+ );
287
+ }
288
+ return instance.ready;
289
+ };
290
+
291
+ /**
292
+ * 取一条挂起中的事务。
293
+ *
294
+ * @remarks
295
+ * 同时核对 `sessionId`:事务 ID 是随机 UUID,猜不到,但**会话已经关掉、事务 ID 却被
296
+ * 复用**这条路径是猜得到的。归属对不上时报 `transaction_not_found` 而不是别的码,
297
+ * 因为对调用方而言这条事务确实不存在——它的语句一条都没有执行。
298
+ */
299
+ const requireTransaction = (transactionId: string, sessionId: string): TransactionEntry => {
300
+ const entry = transactions.get(transactionId);
301
+ if (!entry || entry.sessionId !== sessionId) {
302
+ throw new RxDBAdapterDesktopError(
303
+ 'transaction_not_found',
304
+ `transaction ${transactionId} is unknown, already finished, or not owned by session ${sessionId}`
305
+ );
306
+ }
307
+ return entry;
308
+ };
309
+
310
+ /**
311
+ * 结掉一条挂起的事务,返回**提交失败**的原因(成功或回滚时为 `undefined`)。
312
+ *
313
+ * @remarks
314
+ * 返回值只在 `failure === null` 那一路才可能有值,这个区分是本函数的全部要点:
315
+ * 回滚是靠 `settle.reject(failure)` 让挂起的 callback 抛出来实现的,所以回滚路径上
316
+ * `finished` **必然** reject——把它一并当成失败,每次正常回滚都会变成错误帧。
317
+ * 而提交路径上 `finished` 一旦 reject 就是 COMMIT 自己失败(延迟约束、磁盘写满等),
318
+ * 吞掉它意味着告诉调用方「写成功了」而数据库里什么都没有。
319
+ */
320
+ const settle = async (transactionId: string, failure: Error | null): Promise<Error | undefined> => {
321
+ const entry = transactions.get(transactionId);
322
+ if (!entry) return undefined;
323
+ transactions.delete(transactionId);
324
+ if (failure) entry.settle.reject(failure);
325
+ else entry.settle.resolve();
326
+ // 必须等 `transaction(...)` 自己落地:不等的话 COMMIT 还在飞,紧接着的读可能看不到
327
+ // 刚写进去的数据,而那会被误读成「事务语义不成立」。
328
+ const commitError = await entry.finished.then(
329
+ () => undefined,
330
+ (error: unknown) => error
331
+ );
332
+ if (failure || commitError === undefined) return undefined;
333
+ if (commitError instanceof RxDBAdapterDesktopError) return commitError;
334
+ return new RxDBAdapterDesktopError(
335
+ 'statement_failed',
336
+ commitError instanceof Error ? commitError.message : String(commitError),
337
+ { cause: commitError }
338
+ );
339
+ };
340
+
341
+ const open = async (
342
+ request: Extract<DesktopPgliteRequest, { kind: 'pg.open' }>,
343
+ ownerId: number
344
+ ): Promise<DesktopPgliteResponse> => {
345
+ const { dataDirectoryName } = request.storage;
346
+ const instance = instances.get(dataDirectoryName) ?? createInstance(dataDirectoryName);
347
+ instances.set(dataDirectoryName, instance);
348
+ try {
349
+ await instance.ready;
350
+ } catch (error) {
351
+ // 起不来的实例不能留在表里:留着的话下一次 `pg.open` 会拿到同一个已经 reject 的
352
+ // 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
+ );
359
+ }
360
+ const sessionId = randomUUID();
361
+ instance.sessions.add(sessionId);
362
+ sessions.set(sessionId, { dataDirectoryName, owner: ownerId });
363
+ return {
364
+ kind: 'pg.open',
365
+ result: {
366
+ sessionId,
367
+ resolvedLocation: `${LOGICAL_LOCATION_SCHEME}/${dataDirectoryName}`,
368
+ protocolVersion: DESKTOP_PGLITE_PROTOCOL_VERSION
369
+ }
370
+ };
371
+ };
372
+
373
+ /** 关掉一条会话;数据目录上最后一条会话消失时连实例一起释放。 */
374
+ const closeSession = async (sessionId: string): Promise<void> => {
375
+ const session = sessions.get(sessionId);
376
+ if (!session) return;
377
+ sessions.delete(sessionId);
378
+ const instance = instances.get(session.dataDirectoryName);
379
+ if (!instance) return;
380
+ 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();
385
+ };
386
+
387
+ /** 回滚一条会话名下全部挂起的事务,返回条数。 */
388
+ const rollbackSessionTransactions = async (sessionId: string, reason: string): Promise<number> => {
389
+ const doomed = [...transactions.entries()].filter(([, entry]) => entry.sessionId === sessionId).map(([id]) => id);
390
+ for (const transactionId of doomed) {
391
+ await settle(transactionId, new RxDBAdapterDesktopError('transaction_not_found', reason));
392
+ }
393
+ return doomed.length;
394
+ };
395
+
396
+ const begin = async (
397
+ request: Extract<DesktopPgliteRequest, { kind: 'pg.begin' }>,
398
+ ownerId: number
399
+ ): Promise<DesktopPgliteResponse> => {
400
+ 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
+
435
+ const transactionId = randomUUID();
436
+ transactions.set(transactionId, { sessionId: request.sessionId, tx, settle: settleHandles, finished });
437
+ return { kind: 'pg.begin', result: { transactionId } };
438
+ };
439
+
440
+ const query = async (
441
+ request: Extract<DesktopPgliteRequest, { kind: 'pg.query' }>,
442
+ ownerId: number
443
+ ): Promise<DesktopPgliteResponse> => {
444
+ const session = requireSession(request.sessionId, ownerId);
445
+ const params = [...request.params];
446
+ if (request.transactionId !== undefined) {
447
+ const entry = requireTransaction(request.transactionId, request.sessionId);
448
+ return { kind: 'pg.query', result: toWireResult(await runStatement(() => entry.tx.query(request.sql, params))) };
449
+ }
450
+ const runtime = await requireRuntime(session);
451
+ return { kind: 'pg.query', result: toWireResult(await runStatement(() => runtime.query(request.sql, params))) };
452
+ };
453
+
454
+ const exec = async (
455
+ request: Extract<DesktopPgliteRequest, { kind: 'pg.exec' }>,
456
+ ownerId: number
457
+ ): Promise<DesktopPgliteResponse> => {
458
+ const session = requireSession(request.sessionId, ownerId);
459
+ const target =
460
+ request.transactionId === undefined ?
461
+ await requireRuntime(session)
462
+ : requireTransaction(request.transactionId, request.sessionId).tx;
463
+ const results = await runStatement(() => target.exec(request.sql));
464
+ return { kind: 'pg.exec', result: results.map(toWireResult) };
465
+ };
466
+
467
+ const end = async (
468
+ request: Extract<DesktopPgliteRequest, { kind: 'pg.commit' | 'pg.rollback' }>,
469
+ ownerId: number
470
+ ): Promise<DesktopPgliteResponse> => {
471
+ requireSession(request.sessionId, ownerId);
472
+ requireTransaction(request.transactionId, request.sessionId);
473
+ const failure =
474
+ request.kind === 'pg.rollback' ? new RxDBAdapterDesktopError('write_aborted', 'rollback requested') : null;
475
+ const commitError = await settle(request.transactionId, failure);
476
+ // COMMIT 失败时事务已经被 PostgreSQL 回滚干净,这里只需把原因如实回给调用方——
477
+ // 无条件回成功等于让上层把「一条都没写进去」当成「写完了」。
478
+ if (commitError) throw commitError;
479
+ return { kind: request.kind };
480
+ };
481
+
482
+ const version = async (
483
+ request: Extract<DesktopPgliteRequest, { kind: 'pg.version' }>,
484
+ ownerId: number
485
+ ): Promise<DesktopPgliteResponse> => {
486
+ const runtime = await requireRuntime(requireSession(request.sessionId, ownerId));
487
+ const result = await runStatement(() => runtime.query('SELECT version() AS version'));
488
+ const value = result.rows[0]?.['version'];
489
+ if (typeof value !== 'string') {
490
+ throw new RxDBAdapterDesktopError('host_internal_error', 'PostgreSQL did not report a version string');
491
+ }
492
+ return { kind: 'pg.version', result: value };
493
+ };
494
+
495
+ const close = async (
496
+ request: Extract<DesktopPgliteRequest, { kind: 'pg.close' }>,
497
+ ownerId: number
498
+ ): Promise<DesktopPgliteResponse> => {
499
+ requireSession(request.sessionId, ownerId);
500
+ await rollbackSessionTransactions(request.sessionId, `session ${request.sessionId} was closed`);
501
+ await closeSession(request.sessionId);
502
+ return { kind: 'pg.close' };
503
+ };
504
+
505
+ /**
506
+ * 按种类派发一条已通过协议校验的请求。
507
+ *
508
+ * @remarks
509
+ * 穷尽 `switch` 的理由与 SQLite host 完全一致:协议加了新种类而这里忘记补分支时,
510
+ * `_exhaustive: never` 让它在 `tsc` 阶段就红,而不是在运行期变成怪异失败。
511
+ */
512
+ const dispatch = (request: DesktopPgliteRequest, ownerId: number): Promise<DesktopPgliteResponse> => {
513
+ switch (request.kind) {
514
+ // 握手排在最前,且不碰会话表、不碰 `createRuntime`:它的全部意义就是让 renderer
515
+ // 在建目录之前把版本对上。这里一旦有任何副作用,磁盘上就会多出一棵 initdb 目录树,
516
+ // 而那时调用方连收拾它的把手都没有(AC#11)。
517
+ case 'pg.handshake':
518
+ return Promise.resolve({
519
+ kind: 'pg.handshake',
520
+ result: { protocolVersion: DESKTOP_PGLITE_PROTOCOL_VERSION }
521
+ });
522
+ case 'pg.open':
523
+ return open(request, ownerId);
524
+ case 'pg.query':
525
+ return query(request, ownerId);
526
+ case 'pg.exec':
527
+ return exec(request, ownerId);
528
+ case 'pg.begin':
529
+ return begin(request, ownerId);
530
+ case 'pg.commit':
531
+ case 'pg.rollback':
532
+ return end(request, ownerId);
533
+ case 'pg.version':
534
+ return version(request, ownerId);
535
+ case 'pg.close':
536
+ 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
+ }
544
+ }
545
+ };
546
+
547
+ return {
548
+ handle: async (request: unknown, ownerId: number): Promise<DesktopPgliteResponse> => {
549
+ try {
550
+ return await dispatch(parseDesktopPgliteRequest(request), ownerId);
551
+ } catch (error) {
552
+ return toErrorResponse(error);
553
+ }
554
+ },
555
+ get openSessionCount(): number {
556
+ return sessions.size;
557
+ },
558
+ get openInstanceCount(): number {
559
+ return instances.size;
560
+ },
561
+ get openTransactionCount(): number {
562
+ return transactions.size;
563
+ },
564
+ releaseOwner: async (ownerId: number): Promise<number> => {
565
+ const doomed = [...sessions.entries()].filter(([, session]) => session.owner === ownerId).map(([id]) => id);
566
+ let rolledBack = 0;
567
+ for (const sessionId of doomed) {
568
+ rolledBack += await rollbackSessionTransactions(sessionId, `owner ${ownerId} is gone`);
569
+ await closeSession(sessionId);
570
+ }
571
+ return rolledBack;
572
+ },
573
+ closeAll: async (): Promise<void> => {
574
+ for (const sessionId of [...sessions.keys()]) {
575
+ await rollbackSessionTransactions(sessionId, 'the host is shutting down');
576
+ await closeSession(sessionId);
577
+ }
578
+ }
579
+ };
580
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * `@aiao/rxdb-adapter-electron` 的 **PGlite 特权侧**入口。
3
+ *
4
+ * @remarks
5
+ * 与 `@aiao/rxdb-adapter-electron/host` 分开而不是并进去,理由是依赖:那个入口只要
6
+ * `node:sqlite`(Node 内建,装了就有),本入口却要 `@electric-sql/pglite` 这个可选 peer。
7
+ * 合并的话,任何一个只用 SQLite 的宿主应用都会在加载 host 时撞上「找不到
8
+ * `@electric-sql/pglite`」——而它压根没打算用 PGlite。
9
+ *
10
+ * 本入口只能在有 Node 运行时的地方加载(Electron 主进程,或它自己拥有的 worker)。
11
+ * renderer 侧请用 `@aiao/rxdb-adapter-electron/pglite`。
12
+ *
13
+ * @module @aiao/rxdb-adapter-electron/pglite-host
14
+ */
15
+
16
+ export {
17
+ DESKTOP_PGLITE_WATCH_CHANNELS,
18
+ createElectronPgliteHost,
19
+ type ElectronPgliteHost,
20
+ type ElectronPgliteHostOptions,
21
+ type ElectronPgliteRuntime,
22
+ type ElectronPgliteRuntimeResult,
23
+ type ElectronPgliteTransaction
24
+ } from './pglite-host/electron-pglite-host.js';
25
+
26
+ // 协议与错误类型原样转出,理由与 `./host` 入口一致:宿主作者只装一个包,
27
+ // 就该拿全写一个 PGlite host 需要的全部东西。
28
+ export {
29
+ DESKTOP_PGLITE_DEFAULT_BEGIN_TIMEOUT_MS,
30
+ DESKTOP_PGLITE_MAX_BEGIN_TIMEOUT_MS,
31
+ DESKTOP_PGLITE_PROTOCOL_VERSION,
32
+ RxDBAdapterDesktopError,
33
+ // 宿主自己拼数据目录路径时要用:协议校验管的是「请求合法」,落盘前那次校验管的是
34
+ // 「拼进 join() 的东西不会越出数据根」。不转出来,宿主就只能自己再写一份同样的规则。
35
+ assertValidDesktopDatabaseName,
36
+ isDesktopPgliteRequestKind,
37
+ parseDesktopPgliteRequest,
38
+ type DesktopPgliteNotifyMessage,
39
+ type DesktopPgliteQueryResult,
40
+ type DesktopPgliteRequest,
41
+ type DesktopPgliteResponse,
42
+ type RxDBAdapterDesktopErrorCode
43
+ } from '@aiao/rxdb-adapter-sqlite-core/desktop-host';
package/src/pglite.ts ADDED
@@ -0,0 +1,42 @@
1
+ /**
2
+ * `@aiao/rxdb-adapter-electron` 的 **PGlite renderer 侧**入口。
3
+ *
4
+ * @remarks
5
+ * 与默认入口分开而不是并进去,理由是依赖:默认入口只要共享层,本入口却要
6
+ * `@aiao/rxdb-adapter-pglite` 这个可选 peer。合并的话,任何一个只用 SQLite 的应用
7
+ * 都会在 import 适配器时撞上「找不到 `@aiao/rxdb-adapter-pglite`」——而它压根没打算用 PGlite。
8
+ *
9
+ * 本入口**不引用 PostgreSQL 运行时**:WASM 实例活在主进程,renderer 这边只有一层协议
10
+ * 代理,从 `@electric-sql/pglite` 拿的只有类型和 `/template` 那个约 2 KB 的模板编译子路径,
11
+ * 因此 bundle 里不会多出几十兆的 PostgreSQL。特权侧请用
12
+ * `@aiao/rxdb-adapter-electron/pglite-host`。
13
+ *
14
+ * @module @aiao/rxdb-adapter-electron/pglite
15
+ */
16
+
17
+ export {
18
+ DEFAULT_DATA_DIRECTORY_SUFFIX as DESKTOP_DEFAULT_DATA_DIRECTORY_SUFFIX,
19
+ ADAPTER_NAME as ELECTRON_PGLITE_ADAPTER_NAME,
20
+ type ElectronPGliteOptions
21
+ } from './pglite/pglite-adapter.interface.js';
22
+
23
+ export { RxDBAdapterElectronPGlite } from './pglite/RxDBAdapterElectronPGlite.js';
24
+
25
+ export { DesktopPGliteClient, type DesktopPGliteClientOptions } from './pglite/desktop-pglite-client.js';
26
+
27
+ // 协议常量与错误类型原样转出,理由与默认入口一致:下游按名字 import,
28
+ // 实现搬到哪个共享包不该让用户改行。
29
+ export {
30
+ DESKTOP_HOST_TRANSPORT_KEY,
31
+ DESKTOP_PGLITE_DEFAULT_BEGIN_TIMEOUT_MS,
32
+ DESKTOP_PGLITE_MAX_BEGIN_TIMEOUT_MS,
33
+ DESKTOP_PGLITE_PROTOCOL_VERSION,
34
+ RxDBAdapterDesktopError,
35
+ resolveDesktopHostTransport,
36
+ type DesktopHostTransport,
37
+ type DesktopPgliteNotifyMessage,
38
+ type DesktopPgliteQueryResult,
39
+ type DesktopPgliteRequest,
40
+ type DesktopPgliteResponse,
41
+ type RxDBAdapterDesktopErrorCode
42
+ } from '@aiao/rxdb-adapter-sqlite-core/desktop-host';