OmniRoute 可插拔持久化边界 ADR:从同步 SqliteAdapter 到域仓库契约的架构决策解析
2026/9/13 17:07:06 网站建设 项目流程

OmniRoute 可插拔持久化边界 ADR:从同步 SqliteAdapter 到域仓库契约的架构决策解析

【免费下载链接】OmniRouteNever stop coding. Free MIT AI gateway: one endpoint, 352 providers (150+ free), 1200+ models Kimi, Claude, GPT, Gemini, GLM, DeepSeek, MiniMax. Works with Claude Code, Codex, Cursor, OpenCode, Cline & Copilot. Quota-aware auto-fallback, RTK+Caveman compression saves 15-95% tokens, MCP/A2A, Desktop/PWA. Built by 550+ contributors项目地址: https://gitcode.com/GitHub_Trending/om/OmniRoute

本文基于 OmniRoute 仓库中的架构决策记录(ADR)《Pluggable persistence boundary》(波兰语译文位于 persistence-backend-boundary.md,英文原版见 persistence-backend-boundary.md),系统解析该项目如何为多数据库后端规划"域仓库 + 内部异步后端"两级持久化边界:它为什么不在现有同步 SQLite 适配器上硬塞 PostgreSQL,如何划定可移植仓库的允许/禁止面,以及用何种一致性测试与交付序列保证迁移过程可回滚、可审查。读完后,你将能够理解一个嵌入式 SQLite 系统在引入外部数据库前应做的边界设计,并能对照仓库源码核实该决策的现状证据。

文档定位与状态

这份 ADR 的关键元信息如下:

  • 状态:Proposed(提案中)——在 maintainer 批准前,不启动任何运行时改造;
  • 跟踪问题:#8075;
  • 范围:仅涉及持久化架构。该决策本身不引入、也不选择任何外部数据库,不新增数据库依赖、环境变量、schema 或迁移文件。

需要特别强调的是一条"非目标"边界:直到 maintainer 对文末开放问题作出裁定之前,该文档只是一份提案,不隐含任何运行时重构。这决定了本文所有"未来形态"描述都属于设计意图,而非仓库当前已实现的能力。

背景:SQLite 形态的持久化现状

ADR 的第一部分用"耦合清单"说明问题所在。以下逐条对照当前仓库源码核实。

1. 同步的 SqliteAdapter 契约

OmniRoute 的领域持久化函数集中在src/lib/db/目录(该目录包含数百个领域模块,如apiKeys.tscombos.tsproviders.ts等),而所有模块共享的数据库连接由 core.ts 返回,其类型是定义在 types.ts 中的同步SqliteAdapter接口:

export interface SqliteAdapter { readonly driver: "better-sqlite3" | "node:sqlite" | "bun:sqlite" | "sql.js"; readonly open: boolean; readonly name: string; readonly inTransaction?: boolean; prepare(sql: string): PreparedStatement; exec(sql: string): void; pragma(pragmaStr: string, options?: { simple?: boolean }): unknown; /** 在 DEFERRED 事务中执行 fn */ transaction<T>(fn: (...args: unknown[]) => T): (...args: unknown[]) => T; /** 在 IMMEDIATE 事务中执行 fn(立即获取写锁) */ immediate(fn: () => void): void; /** 原生备份或 file-copy 回退 */ backup(destination: string): Promise<void>; checkpoint(mode?: string): void; close(): void; readonly raw: unknown; }

(见 types.ts,PreparedStatement提供同步的run/get/all方法。)

从源码结构看,这个接口虽然横跨四个 SQLite 运行时(better-sqlite3node:sqlitebun:sqlitesql.js),但其表面完全是 SQLite 形状的:同步 prepared statements、pragma、deferred/immediate 双模式事务、原生备份/文件复制备份、WAL checkpoint 以及本地数据库句柄(raw)。ADR 的判断是:这些是嵌入式 SQLite 部署的合理属性,应当保留,但不应强迫 PostgreSQL 或 MySQL 去模拟这套 API

