@aiao/rxdb-adapter-electron 0.0.0-stage → 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.
- package/LICENSE +21 -0
- package/README.md +394 -2
- package/dist/RxDBAdapterElectron.d.ts +61 -0
- package/dist/RxDBAdapterElectron.d.ts.map +1 -0
- package/dist/electron-adapter.interface.d.ts +23 -0
- package/dist/electron-adapter.interface.d.ts.map +1 -0
- package/dist/electron-file-host.d.ts +52 -0
- package/dist/electron-file-host.d.ts.map +1 -0
- package/dist/electron-sqlite-host.d.ts +65 -0
- package/dist/electron-sqlite-host.d.ts.map +1 -0
- package/dist/host.d.ts +19 -0
- package/dist/host.d.ts.map +1 -0
- package/dist/host.js +768 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +36 -0
- package/dist/node-sqlite-engine.d.ts +97 -0
- package/dist/node-sqlite-engine.d.ts.map +1 -0
- package/dist/pglite/RxDBAdapterElectronPGlite.d.ts +96 -0
- package/dist/pglite/RxDBAdapterElectronPGlite.d.ts.map +1 -0
- package/dist/pglite/desktop-pglite-client.d.ts +144 -0
- package/dist/pglite/desktop-pglite-client.d.ts.map +1 -0
- package/dist/pglite/desktop-pglite-results.d.ts +14 -0
- package/dist/pglite/desktop-pglite-results.d.ts.map +1 -0
- package/dist/pglite/electron-pglite-backup-error.d.ts +32 -0
- package/dist/pglite/electron-pglite-backup-error.d.ts.map +1 -0
- package/dist/pglite/pglite-adapter.interface.d.ts +68 -0
- package/dist/pglite/pglite-adapter.interface.d.ts.map +1 -0
- package/dist/pglite/restore-electron-pglite-database.d.ts +68 -0
- package/dist/pglite/restore-electron-pglite-database.d.ts.map +1 -0
- package/dist/pglite-host/electron-pglite-host.d.ts +129 -0
- package/dist/pglite-host/electron-pglite-host.d.ts.map +1 -0
- package/dist/pglite-host/pglite-host-data-dir.d.ts +75 -0
- package/dist/pglite-host/pglite-host-data-dir.d.ts.map +1 -0
- package/dist/pglite-host/pglite-host-lock.d.ts +48 -0
- package/dist/pglite-host/pglite-host-lock.d.ts.map +1 -0
- package/dist/pglite-host/pglite-host-restore.d.ts +38 -0
- package/dist/pglite-host/pglite-host-restore.d.ts.map +1 -0
- package/dist/pglite-host/pglite-host-runtime.d.ts +90 -0
- package/dist/pglite-host/pglite-host-runtime.d.ts.map +1 -0
- package/dist/pglite-host.d.ts +17 -0
- package/dist/pglite-host.d.ts.map +1 -0
- package/dist/pglite-host.js +738 -0
- package/dist/pglite.d.ts +22 -0
- package/dist/pglite.d.ts.map +1 -0
- package/dist/pglite.js +493 -0
- package/dist/sqlite-script.d.ts +26 -0
- package/dist/sqlite-script.d.ts.map +1 -0
- package/package.json +94 -3
- package/src/RxDBAdapterElectron.ts +105 -0
- package/src/electron-adapter.interface.ts +25 -0
- package/src/electron-file-host.ts +758 -0
- package/src/electron-sqlite-host.ts +301 -0
- package/src/host.ts +64 -0
- package/src/index.ts +71 -0
- package/src/node-sqlite-engine.ts +631 -0
- package/src/pglite/RxDBAdapterElectronPGlite.ts +152 -0
- package/src/pglite/desktop-pglite-client.ts +614 -0
- package/src/pglite/desktop-pglite-results.ts +24 -0
- package/src/pglite/electron-pglite-backup-error.ts +128 -0
- package/src/pglite/pglite-adapter.interface.ts +78 -0
- package/src/pglite/restore-electron-pglite-database.ts +429 -0
- package/src/pglite-host/electron-pglite-host.ts +829 -0
- package/src/pglite-host/pglite-host-data-dir.ts +224 -0
- package/src/pglite-host/pglite-host-lock.ts +154 -0
- package/src/pglite-host/pglite-host-restore.ts +460 -0
- package/src/pglite-host/pglite-host-runtime.ts +166 -0
- package/src/pglite-host.ts +50 -0
- package/src/pglite.ts +51 -0
- package/src/sqlite-script.ts +134 -0
|
@@ -0,0 +1,614 @@
|
|
|
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
|
+
type PGliteDataDirItem
|
|
22
|
+
} from '@aiao/rxdb-adapter-pglite';
|
|
23
|
+
import {
|
|
24
|
+
DESKTOP_PGLITE_DEFAULT_BEGIN_TIMEOUT_MS,
|
|
25
|
+
RxDBAdapterDesktopError,
|
|
26
|
+
assertDesktopPgliteResponse,
|
|
27
|
+
parseDesktopPgliteBackupItem,
|
|
28
|
+
parseDesktopPgliteHandshakeResult,
|
|
29
|
+
parseDesktopPgliteNotifyMessage,
|
|
30
|
+
parseDesktopPgliteOpenResult,
|
|
31
|
+
type DesktopHostTransport,
|
|
32
|
+
type DesktopPgliteParam,
|
|
33
|
+
type DesktopPgliteRequest,
|
|
34
|
+
type DesktopPgliteResponse,
|
|
35
|
+
type RxDBAdapterDesktopErrorCode
|
|
36
|
+
} from '@aiao/rxdb-adapter-sqlite-core/desktop-host';
|
|
37
|
+
import { EventDispatcher } from '@aiao/utils';
|
|
38
|
+
import type { QueryOptions, Results, Transaction } from '@electric-sql/pglite';
|
|
39
|
+
// 模板编译走 PGlite 自己的实现,而不是本地再拼一遍 `$1、$2`:`identifier` / `raw` / 嵌套
|
|
40
|
+
// `sql` 这些辅助器的语义只有原实现说了算,照抄一份的分叉表征是「同一个模板在桌面下
|
|
41
|
+
// 少转义了一个标识符」。`/template` 是个独立子路径(约 2 KB),不带任何 WASM 进 renderer。
|
|
42
|
+
import { query as compileTemplate } from '@electric-sql/pglite/template';
|
|
43
|
+
import { toDesktopPGliteResults as toResults } from './desktop-pglite-results.js';
|
|
44
|
+
import { toElectronPGliteBackupError } from './electron-pglite-backup-error.js';
|
|
45
|
+
|
|
46
|
+
/** {@link DesktopPGliteClient} 的构造参数。 */
|
|
47
|
+
export interface DesktopPGliteClientOptions {
|
|
48
|
+
/** preload 或 worker 桥接提供的传输层。 */
|
|
49
|
+
readonly transport: DesktopHostTransport;
|
|
50
|
+
/** 应用作用域内的逻辑数据目录名;host 在自己的应用数据目录里解析它。 */
|
|
51
|
+
readonly dataDirectoryName: string;
|
|
52
|
+
/** `pg.begin` 的等待上限(毫秒);省略时由 host 用协议默认值。 */
|
|
53
|
+
readonly beginTimeout?: number;
|
|
54
|
+
/** NOTIFY 批量窗口(毫秒);省略时用 {@link DEFAULT_NOTIFY_BATCH_TIMEOUT_MS}。 */
|
|
55
|
+
readonly batchTimeout?: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** `pg.open` 撞上恢复流程时的拒绝码,见 {@link DesktopPGliteClient.#assertOpened}。 */
|
|
59
|
+
const RESTORE_CONFLICT_CODES: ReadonlySet<RxDBAdapterDesktopErrorCode> = new Set([
|
|
60
|
+
'restore_in_progress',
|
|
61
|
+
'restore_incomplete'
|
|
62
|
+
]);
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* 拒绝本代理无法转发的 `QueryOptions`。
|
|
66
|
+
*
|
|
67
|
+
* @remarks
|
|
68
|
+
* `parsers` / `serializers` / `onNotice` 都是函数,`blob` 是流——没有一个过得了结构化克隆。
|
|
69
|
+
* 静默忽略它们的后果是调用方以为自己换了解析器,而拿到的还是默认解析结果,
|
|
70
|
+
* 且**没有任何报错**。这里明确失败,比装作支持诚实。
|
|
71
|
+
*/
|
|
72
|
+
const assertNoQueryOptions = (options: QueryOptions | undefined): void => {
|
|
73
|
+
if (options === undefined) return;
|
|
74
|
+
throw new RxDBAdapterDesktopError(
|
|
75
|
+
'protocol_violation',
|
|
76
|
+
'the desktop PGlite protocol carries no QueryOptions; parsers, serializers, onNotice and blob ' +
|
|
77
|
+
'cannot cross the process boundary'
|
|
78
|
+
);
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* 拒绝由 renderer 指定的落盘位置。
|
|
83
|
+
*
|
|
84
|
+
* @remarks
|
|
85
|
+
* 存储归主进程独占(AC#5):默默忽略 `store` / `dataDir` 会让调用方以为数据落在它指定的
|
|
86
|
+
* 位置,而实际在别处——症状是「重启后数据没了」,排查时根本不会怀疑到这两个被忽略的字段。
|
|
87
|
+
*/
|
|
88
|
+
const assertHostOwnedStorage = (options: PGliteClientOptions): void => {
|
|
89
|
+
if (options.store === undefined && options.dataDir === undefined) return;
|
|
90
|
+
throw new RxDBAdapterDesktopError(
|
|
91
|
+
'protocol_violation',
|
|
92
|
+
'the desktop PGlite host owns storage; store and dataDir must not be set on the renderer side'
|
|
93
|
+
);
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const unsupportedTransactionOperation = (operation: string): RxDBAdapterDesktopError =>
|
|
97
|
+
new RxDBAdapterDesktopError(
|
|
98
|
+
'protocol_violation',
|
|
99
|
+
`${operation}() cannot be proxied to the desktop PGlite host; use query() or exec() instead`
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* 把通知回调里的异常挪出传输层的调用栈。
|
|
104
|
+
*
|
|
105
|
+
* @remarks
|
|
106
|
+
* 与 `DesktopSqliteClient` 的同名手法一字不差,因为病因一字不差:NOTIFY 通道是全 renderer
|
|
107
|
+
* 共享的,`ipcRenderer.on` 又是**同步扇出**,异常一路抛回 Electron 自己的 IPC 派发循环,
|
|
108
|
+
* 排在后面的监听者(通常是另一个库的客户端)就收不到这条消息——表现为「某个库的响应式
|
|
109
|
+
* 查询不刷新」,与那条畸形消息毫无关联线索。
|
|
110
|
+
*
|
|
111
|
+
* 不吞:丢进微任务原样重抛,落成一条 uncaught error。传输层不该替调用方决定
|
|
112
|
+
* 「这个错误不重要」,它只是不该由传输层来接。
|
|
113
|
+
*/
|
|
114
|
+
const reportOutOfBand = (error: unknown): void => {
|
|
115
|
+
queueMicrotask(() => {
|
|
116
|
+
throw error;
|
|
117
|
+
});
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* 通过桌面 host 访问主进程 PGlite 实例的客户端。
|
|
122
|
+
*
|
|
123
|
+
* @remarks
|
|
124
|
+
* 两条串行化链各管一件事:
|
|
125
|
+
* - {@link DesktopPGliteClient.#tail} 是**会话内 FIFO**,保证发出顺序即执行顺序;
|
|
126
|
+
* - {@link DesktopPGliteClient.#lock} 是**本地互斥**,让事务独占整段时间。
|
|
127
|
+
*
|
|
128
|
+
* 后者不是可有可无的谨慎:主进程只有一条 PGlite 连接,一条事务开着时它被挂起的
|
|
129
|
+
* callback 攥着。没有本地互斥的话,第二条事务的 `pg.begin`(或一条自动提交语句)会排在
|
|
130
|
+
* 会话队列里等那条连接,而连接又要等第一条事务把剩下的语句发完——双方互相等,
|
|
131
|
+
* 表现为几秒后一句 `transaction_unavailable`,看上去像 host 有 bug。浏览器路径上
|
|
132
|
+
* PGlite 自己的 mutex 做的正是同一件事,这里只是把它挪到了进程边界的这一侧。
|
|
133
|
+
*
|
|
134
|
+
* @public
|
|
135
|
+
*/
|
|
136
|
+
export class DesktopPGliteClient extends EventDispatcher<PGliteClientEvents> implements IPGliteClient {
|
|
137
|
+
readonly #options: DesktopPGliteClientOptions;
|
|
138
|
+
readonly #batcher: PGliteNotificationBatcher;
|
|
139
|
+
#dbName = '';
|
|
140
|
+
#sessionId?: string;
|
|
141
|
+
#resolvedLocation?: string;
|
|
142
|
+
#unsubscribe?: () => void;
|
|
143
|
+
#closed = false;
|
|
144
|
+
#closePromise?: Promise<void>;
|
|
145
|
+
/** 会话内 FIFO 队尾;恒不 reject,否则一条查询出错会把整个会话废掉。 */
|
|
146
|
+
#tail: Promise<unknown> = Promise.resolve();
|
|
147
|
+
/** 本地互斥队尾;同样恒不 reject。 */
|
|
148
|
+
#lock: Promise<unknown> = Promise.resolve();
|
|
149
|
+
|
|
150
|
+
/** 本客户端在 host 上的会话 ID,用于把推送过来的 NOTIFY 对号入座。 */
|
|
151
|
+
get sessionId(): string {
|
|
152
|
+
if (!this.#sessionId) {
|
|
153
|
+
throw new RxDBAdapterDesktopError('session_closed', 'the desktop PGlite client has not been initialised yet');
|
|
154
|
+
}
|
|
155
|
+
return this.#sessionId;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** host 解析出的逻辑位置,仅供诊断(AC#5)。 */
|
|
159
|
+
get resolvedLocation(): string {
|
|
160
|
+
if (!this.#resolvedLocation) {
|
|
161
|
+
throw new RxDBAdapterDesktopError('session_closed', 'the desktop PGlite client has not been initialised yet');
|
|
162
|
+
}
|
|
163
|
+
return this.#resolvedLocation;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** 尚未分发的 NOTIFY 行事件数量。 */
|
|
167
|
+
get pendingNotificationCount(): number {
|
|
168
|
+
return this.#batcher.pendingCount;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
constructor(options: DesktopPGliteClientOptions) {
|
|
172
|
+
super();
|
|
173
|
+
this.#options = options;
|
|
174
|
+
this.#batcher = new PGliteNotificationBatcher({
|
|
175
|
+
resolveDbName: () => this.#dbName,
|
|
176
|
+
emit: event => this.dispatchEvent(event.type, event),
|
|
177
|
+
batchTimeout: options.batchTimeout ?? DEFAULT_NOTIFY_BATCH_TIMEOUT_MS,
|
|
178
|
+
onParseError: error => console.error('Failed to parse desktop NOTIFY payload:', error)
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* 握手、打开会话并订阅变更推送。
|
|
184
|
+
*
|
|
185
|
+
* @remarks
|
|
186
|
+
* 握手排在 `pg.open` 之前是 AC#11 的全部内容:`pg.open` 会 `mkdir` 出数据目录并在里面
|
|
187
|
+
* 跑 initdb,等 renderer 从 open 应答里读出版本不匹配时,磁盘上已经多了一整棵目录树,
|
|
188
|
+
* 而调用方拿不到 client,也就没有把手去收拾它。
|
|
189
|
+
*
|
|
190
|
+
* @param dbName - RxDB 逻辑库名;只用于变更事件的 `dbName` 字段,不参与落盘位置解析
|
|
191
|
+
* @param options - PGlite 选项;**不得**含 `store` 或 `dataDir`
|
|
192
|
+
* @throws `RxDBBackupError` 数据目录正在被恢复(`restore_in_progress`)或留着未完成的恢复
|
|
193
|
+
* (`restore_incomplete`)时,见 {@link DesktopPGliteClient.#assertOpened}
|
|
194
|
+
* @throws {@link RxDBAdapterDesktopError} 指定了落盘位置、协议版本不匹配或 host 打不开数据目录时
|
|
195
|
+
*/
|
|
196
|
+
async init(dbName: string, options: PGliteClientOptions): Promise<void> {
|
|
197
|
+
assertHostOwnedStorage(options);
|
|
198
|
+
if (this.#sessionId) {
|
|
199
|
+
throw new RxDBAdapterDesktopError(
|
|
200
|
+
'protocol_violation',
|
|
201
|
+
`the desktop PGlite client is already bound to session ${this.#sessionId}`
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
this.#dbName = dbName;
|
|
205
|
+
|
|
206
|
+
const handshake = assertDesktopPgliteResponse(
|
|
207
|
+
'pg.handshake',
|
|
208
|
+
await this.#options.transport.request({ kind: 'pg.handshake' })
|
|
209
|
+
);
|
|
210
|
+
parseDesktopPgliteHandshakeResult(handshake.result);
|
|
211
|
+
|
|
212
|
+
const opened = this.#assertOpened(
|
|
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
|
+
* 在 host 的一致快照上逐项读取数据目录(US-217)。
|
|
301
|
+
*
|
|
302
|
+
* @remarks
|
|
303
|
+
* host 在本会话那条唯一连接上开事务并 `CHECKPOINT`,`fn` 返回之前一直占着它:没有写入能落进
|
|
304
|
+
* 正在读的文件,快照边界前已提交的 WAL 也都已落进数据文件(AC#16)。
|
|
305
|
+
*
|
|
306
|
+
* - **独占**:整段排在本地互斥里,同一客户端的其他语句等快照结束再发。否则它们在 host 上
|
|
307
|
+
* 排在快照后面,而快照又在等 renderer 来取下一块——两边互等。
|
|
308
|
+
* - **背压**:`items` 每被取一项才发一次 `pg.backup.next`,同一时刻至多一个在途请求,
|
|
309
|
+
* renderer 不取,host 就不读下一块(AC#21)。
|
|
310
|
+
* - **收尾**:无论 `fn` 读完、提前返回还是抛出,都发 `pg.backup.end` 交还连接(AC#19)。
|
|
311
|
+
* `fn` 失败时收尾失败不覆盖原始错误;`fn` 成功而收尾失败则整体失败——快照没结束的
|
|
312
|
+
* 备份不能报成功。
|
|
313
|
+
*
|
|
314
|
+
* 等连接空闲的上限沿用构造参数里的 `beginTimeout`(与 `pg.begin` 同档):契约方法不带
|
|
315
|
+
* 备份选项,`lockTimeoutMs` 传不到这里;到期由 host 以 `transaction_unavailable` 拒绝。
|
|
316
|
+
*
|
|
317
|
+
* @param fn - 消费快照的回调;`items` 只在回调返回之前有效
|
|
318
|
+
* @returns 回调的返回值
|
|
319
|
+
* @throws {@link RxDBAdapterDesktopError} 会话已关闭、host 不带备份能力(`unsupported_operation`)、
|
|
320
|
+
* 等不到连接或收尾失败时;`fn` 抛出的错误原样重抛
|
|
321
|
+
*/
|
|
322
|
+
async snapshotDataDir<T>(fn: (items: AsyncIterable<PGliteDataDirItem>) => Promise<T>): Promise<T> {
|
|
323
|
+
this.#assertOpen();
|
|
324
|
+
return this.#exclusive(async () => {
|
|
325
|
+
const begun = assertDesktopPgliteResponse(
|
|
326
|
+
'pg.backup.begin',
|
|
327
|
+
await this.#send({
|
|
328
|
+
kind: 'pg.backup.begin',
|
|
329
|
+
sessionId: this.sessionId,
|
|
330
|
+
timeout: this.#options.beginTimeout ?? DESKTOP_PGLITE_DEFAULT_BEGIN_TIMEOUT_MS
|
|
331
|
+
})
|
|
332
|
+
);
|
|
333
|
+
const backupId = begun.result.backupId;
|
|
334
|
+
const end = async (): Promise<void> => {
|
|
335
|
+
assertDesktopPgliteResponse(
|
|
336
|
+
'pg.backup.end',
|
|
337
|
+
await this.#send({ kind: 'pg.backup.end', sessionId: this.sessionId, backupId })
|
|
338
|
+
);
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
let result: T;
|
|
342
|
+
try {
|
|
343
|
+
result = await fn(this.#backupItems(backupId));
|
|
344
|
+
} catch (error) {
|
|
345
|
+
// 收尾失败不覆盖原始错误:调用方要看的是消费方那条,而不是收摊那条。
|
|
346
|
+
await end().catch(() => undefined);
|
|
347
|
+
throw error;
|
|
348
|
+
}
|
|
349
|
+
await end();
|
|
350
|
+
return result;
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* 断开会话。
|
|
356
|
+
*
|
|
357
|
+
* @remarks
|
|
358
|
+
* 等在途请求与开着的事务跑完再关,因此调用方返回时 host 上确实已经没有本会话的
|
|
359
|
+
* 任何残留。并发调用共享同一个关闭流程。
|
|
360
|
+
*/
|
|
361
|
+
async disconnect(): Promise<void> {
|
|
362
|
+
this.#closePromise ??= this.#runClose(true);
|
|
363
|
+
await this.#closePromise;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* 不等在途请求,直接让 host 释放本会话。
|
|
368
|
+
*
|
|
369
|
+
* @remarks
|
|
370
|
+
* host 收到 `pg.close` 会把本会话仍开着的事务全部回滚,因此「不等」丢掉的只是那些
|
|
371
|
+
* 未提交的写——这正是 `forceClose` 与 {@link DesktopPGliteClient.disconnect} 的区别。
|
|
372
|
+
*/
|
|
373
|
+
async forceClose(): Promise<void> {
|
|
374
|
+
this.#closePromise ??= this.#runClose(false);
|
|
375
|
+
await this.#closePromise;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* 校验 `pg.open` 应答;目标此刻归恢复流程所有时,改报备份契约里的码。
|
|
380
|
+
*
|
|
381
|
+
* @remarks
|
|
382
|
+
* `restore_in_progress` / `restore_incomplete` 说的不是「连接坏了」,而是「这个目录正被恢复
|
|
383
|
+
* 或留着一次没做完的恢复」(AC#11、#12):调用方要么等,要么调
|
|
384
|
+
* `cleanupIncompleteElectronPGliteRestore()` 收拾,只有按备份契约写的判断接得住。
|
|
385
|
+
* 其余打不开的原因与恢复无关,仍是桌面错误。
|
|
386
|
+
*/
|
|
387
|
+
#assertOpened(response: unknown): Extract<DesktopPgliteResponse, { kind: 'pg.open' }> {
|
|
388
|
+
try {
|
|
389
|
+
return assertDesktopPgliteResponse('pg.open', response);
|
|
390
|
+
} catch (error) {
|
|
391
|
+
if (error instanceof RxDBAdapterDesktopError && RESTORE_CONFLICT_CODES.has(error.code)) {
|
|
392
|
+
throw toElectronPGliteBackupError(error, this.#options.dataDirectoryName, 'restore');
|
|
393
|
+
}
|
|
394
|
+
throw error;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* 解析 `pg.open` 结果;解析不过就先把 host 上刚开出来的会话关掉,再抛原始错误。
|
|
400
|
+
*
|
|
401
|
+
* @remarks
|
|
402
|
+
* 走到这里说明 host 已经建好数据目录、起了实例、登记了会话,而 renderer 才发现结果
|
|
403
|
+
* 形状不对。此时调用方拿不到 client,也就永远没有关掉那条会话的把手。
|
|
404
|
+
*/
|
|
405
|
+
async #parseOpenResultOrClose(result: unknown): Promise<{ sessionId: string; resolvedLocation: string }> {
|
|
406
|
+
try {
|
|
407
|
+
return parseDesktopPgliteOpenResult(result);
|
|
408
|
+
} catch (error) {
|
|
409
|
+
const record = typeof result === 'object' && result !== null ? (result as Record<string, unknown>) : undefined;
|
|
410
|
+
const sessionId = record?.['sessionId'];
|
|
411
|
+
if (typeof sessionId === 'string') {
|
|
412
|
+
// 收摊失败不上报:连接已经带着准确的原因失败了,把它盖掉只会让诊断更远。
|
|
413
|
+
await this.#options.transport.request({ kind: 'pg.close', sessionId }).catch(() => undefined);
|
|
414
|
+
}
|
|
415
|
+
throw error;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* 等变更推送通道真正建好,失败就回滚刚打开的会话。
|
|
421
|
+
*
|
|
422
|
+
* @remarks
|
|
423
|
+
* 订阅建不起来意味着响应式查询永远不刷新。返回一个「能查、但永不刷新」的客户端是所有
|
|
424
|
+
* 故障形态里最难查的一种,所以这里让 `init()` 直接失败。
|
|
425
|
+
*/
|
|
426
|
+
async #awaitSubscription(): Promise<void> {
|
|
427
|
+
try {
|
|
428
|
+
await this.#options.transport.subscriptionReady?.();
|
|
429
|
+
} catch (error) {
|
|
430
|
+
const sessionId = this.#sessionId;
|
|
431
|
+
this.#closed = true;
|
|
432
|
+
this.#unsubscribe?.();
|
|
433
|
+
await this.#send({ kind: 'pg.close', sessionId: sessionId! }).catch(() => undefined);
|
|
434
|
+
throw new RxDBAdapterDesktopError(
|
|
435
|
+
'host_unavailable',
|
|
436
|
+
`desktop PGlite session ${String(sessionId)} could not subscribe to host notifications`,
|
|
437
|
+
{ cause: error }
|
|
438
|
+
);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
async #runClose(drain: boolean): Promise<void> {
|
|
443
|
+
if (drain) await this.#lock.catch(() => undefined);
|
|
444
|
+
this.#closed = true;
|
|
445
|
+
if (drain) await this.#tail.catch(() => undefined);
|
|
446
|
+
const sessionId = this.#sessionId;
|
|
447
|
+
this.#unsubscribe?.();
|
|
448
|
+
this.#batcher.clear();
|
|
449
|
+
this.removeAllEventListeners();
|
|
450
|
+
if (!sessionId) return;
|
|
451
|
+
assertDesktopPgliteResponse('pg.close', await this.#options.transport.request({ kind: 'pg.close', sessionId }));
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
async #runTransaction<T>(callback: (tx: Transaction) => Promise<T>): Promise<T> {
|
|
455
|
+
const begun = assertDesktopPgliteResponse(
|
|
456
|
+
'pg.begin',
|
|
457
|
+
await this.#send({
|
|
458
|
+
kind: 'pg.begin',
|
|
459
|
+
sessionId: this.sessionId,
|
|
460
|
+
...(this.#options.beginTimeout === undefined ? {} : { timeout: this.#options.beginTimeout })
|
|
461
|
+
} as DesktopPgliteRequest)
|
|
462
|
+
);
|
|
463
|
+
const transactionId = begun.result.transactionId;
|
|
464
|
+
let settled = false;
|
|
465
|
+
|
|
466
|
+
const end = async (kind: 'pg.commit' | 'pg.rollback'): Promise<void> => {
|
|
467
|
+
settled = true;
|
|
468
|
+
assertDesktopPgliteResponse(kind, await this.#send({ kind, sessionId: this.sessionId, transactionId }));
|
|
469
|
+
};
|
|
470
|
+
const assertLive = (): void => {
|
|
471
|
+
if (settled) {
|
|
472
|
+
throw new RxDBAdapterDesktopError('transaction_not_found', `transaction ${transactionId} is already closed`);
|
|
473
|
+
}
|
|
474
|
+
};
|
|
475
|
+
|
|
476
|
+
const tx: Transaction = {
|
|
477
|
+
query: async <R>(sql: string, params?: unknown[], options?: QueryOptions): Promise<Results<R>> => {
|
|
478
|
+
assertLive();
|
|
479
|
+
assertNoQueryOptions(options);
|
|
480
|
+
return this.#queryOn(transactionId, sql, params);
|
|
481
|
+
},
|
|
482
|
+
exec: async (sql: string, options?: QueryOptions): Promise<Results[]> => {
|
|
483
|
+
assertLive();
|
|
484
|
+
assertNoQueryOptions(options);
|
|
485
|
+
return this.#execOn(transactionId, sql);
|
|
486
|
+
},
|
|
487
|
+
rollback: async (): Promise<void> => {
|
|
488
|
+
assertLive();
|
|
489
|
+
await end('pg.rollback');
|
|
490
|
+
},
|
|
491
|
+
sql: async <R>(sqlStrings: TemplateStringsArray, ...values: unknown[]): Promise<Results<R>> => {
|
|
492
|
+
assertLive();
|
|
493
|
+
const compiled = compileTemplate(sqlStrings, ...values);
|
|
494
|
+
return this.#queryOn(transactionId, compiled.query, compiled.params);
|
|
495
|
+
},
|
|
496
|
+
// `listen` 代理不了:它要在**这条连接**上挂一个回调,而回调过不了进程边界。
|
|
497
|
+
// 静默降级成「订阅了但永远收不到」是最难查的一种故障,所以当场炸。
|
|
498
|
+
// 事务内的通知请改用 renderer 侧的 NOTIFY 批量器(本客户端已经在监听)。
|
|
499
|
+
listen: (): Promise<(tx?: Transaction) => Promise<void>> => {
|
|
500
|
+
throw unsupportedTransactionOperation('listen');
|
|
501
|
+
},
|
|
502
|
+
get closed(): boolean {
|
|
503
|
+
return settled;
|
|
504
|
+
}
|
|
505
|
+
};
|
|
506
|
+
|
|
507
|
+
try {
|
|
508
|
+
const result = await callback(tx);
|
|
509
|
+
if (!settled) await end('pg.commit');
|
|
510
|
+
return result;
|
|
511
|
+
} catch (error) {
|
|
512
|
+
// 回滚失败不覆盖原始错误:调用方要看的是业务那条,而不是收摊那条。
|
|
513
|
+
if (!settled) await end('pg.rollback').catch(() => undefined);
|
|
514
|
+
throw error;
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
async #queryOn<T>(transactionId: string | undefined, sql: string, params?: unknown[]): Promise<Results<T>> {
|
|
519
|
+
const request: DesktopPgliteRequest = {
|
|
520
|
+
kind: 'pg.query',
|
|
521
|
+
sessionId: this.sessionId,
|
|
522
|
+
sql,
|
|
523
|
+
params: (params ?? []) as readonly DesktopPgliteParam[],
|
|
524
|
+
...(transactionId === undefined ? {} : { transactionId })
|
|
525
|
+
};
|
|
526
|
+
return toResults<T>(assertDesktopPgliteResponse('pg.query', await this.#send(request)).result);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* 快照的逐项迭代器。
|
|
531
|
+
*
|
|
532
|
+
* @remarks
|
|
533
|
+
* 异步生成器自带排队:消费方连调两次 `next()`,第二次也要等第一次的 `pg.backup.next`
|
|
534
|
+
* 回来才发,背压因此不依赖消费方守规矩。
|
|
535
|
+
*/
|
|
536
|
+
async *#backupItems(backupId: string): AsyncGenerator<PGliteDataDirItem> {
|
|
537
|
+
for (;;) {
|
|
538
|
+
const next = assertDesktopPgliteResponse(
|
|
539
|
+
'pg.backup.next',
|
|
540
|
+
await this.#send({ kind: 'pg.backup.next', sessionId: this.sessionId, backupId })
|
|
541
|
+
);
|
|
542
|
+
const item = parseDesktopPgliteBackupItem(next.result);
|
|
543
|
+
if (item.type === 'end') return;
|
|
544
|
+
yield item;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
async #execOn(transactionId: string | undefined, sql: string): Promise<Results[]> {
|
|
549
|
+
const request: DesktopPgliteRequest = {
|
|
550
|
+
kind: 'pg.exec',
|
|
551
|
+
sessionId: this.sessionId,
|
|
552
|
+
sql,
|
|
553
|
+
...(transactionId === undefined ? {} : { transactionId })
|
|
554
|
+
};
|
|
555
|
+
return assertDesktopPgliteResponse('pg.exec', await this.#send(request)).result.map(result => toResults(result));
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* 独占执行:事务整段、单条自动提交语句各算一次。
|
|
560
|
+
*
|
|
561
|
+
* @remarks
|
|
562
|
+
* 必须在任何 `await` 之前**同步**接上队尾,否则两个并发调用会抢到同一个队尾,
|
|
563
|
+
* 互斥退化成没有。
|
|
564
|
+
*/
|
|
565
|
+
#exclusive<T>(run: () => Promise<T>): Promise<T> {
|
|
566
|
+
const settled = this.#lock.then(run);
|
|
567
|
+
this.#lock = settled.then(undefined, () => undefined);
|
|
568
|
+
return settled;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* 把一个请求排进会话队列,队列空了才发上传输层。
|
|
573
|
+
*
|
|
574
|
+
* @remarks
|
|
575
|
+
* 一个会话同一时刻只允许有一个在途请求:会话在 host 侧就是同一条 PGlite 连接上的
|
|
576
|
+
* 一串调用,允许交错的话「先发的先执行」不再成立,一条 `SELECT` 可能挤到 `DELETE`
|
|
577
|
+
* 前面跑,上层看到的是刚删掉的行还在。
|
|
578
|
+
*/
|
|
579
|
+
#send(payload: DesktopPgliteRequest): Promise<unknown> {
|
|
580
|
+
const settled = this.#tail.then(() => this.#options.transport.request(payload));
|
|
581
|
+
this.#tail = settled.then(undefined, () => undefined);
|
|
582
|
+
return settled;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
#assertOpen(): void {
|
|
586
|
+
if (this.#closed || !this.#sessionId) {
|
|
587
|
+
throw new RxDBAdapterDesktopError(
|
|
588
|
+
'session_closed',
|
|
589
|
+
`desktop PGlite session ${String(this.#sessionId)} is not open`
|
|
590
|
+
);
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* 收下一条 host 推送。
|
|
596
|
+
*
|
|
597
|
+
* @remarks
|
|
598
|
+
* 变更通道是全 renderer 共享的:SQLite host 的 `change` 消息、别的会话的 NOTIFY 都会
|
|
599
|
+
* 从这里过。先按 `kind` 与 `sessionId` 筛掉不属于自己的,再解析——顺序反过来的话,
|
|
600
|
+
* 一条本来就不该由自己处理的消息会因为形状不符而抛错。
|
|
601
|
+
*/
|
|
602
|
+
#onMessage(message: unknown): void {
|
|
603
|
+
if (this.#closed) return;
|
|
604
|
+
if (typeof message !== 'object' || message === null) return;
|
|
605
|
+
if ((message as { kind?: unknown }).kind !== 'pg.notify') return;
|
|
606
|
+
if ((message as { sessionId?: unknown }).sessionId !== this.#sessionId) return;
|
|
607
|
+
try {
|
|
608
|
+
const notification = parseDesktopPgliteNotifyMessage(message);
|
|
609
|
+
this.#batcher.accept(notification.channel, notification.payload);
|
|
610
|
+
} catch (error) {
|
|
611
|
+
reportOutOfBand(error);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* host 查询应答到 PGlite `Results` 的还原,普通会话与恢复校验共用。
|
|
3
|
+
*
|
|
4
|
+
* @module pglite/desktop-pglite-results
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { DesktopPgliteQueryResult } from '@aiao/rxdb-adapter-sqlite-core/desktop-host';
|
|
8
|
+
import type { Results } from '@electric-sql/pglite';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* 把 host 的应答还原成 PGlite 的 `Results`。
|
|
12
|
+
*
|
|
13
|
+
* @remarks
|
|
14
|
+
* `rows` 里的值已经是 PG 的原生 JS 表示(bigint / `Uint8Array` / `Date` / 普通对象),
|
|
15
|
+
* 结构化克隆逐值搬过来,这里不做任何再解析——多一层转换就多一处能悄悄丢精度的地方。
|
|
16
|
+
*
|
|
17
|
+
* @param result - 已通过协议校验的查询结果
|
|
18
|
+
* @returns 与浏览器路径同形状的结果
|
|
19
|
+
*/
|
|
20
|
+
export const toDesktopPGliteResults = <T>(result: DesktopPgliteQueryResult): Results<T> => ({
|
|
21
|
+
rows: result.rows as unknown as T[],
|
|
22
|
+
fields: result.fields.map(field => ({ name: field.name, dataTypeID: field.dataTypeID })),
|
|
23
|
+
affectedRows: result.affectedRows
|
|
24
|
+
});
|