diff --git a/.changeset/import-job-max-rows-single-definition.md b/.changeset/import-job-max-rows-single-definition.md new file mode 100644 index 0000000000..7435e85161 --- /dev/null +++ b/.changeset/import-job-max-rows-single-definition.md @@ -0,0 +1,16 @@ +--- +'@objectstack/rest': patch +--- + +rest 的异步导入行数上限改为直接读取 spec 的 `IMPORT_JOB_MAX_ROWS` 导出,不再自己声明一份同值字面量(#6535)。 + +**行为没有任何变化**:两处此前都是 `50_000`,改后仍是 `50_000`,上限、`413` 文案、拒绝边界全部不动。 +这是一次一致性收敛,不是缺陷修复——因此按 patch 计。 + +收敛掉的是一处漂移面:`packages/spec/src/api/export.zod.ts` 的那份导出带着 TSDoc,是这个 +上限的**对外说明**(喂给生成的 reference 表面);而真正执行拒绝的是 `packages/rest`,它此前 +读的是自己那份本地 `const`,两者之间只有一句 "mirrors spec" 注释相连。没有任何 gate 比较这 +两个数——`api-surface/api.json` 只记下 `"IMPORT_JOB_MAX_ROWS (const)"` 这个**名字**,不记它的 +**值**——所以把 spec 那份改成 20_000、执行侧纹丝不动,`pnpm test` 与全部 `check:*` 依然全绿 +(本 PR 实测过)。失效方向是文档说一套、系统做一套,而 `413` 文案里内插的又是 rest 那一份, +连报错都会自洽地说谎。现在一处定义、两处读点(`maxRows:` 与 `413` 文案)同源。 diff --git a/packages/rest/src/import-job-integration.test.ts b/packages/rest/src/import-job-integration.test.ts index 709612436a..99dcccae6d 100644 --- a/packages/rest/src/import-job-integration.test.ts +++ b/packages/rest/src/import-job-integration.test.ts @@ -25,6 +25,9 @@ import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; import { SysImportJob } from '@objectstack/platform-objects/audit'; +// #6535: the ONE definition of the async-import row ceiling. The pin below reads it +// from here so that moving it is observable at the enforcement point. +import { IMPORT_JOB_MAX_ROWS } from '@objectstack/spec/api'; import { RestServer } from './rest-server'; // The real backend: better-sqlite3 `:memory:`, constructed the canonical way @@ -172,12 +175,28 @@ describe('async import job — real engine + protocol integration', () => { expect(results._json.results.find((r: any) => !r.ok)).toMatchObject({ field: 'score', code: 'invalid_number' }); }); - it('rejects a payload above the 50k async ceiling with 413', async () => { - const rows = Array.from({ length: 50_001 }, (_, i) => ({ id: `x${i}`, title: 't' })); + // #6535: the ceiling has ONE definition — the spec export — and rest is its only + // enforcer. So this case derives everything it knows about the ceiling from that + // export: how big a payload must be to breach it, and the number the 413 copy is + // required to name. Re-spelling 50_000 here would just move the duplicated literal + // into a third place and re-open the drift surface the fix closes. + // + // What this pin buys, precisely: while spec and rest agree (they do today, both + // 50_000) it is green against BOTH implementations — there is no live defect to + // catch. It goes red the moment they DISAGREE. With a rest that re-declares its own + // literal, moving the spec constant down makes this send `spec + 1` rows at a route + // still enforcing the stale, larger number: the payload is accepted and no 413 is + // produced at all. Under the imported constant the two move together and stay green. + it('rejects a payload above the async ceiling the spec declares, with 413', async () => { + const rows = Array.from({ length: IMPORT_JOB_MAX_ROWS + 1 }, (_, i) => ({ id: `x${i}`, title: 't' })); const res = await callCreate(ctx.create, { format: 'json', rows }); + // Rejection-class: assert the ADR-0112 envelope (status AND code), never a bare throw. expect(res._status).toBe(413); expect(res._json.code).toBe('PAYLOAD_TOO_LARGE'); - expect(String(res._json.error)).toMatch(/50000/); + // The wording is contract here — the 413 tells the caller what to split the file + // into — so the interpolated number must be the spec's, on top of the envelope. + expect(String(res._json.error)).toContain(String(IMPORT_JOB_MAX_ROWS)); + expect(String(res._json.error)).toContain(`batches of ${IMPORT_JOB_MAX_ROWS}`); }); it('lists jobs in history and filters by status', async () => { diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index b77c62ce70..76e5fa0b19 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -35,6 +35,10 @@ import type { DirectMountedRoute, MountedRouteSource } from './direct-mount.js'; import { RestServerConfig, RestApiConfig, CrudEndpointsConfig, MetadataEndpointsConfig, BatchEndpointsConfig, RouteGenerationConfig } from '@objectstack/spec/api'; import { DataProtocol, MetadataProtocol } from '@objectstack/spec/api'; import type { FieldErrorCode } from '@objectstack/spec/api'; +// The async-import row ceiling has exactly one definition, in the spec, whose +// TSDoc is its public statement (#6535). rest is the only enforcer, so it reads +// that export rather than re-declaring the literal beside a "mirrors spec" comment. +import { IMPORT_JOB_MAX_ROWS } from '@objectstack/spec/api'; import { PUBLIC_FORM_SERVER_MANAGED_FIELDS } from '@objectstack/spec/security'; import { PLURAL_TO_SINGULAR } from '@objectstack/spec/shared'; import { stripReadDecorations } from '@objectstack/spec/kernel'; @@ -1398,8 +1402,6 @@ export function apiAccessDenialFromEnable( /** Platform object backing async import jobs (see sys-import-job.object.ts). */ const IMPORT_JOB_OBJECT = 'sys_import_job'; -/** Hard ceiling on rows per async import job (mirrors spec IMPORT_JOB_MAX_ROWS). */ -const IMPORT_JOB_MAX_ROWS = 50_000; /** Cap on per-row results persisted on the job (failures first). */ const IMPORT_JOB_RESULTS_CAP = 500; /** Undo (logical rollback) is only recorded for jobs at or under this row