跨平台注册表接缝(多仓库架构因此存在)
上级:key-designs
@lucerna/core 从不导入某个 shell,却同时驱动着一个 Next.js Web 应用和一个 Electron 桌面应用。其机制是三个模块级的注册表接缝(registry seam),各自由所属的 shell 在启动时设置一次,并通过一个惰性代理来读取,因此导入顺序无关紧要。
接缝一——平台适配器(core/src/platform/index.ts)。PlatformAdapter = storage(键值存储)+ navigate(path) + origin + setTheme。buildDefaultAdapter() 具备浏览器感知能力(若存在 window 则接入 localStorage/location/documentElement,在 SSR/测试环境下则全部空操作,以免模块初始化时的读取动作导致崩溃)。setPlatformAdapter(impl) 用来替换它;platform 是一个 getter 代理,每次访问都会重新读取当前生效的适配器。
接缝二——仓库层的依赖反转(最要紧的一个)。接口定义在 core/src/domain/repositories/ 中;该目录的桶文件(barrel)本身就是 DI 容器:setRepositories(partial) 把各实现合并进一个 registered 映射表,resolve(key) 在未注册时会抛出 "core: ${key}Repository not registered"(失败即报错,没有任何回退),而 lazy<T>(key) 返回一个 Proxy,它解析出当前生效的实现,并在每次调用时把方法绑定到该实现上。Core 端只需要调用 getVocabularyRepository().create(...),完全不知道底层走的是 HTTP 还是 SQLite。(如实记下的一笔债:HttpError 之所以要从 domain 的桶文件里重新导出,是因为用例代码需要根据 err.status === 401 分支处理——干净的修法已经被承认,但还没有落地。)
接缝三——domain 包里的 zustand store(core/src/state/)。与 UI 无关的 store 通过 platform.storage(而不是直接用 localStorage)来做持久化,因此同一个 store 在 Electron、SSR 和测试环境下都能正常工作;React 一侧的 shell 把它们当作普通的 hook 来消费即可。
同一个仓库接口,两套实现。 VocabularyRepository(core 中的接口)→
- web:
HttpVocabularyRepository——对vocabularyApi.*的纯委托,不持有任何本地状态; - desktop:
HybridVocabularyRepository——本地 SQLite 加 outbox 包装器(offline-first-outbox)。 每个 shell 的coreBootstrap.ts都会组装出自己的实现集合,并调用一次setRepositories(...)(web → 10 个 HTTP 委托;desktop → 7 个 hybrid 加 3 个 HTTP)。
web 与 desktop 之间真正的差异(存储方式之外):认证传输方式(web 用会话 cookie,desktop 用 Bearer token,因为 webview 会丢弃跨站的 Set-Cookie);导航方式(web 用整页的 location.href 刷新,desktop 用 SPA 式的 pushState,以便在认证事件前后保留 sqlite 句柄与 outbox);语言区域(web 走 URL 前缀 /en/…,desktop 则只存在 store 里);以及 Electron 独有的 main process(better-sqlite3、操作系统密钥库、自动更新、本地 TTS)——浏览器端根本没有与之对应的东西。
这就是整个多仓库架构存在的原因:这些接缝就是那份契约,正是它让同一个 domain core 能够服务于两种截然不同的运行时。