Uh oh!
There was an error while loading. Please reload this page.
fix(cli): resolve serve's cluster driver imports from the host app, not the CLI - #10765
Conversation
…10645) `os serve` reached `@objectstack/service-cluster` and its driver through a bare dynamic `import()`. Node ESM resolves a bare specifier against the importer's own realpath, which for the CLI is inside the framework workspace, so the cluster packages -- shipped with a distribution and declared by the APP -- were invisible to it. Measured on a published EE image: the CLI's `node_modules/@objectstack/` held 48 packages and neither cluster one, so `OS_CLUSTER_DRIVER=redis` died at boot with `Cannot find package '@objectstack/service-cluster'` while app-side code loaded it fine. Both loads now go through `createHostImporter`, the host-anchored importer `serve` already uses for its organizations and capability loads. That fixes the class rather than these two packages: any app-declared optional package the CLI advertises it will load now resolves as the app declares it, which declaring the packages as CLI dependencies would not have done. The helper is hoisted to the top of the boot sequence. It sits in one long boot function, so a load placed above it silently falls back to bare resolution -- the same defect that previously cost the enterprise organizations load (cloud#1013). A new test pins both the boundary crossing and that ordering. No change to what `serve` accepts or refuses: an undeclared package is still refused by the unchanged #4719 declaration gate. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019bmVFqoQPq63zhKrxdYG1r
📓 Docs Drift CheckThis PR changes 1 package(s): 16 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 4 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 23 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 bb71a209ee9477b8b9c1f6920470c1a7c9fbc076 && git checkout bb71a209ee9477b8b9c1f6920470c1a7c9fbc076
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin e2bb237e3da8cedceb3fbc7d0b220337c87d8a8d cba91346c244749974a9cab7fd2e239f9b97b87b && git checkout -B drift-repro e2bb237e3da8cedceb3fbc7d0b220337c87d8a8d && git merge --no-ff cba91346c244749974a9cab7fd2e239f9b97b87b
node scripts/docs-audit/affected-docs.mjs --json e2bb237e3da8cedceb3fbc7d0b220337c87d8a8d
|
Uh oh!
There was an error while loading. Please reload this page.
Fixes#10645
The defect
With
OS_CLUSTER_DRIVER=redisset,os servedied at boot:serve.tsreached the cluster gate and its driver through a bare dynamicimport(). Node ESM resolves a bare specifier against the importer's ownrealpath, which for the CLI is inside the framework workspace — so it can only
ever see what the framework installed. The cluster packages ship with a
distribution and are declared by the app, so they live in the app's
node_modules. The one hop that could not work was CLI to app; app-side codeloaded the very same packages fine.
Route taken: (B) host-anchored resolution — and why not (A)
The card offered two routes and stated the criterion that decides between them:
"the general fix should cover any app-declared optional package the CLI
advertises it will load, not just this driver."
Route (A) — declaring
@objectstack/service-cluster*aspackages/clidependencies — silences this driver and leaves the class open. The next
app-declared optional package breaks identically, a third-party cluster driver
could never work at all, and the open-core CLI would take a static dependency on
packages that ship with a distribution — precisely the coupling the non-literal
specifier in
serve.tsexists to avoid, and whichserve-multi-node-cap-advisory.pin.test.tsdocuments as the reason itshand-written cast exists.
Route (B) is what landed, and the repo already owned the mechanism:
createHostImporterfrom@objectstack/types/node, whichservewas alreadyusing further down for its organizations and capability loads. It resolves from
the host app's root, gated on the host's declaration rather than mere
reachability (#4719). The two cluster loads now go through it. Nothing new was
invented; two loads were moved onto the existing general path.
The ordering half — the part that actually regressed twice
importFromHostis aconstinside one long boot function, so a load placedabove it is not a compile error — it is a silent fall-back to bare
resolution. It was defined at the auth block (line ~2128), while the cluster
block sits at ~1425, so the cluster loads could never have used it.
This is the second time that exact shape has cost a release: the helper
previously sat below the auth block, and the enterprise organizations load fell
back to a bare import, so every walled-posture deployment hit the ADR-0093 D5
fail-fast and exited 1 (cloud#1013). The helper is now hoisted to the top of the
boot sequence, and the comment says so, so the next optional load added above it
is a decision rather than an accident.
Verification
The asymmetry was reproduced before fixing it. In this worktree the CLI's own
node_modules/@objectstack/holds 49 packages and neither cluster one (the EEimage measured 48, same shape). A probe placed inside
packages/cli— the sameresolution base as the shipped
dist/commands/serve.js— against a fixture appthat declares the packages:
Same error string as the card's measured symptom, and the fix demonstrably
crosses the boundary.
New regression test —
packages/cli/src/commands/serve-cluster-host-resolution.test.ts,8 tests, in two halves:
only in a fixture app's
node_modulesis invisible to a bare import frompackages/cliand IS loadable through the host importer. Synthetic on purpose— the contract is "any app-declared optional package", not these two — and it
needs nothing built.
importFromHost, never through a bareimport(), and the helper is definedabove the block that consumes it.
Reverse verification (ablation). With
serve.tsreverted toorigin/mainand the fix otherwise intact, exactly 3 of the new tests go red — all three
source-shape assertions, including the ordering one — while the 3 behavioural
boundary tests stay green, since they exercise
createHostImporterdirectlyrather than
serve.ts:Restore leg re-run green (
Test Files 2 passed,Tests 11 passed). Neither legrequired a rebuild: the mutated subject is read as source text from
src, sono
dist/sits between the mutation and the assertion.The pinned import shape did not move.
serve-multi-node-cap-advisory.pin.test.tsis green both before and after — it passed in the ablation run against
origin/main'sserve.tstoo. Its assertions pin the gate call's arity, theOS_CLUSTER_REPLICASargument and the hand-written verdict cast; none of thosetext spans is touched by swapping
import(forimportFromHost(. No assertionin it was edited. Note for whoever picks up #10514: the import expression in
that block has changed shape, so re-derive rather than inherit — and that pin's
scanner still reads
serve.tsraw, comments included, which is #10514's defect.Gates run locally, all on
cba91346c2(the final commit, clean tree). The setwas re-derived from the actual changeset with
node scripts/pm/dispatch-gates.mjsafter the last commit, not inherited from the dispatch word:
check:nul-bytes·check:changeset-gate-self-tests·check:objectui-changeset·check:route-envelope·check:cross-package-test-inputs·check:slot-lookup·check:test-source-alias·check:type-source-resolution·check:engine-double-contract·check:where-matcher·check:query-options-erasure·check:type-check-coverage·check-adr-0087-registration·check-changeset-no-major·check-empty-changeset·check-cross-package-test-inputs·docs-audit/check-affected-docs— all exit 0.pnpm --filter @objectstack/cli typecheckgreen (> tsc --noEmitechoed, so this isa real run and not a zero-match). The ratchet, after a full workspace build
(70/70 tasks):
check-type-check-coverage --re-measure: OK -- 33 ledger entr(ies) re-measured in 266.6s, 1912 raw tsc error(s) total, none above its recorded number.The 12-error
plugin-authsurplus it reports is pre-existing and untouched here.Scope
Clause-② holds: this changes only where a module is resolved from. No request
is accepted or refused differently and no public surface widens — the #4719
declaration gate is unchanged, and a test asserts an undeclared package is still
refused. No gate weakened, no threshold moved, no test skipped. Nothing under
content/docs/releases/**; the changeset is the release-notes input.objectstack-ai/cloud#1502was not touched.Generated by Claude Code