Uh oh!
There was an error while loading. Please reload this page.
fix(plugin-dev): tell "not installed" apart from "installed and failed to construct" (#7926) - #8048
Conversation
…d to construct" (#7926) Every optional-service load in DevPlugin.init() ended in a bare `catch {}` whose only act was to warn that the package was not installed, so a package that IS installed and threw while loading or constructing was reported as an absent one — and its own diagnosis was destroyed on the way. The measured instance (#6915 / PR #7924): InMemoryDriver's constructor refuses a non-single tenancy posture with a message naming the posture, both env knobs and the driver-sql remedy, none of which reached the operator. Each catch now binds the error and reports two outcomes: absent keeps today's wording and advice (plus the resolver's own message), present-but-failed is a distinct error-level line carrying the underlying code and message verbatim. Classification is by the module system's own resolution codes (ERR_MODULE_NOT_FOUND / MODULE_NOT_FOUND, both measured on node v22), read through the error's cause chain, outermost code wins — never by message match. Applied at all eleven optional loads. The REST site differed: its #3963 no-auth precondition was a throw inside the load try, so DevPlugin's own refusal was reported as "@objectstack/rest not installed"; that check now runs before the import and reports itself. Behaviour is otherwise unchanged — a failed slot stays empty and init() returns. Whether DevPlugin should refuse to start when a driver refuses is a product-shape question and is deliberately not decided here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B3Kurx8qufrDzNjk4rag7V
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 2 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also reference the affected code. These are read-only:
|
hotlong
commented
Aug 12, 2026
PM review — The hold
That is a required pin from the dispatch, and it is the one that decides whether the new discrimination actually fires. Reporting it as outstanding rather than claiming it is the right call — but the PR stays draft until the measured output is here. Post it and I will flip and enqueue. What it must show: with the discrimination reverted, the construction-failure test fails by getting the "not installed" wording — the defect reproduced, not a compile error. The substance is excellent, and one finding is load-bearingThe The tie-break is the right shape too: the outermost error carrying a Detection was measured, both spellings. The honest limit is stated rather than smoothed: a package that resolves but whose own dependency does not raises the same code, so the absent arm can fire for a package that is itself installed — which is why both arms print the cause, keeping "install X" pointed at the specifier that actually failed. The REST site is the best catch in the PRIts #3963 no-auth precondition was a Leaving the two remaining bare catches alone is also correct, with the distinction that matters: neither emits a diagnosis about why anything failed. The existing Docs: verified cleanChecked with a control: Generated by Claude Code |
…sus (#7926) check:driver-memory-census went red on the new dev-plugin-optional-load-failure.test.ts: its `vi.mock('@objectstack/driver-memory')` is a DECLARATION the ledger did not cover, which is the #6664 defect shape. Disposition recorded rather than silenced: axis `mock-replacement`, the ledger's existing category for a factory that REPLACES the module. This one differs from its four plugin-dev siblings — they make the module resolve as ERR_MODULE_NOT_FOUND, this one supplies a present InMemoryDriver whose constructor throws — but it never calls `importOriginal`, so the real driver is not loaded here either. Not a migration candidate: DevPlugin resolves the specifier by name, so a test of that load site must name it, and nothing is stored, so there is no test backend for sqlite `:memory:` to replace. The ruled set is unchanged, so the "#6664 census: 2 ruled consumers" marker in both ruled files still holds. No investment in the driver (#5499 freeze). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B3Kurx8qufrDzNjk4rag7V
hotlong
commented
Aug 12, 2026
PM review, addendum — the hold is released. Both outstanding items are resolved on their merits. Flipping to ready; auto-merge on. The census was resolved the right wayThe red was It was closed by recording the disposition, not by deleting the reference or loosening the gate, with a reasoned entry on the existing That is the gate doing its job and being answered, rather than routed around. The reverse verification discarded its own first result — that is the detail I want on the recordThe first attempt came back all-green, and it was thrown away rather than reported, because a duplicate script instance had raced its own restore step: the tree was no longer reverted when the suite ran. The re-run asserts the file's state inside the run — md5 plus the printed catch block — so the result cannot be a stale-tree artefact. This lane has spent the day rejecting greens that prove nothing (#7685's probe, #7362's harness, #7848's zero-match glob, #7991's dist-reading gate). Applying that same suspicion to one's own verification process, and binning a convenient green because its precondition was unproven, is the discipline working where nobody would have checked. The measured result is the right failure, in the right direction: plus three more failing as Two risks accepted, both stated rather than hiddenThe present-but-failed arm logs at The absent arm can still fire for a package that is installed but whose own dependency is not — measured, same code. Mitigated rather than concealed: both arms print the resolver's message, which names the specifier that actually failed, so "install X" stays pointed at the right thing. Generated by Claude Code |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#7926
The defect
Every optional-service load in
DevPlugin.init()ended in a barecatch {}whose only act was to warn that the package was not installed. Any failure at all — bad config, a missing peer, a deliberate refusal, a genuine bug in a constructor — came out as an absent package, and the operator went off to install something they already had.The measured instance (#6915 / PR #7924):
InMemoryDriver's constructor refuses a non-singletenancy posture with a message naming the detected posture, both env knobs (OS_TENANCY_POSTURE/OS_MULTI_ORG_ENABLED) and the@objectstack/driver-sqlremedy includingconnection: { filename: ':memory:' }. UnderOS_TENANCY_POSTURE=isolatedan operator saw none of it — only:A well-written refusal, replaced by a false diagnosis.
What changed
Each
catchbinds the error and tells the two cases apart:errorlevel saying the package is installed, that installing it again will not help, and carrying the underlyingcodeandmessageverbatim.Neither arm swallows the original error. That was the whole reason this defect survived: the
catchdiscarded the evidence that would have named it.Detection
The classifier is the module system's own resolution verdict, never a message match:
await import()(theesmbuild)ERR_MODULE_NOT_FOUNDrequire()(thecjsbuild of the same call)MODULE_NOT_FOUNDBoth spellings are live because this package ships both formats.
err.codeis the only structured field Node attaches — measured,Object.getOwnPropertyNames(err)is['stack', 'code', 'message'], with nourlnaming the failed specifier.This is not in tension with the "which stage threw" classifier the organizations block uses one screen below, and not a competing convention: both refuse to read a plugin's private refusal semantics.
ERR_MODULE_NOT_FOUNDis the module system's verdict about resolution, which is exactly the fact being classified here.The code is read through the
causechain, and that too is measured rather than assumed. A loader failure does not always arrive bare:@vitest/mocker'screateHelpfulErrorwraps everyvi.mockfactory throw in its own uncodedErrorwith the real one oncause— and a throwing factory is exactly how this repo simulates an absent package indev-plugin.test.ts. Reading only the outer error would have classified every mocked-absent package as present-but-failed, i.e. broken the pins this card requires to stay green. The tie-break is that the outermost error carrying acodedecides: a typed refusal is authoritative about itself, so a constructor that failed while reaching for a lazy optional peer stays a construction failure. Pinned by its own test.One honest limit, and why both arms print the cause. A package that resolves but whose own dependency does not raises the same code (measured), so the absent arm can fire for a package that is itself installed. The resolver's message names the specifier that actually failed, so the appended cause keeps "install X" actionable rather than misdirecting to the package we asked for.
Sites
All eleven optional loads: objectql, driver, app metadata, i18n, storage, realtime, auth, the setup/account app packages, security, REST, dispatcher.
One site genuinely differs — REST. Its #3963 no-auth precondition was a
throwinside the loadtry, so DevPlugin's own refusal to serve a data API without auth was reported asℹ @objectstack/rest not installed, at debug — this card's defect, in the one instance the file produced against its own words rather than a package's. That check now runs before the import and reports itself. Only the diagnosis changed: the REST plugin is not registered either way andinit()still returns.Two remaining bare
catchblocks are deliberately untouched because they are a different class: thectx.getService(svc)probe that maps "throws" to "slot is empty", anddestroy()'s cleanup swallow. Neither emits a diagnosis about why anything failed.Tests
packages/plugins/plugin-dev/src/dev-plugin-optional-load-failure.test.ts(new, 9 cases). The existing absent-case pins indev-plugin.test.ts:263-264are untouched and still green — they are correct for a genuinely absent package. The new file adds the second outcome:codeand every actionable fact of the message reach the log;causeis a module-not-found stays a construction failure (the tie-break), with the nested cause still printed;@objectstack/rest.pnpm --filter @objectstack/plugin-dev typecheckclean;pnpm --filter @objectstack/plugin-dev test→ 5 files, 54 tests passed.Reverse verification — measured
The discrimination was reverted at the driver site only (the pre-#7926 bare
catchrestored verbatim), with the file's state asserted inside the run itself so the result cannot be an artifact of a stale tree:The new pin fails by producing the "not installed" wording — the defect reproduced, not a compile error:
Three more fail alongside it, each because the present-but-failed line does not exist at all under the reverted code (
expected undefined to be defined): thecode+messagesurfacing case, the both-packages-named case, and thecausetie-break case.The 50 that stay green include every existing absent-case pin —
dev-plugin.test.tsis untouched by the revert, which is the point: the new discrimination is what fails, and only it. Restoring the fix returns the suite to 54/54.Census gate
check:driver-memory-census(inside the ESLint job) went red on this branch: the new test'svi.mock('@objectstack/driver-memory')is a declaration the #6664 ledger did not cover. Recorded rather than silenced —scripts/driver-memory-census.ledger.jsongains an entry on the existingmock-replacementaxis, with its reason: the factory supplies its ownInMemoryDriverwhose constructor throws and never callsimportOriginal, so the real driver is not loaded, and it is not a migration candidate because DevPlugin resolves the specifier by name (a test of that load site must name it) and nothing is stored (no test backend for sqlite:memory:to replace). The ruled set is unchanged, so both ruled files'#6664 census: 2 ruled consumersmarker still holds. Gate now green locally, self-test included.Out of scope, deliberately
Whether DevPlugin should refuse to start when a driver refuses is a product-shape question (#7926 pins it as out of scope). Behaviour is unchanged: a failed slot stays empty and
init()returns.Generated by Claude Code