同步策略
RxDB 支持在本地与远程之间灵活同步数据。通过配置 sync 选项,你可以实现从完全离线到实时同步的各种模式。
import { RxDB, SyncType } from '@aiao/rxdb';
const rxdb = new RxDB({
dbName: 'demo',
entities: [...],
sync: {
type: SyncType.None, // 策略类型
local: { adapter: 'wa-sqlite' }, // 本地存储(可选)
// remote: { adapter: 'xxx' } // 远程存储(可选)
}
});
策略类型
| 策略 | 数据流向 | 场景 |
|---|---|---|
None | 单向 | 离线应用 |
Full | 双向 | 完整离线访问 |
Filter | 双向 | 条件过滤 |
QueryCache | 按需 | 海量数据缓存 |
:::tip 推荐配置
对于需要离线访问的应用,推荐使用 SyncType.Full 实现双向数据同步。
:::
Full 同步快速入门
SyncType.Full 是最常用的同步策略,适用于需要完整离线访问能力的应用。
基本配置
推拉同步不在 core 里,需要装两个插件:
pnpm add @aiao/rxdb-plugin-history @aiao/rxdb-plugin-sync
@aiao/rxdb-plugin-sync 声明 inject: ['plugin:history'],历史插件是它进入 active 的硬前置——一次往返结束要作废 undo 边界、结算待拉计数,那些状态的主人在历史侧。没装历史插件时同步插件不会安装,rxdb.syncManager 这个槽位就不存在。
import { RxDB, SyncType } from '@aiao/rxdb';
import { rxDBPluginHistory } from '@aiao/rxdb-plugin-history';
import { rxDBPluginSync } from '@aiao/rxdb-plugin-sync';
import { RxDBAdapterWaSqlite } from '@aiao/rxdb-adapter-wa-sqlite';
import { RxDBAdapterSupabase } from '@aiao/rxdb-adapter-supabase';
const rxdb = new RxDB({
dbName: 'my-app',
entities: [Todo, User],
sync: {
type: SyncType.Full,
local: { adapter: 'wa-sqlite' },
remote: { adapter: 'supabase' }
}
});
// 注册适配器
rxdb.adapter('wa-sqlite', db => new RxDBAdapterWaSqlite(db, { vfs: 'IDBBatchAtomicVFS' }));
rxdb.adapter(
'supabase',
db =>
new RxDBAdapterSupabase(db, {
supabaseUrl: 'YOUR_SUPABASE_URL',
supabaseKey: 'YOUR_SUPABASE_KEY'
})
);
// 注册插件。顺序随意:宿主按 `inject` 拓扑排序,历史插件一定先装完
rxdb.use(rxDBPluginHistory);
rxdb.use(rxDBPluginSync);
await rxdb.connect('wa-sqlite');
use() 传的是插件工厂函数本身,不要调用它(rxdb.use(rxDBPluginSync),不是 rxdb.use(rxDBPluginSync()))。
执行同步
同步入口挂在 rxdb.syncManager 上,由同步插件在连接纪元内装配——await connect() 之后才可用。
const sync = rxdb.syncManager;
// 双向同步单个仓库
const result = await sync.syncRepository('public', 'Todo');
console.log(`拉取: ${result.pullResult.pulled}, 推送: ${result.pushResult.pushed}`);
// 批量同步所有仓库
const bulkResult = await sync.bulkSync();
console.log(`同步了 ${bulkResult.results.length} 个仓库`);
// 仅拉取远程数据
await sync.syncRepository('public', 'Todo', { direction: 'pull' });
// 仅推送本地变更
await sync.syncRepository('public', 'Todo', { direction: 'push' });
:::warning 从 versionManager 迁移
同步方法过去挂在 rxdb.versionManager 上,现在全部搬到 rxdb.syncManager。versionManager 仍然存在,但只保留历史与分支 API。对照表见历史与同步拆包。
:::
冲突解决
当 pull 或 sync 过程中检测到本地未推送变更与远程变更命中同一实体时,系统会先进入冲突检测,再默认使用 Last Write Wins (LWW) 策略:
- 比较
createdAt时间戳,较新的修改获胜 - 时间戳相等时,本地修改优先
当前运行时会自动执行两种结果:
KEEP_LOCAL:跳过远程 patch,保留本地待推送变更KEEP_REMOTE:应用远程 patch,并将本地冲突变更标记为已被远端覆盖
如果自定义 resolver 返回 MERGE 或 DEFER,运行时会抛出错误并发出冲突待处理事件,避免在没有持久化冲突状态的情况下静默丢失远端变更。
import { IConflictResolver, ConflictContext, ConflictResult } from '@aiao/rxdb';
class MyConflictResolver implements IConflictResolver {
resolve(context: ConflictContext): ConflictResult {
// 自定义冲突解决逻辑:本地修改时间更新则保留本地
const localTime = context.local?.updatedAt?.getTime() ?? 0;
const remoteTime = context.remote?.updatedAt?.getTime() ?? 0;
return localTime >= remoteTime ? ConflictResult.KEEP_LOCAL : ConflictResult.KEEP_REMOTE;
}
}
const result = await rxdb.syncManager.syncRepository('public', 'Todo', {
pull: {
conflictResolver: new MyConflictResolver()
}
});
监听同步事件
// 监听同步完成
rxdb.addEventListener('repository-sync-complete', event => {
console.log(`${event.entity} 同步完成:`, event.result);
});
// 监听同步错误
rxdb.addEventListener('repository-sync-error', event => {
console.error(`${event.entity} 同步失败:`, event.error);
});
// 监听冲突检测
rxdb.addEventListener('CONFLICT_DETECTED', event => {
console.log('自动解决冲突数:', event.resolved, '待处理冲突数:', event.deferred);
});
Filter 同步快速入门
SyncType.Filter 适用于需要条件过滤同步的场景,例如只同步最近 30 天的数据。
Filter 基本配置
import { Entity, EntityBase, PropertyType, SyncType } from '@aiao/rxdb';
import { subDays } from 'date-fns';
@Entity({
name: 'Todo',
properties: [
{ name: 'title', type: PropertyType.string, required: true },
{ name: 'completed', type: PropertyType.boolean, default: false },
{ name: 'updatedAt', type: PropertyType.date }
],
sync: {
type: SyncType.Filter,
local: { enabled: true },
remote: {
enabled: true,
// 动态过滤:只同步最近 30 天的数据
filter: () => ({
combinator: 'and',
rules: [{ field: 'updatedAt', operator: '>=', value: subDays(new Date(), 30) }]
})
}
}
})
export class Todo extends EntityBase {}
:::info filter 函数
filter 是一个返回 RuleGroup 的函数,每次 pull 时都会重新执行。这意味着:
- 滚动时间窗口会自动更新(如 "最近30天" 会随时间推移)
- 可以基于运行时状态动态调整过滤条件 :::
Filter 执行同步
const sync = rxdb.syncManager;
// 拉取:只获取满足 filter 条件的远程数据
await sync.syncRepository('public', 'Todo', { direction: 'pull' });
// 推送:本地变更无限制推送
await sync.syncRepository('public', 'Todo', { direction: 'push' });
// 双向同步(拉取受限,推送不受限)
await sync.syncRepository('public', 'Todo');
清理过期数据
当使用滚动时间窗口时,本地可能存在不再满足 filter 条件的"过期"数据。使用 cleanupExpired 清理:
const sync = rxdb.syncManager;
// 删除不再满足 filter 条件的本地数据
const result = await sync.cleanupExpired('public', 'Todo');
console.log(`清理了 ${result.removed} 条过期记录`);
// 预览模式:仅返回将被删除的数据,不实际执行删除
const preview = await sync.cleanupExpired('public', 'Todo', { dryRun: true });
console.log(`将清理 ${preview.removed} 条记录:`, preview.removedIds);
复杂过滤条件
// 多条件组合:最近 30 天 且 未归档
sync: {
type: SyncType.Filter,
local: { enabled: true },
remote: {
enabled: true,
filter: () => ({
combinator: 'and',
rules: [
{ field: 'updatedAt', operator: '>=', value: subDays(new Date(), 30) },
{ field: 'archived', operator: '=', value: false }
]
})
}
}
Filter vs Full 对比
| 特性 | Full 同步 | Filter 同步 |
|---|---|---|
| 拉取范围 | 全量数据 | 仅满足条件 |
| 推送范围 | 全量数据 | 全量数据(不受限) |
| 本地存储 | 完整数据集 | 数据子集 |
| 使用场景 | 小数据集,需完整离线 | 大数据集,只需最近数据 |
QueryCache 快速入门
SyncType.QueryCache 适用于数据量远超本地能装下的范围、又希望重复查询能命中本地的场景。
它与 Full / Filter 的根本区别是:远端是唯一事实源,本地只是缓存。
QueryCache 基本配置
import { RxDB, SyncType } from '@aiao/rxdb';
const rxdb = new RxDB({
dbName: 'catalog',
entities: [Product],
sync: {
type: SyncType.QueryCache,
local: { adapter: 'wa-sqlite' },
remote: { adapter: 'supabase' }
}
});
const repo = rxdb.getRepository(Product);
// 与其他策略完全相同的调用面:Promise / 实体实例 / limit / offset / orderBy
const rows = await repo.find({
where: { combinator: 'and', rules: [{ field: 'categoryId', operator: '=', value: 'c-1' }] },
limit: 20,
offset: 0
});
一次 find 发生了什么
fetchMetadata(where)从远端取该查询命中的全部{ id, updatedAt };- 同时读本地对该
where的投影,得到本地已有的行; - 两侧比对,分成四类:缺失(本地没有)、过时(本地
updatedAt更旧)、新鲜、孤儿(本地有、远端已无); - 孤儿从本地删除,缺失 + 过时经
findByIds拉回并写入本地; - 从本地行仓储按完整查询选项读出结果,
limit/offset/orderBy下推成 SQL。
与其他策略的行为差异
| 行为 | Full / Filter | QueryCache |
|---|---|---|
| 事实源 | 本地(可离线写) | 远端 |
| 写路径 | 写本地,随同步推送 | 先写远端,成功后才更新本地缓存;远端失败则本地不动 |
| 本地 changelog | 记录(支持 undo/redo、冲突解决) | 不记录,也没有冲突解决 |
| 远端删除 | 经变更流下发 | 同步时按孤儿从本地清除 |
| 本地数据完整性 | 完整数据集 / 子集 | 仅「查过的 where」的并集 |
:::warning 同步粒度是 where,不是 limit
fetchMetadata 的粒度就是整个 where:limit: 20 不会让同步只处理 20 条。
用 where 收窄结果集;靠 limit 收窄会造成拉取放大。放大程度可用 onSyncStats 观测。
:::
同步记忆(syncStaleTime)
同步的粒度既然是整个 where,翻页就只改 limit / offset —— 同一个 where 的第二页
不该再向远端校验一次。sync.local.syncStaleTime(毫秒,默认 1000)就是这段「刚同步过」的记忆窗口:
窗口内对同一 where 的重复读直接读本地投影,一次翻页交互只发生一次同步。
sync: {
type: SyncType.QueryCache,
local: { adapter: 'wa-sqlite', syncStaleTime: 5000 },
remote: { adapter: 'supabase' }
}
窗口只推迟重新校验,不取消它:到期、本实体发生写、适配器重连,三者任一都会立即让记忆失效。
配 0 表示完全关闭记忆,回到「每次读都向远端校验」。
缓存优先与离线降级
const rows = await repo.find({
where,
// 先把本地缓存交出来,远端校验在后台跑完并落本地(stale-while-revalidate)
localCacheFirst: true,
// 网络故障时降级读本地缓存;本地也没有则抛 NetworkOfflineError
offlineFallback: true,
onSyncStats: stats => {
// remoteCount 是 where 命中的远端总数,据此判断拉取是否放大
console.log(stats.remoteCount, stats.pulledCount, stats.durationMs);
}
});
localCacheFirst 也可以在 sync.local.localCacheFirst 里配成默认值,调用级传入的值优先。
offlineFallback 只吞网络故障(连接失败、DNS、超时)。业务错误——401、唯一键冲突、
字段校验失败等带 HTTP 状态码的响应——原样上抛,不会被静默换成一份陈旧缓存。
适配器能力要求
两侧适配器需实现 QueryCache 的必需方法:本地 getMetadataByIds / upsertMany / deleteByIds,
远端 fetchMetadata / findByIds。继承适配器基类时它们是 abstract,编译期即有保证;
自定义适配器对象缺任一项时,构造仓储即抛 RxDBQueryCacheCapabilityError 并列出缺失的方法名。
远端行的列契约
findByIds 返回的每一行,必须带齐本地表的全部非空列,并给出非空值 —— 包括 EntityBase
声明的 createdAt / updatedAt。这条约束对所有 QueryCache 远端成立(HTTP、Supabase、自研服务),
因为它来自落地路径而不是某个协议。
「带齐」判的是值而不只是键:{ createdAt: null } 与整个不写 createdAt 一样会被拒。
远端那一列可空、或 join 落空时,select('*') 返回的正是前一种形状。两者最终都撞同一条
NOT NULL——null 原样绑进参数,undefined 则让 INSERT 干脆不提这一列——因此契约一视同仁,
只是在消息里分成两栏:「没带这一列」要让远端把列发出来,「带了键但值为空」要查远端为什么给 null。
原因是 upsertMany 是绕开仓储的裸 SQL 写:
// 实体上写了默认值 —— 但它是**仓储层**的东西
{ name: 'createdAt', type: PropertyType.date, default: () => new Date() }
函数形式的 default 一个字都不会进 CREATE TABLE,本地表上 createdAt 就是 NOT NULL。
QueryCache 的拉取落地不经过仓储,于是远端不带这一列 → INSERT 不含该列 → 被数据库拒绝。
哪些列可以省略,两个本地后端共同成立的部分:
- 可空列(
nullable: true); - 写了字面量
default的列——它进了 DDL 的DEFAULT子句。binary除外: 建表时明确跳过这一类的默认值,列上仍是光秃秃的NOT NULL; integer主键(sqlite 的AUTOINCREMENT/ PostgreSQL 的serial都能自己生成)。
关系的外键列(ONE_TO_ONE / MANY_TO_ONE)也是本地表上的物理列,同样要带,但豁免口径与
普通列不一样:可空才可省;字面量 default 只对 MANY_TO_ONE 生效——DDL 的
DEFAULT 子句嵌在 kind === MANY_TO_ONE 分支里,一对一列建出来只有 NOT NULL,
跟着放行等于让「过了校验的行」在 INSERT 时被数据库拒掉。
两处按后端不同
判据算的是「本地后端的建表 DDL 会把哪些列建成 NOT NULL 且拿不到默认值」, 而两个后端的建表规则在两处结论不同:
| 情形 | SQLite family(wa-sqlite / sqlite-wasm / sqliteai / desktop) | PGlite |
|---|---|---|
uuid(以及 string / bigint)主键 | 可省:建表发 DEFAULT (lower(hex(randomblob(16)))) | 必带:建表只发 "id" uuid PRIMARY KEY,无默认值 |
关系列 onDelete / onUpdate 为 SET NULL 且 nullable: false | 可省:DDL 把这种列降级为可空,不发 NOT NULL | 必带:DDL 只看 nullable,照发 NOT NULL |
第一行是两边 DDL 本来就不同:SQLite 给非 integer 主键塞得进一个随机默认值,PostgreSQL 这一侧 没有等价写法。给 PGlite 用的远端必须自带主键值;想写一份两个后端通吃的远端实现,按 PGlite 这一列来(它是两者中更严的那一侧)。
第二行不一样——它是 PGlite 建表的一个已知缺陷,不是设计上的后端差异。同一张表上
NOT NULL 与 ON DELETE SET NULL 自相矛盾:父行被删时 PostgreSQL 要把这一列置空,而列上的
NOT NULL 又不许,删父行必然失败。SQLite 侧把这种列降级为可空正是为了避开它。契约的职责是
如实反映本后端当前的 DDL,不是替它纠偏,所以这一列今天照发 NOT NULL、契约也就照要——
但这一格会随 PGlite 建表修好而变成「可省」,届时向宽松方向收敛,不会弄红既有的远端实现。
键名的三种写法
其余一律必须自带。校验与落地路径共用同一张写法表(各包的
queryCacheForeignKeyColumns),因此「校验放行的写法」与「落地认得的写法」一字不差:
- 属性名 / 关系名:
nickName、owner; - 物理列名:
nick_name、owner_id(远端走select('*')时返回的就是这一种); - 关系列另加外键别名:
ownerId。
三种写法对同一个外键列完全等价,落地时一律翻译成物理列名 owner_id。
:::warning 同一列只能用一种写法
一行里给同一个列写两种(ownerId 与 owner_id,或 nickName 与 nick_name)会被
两个后端一致拒绝,不会静默取一个:两个值不同时,落地哪一个取决于键的枚举顺序,
且没有任何信号 —— SQLite 对重复列名根本不报错,它留第一个、丢第二个,库里于是多出一个
「写入成功」的错值。远端一行里每个列只发一次。
:::
批内列集一致:只有 SQLite family 要求
SQLite family 侧同一批里的行还必须列集一致。批内异构(第 1 行带 tag、第 2 行不带)会被
拒绝:它按批生成一条 INSERT,缺键的行会被绑成 NULL,把可空列静默清空。
PGlite 侧不作此要求——它按列集把批分组,每组一条 INSERT,缺的键根本不会出现在列清单里。
不满足时,两个后端都在落地之前抛 RxDBQueryCacheRowContractError(各包一个同名类,
name 与消息骨架逐字一致),点名实体、缺失列与成因,且这一批一行都不会写入,
连事务都不会开。
:::warning 缺列不会就地补默认值
补一个 new Date() 出来的是本机拉取的时刻,不是记录创建的时刻:两台设备拉同一行会得到
不同的 createdAt,而这个差异要到跨设备对比时才暴露。缺列是后端或协议实现的问题,
在这里补齐等于把它伪装成成功。
:::
实例级覆盖(syncOverrides)
同一个实体类在不同的 RxDB 实例里可以走不同的策略。典型场景是前后端共享领域模块:
浏览器端按装饰器声明的 QueryCache 走 HTTP,Node 后端是权威库,同一个类只需要纯本地。
import { RxDB, SyncType } from '@aiao/rxdb';
import { Recipe } from '@modules/recipes-domain'; // 装饰器声明 QueryCache + http
const server = new RxDB({
dbName: 'recipes-server',
entities: [Recipe],
sync: { type: SyncType.None, local: { adapter: 'pglite' } },
syncOverrides: [{ entity: Recipe, sync: { type: SyncType.None, local: { adapter: 'pglite' } } }]
});
优先级:实例覆盖 > 实体装饰器 sync > 库级 sync。
整体替换,不深合并:覆盖条目的 sync 是该实体在本实例里的完整配置,装饰器上的
remote、适配器名与缓存选项(如 syncStaleTime)一个都不残留。
只作用于本实例:覆盖不改写实体类的元数据,也不冻结调用方传入的对象——构造时即拍快照, 之后改动传入的条目不影响运行中或重连后的实例。另一个实例注册同一个类,照旧按自己的配置走。
生效配置只读:被覆盖实体的 repository.sync 与 rxdb.entitySync.resolve(Entity) 返回的是
本实例冻结的快照,运行时给它赋值会抛 TypeError。要换策略就用新的 syncOverrides 重建实例,
不要在运行中改配置对象。
不换适配器:适配器只从库级 sync 注册。覆盖声明的每一侧都必须在库级注册同名适配器;
库级缺侧或名字不同的覆盖在构造期就被拒绝,不会留下永不发射的适配器流。
生效配置决定校验:覆盖声明的 remote 若未在库级注册,构造期即报 invalid-sync;
QueryCache 缺插件、适配器不支持该模式等 fail-fast 错误按覆盖后的生效配置触发;
覆盖成纯本地的实体既不需要远端适配器,也不进入出站重放与缓存刷新管道。
非法条目在建立实体绑定与任何数据库写入之前抛出 RxDBSyncOverrideError,按 reason 判别:
reason | 含义 |
|---|---|
unregistered | 目标不在本实例的 entities 里(含自动生成的关系中间实体) |
system-entity | 目标是 RxDB 或插件注入的系统表 |
duplicate | 同一实体出现多条覆盖 |
invalid-entry | syncOverrides 不是数组、数组里有空位(稀疏数组)、条目不是对象,或 entity 不是实体类 |
invalid-sync | sync 为 null、缺少或写错 type、适配器选项形状不对;Full / Filter / QueryCache 缺一侧,Filter 的 remote.filter 缺失或不是函数(返回值到拉取时才校验);覆盖声明的一侧未在库级注册同名适配器 |
:::tip 插件作者
读取实体策略一律经 rxdb.entitySync.resolve(Entity) / resolveType(Entity),不要直接读
getEntityMetadata(Entity).sync——后者是装饰器原值,看不到实例覆盖。
:::
关系查询与同步
核心规则:外键只能从本地指向任意位置,不能从远程指向本地
查询路由规则
查询引擎根据查询条件涉及的表和表的存储位置自动选择查询路径:
| 查询条件 | 主表位置 | 从表位置 | 查询路径 |
|---|---|---|---|
| 仅主表属性 | 本地 | - | 本地 |
| 仅主表属性 | 远程 | - | 远程 |
| 仅主表属性 | 同步 | - | 本地 |
| 包含从表属性 | 本地 | 本地 | 本地 |
| 包含从表属性 | 本地 | 远程 | 远程 |
| 包含从表属性 | 本地 | 同步 | 本地 |
| 包含从表属性 | 远程 | 本地 | ❌ |
| 包含从表属性 | 远程 | 远程 | 远程 |
| 包含从表属性 | 远程 | 同步 | 远程 |
| 包含从表属性 | 同步 | 本地 | 本地 |
| 包含从表属性 | 同步 | 远程 | 远程 |
| 包含从表属性 | 同步 | 同步 | 本地 |
规则总结:
- 仅主表属性:优先本地(同步表走本地)
- 包含从表属性:任一表在远程 → 远程查询
- 远程主表 + 本地从表:不可行(违反外键规则)
// 示例:Article(同步) 关联 Author(远程)
// 仅主表属性 → 本地查询
repository.find({ where: { title: 'Hello' } });
// 包含从表属性 → 远程查询
repository.find({
where: { author: { name: 'Alice' } },
relations: ['author']
});
主表:纯本地
一对多(主表1 ← 从表N,外键在从表)
| 从表策略 | 可行性 | 原因 |
|---|---|---|
| 纯本地 | ✅ | 外键在本地,可引用本地 |
| 纯远程 | ❌ | 外键在远程,无法引用本地 |
| 全量同步 | ❌ | 外键在远程,无法引用本地 |
| 条件同步 | ❌ | 外键在远程,无法引用本地 |
| 按需缓存 | ❌ | 外键在远程,无法引用本地 |
示例:本地 User ← 远程 Article 不可行(远程无法存储本地用户ID)
多对一(从表N → 主表1,外键在从表)
当前实体是从表,主表策略:
| 主表策略 | 可行性 | 原因 |
|---|---|---|
| 纯本地 | ✅ | 外键在本地,可引用本地 |
| 纯远程 | ✅ | 外键在本地,可引用远程 |
| 全量同步 | ✅ | 外键在本地,可引用远程 |
| 条件同步 | ✅ | 外键在本地,可引用远程 |
| 按需缓存 | ✅ | 外键在本地,可引用远程 |
示例:本地 Article → 远程 User 可行(本地可存储远程用户ID)
一对一(外键在任一方)
| 从表策略 | 外键位置 | 可行性 | 原因 |
|---|---|---|---|
| 纯本地 | 任一方 | ✅ | 都在本地 |
| 纯远程 | 本地 | ✅ | 外键在本地,可引用远程 |
| 纯远程 | 远程 | ❌ | 外键在远程,无法引用本地 |
| 全量同步 | 本地 | ✅ | 外键在本地,可引用远程 |
| 全量同步 | 远程 | ❌ | 外键在远程,无法引用本地 |
| 条件同步 | 本地 | ✅ | 外键在本地,可引用远程 |
| 条件同步 | 远程 | ❌ | 外键在远程,无法引用本地 |
| 按需缓存 | 本地 | ✅ | 外键在本地,可引用远程 |
| 按需缓存 | 远程 | ❌ | 外键在远程,无法引用本地 |
多对多(中间表存储双方ID)
| 从表策略 | 中间表位置 | 可行性 | 原因 |
|---|---|---|---|
| 纯本地 | 本地 | ✅ | 都在本地 |
| 纯远程 | 本地 | ✅ | 中间表在本地,可引用双方 |
| 纯远程 | 远程 | ❌ | 中间表在远程,无法引用本地 |
| 全量同步 | 本地 | ✅ | 中间表在本地,可引用双方 |
| 全量同步 | 远程 | ❌ | 中间表在远程,无法引用本地 |
| 条件同步 | 本地 | ✅ | 中间表在本地,可引用双方 |
| 条件同步 | 远程 | ❌ | 中间表在远程,无法引用本地 |
| 按需缓存 | 本地 | ✅ | 中间表在本地,可引用双方 |
| 按需缓存 | 远程 | ❌ | 中间表在远程,无法引用本地 |
主表:纯远程
{ type: SyncType.None, remote: { adapter: 'supabase' } }
查询远程数据,无本地存储,无法建立本地关系。
主表:全量同步
{
type: SyncType.Full,
local: { adapter: 'wa-sqlite' },
remote: { adapter: 'supabase' }
}
一对多(主表1 ← 从表N,外键在从表)
| 从表策略 | 可行性 | 原因 |
|---|---|---|
| 纯本地 | ✅ | 外键在本地,可引用本地 |
| 纯远程 | ❌ | 外键在远程,无法引用本地 |
| 全量同步 | ✅ | 外键在本地,可引用同步数据 |
| 条件同步 | ✅ | 外键在本地,可引用同步数据 |
| 按需缓存 | ✅ | 外键在本地,可引用缓存数据 |
多对一(从表N → 主表1,外键在从表)
当前实体是从表,主表策略:
| 从表策略 | 可行性 | 原因 |
|---|---|---|
| 纯本地 | ✅ | 外键在本地,可引用本地 |
| 纯远程 | ✅ | 外键在本地,可引用远程 |
| 全量同步 | ✅ | 外键在本地,可引用同步数据 |
| 条件同步 | ✅ | 外键在本地,可引用同步数据 |
| 按需缓存 | ✅ | 外键在本地,可引用缓存数据 |
一对一(外键在任一方)
| 从表策略 | 外键位置 | 可行性 | 原因 |
|---|---|---|---|
| 纯本地 | 任一方 | ✅ | 都在本地 |
| 纯远程 | 本地 | ✅ | 外键在本地,可引用远程 |
| 纯远程 | 远程 | ❌ | 外键在远程,无法引用本地 |
| 全量同步 | 任一方 | ✅ | 都已同步到本地 |
| 条件同步 | 本地 | ✅ | 外键在本地,可引用同步数据 |
| 条件同步 | 远程 | ❌ | 外键在远程,无法引用本地 |
| 按需缓存 | 本地 | ✅ | 外键在本地,可引用缓存数据 |
| 按需缓存 | 远程 | ❌ | 外键在远程,无法引用本地 |
多对多(中间表存储双方ID)
| 从表策略 | 中间表位置 | 可行性 | 原因 |
|---|---|---|---|
| 纯本地 | 本地 | ✅ | 都在本地 |
| 纯远程 | 本地 | ✅ | 中间表在本地,可引用双方 |
| 纯远程 | 远程 | ❌ | 中间表在远程,无法引用本地 |
| 全量同步 | 本地 | ✅ | 中间表在本地,可引用双方 |
| 全量同步 | 远程 | ✅ | 双方都已同步,可在远程关联 |
| 条件同步 | 本地 | ✅ | 中间表在本地,可引用双方 |
| 条件同步 | 远程 | ⚠️ | 需确保关联数据都已同步 |
| 按需缓存 | 本地 | ✅ | 中间表在本地,可引用双方 |
| 按需缓存 | 远程 | ❌ | 缓存不可预测,无法保证一致性 |
主表:条件同步
{
type: SyncType.Filter,
local: { adapter: 'wa-sqlite' },
remote: {
adapter: 'supabase',
filter: () => ({
combinator: 'and',
rules: [{ field: 'updatedAt', operator: '>=', value: new Date(Date.now() - 30 * 86400000) }]
})
}
}
同"全量同步",但需注意:
- 从表若也是条件同步,需确保过滤条件能保证数据完整性
- 中间表在远程时,需确保关联的双方都满足同步条件
主表:按需缓存
{
type: SyncType.QueryCache,
local: { adapter: 'wa-sqlite' },
remote: { adapter: 'supabase' }
}
同"全量同步",但限制更多:
- 不建议中间表在远程(缓存数据不稳定)
- 从表建议纯本地或也是按需缓存(确保关联数据可用)