2. 启动路径拥有 SQLite 文件生命周期

core.ts 解析数据目录并定位storage.sqlite:

export const DATA_DIR = resolveWritableDataDir({ isCloud }); const LEGACY_DATA_DIR = isCloud ? null : getLegacyDotDataDir(); export const SQLITE_FILE = isCloud ? null : path.join(DATA_DIR, "storage.sqlite"); const JSON_DB_FILE = isCloud ? null : path.join(DATA_DIR, "db.json"); export const DB_BACKUPS_DIR = isCloud ? null : path.join(DATA_DIR, "db_backups");

同一模块还维护着一个进程级全局适配器,负责 WAL checkpoint(见 walMaintenance 的导入与startWalMaintenance/runCheckpointNow调用)、重建数据库时删除 SQLite 伴生文件(WAL、shm 等),并在恢复流程中快照/保留关键表。核心表清单CRITICAL_DB_TABLES直接写在 core.ts 中,包括key_valueprovider_connectionsprovider_nodescombosapi_keysproxy_registrywebhooks等,每张表带maxRows上限(5,000~10,000 行)。这正是 ADR 所指的"启动与恢复路径拥有 SQLite 文件生命周期"的具体形态——业务语义与文件操作耦合在同一个模块里。

3. 驱动选择不是外部后端抽象

driverFactory.ts 负责在受支持的 SQLite 运行时之间做驱动级联选择。从源码可以看到它通过字面量switch分支加载驱动(L109-L120 的requireSqliteDriver只接受bun:sqlitebetter-sqlite3node:sqlite,其他一律抛出Unsupported SQLite driver module),并配有 Windows 原生插件挂起守护(子进程有界超时探测 better-sqlite3 是否可加载,避免 ABI 不匹配的 native addon 在DllMain中卡死整个进程)。

ADR 对此的定性很关键:这套级联解决的是"哪个 SQLite 运行时可用",而不是"外部后端抽象"。把 PostgreSQL 塞进这条级联,等于让一个为同步 SQLite 运行时设计的兼容层去承接一个异步网络数据库——这正是被否决的替代方案之一(见下文)。

4. Schema 演进同样耦合

migrationRunner.ts 从migrations目录按序应用编号 SQL 文件,其模块头注释说明了机制:

  • 命名规范NNN_description.sql(如001_initial_schema.sql);
  • 已应用版本记录在schema_migrations表;
  • 每个文件的全部迁移在单个事务中执行(全有或全无);
  • 安全措施包括:迁移前备份、批量迁移检测(已有库上待执行迁移数超过阈值即中止,防止迁移跟踪表被误删后从头重放)、迁移重命名告警。

Runner 内部还会探测sqlite_masterPRAGMA table_info、检测可选的 FTS5 支持、处理遗留版本槽位与重编号兼容(见 migrationRunner 子模块 中的LEGACY_VERSION_SLOT_MIGRATIONSOPTIONAL_FTS5_MIGRATION_VERSIONS等常量)。这些机制全部建立在 SQLite 专属元数据之上,是 ADR 中"外部后端不能假定 SQLite SQL 文件可移植"这一条规则的直接依据。

5. 运维模块直接使用 SQLite 语义

  • backup.ts:基于SQLITE_FILE的文件复制备份、DB_BACKUPS_DIR保留策略(环境变量DB_BACKUP_MAX_FILES/DB_BACKUP_RETENTION_DAYS覆盖 → 持久化 UI 值 → 默认值),并有 60 分钟节流防止高变更场景下每次调用都复制整个数据库文件;
  • optimizationSettings.ts:直接操作 page-size、cache-size、auto-vacuum 等PRAGMA参数,配合VACUUM语义;
  • 此外还有vacuumScheduler.tsrecovery.tsmemoryVec.ts(sqlite-vec 集成)等模块。

