Skip to content

Commit 48c110e

Browse files
authored
feat(datasource): a datasource that is down is visible, and says why when queried (#3827, #3828) (#3836)
#3816 made an explicitly-bound datasource that cannot connect refuse the boot. Two gaps survived it, in the cases that still boot — a policy denial, an `autoConnect` datasource, or a failure waved through with OS_ALLOW_DRIVER_CONNECT_FAILURE: the datasource was invisible (`DatasourceSummary.status` was a hardcoded 'unvalidated'; `checkDriversHealth()` cannot see a driver that was never registered), and the query-time error said only "is not registered" for four different situations. Both from one root: `connect()` produced a ConnectResult per attempt and every caller threw it away. - Retain the last verdict per datasource (available / blocked / failed / unattempted); `getConnectionState`, `listConnectionStates`. - `DatasourceSummary.status`: ok | error | blocked | unvalidated, + statusReason. - New DatasourceUnavailableError (ERR_DATASOURCE_UNAVAILABLE, HTTP 503) naming which situation it is. An undeclared name keeps the original message. - Privileged/public split: the error never carries the underlying cause; DatasourceConnectDecision gains an opt-in `publicReason` for tenant-facing text. - Readiness stays ungated on this. Also lands the #3826 drift guard (degraded-boot-parity.test.ts) and corrects ADR-0062's status: D1 is partially implemented, not complete.
1 parent 6633337 commit 48c110e

21 files changed

Lines changed: 1189 additions & 22 deletions
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
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.

content/docs/api/error-catalog.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ ObjectStack uses a structured error system with **9 error categories** and **51
1313
</Callout>
1414

1515
<Callout type="warn">
16-
**Spec vs. wire format:** The codes below are `StandardErrorCode` — the standardized, lowercase snake_case contract that plugin/hook authors should throw with (see [Server-Side Error Handling](/docs/api/error-handling-server)). The kernel REST server (`@objectstack/rest`) that serves `/api/v1/data/*` today emits a flatter envelope with SCREAMING_SNAKE_CASE codes instead — e.g. `VALIDATION_FAILED`, `PERMISSION_DENIED`, `RECORD_NOT_FOUND`, `CONCURRENT_UPDATE` — not the codes in this catalog. See the [API Overview](/docs/api#error-handling) for both wire formats in use and [Wire Format](/docs/api/wire-format#7-error-response-format) for JSON examples.
16+
**Spec vs. wire format:** The codes below are `StandardErrorCode` — the standardized, lowercase snake_case contract that plugin/hook authors should throw with (see [Server-Side Error Handling](/docs/api/error-handling-server)). The kernel REST server (`@objectstack/rest`) that serves `/api/v1/data/*` today emits a flatter envelope with SCREAMING_SNAKE_CASE codes instead — e.g. `VALIDATION_FAILED`, `PERMISSION_DENIED`, `RECORD_NOT_FOUND`, `CONCURRENT_UPDATE`, `ERR_DATASOURCE_UNAVAILABLE` — not the codes in this catalog. See the [API Overview](/docs/api#error-handling) for both wire formats in use and [Wire Format](/docs/api/wire-format#7-error-response-format) for JSON examples.
1717
</Callout>
1818

1919
---

content/docs/api/wire-format.mdx

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -380,6 +380,28 @@ Returned when an `If-Match` / `expectedVersion` token no longer matches the stor
380380
}
381381
```
382382

383+
### Datasource Unavailable — `503 Service Unavailable`
384+
385+
Returned when the object's declared `datasource` has no live driver: the host's
386+
connect policy refused it, or it failed to connect at startup and the server was
387+
started with `OS_ALLOW_DRIVER_CONNECT_FAILURE`. Nothing about the request is
388+
wrong, and the state may clear — so this is a `503`, not a `400` or a `500`.
389+
390+
`reason` is the class (`blocked` | `failed`). The underlying cause is
391+
deliberately **not** included — it routinely contains hosts, ports and DSNs; it
392+
stays in the server logs and **Setup → Datasources**. See
393+
[External Datasources](/docs/data-modeling/external-datasources#what-a-query-against-an-unusable-datasource-says).
394+
395+
```json
396+
{
397+
"error": "[ObjectQL] Datasource 'analytics' configured for object 'visit' is declared but not connected: the host's datasource connect policy refused it. See the startup logs or Setup → Datasources for the cause.",
398+
"code": "ERR_DATASOURCE_UNAVAILABLE",
399+
"datasource": "analytics",
400+
"reason": "blocked",
401+
"object": "visit"
402+
}
403+
```
404+
383405
<Callout type="info">
384406
**Error Codes:** See the [Error Catalog](/docs/api/error-catalog) for the complete list of error codes and their meanings.
385407
</Callout>

content/docs/data-modeling/external-datasources.mdx

Lines changed: 58 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -149,11 +149,66 @@ one failed start reports *all* the misconfigured ones.
149149
[engine-level driver-connect guard](/docs/data-modeling/drivers#startup-a-driver-that-cannot-connect-aborts-the-boot)
150150
in an explicitly degraded state announced by a `DEGRADED BOOT` banner. The
151151
datasource stays unconnected for the process lifetime: nothing re-runs the
152-
connect, so queries against its objects keep failing with
153-
`Datasource 'x' is not registered` even after the database comes back. Do not set
154-
it in production.
152+
connect, so queries against its objects keep failing until the server is
153+
restarted. Do not set it in production.
155154
</Callout>
156155

156+
### Seeing the state of a datasource
157+
158+
Every connect attempt's verdict is retained, so a datasource that is down no
159+
longer has to be diagnosed by restarting the server and re-reading boot logs
160+
([#3827](https://github.com/objectstack-ai/objectstack/issues/3827)).
161+
162+
**Setup → Datasources** and `GET /api/v1/datasources` report a `status` per
163+
datasource:
164+
165+
| `status` | Meaning |
166+
|:---|:---|
167+
| `ok` | A live driver is registered and routable. |
168+
| `error` | A connect was attempted and failed. `statusReason` carries the cause. |
169+
| `blocked` | The host's connect policy refused it — a decision, not a fault. It will not clear on its own. |
170+
| `unvalidated` | No connect attempted: a `managed` datasource left metadata-only by the gate above, or a runtime row nobody has tested. |
171+
172+
`statusReason` is **operator-facing** and may name hosts, ports, or internal
173+
plans — this surface is already admin-gated.
174+
175+
Note that `checkDriversHealth()` (and therefore `/ready`) cannot see these: a
176+
datasource that never connected was never registered as a driver, so it is
177+
*absent* from the health probe rather than reported unhealthy. Readiness is
178+
deliberately **not** gated on them — an optional datasource being down must not
179+
pull an otherwise-working replica out of the load balancer.
180+
181+
### What a query against an unusable datasource says
182+
183+
An object bound to a datasource with no live driver fails with
184+
`ERR_DATASOURCE_UNAVAILABLE` (HTTP **503**), and the message says which situation
185+
it is ([#3828](https://github.com/objectstack-ai/objectstack/issues/3828)):
186+
refused by policy, or a connect that failed under
187+
`OS_ALLOW_DRIVER_CONNECT_FAILURE`. A datasource name that was never declared
188+
still gets the original `is not registered` — that one really is an authoring
189+
bug, and there is nothing to add.
190+
191+
The error **never carries the underlying cause**: connect errors routinely
192+
contain hosts, ports and DSNs, and a policy's `reason` is written for operators.
193+
Both stay in the logs and the admin list. A host that wants to tell tenants
194+
something specific sets `publicReason` on its connect decision — opt-in, and the
195+
only string that reaches an end user:
196+
197+
```typescript
198+
const policy: DatasourceConnectPolicy = {
199+
canConnect: (ds) =>
200+
allowedFor(tenant, ds)
201+
? { allow: true }
202+
: {
203+
allow: false,
204+
// operator-facing: logs + Setup → Datasources only
205+
reason: `egress allow-list miss for ${ds.name} (${tenant.id}, plan=${tenant.plan})`,
206+
// tenant-facing: appended to the query-time error
207+
publicReason: 'External datasources require the Scale plan.',
208+
},
209+
};
210+
```
211+
157212
## 4. Credentials
158213

159214
Never inline a password. Put a reference in `external.credentialsRef` and store the

docs/adr/0062-external-datasource-runtime.md

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# ADR-0062: External Datasource Runtime — connection lifecycle, credentials, visibility & query completeness
22

3-
**Status**: Accepted (2026-06-22) — D1–D8 implemented (`service-datasource` connection service + opt-in-safe gate + fail-closed connect policy; native-SQL declines external per D6; D7 lint in `validate-expressions.ts`).
3+
**Status**: Accepted (2026-06-22) — D2–D8 implemented (`service-datasource` connection service + opt-in-safe gate + fail-closed connect policy; native-SQL declines external per D6; D7 lint in `validate-expressions.ts`). **D1 partially implemented**: declared datasources auto-connect through the one service, but the `default` driver still has its own connect + failure path — see the status correction under D1 (#3826).
44

55
**Supersedes the runtime portions of**: ADR-0015 §18 addendum (kept as the historical record). ADR-0015 remains the canonical spec/binding decision; this ADR is the canonical *runtime* decision.
66

@@ -53,6 +53,21 @@ R2/R3/R8 are the same change seen from three angles — you cannot "auto-connect
5353

5454
Introduce a single service that, given a datasource definition, builds a driver via the **injected** driver factory (the same `createDefaultDatasourceDriverFactory` used by the runtime-admin path), connects it, and registers it into the ObjectQL engine under the datasource name. `app-plugin` calls it for every declared datasource (in addition to today's `registerInMemory` for visibility); `standalone-stack`'s single-`default`-driver bootstrap is refactored to go through the same service so there is exactly one "definition → live driver" code path. `engine.registerDriver` remains the sink. The `onEnable` bridge becomes unnecessary for the common case (kept as an escape hatch — D8).
5555

56+
> **Status correction (#3826) — the `default` refactor is NOT done; the "exactly one code path" claim is half-true.** The *construction* half converged: `standalone-stack` builds the `default` driver through `createDefaultDatasourceDriverFactory` (`standalone-stack.ts`, "the SAME `create({driver,config})` used for declared/runtime datasources"). The *connect + failure-verdict* half did not, and this ADR's header has been claiming D1 as implemented while two independent implementations coexist:
57+
>
58+
> | | `default` | declared datasource |
59+
> |:---|:---|:---|
60+
> | build | shared factory ✅ | shared factory ✅ |
61+
> | register | `DriverPlugin` → a `driver.*` kernel service, discovered in `ObjectQLPlugin.start()` | `engine.registerDriver()` inside `connect()` |
62+
> | connect | `ObjectQLEngine.init()` | `DatasourceConnectionService.connect()` |
63+
> | failure verdict | `DriverConnectError`, aggregate (#3741) | `handleFailure()` (#3758) |
64+
> | pool teardown | kernel shutdown via `DriverPlugin` | `DatasourceConnectionService.disconnect()` |
65+
> | connect policy | not consulted | `DatasourceConnectPolicy` |
66+
>
67+
> **What actually blocks the merge is an input-shape mismatch, not ordering.** The kernel's init-all-then-start-all means the connection service *does* exist by `ObjectQLPlugin.start()`, so timing is available. The obstacles are: (1) `DatasourceConnectionService.connect()` takes a datasource *definition* and **builds** the driver, while `default` arrives as an already-constructed driver instance published as a `driver.*` kernel service — there is no "adopt this driver" entry point; and (2) routing `default` through the service would make `ObjectQLPlugin`'s boot depend on an **optional service from a higher layer** (`service-datasource`), inverting the layering for the one driver every app needs. Closing this means either adding an `adoptDriver()` seam to the connection service, or making the standalone `default` a real declared datasource definition — a design decision, not a mechanical move, and still the riskiest single step per §Risk.
68+
>
69+
> Until then the divergence is guarded rather than assumed: `packages/runtime/src/degraded-boot-parity.test.ts` pins both paths to the same operator-visible contract (fail-fast by default, identical `OS_ALLOW_DRIVER_CONNECT_FAILURE` parsing, `DEGRADED BOOT` on stderr), so a change to one that forgets the other fails CI instead of shipping. #3741#3758 was exactly that miss, and it cost three months and a second bug report.
70+
5671
### D2 — Connect is opt-in-safe: existing managed apps are byte-for-byte unchanged
5772

5873
Auto-connect must not change apps that today declare datasources that are *decorative* or routed via `datasourceMapping` (e.g. `examples/app-crm`'s `crm_primary`/`crm_analytics`). Gate auto-connect so a declared datasource is only connected when it is meaningfully addressed: **(a)** it is `external` (`schemaMode !== 'managed'`), or **(b)** an object/`datasourceMapping` actually routes to it, or **(c)** it sets an explicit `autoConnect: true`. A managed datasource that nothing routes to stays metadata-only (today's behavior). The `default` datasource keeps its current dedicated bootstrap. This is the load-bearing backward-compat decision.

packages/objectql/src/core.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,13 @@ export type { ObjectQLHostContext, HookHandler, HookEntry, OperationContext, Eng
4242
// Boot guard: thrown by `ObjectQL.init()` when a registered driver's connect()
4343
// fails (framework#3741). Embedders that boot the engine themselves can catch
4444
// it to render their own "database unreachable" message.
45-
export { DriverConnectError } from './driver-connect-errors.js';
46-
export type { DriverConnectFailure, DriverHealth } from './driver-connect-errors.js';
45+
export { DriverConnectError, DatasourceUnavailableError } from './driver-connect-errors.js';
46+
export type {
47+
DriverConnectFailure,
48+
DriverHealth,
49+
DatasourceUnavailableInfo,
50+
DatasourceUnavailableKind,
51+
} from './driver-connect-errors.js';
4752

4853
// In-memory aggregation fallback
4954
export { applyInMemoryAggregation, bucketDateValue } from './in-memory-aggregation.js';

0 commit comments

Comments
 (0)