@aiao/rxdb-adapter-electron 0.0.0-stage → 0.0.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +293 -2
  3. package/dist/RxDBAdapterElectron.d.ts +35 -0
  4. package/dist/RxDBAdapterElectron.d.ts.map +1 -0
  5. package/dist/electron-adapter.interface.d.ts +23 -0
  6. package/dist/electron-adapter.interface.d.ts.map +1 -0
  7. package/dist/electron-file-host.d.ts +52 -0
  8. package/dist/electron-file-host.d.ts.map +1 -0
  9. package/dist/electron-sqlite-host.d.ts +65 -0
  10. package/dist/electron-sqlite-host.d.ts.map +1 -0
  11. package/dist/host.d.ts +19 -0
  12. package/dist/host.d.ts.map +1 -0
  13. package/dist/host.js +762 -0
  14. package/dist/index.d.ts +22 -0
  15. package/dist/index.d.ts.map +1 -0
  16. package/dist/index.js +22 -0
  17. package/dist/node-sqlite-engine.d.ts +87 -0
  18. package/dist/node-sqlite-engine.d.ts.map +1 -0
  19. package/dist/pglite/RxDBAdapterElectronPGlite.d.ts +44 -0
  20. package/dist/pglite/RxDBAdapterElectronPGlite.d.ts.map +1 -0
  21. package/dist/pglite/desktop-pglite-client.d.ts +118 -0
  22. package/dist/pglite/desktop-pglite-client.d.ts.map +1 -0
  23. package/dist/pglite/pglite-adapter.interface.d.ts +56 -0
  24. package/dist/pglite/pglite-adapter.interface.d.ts.map +1 -0
  25. package/dist/pglite-host/electron-pglite-host.d.ts +114 -0
  26. package/dist/pglite-host/electron-pglite-host.d.ts.map +1 -0
  27. package/dist/pglite-host.d.ts +17 -0
  28. package/dist/pglite-host.d.ts.map +1 -0
  29. package/dist/pglite-host.js +215 -0
  30. package/dist/pglite.d.ts +20 -0
  31. package/dist/pglite.d.ts.map +1 -0
  32. package/dist/pglite.js +217 -0
  33. package/dist/sqlite-script.d.ts +26 -0
  34. package/dist/sqlite-script.d.ts.map +1 -0
  35. package/package.json +91 -3
  36. package/src/RxDBAdapterElectron.ts +66 -0
  37. package/src/electron-adapter.interface.ts +25 -0
  38. package/src/electron-file-host.ts +758 -0
  39. package/src/electron-sqlite-host.ts +298 -0
  40. package/src/host.ts +63 -0
  41. package/src/index.ts +70 -0
  42. package/src/node-sqlite-engine.ts +612 -0
  43. package/src/pglite/RxDBAdapterElectronPGlite.ts +77 -0
  44. package/src/pglite/desktop-pglite-client.ts +520 -0
  45. package/src/pglite/pglite-adapter.interface.ts +64 -0
  46. package/src/pglite-host/electron-pglite-host.ts +580 -0
  47. package/src/pglite-host.ts +43 -0
  48. package/src/pglite.ts +42 -0
  49. package/src/sqlite-script.ts +134 -0
