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. 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..3642639839 100644 --- a/packages/objectql/src/driver-connect-errors.ts +++ b/packages/objectql/src/driver-connect-errors.ts @@ -73,6 +73,49 @@ function failureMessage(error: unknown): string { return String(error); } +/** + * [#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; + /** * Thrown by `ObjectQL.init()` when one or more boot-registered drivers fail to * connect (framework#3741). Aborts kernel bootstrap: a server that cannot reach @@ -96,7 +139,7 @@ function failureMessage(error: unknown): string { * boundaries. */ 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` @@ -145,6 +188,32 @@ export interface DatasourceUnavailableInfo { publicDetail?: string; } +/** + * [#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; + /** * Thrown by `getDriver()` when an object's `datasource` was **declared** but has * no live driver, and the connection layer knows why (framework#3828). @@ -171,7 +240,7 @@ export interface DatasourceUnavailableInfo { * specific sets `publicReason` on its connect decision. */ 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..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 @@ -206,7 +217,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, diff --git a/packages/objectql/src/summary-errors.ts b/packages/objectql/src/summary-errors.ts index 2424711017..2d4ef78cfd 100644 --- a/packages/objectql/src/summary-errors.ts +++ b/packages/objectql/src/summary-errors.ts @@ -9,6 +9,45 @@ export interface SummaryRecomputeFailure { error: unknown; } +/** + * [#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; + /** * Thrown by engine.insert/update/delete when one or more parent roll-up * summaries fail to recompute after transient retries (framework#3147). @@ -21,7 +60,7 @@ export interface SummaryRecomputeFailure { * rather than `instanceof` so it survives crossing package boundaries. */ 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); + }); +});