ADR 的结论是:这些是"正确且应当保留"的嵌入式 SQLite 特性,但必须被隔离在 SQLite 自己的实现与运维接口之后,而不是成为可移植层的一部分。

决策:两级持久化边界

ADR 的核心决定是为"可移植的持久状态"建立两级边界:

第一级:域仓库契约(Domain repository contracts)

定义业务与路由代码所需的持久化操作。调用方只依赖领域行为与领域数据,不依赖 SQL 文本、prepared statement、数据库文件或方言对象。

第二级:内部异步后端契约(internal async backend contract)

支撑仓库实现,提供:事务上下文、health/readiness、迁移协调、后端能力声明(capabilities)与分类错误。

值得注意的一个工程取舍是:ADR刻意不冻结具体的 TypeScript API 表面——精确的接口将随第一个实现 PR 提出,并由一致性测试验证。这是一种"以测试固化契约、而非以文档固化契约"的写法,避免 ADR 变成一份过早的 API 规格。

后端选型顺序被明确排定:

  1. SQLite 保持默认实现。现有驱动级联与同步SqliteAdapter留在 SQLite 仓库实现之后,各领域以小的垂直切片(vertical slice)逐步迁移;任何用户都不需要配置外部服务;
  2. PostgreSQL 是第一个外部实现,且前提是先对 SQLite 证明仓库边界;
  3. MySQL 作为同一套一致性测试套件下的对等实现跟进,而不是第二份业务逻辑分叉。

仓库现状佐证:垂直切片已经开始落地

当前仓库中已存在 src/lib/db/repositories/ 目录,包含三个文件:routingConfigRepositories.tssqliteComboRepository.tssqliteModelComboMappingRepository.ts。从源码结构看,这与 ADR 交付序列中第 2、3 步(引入首批域仓库契约、将现有 SQLite 实现适配到契约之后)相吻合,且 ADR 点名的候选域(provider connections、API keys、combos、routing configuration)正是这批文件覆盖的方向。

边界规则

可移植仓库的允许面

一个可移植仓库可以暴露:

  • 领域读写;
  • 显式的原子操作,以及事务作用域内的仓库访问;
  • 当并发语义属于领域本身时,compare/update 或 lease 操作;
  • 后端中立的分页、排序与约束错误。

后端 health、readiness 与迁移协调归属于内部后端/运维契约,不属于单个域仓库——这避免了每个仓库都携带一份健康检查样板。

可移植仓库的禁止面

可移植仓库不得暴露:

禁止项原因(结合源码理解)
preparegetallrun或裸驱动句柄会泄露同步 SQLite 方言与驱动对象
PRAGMA、WAL checkpoint 模式、VACUUM、page/cache 调优SQLite 专属运维语义(当前散落在 optimizationSettings 等模块中)
SQLite 文件路径、伴生文件、文件复制备份外部后端没有"数据库文件"概念
lastInsertRowid作为跨后端领域契约依赖 SQLite rowid 的隐式 ID 语义无法移植(对照 types.ts 中RunResult.lastInsertRowid正是当前领域代码广泛使用的返回值)
FTS5 或sqlite-vec语法全文/向量检索属于能力项,不属于可移植面
供普通业务代码使用的"通用方言逃生舱"逃生舱一旦存在就会被用滥,边界形同虚设

后端能力面

SQLite 专属维护保持在 SQLite 自己的实现与运维接口之后,包括:运行时驱动选择、WAL checkpoint 与关闭行为、page-size/cache-size/auto-vacuum 设置、数据库文件备份/恢复、SQLite schema 内省、FTS5 与sqlite-vec集成。

对等规则是:外部后端无义务模仿这些能力。仓库必须三选一:使用可移植能力、提供带文档行为的后端专属实现、或明确报告该能力不可用。这条规则防止了"兼容性 shim 慢慢渗漏"的经典劣化路径。

事务与迁移模型

事务:契约面向可观察保证,而非 SQL 模式

