Skip to content

Commit 91093cc

Browse files
claude[bot]claude
andauthored
feat(objectql): publish DriverConnect, DatasourceUnavailable and SummaryRecompute error codes as constants (#16308)
* feat(objectql): publish DriverConnectError and DatasourceUnavailableError codes as constants Both classes' own docblocks already say the refusal is "Identified by `code` rather than `instanceof` so it survives crossing package boundaries", and neither offered anything to import. Following that published instruction meant re-spelling the wire string in the consumer's own package -- a `check:error-code-provenance` stamp site there, free to drift from what this engine throws with no compile error to say so. `packages/rest/src/error-response.ts` does exactly that for the datasource refusal today. `DRIVER_CONNECT_CODE` and `DATASOURCE_UNAVAILABLE_CODE` are new exports from `@objectstack/objectql`, re-exported from `index.ts` beside the classes they name. Dropping the `ERR_` prefix from the constants' NAMES follows this package's existing precedents (`HOOK_TARGET_REBIND_ERROR_CODE`, `READONLY_FIELD_REJECTED_CODE`). Both strings are byte-identical to the literals they replace: each quoted spelling occurs exactly once in the file on both sides of the change -- it moved, it did not multiply or mutate. Both classes are ALSO published from the lean `./core` entry while the constants, like every other `*_CODE` here, are batteries-only. That asymmetry is #16260's subject for the whole family and is deliberately not decided by this mechanical conversion. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ * feat(objectql): publish SummaryRecomputeError's code as a constant `SummaryRecomputeError`'s docblock already says it is "Identified by `code` rather than `instanceof` so it survives crossing package boundaries", and offered nothing to import. This row's cost is the batch's most concrete: TWO first-party packages already re-spell the wire string, and both do it to implement the very recovery the class was designed for -- "the triggering records WERE written, so treat a failed roll-up as a warning and keep them": `packages/rest/src/import-runner.ts` and `packages/metadata-protocol/src/seed-loader.ts`. Three spellings of one code across three packages, kept equal by nothing but a grep. `SUMMARY_RECOMPUTE_CODE` is a new export from `@objectstack/objectql`, re-exported from `index.ts` beside the class, which is where that class is already published. Naming and field spelling follow this package's precedents. The string is byte-identical to the literal it replaces: the quoted spelling occurs exactly once in the file on both sides of the change. This commit does NOT rewire the two consumers named above -- that is a consumer-side change in two other packages, outside a producer-side sweep, and no gate asks for it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ * chore(changeset): minor for the three objectql error-code constants One changeset, three graded entries -- batching these rows into one PR changed how many PRs the sweep costs, not how each row is graded. Each of `DRIVER_CONNECT_CODE`, `DATASOURCE_UNAVAILABLE_CODE` and `SUMMARY_RECOMPUTE_CODE` is additive widening of a published surface with nothing removed, which is `minor` on its own account. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ * fix(objectql): keep each error class documented in the emitted .d.ts Each of the three new `*_CODE` constants sat BETWEEN its class's docblock and the class. Two consecutive JSDoc blocks both attach to the declaration that follows, so the emitted declarations carried both blocks on the CONSTANT and `declare class ...Error` shipped undocumented — measured on the package's own `tsup` emitter, not inferred. Move each constant and its own docblock ABOVE the class docblock, the grouped shape `registry.ts` already uses. Pure line reordering: the sorted line multiset of both files is byte-identical to the previous commit's, and the exported name set of every declaration file `files[]` publishes is unchanged. Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7d356a6 commit 91093cc

7 files changed

Lines changed: 434 additions & 5 deletions
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
"@objectstack/objectql": minor
3+
---
4+
5+
Three more engine refusals publish their error `code` as an importable constant.
6+
7+
Each of these classes already tells the reader, in its own docblock, that it is *"Identified by `code` rather than `instanceof` so it survives crossing package boundaries"* — and none of them offered anything to import. The only way to FOLLOW that published instruction was to re-spell the wire string in your own package, which acquires a `check:error-code-provenance` stamp site there and can then drift from what the engine throws with no compile error to say so.
8+
9+
Three new exports from `@objectstack/objectql`, each graded on its own:
10+
11+
- `DRIVER_CONNECT_CODE``DriverConnectError`'s ADR-0112 `code`. Thrown by `ObjectQL.init()` when boot-registered drivers fail to connect, which aborts kernel bootstrap. **Additive widening, `minor`.**
12+
- `DATASOURCE_UNAVAILABLE_CODE``DatasourceUnavailableError`'s ADR-0112 `code`. Thrown by `getDriver()` when an object's datasource was declared but has no live driver. **Additive widening, `minor`.**
13+
- `SUMMARY_RECOMPUTE_CODE``SummaryRecomputeError`'s ADR-0112 `code`. Thrown by `insert`/`update`/`delete` when parent roll-up summaries fail to recompute *after the triggering records were written*. **Additive widening, `minor`.**
14+
15+
**The cost these close is already shipped, not hypothetical.** Three first-party packages in this repo match these refusals by `code` today and therefore carry a second spelling of the string: `packages/rest/src/error-response.ts` (datasource-unavailable), `packages/rest/src/import-runner.ts` and `packages/metadata-protocol/src/seed-loader.ts` (summary-recompute — both to implement the documented "the records WERE written, treat it as a warning" recovery). They keep working unchanged; they can now import the constant instead of authoring the string.
16+
17+
**Why `code` and not `instanceof`.** This package declares both realms in its own `exports` (`import` reaches `dist/index.mjs`, `require` reaches `dist/index.js`), so a consumer holding the other realm's copy of a class gets `instanceof` === false — measured, and silent. A `code` compare is the check that survives that boundary, which is what these docblocks have been telling readers to do.
18+
19+
**Nothing about the wire changed.** Each constant holds text byte-identical to the literal it replaces; every refusal throws the same `code` and the same message as before. Consumers that spell the strings themselves keep working unchanged — this adds affordances, it removes nothing.
20+
21+
**All three classes were already exported and stay exported.** The constants join them on the batteries barrel; like every other `*_CODE` in this package they are deliberately not added to the lean `core.ts` entry, even though `DriverConnectError` and `DatasourceUnavailableError` themselves are published there. That asymmetry is #16260's subject for the whole family and is not decided here.
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #16159 row 2 of 3 in this batch — `DatasourceUnavailableError` publishes its
5+
* ADR-0112 `code` as an importable constant.
6+
*
7+
* ## Why this row has the strongest evidence in the batch
8+
*
9+
* It has a LIVE first-party consumer doing precisely what the card describes:
10+
* `packages/rest/src/error-response.ts` matches this refusal by `code` and then
11+
* re-authors the same spelling into the response envelope it builds. That is
12+
* two spellings of one refusal, in two packages, kept equal by nothing but a
13+
* grep — and it is the ONLY option a consumer had, because the code was an
14+
* inline literal with nothing to import.
15+
*
16+
* ⚠️ This refusal carries NO `status` field of its own (the REST door assigns
17+
* the HTTP status when it recognises the code), so ADR-0112's `code` + `status`
18+
* minimum reduces here to `code` plus the fields that discriminate the refusal.
19+
* ⛔ Inventing a `status` on the class would be new published surface, which is
20+
* not what this card converts.
21+
*
22+
* Five facts, each its own case. The reasoning behind each is spelled out in
23+
* `driver-connect-code-constant.test.ts` (same batch, same shape); the
24+
* load-bearing points repeated here are (1) the literal spelling is the
25+
* byte-identity fence and must NOT be "simplified" into a constant compare, and
26+
* (5) the cross-realm case is the control without which the whole file would
27+
* pass just as happily against an `instanceof` recommendation.
28+
*
29+
* ⭐ Case 2 drives BOTH `kind`s — `blocked` and `failed`. The MESSAGE branches
30+
* on `kind` by design while the CODE deliberately does not; pinning both is
31+
* what stops a future message split taking the code with it.
32+
*/
33+
34+
import { describe, it, expect } from 'vitest';
35+
import {
36+
DatasourceUnavailableError,
37+
DATASOURCE_UNAVAILABLE_CODE,
38+
} from './driver-connect-errors.js';
39+
import * as barrel from './index.js';
40+
41+
describe('#16159 DatasourceUnavailableError publishes its code as a constant', () => {
42+
it('the constant holds the exact wire string it replaced', () => {
43+
expect(DATASOURCE_UNAVAILABLE_CODE).toBe('ERR_DATASOURCE_UNAVAILABLE');
44+
});
45+
46+
it('the constant IS the code both kinds of refusal carry — the code does not branch on kind', () => {
47+
const blocked = new DatasourceUnavailableError('billing', 'crm_invoice', 'blocked');
48+
const failed = new DatasourceUnavailableError('billing', 'crm_invoice', 'failed');
49+
50+
expect(blocked.code).toBe(DATASOURCE_UNAVAILABLE_CODE);
51+
expect(failed.code).toBe(DATASOURCE_UNAVAILABLE_CODE);
52+
expect(blocked.name).toBe('DatasourceUnavailableError');
53+
expect(blocked.datasource).toBe('billing');
54+
expect(blocked.objectName).toBe('crm_invoice');
55+
56+
// The MESSAGE branches on `kind` by design; the CODE deliberately does not.
57+
expect(blocked.message).not.toBe(failed.message);
58+
});
59+
60+
it('it is re-exported from the package barrel, which is where a consumer reaches it', () => {
61+
// Identity, not equality: a barrel that re-declared the string instead of
62+
// re-exporting the constant would satisfy `toBe` on the VALUE while having
63+
// re-introduced the second spelling this card exists to remove.
64+
expect(barrel.DATASOURCE_UNAVAILABLE_CODE).toBe(DATASOURCE_UNAVAILABLE_CODE);
65+
});
66+
67+
it("the barrel's constant and the barrel's already-exported class name the same refusal", () => {
68+
const err = new barrel.DatasourceUnavailableError('billing', 'crm_invoice', 'failed');
69+
expect(err.code).toBe(barrel.DATASOURCE_UNAVAILABLE_CODE);
70+
});
71+
72+
it("a `code` compare matches the OTHER realm's copy — the exact case `instanceof` gets wrong", () => {
73+
class DatasourceUnavailableErrorOtherRealmCopy extends Error {
74+
readonly code = 'ERR_DATASOURCE_UNAVAILABLE';
75+
}
76+
const fromOtherRealm = new DatasourceUnavailableErrorOtherRealmCopy();
77+
78+
// THE CONTROL — see the header.
79+
expect(fromOtherRealm instanceof DatasourceUnavailableError).toBe(false);
80+
expect(fromOtherRealm.code).toBe(DATASOURCE_UNAVAILABLE_CODE);
81+
});
82+
});
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #16159 row 1 of 3 in this batch — `DriverConnectError` publishes its ADR-0112
5+
* `code` as an importable constant.
6+
*
7+
* ## What this pins, and why each assertion is here
8+
*
9+
* `DriverConnectError`'s own docblock says it is "Identified by `code` rather
10+
* than `instanceof` so it survives crossing package boundaries", and until this
11+
* change offered nothing to import. Following that published instruction meant
12+
* RE-SPELLING the wire string in the consumer's own package, which acquires a
13+
* `check:error-code-provenance` stamp site there and can then drift from what
14+
* this engine throws with no compile error to say so.
15+
*
16+
* ⚠️ This refusal carries NO `status` field, so ADR-0112's `code` + `status`
17+
* minimum reduces here to `code` plus the fields that discriminate the refusal.
18+
* ⛔ Inventing a `status` to satisfy a habit would be new published surface, and
19+
* that is not what this card converts.
20+
*
21+
* Five facts, each its own case so a failure reads as the specific regression:
22+
*
23+
* 1. the constant holds the exact wire string, spelled LITERALLY here on
24+
* purpose. The test layer sits outside `check:error-code-provenance`'s
25+
* scanned population, so pinning it costs no stamp site while making a
26+
* silent rename of a published code impossible to pass off as "still the
27+
* same code". ⛔ This is the byte-identity fence — the conversion moves
28+
* where a spelling lives, never what it says. Slice 1 (#16259) measured
29+
* that mutating a constant's VALUE turns ONLY a case of this shape red,
30+
* because every other case compares AGAINST the constant. ⛔ Do not
31+
* "simplify" it into a constant compare; a pin that reads the constant
32+
* cannot catch the constant being wrong.
33+
* 2. the constant IS the code a real refusal carries, asserted with `name`
34+
* and with the failure detail the class exists to report. ⛔ Never a bare
35+
* `toThrow()`: this package's own history is the argument — a
36+
* throw-shaped assertion stays green when a DIFFERENT refusal fires one
37+
* step later, which is exactly the confusion `code` is meant to end.
38+
* 3. it is reachable from the package BARREL, which is the whole affordance
39+
* this card buys — a constant a consumer cannot import is not an answer to
40+
* "identify it by `code`" — and it is what a future barrel edit would lose
41+
* silently.
42+
* 4. the barrel's constant and the barrel's already-exported class name the
43+
* same refusal. Both routes are published here, so a consumer can hold
44+
* either and they must agree.
45+
* 5. a `code` compare matches a foreign-realm copy of the refusal where
46+
* `instanceof` returns false. THE CONTROL, and the reason the convention
47+
* exists (#14936): `@objectstack/objectql` declares both realms in its own
48+
* `exports`, so a consumer holding the other realm's copy of the class
49+
* gets `instanceof` === false, silently. Without this case the others
50+
* would pass just as happily against an `instanceof`-based
51+
* recommendation — the thing the docblock tells readers NOT to use.
52+
*/
53+
54+
import { describe, it, expect } from 'vitest';
55+
import { DriverConnectError, DRIVER_CONNECT_CODE } from './driver-connect-errors.js';
56+
import * as barrel from './index.js';
57+
58+
describe('#16159 DriverConnectError publishes its code as a constant', () => {
59+
it('the constant holds the exact wire string it replaced', () => {
60+
expect(DRIVER_CONNECT_CODE).toBe('ERR_DRIVER_CONNECT');
61+
});
62+
63+
it('the constant IS the code a boot-abort refusal carries', () => {
64+
const err = new DriverConnectError(
65+
[{ driverName: 'default', error: new Error('ECONNREFUSED 127.0.0.1:5432') }],
66+
2,
67+
);
68+
expect(err.code).toBe(DRIVER_CONNECT_CODE);
69+
expect(err.name).toBe('DriverConnectError');
70+
// The refusal's payload is part of what a `code` match buys a caller: it
71+
// names every driver that failed, which is why the CLI can print
72+
// `error.message` alone.
73+
expect(err.failedDrivers).toEqual(['default']);
74+
expect(err.message).toContain('1 of 2 data driver(s) failed to connect');
75+
});
76+
77+
it('it is re-exported from the package barrel, which is where a consumer reaches it', () => {
78+
// Identity, not equality: a barrel that re-declared the string instead of
79+
// re-exporting the constant would satisfy `toBe` on the VALUE while having
80+
// re-introduced exactly the second spelling this card exists to remove.
81+
expect(barrel.DRIVER_CONNECT_CODE).toBe(DRIVER_CONNECT_CODE);
82+
});
83+
84+
it("the barrel's constant and the barrel's already-exported class name the same refusal", () => {
85+
const err = new barrel.DriverConnectError([{ driverName: 'reporting', error: 'timeout' }], 1);
86+
expect(err.code).toBe(barrel.DRIVER_CONNECT_CODE);
87+
});
88+
89+
it("a `code` compare matches the OTHER realm's copy — the exact case `instanceof` gets wrong", () => {
90+
// What a consumer holding the other realm's copy of this module actually
91+
// has: a structurally identical refusal from a DIFFERENT class object.
92+
class DriverConnectErrorOtherRealmCopy extends Error {
93+
readonly code = 'ERR_DRIVER_CONNECT';
94+
}
95+
const fromOtherRealm = new DriverConnectErrorOtherRealmCopy();
96+
97+
// THE CONTROL. Without this line the assertion below would pass against an
98+
// `instanceof` recommendation too, i.e. against the defect the convention
99+
// exists to avoid.
100+
expect(fromOtherRealm instanceof DriverConnectError).toBe(false);
101+
expect(fromOtherRealm.code).toBe(DRIVER_CONNECT_CODE);
102+
});
103+
});

packages/objectql/src/driver-connect-errors.ts

Lines changed: 71 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,49 @@ function failureMessage(error: unknown): string {
7373
return String(error);
7474
}
7575

76+
/**
77+
* [#16159] The ADR-0112 `code` {@link DriverConnectError} carries, as a
78+
* constant a consumer can import instead of re-spelling.
79+
*
80+
* The docblock below already says this refusal is "Identified by `code` rather
81+
* than `instanceof` so it survives crossing package boundaries" — and until now
82+
* offered nothing to import, so the only way to FOLLOW that instruction was to
83+
* re-author the wire string in the consumer's own package. That acquires a
84+
* `check:error-code-provenance` stamp site there and is then free to drift from
85+
* what this engine throws, with no compile error to say so. The guidance and
86+
* the surface disagreed; this closes that half of #16159's table.
87+
*
88+
* ⛔ The string is byte-identical to the literal it replaces. This moves where a
89+
* spelling lives, never what it says; renaming the code is a separate breaking
90+
* decision and never a rider on this conversion.
91+
*
92+
* ⚠️ `ERR_DRIVER_CONNECT` IS registered in `ERROR_CODE_LEDGER` under
93+
* `@objectstack/objectql`, so this declaration is a `constdef` stamp site
94+
* `check:error-code-provenance` DOES see (that gate skips unregistered codes),
95+
* and it is listed under this package's own owner key, which is what makes the
96+
* gate accept it. Equally, no row moves in
97+
* `packages/runtime/src/dispatcher-error-vocabulary.ts`: that table records
98+
* UNREGISTERED code sites, so a registered code is invisible to it by
99+
* construction. The two gates are exactly inverted — measured on this branch,
100+
* not assumed.
101+
*
102+
* The `_CODE` NAME and the bare `readonly code = DRIVER_CONNECT_CODE;`
103+
* spelling are load-bearing rather than cosmetic: the first is the shape
104+
* `check:error-code-provenance`'s `constdef` pattern can see, the second is the
105+
* shape `check:dispatcher-error-vocabulary` classifies as `classconst` (an
106+
* `as const` suffix on the FIELD would take it out of that pattern). ⛔ Never
107+
* rename out of either shape to quiet a gate.
108+
*
109+
* Dropping the `ERR_` prefix from the CONSTANT's name follows this package's
110+
* existing precedents (`HOOK_TARGET_REBIND_ERROR_CODE`,
111+
* `READONLY_FIELD_REJECTED_CODE`). Re-exported from the `index.ts` barrel and,
112+
* like every other `*_CODE` here, NOT from the lean `core.ts` entry — even
113+
* though `DriverConnectError` itself IS on `core.ts`. That asymmetry is real,
114+
* it is #16260's subject for the whole family, and ⛔ this mechanical sweep does
115+
* not decide it.
116+
*/
117+
export const DRIVER_CONNECT_CODE = 'ERR_DRIVER_CONNECT' as const;
118+
76119
/**
77120
* Thrown by `ObjectQL.init()` when one or more boot-registered drivers fail to
78121
* connect (framework#3741). Aborts kernel bootstrap: a server that cannot reach
@@ -96,7 +139,7 @@ function failureMessage(error: unknown): string {
96139
* boundaries.
97140
*/
98141
export class DriverConnectError extends Error {
99-
readonly code = 'ERR_DRIVER_CONNECT' as const;
142+
readonly code = DRIVER_CONNECT_CODE;
100143

101144
/**
102145
* The first failure's `Error`, so its stack stays reachable for `DEBUG`
@@ -145,6 +188,32 @@ export interface DatasourceUnavailableInfo {
145188
publicDetail?: string;
146189
}
147190

191+
/**
192+
* [#16159] The ADR-0112 `code` {@link DatasourceUnavailableError} carries, as a
193+
* constant a consumer can import instead of re-spelling.
194+
*
195+
* This row has a live first-party consumer making the card's argument for it:
196+
* `packages/rest/src/error-response.ts` matches this refusal by `code` and then
197+
* re-authors the same spelling into the response envelope it builds — two
198+
* spellings of one refusal, in two packages, with nothing but a grep keeping
199+
* them equal.
200+
*
201+
* ⛔ The string is byte-identical to the literal it replaces — the conversion
202+
* moves where a spelling lives, never what it says.
203+
*
204+
* ⚠️ `ERR_DATASOURCE_UNAVAILABLE` is registered in `ERROR_CODE_LEDGER` under
205+
* BOTH `@objectstack/objectql` and the datasource-service owner key (it is one
206+
* refusal raised from two sides), so this declaration is a `constdef` stamp
207+
* site `check:error-code-provenance` sees and accepts under this package's own
208+
* key, while `check:dispatcher-error-vocabulary` stays blind to it by
209+
* construction. See the note on {@link DRIVER_CONNECT_CODE} for why those two
210+
* gates answer oppositely.
211+
*
212+
* Naming, field spelling and barrel placement follow {@link DRIVER_CONNECT_CODE}
213+
* exactly, including the `core.ts` asymmetry #16260 owns.
214+
*/
215+
export const DATASOURCE_UNAVAILABLE_CODE = 'ERR_DATASOURCE_UNAVAILABLE' as const;
216+
148217
/**
149218
* Thrown by `getDriver()` when an object's `datasource` was **declared** but has
150219
* no live driver, and the connection layer knows why (framework#3828).
@@ -171,7 +240,7 @@ export interface DatasourceUnavailableInfo {
171240
* specific sets `publicReason` on its connect decision.
172241
*/
173242
export class DatasourceUnavailableError extends Error {
174-
readonly code = 'ERR_DATASOURCE_UNAVAILABLE' as const;
243+
readonly code = DATASOURCE_UNAVAILABLE_CODE;
175244

176245
constructor(
177246
public readonly datasource: string,

0 commit comments

Comments
 (0)