From f874c9817813051f6b43feb9cbcc56e808bfe921 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Sun, 6 Sep 2026 10:13:24 +0000 Subject: [PATCH] feat(objectql): publish ReadonlyFieldRejectedError's code as an importable constant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `content/docs/kernel/contracts/data-engine.mdx` already tells readers, of this exact refusal, to "Catch it by `code`, not `instanceof`, and read `drops` for the per-reason breakdown" — and the code was an inline string literal with nothing to import. The published guidance and the published surface disagreed, in the documentation's own words; that is why this row of #16159's table was converted ahead of the latent ones. `READONLY_FIELD_REJECTED_CODE` is a new export from `@objectstack/objectql`, re-exported from `index.ts` and deliberately not from the lean `core.ts` entry, matching the `*_CODE` constants already in this package. Dropping the `ERR_` prefix from the constant's NAME follows the two `ERR_`-prefixed precedents here (`HOOK_TARGET_REBIND_ERROR_CODE`, `SYSTEM_WRITE_ORGANIZATION_REQUIRED_CODE`). 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 — it moved, it did not multiply or mutate. Two gate readings, measured rather than assumed, and the second contradicts what was expected of it: - `check:error-code-provenance` is NOT neutral here. Unlike the codes converted on #16259, `ERR_READONLY_FIELD_REJECTED` IS in `ERROR_CODE_LEDGER` (the gate skips unregistered codes), so the new constant is a `constdef` stamp site the gate sees: 310 -> 311 sites, 294 -> 295 listed, waivers unchanged at 16, exit 0 both sides. It passes because the code is listed under this package's own owner key. - `check:dispatcher-error-vocabulary` does NOT move, and no row in `packages/runtime/src/dispatcher-error-vocabulary.ts` changes. That table records UNREGISTERED code sites (`return !registered.has(value)`), and this code is registered, so the site is invisible to it by construction — there is no row for it to move. 66/66 classified either side, exit 0. No cross-package edit is owed for this row. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ --- .../readonly-field-rejected-code-constant.md | 17 +++ packages/objectql/src/index.ts | 15 ++- ...donly-field-rejected-code-constant.test.ts | 120 ++++++++++++++++++ .../objectql/src/readonly-strict-errors.ts | 47 ++++++- 4 files changed, 197 insertions(+), 2 deletions(-) create mode 100644 .changeset/readonly-field-rejected-code-constant.md create mode 100644 packages/objectql/src/readonly-field-rejected-code-constant.test.ts diff --git a/.changeset/readonly-field-rejected-code-constant.md b/.changeset/readonly-field-rejected-code-constant.md new file mode 100644 index 0000000000..af3a98ec62 --- /dev/null +++ b/.changeset/readonly-field-rejected-code-constant.md @@ -0,0 +1,17 @@ +--- +"@objectstack/objectql": minor +--- + +`ReadonlyFieldRejectedError`'s error `code` is now an importable constant. + +The strict-readonly refusal — thrown by `engine.update` and `engine.insert` when `options.strictReadonlyWrites` is set and the payload carried caller-supplied fields the engine would have stripped — already told readers to identify it by `code`. `content/docs/kernel/contracts/data-engine.mdx` says so in its own words: *"Catch it by `code`, not `instanceof`, and read `drops` for the per-reason breakdown"*. Until now the code was an inline string literal with nothing to import, so the only way to FOLLOW that published instruction was to re-spell `'ERR_READONLY_FIELD_REJECTED'` 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`: + +- `READONLY_FIELD_REJECTED_CODE` — `ReadonlyFieldRejectedError`'s ADR-0112 `code`. + +**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 the class gets `instanceof` === false — measured, and silent. A `code` compare is the check that survives crossing that boundary, which is exactly what the documentation has been telling readers to do. + +**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, it removes nothing. + +**`ReadonlyFieldRejectedError` itself was already exported and stays exported.** Unlike the classes converted alongside it on this sweep, both routes are published here, so the class and the constant must name the same refusal; a test pins that they do. diff --git a/packages/objectql/src/index.ts b/packages/objectql/src/index.ts index dba31f54ae..0a62beebef 100644 --- a/packages/objectql/src/index.ts +++ b/packages/objectql/src/index.ts @@ -138,7 +138,20 @@ export type { SummaryRecomputeFailure } from './summary-errors.js'; // payload would have had read-only fields stripped. Exported so an in-process // caller (a cron / server-side plugin) can narrow on the class; the `code` is // the boundary-crossing identity. -export { ReadonlyFieldRejectedError } from './readonly-strict-errors.js'; +// [#16159] `READONLY_FIELD_REJECTED_CODE` joins it, so that identity is +// something a consumer can IMPORT rather than re-spell. +// `content/docs/kernel/contracts/data-engine.mdx` already tells readers, of +// this very refusal, to "Catch it by `code`, not `instanceof`" — and until +// now offered nothing to import, so following the published instruction meant +// authoring the 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). The class stays exported as +// it already was — this adds the affordance the docs assume, it removes +// nothing — but `code` is what survives 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. +export { ReadonlyFieldRejectedError, READONLY_FIELD_REJECTED_CODE } from './readonly-strict-errors.js'; // [#14095] Thrown by `engine.insert` when a driver refuses a row as a unique // violation. Exported so an application implementing the platform's own // "declare a unique index, attempt the insert, swallow the violation" idiom can diff --git a/packages/objectql/src/readonly-field-rejected-code-constant.test.ts b/packages/objectql/src/readonly-field-rejected-code-constant.test.ts new file mode 100644 index 0000000000..d20c920b0b --- /dev/null +++ b/packages/objectql/src/readonly-field-rejected-code-constant.test.ts @@ -0,0 +1,120 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #16159 — `ReadonlyFieldRejectedError` publishes its ADR-0112 `code` as an + * importable constant. + * + * ## What this pins, and why each assertion is here + * + * This row was converted ahead of the rest of #16159's table because it is the + * only one whose cost is already SHIPPED rather than latent: + * `content/docs/kernel/contracts/data-engine.mdx` tells customers, of this + * exact refusal, to "Catch it by `code`, not `instanceof`" — while the code was + * an inline string literal with nothing to import. The published guidance and + * the published surface disagreed, in the documentation's own words. Following + * that instruction meant RE-SPELLING the string in the consumer's own package, + * which acquires a `check:error-code-provenance` stamp site there and can drift + * from what this engine throws with no compile error to say so. + * + * 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 is 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 this case and case 6 red, + * because every other case compares AGAINST the constant. ⛔ Do not + * "simplify" this case into a constant compare; a pin that reads the + * constant cannot catch the constant being wrong. + * 2/3. the constant IS the code the thrown refusal carries, on BOTH throw + * sites' operations — `update` (#5126) and `insert` (#5503) — asserted + * together with `status`. ⛔ Never a bare `toThrow()`: this file's own + * history is the argument (#14367 measured on this path that a + * throw-shaped assertion stayed GREEN with a check one layer up ablated, + * because a second refusal fired one step later and was + * indistinguishable). Both operations are driven because the `code` + * deliberately does NOT branch on `operation` while the MESSAGE does — a + * future message split must not be able to take the code with it. + * 4. the constant is reachable from the package BARREL. This is the whole + * affordance the card buys — a constant a consumer cannot import is not an + * answer to "catch it by `code`" — and it is what a future barrel edit + * would lose silently. + * 5. the barrel's `code` and the barrel's CLASS agree. This class, unlike + * slice 1's three, was ALREADY exported, so both routes are published and + * a consumer can hold either; they must name the same refusal. + * 6. 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 gets + * `instanceof` === false, silently. Without this case the others would + * pass just as happily against an `instanceof`-based recommendation — + * which is precisely what the docs tell readers NOT to use. + */ + +import { describe, it, expect } from 'vitest'; +import { ReadonlyFieldRejectedError, READONLY_FIELD_REJECTED_CODE } from './readonly-strict-errors.js'; +import * as barrel from './index.js'; + +describe('#16159 ReadonlyFieldRejectedError publishes its code as a constant', () => { + it('the constant holds the exact wire string it replaced', () => { + expect(READONLY_FIELD_REJECTED_CODE).toBe('ERR_READONLY_FIELD_REJECTED'); + }); + + it('the constant IS the code an UPDATE refusal carries', () => { + const err = new ReadonlyFieldRejectedError('crm_account', ['created_at'], [ + { object: 'crm_account', fields: ['created_at'], reason: 'readonly' }, + ]); + expect(err.code).toBe(READONLY_FIELD_REJECTED_CODE); + expect(err.operation).toBe('update'); + expect(err.name).toBe('ReadonlyFieldRejectedError'); + }); + + it('the constant IS the code an INSERT refusal carries — the code does not branch on operation', () => { + const err = new ReadonlyFieldRejectedError( + 'crm_account', + ['record_no'], + [{ object: 'crm_account', fields: ['record_no'], reason: 'readonly' }], + 'insert', + ); + expect(err.code).toBe(READONLY_FIELD_REJECTED_CODE); + expect(err.operation).toBe('insert'); + // The MESSAGE differs per operation by design; the CODE deliberately does + // not. Pinning both here is what stops a future message split taking the + // code with it. + expect(err.message).not.toBe( + new ReadonlyFieldRejectedError('crm_account', ['record_no'], [ + { object: 'crm_account', fields: ['record_no'], reason: 'readonly' }, + ]).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 exactly the second spelling this card exists to remove. + expect(barrel.READONLY_FIELD_REJECTED_CODE).toBe(READONLY_FIELD_REJECTED_CODE); + }); + + it("the barrel's constant and the barrel's already-exported class name the same refusal", () => { + const err = new barrel.ReadonlyFieldRejectedError('crm_account', ['created_at'], [ + { object: 'crm_account', fields: ['created_at'], reason: 'readonly' }, + ]); + expect(err.code).toBe(barrel.READONLY_FIELD_REJECTED_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 ReadonlyFieldRejectedErrorOtherRealmCopy extends Error { + readonly code = 'ERR_READONLY_FIELD_REJECTED'; + } + const fromOtherRealm = new ReadonlyFieldRejectedErrorOtherRealmCopy(); + + // THE CONTROL. Without this line the assertion below would pass against an + // `instanceof` recommendation too, i.e. against the defect the docs warn of. + expect(fromOtherRealm instanceof ReadonlyFieldRejectedError).toBe(false); + expect(fromOtherRealm.code).toBe(READONLY_FIELD_REJECTED_CODE); + }); +}); diff --git a/packages/objectql/src/readonly-strict-errors.ts b/packages/objectql/src/readonly-strict-errors.ts index ccb399792b..7b11be5230 100644 --- a/packages/objectql/src/readonly-strict-errors.ts +++ b/packages/objectql/src/readonly-strict-errors.ts @@ -157,8 +157,53 @@ function buildRefusalMessage( ); } +/** + * [#16159] The ADR-0112 `code` this file's refusal carries, as a constant a + * consumer can import instead of re-spelling. + * + * The docblock above already tells the reader to identify this refusal by + * `code` rather than `instanceof`, and + * `content/docs/kernel/contracts/data-engine.mdx` repeats the instruction to + * customers in its own words ("Catch it by `code`, not `instanceof`"). Until + * now the code was an inline string literal, so the only way to FOLLOW that + * published instruction was to RE-SPELL the 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. The guidance and the surface disagreed; this closes that, and it is why + * this row was converted ahead of the others on #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. + * + * ⚠️ Unlike the other conversions on #16159, `ERR_READONLY_FIELD_REJECTED` is + * REGISTERED in `ERROR_CODE_LEDGER` under `@objectstack/objectql`, so this + * declaration is a `constdef` stamp site that `check:error-code-provenance` + * DOES see (it skips unregistered codes) — and it is listed under this + * package's own owner key, which is what makes that gate accept it. It is also + * why no row moves in `packages/runtime/src/dispatcher-error-vocabulary.ts`: + * that table records UNREGISTERED code sites, and a registered code is + * invisible to it by construction. + * + * The `_CODE` NAME and the bare `readonly code = READONLY_FIELD_REJECTED_CODE;` + * spelling are both 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: a spelling a gate cannot see is + * the failure mode the gate exists to catch, not a clean result. + * + * Shape and placement follow the `*_CODE` constants already in this package; + * dropping the `ERR_` prefix from the CONSTANT's name follows the two + * `ERR_`-prefixed precedents here (`HOOK_TARGET_REBIND_ERROR_CODE` = + * `'ERR_HOOK_TARGET_REBIND'`, `SYSTEM_WRITE_ORGANIZATION_REQUIRED_CODE` = + * `'ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED'`). Re-exported from the `index.ts` + * barrel and, like all of them, deliberately NOT from the lean `core.ts` entry. + */ +export const READONLY_FIELD_REJECTED_CODE = 'ERR_READONLY_FIELD_REJECTED' as const; + export class ReadonlyFieldRejectedError extends Error { - readonly code = 'ERR_READONLY_FIELD_REJECTED' as const; + readonly code = READONLY_FIELD_REJECTED_CODE; constructor( public readonly object: string, public readonly fields: string[],