仓库 API 定义原子业务操作,调用方不选择SQL 事务模式(deferred/immediate 由 SQLite 实现内部决定)。每个操作必须定义其可观察的并发保证:受保护的不变量、冲突检测、重试分类、幂等性预期、事务上下文传播。实现可以使用不同的事务与隔离机制,前提是这些可观察保证等价;SQLite 内部可以继续用当前的 deferred/immediate 行为,只要满足操作契约即可。

迁移:显式的所有权

外部后端要求显式的迁移所有权,防止多个应用副本竞争同一 schema 变更。后端之间的迁移历史可以共享"逻辑里程碑",但 SQLite 的 SQL 文件不被假定为可移植、更不假定可复用于其他方言——这与 migrationRunner 深度依赖sqlite_master/PRAGMA table_info的现状一致。

跨后端一致性语义

ADR 要求一致性测试覆盖行为而非仅仅仓库方法签名。每个迁移后的域必须定义并验证九个维度:

  1. 时间戳的时区、精度与序列化;
  2. NULL排序、collation 与大小写敏感性预期;
  3. JSON 表示与比较行为;
  4. 整数、小数与货币精度;
  5. 稳定排序与分页时的确定性平局裁决(tie-breaker);
  6. 不依赖 SQLite row ID 的 ID 生成;
  7. 唯一性与外键违规的分类;
  8. no-op、compare/update 与 delete 操作的 affected-row 行为;
  9. 并发写入的结果、可重试冲突与幂等重试。

收尾条款同样重要:如果一个域无法表达等价的可观察语义,它就不算可移植,必须保持后端专属,直到该契约被设计出来。这为"先做、再谈移植"提供了清晰的判据,而不是模糊的"尽量兼容"。

兼容性要求

任何遵循该 ADR 的实现必须保持以下属性(逐条对应仓库现状):

  • SQLite 保持零配置默认;
  • 现有 SQLite 文件与迁移历史保持可读;
  • npm、Electron、Docker 与受限运行时的 SQLite 回退保持当前启动路径(对应 driverFactory 的四运行时级联与 electron/ 桌面的零服务启动模型);
  • 存储的 provider 凭据继续使用现有的应用层加密行为(见 encryption.ts 的migrateLegacyEncryptedString等机制,由 core.ts 导入);
  • 仓库迁移不得悄悄改变路由、配额、API 密钥或审计语义;
  • 备份与恢复行为按后端分别文档化,而不是一律宣称通用;
  • 纯 SQLite 的干净安装不加载、也不要求任何外部数据库驱动。

交付序列

ADR 将实施拆成七步,每步都是独立的、可审查的 PR,且后一步不能作为提前合入前一步"未证明的抽象"的理由:

  1. 发布可复现的 SQLite 耦合清单(coupling inventory),作为独立审查工件;
  2. 引入首批域仓库契约与一致性测试;
  3. 在不改变默认配置的前提下,将现有 SQLite 实现适配到契约之后;
  4. 经 maintainer 批准后,为一个受限的 control-plane 切片加入 PostgreSQL 作为第一个外部实现;
  5. 只有存在并发写入与迁移所有权测试后,才扩展共享状态;
  6. 在宣传"可切换数据库"之前,先加入离线、已验证的 SQLite-to-external 迁移路径;
  7. 让 MySQL 对已证明的仓库与后端契约进行实现。

首个实现切片的准入条件

首个运行时切片应在耦合清单评审后选定。候选域是 provider connections、API keys、combos 与路由配置——因为它们的基表在 core.ts 的CRITICAL_DB_TABLES中可见——但 ADR 明确不批准任何表清单或迁移 PR。切片必须包含:

  • 保持 SQLite 行为不变的行为保留测试;
  • 仓库一致性测试;
  • 显式的事务边界;
  • 对存储凭据的加密与脱敏(redaction)验证;
  • 默认启动配置零变更。

被否决的替代方案

ADR 完整记录了五个被否决的方向及其理由,这是判断架构取舍的典型样本:

