You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 2274894
Browse filesBrowse the repository at this point in the historyBrowse files
fix(spec): break the eager import cycle that crashed /api and /data under OS_EAGER_SCHEMAS=1 (#19943)
Fixes#19930
Clause-②: no
## What was wrong
`OS_EAGER_SCHEMAS=1` is 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/api` or
`@objectstack/spec/data` threw at import. The default lazy path was not
affected.
## Measured before changing anything (`origin/main` `fdeeea0cc9`)
- **Load chain**, taken with a `Module._load` hook 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 is `ReferenceError: Cannot access
'FilterConditionSchema' before initialization` at `field.zod.ts:1529`
(`relatedListFilter`), reached through the eager `lazySchema` at
`:1000`.
- **Whole-package sweep**: each of the 1039 non-test source modules
taken as the first import of a fresh module graph under the flag: **22
threw**, 20 with the `FilterConditionSchema` signature and 2
(`data/authoring-key-lint.ts`, `kernel/metadata-authoring-lint.ts`) with
`Cannot access 'FIELD_KEY_GUIDANCE' before initialization`, through the
same `suggestions.zod.ts` → `field.zod.ts` edge.
- **`FieldType` in `suggestions.zod.ts`** was read only inside
`suggestFieldType` (`FieldType.options`), never while the module loads.
- `scripts/lib/refinement-projection.ts` and
`src/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. `suggestFieldType` and its alias
table move out of `shared/suggestions.zod.ts` into
`shared/field-type-suggestion.ts`, which imports `data/field.zod` in one
direction only. After the move `suggestions.zod.ts` imports no schema
module, and its header says why that must stay true. `shared/index.ts`,
the root barrel and `error-map.zod.ts` import the function from its new
home. **Public names are unchanged**: `suggestFieldType` is still
exported from `@objectstack/spec` and `@objectstack/spec/shared`, same
signature, same answers; `check:api-surface` reports "public API surface
+ factory signatures unchanged". `export-origins/` records the new
declaration site. `lazy-schema.ts` and `field.zod.ts` are 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
closure `gen:skill-refs` walks; the two `objectstack-i18n` rows of
`TRANSITIVE_ALLOWLIST` were reached only through that edge, so the
generator refused and `check:skill-refs` was red.
- `packages/spec/scripts/lib/skill-map-guards.ts`: a
`TRANSITIVE_ALLOWLIST` row 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's `node_modules`). Its reachability half is gone. The selection
moved into `publishedPointers(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 calls
`publishedPointers`. 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 guard `existsInSpecSrc` and no longer reads
`closures[skillName]`. 36 tests pass.
- **Ablation** (commit-first, `scripts/ablation-replace.mjs`, expected
direction stated before the run: red): re-introducing the reachability
filter in `publishedPointers` (`&& closure.includes(rel)`, blob
`99c90e3e` → `1cca6cf1`, anchor 1 → 0) turned exactly the two publishing
tests red (`2 failed | 34 passed`) and `check:skill-refs` red naming
`skills/objectstack-i18n/references/_index.md (out of date)`. Restore
proven: blob == HEAD `99c90e3e`, `git diff HEAD` empty.
### Regenerated indexes (`pnpm --filter @objectstack/spec
gen:skill-refs`, never by hand)
- `objectstack-i18n/references/_index.md`: **byte-identical** to
`origin/main` (`git diff --exit-code origin/main --
skills/objectstack-i18n/references/_index.md` exits 0). `data`, `ai`,
`ui`, `platform` and `formula` are unchanged too.
- `objectstack-query` (7 → 1 transitive pointers) loses
`data/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`; keeps
`shared/suggestions.zod.ts`.
- `objectstack-api` (18 → 13) loses `data/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) loses `data/field-value.zod.ts`,
`data/field.zod.ts`, `data/filter.zod.ts`, `shared/value-domain.zod.ts`.
- **None of them is taught by its skill.** Over each skill's authored
markdown (every `.md` except the generated `references/_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 one
`filter.zod` hit names its own core file, which it keeps). By exported
identifier, every `export` name 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 core
`builtin-node-config` / `io-node-config` pointers.
## Pin: `packages/spec/src/eager-entry-import.test.ts`
Every module subpath in spec's own `exports` map is mapped to its source
entry, 17 today. Each entry is imported first in a fresh child process
with `OS_EAGER_SCHEMAS=1` and must not throw. Two controls keep a green
meaningful: under the flag a known `lazySchema` export on `./data`
(`FieldSchema`) and on `./api` (`ApiErrorSchema`) arrives as a built zod
object (`typeof` is `object`), proving the flag reached the child;
without the flag the same entries still import and the export stays the
lazy Proxy (`typeof` is `function`). Source entries only:
`@objectstack/spec#test` depends on `^build`, not its own build, so
`dist/` is not guaranteed at test time, and `check:test-source-alias`
wants unit verdicts on source. The bundles were measured by hand
(below).
## Evidence on the merged tree (head `247d0611fc`, `origin/main`
`61609edf81` merged via `scripts/pm/os-regen-merge.sh`)
- **Merge**: 326 files from main; on this surface it brought
`packages/spec/src/shared/lazy-schema.ts` (+49 / −2, the landed
lazy-schema metadata aliasing) and `skills/objectstack-ui/SKILL.md`; no
`_index.md`, no `skill-map-guards.ts`. Step 2 kept the branch's
`export-origins/root.json` and `shared.json` (branch-only edits). After
`pnpm install --frozen-lockfile` and a spec rebuild, `check:generated`
read all 15 artifacts current, so the regeneration commit the script
prescribes was empty.
- **Cycle fix re-measured**: the pin passes (`Test Files 1 passed, Tests
20 passed`: 17 entries under the flag, 2 unflagged controls, 1 mapping).
Dist, on the rebuilt `dist/` (`.build-input-hash` `61e345f25156fe1e…`):
all **44** published JS files (main and browser conditions, `.js` and
`.mjs`) exit 0 as a first import both with and without the flag (88
runs, 0 failures); on the 8 `/data` and `/api` files the probe export
reads `object` under the flag and `function` without it.
- **Spec suites**: `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.
- **Gates**: `dispatch-gates --commands --repo
objectstack-ai/objectstack` derived 97 families on this diff at
`247d0611fc`; 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 them `check: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-loads` and `check:type-check-debt`, both
exit 3 on their own prerequisite (every workspace package built; 66 have
no `dist/` 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`,head `247d0611fc`):裁决 A
逐条兑现,只对允许清单生效、不外扩;`objectstack-i18n`
索引逐字节不变;三份少掉的指针复核员逐一对过各自技能正文,全部没教过;公开导出面逐字节不变;两道围栏都守住。CI 32 项通过、3
项按设计跳过。
**你要做的**:确认 query / api / automation
三份索引少掉的指针清单(上文列出)可以接受;可以就合并。不合并也不会更糟——只是回退开关继续崩、门禁继续红。
## Acceptance notes (noted, not filed)
- Several comments still describe the removed edge in the present tense;
the defensive code under them (hoisted functions, deferred error maps,
lazily built sets) stays valid and is not removed here. Sites: the
`shared/strict-object.ts` docblocks around `declarationStore`,
`strictObjectError`, `PrimableErrorMap` and `closedObjectConstructor`;
`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. The `scripts/lib/skill-map-guards.ts` docblock is
rewritten in this PR because ruling A changes what it explains. Carrier:
none.
- The first-import crash reported for the two `refinement-projection`
modules did not reproduce at `fdeeea0cc9` (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 generated `skills/**`
output. The at-tier contract review the ruling asks for is the seat's
record on the card. This PR touches `skills/**`, so it lands **Tier H**
on the maintainer's merge.
---
_Generated by [Claude Code](https://claude.ai/code)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
0 commit comments