diff --git a/.changeset/dev-plugin-installed-but-failed-load.md b/.changeset/dev-plugin-installed-but-failed-load.md new file mode 100644 index 0000000000..f9a6b47b98 --- /dev/null +++ b/.changeset/dev-plugin-installed-but-failed-load.md @@ -0,0 +1,56 @@ +--- +"@objectstack/plugin-dev": patch +--- + +fix(plugin-dev): a service that IS installed and fails to construct is no longer reported as "not installed" (#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 any +failure at all — a 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-`single` tenancy posture with a message naming the detected posture, both +env knobs (`OS_TENANCY_POSTURE` / `OS_MULTI_ORG_ENABLED`) and the +`@objectstack/driver-sql` remedy including `connection: { filename: ':memory:' }`. +Under `OS_TENANCY_POSTURE=isolated` an operator saw none of it — only +`✘ @objectstack/runtime or @objectstack/driver-memory not installed — skipping +driver`. A well-written refusal replaced by a false diagnosis, which is the +shape Prime Directive #10 forbids. + +Each `catch` now binds the error and tells the two cases apart: + +- **Absent** — today's wording and today's advice, unchanged, plus the + resolver's own message. That last part matters for the case the code alone + cannot separate: a package that resolves but whose *own* dependency does not + raises the same code, and the appended message names the specifier that + actually failed, so "install X" stays actionable. +- **Present but failed** — a distinct line at `error` level that says the + package **is** installed, states that installing it again will not help, and + carries the underlying `code` and `message` verbatim. + +The classifier is the module system's own resolution verdict — +`ERR_MODULE_NOT_FOUND` from the ESM loader, `MODULE_NOT_FOUND` from the CJS +build's `require()`, both measured on node v22 rather than assumed — never a +match against message text. It reads no plugin's private refusal semantics, so +it does not compete with the "which stage threw" classifier the organizations +block uses one screen below. + +The code is read through the error's `cause` chain, because a loader failure +does not always arrive bare: a host that transforms modules can hand back its +own error with the real one on `cause`. The **outermost** error carrying a +`code` decides, so a plugin's typed refusal is never re-read as a resolution +failure just because something deeper in its chain happens to be one. + +Fixed at all eleven optional loads (objectql, driver, app metadata, i18n, +storage, realtime, auth, the setup/account app packages, security, REST, +dispatcher). The REST site differed and is handled on its own terms: its `#3963` +no-auth precondition was a `throw` *inside* the load `try`, so DevPlugin's own +refusal to serve a data API without auth was reported as +`ℹ @objectstack/rest not installed` at debug level. That check now runs before +the import and reports itself. + +Behaviour is otherwise unchanged: a failed slot stays empty and `init()` still +returns. Whether DevPlugin should refuse to start when a driver refuses is a +product-shape question and is deliberately not decided here. diff --git a/packages/plugins/plugin-dev/src/dev-plugin-optional-load-failure.test.ts b/packages/plugins/plugin-dev/src/dev-plugin-optional-load-failure.test.ts new file mode 100644 index 0000000000..491013f267 --- /dev/null +++ b/packages/plugins/plugin-dev/src/dev-plugin-optional-load-failure.test.ts @@ -0,0 +1,288 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// #7926 — ABSENT vs. PRESENT-BUT-FAILED, at every optional-service load. +// +// Every load in `DevPlugin.init()` used to end in a bare `catch {}` whose only +// act was to warn that the package was "not installed". A package that IS +// installed and threw while loading or constructing was therefore reported as +// an absent one, and the operator went off to install something they already +// had. The measured instance (#6915 / PR #7924): `InMemoryDriver`'s constructor +// refuses a non-`single` tenancy posture with a message naming the detected +// posture, both env knobs and the `@objectstack/driver-sql` remedy — and an +// operator saw `✘ @objectstack/runtime or @objectstack/driver-memory not +// installed — skipping driver` instead. Not one word of the refusal survived. +// +// ── What this file mocks, and why it must ────────────────────────────────── +// "The package is present and it threw" cannot be observed without a present +// package that throws, so the mocks supply exactly that and nothing else: the +// classification under test stays entirely in dev-plugin.ts. The same reasoning +// dev-plugin-tenancy-mount-refusal.test.ts records for its own mock. +// +// The ABSENT arm is pinned in dev-plugin.test.ts (`service-storage not +// installed` / `service-realtime not installed`) and is deliberately NOT +// repurposed here — those assertions are correct for a genuinely absent package +// and must keep passing untouched. This file adds the second outcome; the pair +// is what makes the distinction real. One absent-arm case is re-pinned below +// alongside its failed-arm twin, because "these two inputs produce two +// different diagnoses" is the claim, and a claim about a distinction cannot be +// tested from one side. + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { DevPlugin } from './dev-plugin'; + +/** How the mocked packages should misbehave for the current test. */ +const behaviour = vi.hoisted(() => ({ + /** `driver-memory` resolves; its constructor throws (the #6915 shape). */ + driverConstructorThrows: true, + /** The constructor's refusal carries a module-not-found error as its cause. */ + driverErrorWrapsModuleNotFound: false, + /** `service-storage` resolves; its module body throws at evaluation time. */ + storageModuleThrows: true, +})); + +/** The shape Node's loader raises for a genuinely absent package. */ +const absent = (pkg: string) => + Object.assign(new Error(`Cannot find package '${pkg}' imported from /app/dev-plugin.js`), { + code: 'ERR_MODULE_NOT_FOUND', + }); + +// Present, and healthy enough to be constructed — the driver mock is what +// fails, so `DriverPlugin` must exist for the failure to be attributable. +vi.mock('@objectstack/runtime', () => ({ + DriverPlugin: class { + name = 'com.objectstack.plugin.driver'; + version = '1.0.0'; + constructor(_driver: unknown, _name: string) {} + async init() {} + }, + AppPlugin: class { + name = 'com.objectstack.plugin.app'; + version = '1.0.0'; + async init() {} + }, + createDispatcherPlugin: () => ({ + name: 'com.objectstack.plugin.dispatcher', + version: '1.0.0', + init: async () => {}, + }), +})); + +// PRESENT. Its constructor refuses, exactly as InMemoryDriver's tenancy guard +// does under a walled posture (#6915 — `memory-tenancy-guard.ts`). +vi.mock('@objectstack/driver-memory', () => ({ + InMemoryDriver: class { + constructor(_opts: unknown) { + if (!behaviour.driverConstructorThrows) return; + throw Object.assign( + new Error( + "InMemoryDriver refuses to start under tenancy posture 'isolated': it has no " + + 'organization scoping. Set OS_TENANCY_POSTURE=single, or use @objectstack/driver-sql ' + + "with connection: { filename: ':memory:' }.", + ), + { + code: 'MEMORY_MULTI_TENANT_UNSUPPORTED', + cause: behaviour.driverErrorWrapsModuleNotFound + ? Object.assign(new Error("Cannot find package 'some-lazy-optional-peer'"), { + code: 'ERR_MODULE_NOT_FOUND', + }) + : undefined, + }, + ); + } + }, +})); + +// PRESENT, and throws while its module body is evaluated — the other flavour of +// "installed but failed": no constructor is ever reached. +vi.mock('@objectstack/service-storage', () => { + if (behaviour.storageModuleThrows) { + throw Object.assign(new Error('STORAGE_ADAPTER_MISCONFIGURED: OS_STORAGE_ROOT is not writable'), { + code: 'STORAGE_ADAPTER_MISCONFIGURED', + }); + } + return { StorageServicePlugin: class { name = 'storage'; version = '1.0.0'; } }; +}); + +// Everything else: genuinely absent, so the run stays fast and the two arms are +// exercised side by side in one boot (#3060's reason for mocking at all). +vi.mock('@objectstack/objectql', () => { throw absent('@objectstack/objectql'); }); +vi.mock('@objectstack/service-i18n', () => { throw absent('@objectstack/service-i18n'); }); +vi.mock('@objectstack/service-realtime', () => { throw absent('@objectstack/service-realtime'); }); +vi.mock('@objectstack/plugin-auth', () => { throw absent('@objectstack/plugin-auth'); }); +vi.mock('@objectstack/plugin-security', () => { throw absent('@objectstack/plugin-security'); }); +vi.mock('@objectstack/plugin-hono-server', () => { throw absent('@objectstack/plugin-hono-server'); }); +vi.mock('@objectstack/rest', () => { throw absent('@objectstack/rest'); }); +vi.mock('@objectstack/setup', () => { throw absent('@objectstack/setup'); }); +vi.mock('@objectstack/account', () => { throw absent('@objectstack/account'); }); + +function mockCtx() { + const ctx: any = { + logger: { info: vi.fn(), debug: vi.fn(), warn: vi.fn(), error: vi.fn() }, + getService: vi.fn(() => { throw new Error('not found'); }), + getServices: vi.fn(() => new Map()), + registerService: vi.fn(), + hook: vi.fn(), + trigger: vi.fn(), + getKernel: vi.fn(), + }; + return ctx; +} + +/** Every line the boot logged, at any level, in one flat list. */ +const allLines = (ctx: any): string[] => + [ + ...ctx.logger.warn.mock.calls, + ...ctx.logger.info.mock.calls, + ...ctx.logger.debug.mock.calls, + ...ctx.logger.error.mock.calls, + ].map((call: unknown[]) => String(call[0])); + +const errorLines = (ctx: any): string[] => + ctx.logger.error.mock.calls.map((call: unknown[]) => String(call[0])); + +const OLD_NODE_ENV = process.env.NODE_ENV; + +beforeEach(() => { + behaviour.driverConstructorThrows = true; + behaviour.driverErrorWrapsModuleNotFound = false; + behaviour.storageModuleThrows = true; + process.env.NODE_ENV = 'development'; +}); + +afterEach(() => { + if (OLD_NODE_ENV === undefined) delete process.env.NODE_ENV; + else process.env.NODE_ENV = OLD_NODE_ENV; + vi.restoreAllMocks(); +}); + +describe('DevPlugin — an optional service that is installed and fails to construct (#7926)', () => { + it('does NOT report the driver as "not installed" when its constructor throws', async () => { + const ctx = mockCtx(); + await new DevPlugin({ seedAdminUser: false }).init(ctx); + + const driverLines = allLines(ctx).filter((l) => l.includes('driver-memory')); + expect(driverLines.length, 'the driver failure is reported exactly once').toBe(1); + + // The defect, stated as an assertion: this line used to be the absent-package + // one. An operator reading it must not be sent to install a package they have. + expect(driverLines[0]).not.toContain('not installed'); + expect(driverLines[0]).toContain('installed but failed to initialize'); + expect(driverLines[0]).toContain('NOT a missing-package problem'); + }); + + it('surfaces the underlying error — both its code and its message — verbatim', async () => { + const ctx = mockCtx(); + await new DevPlugin({ seedAdminUser: false }).init(ctx); + + const line = errorLines(ctx).find((l) => l.includes('driver-memory')); + expect(line, 'a present-but-failed load is reported at error level').toBeDefined(); + expect(line).toContain('code: MEMORY_MULTI_TENANT_UNSUPPORTED'); + // Every actionable fact the refusal carried reaches the operator: the + // posture it detected, the knob that produces it, and the remedy. + expect(line).toContain("tenancy posture 'isolated'"); + expect(line).toContain('OS_TENANCY_POSTURE=single'); + expect(line).toContain('@objectstack/driver-sql'); + // …and it is named as the package's own words, not the framework's reading. + expect(line).toContain('verbatim — the framework does not interpret it'); + }); + + it('names both packages the load needed, and what the stack does without it', async () => { + const ctx = mockCtx(); + await new DevPlugin({ seedAdminUser: false }).init(ctx); + + const line = errorLines(ctx).find((l) => l.includes('driver-memory'))!; + expect(line).toContain('@objectstack/runtime, @objectstack/driver-memory ARE installed'); + expect(line).toContain('skipping driver'); + }); + + it('classifies a module body that throws at evaluation time the same way', async () => { + const ctx = mockCtx(); + await new DevPlugin({ seedAdminUser: false }).init(ctx); + + const line = errorLines(ctx).find((l) => l.includes('service-storage')); + expect(line, 'an evaluation-time throw is a present-but-failed load too').toBeDefined(); + expect(line).not.toContain('not installed'); + expect(line).toContain('code: STORAGE_ADAPTER_MISCONFIGURED'); + expect(line).toContain('OS_STORAGE_ROOT is not writable'); + expect(line).toContain('the file-storage slot stays empty'); + }); + + it('leaves the slot empty and still boots — this card changes the diagnosis, not the outcome', async () => { + const ctx = mockCtx(); + await expect(new DevPlugin({ seedAdminUser: false }).init(ctx)).resolves.toBeUndefined(); + expect(ctx.registerService).not.toHaveBeenCalled(); + }); + + // The other half of the distinction. A genuinely absent package keeps today's + // wording and today's advice — that is what dev-plugin.test.ts pins — and now + // also carries the resolver's own message, so a transitive missing dependency + // (same `ERR_MODULE_NOT_FOUND`, different specifier) names itself instead of + // hiding behind the package we asked for. + // + // This case also pins the wrapper the chain walk exists for: `@vitest/mocker` + // hands a factory's throw back inside its own uncoded `Error`, with the real + // one on `cause` (`createHelpfulError`). Reading only the outer error would + // classify every mocked-absent package in this repo as present-but-failed. + it('still says "not installed" for a genuinely absent package, and says which specifier failed', async () => { + const ctx = mockCtx(); + await new DevPlugin({ seedAdminUser: false }).init(ctx); + + const line = allLines(ctx).find((l) => l.includes('service-realtime')); + expect(line).toContain('@objectstack/service-realtime not installed'); + expect(line).toContain('the realtime slot stays empty'); + expect(line).toContain('ERR_MODULE_NOT_FOUND'); + expect(line).toContain("Cannot find package '@objectstack/service-realtime'"); + // An absent optional package is a normal dev-stack state: it must NOT be + // promoted to the error level the present-but-failed arm uses. + expect(errorLines(ctx).some((l) => l.includes('service-realtime'))).toBe(false); + }); + + // The tie-break rule, pinned: the OUTERMOST error carrying a `code` decides. + // A refusal is authoritative about itself, so a constructor that failed while + // reaching for a lazy optional peer is still a construction failure — not a + // missing `@objectstack/driver-memory`. The chain walk exists because a + // wrapper without a code of its own must stay transparent (`@vitest/mocker` + // wraps every factory throw exactly that way); it must not turn any nested + // resolution error into a verdict about the package we asked for. + it('does not re-read a typed refusal as "not installed" because its cause is a module-not-found', async () => { + behaviour.driverErrorWrapsModuleNotFound = true; + const ctx = mockCtx(); + await new DevPlugin({ seedAdminUser: false }).init(ctx); + + const line = errorLines(ctx).find((l) => l.includes('driver-memory')); + expect(line, 'the outermost code decides — this is still a construction failure').toBeDefined(); + expect(line).not.toContain('not installed'); + expect(line).toContain('code: MEMORY_MULTI_TENANT_UNSUPPORTED'); + // …and the nested cause is still printed, because neither arm swallows it. + expect(line).toContain('caused by'); + expect(line).toContain("Cannot find package 'some-lazy-optional-peer'"); + }); + + // Both arms in one boot, from one code path — the distinction is live, not a + // property of which test file ran. + it('reports absent and present-but-failed differently in the same boot', async () => { + const ctx = mockCtx(); + await new DevPlugin({ seedAdminUser: false }).init(ctx); + + const realtime = allLines(ctx).find((l) => l.includes('service-realtime'))!; + const storage = allLines(ctx).find((l) => l.includes('service-storage'))!; + expect(realtime).toContain('not installed'); + expect(storage).not.toContain('not installed'); + }); + + // #3963's refusal used to travel through the same load `catch` and come out as + // `ℹ @objectstack/rest not installed` — the one instance of this defect the + // file produced against its own words rather than a package's. + it('does not blame @objectstack/rest when it is DevPlugin that refuses the data API', async () => { + const ctx = mockCtx(); + await new DevPlugin({ seedAdminUser: false }).init(ctx); + + const line = allLines(ctx).find((l) => l.includes('REST API NOT enabled')); + expect(line, 'the no-auth refusal is reported on its own terms').toBeDefined(); + expect(line).toContain('no auth is mounted'); + expect(line).toContain('#3963'); + expect(line).toContain('NOT a missing-package problem'); + // And the false claim it used to emit instead is gone. + expect(allLines(ctx).some((l) => l.includes('@objectstack/rest not installed'))).toBe(false); + }); +}); diff --git a/packages/plugins/plugin-dev/src/dev-plugin.ts b/packages/plugins/plugin-dev/src/dev-plugin.ts index d7ec1107f9..1b5fa2c298 100644 --- a/packages/plugins/plugin-dev/src/dev-plugin.ts +++ b/packages/plugins/plugin-dev/src/dev-plugin.ts @@ -171,6 +171,149 @@ function productionOverrideWarnings( return lines; } +/** + * The two codes Node's module system raises when a specifier cannot be + * RESOLVED — the only signal this file uses to tell "the package is not + * installed" apart from "the package is installed and something in it threw" + * (#7926). + * + * Both spellings are live because this package ships both module formats: + * the ESM build's `await import()` reaches the ESM loader, which raises + * `ERR_MODULE_NOT_FOUND`; the CJS build resolves the same call through + * `require()`, which raises `MODULE_NOT_FOUND`. Measured on node v22.22, both + * paths, rather than assumed. + * + * ⛔ Never classify on the message text instead. A message match is a guess + * about wording Node is free to change — the class of guess this repo removed + * from the query normalizer (#4181 / #4121). + * + * This is NOT in tension with the "which stage threw" classifier the + * organizations block below uses, and it is not a competing convention: both + * refuse to read a *plugin's* private refusal semantics. `ERR_MODULE_NOT_FOUND` + * is the module system's own verdict about resolution, which is precisely the + * fact being classified here; the organizations block classifies a *plugin's* + * refusal, about which the framework knows — and must know — nothing. + * + * One honest limit: 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. That is why both arms print the resolver's own message — it names + * the specifier that actually failed, so "install X" stays actionable. + * + * The code is read through {@link errorChain}, because the failure does not + * always arrive bare. + */ +const MODULE_NOT_FOUND_CODES: readonly string[] = ['ERR_MODULE_NOT_FOUND', 'MODULE_NOT_FOUND']; + +/** + * The error and everything it was wrapped around, outermost first. + * + * Loader failures do not always arrive bare: a host that transforms modules can + * hand back its own `Error` with the real one on `cause`. Measured, not + * supposed — `@vitest/mocker`'s `createHelpfulError` does exactly this to any + * throw from a `vi.mock` factory, which is how this repo's own tests simulate an + * absent package (`dev-plugin.test.ts`). Reading only the outer error there + * would classify every simulated-absent package as present-but-failed. + * + * Bounded, and cycle-safe: an error chain is untrusted input. + */ +function errorChain(err: unknown, maxDepth = 5): unknown[] { + const chain: unknown[] = []; + let current: unknown = err; + while (current != null && chain.length < maxDepth) { + if (chain.includes(current)) break; + chain.push(current); + current = (current as { cause?: unknown }).cause; + } + return chain; +} + +/** + * Whether `err` is the module system reporting that a specifier did not resolve. + * + * The OUTERMOST error that carries a `code` decides: a coded error is + * authoritative about itself, and an uncoded wrapper is transparent. So a + * constructor's own typed refusal is never re-read as a resolution failure just + * because something further down the chain happens to be one. + */ +function isModuleNotFound(err: unknown): boolean { + for (const link of errorChain(err)) { + const code = (link as { code?: unknown } | null | undefined)?.code; + if (typeof code === 'string') return MODULE_NOT_FOUND_CODES.includes(code); + } + return false; +} + +/** + * The evidence a failed optional load carried, rendered for a log line. + * + * Printed by BOTH arms, whole chain included. The defect this replaces (#7926) + * was a bare `catch` that discarded the one thing capable of naming the real + * cause: `driver-memory` refused to construct under a multi-tenant posture with + * a message that named the posture, both env knobs and the remedy, and an + * operator saw none of it. + */ +function loadFailureDetail(err: unknown): string { + return errorChain(err) + .map((link) => { + const message = link instanceof Error ? link.message : String(link); + const code = (link as { code?: unknown } | null | undefined)?.code; + return code === undefined ? message : `code: ${String(code)} — ${message}`; + }) + .join(' ← caused by: '); +} + +/** One optional-service load, as its `catch` needs to describe it. */ +interface OptionalLoadSpec { + /** Every package the `try` block imports — named in the "IS installed" arm. */ + packages: readonly string[]; + /** + * Today's absent-case line, verbatim: same wording, same advice. An absent + * optional package is a normal dev-stack state and its diagnosis was already + * right, so nothing about it changes except that the resolver's own message + * now rides along. + */ + absent: string; + /** Level for the absent case — per slot, since "absent" is normal noise. */ + absentLevel: 'warn' | 'info' | 'debug'; + /** What the stack does without this service, e.g. `'skipping driver'`. */ + outcome: string; +} + +/** + * Report a failed optional-service load, telling ABSENT apart from + * PRESENT-BUT-FAILED (#7926). + * + * Every optional load in `init()` used to end in a bare `catch {}` whose single + * act was to warn that the package was "not installed". So a package that IS + * installed and threw while loading or constructing — bad config, a missing + * peer, a deliberate refusal, a genuine bug — was reported as an absent one, + * and the operator went off to install something they already had. Worse, the + * refusal's own message was destroyed on the way: a well-written diagnosis + * replaced by a false one. + * + * The failed arm logs at `error` regardless of the slot's absent level: an + * absent optional package is ordinary, a present one that threw is a defect in + * *this* deployment and must not inherit the quiet level that "absent is fine" + * earned. Same level, and same "verbatim — the framework does not interpret it" + * discipline, as the child-`init()` failure loop below. + * + * What this deliberately does NOT decide: whether DevPlugin should refuse to + * start when a driver refuses. That is a product-shape question (#7926 scope), + * so both arms keep today's behaviour — log, skip the slot, boot on. + */ +function reportOptionalLoadFailure(ctx: PluginContext, err: unknown, spec: OptionalLoadSpec): void { + if (isModuleNotFound(err)) { + ctx.logger[spec.absentLevel](`${spec.absent} (${loadFailureDetail(err)})`); + return; + } + ctx.logger.error( + ` ✘ ${spec.packages.join(', ')} ${spec.packages.length > 1 ? 'ARE' : 'IS'} installed but failed ` + + `to initialize — ${spec.outcome}. This is NOT a missing-package problem: the package resolved ` + + 'here, so installing it again will not help. It reported (verbatim — the framework does not ' + + `interpret it): ${loadFailureDetail(err)}`, + ); +} + /** * Development Assembly Plugin for ObjectStack * @@ -215,6 +358,12 @@ function productionOverrideWarnings( * Every part is loaded via dynamic import and skipped (with a log line) when * its package is not installed, and can be disabled via `options.services`. * + * A load that fails for any OTHER reason — the package is installed and threw + * while loading or constructing — is reported as its own outcome, at `error`, + * carrying the underlying `code` and `message` (#7926). Both facts used to + * arrive as the same "not installed" line, which sent operators to install a + * package they already had. + * * ## Empty slots stay empty (ADR-0115) * * This plugin registers **no service implementations of its own**. A @@ -274,7 +423,10 @@ export class DevPlugin implements Plugin { * * Dynamically imports and instantiates all core plugins. * Uses dynamic imports so that peer dependencies remain optional — - * if a package isn't installed the service is silently skipped. + * if a package isn't installed the service is skipped with a log line + * naming it. A package that IS installed and fails to load or construct is + * a different outcome with a different message (#7926); see + * {@link reportOptionalLoadFailure}. */ async init(ctx: PluginContext): Promise { this.productionOverride = assertNotProduction(); @@ -302,8 +454,13 @@ export class DevPlugin implements Plugin { const qlPlugin = new ObjectQLPlugin(); this.childPlugins.push(qlPlugin); ctx.logger.info(' ✔ ObjectQL engine enabled (data + metadata)'); - } catch { - ctx.logger.warn(' ✘ @objectstack/objectql not installed — skipping data engine'); + } catch (err) { + reportOptionalLoadFailure(ctx, err, { + packages: ['@objectstack/objectql'], + absent: ' ✘ @objectstack/objectql not installed — skipping data engine', + absentLevel: 'warn', + outcome: 'skipping data engine', + }); } } @@ -321,8 +478,17 @@ export class DevPlugin implements Plugin { const driverPlugin = new DriverPlugin(driver, 'memory'); this.childPlugins.push(driverPlugin); ctx.logger.info(' ✔ InMemoryDriver enabled'); - } catch { - ctx.logger.warn(' ✘ @objectstack/runtime or @objectstack/driver-memory not installed — skipping driver'); + } catch (err) { + // [#7926] The measured instance of this defect: `InMemoryDriver`'s + // constructor refuses a non-`single` tenancy posture (#6915) with a + // message naming the posture, both env knobs and the `driver-sql` + // remedy — all of which this catch used to replace with "not installed". + reportOptionalLoadFailure(ctx, err, { + packages: ['@objectstack/runtime', '@objectstack/driver-memory'], + absent: ' ✘ @objectstack/runtime or @objectstack/driver-memory not installed — skipping driver', + absentLevel: 'warn', + outcome: 'skipping driver', + }); } } @@ -335,8 +501,16 @@ export class DevPlugin implements Plugin { const appPlugin = new AppPlugin(this.options.stack); this.childPlugins.push(appPlugin); ctx.logger.info(' ✔ App metadata loaded from stack definition'); - } catch { - ctx.logger.warn(' ✘ @objectstack/runtime not installed — skipping app metadata'); + } catch (err) { + // `new AppPlugin(stack)` parses the stack definition, so a malformed + // stack throws HERE — a construction failure with a real diagnosis, + // previously reported as an absent @objectstack/runtime. + reportOptionalLoadFailure(ctx, err, { + packages: ['@objectstack/runtime'], + absent: ' ✘ @objectstack/runtime not installed — skipping app metadata', + absentLevel: 'warn', + outcome: 'skipping app metadata', + }); } } @@ -361,10 +535,13 @@ export class DevPlugin implements Plugin { }); this.childPlugins.push(i18nPlugin); ctx.logger.info(' ✔ I18nServicePlugin auto-registered (translations detected in stack)'); - } catch { - ctx.logger.info( - ' ℹ @objectstack/service-i18n not installed — using core in-memory i18n fallback with locale resolution' - ); + } catch (err) { + reportOptionalLoadFailure(ctx, err, { + packages: ['@objectstack/service-i18n'], + absent: ' ℹ @objectstack/service-i18n not installed — using core in-memory i18n fallback with locale resolution', + absentLevel: 'info', + outcome: 'falling back to the core in-memory i18n fallback with locale resolution', + }); } } } @@ -385,8 +562,13 @@ export class DevPlugin implements Plugin { const { StorageServicePlugin } = await import('@objectstack/service-storage') as any; this.childPlugins.push(new StorageServicePlugin()); ctx.logger.info(' ✔ Storage service enabled (@objectstack/service-storage, local adapter)'); - } catch { - ctx.logger.info(' ℹ @objectstack/service-storage not installed — the file-storage slot stays empty'); + } catch (err) { + reportOptionalLoadFailure(ctx, err, { + packages: ['@objectstack/service-storage'], + absent: ' ℹ @objectstack/service-storage not installed — the file-storage slot stays empty', + absentLevel: 'info', + outcome: 'the file-storage slot stays empty', + }); } } if (enabled('realtime')) { @@ -394,8 +576,13 @@ export class DevPlugin implements Plugin { const { RealtimeServicePlugin } = await import('@objectstack/service-realtime') as any; this.childPlugins.push(new RealtimeServicePlugin()); ctx.logger.info(' ✔ Realtime service enabled (@objectstack/service-realtime, in-memory adapter)'); - } catch { - ctx.logger.info(' ℹ @objectstack/service-realtime not installed — the realtime slot stays empty'); + } catch (err) { + reportOptionalLoadFailure(ctx, err, { + packages: ['@objectstack/service-realtime'], + absent: ' ℹ @objectstack/service-realtime not installed — the realtime slot stays empty', + absentLevel: 'info', + outcome: 'the realtime slot stays empty', + }); } } @@ -415,8 +602,13 @@ export class DevPlugin implements Plugin { this.childPlugins.push(authPlugin); authMounted = true; ctx.logger.info(' ✔ Auth plugin enabled (dev credentials)'); - } catch { - ctx.logger.warn(' ✘ @objectstack/plugin-auth not installed — skipping auth'); + } catch (err) { + reportOptionalLoadFailure(ctx, err, { + packages: ['@objectstack/plugin-auth'], + absent: ' ✘ @objectstack/plugin-auth not installed — skipping auth', + absentLevel: 'warn', + outcome: 'skipping auth', + }); } // ADR-0048 — the platform apps (Setup/Account) moved out of @@ -433,8 +625,13 @@ export class DevPlugin implements Plugin { const mod: any = await import(/* @vite-ignore */ spec[0]); this.childPlugins.push(mod[spec[1]]()); ctx.logger.info(` ✔ App package enabled (${spec[0]})`); - } catch { - ctx.logger.warn(` ✘ ${spec[0]} not installed — skipping its app`); + } catch (err) { + reportOptionalLoadFailure(ctx, err, { + packages: [spec[0]], + absent: ` ✘ ${spec[0]} not installed — skipping its app`, + absentLevel: 'warn', + outcome: 'skipping its app', + }); } } } @@ -558,8 +755,13 @@ export class DevPlugin implements Plugin { const { SecurityPlugin } = await import('@objectstack/plugin-security') as any; this.childPlugins.push(new SecurityPlugin()); ctx.logger.info(` ✔ Security plugin enabled (RBAC, RLS, field masking; multiTenant=${multiTenant})`); - } catch { - ctx.logger.debug(' ℹ @objectstack/plugin-security not installed — skipping security'); + } catch (err) { + reportOptionalLoadFailure(ctx, err, { + packages: ['@objectstack/plugin-security'], + absent: ' ℹ @objectstack/plugin-security not installed — skipping security', + absentLevel: 'debug', + outcome: 'skipping security', + }); } } @@ -572,33 +774,54 @@ export class DevPlugin implements Plugin { }); this.childPlugins.push(serverPlugin); ctx.logger.info(` ✔ Hono HTTP server enabled on port ${this.options.port}`); - } catch { - ctx.logger.warn(' ✘ @objectstack/plugin-hono-server not installed — skipping HTTP server'); + } catch (err) { + reportOptionalLoadFailure(ctx, err, { + packages: ['@objectstack/plugin-hono-server'], + absent: ' ✘ @objectstack/plugin-hono-server not installed — skipping HTTP server', + absentLevel: 'warn', + outcome: 'skipping HTTP server', + }); } } // 7. REST API endpoints (CRUD + metadata read/write) if (enabled('rest')) { - try { - const { createRestApiPlugin } = await import('@objectstack/rest') as any; - // [#3963] The auth-less fail-open carve-out is gone. It used to pass an - // EXPLICIT `requireAuth: false` when no auth was mounted, on the grounds - // that nobody could authenticate so the deny default would brick the - // playground's data API. That reasoning inverts the right conclusion: a - // stack with no auth has no security model, so it should not serve a data - // API at all — and leaving the back door here would have made the dev - // plugin the one surface that still opens the whole data plane. - if (!authMounted) { - throw new Error( - '[dev] Cannot enable the data API: no auth is mounted in this stack, so no caller could ' - + 'ever authenticate and anonymous access to object data is always denied (#3963). ' - + 'Install/enable plugin-auth (or the `auth` tier), or drop the REST API from this dev stack.', - ); + // [#3963] The auth-less fail-open carve-out is gone. It used to pass an + // EXPLICIT `requireAuth: false` when no auth was mounted, on the grounds + // that nobody could authenticate so the deny default would brick the + // playground's data API. That reasoning inverts the right conclusion: a + // stack with no auth has no security model, so it should not serve a data + // API at all — and leaving the back door here would have made the dev + // plugin the one surface that still opens the whole data plane. + // + // [#7926] This precondition is checked BEFORE the import and is no longer + // expressed as a `throw` inside the load `try`. It is not a load failure: + // @objectstack/rest can be installed and perfectly healthy and this stack + // still must not serve a data API. Routed through the load `catch` it came + // out as `ℹ @objectstack/rest not installed`, at debug — this card's defect + // exactly, and the one instance of it the file produced against its OWN + // words rather than a package's. Only the diagnosis changed: the REST + // plugin is not registered either way and init still returns. + if (!authMounted) { + ctx.logger.warn( + ' ✘ REST API NOT enabled: no auth is mounted in this stack, so no caller could ever ' + + 'authenticate and anonymous access to object data is always denied (#3963). This is NOT a ' + + 'missing-package problem — @objectstack/rest was never consulted. Install/enable ' + + 'plugin-auth (or the `auth` tier), or drop the REST API from this dev stack.', + ); + } else { + try { + const { createRestApiPlugin } = await import('@objectstack/rest') as any; + this.childPlugins.push(createRestApiPlugin()); + ctx.logger.info(' ✔ REST API endpoints enabled (CRUD + metadata)'); + } catch (err) { + reportOptionalLoadFailure(ctx, err, { + packages: ['@objectstack/rest'], + absent: ' ℹ @objectstack/rest not installed — skipping REST endpoints', + absentLevel: 'debug', + outcome: 'skipping REST endpoints', + }); } - this.childPlugins.push(createRestApiPlugin()); - ctx.logger.info(' ✔ REST API endpoints enabled (CRUD + metadata)'); - } catch { - ctx.logger.debug(' ℹ @objectstack/rest not installed — skipping REST endpoints'); } } @@ -609,8 +832,13 @@ export class DevPlugin implements Plugin { const dispatcherPlugin = createDispatcherPlugin(); this.childPlugins.push(dispatcherPlugin); ctx.logger.info(' ✔ Dispatcher enabled (auth, GraphQL, analytics, packages, storage)'); - } catch { - ctx.logger.debug(' ℹ Dispatcher not available — skipping extended API routes'); + } catch (err) { + reportOptionalLoadFailure(ctx, err, { + packages: ['@objectstack/runtime'], + absent: ' ℹ Dispatcher not available — skipping extended API routes', + absentLevel: 'debug', + outcome: 'skipping extended API routes', + }); } } diff --git a/scripts/driver-memory-census.ledger.json b/scripts/driver-memory-census.ledger.json index 252eed72c8..73909e91f7 100644 --- a/scripts/driver-memory-census.ledger.json +++ b/scripts/driver-memory-census.ledger.json @@ -89,6 +89,12 @@ "axis": "mock-replacement", "why": "the mock makes the module resolve as ERR_MODULE_NOT_FOUND, so this file pins DevPlugin's behaviour when the driver is ABSENT. It is the opposite of a consumer." }, + { + "file": "packages/plugins/plugin-dev/src/dev-plugin-optional-load-failure.test.ts", + "kind": "mock", + "axis": "mock-replacement", + "why": "the #7926 pin's PRESENT-but-failing arm, and the one plugin-dev mock that is not the absent-module shape: the factory supplies its own `InMemoryDriver` whose constructor throws, so DevPlugin's load site can be shown reporting a construction failure instead of 'not installed'. It never calls `importOriginal`, so the real driver is not loaded here either. Not a migration candidate: DevPlugin resolves this specifier BY NAME, so a test of that load site must name it, and nothing is stored — there is no test backend here for sqlite `:memory:` to replace." + }, { "file": "packages/plugins/plugin-dev/src/dev-plugin-tenancy-failfast.test.ts", "kind": "mock",