Skip to content

Commit 11f848e

Browse files
claude[bot]claude
andauthored
feat(objectql): publish ReadonlyFieldRejectedError's code as an importable constant (#16283)
`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. Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent bda7b7c commit 11f848e

4 files changed

Lines changed: 197 additions & 2 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
"@objectstack/objectql": minor
3+
---
4+
5+
`ReadonlyFieldRejectedError`'s error `code` is now an importable constant.
6+
7+
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.
8+
9+
One new export from `@objectstack/objectql`:
10+
11+
- `READONLY_FIELD_REJECTED_CODE``ReadonlyFieldRejectedError`'s ADR-0112 `code`.
12+
13+
**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.
14+
15+
**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.
16+
17+
**`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.

packages/objectql/src/index.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,20 @@ export type { SummaryRecomputeFailure } from './summary-errors.js';
138138
// payload would have had read-only fields stripped. Exported so an in-process
139139
// caller (a cron / server-side plugin) can narrow on the class; the `code` is
140140
// the boundary-crossing identity.
141-
export { ReadonlyFieldRejectedError } from './readonly-strict-errors.js';
141+
// [#16159] `READONLY_FIELD_REJECTED_CODE` joins it, so that identity is
142+
// something a consumer can IMPORT rather than re-spell.
143+
// `content/docs/kernel/contracts/data-engine.mdx` already tells readers, of
144+
// this very refusal, to "Catch it by `code`, not `instanceof`" — and until
145+
// now offered nothing to import, so following the published instruction meant
146+
// authoring the string in the consumer's own package (a
147+
// `check:error-code-provenance` stamp site there, free to drift from what this
148+
// engine throws with no compile error to say so). The class stays exported as
149+
// it already was — this adds the affordance the docs assume, it removes
150+
// nothing — but `code` is what survives the two-realm split #14936 measured:
151+
// this package declares BOTH realms in its own `exports`, so a consumer
152+
// holding the other realm's copy of the class gets `instanceof` === false,
153+
// silently.
154+
export { ReadonlyFieldRejectedError, READONLY_FIELD_REJECTED_CODE } from './readonly-strict-errors.js';
142155
// [#14095] Thrown by `engine.insert` when a driver refuses a row as a unique
143156
// violation. Exported so an application implementing the platform's own
144157
// "declare a unique index, attempt the insert, swallow the violation" idiom can
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #16159 — `ReadonlyFieldRejectedError` publishes its ADR-0112 `code` as an
5+
* importable constant.
6+
*
7+
* ## What this pins, and why each assertion is here
8+
*
9+
* This row was converted ahead of the rest of #16159's table because it is the
10+
* only one whose cost is already SHIPPED rather than latent:
11+
* `content/docs/kernel/contracts/data-engine.mdx` tells customers, of this
12+
* exact refusal, to "Catch it by `code`, not `instanceof`" — while the code was
13+
* an inline string literal with nothing to import. The published guidance and
14+
* the published surface disagreed, in the documentation's own words. Following
15+
* that instruction meant RE-SPELLING the string in the consumer's own package,
16+
* which acquires a `check:error-code-provenance` stamp site there and can drift
17+
* from what this engine throws with no compile error to say so.
18+
*
19+
* Six facts, each its own case so a failure reads as the specific regression:
20+
*
21+
* 1. the constant holds the exact wire string. Spelled LITERALLY here on
22+
* purpose: the test layer is outside `check:error-code-provenance`'s
23+
* scanned population, so pinning it costs no stamp site while making a
24+
* silent rename of a published code impossible to pass off as "still the
25+
* same code". ⛔ This is the byte-identity fence — the conversion moves
26+
* where a spelling lives, never what it says. Slice 1 (#16259) measured
27+
* that mutating a constant's VALUE turns ONLY this case and case 6 red,
28+
* because every other case compares AGAINST the constant. ⛔ Do not
29+
* "simplify" this case into a constant compare; a pin that reads the
30+
* constant cannot catch the constant being wrong.
31+
* 2/3. the constant IS the code the thrown refusal carries, on BOTH throw
32+
* sites' operations — `update` (#5126) and `insert` (#5503) — asserted
33+
* together with `status`. ⛔ Never a bare `toThrow()`: this file's own
34+
* history is the argument (#14367 measured on this path that a
35+
* throw-shaped assertion stayed GREEN with a check one layer up ablated,
36+
* because a second refusal fired one step later and was
37+
* indistinguishable). Both operations are driven because the `code`
38+
* deliberately does NOT branch on `operation` while the MESSAGE does — a
39+
* future message split must not be able to take the code with it.
40+
* 4. the constant is reachable from the package BARREL. This is the whole
41+
* affordance the card buys — a constant a consumer cannot import is not an
42+
* answer to "catch it by `code`" — and it is what a future barrel edit
43+
* would lose silently.
44+
* 5. the barrel's `code` and the barrel's CLASS agree. This class, unlike
45+
* slice 1's three, was ALREADY exported, so both routes are published and
46+
* a consumer can hold either; they must name the same refusal.
47+
* 6. a `code` compare matches a foreign-realm copy of the refusal where
48+
* `instanceof` returns false. THE CONTROL, and the reason the convention
49+
* exists (#14936): `@objectstack/objectql` declares both realms in its own
50+
* `exports`, so a consumer holding the other realm's copy gets
51+
* `instanceof` === false, silently. Without this case the others would
52+
* pass just as happily against an `instanceof`-based recommendation —
53+
* which is precisely what the docs tell readers NOT to use.
54+
*/
55+
56+
import { describe, it, expect } from 'vitest';
57+
import { ReadonlyFieldRejectedError, READONLY_FIELD_REJECTED_CODE } from './readonly-strict-errors.js';
58+
import * as barrel from './index.js';
59+
60+
describe('#16159 ReadonlyFieldRejectedError publishes its code as a constant', () => {
61+
it('the constant holds the exact wire string it replaced', () => {
62+
expect(READONLY_FIELD_REJECTED_CODE).toBe('ERR_READONLY_FIELD_REJECTED');
63+
});
64+
65+
it('the constant IS the code an UPDATE refusal carries', () => {
66+
const err = new ReadonlyFieldRejectedError('crm_account', ['created_at'], [
67+
{ object: 'crm_account', fields: ['created_at'], reason: 'readonly' },
68+
]);
69+
expect(err.code).toBe(READONLY_FIELD_REJECTED_CODE);
70+
expect(err.operation).toBe('update');
71+
expect(err.name).toBe('ReadonlyFieldRejectedError');
72+
});
73+
74+
it('the constant IS the code an INSERT refusal carries — the code does not branch on operation', () => {
75+
const err = new ReadonlyFieldRejectedError(
76+
'crm_account',
77+
['record_no'],
78+
[{ object: 'crm_account', fields: ['record_no'], reason: 'readonly' }],
79+
'insert',
80+
);
81+
expect(err.code).toBe(READONLY_FIELD_REJECTED_CODE);
82+
expect(err.operation).toBe('insert');
83+
// The MESSAGE differs per operation by design; the CODE deliberately does
84+
// not. Pinning both here is what stops a future message split taking the
85+
// code with it.
86+
expect(err.message).not.toBe(
87+
new ReadonlyFieldRejectedError('crm_account', ['record_no'], [
88+
{ object: 'crm_account', fields: ['record_no'], reason: 'readonly' },
89+
]).message,
90+
);
91+
});
92+
93+
it('it is re-exported from the package barrel, which is where a consumer reaches it', () => {
94+
// Identity, not equality: a barrel that re-declared the string instead of
95+
// re-exporting the constant would satisfy `toBe` on the VALUE while having
96+
// re-introduced exactly the second spelling this card exists to remove.
97+
expect(barrel.READONLY_FIELD_REJECTED_CODE).toBe(READONLY_FIELD_REJECTED_CODE);
98+
});
99+
100+
it("the barrel's constant and the barrel's already-exported class name the same refusal", () => {
101+
const err = new barrel.ReadonlyFieldRejectedError('crm_account', ['created_at'], [
102+
{ object: 'crm_account', fields: ['created_at'], reason: 'readonly' },
103+
]);
104+
expect(err.code).toBe(barrel.READONLY_FIELD_REJECTED_CODE);
105+
});
106+
107+
it("a `code` compare matches the OTHER realm's copy — the exact case `instanceof` gets wrong", () => {
108+
// What a consumer holding the other realm's copy of this module actually
109+
// has: a structurally identical refusal from a DIFFERENT class object.
110+
class ReadonlyFieldRejectedErrorOtherRealmCopy extends Error {
111+
readonly code = 'ERR_READONLY_FIELD_REJECTED';
112+
}
113+
const fromOtherRealm = new ReadonlyFieldRejectedErrorOtherRealmCopy();
114+
115+
// THE CONTROL. Without this line the assertion below would pass against an
116+
// `instanceof` recommendation too, i.e. against the defect the docs warn of.
117+
expect(fromOtherRealm instanceof ReadonlyFieldRejectedError).toBe(false);
118+
expect(fromOtherRealm.code).toBe(READONLY_FIELD_REJECTED_CODE);
119+
});
120+
});

packages/objectql/src/readonly-strict-errors.ts

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,8 +157,53 @@ function buildRefusalMessage(
157157
);
158158
}
159159

160+
/**
161+
* [#16159] The ADR-0112 `code` this file's refusal carries, as a constant a
162+
* consumer can import instead of re-spelling.
163+
*
164+
* The docblock above already tells the reader to identify this refusal by
165+
* `code` rather than `instanceof`, and
166+
* `content/docs/kernel/contracts/data-engine.mdx` repeats the instruction to
167+
* customers in its own words ("Catch it by `code`, not `instanceof`"). Until
168+
* now the code was an inline string literal, so the only way to FOLLOW that
169+
* published instruction was to RE-SPELL the string in the consumer's own
170+
* package — which acquires a `check:error-code-provenance` stamp site there
171+
* and can then drift from what this engine throws with no compile error to say
172+
* so. The guidance and the surface disagreed; this closes that, and it is why
173+
* this row was converted ahead of the others on #16159's table.
174+
*
175+
* ⛔ The string is byte-identical to the literal it replaces. This moves where
176+
* a spelling lives, never what it says; renaming the code is a separate
177+
* breaking decision and never a rider on this conversion.
178+
*
179+
* ⚠️ Unlike the other conversions on #16159, `ERR_READONLY_FIELD_REJECTED` is
180+
* REGISTERED in `ERROR_CODE_LEDGER` under `@objectstack/objectql`, so this
181+
* declaration is a `constdef` stamp site that `check:error-code-provenance`
182+
* DOES see (it skips unregistered codes) — and it is listed under this
183+
* package's own owner key, which is what makes that gate accept it. It is also
184+
* why no row moves in `packages/runtime/src/dispatcher-error-vocabulary.ts`:
185+
* that table records UNREGISTERED code sites, and a registered code is
186+
* invisible to it by construction.
187+
*
188+
* The `_CODE` NAME and the bare `readonly code = READONLY_FIELD_REJECTED_CODE;`
189+
* spelling are both load-bearing rather than cosmetic: the first is the shape
190+
* `check:error-code-provenance`'s `constdef` pattern can see, the second is the
191+
* shape `check:dispatcher-error-vocabulary` classifies as `classconst` (an
192+
* `as const` suffix on the field would take it out of that pattern). ⛔ Never
193+
* rename out of either shape to quiet a gate: a spelling a gate cannot see is
194+
* the failure mode the gate exists to catch, not a clean result.
195+
*
196+
* Shape and placement follow the `*_CODE` constants already in this package;
197+
* dropping the `ERR_` prefix from the CONSTANT's name follows the two
198+
* `ERR_`-prefixed precedents here (`HOOK_TARGET_REBIND_ERROR_CODE` =
199+
* `'ERR_HOOK_TARGET_REBIND'`, `SYSTEM_WRITE_ORGANIZATION_REQUIRED_CODE` =
200+
* `'ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED'`). Re-exported from the `index.ts`
201+
* barrel and, like all of them, deliberately NOT from the lean `core.ts` entry.
202+
*/
203+
export const READONLY_FIELD_REJECTED_CODE = 'ERR_READONLY_FIELD_REJECTED' as const;
204+
160205
export class ReadonlyFieldRejectedError extends Error {
161-
readonly code = 'ERR_READONLY_FIELD_REJECTED' as const;
206+
readonly code = READONLY_FIELD_REJECTED_CODE;
162207
constructor(
163208
public readonly object: string,
164209
public readonly fields: string[],

0 commit comments

Comments
 (0)