Repository navigation
fix(spec): break the eager import cycle that crashed /api and /data under OS_EAGER_SCHEMAS=1 - #19943
Conversation
…mport cycle `suggestions.zod.ts` is imported by `strict-object.ts`, which nearly every closed schema imports; its value import of `FieldType` from `data/field.zod` closed the loop filter.zod -> strict-object -> suggestions.zod -> field.zod. Under OS_EAGER_SCHEMAS=1 the eager FieldSchema factory then read FilterConditionSchema before filter.zod finished, so the `/api` and `/data` entries threw at import. `FieldType` was only read when a suggestion is asked for, so `suggestFieldType` (and its alias table) moves to `shared/field-type-suggestion.ts`, which imports field.zod one-directionally. Public names are unchanged: it is still exported from the package root and `./shared`. Claude-Session: https://claude.ai/code/session_019c3Hi6ZMU1p6m6aA6Bz45d Co-authored-by: Claude <noreply@anthropic.com>
Each `exports` subpath's source entry is imported first in a fresh child under OS_EAGER_SCHEMAS=1 and must not throw. Two controls: under the flag a known lazySchema export on `/data` and `/api` arrives built (typeof object), so the flag reached the child; without the flag the same entries import and the export stays the lazy Proxy (typeof function). Claude-Session: https://claude.ai/code/session_019c3Hi6ZMU1p6m6aA6Bz45d Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019c3Hi6ZMU1p6m6aA6Bz45d Co-authored-by: Claude <noreply@anthropic.com>
`suggestFieldType` now resolves from `shared/field-type-suggestion.ts`; the suite's import follows it, and `export-origins/` records the new declaration site for the root and `./shared` entries (names and kinds unchanged). Claude-Session: https://claude.ai/code/session_019c3Hi6ZMU1p6m6aA6Bz45d Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 22 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 6 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 136 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin c3966d044ded46ded39f2a766cf47cdea3b3e937 && git checkout c3966d044ded46ded39f2a766cf47cdea3b3e937
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin b81da66df773b5d237cad04d6b05a61e9544d553 247d0611fc164e95aedc91b213bd0642515b5760 && git checkout -B drift-repro b81da66df773b5d237cad04d6b05a61e9544d553 && git merge --no-ff 247d0611fc164e95aedc91b213bd0642515b5760
node scripts/docs-audit/affected-docs.mjs --json b81da66df773b5d237cad04d6b05a61e9544d553
|
…reachability `TRANSITIVE_ALLOWLIST` rows were a filter over the import closure: a row published only when some import edge also reached the file, and the guard refused a row the closure missed. Both `objectstack-i18n` rows were reached through the `suggestions.zod -> field.zod` edge that this branch removed to break the eager import cycle, so `gen:skill-refs` refused and `check:skill-refs` went red. A row is now a DECLARED pointer: it publishes when the file exists under `packages/spec/src` and ships (`*.zod.ts`), for packages with a list only. The guard keeps its typo / dead-file half and gains a does-not-ship half; its reachability half is gone. `publishedPointers()` carries the selection so it is unit-testable: an allowlisted, existing, unreachable pointer publishes; an allowlisted dead file is still refused; a non-allowlisted unreachable pointer is still not published; a package with no list keeps its full closure. Regenerated with `gen:skill-refs`: `objectstack-i18n` is byte-identical to main; `objectstack-query` (-6), `objectstack-api` (-5) and `objectstack-automation` (-4) lose only pointers reached through the removed helper edge, none of which their authored markdown names. Claude-Session: https://claude.ai/code/session_019c3Hi6ZMU1p6m6aA6Bz45d Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: 88/88 Isolated at-tier reviewer subagent, run by the Inputs read: card #19930 body and all 8 comments (triage ① Derived judgmentsRuling A, quoted from (a) The head implements A exactly; no widening beyond the list.
(b) i18n byte-identical; the three removals are helper-edge pointers only.
(c) Public surface identical; the pin is weight-bearing with real controls.
(d) Fences held. Per-commit file lists ( (e) Changeset and Clause-② consistent. (f) The Landing class: Proposed body ( ② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
Fixes #19930
Clause-②: no
What was wrong
OS_EAGER_SCHEMAS=1is the documented emergency rollback of the lazy-schema memory optimization (content/docs/deployment/environment-variables.mdx:30). With it set, a process whose first spec import was@objectstack/spec/apior@objectstack/spec/datathrew at import. The default lazy path was not affected.Measured before changing anything (
origin/mainfdeeea0cc9)Module._loadhook on the source entry:src/data/index.ts→data/query.zod.ts→data/filter.zod.ts→shared/strict-object.ts→shared/suggestions.zod.ts→data/field.zod.ts. The throw isReferenceError: Cannot access 'FilterConditionSchema' before initializationatfield.zod.ts:1529(relatedListFilter), reached through the eagerlazySchemaat:1000.FilterConditionSchemasignature and 2 (data/authoring-key-lint.ts,kernel/metadata-authoring-lint.ts) withCannot access 'FIELD_KEY_GUIDANCE' before initialization, through the samesuggestions.zod.ts→field.zod.tsedge.FieldTypeinsuggestions.zod.tswas read only insidesuggestFieldType(FieldType.options), never while the module loads.scripts/lib/refinement-projection.tsandsrc/shared/refinement-projection.ts, reported crashing elsewhere, do not crash as a first import under the flag (exit 0 in both probe shapes). No independent second cycle exists.The fix
The fix is the edge the loop closes on.
suggestFieldTypeand its alias table move out ofshared/suggestions.zod.tsintoshared/field-type-suggestion.ts, which importsdata/field.zodin one direction only. After the movesuggestions.zod.tsimports no schema module, and its header says why that must stay true.shared/index.ts, the root barrel anderror-map.zod.tsimport the function from its new home. Public names are unchanged:suggestFieldTypeis still exported from@objectstack/specand@objectstack/spec/shared, same signature, same answers;check:api-surfacereports "public API surface + factory signatures unchanged".export-origins/records the new declaration site.lazy-schema.tsandfield.zod.tsare untouched, so both named fences hold.Ruling A, implemented: an allowlisted skill pointer publishes on existence
Ruling: issue comment
5814528240(batch #220 item 2, letter A, maintainer 「批 #220 同意」). Cutting the edge changed the textual import closuregen:skill-refswalks; the twoobjectstack-i18nrows ofTRANSITIVE_ALLOWLISTwere reached only through that edge, so the generator refused andcheck:skill-refswas red.packages/spec/scripts/lib/skill-map-guards.ts: aTRANSITIVE_ALLOWLISTrow is now a declared pointer.checkTransitiveAllowlist(map, allowlist, existsInSpecSrc)keeps its typo half (a package the map does not have), its dead-file half (a path not on disk), the already-core and listed-twice checks, and gains a does-not-ship half (a path outside*.zod.ts, which would 404 in a consumer'snode_modules). Its reachability half is gone. The selection moved intopublishedPointers(coreFiles, closure, allowed, existsInSpecSrc): with no list the closure publishes unchanged; with a list the index publishes the core files plus every declared row that ships, and nothing the closure reached on its own. The docblock explains why a row publishes on existence and records the removed edge in the past tense.packages/spec/scripts/build-skill-references.ts: the guard is handed the disk (existsInSpecSrc), not a closure; the emit loop callspublishedPointers. The up-front closure pass the guard needed is gone.packages/spec/scripts/skill-map-guards.test.ts: an allowlisted, existing, unreachable pointer publishes; an allowlisted dead file is still refused (guard) and never emitted (selection); a non-allowlisted unreachable pointer is not published, with or without a list; a file that exists but does not ship is refused; with a list, a file the closure reached on its own stays out; with no list the closure publishes unchanged; rows come back sorted; the wiring pin asserts the generator hands the guardexistsInSpecSrcand no longer readsclosures[skillName]. 36 tests pass.scripts/ablation-replace.mjs, expected direction stated before the run: red): re-introducing the reachability filter inpublishedPointers(&& closure.includes(rel), blob99c90e3e→1cca6cf1, anchor 1 → 0) turned exactly the two publishing tests red (2 failed | 34 passed) andcheck:skill-refsred namingskills/objectstack-i18n/references/_index.md (out of date). Restore proven: blob == HEAD99c90e3e,git diff HEADempty.Regenerated indexes (
pnpm --filter @objectstack/spec gen:skill-refs, never by hand)objectstack-i18n/references/_index.md: byte-identical toorigin/main(git diff --exit-code origin/main -- skills/objectstack-i18n/references/_index.mdexits 0).data,ai,ui,platformandformulaare unchanged too.objectstack-query(7 → 1 transitive pointers) losesdata/field-value.zod.ts,data/field.zod.ts,kernel/metadata-protection.zod.ts,shared/expression.zod.ts,shared/identifiers.zod.ts,shared/value-domain.zod.ts; keepsshared/suggestions.zod.ts.objectstack-api(18 → 13) losesdata/field-value.zod.ts,data/field.zod.ts,shared/expression.zod.ts,shared/identifiers.zod.ts,shared/value-domain.zod.ts.objectstack-automation(11 → 7) losesdata/field-value.zod.ts,data/field.zod.ts,data/filter.zod.ts,shared/value-domain.zod.ts..mdexcept the generatedreferences/_index.md): by file stem (field.zod,field-value,expression.zod,identifiers.zod,value-domain,metadata-protection,filter.zod), 0 hits for every removed pointer in its own skill (query's onefilter.zodhit names its own core file, which it keeps). By exported identifier, everyexportname of each removed file, word-bounded: no schema-level name (FieldSchema,FilterConditionSchema,ExpressionSchema,IdentifierSchema,ValueDomain…,MetadataProtection…) appears; the only collisions are ordinary English words some file also exports (Field,Filter,FilterCondition,Expression,expression,cel,cron,P,Predicate), for example the api routing row "Filter, sort, paginate, or aggregate a request → objectstack-query" and the automation heading "Filter tokens (config.filter)", a node-config slot whose shape lives in the corebuiltin-node-config/io-node-configpointers.Pin:
packages/spec/src/eager-entry-import.test.tsEvery module subpath in spec's own
exportsmap is mapped to its source entry, 17 today. Each entry is imported first in a fresh child process withOS_EAGER_SCHEMAS=1and must not throw. Two controls keep a green meaningful: under the flag a knownlazySchemaexport on./data(FieldSchema) and on./api(ApiErrorSchema) arrives as a built zod object (typeofisobject), proving the flag reached the child; without the flag the same entries still import and the export stays the lazy Proxy (typeofisfunction). Source entries only:@objectstack/spec#testdepends on^build, not its own build, sodist/is not guaranteed at test time, andcheck:test-source-aliaswants unit verdicts on source. The bundles were measured by hand (below).Evidence on the merged tree (head
247d0611fc,origin/main61609edf81merged viascripts/pm/os-regen-merge.sh)packages/spec/src/shared/lazy-schema.ts(+49 / −2, the landed lazy-schema metadata aliasing) andskills/objectstack-ui/SKILL.md; no_index.md, noskill-map-guards.ts. Step 2 kept the branch'sexport-origins/root.jsonandshared.json(branch-only edits). Afterpnpm install --frozen-lockfileand a spec rebuild,check:generatedread all 15 artifacts current, so the regeneration commit the script prescribes was empty.Test Files 1 passed, Tests 20 passed: 17 entries under the flag, 2 unflagged controls, 1 mapping). Dist, on the rebuiltdist/(.build-input-hash61e345f25156fe1e…): all 44 published JS files (main and browser conditions,.jsand.mjs) exit 0 as a first import both with and without the flag (88 runs, 0 failures); on the 8/dataand/apifiles the probe export readsobjectunder the flag andfunctionwithout it.vitest run --project local --maxWorkers=2: 533 files passed, 15675 tests passed, 2 todo.--project repo: 31 files, 579 tests passed.typecheck(tsc --noEmit+check:scripts-typecheck+check:test-typecheck): exit 0, 53 files / 255 errors / 142 pinned signatures held.dispatch-gates --commands --repo objectstack-ai/objectstackderived 97 families on this diff at247d0611fc; every one was run with its exit code landed on disk and reconciled with--ran: "97 derived famil(ies) accounted for — 95 run, 2 NOT-MEASURED, 0 UNRUN". 95 exit 0, among themcheck:skill-refs("9 generated files in sync with packages/spec"),check:generated(15/15 current),check:api-surface(unchanged),check:export-origins(5260 exports across 17 entry points),check:doc-authoring,check:skill-identifier-liveness,check:nul-bytes,check:test-source-alias,check:cross-package-test-inputs,check:pm-governed-merges,check:pm-dispatch-gates(1925 cases). NOT MEASURED:check:dual-build-cjs-loadsandcheck:type-check-debt, both exit 3 on their own prerequisite (every workspace package built; 66 have nodist/here); CI builds all.维护者速读(草稿)
改了什么:两件事。① 文档写的应急回退开关
OS_EAGER_SCHEMAS=1一打开,@objectstack/spec的/api、/data入口在导入时就崩;修法是把suggestFieldType搬到一个只单向引用字段 schema 的小文件里,断开那条导入环。公开导出一字不变。② 按您批的 A:技能说明书的「相关 schema 指针」允许清单,从「导入链够得着才发布」改成「文件存在且随包发布就发布」,只对清单内条目生效;生成器重跑后,objectstack-i18n的索引逐字节不变,query / api / automation 三份索引各少掉几条从没教过的内部指针。为什么改:一个会崩的回退开关比没有更糟;运维最需要它的时候正是怀疑延迟加载本身出问题的时候。而 i18n 那两个指针本来就是说明书教的内容,不是导入关系——靠一条偶然的环把它们带出来,恰恰是这张卡要拆的隐患。
风险与代价(含回滚):运行时零风险——只搬了一个函数的声明位置,
check:api-surface读作公开面不变,17 个入口、44 个发布产物在开关下全部实测能导入。生成器一侧的代价是三份技能索引少了几条指针;实测这三份说明书的正文一个都没教过它们。回滚:整体 revert 本 PR 即可,不需要数据迁移。本 PR 改到skills/**,按规矩 Tier H,由您合并。席位意见:建议合并。达档复核 PASS(
5820632479,head247d0611fc):裁决 A 逐条兑现,只对允许清单生效、不外扩;objectstack-i18n索引逐字节不变;三份少掉的指针复核员逐一对过各自技能正文,全部没教过;公开导出面逐字节不变;两道围栏都守住。CI 32 项通过、3 项按设计跳过。你要做的:确认 query / api / automation 三份索引少掉的指针清单(上文列出)可以接受;可以就合并。不合并也不会更糟——只是回退开关继续崩、门禁继续红。
Acceptance notes (noted, not filed)
shared/strict-object.tsdocblocks arounddeclarationStore,strictObjectError,PrimableErrorMapandclosedObjectConstructor;shared/strict-object.test.ts(the eager-construction block);data/field-value.zod.ts:35;data/field.zod.ts:184, fenced by the PR holding that file. Thescripts/lib/skill-map-guards.tsdocblock is rewritten in this PR because ruling A changes what it explains. Carrier: none.refinement-projectionmodules did not reproduce atfdeeea0cc9(see above).Contract review
No accept or reject result changes, and the public export surface is byte-identical per
check:api-surface; only the declaration site of one function moves, plus generator tooling and its generatedskills/**output. The at-tier contract review the ruling asks for is the seat's record on the card. This PR touchesskills/**, so it lands Tier H on the maintainer's merge.Generated by Claude Code