Skip to content
16 changes: 16 additions & 0 deletions .changeset/import-job-max-rows-single-definition.md
Original file line number Diff line number Diff line change
@@ -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` 文案)同源。
25 changes: 22 additions & 3 deletions packages/rest/src/import-job-integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 () => {
Expand Down
6 changes: 4 additions & 2 deletions packages/rest/src/rest-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down
Loading