Skip to content

Commit eddd612

Browse files
claude[bot]claude
andauthored
feat(objectql): the two transaction-seam refusals publish their error code as a constant (#16326)
* feat(objectql): the two transaction-seam refusals publish their code as a constant `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 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 is then free to drift from what this engine throws with no compile error to say so. `TRANSACTION_UNSUPPORTED_CODE` and `CROSS_DATASOURCE_TRANSACTION_WRITE_CODE` each hold text byte-identical to the literal they replace: the quoted wire spelling occurs exactly once per code in this file on both sides of the change, so it moved rather than multiplied. Both classes were already exported from the barrel and stay exported; the constants join them there. Neither class is published from the lean `./core` entry, so the batteries-only placement of these two constants adds nothing to #16260's population: class and constant are reachable from exactly the same entry point. Placement differs deliberately from the sibling batch on this card. Each constant and its docblock sit ABOVE the class's own docblock rather than between that docblock and the class. Measured with `tsc --declaration`: two consecutive JSDoc blocks are both emitted against the declaration that follows, so interposing the constant moves the class's documentation onto the CONSTANT in the published `.d.ts` and leaves the class with none. The grouped shape already landed in `registry.ts` avoids the same thing. Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ Co-authored-by: Claude <noreply@anthropic.com> * test(objectql): pin TRANSACTION_UNSUPPORTED_CODE against its class and the barrel Row 1 of this batch, graded and pinned on its own. Five cases: the literal wire string (the byte-identity fence -- deliberately NOT a constant compare, because a pin that reads the constant cannot catch the constant being wrong); the code a real refusal carries, asserted with `name` and the datasource it reports rather than a bare `toThrow()`; barrel reachability by identity; barrel-class and barrel-constant agreement; and a cross-realm copy where `instanceof` is false while the `code` compare holds -- the control without which the file would pass just as happily against the `instanceof` recommendation this module tells readers not to use. This refusal carries no `status` field, so ADR-0112's `code` + `status` minimum reduces here to `code` plus the fields that discriminate it; inventing a `status` would be new published surface and is not what this card converts. Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ Co-authored-by: Claude <noreply@anthropic.com> * test(objectql): pin CROSS_DATASOURCE_TRANSACTION_WRITE_CODE against its class and the barrel Row 2 of this batch, graded and pinned on its own, in the shape row 1 spells out. Case 2 drives all three write operations: the message embeds the operation by design while the code deliberately does not, so the three messages differ and the three codes are equal -- which is what stops a future per-operation message split taking the code with it. It also pins the four fields a caller reads to apply the remedy the message prescribes (split the unit per datasource), since recognising the refusal is the step that recovery begins with. Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ Co-authored-by: Claude <noreply@anthropic.com> * chore(changeset): minor for the two transaction-seam code constants Both rows are additive widening of a published surface and nothing is removed, so each is graded `minor` on its own line rather than as one lump. The entry records what a consumer gains, why `code` and not `instanceof`, and that the wire strings are byte-identical to the literals they replace. Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ Co-authored-by: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent ddfbf04 commit eddd612

5 files changed

Lines changed: 334 additions & 4 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
"@objectstack/objectql": minor
3+
---
4+
5+
Both transaction-seam refusals publish their error `code` as an importable constant.
6+
7+
`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.
8+
9+
Two new exports from `@objectstack/objectql`, each graded on its own:
10+
11+
- `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`.**
12+
- `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`.**
13+
14+
**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.
15+
16+
**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.
17+
18+
**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.
19+
20+
**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.
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #16159 row 2 of 2 in this batch — `CrossDatasourceTransactionWriteError`
5+
* publishes its ADR-0112 `code` as an importable constant.
6+
*
7+
* ## Why this row is the batch's strongest case for the affordance
8+
*
9+
* This refusal is one a caller is meant to RECOVER from, not merely log: the
10+
* class's own docblock records the decided behaviour as "refuse, by name,
11+
* before anything runs", and the remedy it prescribes — "split the work into
12+
* per-datasource units and have the caller reconcile them explicitly" — is
13+
* something a caller implements AROUND this exact refusal. Recognising it by
14+
* `instanceof` is what #14936 measured as silently false across the realm split
15+
* this package declares in its own `exports`; recognising it by `code` meant
16+
* re-authoring the wire string, until now.
17+
*
18+
* ⚠️ This refusal carries NO `status` field, so ADR-0112's `code` + `status`
19+
* minimum reduces here to `code` plus the fields that discriminate the refusal.
20+
* ⛔ Inventing a `status` would be new published surface, which is not what this
21+
* card converts.
22+
*
23+
* The five facts and the reasoning behind each are spelled out in
24+
* `transaction-unsupported-code-constant.test.ts` (same batch, same shape,
25+
* same file). The load-bearing points repeated here are (1) the literal
26+
* spelling is the byte-identity fence and must NOT be "simplified" into a
27+
* constant compare, and (5) the cross-realm case is the control without which
28+
* the whole file would pass just as happily against an `instanceof`
29+
* recommendation.
30+
*
31+
* ⭐ Case 2 drives all three write operations. The MESSAGE embeds the operation
32+
* by design while the CODE deliberately does not; pinning all three is what
33+
* stops a future per-operation message split taking the code with it.
34+
*/
35+
36+
import { describe, it, expect } from 'vitest';
37+
import {
38+
CrossDatasourceTransactionWriteError,
39+
CROSS_DATASOURCE_TRANSACTION_WRITE_CODE,
40+
} from './transaction-errors.js';
41+
import * as barrel from './index.js';
42+
43+
describe('#16159 CrossDatasourceTransactionWriteError publishes its code as a constant', () => {
44+
it('the constant holds the exact wire string it replaced', () => {
45+
expect(CROSS_DATASOURCE_TRANSACTION_WRITE_CODE).toBe('ERR_CROSS_DATASOURCE_TRANSACTION_WRITE');
46+
});
47+
48+
it('the constant IS the code every operation carries — the code does not branch on the verb', () => {
49+
const operations = ['insert', 'update', 'delete'] as const;
50+
const errors = operations.map(
51+
(operation) =>
52+
new CrossDatasourceTransactionWriteError('crm_invoice', operation, 'billing', 'default'),
53+
);
54+
55+
for (const err of errors) {
56+
expect(err.code).toBe(CROSS_DATASOURCE_TRANSACTION_WRITE_CODE);
57+
expect(err.name).toBe('CrossDatasourceTransactionWriteError');
58+
}
59+
60+
// The four fields are what a `code` match buys a caller: which write, on
61+
// which object, and the two datasources whose divergence caused the
62+
// refusal. They are how a caller splits the unit per datasource, which is
63+
// the remedy the message prescribes.
64+
const [insertError] = errors;
65+
expect(insertError.object).toBe('crm_invoice');
66+
expect(insertError.operation).toBe('insert');
67+
expect(insertError.datasource).toBe('billing');
68+
expect(insertError.transactionDatasource).toBe('default');
69+
70+
// The MESSAGE embeds the operation by design; the CODE deliberately does
71+
// not, so the three messages differ while the three codes are equal.
72+
expect(new Set(errors.map((e) => e.message)).size).toBe(3);
73+
// Fail-closed, and the sentence a caller is told to trust.
74+
expect(insertError.message).toContain('Nothing was written');
75+
});
76+
77+
it('it is re-exported from the package barrel, which is where a consumer reaches it', () => {
78+
// Identity, not equality: a barrel that re-declared the string instead of
79+
// re-exporting the constant would satisfy `toBe` on the VALUE while having
80+
// re-introduced the second spelling this card exists to remove.
81+
expect(barrel.CROSS_DATASOURCE_TRANSACTION_WRITE_CODE).toBe(
82+
CROSS_DATASOURCE_TRANSACTION_WRITE_CODE,
83+
);
84+
});
85+
86+
it("the barrel's constant and the barrel's already-exported class name the same refusal", () => {
87+
const err = new barrel.CrossDatasourceTransactionWriteError(
88+
'crm_payment',
89+
'update',
90+
'ledger',
91+
'default',
92+
);
93+
expect(err.code).toBe(barrel.CROSS_DATASOURCE_TRANSACTION_WRITE_CODE);
94+
});
95+
96+
it("a `code` compare matches the OTHER realm's copy — the exact case `instanceof` gets wrong", () => {
97+
class CrossDatasourceTransactionWriteErrorOtherRealmCopy extends Error {
98+
readonly code = 'ERR_CROSS_DATASOURCE_TRANSACTION_WRITE';
99+
}
100+
const fromOtherRealm = new CrossDatasourceTransactionWriteErrorOtherRealmCopy();
101+
102+
// THE CONTROL — see the header.
103+
expect(fromOtherRealm instanceof CrossDatasourceTransactionWriteError).toBe(false);
104+
expect(fromOtherRealm.code).toBe(CROSS_DATASOURCE_TRANSACTION_WRITE_CODE);
105+
});
106+
});

packages/objectql/src/index.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -245,12 +245,27 @@ export type { InsertManyRowOutcome } from './engine.js';
245245
// [#5696] Thrown by `transaction(cb, base, { require: true })` when the
246246
// datasource cannot give a real transaction. Exported so a caller that fails
247247
// closed can narrow on the class; `code` is the boundary-crossing identity.
248-
export { TransactionUnsupportedError } from './transaction-errors.js';
248+
// [#16159] `TRANSACTION_UNSUPPORTED_CODE` joins the class it names: the line
249+
// above already tells a caller that `code` is the boundary-crossing identity,
250+
// and offered nothing to import — so following it meant re-spelling the wire
251+
// string in the consumer's own package. The class stays exported exactly as it
252+
// was; this adds an affordance and removes nothing.
253+
export { TransactionUnsupportedError, TRANSACTION_UNSUPPORTED_CODE } from './transaction-errors.js';
249254
// [#5351/#5696] Thrown when a BUSINESS write inside an open transaction()
250255
// resolves to a driver that transaction does not cover. Append-only system
251256
// ledgers (lifecycle.class audit/telemetry/event) are carved out and never
252257
// raise it. Narrow on the class in-process; `code` crosses package boundaries.
253-
export { CrossDatasourceTransactionWriteError } from './transaction-errors.js';
258+
// [#16159] `CROSS_DATASOURCE_TRANSACTION_WRITE_CODE` joins its class for the same
259+
// reason (#14936: this package declares BOTH realms in its own `exports`, so a
260+
// consumer holding the other realm's copy of the class gets `instanceof` ===
261+
// false, silently). ⭐ Neither class in `transaction-errors.js` is published from
262+
// the lean `./core` entry, so batteries-only constants introduce no asymmetry
263+
// here — class and constant are reachable from exactly one entry point, the same
264+
// one. #16260 owns that question for the classes that ARE on `./core`.
265+
export {
266+
CrossDatasourceTransactionWriteError,
267+
CROSS_DATASOURCE_TRANSACTION_WRITE_CODE,
268+
} from './transaction-errors.js';
254269

255270
// [#4550] The delete-dispatch contract, exported so a TEST DOUBLE that stands
256271
// in for the engine can import the producer's own decision rather than

packages/objectql/src/transaction-errors.ts

Lines changed: 88 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,57 @@
1010
* can exist.
1111
*/
1212

13+
/**
14+
* [#16159] The ADR-0112 `code` {@link TransactionUnsupportedError} carries, as a
15+
* constant a consumer can import instead of re-spelling.
16+
*
17+
* This module's own header already says these errors "identify themselves by a
18+
* `code` field rather than by `instanceof`, for the reason `DriverConnectError`
19+
* already records: the check has to survive crossing a package boundary, where
20+
* two copies of this module can exist" — and until now offered nothing to
21+
* import. The only way to FOLLOW that published instruction was to re-author
22+
* the wire string in the consumer's own package, which acquires a
23+
* `check:error-code-provenance` stamp site there and is then free to drift from
24+
* what this engine throws, with no compile error to say so.
25+
*
26+
* ⛔ The string is byte-identical to the literal it replaces. This moves where a
27+
* spelling lives, never what it says; renaming the code is a separate breaking
28+
* decision and never a rider on this conversion.
29+
*
30+
* ⚠️ `ERR_TRANSACTION_UNSUPPORTED` IS registered in `ERROR_CODE_LEDGER` under
31+
* `@objectstack/objectql`, so this declaration is a `constdef` stamp site
32+
* `check:error-code-provenance` DOES see (that gate skips unregistered codes),
33+
* and it is listed under this package's own owner key, which is what makes the
34+
* gate accept it. Equally, no row moves in
35+
* `packages/runtime/src/dispatcher-error-vocabulary.ts`: that table records
36+
* UNREGISTERED code sites, so a registered code is invisible to it by
37+
* construction. The two gates are exactly inverted — measured on this branch,
38+
* not assumed.
39+
*
40+
* The `_CODE` NAME and the bare `readonly code = TRANSACTION_UNSUPPORTED_CODE;`
41+
* spelling are load-bearing rather than cosmetic: the first is the shape
42+
* `check:error-code-provenance`'s `constdef` pattern can see, the second is the
43+
* shape `check:dispatcher-error-vocabulary` classifies as `classconst` — its
44+
* pattern requires the constant name to be followed by `;`, `,` or a newline,
45+
* so an `as const` suffix on the FIELD takes the site out of it. ⛔ Never rename
46+
* out of either shape to quiet a gate.
47+
*
48+
* Dropping the `ERR_` prefix from the CONSTANT's name follows this package's
49+
* existing precedents (`READONLY_FIELD_REJECTED_CODE`,
50+
* `HOOK_TARGET_REBIND_ERROR_CODE`). Re-exported from the `index.ts` barrel,
51+
* beside the class that is already published there.
52+
*
53+
* ⚠️ Placement is deliberate and differs from the sibling batch on this card:
54+
* the constant and its docblock sit ABOVE the class's own docblock, not between
55+
* that docblock and the class. Measured with `tsc --declaration`: two
56+
* consecutive JSDoc blocks are both emitted against the declaration that
57+
* follows them, so interposing this constant would move the class's
58+
* documentation onto the CONSTANT in the published `.d.ts` and leave the class
59+
* with none. That is a published-surface documentation regression, and the
60+
* grouped shape #16259 landed in `registry.ts` already avoids it.
61+
*/
62+
export const TRANSACTION_UNSUPPORTED_CODE = 'ERR_TRANSACTION_UNSUPPORTED' as const;
63+
1364
/**
1465
* `transaction(cb, base, { require: true })` was called on a datasource whose
1566
* driver has no `beginTransaction` (#5696 point 1).
@@ -23,7 +74,7 @@
2374
* posture `batchData`'s atomic gate established (ADR-0119 D4).
2475
*/
2576
export class TransactionUnsupportedError extends Error {
26-
readonly code = 'ERR_TRANSACTION_UNSUPPORTED' as const;
77+
readonly code = TRANSACTION_UNSUPPORTED_CODE;
2778

2879
constructor(public readonly datasource: string) {
2980
super(
@@ -37,6 +88,41 @@ export class TransactionUnsupportedError extends Error {
3788
}
3889
}
3990

91+
/**
92+
* [#16159] The ADR-0112 `code` {@link CrossDatasourceTransactionWriteError}
93+
* carries, as a constant a consumer can import instead of re-spelling.
94+
*
95+
* This row is the one on the card whose refusal a caller is most likely to
96+
* WANT to match rather than merely log: the class's docblock below says the
97+
* decided behaviour is to "refuse, by name, before anything runs", and the
98+
* remedy it prescribes ("split the work into per-datasource units and have the
99+
* caller reconcile them explicitly") is a recovery a caller implements around
100+
* this exact refusal. Matching it by `instanceof` is the thing #14936 measured
101+
* as silently false across the realm split this package declares in its own
102+
* `exports`; matching it by `code` meant re-spelling the string, until now.
103+
*
104+
* ⛔ The string is byte-identical to the literal it replaces — the conversion
105+
* moves where a spelling lives, never what it says.
106+
*
107+
* ⚠️ `ERR_CROSS_DATASOURCE_TRANSACTION_WRITE` is registered in
108+
* `ERROR_CODE_LEDGER` under `@objectstack/objectql`, so, exactly as for
109+
* {@link TRANSACTION_UNSUPPORTED_CODE}, this declaration is a `constdef` stamp
110+
* site `check:error-code-provenance` sees and accepts under this package's own
111+
* owner key, while `check:dispatcher-error-vocabulary` — which records only
112+
* UNREGISTERED sites — stays blind to it by construction.
113+
*
114+
* ⭐ Unlike the two `driver-connect-errors.ts` rows of this card's sweep,
115+
* NEITHER class in this file is published from the lean `./core` entry, so the
116+
* batteries-only placement of these constants introduces no asymmetry at all
117+
* here: class and constant are reachable from exactly the same entry point.
118+
* #16260 owns that question for the classes that ARE on `./core`; this file
119+
* adds nothing to its population.
120+
*
121+
* Naming, field spelling and placement follow
122+
* {@link TRANSACTION_UNSUPPORTED_CODE} exactly.
123+
*/
124+
export const CROSS_DATASOURCE_TRANSACTION_WRITE_CODE = 'ERR_CROSS_DATASOURCE_TRANSACTION_WRITE' as const;
125+
40126
/**
41127
* A BUSINESS write inside an open `transaction()` resolved to a driver that
42128
* transaction does not cover (#5696 point 2, decided together with #5351 by the
@@ -61,7 +147,7 @@ export class TransactionUnsupportedError extends Error {
61147
* `isSystemLedgerObject` in the engine.
62148
*/
63149
export class CrossDatasourceTransactionWriteError extends Error {
64-
readonly code = 'ERR_CROSS_DATASOURCE_TRANSACTION_WRITE' as const;
150+
readonly code = CROSS_DATASOURCE_TRANSACTION_WRITE_CODE;
65151

66152
constructor(
67153
public readonly object: string,

0 commit comments

Comments
 (0)