@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.
Files changed (70) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +394 -2
  3. package/dist/RxDBAdapterElectron.d.ts +61 -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 +768 -0
  14. package/dist/index.d.ts +22 -0
  15. package/dist/index.d.ts.map +1 -0
  16. package/dist/index.js +36 -0
  17. package/dist/node-sqlite-engine.d.ts +97 -0
  18. package/dist/node-sqlite-engine.d.ts.map +1 -0
  19. package/dist/pglite/RxDBAdapterElectronPGlite.d.ts +96 -0
  20. package/dist/pglite/RxDBAdapterElectronPGlite.d.ts.map +1 -0
  21. package/dist/pglite/desktop-pglite-client.d.ts +144 -0
  22. package/dist/pglite/desktop-pglite-client.d.ts.map +1 -0
  23. package/dist/pglite/desktop-pglite-results.d.ts +14 -0
  24. package/dist/pglite/desktop-pglite-results.d.ts.map +1 -0
  25. package/dist/pglite/electron-pglite-backup-error.d.ts +32 -0
  26. package/dist/pglite/electron-pglite-backup-error.d.ts.map +1 -0
  27. package/dist/pglite/pglite-adapter.interface.d.ts +68 -0
  28. package/dist/pglite/pglite-adapter.interface.d.ts.map +1 -0
  29. package/dist/pglite/restore-electron-pglite-database.d.ts +68 -0
  30. package/dist/pglite/restore-electron-pglite-database.d.ts.map +1 -0
  31. package/dist/pglite-host/electron-pglite-host.d.ts +129 -0
  32. package/dist/pglite-host/electron-pglite-host.d.ts.map +1 -0
  33. package/dist/pglite-host/pglite-host-data-dir.d.ts +75 -0
  34. package/dist/pglite-host/pglite-host-data-dir.d.ts.map +1 -0
  35. package/dist/pglite-host/pglite-host-lock.d.ts +48 -0
  36. package/dist/pglite-host/pglite-host-lock.d.ts.map +1 -0
  37. package/dist/pglite-host/pglite-host-restore.d.ts +38 -0
  38. package/dist/pglite-host/pglite-host-restore.d.ts.map +1 -0
  39. package/dist/pglite-host/pglite-host-runtime.d.ts +90 -0
  40. package/dist/pglite-host/pglite-host-runtime.d.ts.map +1 -0
  41. package/dist/pglite-host.d.ts +17 -0
  42. package/dist/pglite-host.d.ts.map +1 -0
  43. package/dist/pglite-host.js +738 -0
  44. package/dist/pglite.d.ts +22 -0
  45. package/dist/pglite.d.ts.map +1 -0
  46. package/dist/pglite.js +493 -0
  47. package/dist/sqlite-script.d.ts +26 -0
  48. package/dist/sqlite-script.d.ts.map +1 -0
  49. package/package.json +94 -3
  50. package/src/RxDBAdapterElectron.ts +105 -0
  51. package/src/electron-adapter.interface.ts +25 -0
  52. package/src/electron-file-host.ts +758 -0
  53. package/src/electron-sqlite-host.ts +301 -0
  54. package/src/host.ts +64 -0
  55. package/src/index.ts +71 -0
  56. package/src/node-sqlite-engine.ts +631 -0
  57. package/src/pglite/RxDBAdapterElectronPGlite.ts +152 -0
  58. package/src/pglite/desktop-pglite-client.ts +614 -0
  59. package/src/pglite/desktop-pglite-results.ts +24 -0
  60. package/src/pglite/electron-pglite-backup-error.ts +128 -0
  61. package/src/pglite/pglite-adapter.interface.ts +78 -0
  62. package/src/pglite/restore-electron-pglite-database.ts +429 -0
  63. package/src/pglite-host/electron-pglite-host.ts +829 -0
  64. package/src/pglite-host/pglite-host-data-dir.ts +224 -0
  65. package/src/pglite-host/pglite-host-lock.ts +154 -0
  66. package/src/pglite-host/pglite-host-restore.ts +460 -0
  67. package/src/pglite-host/pglite-host-runtime.ts +166 -0
  68. package/src/pglite-host.ts +50 -0
  69. package/src/pglite.ts +51 -0
  70. package/src/sqlite-script.ts +134 -0