@@ -0,0 +1,758 @@
1
+ /**
2
+ * 桌面文件 host:把 renderer 送来的 `file.*` 请求落到应用数据目录里的原生文件。
3
+ *
4
+ * @remarks
5
+ * 与 {@link createElectronSqliteHost} 一样运行在**特权侧**,是唯一接触文件系统的地方。
6
+ * 存在的理由见 US-504:文件内容此前写在 WebView 的 OPFS 里,与桌面 SQLite 不在同一个
7
+ * 备份域 —— 拷贝应用数据目录只带走 metadata,恢复后 meta 指向不存在的文件。
8
+ *
9
+ * 三条不变式贯穿本模块:
10
+ * - **原子提交**:写入先落临时文件,`fsync` 后 `rename` 覆盖目标。进程在任何一刻被杀,
11
+ * 目标要么是旧内容要么是新内容,不会是半写。
12
+ * - **会话归属**:未提交的写入与已持有的锁都挂在会话上,窗口销毁即整体回收。
13
+ * - **永不 reject**:失败一律以 `{ kind:'error', code, message }` 返回,见
14
+ * {@link ElectronFileHost.handle}。
15
+ *
16
+ * @module desktop-file-host
17
+ */
18
+
19
+ import {
20
+ DESKTOP_HOST_MAX_PENDING_WRITES_PER_SESSION,
21
+ DESKTOP_HOST_MAX_QUEUED_LOCKS_PER_NAME,
22
+ DESKTOP_HOST_PROTOCOL_VERSION,
23
+ RxDBAdapterDesktopError,
24
+ parseDesktopHostFileRequest,
25
+ type DesktopHostFileEntry,
26
+ type DesktopHostFileLockAcquireRequest,
27
+ type DesktopHostFileLockMode,
28
+ type DesktopHostFileLockReleaseRequest,
29
+ type DesktopHostFilePathRequest,
30
+ type DesktopHostFileReadResult,
31
+ type DesktopHostFileRequest,
32
+ type DesktopHostFileResponse,
33
+ type DesktopHostFileStat,
34
+ type DesktopHostFileWriteBeginRequest,
35
+ type DesktopHostFileWriteChunkRequest,
36
+ type DesktopHostFileWriteFinishRequest,
37
+ type RxDBAdapterDesktopErrorCode
38
+ } from '@aiao/rxdb-adapter-sqlite-core/desktop-host';
39
+ import { randomUUID } from 'node:crypto';
40
+ import { mkdir, open, readdir, realpath, rename, rm, stat, type FileHandle } from 'node:fs/promises';
41
+ import { basename, dirname, join, resolve, sep } from 'node:path';
42
+
43
+ /** 写入相关的请求。 */
44
+ type FileWriteRequest =
45
+ DesktopHostFileWriteBeginRequest | DesktopHostFileWriteChunkRequest | DesktopHostFileWriteFinishRequest;
46
+
47
+ /** 锁相关的请求。 */
48
+ type FileLockRequest = DesktopHostFileLockAcquireRequest | DesktopHostFileLockReleaseRequest;
49
+
50
+ const isWriteRequest = (request: DesktopHostFileRequest): request is FileWriteRequest =>
51
+ request.kind.startsWith('file.write');
52
+
53
+ const isLockRequest = (request: DesktopHostFileRequest): request is FileLockRequest =>
54
+ request.kind.startsWith('file.lock');
55
+
56
+ /** {@link createElectronFileHost} 的入参。 */
57
+ export interface ElectronFileHostOptions {
58
+ /**
59
+ * 解析文件存储根的物理绝对路径。
60
+ *
61
+ * @remarks
62
+ * 由宿主应用提供,因为只有它知道自己的数据目录(Electron 下通常是
63
+ * `app.getPath('userData')` 下的一个子目录)。每次请求都会调用它而不是只调一次:
64
+ * Electron 里 `userData` 在 `app.ready` 之前拿不到,缓存会把「还没准备好」永久固化。
65
+ */
66
+ readonly resolveStorageRoot: () => string;
67
+ }
68
+
69
+ /** 桌面文件 host 实例。 */
70
+ export interface ElectronFileHost {
71
+ /**
72
+ * 处理一条来自 renderer 的文件请求。
73
+ *
74
+ * @remarks
75
+ * **永不 reject**:失败以 `kind: 'error'` 的应答返回。Electron 的 `ipcRenderer.invoke`
76
+ * 在 reject 时会把错误压平成字符串,自定义错误码随之丢失。
77
+ *
78
+ * @param request - 未经校验的请求负载
79
+ * @returns 协议应答
80
+ */
81
+ handle(request: unknown): Promise<DesktopHostFileResponse>;
82
+ /** 当前打开的会话数,用于诊断与关停检查。 */
83
+ readonly openSessionCount: number;
84
+ /**
85
+ * 当前仍被跟踪的锁名个数,用于诊断。
86
+ *
87
+ * @remarks
88
+ * 锁名是逐文件的:稳态下它应当回到 0,长期只增不减就说明队列没被回收。
89
+ */
90
+ readonly trackedLockNameCount: number;
91
+ /**
92
+ * 关闭全部会话,通常在应用退出前调用。
93
+ *
94
+ * @remarks
95
+ * 返回的 Promise 落地才意味着未提交写入的临时文件真的从磁盘上消失了。
96
+ * 调用方(退出钩子)必须等它 —— 不等就等于没清,进程先一步退了。
97
+ */
98
+ closeAll(): Promise<void>;
99
+ }
100
+
101
+ /** 一次尚未提交的写入。 */
102
+ interface PendingWrite {
103
+ /**
104
+ * renderer 送来的存储根内相对路径。
105
+ *
106
+ * @remarks
107
+ * 只用于报错。协议错误里绝不能出现 {@link PendingWrite.targetPath} —— 那是宿主数据目录的
108
+ * 物理绝对路径,回到 renderer 就等于把用户名、安装位置这些与请求无关的信息一并交出去,
109
+ * 而调用方能用来定位问题的本来就只有它自己送上来的那条相对路径。
110
+ */
111
+ readonly logicalPath: string;
112
+ readonly targetPath: string;
113
+ readonly temporaryPath: string;
114
+ readonly handle: FileHandle;
115
+ }
116
+
117
+ /** 一把排队中的锁请求。 */
118
+ interface LockWaiter {
119
+ readonly lockId: string;
120
+ readonly sessionId: string;
121
+ readonly mode: DesktopHostFileLockMode;
122
+ readonly grant: () => void;
123
+ readonly deny: (error: unknown) => void;
124
+ }
125
+
126
+ /** 一个锁名下的持有者与等待队列。 */
127
+ interface LockQueue {
128
+ readonly held: Map<string, DesktopHostFileLockMode>;
129
+ waiting: LockWaiter[];
130
+ }
131
+
132
+ /** 一个会话持有的全部资源。 */
133
+ interface FileSession {
134
+ readonly writes: Map<string, PendingWrite>;
135
+ /**
136
+ * 已占额度、句柄尚未就位的写入。
137
+ *
138
+ * @remarks
139
+ * 存在的唯一理由是让额度检查与占位落在同一段同步代码里,见 `beginWrite`。
140
+ * 这里的条目还没有 fd,因此 `closeSession` 不必扫它。
141
+ */
142
+ readonly reservations: Set<string>;
143
+ readonly lockNames: Map<string, string>;
144
+ /**
145
+ * 会话是否已被回收。
146
+ *
147
+ * @remarks
148
+ * 光靠「还在不在 `sessions` 表里」不够:`beginWrite` 拿着的是 session **对象**,
149
+ * 回收之后它仍能往里写。这个标志让每个异步边界都能问出「我还属于这个 host 吗」。
150
+ */
151
+ closed: boolean;
152
+ }
153
+
154
+ /**
155
+ * errno → 稳定错误码。
156
+ *
157
+ * @remarks
158
+ * 映射发生在 host 侧而不是 renderer 侧:errno 是平台细节,
159
+ * 让它跨过 IPC 只会把平台差异一路带到调用方的 `switch` 里(AC#6)。
160
+ */
161
+ const ERRNO_CODES: Readonly<Record<string, RxDBAdapterDesktopErrorCode>> = {
162
+ ENOENT: 'file_not_found',
163
+ ENOTDIR: 'invalid_file_path',
164
+ EISDIR: 'invalid_file_path',
165
+ ENAMETOOLONG: 'invalid_file_path',
166
+ EEXIST: 'invalid_file_path',
167
+ ELOOP: 'invalid_file_path',
168
+ EACCES: 'permission_denied',
169
+ EPERM: 'permission_denied',
170
+ EROFS: 'permission_denied',
171
+ ENOSPC: 'disk_full',
172
+ EDQUOT: 'disk_full'
173
+ };
174
+
175
+ const readErrno = (error: unknown): string | undefined => {
176
+ if (typeof error !== 'object' || error === null) return undefined;
177
+ const code = (error as { code?: unknown }).code;
178
+ return typeof code === 'string' ? code : undefined;
179
+ };
180
+
181
+ /**
182
+ * 把一次文件系统失败翻译成协议错误。
183
+ *
184
+ * @remarks
185
+ * 认不出的 errno 归到 `host_internal_error` 而不是猜一个近似码:
186
+ * 猜错会让调用方按错误的语义去补偿,比明确的「host 出了意料之外的问题」更糟。
187
+ */
188
+ const toFilesystemError = (error: unknown, path: string): RxDBAdapterDesktopError => {
189
+ if (error instanceof RxDBAdapterDesktopError) return error;
190
+ const errno = readErrno(error);
191
+ const code = errno === undefined ? undefined : ERRNO_CODES[errno];
192
+ return new RxDBAdapterDesktopError(code ?? 'host_internal_error', `${errno ?? 'filesystem failure'} on ${path}`, {
193
+ cause: error
194
+ });
195
+ };
196
+
197
+ const toErrorResponse = (error: unknown): DesktopHostFileResponse => {
198
+ if (error instanceof RxDBAdapterDesktopError) {
199
+ return { kind: 'error', code: error.code, message: error.detail };
200
+ }
201
+ return {
202
+ kind: 'error',
203
+ code: 'host_internal_error',
204
+ message: error instanceof Error ? error.message : String(error)
205
+ };
206
+ };
207
+
208
+ const escapesRoot = (relativePath: string): RxDBAdapterDesktopError =>
209
+ new RxDBAdapterDesktopError('invalid_file_path', `path escapes the storage root: ${relativePath}`);
210
+
211
+ /** 字面量判定:拼接并规范化之后是否仍在根前缀之下。 */
212
+ const isInside = (root: string, absolute: string): boolean => absolute === root || absolute.startsWith(root + sep);
213
+
214
+ /**
215
+ * 词法判定一次读写是否落在存储根之内。
216
+ *
217
+ * @remarks
218
+ * 协议层已经逐段挡过 `..` / 绝对路径 / 盘符,这里补上「拼完再看落点」:逐段校验看的是字面量,
219
+ * 只有规范化之后才知道路径最终指向哪里。
220
+ *
221
+ * `resolve` 不读文件系统,因此这一层**拦不住符号链接** —— 那由
222
+ * {@link createElectronFileHost} 内的 `containedPath` 用 `realpath` 兜住。两层都要:
223
+ * 词法这层不碰磁盘,能在完全不存在的路径上给出确定答案,也是 `realpath` 逐级上探时的起点。
224
+ */
225
+ const resolveWithinRoot = (root: string, relativePath: string): string => {
226
+ const absolute = relativePath === '' ? root : resolve(root, relativePath);
227
+ if (!isInside(root, absolute)) throw escapesRoot(relativePath);
228
+ return absolute;
229
+ };
230
+
231
+ /**
232
+ * 求一条**可能尚不存在**的路径的规范形式。
233
+ *
234
+ * @remarks
235
+ * `realpath` 只认已经存在的路径,而写入的目标在 `writeBegin` 那一刻往往还不存在。
236
+ * 于是逐级上探到第一个存在的祖先,把它 `realpath` 之后再把剩下的段拼回去 —— 不存在的段
237
+ * 不可能是符号链接,拼回去不会漏掉任何一次跳转。
238
+ *
239
+ * 一路上探到卷根仍不存在(根目录本身还没建出来)时按字面量返回:此时磁盘上根本没有可解析的
240
+ * 东西,字面量就是它未来的规范形式。
241
+ */
242
+ const canonicalize = async (absolute: string): Promise<string> => {
243
+ const trailing: string[] = [];
244
+ let probe = absolute;
245
+
246
+ for (;;) {
247
+ try {
248
+ const real = await realpath(probe);
249
+ return trailing.length === 0 ? real : join(real, ...trailing);
250
+ } catch (error) {
251
+ if (readErrno(error) !== 'ENOENT') throw error;
252
+
253
+ const parent = dirname(probe);
254
+ if (parent === probe) return join(probe, ...trailing);
255
+ trailing.unshift(basename(probe));
256
+ probe = parent;
257
+ }
258
+ }
259
+ };
260
+
261
+ const toEntryKind = (isDirectory: boolean): DesktopHostFileEntry['kind'] => (isDirectory ? 'directory' : 'file');
262
+
263
+ /**
264
+ * 目标存在但类型与本次操作不符时抛 `invalid_file_path`。
265
+ *
266
+ * @remarks
267
+ * 显式判类型而不是让 errno 兜底:`rm(recursive: true)` 撞上文件会把**文件**删掉且一声不吭,
268
+ * `rm` 撞上目录、`read` 撞上目录则按平台给出不同 errno(Linux EISDIR / Windows EPERM、EACCES),
269
+ * 同一份代码在三个平台上是三种结果。调用方在协议这一层已经知道类型(服务层的 `clear()`
270
+ * 按 `entry.kind` 分派),撞上类型不符只说明它的模型与盘上真实情况漂移了 —— 那要出声,
271
+ * 且不能顺手动掉另一种条目。跨后端判据见 `rxdb-plugin-storage` 的 `storage-backend-parity.suite.ts`。
272
+ *
273
+ * 目标不存在时静默返回,把定性权交回调用点:删除据此保持幂等,读则照常走到 `open` 报
274
+ * `file_not_found`。
275
+ *
276
+ * 用 `stat` 而不是 `lstat`:判的是这次操作真正会碰到的那个条目。根内链接是合法布局
277
+ * (见「still serves a symlink that resolves back inside the root」),逃出根的那些
278
+ * 在更早的 `containedPath` 就已经被拦下。
279
+ *
280
+ * @param target - 已通过根内校验的绝对路径
281
+ * @param relativePath - 调用方传进来的原始路径,只用于错误消息
282
+ * @param expected - 本次操作要求的条目类型
283
+ */
284
+ const requireEntryKind = async (
285
+ target: string,
286
+ relativePath: string,
287
+ expected: DesktopHostFileEntry['kind']
288
+ ): Promise<void> => {
289
+ let isDirectory: boolean;
290
+ try {
291
+ isDirectory = (await stat(target)).isDirectory();
292
+ } catch (error) {
293
+ if (readErrno(error) === 'ENOENT') return;
294
+ throw toFilesystemError(error, relativePath);
295
+ }
296
+ if (toEntryKind(isDirectory) !== expected) {
297
+ throw new RxDBAdapterDesktopError('invalid_file_path', `path is not a ${expected}: ${relativePath}`);
298
+ }
299
+ };
300
+
301
+ /**
302
+ * 创建一个桌面文件 host。
303
+ *
304
+ * @param options - host 配置
305
+ * @returns host 实例
306
+ */
307
+ export function createElectronFileHost(options: ElectronFileHostOptions): ElectronFileHost {
308
+ const sessions = new Map<string, FileSession>();
309
+ const queues = new Map<string, LockQueue>();
310
+
311
+ const requireSession = (sessionId: string): FileSession => {
312
+ const session = sessions.get(sessionId);
313
+ if (!session) {
314
+ throw new RxDBAdapterDesktopError('session_closed', `file session ${sessionId} is not open on this host`);
315
+ }
316
+ return session;
317
+ };
318
+
319
+ const requireWrite = (session: FileSession, writeId: string): PendingWrite => {
320
+ const pending = session.writes.get(writeId);
321
+ if (!pending) {
322
+ throw new RxDBAdapterDesktopError('write_aborted', `write ${writeId} is no longer pending`);
323
+ }
324
+ return pending;
325
+ };
326
+
327
+ const rootPath = (): string => {
328
+ try {
329
+ return resolve(options.resolveStorageRoot());
330
+ } catch (error) {
331
+ throw new RxDBAdapterDesktopError('host_internal_error', 'the application could not resolve the storage root', {
332
+ cause: error
333
+ });
334
+ }
335
+ };
336
+
337
+ /**
338
+ * 把相对路径解析成物理绝对路径,并确认它**真的**落在存储根之内。
339
+ *
340
+ * @param relativePath - renderer 送来的存储根内相对路径
341
+ * @returns 词法解析后的绝对路径(不是规范形式)
342
+ * @throws {@link RxDBAdapterDesktopError} 路径逃出存储根,或解析过程本身失败时抛出。
343
+ *
344
+ * @remarks
345
+ * 词法判定之后再比一次**规范形式**:`resolve` 不读磁盘,根内的一个符号链接指到根外时它照放
346
+ * 不误,而随后的读写就落在根之外了。这里把根与目标各自 {@link canonicalize} 一遍再比前缀,
347
+ * 链接跳转因此逃不掉。根自己也要规范化 —— macOS 的 `/var`、`/tmp` 本身就是链接,
348
+ * 拿字面量的根去比会把每一条合法路径都判成越界。
349
+ *
350
+ * 返回的仍是**词法**路径而不是规范形式:链接只要解析后落在根内就是合法的存储布局,
351
+ * 照着它写才是调用方期待的语义。规范形式只用于判定。
352
+ *
353
+ * 判定与随后的读写之间存在 TOCTOU 窗口,这里不试图关掉它 —— 关掉需要 `openat` 一级的
354
+ * 原语,Node 的 `fs` 给不了。存储根由宿主应用独占仍是首要前提,本函数是它的第二道。
355
+ */
356
+ const containedPath = async (relativePath: string): Promise<string> => {
357
+ const root = rootPath();
358
+ const absolute = resolveWithinRoot(root, relativePath);
359
+
360
+ try {
361
+ const [realRoot, realTarget] = await Promise.all([canonicalize(root), canonicalize(absolute)]);
362
+ if (!isInside(realRoot, realTarget)) throw escapesRoot(relativePath);
363
+ return absolute;
364
+ } catch (error) {
365
+ throw toFilesystemError(error, relativePath);
366
+ }
367
+ };
368
+
369
+ // ---- 锁仲裁 -------------------------------------------------------------
370
+
371
+ const queueOf = (name: string): LockQueue => {
372
+ const existing = queues.get(name);
373
+ if (existing) return existing;
374
+ const created: LockQueue = { held: new Map(), waiting: [] };
375
+ queues.set(name, created);
376
+ return created;
377
+ };
378
+
379
+ const canGrant = (queue: LockQueue, waiter: LockWaiter): boolean => {
380
+ if (queue.held.size === 0) return true;
381
+ if (waiter.mode === 'exclusive') return false;
382
+ return [...queue.held.values()].every(mode => mode === 'shared');
383
+ };
384
+
385
+ /**
386
+ * 按 FIFO 推进队列;队首拿不到就整队停住,避免独占请求被共享流饿死。
387
+ *
388
+ * @remarks
389
+ * 推进完顺手把空队列删掉:锁名是逐文件的,长跑的 host 不清理就会按访问过的文件数无界增长。
390
+ * 所有释放路径(release / 会话回收 / 拒绝排队者)末尾都会走到这里,因此回收点只此一处。
391
+ */
392
+ const pump = (name: string): void => {
393
+ const queue = queues.get(name);
394
+ if (!queue) return;
395
+ while (queue.waiting.length > 0 && canGrant(queue, queue.waiting[0])) {
396
+ const waiter = queue.waiting.shift() as LockWaiter;
397
+ queue.held.set(waiter.lockId, waiter.mode);
398
+ waiter.grant();
399
+ }
400
+ if (queue.held.size === 0 && queue.waiting.length === 0) {
401
+ queues.delete(name);
402
+ }
403
+ };
404
+
405
+ const acquireLock = (sessionId: string, name: string, mode: DesktopHostFileLockMode): Promise<string> => {
406
+ const session = requireSession(sessionId);
407
+ const queue = queueOf(name);
408
+ if (queue.waiting.length >= DESKTOP_HOST_MAX_QUEUED_LOCKS_PER_NAME) {
409
+ // 等待者不超时(对齐 Web Locks),队列因此只会越堆越长。到这个量级只可能是调用方失控。
410
+ throw new RxDBAdapterDesktopError(
411
+ 'protocol_violation',
412
+ `lock ${name} already has ${DESKTOP_HOST_MAX_QUEUED_LOCKS_PER_NAME} queued waiters`
413
+ );
414
+ }
415
+ const lockId = randomUUID();
416
+ return new Promise<string>((grantResolve, grantReject) => {
417
+ queue.waiting.push({
418
+ lockId,
419
+ sessionId,
420
+ mode,
421
+ grant: () => {
422
+ session.lockNames.set(lockId, name);
423
+ grantResolve(lockId);
424
+ },
425
+ deny: grantReject
426
+ });
427
+ pump(name);
428
+ });
429
+ };
430
+
431
+ const releaseLock = (session: FileSession, lockId: string): void => {
432
+ const name = session.lockNames.get(lockId);
433
+ if (name === undefined) {
434
+ throw new RxDBAdapterDesktopError('protocol_violation', `lock ${lockId} is not held by this session`);
435
+ }
436
+ session.lockNames.delete(lockId);
437
+ queues.get(name)?.held.delete(lockId);
438
+ pump(name);
439
+ };
440
+
441
+ /** 会话消失时,它排队中的请求必须被显式拒绝,否则调用方的 await 永远悬着。 */
442
+ const dropWaiters = (sessionId: string): void => {
443
+ for (const [name, queue] of queues) {
444
+ const denied = queue.waiting.filter(waiter => waiter.sessionId === sessionId);
445
+ queue.waiting = queue.waiting.filter(waiter => waiter.sessionId !== sessionId);
446
+ for (const waiter of denied) {
447
+ waiter.deny(new RxDBAdapterDesktopError('session_closed', `file session ${sessionId} closed while queued`));
448
+ }
449
+ pump(name);
450
+ }
451
+ };
452
+
453
+ // ---- 会话生命周期 -------------------------------------------------------
454
+
455
+ /**
456
+ * 丢弃一次写入。
457
+ *
458
+ * @remarks
459
+ * 关闭句柄与删除临时文件的失败都吞掉:本函数只在放弃路径与会话回收路径上跑,
460
+ * 再抛一次会盖住真正的失败原因,而残留的临时文件在下次启动时无害。
461
+ */
462
+ const discardWrite = async (pending: PendingWrite): Promise<void> => {
463
+ await pending.handle.close().catch(() => undefined);
464
+ await rm(pending.temporaryPath, { force: true }).catch(() => undefined);
465
+ };
466
+
467
+ const closeSession = async (sessionId: string): Promise<void> => {
468
+ const session = sessions.get(sessionId);
469
+ if (!session) return;
470
+ sessions.delete(sessionId);
471
+ session.closed = true;
472
+
473
+ // 顺序不能反:先放持有的锁,那一步的 `pump` 有可能把锁授给这个正在关闭的会话自己还排着的
474
+ // 申请 —— 新锁既不在下面这轮回收的快照里,`dropWaiters` 也够不着它(它已经从队列里被摘走),
475
+ // 于是同名的后续申请永远排在一把没人释放的锁后面。先把队列清干净,这条路径就不存在。
476
+ // Rust 侧 `LockTable::drop_session` 用的正是这个顺序,两端必须一致。
477
+ dropWaiters(sessionId);
478
+ for (const lockId of [...session.lockNames.keys()]) {
479
+ const name = session.lockNames.get(lockId) as string;
480
+ session.lockNames.delete(lockId);
481
+ queues.get(name)?.held.delete(lockId);
482
+ pump(name);
483
+ }
484
+
485
+ await Promise.all([...session.writes.values()].map(discardWrite));
486
+ session.writes.clear();
487
+ };
488
+
489
+ // ---- 文件操作 -----------------------------------------------------------
490
+
491
+ const statPath = async (relativePath: string): Promise<DesktopHostFileStat | null> => {
492
+ const target = await containedPath(relativePath);
493
+ try {
494
+ const stats = await stat(target);
495
+ return {
496
+ kind: toEntryKind(stats.isDirectory()),
497
+ size: stats.isDirectory() ? 0 : stats.size,
498
+ lastModified: stats.mtimeMs
499
+ };
500
+ } catch (error) {
501
+ if (readErrno(error) === 'ENOENT') return null;
502
+ throw toFilesystemError(error, relativePath);
503
+ }
504
+ };
505
+
506
+ const listPath = async (relativePath: string): Promise<readonly DesktopHostFileEntry[]> => {
507
+ const target = await containedPath(relativePath);
508
+ try {
509
+ const entries = await readdir(target, { withFileTypes: true });
510
+ return entries.map(entry => ({ name: entry.name, kind: toEntryKind(entry.isDirectory()) }));
511
+ } catch (error) {
512
+ throw toFilesystemError(error, relativePath);
513
+ }
514
+ };
515
+
516
+ const runPathOperation = async (operation: () => Promise<unknown>, relativePath: string): Promise<void> => {
517
+ try {
518
+ await operation();
519
+ } catch (error) {
520
+ throw toFilesystemError(error, relativePath);
521
+ }
522
+ };
523
+
524
+ const movePath = async (fromPath: string, toPath: string): Promise<void> => {
525
+ const source = await containedPath(fromPath);
526
+ const target = await containedPath(toPath);
527
+ await runPathOperation(() => mkdir(dirname(target), { recursive: true }), toPath);
528
+ await runPathOperation(() => rename(source, target), fromPath);
529
+ };
530
+
531
+ const readFrame = async (
532
+ relativePath: string,
533
+ offset: number,
534
+ length: number
535
+ // 用协议类型而不是就地写一个结构一致的字面量类型:协议把 `chunk` 收窄到
536
+ // `Uint8Array<ArrayBuffer>`,就地写的那份会默认宽成 `ArrayBufferLike`,
537
+ // 于是这里读到的帧到了应答位置反而装不进协议。
538
+ ): Promise<DesktopHostFileReadResult> => {
539
+ const target = await containedPath(relativePath);
540
+ // 判在 open 之前:Windows 上 `open` 一个目录自己就先失败(EACCES/EPERM),拿不到句柄再判;
541
+ // 而 POSIX 上 open 成功、要等到 `read` 才炸,偏偏 `offset` 越过目录 stat 的 size 时
542
+ // 连 `read` 都不会调用 —— 那条路会安静地返回一个空帧加 eof。一次 stat 换四兆一帧,可以忽略。
543
+ await requireEntryKind(target, relativePath, 'file');
544
+ let handle: FileHandle | undefined;
545
+ try {
546
+ handle = await open(target, 'r');
547
+ const size = (await handle.stat()).size;
548
+ const buffer = new Uint8Array(Math.max(0, Math.min(length, size - offset)));
549
+ if (buffer.byteLength > 0) await handle.read(buffer, 0, buffer.byteLength, offset);
550
+ return { chunk: buffer, eof: offset + buffer.byteLength >= size };
551
+ } catch (error) {
552
+ throw toFilesystemError(error, relativePath);
553
+ } finally {
554
+ await handle?.close().catch(() => undefined);
555
+ }
556
+ };
557
+
558
+ /** 打开临时文件;errno 在这里就收敛成稳定错误码。 */
559
+ const openTemporary = async (temporaryPath: string, relativePath: string): Promise<FileHandle> => {
560
+ try {
561
+ // 'wx' 而不是 'w':临时名带 UUID,撞名只可能是同名文件已被别处占用,静默覆盖会丢它的内容。
562
+ return await open(temporaryPath, 'wx');
563
+ } catch (error) {
564
+ throw toFilesystemError(error, relativePath);
565
+ }
566
+ };
567
+
568
+ const beginWrite = async (session: FileSession, relativePath: string): Promise<string> => {
569
+ const writeId = randomUUID();
570
+ // 额度检查与占位之间不能有 await。中间隔着 containedPath / mkdir / open 时,并发的
571
+ // file.writeBegin 会在任何一次 set 落地之前集体通过检查,上限被并发窗口整体绕过 ——
572
+ // 那正是这道上限本来要防的 DoS。Rust 侧的 register_write 在同一把锁内 check + insert,
573
+ // 拿到的是同一个不变式;JS 这边靠「同步段内完成检查与占位」达成。
574
+ if (session.writes.size + session.reservations.size >= DESKTOP_HOST_MAX_PENDING_WRITES_PER_SESSION) {
575
+ // 每个挂起的写入都占用一个打开的临时文件句柄,只有 commit/abort 才归还。
576
+ // 不设上限,一个只 begin 不 commit 的 renderer 就能把宿主的 fd 耗光,连带数据库也打不开。
577
+ throw new RxDBAdapterDesktopError(
578
+ 'protocol_violation',
579
+ `session already has ${DESKTOP_HOST_MAX_PENDING_WRITES_PER_SESSION} pending writes`
580
+ );
581
+ }
582
+ session.reservations.add(writeId);
583
+ try {
584
+ const target = await containedPath(relativePath);
585
+ const temporaryPath = join(dirname(target), `.${writeId}.rxdb-tmp`);
586
+ await runPathOperation(() => mkdir(dirname(target), { recursive: true }), relativePath);
587
+ const pending: PendingWrite = {
588
+ logicalPath: relativePath,
589
+ targetPath: target,
590
+ temporaryPath,
591
+ handle: await openTemporary(temporaryPath, relativePath)
592
+ };
593
+ // 上面几个 await 期间会话可能已经被回收(窗口销毁走的就是这条路)。`closeSession` 只扫它
594
+ // 当时看到的那些写入,此刻再 set 进去,句柄就挂在一个脱离宿主的 session 上:`closeAll` 与
595
+ // `file.writeAbort` 都够不着它,fd 与临时文件泄漏到进程退出为止,重复走还能绕开
596
+ // 每会话的 pending-write 上限。所以在这里自己收掉,并按可重连的语义报回去。
597
+ if (session.closed) {
598
+ await discardWrite(pending);
599
+ throw new RxDBAdapterDesktopError('session_closed', 'file session closed while the write was being opened');
600
+ }
601
+ session.writes.set(writeId, pending);
602
+ return writeId;
603
+ } finally {
604
+ // 占位只是额度的凭据:成功时额度改由 session.writes 记着,失败时本就该归还。
605
+ session.reservations.delete(writeId);
606
+ }
607
+ };
608
+
609
+ /**
610
+ * 把目录项的变更刷到盘上。
611
+ *
612
+ * @remarks
613
+ * `rename` 的原子性只覆盖「要么旧要么新」,不覆盖「已经落盘」:目录项还在页缓存里时掉电,
614
+ * 重启后看到的可能仍是改名前的状态 —— 内容已 fsync 也救不回来,因为指向它的那条目录项没落。
615
+ *
616
+ * Windows 打不开目录句柄(`open` 回 EISDIR/EPERM/EACCES),那里的 rename 由文件系统日志保证,
617
+ * 只吞这三个 errno;其余失败照常上抛,否则「提交成功」就成了没有依据的断言。
618
+ */
619
+ const syncDirectory = async (directoryPath: string): Promise<void> => {
620
+ let handle: FileHandle | undefined;
621
+ try {
622
+ handle = await open(directoryPath, 'r');
623
+ await handle.sync();
624
+ } catch (error) {
625
+ const errno = readErrno(error);
626
+ if (errno !== 'EISDIR' && errno !== 'EPERM' && errno !== 'EACCES') throw error;
627
+ } finally {
628
+ await handle?.close().catch(() => undefined);
629
+ }
630
+ };
631
+
632
+ /**
633
+ * 提交一次写入。
634
+ *
635
+ * @remarks
636
+ * 顺序是不变式的一部分:内容 `sync` → `close` → `rename` → 父目录 `sync`。
637
+ * 内容先落盘,否则掉电后目标会指向一个内容为空洞的新 inode;父目录后落盘,
638
+ * 否则改名本身可能丢失(见 {@link syncDirectory})。
639
+ */
640
+ const commitWrite = async (session: FileSession, writeId: string): Promise<void> => {
641
+ const pending = requireWrite(session, writeId);
642
+ session.writes.delete(writeId);
643
+ try {
644
+ await pending.handle.sync();
645
+ await pending.handle.close();
646
+ await rename(pending.temporaryPath, pending.targetPath);
647
+ await syncDirectory(dirname(pending.targetPath));
648
+ } catch (error) {
649
+ // 句柄必须在这里关:本次写入在 555 行就已从 session.writes 摘掉,closeSession 的
650
+ // discardWrite 扫描和 file.writeAbort 都够不着它 —— 不关就泄漏到宿主进程退出为止,
651
+ // 顺带绕过 beginWrite 那道 fd 上限。Rust 侧靠 File 的 Drop 拿到同样的保证。
652
+ // 带 .catch:失败发生在 close 之后(rename / 目录 sync)时二次 close 会 reject,
653
+ // 与 discardWrite 同样处理 —— 回收路径的失败不该盖住真正的原因。
654
+ await pending.handle.close().catch(() => undefined);
655
+ await rm(pending.temporaryPath, { force: true }).catch(() => undefined);
656
+ throw toFilesystemError(error, pending.logicalPath);
657
+ }
658
+ };
659
+
660
+ // ---- 派发 ---------------------------------------------------------------
661
+
662
+ const dispatchWrite = async (request: FileWriteRequest): Promise<DesktopHostFileResponse> => {
663
+ const session = requireSession(request.sessionId);
664
+ if (request.kind === 'file.writeBegin') {
665
+ return { kind: 'file.writeBegin', result: { writeId: await beginWrite(session, request.path) } };
666
+ }
667
+ if (request.kind === 'file.writeChunk') {
668
+ const pending = requireWrite(session, request.writeId);
669
+ try {
670
+ await pending.handle.write(request.chunk);
671
+ } catch (error) {
672
+ throw toFilesystemError(error, pending.logicalPath);
673
+ }
674
+ return { kind: 'file.writeChunk' };
675
+ }
676
+ if (request.kind === 'file.writeCommit') {
677
+ await commitWrite(session, request.writeId);
678
+ return { kind: 'file.writeCommit' };
679
+ }
680
+ const pending = requireWrite(session, request.writeId);
681
+ session.writes.delete(request.writeId);
682
+ await discardWrite(pending);
683
+ return { kind: 'file.writeAbort' };
684
+ };
685
+
686
+ const dispatchLock = async (request: FileLockRequest): Promise<DesktopHostFileResponse> => {
687
+ if (request.kind === 'file.lockAcquire') {
688
+ const lockId = await acquireLock(request.sessionId, request.name, request.mode);
689
+ return { kind: 'file.lockAcquire', result: { lockId } };
690
+ }
691
+ releaseLock(requireSession(request.sessionId), request.lockId);
692
+ return { kind: 'file.lockRelease' };
693
+ };
694
+
695
+ const dispatchPath = async (request: DesktopHostFilePathRequest): Promise<DesktopHostFileResponse> => {
696
+ requireSession(request.sessionId);
697
+ if (request.kind === 'file.stat') return { kind: 'file.stat', result: await statPath(request.path) };
698
+ if (request.kind === 'file.list') return { kind: 'file.list', result: await listPath(request.path) };
699
+
700
+ const target = await containedPath(request.path);
701
+ if (request.kind === 'file.mkdir') {
702
+ await runPathOperation(() => mkdir(target, { recursive: true }), request.path);
703
+ return { kind: 'file.mkdir' };
704
+ }
705
+ if (request.kind === 'file.rmdir') {
706
+ await requireEntryKind(target, request.path, 'directory');
707
+ await runPathOperation(() => rm(target, { force: true, recursive: true }), request.path);
708
+ return { kind: 'file.rmdir' };
709
+ }
710
+ await requireEntryKind(target, request.path, 'file');
711
+ await runPathOperation(() => rm(target, { force: true }), request.path);
712
+ return { kind: 'file.remove' };
713
+ };
714
+
715
+ const dispatch = async (request: DesktopHostFileRequest): Promise<DesktopHostFileResponse> => {
716
+ if (request.kind === 'file.open') {
717
+ const sessionId = randomUUID();
718
+ sessions.set(sessionId, { writes: new Map(), reservations: new Set(), lockNames: new Map(), closed: false });
719
+ return { kind: 'file.open', result: { sessionId, protocolVersion: DESKTOP_HOST_PROTOCOL_VERSION } };
720
+ }
721
+ if (request.kind === 'file.close') {
722
+ requireSession(request.sessionId);
723
+ await closeSession(request.sessionId);
724
+ return { kind: 'file.close' };
725
+ }
726
+ if (request.kind === 'file.move') {
727
+ requireSession(request.sessionId);
728
+ await movePath(request.fromPath, request.toPath);
729
+ return { kind: 'file.move' };
730
+ }
731
+ if (request.kind === 'file.read') {
732
+ requireSession(request.sessionId);
733
+ return { kind: 'file.read', result: await readFrame(request.path, request.offset, request.length) };
734
+ }
735
+ if (isWriteRequest(request)) return dispatchWrite(request);
736
+ if (isLockRequest(request)) return dispatchLock(request);
737
+ return dispatchPath(request);
738
+ };
739
+
740
+ return {
741
+ handle: async (request: unknown): Promise<DesktopHostFileResponse> => {
742
+ try {
743
+ return await dispatch(parseDesktopHostFileRequest(request));
744
+ } catch (error) {
745
+ return toErrorResponse(error);
746
+ }
747
+ },
748
+ get openSessionCount(): number {
749
+ return sessions.size;
750
+ },
751
+ get trackedLockNameCount(): number {
752
+ return queues.size;
753
+ },
754
+ closeAll: async (): Promise<void> => {
755
+ await Promise.all([...sessions.keys()].map(sessionId => closeSession(sessionId)));
756
+ }
757
+ };
758
+ }