|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#13905] The discriminator that tells **"nothing ever registered this |
| 5 | + * service"** apart from **"the service IS registered and could not be built"** |
| 6 | + * on the ASYNCHRONOUS resolution path. |
| 7 | + * |
| 8 | + * ## The fault |
| 9 | + * |
| 10 | + * `PluginLoader.getService` (reached through `Kernel.getServiceAsync`) answered |
| 11 | + * both facts with the same bare `Error`. A caller that holds only the rejection |
| 12 | + * therefore could not tell an UNWIRED embedder from a BROKEN one, and the only |
| 13 | + * thing separating them was message text. |
| 14 | + * |
| 15 | + * That mattered one layer out. `RestServer.computeExecCtx`'s kernel branch |
| 16 | + * absorbs a failed `getServiceAsync('objectql')` and degrades to "no engine is |
| 17 | + * wired". It must keep doing so — a kernel with no data plane is a SUPPORTED |
| 18 | + * configuration (`rest-api-plugin.ts` declares |
| 19 | + * `optionalDependencies: ['com.objectstack.engine.objectql']`) — but a |
| 20 | + * multi-tenant host whose engine FAILED TO CONSTRUCT reached that same resolver |
| 21 | + * as "no engine is wired", degrading silently where it should have refused |
| 22 | + * loudly. The branch could not be repaired from the outside, because the fact |
| 23 | + * it needed had been collapsed before it arrived. |
| 24 | + * |
| 25 | + * ## Why a brand, and ⛔ not message text |
| 26 | + * |
| 27 | + * The SYNCHRONOUS accessor in `kernel.ts` already draws exactly this line, and |
| 28 | + * the comment there records what happened the last time someone read the fact |
| 29 | + * off the wrong surface: reading "not found" off the async path "reported every |
| 30 | + * missing service as `is async - use await` — the wrong fix, pointing at the |
| 31 | + * wrong layer". A second text classifier on a resolution path is the failure |
| 32 | + * mode this module removes, ⛔ not a repair of it. |
| 33 | + * |
| 34 | + * The sync side decides from the REGISTRY — synchronous and authoritative — and |
| 35 | + * raises two different messages. The async side now carries that same |
| 36 | + * distinction as a branded, `code`-bearing rejection: one fact, spelled for a |
| 37 | + * caller that only ever sees the rejection. |
| 38 | + * |
| 39 | + * ## The test is CLOSED, and its default is LOUD |
| 40 | + * |
| 41 | + * Exactly one throw in `PluginLoader.getService` means "never registered", and |
| 42 | + * it is the one branded here. Every other way that method can reject — a |
| 43 | + * factory that threw, a missing scope id, an unset loader context, a circular |
| 44 | + * service dependency — is a service that IS registered and could not be |
| 45 | + * produced, and stays unbranded. So `false` is the safe answer: a consumer that |
| 46 | + * absorbs only the branded rejection stays loud about everything else, |
| 47 | + * including rejections added to that method later. |
| 48 | + * |
| 49 | + * ## Two deliberate omissions |
| 50 | + * |
| 51 | + * - **No `status`.** An ADR-0112 envelope pairs `code` with a `status`, but |
| 52 | + * the whole point of this discriminator is that the CONSUMER decides what an |
| 53 | + * unwired service means — absorb and degrade (the supported no-data-plane |
| 54 | + * kernel) or refuse. Carrying an HTTP status here would presuppose that |
| 55 | + * decision at the layer that must not make it. |
| 56 | + * - **No `name` override.** The rejection stays `name: 'Error'` with a |
| 57 | + * byte-identical message, so `String(err)`, logs and existing assertions |
| 58 | + * render exactly as before. The only observable change is two added |
| 59 | + * own-properties. |
| 60 | + * |
| 61 | + * Brand shape follows `AuthzStoreUnavailableError` (2026-08-30): a string-keyed |
| 62 | + * own property rather than `instanceof`, so the predicate still answers |
| 63 | + * correctly when two copies of `@objectstack/core` are installed (a duplicated |
| 64 | + * module makes `instanceof` say "no" to an error it built itself). |
| 65 | + * |
| 66 | + * ⚠️ The brand does NOT survive `structuredClone`, and no claim here depends on |
| 67 | + * it doing so — measured on Node 22: cloning an `Error` keeps `name`, `message`, |
| 68 | + * `stack` and `cause` and DROPS every other own property, brand and `code` |
| 69 | + * alike. This discriminator is for an in-process rejection travelling from |
| 70 | + * `PluginLoader.getService` to a seam that catches it, which is the only path |
| 71 | + * it is used on. |
| 72 | + */ |
| 73 | + |
| 74 | +/** |
| 75 | + * The code carried by the "never registered" rejection. |
| 76 | + * |
| 77 | + * ⚠️ Spelled the ADR-0112 way, but deliberately NOT wire vocabulary: this value |
| 78 | + * is read in-process by the seam that catches the rejection and is never |
| 79 | + * serialized into an `error.code` envelope. `dispatcher-error-vocabulary.ts` |
| 80 | + * classifies it `door: 'none'` / `boot-refusal` for exactly that reason — the |
| 81 | + * same class as the migration-journal runner refusals. If a transport ever |
| 82 | + * needs to ANSWER with this fact, that is a registration question for #8846's |
| 83 | + * ledger, ⛔ not something to start doing at a door. |
| 84 | + */ |
| 85 | +export const SERVICE_NOT_REGISTERED_CODE = 'SERVICE_NOT_REGISTERED'; |
| 86 | + |
| 87 | +/** |
| 88 | + * The own-property brand {@link isServiceNotRegisteredError} tests for. |
| 89 | + * A plain string key rather than `instanceof` or a `Symbol.for` registry key, |
| 90 | + * so a duplicated copy of this module still brands identically. See the module |
| 91 | + * doc for what it deliberately does NOT claim. |
| 92 | + */ |
| 93 | +const SERVICE_NOT_REGISTERED_BRAND = '__objectstackServiceNotRegistered'; |
| 94 | + |
| 95 | +/** |
| 96 | + * Build the rejection for "no factory and no instance is registered under this |
| 97 | + * name". Package-internal on purpose: `PluginLoader.getService` is the single |
| 98 | + * construction site, and `@objectstack/core` publishes only the two symbols a |
| 99 | + * CONSUMER needs ({@link SERVICE_NOT_REGISTERED_CODE} and |
| 100 | + * {@link isServiceNotRegisteredError}) — see `index.ts`. |
| 101 | + * |
| 102 | + * The message is kept verbatim: callers and tests that render or assert on it |
| 103 | + * must not move when the discriminator arrives. |
| 104 | + */ |
| 105 | +export function serviceNotRegisteredError(name: string): Error { |
| 106 | + const err = new Error(`Service '${name}' not found`) as Error & { |
| 107 | + [SERVICE_NOT_REGISTERED_BRAND]?: true; |
| 108 | + code?: string; |
| 109 | + serviceName?: string; |
| 110 | + }; |
| 111 | + err[SERVICE_NOT_REGISTERED_BRAND] = true; |
| 112 | + err.code = SERVICE_NOT_REGISTERED_CODE; |
| 113 | + err.serviceName = name; |
| 114 | + return err; |
| 115 | +} |
| 116 | + |
| 117 | +/** |
| 118 | + * True when `err` is the rejection meaning **nothing was ever registered under |
| 119 | + * that service name** — never when a registered service failed to construct. |
| 120 | + * |
| 121 | + * The predicate a seam uses to keep absorbing the supported "no data plane" |
| 122 | + * composition while staying loud about a service that IS wired and broke. |
| 123 | + */ |
| 124 | +export function isServiceNotRegisteredError( |
| 125 | + err: unknown, |
| 126 | +): err is Error & { readonly code: typeof SERVICE_NOT_REGISTERED_CODE; readonly serviceName: string } { |
| 127 | + return ( |
| 128 | + typeof err === 'object' |
| 129 | + && err !== null |
| 130 | + && (err as Record<string, unknown>)[SERVICE_NOT_REGISTERED_BRAND] === true |
| 131 | + ); |
| 132 | +} |
0 commit comments