@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.
- package/README.md +122 -21
- package/dist/RxDBAdapterElectron.d.ts +27 -1
- package/dist/RxDBAdapterElectron.d.ts.map +1 -1
- package/dist/electron-sqlite-host.d.ts.map +1 -1
- package/dist/host.d.ts +1 -1
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +46 -40
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +20 -6
- package/dist/node-sqlite-engine.d.ts +10 -0
- package/dist/node-sqlite-engine.d.ts.map +1 -1
- package/dist/pglite/RxDBAdapterElectronPGlite.d.ts +53 -1
- package/dist/pglite/RxDBAdapterElectronPGlite.d.ts.map +1 -1
- package/dist/pglite/desktop-pglite-client.d.ts +27 -1
- package/dist/pglite/desktop-pglite-client.d.ts.map +1 -1
- 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 +12 -0
- package/dist/pglite/pglite-adapter.interface.d.ts.map +1 -1
- 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 +47 -32
- package/dist/pglite-host/electron-pglite-host.d.ts.map +1 -1
- 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 +2 -2
- package/dist/pglite-host.d.ts.map +1 -1
- package/dist/pglite-host.js +643 -120
- package/dist/pglite.d.ts +2 -0
- package/dist/pglite.d.ts.map +1 -1
- package/dist/pglite.js +341 -65
- package/package.json +13 -10
- package/src/RxDBAdapterElectron.ts +40 -1
- package/src/electron-sqlite-host.ts +3 -0
- package/src/host.ts +1 -0
- package/src/index.ts +1 -0
- package/src/node-sqlite-engine.ts +20 -1
- package/src/pglite/RxDBAdapterElectronPGlite.ts +80 -5
- package/src/pglite/desktop-pglite-client.ts +111 -17
- 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 +14 -0
- package/src/pglite/restore-electron-pglite-database.ts +429 -0
- package/src/pglite-host/electron-pglite-host.ts +409 -160
- 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 +7 -0
- package/src/pglite.ts +9 -0
|
@@ -5,11 +5,12 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import type { RxDB } from '@aiao/rxdb';
|
|
8
|
-
import { RxDBAdapterSqliteBase } from '@aiao/rxdb-adapter-sqlite-core';
|
|
8
|
+
import { RxDBAdapterSqliteBase, type SqliteBackupStorage, type SqliteClientLike } from '@aiao/rxdb-adapter-sqlite-core';
|
|
9
9
|
import {
|
|
10
10
|
DEFAULT_DATABASE_SUFFIX,
|
|
11
11
|
DesktopSqliteClient,
|
|
12
12
|
assertValidDesktopDatabaseName,
|
|
13
|
+
connectDesktopRestoreTarget,
|
|
13
14
|
resolveDesktopHostTransport,
|
|
14
15
|
type DesktopOptions
|
|
15
16
|
} from '@aiao/rxdb-adapter-sqlite-core/desktop-host';
|
|
@@ -28,6 +29,8 @@ import { ADAPTER_NAME } from './electron-adapter.interface.js';
|
|
|
28
29
|
*/
|
|
29
30
|
export class RxDBAdapterElectron extends RxDBAdapterSqliteBase {
|
|
30
31
|
readonly #databaseName: string;
|
|
32
|
+
/** 备份与恢复争用的锁名,按库文件而不是 `dbName` 区分(见 {@link backupStorage})。 */
|
|
33
|
+
readonly #storageKey: string;
|
|
31
34
|
override readonly name: string = ADAPTER_NAME;
|
|
32
35
|
|
|
33
36
|
/** 解析出的逻辑数据库名,与 host 侧的物理文件一一对应。 */
|
|
@@ -48,6 +51,7 @@ export class RxDBAdapterElectron extends RxDBAdapterSqliteBase {
|
|
|
48
51
|
this.#databaseName = options.databaseName ?? `${rxdb.config.dbName}${DEFAULT_DATABASE_SUFFIX}`;
|
|
49
52
|
// 在构造期就校验:名字非法时根本没有能打开的库,等发出 IPC 才报错只会让原因离现场更远。
|
|
50
53
|
assertValidDesktopDatabaseName(this.#databaseName);
|
|
54
|
+
this.#storageKey = `${this.name}:file:${this.#databaseName}`;
|
|
51
55
|
}
|
|
52
56
|
|
|
53
57
|
protected override async createClient(): Promise<DesktopSqliteClient> {
|
|
@@ -57,6 +61,41 @@ export class RxDBAdapterElectron extends RxDBAdapterSqliteBase {
|
|
|
57
61
|
{ batchTimeout: this.options.batchTimeout }
|
|
58
62
|
);
|
|
59
63
|
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* 桌面库是应用数据目录里的一个库文件,备份与恢复都按它支持。
|
|
67
|
+
*
|
|
68
|
+
* @remarks
|
|
69
|
+
* `storageKey` 用库文件名而不是 `dbName`:配了同一个 `databaseName` 的两个实例打开的是同一个文件,
|
|
70
|
+
* 必须争同一把锁;同名实例恢复到另一个 `databaseName`(新的数据位置)则与原库互不相干。
|
|
71
|
+
*
|
|
72
|
+
* @returns 持久化存储后端,`label` 为 `file`
|
|
73
|
+
*/
|
|
74
|
+
protected override backupStorage(): SqliteBackupStorage {
|
|
75
|
+
return { kind: 'persistent', label: 'file', storageKey: this.#storageKey };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Electron host 以 defensive 模式运行 SQLite,改不了影子表。
|
|
80
|
+
*
|
|
81
|
+
* @remarks
|
|
82
|
+
* host 在特权主进程里执行 renderer 透传的 SQL;defensive 挡住直接改写影子表、`writable_schema` 这类
|
|
83
|
+
* 能把库文件改坏的操作,不能为了恢复而关掉。含 FTS5 等虚表的库因此不在 Electron 的备份矩阵里。
|
|
84
|
+
*
|
|
85
|
+
* @returns 恒为 `false`
|
|
86
|
+
*/
|
|
87
|
+
protected override shadowTablesWritable(): boolean {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* 别的窗口、别的进程可能开着同一个库文件,Web Lock 挡不住它们:恢复与清理前先让这条连接独占库文件。
|
|
93
|
+
*
|
|
94
|
+
* @returns 独占库文件的连接
|
|
95
|
+
*/
|
|
96
|
+
protected override createRestoreTargetClient(): Promise<SqliteClientLike> {
|
|
97
|
+
return connectDesktopRestoreTarget(() => this.createClient(), this.#storageKey);
|
|
98
|
+
}
|
|
60
99
|
}
|
|
61
100
|
|
|
62
101
|
declare module '@aiao/rxdb' {
|
|
@@ -266,6 +266,9 @@ export function createElectronSqliteHost(options: ElectronSqliteHostOptions): El
|
|
|
266
266
|
return close(request.sessionId);
|
|
267
267
|
case 'version':
|
|
268
268
|
return { kind: 'version', result: requireSession(request.sessionId).version() };
|
|
269
|
+
case 'mute':
|
|
270
|
+
requireSession(request.sessionId).setChangeEventsMuted(request.muted);
|
|
271
|
+
return { kind: 'mute' };
|
|
269
272
|
case 'execute':
|
|
270
273
|
return execute(request);
|
|
271
274
|
default: {
|
package/src/host.ts
CHANGED
package/src/index.ts
CHANGED
|
@@ -224,6 +224,8 @@ export class NodeSqliteEngine {
|
|
|
224
224
|
#maxWaitTimer?: BatchTimer;
|
|
225
225
|
#changesStatement?: StatementSync;
|
|
226
226
|
#totalChangesStatement?: StatementSync;
|
|
227
|
+
/** 为 `true` 时通知函数照常被触发器调用,但变更既不入批也不派发。 */
|
|
228
|
+
#muted = false;
|
|
227
229
|
#closed = false;
|
|
228
230
|
|
|
229
231
|
private constructor(db: DatabaseSync, options: NodeSqliteEngineOptions) {
|
|
@@ -249,7 +251,9 @@ export class NodeSqliteEngine {
|
|
|
249
251
|
static open(options: NodeSqliteEngineOptions): NodeSqliteEngine {
|
|
250
252
|
let db: DatabaseSync;
|
|
251
253
|
try {
|
|
252
|
-
|
|
254
|
+
// defensive 显式钉住而不靠运行时默认值:host 在特权主进程里执行 renderer 透传的 SQL,
|
|
255
|
+
// 不能让它直接改写影子表或 schema 把库文件改坏。代价是恢复不了含影子表的库,备份侧因此整体拒绝这类库。
|
|
256
|
+
db = new DatabaseSync(options.filePath, { defensive: true });
|
|
253
257
|
} catch (error) {
|
|
254
258
|
throw new RxDBAdapterDesktopError(
|
|
255
259
|
classify(error, 'open_failed'),
|
|
@@ -317,6 +321,20 @@ export class NodeSqliteEngine {
|
|
|
317
321
|
return String(row?.rows[0]?.[0]);
|
|
318
322
|
}
|
|
319
323
|
|
|
324
|
+
/**
|
|
325
|
+
* 暂停 / 恢复变更事件采集。
|
|
326
|
+
*
|
|
327
|
+
* @remarks
|
|
328
|
+
* 恢复整库时静音(US-217):恢复写进去的系统表行不是新变更。静音前已经攒下的批次不丢,
|
|
329
|
+
* 照常按定时器发出——它们记录的是静音前真实发生的写入。
|
|
330
|
+
*
|
|
331
|
+
* @param muted - `true` 时触发器报上来的变更直接丢弃,不入批也不派发
|
|
332
|
+
*/
|
|
333
|
+
setChangeEventsMuted(muted: boolean): void {
|
|
334
|
+
this.#assertOpen();
|
|
335
|
+
this.#muted = muted;
|
|
336
|
+
}
|
|
337
|
+
|
|
320
338
|
/**
|
|
321
339
|
* 断开连接并释放文件句柄。
|
|
322
340
|
*
|
|
@@ -470,6 +488,7 @@ export class NodeSqliteEngine {
|
|
|
470
488
|
}
|
|
471
489
|
|
|
472
490
|
#recordChange(type: SQLiteChangeType, tableName: string, rowId: bigint): void {
|
|
491
|
+
if (this.#muted) return;
|
|
473
492
|
const key = `${type} ${tableName}`;
|
|
474
493
|
const pending = this.#pendingChanges.get(key);
|
|
475
494
|
if (pending) pending.rowIds.push(rowId);
|
|
@@ -4,14 +4,18 @@
|
|
|
4
4
|
* @module pglite/RxDBAdapterElectronPGlite
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import type { RxDB } from '@aiao/rxdb';
|
|
7
|
+
import type { RxDB, RxDBBackupOptions, RxDBBackupResult } from '@aiao/rxdb';
|
|
8
8
|
import { RxDBAdapterPGlite, type IPGliteClient } from '@aiao/rxdb-adapter-pglite';
|
|
9
9
|
import {
|
|
10
|
+
assertDesktopPgliteResponse,
|
|
10
11
|
assertValidDesktopDatabaseName,
|
|
11
|
-
|
|
12
|
+
parseDesktopPgliteEngineResult,
|
|
13
|
+
resolveDesktopHostTransport,
|
|
14
|
+
type DesktopHostTransport
|
|
12
15
|
} from '@aiao/rxdb-adapter-sqlite-core/desktop-host';
|
|
13
16
|
import { DesktopPGliteClient } from './desktop-pglite-client.js';
|
|
14
|
-
import {
|
|
17
|
+
import { toElectronPGliteBackupError } from './electron-pglite-backup-error.js';
|
|
18
|
+
import { ADAPTER_NAME, resolveDataDirectoryName, type ElectronPGliteOptions } from './pglite-adapter.interface.js';
|
|
15
19
|
|
|
16
20
|
/**
|
|
17
21
|
* 把 RxDB 接到 Electron 主进程持有的那个唯一 PGlite 实例上。
|
|
@@ -50,11 +54,53 @@ export class RxDBAdapterElectronPGlite extends RxDBAdapterPGlite {
|
|
|
50
54
|
// 传下去只会让 `RxDBAdapterPGlite` 以为自己还管着存储。
|
|
51
55
|
super(rxdb, {});
|
|
52
56
|
this.#options = options;
|
|
53
|
-
this.#dataDirectoryName =
|
|
57
|
+
this.#dataDirectoryName = resolveDataDirectoryName(rxdb.config.dbName, options);
|
|
54
58
|
// 在构造期就校验:名字非法时根本没有能打开的目录,等发出 IPC 才报错只会让原因离现场更远。
|
|
55
59
|
assertValidDesktopDatabaseName(this.#dataDirectoryName);
|
|
56
60
|
}
|
|
57
61
|
|
|
62
|
+
/**
|
|
63
|
+
* 把 host 上的整个数据目录写成一份可恢复的归档(US-217)。
|
|
64
|
+
*
|
|
65
|
+
* @remarks
|
|
66
|
+
* 快照由 host 取:它在本会话那条唯一的连接上挂起一个空事务并 `CHECKPOINT`,写出期间别的窗口、
|
|
67
|
+
* 别的会话都改不动文件,归档因此对应一个已提交事务边界,快照边界前 WAL 里已提交的数据也都
|
|
68
|
+
* 已落进数据文件。数据块按 renderer 的读取节奏逐块过 IPC,host 不预读,内存占用与库大小无关。
|
|
69
|
+
*
|
|
70
|
+
* 与浏览器端的差异:
|
|
71
|
+
* - 等连接空闲的上限是构造参数里的 `beginTimeout`,不是 `options.lockTimeoutMs`(后者只管本
|
|
72
|
+
* adapter 内部队列的排队)。另一个窗口长期占着事务时报 `lock_timeout`。
|
|
73
|
+
* - manifest 的元数据(引擎版本、系统表版本)在快照开始之前读取,这两次读取与普通语句一样排在
|
|
74
|
+
* 别的窗口的事务后面。
|
|
75
|
+
* - 主进程创建 host 时没有传 `backup` 选项(未接入 US-217)时报 `unsupported_combination`,
|
|
76
|
+
* 此时不获取输出流的 writer,也不 abort 它。
|
|
77
|
+
*
|
|
78
|
+
* 任何失败都归一成 {@link RxDBBackupError}:桌面错误码按
|
|
79
|
+
* {@link toElectronPGliteBackupError} 的映射表转换,host 不可达等其余失败报 `io_error`,
|
|
80
|
+
* 原始错误留在 `cause` 上。
|
|
81
|
+
*
|
|
82
|
+
* @param sink - 输出流;成功时被 close,快照开始后的失败会 abort 它
|
|
83
|
+
* @param options - 取消信号与排队时限
|
|
84
|
+
* @returns 结束标记(条目数、字节数、SHA-256)、manifest 与范围;输出流 close 已完成
|
|
85
|
+
* @throws RxDBBackupError `unsupported_combination` / `lock_timeout` / `aborted` / `invalid_state` /
|
|
86
|
+
* `io_error` / `storage_full`
|
|
87
|
+
*
|
|
88
|
+
* @example
|
|
89
|
+
* ```typescript
|
|
90
|
+
* // sink 由宿主应用提供:沙箱里的 renderer 没有 node:fs,常见做法是 preload 暴露一条
|
|
91
|
+
* // 只能写进应用备份目录的 WritableStream
|
|
92
|
+
* const result = await adapter.backup(sink);
|
|
93
|
+
* console.log(result.sha256);
|
|
94
|
+
* ```
|
|
95
|
+
*/
|
|
96
|
+
override async backup(sink: WritableStream<Uint8Array>, options: RxDBBackupOptions = {}): Promise<RxDBBackupResult> {
|
|
97
|
+
try {
|
|
98
|
+
return await super.backup(sink, options);
|
|
99
|
+
} catch (error) {
|
|
100
|
+
throw toElectronPGliteBackupError(error, this.#dataDirectoryName, 'backup');
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
58
104
|
/**
|
|
59
105
|
* 建出转发到主进程的客户端。
|
|
60
106
|
*
|
|
@@ -62,12 +108,41 @@ export class RxDBAdapterElectronPGlite extends RxDBAdapterPGlite {
|
|
|
62
108
|
*/
|
|
63
109
|
protected override createClient(): IPGliteClient {
|
|
64
110
|
return new DesktopPGliteClient({
|
|
65
|
-
transport: this.#
|
|
111
|
+
transport: this.#transport(),
|
|
66
112
|
dataDirectoryName: this.#dataDirectoryName,
|
|
67
113
|
...(this.#options.beginTimeout === undefined ? {} : { beginTimeout: this.#options.beginTimeout }),
|
|
68
114
|
...(this.#options.batchTimeout === undefined ? {} : { batchTimeout: this.#options.batchTimeout })
|
|
69
115
|
});
|
|
70
116
|
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* 备份 manifest 记录的存储后端:host 上的一棵数据目录。
|
|
120
|
+
*
|
|
121
|
+
* @returns 固定为 `directory`
|
|
122
|
+
*/
|
|
123
|
+
protected override backupStorage(): string {
|
|
124
|
+
return 'directory';
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* 备份 manifest 记录的扩展名:host 运行时实际加载的那一组(已去重、排序)。
|
|
129
|
+
*
|
|
130
|
+
* @remarks
|
|
131
|
+
* renderer 侧的 PGlite 选项全部为空,只有 host 知道每个实例装了哪些扩展;恢复时目标 host 必须
|
|
132
|
+
* 全部提供。host 未启用备份时 `pg.engine` 报 `unsupported_operation`,于是在排队、取 writer 之前
|
|
133
|
+
* 就拒绝。
|
|
134
|
+
*
|
|
135
|
+
* @returns 扩展名
|
|
136
|
+
*/
|
|
137
|
+
protected override async backupExtensions(): Promise<readonly string[]> {
|
|
138
|
+
const response = assertDesktopPgliteResponse('pg.engine', await this.#transport().request({ kind: 'pg.engine' }));
|
|
139
|
+
return parseDesktopPgliteEngineResult(response.result).extensions;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** 构造参数里的传输层,缺省时取 preload 暴露的那条。 */
|
|
143
|
+
#transport(): DesktopHostTransport {
|
|
144
|
+
return this.#options.transport ?? resolveDesktopHostTransport();
|
|
145
|
+
}
|
|
71
146
|
}
|
|
72
147
|
|
|
73
148
|
declare module '@aiao/rxdb' {
|
|
@@ -17,18 +17,22 @@ import {
|
|
|
17
17
|
PGliteNotificationBatcher,
|
|
18
18
|
type IPGliteClient,
|
|
19
19
|
type PGliteClientEvents,
|
|
20
|
-
type PGliteClientOptions
|
|
20
|
+
type PGliteClientOptions,
|
|
21
|
+
type PGliteDataDirItem
|
|
21
22
|
} from '@aiao/rxdb-adapter-pglite';
|
|
22
23
|
import {
|
|
24
|
+
DESKTOP_PGLITE_DEFAULT_BEGIN_TIMEOUT_MS,
|
|
23
25
|
RxDBAdapterDesktopError,
|
|
24
26
|
assertDesktopPgliteResponse,
|
|
27
|
+
parseDesktopPgliteBackupItem,
|
|
25
28
|
parseDesktopPgliteHandshakeResult,
|
|
26
29
|
parseDesktopPgliteNotifyMessage,
|
|
27
30
|
parseDesktopPgliteOpenResult,
|
|
28
31
|
type DesktopHostTransport,
|
|
29
32
|
type DesktopPgliteParam,
|
|
30
|
-
type
|
|
31
|
-
type
|
|
33
|
+
type DesktopPgliteRequest,
|
|
34
|
+
type DesktopPgliteResponse,
|
|
35
|
+
type RxDBAdapterDesktopErrorCode
|
|
32
36
|
} from '@aiao/rxdb-adapter-sqlite-core/desktop-host';
|
|
33
37
|
import { EventDispatcher } from '@aiao/utils';
|
|
34
38
|
import type { QueryOptions, Results, Transaction } from '@electric-sql/pglite';
|
|
@@ -36,6 +40,8 @@ import type { QueryOptions, Results, Transaction } from '@electric-sql/pglite';
|
|
|
36
40
|
// `sql` 这些辅助器的语义只有原实现说了算,照抄一份的分叉表征是「同一个模板在桌面下
|
|
37
41
|
// 少转义了一个标识符」。`/template` 是个独立子路径(约 2 KB),不带任何 WASM 进 renderer。
|
|
38
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';
|
|
39
45
|
|
|
40
46
|
/** {@link DesktopPGliteClient} 的构造参数。 */
|
|
41
47
|
export interface DesktopPGliteClientOptions {
|
|
@@ -49,18 +55,11 @@ export interface DesktopPGliteClientOptions {
|
|
|
49
55
|
readonly batchTimeout?: number;
|
|
50
56
|
}
|
|
51
57
|
|
|
52
|
-
/**
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
});
|
|
58
|
+
/** `pg.open` 撞上恢复流程时的拒绝码,见 {@link DesktopPGliteClient.#assertOpened}。 */
|
|
59
|
+
const RESTORE_CONFLICT_CODES: ReadonlySet<RxDBAdapterDesktopErrorCode> = new Set([
|
|
60
|
+
'restore_in_progress',
|
|
61
|
+
'restore_incomplete'
|
|
62
|
+
]);
|
|
64
63
|
|
|
65
64
|
/**
|
|
66
65
|
* 拒绝本代理无法转发的 `QueryOptions`。
|
|
@@ -190,6 +189,8 @@ export class DesktopPGliteClient extends EventDispatcher<PGliteClientEvents> imp
|
|
|
190
189
|
*
|
|
191
190
|
* @param dbName - RxDB 逻辑库名;只用于变更事件的 `dbName` 字段,不参与落盘位置解析
|
|
192
191
|
* @param options - PGlite 选项;**不得**含 `store` 或 `dataDir`
|
|
192
|
+
* @throws `RxDBBackupError` 数据目录正在被恢复(`restore_in_progress`)或留着未完成的恢复
|
|
193
|
+
* (`restore_incomplete`)时,见 {@link DesktopPGliteClient.#assertOpened}
|
|
193
194
|
* @throws {@link RxDBAdapterDesktopError} 指定了落盘位置、协议版本不匹配或 host 打不开数据目录时
|
|
194
195
|
*/
|
|
195
196
|
async init(dbName: string, options: PGliteClientOptions): Promise<void> {
|
|
@@ -208,8 +209,7 @@ export class DesktopPGliteClient extends EventDispatcher<PGliteClientEvents> imp
|
|
|
208
209
|
);
|
|
209
210
|
parseDesktopPgliteHandshakeResult(handshake.result);
|
|
210
211
|
|
|
211
|
-
const opened =
|
|
212
|
-
'pg.open',
|
|
212
|
+
const opened = this.#assertOpened(
|
|
213
213
|
await this.#options.transport.request({
|
|
214
214
|
kind: 'pg.open',
|
|
215
215
|
storage: { engine: 'pglite', dataDirectoryName: this.#options.dataDirectoryName }
|
|
@@ -296,6 +296,61 @@ export class DesktopPGliteClient extends EventDispatcher<PGliteClientEvents> imp
|
|
|
296
296
|
);
|
|
297
297
|
}
|
|
298
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
|
+
|
|
299
354
|
/**
|
|
300
355
|
* 断开会话。
|
|
301
356
|
*
|
|
@@ -320,6 +375,26 @@ export class DesktopPGliteClient extends EventDispatcher<PGliteClientEvents> imp
|
|
|
320
375
|
await this.#closePromise;
|
|
321
376
|
}
|
|
322
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
|
+
|
|
323
398
|
/**
|
|
324
399
|
* 解析 `pg.open` 结果;解析不过就先把 host 上刚开出来的会话关掉,再抛原始错误。
|
|
325
400
|
*
|
|
@@ -451,6 +526,25 @@ export class DesktopPGliteClient extends EventDispatcher<PGliteClientEvents> imp
|
|
|
451
526
|
return toResults<T>(assertDesktopPgliteResponse('pg.query', await this.#send(request)).result);
|
|
452
527
|
}
|
|
453
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
|
+
|
|
454
548
|
async #execOn(transactionId: string | undefined, sql: string): Promise<Results[]> {
|
|
455
549
|
const request: DesktopPgliteRequest = {
|
|
456
550
|
kind: 'pg.exec',
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 桌面 PGlite 备份 / 恢复的错误归一(US-217)。
|
|
3
|
+
*
|
|
4
|
+
* @module pglite/electron-pglite-backup-error
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { RxDBBackupError, type RxDBBackupErrorCode, type RxDBBackupErrorDetails } from '@aiao/rxdb';
|
|
8
|
+
import {
|
|
9
|
+
DESKTOP_PGLITE_PROTOCOL_VERSION,
|
|
10
|
+
RxDBAdapterDesktopError,
|
|
11
|
+
type RxDBAdapterDesktopErrorCode
|
|
12
|
+
} from '@aiao/rxdb-adapter-sqlite-core/desktop-host';
|
|
13
|
+
|
|
14
|
+
/** 备份读源库,恢复写目标库;同一个桌面错误码在两个方向上的归因不同。 */
|
|
15
|
+
export type ElectronPGliteBackupDirection = 'backup' | 'restore';
|
|
16
|
+
|
|
17
|
+
/** 这些码说的都是「数据目录此刻的状态」,`details` 指向目录名,调用方据此决定清理、等待还是换目标。 */
|
|
18
|
+
const TARGET_STATE_CODES = new Map<RxDBAdapterDesktopErrorCode, RxDBBackupErrorCode>([
|
|
19
|
+
['database_busy', 'target_busy'],
|
|
20
|
+
['restore_in_progress', 'restore_in_progress'],
|
|
21
|
+
['restore_incomplete', 'restore_incomplete'],
|
|
22
|
+
['target_not_empty', 'target_not_empty'],
|
|
23
|
+
['cleanup_pending', 'cleanup_pending']
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
/** 这一侧的 host 或运行环境根本做不了这件事:重试无用,换组合才行。 */
|
|
27
|
+
const UNSUPPORTED_CODES: ReadonlySet<RxDBAdapterDesktopErrorCode> = new Set([
|
|
28
|
+
'unsupported_runtime_engine',
|
|
29
|
+
'invalid_database_name',
|
|
30
|
+
'unsupported_operation'
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
const targetStateMessage = (code: RxDBBackupErrorCode, name: string): string => {
|
|
34
|
+
switch (code) {
|
|
35
|
+
case 'target_busy':
|
|
36
|
+
return `Desktop PGlite data directory "${name}" is open in another connection or being restored`;
|
|
37
|
+
case 'restore_in_progress':
|
|
38
|
+
return `Desktop PGlite data directory "${name}" is being restored`;
|
|
39
|
+
case 'restore_incomplete':
|
|
40
|
+
return `A previous restore into "${name}" did not finish; call cleanupIncompleteElectronPGliteRestore()`;
|
|
41
|
+
case 'target_not_empty':
|
|
42
|
+
return `Desktop PGlite data directory "${name}" already holds a database`;
|
|
43
|
+
default:
|
|
44
|
+
return `Failed to clean up the incomplete restore of "${name}"; call cleanupIncompleteElectronPGliteRestore()`;
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const generalCode = (
|
|
49
|
+
code: RxDBAdapterDesktopErrorCode,
|
|
50
|
+
direction: ElectronPGliteBackupDirection
|
|
51
|
+
): RxDBBackupErrorCode => {
|
|
52
|
+
if (UNSUPPORTED_CODES.has(code)) return 'unsupported_combination';
|
|
53
|
+
switch (code) {
|
|
54
|
+
case 'disk_full':
|
|
55
|
+
return 'storage_full';
|
|
56
|
+
case 'transaction_unavailable':
|
|
57
|
+
return 'lock_timeout';
|
|
58
|
+
case 'session_closed':
|
|
59
|
+
return 'invalid_state';
|
|
60
|
+
case 'database_corrupted':
|
|
61
|
+
// 恢复时坏的是归档写出来的目录;备份时坏的是源库,归档还没写出来,不能让人去怀疑它。
|
|
62
|
+
return direction === 'restore' ? 'corrupt_archive' : 'io_error';
|
|
63
|
+
default:
|
|
64
|
+
return 'io_error';
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const fromDesktopError = (
|
|
69
|
+
error: RxDBAdapterDesktopError,
|
|
70
|
+
name: string,
|
|
71
|
+
direction: ElectronPGliteBackupDirection
|
|
72
|
+
): RxDBBackupError => {
|
|
73
|
+
const targetState = TARGET_STATE_CODES.get(error.code);
|
|
74
|
+
if (targetState !== undefined) {
|
|
75
|
+
const details: RxDBBackupErrorDetails = { field: 'dataDirectoryName', actual: name };
|
|
76
|
+
return new RxDBBackupError(targetState, targetStateMessage(targetState, name), { details, cause: error });
|
|
77
|
+
}
|
|
78
|
+
if (error.code === 'protocol_violation') {
|
|
79
|
+
return new RxDBBackupError('unsupported_combination', `The desktop host does not speak this renderer's protocol`, {
|
|
80
|
+
details: { field: 'protocolVersion', expected: DESKTOP_PGLITE_PROTOCOL_VERSION },
|
|
81
|
+
cause: error
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
return new RxDBBackupError(
|
|
85
|
+
generalCode(error.code, direction),
|
|
86
|
+
`Desktop PGlite ${direction} failed: ${error.detail}`,
|
|
87
|
+
{
|
|
88
|
+
cause: error
|
|
89
|
+
}
|
|
90
|
+
);
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* 把桌面 PGlite 备份 / 恢复途中的任意失败归一成 {@link RxDBBackupError}。
|
|
95
|
+
*
|
|
96
|
+
* @remarks
|
|
97
|
+
* 备份与恢复的公共契约只认 `RxDBBackupError.code`;桌面错误码属于 host 线协议,漏到调用方手里,
|
|
98
|
+
* 按契约写的 `switch (error.code)` 就会静默落进 default。已经是 `RxDBBackupError` 的原样返回;
|
|
99
|
+
* 其余一律带着原始错误作为 `cause`:
|
|
100
|
+
*
|
|
101
|
+
* | 桌面错误码 | 备份契约 |
|
|
102
|
+
* | --- | --- |
|
|
103
|
+
* | `database_busy` | `target_busy` |
|
|
104
|
+
* | `restore_in_progress` / `restore_incomplete` / `target_not_empty` / `cleanup_pending` | 同名码 |
|
|
105
|
+
* | `protocol_violation` | `unsupported_combination`(`details.field` 为 `protocolVersion`) |
|
|
106
|
+
* | `unsupported_operation` / `unsupported_runtime_engine` / `invalid_database_name` | `unsupported_combination` |
|
|
107
|
+
* | `disk_full` | `storage_full` |
|
|
108
|
+
* | `transaction_unavailable` | `lock_timeout` |
|
|
109
|
+
* | `session_closed` | `invalid_state` |
|
|
110
|
+
* | `database_corrupted` | 恢复为 `corrupt_archive`,备份为 `io_error` |
|
|
111
|
+
* | 其余桌面错误及非桌面错误 | `io_error` |
|
|
112
|
+
*
|
|
113
|
+
* 目标状态类的码(前两行)`details` 为 `{ field: 'dataDirectoryName', actual: 目录名 }`。
|
|
114
|
+
*
|
|
115
|
+
* @param error - 捕获到的任意值
|
|
116
|
+
* @param dataDirectoryName - 本次操作的数据目录名
|
|
117
|
+
* @param direction - 发生在备份还是恢复
|
|
118
|
+
* @returns 归一后的备份错误
|
|
119
|
+
*/
|
|
120
|
+
export const toElectronPGliteBackupError = (
|
|
121
|
+
error: unknown,
|
|
122
|
+
dataDirectoryName: string,
|
|
123
|
+
direction: ElectronPGliteBackupDirection
|
|
124
|
+
): RxDBBackupError => {
|
|
125
|
+
if (error instanceof RxDBBackupError) return error;
|
|
126
|
+
if (error instanceof RxDBAdapterDesktopError) return fromDesktopError(error, dataDirectoryName, direction);
|
|
127
|
+
return new RxDBBackupError('io_error', `Desktop PGlite ${direction} failed`, { cause: error });
|
|
128
|
+
};
|
|
@@ -62,3 +62,17 @@ export interface ElectronPGliteOptions {
|
|
|
62
62
|
*/
|
|
63
63
|
readonly batchTimeout?: number;
|
|
64
64
|
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* 解析实例对应的逻辑数据目录名。
|
|
68
|
+
*
|
|
69
|
+
* @remarks
|
|
70
|
+
* 适配器连接与恢复入口必须算出同一个名字:恢复写进的目录就是之后 `connect` 打开的目录,两处各写一遍
|
|
71
|
+
* 默认规则迟早会分叉。
|
|
72
|
+
*
|
|
73
|
+
* @param dbName - `rxdb.config.dbName`
|
|
74
|
+
* @param options - 适配器选项;只读取 `dataDirectoryName`
|
|
75
|
+
* @returns 显式给出的名字,否则为 `<dbName>-pgdata`;不做校验
|
|
76
|
+
*/
|
|
77
|
+
export const resolveDataDirectoryName = (dbName: string, options: ElectronPGliteOptions): string =>
|
|
78
|
+
options.dataDirectoryName ?? `${dbName}${DEFAULT_DATA_DIRECTORY_SUFFIX}`;
|