Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .changeset/27501-import-job-unknown-object.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
'@objectstack/rest': patch
---

fix(rest)!: an async import job for an unregistered object is refused with 404 `OBJECT_NOT_FOUND` before any job is created (#27501)

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.
Metadata-protocol: none

<!-- 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. -->

**BREAKING** accept-set narrowing at one REST data door (`@objectstack/rest`).

- **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`.
- **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.
- **Unchanged.** A registered object gets `201` and a job exactly as before. A job whose rows partly fail still ends `succeeded`, as before.
- **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.
- **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).

**The remedy.** Send the object's registered name, the one `POST /data/:object` accepts.
48 changes: 47 additions & 1 deletion packages/rest/src/data-action-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,9 @@ import { sendError as sendEnvelopeError, sendOk } from '@objectstack/types';
// [#26594] The read path's ONE no-row answer, for a row that left the caller's
// sight between the record read and the projection.
import { recordNotFoundError } from '@objectstack/core';
import { logError } from './log.js';
// [#27501] The data door's answer for a name no object is behind.
import { objectNotFoundError } from '@objectstack/core';
import { logError, logWarn } from './log.js';
import {
DATA_EXPORT_PARAMS,
IMPORT_JOB_OBJECT,
Expand Down Expand Up @@ -308,6 +310,42 @@ function importUndoFailure(id: string, action: ImportUndoFailure['action'], erro
return { id, action, code: answer.code, error: answer.message };
}

/**
* [#27501] Refuse an import job for an object nothing is behind, with the data
* door's own answer for that name: `objectNotFoundError` (`@objectstack/core`),
* the one factory the protocol's existence gate and the engine's verbs throw,
* classified by the same `handleRouteError` — 404 `OBJECT_NOT_FOUND`, the same
* envelope `POST /data/OBJ` answers.
*
* The object is read by name as the exposure gate reads it (the execution face
* where the protocol has one, else `getMetaItem`), and counts only under its
* own name. A read the protocol refuses `OBJECT_PACKAGE_DISABLED` (its
* by-name read's answer for an object of a disabled package, and the data
* door's code for it too) propagates as thrown. Any other thrown read fails
* OPEN and logs, as the exposure gate's same read does (`loadObjectItem`,
* #3545): it is no answer about existence, and each row's data call still
* gives the data door's own. A protocol with no by-name read cannot answer,
* and is not asked.
*/
async function assertImportObjectExists(p: any, objectName: string, environmentId: string | undefined): Promise<void> {
const byName = typeof p.getMetaItemForExecution === 'function'
? p.getMetaItemForExecution
: typeof p.getMetaItem === 'function' ? p.getMetaItem : undefined;
if (!byName) return;
let envelope: any;
try {
envelope = await byName.call(p, { type: 'object', name: objectName, ...(environmentId ? { environmentId } : {}) });
} catch (err) {
if ((err as { code?: unknown } | null)?.code === 'OBJECT_PACKAGE_DISABLED') throw err;
logWarn('[REST] import job: object metadata read failed — not refused up front '
+ '(each row\'s data call still answers for the object)', (err as Error)?.message ?? err);
return;
}
const item = envelope?.item;
if (item !== null && typeof item === 'object' && item.name === objectName) return;
throw objectNotFoundError(objectName);
}

/**
* Register object-specific action endpoints that don't fit the generic CRUD
* shape — `RestServer#registerDataActionEndpoints`' body, called by that method
Expand Down Expand Up @@ -592,6 +630,14 @@ export function registerDataActionEndpoints(this: DataActionRouteHost, basePath:
}
if (await this.enforceApiAccess(req, res, p, environmentId, 'import')) return;

// [#27501] The object must exist before a job does. The exposure
// gate above passes an unknown object through by design (#3770),
// and the data door's existence refusal only fires inside the
// per-row writes — AFTER the 201 and the persisted job, so the
// job read `succeeded` with every row refused. Refused here, up
// front, before the body is parsed and before any job row exists.
await assertImportObjectExists(p, objectName, environmentId);

// [#24348] The synchronous door's control opt-out gate, asked at
// the same point: before the body is parsed and before a job row
// exists, so a refused request creates no job and writes no row.
Expand Down
48 changes: 48 additions & 0 deletions packages/rest/src/import-job-integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ async function boot(decorateDriver?: (driver: any) => void) {
list: find('GET', '/api/v1/data/import/jobs'),
cancel: find('POST', '/api/v1/data/import/jobs/:jobId/cancel'),
undo: find('POST', '/api/v1/data/import/jobs/:jobId/undo'),
dataCreate: find('POST', '/api/v1/data/:object'),
};
}

Expand Down Expand Up @@ -460,3 +461,50 @@ describe('async import job — real engine + protocol integration', () => {
for (const w of restoreWrites) expect(w.context?.preserveAudit).toBeUndefined();
});
});

/**
* The async create door resolves the object BEFORE it persists a job: an
* object name the runtime does not register is refused up front with the
* answer the data door gives the same name (`POST /data/OBJ`, 404
* `OBJECT_NOT_FOUND`), and no `sys_import_job` row is written — rather than a
* 201, a job id and a job whose every row then fails on that same refusal.
*/
describe('async import job — an unregistered object is refused before a job exists', () => {
let ctx: Awaited<ReturnType<typeof boot>>;
beforeEach(async () => {
ctx = await boot();
expect(ctx.create && ctx.dataCreate).toBeTruthy();
});

const jobRows = async () => ctx.engine.find('sys_import_job', { where: {} });
const call = (route: any, object: string, body: any) => {
const res = makeRes();
return route.handler({ params: { object }, body } as any, res).then(() => res);
};

it('answers 404 OBJECT_NOT_FOUND, the data door\'s answer, and persists no job row', async () => {
expect(await jobRows()).toHaveLength(0);

const res = await call(ctx.create, 'no_such_object', { format: 'csv', csv: 'title\nx\n' });
expect(res._status).toBe(404);
expect(res._json?.error?.code ?? res._json?.code).toBe('OBJECT_NOT_FOUND');

// The same envelope the data door answers for the same name, byte for byte.
const door = await call(ctx.dataCreate, 'no_such_object', { title: 'x' });
expect(door._status).toBe(404);
expect(res._json).toEqual(door._json);

// Counted at the engine: the refusal wrote no job.
expect(await jobRows()).toHaveLength(0);
});

it('control: a registered object still gets 201 and exactly one job row', async () => {
const res = await call(ctx.create, 'task', { format: 'json', rows: [{ id: 'k1', title: 'kept' }] });
expect(res._status).toBe(201);
expect(res._json).toMatchObject({ object: 'task', status: 'pending', total: 1 });
await waitForTerminal(ctx.progress, res._json.jobId);
const rows = await jobRows();
expect(rows).toHaveLength(1);
expect(rows[0]).toMatchObject({ id: res._json.jobId, object_name: 'task' });
});
});
Loading