diff --git a/.changeset/transaction-error-code-constants.md b/.changeset/transaction-error-code-constants.md new file mode 100644 index 0000000000..8a5a054256 --- /dev/null +++ b/.changeset/transaction-error-code-constants.md @@ -0,0 +1,20 @@ +--- +"@objectstack/objectql": minor +--- + +Both transaction-seam refusals publish their error `code` as an importable constant. + +`packages/objectql/src/transaction-errors.ts` opens by telling the reader that the errors in it "identify themselves by a `code` field rather than by `instanceof`, for the reason `DriverConnectError` already records: the check has to survive crossing a package boundary, where two copies of this module can exist" — and neither of them offered anything to import. The only way to FOLLOW that published instruction was to re-spell the wire string 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. + +Two new exports from `@objectstack/objectql`, each graded on its own: + +- `TRANSACTION_UNSUPPORTED_CODE` — `TransactionUnsupportedError`'s ADR-0112 `code`. Thrown by `transaction(cb, base, { require: true })` when the datasource's driver has no `beginTransaction`, refused before the callback runs so nothing has been written. **Additive widening, `minor`.** +- `CROSS_DATASOURCE_TRANSACTION_WRITE_CODE` — `CrossDatasourceTransactionWriteError`'s ADR-0112 `code`. Thrown when a business write inside an open `transaction()` resolves to a driver that transaction does not cover. **Additive widening, `minor`.** + +**The second one is a refusal callers are meant to recover from.** Its own message prescribes the remedy — split the work into per-datasource units and reconcile them explicitly — which is code a caller writes *around* this refusal, and therefore code that has to recognise it first. That recognition now has something to import. + +**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 a class gets `instanceof` === false — measured, and silent. A `code` compare is the check that survives that boundary, which is what this module's header has been telling readers to do. + +**Nothing about the wire changed.** Each constant holds text byte-identical to the literal it replaces; every refusal throws the same `code` and the same message as before. Consumers that spell the strings themselves keep working unchanged — this adds affordances, it removes nothing. + +**Both classes were already exported and stay exported**, and neither is published from the lean `./core` entry, so the constants join them on the one entry point that publishes them: class and constant are reachable from exactly the same place. diff --git a/packages/objectql/src/cross-datasource-transaction-write-code-constant.test.ts b/packages/objectql/src/cross-datasource-transaction-write-code-constant.test.ts new file mode 100644 index 0000000000..d2f70768d4 --- /dev/null +++ b/packages/objectql/src/cross-datasource-transaction-write-code-constant.test.ts @@ -0,0 +1,106 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #16159 row 2 of 2 in this batch — `CrossDatasourceTransactionWriteError` + * publishes its ADR-0112 `code` as an importable constant. + * + * ## Why this row is the batch's strongest case for the affordance + * + * This refusal is one a caller is meant to RECOVER from, not merely log: the + * class's own docblock records the decided behaviour as "refuse, by name, + * before anything runs", and the remedy it prescribes — "split the work into + * per-datasource units and have the caller reconcile them explicitly" — is + * something a caller implements AROUND this exact refusal. Recognising it by + * `instanceof` is what #14936 measured as silently false across the realm split + * this package declares in its own `exports`; recognising it by `code` meant + * re-authoring the wire string, until now. + * + * ⚠️ This refusal carries NO `status` field, so ADR-0112's `code` + `status` + * minimum reduces here to `code` plus the fields that discriminate the refusal. + * ⛔ Inventing a `status` would be new published surface, which is not what this + * card converts. + * + * The five facts and the reasoning behind each are spelled out in + * `transaction-unsupported-code-constant.test.ts` (same batch, same shape, + * same file). The load-bearing points repeated here are (1) the literal + * spelling is the byte-identity fence and must NOT be "simplified" into a + * constant compare, and (5) the cross-realm case is the control without which + * the whole file would pass just as happily against an `instanceof` + * recommendation. + * + * ⭐ Case 2 drives all three write operations. The MESSAGE embeds the operation + * by design while the CODE deliberately does not; pinning all three is what + * stops a future per-operation message split taking the code with it. + */ + +import { describe, it, expect } from 'vitest'; +import { + CrossDatasourceTransactionWriteError, + CROSS_DATASOURCE_TRANSACTION_WRITE_CODE, +} from './transaction-errors.js'; +import * as barrel from './index.js'; + +describe('#16159 CrossDatasourceTransactionWriteError publishes its code as a constant', () => { + it('the constant holds the exact wire string it replaced', () => { + expect(CROSS_DATASOURCE_TRANSACTION_WRITE_CODE).toBe('ERR_CROSS_DATASOURCE_TRANSACTION_WRITE'); + }); + + it('the constant IS the code every operation carries — the code does not branch on the verb', () => { + const operations = ['insert', 'update', 'delete'] as const; + const errors = operations.map( + (operation) => + new CrossDatasourceTransactionWriteError('crm_invoice', operation, 'billing', 'default'), + ); + + for (const err of errors) { + expect(err.code).toBe(CROSS_DATASOURCE_TRANSACTION_WRITE_CODE); + expect(err.name).toBe('CrossDatasourceTransactionWriteError'); + } + + // The four fields are what a `code` match buys a caller: which write, on + // which object, and the two datasources whose divergence caused the + // refusal. They are how a caller splits the unit per datasource, which is + // the remedy the message prescribes. + const [insertError] = errors; + expect(insertError.object).toBe('crm_invoice'); + expect(insertError.operation).toBe('insert'); + expect(insertError.datasource).toBe('billing'); + expect(insertError.transactionDatasource).toBe('default'); + + // The MESSAGE embeds the operation by design; the CODE deliberately does + // not, so the three messages differ while the three codes are equal. + expect(new Set(errors.map((e) => e.message)).size).toBe(3); + // Fail-closed, and the sentence a caller is told to trust. + expect(insertError.message).toContain('Nothing was written'); + }); + + 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 the second spelling this card exists to remove. + expect(barrel.CROSS_DATASOURCE_TRANSACTION_WRITE_CODE).toBe( + CROSS_DATASOURCE_TRANSACTION_WRITE_CODE, + ); + }); + + it("the barrel's constant and the barrel's already-exported class name the same refusal", () => { + const err = new barrel.CrossDatasourceTransactionWriteError( + 'crm_payment', + 'update', + 'ledger', + 'default', + ); + expect(err.code).toBe(barrel.CROSS_DATASOURCE_TRANSACTION_WRITE_CODE); + }); + + it("a `code` compare matches the OTHER realm's copy — the exact case `instanceof` gets wrong", () => { + class CrossDatasourceTransactionWriteErrorOtherRealmCopy extends Error { + readonly code = 'ERR_CROSS_DATASOURCE_TRANSACTION_WRITE'; + } + const fromOtherRealm = new CrossDatasourceTransactionWriteErrorOtherRealmCopy(); + + // THE CONTROL — see the header. + expect(fromOtherRealm instanceof CrossDatasourceTransactionWriteError).toBe(false); + expect(fromOtherRealm.code).toBe(CROSS_DATASOURCE_TRANSACTION_WRITE_CODE); + }); +}); diff --git a/packages/objectql/src/index.ts b/packages/objectql/src/index.ts index 0a62beebef..78a0f47126 100644 --- a/packages/objectql/src/index.ts +++ b/packages/objectql/src/index.ts @@ -219,12 +219,27 @@ export type { InsertManyRowOutcome } from './engine.js'; // [#5696] Thrown by `transaction(cb, base, { require: true })` when the // datasource cannot give a real transaction. Exported so a caller that fails // closed can narrow on the class; `code` is the boundary-crossing identity. -export { TransactionUnsupportedError } from './transaction-errors.js'; +// [#16159] `TRANSACTION_UNSUPPORTED_CODE` joins the class it names: the line +// above already tells a caller that `code` is the boundary-crossing identity, +// and offered nothing to import — so following it meant re-spelling the wire +// string in the consumer's own package. The class stays exported exactly as it +// was; this adds an affordance and removes nothing. +export { TransactionUnsupportedError, TRANSACTION_UNSUPPORTED_CODE } from './transaction-errors.js'; // [#5351/#5696] Thrown when a BUSINESS write inside an open transaction() // resolves to a driver that transaction does not cover. Append-only system // ledgers (lifecycle.class audit/telemetry/event) are carved out and never // raise it. Narrow on the class in-process; `code` crosses package boundaries. -export { CrossDatasourceTransactionWriteError } from './transaction-errors.js'; +// [#16159] `CROSS_DATASOURCE_TRANSACTION_WRITE_CODE` joins its class for the same +// reason (#14936: 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). ⭐ Neither class in `transaction-errors.js` is published from +// the lean `./core` entry, so batteries-only constants introduce no asymmetry +// here — class and constant are reachable from exactly one entry point, the same +// one. #16260 owns that question for the classes that ARE on `./core`. +export { + CrossDatasourceTransactionWriteError, + CROSS_DATASOURCE_TRANSACTION_WRITE_CODE, +} from './transaction-errors.js'; // [#4550] The delete-dispatch contract, exported so a TEST DOUBLE that stands // in for the engine can import the producer's own decision rather than diff --git a/packages/objectql/src/transaction-errors.ts b/packages/objectql/src/transaction-errors.ts index 2fc244be26..c93e7e0a03 100644 --- a/packages/objectql/src/transaction-errors.ts +++ b/packages/objectql/src/transaction-errors.ts @@ -10,6 +10,57 @@ * can exist. */ +/** + * [#16159] The ADR-0112 `code` {@link TransactionUnsupportedError} carries, as a + * constant a consumer can import instead of re-spelling. + * + * This module's own header already says these errors "identify themselves by a + * `code` field rather than by `instanceof`, for the reason `DriverConnectError` + * already records: the check has to survive crossing a package boundary, where + * two copies of this module can exist" — and until now offered nothing to + * import. The only way to FOLLOW that published instruction was to re-author + * 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 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. + * + * ⚠️ `ERR_TRANSACTION_UNSUPPORTED` IS registered in `ERROR_CODE_LEDGER` under + * `@objectstack/objectql`, 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 = TRANSACTION_UNSUPPORTED_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. + * + * Dropping the `ERR_` prefix from the CONSTANT's name follows this package's + * existing precedents (`READONLY_FIELD_REJECTED_CODE`, + * `HOOK_TARGET_REBIND_ERROR_CODE`). Re-exported from the `index.ts` barrel, + * beside the class that is already published there. + * + * ⚠️ Placement is deliberate and differs from the sibling batch on this card: + * the constant and its docblock sit ABOVE the class's own docblock, not between + * that docblock and the class. Measured with `tsc --declaration`: two + * consecutive JSDoc blocks are both emitted against the declaration that + * follows them, so interposing this constant would move the class's + * documentation onto the CONSTANT in the published `.d.ts` and leave the class + * with none. That is a published-surface documentation regression, and the + * grouped shape #16259 landed in `registry.ts` already avoids it. + */ +export const TRANSACTION_UNSUPPORTED_CODE = 'ERR_TRANSACTION_UNSUPPORTED' as const; + /** * `transaction(cb, base, { require: true })` was called on a datasource whose * driver has no `beginTransaction` (#5696 point 1). @@ -23,7 +74,7 @@ * posture `batchData`'s atomic gate established (ADR-0119 D4). */ export class TransactionUnsupportedError extends Error { - readonly code = 'ERR_TRANSACTION_UNSUPPORTED' as const; + readonly code = TRANSACTION_UNSUPPORTED_CODE; constructor(public readonly datasource: string) { super( @@ -37,6 +88,41 @@ export class TransactionUnsupportedError extends Error { } } +/** + * [#16159] The ADR-0112 `code` {@link CrossDatasourceTransactionWriteError} + * carries, as a constant a consumer can import instead of re-spelling. + * + * This row is the one on the card whose refusal a caller is most likely to + * WANT to match rather than merely log: the class's docblock below says the + * decided behaviour is to "refuse, by name, before anything runs", and the + * remedy it prescribes ("split the work into per-datasource units and have the + * caller reconcile them explicitly") is a recovery a caller implements around + * this exact refusal. Matching it by `instanceof` is the thing #14936 measured + * as silently false across the realm split this package declares in its own + * `exports`; matching it by `code` meant re-spelling the string, until now. + * + * ⛔ The string is byte-identical to the literal it replaces — the conversion + * moves where a spelling lives, never what it says. + * + * ⚠️ `ERR_CROSS_DATASOURCE_TRANSACTION_WRITE` is registered in + * `ERROR_CODE_LEDGER` under `@objectstack/objectql`, so, exactly as for + * {@link TRANSACTION_UNSUPPORTED_CODE}, this declaration is a `constdef` stamp + * site `check:error-code-provenance` sees and accepts under this package's own + * owner key, while `check:dispatcher-error-vocabulary` — which records only + * UNREGISTERED sites — stays blind to it by construction. + * + * ⭐ Unlike the two `driver-connect-errors.ts` rows of this card's sweep, + * NEITHER class in this file is published from the lean `./core` entry, so the + * batteries-only placement of these constants introduces no asymmetry at all + * here: class and constant are reachable from exactly the same entry point. + * #16260 owns that question for the classes that ARE on `./core`; this file + * adds nothing to its population. + * + * Naming, field spelling and placement follow + * {@link TRANSACTION_UNSUPPORTED_CODE} exactly. + */ +export const CROSS_DATASOURCE_TRANSACTION_WRITE_CODE = 'ERR_CROSS_DATASOURCE_TRANSACTION_WRITE' as const; + /** * A BUSINESS write inside an open `transaction()` resolved to a driver that * transaction does not cover (#5696 point 2, decided together with #5351 by the @@ -61,7 +147,7 @@ export class TransactionUnsupportedError extends Error { * `isSystemLedgerObject` in the engine. */ export class CrossDatasourceTransactionWriteError extends Error { - readonly code = 'ERR_CROSS_DATASOURCE_TRANSACTION_WRITE' as const; + readonly code = CROSS_DATASOURCE_TRANSACTION_WRITE_CODE; constructor( public readonly object: string, diff --git a/packages/objectql/src/transaction-unsupported-code-constant.test.ts b/packages/objectql/src/transaction-unsupported-code-constant.test.ts new file mode 100644 index 0000000000..b1f870a68f --- /dev/null +++ b/packages/objectql/src/transaction-unsupported-code-constant.test.ts @@ -0,0 +1,103 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #16159 row 1 of 2 in this batch — `TransactionUnsupportedError` publishes its + * ADR-0112 `code` as an importable constant. + * + * ## What this pins, and why each assertion is here + * + * `transaction-errors.ts`'s module header already says the errors in it + * "identify themselves by a `code` field rather than by `instanceof`, for the + * reason `DriverConnectError` already records: the check has to survive + * crossing a package boundary, where two copies of this module can exist" — and + * until this change offered nothing to import. Following that published + * instruction meant RE-SPELLING the wire 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. + * + * ⚠️ This refusal carries NO `status` field, so ADR-0112's `code` + `status` + * minimum reduces here to `code` plus the fields that discriminate the refusal. + * ⛔ Inventing a `status` on the class to satisfy a habit would be new published + * surface, and that is not what this card converts. + * + * Five 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 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): `@objectstack/objectql` declares both realms in its own + * `exports`, so a consumer holding the other realm's copy of the class + * gets `instanceof` === false, silently. Without this case the others + * would pass just as happily against an `instanceof`-based recommendation + * — the thing the module header tells readers NOT to use. + */ + +import { describe, it, expect } from 'vitest'; +import { TransactionUnsupportedError, TRANSACTION_UNSUPPORTED_CODE } from './transaction-errors.js'; +import * as barrel from './index.js'; + +describe('#16159 TransactionUnsupportedError publishes its code as a constant', () => { + it('the constant holds the exact wire string it replaced', () => { + expect(TRANSACTION_UNSUPPORTED_CODE).toBe('ERR_TRANSACTION_UNSUPPORTED'); + }); + + it('the constant IS the code the require:true refusal carries', () => { + const err = new TransactionUnsupportedError('reporting'); + + expect(err.code).toBe(TRANSACTION_UNSUPPORTED_CODE); + expect(err.name).toBe('TransactionUnsupportedError'); + // The datasource is what a caller acts on: it names which driver has to + // gain `beginTransaction` for this call to be honoured. + expect(err.datasource).toBe('reporting'); + // Refused BEFORE the callback runs — the fail-closed posture ADR-0119 D4 + // established, and the half of the message a caller is told to trust. + expect(err.message).toContain('nothing has been written'); + }); + + 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.TRANSACTION_UNSUPPORTED_CODE).toBe(TRANSACTION_UNSUPPORTED_CODE); + }); + + it("the barrel's constant and the barrel's already-exported class name the same refusal", () => { + const err = new barrel.TransactionUnsupportedError('billing'); + expect(err.code).toBe(barrel.TRANSACTION_UNSUPPORTED_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 TransactionUnsupportedErrorOtherRealmCopy extends Error { + readonly code = 'ERR_TRANSACTION_UNSUPPORTED'; + } + const fromOtherRealm = new TransactionUnsupportedErrorOtherRealmCopy(); + + // 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 TransactionUnsupportedError).toBe(false); + expect(fromOtherRealm.code).toBe(TRANSACTION_UNSUPPORTED_CODE); + }); +});