Skip to content

Commit 81d00a4

Browse files
fix(rest)!: an async import job for an unregistered object is refused with 404 OBJECT_NOT_FOUND before any job is created (#27711)
domain:server#2 · os-sales · session_01L6bhgUCizoYWQVJWjhdR6F <!-- claude-projects-attribution: {"github_login":"os-sales"} --> _Requested by **Sales** · [project thread](https://claude.ai/code/project/chan_01D5bWFY3rFzgJcytD5gD4Uu?thread=cmsg_01D5bWFY3rFzgJcytD5gD4UuPMZ8BHWtPf6nBTp51qSM64)_ Fixes #27501 Scope: ask (a) only, per triage 6498786930. Ask (b), "a job whose every row failed does not read `succeeded`", was split to the decision card #27676 and is not built here: the job's terminal status is unchanged. Clause-②: no (narrowing) Metadata-protocol: none Skills-docs: none (no published skill names the async import job door's object handling) ## 维护者速读 - 改了什么:`POST /data/OBJ/import/jobs` 在对象未注册时直接返回 404 `OBJECT_NOT_FOUND`,与 `POST /data/OBJ` 的应答信封完全一致;不再建 `sys_import_job`,也不启动后台任务。 - 为什么改:之前拼错对象名会得到 201、一个任务 id,随后任务显示 `succeeded`,只有逐行错误里写着找不到对象;自动化导入方会把这当成成功。 - 风险与回滚:只收窄一个入口;已注册对象行为不变。对象元数据读取本身出错时不拦截(与 API 暴露闸门同样放行),逐行写入仍按数据入口应答。回滚即还原 `data-action-routes.ts` 的一处调用。 - 先看:`packages/rest/src/data-action-routes.ts` 的 `assertImportObjectExists` 及其调用点;`packages/rest/src/import-job-integration.test.ts` 末尾新增的 describe。 - 两分钟验证:`pnpm --filter @objectstack/rest exec vitest run src/import-job-integration.test.ts`,预期 15/15 通过。 - 需要你做的:无,席位落地。 ## What changes - `POST /data/:object/import/jobs` now reads the object by name, the same read the API-exposure gate makes (the protocol's execution face where it has one, else `getMetaItem`), right after that gate and before the body is parsed. If no object is behind the name, it throws `objectNotFoundError(objectName)` from `@objectstack/core`. That is the one factory the protocol's existence gate and the engine's verbs throw, and the route's `handleRouteError` classifies it. The answer is 404 `OBJECT_NOT_FOUND` with the same body `POST /data/:object` gives the same name. No new code is minted. - No `sys_import_job` row is written and no worker is scheduled for a refused request. - A read refused `OBJECT_PACKAGE_DISABLED` propagates (the protocol's by-name read documents that refusal; this was not measured here). Any other thrown read fails open and logs at `warn`, as the exposure gate's `loadObjectItem` does: such a read says nothing about existence, and each row's data call still answers for the object. Two existing real-protocol harnesses whose metadata store is not provisioned (`import-row-schema-drift-20701`, `import-row-dropped-fields-20701`) need this; with the fault propagated they answered 503. - Changeset: `@objectstack/rest` patch, with a BREAKING note and the ADR-0087 `not-required (no-migration-prescription)` marker. ## Tests Two cases in `packages/rest/src/import-job-integration.test.ts`. They use the real `RestServer` route, the real `ObjectQL` engine with `ObjectStackProtocolImplementation`, and SQLite `:memory:`. - An unregistered object → 404 and `OBJECT_NOT_FOUND`. The body is `toEqual` the data door's (`POST /api/v1/data/:object`) body for the same name. `sys_import_job` holds 0 rows, counted at the engine before and after. - Control: a registered object → 201, `{object:'task', status:'pending', total:1}`, and exactly one `sys_import_job` row once the job finishes. ## Acceptance notes - Out of scope: the synchronous door `POST /data/:object/import` has the same gap. It runs no existence check, answers 200, and reports `OBJECT_NOT_FOUND` per row (read from the handler; the card's reproduction step 3 measured it). Triage did not name it, so it is not changed here. It is reported to the seat. - The pins drive the route handler registered on a real `RestServer`, not an HTTP transport. This is the harness the file's existing cases use. ## Verification Final head `bb1e1d2f` (merged with `origin/main`). <details><summary>Red before the fix (commit fcde8d5, test only)</summary> ``` × answers 404 OBJECT_NOT_FOUND, the data door's answer, and persists no job row AssertionError: expected 201 to be 404 // Object.is equality Tests 1 failed | 14 passed (15) ``` </details> <details><summary>Ablation on the final head (scripts/ablation-replace.mjs)</summary> ``` ablation-replace: ok mutation landed: anchor 1 -> 0, blob e436474 -> 01857e27771d × answers 404 OBJECT_NOT_FOUND, the data door's answer, and persists no job row AssertionError: expected 201 to be 404 // Object.is equality Tests 1 failed | 14 passed (15) ablation-replace: ok restored: blob == HEAD (e436474) and `git diff HEAD` is empty ``` The ablation removed the `await assertImportObjectExists(...)` call. The suite imports `./rest-server` from source, so no dist rebuild was involved. </details> <details><summary>Package suites and gates</summary> Read at head `bb1e1d2f`, except where a line names another sha. - `pnpm --filter @objectstack/rest test`: exit 0, 340/340 files, 6668 passed, 326 skipped. Run at 06dd5e1; the commit after it changes only the new test's row-count helper, and that file re-ran 15/15 at bb1e1d2. - `pnpm --filter @objectstack/rest typecheck`: exit 0 (06dd5e1, after a declaration build of the `@objectstack/rest...` closure). - `eslint packages/rest --no-inline-config`: exit 0, 0 errors. The 2 warnings are in an untouched file. - `dispatch-gates --commands` derived 59 families. 57 exited 0. `check:query-options-erasure` was red at first (the new test's `as any` options cast grew the test surface 236 → 237); it was fixed in bb1e1d2 and re-ran green at 236. `check:dual-build-cjs-loads` exited 3, PREREQUISITE NOT MET: it reads every package's dist and needs a full `pnpm build`, so it is NOT MEASURED locally and the CI `Lint & Repo Gates` job carries it. `--ran`: 59 derived, 59 run, 0 NOT-MEASURED, 0 UNRUN. - The test-reading gates re-ran green at bb1e1d2: cross-package-test-inputs, test-source-alias, engine-double-contract, nul-bytes, rest-log-spy-declared and doc-authoring. - Dogfood battery (`(narrowing)` arm) at bb1e1d2: os-verify-lock VERDICT command-exit 0 (held 36m11s), closure build exit 0, 352 files passed and 3 skipped, 2844 tests passed and 20 skipped, no failures (os-dev-report 6508232970 on #27501). - No open PR touches `data-action-routes.ts` or `import-job-integration.test.ts` (REST read of all 29 open PRs' files). </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01L6bhgUCizoYWQVJWjhdR6F)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01L6bhgUCizoYWQVJWjhdR6F --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 74aae1e commit 81d00a4

3 files changed

Lines changed: 115 additions & 1 deletion

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/rest': patch
3+
---
4+
5+
fix(rest)!: an async import job for an unregistered object is refused with 404 `OBJECT_NOT_FOUND` before any job is created (#27501)
6+
7+
Clause-②: no (narrowing) — `POST /data/:object/import/jobs` now refuses an object name the runtime does not register, where it used to answer `201` and persist a job. Nothing is widened.
8+
Metadata-protocol: none
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped, and no export or type changes: one REST door now refuses, up front, an object name that every row of its job was already refused for. There is nothing for a migration to rewrite and no tombstone. -->
11+
12+
**BREAKING** accept-set narrowing at one REST data door (`@objectstack/rest`).
13+
14+
- **Before.** `POST /data/no_such_object/import/jobs` answered `201` with a job id and persisted a `sys_import_job` row. The job then ran, every row failed with `OBJECT_NOT_FOUND`, and the job read `succeeded`.
15+
- **Now.** The door answers `404` with `error.code: 'OBJECT_NOT_FOUND'`, the same envelope `POST /data/no_such_object` answers, before the body is parsed. No job row is written and no worker runs.
16+
- **Unchanged.** A registered object gets `201` and a job exactly as before. A job whose rows partly fail still ends `succeeded`, as before.
17+
- **Who sees it.** An import client that sends an object name nothing is behind, for example a typo. Such a request used to look accepted while it could import nothing.
18+
- **Also refused up front.** An object of a disabled package now gets the data door's `404 OBJECT_PACKAGE_DISABLED` instead of a job (re-enable the package). A legacy `ns__short` object addressed by its short name alone gets `404 OBJECT_NOT_FOUND`, because the door counts an object only under its own name, as the API-exposure gate does (send the full registered name).
19+
20+
**The remedy.** Send the object's registered name, the one `POST /data/:object` accepts.

‎packages/rest/src/data-action-routes.ts‎

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,9 @@ import { sendError as sendEnvelopeError, sendOk } from '@objectstack/types';
8080
// [#26594] The read path's ONE no-row answer, for a row that left the caller's
8181
// sight between the record read and the projection.
8282
import { recordNotFoundError } from '@objectstack/core';
83-
import { logError } from './log.js';
83+
// [#27501] The data door's answer for a name no object is behind.
84+
import { objectNotFoundError } from '@objectstack/core';
85+
import { logError, logWarn } from './log.js';
8486
import {
8587
DATA_EXPORT_PARAMS,
8688
IMPORT_JOB_OBJECT,
@@ -308,6 +310,42 @@ function importUndoFailure(id: string, action: ImportUndoFailure['action'], erro
308310
return { id, action, code: answer.code, error: answer.message };
309311
}
310312

313+
/**
314+
* [#27501] Refuse an import job for an object nothing is behind, with the data
315+
* door's own answer for that name: `objectNotFoundError` (`@objectstack/core`),
316+
* the one factory the protocol's existence gate and the engine's verbs throw,
317+
* classified by the same `handleRouteError` — 404 `OBJECT_NOT_FOUND`, the same
318+
* envelope `POST /data/OBJ` answers.
319+
*
320+
* The object is read by name as the exposure gate reads it (the execution face
321+
* where the protocol has one, else `getMetaItem`), and counts only under its
322+
* own name. A read the protocol refuses `OBJECT_PACKAGE_DISABLED` (its
323+
* by-name read's answer for an object of a disabled package, and the data
324+
* door's code for it too) propagates as thrown. Any other thrown read fails
325+
* OPEN and logs, as the exposure gate's same read does (`loadObjectItem`,
326+
* #3545): it is no answer about existence, and each row's data call still
327+
* gives the data door's own. A protocol with no by-name read cannot answer,
328+
* and is not asked.
329+
*/
330+
async function assertImportObjectExists(p: any, objectName: string, environmentId: string | undefined): Promise<void> {
331+
const byName = typeof p.getMetaItemForExecution === 'function'
332+
? p.getMetaItemForExecution
333+
: typeof p.getMetaItem === 'function' ? p.getMetaItem : undefined;
334+
if (!byName) return;
335+
let envelope: any;
336+
try {
337+
envelope = await byName.call(p, { type: 'object', name: objectName, ...(environmentId ? { environmentId } : {}) });
338+
} catch (err) {
339+
if ((err as { code?: unknown } | null)?.code === 'OBJECT_PACKAGE_DISABLED') throw err;
340+
logWarn('[REST] import job: object metadata read failed — not refused up front '
341+
+ '(each row\'s data call still answers for the object)', (err as Error)?.message ?? err);
342+
return;
343+
}
344+
const item = envelope?.item;
345+
if (item !== null && typeof item === 'object' && item.name === objectName) return;
346+
throw objectNotFoundError(objectName);
347+
}
348+
311349
/**
312350
* Register object-specific action endpoints that don't fit the generic CRUD
313351
* shape — `RestServer#registerDataActionEndpoints`' body, called by that method
@@ -592,6 +630,14 @@ export function registerDataActionEndpoints(this: DataActionRouteHost, basePath:
592630
}
593631
if (await this.enforceApiAccess(req, res, p, environmentId, 'import', context)) return;
594632

633+
// [#27501] The object must exist before a job does. The exposure
634+
// gate above passes an unknown object through by design (#3770),
635+
// and the data door's existence refusal only fires inside the
636+
// per-row writes — AFTER the 201 and the persisted job, so the
637+
// job read `succeeded` with every row refused. Refused here, up
638+
// front, before the body is parsed and before any job row exists.
639+
await assertImportObjectExists(p, objectName, environmentId);
640+
595641
// [#24348] The synchronous door's control opt-out gate, asked at
596642
// the same point: before the body is parsed and before a job row
597643
// exists, so a refused request creates no job and writes no row.

‎packages/rest/src/import-job-integration.test.ts‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ async function boot(decorateDriver?: (driver: any) => void) {
127127
list: find('GET', '/api/v1/data/import/jobs'),
128128
cancel: find('POST', '/api/v1/data/import/jobs/:jobId/cancel'),
129129
undo: find('POST', '/api/v1/data/import/jobs/:jobId/undo'),
130+
dataCreate: find('POST', '/api/v1/data/:object'),
130131
};
131132
}
132133

@@ -460,3 +461,50 @@ describe('async import job — real engine + protocol integration', () => {
460461
for (const w of restoreWrites) expect(w.context?.preserveAudit).toBeUndefined();
461462
});
462463
});
464+
465+
/**
466+
* The async create door resolves the object BEFORE it persists a job: an
467+
* object name the runtime does not register is refused up front with the
468+
* answer the data door gives the same name (`POST /data/OBJ`, 404
469+
* `OBJECT_NOT_FOUND`), and no `sys_import_job` row is written — rather than a
470+
* 201, a job id and a job whose every row then fails on that same refusal.
471+
*/
472+
describe('async import job — an unregistered object is refused before a job exists', () => {
473+
let ctx: Awaited<ReturnType<typeof boot>>;
474+
beforeEach(async () => {
475+
ctx = await boot();
476+
expect(ctx.create && ctx.dataCreate).toBeTruthy();
477+
});
478+
479+
const jobRows = async () => ctx.engine.find('sys_import_job', { where: {} });
480+
const call = (route: any, object: string, body: any) => {
481+
const res = makeRes();
482+
return route.handler({ params: { object }, body } as any, res).then(() => res);
483+
};
484+
485+
it('answers 404 OBJECT_NOT_FOUND, the data door\'s answer, and persists no job row', async () => {
486+
expect(await jobRows()).toHaveLength(0);
487+
488+
const res = await call(ctx.create, 'no_such_object', { format: 'csv', csv: 'title\nx\n' });
489+
expect(res._status).toBe(404);
490+
expect(res._json?.error?.code ?? res._json?.code).toBe('OBJECT_NOT_FOUND');
491+
492+
// The same envelope the data door answers for the same name, byte for byte.
493+
const door = await call(ctx.dataCreate, 'no_such_object', { title: 'x' });
494+
expect(door._status).toBe(404);
495+
expect(res._json).toEqual(door._json);
496+
497+
// Counted at the engine: the refusal wrote no job.
498+
expect(await jobRows()).toHaveLength(0);
499+
});
500+
501+
it('control: a registered object still gets 201 and exactly one job row', async () => {
502+
const res = await call(ctx.create, 'task', { format: 'json', rows: [{ id: 'k1', title: 'kept' }] });
503+
expect(res._status).toBe(201);
504+
expect(res._json).toMatchObject({ object: 'task', status: 'pending', total: 1 });
505+
await waitForTerminal(ctx.progress, res._json.jobId);
506+
const rows = await jobRows();
507+
expect(rows).toHaveLength(1);
508+
expect(rows[0]).toMatchObject({ id: res._json.jobId, object_name: 'task' });
509+
});
510+
});

0 commit comments

Comments
 (0)