Skip to content

Commit f19fbb8

Browse files
fix(core): an undeclared hook abort is HOOK_ABORTED on the import row too (#29996)
<!-- claude-projects-attribution: {"github_login":"os-support-ai"} --> _Requested by **Yibo Wang** · [project thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwBCyXvgM1LSLj8HhcsPZvUkP)_ Fixes #29978 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么**:钩子用自由文本中止写入且未声明错误码时,`POST /api/v1/data/:object/import` 与异步导入作业的行错误码现在是 `HOOK_ABORTED`,不再是 `IMPORT_ROW_FAILED`,与 `/data`、`/createMany`、`/batch` 和每个 `/data/:object/batch` 行一致。 - **为什么改**:#27597 给这种中止定了唯一线上码 `HOOK_ABORTED`,PR #29977 补上了批量行;导入行仍答 `IMPORT_ROW_FAILED`,导入客户端无法把业务规则拒绝与其它失败行区分开。 - **风险与回滚**:只换这一种中止的行码(行文案本就是钩子原话,不变);钩子声明了码的、钩子崩溃的、声明 5xx 的、驱动故障与畸形行都照旧。回滚即 revert 本 PR。 - **先看**:`packages/core/src/utils/import-runner.ts` 的 `toFailedResult` 那一行 `code` 与新函数 `isUndeclaredHookAbort`;新 pin 在 `packages/qa/dogfood/test/batch-row-hook-aborted.dogfood.test.ts` 的 §4。 - **两分钟验证**:`pnpm --filter @objectstack/dogfood exec vitest run test/batch-row-hook-aborted.dogfood.test.ts`,预期 23 passed。 - **需要你做的**:无,席位落地。 ## What changes - **`@objectstack/core` (patch).** `toFailedResult`, the core import runner's row mapper, now answers `UNDECLARED_HOOK_REFUSAL_CODE` (`HOOK_ABORTED`, from `@objectstack/types`) where it used to fall back to `IMPORT_ROW_FAILED`, when the failure is a sandboxed hook body's refusal that declared no code. The precedence is unchanged above it: a field finding's code, then the code the thrown error declared (registered or not), and only then the new arm, before `IMPORT_ROW_FAILED`. The row's `error` was already the hook's sentence (`sandboxBusinessMessage`, #22694) and does not move. - **The judge.** The new private `isUndeclaredHookAbort(err)` asks `resolveThrownHttpError(err, 400).code === UNDECLARED_HOOK_REFUSAL_CODE`. That resolver is where the constant is declared and the one place that answers it for exactly this throw: a sandbox business refusal, no `code` spelled, served at a 4xx. The `400` fallback is the status the data doors serve an undeclared sandbox refusal at. - **Why not import `isUndeclaredHookAbort` from metadata-protocol** (the dispatch's mechanism assumption, measured and falsified): it is a module-private function in `@objectstack/metadata-protocol`, which depends on `@objectstack/core` (`packages/metadata-protocol/package.json`), so core importing it is a cycle. Exporting it from `@objectstack/types` would add a public export (`Clause-②: yes (widening)`) and move a second package. Its own doc comment says it is "the resolver's own test, restated over the row's inputs", so the resolver is the shared judge both rows reach, and no second reading of `innerMessage`, `code` or the status is written here. - **Why not adopt `mapDataError`'s verdict** (the other candidate, measured): its wrapper fallback answers `HOOK_ABORTED` for a plain `Error` that merely carries the `hook 'x' threw:` text with no `innerMessage`. The row deliberately relays that error's own text (§4 control in `import-runner-sandbox-refusal-row.test.ts`), and the batch row's `isUndeclaredHookAbort` does not count it as an abort either. Adopting the door's verdict would have moved that population too. - **Import row-code vocabulary, measured before the change.** `ImportRowResultSchema.code` (`packages/spec/src/api/export.zod.ts`) is `z.string().optional()`, an open string: the row already carries field-level codes (`required`, `min_value`), a hook's declared code verbatim, `UNIQUE_VIOLATION`, `INVALID_FIELD`, `DUPLICATE_MATCH_KEY` and `IMPORT_ROW_FAILED`. No published enum moves, and `HOOK_ABORTED` is already a ledger row (`@objectstack/types`, `error-code-ledger.zod.ts`). Hand-written docs (`content/docs` outside `references/`, `skills/`) name `IMPORT_ROW_FAILED` nowhere. So `Metadata-protocol: none`, `Clause-②: no`, `patch`, as the claim declared. ## Tests - `packages/core/src/utils/import-runner-sandbox-refusal-row.test.ts`: §1's four write paths (inline `createData`, batched `createManyData` degraded to per row, partial-success `insertManyData`, the update half of an upsert) now expect `UNDECLARED_HOOK_REFUSAL_CODE`. New §6 pins door-to-row agreement (the row's `code` IS `mapDataError(err).body.code`, with an anti-vacuity check that the door says `HOOK_ABORTED`), a declared 4xx with no code, and six controls that keep their own code: a declared registered code, a declared unregistered code, a crashed body, a declared 5xx with no code, a plain error carrying only the wrapper text, and a driver fault. - `packages/qa/dogfood/test/batch-row-hook-aborted.dogfood.test.ts` §4, beside PR #29977's batch-row pin and on the same boot: the card's fixture (`beforeInsert` throwing free text) through `POST /data/:object/import` and `POST /data/:object/import/jobs`. Each door: the abort is `HOOK_ABORTED` with the hook's sentence; a declared `RECORD_LOCKED` wins; a crashed body keeps `IMPORT_ROW_FAILED`; a malformed row (blank required `name`) keeps its cell code. Then nothing was written. ## Acceptance notes - **The malformed-row control pins `required`, not `IMPORT_ROW_FAILED`.** The dispatch expected a malformed row to keep `IMPORT_ROW_FAILED`. Measured live on both doors, a blank required cell answers `{ code: 'required', field: 'name', error: 'Name is required' }` before and after this change, so that is what is pinned. `IMPORT_ROW_FAILED` is the fallback for a write failure that names no code at all; the crashed-body control holds that population. - The §4 pin lives inside the file's one top-level `describe` so it shares the boot (one `bootStack` per file) instead of booting a second stack. - `pnpm lint` (repo-wide eslint) was not run locally: `.claude/agents/os-dev.md` puts repo scans on CI and wins over the dispatch where they conflict. eslint with `--no-inline-config` on the three touched source files exited 0, and CI's `Lint & Repo Gates` runs the full scan. ## Verification All readings below are on head `91ca524d` (objectstack-ai/objectstack), taken in this branch's own worktree. <details><summary>Build, suites, typecheck, eslint</summary> - `pnpm --filter '@objectstack/core...' build` under `os-verify-lock`: `VERDICT command-exit 0`. - `pnpm --filter @objectstack/core test`: exit 0, `Test Files 133 passed (133)`, `Tests 2952 passed (2952)`. - `pnpm --filter @objectstack/core typecheck`: exit 0 (`check:test-typecheck: OK`, 4/4 pinned debt signatures held). - `pnpm --filter '@objectstack/dogfood^...' build && pnpm --filter @objectstack/dogfood exec vitest run test/batch-row-hook-aborted.dogfood.test.ts`: `VERDICT command-exit 0`, `Tests 23 passed (23)`. - `pnpm --filter @objectstack/dogfood typecheck` (8 GB heap): exit 0. - `pnpm --filter '@objectstack/rest^...' build` then the rest import and hook-refusal files (`rest-write-route-hook-refusal-sentence.ledger`, `import-undo-controls`, `rest-hook-refusal-code-parity`, `import-runner-unique-violation-row`, `import-row-report-field-20701`, `import-row-schema-drift-20701`): `Tests 657 passed (657)`. No rest pin asserted the import row's `IMPORT_ROW_FAILED` for an abort, so none flips there. - `eslint --no-inline-config` on the three touched files: exit 0. </details> <details><summary>Ablation (once, the mapping reverted)</summary> Mutation by `scripts/ablation-replace.mjs`: the anchor `(isUndeclaredHookAbort(err) ? UNDECLARED_HOOK_REFUSAL_CODE : 'IMPORT_ROW_FAILED')` (x1 → x0) replaced by a string-marked `'IMPORT_ROW_FAILED'` (x0 → x1), blob `6780021e` → `e8a5bddf`. `pnpm --filter @objectstack/core build` exit 0; `ablation-dist-preflight.mjs @objectstack/core 'ablated-29978'`: marker present in `dist/index.js` and `dist/index.cjs`. - dogfood file: exit 1, `Tests 2 failed | 21 passed (23)`. The two reds are `sync` and `jobs`: "an undeclared abort is HOOK_ABORTED with the hook's sentence", `expected 'IMPORT_ROW_FAILED' to be 'HOOK_ABORTED'`, the row reading `{"row":1,"ok":false,"action":"failed","error":"A QA budget line needs an approver before it can be recorded.","code":"IMPORT_ROW_FAILED"}`. Every control stayed green. - core unit file: exit 1, 6 failed: §1's four write paths and §6's two abort cases, each `"code": "IMPORT_ROW_FAILED"` received. §6's six controls stayed green. Restore: `git checkout HEAD -- packages/core/src/utils/import-runner.ts`, `git diff HEAD` empty, `git hash-object` = HEAD blob `6780021ee723`, core rebuilt, `ablation-dist-preflight … --absent`: marker absent from all 14 built files, working tree clean. </details> <details><summary>Derived gates (dispatch-gates --commands, 64 commands)</summary> `node scripts/pm/dispatch-gates.mjs --ran` on `91ca524d`: `64 derived famil(ies) accounted for — 64 run, 0 NOT-MEASURED`. Two exited 3 (prerequisite) on the first pass and were rerun after the prerequisite was met: `check-plugin-teardown-shape.mjs --self-test` after `git fetch --unshallow origin main`, and `check:dual-build-cjs-loads` after building the seven packages it named (studio, client-react, embedder-openai, knowledge-memory, knowledge-ragflow, service-cluster-redis, service-knowledge). Both then exited 0. Every recorded exit code: ```text node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0 node scripts/check-adr-0087-registration.mjs --self-test :: exit 0 node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0 node scripts/check-closing-keyword-parity.mjs :: exit 0 node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0 node scripts/check-comment-mask-adoption.mjs :: exit 0 node scripts/check-comment-mask-adoption.mjs --self-test :: exit 0 node scripts/check-comment-mask-corpus.mjs :: exit 0 node scripts/check-dev-prereqs.mjs --self-test :: exit 0 node scripts/check-dts-emitted.mjs --self-test :: exit 0 node scripts/check-empty-changeset.mjs --base origin/main :: exit 0 node scripts/check-empty-changeset.mjs --self-test :: exit 0 node scripts/check-issue-citations.mjs :: exit 0 node scripts/check-plugin-teardown-shape.mjs :: exit 0 node scripts/ci/assert-turbo-remote-cache.mjs --self-test :: exit 0 node scripts/ci/plan-test-core-shards.mjs --self-test :: exit 0 node scripts/ci/prune-turbo-cache.mjs --self-test :: exit 0 node scripts/docs-audit/check-affected-docs.mjs :: exit 0 node scripts/docs-audit/check-drift-comment.mjs :: exit 0 node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0 node scripts/release-pending-publish.mjs --self-test :: exit 0 pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0 pnpm --filter @objectstack/spec run check:empty-state :: exit 0 pnpm --filter @objectstack/spec run check:liveness :: exit 0 pnpm --filter @objectstack/spec run check:strictness-ledger :: exit 0 pnpm --filter @objectstack/spec run check:variant-docs :: exit 0 pnpm check:changeset-gate-self-tests :: exit 0 pnpm check:ci-filter-parity :: exit 0 pnpm check:closed-ceremony-text :: exit 0 pnpm check:cross-package-test-inputs :: exit 0 pnpm check:dispatcher-error-vocabulary :: exit 0 pnpm check:doc-authoring :: exit 0 pnpm check:driver-memory-census :: exit 0 pnpm check:dts-closure :: exit 0 pnpm check:engine-double-contract :: exit 0 pnpm check:error-code-casing :: exit 0 pnpm check:gitlink-declared :: exit 0 pnpm check:issue-citations :: exit 0 pnpm check:kernel-hook-pairs :: exit 0 pnpm check:keyed-text-bounds :: exit 0 pnpm check:lean-entry-closure :: exit 0 pnpm check:logger-receiver-detach :: exit 0 pnpm check:nul-bytes :: exit 0 pnpm check:objectql-double-limit :: exit 0 pnpm check:org-identifier :: exit 0 pnpm check:page-declaration-shape :: exit 0 pnpm check:platform-object-tenancy-census :: exit 0 pnpm check:published-files :: exit 0 pnpm check:query-options-erasure :: exit 0 pnpm check:refd-timer-probe :: exit 0 pnpm check:registry-log-declared :: exit 0 pnpm check:rest-log-spy-declared :: exit 0 pnpm check:slot-lookup :: exit 0 pnpm check:sourcemap-no-sources-content :: exit 0 pnpm check:system-context-census :: exit 0 pnpm check:test-source-alias :: exit 0 pnpm check:tier-file-adoption :: exit 0 pnpm check:type-check-coverage :: exit 0 pnpm check:type-check-debt :: exit 0 pnpm check:undeclared-dep-imports :: exit 0 pnpm check:watch-hint-literal :: exit 0 pnpm check:where-matcher :: exit 0 node scripts/check-plugin-teardown-shape.mjs --self-test :: exit 0 pnpm check:dual-build-cjs-loads :: exit 0 ``` </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01WGufzizyYnG1BNnFxDRaQb)_ <!-- signed: domain:server#6 · os-support-ai · session_01WGufzizyYnG1BNnFxDRaQb --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f7aab89 commit f19fbb8

4 files changed

Lines changed: 143 additions & 8 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/core': patch
3+
---
4+
5+
fix(core): a hook that aborts with free text answers `HOOK_ABORTED` on the import row too, not `IMPORT_ROW_FAILED`
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
- **Before.** A sandboxed hook body that aborts with free text (`throw new Error('…')`, no `code`) answers `/api/v1/data/:object`, `/createMany`, `/api/v1/batch` and every `POST /api/v1/data/:object/batch` row with `HOOK_ABORTED` and the hook's sentence. `POST /api/v1/data/:object/import` and the async import job answered the same abort as a row `{ ok: false, code: 'IMPORT_ROW_FAILED', error: <sentence> }`, so an import client branching on the row's `code` could not tell a business rule's refusal from any other failed row.
11+
- **Now.** The import row answers `HOOK_ABORTED` (the `UNDECLARED_HOOK_REFUSAL_CODE` the other doors read), judged by `resolveThrownHttpError`, the resolver that declares that code. The value moves, from `IMPORT_ROW_FAILED` to `HOOK_ABORTED`, on every row the core import runner builds from a write failure: the synchronous import, the async import job's stored results, the create and update halves of an upsert, and every write path (single-row, batched, partial-success).
12+
- **Unchanged.** A code the hook declared still wins, registered or not. The row's `error` is still the hook's sentence. A body that crashed, a refusal that declared a 5xx status, a driver fault and every other row failure keep the code they had. The row's `code` is an open string in `ImportRowResultSchema`, so no schema moves, and no error code is added: `HOOK_ABORTED` is the one the ledger already registers.

‎packages/core/src/utils/import-runner-sandbox-refusal-row.test.ts‎

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,16 @@
3232
* §5 (#22718) holds the two sandboxed failures the door answers as a fault, a
3333
* crashed body and a refusal that declared a 5xx: their row reads the door's
3434
* sentence.
35+
*
36+
* [#29978] The row's `code` for a refusal that declared none is
37+
* `HOOK_ABORTED` (`UNDECLARED_HOOK_REFUSAL_CODE`), the code the create door
38+
* answers for it, where it was `IMPORT_ROW_FAILED`; §1 pins it on each write
39+
* path and §6 pins door-to-row agreement and the failures that keep
40+
* `IMPORT_ROW_FAILED`.
3541
*/
3642

3743
import { describe, it, expect, vi } from 'vitest';
38-
import { mapDataError, INTERNAL_ERROR_MESSAGE } from '@objectstack/types';
44+
import { mapDataError, INTERNAL_ERROR_MESSAGE, UNDECLARED_HOOK_REFUSAL_CODE } from '@objectstack/types';
3945
import { runImport, sanitizeRowError, type ImportProtocolLike } from './import-runner';
4046
import type { ExportFieldMeta } from './import-field-meta.js';
4147

@@ -98,7 +104,7 @@ describe('[#22694] §1 — each write path reports the hook\'s sentence', () =>
98104
const summary = await runImport({ ...baseOpts, p, rows: [{ name: 'r0' }, { name: 'r1' }, { name: 'r2' }] });
99105

100106
expect(summary.created).toBe(2);
101-
expect(summary.results[1]).toEqual({ row: 2, ok: false, action: 'failed', error: SENTENCE, code: 'IMPORT_ROW_FAILED' });
107+
expect(summary.results[1]).toEqual({ row: 2, ok: false, action: 'failed', error: SENTENCE, code: UNDECLARED_HOOK_REFUSAL_CODE });
102108
expect(JSON.stringify(summary)).not.toMatch(WRAPPER_RE);
103109
});
104110

@@ -110,7 +116,7 @@ describe('[#22694] §1 — each write path reports the hook\'s sentence', () =>
110116
// Anti-vacuity: the batch really ran and really degraded.
111117
expect(createManyData).toHaveBeenCalledTimes(1);
112118
expect(p.createData).toHaveBeenCalledTimes(3);
113-
expect(summary.results[1]).toMatchObject({ ok: false, action: 'failed', error: SENTENCE, code: 'IMPORT_ROW_FAILED' });
119+
expect(summary.results[1]).toMatchObject({ ok: false, action: 'failed', error: SENTENCE, code: UNDECLARED_HOOK_REFUSAL_CODE });
114120
expect(JSON.stringify(summary)).not.toMatch(WRAPPER_RE);
115121
});
116122

@@ -126,7 +132,7 @@ describe('[#22694] §1 — each write path reports the hook\'s sentence', () =>
126132

127133
expect(insertManyData).toHaveBeenCalledTimes(1);
128134
expect(createManyData).not.toHaveBeenCalled();
129-
expect(summary.results[1]).toMatchObject({ ok: false, action: 'failed', error: SENTENCE, code: 'IMPORT_ROW_FAILED' });
135+
expect(summary.results[1]).toMatchObject({ ok: false, action: 'failed', error: SENTENCE, code: UNDECLARED_HOOK_REFUSAL_CODE });
130136
expect(JSON.stringify(summary)).not.toMatch(WRAPPER_RE);
131137
});
132138

