Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/readonly-field-rejected-code-constant.md
Original file line number Diff line number Diff line change
@@ -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.
15 changes: 14 additions & 1 deletion packages/objectql/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
120 changes: 120 additions & 0 deletions packages/objectql/src/readonly-field-rejected-code-constant.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
47 changes: 46 additions & 1 deletion packages/objectql/src/readonly-strict-errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[],
Expand Down
Loading