From fcde8d593cc42690e5555b974393d36579ba8598 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 18:20:40 +0000 Subject: [PATCH 1/7] test(rest): pin an async import job on an unregistered object (red) Claude-Session: https://claude.ai/code/session_01L6bhgUCizoYWQVJWjhdR6F Co-authored-by: Claude --- .../rest/src/import-job-integration.test.ts | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/packages/rest/src/import-job-integration.test.ts b/packages/rest/src/import-job-integration.test.ts index cfa9745e7e7..8d768974e2a 100644 --- a/packages/rest/src/import-job-integration.test.ts +++ b/packages/rest/src/import-job-integration.test.ts @@ -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'), }; } @@ -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>; + beforeEach(async () => { + ctx = await boot(); + expect(ctx.create && ctx.dataCreate).toBeTruthy(); + }); + + const jobRows = async () => (await ctx.engine.find('sys_import_job', {}, { isSystem: true } as any)) as any[]; + 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' }); + }); +}); From 4e5bee99a1a30f642b6bcac4c2766a2b138f766b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 18:36:45 +0000 Subject: [PATCH 2/7] fix(rest): refuse an async import job for an unregistered object with 404 before persisting it Claude-Session: https://claude.ai/code/session_01L6bhgUCizoYWQVJWjhdR6F Co-authored-by: Claude --- packages/rest/src/data-action-routes.ts | 35 +++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/packages/rest/src/data-action-routes.ts b/packages/rest/src/data-action-routes.ts index 4a4b352aa08..5eebd6c3222 100644 --- a/packages/rest/src/data-action-routes.ts +++ b/packages/rest/src/data-action-routes.ts @@ -80,6 +80,8 @@ 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'; +// [#27501] The data door's answer for a name no object is behind. +import { objectNotFoundError } from '@objectstack/core'; import { logError } from './log.js'; import { DATA_EXPORT_PARAMS, @@ -308,6 +310,31 @@ 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` for an + * object of a disabled package, the data door's code for it too — or a read + * that faults propagates to the caller as thrown; ⛔ never turned into a job. + * A protocol with no by-name read cannot answer, and is not asked. + */ +async function assertImportObjectExists(p: any, objectName: string, environmentId: string | undefined): Promise { + const byName = typeof p.getMetaItemForExecution === 'function' + ? p.getMetaItemForExecution + : typeof p.getMetaItem === 'function' ? p.getMetaItem : undefined; + if (!byName) return; + const envelope: any = await byName.call(p, { type: 'object', name: objectName, ...(environmentId ? { environmentId } : {}) }); + 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 @@ -592,6 +619,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. From c586f114809e5b35ae83a9720b91b200043e34cf Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 18:37:40 +0000 Subject: [PATCH 3/7] chore(changeset): rest async import job refuses an unregistered object Claude-Session: https://claude.ai/code/session_01L6bhgUCizoYWQVJWjhdR6F Co-authored-by: Claude --- .changeset/27501-import-job-unknown-object.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 .changeset/27501-import-job-unknown-object.md diff --git a/.changeset/27501-import-job-unknown-object.md b/.changeset/27501-import-job-unknown-object.md new file mode 100644 index 00000000000..57244618193 --- /dev/null +++ b/.changeset/27501-import-job-unknown-object.md @@ -0,0 +1,19 @@ +--- +'@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 + + + +**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. An object of a disabled package is refused the same way the data door refuses it, `404 OBJECT_PACKAGE_DISABLED`. +- **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. + +**The remedy.** Send the object's registered name, the one `POST /data/:object` accepts. From 59332ae83e5074f97a70adf0fbd5f49d875cc8ec Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 18:38:00 +0000 Subject: [PATCH 4/7] docs(rest): state the disabled-package read as the protocol documents it Claude-Session: https://claude.ai/code/session_01L6bhgUCizoYWQVJWjhdR6F Co-authored-by: Claude --- .changeset/27501-import-job-unknown-object.md | 2 +- packages/rest/src/data-action-routes.ts | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/.changeset/27501-import-job-unknown-object.md b/.changeset/27501-import-job-unknown-object.md index 57244618193..01c5b22089c 100644 --- a/.changeset/27501-import-job-unknown-object.md +++ b/.changeset/27501-import-job-unknown-object.md @@ -12,7 +12,7 @@ Metadata-protocol: none **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. An object of a disabled package is refused the same way the data door refuses it, `404 OBJECT_PACKAGE_DISABLED`. +- **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. diff --git a/packages/rest/src/data-action-routes.ts b/packages/rest/src/data-action-routes.ts index 5eebd6c3222..0610446d621 100644 --- a/packages/rest/src/data-action-routes.ts +++ b/packages/rest/src/data-action-routes.ts @@ -319,9 +319,10 @@ function importUndoFailure(id: string, action: ImportUndoFailure['action'], erro * * 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` for an - * object of a disabled package, the data door's code for it too — or a read - * that faults propagates to the caller as thrown; ⛔ never turned into a job. + * own name. A read the protocol refuses (its by-name read documents + * `OBJECT_PACKAGE_DISABLED` for an object of a disabled package, the data + * door's code for it too) or a read that faults propagates to the caller as + * thrown; ⛔ never turned into a job. * A protocol with no by-name read cannot answer, and is not asked. */ async function assertImportObjectExists(p: any, objectName: string, environmentId: string | undefined): Promise { From 99c7e614ca20c683077d6285c8dd84d31185a47b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 18:50:12 +0000 Subject: [PATCH 5/7] fix(rest): a faulted object read fails open at the import job door, as the exposure gate's does Claude-Session: https://claude.ai/code/session_01L6bhgUCizoYWQVJWjhdR6F Co-authored-by: Claude --- packages/rest/src/data-action-routes.ts | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/packages/rest/src/data-action-routes.ts b/packages/rest/src/data-action-routes.ts index 0610446d621..98a169c6bc4 100644 --- a/packages/rest/src/data-action-routes.ts +++ b/packages/rest/src/data-action-routes.ts @@ -82,7 +82,7 @@ import { sendError as sendEnvelopeError, sendOk } from '@objectstack/types'; import { recordNotFoundError } from '@objectstack/core'; // [#27501] The data door's answer for a name no object is behind. import { objectNotFoundError } from '@objectstack/core'; -import { logError } from './log.js'; +import { logError, logWarn } from './log.js'; import { DATA_EXPORT_PARAMS, IMPORT_JOB_OBJECT, @@ -319,18 +319,28 @@ function importUndoFailure(id: string, action: ImportUndoFailure['action'], erro * * 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 (its by-name read documents - * `OBJECT_PACKAGE_DISABLED` for an object of a disabled package, the data - * door's code for it too) or a read that faults propagates to the caller as - * thrown; ⛔ never turned into a job. - * A protocol with no by-name read cannot answer, and is not asked. + * 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 { const byName = typeof p.getMetaItemForExecution === 'function' ? p.getMetaItemForExecution : typeof p.getMetaItem === 'function' ? p.getMetaItem : undefined; if (!byName) return; - const envelope: any = await byName.call(p, { type: 'object', name: objectName, ...(environmentId ? { environmentId } : {}) }); + 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); From bb1e1d2f5a4408afc35b5b4113891e6125b8186a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 19:52:53 +0000 Subject: [PATCH 6/7] test(rest): count import job rows without an untyped options cast Claude-Session: https://claude.ai/code/session_01L6bhgUCizoYWQVJWjhdR6F Co-authored-by: Claude --- packages/rest/src/import-job-integration.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/rest/src/import-job-integration.test.ts b/packages/rest/src/import-job-integration.test.ts index 8d768974e2a..bec74b227eb 100644 --- a/packages/rest/src/import-job-integration.test.ts +++ b/packages/rest/src/import-job-integration.test.ts @@ -476,7 +476,7 @@ describe('async import job — an unregistered object is refused before a job ex expect(ctx.create && ctx.dataCreate).toBeTruthy(); }); - const jobRows = async () => (await ctx.engine.find('sys_import_job', {}, { isSystem: true } as any)) as any[]; + 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); From 004cbb14df2d48a6c6d4adec0d180b7d6f849bf0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 21:17:42 +0000 Subject: [PATCH 7/7] chore(changeset): name the disabled-package and short-name refusals of the import job door Claude-Session: https://claude.ai/code/session_01L6bhgUCizoYWQVJWjhdR6F Co-authored-by: Claude --- .changeset/27501-import-job-unknown-object.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.changeset/27501-import-job-unknown-object.md b/.changeset/27501-import-job-unknown-object.md index 01c5b22089c..c2f0bc76370 100644 --- a/.changeset/27501-import-job-unknown-object.md +++ b/.changeset/27501-import-job-unknown-object.md @@ -15,5 +15,6 @@ Metadata-protocol: none - **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.