Skip to content

Commit 88cd0ab

Browse files
fix(metadata-protocol): an undeclared hook abort is HOOK_ABORTED on the per-object batch row too (#29977)
<!-- claude-projects-attribution: {"github_login":"os-support-ai"} --> _Requested by **Yibo Wang** · [project thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwB7J3TmFTYMYQrfpye6GQNg9)_ Fixes #29894 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么**:钩子用自由文本中止写入且未声明错误码时,`POST /api/v1/data/:object/batch` 的行错误码现在是 `HOOK_ABORTED`,不再是 `VALIDATION_ERROR`,与 `/data`、`/createMany`、`/batch` 一致。 - **为什么改**:#27597 给这种中止定了唯一线上码 `HOOK_ABORTED`,但行映射 `toRowApiError` 仍按 400 推导出 `VALIDATION_ERROR`,批量客户端会把业务拒绝当成输入格式错误。 - **风险与回滚**:只换这一种中止的行码(文案、`httpStatus` 不变);钩子声明了码的照旧。回滚即 revert 本 PR。 - **先看**:`packages/metadata-protocol/src/protocol-client-failures.ts` 的 `toRowApiError` / `isUndeclaredHookAbort`;新 pin `packages/qa/dogfood/test/batch-row-hook-aborted.dogfood.test.ts`。 - **两分钟验证**:`pnpm --filter @objectstack/dogfood exec vitest run test/batch-row-hook-aborted.dogfood.test.ts`,预期 14 passed。 - **需要你做的**:无,席位落地。 ## What changes - **`@objectstack/metadata-protocol` (patch).** `toRowApiError` now answers `UNDECLARED_HOOK_REFUSAL_CODE` (`HOOK_ABORTED`, from `@objectstack/types`) for a sandboxed hook body's refusal that spelled no `code` and is served at a 4xx. That is the same test `resolveThrownHttpError` applies on the doors #27672 moved, restated over the row's inputs (`isUndeclaredHookAbort`). A registered code the body declared still wins. An unregistered one still gets the code its status derives, as on the resolver's doors. The row's `message` (the hook's sentence) and the absent `httpStatus` don't change. - **FROM → TO.** Row code `VALIDATION_ERROR` → `HOOK_ABORTED` for an undeclared hook abort, on every row that shares the mapper: `POST /api/v1/data/:object/batch` with `operation` `create` / `update` / `upsert` / `delete`, in every mode (plain, `atomic`, `continueOnError`), and the per-row results of `updateManyData` / `deleteManyData`. - **How the move is declared.** It is declared the way #27672 (PR #27898) declared the same `VALIDATION_ERROR` → `HOOK_ABORTED` move on its doors: `patch`, `Clause-②: no`, `Metadata-protocol: none`. No input that was accepted before is refused, no key is added, and no code is minted (`HOOK_ABORTED` is #27597's ledgered code). ### Same-pattern sweep (what I checked) - `protocol-batch-data.ts`: the create/upsert loop (`:586`), the update loop (`:887`) and the delete loop (`:1079`) all call `toRowApiError`, so one fix covers them. The atomic siblings stay `NOT_ATTEMPTED` / `ROLLED_BACK`, and the causal row now carries `HOOK_ABORTED` (pinned live). - `clientFacingFailureCode` (same file): it builds the metadata `failed[]` code limb. No hook abort reaches it, so it is left as is. - **Import rows (`POST /data/:object/import`, `core/src/utils/import-runner.ts` `toFailedResult`) do not share the mapper.** Measured on the same boot, the row answers `{ error: <sentence>, code: "IMPORT_ROW_FAILED" }`, which is the import report's own generic code and not `VALIDATION_ERROR`. This PR leaves it alone, because the order scopes the sweep to doors that share the mapper. Whether the import row should also say `HOOK_ABORTED` is reported to the seat as a possible follow-up. ## Pins - **New, live:** `packages/qa/dogfood/test/batch-row-hook-aborted.dogfood.test.ts`. It boots once (14 cases). §1 covers the card's three whole-request doors (`/data`, `/createMany`, `/batch`): `400`, the sentence, and `HOOK_ABORTED`. §2 covers the per-object batch row: `create`, `create atomic`, `create continueOnError`, `update`, `upsert` (existing id), `delete`, each with the sentence and `HOOK_ABORTED`, and nothing written. §3 is the control: a hook that declares `RECORD_LOCKED` keeps it on all four doors. - **Moved:** `packages/metadata-protocol/src/protocol.batch-row-sandbox-refusal.test.ts` §1/§2 (8 cases) now expect `HOOK_ABORTED`, and §1 compares the row's code to the `/data` door's own `body.code` instead of the code its status derives. §3's unregistered-code case still pins `VALIDATION_ERROR` and is unchanged. **Ablation, once.** I restored `protocol-client-failures.ts` from `origin/main` and rebuilt metadata-protocol. The dogfood file then went 6 failed / 8 passed: exactly the six §2 rows, each `expected 'VALIDATION_ERROR' to be 'HOOK_ABORTED'`, while §1 and the §3 controls stayed green. The unit file went 8 failed / 12 passed. After restoring the fix and rebuilding: 14/14 and 20/20. ## Verification - `pnpm --filter @objectstack/metadata-protocol test`: exit 0 (312 files, 22339 passed). `typecheck`: exit 0. - `pnpm --filter @objectstack/dogfood typecheck`: exit 0. The new dogfood file: 14 passed. - `pnpm lint`: exit 0. - Build: `turbo run build --filter=@objectstack/dogfood^...` exit 0, plus the seven dists `check:dual-build-cjs-loads` reads. - `node scripts/pm/dispatch-gates.mjs --commands` on `f286c443`: 63 derived, 63 run, all exit 0. `--ran` reports "63 run, 0 NOT-MEASURED". `check:dual-build-cjs-loads` exited 3 until those dists were built, then 0. --- 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01WGufzeV9ZbECNr1Z2G2Puu <!-- signed: domain:server#6 · os-support-ai · session_01WGufzeV9ZbECNr1Z2G2Puu --> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: objectstack-fleet[bot] <332303061+objectstack-fleet[bot]@users.noreply.github.com>
1 parent 9049be2 commit 88cd0ab

4 files changed

Lines changed: 205 additions & 9 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/metadata-protocol': patch
3+
---
4+
5+
fix(metadata-protocol): a hook that aborts with free text answers `HOOK_ABORTED` on the per-object batch row too, not `VALIDATION_ERROR`
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
- **Before.** Since #27597 (and #27672) a sandboxed hook body that aborts with free text (`throw new Error('…')`, no `code`) answers `/api/v1/data/:object`, `/createMany` and `/api/v1/batch` with `400 HOOK_ABORTED` and the hook's sentence. `POST /api/v1/data/:object/batch` answered the same abort as a row `{ code: 'VALIDATION_ERROR', message: <sentence> }`: the row mapper (`toRowApiError`) derived the code from the `400` the doors serve, so a bulk client branching on the code read a business refusal as malformed input.
11+
- **Now.** The row answers `HOOK_ABORTED` (the `UNDECLARED_HOOK_REFUSAL_CODE` the other doors read). The value moves, from `VALIDATION_ERROR` to `HOOK_ABORTED`, on every row that shares the mapper: `POST /api/v1/data/:object/batch` with `operation` `create`, `update`, `upsert` or `delete`, in every mode (plain, `atomic`, `continueOnError`), and the per-row results of `updateManyData` / `deleteManyData`.
12+
- **Unchanged.** A code the hook declared still wins (a registered code is its own row code; an unregistered one still gets the code its status derives). The row's `message` is still the hook's sentence and `httpStatus` is still absent when the body declared no status. A crashed body and a non-sandbox producer answer as before. No error code is added; `HOOK_ABORTED` is the one #27597 registered.

‎packages/metadata-protocol/src/protocol-client-failures.ts‎

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
// A MOVE, not a redesign: every declaration below is byte-identical to the text that stood
88
// above `ObjectStackProtocolImplementation`; only the imports and the export list at the foot
99
// are new. `protocol.ts` re-exports what it exported.
10-
import { declaredHttpStatus, declaredUserMessage, isSandboxOrigin, resolveThrownHttpError, sandboxBusinessMessage } from '@objectstack/types';
10+
import { declaredHttpStatus, declaredUserMessage, isSandboxOrigin, resolveThrownHttpError, sandboxBusinessMessage, UNDECLARED_HOOK_REFUSAL_CODE } from '@objectstack/types';
1111
import type { ApiError, BatchOperationResult } from '@objectstack/spec/api';
1212
import { ErrorCode, standardErrorCodeForHttpStatus } from '@objectstack/spec/api';
1313
import { zodIssuesToMetadataIssues } from './protocol-metadata-issues.js';
@@ -139,6 +139,9 @@ export type BatchDataRowResult = BatchOperationResult;
139139
* answers, the `code` limb derives from the `400` the doors serve (the
140140
* `declared ?? 400` of `classifyDataError`, `@objectstack/types`). A code or
141141
* status the body DID declare still wins, as for every other producer.
142+
* [#29894] Since #27597 those doors answer that refusal `HOOK_ABORTED`, not the
143+
* 400's `VALIDATION_ERROR`, so the row does too: {@link isUndeclaredHookAbort}
144+
* picks {@link UNDECLARED_HOOK_REFUSAL_CODE} before the status derivation.
142145
*
143146
* ⛔ `httpStatus` is NOT stamped for it. That limb carries only a status the
144147
* producer declared (above), and this body declared none.
@@ -150,14 +153,37 @@ export function toRowApiError(err: any, fallback: string): ApiError {
150153
? (err.code as ApiError['code'])
151154
: undefined;
152155
const { declaredStatus } = resolveThrownHttpError(err);
153-
const servedStatus = declaredStatus ?? (sandboxRefusalSentence(err) !== undefined ? 400 : undefined);
156+
const refusal = sandboxRefusalSentence(err) !== undefined;
157+
const servedStatus = declaredStatus ?? (refusal ? 400 : undefined);
154158
return {
155-
code: thrown ?? (servedStatus !== undefined ? standardErrorCodeForHttpStatus(servedStatus) : 'INTERNAL_ERROR'),
159+
code: thrown
160+
?? (isUndeclaredHookAbort(err, refusal, servedStatus) ? UNDECLARED_HOOK_REFUSAL_CODE
161+
: servedStatus !== undefined ? standardErrorCodeForHttpStatus(servedStatus) : 'INTERNAL_ERROR'),
156162
message: clientFacingRowFailureText(err, fallback),
157163
...(declaredStatus !== undefined ? { httpStatus: declaredStatus } : {}),
158164
};
159165
}
160166

167+
/**
168+
* [#29894] Is this row failure a hook ABORT that declared no code?
169+
*
170+
* #27597 gave a sandboxed body's free-text refusal one wire code,
171+
* {@link UNDECLARED_HOOK_REFUSAL_CODE} (`HOOK_ABORTED`), on `/data`, and
172+
* #27672 moved `resolveThrownHttpError`'s doors to it. The batch row kept the
173+
* code its `400` derives (`VALIDATION_ERROR`), so `POST /data/:object/batch`
174+
* named the same refusal differently from `/data`, `/createMany` and
175+
* `/batch`. This is the resolver's own test, restated over the row's inputs:
176+
* a sandboxed refusal ({@link sandboxRefusalSentence} answered), no `code`
177+
* spelled at all (an unregistered one is a declaration and keeps the
178+
* status-derived code, as on the resolver's doors), served at a 4xx.
179+
*/
180+
function isUndeclaredHookAbort(err: unknown, refusal: boolean, servedStatus: number | undefined): boolean {
181+
const code = (err as { code?: unknown } | null | undefined)?.code;
182+
return refusal
183+
&& !(typeof code === 'string' && code !== '')
184+
&& servedStatus !== undefined && servedStatus >= 400 && servedStatus < 500;
185+
}
186+
161187
/**
162188
* [commit 65846bc46] Is this thrown value the ENGINE's unique-violation envelope?
163189
*

‎packages/metadata-protocol/src/protocol.batch-row-sandbox-refusal.test.ts‎

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@
1111
* created.')`:
1212
*
1313
* - `POST /api/v1/data/:object`, `/createMany` and `/import` answered the
14-
* sentence (`400 VALIDATION_ERROR` on the first two, the row's `error` on the
14+
* sentence (`400 VALIDATION_ERROR` on the first two — `HOOK_ABORTED` since
15+
* #27597, and on the batch row since #29894 — the row's `error` on the
1516
* third);
1617
* - `POST /api/v1/data/:object/batch` answered `200` with the row
1718
* `{ code: 'INTERNAL_ERROR', message: 'The create of this record failed. The
@@ -179,13 +180,15 @@ async function batchCreate(error: Error): Promise<any> {
179180
// ─── §1 ──────────────────────────────────────────────────────────────────────
180181

181182
describe('[#24561] §1 the card: a sandboxed refusal on a batch create reads as the hook\'s sentence', () => {
182-
it('the row carries the sentence and the 400 floor code, and no withhold warning is written', async () => {
183+
// [#29894] The code is `HOOK_ABORTED` (#27597's ledgered code for an undeclared
184+
// hook abort), not the 400 floor `VALIDATION_ERROR` the row used to derive.
185+
it('the row carries the sentence and HOOK_ABORTED, and no withhold warning is written', async () => {
183186
const warn = captureWarn();
184187
try {
185188
const res = await batchCreate(sandboxRefusal());
186189

187190
expect(res.results[0].success).toBe(false);
188-
expect(res.results[0].errors).toEqual([{ code: 'VALIDATION_ERROR', message: SENTENCE }]);
191+
expect(res.results[0].errors).toEqual([{ code: 'HOOK_ABORTED', message: SENTENCE }]);
189192
// No `httpStatus`: the body declared none, and the limb reports only a
190193
// declared status (#8570). The row's shape is what it was.
191194
expect(res.results[0].errors[0]).not.toHaveProperty('httpStatus');
@@ -197,14 +200,16 @@ describe('[#24561] §1 the card: a sandboxed refusal on a batch create reads as
197200
}
198201
});
199202

200-
it('the row agrees with the data door: the same sentence, and the code the door\'s status names', async () => {
203+
it('the row agrees with the data door: the same sentence, and the same code', async () => {
201204
const refusal = sandboxRefusal();
202205
const door = mapDataError(refusal, OBJECT);
203206
const res = await batchCreate(refusal);
204207

205208
expect(door.status).toBe(400);
206209
expect(res.results[0].errors[0].message).toBe(door.body.error);
207-
expect(res.results[0].errors[0].code).toBe(standardErrorCodeForHttpStatus(door.status));
210+
// [#29894] The door's own code, not the code its status derives.
211+
expect(door.body.code).toBe('HOOK_ABORTED');
212+
expect(res.results[0].errors[0].code).toBe(door.body.code);
208213
});
209214
});
210215

@@ -225,7 +230,7 @@ describe('[#24561] §2 every loop that shares the row sink answers the same way'
225230
const warn = captureWarn();
226231
try {
227232
const res = await run(refusing());
228-
expect(res.results[0].errors).toEqual([{ code: 'VALIDATION_ERROR', message: SENTENCE }]);
233+
expect(res.results[0].errors).toEqual([{ code: 'HOOK_ABORTED', message: SENTENCE }]);
229234
expect(JSON.stringify(res)).not.toMatch(WRAPPER_RE);
230235
expect(withholdCalls(warn)).toHaveLength(0);
231236
} finally {
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// [#29894] An undeclared hook abort answers `HOOK_ABORTED` on the per-object
4+
// batch row too, the code `/data`, `/createMany` and `/batch` give it.
5+
//
6+
// #27597 gave a sandboxed hook body's free-text refusal (`throw new Error('…')`,
7+
// no `code`) the ledgered wire code `HOOK_ABORTED`. The per-object batch door
8+
// (`POST /data/:object/batch`) reports a row failure through `toRowApiError`
9+
// (`@objectstack/metadata-protocol`), which derived the code from the 400 the
10+
// sibling doors serve, so its row said `VALIDATION_ERROR` for the same
11+
// refusal. This file is the card's four-door reproduction over the real HTTP
12+
// stack of one boot, plus the batch row's other modes and verbs, and the
13+
// control that a code the hook DID declare still wins on every door.
14+
15+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
16+
import { bootStack, type VerifyStack } from '@objectstack/verify';
17+
import { defineStack } from '@objectstack/spec';
18+
import { ObjectSchema, Field } from '@objectstack/spec/data';
19+
20+
const SENTENCE = 'A QA budget line needs an approver before it can be recorded.';
21+
const DECLARED = 'This budget period is locked.';
22+
23+
const obj = (name: string) => ObjectSchema.create({
24+
name,
25+
sharingModel: 'public_read_write',
26+
label: name,
27+
pluralLabel: name,
28+
fields: { name: Field.text({ label: 'Name', required: true }) },
29+
});
30+
31+
const throwUndeclared = { language: 'js', source: `throw new Error(${JSON.stringify(SENTENCE)});` } as const;
32+
33+
const stack29894 = defineStack({
34+
manifest: {
35+
id: 'com.dogfood.batch-row-hook-aborted',
36+
namespace: 'bra',
37+
version: '0.0.0',
38+
type: 'app',
39+
name: 'Batch Row Hook Aborted Fixture',
40+
description: 'Hooks that abort a write in their own words, with and without a declared code.',
41+
},
42+
objects: [obj('bra_ins'), obj('bra_upd'), obj('bra_del'), obj('bra_declared')],
43+
hooks: [
44+
{ name: 'bra_ins_refuse', object: 'bra_ins', events: ['beforeInsert'], body: throwUndeclared },
45+
{ name: 'bra_upd_refuse', object: 'bra_upd', events: ['beforeUpdate'], body: throwUndeclared },
46+
{ name: 'bra_del_refuse', object: 'bra_del', events: ['beforeDelete'], body: throwUndeclared },
47+
{
48+
name: 'bra_declared_refuse',
49+
object: 'bra_declared',
50+
events: ['beforeInsert'],
51+
body: { language: 'js', source: `throw Object.assign(new Error(${JSON.stringify(DECLARED)}), { code: 'RECORD_LOCKED' });` },
52+
},
53+
],
54+
} as any);
55+
56+
const SYS = { context: { isSystem: true } } as const;
57+
58+
/** The failing row of a per-object batch answer: the one that is not a sibling's NOT_ATTEMPTED / ROLLED_BACK. */
59+
function causalRowError(body: any): any {
60+
const rows: any[] = body?.results ?? body?.data?.results ?? body?.error?.details?.results ?? [];
61+
const errors = rows.flatMap((r) => r?.errors ?? []);
62+
return errors.find((e) => e?.code !== 'NOT_ATTEMPTED' && e?.code !== 'ROLLED_BACK');
63+
}
64+
65+
describe('[#29894] an undeclared hook abort is HOOK_ABORTED on every data door, the batch row included', () => {
66+
let stack: VerifyStack;
67+
let admin = '';
68+
let ql: any;
69+
let updId = '';
70+
let delId = '';
71+
72+
beforeAll(async () => {
73+
stack = await bootStack(stack29894 as never, {});
74+
admin = await stack.signIn();
75+
ql = await stack.kernel.getServiceAsync('objectql');
76+
updId = String((await ql.insert('bra_upd', { name: 'seed' }, SYS))?.id);
77+
delId = String((await ql.insert('bra_del', { name: 'seed' }, SYS))?.id);
78+
}, 240_000);
79+
80+
afterAll(async () => {
81+
await stack?.stop?.();
82+
});
83+
84+
describe('§1 the card: the whole-request doors answer 400 HOOK_ABORTED with the sentence', () => {
85+
const doors: Array<[string, () => Promise<Response>]> = [
86+
['POST /data/OBJECT', () => stack.apiAs(admin, 'POST', '/data/bra_ins', { name: 'x' })],
87+
['POST /data/OBJECT/createMany', () => stack.apiAs(admin, 'POST', '/data/bra_ins/createMany', [{ name: 'x' }])],
88+
['POST /batch', () => stack.apiAs(admin, 'POST', '/batch', { operations: [{ action: 'create', object: 'bra_ins', data: { name: 'x' } }] })],
89+
];
90+
for (const [door, send] of doors) {
91+
it(door, async () => {
92+
const res = await send();
93+
const body: any = await res.json();
94+
expect(res.status, JSON.stringify(body)).toBe(400);
95+
expect(JSON.stringify(body)).toContain(SENTENCE);
96+
expect(JSON.stringify(body)).toContain('"code":"HOOK_ABORTED"');
97+
});
98+
}
99+
});
100+
101+
describe('§2 the per-object batch row answers the same code, in every mode and verb', () => {
102+
const cases: Array<[string, () => Promise<Response>]> = [
103+
['create', () => stack.apiAs(admin, 'POST', '/data/bra_ins/batch', { operation: 'create', records: [{ data: { name: 'x' } }] })],
104+
['create atomic', () => stack.apiAs(admin, 'POST', '/data/bra_ins/batch', { operation: 'create', records: [{ data: { name: 'x' } }, { data: { name: 'y' } }], options: { atomic: true } })],
105+
['create continueOnError', () => stack.apiAs(admin, 'POST', '/data/bra_ins/batch', { operation: 'create', records: [{ data: { name: 'x' } }, { data: { name: 'y' } }], options: { continueOnError: true } })],
106+
['update', () => stack.apiAs(admin, 'POST', '/data/bra_upd/batch', { operation: 'update', records: [{ id: updId, data: { name: 'moved' } }] })],
107+
['upsert (existing id)', () => stack.apiAs(admin, 'POST', '/data/bra_upd/batch', { operation: 'upsert', records: [{ id: updId, data: { name: 'moved' } }] })],
108+
['delete', () => stack.apiAs(admin, 'POST', '/data/bra_del/batch', { operation: 'delete', records: [{ id: delId }] })],
109+
];
110+
for (const [label, send] of cases) {
111+
it(label, async () => {
112+
const res = await send();
113+
const body: any = await res.json();
114+
const row = causalRowError(body);
115+
expect(row, JSON.stringify(body)).toBeDefined();
116+
expect(row.message, JSON.stringify(body)).toBe(SENTENCE);
117+
expect(row.code, `#29894: the row answers the code its sibling doors give the same abort: ${JSON.stringify(body)}`).toBe('HOOK_ABORTED');
118+
});
119+
}
120+
121+
it('nothing was written or moved', async () => {
122+
expect(await ql.find('bra_ins', { where: {}, ...SYS })).toHaveLength(0);
123+
const [upd] = await ql.find('bra_upd', { where: {}, ...SYS });
124+
expect(upd?.name).toBe('seed');
125+
expect(await ql.find('bra_del', { where: {}, ...SYS })).toHaveLength(1);
126+
});
127+
});
128+
129+
describe('§3 control: a code the hook declared still wins on every door', () => {
130+
const doors: Array<[string, () => Promise<Response>]> = [
131+
['POST /data/OBJECT', () => stack.apiAs(admin, 'POST', '/data/bra_declared', { name: 'x' })],
132+
['POST /data/OBJECT/createMany', () => stack.apiAs(admin, 'POST', '/data/bra_declared/createMany', [{ name: 'x' }])],
133+
['POST /batch', () => stack.apiAs(admin, 'POST', '/batch', { operations: [{ action: 'create', object: 'bra_declared', data: { name: 'x' } }] })],
134+
];
135+
for (const [door, send] of doors) {
136+
it(door, async () => {
137+
const res = await send();
138+
const text = await res.text();
139+
expect(text).toContain(DECLARED);
140+
expect(text).toContain('"code":"RECORD_LOCKED"');
141+
expect(text).not.toContain('HOOK_ABORTED');
142+
});
143+
}
144+
145+
it('POST /data/OBJECT/batch', async () => {
146+
const res = await stack.apiAs(admin, 'POST', '/data/bra_declared/batch', { operation: 'create', records: [{ data: { name: 'x' } }] });
147+
const body: any = await res.json();
148+
const row = causalRowError(body);
149+
expect(row?.message, JSON.stringify(body)).toBe(DECLARED);
150+
expect(row?.code, JSON.stringify(body)).toBe('RECORD_LOCKED');
151+
});
152+
});
153+
});

0 commit comments

Comments
 (0)