TL;DR
第一次 pnpm dev 全绿(130 rows,0 ERROR)。从第二次启动起,永久变成 10 条 ERROR、10 条种子记录写不进去 —— 数据没变、代码没变,只是重启了一次。
被拒的正是首启自己写进去的数据。
复现
rm -rf examples/app-showcase/.objectstack
pnpm dev # 启动完成后 Ctrl+C
pnpm dev # 第二次
|
ERROR 行数 |
Seeds |
冷启(删掉 .objectstack) |
0 |
com.example.showcase 130 rows |
| 第二次启动 |
10 |
⚠ 120 ok / 10 errors ⚠ |
第二次启动的报错(×10,每条带完整栈):
ERROR Update operation failed {"object":"showcase_task","error":{"message":
"Cover Image has an invalid image value: Expected an opaque sys_file id",
"stack":"ValidationError: ... at validateRecord (packages/objectql/dist/index.mjs:2208:32)"}}
WARN [Seeder] Seed loading completed with 10 dropped record(s) and 10 error(s)
{"inserted":0,"updated":1,"skipped":119,"errored":10}
冷启时同一份数据只产生一条 warn-first:
[value-shape] cover has an invalid image value: Expected an opaque sys_file id
— accepted for now (ADR-0104 warn-first; run `os migrate files-to-references --apply` ...)
根因
冷启过程中,平台往 sys_migration 写了两行(直接查 examples/app-showcase/.objectstack/data/dev.db 得到):
{"id":"adr-0104-file-references","last_run_at":"2026-08-03T04:24:43.382Z",
"verified_at":"2026-08-03T04:24:43.382Z","blocking":0,
"details":"{\"attested\":\"datastore-created-empty\"}"}
{"id":"adr-0104-value-shapes", ...同上}
意图是合理的:全新空库没有历史值,天然满足迁移,所以标成 verified,让新部署直接进 strict("born-migrated")。
问题是顺序反了。 这个自证写在 04:24:43.382,而同一次启动的 seed 在 04:24:44.3+ 才跑,写进去的 cover 值恰恰违反刚刚被证明的契约。也就是说:部署先给自己开了"我的数据符合 ADR-0104"的证明,然后立刻写入不符合的数据。
首启为什么还是绿的 —— ObjectQL.isFileReferencesMigrationVerified() 把 flag 读结果记忆化:
async isFileReferencesMigrationVerified(): Promise<boolean> {
if (!this.fileReferencesMigrationVerified) {
this.fileReferencesMigrationVerified = this.readMigrationFlagVerified(...);
}
return this.fileReferencesMigrationVerified;
}
首启在 flag 行写入之前就已经有写操作触发过这次读取,于是整个进程缓存了 false,一路保持 warn-first。第二次启动是新进程,第一次读就看到已存在的 verified 行 → 缓存 true → mediaStrictEffective() 返回 true → record-validator.ts:519 走 fail('invalid_type', ...) 而不是 warnOnce(...)。
相关代码:
packages/objectql/src/validation/record-validator.ts:513-533 — strict 时 fail,否则 warnOnce
packages/objectql/src/engine.ts:3061-3072 — 记忆化的 flag 读取
packages/spec/src/system/migration.zod.ts:138 — FILE_REFERENCES_MIGRATION_ID
要恢复的不变量
一次启动不能证明一个它即将在同一次启动里违反的契约。
修法(需要定夺,故未直接动手)
- 把 attestation 推迟到首启 seed 之后,并对 seed 完的数据重跑自检 —— 空库的前提在 seed 写入后就不再成立了,"empty ⇒ clean" 这个推理需要在真正没有数据可写之后才下结论。
- 让 seed 走同一套契约 —— 让 insert 和后续 update 口径一致:要么都接受、要么都拒绝。目前 insert 宽松、update 严格,本身就是个不对称。
倾向 1,因为它修的是"证据在被推翻之前就被记账"这个根本问题;2 只是让两条路径一样糟或一样严。
顺带:adr-0104-value-shapes 那行是同一时刻、同样 attested: datastore-created-empty 写下的,同一个问题,别只修 media 那半边。
关联
showcase 的 cover 种子值(src/data/seed/index.ts 的 placeholderCover(...))确实不是 opaque sys_file id —— 那是本 issue 的诱因数据,单独在 showcase 的 issue 里跟踪;但把种子数据改干净只是让这个部署不再触发,不修复"提前自证"本身。
TL;DR
第一次
pnpm dev全绿(130 rows,0 ERROR)。从第二次启动起,永久变成 10 条 ERROR、10 条种子记录写不进去 —— 数据没变、代码没变,只是重启了一次。被拒的正是首启自己写进去的数据。
复现
.objectstack)com.example.showcase 130 rows⚠ 120 ok / 10 errors ⚠第二次启动的报错(×10,每条带完整栈):
冷启时同一份数据只产生一条 warn-first:
根因
冷启过程中,平台往
sys_migration写了两行(直接查examples/app-showcase/.objectstack/data/dev.db得到):{"id":"adr-0104-file-references","last_run_at":"2026-08-03T04:24:43.382Z", "verified_at":"2026-08-03T04:24:43.382Z","blocking":0, "details":"{\"attested\":\"datastore-created-empty\"}"} {"id":"adr-0104-value-shapes", ...同上}意图是合理的:全新空库没有历史值,天然满足迁移,所以标成 verified,让新部署直接进 strict("born-migrated")。
问题是顺序反了。 这个自证写在
04:24:43.382,而同一次启动的 seed 在04:24:44.3+才跑,写进去的cover值恰恰违反刚刚被证明的契约。也就是说:部署先给自己开了"我的数据符合 ADR-0104"的证明,然后立刻写入不符合的数据。首启为什么还是绿的 ——
ObjectQL.isFileReferencesMigrationVerified()把 flag 读结果记忆化:首启在 flag 行写入之前就已经有写操作触发过这次读取,于是整个进程缓存了
false,一路保持 warn-first。第二次启动是新进程,第一次读就看到已存在的 verified 行 → 缓存true→mediaStrictEffective()返回 true →record-validator.ts:519走fail('invalid_type', ...)而不是warnOnce(...)。相关代码:
packages/objectql/src/validation/record-validator.ts:513-533— strict 时fail,否则warnOncepackages/objectql/src/engine.ts:3061-3072— 记忆化的 flag 读取packages/spec/src/system/migration.zod.ts:138—FILE_REFERENCES_MIGRATION_ID要恢复的不变量
一次启动不能证明一个它即将在同一次启动里违反的契约。
修法(需要定夺,故未直接动手)
倾向 1,因为它修的是"证据在被推翻之前就被记账"这个根本问题;2 只是让两条路径一样糟或一样严。
顺带:
adr-0104-value-shapes那行是同一时刻、同样attested: datastore-created-empty写下的,同一个问题,别只修 media 那半边。关联
showcase 的
cover种子值(src/data/seed/index.ts的placeholderCover(...))确实不是 opaquesys_fileid —— 那是本 issue 的诱因数据,单独在 showcase 的 issue 里跟踪;但把种子数据改干净只是让这个部署不再触发,不修复"提前自证"本身。