@@ -140,7 +146,7 @@ describe('[#22694] §1 — each write path reports the hook\'s sentence', () =>
140146
const summary = await runImport({ ...baseOpts, writeMode: 'upsert', matchFields: ['name'], p, rows: [{ name: 'r1' }] });
141147

142148
expect(updateData).toHaveBeenCalledTimes(1);
143-
expect(summary.results[0]).toMatchObject({ ok: false, action: 'failed', error: SENTENCE, code: 'IMPORT_ROW_FAILED' });
149+
expect(summary.results[0]).toMatchObject({ ok: false, action: 'failed', error: SENTENCE, code: UNDECLARED_HOOK_REFUSAL_CODE });
144150
});
145151
});
146152

@@ -223,3 +229,32 @@ describe('[#22718] §5 — a sandboxed body the door answers as a fault reads as
223229
expect(JSON.stringify(row)).not.toMatch(leak);
224230
});
225231
});
232+
233+
describe('[#29978] §6 — an undeclared abort answers the create door\'s code', () => {
234+
it('the row\'s code is the code `mapDataError` answers for the same refusal', async () => {
235+
const err = () => sandboxRefusal(SENTENCE);
236+
const door = mapDataError(err(), OBJECT);
237+
// Anti-vacuity: the door really answers the abort with that code.
238+
expect(door.body.code).toBe('HOOK_ABORTED');
239+
240+
const row = await rowFor(err);
241+
expect(row).toEqual({ row: 1, ok: false, action: 'failed', error: SENTENCE, code: door.body.code });
242+
});
243+
244+
it('a refusal that declared a 4xx status but no code is still an abort', async () => {
245+
const row = await rowFor(() => sandboxRefusal(SENTENCE, { status: 422 }));
246+
expect(row).toMatchObject({ error: SENTENCE, code: UNDECLARED_HOOK_REFUSAL_CODE });
247+
});
248+
249+
it.each([
250+
['a declared registered code', () => sandboxRefusal(SENTENCE, { code: 'RECORD_LOCKED' }), 'RECORD_LOCKED'],
251+
['a declared unregistered code', () => sandboxRefusal(SENTENCE, { code: 'MZ_PERIOD_CLOSED' }), 'MZ_PERIOD_CLOSED'],
252+
['a body that CRASHED', sandboxCrash, 'IMPORT_ROW_FAILED'],
253+
['a refusal that declared a 5xx and no code', () => sandboxRefusal(SENTENCE, { status: 503 }), 'IMPORT_ROW_FAILED'],
254+
['a plain error carrying the wrapper text but no `innerMessage`', () => new Error(`hook 'g' threw: Error: ${SENTENCE}`), 'IMPORT_ROW_FAILED'],
255+
['a driver fault', () => new Error('SQLITE_BUSY: database is locked'), 'IMPORT_ROW_FAILED'],
256+
] as const)('CONTROL — %s keeps its own code', async (_label, err, code) => {
257+
const row = await rowFor(err);
258+
expect(row.code).toBe(code);
259+
});
260+
});

