|
| 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 | +}); |
0 commit comments