· 8 分钟阅读

为什么数据库 GUI 永远不应直接写入

每个数据库 GUI 都有这样一个时刻:一次点击变成了一次针对生产环境的写入。我们决定让这个时刻根本不存在。在 DynoTable 中,你所做的一切 —— 无论是编辑一个项、批量删除,还是一个 的改动 —— 都不会直接触及 DynamoDB。每一次写入都会作为一份可审查的、逐属性的 diff 落入一个,并且只有当人来提交时才会发出。

我们所追求的心智模型是 git:一个供审查的暂存区,以及一次行为类似 git push --force-with-lease 的提交 —— 只有当远端仍然维持你上次所见的样子时,它才会成功。这篇文章讲的是其底层的工程:那个重塑了整个设计的跨标签页 bug、那次主要产出是_被删除的代码_的重构,以及为什么 AI 智能体的到来把一项 UX 上的锦上添花变成了那面承重的安全墙。

写入即 diff

编辑会累积在一个由本地 SQLite 支撑的暂存中,并在侧边栏中渲染成 diff 卡片 —— 旧值、新值,逐属性呈现。网格中的行会染上色调,因此暂存状态从数据本身就能看到,而不只是在面板里。提交会把暂存集合作为 DynamoDB 发出,按服务的上限分块(每个事务 100 项,并对每次操作和每次请求设有字节预算),顺序执行,每个分块有 30 秒超时。

暂存面板,将待处理的更改显示为逐属性的 diff 卡片,支持逐行提交和批量提交。
暂存面板,将待处理的更改显示为逐属性的 diff 卡片,支持逐行提交和批量提交。

暂存文档介绍了日常工作流。它们没有涵盖的是暂存以什么_作为键_ —— 而这最终成了整个系统中最具影响力的决定。

以表为键,而非以标签页为键

第一个版本将每个暂存的作用域限定在创建它的那个标签页上。这个设计催生了一个真实的 bug:AI 的暂存工具会从_当前活动标签页_推导它的目标,而它的守卫会跳过那些并非表视图的标签页。因此,如果你在一个 SQL 工作台标签页处于焦点时让助手修正某一行,这次编辑就会被暂存进工作台的暂存里 —— 而“显示暂存更改”的 chip 会打开错误的标签页。

更糟的是,提交锁也是按标签页划分的。两个查看_同一张表_的标签页持有两把独立的锁 —— 这意味着两者可以并发地把同样的暂存行提交到真实的 DynamoDB。一个针对生产环境的双重提交竞态,就这样内建进了数据模型里。

修复方案是把一切都改为以表身份 —— {profile, region, tableName} —— 而非以标签页为键:

  • 每张表一个暂存,从它的每一个视图都能看到,并在标签页关闭再重开后依然存续。AI 甚至可以在完全没有打开表标签页的情况下暂存。
  • 每张表一把提交锁。双重提交竞态并非被“处理”了 —— 它根本无法被表示。
  • 那一类错标签页的 bug 已经_从构造上_消失了:键里根本没有标签页。

而这次重新设键删除的代码比它新增的还多。那个在启动时为已关闭标签页回收暂存的清扫过程?一位评审者指出它在新模型下是在破坏功能 —— 暂存不再随标签页消亡 —— 于是它被彻底移除了。关闭标签页即丢弃及其确认对话框:移除了。唯一真正剩下的孤儿是一个被删除的连接配置文件,它会被显式地级联清理。

这次迁移本身是本仓库第一个会对行进行_变更_的迁移:一次手写的 SQLite 表重建,用一个 ROW_NUMBER() OVER (PARTITION BY …) 窗口(最新的编辑胜出)对遗留的按标签页划分的行去重,并用 char(0) 分隔符回填新的键,与运行时的键构造器逐字节一致。对行进行变更的迁移在那天有了自己的先种子后迁移的测试框架。

--force-with-lease,用于 DynamoDB

如果你审查的东西并不是最终被写入的东西,那么审查界面就毫无价值。在暂存与提交之间,可能已经有别人改动了那一行。因此每一次提交操作都携带,把写入钉死在你所审查的那个确切快照上 —— ,逐个属性地进行:

  • 一次创建会断言该项尚不存在。
  • 一次更新会断言你正在改动的每一个属性仍然保持着你暂存它时所看到的值。
  • 甚至一次移除也会断言该属性仍然等于它的旧值 —— 如果在你对 note 的移除处于暂存状态时,一位队友把 note"old" 改成了 "new",那么这次提交绝不能悄无声息地删掉他们的编辑。

当某个条件不成立时,DynamoDB 会取消整个事务,并告诉我们是哪一项发生了漂移。那一行会得到一个冲突横幅 —— 针对实时值进行变基,或是中止 —— 而不是在任一方向上悄无声息地覆盖。

