Skip to content

fix(objectql): sys_file hydrate 读故障与「无文件」可分辨 (#6116) - #6456

Merged
baozhoutao merged 3 commits into
mainfrom
claude/issue-6116-sys-file-hydrate-fault
Aug 7, 2026
Merged

fix(objectql): sys_file hydrate 读故障与「无文件」可分辨 (#6116)#6456
baozhoutao merged 3 commits into
mainfrom
claude/issue-6116-sys-file-hydrate-fault

Conversation

@baozhoutao

Copy link
Copy Markdown
Contributor

Fixes #6116

resolveFileReferences 把存成 sys_file 不透明 id 的文件字段值富化成
{ id, name, size, mimeType, url }(ADR-0104 D3 wave 2)。它那一次批量查找此前
坐在裸 catch { return records } 后面:连接中断、超时、权限拒绝、查询错误,与
良性的「表还没建」一样,都被同一次静默的裸 id 穿过所回答。消费方(UI / 导出)
拿到裸 id 后按「无附件」渲染,故障期间的表现与「记录本就没有文件」不可分辨 ——
ADR-0110 D3 的族形,只是载体是功能面而非持久性面。

fail-open 行为本身不变,这是可诊断性修复,不是行为修复。 文件元数据读失败
不该拖垮发起它的记录读,所以两个分支都仍然原样返回入参,没有任何读开始抛错。
变的是两种原因不再共用同一份沉默。

前提复核表(动手前逐条实测)

前提 结论 证据
P1 catch 位仍是 catch { return records } 零日志形状 ✅ 成立,但对 issue 正文有一处更正 以内容定位(不信行号,#6433/#6440 刚动过 engine.ts):实际落在 packages/objectql/src/engine.tsresolveFileReferences 内、sys_file 批量读之后,原文为 } catch { + return records; // sys_file unregistered / unreadable — leave ids as-is更正:catch 位确为零输出,但整条路径并非字面无声 —— 见下节。
P2 isMissingTableError 存在且是仓内同职良性判别器的标准判法 ✅ 成立 engine.ts 第 76 行 import { isMissingTableError } from '@objectstack/metadata/errors';现用调用点在同文件 seedAutonumber(#5979 落地),形状完全同族:良性 ⇒ 保持廉价答案,非良性 ⇒ 不当作良性处理。闸门的 READ_FAILURE_DISCRIMINATORS 也只登记了这一个判别器。
P3 对齐 #5840 getDiagnosed 是否需要改调用方契约 ✅ 需要 ⇒ 按分诊口径不取该形,取最小形 getDiagnosedMetadataManager 上的读 API,返回 { data, degraded, errors }。本处 resolveFileReferences 是 private 方法,两个调用点是 find(engine.ts 内)与 findOne;要把判定真正交到「调用方」手里,信封必须一路透出到 find/findOne公开返回契约上。分诊原话是「仅当不动契约时可选」,故排除。

P1 的更正:通用行存在,但它不是判别器

实测(fake driver 注入故障 + 捕获 logger):上一帧的通用读处理器
engine.ts'Find operation failed' 已在重抛进本 catch 之前记了一行
error,meta 为 { object: 'sys_file' }。所以 issue 正文的「不记一行日志」在
catch 位上成立,在整条路径上不精确。

该行未被本 PR 触碰(分诊红线:不触 engine.ts 其他区域),也不构成对验收的
替代 —— 测试里 the pre-existing generic line cannot tell the two apart 一节
把两条理由钉住:

  1. 它对良性与非良性故障逐字相同(两次都恰好是 ['Find operation failed']),
    所以只读这一行的运维无法判断手上的答案能不能信;
  2. 它只描述 sys_file 子读,meta 里没有 doc 也没有 attachment —— 从不提及
    父对象、被留作裸 id 的字段,或那个仍然返回给调用方的降级答案。

验收要求的「可分辨」正是这两点,所以修复照做。

取舍:为什么取最小形

最小形(catch 内按良性判别器分流 + 非良性一条 warn),不取 getDiagnosed 形:

  • getDiagnosed 形的收益是把判定交给调用方,但本处真正的「调用方」是 find /
    findOne 的公开返回值。要透出信封就得改这两个公开契约,而本单是可诊断性修复,
    分诊也明确只在「不动契约」时才允许该形 —— 契约要动,所以不动;
  • 最小形与同文件既有形状对齐两处:错误分流对齐 seedAutonumber
    (isMissingTableError),日志形状对齐紧邻上方expandRelatedRecords
    —— 那个 catch 同样是 fail-open 保留裸外键 id,同样记一条
    this.logger.warn('Failed to expand relationship field; retaining foreign key IDs', { object, field, … })
    file 字段的 hydrate 就是 lookup 展开的孪生路径,孪生路径已经在 warn,这条不该继续沉默。

分流后的两个分支:

  • 表未建(storage 插件在、schema sync 没跑):确实没有已提交的行,未 hydrate 的
    答案就是真相 ⇒ 保持现状,静默穿过。反过来在这里出声会让所有还没同步存储
    schema 的应用每次读都刷一行,正是让真实告警变得不可读的噪音;
  • 其他读故障:一条 warn,带父对象、未 hydrate 的字段、未解析 id 数、driver
    自己的报错、后果与修法;每次读说一次,不是每条记录或每个 id 说一次。

warn 不升 error,按 AGENTS「Degradation log levels」的判定问句:此路径不声称
任何持久化,损失是功能性的、只影响本次响应,下一次成功读即修复。

闸门词表表态(必答项)

结论:该扩,但作为一条新判据,而不是往 EMPTY 值表里塞一项;且扩之前必须先量
假阳性面。已按 Prime Directive #10 立独立单 #6451(未认领,finding 标签),
⛔ 未夹带进本 PR
(闸门在 scripts/,devx 车道)。

理由摘要:

  • 支持扩:危害轴与 [] / null 完全同族 —— 读没发生,调用方却拿到一个与
    合法的「空 / 无」不可分辨的答案。规则名叫 invention,但它真正保护的是可分辨性;
  • 需审慎:对富化 / 装饰类函数,「原样返回入参」是它 happy path 上的已声明
    契约
    (inline blob、外部 url 字符串本来就原样穿过),不是编造。要把「作为契约的
    穿过」与「吞掉故障的穿过」分开,需要知道函数的声明语义,而这条语法规则看不到 ——
    与脚本头部自陈的局限 1(信封形状判不了)是同一类盲区;
  • 因此判据应是「catch 返回了本函数的某个形参」,仍受原有两条豁免约束(catch 内有
    任意级别日志 ⇒ 豁免;按错误类型判别 ⇒ 豁免)。本 PR 修完后这两条豁免都命中,
    所以将来扩了也不会把这个点判红。

实测佐证:闸门在修复前后都报绿(65 read seam(s), none invents an unreported empty answer)—— 缺陷在闸门下存活到人工清扫才被看见,这正是 #6451 的实证样本。

反向验证表(方向先写死,再跑)

预测(动手前写定):把分流逻辑回退成裸 catch { return records }非良性
warn 断言转红,fail-open pin 保持绿

断言组 预测 实测 判定
非良性 ⇒ 恰好一条 warn(4 类故障)+ 文案含对象 / 字段 / 后果 / 修法 + 每次读只说一次 红,6 例 ✅ 与预测一致
fail-open pin(7 类故障 × 返回入参不变、不抛;含 findOne) 绿 绿
良性 ⇒ 不新增日志 绿 绿
通用行无法分辨(documentary,钉的是「为什么需要修」) 绿 绿 ✅ 诚实标注:这一组不由本次改动驱动,它固化的是前提事实而非修复本身
健康路径 / 干净未命中仍静默 绿 绿

回退实测输出:Tests 6 failed | 15 passed (21),红的恰好是
read outage → exactly one warn that names the loss 整组。恢复修复后 21 passed (21)

命令输出

# 新测试文件(21 例)
$ npx vitest run --maxWorkers=2 src/engine-file-hydrate-outage.test.ts
 Test Files  1 passed (1)
      Tests  21 passed (21)

# objectql 全量(merge origin/main 之后重跑)
$ pnpm --workspace-concurrency=2 --filter @objectstack/objectql test -- --maxWorkers=2
 Test Files  143 passed (143)
      Tests  2401 passed (2401)

$ pnpm --workspace-concurrency=2 --filter @objectstack/objectql typecheck
> tsc --noEmit          # 无输出,exit 0

$ node scripts/check-durability-degradation-log-level.mjs
✓ durability-degradation log levels: 24 durability-critical catch seam(s), all loud, rethrowing or propagating to the caller (3 propagating, declared).
✓ read-seam invention (#5186, 3 package roots): 65 read seam(s), none invents an unreported empty answer (7 return an empty value on a type-discriminated benign branch) (1 baselined).

$ pnpm check:engine-double-contract
check-engine-double-contract: OK — 80 pinned, 133 in the DEBT ledger, 4 exempt.

# 先全量 build 再跑棘轮(§9 陈旧产物陷阱)
$ npx turbo run build --concurrency=2 --filter='./packages/*' --filter='./packages/*/*'
 Tasks:    70 successful, 70 total
$ pnpm check:type-check-debt
check-type-check-coverage: OK — 62/77 workspace packages type-checked (plus the root), 15 in the DEBT ledger …
  ℹ @objectstack/objectql: TEST_DEBT records 355, tsc now reports 347 (-8) -- the entry can be lowered.
check-type-check-coverage --re-measure: OK — 34 ledger entr(ies) re-measured in 238.6s, 1771 raw tsc error(s) total, none above its recorded number.

$ node scripts/check-nul-bytes.mjs
check-nul-bytes: OK (scanned 6086 tracked text file(s); … no raw ASCII control bytes).

objectql TEST_DEBT 台账 355、本次实测 347(低于派单时提到的 351),新增代码
引入的 tsc 错误为 0,⛔ 未抬账。

红线遵守

  • ⛔ 未触 engine.ts 已裁定的显式三态 { verified: false, conclusive: false }(referenceExists 一带);
  • ⛔ 未触 engine.ts 其他区域 —— 本 PR 对 engine.ts 的改动只有那一个 catch 块;
  • ⛔ 未动 scripts/check-durability-degradation-log-level.mjs;
  • ⛔ 未动 content/docs/releases/;changeset 为 patch @objectstack/objectql

测试

新增 packages/objectql/src/engine-file-hydrate-outage.test.ts(21 例),驱动 fake
driver(非 fake engine),不涉及 engine 写动词分发契约,故与
check:engine-double-contract 无交集。良性 3 类(PG 42P01 / MySQL ER_NO_SUCH_TABLE /
SQLite message-only)、非良性 4 类(ECONNREFUSED / statement timeout / permission
denied / connection terminated)分别成组。


Generated by Claude Code

claude added 2 commits August 7, 2026 21:38
`resolveFileReferences` 的 sys_file 批量查找此前坐在裸 `catch { return
records }` 后面:连接中断、超时、权限拒绝、查询错误,与良性的「表还没建」
一样,都被同一次静默的裸 id 穿过所回答。消费方(UI / 导出)拿到裸 id 后按
「无附件」渲染,故障期间的表现与「记录本就没有文件」不可分辨 —— ADR-0110
D3 的族形,只是载体是功能面而非持久性面。

fail-open 行为本身不变(这是可诊断性修复,不是行为修复):文件元数据读失败
不该拖垮发起它的记录读,所以两个分支都仍然原样返回入参。变的是两种原因不再
共用同一份沉默。

catch 现在按错误类型分流,走仓内既有的 `isMissingTableError` 判别器
(`@objectstack/metadata/errors`),与本文件 `seedAutonumber` 同一调用,而不是
手抄一份 `code === '42P01'`:

- 表未建:确实没有已提交的行,未 hydrate 的答案就是真相,保持静默穿过;
- 其他读故障:一条 `warn`,带父对象、未 hydrate 的字段、未解析 id 数、driver
  自己的报错、后果(这些 id 本次读会渲染成「无文件」)与修法。每次读说一次,
  不是每条记录或每个 id 说一次。

按 AGENTS「Degradation log levels」判定为 warn 不升 error:此路径不声称任何
持久化,损失是功能性的、只影响本次响应,下一次成功读即修复。

一处对 issue 正文的实测更正:catch 位确为零输出,但整条路径并非字面无声 ——
上一帧的通用读处理器已在重抛前记 `Find operation failed`。该行未被触碰,也不
构成替代:它对良性与非良性故障逐字相同,且只描述 sys_file 子读,从不提及父
对象、被留作裸 id 的字段,或那个仍然返回给调用方的降级答案。测试中
`the pre-existing generic line cannot tell the two apart` 一节把这点钉住。

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

vercel Bot commented Aug 7, 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 7, 2026 9:56pm

Request Review

@github-actions github-actions Bot added the size/l label Aug 7, 2026
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/concepts/metadata-lifecycle.mdx (via @objectstack/objectql)
  • content/docs/data-modeling/formulas.mdx (via packages/objectql)
  • content/docs/deployment/migration-from-objectql.mdx (via @objectstack/objectql)
  • content/docs/deployment/vercel.mdx (via @objectstack/objectql)
  • content/docs/kernel/runtime-services/examples.mdx (via packages/objectql)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/objectql)
  • content/docs/kernel/services.mdx (via @objectstack/objectql)
  • content/docs/permissions/authentication.mdx (via @objectstack/objectql)
  • content/docs/plugins/index.mdx (via @objectstack/objectql)
  • content/docs/plugins/packages.mdx (via @objectstack/objectql)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/objectql)
  • content/docs/protocol/objectql/query-syntax.mdx (via packages/objectql)
  • content/docs/protocol/objectql/state-machine.mdx (via @objectstack/objectql)
  • content/docs/releases/implementation-status.mdx (via @objectstack/objectql)

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.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Aug 7, 2026
CI 的 `check:query-options-erasure` 棘轮判红:新测试把 test 面从 263 抬到
273 —— 10 处 `engine.find('doc', {} as any)` / `findOne(..., { … } as any)`。

这些入参本来就在契约内,不属于「故意越界的拒绝测试」,所以按棘轮给的第一条
remedy 处理:上类型,而不是 `as unknown as EngineQueryOptionsParsed`。
`find` / `findOne` 的 query 形参本身可选,`{}` 直接省略即可;findOne 的
`{ where: { id: 'd1' } }` 去掉断言后原样通过 `EngineQueryOptionsParsed`。

test 面回到上限 263,断言与用例数不变(21 例仍全绿)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Q7oc7ASjh8yxyS3Yz78We
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