‎packages/core/src/utils/import-runner.ts‎

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import type { ExecutionContext } from '@objectstack/spec/kernel';
1212
import type { CreateDataRequest, FindDataRequest, GetMetaItemRequest, UpdateDataRequest, ValidateDataIssue, ValidateDataRequest, ValidateDataResponse } from '@objectstack/spec/api';
1313
import { isMatchKeySets } from '@objectstack/spec/api';
1414
import { bulkWrite, withTransientRetry, defaultIsTransientError, type BulkWriteRowResult } from './bulk-write.js';
15-
import { isUniqueViolationError, isEngineDuplicateRecordEnvelope, mapDataError, sandboxBusinessMessage, isSandboxOrigin, isSandboxOwnFault } from '@objectstack/types';
15+
import { isUniqueViolationError, isEngineDuplicateRecordEnvelope, mapDataError, sandboxBusinessMessage, isSandboxOrigin, isSandboxOwnFault, resolveThrownHttpError, UNDECLARED_HOOK_REFUSAL_CODE } from '@objectstack/types';
1616

1717
/**
1818
* import-runner — the shared row-processing core for bulk import.
@@ -527,6 +527,17 @@ export function sanitizeRowError(raw: unknown): string {
527527
* rule applies: a sandboxed error the door answers with a 5xx takes the door's
528528
* sentence. ⛔ Still not "every door 5xx": a driver error is neither a sandbox
529529
* origin nor a sandbox fault, and its row keeps {@link sanitizeRowError}'s text.
530+
*
531+
* ## An undeclared hook abort is `HOOK_ABORTED`, as on every other write door (#29978)
532+
*
533+
* A sandboxed body's free-text refusal that declared no code answers
534+
* {@link UNDECLARED_HOOK_REFUSAL_CODE} on `/data`, `/createMany`, `/batch` and
535+
* the per-object batch row. This row said `IMPORT_ROW_FAILED`, so an import
536+
* client branching on `code` could not tell a business rule's refusal from any
537+
* other failed row. {@link isUndeclaredHookAbort} now picks that code where
538+
* the row had none of its own: a finding's code and a code the body declared
539+
* still win, and every other failure keeps `IMPORT_ROW_FAILED`. The row's
540+
* sentence is unchanged; it was already the hook's.
530541
*/
531542
function toFailedResult(rowNo: number, err: unknown, objectName: string): ImportRowResult {
532543
const e = err as { code?: unknown; message?: unknown; fields?: unknown; field?: unknown } | null | undefined;
@@ -537,7 +548,7 @@ function toFailedResult(rowNo: number, err: unknown, objectName: string): Import
537548
if (adopted !== undefined) return adopted;
538549
}
539550
const thrownCode = isEngineDuplicateRecordEnvelope(e) ? 'UNIQUE_VIOLATION' : e?.code;
540-
const code = first?.code ?? thrownCode ?? 'IMPORT_ROW_FAILED';
551+
const code = first?.code ?? thrownCode ?? (isUndeclaredHookAbort(err) ? UNDECLARED_HOOK_REFUSAL_CODE : 'IMPORT_ROW_FAILED');
541552
const field = first?.field != null && first.field !== '' ? first.field : e?.field;
542553
const sandboxed = isSandboxOrigin(err) || isSandboxOwnFault(err);
543554
const sandboxFault = sandboxed && door.status >= 500 ? String(door.body.error) : undefined;
@@ -548,6 +559,24 @@ function toFailedResult(rowNo: number, err: unknown, objectName: string): Import
548559
};
549560
}
550561

