|
| 1 | +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** One boot-registered driver whose `connect()` rejected during engine init. */ |
| 4 | +export interface DriverConnectFailure { |
| 5 | + driverName: string; |
| 6 | + error: unknown; |
| 7 | +} |
| 8 | + |
| 9 | +/** `error.message` when it is an Error, its string form otherwise. */ |
| 10 | +function failureMessage(error: unknown): string { |
| 11 | + if (error instanceof Error) return error.message || error.name; |
| 12 | + return String(error); |
| 13 | +} |
| 14 | + |
| 15 | +/** |
| 16 | + * Thrown by `ObjectQL.init()` when one or more boot-registered drivers fail to |
| 17 | + * connect (framework#3741). Aborts kernel bootstrap: a server that cannot reach |
| 18 | + * its database must not report itself started. |
| 19 | + * |
| 20 | + * Two failures are collapsed into one class on purpose, because `init()` cannot |
| 21 | + * tell them apart and the correct response to both is the same — don't boot: |
| 22 | + * |
| 23 | + * - the datasource is genuinely unreachable (wrong `OS_DATABASE_URL`, rotated |
| 24 | + * password, closed network path), and |
| 25 | + * - the driver is DELIBERATELY REFUSING to start (licence, server version, |
| 26 | + * incompatible configuration, missing capability). Throwing from `connect()` |
| 27 | + * is the supported way for a driver to veto boot; before this error existed, |
| 28 | + * such a veto was caught and downgraded to a query-time error, which is why |
| 29 | + * driver-mongodb's tenancy guard had to be hoisted into its constructor |
| 30 | + * (#3724 / #3734). |
| 31 | + * |
| 32 | + * The message is self-contained — it names every failed driver and its cause — |
| 33 | + * because the CLI prints `error.message` alone (stack only under `DEBUG`). |
| 34 | + * Identified by `code` rather than `instanceof` so it survives crossing package |
| 35 | + * boundaries. |
| 36 | + */ |
| 37 | +export class DriverConnectError extends Error { |
| 38 | + readonly code = 'ERR_DRIVER_CONNECT' as const; |
| 39 | + |
| 40 | + /** |
| 41 | + * The first failure's `Error`, so its stack stays reachable for `DEBUG` |
| 42 | + * output and structured logging. Declared here rather than inherited: the |
| 43 | + * repo compiles against `lib: ES2020`, which predates `Error.cause`. |
| 44 | + */ |
| 45 | + readonly cause?: unknown; |
| 46 | + |
| 47 | + constructor( |
| 48 | + public readonly failures: DriverConnectFailure[], |
| 49 | + public readonly totalDrivers: number, |
| 50 | + ) { |
| 51 | + const detail = failures |
| 52 | + .map((f) => ` • ${f.driverName}: ${failureMessage(f.error)}`) |
| 53 | + .join('\n'); |
| 54 | + super( |
| 55 | + `${failures.length} of ${totalDrivers} data driver(s) failed to connect — refusing to boot.\n` + |
| 56 | + `${detail}\n` + |
| 57 | + `A driver that did not connect cannot serve queries (there is no lazy reconnection) and the ` + |
| 58 | + `schema sync that runs right after init would issue DDL against it. Fix the datasource ` + |
| 59 | + `configuration (e.g. OS_DATABASE_URL), or set OS_ALLOW_DRIVER_CONNECT_FAILURE=1 to boot anyway ` + |
| 60 | + `in an explicitly degraded state where every query to those drivers fails.`, |
| 61 | + ); |
| 62 | + this.name = 'DriverConnectError'; |
| 63 | + if (failures[0]?.error instanceof Error) { |
| 64 | + (this as { cause?: unknown }).cause = failures[0].error; |
| 65 | + } |
| 66 | + } |
| 67 | + |
| 68 | + /** Names of the drivers that failed, in registration order. */ |
| 69 | + get failedDrivers(): string[] { |
| 70 | + return this.failures.map((f) => f.driverName); |
| 71 | + } |
| 72 | +} |
| 73 | + |
| 74 | +/** |
| 75 | + * Emit the degraded-boot banner on a channel the host cannot accidentally |
| 76 | + * silence. |
| 77 | + * |
| 78 | + * `OS_ALLOW_DRIVER_CONNECT_FAILURE` only justifies itself if the state it opts |
| 79 | + * into is impossible to miss — and a logger-only banner is missable: `os serve` |
| 80 | + * swallows ALL of stdout while the kernel boots (its "boot-quiet" capture), and |
| 81 | + * `Logger` routes `warn` to stdout, so the one message that matters would be |
| 82 | + * invisible in exactly the situation it exists for. Writing to stderr as well |
| 83 | + * is the same belt-and-braces the kernel already uses for plugin startup |
| 84 | + * failures. |
| 85 | + * |
| 86 | + * Best-effort and never throws: falls back to `console.error`, then to silence |
| 87 | + * on runtimes that have neither (the logger still carries the structured |
| 88 | + * record either way). |
| 89 | + */ |
| 90 | +export function emitDegradedBootBanner(message: string): void { |
| 91 | + const proc = (globalThis as { |
| 92 | + process?: { stderr?: { write?: (chunk: string) => unknown } }; |
| 93 | + }).process; |
| 94 | + try { |
| 95 | + if (typeof proc?.stderr?.write === 'function') { |
| 96 | + proc.stderr.write(`${message}\n`); |
| 97 | + return; |
| 98 | + } |
| 99 | + } catch { |
| 100 | + /* stderr unavailable / closed — fall through to console */ |
| 101 | + } |
| 102 | + try { |
| 103 | + (globalThis as { console?: { error?: (msg: string) => void } }).console?.error?.(message); |
| 104 | + } catch { |
| 105 | + /* no output channel at all — the logger record is the remaining trace */ |
| 106 | + } |
| 107 | +} |
0 commit comments