diff --git a/.changeset/23492-empty-version-token-refused.md b/.changeset/23492-empty-version-token-refused.md new file mode 100644 index 00000000000..bddfdfe03f0 --- /dev/null +++ b/.changeset/23492-empty-version-token-refused.md @@ -0,0 +1,27 @@ +--- +'@objectstack/rest': minor +'@objectstack/runtime': minor +--- + +fix(rest,runtime)!: a `/data` `PATCH` or `DELETE` whose version token can never carry a version (`expectedVersion: ''` or `false`, `?expectedVersion=`, an empty `If-Match`) is refused `400 VALIDATION_FAILED` on both doors, instead of being applied unguarded beside a stale `If-Match` + +Both doors of `PATCH /data/:object/:id` and `DELETE /data/:object/:id`, `RestServer` and the runtime dispatcher's `/data` domain, read an optimistic-concurrency token from the body's `expectedVersion` (`PATCH`) or `?expectedVersion` (`DELETE`), else from `If-Match`. They forwarded it to the protocol only when it was truthy. `''` and `false` are not nullish, so a stale `If-Match` beside them was never read, and they are not truthy, so no token was forwarded: the write ran unguarded and answered `200`. The protocol already refuses the quoted-empty tag `""` with `400 VALIDATION_FAILED`, under the ruling that a token shape that can never carry a version is refused, neither read as "no token" nor answered `409`. It never saw these tokens, because the door dropped them first, and it reads a blank string as no token. Both doors now judge every token a caller sends, or refuse it before the protocol is asked, and they answer each token alike. + +Clause-②: no (narrowing) + + + +**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + +What changes for you, on both doors, wherever `@objectstack/rest` or the runtime dispatcher serves these routes: + +- FROM a `PATCH` whose body `expectedVersion` is `''`, a blank string, `false` or an empty array, or a `DELETE` whose `?expectedVersion` is empty or blank, which wrote or deleted the record unguarded and answered `200` even beside a stale `If-Match`, TO `400 VALIDATION_FAILED` with the record unchanged, before the protocol is asked. +- FROM an empty `If-Match` sent with no body or query token, which wrote or deleted unguarded and answered `200`, TO the same `400 VALIDATION_FAILED`. An `If-Match` that is sent asks for a guard, and an empty one carries nothing to judge. A request that sends no `If-Match` at all is unchanged. +- FROM a body `expectedVersion` of `true`, an object or a non-empty array, which was judged as its string (`'true'`, `'[object Object]'`, the comma-joined elements), TO the same `400 VALIDATION_FAILED`. +- FROM a body `expectedVersion` of `0`, which was dropped like `''` and so wrote unguarded, TO a token judged as `'0'`, as every other number already was. On a real record that is `409 CONCURRENT_UPDATE`. + +How it renders: `RestServer` answers `{ error, code: 'VALIDATION_FAILED', fields: [], object }`, the envelope it already gives the protocol's `""` refusal. The dispatcher returns its own `400 VALIDATION_FAILED` envelope instead of throwing, so a `@objectstack/hono` `createHonoApp` host renders it as `400`. That is unlike the refusals the protocol throws out of `dispatch()`, `409 CONCURRENT_UPDATE` and the `400` for `""`, which that host still renders as `500` (the record is unchanged), because its catch-all reads only an error's `statusCode`. Rendering their declared status is a separate fix. + +Unchanged: a request with no token, a body `expectedVersion: null` (it is no token, so `If-Match` is read), a non-empty string or a non-zero number token (judged; `409` on a mismatch), and `""` (the protocol's own `400`). The body key is still stripped from the patch whatever its value. + +To write over a version you read, send that version (the record's `updated_at`) as the token. To write unguarded, omit both `expectedVersion` and `If-Match` rather than sending either one empty. The `@objectstack/client` SDK's `data.update` and `data.delete` already omit `If-Match` when `ifMatch` is empty, so an SDK caller is affected only when its `data` carries an `expectedVersion` that can never carry a version. diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index a7807776a42..8bcfb6dda9d 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -1733,6 +1733,60 @@ function sendMetaItemAbsent(res: any): void { }); } +/** + * [#23492] What a `/data` write door does with the optimistic-concurrency + * token it read — the body's `expectedVersion` (`PATCH`) or `?expectedVersion` + * (`DELETE`), else `If-Match` — before the protocol is asked. One of three: + * + * - ABSENT: the spelling consulted is `undefined` or `null`. The body key or + * the query name falls through to `If-Match`, and no `If-Match` either is + * the legal unguarded write. + * - JUDGED: a string with something in it, or a finite number. The door + * forwards its string, and the protocol compares it (`409 + * CONCURRENT_UPDATE` on a mismatch; the quoted-empty tag `""` is the + * protocol's own `400`, `MalformedVersionTokenError`). + * - REFUSED `400 VALIDATION_FAILED`: anything else the caller put in a token + * position — an empty or blank string, a boolean, an array, an object. It + * can never carry a version, so it is the protocol's ruled answer to `""` + * (maintainer decision #20 ①: a token shape that can never carry a version + * is refused, neither read as "no token" nor answered `409`), given here + * because the protocol never sees these: it reads a blank string as no + * token. + * + * What it replaces is a truthiness test. The door forwarded a token only when + * it was truthy, and `bodyVersion ?? If-Match` reads `If-Match` only when the + * body key is nullish, so `expectedVersion: ''` or `false` — not nullish, not + * truthy — hid a stale `If-Match`, and the write ran unguarded with a `200`. + * `?expectedVersion=` did the same to a `DELETE`, and an empty `If-Match` + * alone, the caller asking for a guard and sending nothing to judge, ran + * unguarded too. + * + * Returns the refusal's sentence, or `undefined` when the token is absent or + * judged. Both doors build the refusal with the one constructor, + * `validationFailure(sentence, [])` (`@objectstack/types`), and render it with + * their own classifier: this file THROWS it into the route's catch + * (`mapDataError`, the envelope the protocol's `""` refusal gets here), and the + * dispatcher hands it to `deps.errorFromThrown` and RETURNS the answer (a throw + * out of `dispatch()` reaches a `createHonoApp` host's catch-all, which reads + * only `statusCode` and would answer `500`). The runtime dispatcher's `/data` + * domain carries this function statement for statement (`versionTokenRefusal` + * in `packages/runtime/src/domains/data.ts`), so the two doors answer every + * token alike; `data-update-expected-version.test.ts` and + * `data-delete-expected-version.test.ts` hold them to it. + */ +function versionTokenRefusal(token: unknown, spelling: string): string | undefined { + if (token === undefined || token === null) return undefined; + if (typeof token === 'string' && token.trim() !== '') return undefined; + if (typeof token === 'number' && Number.isFinite(token)) return undefined; + const shape = typeof token === 'string' ? 'empty' + : Array.isArray(token) ? 'an array' + : typeof token === 'object' ? 'an object' + : `the ${typeof token} \`${String(token)}\``; + return `${spelling} is ${shape}, which can never carry a version, so it is refused rather than read as ` + + 'no token. Send the version you read (the record\'s `updated_at`), or omit both expectedVersion and ' + + 'If-Match to perform an unguarded write.'; +} + /** * [#20338] May this caller read PENDING metadata — a `sys_metadata` row in * `state: 'draft'`, unpublished authoring work? THE question every door that @@ -9067,6 +9121,18 @@ export class RestServer { const { expectedVersion: _drop, ...rest } = data as any; data = rest; } + // [#23492] A token that can never carry a version is + // refused here, before anything is asked, and every + // other present token is forwarded below: no token the + // caller sent is read as "no token". THROWN as + // `validationFailure`, so this route's catch answers it + // through `mapDataError`, in the envelope it already + // gives the protocol's own refusal of `""`. + const versionRefusal = versionTokenRefusal( + expectedVersion, + bodyVersion != null ? 'The body\'s `expectedVersion`' : 'The `If-Match` header', + ); + if (versionRefusal) throw validationFailure(versionRefusal, []); if (await this.enforceApiAccess(req, res, p, environmentId, 'update')) return; // [#3899] Same gate as create: the wire body is the bare // field patch (`expectedVersion` already stripped above), @@ -9078,7 +9144,7 @@ export class RestServer { object: req.params.object, id: req.params.id, data: data ?? {}, - ...(expectedVersion ? { expectedVersion: String(expectedVersion) } : {}), + ...(expectedVersion != null ? { expectedVersion: String(expectedVersion) } : {}), }; const parsedUpdateOne = (UpdateDataRequestSchema as any).safeParse(updateInput); if (!parsedUpdateOne.success) { @@ -9094,7 +9160,7 @@ export class RestServer { object: req.params.object, id: req.params.id, data: data ?? {}, - ...(expectedVersion ? { expectedVersion: String(expectedVersion) } : {}), + ...(expectedVersion != null ? { expectedVersion: String(expectedVersion) } : {}), ...(environmentId ? { environmentId } : {}), ...(context ? { context } : {}), }; @@ -9142,11 +9208,19 @@ export class RestServer { ? (req.query as any).expectedVersion : undefined; const expectedVersion = queryVersion ?? ifMatchHeader; + // [#23492] The `PATCH` above's rule, thrown the same + // way: a token that can never carry a version is + // refused, every other present token is forwarded. + const versionRefusal = versionTokenRefusal( + expectedVersion, + queryVersion != null ? 'The `expectedVersion` query parameter' : 'The `If-Match` header', + ); + if (versionRefusal) throw validationFailure(versionRefusal, []); if (await this.enforceApiAccess(req, res, p, environmentId, 'delete')) return; const deleteRequest: ServerScopedDataRequest = { object: req.params.object, id: req.params.id, - ...(expectedVersion ? { expectedVersion: String(expectedVersion) } : {}), + ...(expectedVersion != null ? { expectedVersion: String(expectedVersion) } : {}), ...(environmentId ? { environmentId } : {}), ...(context ? { context } : {}), }; diff --git a/packages/runtime/src/domains/data-delete-expected-version.test.ts b/packages/runtime/src/domains/data-delete-expected-version.test.ts index 6c6ab66c13f..ee5a1a832e4 100644 --- a/packages/runtime/src/domains/data-delete-expected-version.test.ts +++ b/packages/runtime/src/domains/data-delete-expected-version.test.ts @@ -314,6 +314,64 @@ describe('[#23265] the two doors refuse a stale token and a repeated one with th }, 60_000); }); +// ── A token that can never carry a version ──────────────────────────────────── + +/** + * [#23492] Both doors read `?expectedVersion ?? If-Match` and used to forward + * a token only when it was truthy, so `?expectedVersion=` (empty) hid a stale + * `If-Match` and the delete was APPLIED, `200`, on both doors (measured on + * this rig before the fix, the row read back gone). Now a token that can never + * carry a version is refused with the protocol's own answer to the + * quoted-empty tag `""`, `400 VALIDATION_FAILED`, before `deleteData` is + * asked; maintainer decision #20 ① rules that shape refused, neither read as + * "no token" nor answered `409`. `data-update-expected-version.test.ts` pins + * the body spellings on `PATCH`. + */ +const UNUSABLE: ReadonlyArray = [ + ['an empty ?expectedVersion beside a stale If-Match', { query: { expectedVersion: '' }, headers: { 'If-Match': STALE } }], + ['an empty ?expectedVersion and no If-Match', { query: { expectedVersion: '' } }], + ['a blank ?expectedVersion beside a stale If-Match', { query: { expectedVersion: ' ' }, headers: { 'If-Match': STALE } }], + ['an empty If-Match and no ?expectedVersion', { headers: { 'If-Match': '' } }], +]; + +const refused = (a: Answer) => + expect({ status: a.status, code: a.code }, JSON.stringify(a)).toEqual({ status: 400, code: 'VALIDATION_FAILED' }); + +for (const [doorName, boot] of DOORS) { + describe(`[#23492] ${doorName}: DELETE /data/:object/:id refuses a token that can never carry a version`, () => { + for (const [shape, opts] of UNUSABLE) { + it(`${shape}: 400 VALIDATION_FAILED, deleteData never asked, the row kept`, async () => { + const door = await boot(); + refused(await door.delete(opts)); + expect(door.forwarded()).toEqual([]); + expect(await door.present()).toBe(true); + }, 60_000); + } + + it('the refusal is the protocol\'s own answer to the quoted-empty tag: the same status and code', async () => { + const quoted = await boot(); + const protocolAnswer = await quoted.delete({ query: { expectedVersion: '""' } }); + // The protocol, not the door, refused this one: the token reached it. + expect(quoted.forwarded()).toEqual(['""']); + const empty = await boot(); + const doorAnswer = await empty.delete({ query: { expectedVersion: '' } }); + expect({ status: doorAnswer.status, code: doorAnswer.code }) + .toEqual({ status: protocolAnswer.status, code: protocolAnswer.code }); + }, 60_000); + }); +} + +describe('[#23492] the two doors refuse each token that can never carry a version alike', () => { + for (const [shape, opts] of UNUSABLE) { + it(`${shape}: the same status, code and sentence`, async () => { + const answers: Answer[] = []; + for (const [, boot] of DOORS) answers.push(await (await boot()).delete(opts)); + const [rest, catchAllAnswer] = answers; + expect(catchAllAnswer).toEqual(rest); + }, 60_000); + } +}); + // ── The dispatcher's fallback deleter ───────────────────────────────────────── /** diff --git a/packages/runtime/src/domains/data-update-expected-version.test.ts b/packages/runtime/src/domains/data-update-expected-version.test.ts index 8d0339f727b..2a8ae9866ec 100644 --- a/packages/runtime/src/domains/data-update-expected-version.test.ts +++ b/packages/runtime/src/domains/data-update-expected-version.test.ts @@ -294,7 +294,8 @@ for (const [doorName, boot] of DOORS) { * row. What either door does with them is `RestServer`'s reading, not this * file's: a token is the body key when it is not nullish (else `If-Match`), * the key is stripped from the patch whatever its value, and a token is - * forwarded as its string only when it is truthy. + * forwarded as its string unless it can never carry a version, which is + * refused (#23492, pinned in its own block below). */ const EDGE_SHAPES: ReadonlyArray PatchOptions]> = [ ['a stale token', () => ({ body: { name: AFTER, expectedVersion: STALE } })], @@ -334,6 +335,82 @@ describe('[#23462] the two doors answer each token shape with the same status, c }, 60_000); }); +// ── A token that can never carry a version ──────────────────────────────────── + +/** + * [#23492] Both doors read `bodyVersion ?? If-Match` and used to forward a + * token only when it was truthy. `''` and `false` are not nullish, so the + * stale `If-Match` beside them was never read, and not truthy, so no token was + * forwarded: the update was APPLIED, `200`, on both doors (measured on this + * rig before the fix, the row read back as written). Now a token in a token + * position that can never carry a version is refused with the protocol's own + * answer to the quoted-empty tag `""`, `400 VALIDATION_FAILED`, before + * `updateData` is asked; maintainer decision #20 ① rules that shape refused, + * neither read as "no token" nor answered `409`. + */ +const UNUSABLE: ReadonlyArray = [ + ["a body expectedVersion of '' beside a stale If-Match", { body: { name: AFTER, expectedVersion: '' }, headers: { 'If-Match': STALE } }], + ["a body expectedVersion of '' and no If-Match", { body: { name: AFTER, expectedVersion: '' } }], + ['a body expectedVersion of false beside a stale If-Match', { body: { name: AFTER, expectedVersion: false }, headers: { 'If-Match': STALE } }], + ['a body expectedVersion of false and no If-Match', { body: { name: AFTER, expectedVersion: false } }], + ['a blank body expectedVersion beside a stale If-Match', { body: { name: AFTER, expectedVersion: ' ' }, headers: { 'If-Match': STALE } }], + ['a body expectedVersion of true', { body: { name: AFTER, expectedVersion: true } }], + ['a body expectedVersion that is an array, beside a stale If-Match', { body: { name: AFTER, expectedVersion: [] }, headers: { 'If-Match': STALE } }], + ['an empty If-Match and no body token', { body: { name: AFTER }, headers: { 'If-Match': '' } }], +]; + +const refused = (a: Answer) => + expect({ status: a.status, code: a.code }, JSON.stringify(a)).toEqual({ status: 400, code: 'VALIDATION_FAILED' }); + +for (const [doorName, boot] of DOORS) { + describe(`[#23492] ${doorName}: PATCH /data/:object/:id refuses a token that can never carry a version`, () => { + for (const [shape, opts] of UNUSABLE) { + it(`${shape}: 400 VALIDATION_FAILED, updateData never asked, the row unchanged`, async () => { + const door = await boot(); + refused(await door.patch(opts)); + expect(door.forwarded()).toEqual([]); + expect(await door.name()).toBe(BEFORE); + }, 60_000); + } + + it('the refusal is the protocol\'s own answer to the quoted-empty tag: the same status and code', async () => { + const quoted = await boot(); + const protocolAnswer = await quoted.patch({ body: { name: AFTER, expectedVersion: '""' } }); + // The protocol, not the door, refused this one: the token reached it. + expect(quoted.forwarded().map((f) => f.expectedVersion)).toEqual(['""']); + const empty = await boot(); + const doorAnswer = await empty.patch({ body: { name: AFTER, expectedVersion: '' } }); + expect({ status: doorAnswer.status, code: doorAnswer.code }) + .toEqual({ status: protocolAnswer.status, code: protocolAnswer.code }); + }, 60_000); + + it('control: a null body token is no token, so a stale If-Match beside it is judged and refused 409', async () => { + const door = await boot(); + conflict(await door.patch({ body: { name: AFTER, expectedVersion: null }, headers: { 'If-Match': STALE } })); + expect(door.forwarded().map((f) => f.expectedVersion)).toEqual([STALE]); + expect(await door.name()).toBe(BEFORE); + }, 60_000); + + it('control: a body token of 0 is a number, judged as "0" (refused 409), never dropped', async () => { + const door = await boot(); + conflict(await door.patch({ body: { name: AFTER, expectedVersion: 0 }, headers: { 'If-Match': door.version } })); + expect(door.forwarded()).toEqual([{ expectedVersion: '0', data: { name: AFTER } }]); + expect(await door.name()).toBe(BEFORE); + }, 60_000); + }); +} + +describe('[#23492] the two doors refuse each token that can never carry a version alike', () => { + for (const [shape, opts] of UNUSABLE) { + it(`${shape}: the same status, code and sentence`, async () => { + const answers: Answer[] = []; + for (const [, boot] of DOORS) answers.push(await (await boot()).patch(opts)); + const [rest, catchAllAnswer] = answers; + expect(catchAllAnswer).toEqual(rest); + }, 60_000); + } +}); + // ── The dispatcher's fallback updater ───────────────────────────────────────── /** diff --git a/packages/runtime/src/domains/data.ts b/packages/runtime/src/domains/data.ts index daba6c63ab3..8804b5772d2 100644 --- a/packages/runtime/src/domains/data.ts +++ b/packages/runtime/src/domains/data.ts @@ -34,6 +34,9 @@ import type { DomainHandlerDeps, DomainRoute } from '../domain-handler-registry. // [#23265] …and the repo's one sentence for a repeated single-valued // parameter, which the delete's `?expectedVersion` answers. import { assertFilterParamSuppliedOnce, repeatedQueryParamMessage } from '@objectstack/rest'; +// [#23492] The dispatcher's one constructor for a `400 VALIDATION_FAILED` +// answer, which a refused version token returns in this transport's envelope. +import { validationFailure, VALIDATION_FAILED_STATUS } from '../validation-failure.js'; /** * [#23265] The request's `If-Match`, from either header shape a host hands @@ -50,6 +53,37 @@ function requestIfMatch(headers: unknown): unknown { return h['if-match'] ?? h['If-Match']; } +/** + * [#23492] What this domain's `PATCH` and `DELETE` do with the + * optimistic-concurrency token they read, before `callData` is asked: absent + * (`undefined` or `null`, which falls through to `If-Match`), judged (a string + * with something in it, or a finite number, forwarded as its string), or + * refused `400 VALIDATION_FAILED` (an empty or blank string, a boolean, an + * array, an object: a token shape that can never carry a version, maintainer + * decision #20 ①). It replaces a truthiness test that let `expectedVersion: ''` + * or `false`, and `?expectedVersion=`, hide a stale `If-Match` and run the + * write unguarded. + * + * `RestServer`'s twin routes ask the same function, statement for statement + * (`versionTokenRefusal` in `packages/rest/src/rest-server.ts`, whose docblock + * carries the full argument), so the two doors answer every token alike; + * `data-update-expected-version.test.ts` and + * `data-delete-expected-version.test.ts` hold them to it. Returns the refusal's + * sentence, or `undefined` when the token is absent or judged. + */ +function versionTokenRefusal(token: unknown, spelling: string): string | undefined { + if (token === undefined || token === null) return undefined; + if (typeof token === 'string' && token.trim() !== '') return undefined; + if (typeof token === 'number' && Number.isFinite(token)) return undefined; + const shape = typeof token === 'string' ? 'empty' + : Array.isArray(token) ? 'an array' + : typeof token === 'object' ? 'an object' + : `the ${typeof token} \`${String(token)}\``; + return `${spelling} is ${shape}, which can never carry a version, so it is refused rather than read as ` + + 'no token. Send the version you read (the record\'s `updated_at`), or omit both expectedVersion and ' + + 'If-Match to perform an unguarded write.'; +} + export function createDataDomain(deps: DomainHandlerDeps): DomainRoute { return { prefix: '/data', @@ -170,9 +204,16 @@ export async function handleDataRequest(deps: DomainHandlerDeps, path: string, m // whole body over as the patch, so through the `@objectstack/hono` // catch-all a stale `If-Match` update was APPLIED, and a body // `expectedVersion` reached the engine as a field it refused as - // unknown. The key is stripped from the patch whatever its value, - // and a token is forwarded only when truthy: the twin's own - // statements, so both doors send `updateData` the same thing. + // unknown. The key is stripped from the patch whatever its value: + // the twin's own statements, so both doors send `updateData` the + // same thing. + // + // [#23492] …and every token the caller sent is judged or refused: + // one that can never carry a version is refused `400` before + // `callData` is asked (`versionTokenRefusal`), and every other + // present token is forwarded. A truthiness test used to decide + // the forward, so `expectedVersion: ''` or `false` hid a stale + // `If-Match` and the update ran unguarded, on both doors. const bodyVersion = (body && typeof body === 'object') ? body.expectedVersion : undefined; const expectedVersion = bodyVersion ?? requestIfMatch(_context.request?.headers); let data = body; @@ -180,12 +221,22 @@ export async function handleDataRequest(deps: DomainHandlerDeps, path: string, m const { expectedVersion: _token, ...patch } = body; data = patch; } + const versionRefusal = versionTokenRefusal( + expectedVersion, + bodyVersion != null ? 'The body\'s `expectedVersion`' : 'The `If-Match` header', + ); + if (versionRefusal) { + return { + handled: true, + response: deps.errorFromThrown(validationFailure(versionRefusal, []), VALIDATION_FAILED_STATUS), + }; + } // Spec: returns UpdateDataResponse = { object, id, record } const result = await actionExec.callData(deps, _context, 'update', { object: objectName, id, data, - ...(expectedVersion ? { expectedVersion: String(expectedVersion) } : {}), + ...(expectedVersion != null ? { expectedVersion: String(expectedVersion) } : {}), }, _context.dataDriver, _context.environmentId, _context.executionContext); return { handled: true, response: deps.success(result) }; } @@ -210,8 +261,13 @@ export async function handleDataRequest(deps: DomainHandlerDeps, path: string, m // request that sent no token. A repeated `?expectedVersion` is // refused first, as the twin's arity gate refuses it, before the // service is asked; a one-element array is one occurrence and an - // empty one is none. A token is forwarded only when non-empty, the - // twin's own test, so both doors send `deleteData` the same thing. + // empty one is none. + // + // [#23492] The `PATCH` above's rule, the twin's own: a token that + // can never carry a version is refused `400` before `callData` is + // asked, and every other present token is forwarded. A truthiness + // test used to decide the forward, so `?expectedVersion=` hid a + // stale `If-Match` and the delete ran unguarded, on both doors. const rawVersion = query?.expectedVersion; if (Array.isArray(rawVersion) && rawVersion.length > 1) { return { @@ -221,10 +277,20 @@ export async function handleDataRequest(deps: DomainHandlerDeps, path: string, m } const queryVersion = Array.isArray(rawVersion) ? rawVersion[0] : rawVersion; const expectedVersion = queryVersion ?? requestIfMatch(_context.request?.headers); + const versionRefusal = versionTokenRefusal( + expectedVersion, + queryVersion != null ? 'The `expectedVersion` query parameter' : 'The `If-Match` header', + ); + if (versionRefusal) { + return { + handled: true, + response: deps.errorFromThrown(validationFailure(versionRefusal, []), VALIDATION_FAILED_STATUS), + }; + } const result = await actionExec.callData(deps, _context, 'delete', { object: objectName, id, - ...(expectedVersion ? { expectedVersion: String(expectedVersion) } : {}), + ...(expectedVersion != null ? { expectedVersion: String(expectedVersion) } : {}), }, _context.dataDriver, _context.environmentId, _context.executionContext); return { handled: true, response: deps.success(result) }; }