{"_id":"@aiao/rxdb-adapter-desktop","name":"@aiao/rxdb-adapter-desktop","dist-tags":{"latest":"0.0.25"},"versions":{"0.0.25":{"name":"@aiao/rxdb-adapter-desktop","version":"0.0.25","license":"MIT","repository":{"type":"git","url":"git+https://github.com/aiao-io/rxdb.git","directory":"packages/rxdb-adapter-desktop"},"type":"module","main":"./dist/index.js","module":"./dist/index.js","types":"./dist/index.d.ts","sideEffects":false,"exports":{"./package.json":"./package.json",".":{"types":"./dist/index.d.ts","import":"./dist/index.js","default":"./dist/index.js"},"./host":{"types":"./dist/host.d.ts","import":"./dist/host.js","default":"./dist/host.js"}},"publishConfig":{"access":"public"},"nx":{"name":"rxdb-adapter-desktop","tags":["js-lib"]},"dependencies":{"@aiao/rxdb":"0.0.25","@aiao/rxdb-adapter-sqlite-core":"0.0.25"},"devDependencies":{"@aiao/rxdb-test":"0.0.25"},"_id":"@aiao/rxdb-adapter-desktop@0.0.25","description":"RxDB 适配器，把数据落到**桌面应用私有目录里的真实 SQLite 文件**。","bugs":{"url":"https://github.com/aiao-io/rxdb/issues"},"homepage":"https://github.com/aiao-io/rxdb#readme","_integrity":"sha512-ZHSXvz89IFi20U4Zr9aZ24houLHPq8n9WsnM3Mrfs+ExWNi0ItZ6ssgNKn/qJcIuVBJgEYpaz4QB9+y6k5F8nQ==","_resolved":"/tmp/2931610b9729850c41ead6f261e9d3bc/aiao-rxdb-adapter-desktop-0.0.25.tgz","_from":"file:aiao-rxdb-adapter-desktop-0.0.25.tgz","_nodeVersion":"26.7.0","_npmVersion":"11.19.0","dist":{"integrity":"sha512-ZHSXvz89IFi20U4Zr9aZ24houLHPq8n9WsnM3Mrfs+ExWNi0ItZ6ssgNKn/qJcIuVBJgEYpaz4QB9+y6k5F8nQ==","shasum":"ba275649647a0c71166636d10ae48762ef239365","tarball":"https://registry.npmjs.org/@aiao/rxdb-adapter-desktop/-/rxdb-adapter-desktop-0.0.25.tgz","fileCount":39,"unpackedSize":156360,"attestations":{"url":"https://registry.npmjs.org/-/npm/v1/attestations/@aiao%2frxdb-adapter-desktop@0.0.25","provenance":{"predicateType":"https://slsa.dev/provenance/v1"}},"signatures":[{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEUCIDkzNhiboDrc5SeQd6Y5NjceXb1RCeiROM6LrZXN9A4qAiEAz8INL1FFdwSR+ZkhMhw+mW4TRKhFTX4iYfXMuSWDV+Q="}]},"_npmUser":{"name":"aiao","email":"hero63418@gmail.com"},"directories":{},"maintainers":[{"name":"aiao","email":"hero63418@gmail.com"}],"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/rxdb-adapter-desktop_0.0.25_1786694792675_0.5802119146218347"},"_hasShrinkwrap":false}},"time":{"created":"2026-08-14T08:06:32.473Z","0.0.25":"2026-08-14T08:06:32.929Z","modified":"2026-08-14T08:06:33.342Z"},"maintainers":[{"name":"aiao","email":"hero63418@gmail.com"}],"description":"RxDB 适配器，把数据落到**桌面应用私有目录里的真实 SQLite 文件**。","homepage":"https://github.com/aiao-io/rxdb#readme","repository":{"type":"git","url":"git+https://github.com/aiao-io/rxdb.git","directory":"packages/rxdb-adapter-desktop"},"bugs":{"url":"https://github.com/aiao-io/rxdb/issues"},"license":"MIT","readme":"# @aiao/rxdb-adapter-desktop\n\nRxDB 适配器，把数据落到**桌面应用私有目录里的真实 SQLite 文件**。\n\n数据由特权侧（Electron 主进程或它拥有的 worker）用 `node:sqlite` 直接读写；渲染进程只通过一条窄传输层发协议请求，因此它既拿不到文件系统句柄，也拿不到物理路径。\n\n## 功能特性\n\n- **真文件持久化**：数据在应用数据目录里的 `.sqlite3` 文件中，重启后仍在，不依赖浏览器存储配额\n- **渲染进程零文件系统权限**：`contextIsolation: true` + `sandbox: true` 下照常工作\n- **多窗口安全**：同一文件上的多个窗口连接共享 writer lease 与 `BEGIN IMMEDIATE` 事务，撞锁自动重试\n- **不回退**：runtime 与 engine 的组合不受支持时直接抛错，绝不静默切到 memory/OPFS/IndexedDB\n- **复用 SQL 核心**：查询、事务、分支切换、writer lease 全部来自 `@aiao/rxdb-adapter-sqlite-core`，与 wa-sqlite / sqlite-wasm 同语义\n\n## 何时使用\n\n- Electron 应用需要「用户看得见、备份得了、卸载才会没」的本地数据\n- 数据量超出浏览器存储配额的舒适区，或不接受 OPFS 被浏览器回收的风险\n- 需要外部工具（`sqlite3` CLI、DB Browser）直接打开同一份数据\n\n浏览器内运行请改用 [`@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)。\n\n## 能力矩阵\n\n| 运行时   | SQLite 单文件                  | PGlite data directory         |\n| -------- | ------------------------------ | ----------------------------- |\n| Electron | ✅                             | ❌ 未实现，见 US-208          |\n| Tauri    | ⚠️ 仅存储形状与校验，见 US-210 | ❌ 永不支持（无 Node 主进程） |\n\n不在矩阵内的组合会被 `assertSupportedDesktopStorage` 以 `unsupported_runtime_engine` 拒绝。Tauri 侧目前只有类型与校验：host 实现基于 `node:sqlite`，Tauri 没有 Node 主进程，需要另一套 host。\n\nhost 侧需要一个内置 `node:sqlite` 的运行时。本包在 Node 26 与 Electron 43 上验证，更早的版本未验证。\n\n## 安装\n\n```bash\nnpm install @aiao/rxdb-adapter-desktop\n# 或\npnpm add @aiao/rxdb-adapter-desktop\n```\n\n## 两个入口\n\n包**刻意**分成两个入口，不要混用：\n\n| 入口                              | 加载位置                          | 是否引用 `node:sqlite` |\n| --------------------------------- | --------------------------------- | ---------------------- |\n| `@aiao/rxdb-adapter-desktop`      | renderer（浏览器上下文）          | 否，可安全打进 bundle  |\n| `@aiao/rxdb-adapter-desktop/host` | Electron 主进程 / 它拥有的 worker | 是                     |\n\n把 `/host` 打进 renderer bundle 等于把文件系统能力还给渲染进程，整个隔离随之作废。\n\n## 使用\n\n接线分三处。\n\n### 1. 主进程：起 host\n\n```typescript\nimport { createDesktopSqliteHost } from '@aiao/rxdb-adapter-desktop/host';\nimport { app, ipcMain, type WebContents } from 'electron';\nimport { mkdirSync } from 'node:fs';\nimport { join } from 'node:path';\n\nconst root = join(app.getPath('userData'), 'rxdb-data');\n\n// sessionId → 该会话归属的窗口。open 应答里带 sessionId，变更事件按它回送。\nconst targets = new Map<string, WebContents>();\n\nconst host = createDesktopSqliteHost({\n  // 只有宿主应用知道自己的数据目录。传进来的名字已过白名单校验，不含任何路径分隔符。\n  resolveDatabasePath: databaseName => {\n    mkdirSync(root, { recursive: true });\n    return join(root, databaseName);\n  },\n  postChange: message => {\n    const target = targets.get(message.sessionId);\n    // 窗口已经没了：写入早已落库，事件无处可送。这是常规竞态而不是失败。\n    if (!target || target.isDestroyed()) return;\n    target.send('desktop-sqlite:change', message);\n  },\n  onDeliveryError: error => console.warn('[desktop-sqlite] 变更事件送达失败', error)\n});\n\n// host.handle 永不 reject：失败以 `kind: 'error'` 的应答返回。\n// ipcRenderer.invoke 在 reject 时会把错误压平成字符串，自定义错误码随之丢失。\nipcMain.handle('desktop-sqlite:request', async (event, payload: unknown) => {\n  const response = await host.handle(payload);\n  if (response.kind === 'open') targets.set(response.result.sessionId, event.sender);\n  return response;\n});\n\napp.on('before-quit', () => host.closeAll());\n```\n\n会话回收（窗口 `destroyed` 时关掉它名下未关闭的会话）比上面这段更啰嗦一些，完整实现见文末示例里的 `desktop-sqlite-bridge.ts`。\n\n### 2. preload：暴露传输层\n\n传输层只有两个方法，renderer 因此拿不到原始 `ipcRenderer`，无法向任意频道发消息。\n\n```typescript\nimport { contextBridge, ipcRenderer, type IpcRendererEvent } from 'electron';\n\n// 必须与适配器的 DESKTOP_HOST_TRANSPORT_KEY 逐字相同。\ncontextBridge.exposeInMainWorld('__aiaoRxdbDesktopHost__', {\n  request: (payload: unknown) => ipcRenderer.invoke('desktop-sqlite:request', payload),\n  subscribe: (listener: (message: unknown) => void) => {\n    // 只转消息本体：IpcRendererEvent 带着 sender，交给 renderer 等于把通道能力一并送出。\n    const forward = (_event: IpcRendererEvent, message: unknown): void => listener(message);\n    ipcRenderer.on('desktop-sqlite:change', forward);\n    return () => ipcRenderer.removeListener('desktop-sqlite:change', forward);\n  }\n});\n```\n\n### 3. renderer：像用别的适配器一样用\n\n```typescript\nimport { RxDB, SyncType } from '@aiao/rxdb';\nimport { DESKTOP_ADAPTER_NAME, RxDBAdapterDesktop } from '@aiao/rxdb-adapter-desktop';\n\nconst rxdb = new RxDB({\n  dbName: 'demo',\n  entities: [],\n  sync: { type: SyncType.None, local: { adapter: DESKTOP_ADAPTER_NAME } }\n});\n\n// 不传 transport：适配器自己去全局键上找 preload 暴露的桥接。\nrxdb.adapter(DESKTOP_ADAPTER_NAME, async database => new RxDBAdapterDesktop(database));\nrxdb.init();\nawait rxdb.connect(DESKTOP_ADAPTER_NAME);\n\n// 组件/窗口销毁时把连接交还给 host，否则会话要等到窗口 'destroyed' 才回收。\nawait rxdb.disconnectAll();\n```\n\n## 逻辑库名不是路径\n\n`databaseName` 是**应用作用域内的逻辑名**。renderer 无从得知、也不需要得知物理根目录。\n\n省略时按 `${rxdb.config.dbName}.sqlite3` 推导；只有接管一个已存在的库、或多个 RxDB 实例共用同一个文件时才需要显式指定。\n\n允许集是白名单 `/^[A-Za-z0-9][A-Za-z0-9._@-]*$/`（≤ 128 字符）而非黑名单：字符集里没有 `/`、`\\`、`:`，也不允许以 `.` 开头，于是 `..`、绝对路径、盘符、`~` 展开、URL scheme 全部落在集合外，不需要逐一枚举攻击形态。违反时抛 `invalid_database_name`。\n\n> 名字来自 renderer，即便有 `contextIsolation` 也**不可信**。host 侧会再校验一次；宿主应用的 `resolveDatabasePath` 里建议在 `mkdir` 之前再调一次 `assertValidDesktopDatabaseName` —— 非法入参不该在磁盘上留下任何痕迹。\n\n## ⚠️ 库目录不要叫 `databases`\n\n在 Electron 的 `userData` 下选子目录名时，**避开 `databases`**：那是 Chromium 自己的 WebSQL 目录，它的存储层启动时会把目录里没有登记过的文件全部删掉——你的库文件正是「没登记过的」。\n\n实测（打包产物，macOS，同一个 `--user-data-dir` 连开两次）：第一次启动写入的数据在第二次启动时被整体清空，全程**没有任何报错**，应用照常显示已连接、照常写入，只是上一次的数据没了。同一层级另建的 `rxdb-data/` 毫发无损。\n\n## 错误码\n\n程序分支请读 `error.code`，不要匹配消息文本（消息以 `[code] ` 前缀开头，仅便于日志检索）。原始原因通过 `cause` 原样透传。\n\n| code                         | 含义                                                             |\n| ---------------------------- | ---------------------------------------------------------------- |\n| `unsupported_runtime_engine` | runtime 与 engine 的组合不在能力矩阵内                           |\n| `invalid_database_name`      | 逻辑库名非法，或试图越出应用作用域                               |\n| `host_unavailable`           | renderer 拿不到 host（未注入 transport / preload 未暴露）        |\n| `session_closed`             | 会话已断开后继续使用                                             |\n| `protocol_violation`         | 请求或响应不符合协议形状                                         |\n| `open_failed`                | 打开数据库失败，`cause` 保留原始原因                             |\n| `permission_denied`          | 路径无权限                                                       |\n| `database_corrupted`         | 目标文件不是可用的 SQLite 数据库                                 |\n| `statement_failed`           | SQL 本身执行失败（语法、约束等）                                 |\n| `host_internal_error`        | host 自身出错，属于缺陷而非调用方问题                            |\n| `database_busy`              | 另一个连接（通常是另一个窗口）正持有冲突的锁，重试即可，数据无损 |\n\n错误码是**契约的一部分**：新增只能追加，不得复用或改写既有含义。\n\n`RxDBAdapterDesktopError` 这个类跨不过结构化克隆，host 侧的错误以 `{ kind: 'error', code, message }` 回到 renderer，由适配器按契约重新抛成 `RxDBAdapterDesktopError`——调用方写的仍是普通 `try/catch`，感觉不到中间隔着一条 IPC。不在契约内的 `code` 一律按 `protocol_violation` 处理，不会被当成错误码原样上抛。\n\n## 多窗口与事务\n\n事务用 `BEGIN IMMEDIATE` 而非裸 `BEGIN`：后者延迟取锁，写锁要等到事务里第一条写语句才拿，撞锁于是发生在**事务中途**——那时已经读过一份快照，SQLite 要求整个事务回滚重来。`IMMEDIATE` 把取锁挪到起点，撞锁时事务还没开，重试就是无副作用地再发一次。\n\n撞锁后按指数退避重试，默认总预算 5 秒（盖住「另一个窗口正在跑一次系统 schema 迁移」这段最长持锁时间），超时报 `database_busy`。可用 host 的 `busyRetryBudgetMs` 调整。\n\n### 变更事件不跨窗口\n\n每个 `open` 得到一条**独立**的 `DatabaseSync` 连接——共享连接会让多个窗口的 `BEGIN` 块互相穿插，事务隔离直接失效。代价是变更通知只在写入所在的那条连接上开火：通知靠 TEMP 触发器实现，而 TEMP 对象是连接私有的。\n\n所以 **A 窗口的写入不会触发 B 窗口的响应式查询**。数据本身是一致的（同一个文件，SQLite 的锁保证了这点），不一致的只是「B 什么时候知道」——B 要等到自己下一次查询才看到新数据。\n\n需要跨窗口实时同步的话，目前得由宿主应用自己广播（例如主进程把 `postChange` 收到的事件转发给其余 `webContents`，各 renderer 收到后主动重查）。\n\n## 完整示例\n\n参考 [dev-rxdb-electron](https://github.com/aiao-io/rxdb/tree/main/apps/dev-rxdb-electron)：`src-electron/desktop-sqlite-bridge.ts`（主进程接线与窗口归属）、`src-electron/preload.ts`（桥接暴露）、`src/app/services/desktop-database.service.ts`（renderer 侧使用）。\n","readmeFilename":"README.md","_rev":"1-0ea708330ff94aa39194bd5df75776fc"}