From 3fd854f33599a54501cd52c991e47623c3010c47 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 12:41:02 +0000 Subject: [PATCH 1/4] 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 Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ --- ...tasource-unavailable-code-constant.test.ts | 82 ++++++++++++++ .../src/driver-connect-code-constant.test.ts | 103 ++++++++++++++++++ .../objectql/src/driver-connect-errors.ts | 73 ++++++++++++- packages/objectql/src/index.ts | 17 ++- 4 files changed, 272 insertions(+), 3 deletions(-) create mode 100644 packages/objectql/src/datasource-unavailable-code-constant.test.ts create mode 100644 packages/objectql/src/driver-connect-code-constant.test.ts diff --git a/packages/objectql/src/datasource-unavailable-code-constant.test.ts b/packages/objectql/src/datasource-unavailable-code-constant.test.ts new file mode 100644 index 0000000000..d8b470e065 --- /dev/null +++ b/packages/objectql/src/datasource-unavailable-code-constant.test.ts @@ -0,0 +1,82 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #16159 row 2 of 3 in this batch — `DatasourceUnavailableError` publishes its + * ADR-0112 `code` as an importable constant. + * + * ## Why this row has the strongest evidence in the batch + * + * It has a LIVE first-party consumer doing precisely what the card describes: + * `packages/rest/src/error-response.ts` matches this refusal by `code` and then + * re-authors the same spelling into the response envelope it builds. That is + * two spellings of one refusal, in two packages, kept equal by nothing but a + * grep — and it is the ONLY option a consumer had, because the code was an + * inline literal with nothing to import. + * + * ⚠️ This refusal carries NO `status` field of its own (the REST door assigns + * the HTTP status when it recognises the code), so ADR-0112's `code` + `status` + * minimum reduces here to `code` plus the fields that discriminate the refusal. + * ⛔ Inventing a `status` on the class would be new published surface, which is + * not what this card converts. + * + * Five facts, each its own case. The reasoning behind each is spelled out in + * `driver-connect-code-constant.test.ts` (same batch, same shape); the + * load-bearing points repeated here are (1) the literal spelling is the + * byte-identity fence and must NOT be "simplified" into a constant compare, and + * (5) the cross-realm case is the control without which the whole file would + * pass just as happily against an `instanceof` recommendation. + * + * ⭐ Case 2 drives BOTH `kind`s — `blocked` and `failed`. The MESSAGE branches + * on `kind` by design while the CODE deliberately does not; pinning both is + * what stops a future message split taking the code with it. + */ + +import { describe, it, expect } from 'vitest'; +import { + DatasourceUnavailableError, + DATASOURCE_UNAVAILABLE_CODE, +} from './driver-connect-errors.js'; +import * as barrel from './index.js'; + +describe('#16159 DatasourceUnavailableError publishes its code as a constant', () => { + it('the constant holds the exact wire string it replaced', () => { + expect(DATASOURCE_UNAVAILABLE_CODE).toBe('ERR_DATASOURCE_UNAVAILABLE'); + }); + + it('the constant IS the code both kinds of refusal carry — the code does not branch on kind', () => { + const blocked = new DatasourceUnavailableError('billing', 'crm_invoice', 'blocked'); + const failed = new DatasourceUnavailableError('billing', 'crm_invoice', 'failed'); + + expect(blocked.code).toBe(DATASOURCE_UNAVAILABLE_CODE); + expect(failed.code).toBe(DATASOURCE_UNAVAILABLE_CODE); + expect(blocked.name).toBe('DatasourceUnavailableError'); + expect(blocked.datasource).toBe('billing'); + expect(blocked.objectName).toBe('crm_invoice'); + + // The MESSAGE branches on `kind` by design; the CODE deliberately does not. + expect(blocked.message).not.toBe(failed.message); + }); + + it('it is re-exported from the package barrel, which is where a consumer reaches it', () => { + // Identity, not equality: a barrel that re-declared the string instead of + // re-exporting the constant would satisfy `toBe` on the VALUE while having + // re-introduced the second spelling this card exists to remove. + expect(barrel.DATASOURCE_UNAVAILABLE_CODE).toBe(DATASOURCE_UNAVAILABLE_CODE); + }); + + it("the barrel's constant and the barrel's already-exported class name the same refusal", () => { + const err = new barrel.DatasourceUnavailableError('billing', 'crm_invoice', 'failed'); + expect(err.code).toBe(barrel.DATASOURCE_UNAVAILABLE_CODE); + }); + + it("a `code` compare matches the OTHER realm's copy — the exact case `instanceof` gets wrong", () => { + class DatasourceUnavailableErrorOtherRealmCopy extends Error { + readonly code = 'ERR_DATASOURCE_UNAVAILABLE'; + } + const fromOtherRealm = new DatasourceUnavailableErrorOtherRealmCopy(); + + // THE CONTROL — see the header. + expect(fromOtherRealm instanceof DatasourceUnavailableError).toBe(false); + expect(fromOtherRealm.code).toBe(DATASOURCE_UNAVAILABLE_CODE); + }); +}); diff --git a/packages/objectql/src/driver-connect-code-constant.test.ts b/packages/objectql/src/driver-connect-code-constant.test.ts new file mode 100644 index 0000000000..c37fb1a1ee --- /dev/null +++ b/packages/objectql/src/driver-connect-code-constant.test.ts @@ -0,0 +1,103 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #16159 row 1 of 3 in this batch — `DriverConnectError` publishes its ADR-0112 + * `code` as an importable constant. + * + * ## What this pins, and why each assertion is here + * + * `DriverConnectError`'s own docblock says it is "Identified by `code` rather + * than `instanceof` so it survives crossing package boundaries", and until this + * change offered nothing to import. Following that published instruction meant + * RE-SPELLING the wire string in the consumer's own package, which acquires a + * `check:error-code-provenance` stamp site there and can then drift from what + * this engine throws with no compile error to say so. + * + * ⚠️ This refusal carries NO `status` field, so ADR-0112's `code` + `status` + * minimum reduces here to `code` plus the fields that discriminate the refusal. + * ⛔ Inventing a `status` to satisfy a habit would be new published surface, and + * that is not what this card converts. + * + * Five facts, each its own case so a failure reads as the specific regression: + * + * 1. the constant holds the exact wire string, spelled LITERALLY here on + * purpose. The test layer sits outside `check:error-code-provenance`'s + * scanned population, so pinning it costs no stamp site while making a + * silent rename of a published code impossible to pass off as "still the + * same code". ⛔ This is the byte-identity fence — the conversion moves + * where a spelling lives, never what it says. Slice 1 (#16259) measured + * that mutating a constant's VALUE turns ONLY a case of this shape red, + * because every other case compares AGAINST the constant. ⛔ Do not + * "simplify" it into a constant compare; a pin that reads the constant + * cannot catch the constant being wrong. + * 2. the constant IS the code a real refusal carries, asserted with `name` + * and with the failure detail the class exists to report. ⛔ Never a bare + * `toThrow()`: this package's own history is the argument — a + * throw-shaped assertion stays green when a DIFFERENT refusal fires one + * step later, which is exactly the confusion `code` is meant to end. + * 3. it is reachable from the package BARREL, which is the whole affordance + * this card buys — a constant a consumer cannot import is not an answer to + * "identify it by `code`" — and it is what a future barrel edit would lose + * silently. + * 4. the barrel's constant and the barrel's already-exported class name the + * same refusal. Both routes are published here, so a consumer can hold + * either and they must agree. + * 5. a `code` compare matches a foreign-realm copy of the refusal where + * `instanceof` returns false. THE CONTROL, and the reason the convention + * exists (#14936): `@objectstack/objectql` declares both realms in its own + * `exports`, so a consumer holding the other realm's copy of the class + * gets `instanceof` === false, silently. Without this case the others + * would pass just as happily against an `instanceof`-based + * recommendation — the thing the docblock tells readers NOT to use. + */ + +import { describe, it, expect } from 'vitest'; +import { DriverConnectError, DRIVER_CONNECT_CODE } from './driver-connect-errors.js'; +import * as barrel from './index.js'; + +describe('#16159 DriverConnectError publishes its code as a constant', () => { + it('the constant holds the exact wire string it replaced', () => { + expect(DRIVER_CONNECT_CODE).toBe('ERR_DRIVER_CONNECT'); + }); + + it('the constant IS the code a boot-abort refusal carries', () => { + const err = new DriverConnectError( + [{ driverName: 'default', error: new Error('ECONNREFUSED 127.0.0.1:5432') }], + 2, + ); + expect(err.code).toBe(DRIVER_CONNECT_CODE); + expect(err.name).toBe('DriverConnectError'); + // The refusal's payload is part of what a `code` match buys a caller: it + // names every driver that failed, which is why the CLI can print + // `error.message` alone. + expect(err.failedDrivers).toEqual(['default']); + expect(err.message).toContain('1 of 2 data driver(s) failed to connect'); + }); + + it('it is re-exported from the package barrel, which is where a consumer reaches it', () => { + // Identity, not equality: a barrel that re-declared the string instead of + // re-exporting the constant would satisfy `toBe` on the VALUE while having + // re-introduced exactly the second spelling this card exists to remove. + expect(barrel.DRIVER_CONNECT_CODE).toBe(DRIVER_CONNECT_CODE); + }); + + it("the barrel's constant and the barrel's already-exported class name the same refusal", () => { + const err = new barrel.DriverConnectError([{ driverName: 'reporting', error: 'timeout' }], 1); + expect(err.code).toBe(barrel.DRIVER_CONNECT_CODE); + }); + + it("a `code` compare matches the OTHER realm's copy — the exact case `instanceof` gets wrong", () => { + // What a consumer holding the other realm's copy of this module actually + // has: a structurally identical refusal from a DIFFERENT class object. + class DriverConnectErrorOtherRealmCopy extends Error { + readonly code = 'ERR_DRIVER_CONNECT'; + } + const fromOtherRealm = new DriverConnectErrorOtherRealmCopy(); + + // THE CONTROL. Without this line the assertion below would pass against an + // `instanceof` recommendation too, i.e. against the defect the convention + // exists to avoid. + expect(fromOtherRealm instanceof DriverConnectError).toBe(false); + expect(fromOtherRealm.code).toBe(DRIVER_CONNECT_CODE); + }); +}); diff --git a/packages/objectql/src/driver-connect-errors.ts b/packages/objectql/src/driver-connect-errors.ts index ffd11cf887..a60bf8a213 100644 --- a/packages/objectql/src/driver-connect-errors.ts +++ b/packages/objectql/src/driver-connect-errors.ts @@ -95,8 +95,51 @@ function failureMessage(error: unknown): string { * Identified by `code` rather than `instanceof` so it survives crossing package * boundaries. */ +/** + * [#16159] The ADR-0112 `code` {@link DriverConnectError} carries, as a + * constant a consumer can import instead of re-spelling. + * + * The docblock below already says this refusal is "Identified by `code` rather + * than `instanceof` so it survives crossing package boundaries" — and until now + * offered nothing to import, so the only way to FOLLOW that instruction was to + * re-author the wire string in the consumer's own package. That acquires a + * `check:error-code-provenance` stamp site there and is then free to drift from + * what this engine throws, with no compile error to say so. The guidance and + * the surface disagreed; this closes that half of #16159's table. + * + * ⛔ The string is byte-identical to the literal it replaces. This moves where a + * spelling lives, never what it says; renaming the code is a separate breaking + * decision and never a rider on this conversion. + * + * ⚠️ `ERR_DRIVER_CONNECT` IS registered in `ERROR_CODE_LEDGER` under + * `@objectstack/objectql`, so this declaration is a `constdef` stamp site + * `check:error-code-provenance` DOES see (that gate skips unregistered codes), + * and it is listed under this package's own owner key, which is what makes the + * gate accept it. Equally, no row moves in + * `packages/runtime/src/dispatcher-error-vocabulary.ts`: that table records + * UNREGISTERED code sites, so a registered code is invisible to it by + * construction. The two gates are exactly inverted — measured on this branch, + * not assumed. + * + * The `_CODE` NAME and the bare `readonly code = DRIVER_CONNECT_CODE;` + * spelling are load-bearing rather than cosmetic: the first is the shape + * `check:error-code-provenance`'s `constdef` pattern can see, the second is the + * shape `check:dispatcher-error-vocabulary` classifies as `classconst` (an + * `as const` suffix on the FIELD would take it out of that pattern). ⛔ Never + * rename out of either shape to quiet a gate. + * + * Dropping the `ERR_` prefix from the CONSTANT's name follows this package's + * existing precedents (`HOOK_TARGET_REBIND_ERROR_CODE`, + * `READONLY_FIELD_REJECTED_CODE`). Re-exported from the `index.ts` barrel and, + * like every other `*_CODE` here, NOT from the lean `core.ts` entry — even + * though `DriverConnectError` itself IS on `core.ts`. That asymmetry is real, + * it is #16260's subject for the whole family, and ⛔ this mechanical sweep does + * not decide it. + */ +export const DRIVER_CONNECT_CODE = 'ERR_DRIVER_CONNECT' as const; + export class DriverConnectError extends Error { - readonly code = 'ERR_DRIVER_CONNECT' as const; + readonly code = DRIVER_CONNECT_CODE; /** * The first failure's `Error`, so its stack stays reachable for `DEBUG` @@ -170,8 +213,34 @@ export interface DatasourceUnavailableInfo { * says which of those to read. A host that wants to tell tenants something * specific sets `publicReason` on its connect decision. */ +/** + * [#16159] The ADR-0112 `code` {@link DatasourceUnavailableError} carries, as a + * constant a consumer can import instead of re-spelling. + * + * This row has a live first-party consumer making the card's argument for it: + * `packages/rest/src/error-response.ts` matches this refusal by `code` and then + * re-authors the same spelling into the response envelope it builds — two + * spellings of one refusal, in two packages, with nothing but a grep keeping + * them equal. + * + * ⛔ The string is byte-identical to the literal it replaces — the conversion + * moves where a spelling lives, never what it says. + * + * ⚠️ `ERR_DATASOURCE_UNAVAILABLE` is registered in `ERROR_CODE_LEDGER` under + * BOTH `@objectstack/objectql` and the datasource-service owner key (it is one + * refusal raised from two sides), so this declaration is a `constdef` stamp + * site `check:error-code-provenance` sees and accepts under this package's own + * key, while `check:dispatcher-error-vocabulary` stays blind to it by + * construction. See the note on {@link DRIVER_CONNECT_CODE} for why those two + * gates answer oppositely. + * + * Naming, field spelling and barrel placement follow {@link DRIVER_CONNECT_CODE} + * exactly, including the `core.ts` asymmetry #16260 owns. + */ +export const DATASOURCE_UNAVAILABLE_CODE = 'ERR_DATASOURCE_UNAVAILABLE' as const; + export class DatasourceUnavailableError extends Error { - readonly code = 'ERR_DATASOURCE_UNAVAILABLE' as const; + readonly code = DATASOURCE_UNAVAILABLE_CODE; constructor( public readonly datasource: string, diff --git a/packages/objectql/src/index.ts b/packages/objectql/src/index.ts index 0a62beebef..a4b7acf121 100644 --- a/packages/objectql/src/index.ts +++ b/packages/objectql/src/index.ts @@ -206,7 +206,22 @@ export type { HookRunAs, HookRunAsRef, RunAsDerivableApi } from './hook-run-as.j // Boot guard: thrown by `ObjectQL.init()` when a registered driver's connect() // fails (framework#3741). Hosts that boot the engine themselves can catch it to // render their own "database unreachable" message. -export { DriverConnectError, DatasourceUnavailableError } from './driver-connect-errors.js'; +// [#16159] `DRIVER_CONNECT_CODE` and `DATASOURCE_UNAVAILABLE_CODE` join the two +// classes they name, for the same reason: both refusals' docblocks say they are +// "Identified by `code` rather than `instanceof` so it survives crossing package +// boundaries", and neither offered anything to import. +// `packages/rest/src/error-response.ts` already matches the datasource refusal +// by `code` and re-authors the same spelling into the envelope it builds. +// ⚠️ Both CLASSES are also published from the lean `./core` entry while these +// constants, like every other `*_CODE` in this package, are batteries-only — +// #16260 owns that asymmetry for the whole family and ⛔ this sweep does not +// decide it. +export { + DriverConnectError, + DatasourceUnavailableError, + DRIVER_CONNECT_CODE, + DATASOURCE_UNAVAILABLE_CODE, +} from './driver-connect-errors.js'; export type { DriverConnectFailure, DriverHealth, From 34660a1ca55202aee01c3380a2cba9245572633e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 12:41:30 +0000 Subject: [PATCH 2/4] 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 Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ --- packages/objectql/src/index.ts | 13 ++- packages/objectql/src/summary-errors.ts | 41 ++++++++- .../summary-recompute-code-constant.test.ts | 89 +++++++++++++++++++ 3 files changed, 141 insertions(+), 2 deletions(-) create mode 100644 packages/objectql/src/summary-recompute-code-constant.test.ts diff --git a/packages/objectql/src/index.ts b/packages/objectql/src/index.ts index a4b7acf121..18676957f9 100644 --- a/packages/objectql/src/index.ts +++ b/packages/objectql/src/index.ts @@ -132,7 +132,18 @@ export type { AdmittedValueShapeViolationTally } from './engine.js'; // type of `ObjectQL.listDatasourceDefs()`. Exported so a consumer sweeping for // `sys_secret` references can name the shape it reads instead of re-declaring it. export type { DatasourceDef } from './engine.js'; -export { SummaryRecomputeError } from './summary-errors.js'; +// [#16159] `SUMMARY_RECOMPUTE_CODE` joins the class it names. The refusal's own +// docblock tells a caller to identify it by `code` rather than `instanceof` +// (the two-realm split #14936 measured: this package declares BOTH realms in +// its own `exports`, so a consumer holding the other realm's copy of the class +// gets `instanceof` === false, silently) — and until now offered nothing to +// import. Two first-party packages already re-spell this code to implement the +// documented "the records WERE written, treat it as a warning" recovery +// (`packages/rest/src/import-runner.ts`, +// `packages/metadata-protocol/src/seed-loader.ts`); they can now import it +// instead. The class stays exported exactly as it was — this adds an +// affordance, it removes nothing. +export { SummaryRecomputeError, SUMMARY_RECOMPUTE_CODE } from './summary-errors.js'; export type { SummaryRecomputeFailure } from './summary-errors.js'; // [#5126] Thrown by `update` when `options.strictReadonlyWrites` is set and the // payload would have had read-only fields stripped. Exported so an in-process diff --git a/packages/objectql/src/summary-errors.ts b/packages/objectql/src/summary-errors.ts index 2424711017..da952665c1 100644 --- a/packages/objectql/src/summary-errors.ts +++ b/packages/objectql/src/summary-errors.ts @@ -20,8 +20,47 @@ export interface SummaryRecomputeFailure { * recover the records instead of re-running the write. Identified by `code` * rather than `instanceof` so it survives crossing package boundaries. */ +/** + * [#16159] The ADR-0112 `code` {@link SummaryRecomputeError} carries, as a + * constant a consumer can import instead of re-spelling. + * + * The docblock below already says this refusal is "Identified by `code` rather + * than `instanceof` so it survives crossing package boundaries" — and offered + * nothing to import, so following that instruction meant re-authoring the wire + * string in the consumer's own package. This row is the sweep's clearest case + * that the cost is not hypothetical: TWO first-party consumers already do + * exactly that, and both do it to implement the same recovery this class was + * designed for — `packages/rest/src/import-runner.ts` and + * `packages/metadata-protocol/src/seed-loader.ts` each match this refusal by + * `code` so they can treat a stale summary as a warning and keep the records + * that WERE written. Three spellings of one code, in three packages, with + * nothing but a grep keeping them equal. + * + * ⛔ The string is byte-identical to the literal it replaces. This moves where a + * spelling lives, never what it says; renaming the code is a separate breaking + * decision and never a rider on this conversion. ⛔ Nor does this row rewire + * those two consumers: that is a consumer-side change to two other packages, + * outside a producer-side sweep, and no gate asks for it. + * + * ⚠️ `ERR_SUMMARY_RECOMPUTE` is registered in `ERROR_CODE_LEDGER` under + * `@objectstack/objectql`, so this declaration is a `constdef` stamp site + * `check:error-code-provenance` DOES see and accepts under this package's own + * owner key, while `check:dispatcher-error-vocabulary` — which records only + * UNREGISTERED sites — stays blind to it by construction. + * + * The `_CODE` NAME and the bare `readonly code = SUMMARY_RECOMPUTE_CODE;` + * spelling are load-bearing: the first is the shape the provenance gate's + * `constdef` pattern can see, the second is the shape the vocabulary gate + * classifies as `classconst` (an `as const` suffix on the FIELD would take it + * out of that pattern). Dropping the `ERR_` prefix from the CONSTANT's name + * follows this package's precedents (`READONLY_FIELD_REJECTED_CODE`, + * `HOOK_TARGET_REBIND_ERROR_CODE`); re-exported from `index.ts` beside the + * class, which is where this one is already published. + */ +export const SUMMARY_RECOMPUTE_CODE = 'ERR_SUMMARY_RECOMPUTE' as const; + export class SummaryRecomputeError extends Error { - readonly code = 'ERR_SUMMARY_RECOMPUTE' as const; + readonly code = SUMMARY_RECOMPUTE_CODE; constructor( public readonly failures: SummaryRecomputeFailure[], public readonly written: unknown, diff --git a/packages/objectql/src/summary-recompute-code-constant.test.ts b/packages/objectql/src/summary-recompute-code-constant.test.ts new file mode 100644 index 0000000000..7037e190d0 --- /dev/null +++ b/packages/objectql/src/summary-recompute-code-constant.test.ts @@ -0,0 +1,89 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #16159 row 3 of 3 in this batch — `SummaryRecomputeError` publishes its + * ADR-0112 `code` as an importable constant. + * + * ## Why this row's cost is already shipped rather than latent + * + * TWO first-party consumers already re-spell this code, 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` + * - `packages/metadata-protocol/src/seed-loader.ts` + * + * Three spellings of one code across three packages, kept equal by nothing but + * a grep. That was the only option available, because the code was an inline + * literal with nothing to import. ⛔ This PR does NOT rewire those two + * consumers: that is a consumer-side change in two other packages, outside a + * producer-side sweep, and no gate asks for it. + * + * ⚠️ This refusal carries NO `status` field, so ADR-0112's `code` + `status` + * minimum reduces here to `code` plus the fields that discriminate the refusal. + * ⛔ Inventing one would be new published surface. + * + * Five facts, each its own case; the reasoning for each shape is spelled out in + * `driver-connect-code-constant.test.ts` (same batch). The two load-bearing + * points, repeated because they are the ones a later edit is tempted to undo: + * case 1 spells the wire string LITERALLY and must not be "simplified" into a + * constant compare (a pin that reads the constant cannot catch the constant + * being wrong), and case 5's cross-realm copy is the control without which the + * file would pass just as happily against an `instanceof` recommendation. + * + * ⭐ Case 2 also pins `written`, because that field is inseparable from the + * code's meaning here: a consumer matching this code does so precisely to + * recover the records the write DID persist. + */ + +import { describe, it, expect } from 'vitest'; +import { SummaryRecomputeError, SUMMARY_RECOMPUTE_CODE } from './summary-errors.js'; +import * as barrel from './index.js'; + +const failure = { + childObject: 'crm_invoice_line', + parentObject: 'crm_invoice', + parentId: 'inv_1', + field: 'total_amount', + error: new Error('driver timeout'), +}; + +describe('#16159 SummaryRecomputeError publishes its code as a constant', () => { + it('the constant holds the exact wire string it replaced', () => { + expect(SUMMARY_RECOMPUTE_CODE).toBe('ERR_SUMMARY_RECOMPUTE'); + }); + + it('the constant IS the code the refusal carries, alongside the records that WERE written', () => { + const written = [{ _id: 'line_1' }, { _id: 'line_2' }]; + const err = new SummaryRecomputeError([failure], written); + + expect(err.code).toBe(SUMMARY_RECOMPUTE_CODE); + expect(err.name).toBe('SummaryRecomputeError'); + // The whole reason a consumer matches this code rather than treating it as + // a failed write: the records are recoverable from the refusal itself. + expect(err.written).toBe(written); + expect(err.failures).toHaveLength(1); + expect(err.message).toContain('WERE written'); + }); + + it('it is re-exported from the package barrel, which is where a consumer reaches it', () => { + // Identity, not equality — see the header. + expect(barrel.SUMMARY_RECOMPUTE_CODE).toBe(SUMMARY_RECOMPUTE_CODE); + }); + + it("the barrel's constant and the barrel's already-exported class name the same refusal", () => { + const err = new barrel.SummaryRecomputeError([failure], { _id: 'line_1' }); + expect(err.code).toBe(barrel.SUMMARY_RECOMPUTE_CODE); + }); + + it("a `code` compare matches the OTHER realm's copy — the exact case `instanceof` gets wrong", () => { + class SummaryRecomputeErrorOtherRealmCopy extends Error { + readonly code = 'ERR_SUMMARY_RECOMPUTE'; + } + const fromOtherRealm = new SummaryRecomputeErrorOtherRealmCopy(); + + // THE CONTROL — see the header. + expect(fromOtherRealm instanceof SummaryRecomputeError).toBe(false); + expect(fromOtherRealm.code).toBe(SUMMARY_RECOMPUTE_CODE); + }); +}); From 0f1b413935671477f780b21fbe867be052f68211 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 12:41:38 +0000 Subject: [PATCH 3/4] 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 Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ --- ...er-connect-summary-error-code-constants.md | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 .changeset/driver-connect-summary-error-code-constants.md diff --git a/.changeset/driver-connect-summary-error-code-constants.md b/.changeset/driver-connect-summary-error-code-constants.md new file mode 100644 index 0000000000..640e5d059d --- /dev/null +++ b/.changeset/driver-connect-summary-error-code-constants.md @@ -0,0 +1,21 @@ +--- +"@objectstack/objectql": minor +--- + +Three more engine refusals publish their error `code` as an importable constant. + +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. + +Three new exports from `@objectstack/objectql`, each graded on its own: + +- `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`.** +- `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`.** +- `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`.** + +**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. + +**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. + +**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. + +**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. From 97002640edbe386774c4b780d1c7ad0a9a4ab7e8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 14:52:31 +0000 Subject: [PATCH 4/4] fix(objectql): keep each error class documented in the emitted .d.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../objectql/src/driver-connect-errors.ts | 94 +++++++++---------- packages/objectql/src/summary-errors.ts | 22 ++--- 2 files changed, 58 insertions(+), 58 deletions(-) diff --git a/packages/objectql/src/driver-connect-errors.ts b/packages/objectql/src/driver-connect-errors.ts index a60bf8a213..3642639839 100644 --- a/packages/objectql/src/driver-connect-errors.ts +++ b/packages/objectql/src/driver-connect-errors.ts @@ -73,28 +73,6 @@ function failureMessage(error: unknown): string { return String(error); } -/** - * Thrown by `ObjectQL.init()` when one or more boot-registered drivers fail to - * connect (framework#3741). Aborts kernel bootstrap: a server that cannot reach - * its database must not report itself started. - * - * Two failures are collapsed into one class on purpose, because `init()` cannot - * tell them apart and the correct response to both is the same — don't boot: - * - * - the datasource is genuinely unreachable (wrong `OS_DATABASE_URL`, rotated - * password, closed network path), and - * - the driver is DELIBERATELY REFUSING to start (licence, server version, - * incompatible configuration, missing capability). Throwing from `connect()` - * is the supported way for a driver to veto boot; before this error existed, - * such a veto was caught and downgraded to a query-time error, which is why - * driver-mongodb's tenancy guard had to be hoisted into its constructor - * (#3724 / #3734). - * - * The message is self-contained — it names every failed driver and its cause — - * because the CLI prints `error.message` alone (stack only under `DEBUG`). - * Identified by `code` rather than `instanceof` so it survives crossing package - * boundaries. - */ /** * [#16159] The ADR-0112 `code` {@link DriverConnectError} carries, as a * constant a consumer can import instead of re-spelling. @@ -138,6 +116,28 @@ function failureMessage(error: unknown): string { */ export const DRIVER_CONNECT_CODE = 'ERR_DRIVER_CONNECT' as const; +/** + * Thrown by `ObjectQL.init()` when one or more boot-registered drivers fail to + * connect (framework#3741). Aborts kernel bootstrap: a server that cannot reach + * its database must not report itself started. + * + * Two failures are collapsed into one class on purpose, because `init()` cannot + * tell them apart and the correct response to both is the same — don't boot: + * + * - the datasource is genuinely unreachable (wrong `OS_DATABASE_URL`, rotated + * password, closed network path), and + * - the driver is DELIBERATELY REFUSING to start (licence, server version, + * incompatible configuration, missing capability). Throwing from `connect()` + * is the supported way for a driver to veto boot; before this error existed, + * such a veto was caught and downgraded to a query-time error, which is why + * driver-mongodb's tenancy guard had to be hoisted into its constructor + * (#3724 / #3734). + * + * The message is self-contained — it names every failed driver and its cause — + * because the CLI prints `error.message` alone (stack only under `DEBUG`). + * Identified by `code` rather than `instanceof` so it survives crossing package + * boundaries. + */ export class DriverConnectError extends Error { readonly code = DRIVER_CONNECT_CODE; @@ -188,31 +188,6 @@ export interface DatasourceUnavailableInfo { publicDetail?: string; } -/** - * Thrown by `getDriver()` when an object's `datasource` was **declared** but has - * no live driver, and the connection layer knows why (framework#3828). - * - * Before this, all four of these produced the same sentence — `Datasource 'x' - * is not registered.`: - * - * 1. the host's connect policy refused it (plan / egress isolation), - * 2. it failed to connect at boot and `OS_ALLOW_DRIVER_CONNECT_FAILURE` let - * the server start anyway, - * 3. the app misspelled the datasource name, and - * 4. it is declared `active: false`. - * - * (3) is an authoring bug; (1) and (2) are states of the deployment. Answering - * all of them identically sends the reader hunting for a typo that isn't there. - * Cases the connection layer never recorded keep the original message — there is - * genuinely nothing more to say about a name nobody declared. - * - * **The message never carries the underlying cause.** A connect failure's text - * routinely contains the host, port, or DSN, and a policy's `reason` is written - * for operators; neither is safe to hand to whoever is browsing a record. The - * cause stays in the startup logs and the datasource-admin list, and this error - * says which of those to read. A host that wants to tell tenants something - * specific sets `publicReason` on its connect decision. - */ /** * [#16159] The ADR-0112 `code` {@link DatasourceUnavailableError} carries, as a * constant a consumer can import instead of re-spelling. @@ -239,6 +214,31 @@ export interface DatasourceUnavailableInfo { */ export const DATASOURCE_UNAVAILABLE_CODE = 'ERR_DATASOURCE_UNAVAILABLE' as const; +/** + * Thrown by `getDriver()` when an object's `datasource` was **declared** but has + * no live driver, and the connection layer knows why (framework#3828). + * + * Before this, all four of these produced the same sentence — `Datasource 'x' + * is not registered.`: + * + * 1. the host's connect policy refused it (plan / egress isolation), + * 2. it failed to connect at boot and `OS_ALLOW_DRIVER_CONNECT_FAILURE` let + * the server start anyway, + * 3. the app misspelled the datasource name, and + * 4. it is declared `active: false`. + * + * (3) is an authoring bug; (1) and (2) are states of the deployment. Answering + * all of them identically sends the reader hunting for a typo that isn't there. + * Cases the connection layer never recorded keep the original message — there is + * genuinely nothing more to say about a name nobody declared. + * + * **The message never carries the underlying cause.** A connect failure's text + * routinely contains the host, port, or DSN, and a policy's `reason` is written + * for operators; neither is safe to hand to whoever is browsing a record. The + * cause stays in the startup logs and the datasource-admin list, and this error + * says which of those to read. A host that wants to tell tenants something + * specific sets `publicReason` on its connect decision. + */ export class DatasourceUnavailableError extends Error { readonly code = DATASOURCE_UNAVAILABLE_CODE; diff --git a/packages/objectql/src/summary-errors.ts b/packages/objectql/src/summary-errors.ts index da952665c1..2d4ef78cfd 100644 --- a/packages/objectql/src/summary-errors.ts +++ b/packages/objectql/src/summary-errors.ts @@ -9,17 +9,6 @@ export interface SummaryRecomputeFailure { error: unknown; } -/** - * Thrown by engine.insert/update/delete when one or more parent roll-up - * summaries fail to recompute after transient retries (framework#3147). - * - * The triggering records WERE written — this signals a stale/incorrect - * summary, not a failed write. `written` carries the write's result (the array - * for a batch, the single record otherwise) so a caller that can tolerate a - * stale summary (e.g. a bulk seed/import, which treats it as a warning) can - * recover the records instead of re-running the write. Identified by `code` - * rather than `instanceof` so it survives crossing package boundaries. - */ /** * [#16159] The ADR-0112 `code` {@link SummaryRecomputeError} carries, as a * constant a consumer can import instead of re-spelling. @@ -59,6 +48,17 @@ export interface SummaryRecomputeFailure { */ export const SUMMARY_RECOMPUTE_CODE = 'ERR_SUMMARY_RECOMPUTE' as const; +/** + * Thrown by engine.insert/update/delete when one or more parent roll-up + * summaries fail to recompute after transient retries (framework#3147). + * + * The triggering records WERE written — this signals a stale/incorrect + * summary, not a failed write. `written` carries the write's result (the array + * for a batch, the single record otherwise) so a caller that can tolerate a + * stale summary (e.g. a bulk seed/import, which treats it as a warning) can + * recover the records instead of re-running the write. Identified by `code` + * rather than `instanceof` so it survives crossing package boundaries. + */ export class SummaryRecomputeError extends Error { readonly code = SUMMARY_RECOMPUTE_CODE; constructor(