@@ -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
+ };
@@ -0,0 +1,78 @@
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
+ }
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}`;
@@ -0,0 +1,429 @@
1
+ /**
2
+ * Electron PGlite 的恢复入口(renderer 侧,US-217 阶段 C)。
3
+ *
4
+ * @module pglite/restore-electron-pglite-database
5
+ */
6
+
7
+ import {
8
+ assertRxDBBackupCompatible,
9
+ getRxDBBackupAuthDomain,
10
+ getRxDBBackupSchemaFingerprint,
11
+ RXDB_CHANGE_CODEC_VERSION,
12
+ RXDB_SYSTEM_SCHEMA_VERSION,
13
+ RxDBBackupArchiveReader,
14
+ RxDBBackupError,
15
+ type RxDB,
16
+ type RxDBBackupCompatibility,
17
+ type RxDBBackupEntryHeader,
18
+ type RxDBBackupManifest,
19
+ type RxDBBackupTrailer,
20
+ type RxDBRestoreResult
21
+ } from '@aiao/rxdb';
22
+ import {
23
+ PGLITE_BACKUP_ENGINE,
24
+ toPGliteEngineInfo,
25
+ verifyPGliteRestored,
26
+ type PGliteQueryable,
27
+ type PGliteRestoreOptions
28
+ } from '@aiao/rxdb-adapter-pglite';
29
+ import {
30
+ assertDesktopPgliteResponse,
31
+ assertValidDesktopDatabaseName,
32
+ parseDesktopPgliteBackupItem,
33
+ parseDesktopPgliteEngineResult,
34
+ parseDesktopPgliteHandshakeResult,
35
+ resolveDesktopHostTransport,
36
+ RxDBAdapterDesktopError,
37
+ type DesktopHostTransport,
38
+ type DesktopPgliteDataDirItem,
39
+ type DesktopPgliteEngineResult,
40
+ type DesktopPgliteParam,
41
+ type DesktopPgliteQueryResult,
42
+ type DesktopPgliteRestoreStepRequest
43
+ } from '@aiao/rxdb-adapter-sqlite-core/desktop-host';
44
+ import { toDesktopPGliteResults } from './desktop-pglite-results.js';
45
+ import { toElectronPGliteBackupError } from './electron-pglite-backup-error.js';
46
+ import { ADAPTER_NAME, resolveDataDirectoryName, type ElectronPGliteOptions } from './pglite-adapter.interface.js';
47
+
48
+ /**
49
+ * 恢复目标:一个**尚未连接**的 RxDB 实例,加上它将来连接时交给
50
+ * {@link RxDBAdapterElectronPGlite} 的同一份选项。
51
+ *
52
+ * @remarks
53
+ * 数据目录名、传输层、实体结构指纹与加密认证域都按这份配置判定;与之后连接时的选项不一致,
54
+ * 恢复出来的库就会落在另一个目录,或者被判成不兼容。
55
+ */
56
+ export interface ElectronPGliteRestoreTarget {
57
+ readonly rxdb: RxDB;
58
+ readonly options: ElectronPGliteOptions;
59
+ }
60
+
61
+ type RestoreStepKind = Exclude<DesktopPgliteRestoreStepRequest['kind'], 'pg.restore.abort'>;
62
+
63
+ const aborted = (signal: AbortSignal): RxDBBackupError =>
64
+ new RxDBBackupError('aborted', 'Desktop PGlite restore was aborted', { cause: signal.reason });
65
+
66
+ const throwIfAborted = (signal: AbortSignal | undefined): void => {
67
+ if (signal?.aborted) throw aborted(signal);
68
+ };
69
+
70
+ /**
71
+ * 目标实例本身必须还没连接:已连接的实例有自己的库,恢复结果既不能替换它、也不能被它领取。
72
+ *
73
+ * @remarks
74
+ * `connected$` 是 BehaviorSubject 派生流,订阅时同步给出当前值;本包不依赖 rxjs,直接订阅一次再退订。
75
+ */
76
+ const assertTargetDisconnected = (rxdb: RxDB): void => {
77
+ let connected = false;
78
+ rxdb.connected$
79
+ .subscribe(value => {
80
+ connected = value;
81
+ })
82
+ .unsubscribe();
83
+ if (!connected) return;
84
+ throw new RxDBBackupError('target_busy', `RxDB "${rxdb.config.dbName}" is already connected`, {
85
+ details: { field: 'rxdb', actual: rxdb.config.dbName }
86
+ });
87
+ };
88
+
89
+ /** 协议版本不一致时 host 与 renderer 对恢复步骤的理解不同,必须在发出任何恢复请求之前拒绝(AC#21)。 */
90
+ const handshake = async (transport: DesktopHostTransport): Promise<void> => {
91
+ const response = assertDesktopPgliteResponse('pg.handshake', await transport.request({ kind: 'pg.handshake' }));
92
+ parseDesktopPgliteHandshakeResult(response.result);
93
+ };
94
+
95
+ const engineOf = async (transport: DesktopHostTransport): Promise<DesktopPgliteEngineResult> => {
96
+ const response = assertDesktopPgliteResponse('pg.engine', await transport.request({ kind: 'pg.engine' }));
97
+ return parseDesktopPgliteEngineResult(response.result);
98
+ };
99
+
100
+ /**
101
+ * 目标侧的兼容性期望。
102
+ *
103
+ * @remarks
104
+ * 目标库此时还不存在,引擎版本与扩展只能问 host:它回答的是将来打开这个目录的那个运行时。
105
+ * 目标实例会被 `init()`(计算实体集合),但不会连接。
106
+ */
107
+ const targetCompatibility = (rxdb: RxDB, engine: DesktopPgliteEngineResult): RxDBBackupCompatibility => {
108
+ rxdb.init();
109
+ return {
110
+ adapterName: ADAPTER_NAME,
111
+ engine: PGLITE_BACKUP_ENGINE,
112
+ engineCompatibility: toPGliteEngineInfo(engine.serverVersion).compatibility,
113
+ extensions: engine.extensions,
114
+ systemSchemaVersion: RXDB_SYSTEM_SCHEMA_VERSION,
115
+ changeCodecVersion: RXDB_CHANGE_CODEC_VERSION,
116
+ schemaFingerprint: getRxDBBackupSchemaFingerprint(rxdb),
117
+ authDomain: getRxDBBackupAuthDomain(rxdb)
118
+ };
119
+ };
120
+
121
+ /** 一次已取得独占权的 host 侧恢复。 */
122
+ interface HostRestore {
123
+ step(kind: RestoreStepKind): Promise<void>;
124
+ write(item: DesktopPgliteDataDirItem): Promise<void>;
125
+ query(sql: string, params: readonly DesktopPgliteParam[]): Promise<DesktopPgliteQueryResult>;
126
+ abort(): Promise<void>;
127
+ }
128
+
129
+ /**
130
+ * 取得目标目录的独占权并检查它是否可以恢复。
131
+ *
132
+ * @remarks
133
+ * host **先**取目录锁、**再**查未完成标记与空状态,独占一直保持到 `commit` 或 `abort`:
134
+ * 目录正被打开或正在恢复报 `database_busy`,有未完成标记报 `restore_incomplete`,
135
+ * 已有数据库(哪怕只是 initdb 的产物)报 `target_not_empty`。
136
+ */
137
+ const beginHostRestore = async (transport: DesktopHostTransport, dataDirectoryName: string): Promise<HostRestore> => {
138
+ const begun = assertDesktopPgliteResponse(
139
+ 'pg.restore.begin',
140
+ await transport.request({ kind: 'pg.restore.begin', storage: { engine: 'pglite', dataDirectoryName } })
141
+ );
142
+ const { restoreId } = begun.result;
143
+ return {
144
+ step: async kind => {
145
+ assertDesktopPgliteResponse(kind, await transport.request({ kind, restoreId }));
146
+ },
147
+ write: async item => {
148
+ assertDesktopPgliteResponse(
149
+ 'pg.restore.write',
150
+ await transport.request({ kind: 'pg.restore.write', restoreId, item })
151
+ );
152
+ },
153
+ query: async (sql, params) => {
154
+ const response = assertDesktopPgliteResponse(
155
+ 'pg.restore.query',
156
+ await transport.request({ kind: 'pg.restore.query', restoreId, sql, params })
157
+ );
158
+ return response.result;
159
+ },
160
+ abort: async () => {
161
+ assertDesktopPgliteResponse('pg.restore.abort', await transport.request({ kind: 'pg.restore.abort', restoreId }));
162
+ }
163
+ };
164
+ };
165
+
166
+ /**
167
+ * 归档条目头转成 host 写入项。
168
+ *
169
+ * @remarks
170
+ * 归档读取器只保证路径是安全的 POSIX 相对路径;host 还要求每一段在 Windows 上也合法、且不是运行态
171
+ * 文件。由本 adapter 备份出来的归档必然满足(备份方向走同一套校验),不满足只能是归档被改过,
172
+ * 所以在发给 host 之前按损坏拒绝,而不是让 host 以协议错误拒绝后被误判成「组合不支持」。
173
+ */
174
+ const toHostEntry = (header: RxDBBackupEntryHeader): DesktopPgliteDataDirItem => {
175
+ const item: DesktopPgliteDataDirItem = {
176
+ type: 'entry',
177
+ header: { path: header.path, kind: header.kind, size: header.size }
178
+ };
179
+ try {
180
+ parseDesktopPgliteBackupItem(item);
181
+ } catch (error) {
182
+ throw new RxDBBackupError(
183
+ 'corrupt_archive',
184
+ `Archive entry ${JSON.stringify(header.path)} cannot be restored into a desktop data directory`,
185
+ { details: { field: 'path', actual: header.path }, cause: error }
186
+ );
187
+ }
188
+ return item;
189
+ };
190
+
191
+ /**
192
+ * 数据块必须独占自己的 `ArrayBuffer`。
193
+ *
194
+ * @remarks
195
+ * 结构化克隆按整个底层 buffer 复制:一个视图过 IPC,会把它背后的整块内存一起带过去,
196
+ * host 也会以超限拒绝。读取器产出的帧本来就独占 buffer,这里只在例外时复制一次。
197
+ */
198
+ const ownedChunk = (bytes: Uint8Array): Uint8Array<ArrayBuffer> =>
199
+ bytes.buffer instanceof ArrayBuffer && bytes.byteOffset === 0 && bytes.byteLength === bytes.buffer.byteLength ?
200
+ (bytes as Uint8Array<ArrayBuffer>)
201
+ : bytes.slice();
202
+
203
+ /**
204
+ * 把归档逐项写进 host 的暂存目录。
205
+ *
206
+ * @remarks
207
+ * 每一项都等 host 确认写入之后才读下一项:读取节奏跟着 host 的磁盘走,内存里最多停留一个数据帧,
208
+ * 与归档大小无关(AC#9、AC#21)。
209
+ */
210
+ const writeArchive = async (archive: RxDBBackupArchiveReader, host: HostRestore): Promise<RxDBBackupTrailer> => {
211
+ let item = await archive.next();
212
+ while (item.type !== 'end') {
213
+ await host.write(
214
+ item.type === 'entry' ? toHostEntry(item.header) : { type: 'data', bytes: ownedChunk(item.bytes) }
215
+ );
216
+ item = await archive.next();
217
+ }
218
+ return item.trailer;
219
+ };
220
+
221
+ /**
222
+ * 在 host 的私有实例上核对引擎与系统表水位。
223
+ *
224
+ * @remarks
225
+ * 校验语句在一个摘要正确的库上执行失败(比如缺系统表),说明归档里装的不是 manifest 声明的那个库,
226
+ * 与浏览器端「PostgreSQL 起不来」同样归为损坏。
227
+ */
228
+ const verifyOnHost = async (host: HostRestore, manifest: RxDBBackupManifest): Promise<void> => {
229
+ const db: PGliteQueryable = {
230
+ query: async <T>(sql: string, params: unknown[] = []) =>
231
+ toDesktopPGliteResults<T>(await host.query(sql, params as readonly DesktopPgliteParam[]))
232
+ };
233
+ try {
234
+ await verifyPGliteRestored(db, manifest);
235
+ } catch (error) {
236
+ if (!(error instanceof RxDBAdapterDesktopError && error.code === 'statement_failed')) throw error;
237
+ throw new RxDBBackupError('corrupt_archive', 'Restored database cannot answer the verification queries', {
238
+ cause: error
239
+ });
240
+ }
241
+ };
242
+
243
+ /** 恢复运行时:独占已取得,归档读到了 manifest 并判定兼容。 */
244
+ interface RestoreSession {
245
+ readonly host: HostRestore;
246
+ readonly archive: RxDBBackupArchiveReader;
247
+ readonly manifest: RxDBBackupManifest;
248
+ readonly options: PGliteRestoreOptions;
249
+ }
250
+
251
+ /**
252
+ * 从未完成标记落盘到提交。
253
+ *
254
+ * @remarks
255
+ * 顺序与浏览器端的 IndexedDB 目标一致:标记 → 写入 → 启动 → 校验 → 落盘 → 删标记。标记之后到
256
+ * 删标记之前任何一刻进程被杀,下一次打开都会以 `restore_incomplete` 拒绝,而不是把半截库当成正常库。
257
+ */
258
+ const runRestore = async (session: RestoreSession): Promise<RxDBBackupTrailer> => {
259
+ const { host, archive, options } = session;
260
+ await host.step('pg.restore.prepare');
261
+ await options.onStage?.('marker-written');
262
+ throwIfAborted(options.signal);
263
+ const trailer = await writeArchive(archive, host);
264
+ await host.step('pg.restore.open');
265
+ await options.onStage?.('files-written');
266
+ throwIfAborted(options.signal);
267
+ await verifyOnHost(host, session.manifest);
268
+ await options.onStage?.('verified');
269
+ throwIfAborted(options.signal);
270
+ await host.step('pg.restore.persist');
271
+ await options.onStage?.('persisted');
272
+ await host.step('pg.restore.commit');
273
+ return trailer;
274
+ };
275
+
276
+ /**
277
+ * 失败后让 host 把目标退回「从未恢复过」,并交还独占。
278
+ *
279
+ * @remarks
280
+ * 未完成标记已写下之后连 abort 都没成功(host 断开、删目录失败),目标上就留着一份需要清理的残留:
281
+ * 此时报 `cleanup_pending`。标记写下之前的 abort 失败不留残留,照原始失败的码报;但独占可能还在 host
282
+ * 上(直到这个窗口断开才交还),清理失败因此不能丢。两种情况下 `cause` 都是装着原始失败与清理失败的
283
+ * `AggregateError`。
284
+ */
285
+ const abandon = async (
286
+ host: HostRestore,
287
+ marked: boolean,
288
+ dataDirectoryName: string,
289
+ cause: unknown
290
+ ): Promise<never> => {
291
+ try {
292
+ await host.abort();
293
+ } catch (cleanupError) {
294
+ const both = new AggregateError([cause, cleanupError], 'Restore and cleanup both failed');
295
+ if (!marked) {
296
+ const failure = toElectronPGliteBackupError(cause, dataDirectoryName, 'restore');
297
+ throw new RxDBBackupError(failure.code, failure.message, { details: failure.details, cause: both });
298
+ }
299
+ throw new RxDBBackupError(
300
+ 'cleanup_pending',
301
+ `Desktop PGlite restore failed and its partial data could not be removed; call cleanupIncompleteElectronPGliteRestore()`,
302
+ {
303
+ details: { field: 'dataDirectoryName', actual: dataDirectoryName },
304
+ cause: both
305
+ }
306
+ );
307
+ }
308
+ throw cause;
309
+ };
310
+
311
+ const restoreOnHost = async (
312
+ sourceReader: ReadableStreamDefaultReader<Uint8Array>,
313
+ target: ElectronPGliteRestoreTarget,
314
+ dataDirectoryName: string,
315
+ options: PGliteRestoreOptions
316
+ ): Promise<RxDBRestoreResult> => {
317
+ const transport = target.options.transport ?? resolveDesktopHostTransport();
318
+ await handshake(transport);
319
+ const expected = targetCompatibility(target.rxdb, await engineOf(transport));
320
+ throwIfAborted(options.signal);
321
+ const host = await beginHostRestore(transport, dataDirectoryName);
322
+ let marked = false;
323
+ try {
324
+ const archive = new RxDBBackupArchiveReader(sourceReader, options.signal);
325
+ const manifest = await archive.readManifest();
326
+ assertRxDBBackupCompatible(manifest, expected);
327
+ // 从这里起 host 可能已经写下未完成标记(请求发出去了、应答却没回来也算)。
328
+ marked = true;
329
+ const trailer = await runRestore({ host, archive, manifest, options });
330
+ return { ...trailer, manifest, scope: manifest.scope };
331
+ } catch (error) {
332
+ return abandon(host, marked, dataDirectoryName, error);
333
+ }
334
+ };
335
+
336
+ /**
337
+ * 把 {@link RxDBAdapterElectronPGlite.backup} 产出的归档恢复进 host 上的一个新数据目录。
338
+ *
339
+ * @remarks
340
+ * 只写入**空**目标,不做合并或覆盖。步骤:
341
+ *
342
+ * 1. 在读取归档之前拒绝已连接的目标实例(`target_busy`,`details.field` 为 `rxdb`)、协议版本不一致
343
+ * 或未启用备份的 host(`unsupported_combination`)。
344
+ * 2. host **先**取得目录的独占权,**再**检查未完成标记与空状态;独占一直保持到提交或失败处理结束,
345
+ * 期间其他窗口的 `connect`、其他恢复与清理请求都被拒绝。
346
+ * 3. 读 manifest,在写入第一个字节之前判定兼容性:适配器、引擎大版本、扩展(目标 host 必须全部提供)、
347
+ * 系统表 / 变更编码版本、实体结构指纹与加密认证域(`incompatible_archive` /
348
+ * `auth_domain_mismatch`)。
349
+ * 4. host 持久化未完成标记,归档逐项写进目录;读到结尾、摘要通过后才在私有实例上启动 PostgreSQL,
350
+ * 核对引擎与系统表水位,关闭实例并逐一 fsync,最后删除标记、交还独占。
351
+ *
352
+ * 任一步失败都由 host 删除目录与标记,目标回到从未恢复过的状态;连这一步也失败时报
353
+ * `cleanup_pending`,调 {@link cleanupIncompleteElectronPGliteRestore} 重试。恢复途中窗口或进程
354
+ * 退出时标记留在盘上,下一次连接以 `restore_incomplete` 拒绝,同样用它清理后再恢复。
355
+ *
356
+ * 归档只含数据库:结果与 manifest 的 `scope.externalFiles` 恒为 `excluded`,外置文件须另行备份恢复。
357
+ * 输入流在失败时被取消,成功时只释放读锁。桌面错误码按 {@link toElectronPGliteBackupError} 归一。
358
+ *
359
+ * @param source - 归档字节流
360
+ * @param target - 尚未连接的目标实例与其 adapter 选项
361
+ * @param options - 取消信号与阶段回调;四个阶段都会触发
362
+ * @returns 结束标记(条目数、字节数、SHA-256)、manifest 与范围
363
+ * @throws RxDBBackupError `target_busy` / `target_not_empty` / `restore_in_progress` / `restore_incomplete` /
364
+ * `unsupported_combination` / `incompatible_archive` / `auth_domain_mismatch` / `corrupt_archive` /
365
+ * `truncated_archive` / `aborted` / `io_error` / `storage_full` / `cleanup_pending`
366
+ *
367
+ * @example
368
+ * ```typescript
369
+ * const target = { rxdb, options: {} };
370
+ * await restoreElectronPGliteDatabase(file.stream(), target);
371
+ * rxdb.adapter(ELECTRON_PGLITE_ADAPTER_NAME, db => new RxDBAdapterElectronPGlite(db, target.options));
372
+ * await rxdb.connect(ELECTRON_PGLITE_ADAPTER_NAME);
373
+ * ```
374
+ */
375
+ export const restoreElectronPGliteDatabase = async (
376
+ source: ReadableStream<Uint8Array>,
377
+ target: ElectronPGliteRestoreTarget,
378
+ options: PGliteRestoreOptions = {}
379
+ ): Promise<RxDBRestoreResult> => {
380
+ const sourceReader = source.getReader();
381
+ const dataDirectoryName = resolveDataDirectoryName(target.rxdb.config.dbName, target.options);
382
+ try {
383
+ throwIfAborted(options.signal);
384
+ assertValidDesktopDatabaseName(dataDirectoryName);
385
+ assertTargetDisconnected(target.rxdb);
386
+ const result = await restoreOnHost(sourceReader, target, dataDirectoryName, options);
387
+ sourceReader.releaseLock();
388
+ return result;
389
+ } catch (error) {
390
+ await sourceReader.cancel(error).catch(() => undefined);
391
+ throw toElectronPGliteBackupError(error, dataDirectoryName, 'restore');
392
+ }
393
+ };
394
+
395
+ /**
396
+ * 清理一次没做完的恢复(窗口或进程在恢复中途退出、或恢复失败后清理本身也失败)。
397
+ *
398
+ * @remarks
399
+ * host 先取目录的独占权:目录正被打开或正在恢复时报 `target_busy`,从不删别人正在用的库。
400
+ * 没有未完成标记时什么都不做——一个正常的库永远不会被这里删掉。
401
+ *
402
+ * @param target - 恢复时使用的同一个目标
403
+ * @returns 确实清理了残留时为 `true`
404
+ * @throws RxDBBackupError `target_busy` / `unsupported_combination` / `cleanup_pending`
405
+ */
406
+ export const cleanupIncompleteElectronPGliteRestore = async (target: ElectronPGliteRestoreTarget): Promise<boolean> => {
407
+ const dataDirectoryName = resolveDataDirectoryName(target.rxdb.config.dbName, target.options);
408
+ try {
409
+ assertValidDesktopDatabaseName(dataDirectoryName);
410
+ const transport = target.options.transport ?? resolveDesktopHostTransport();
411
+ await handshake(transport);
412
+ const response = assertDesktopPgliteResponse(
413
+ 'pg.restore.cleanup',
414
+ await transport.request({ kind: 'pg.restore.cleanup', storage: { engine: 'pglite', dataDirectoryName } })
415
+ );
416
+ return response.result.cleaned;
417
+ } catch (error) {
418
+ const mapped = toElectronPGliteBackupError(error, dataDirectoryName, 'restore');
419
+ if (mapped.code !== 'io_error') throw mapped;
420
+ throw new RxDBBackupError(
421
+ 'cleanup_pending',
422
+ `Failed to clean up the incomplete restore of "${dataDirectoryName}"`,
423
+ {
424
+ details: { field: 'dataDirectoryName', actual: dataDirectoryName },
425
+ cause: error
426
+ }
427
+ );
428
+ }
429
+ };