@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
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Aiao Team
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,294 @@
1
- # Temporary Holding Version
1
+ # @aiao/rxdb-adapter-electron
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ RxDB 适配器,把数据落到 **Electron 应用私有目录里的真实文件**。本包提供两个并列的适配器,可同时注册:
4
+
5
+ | 适配器名 | 落盘形态 | 入口 |
6
+ | ----------------- | -------------------------- | ----------------------------------------------- |
7
+ | `sqlite-electron` | 单个 `.sqlite3` 文件 | `.`(renderer)+ `/host`(特权侧) |
8
+ | `pglite-electron` | 一棵 PGlite data directory | `/pglite`(renderer)+ `/pglite-host`(特权侧) |
9
+
10
+ 数据由特权侧(Electron 主进程或它拥有的 worker)直接读写——SQLite 走 `node:sqlite`,PGlite 走 `@electric-sql/pglite` 的 Node filesystem backend;渲染进程只通过一条窄传输层发协议请求,因此它既拿不到文件系统句柄,也拿不到物理路径。
11
+
12
+ Tauri 请改用 [`@aiao/rxdb-adapter-tauri`](https://www.npmjs.com/package/@aiao/rxdb-adapter-tauri):协议与 renderer 客户端两包共用同一份实现(在 `@aiao/rxdb-adapter-sqlite-core/desktop-host`),差别只在特权侧是 `node:sqlite` 还是 Rust。
13
+
14
+ ## 功能特性
15
+
16
+ - **真文件持久化**:数据在应用数据目录里(SQLite 是 `.sqlite3` 文件,PGlite 是一棵 data directory),重启后仍在,不依赖浏览器存储配额
17
+ - **渲染进程零文件系统权限**:`contextIsolation: true` + `sandbox: true` 下照常工作
18
+ - **多窗口安全**:两族各有各的做法,且**行为不同**——见下方[多窗口与事务](#多窗口与事务)
19
+ - **不回退**:存储配置不受支持时直接抛错,绝不静默切到 memory/OPFS/IndexedDB
20
+ - **复用引擎核心**:查询、事务、分支切换来自 `@aiao/rxdb-adapter-sqlite-core` / `@aiao/rxdb-adapter-pglite`,与各自的浏览器档位同语义、同一批测试套件
21
+
22
+ ## 何时使用
23
+
24
+ - Electron 应用需要「用户看得见、备份得了、卸载才会没」的本地数据
25
+ - 数据量超出浏览器存储配额的舒适区,或不接受 OPFS 被浏览器回收的风险
26
+ - 需要外部工具直接打开同一份数据(SQLite:`sqlite3` CLI、DB Browser)
27
+
28
+ 两族之间怎么选:默认选 `sqlite-electron`——单文件、外部工具好接、依赖只有 Node 内建。需要 PostgreSQL 的类型与 SQL 方言(JSONB、关系约束),或需要变更事件跨窗口(`LISTEN`/`NOTIFY`,见下方[多窗口与事务](#多窗口与事务))时才选 `pglite-electron`,代价是几十兆 WASM 和一条 worker 线程。
29
+
30
+ > **全文搜索两族都还用不了**:`@aiao/rxdb-plugin-search` 的放行名单里只有浏览器内的引擎(`sqlite-wasm` / `sqlite` / `sqliteai` / `pglite`),两个桌面适配器名都不在表内,`createRxDatabase` 阶段会抛 `SearchUnsupportedAdapterError`——不降级、不挂 `.search`。
31
+
32
+ 浏览器内运行请改用 [`@aiao/rxdb-adapter-wa-sqlite`](https://www.npmjs.com/package/@aiao/rxdb-adapter-wa-sqlite) 或 [`@aiao/rxdb-adapter-sqlite-wasm`](https://www.npmjs.com/package/@aiao/rxdb-adapter-sqlite-wasm)。
33
+
34
+ ## 能力矩阵
35
+
36
+ | 存储 | 状态 |
37
+ | --------------------- | --------------------------------------------------------------------------------------------- |
38
+ | SQLite 单文件 | ✅ host 在包内(`/host`),适配器名 `sqlite-electron` |
39
+ | PGlite data directory | ✅ host 在包内(`/pglite-host`),适配器名 `pglite-electron`;需要可选 peer(见下方「安装」) |
40
+
41
+ 两族走的是**两套协议**(SQLite 侧 `sqlite.*`,PGlite 侧 `pg.*`),不是同一个协议的两种后端:PGlite 的 callback transaction 跨不过 IPC,只能用显式的事务 ID 协议(`begin` / `query` / `commit` / `rollback`,同一事务 ID 绑定同一条物理连接)。所以 `pglite-electron` **刻意不在** `DesktopHostAdapterName` 登记表里——那份表是「走 SQLite host 协议」的清单,硬套上去会让共享层误以为它能接受 `DesktopSqliteFileStorage`。
42
+
43
+ 不在矩阵内的组合会被 `assertDesktopSqliteStorage` 以 `unsupported_runtime_engine` 拒绝——不静默退化。
44
+
45
+ host 侧需要一个 Node 运行时:SQLite 侧要内置 `node:sqlite`,PGlite 侧要能加载 `@electric-sql/pglite`。本包在 Node 26 与 Electron 43 上验证,更早的版本未验证。
46
+
47
+ ## 安装
48
+
49
+ ```bash
50
+ npm install @aiao/rxdb-adapter-electron
51
+ # 或
52
+ pnpm add @aiao/rxdb-adapter-electron
53
+ ```
54
+
55
+ 只用 SQLite 的话装到这里就够了。**要用 PGlite 才需要**再装两个可选 peer:
56
+
57
+ ```bash
58
+ pnpm add @aiao/rxdb-adapter-pglite @electric-sql/pglite
59
+ ```
60
+
61
+ 两者都声明为 `optional: true` 的 peer,正是为了让只用 SQLite 的应用不必装 PGlite——这也是下面四个入口必须分开的原因。
62
+
63
+ ## 四个入口
64
+
65
+ 包**刻意**分成四个入口,不要混用。分法是两条正交的线:**renderer / 特权侧**(谁能碰文件系统)× **SQLite / PGlite**(引入哪个可选 peer)。
66
+
67
+ | 入口 | 加载位置 | 引入的重依赖 |
68
+ | ----------------------------------------- | --------------------------------- | ---------------------------------------------- |
69
+ | `@aiao/rxdb-adapter-electron` | renderer(浏览器上下文) | 无,可安全打进 bundle |
70
+ | `@aiao/rxdb-adapter-electron/host` | Electron 主进程 / 它拥有的 worker | `node:sqlite` |
71
+ | `@aiao/rxdb-adapter-electron/pglite` | renderer(浏览器上下文) | 仅 `@aiao/rxdb-adapter-pglite` + 类型,见下 |
72
+ | `@aiao/rxdb-adapter-electron/pglite-host` | Electron 主进程 / 它拥有的 worker | `@electric-sql/pglite`(完整 PostgreSQL WASM) |
73
+
74
+ 把任一 host 入口打进 renderer bundle 等于把文件系统能力还给渲染进程,整个隔离随之作废。
75
+
76
+ > `/pglite` 这个 renderer 入口**不引用 PostgreSQL 运行时**:WASM 实例活在特权侧,renderer 这边只有一层协议代理,从 `@electric-sql/pglite` 取的只有类型和 `/template` 那个约 2 KB 的模板编译子路径。所以用 PGlite 不会让 renderer bundle 多出几十兆。
77
+
78
+ ## 使用
79
+
80
+ 接线分三处。
81
+
82
+ ### 1. 主进程:起 host
83
+
84
+ ```typescript
85
+ import { createElectronSqliteHost } from '@aiao/rxdb-adapter-electron/host';
86
+ import { app, ipcMain, type WebContents } from 'electron';
87
+ import { mkdirSync } from 'node:fs';
88
+ import { join } from 'node:path';
89
+
90
+ const root = join(app.getPath('userData'), 'rxdb-data');
91
+
92
+ // sessionId → 该会话归属的窗口。open 应答里带 sessionId,变更事件按它回送。
93
+ const targets = new Map<string, WebContents>();
94
+
95
+ const host = createElectronSqliteHost({
96
+ // 只有宿主应用知道自己的数据目录。传进来的名字已过白名单校验,不含任何路径分隔符。
97
+ resolveDatabasePath: databaseName => {
98
+ mkdirSync(root, { recursive: true });
99
+ return join(root, databaseName);
100
+ },
101
+ postChange: message => {
102
+ const target = targets.get(message.sessionId);
103
+ // 窗口已经没了:写入早已落库,事件无处可送。这是常规竞态而不是失败。
104
+ if (!target || target.isDestroyed()) return;
105
+ target.send('desktop-sqlite:change', message);
106
+ },
107
+ onDeliveryError: error => console.warn('[desktop-sqlite] 变更事件送达失败', error)
108
+ });
109
+
110
+ // host.handle 永不 reject:失败以 `kind: 'error'` 的应答返回。
111
+ // ipcRenderer.invoke 在 reject 时会把错误压平成字符串,自定义错误码随之丢失。
112
+ ipcMain.handle('desktop-sqlite:request', async (event, payload: unknown) => {
113
+ const response = await host.handle(payload);
114
+ if (response.kind === 'open') targets.set(response.result.sessionId, event.sender);
115
+ return response;
116
+ });
117
+
118
+ app.on('before-quit', () => host.closeAll());
119
+ ```
120
+
121
+ 会话回收(窗口 `destroyed` 时关掉它名下未关闭的会话)比上面这段更啰嗦一些,完整实现见文末示例里的 `desktop-sqlite-bridge.ts`。
122
+
123
+ ### 2. preload:暴露传输层
124
+
125
+ 传输层只有两个方法,renderer 因此拿不到原始 `ipcRenderer`,无法向任意频道发消息。
126
+
127
+ ```typescript
128
+ import { contextBridge, ipcRenderer, type IpcRendererEvent } from 'electron';
129
+
130
+ // 必须与适配器的 DESKTOP_HOST_TRANSPORT_KEY 逐字相同。
131
+ contextBridge.exposeInMainWorld('__aiaoRxdbDesktopHost__', {
132
+ request: (payload: unknown) => ipcRenderer.invoke('desktop-sqlite:request', payload),
133
+ subscribe: (listener: (message: unknown) => void) => {
134
+ // 只转消息本体:IpcRendererEvent 带着 sender,交给 renderer 等于把通道能力一并送出。
135
+ const forward = (_event: IpcRendererEvent, message: unknown): void => listener(message);
136
+ ipcRenderer.on('desktop-sqlite:change', forward);
137
+ return () => ipcRenderer.removeListener('desktop-sqlite:change', forward);
138
+ }
139
+ });
140
+ ```
141
+
142
+ ### 3. renderer:像用别的适配器一样用
143
+
144
+ ```typescript
145
+ import { RxDB, SyncType } from '@aiao/rxdb';
146
+ import { ELECTRON_ADAPTER_NAME, RxDBAdapterElectron } from '@aiao/rxdb-adapter-electron';
147
+
148
+ const rxdb = new RxDB({
149
+ dbName: 'demo',
150
+ entities: [],
151
+ sync: { type: SyncType.None, local: { adapter: ELECTRON_ADAPTER_NAME } }
152
+ });
153
+
154
+ // 不传 transport:适配器自己去全局键上找 preload 暴露的桥接。
155
+ rxdb.adapter(ELECTRON_ADAPTER_NAME, async database => new RxDBAdapterElectron(database));
156
+ rxdb.init();
157
+ await rxdb.connect(ELECTRON_ADAPTER_NAME);
158
+
159
+ // 组件/窗口销毁时把连接交还给 host,否则会话要等到窗口 'destroyed' 才回收。
160
+ await rxdb.disconnectAll();
161
+ ```
162
+
163
+ ### 换成 PGlite
164
+
165
+ renderer 侧只换两个名字,其余逐字相同——传输层、preload、错误契约三者两族共用:
166
+
167
+ ```typescript
168
+ import { ELECTRON_PGLITE_ADAPTER_NAME, RxDBAdapterElectronPGlite } from '@aiao/rxdb-adapter-electron/pglite';
169
+
170
+ rxdb.adapter(
171
+ ELECTRON_PGLITE_ADAPTER_NAME,
172
+ async database => new RxDBAdapterElectronPGlite(database, { dataDirectoryName: 'demo-pgdata' })
173
+ );
174
+ ```
175
+
176
+ 特权侧则换成 `createElectronPgliteHost`(来自 `/pglite-host`),并按上面「PGlite host 不要放在主线程」放进 worker。两个适配器可以在同一个 `RxDB` 实例上同时注册——名字不同,协议也不同。
177
+
178
+ #### 共用一条 IPC 通道时的分派
179
+
180
+ 不必为 PGlite 新开通道:一条 `request` / `subscribe` 就够,主进程按请求的 `kind` 分派给三族 host(SQLite / 文件 / PGlite)。**顺序不能反**——
181
+
182
+ ```typescript
183
+ // 1. 先做闭集守卫:未知 kind 在任何 host 被触碰之前就收口
184
+ if (!isKnownDesktopHostRequestKind(request)) {
185
+ return Promise.resolve({ kind: 'error', code: 'protocol_violation', message: '...' });
186
+ }
187
+ // 2. 再分派;SQLite 只能垫底
188
+ const kind = readDesktopHostRequestKind(request);
189
+ if (isDesktopHostFileRequestKind(kind)) return file.handle(target, request);
190
+ if (isDesktopPgliteRequestKind(kind)) return pglite.handle(target, request);
191
+ return sqlite.handle(target, request);
192
+ ```
193
+
194
+ SQLite 那支是**兜底分支**(「不是 handshake/open/version/close 的」一律当 `execute`),所以漏判一族的后果不是报错,而是一条 PGlite 请求被顺着 SQL 路径跑一遍。两层因此都要有:分派判据用协议包导出的 `isDesktopPgliteRequestKind`(`/pglite-host`)与 `isDesktopHostFileRequestKind`(`/host`),不要在接线处自己列 `kind` 名单——那份名单必然随协议版本漂移;闭集守卫则是应用侧自己的几行(协议未导出 SQLite 族谓词),负责把三族之外的 `kind` 挡在兜底分支之前。
195
+
196
+ > 把 PGlite 接进来不会让只用 SQLite 的会话变重:示例里的 PGlite 通道工厂是**惰性调用**的,没有 `pg.*` 请求就不会启动那条 worker 线程、也不会加载 PostgreSQL WASM。
197
+
198
+ 完整实现见文末示例里的 `desktop-host-bridge.ts` 与 `desktop-host-request-guard.ts`。
199
+
200
+ ## 逻辑库名不是路径
201
+
202
+ `databaseName` 是**应用作用域内的逻辑名**。renderer 无从得知、也不需要得知物理根目录。
203
+
204
+ 省略时按 `${rxdb.config.dbName}.sqlite3` 推导;PGlite 侧的对应项是 `dataDirectoryName`,省略时按 `${rxdb.config.dbName}-pgdata` 推导(落盘的是 initdb 生成的**目录树**而不是单个文件,用 `.db` 之类的文件后缀命名只会误导运维)。只有接管一个已存在的库、或多个 RxDB 实例共用同一个文件时才需要显式指定。
205
+
206
+ 允许集是白名单 `/^[A-Za-z0-9][A-Za-z0-9._@-]*$/`(≤ 128 字符)而非黑名单:字符集里没有 `/`、`\`、`:`,也不允许以 `.` 开头,于是 `..`、绝对路径、盘符、`~` 展开、URL scheme 全部落在集合外,不需要逐一枚举攻击形态。违反时抛 `invalid_database_name`。
207
+
208
+ > 名字来自 renderer,即便有 `contextIsolation` 也**不可信**。host 侧会再校验一次;宿主应用的 `resolveDatabasePath` 里建议在 `mkdir` 之前再调一次 `assertValidDesktopDatabaseName` —— 非法入参不该在磁盘上留下任何痕迹。
209
+
210
+ ## ⚠️ 库目录不要叫 `databases`
211
+
212
+ 在 Electron 的 `userData` 下选子目录名时,**避开 `databases`**:那是 Chromium 自己的 WebSQL 目录,它的存储层启动时会把目录里没有登记过的文件全部删掉——你的库文件正是「没登记过的」。
213
+
214
+ 实测(打包产物,macOS,同一个 `--user-data-dir` 连开两次):第一次启动写入的数据在第二次启动时被整体清空,全程**没有任何报错**,应用照常显示已连接、照常写入,只是上一次的数据没了。同一层级另建的 `rxdb-data/` 毫发无损。
215
+
216
+ ## 错误码
217
+
218
+ 程序分支请读 `error.code`,不要匹配消息文本(消息以 `[code] ` 前缀开头,仅便于日志检索)。原始原因通过 `cause` 原样透传。
219
+
220
+ | code | 含义 |
221
+ | ---------------------------- | ----------------------------------------------------------------------------- |
222
+ | `unsupported_runtime_engine` | 存储 `engine` 不在能力矩阵内(例如把 PGlite data directory 递给 SQLite 协议) |
223
+ | `invalid_database_name` | 逻辑库名非法,或试图越出应用作用域 |
224
+ | `host_unavailable` | renderer 拿不到 host(未注入 transport / preload 未暴露) |
225
+ | `session_closed` | 会话已断开后继续使用 |
226
+ | `protocol_violation` | 请求或响应不符合协议形状 |
227
+ | `open_failed` | 打开数据库失败,`cause` 保留原始原因 |
228
+ | `permission_denied` | 路径无权限,或语句被 SQLite 授权器拒绝(`ATTACH` / `VACUUM INTO` 等) |
229
+ | `database_corrupted` | 目标文件不是可用的 SQLite 数据库 |
230
+ | `statement_failed` | SQL 本身执行失败(语法、约束等) |
231
+ | `host_internal_error` | host 自身出错,属于缺陷而非调用方问题 |
232
+ | `database_busy` | 另一个连接(通常是另一个窗口)正持有冲突的锁,重试即可,数据无损 |
233
+ | `file_not_found` | 目标文件或目录不存在;renderer 侧据此还原成 `NotFoundError` |
234
+ | `invalid_file_path` | 路径逃出存储根,或指向的类型与操作不符 |
235
+ | `disk_full` | 磁盘空间或配额耗尽(ENOSPC / EDQUOT) |
236
+ | `write_aborted` | 写入令牌已失效(会话关闭、已提交或已丢弃),目标保持写入前的内容 |
237
+ | `transaction_not_found` | 事务 ID 未知、已结束,或不属于本会话;该事务一条语句都没执行 |
238
+ | `transaction_unavailable` | 等不到 PGlite 连接(另一个事务仍持有),或开启该事务的 `begin` 已超时 |
239
+
240
+ `file_not_found` / `invalid_file_path` / `disk_full` / `write_aborted` 来自文件宿主(`createElectronFileHost`,供 `@aiao/rxdb-plugin-storage/desktop` 使用),SQLite 侧用不到。
241
+
242
+ 最后两个码只出现在 PGlite 路径上:PGlite 的事务靠显式事务 ID 串联多次 IPC,SQLite 侧没有这个概念。`transaction_unavailable` 的超时预算由 `DESKTOP_PGLITE_DEFAULT_BEGIN_TIMEOUT_MS` 给出,上限是 `DESKTOP_PGLITE_MAX_BEGIN_TIMEOUT_MS`。
243
+
244
+ 错误码是**契约的一部分**:新增只能追加,不得复用或改写既有含义。
245
+
246
+ `RxDBAdapterDesktopError` 这个类跨不过结构化克隆,host 侧的错误以 `{ kind: 'error', code, message }` 回到 renderer,由适配器按契约重新抛成 `RxDBAdapterDesktopError`——调用方写的仍是普通 `try/catch`,感觉不到中间隔着一条 IPC。不在契约内的 `code` 一律按 `protocol_violation` 处理,不会被当成错误码原样上抛。
247
+
248
+ ## 多窗口与事务
249
+
250
+ **两族在这一节上的行为不同**,这是选型时最容易踩的一处差异:
251
+
252
+ | 维度 | `sqlite-electron` | `pglite-electron` |
253
+ | ---------- | ------------------------------------------ | ------------------------------------------------------- |
254
+ | 连接模型 | 每个 `open` 一条独立连接 | 同一数据目录**共用一个实例**,跨会话复用 |
255
+ | 并发写 | SQLite 文件锁 + 退避重试 → `database_busy` | 事务在唯一连接上排队 → 超时报 `transaction_unavailable` |
256
+ | 变更跨窗口 | ❌ 不跨(见下) | ✅ 跨,`NOTIFY` 扇出到该实例的全部会话 |
257
+
258
+ ### SQLite:`BEGIN IMMEDIATE` 与撞锁重试
259
+
260
+ 事务用 `BEGIN IMMEDIATE` 而非裸 `BEGIN`:后者延迟取锁,写锁要等到事务里第一条写语句才拿,撞锁于是发生在**事务中途**——那时已经读过一份快照,SQLite 要求整个事务回滚重来。`IMMEDIATE` 把取锁挪到起点,撞锁时事务还没开,重试就是无副作用地再发一次。
261
+
262
+ 撞锁后按指数退避重试,默认总预算 5 秒(盖住「另一个窗口正在跑一次系统 schema 迁移」这段最长持锁时间),超时报 `database_busy`。可用 host 的 `busyRetryBudgetMs` 调整。
263
+
264
+ #### 变更事件不跨窗口
265
+
266
+ 每个 `open` 得到一条**独立**的 `DatabaseSync` 连接——共享连接会让多个窗口的 `BEGIN` 块互相穿插,事务隔离直接失效。代价是变更通知只在写入所在的那条连接上开火:通知靠 TEMP 触发器实现,而 TEMP 对象是连接私有的。
267
+
268
+ 所以 **A 窗口的写入不会触发 B 窗口的响应式查询**。数据本身是一致的(同一个文件,SQLite 的锁保证了这点),不一致的只是「B 什么时候知道」——B 要等到自己下一次查询才看到新数据。
269
+
270
+ 需要跨窗口实时同步的话,目前得由宿主应用自己广播(例如主进程把 `postChange` 收到的事件转发给其余 `webContents`,各 renderer 收到后主动重查)。
271
+
272
+ ### PGlite:单实例 + 事务 ID
273
+
274
+ PGlite 的 data directory **不支持两个实例并发打开**,所以 host 按数据目录名做单实例复用:第二个窗口打开同一目录时拿到的是同一个实例,而不是第二份。这既是必需的,也顺带解决了 SQLite 侧那个跨窗口通知问题——`LISTEN` 在实例建立时**一次性**订阅(每会话订一次会让同一条 `NOTIFY` 回调多次),收到通知后扇出给该实例名下的全部会话,于是 **A 窗口的写入会触发 B 窗口的响应式查询**。
275
+
276
+ 代价在事务上:所有事务排队争用那一条连接。`begin` 等不到连接就报 `transaction_unavailable` 而不是无限等待——无限等待会让现场看起来像「数据库不响应」。事务 ID 是随机 UUID 且绑定会话,归属对不上一律 `transaction_not_found`。
277
+
278
+ 窗口崩溃或关闭时,该会话名下所有在途事务被回滚并释放(`releaseOwner` 挂在 `render-process-gone` 与窗口 `destroyed` 上),不留悬挂事务。
279
+
280
+ #### PGlite host 不要放在主线程
281
+
282
+ PGlite 的 WASM 在**调用线程上同步**跑完整条查询:实测一条 2 秒的查询把主进程堵了 2007 毫秒——窗口不重绘、菜单不响应、IPC 全排队。「主进程持有单实例」和「主进程不能被堵住」因此无法同时成立,单实例得挪到自己的线程上。
283
+
284
+ 而且要搬就得**整个 host 一起搬**,不能只把 PGlite 放过去:`ElectronPgliteRuntime.transaction` 收的是一个回调,回调过不了线程边界(和它过不了 IPC 边界是同一个原因)。host 搬过去之后,线程边界上流动的就只剩 `DesktopPgliteRequest` / `DesktopPgliteResponse` 这类纯数据。示例见下。
285
+
286
+ ## 完整示例
287
+
288
+ 参考 [dev-rxdb-electron](https://github.com/aiao-io/rxdb/tree/main/apps/dev-rxdb-electron):
289
+
290
+ - **SQLite**:`src-electron/desktop-sqlite-bridge.ts`(主进程接线与窗口归属)
291
+ - **PGlite**:`src-electron/desktop-pglite-bridge.ts` + `src-electron/desktop-pglite-worker.ts`——**整个 host 连同 WASM 跑在 worker 线程里**,桥接只做归属记账与转发
292
+ - 两族共用:`src-electron/preload.ts`(桥接暴露)、`src/app/services/desktop-database.service.ts`(renderer 侧使用)
293
+
294
+ > 三个目录根**必须互不相同**:PGlite 数据目录是一棵会被整体删除/重建的树,把 SQLite 库文件或用户文件混一层进去,一次重建就连带删掉它们。
@@ -0,0 +1,35 @@
1
+ import { RxDB } from '@aiao/rxdb';
2
+ import { RxDBAdapterSqliteBase } from '@aiao/rxdb-adapter-sqlite-core';
3
+ import { DesktopSqliteClient, DesktopOptions } from '@aiao/rxdb-adapter-sqlite-core/desktop-host';
4
+ import { ADAPTER_NAME } from './electron-adapter.interface.js';
5
+ /**
6
+ * 把 RxDB 接到 Electron 应用私有目录里的真实 SQLite 文件上。
7
+ *
8
+ * @remarks
9
+ * 数据落在主进程的 `node:sqlite` 连接里,renderer 只通过一条窄传输层发请求,
10
+ * 因此它既拿不到文件系统句柄,也拿不到物理路径(AC#3)。
11
+ *
12
+ * 适配器本身没有自己的查询实现:它复用 {@link RxDBAdapterSqliteBase} 的全套 SQL、事务
13
+ * 与分支切换逻辑,只把「客户端从哪来」换成了桌面 host。Tauri 侧是同构的另一个类
14
+ * (`@aiao/rxdb-adapter-tauri` 的 `RxDBAdapterTauri`),两者共用同一份线协议与 renderer client。
15
+ */
16
+ export declare class RxDBAdapterElectron extends RxDBAdapterSqliteBase {
17
+ #private;
18
+ readonly options: DesktopOptions;
19
+ readonly name: string;
20
+ /** 解析出的逻辑数据库名,与 host 侧的物理文件一一对应。 */
21
+ get databaseName(): string;
22
+ /**
23
+ * @param rxdb - 绑定的 RxDB 实例
24
+ * @param options - 传输层与逻辑数据库名
25
+ * @throws {@link RxDBAdapterDesktopError} 逻辑数据库名越出应用作用域时抛 `invalid_database_name`
26
+ */
27
+ constructor(rxdb: RxDB, options?: DesktopOptions);
28
+ protected createClient(): Promise<DesktopSqliteClient>;
29
+ }
30
+ declare module '@aiao/rxdb' {
31
+ interface RxDBAdapters {
32
+ [ADAPTER_NAME]: RxDBAdapterElectron;
33
+ }
34
+ }
35
+ //# sourceMappingURL=RxDBAdapterElectron.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"RxDBAdapterElectron.d.ts","sourceRoot":"","sources":["../src/RxDBAdapterElectron.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,YAAY,CAAC;AACvC,OAAO,EAAE,qBAAqB,EAAE,MAAM,gCAAgC,CAAC;AACvE,OAAO,EAEL,mBAAmB,EAGnB,KAAK,cAAc,EACpB,MAAM,6CAA6C,CAAC;AACrD,OAAO,EAAE,YAAY,EAAE,MAAM,iCAAiC,CAAC;AAE/D;;;;;;;;;;GAUG;AACH,qBAAa,mBAAoB,SAAQ,qBAAqB;;IAgB1D,QAAQ,CAAC,OAAO,EAAE,cAAc;IAdlC,SAAkB,IAAI,EAAE,MAAM,CAAgB;IAE9C,oCAAoC;IACpC,IAAI,YAAY,IAAI,MAAM,CAEzB;IAED;;;;OAIG;gBAED,IAAI,EAAE,IAAI,EACD,OAAO,GAAE,cAAmB;cAQd,YAAY,IAAI,OAAO,CAAC,mBAAmB,CAAC;CAOtE;AAED,OAAO,QAAQ,YAAY,CAAC;IAC1B,UAAU,YAAY;QACpB,CAAC,YAAY,CAAC,EAAE,mBAAmB,CAAC;KACrC;CACF"}
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Electron 适配器在本包内的身份。
3
+ *
4
+ * @remarks
5
+ * 选项形状与逻辑文件名后缀已下沉到 `@aiao/rxdb-adapter-sqlite-core/desktop-host`
6
+ * (两个桌面运行时共用同一份);留在这里的只有**注册名**——它是每个运行时包各自的身份,
7
+ * 共享层不替它们定名。
8
+ *
9
+ * 名字按 `<引擎>-<运行时>` 构成(与 `@aiao/rxdb-adapter-miniprogram` 的
10
+ * `wa-sqlite-miniprogram` 同构)。它不能只叫 `electron`:US-208 会在同一个运行时上
11
+ * 再立一个 PGlite 适配器(`pglite-electron`),到时两者都要能同时注册。
12
+ *
13
+ * @module electron-adapter.interface
14
+ */
15
+ /**
16
+ * 适配器在 `RxDBAdapters` 注册表中的名字。
17
+ *
18
+ * @remarks
19
+ * `satisfies` 把它钉在共享层的登记表上:这个名字改了而 `DESKTOP_HOST_ADAPTER_NAMES` 没跟上,
20
+ * 是编译错误而不是某个下游在运行期才发现的 `adapter_mismatch`。
21
+ */
22
+ export declare const ADAPTER_NAME: "sqlite-electron";
23
+ //# sourceMappingURL=electron-adapter.interface.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"electron-adapter.interface.d.ts","sourceRoot":"","sources":["../src/electron-adapter.interface.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAIH;;;;;;GAMG;AACH,eAAO,MAAM,YAAY,mBAA8D,CAAC"}
@@ -0,0 +1,52 @@
1
+ import { DesktopHostFileResponse } from '@aiao/rxdb-adapter-sqlite-core/desktop-host';
2
+ /** {@link createElectronFileHost} 的入参。 */
3
+ export interface ElectronFileHostOptions {
4
+ /**
5
+ * 解析文件存储根的物理绝对路径。
6
+ *
7
+ * @remarks
8
+ * 由宿主应用提供,因为只有它知道自己的数据目录(Electron 下通常是
9
+ * `app.getPath('userData')` 下的一个子目录)。每次请求都会调用它而不是只调一次:
10
+ * Electron 里 `userData` 在 `app.ready` 之前拿不到,缓存会把「还没准备好」永久固化。
11
+ */
12
+ readonly resolveStorageRoot: () => string;
13
+ }
14
+ /** 桌面文件 host 实例。 */
15
+ export interface ElectronFileHost {
16
+ /**
17
+ * 处理一条来自 renderer 的文件请求。
18
+ *
19
+ * @remarks
20
+ * **永不 reject**:失败以 `kind: 'error'` 的应答返回。Electron 的 `ipcRenderer.invoke`
21
+ * 在 reject 时会把错误压平成字符串,自定义错误码随之丢失。
22
+ *
23
+ * @param request - 未经校验的请求负载
24
+ * @returns 协议应答
25
+ */
26
+ handle(request: unknown): Promise<DesktopHostFileResponse>;
27
+ /** 当前打开的会话数,用于诊断与关停检查。 */
28
+ readonly openSessionCount: number;
29
+ /**
30
+ * 当前仍被跟踪的锁名个数,用于诊断。
31
+ *
32
+ * @remarks
33
+ * 锁名是逐文件的:稳态下它应当回到 0,长期只增不减就说明队列没被回收。
34
+ */
35
+ readonly trackedLockNameCount: number;
36
+ /**
37
+ * 关闭全部会话,通常在应用退出前调用。
38
+ *
39
+ * @remarks
40
+ * 返回的 Promise 落地才意味着未提交写入的临时文件真的从磁盘上消失了。
41
+ * 调用方(退出钩子)必须等它 —— 不等就等于没清,进程先一步退了。
42
+ */
43
+ closeAll(): Promise<void>;
44
+ }
45
+ /**
46
+ * 创建一个桌面文件 host。
47
+ *
48
+ * @param options - host 配置
49
+ * @returns host 实例
50
+ */
51
+ export declare function createElectronFileHost(options: ElectronFileHostOptions): ElectronFileHost;
52
+ //# sourceMappingURL=electron-file-host.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"electron-file-host.d.ts","sourceRoot":"","sources":["../src/electron-file-host.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,EAaL,KAAK,uBAAuB,EAM7B,MAAM,6CAA6C,CAAC;AAkBrD,0CAA0C;AAC1C,MAAM,WAAW,uBAAuB;IACtC;;;;;;;OAOG;IACH,QAAQ,CAAC,kBAAkB,EAAE,MAAM,MAAM,CAAC;CAC3C;AAED,oBAAoB;AACpB,MAAM,WAAW,gBAAgB;IAC/B;;;;;;;;;OASG;IACH,MAAM,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,uBAAuB,CAAC,CAAC;IAC3D,0BAA0B;IAC1B,QAAQ,CAAC,gBAAgB,EAAE,MAAM,CAAC;IAClC;;;;;OAKG;IACH,QAAQ,CAAC,oBAAoB,EAAE,MAAM,CAAC;IACtC;;;;;;OAMG;IACH,QAAQ,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CAC3B;AA0MD;;;;;GAKG;AACH,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,uBAAuB,GAAG,gBAAgB,CAmczF"}
@@ -0,0 +1,65 @@
1
+ import { DesktopHostChangeEventMessage, DesktopHostResponse } from '@aiao/rxdb-adapter-sqlite-core/desktop-host';
2
+ /** {@link createElectronSqliteHost} 的入参。 */
3
+ export interface ElectronSqliteHostOptions {
4
+ /**
5
+ * 把逻辑数据库名解析成物理绝对路径。
6
+ *
7
+ * @remarks
8
+ * 由宿主应用提供,因为只有它知道自己的数据目录(Electron 下通常是
9
+ * `app.getPath('userData')`)。传进来的名字已经过白名单校验,不含任何路径分隔符,
10
+ * 因此 `join(root, databaseName)` 不可能越出 `root`。
11
+ */
12
+ readonly resolveDatabasePath: (databaseName: string) => string;
13
+ /** 把变更事件送达对应会话的 renderer,例如 `webContents.send`。 */
14
+ readonly postChange: (message: DesktopHostChangeEventMessage) => void;
15
+ /**
16
+ * 变更事件送达失败时的上报口。
17
+ *
18
+ * @remarks
19
+ * 窗口在写入途中被销毁是常规竞态,此时写入已经落库;若把送达失败当成写失败回给调用方,
20
+ * 它会重试一次已经成功的写入,产生重复数据。所以送达是 best-effort,失败只上报不影响写结果。
21
+ * 不传则丢弃——但那意味着这类竞态在日志里完全无痕,建议显式接上。
22
+ */
23
+ readonly onDeliveryError?: (error: unknown) => void;
24
+ /** SQLite page cache 大小(KB)。 */
25
+ readonly cacheSizeKb?: number;
26
+ /**
27
+ * 开事务时撞上另一个连接的锁后,重试多久才放弃(毫秒),默认
28
+ * {@link DEFAULT_BUSY_RETRY_BUDGET_MS}。
29
+ *
30
+ * @remarks
31
+ * 主要留给测试收窄等待;生产上调它意味着你对「对方最长持锁多久」有更准的判断。
32
+ */
33
+ readonly busyRetryBudgetMs?: number;
34
+ }
35
+ /** 桌面 SQLite host 实例。 */
36
+ export interface ElectronSqliteHost {
37
+ /**
38
+ * 处理一条来自 renderer 的请求。
39
+ *
40
+ * @remarks
41
+ * **永不 reject**:失败以 `kind: 'error'` 的应答返回。Electron 的 `ipcRenderer.invoke`
42
+ * 在 reject 时会把错误压平成字符串,自定义错误码随之丢失。
43
+ *
44
+ * @param request - 未经校验的请求负载
45
+ * @returns 协议应答
46
+ */
47
+ handle(request: unknown): Promise<DesktopHostResponse>;
48
+ /** 当前打开的会话数,用于诊断与关停检查。 */
49
+ readonly openSessionCount: number;
50
+ /** 关闭全部会话,通常在应用退出前调用。 */
51
+ closeAll(): void;
52
+ }
53
+ /**
54
+ * 创建一个桌面 SQLite host。
55
+ *
56
+ * @remarks
57
+ * 每个 `open` 请求得到**独立的** `DatabaseSync` 连接,而不是共享一条:
58
+ * 多个窗口共用连接时它们的 `BEGIN` 块会互相穿插,事务隔离直接失效。
59
+ * 各持连接后,跨窗口并发交由 SQLite 自己的文件锁处理(AC#5)。
60
+ *
61
+ * @param options - host 配置
62
+ * @returns host 实例
63
+ */
64
+ export declare function createElectronSqliteHost(options: ElectronSqliteHostOptions): ElectronSqliteHost;
65
+ //# sourceMappingURL=electron-sqlite-host.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"electron-sqlite-host.d.ts","sourceRoot":"","sources":["../src/electron-sqlite-host.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAGH,OAAO,EAIL,KAAK,6BAA6B,EAElC,KAAK,mBAAmB,EACzB,MAAM,6CAA6C,CAAC;AAmDrD,4CAA4C;AAC5C,MAAM,WAAW,yBAAyB;IACxC;;;;;;;OAOG;IACH,QAAQ,CAAC,mBAAmB,EAAE,CAAC,YAAY,EAAE,MAAM,KAAK,MAAM,CAAC;IAC/D,mDAAmD;IACnD,QAAQ,CAAC,UAAU,EAAE,CAAC,OAAO,EAAE,6BAA6B,KAAK,IAAI,CAAC;IACtE;;;;;;;OAOG;IACH,QAAQ,CAAC,eAAe,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,CAAC;IACpD,gCAAgC;IAChC,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B;;;;;;OAMG;IACH,QAAQ,CAAC,iBAAiB,CAAC,EAAE,MAAM,CAAC;CACrC;AAED,yBAAyB;AACzB,MAAM,WAAW,kBAAkB;IACjC;;;;;;;;;OASG;IACH,MAAM,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,mBAAmB,CAAC,CAAC;IACvD,0BAA0B;IAC1B,QAAQ,CAAC,gBAAgB,EAAE,MAAM,CAAC;IAClC,yBAAyB;IACzB,QAAQ,IAAI,IAAI,CAAC;CAClB;AA4BD;;;;;;;;;;GAUG;AACH,wBAAgB,wBAAwB,CAAC,OAAO,EAAE,yBAAyB,GAAG,kBAAkB,CA0I/F"}
package/dist/host.d.ts ADDED
@@ -0,0 +1,19 @@
1
+ /**
2
+ * `@aiao/rxdb-adapter-electron` 的**特权侧**入口。
3
+ *
4
+ * @remarks
5
+ * 本入口引用 `node:sqlite`,只能在有 Node 运行时的地方加载——Electron 主进程,
6
+ * 或它自己拥有的 `worker_threads` worker。**不要**把它打进 renderer bundle:
7
+ * 那等于把文件系统能力还给了渲染进程,AC#3 的隔离随之作废。
8
+ *
9
+ * renderer 侧请用 `@aiao/rxdb-adapter-electron`。
10
+ *
11
+ * @module @aiao/rxdb-adapter-electron/host
12
+ */
13
+ export { createElectronSqliteHost, type ElectronSqliteHost, type ElectronSqliteHostOptions } from './electron-sqlite-host.js';
14
+ export { createElectronFileHost, type ElectronFileHost, type ElectronFileHostOptions } from './electron-file-host.js';
15
+ export { NodeSqliteEngine, type NodeSqliteEngineOptions } from './node-sqlite-engine.js';
16
+ export { RxDBAdapterDesktopError, type RxDBAdapterDesktopErrorCode } from '@aiao/rxdb-adapter-sqlite-core/desktop-host';
17
+ export { assertDesktopSqliteStorage, assertValidDesktopDatabaseName, isDesktopPgliteDirectoryStorage, isDesktopSqliteFileStorage, type DesktopPgliteDirectoryStorage, type DesktopSqliteFileStorage, type DesktopStorage } from '@aiao/rxdb-adapter-sqlite-core/desktop-host';
18
+ export { DESKTOP_HOST_MAX_BINDINGS, DESKTOP_HOST_MAX_BLOB_BYTES, DESKTOP_HOST_MAX_FILE_CHUNK_BYTES, DESKTOP_HOST_MAX_SQL_LENGTH, DESKTOP_HOST_PROTOCOL_VERSION, isDesktopHostFileRequestKind, parseDesktopHostFileRequest, parseDesktopHostRequest, type DesktopHostChangeEventMessage, type DesktopHostCloseRequest, type DesktopHostExecuteRequest, type DesktopHostFileEntry, type DesktopHostFileReadResult, type DesktopHostFileRequest, type DesktopHostFileResponse, type DesktopHostFileStat, type DesktopHostHandshakeRequest, type DesktopHostHandshakeResult, type DesktopHostOpenRequest, type DesktopHostOpenResult, type DesktopHostRequest, type DesktopHostResponse, type DesktopHostVersionRequest } from '@aiao/rxdb-adapter-sqlite-core/desktop-host';
19
+ //# sourceMappingURL=host.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"host.d.ts","sourceRoot":"","sources":["../src/host.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,EACL,wBAAwB,EACxB,KAAK,kBAAkB,EACvB,KAAK,yBAAyB,EAC/B,MAAM,2BAA2B,CAAC;AAEnC,OAAO,EAAE,sBAAsB,EAAE,KAAK,gBAAgB,EAAE,KAAK,uBAAuB,EAAE,MAAM,yBAAyB,CAAC;AAEtH,OAAO,EAAE,gBAAgB,EAAE,KAAK,uBAAuB,EAAE,MAAM,yBAAyB,CAAC;AAKzF,OAAO,EAAE,uBAAuB,EAAE,KAAK,2BAA2B,EAAE,MAAM,6CAA6C,CAAC;AAExH,OAAO,EACL,0BAA0B,EAC1B,8BAA8B,EAC9B,+BAA+B,EAC/B,0BAA0B,EAC1B,KAAK,6BAA6B,EAClC,KAAK,wBAAwB,EAC7B,KAAK,cAAc,EACpB,MAAM,6CAA6C,CAAC;AAErD,OAAO,EACL,yBAAyB,EACzB,2BAA2B,EAC3B,iCAAiC,EACjC,2BAA2B,EAC3B,6BAA6B,EAC7B,4BAA4B,EAC5B,2BAA2B,EAC3B,uBAAuB,EACvB,KAAK,6BAA6B,EAClC,KAAK,uBAAuB,EAC5B,KAAK,yBAAyB,EAC9B,KAAK,oBAAoB,EACzB,KAAK,yBAAyB,EAC9B,KAAK,sBAAsB,EAC3B,KAAK,uBAAuB,EAC5B,KAAK,mBAAmB,EACxB,KAAK,2BAA2B,EAChC,KAAK,0BAA0B,EAC/B,KAAK,sBAAsB,EAC3B,KAAK,qBAAqB,EAC1B,KAAK,kBAAkB,EACvB,KAAK,mBAAmB,EACxB,KAAK,yBAAyB,EAC/B,MAAM,6CAA6C,CAAC"}