而在任何分块失败时,提交器会停下。已提交的分块保持已提交,失败的分块原子性地回滚,不再尝试后续任何操作。我们刻意拒绝了尽力而为式的继续:一个越过冲突继续写入的审查工具,正在应用一份无人审查过的变更集。

human reviews diffsTransactWriteItems +per-attribute conditionscondition failedrebaseEdit / AI stageItemStaging areaper tableCommitDynamoDBConflict banner:rebase or abort

那次删除了一个子系统的重构

提交是长时间运行的:应用会把每一次提交登记进一个重放注册表,这样一次渲染进程重载 —— 或是第二个窗口 —— 就能重新挂接并看着它完成。随着时间推移,三条不同的代码路径各自为每次提交挂接了_两个_观察者,而围绕一个问题生长出了一整套仲裁机制:哪个观察者被允许释放提交锁?它有自己吓人的名字(ownsLockOnBeforeRegistration)、一段解释某个 IPC 顺序竞态的注释块,以及一个 230 行的 toast 追踪器。

修复方案是一个单一的所有者:一个提交会话(Commit Session),它每次提交只挂接一次,独占地持有那把锁,把进度投射进 store,并保证它的启动调用在每一条退出路径上都会了结 —— 绝不挂起,绝不拒绝。那套仲裁机制和那个追踪器并没有被搬走;它们被删除了。那个反复冲击暂存面板的内存基准测试,其堆占用下降了大约三分之一。那个月我们收到的最好的代码评审评论是:“这个 PR 基本上都是红色的。”

从这个所有权模型中自然推出两个行为 —— 我们认为它们对于任何写入生产环境的工具都是入场底线:一次进行中的提交能在渲染进程重载中幸存(会话会重新挂接并完成),它甚至能在你的许可证于提交中途翻转为只读时幸存 —— 新的提交会被阻止,但一次已经在途的写入会被观察到底,绝不会在只应用了一半的状态下被抛弃。

为什么线格式是刻意做得丑陋的

暂存的值以原始的 DynamoDB 带类型信封形式存储 —— {N: "42"}{S: "42"} —— 而不是友好的、已 unmarshall 的 JSON。有两个原因。diff 必须是_类型感知_的:{N: "1"}{S: "1"} 是不同的值,而一个基于已 unmarshall 值构建的主键哈希会把它们相撞。而且往返必须是无损的:SDK 的已 unmarshall 数值包装器无法在序列化为 JSON 存入 SQLite 再取回的过程中存续,对照用户输入的深度相等比较也会因此失效。提交路径出于同样的原因使用原始客户端 —— 你所审查的,逐字节就是最终被作为条件判断并被写入的东西。

然后智能体来了

暂存的出现早于我们的 AI 功能,但它正是这些功能得以交付的原因。助手的写入工具会暂存;它自己的描述逐字地告诉模型:

The user reviews + commits from the staging panel —
this tool never writes to DynamoDB directly.

根本没有提交工具。不是受权限门控,不是被审计 —— 而是_不存在_。MCP 服务器在结构上暴露了同样的边界:它中间那一档同意权限范围就直白地命名为“读取 + 暂存”。一个处于该权限范围、被的智能体可以提议一堆垃圾,而其爆炸半径不过是一张人来阅读的 diff 卡片。而且由于提交携带逐属性的乐观锁,即便一个陈旧的智能体编辑也无法悄无声息地砸掉一个并发的人类编辑 —— 它会像其他一切一样浮现为一个冲突。

我们写过如何让智能体的查询变得可靠;暂存则是另一半 —— 让它的_写入_变得平淡无奇。

对任何写入你数据库的工具,你应当要求什么

  • 在意图与写入之间有一个审查步骤 —— 用 diff,而不是确认对话框。
  • 针对_所审查的快照_的乐观并发控制,包括删除和移除 —— 绝不是最后写入者胜出。
  • 原子批次,失败即停,而不是越过冲突尽力而为地继续。
  • 一条能在崩溃或重载中幸存、不会留下只应用了一半的集合的写入路径。
  • 对于 AI:把暂存作为智能体所拥有的_唯一_写入原语 —— 一个缺失的能力胜过一个受守卫的能力。

暂存文档展示了工作流,而编辑 DynamoDB 数据介绍了它所保护的那些基础操作。或者下载 DynoTable,用 ⌘S 暂存几处编辑,看着一次批量更改变成某种在它发生之前你真的能读懂的东西。

无需控制台即可使用 DynamoDB

DynoTable 是面向 DynamoDB 的快速桌面客户端 —— 浏览表、运行 SQL 风格的查询,并在本地编辑项。