562+
/**
563+
* [#29978] Is this row failure a hook ABORT that declared no code?
564+
*
565+
* Asked of `resolveThrownHttpError`, which declares
566+
* {@link UNDECLARED_HOOK_REFUSAL_CODE} and answers it for exactly that throw:
567+
* a sandboxed body's business refusal ({@link sandboxBusinessMessage}
568+
* answered), no `code` spelled, served at a 4xx. The fallback is `400`, the
569+
* status the data doors serve an undeclared sandbox refusal at, so a refusal
570+
* that declared no status resolves into the 4xx band, and one that declared a
571+
* 5xx stays a fault. This is the test the per-object batch row's
572+
* `toRowApiError` (`@objectstack/metadata-protocol`) restates; that package
573+
* sits above this one, so the resolver is the shared judge both reach.
574+
* ⛔ Never a second reading of `innerMessage`, `code` or the status here.
575+
*/
576+
function isUndeclaredHookAbort(err: unknown): boolean {
577+
return resolveThrownHttpError(err, 400).code === UNDECLARED_HOOK_REFUSAL_CODE;
578+
}
579+
551580
/** The head of a `fields` list when it is a finding (an object); a bare name or an empty list is none. */
552581
function firstFinding(fields: unknown): { field?: unknown; code?: unknown } | undefined {
553582
const head: unknown = Array.isArray(fields) ? fields[0] : undefined;

‎packages/qa/dogfood/test/batch-row-hook-aborted.dogfood.test.ts‎

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,12 @@
1111
// refusal. This file is the card's four-door reproduction over the real HTTP
1212
// stack of one boot, plus the batch row's other modes and verbs, and the
1313
// control that a code the hook DID declare still wins on every door.
14+
//
15+
// [#29978] §4 drives the same fixture through both import doors
16+
// (`POST /data/:object/import` and its async job): the import row's `code`
17+
// was `IMPORT_ROW_FAILED` for the abort, from the core import runner's
18+
// `toFailedResult`, and is now `HOOK_ABORTED` with the hook's sentence. A
19+
// declared code, a crashed body and a malformed row keep their own codes.
1420

1521
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
1622
import { bootStack, type VerifyStack } from '@objectstack/verify';
@@ -39,7 +45,7 @@ const stack29894 = defineStack({
3945
name: 'Batch Row Hook Aborted Fixture',
4046
description: 'Hooks that abort a write in their own words, with and without a declared code.',
4147
},
42-
objects: [obj('bra_ins'), obj('bra_upd'), obj('bra_del'), obj('bra_declared')],
48+
objects: [obj('bra_ins'), obj('bra_upd'), obj('bra_del'), obj('bra_declared'), obj('bra_crash'), obj('bra_plain')],
4349
hooks: [
4450
{ name: 'bra_ins_refuse', object: 'bra_ins', events: ['beforeInsert'], body: throwUndeclared },
4551
{ name: 'bra_upd_refuse', object: 'bra_upd', events: ['beforeUpdate'], body: throwUndeclared },
@@ -50,6 +56,8 @@ const stack29894 = defineStack({
5056
events: ['beforeInsert'],
5157
body: { language: 'js', source: `throw Object.assign(new Error(${JSON.stringify(DECLARED)}), { code: 'RECORD_LOCKED' });` },
5258
},
59+
// [#29978] A body that CRASHED rather than refused: a fault, not an abort.
60+
{ name: 'bra_crash_fault', object: 'bra_crash', events: ['beforeInsert'], body: { language: 'js', source: 'const v = null; v.missing();' } },
5361
],
5462
} as any);
5563

@@ -150,4 +158,55 @@ describe('[#29894] an undeclared hook abort is HOOK_ABORTED on every data door,
150158
expect(row?.code, JSON.stringify(body)).toBe('RECORD_LOCKED');
151159
});
152160
});
161+
describe('§4 [#29978] the import row answers the same code, on the sync door and the async job', () => {
162+
type Row = { row: number; ok: boolean; action: string; code?: string; error?: string; field?: string };
163+
const doors = {
164+
sync: async (object: string, rows: object[]): Promise<Row[]> => {
165+
const res = await stack.apiAs(admin, 'POST', `/data/${object}/import`, { format: 'json', rows });
166+
expect(res.status, await res.clone().text()).toBe(200);
167+
return ((await res.json()) as { results: Row[] }).results;
168+
},
169+
jobs: async (object: string, rows: object[]): Promise<Row[]> => {
170+
const res = await stack.apiAs(admin, 'POST', `/data/${object}/import/jobs`, { format: 'json', rows });
171+
expect(res.status, await res.clone().text()).toBe(201);
172+
const { jobId } = (await res.json()) as { jobId: string };
173+
for (let i = 0; i < 400; i++) {
174+
const body: any = await (await stack.apiAs(admin, 'GET', `/data/import/jobs/${jobId}/results`)).json();
175+
if (body.status === 'succeeded' || body.status === 'failed') return body.results;
176+
expect(['pending', 'running'], JSON.stringify(body)).toContain(body.status);
177+
await new Promise((r) => setTimeout(r, 25));
178+
}
179+
throw new Error(`import job ${jobId} did not finish`);
180+
},
181+
};
182+
183+
for (const door of ['sync', 'jobs'] as const) {
184+
it(`${door}: an undeclared abort is HOOK_ABORTED with the hook's sentence`, async () => {
185+
const [row] = await doors[door]('bra_ins', [{ name: 'x' }]);
186+
expect(row, JSON.stringify(row)).toMatchObject({ ok: false, action: 'failed', error: SENTENCE });
187+
expect(row!.code, `#29978: the import row answers the code every other write door gives the same abort: ${JSON.stringify(row)}`).toBe('HOOK_ABORTED');
188+
});
189+
190+
it(`${door}: CONTROL a code the hook declared still wins`, async () => {
191+
const [row] = await doors[door]('bra_declared', [{ name: 'x' }]);
192+
expect(row, JSON.stringify(row)).toMatchObject({ ok: false, error: DECLARED, code: 'RECORD_LOCKED' });
193+
});
194+
195+
it(`${door}: CONTROL a body that crashed keeps IMPORT_ROW_FAILED`, async () => {
196+
const [row] = await doors[door]('bra_crash', [{ name: 'x' }]);
197+
expect(row, JSON.stringify(row)).toMatchObject({ ok: false, action: 'failed', code: 'IMPORT_ROW_FAILED' });
198+
});
199+
200+
it(`${door}: CONTROL a malformed row keeps its own cell code`, async () => {
201+
const [row] = await doors[door]('bra_plain', [{ name: '' }]);
202+
expect(row, JSON.stringify(row)).toMatchObject({ ok: false, action: 'failed', code: 'required', field: 'name' });
203+
});
204+
}
205+
206+
it('nothing was written by the refused imports', async () => {
207+
expect(await ql.find('bra_ins', { where: {}, ...SYS })).toHaveLength(0);
208+
expect(await ql.find('bra_declared', { where: {}, ...SYS })).toHaveLength(0);
209+
expect(await ql.find('bra_crash', { where: {}, ...SYS })).toHaveLength(0);
210+
});
211+
});
153212
});

0 commit comments

Comments
 (0)