@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
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* host 侧 PGlite 恢复协调器(US-217 阶段 C)。
|
|
3
|
+
*
|
|
4
|
+
* @remarks
|
|
5
|
+
* 一次恢复的生命周期:`pg.restore.begin` **先**取目录锁、**再**查恢复标记与空状态,独占一直保持到
|
|
6
|
+
* `commit` 或 `abort`(AC#12);`prepare` 在第一次落盘之前持久化恢复标记,标记直到整棵树 fsync
|
|
7
|
+
* 之后才删除(AC#11)。步骤顺序固定为 prepare → write* → open → query* → persist → commit:
|
|
8
|
+
* 乱序请求报 `protocol_violation` 且不改变恢复状态,其余任何失败之后只接受 `abort`。
|
|
9
|
+
*
|
|
10
|
+
* @module pglite-host/pglite-host-restore
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
RxDBAdapterDesktopError,
|
|
15
|
+
type DesktopPgliteDataDirEntry,
|
|
16
|
+
type DesktopPgliteDataDirItem,
|
|
17
|
+
type DesktopPgliteQueryResult,
|
|
18
|
+
type DesktopPgliteResponse,
|
|
19
|
+
type DesktopPgliteRestoreQueryRequest,
|
|
20
|
+
type DesktopPgliteRestoreStepRequest,
|
|
21
|
+
type DesktopPgliteRestoreTargetRequest,
|
|
22
|
+
type DesktopPgliteRestoreWriteRequest
|
|
23
|
+
} from '@aiao/rxdb-adapter-sqlite-core/desktop-host';
|
|
24
|
+
import { randomUUID } from 'node:crypto';
|
|
25
|
+
import { mkdir, open, type FileHandle } from 'node:fs/promises';
|
|
26
|
+
import { join } from 'node:path';
|
|
27
|
+
import {
|
|
28
|
+
errnoOf,
|
|
29
|
+
hasPgVersion,
|
|
30
|
+
isEmptyOrMissing,
|
|
31
|
+
removePgliteDataDirectory,
|
|
32
|
+
syncPgliteDataDirectory,
|
|
33
|
+
toHostIoError
|
|
34
|
+
} from './pglite-host-data-dir.js';
|
|
35
|
+
import { acquirePgliteDirectoryLock, type PgliteDirectoryLock } from './pglite-host-lock.js';
|
|
36
|
+
import { runStatement, toWireResult, type ElectronPgliteRuntime } from './pglite-host-runtime.js';
|
|
37
|
+
|
|
38
|
+
/** 恢复协调器处理的全部请求。 */
|
|
39
|
+
export type DesktopPgliteRestoreRequest =
|
|
40
|
+
| DesktopPgliteRestoreTargetRequest
|
|
41
|
+
| DesktopPgliteRestoreStepRequest
|
|
42
|
+
| DesktopPgliteRestoreWriteRequest
|
|
43
|
+
| DesktopPgliteRestoreQueryRequest;
|
|
44
|
+
|
|
45
|
+
/** 作用于一次已开始的恢复的请求。 */
|
|
46
|
+
type RestoreStepRequest = Exclude<DesktopPgliteRestoreRequest, DesktopPgliteRestoreTargetRequest>;
|
|
47
|
+
|
|
48
|
+
/** {@link createPgliteRestoreCoordinator} 的依赖。 */
|
|
49
|
+
export interface PgliteRestoreCoordinatorOptions {
|
|
50
|
+
/** 起一个运行时;恢复校验经它打开目标目录,与普通连接用同一份 PGlite 与扩展。 */
|
|
51
|
+
readonly createRuntime: (dataDirectoryName: string) => Promise<ElectronPgliteRuntime>;
|
|
52
|
+
/** 逻辑名到物理路径;必须就是 `createRuntime` 打开的目录。 */
|
|
53
|
+
readonly resolveDataDirectory: (dataDirectoryName: string) => string;
|
|
54
|
+
/** 本 host 是否正连着该目录;正在关闭的实例要等它关完再回答。 */
|
|
55
|
+
readonly isInUse: (dataDirectoryName: string) => Promise<boolean>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** 一个 host 内全部进行中的恢复。 */
|
|
59
|
+
export interface PgliteRestoreCoordinator {
|
|
60
|
+
/** 进行中的恢复数。 */
|
|
61
|
+
readonly openCount: number;
|
|
62
|
+
/** 该名字是否正被本 host 的恢复占着(含正在取锁的);`pg.open` 据此报 `restore_in_progress`。 */
|
|
63
|
+
isReserved(dataDirectoryName: string): boolean;
|
|
64
|
+
/**
|
|
65
|
+
* 处理一条恢复请求。
|
|
66
|
+
*
|
|
67
|
+
* @throws {@link RxDBAdapterDesktopError} 失败以桌面错误抛出,由 host 统一转成错误应答
|
|
68
|
+
*/
|
|
69
|
+
handle(request: DesktopPgliteRestoreRequest, ownerId: number): Promise<DesktopPgliteResponse>;
|
|
70
|
+
/** 打断某个窗口名下的全部恢复:关资源、交还独占,**保留**恢复标记(AC#11)。 */
|
|
71
|
+
releaseOwner(ownerId: number): Promise<void>;
|
|
72
|
+
/** 打断全部恢复,语义同 {@link PgliteRestoreCoordinator.releaseOwner}。 */
|
|
73
|
+
closeAll(): Promise<void>;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
type RestoreStage = 'begun' | 'prepared' | 'opened' | 'persisted' | 'failed';
|
|
77
|
+
|
|
78
|
+
/** 正在写入的文件。 */
|
|
79
|
+
interface OpenFile {
|
|
80
|
+
readonly handle: FileHandle;
|
|
81
|
+
readonly path: string;
|
|
82
|
+
readonly size: number;
|
|
83
|
+
written: number;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
interface RestoreEntry {
|
|
87
|
+
readonly id: string;
|
|
88
|
+
readonly owner: number;
|
|
89
|
+
readonly dataDirectoryName: string;
|
|
90
|
+
readonly directory: string;
|
|
91
|
+
readonly lock: PgliteDirectoryLock;
|
|
92
|
+
stage: RestoreStage;
|
|
93
|
+
/** 恢复标记已落盘:此后中止必须删目录、清标记。 */
|
|
94
|
+
marked: boolean;
|
|
95
|
+
/** 条目声明的大小还没写满的文件。 */
|
|
96
|
+
file: OpenFile | undefined;
|
|
97
|
+
/** 校验用的私有实例;只在 `opened` 阶段存在。 */
|
|
98
|
+
runtime: ElectronPgliteRuntime | undefined;
|
|
99
|
+
/** 同一次恢复上的请求依次执行:上一步落地之前,下一步不会开始。 */
|
|
100
|
+
tail: Promise<void>;
|
|
101
|
+
/** 已提交、已中止或已被打断;排在后面的请求一律按恢复不存在处理。 */
|
|
102
|
+
finished: boolean;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* 这几个 errno 说明归档描述的不是一棵合法的目录树:重复条目(`EEXIST`)、父目录不在归档里
|
|
107
|
+
* (`ENOENT`)、文件占了目录的位置(`ENOTDIR`)。目标在 begin 时是空的且一直被独占,
|
|
108
|
+
* 不存在别的来源。
|
|
109
|
+
*/
|
|
110
|
+
const CORRUPTING_ERRNOS: ReadonlySet<string> = new Set(['EEXIST', 'ENOENT', 'ENOTDIR']);
|
|
111
|
+
|
|
112
|
+
const corrupted = (detail: string, options?: ErrorOptions): RxDBAdapterDesktopError =>
|
|
113
|
+
new RxDBAdapterDesktopError('database_corrupted', detail, options);
|
|
114
|
+
|
|
115
|
+
const toWriteError = (error: unknown): RxDBAdapterDesktopError => {
|
|
116
|
+
if (error instanceof RxDBAdapterDesktopError) return error;
|
|
117
|
+
if (!CORRUPTING_ERRNOS.has(errnoOf(error) ?? '')) {
|
|
118
|
+
return toHostIoError(error, 'failed to write the restored PGlite data directory');
|
|
119
|
+
}
|
|
120
|
+
return corrupted('the archive does not describe a valid directory tree', { cause: error });
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const outOfOrder = (entry: RestoreEntry, kind: RestoreStepRequest['kind']): RxDBAdapterDesktopError =>
|
|
124
|
+
new RxDBAdapterDesktopError('protocol_violation', `${kind} is not allowed while the restore is ${entry.stage}`);
|
|
125
|
+
|
|
126
|
+
const expectStage = (entry: RestoreEntry, stage: RestoreStage, kind: RestoreStepRequest['kind']): void => {
|
|
127
|
+
if (entry.stage !== stage) throw outOfOrder(entry, kind);
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const writeData = async (entry: RestoreEntry, bytes: Uint8Array): Promise<void> => {
|
|
131
|
+
const file = entry.file;
|
|
132
|
+
if (file === undefined) throw corrupted('a data chunk arrived without a file entry');
|
|
133
|
+
if (file.written + bytes.byteLength > file.size) {
|
|
134
|
+
throw corrupted(`the data of "${file.path}" exceeds its declared size`);
|
|
135
|
+
}
|
|
136
|
+
// `writeFile` 在当前位置写并自己处理短写;文件以 `wx` 打开后只经这里顺序写入。
|
|
137
|
+
await file.handle.writeFile(bytes);
|
|
138
|
+
file.written += bytes.byteLength;
|
|
139
|
+
if (file.written < file.size) return;
|
|
140
|
+
entry.file = undefined;
|
|
141
|
+
await file.handle.close();
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const writeEntry = async (entry: RestoreEntry, header: DesktopPgliteDataDirEntry): Promise<void> => {
|
|
145
|
+
if (entry.file !== undefined) throw corrupted(`the data of "${entry.file.path}" is incomplete`);
|
|
146
|
+
const target = join(entry.directory, ...header.path.split('/'));
|
|
147
|
+
// 不递归、不覆盖:父目录必须先出现在归档里,同一路径只能出现一次。
|
|
148
|
+
if (header.kind === 'directory') {
|
|
149
|
+
await mkdir(target, { mode: 0o700 });
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
const handle = await open(target, 'wx', 0o600);
|
|
153
|
+
if (header.size === 0) {
|
|
154
|
+
await handle.close();
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
entry.file = { handle, path: header.path, size: header.size, written: 0 };
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* 创建一个 host 的恢复协调器。
|
|
162
|
+
*
|
|
163
|
+
* @param options - 运行时工厂、目录解析与本 host 的占用查询
|
|
164
|
+
* @returns 恢复协调器
|
|
165
|
+
*/
|
|
166
|
+
export const createPgliteRestoreCoordinator = (options: PgliteRestoreCoordinatorOptions): PgliteRestoreCoordinator => {
|
|
167
|
+
/** 被恢复占着的名字:从取锁之前一直到提交或中止。查与占之间没有 await,两次并发的 begin 只有一个能过。 */
|
|
168
|
+
const reserved = new Set<string>();
|
|
169
|
+
const entries = new Map<string, RestoreEntry>();
|
|
170
|
+
|
|
171
|
+
const claimTarget = async (name: string, directory: string): Promise<PgliteDirectoryLock> => {
|
|
172
|
+
if ((await options.isInUse(name)) || reserved.has(name)) {
|
|
173
|
+
throw new RxDBAdapterDesktopError('database_busy', `"${name}" is open or being restored in this host`);
|
|
174
|
+
}
|
|
175
|
+
reserved.add(name);
|
|
176
|
+
try {
|
|
177
|
+
return acquirePgliteDirectoryLock(directory);
|
|
178
|
+
} catch (error) {
|
|
179
|
+
reserved.delete(name);
|
|
180
|
+
throw error;
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const releaseTarget = (name: string, lock: PgliteDirectoryLock): void => {
|
|
185
|
+
reserved.delete(name);
|
|
186
|
+
lock.release();
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
/** 删掉写了一半的目录再清标记;删不掉时标记留着,之后的连接据此被拒绝,而不是连上一个半恢复的库。 */
|
|
190
|
+
const discardTarget = async (name: string, directory: string, lock: PgliteDirectoryLock): Promise<void> => {
|
|
191
|
+
try {
|
|
192
|
+
await removePgliteDataDirectory(directory);
|
|
193
|
+
lock.clearRestoring();
|
|
194
|
+
} catch (error) {
|
|
195
|
+
throw new RxDBAdapterDesktopError(
|
|
196
|
+
'cleanup_pending',
|
|
197
|
+
`the incomplete restore of "${name}" could not be removed; run pg.restore.cleanup to retry`,
|
|
198
|
+
{ cause: error }
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
const begin = async (request: DesktopPgliteRestoreTargetRequest, ownerId: number): Promise<DesktopPgliteResponse> => {
|
|
204
|
+
const name = request.storage.dataDirectoryName;
|
|
205
|
+
const directory = options.resolveDataDirectory(name);
|
|
206
|
+
const lock = await claimTarget(name, directory);
|
|
207
|
+
try {
|
|
208
|
+
if (lock.hasMarker()) {
|
|
209
|
+
throw new RxDBAdapterDesktopError(
|
|
210
|
+
'restore_incomplete',
|
|
211
|
+
`a restore of "${name}" did not finish; run pg.restore.cleanup first`
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
if (!(await isEmptyOrMissing(directory))) {
|
|
215
|
+
throw new RxDBAdapterDesktopError('target_not_empty', `"${name}" already holds data`);
|
|
216
|
+
}
|
|
217
|
+
} catch (error) {
|
|
218
|
+
releaseTarget(name, lock);
|
|
219
|
+
throw error;
|
|
220
|
+
}
|
|
221
|
+
const id = randomUUID();
|
|
222
|
+
entries.set(id, {
|
|
223
|
+
id,
|
|
224
|
+
owner: ownerId,
|
|
225
|
+
dataDirectoryName: name,
|
|
226
|
+
directory,
|
|
227
|
+
lock,
|
|
228
|
+
stage: 'begun',
|
|
229
|
+
marked: false,
|
|
230
|
+
file: undefined,
|
|
231
|
+
runtime: undefined,
|
|
232
|
+
tail: Promise.resolve(),
|
|
233
|
+
finished: false
|
|
234
|
+
});
|
|
235
|
+
return { kind: 'pg.restore.begin', result: { restoreId: id } };
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
const cleanup = async (request: DesktopPgliteRestoreTargetRequest): Promise<DesktopPgliteResponse> => {
|
|
239
|
+
const name = request.storage.dataDirectoryName;
|
|
240
|
+
const directory = options.resolveDataDirectory(name);
|
|
241
|
+
const lock = await claimTarget(name, directory);
|
|
242
|
+
try {
|
|
243
|
+
if (!lock.hasMarker()) return { kind: 'pg.restore.cleanup', result: { cleaned: false } };
|
|
244
|
+
await discardTarget(name, directory, lock);
|
|
245
|
+
return { kind: 'pg.restore.cleanup', result: { cleaned: true } };
|
|
246
|
+
} finally {
|
|
247
|
+
releaseTarget(name, lock);
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
const requireEntry = (restoreId: string, ownerId: number): RestoreEntry => {
|
|
252
|
+
const entry = entries.get(restoreId);
|
|
253
|
+
if (entry === undefined) {
|
|
254
|
+
throw new RxDBAdapterDesktopError('transaction_not_found', `restore ${restoreId} is unknown or already finished`);
|
|
255
|
+
}
|
|
256
|
+
if (entry.owner !== ownerId) {
|
|
257
|
+
throw new RxDBAdapterDesktopError('permission_denied', `restore ${restoreId} belongs to another window`);
|
|
258
|
+
}
|
|
259
|
+
return entry;
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
const runStep = <T>(entry: RestoreEntry, run: () => Promise<T>): Promise<T> => {
|
|
263
|
+
const result = entry.tail.then(() => {
|
|
264
|
+
if (entry.finished) {
|
|
265
|
+
throw new RxDBAdapterDesktopError('transaction_not_found', `restore ${entry.id} is already finished`);
|
|
266
|
+
}
|
|
267
|
+
return run();
|
|
268
|
+
});
|
|
269
|
+
entry.tail = result.then(
|
|
270
|
+
() => undefined,
|
|
271
|
+
() => undefined
|
|
272
|
+
);
|
|
273
|
+
return result;
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
/** 关掉本次恢复持有的文件与私有实例。尽力而为:调用方随后要么删目录,要么留着标记。 */
|
|
277
|
+
const releaseResources = async (entry: RestoreEntry): Promise<void> => {
|
|
278
|
+
const { file, runtime } = entry;
|
|
279
|
+
entry.file = undefined;
|
|
280
|
+
entry.runtime = undefined;
|
|
281
|
+
await file?.handle.close().catch(() => undefined);
|
|
282
|
+
await runtime?.close().catch(() => undefined);
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
const poisonOnFailure = async (entry: RestoreEntry, run: () => Promise<void>): Promise<void> => {
|
|
286
|
+
try {
|
|
287
|
+
await run();
|
|
288
|
+
} catch (error) {
|
|
289
|
+
if (!(error instanceof RxDBAdapterDesktopError && error.code === 'protocol_violation')) {
|
|
290
|
+
entry.stage = 'failed';
|
|
291
|
+
await releaseResources(entry);
|
|
292
|
+
}
|
|
293
|
+
throw error;
|
|
294
|
+
}
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
const finish = (entry: RestoreEntry): void => {
|
|
298
|
+
entry.finished = true;
|
|
299
|
+
entries.delete(entry.id);
|
|
300
|
+
releaseTarget(entry.dataDirectoryName, entry.lock);
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
const prepare = async (entry: RestoreEntry): Promise<void> => {
|
|
304
|
+
expectStage(entry, 'begun', 'pg.restore.prepare');
|
|
305
|
+
entry.lock.markRestoring();
|
|
306
|
+
entry.marked = true;
|
|
307
|
+
try {
|
|
308
|
+
await mkdir(entry.directory, { recursive: true, mode: 0o700 });
|
|
309
|
+
} catch (error) {
|
|
310
|
+
throw toHostIoError(error, 'failed to create the restore target');
|
|
311
|
+
}
|
|
312
|
+
entry.stage = 'prepared';
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
const writeStep = async (entry: RestoreEntry, item: DesktopPgliteDataDirItem): Promise<void> => {
|
|
316
|
+
expectStage(entry, 'prepared', 'pg.restore.write');
|
|
317
|
+
try {
|
|
318
|
+
await (item.type === 'data' ? writeData(entry, item.bytes) : writeEntry(entry, item.header));
|
|
319
|
+
} catch (error) {
|
|
320
|
+
throw toWriteError(error);
|
|
321
|
+
}
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* 起恢复专用的私有实例,并确认 PostgreSQL 真的起来了。
|
|
326
|
+
*
|
|
327
|
+
* @remarks
|
|
328
|
+
* 归档完整、PostgreSQL 却起不来,说明数据目录本身不可用:与浏览器端一致归为损坏,而不是 host 故障。
|
|
329
|
+
* 实例不登记会话,其他窗口与进程连不上它。
|
|
330
|
+
*/
|
|
331
|
+
const startPrivateRuntime = async (name: string): Promise<ElectronPgliteRuntime> => {
|
|
332
|
+
let runtime: ElectronPgliteRuntime | undefined;
|
|
333
|
+
try {
|
|
334
|
+
runtime = await options.createRuntime(name);
|
|
335
|
+
await runtime.query('SELECT 1');
|
|
336
|
+
return runtime;
|
|
337
|
+
} catch (error) {
|
|
338
|
+
await runtime?.close().catch(() => undefined);
|
|
339
|
+
throw corrupted('the restored data directory cannot be opened', { cause: error });
|
|
340
|
+
}
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
const openTarget = async (entry: RestoreEntry): Promise<void> => {
|
|
344
|
+
expectStage(entry, 'prepared', 'pg.restore.open');
|
|
345
|
+
if (entry.file !== undefined) throw corrupted(`the data of "${entry.file.path}" is incomplete`);
|
|
346
|
+
// 没有 PG_VERSION 的目录会被 PGlite 就地 initdb 成一个空库,必须在起实例之前挡住。
|
|
347
|
+
if (!(await hasPgVersion(entry.directory))) throw corrupted('the archive has no PG_VERSION');
|
|
348
|
+
entry.runtime = await startPrivateRuntime(entry.dataDirectoryName);
|
|
349
|
+
entry.stage = 'opened';
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
const openedRuntime = (entry: RestoreEntry, kind: RestoreStepRequest['kind']): ElectronPgliteRuntime => {
|
|
353
|
+
const runtime = entry.runtime;
|
|
354
|
+
if (runtime === undefined) throw outOfOrder(entry, kind);
|
|
355
|
+
return runtime;
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
/** 校验查询失败只说明这条语句不成立,不作废恢复:是否中止由 renderer 的校验结论决定。 */
|
|
359
|
+
const query = async (
|
|
360
|
+
entry: RestoreEntry,
|
|
361
|
+
request: DesktopPgliteRestoreQueryRequest
|
|
362
|
+
): Promise<DesktopPgliteQueryResult> => {
|
|
363
|
+
const runtime = openedRuntime(entry, request.kind);
|
|
364
|
+
return toWireResult(await runStatement(() => runtime.query(request.sql, [...request.params])));
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
const persist = async (entry: RestoreEntry): Promise<void> => {
|
|
368
|
+
const runtime = openedRuntime(entry, 'pg.restore.persist');
|
|
369
|
+
entry.runtime = undefined;
|
|
370
|
+
try {
|
|
371
|
+
await runtime.close();
|
|
372
|
+
} catch (error) {
|
|
373
|
+
throw toHostIoError(error, 'failed to close the restored PGlite instance');
|
|
374
|
+
}
|
|
375
|
+
await syncPgliteDataDirectory(entry.directory);
|
|
376
|
+
entry.stage = 'persisted';
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
const commit = (entry: RestoreEntry): void => {
|
|
380
|
+
expectStage(entry, 'persisted', 'pg.restore.commit');
|
|
381
|
+
entry.lock.clearRestoring();
|
|
382
|
+
finish(entry);
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
const abort = async (entry: RestoreEntry): Promise<void> => {
|
|
386
|
+
try {
|
|
387
|
+
await releaseResources(entry);
|
|
388
|
+
if (entry.marked) await discardTarget(entry.dataDirectoryName, entry.directory, entry.lock);
|
|
389
|
+
} finally {
|
|
390
|
+
finish(entry);
|
|
391
|
+
}
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
/** 窗口断开或 host 关闭:关资源、交还独占,**不**删目录也不清标记——重新打开时据标记拒绝连接(AC#11)。 */
|
|
395
|
+
const interrupt = (entry: RestoreEntry): Promise<void> =>
|
|
396
|
+
runStep(entry, async () => {
|
|
397
|
+
await releaseResources(entry);
|
|
398
|
+
finish(entry);
|
|
399
|
+
}).catch(() => undefined);
|
|
400
|
+
|
|
401
|
+
const runRequest = async (entry: RestoreEntry, request: RestoreStepRequest): Promise<DesktopPgliteResponse> => {
|
|
402
|
+
switch (request.kind) {
|
|
403
|
+
case 'pg.restore.prepare':
|
|
404
|
+
await poisonOnFailure(entry, () => prepare(entry));
|
|
405
|
+
return { kind: 'pg.restore.prepare' };
|
|
406
|
+
case 'pg.restore.write':
|
|
407
|
+
await poisonOnFailure(entry, () => writeStep(entry, request.item));
|
|
408
|
+
return { kind: 'pg.restore.write' };
|
|
409
|
+
case 'pg.restore.open':
|
|
410
|
+
await poisonOnFailure(entry, () => openTarget(entry));
|
|
411
|
+
return { kind: 'pg.restore.open' };
|
|
412
|
+
case 'pg.restore.query':
|
|
413
|
+
return { kind: 'pg.restore.query', result: await query(entry, request) };
|
|
414
|
+
case 'pg.restore.persist':
|
|
415
|
+
await poisonOnFailure(entry, () => persist(entry));
|
|
416
|
+
return { kind: 'pg.restore.persist' };
|
|
417
|
+
case 'pg.restore.commit':
|
|
418
|
+
await poisonOnFailure(entry, async () => commit(entry));
|
|
419
|
+
return { kind: 'pg.restore.commit' };
|
|
420
|
+
case 'pg.restore.abort':
|
|
421
|
+
await abort(entry);
|
|
422
|
+
return { kind: 'pg.restore.abort' };
|
|
423
|
+
default: {
|
|
424
|
+
const _exhaustive: never = request;
|
|
425
|
+
throw new RxDBAdapterDesktopError(
|
|
426
|
+
'protocol_violation',
|
|
427
|
+
`unsupported restore request kind: ${String((_exhaustive as { kind?: unknown }).kind)}`
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
const step = async (request: RestoreStepRequest, ownerId: number): Promise<DesktopPgliteResponse> => {
|
|
434
|
+
const entry = requireEntry(request.restoreId, ownerId);
|
|
435
|
+
return runStep(entry, () => runRequest(entry, request));
|
|
436
|
+
};
|
|
437
|
+
|
|
438
|
+
return {
|
|
439
|
+
get openCount(): number {
|
|
440
|
+
return entries.size;
|
|
441
|
+
},
|
|
442
|
+
isReserved: name => reserved.has(name),
|
|
443
|
+
handle: async (request, ownerId) => {
|
|
444
|
+
switch (request.kind) {
|
|
445
|
+
case 'pg.restore.begin':
|
|
446
|
+
return begin(request, ownerId);
|
|
447
|
+
case 'pg.restore.cleanup':
|
|
448
|
+
return cleanup(request);
|
|
449
|
+
default:
|
|
450
|
+
return step(request, ownerId);
|
|
451
|
+
}
|
|
452
|
+
},
|
|
453
|
+
releaseOwner: async ownerId => {
|
|
454
|
+
await Promise.all([...entries.values()].filter(entry => entry.owner === ownerId).map(interrupt));
|
|
455
|
+
},
|
|
456
|
+
closeAll: async () => {
|
|
457
|
+
await Promise.all([...entries.values()].map(interrupt));
|
|
458
|
+
}
|
|
459
|
+
};
|
|
460
|
+
};
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 桌面 PGlite host 与宿主应用之间的运行时契约,以及 host 各处共用的语句与事务工具。
|
|
3
|
+
*
|
|
4
|
+
* @module pglite-host/pglite-host-runtime
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { RxDBAdapterDesktopError, type DesktopPgliteQueryResult } from '@aiao/rxdb-adapter-sqlite-core/desktop-host';
|
|
8
|
+
|
|
9
|
+
/** 一条语句的结果;`ElectronPgliteRuntime` 与 PGlite 的 `Results` 在此结构相容。 */
|
|
10
|
+
export interface ElectronPgliteRuntimeResult {
|
|
11
|
+
readonly rows: Record<string, unknown>[];
|
|
12
|
+
readonly fields: { name: string; dataTypeID: number }[];
|
|
13
|
+
readonly affectedRows?: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** 事务句柄;对应 PGlite 的 `Transaction`,只声明 host 真正用到的两个操作。 */
|
|
17
|
+
export interface ElectronPgliteTransaction {
|
|
18
|
+
query(sql: string, params?: unknown[]): Promise<ElectronPgliteRuntimeResult>;
|
|
19
|
+
exec(sql: string): Promise<ElectronPgliteRuntimeResult[]>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* host 需要的 PGlite 能力子集。
|
|
24
|
+
*
|
|
25
|
+
* @remarks
|
|
26
|
+
* 刻意不写成 `PGliteInterface`:宿主应用极可能传进来的不是裸 `PGlite`,而是跑在 worker
|
|
27
|
+
* 里的 `PGliteWorker`——PGlite 的 WASM 在主进程 JS 线程上是**同步**执行的,一条重查询会
|
|
28
|
+
* 把整个窗口的 IPC 一起卡住(故事「冻结带来的三条实现约束」第 3 条)。声明成结构类型后,
|
|
29
|
+
* 两者都能直接传入,而这份清单也顺便说清了 host 只需要这五个操作。
|
|
30
|
+
*/
|
|
31
|
+
export interface ElectronPgliteRuntime {
|
|
32
|
+
query(sql: string, params?: unknown[]): Promise<ElectronPgliteRuntimeResult>;
|
|
33
|
+
exec(sql: string): Promise<ElectronPgliteRuntimeResult[]>;
|
|
34
|
+
transaction<T>(callback: (tx: ElectronPgliteTransaction) => Promise<T>): Promise<T>;
|
|
35
|
+
listen(channel: string, callback: (payload: string) => void): Promise<unknown>;
|
|
36
|
+
close(): Promise<void>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 回答 `pg.engine` 的探针运行时:只需要查询与关闭(US-217)。
|
|
41
|
+
*
|
|
42
|
+
* @remarks
|
|
43
|
+
* 裸 `new PGlite()`(内存实例)即满足;它必须与 `createRuntime` 起的实例是同一份 PGlite、
|
|
44
|
+
* 带同一组扩展,否则回答的引擎版本对真实数据目录不成立。
|
|
45
|
+
*/
|
|
46
|
+
export type ElectronPgliteProbeRuntime = Pick<ElectronPgliteRuntime, 'query' | 'close'>;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 把 PGlite 的结果收窄成能过结构化克隆的形状。
|
|
50
|
+
*
|
|
51
|
+
* @remarks
|
|
52
|
+
* `fields` 上除了 `name` / `dataTypeID` 还挂着 PGlite 自己的解析器,整个丢给
|
|
53
|
+
* `ipcRenderer.invoke` 会以 DataCloneError 失败——而报错点在 IPC 层,看上去与 SQL 毫无关系。
|
|
54
|
+
* `rows` 里的 bigint / Uint8Array / Date 都是结构化克隆原生支持的,原样带走(AC#1)。
|
|
55
|
+
*
|
|
56
|
+
* @param result - 运行时返回的结果
|
|
57
|
+
* @returns 可以跨进程传递的结果
|
|
58
|
+
*/
|
|
59
|
+
export const toWireResult = (result: ElectronPgliteRuntimeResult): DesktopPgliteQueryResult => ({
|
|
60
|
+
rows: result.rows,
|
|
61
|
+
fields: result.fields.map(field => ({ name: field.name, dataTypeID: field.dataTypeID })),
|
|
62
|
+
affectedRows: result.affectedRows
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* 跑一条语句,把引擎抛出的错误归一成 `statement_failed`。
|
|
67
|
+
*
|
|
68
|
+
* @remarks
|
|
69
|
+
* 不归一的话,一条写错的 SQL 会以 `host_internal_error` 回到 renderer——那个码的含义是
|
|
70
|
+
* 「host 有缺陷」,会把调用方的排查引向完全错误的方向。
|
|
71
|
+
*
|
|
72
|
+
* @param run - 执行语句
|
|
73
|
+
* @returns 语句结果
|
|
74
|
+
* @throws {@link RxDBAdapterDesktopError} 语句失败时为 `statement_failed`;已经是桌面错误的原样透传
|
|
75
|
+
*/
|
|
76
|
+
export const runStatement = async <T>(run: () => Promise<T>): Promise<T> => {
|
|
77
|
+
try {
|
|
78
|
+
return await run();
|
|
79
|
+
} catch (error) {
|
|
80
|
+
if (error instanceof RxDBAdapterDesktopError) throw error;
|
|
81
|
+
throw new RxDBAdapterDesktopError('statement_failed', error instanceof Error ? error.message : String(error), {
|
|
82
|
+
cause: error
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/** 一条挂起中的事务:`transaction(cb)` 的 callback 停在 host 持有的 promise 上。 */
|
|
88
|
+
export interface SuspendedTransaction {
|
|
89
|
+
readonly tx: ElectronPgliteTransaction;
|
|
90
|
+
/** 结掉 callback:`resolve` 提交,`reject` 让 callback 抛出从而回滚。 */
|
|
91
|
+
readonly settle: { readonly resolve: () => void; readonly reject: (error: Error) => void };
|
|
92
|
+
/** `transaction(...)` 本身;等它落地才算真的提交/回滚完。 */
|
|
93
|
+
readonly finished: Promise<unknown>;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** 等连接等到超时的哨兵;用 Symbol 是因为任何合法的 `tx` 都不可能与它相等。 */
|
|
97
|
+
const TIMED_OUT = Symbol('transaction begin timed out');
|
|
98
|
+
|
|
99
|
+
const raceTimeout = async <T>(promise: Promise<T>, ms: number): Promise<T | typeof TIMED_OUT> => {
|
|
100
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
101
|
+
try {
|
|
102
|
+
return await Promise.race([
|
|
103
|
+
promise,
|
|
104
|
+
new Promise<typeof TIMED_OUT>(resolve => {
|
|
105
|
+
timer = setTimeout(() => resolve(TIMED_OUT), ms);
|
|
106
|
+
})
|
|
107
|
+
]);
|
|
108
|
+
} finally {
|
|
109
|
+
// 不清掉的话,一次成功的 begin 也会让 Node 的事件循环多活 `ms` 毫秒,
|
|
110
|
+
// 于是应用退出被推迟——在测试里表现为「跑完了但进程不退」。
|
|
111
|
+
clearTimeout(timer);
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* 在运行时上开一条事务并把它挂起,直到调用方结掉 `settle`。
|
|
117
|
+
*
|
|
118
|
+
* @remarks
|
|
119
|
+
* PGlite 只提供 callback 形态的事务(`transaction(cb)`,返回即 COMMIT、抛出即 ROLLBACK),
|
|
120
|
+
* 而 `cb` 跨不了 IPC。这里进入 `cb` 后立刻 `await` 一个由 host 持有的 promise,事务因此在
|
|
121
|
+
* PostgreSQL 侧一直开着、连接一直被占住。`pg.begin` 用它把多次 IPC 串成一条真事务,
|
|
122
|
+
* `pg.backup.begin` 用它在读快照期间挡住一切写入(AC#16)。
|
|
123
|
+
*
|
|
124
|
+
* @param runtime - 运行时
|
|
125
|
+
* @param timeout - 等待连接空闲的上限(毫秒)
|
|
126
|
+
* @returns 已挂起的事务
|
|
127
|
+
* @throws {@link RxDBAdapterDesktopError} `transaction_unavailable`:到期时连接仍被别的事务占着
|
|
128
|
+
*/
|
|
129
|
+
export const suspendTransaction = async (
|
|
130
|
+
runtime: ElectronPgliteRuntime,
|
|
131
|
+
timeout: number
|
|
132
|
+
): Promise<SuspendedTransaction> => {
|
|
133
|
+
let settle!: SuspendedTransaction['settle'];
|
|
134
|
+
const closed = new Promise<void>((resolve, reject) => {
|
|
135
|
+
settle = { resolve, reject };
|
|
136
|
+
});
|
|
137
|
+
// 超时路径会在任何人 await 之前就 reject 它;先接住,免得变成未处理拒绝而打死进程。
|
|
138
|
+
closed.catch(() => undefined);
|
|
139
|
+
|
|
140
|
+
let markStarted!: (tx: ElectronPgliteTransaction) => void;
|
|
141
|
+
const started = new Promise<ElectronPgliteTransaction>(resolve => {
|
|
142
|
+
markStarted = resolve;
|
|
143
|
+
});
|
|
144
|
+
const finished = runtime.transaction(async tx => {
|
|
145
|
+
markStarted(tx);
|
|
146
|
+
// 事务从这里一直开着,直到调用方结掉 `closed`。
|
|
147
|
+
await closed;
|
|
148
|
+
});
|
|
149
|
+
finished.catch(() => undefined);
|
|
150
|
+
|
|
151
|
+
const tx = await raceTimeout(started, timeout);
|
|
152
|
+
if (tx === TIMED_OUT) {
|
|
153
|
+
// 关键在于 reject 而不是简单丢弃:被丢弃的 callback 迟早会在连接空出来的一瞬间
|
|
154
|
+
// 启动,然后永远挂在 `await closed` 上——于是「超时之后再也开不了事务」,
|
|
155
|
+
// 而现场看起来只是「数据库不响应」。reject 让它一进 callback 就抛,
|
|
156
|
+
// PGlite 随即回滚这条空事务并交还连接。
|
|
157
|
+
settle.reject(
|
|
158
|
+
new RxDBAdapterDesktopError('transaction_unavailable', 'the begin that opened this transaction timed out')
|
|
159
|
+
);
|
|
160
|
+
throw new RxDBAdapterDesktopError(
|
|
161
|
+
'transaction_unavailable',
|
|
162
|
+
`waited ${timeout}ms for the PGlite connection but another transaction still holds it`
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
return { tx, settle, finished };
|
|
166
|
+
};
|
package/src/pglite-host.ts
CHANGED
|
@@ -16,8 +16,10 @@
|
|
|
16
16
|
export {
|
|
17
17
|
DESKTOP_PGLITE_WATCH_CHANNELS,
|
|
18
18
|
createElectronPgliteHost,
|
|
19
|
+
type ElectronPgliteBackupOptions,
|
|
19
20
|
type ElectronPgliteHost,
|
|
20
21
|
type ElectronPgliteHostOptions,
|
|
22
|
+
type ElectronPgliteProbeRuntime,
|
|
21
23
|
type ElectronPgliteRuntime,
|
|
22
24
|
type ElectronPgliteRuntimeResult,
|
|
23
25
|
type ElectronPgliteTransaction
|
|
@@ -27,7 +29,9 @@ export {
|
|
|
27
29
|
// 就该拿全写一个 PGlite host 需要的全部东西。
|
|
28
30
|
export {
|
|
29
31
|
DESKTOP_PGLITE_DEFAULT_BEGIN_TIMEOUT_MS,
|
|
32
|
+
DESKTOP_PGLITE_EXCLUDED_FILES,
|
|
30
33
|
DESKTOP_PGLITE_MAX_BEGIN_TIMEOUT_MS,
|
|
34
|
+
DESKTOP_PGLITE_MAX_DATA_CHUNK_BYTES,
|
|
31
35
|
DESKTOP_PGLITE_PROTOCOL_VERSION,
|
|
32
36
|
RxDBAdapterDesktopError,
|
|
33
37
|
// 宿主自己拼数据目录路径时要用:协议校验管的是「请求合法」,落盘前那次校验管的是
|
|
@@ -35,6 +39,9 @@ export {
|
|
|
35
39
|
assertValidDesktopDatabaseName,
|
|
36
40
|
isDesktopPgliteRequestKind,
|
|
37
41
|
parseDesktopPgliteRequest,
|
|
42
|
+
type DesktopPgliteBackupItem,
|
|
43
|
+
type DesktopPgliteDataDirItem,
|
|
44
|
+
type DesktopPgliteEngineResult,
|
|
38
45
|
type DesktopPgliteNotifyMessage,
|
|
39
46
|
type DesktopPgliteQueryResult,
|
|
40
47
|
type DesktopPgliteRequest,
|
package/src/pglite.ts
CHANGED
|
@@ -24,6 +24,15 @@ export { RxDBAdapterElectronPGlite } from './pglite/RxDBAdapterElectronPGlite.js
|
|
|
24
24
|
|
|
25
25
|
export { DesktopPGliteClient, type DesktopPGliteClientOptions } from './pglite/desktop-pglite-client.js';
|
|
26
26
|
|
|
27
|
+
// 备份在 adapter 上;恢复的目标还没连接、没有 adapter 可调,只能是自由函数。
|
|
28
|
+
// 选项与阶段沿用浏览器 PGlite 的类型,一并转出,调用方不必为签名再去 import 那个 peer。
|
|
29
|
+
export type { PGliteRestoreOptions, PGliteRestoreStage } from '@aiao/rxdb-adapter-pglite';
|
|
30
|
+
export {
|
|
31
|
+
cleanupIncompleteElectronPGliteRestore,
|
|
32
|
+
restoreElectronPGliteDatabase,
|
|
33
|
+
type ElectronPGliteRestoreTarget
|
|
34
|
+
} from './pglite/restore-electron-pglite-database.js';
|
|
35
|
+
|
|
27
36
|
// 协议常量与错误类型原样转出,理由与默认入口一致:下游按名字 import,
|
|
28
37
|
// 实现搬到哪个共享包不该让用户改行。
|
|
29
38
|
export {
|