diff --git a/.changeset/validation-failed-code-constant.md b/.changeset/validation-failed-code-constant.md new file mode 100644 index 0000000000..2bb224f933 --- /dev/null +++ b/.changeset/validation-failed-code-constant.md @@ -0,0 +1,23 @@ +--- +"@objectstack/objectql": minor +--- + +`ValidationError` publishes its error `code` as an importable constant — the last row of #16159's census. + +`content/docs/kernel/contracts/data-engine.mdx` teaches the convention: catch an engine refusal *by `code`, not `instanceof`*. Following it for record validation meant re-spelling `'VALIDATION_FAILED'` 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. + +One new export from `@objectstack/objectql`: + +- `VALIDATION_FAILED_CODE` — `ValidationError`'s ADR-0112 `code`. Thrown by `validateRecord` when an insert/update payload violates the object's own field metadata, carrying the per-field breakdown on `fields[]`. **Additive widening, `minor`.** + +**This row's consumer-side drift is the widest on the card, and worth stating precisely rather than as a slogan.** `'VALIDATION_FAILED'` is re-authored as an inline literal at **148 non-test sites across 33 files** in this repo — but the honest reading of that number is that the large majority are **independent producers** minting their own house-code envelope (`@objectstack/rest`'s response bodies, `plugin-approvals`' `VALIDATION_FAILED: …` message-prefix convention, `plugin-sharing`'s locally-declared `SharingCriteriaValidationError`, `@objectstack/metadata-protocol`'s own class whose docblock calls the code *"this package's own house code"*). Those are not consumers of this class and nothing about them changes. + +The sites this export actually serves are the **recognizers**, and there are four: `packages/types/src/validation-failure.ts` and `packages/rest/src/error-response.ts` both test `code === 'VALIDATION_FAILED' || name === 'ValidationError'`, `packages/rest/src/error-response.ts` tests the wire body's `code` a second time, and `packages/plugins/plugin-auth/src/objectql-adapter.ts` does the same to map an engine refusal onto a `better-auth` `APIError`. Each holds its own copy of the string. **No consumer is rewired here** — the card's scope is the producer-side importable constant, and re-pointing another package's recognizer is a cross-package coupling this card never asked for. + +**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 `ValidationError` gets `instanceof` === false — measured, and silent. A `code` compare is the check that survives that boundary. + +**Nothing about the wire changed.** The constant holds text byte-identical to the literal it replaces; the refusal throws the same `code` and the same message as before. Consumers that spell the string themselves keep working unchanged — this adds an affordance and removes nothing. + +**It does not converge `VALIDATION_FAILED` with `VALIDATION_ERROR`.** `EMPTY_CREDENTIAL_REFUSAL_CODE` in the same package is `'VALIDATION_ERROR'`; #16159 explicitly leaves *"whether they should converge"* unruled, and publishing the current spelling keeps that decision exactly as open as it was — a convergence is a breaking rename of a registered wire code either way. A pin test asserts the two are still two, so a future ruling has to argue for itself rather than arrive as a side effect. + +**`ValidationError` was already exported and stays exported.** The constant joins it on the batteries barrel only, matching every existing `*_CODE` in this package; the class is *also* on the lean `./core` entry, so this adds one more instance to the asymmetry #16260 owns — deliberately not decided here. diff --git a/packages/objectql/src/index.ts b/packages/objectql/src/index.ts index 5c3ebd8dc3..13f463116b 100644 --- a/packages/objectql/src/index.ts +++ b/packages/objectql/src/index.ts @@ -354,7 +354,19 @@ export type { RelatedTitleTarget } from './record-title.js'; export { evaluateFormulaField } from './engine.js'; // Export Validation -export { ValidationError, validateRecord } from './validation/record-validator.js'; +// [#16159] `VALIDATION_FAILED_CODE` joins the class it belongs to: this package +// declares BOTH realms in its own `exports` (`import` -> dist/index.mjs, +// `require` -> dist/index.js), so a consumer holding the other realm's copy of +// `ValidationError` gets `instanceof` === false, silently (#14936). The sound +// route is a `code` compare, and until now that meant re-spelling the wire +// string in the consumer's own package -- which 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 class stays +// exported exactly as it was; this adds an affordance and removes nothing. +// [#16260] The constant is batteries-only while `ValidationError` is ALSO on the +// lean `./core` entry, matching every existing `*_CODE` in this package; that +// asymmetry is #16260's question, deliberately not decided here. +export { ValidationError, validateRecord, VALIDATION_FAILED_CODE } from './validation/record-validator.js'; export type { FieldValidationError } from './validation/record-validator.js'; // [ADR-0104 / #4769] The counterexample a boot produces by ADMITTING an // off-shape value. Exported because the fresh-datastore attestation diff --git a/packages/objectql/src/validation-failed-code-constant.test.ts b/packages/objectql/src/validation-failed-code-constant.test.ts new file mode 100644 index 0000000000..a60eec8588 --- /dev/null +++ b/packages/objectql/src/validation-failed-code-constant.test.ts @@ -0,0 +1,128 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #16159 — the LAST row of the card's eleven-row census: `ValidationError` + * publishes its ADR-0112 `code` as an importable constant. + * + * ## What this pins, and why each assertion is here + * + * `@objectstack/objectql` declares BOTH realms in its own `exports` (`import` + * to `dist/index.mjs`, `require` to `dist/index.js`), so a consumer holding the + * other realm's copy of this class gets `instanceof` === false — measured on + * #14936, and silent. The sound route is a `code` compare, and until this + * change the only way to write one was to RE-SPELL the wire string in the + * consumer's own package: that 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. Two in-repo recognizers do exactly that today + * (`packages/types/src/validation-failure.ts` and + * `packages/rest/src/error-response.ts`), each holding its own copy of the + * string. + * + * ⚠️ This refusal carries NO `status` field, so ADR-0112's `code` + `status` + * minimum reduces here to `code` plus the field that discriminates the refusal + * (`fields[]`). ⛔ Inventing a `status` on the class to satisfy a habit would be + * new published surface, and that is not what this card converts. + * + * Six 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. ⛔ Do not "simplify" it into + * a constant compare: a pin that reads the constant cannot catch the + * constant being wrong, and every OTHER case in this file compares against + * the constant, so this is the only case that can. + * 2. the constant IS the code a real refusal carries, asserted with `name` + * and with `fields[]` — the field the class exists to report. ⛔ Never a + * bare `toThrow()`: 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, 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). Without this case the others would pass just as happily + * against an `instanceof`-based recommendation — the thing this card + * replaces. + * 6. ⛔ the constant is NOT the sibling validation spelling. `secret-fields.ts` + * publishes `EMPTY_CREDENTIAL_REFUSAL_CODE = 'VALIDATION_ERROR'`, and the + * card explicitly leaves "whether they should converge" unruled. This case + * pins that this conversion did NOT quietly converge them: the two remain + * two, and a future ruling that merges them will fail HERE first, which is + * where a rename of a registered wire code should be forced to argue for + * itself rather than arriving as a side effect. + */ + +import { describe, it, expect } from 'vitest'; +import { ValidationError, VALIDATION_FAILED_CODE } from './validation/record-validator.js'; +import { EMPTY_CREDENTIAL_REFUSAL_CODE } from './secret-fields.js'; +import * as barrel from './index.js'; + +describe('#16159 ValidationError publishes its code as a constant', () => { + it('the constant holds the exact wire string it replaced', () => { + expect(VALIDATION_FAILED_CODE).toBe('VALIDATION_FAILED'); + }); + + it('the constant IS the code a real record-validation refusal carries', () => { + const err = new ValidationError([ + { field: 'amount', code: 'required', message: 'Amount is required' }, + ]); + + expect(err.code).toBe(VALIDATION_FAILED_CODE); + expect(err.name).toBe('ValidationError'); + // `fields[]` is what a form acts on — the per-field breakdown this refusal + // exists to carry, and the half a caller reads after branching on `code`. + expect(err.fields).toEqual([ + { field: 'amount', code: 'required', message: 'Amount is required' }, + ]); + // The top-level message carries the HUMAN text, which is what generic UI + // surfaces display verbatim. + expect(err.message).toBe('Amount is required'); + }); + + 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.VALIDATION_FAILED_CODE).toBe(VALIDATION_FAILED_CODE); + }); + + it("the barrel's constant and the barrel's already-exported class name the same refusal", () => { + const err = new barrel.ValidationError([ + { field: 'email', code: 'invalid_format', message: 'Not an email' }, + ]); + expect(err.code).toBe(barrel.VALIDATION_FAILED_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 ValidationErrorOtherRealmCopy extends Error { + readonly code = 'VALIDATION_FAILED'; + } + const fromOtherRealm = new ValidationErrorOtherRealmCopy(); + + // 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 ValidationError).toBe(false); + expect(fromOtherRealm.code).toBe(VALIDATION_FAILED_CODE); + }); + + it('⛔ it did NOT converge with the sibling `VALIDATION_ERROR` spelling — that question stays open', () => { + // The card fences this off in its own words: EMPTY_CREDENTIAL_REFUSAL_CODE + // is already 'VALIDATION_ERROR' while this site uses 'VALIDATION_FAILED', + // "and whether they should converge is a question this card does not + // answer". Publishing the current spelling must not decide it by side + // effect, so the divergence is pinned rather than left to be noticed. + expect(EMPTY_CREDENTIAL_REFUSAL_CODE).toBe('VALIDATION_ERROR'); + expect(VALIDATION_FAILED_CODE).not.toBe(EMPTY_CREDENTIAL_REFUSAL_CODE); + }); +}); diff --git a/packages/objectql/src/validation/record-validator.ts b/packages/objectql/src/validation/record-validator.ts index 08f474fd46..90e1d84642 100644 --- a/packages/objectql/src/validation/record-validator.ts +++ b/packages/objectql/src/validation/record-validator.ts @@ -132,8 +132,62 @@ export interface FieldValidationError { options?: string[]; } +/** + * [#16159] The ADR-0112 `code` {@link ValidationError} carries, as a constant a + * consumer can import instead of re-spelling. + * + * This is the LAST row of #16159's eleven-row census, and the one whose + * consumer-side re-spelling is measurably the widest: `'VALIDATION_FAILED'` is + * re-authored as an inline literal at 148 non-test sites in 33 files across + * this repo. Most of those are INDEPENDENT PRODUCERS minting their own + * house-code envelope, not consumers of this class — but the recognizers that + * genuinely catch THIS error had, until now, no importable spelling to compare + * against: `packages/types/src/validation-failure.ts` and + * `packages/rest/src/error-response.ts` both test + * `code === 'VALIDATION_FAILED' || name === 'ValidationError'`, each holding + * its own copy of the string, each free to drift from what this engine throws + * with no compile error to say so. + * + * ⛔ The string is byte-identical to the literal it replaces. This moves where a + * spelling lives, never what it says. + * + * ⛔⛔ It also does NOT answer the question the card fenced off: `secret-fields.ts` + * spells its refusal `EMPTY_CREDENTIAL_REFUSAL_CODE = 'VALIDATION_ERROR'` while + * this one is `'VALIDATION_FAILED'`, and *"whether they should converge is a + * question this card does not answer"*. Publishing the current spelling leaves + * that decision exactly as open as it was: converging them was a breaking + * rename of a registered wire code before this constant existed and still is + * after, and a rename would move this constant's VALUE, not its existence. + * + * ⚠️ `VALIDATION_FAILED` IS registered in `ERROR_CODE_LEDGER` under + * `@objectstack/objectql` (`packages/spec/src/api/error-code-ledger.zod.ts`), + * 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 = VALIDATION_FAILED_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` — its + * pattern requires the constant name to be followed by `;`, `,` or a newline, + * so an `as const` suffix on the FIELD takes the site out of it. ⛔ Never rename + * out of either shape to quiet a gate. + * + * ⚠️ Re-exported from the `index.ts` barrel beside the class, and ⛔ NOT from the + * lean `./core` entry — matching every existing `*_CODE` in this package. + * {@link ValidationError} itself IS on `./core`, so this row adds one more + * instance to the asymmetry #16260 owns; ⛔ deciding that question for one + * member of the family inside a mechanical sweep is the thing this card's + * slicing exists to prevent. + */ +export const VALIDATION_FAILED_CODE = 'VALIDATION_FAILED' as const; + export class ValidationError extends Error { - readonly code = 'VALIDATION_FAILED'; + readonly code = VALIDATION_FAILED_CODE; readonly fields: FieldValidationError[]; constructor(fields: FieldValidationError[]) { // The top-level message is what generic UI surfaces (toasts, CLI output)