替代方案否决理由
SqliteAdapter之下加 PostgreSQLSqliteAdapter是 SQLite 运行时的兼容层,暴露 SQLite 专属操作;模拟该表面会把同步与方言假设泄漏进新后端
向所有域暴露通用 query/execute API作为主边界会集中连接处理,但 SQL 方言、事务与表的耦合仍留在业务模块中;低层后端原语可以存在于仓库实现内部,但不作为应用面向的持久化 API
先重写全部持久化、再验证一个切片当前持久化面很宽(文件生命周期、恢复、搜索、运维设置),垂直切片提供可审查的行为与回滚边界
以外部数据库取代 SQLite 默认嵌入式与桌面部署依赖当前零服务启动模型,外部后端是 opt-in
用 Redis 作为持久权威Redis 适合显式临时的协调、缓存或计数器,不能替代此处定义的持久仓库契约(仓库中 Redis 仅用于此类场景,见 REDIS_PRODUCTION_CONFIG.md)

影响:收益与成本

收益:

  • 业务代码获得独立于数据库方言的稳定持久化接缝;
  • 外部后端定义抽象之前,SQLite 行为先被测试固化;
  • PostgreSQL 与 MySQL 共享契约与测试,而非复制领域逻辑;
  • SQLite 专属能力保持一等公民地位,而不是沦为渗漏的兼容 shim;
  • 多副本迁移与事务行为成为显式设计关切。

成本与风险:

  • 仓库抽取需要增量迁移调用点;
  • 异步边界可能沿当前同步的服务代码向上传播;
  • 跨后端语义需要超越 SQL 语法兼容性的测试;
  • 备份、搜索、向量存储与维护仍按能力项单独处理;
  • 同时运行多个持久化实现会增加 CI 与运维支持成本。

非目标与开放问题

该 ADR 明确不做以下事情:不新增数据库依赖/环境变量/schema/迁移;不改动存活的 SQLite 单例与驱动级联;不承诺某个版本提供 PostgreSQL 或 MySQL 支持;不使 FTS5、sqlite-vec、备份文件或 SQLite 维护变得可移植;不在共享状态与协调测试存在前定义 active-active 就绪度;不批准对src/lib/db/的一次性重写。

留给 maintainer 批准的五个开放问题:

  1. "仓库 + 内部异步后端边界"是否是首选方向,还是外部持久化应放在独立的 control-plane 服务之后?
  2. 在 SQLite 一致性证明之后,PostgreSQL 是否可接受为第一个外部实现?
  3. 哪个域应作为首个受限仓库切片?
  4. 首个多副本里程碑中哪些状态必须共享、哪些保持节点本地?
  5. 被中断或回滚的仓库迁移需要多长的兼容窗口?

小结

这份 ADR 的价值不在于"将来支持 PostgreSQL",而在于它把一个嵌入式 SQLite 系统的耦合面显式化:同步适配器表面、文件生命周期、迁移内省、运维 PRAGMA,全部被清点并划出边界。通过"域仓库契约只暴露领域操作 + 内部后端契约承接 health/迁移/能力"的两级设计,配合九个维度的行为一致性测试与七步可回滚交付序列,它把"换数据库"从一个一次性重写风险,转化为一组独立可审查、独立可回滚的工程步骤。对维护类似多运行时 SQLite 架构的开发者而言,其模板意义——先固化默认实现的测试、再冻结契约、最后才引入外部实现——与具体数据库选型同样重要。

【免费下载链接】OmniRouteNever stop coding. Free MIT AI gateway: one endpoint, 352 providers (150+ free), 1200+ models Kimi, Claude, GPT, Gemini, GLM, DeepSeek, MiniMax. Works with Claude Code, Codex, Cursor, OpenCode, Cline & Copilot. Quota-aware auto-fallback, RTK+Caveman compression saves 15-95% tokens, MCP/A2A, Desktop/PWA. Built by 550+ contributors项目地址: https://gitcode.com/GitHub_Trending/om/OmniRoute

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询