|
| 1 | +--- |
| 2 | +"@objectstack/service-datasource": minor |
| 3 | +"@objectstack/objectql": minor |
| 4 | +"@objectstack/rest": patch |
| 5 | +"@objectstack/runtime": patch |
| 6 | +--- |
| 7 | + |
| 8 | +feat(datasource): a datasource that is down is visible, and says why when queried (#3827, #3828) |
| 9 | + |
| 10 | +#3816 made an explicitly-bound datasource that cannot connect refuse the boot. Two |
| 11 | +gaps survived that fix, both in the cases that still boot — a policy denial, an |
| 12 | +`autoConnect` datasource, or any failure the operator waved through with |
| 13 | +`OS_ALLOW_DRIVER_CONNECT_FAILURE`: |
| 14 | + |
| 15 | +- **It was invisible.** `DatasourceSummary.status` was the literal `'unvalidated'` |
| 16 | + for every row — the contract declared three states and the implementation only |
| 17 | + ever emitted one — so a dead datasource looked exactly like a healthy-untested |
| 18 | + one. `checkDriversHealth()` could not help either: it iterates registered |
| 19 | + drivers, and a datasource that never connected was never registered, so it is |
| 20 | + *absent* from the probe rather than unhealthy. The only trace was a warning |
| 21 | + that scrolled past at boot, which made the diagnostic procedure "restart the |
| 22 | + server and re-read the logs". |
| 23 | +- **The query-time error said nothing.** `getDriver()` answered four different |
| 24 | + situations with one sentence, `Datasource 'x' is not registered.`: refused by |
| 25 | + policy, failed to connect under the escape hatch, a misspelled name, and |
| 26 | + `active: false`. Only the third is an authoring bug, so the other three sent |
| 27 | + the reader hunting for a typo that does not exist. |
| 28 | + |
| 29 | +Both come from the same root: `connect()` already produced a `ConnectResult` for |
| 30 | +every attempt and every caller threw it away. |
| 31 | + |
| 32 | +- **`DatasourceConnectionService` retains the last verdict per datasource**, with a |
| 33 | + coarse `availability` (`available` / `blocked` / `failed` / `unattempted`) beside |
| 34 | + the raw status. New `getConnectionState(name)` / `listConnectionStates()`. |
| 35 | + `disconnect()` drops it, so a removed pool stops explaining itself. |
| 36 | +- **`DatasourceSummary.status` tells the truth**: `ok` | `error` | `blocked` | |
| 37 | + `unvalidated`, with a new operator-facing `statusReason`. `blocked` is new and |
| 38 | + deliberate — a policy denial is a decision, not a fault, and will not clear on |
| 39 | + its own. Reported in **Setup → Datasources**, `GET /api/v1/datasources`, and the |
| 40 | + summary returned from create/update, so a "Save" whose pool failed to open is no |
| 41 | + longer presented as success. |
| 42 | +- **`ERR_DATASOURCE_UNAVAILABLE` (HTTP 503)**: new `DatasourceUnavailableError` |
| 43 | + from `@objectstack/objectql`, thrown by `getDriver()` when the connection layer |
| 44 | + recorded *why* a declared datasource has no driver. An undeclared name keeps the |
| 45 | + original message — there is genuinely nothing to add. 503 rather than 500/400: |
| 46 | + nothing about the request is wrong, and the state may clear. |
| 47 | +- **A privileged/public split for the reason.** The error **never** carries the |
| 48 | + underlying cause — connect failures routinely contain hosts, ports and DSNs, and |
| 49 | + a policy's `reason` is written for operators. Those stay in the logs and the |
| 50 | + (admin-gated) datasource list. `DatasourceConnectDecision` gains an opt-in |
| 51 | + `publicReason` for hosts that want to tell tenants something specific |
| 52 | + (e.g. `'External datasources require the Scale plan.'`); it is the only string |
| 53 | + that reaches an end user. |
| 54 | +- **Readiness is deliberately not gated on this.** `/ready` still reflects |
| 55 | + registered-driver health only: an optional datasource being down must not pull an |
| 56 | + otherwise-working replica out of the load balancer. |
| 57 | + |
| 58 | +Also lands a drift guard for **#3826**, and corrects ADR-0062's status while doing |
| 59 | +it. The ADR claimed D1 ("exactly one definition → live driver path") as |
| 60 | +implemented; only the *construction* half converged. The `default` driver is still |
| 61 | +registered as a `driver.*` kernel service and connected by `ObjectQLEngine.init()`, |
| 62 | +with its own failure verdict, pool teardown, and no connect policy. What blocks the |
| 63 | +merge is an input-shape mismatch, not ordering: `connect()` takes a datasource |
| 64 | +*definition* and builds the driver, while `default` arrives pre-built, and routing |
| 65 | +it through the service would make `ObjectQLPlugin`'s boot depend on an optional |
| 66 | +higher-layer service. Until that is designed, `degraded-boot-parity.test.ts` pins |
| 67 | +both paths to the same operator-visible contract (fail-fast by default, identical |
| 68 | +`OS_ALLOW_DRIVER_CONNECT_FAILURE` parsing, `DEGRADED BOOT` on stderr) so a change |
| 69 | +to one that forgets the other fails CI — #3741 → #3758 was exactly that miss, and |
| 70 | +it cost three months and a second bug report. |
| 71 | + |
| 72 | +**Migration.** Additive. `DatasourceSummary.status` gains a `'blocked'` member: a |
| 73 | +consumer exhaustively switching on it needs a case (the admin UI shows it as a |
| 74 | +distinct state). Nothing that was `'ok'` or `'error'` changes meaning; rows that |
| 75 | +were reported `'unvalidated'` now report their real state. Query-time errors for a |
| 76 | +datasource the connection layer recorded change from a generic `Error` to |
| 77 | +`DatasourceUnavailableError` (503 instead of the previous catch-all status); |
| 78 | +matching on the old `is not registered` text still works for the undeclared-name |
| 79 | +case, which is the only one that was ever accurate. |
0 commit comments