Skip to content

fix(metadata): capabilities.write 同时约束 save() —— 可写 datasource loader 必须实现写入与删除两半 (#5654) - #5761

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-5654-loader-save-contract
Aug 6, 2026
Merged

fix(metadata): capabilities.write 同时约束 save() —— 可写 datasource loader 必须实现写入与删除两半 (#5654)#5761
os-zhuang merged 2 commits into
mainfrom
claude/issue-5654-loader-save-contract

Conversation

@os-zhuang

@os-zhuang os-zhuang commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Fixes #5654

前提复核(先证伪,再动手)

origin/main 上逐条复核了单子的前提,成立

  • packages/metadata/src/metadata-manager.ts:129assertWritableLoaderCanDelete 只查 delete
  • 同文件 :665 的持久化循环把 loader.save && 排在最前,声明了 protocol: 'datasource:' + capabilities.write: true 却没有 save() 的 loader 会被一声不吭地跳过
  • 全仓 datasource: 协议的 loader 只有 DatabaseLoadersave/delete 都有 —— 所以今天没有用户路径踩得到,这是留给第三方 / AI 照接口新写 loader 的坑(单子的判断准确)。

改动

一句话:#5276delete 一侧立的门禁,补齐 save 一侧。同一个 capabilities.write 声明,此前在一个生命周期端点上是强制的、在另一端是装饰性的。

关于 :665loader.save && 短路(按认领评论的方向)

保留,并改写成与 unregister() 完全对称的两行 + 注释:

if (loader.contract.protocol !== 'datasource:' || !loader.contract.capabilities.write) continue;
// [#5654] Defensive only — unreachable for a registered loader ...
if (typeof loader.save !== 'function') continue;
await loader.save(type, name, data);

推理:registerLoader()this.loaders唯一写入口(构造函数的 config.loaders 也走它),所以「datasource: + write + 无 save」对已注册 loader 不可达;但 save?MetadataLoader 接口上仍是可选的(门禁不覆盖的协议合法地没有它),删掉这行的代价是下一行直接 TypeError。所以它从「策略」降级为「类型收窄」,而不是被删除 —— 与 #5652delete 的处理一字不差。读取顺序也调成先协议/能力(策略)后方法(收窄),与删除侧一致。

反向验证(先定方向,再跑)

预测:把门禁的 save 一臂摘掉(WRITABLE_LOADER_METHODS 只留 'delete'),save 侧的拒绝用例应转,delete 侧与作用域/正向用例应保持绿。

实测Tests 5 failed | 19 passed (24) —— 方向一致,但数量与预测差一个,如实记录:转红的是 5 条而非 6 条。第 6 条(the delete-only rejection #5276 shipped still reads exactly as it did)钉的是「只缺 delete 时消息与 #5652 逐字一致」,摘掉 save 一臂后这条消息仍然产生,所以它应当保持绿 —— 它本来就不是 save 一臂的钉子。delete 侧 11 条全绿,证明本次改动没有动到 #5276 的既有语义。

测试

新增 packages/metadata/src/metadata-manager-loader-save-contract.test.ts(13 条),钉住:

  1. save 的可写 datasource loader 在 registerLoader() 与构造函数两条入口都被拒,且没有半注册(不在私有 loaders map 里);
  2. 消息可执行:点名 loader、点名声明、点名后果(created/updated 已广播、gone at the next restart)、两条修法;并且 toBe(buildWritableLoaderMissingMethodsMessage(...)) —— 是 builder 那一份文本本身,不是转述(顺带让这个 export 真正被引用);
  3. 两个方法都缺 → 只抛一次、两个都点名;
  4. 正向用例的耐久性:带两个方法的 loader 注册 → register() 真的写进 store → 用同一个 store 新建一个 manager(模拟重启)仍读得到 —— 正是静默跳过会丢的那一次重启;
  5. 作用域恰好是 register() 作用的组合:只读 datasource:、以及 file:/memory:/http:/s3: 四种协议即使 write: true 且无 save 也照常注册;
  6. DatabaseLoader 在加宽后的门禁下不受影响。

命令与真实输出(均在共享 verify 锁内、--max-old-space-size=4096--maxWorkers=2):

pnpm --workspace-concurrency=2 --filter @objectstack/metadata test
  Test Files  25 passed (25)
       Tests  508 passed (508)

npx vitest run src/metadata-manager-loader-save-contract.test.ts src/metadata-manager-loader-delete-contract.test.ts --reporter=verbose
  Test Files  2 passed (2)
       Tests  24 passed (24)

类型:@objectstack/metadatatypecheck script(在 check-type-check-coverage.mjs 的 DEBT 账本里)。所以直接跑了 tsc --noEmit -p packages/metadata/tsconfig.json 并与改动前对拍:

改动前 92 raw errors  →  改动后 92 raw errors(增量 0;补完 TSDoc 后复跑仍为 92)
我改动的四个文件中的报错数:0
check:type-check-coverage    OK — 63/78 packages type-checked, 15 DEBT, 1 exempt

其他门禁:

check:nul-bytes                 OK(5649 files;另对改动文件做了越过门禁盲区的 grep -naP 自扫,干净)
check:durability-log-level      OK — 24 seams, all loud
check:startup-registry-verdict  OK — 40 seams, none recording a contradictable verdict
eslint --no-inline-config(四个改动文件)  无输出

合入前已 git merge origin/main(无冲突,来项不触及 packages/metadata),重建依赖后重跑上述 metadata 用例仍 508/508。

影响面与 changeset

.changeset/metadata-loader-save-contract.md(patch)写明了 FROM → TO:只影响注册自定义 datasource: + write: true loader 且没有 save() 的使用者 —— 此前注册成功并静默丢写,现在注册即抛,错误里同时给出两条修法(实现 save(type, name, data, options?): Promise< MetadataSaveResult >,或诚实地声明 capabilities.write: false)。仓内唯一的 datasource: loader DatabaseLoader 两个方法都有,不受影响。

作者面:接口 TSDoc 已按 PM 裁决补齐

delete? 上本来带着 #5276 写下的「对 datasource: + write 而言是强制的」那段,而 save? 上没有 —— 门禁加宽后,这个不对称正好指反了:接口只讲了一个约束两半的契约的其中一半。已按 PM 裁决(#5654 评论 5200271661)在同一分支追加一次纯注释提交补齐:

无签名变化、无运行时影响;tsc 增量 0、eslint 无输出。

…ource loader must implement both halves (#5654)

`registerLoader()`'s #5276 gate required `delete()` for a `datasource:` loader
declaring `capabilities.write: true`, and stopped there. `register()`'s
persistence loop read `loader.save &&` FIRST, so the same declaration with no
`save()` method was silently skipped — no warn, no error — while the registry
write, list-cache invalidation, `created`/`updated` event and watcher
notification all proceeded, telling the caller the write had landed. The item
read back for the life of the process and was gone at the next restart.

The gate (renamed `assertWritableLoaderContract`) now requires both methods and
rejects with one message naming which are missing, the consequence, and both
repairs. `register()`'s `save` short-circuit survives as annotated defensive
code whose unreachability is guaranteed by `registerLoader()` being the sole
writer of the loader map — mirroring `unregister()`'s `delete` guard.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V7WetGmnfoXNn8cLieKKmx
@vercel

vercel Bot commented Aug 6, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 6, 2026 4:26am

Request Review

@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/metadata.

7 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/concepts/metadata-lifecycle.mdx (via @objectstack/metadata)
  • content/docs/kernel/cluster.mdx (via packages/metadata)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/metadata)
  • content/docs/plugins/packages.mdx (via @objectstack/metadata)
  • content/docs/protocol/kernel/metadata-service.mdx (via @objectstack/metadata)
  • content/docs/releases/v12.mdx (via @objectstack/metadata)
  • content/docs/releases/v9.mdx (via @objectstack/metadata)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

…ere the author is standing (#5654)

`delete?` carried #5276's note that it is mandatory for a `datasource:` loader
declaring `capabilities.write`; `save?` carried nothing, so after #5654 widened
the gate the asymmetry pointed the wrong way — the interface documented one half
of a contract that binds both.

`save?` now states the same thing in its own terms (silent skip in `register()`,
the write announced but never landed, gone at the next restart), and `delete?`'s
note points at the widened gate by name (`assertWritableLoaderContract`, #5276 +
#5654) instead of describing a delete-only one. Comments only — no signature
change, no runtime effect.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V7WetGmnfoXNn8cLieKKmx

Copy link
Copy Markdown
Contributor Author

已按 PM 裁决(#5654 评论 5200271661)追加纯注释提交 630ff40loaders/loader-interface.tssave? 补上与 delete? 对称的契约说明,delete? 那段改为指向加宽后的同一个门禁 assertWritableLoaderContract#5276 + #5654)。

轻量验证(按裁决要求,未重跑全套件——注释无运行时影响):

tsc --noEmit -p packages/metadata/tsconfig.json   92 raw errors(与改动前对拍,增量 0)
  其中 loader-interface.ts 报错数                  0
eslint --no-inline-config loader-interface.ts     无输出
grep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f]'          无控制字节

PR 正文的「一处按硬约束留手的地方」一节已改写为「作者面:接口 TSDoc 已按 PM 裁决补齐」,请裁决措辞已删除。PR 仍为 draft,未自行转 ready。


Generated by Claude Code

@os-zhuang
os-zhuang marked this pull request as ready for review August 6, 2026 05:14
@os-zhuang
os-zhuang added this pull request to the merge queue Aug 6, 2026
Merged via the queue into main with commit 1624f4a Aug 6, 2026
25 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-5654-loader-save-contract branch August 6, 2026 05:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants