rxdb-adapter-tauri
RxDB 适配器,把数据落到 Tauri 应用私有目录里的真实 SQLite 文件。
数据由 Rust 侧用 rusqlite 直接读写;WebView 只通过 invoke / listen 发协议请求,因此它既拿不到文件系统句柄,也拿不到物理路径。
Electron 请改用 @aiao/rxdb-adapter-electron:协议与 renderer 客户端两包共用同一份实现(在 @aiao/rxdb-adapter-sqlite-core/desktop-host),差别只在特权侧是 Rust 还是 node:sqlite。
功能特性
- 真文件持久化:数据在应用数据目录里的
.sqlite3文件中,重启后仍在,不依赖 WebView 存储配额 - WebView 零文件系统权限:不需要开
fs权限,也不需要把路径交给前端 - 不回退:存储配置不受支持时直接抛错,绝不静默切到 memory/OPFS/IndexedDB
- 复用 SQL 核心:查询、事务、分支切换全部来自
@aiao/rxdb-adapter-sqlite-core,与 wa-sqlite / sqlite-wasm 同语义
⚠️ Rust 宿主:同一个项目,但不经 npm 分发
本 npm 包只有 WebView 这一侧:createTauriHostTransport 是一根把 invoke / listen 接上协议的管子。管子那头真正开库的 rusqlite 宿主(rxdb_desktop_request 命令、引擎、会话表)是一个 Rust crate,随应用二进制走,npm 装不来。
它就在本包目录下的 rust/(crate 名 aiao-rxdb-tauri)——线协议的两端住在同一个项目里,改一端必然看见另一端。
该 crate 尚未发布到 crates.io,因此今天只能按 git 依赖引用:
# src-tauri/Cargo.toml
[dependencies]
aiao-rxdb-tauri = `{ git = "https://github.com/aiao-io/rxdb", tag = "v0.0.26" }`
限制说明与后续计划见 rust/README.md。
能力矩阵
| 存储 | 状态 |
|---|---|
| SQLite 单文件 | ✅ 适配器名 sqlite-tauri,Rust 宿主自备 |
| PGlite data directory | ❌ 永不支持:PGlite 的同步文件系统契约要 Node 主进程 |
不在矩阵内的组合会被 assertDesktopSqliteStorage 以 unsupported_runtime_engine 拒绝——不静默退化。
安装
npm install @aiao/rxdb-adapter-tauri
# 或
pnpm add @aiao/rxdb-adapter-tauri
使用
1. Rust 侧:注册命令、托管宿主、接上两处回收
aiao-rxdb-tauri 是普通 crate,不是 Tauri 插件,命令因此由应用自己 generate_handler! 注册:
use aiao_rxdb_tauri::commands::{rxdb_desktop_request, DesktopHost};
use tauri::Manager;
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![rxdb_desktop_request])
.setup(|app| {
let dir = app.path().app_data_dir()?;
// 第三个参数是**窗口白名单**:只有这些 label 的窗口调得动 `rxdb_desktop_request`。
app.manage(DesktopHost::new(app.handle(), dir, &["main"]));
Ok(())
})
// 窗口没了就回收它的会话,不等整个应用退出:挂 `Destroyed` 而不是 `CloseRequested`,
// 后者可被阻止,也不会在窗口崩溃时触发。带着独占文件锁消失的窗口会让另一个窗口的
// `lockAcquire` 无限期等下去。
.on_window_event(|window, event| {
if matches!(event, tauri::WindowEvent::Destroyed) {
window.state::<DesktopHost>().close_window(window.label());
}
})
.build(tauri::generate_context!())
.expect("error while building tauri application")
// 退出前显式关掉全部会话,否则 `-wal` / `-shm` 与文件句柄会活到进程被杀为止,
// 库文件在应用关闭后仍被占用。
.run(|app, event| {
if matches!(event, tauri::RunEvent::Exit) {
app.state::<DesktopHost>().close_all();
}
});
白名单没有默认值、也不可省略。应用自有命令对每一个 webview 开放(下一段说明为什么),
会话归属只挡得住「用别人的 sessionId」,挡不住「自己开一个新会话」——不点名 owner,
任何一扇窗口(调试窗口、将来某个忘了排除的窗口)都能自行打开应用作用域的库。
未列入白名单的窗口发来的请求会得到一条普通应答 { kind: "error", code: "permission_denied" }。
做成普通 crate 而不是插件是刻意的:generate_handler! 注册的应用自定义命令不受 capability 门禁约束(只有 core: / plugin: 前缀的命令才是),于是接上桌面数据库不需要给应用授予 sql / fs / shell 任何插件权限。做成插件的话命令会带上 plugin: 前缀,恰好落进门禁——省下的只是上面这段样板,换掉的却是一条结构性的安全性质。
不受门禁的只有 rxdb_desktop_request 这一条命令。变更事件那一半仍然要过门禁:listen 是 core:event:listen,用它的窗口需要 core:event:default(core:default 已经含了它,脚手架生成的 default.json 因此开箱可用)。真正会踩到的是自己裁过权限的窗口——那时症状很难看:invoke 通、库开得起来、查询也读得到数据,只是再也收不到变更事件,界面停在第一次查询的结果上,不报任何错。给那扇窗口的 capability 加上 core:event:default 即可。
命令名与事件名由本包的两个常量钉住(TAURI_DESKTOP_REQUEST_COMMAND / TAURI_DESKTOP_CHANGE_EVENT),改名两边就对不上。变更事件按 sessionId 回送,事件名为 rxdb-desktop-change。
2. WebView 侧:像用别的适配器一样用
import { RxDB, SyncType } from '@aiao/rxdb';
import { createTauriHostTransport, RxDBAdapterTauri, TAURI_ADAPTER_NAME } from '@aiao/rxdb-adapter-tauri';
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow';
const transport = createTauriHostTransport({ invoke, listen, target: getCurrentWebviewWindow().label });
const rxdb = new RxDB({
dbName: 'demo',
entities: [],
sync: `{ type: SyncType.None, local: { adapter: TAURI_ADAPTER_NAME } }`
});
rxdb.adapter(TAURI_ADAPTER_NAME, async database => new RxDBAdapterTauri(database, { transport }));
rxdb.init();
await rxdb.connect(TAURI_ADAPTER_NAME);
// 组件/窗口销毁时把连接交还给宿主,否则会话要等到进程退出才回收。
await rxdb.disconnectAll();
invoke / listen 由调用方注入而不是本包直接 import @tauri-apps/api:那样会把一个只在 Tauri 里存在的运行时依赖钉进包的依赖图,本包在浏览器测试环境里就再也加载不起来。
target 是必填的,它是定向投递的另一半。宿主用 emit_to(owner) 只把变更事件发给开出该会话的窗口,但 tauri 在监听者的 target 为 Any 时无条件匹配,而 listen() 的默认恰恰是 Any——收件侧不带 target 注册,任何能调 listen 的 webview 都会收到所有窗口的 sessionId、库名与表名。注入 getCurrentWebviewWindow().listen 也可以,那份 listen 自带窗口 target,会忽略本字段。
逻辑库名不是路径
databaseName 是应用作用域内的逻辑名。WebView 无从得知、也不需要得知物理根目录。
省略时按 ${rxdb.config.dbName}.sqlite3 推导;只有接管一个已存在的库、或多个 RxDB 实例共用同一个文件时才需要显式指定。
允许集是白名单 /^[A-Za-z0-9][A-Za-z0-9._@-]*$/(≤ 128 字符)而非黑名单:字符集里没有 /、\、:,也不允许以 . 开头,于是 ..、绝对路径、盘符、~ 展开、URL scheme 全部落在集合外,不需要逐一枚举攻击形态。违反时抛 invalid_database_name。
名字来自 WebView,不可信。Rust 宿主侧会再校验一次(
paths.rs),非法入参不该在磁盘上留下任何痕迹。
值编码
SQLite 的值经 JSON 往返,encodeDesktopJsonPayload / decodeDesktopJsonPayload 负责两件 JSON 本身做不到的事:
Uint8Array↔ base64:JSON 没有二进制类型,BLOB 直接JSON.stringify会变成{"0":1,"1":2}这种按下标展开的对象;- 大整数:超出
Number.MAX_SAFE_INTEGER的INTEGER以字符串承载,避免静默丢精度。
两端的编码由本包 conformance/ 下的一致性套件按真进程往返验证,rust/src/value.rs 是它的 Rust 对侧。
非有限数:与 Electron 的一处已知差异
JSON 写不出 Infinity / -Infinity / NaN,JSON.stringify 把它们变成 null。悄悄照做的话,
一条绑了 Infinity 的 INSERT 会在库里落成 NULL —— 写进去的不是你给的值,而且全程没有报错。
因此本适配器在两个方向上都拒绝这三个值,抛 protocol_violation:
- 写入:绑定参数里出现非有限数,请求在 WebView 侧就被拒,不会发给宿主(
encodeDesktopJsonPayload); - 读取:某列的值是非有限 REAL(例如
SELECT 1e400溢出成Infinity),宿主拒绝编码这条应答(rust/src/value.rs的encode_real)。
Electron 那侧的 IPC 是结构化克隆,搬得动非有限数,因此同样两件事在那里分别是写入成功、读回
Infinity。这是两端目前已知的一处取值差异:跨后端可移植的代码不要往 SQLite 里存非有限数
(存 NULL 或哨兵值,或改存字符串)。
错误码
程序分支请读 error.code,不要匹配消息文本(消息以 [code] 加一个空格作前缀,仅便于日志检索)。错误码与 Electron 侧完全共用同一套,定义在 @aiao/rxdb-adapter-sqlite-core/desktop-host 的 RxDBAdapterDesktopErrorCode:
| code | 含义 |
|---|---|
unsupported_runtime_engine | 存储 engine 不在能力矩阵内(例如 PGlite data directory) |
invalid_database_name | 逻辑库名非法,或试图越出应用作用域 |
host_unavailable | 请求没递到宿主(命令未注册 / capability 不许 / 参数序列化失败) |
session_closed | 会话已断开后继续使用 |
protocol_violation | 请求或响应不符合协议形状 |
open_failed | 打开数据库失败,cause 保留原始原因 |
permission_denied | 路径无权限,或语句被授权器拒绝 |
database_corrupted | 目标文件不是可用的 SQLite 数据库 |
statement_failed | SQL 本身执行失败(语法、约束等) |
host_internal_error | 宿主自身出错,属于缺陷而非调用方问题 |
database_busy | 另一个连接正持有冲突的锁,重试即可,数据无损 |
file_not_found | 目标文件或目录不存在 |
invalid_file_path | 路径逃出存储根,或指向的类型与操作不符 |
disk_full | 磁盘空间或配额耗尽 |
write_aborted | 写入令牌已失效,目标保持写入前的内容 |
transaction_not_found | 事务 ID 不存在、已结束,或不属于本会话;一条语句都没执行 |
transaction_unavailable | 等待开启事务超时,另一个事务正占着连接;库无损,重试即可 |
后六个只出现在文件存储(@aiao/rxdb-plugin-storage 的桌面后端)与事务这两条路径上。
RxDBAdapterDesktopErrorCode 里另有五个码——restore_in_progress / restore_incomplete / target_not_empty / cleanup_pending / unsupported_operation——只属于 Electron 的 PGlite host(US-217),Rust 宿主从不发出。Tauri 这边备份与恢复的失败一律是 RxDBBackupError,见「备份与恢复」。
错误码是契约的一部分:新增只能追加,不得复用或改写既有含义。
RxDBAdapterDesktopError 这个类跨不过 invoke 的序列化,宿主侧的错误以 { kind: 'error', code, message } 回到 WebView,由适配器按契约重新抛成 RxDBAdapterDesktopError——调用方写的仍是普通 try/catch。不在契约内的 code 一律按 protocol_violation 处理,不会被当成错误码原样上抛。
协议版本
线协议版本号在 TS 与 Rust 两侧各存一份(DESKTOP_HOST_PROTOCOL_VERSION 与 rust/src/protocol.rs 的 PROTOCOL_VERSION)。两个常量之间唯一的机械联系是一致性套件的 conformance/protocol-handshake.spec.ts:它拿真进程报上来的数字与常量比对,改一侧忘了改另一侧时那条用例会红。
renderer 在 open 之前先发一次无副作用的 handshake 协商版本,握手不过就不建库——版本不匹配时磁盘上不该多出一个空文件。
当前版本是 2:US-217 为恢复加了 mute 请求,并把单条语句的长度上限放宽到 64 Mi。这两样 v1 宿主都要到恢复中途才会拒绝,那时目标库已经被写过了,所以版本必须抬,让恢复在写入目标之前就发现宿主太旧。aiao-rxdb-tauri 与本包要取同一个版本。
备份与恢复
sqlite-tauri 能把整个库写成一份 .rxdb-backup 归档流,再恢复进一个空的、未连接的目标。实现与浏览器端的 SQLite 适配器、Electron 的 sqlite-electron 共用,归档格式、兼容性判定、中断与清理语义见 @aiao/rxdb-adapter-sqlite-core 的「备份与恢复」。两端都逐块流式处理,失败一律抛可判别的 RxDBBackupError(按 error.code 分支),不会报告成功。
外部文件不在备份范围内。 归档只含数据库本身,结果与 manifest 的
scope恒为{ database: 'included', externalFiles: 'excluded' }。@aiao/rxdb-plugin-storage桌面后端放在文件宿主根目录里的文件须另行备份与恢复。
输入流与输出流由调用方提供(ReadableStream<Uint8Array> / WritableStream<Uint8Array>):本包不弹文件选择器,也不替 WebView 授权任意路径,写到哪里、从哪里读由宿主应用决定。
// 备份:sink 由宿主应用提供
const adapter = await rxdb.getAdapter(TAURI_ADAPTER_NAME);
const result = await adapter.backup(sink);
// 恢复:target 已注册 adapter,但尚未 connect
const restored = await target.getAdapter(TAURI_ADAPTER_NAME);
await restored.restore(source);
await target.connect(TAURI_ADAPTER_NAME);
与浏览器端相比多出来的三点:
- WAL:Rust 宿主以 WAL 模式打开库文件。备份是一个读事务里的逻辑转储,快照边界前已提交、还没 checkpoint 回主文件的数据也在归档里,不会出现「只复制主文件、漏掉 WAL」。
- 独占:同一库文件可能被别的窗口、别的进程打开,Web Lock 挡不住它们。恢复与清理在检查目标是否为空之前,就让自己那条连接拿到 SQLite 的文件级独占锁,并一直持有到恢复完成或失败清理结束;库文件此刻开在任何别的连接上都报
target_busy(details.field为storage)。恢复期间别的进程也打不开它:Web Lock 管不到的连接等满 host 的 5 秒 busy 超时后报database_busy。 - FTS5 可用:Rust 宿主不以 defensive 模式运行,含 FTS5 虚表的库可以备份,恢复后照常能按词检索。这是与
sqlite-electron的一处差异:那边的 host 开着 defensive,同样的库报unsupported_combination。
归档只恢复进同一个 adapter:sqlite-tauri 的归档不能恢复进 sqlite-electron 或浏览器里的 SQLite,反之亦然,都报 incompatible_archive。
完整示例
参考 dev-rxdb-tauri:src-tauri/src/lib.rs(Rust 侧接线,就是上面那段)、src/app/setup_rxdb_desktop.ts(WebView 侧接线)。宿主实现与跨进程一致性套件在本包的 rust/ 与 conformance/。
@aiao/rxdb-adapter-tauri 的入口。
Remarks
本包只有一侧:WebView。Electron 那边分 . 与 ./host 两个入口,是因为特权侧的
node:sqlite 宿主也是 TypeScript 写的,必须挡在单独的入口后面,免得被打进 renderer bundle。
Tauri 的特权侧是 Rust——它随应用二进制走,不经 npm 分发,也就没有第二个入口可分。
相应地,本包内不该出现任何 node: 内建(tsconfig.lib.json 的 types: [] 约束了这一点)。
协议、renderer client、存储联合与错误类型的实现在
@aiao/rxdb-adapter-sqlite-core/desktop-host(US-207 E1),两个桌面运行时共用一份,
与 Rust 侧的 protocol.rs 由 conformance/protocol-handshake.spec.ts 绑住。
本入口原样转出它们:下游按名字 import,实现搬到哪个包不该让用户改行。
Classes
| Class | Description |
|---|---|
| DesktopSqliteClient | 通过桌面 host 访问本地 SQLite 文件的客户端。 |
| RxDBAdapterDesktopError | 桌面适配器错误。 |
| RxDBAdapterTauri | 把 RxDB 接到 Tauri 应用私有目录里的真实 SQLite 文件上。 |
Interfaces
| Interface | Description |
|---|---|
| DesktopHostChangeEventMessage | host 主动推送给 renderer 的变更事件。 |
| DesktopHostCloseRequest | 关闭会话并释放数据库句柄。 |
| DesktopHostExecuteRequest | 在已打开的会话上执行 SQL。 |
| DesktopHostFileEntry | 目录条目:只有名称与类型,不泄漏物理路径。 |
| DesktopHostFileReadResult | file.read 的一帧结果。 |
| DesktopHostFileStat | 单个路径的元信息;目标不存在时以 null 表达,而不是错误。 |
| DesktopHostHandshakeRequest | 协商线协议版本。 |
| DesktopHostHandshakeResult | handshake 请求的响应。 |
| DesktopHostMuteRequest | 暂停 / 恢复会话的变更事件采集(协议 v2)。 |
| DesktopHostOpenRequest | 打开一个数据库会话。 |
| DesktopHostOpenResult | open 请求的响应。 |
| DesktopHostTransport | renderer 与 host 之间的传输层。 |
| DesktopHostVersionRequest | 查询会话所连数据库的引擎版本。 |
| DesktopOptions | 桌面适配器选项。 |
| DesktopPgliteDirectoryStorage | PGlite data directory 桌面存储。 |
| DesktopSqliteClientOptions | DesktopSqliteClient.connect 的可选行为参数。 |
| DesktopSqliteFileStorage | SQLite 单文件桌面存储。 |
| TauriHostTransportOptions | createTauriHostTransport 的依赖注入点。 |
| TauriOptions | Tauri 适配器选项。 |
Type Aliases
| Type Alias | Description |
|---|---|
| DesktopHostFileLockMode | 锁模式,与 Web Locks 的同名取值一致。 |
| DesktopHostFileRequest | renderer 可以发给文件 host 的全部请求。 |
| DesktopHostFileResponse | host 对一次文件请求的应答,失败同样走正常返回值。 |
| DesktopHostRequest | renderer 可以发给 host 的全部请求。 |
| DesktopHostResponse | host 对一次请求的应答。 |
| DesktopStorage | 桌面存储配置的可辨识联合,按 engine 区分。 |
| RxDBAdapterDesktopErrorCode | 桌面适配器对外承诺的稳定错误码。 |
Variables
| Variable | Description |
|---|---|
| DESKTOP_DEFAULT_DATABASE_SUFFIX | 由 RxDB 数据库名推导逻辑文件名时补的后缀。 |
| DESKTOP_HOST_MAX_BINDINGS | 单条请求允许的绑定参数个数上限。 |
| DESKTOP_HOST_MAX_BLOB_BYTES | 单个 blob 绑定参数的字节上限。 |
| DESKTOP_HOST_MAX_FILE_CHUNK_BYTES | 单帧文件读写的字节上限。 |
| DESKTOP_HOST_MAX_SQL_LENGTH | SQLite 线协议上单条 SQL 文本的长度上限(UTF-16 码元)。 |
| DESKTOP_HOST_PROTOCOL_VERSION | 线协议版本。 |
| DESKTOP_HOST_TRANSPORT_KEY | preload 把传输层挂到 renderer 全局时使用的键。 |
| TAURI_ADAPTER_NAME | 适配器在 RxDBAdapters 注册表中的名字。 |
| TAURI_DESKTOP_CHANGE_EVENT | host 推送变更事件所用的 Tauri 事件名。 |
| TAURI_DESKTOP_REQUEST_COMMAND | Rust 侧 #[tauri::command] 的名字。 |
Functions
| Function | Description |
|---|---|
| assertDesktopHostResponse | 校验一条 host 应答,并把错误应答还原成本地异常。 |
| assertDesktopSqliteStorage | 校验存储配置确实是本协议唯一承载的那个引擎——SQLite 单文件。 |
| assertValidDesktopDatabaseName | 校验逻辑数据库名。 |
| createTauriHostTransport | 用 Tauri 的 invoke / listen 组装一条桌面 host 传输层。 |
| decodeDesktopJsonPayload | 把 JSON 负载还原成协议使用的运行时类型。 |
| encodeDesktopJsonPayload | 把协议负载编码成纯 JSON 可序列化的形状。 |
| isDesktopPgliteDirectoryStorage | 判断存储配置是否为 PGlite data directory 形状。 |
| isDesktopSqliteFileStorage | 判断存储配置是否为 SQLite 单文件形状。 |
| parseDesktopHostChangeEvent | 校验 host 推送过来的变更事件。 |
| parseDesktopHostHandshakeResult | 校验 handshake 响应。 |
| parseDesktopHostOpenResult | 校验 open 响应。 |
| resolveDesktopHostTransport | 转出只为与 @aiao/rxdb-adapter-electron 的导出面对称。 |