Skip to content

Commit fb7de07

Browse files
baozhoutaoclaude
authored andcommitted
fix(rest): import dry run must bound-check values, not just coerce them (#3956)
The same request body, with only `dryRun` flipped, produced two different verdicts. A `number` field declaring `min: 0` received `-500`: the dry run answered `ok:1, errors:0, created:1` — the Console wizard renders that as 「全部 1 行均有效」 — and the real write then answered `ok:0, errors:1, penalty_amount must be ≥ 0`, dropping the row. A pre-check that cannot predict the write is worse than no pre-check: it turns "your file has a problem" into a false all-clear, and reviewers can't use it as evidence. The dry-run branch in `runImport` returned before any field-constraint check ran. Only two gates stood in front of it, and neither looks at a declared bound: - `coerceRow` — pure value conversion (is this cell a number at all, does this select option exist, does this lookup resolve). `-500` is a perfectly good number, so it sailed through. - `firstMissingRequiredField` — required-presence only. Everything the engine's `validateRecord` enforces (numeric range, string length, formats) lives past that branch, on the write path only. This closes the range/length half: - `ExportFieldMeta` now carries `min` / `max` / `minLength` / `maxLength`. The projection built by `buildFieldMetaMap` was dropping them, so the runner could not have checked a bound even if it wanted to. - `firstConstraintViolation` mirrors `validateRecord`'s numeric-range and string-length rules — same type applicability, same comparison, same `code` and `message` text — so both paths now report a violation identically. - The runner consults it on the DRY RUN ONLY. The write path already has the engine's own validation, which runs AFTER beforeInsert hooks; a pre-hook copy there could reject a row a hook would have made legal. The dry run has no such backstop. Deliberately not a full mirror. Format checks (email/url/phone), object-level `validations` rules, uniqueness and the state machine still surface only on the real write — closing those means validating through the engine (a `validateOnly` write path, which `BatchOptions.validateOnly` already declares but nothing implements) rather than growing this copy. The bounded-type lists are the engine's own, not the spec's wider `NUMERIC_VALUE_TYPES` / `STRING_VALUE_TYPES`: `progress` and `summary` are numeric per the spec but unchecked by the engine, and using the wider set would trade the false all-clear for a false alarm. Covers all three consumers of the shared runner: the synchronous import route, the async import-job worker, and plugin-auth's user import. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent eb95d97 commit fb7de07

5 files changed

Lines changed: 233 additions & 4 deletions

File tree

packages/rest/src/export-format.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,18 @@ export interface ExportFieldMeta {
3737
readonly?: boolean;
3838
/** Field declares a `defaultValue` the engine applies on insert (satisfies required). */
3939
hasDefault?: boolean;
40+
// The bounds below drive the import path's field-constraint pre-check
41+
// (import-coerce.ts `firstConstraintViolation`), mirroring the engine's
42+
// `validateRecord` so a dry run predicts a range/length rejection instead of
43+
// green-lighting a row the real write then fails (framework#3956).
44+
/** Lower bound for numeric fields. */
45+
min?: number;
46+
/** Upper bound for numeric fields. */
47+
max?: number;
48+
/** Minimum character count for string fields. */
49+
minLength?: number;
50+
/** Maximum character count for string fields. */
51+
maxLength?: number;
4052
}
4153

4254
/**
@@ -137,6 +149,10 @@ export function buildFieldMetaMap(schema: unknown): Map<string, ExportFieldMeta>
137149
// ⇒ no default): any non-null default — literal, expression object, or the
138150
// `current_user` token — counts as satisfying a required field.
139151
hasDefault: f.defaultValue != null,
152+
min: typeof f.min === 'number' ? f.min : undefined,
153+
max: typeof f.max === 'number' ? f.max : undefined,
154+
minLength: typeof f.minLength === 'number' ? f.minLength : undefined,
155+
maxLength: typeof f.maxLength === 'number' ? f.maxLength : undefined,
140156
});
141157
}
142158
return map;

packages/rest/src/import-coerce.test.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
matchOption,
1515
splitMulti,
1616
coerceRow,
17+
firstConstraintViolation,
1718
} from './import-coerce';
1819
import type { ExportFieldMeta } from './export-format';
1920

@@ -272,3 +273,74 @@ describe('coerceRow', () => {
272273
expect(data).toEqual({ mystery: 'raw' });
273274
});
274275
});
276+
277+
describe('firstConstraintViolation (framework#3956)', () => {
278+
const meta = (defs: Record<string, Partial<ExportFieldMeta>>): Map<string, ExportFieldMeta> => {
279+
const m = new Map<string, ExportFieldMeta>();
280+
for (const [name, d] of Object.entries(defs)) m.set(name, { name, ...d });
281+
return m;
282+
};
283+
284+
it('reports a numeric value below `min` with the engine\'s own message', () => {
285+
// The issue's repro: penalty_amount { type: 'number', min: 0, max: 9999999.99 }
286+
const metaMap = meta({ penalty_amount: { type: 'number', min: 0, max: 9999999.99 } });
287+
expect(firstConstraintViolation({ penalty_amount: -500 }, metaMap)).toEqual({
288+
field: 'penalty_amount', code: 'min_value', message: 'penalty_amount must be ≥ 0',
289+
});
290+
});
291+
292+
it('reports a numeric value above `max`', () => {
293+
const metaMap = meta({ pct: { type: 'percent', max: 100 } });
294+
expect(firstConstraintViolation({ pct: 101 }, metaMap)).toEqual({
295+
field: 'pct', code: 'max_value', message: 'pct must be ≤ 100',
296+
});
297+
});
298+
299+
it('reports string length violations both ways', () => {
300+
const metaMap = meta({ code: { type: 'text', minLength: 3, maxLength: 5 } });
301+
expect(firstConstraintViolation({ code: 'abcdef' }, metaMap)).toEqual({
302+
field: 'code', code: 'max_length', message: 'code must be ≤ 5 characters (got 6)',
303+
});
304+
expect(firstConstraintViolation({ code: 'ab' }, metaMap)).toEqual({
305+
field: 'code', code: 'min_length', message: 'code must be ≥ 3 characters (got 2)',
306+
});
307+
});
308+
309+
it('accepts values inside the declared bounds', () => {
310+
const metaMap = meta({
311+
amount: { type: 'currency', min: 0, max: 100 },
312+
title: { type: 'text', maxLength: 10 },
313+
});
314+
expect(firstConstraintViolation({ amount: 0 }, metaMap)).toBeNull();
315+
expect(firstConstraintViolation({ amount: 100 }, metaMap)).toBeNull();
316+
expect(firstConstraintViolation({ title: 'ten chars!' }, metaMap)).toBeNull();
317+
});
318+
319+
it('skips absent values — a bound never fires on a field the row omits', () => {
320+
const metaMap = meta({ amount: { type: 'number', min: 10 } });
321+
expect(firstConstraintViolation({}, metaMap)).toBeNull();
322+
expect(firstConstraintViolation({ amount: null }, metaMap)).toBeNull();
323+
expect(firstConstraintViolation({ amount: '' }, metaMap)).toBeNull();
324+
});
325+
326+
it('skips system / readonly columns the importer never supplies', () => {
327+
const metaMap = meta({
328+
seq: { type: 'number', min: 100, system: true },
329+
score: { type: 'number', min: 100, readonly: true },
330+
});
331+
expect(firstConstraintViolation({ seq: 1, score: 1 }, metaMap)).toBeNull();
332+
});
333+
334+
it('leaves an unparseable number to coerceRow rather than double-reporting', () => {
335+
const metaMap = meta({ amount: { type: 'number', min: 0 } });
336+
expect(firstConstraintViolation({ amount: 'abc' }, metaMap)).toBeNull();
337+
});
338+
339+
it('bound-checks only the types the engine bound-checks', () => {
340+
// `progress` is numeric per the spec but the engine's validateOne leaves it
341+
// unchecked — mirroring the wider spec set here would reject rows the real
342+
// write accepts.
343+
const metaMap = meta({ p: { type: 'progress', min: 0, max: 1 } });
344+
expect(firstConstraintViolation({ p: 42 }, metaMap)).toBeNull();
345+
});
346+
});

packages/rest/src/import-coerce.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -425,6 +425,84 @@ export function firstMissingRequiredField(
425425
return null;
426426
}
427427

428+
// ── field-constraint pre-check ─────────────────────────────────────
429+
430+
/**
431+
* Number field types the engine bound-checks with `min` / `max`, and string
432+
* field types it bound-checks with `minLength` / `maxLength`.
433+
*
434+
* These are the engine's OWN lists (objectql `record-validator.ts`
435+
* `validateOne`), deliberately NOT the spec's `NUMERIC_VALUE_TYPES` /
436+
* `STRING_VALUE_TYPES` — those are wider (`progress`, `summary`, `color`,
437+
* `signature`, …) and the engine leaves their values unchecked. Using the
438+
* wider sets here would make the dry run reject rows the real write accepts,
439+
* trading a false all-clear for a false alarm.
440+
*/
441+
const BOUNDED_NUMBER_TYPES = new Set(['number', 'currency', 'percent', 'rating', 'slider']);
442+
const BOUNDED_STRING_TYPES = new Set([
443+
'text', 'textarea', 'email', 'url', 'phone', 'password', 'markdown', 'html', 'richtext', 'code',
444+
]);
445+
446+
/**
447+
* The first declared bound a coerced row violates, or `null` when every
448+
* supplied value is in range.
449+
*
450+
* Mirrors the numeric-range and string-length rules of the engine's
451+
* `validateRecord` — same type applicability, same comparison, same `code` and
452+
* `message` text — so the import's dry run predicts the verdict the real write
453+
* produces (framework#3956). Before this, a dry run only reported *coercion*
454+
* failures (a cell that isn't a number at all), so `-500` in a `min: 0` column
455+
* was reported valid and then rejected by the write with
456+
* `penalty_amount must be ≥ 0`.
457+
*
458+
* Applies to CREATE and UPDATE alike: the engine validates every supplied
459+
* value on both, and a value the row doesn't carry is skipped here exactly as
460+
* `validateOne` skips a missing one.
461+
*
462+
* NOT a complete mirror of `validateRecord`, and not meant to be — format
463+
* checks (email/url/phone), object-level `validations` rules, uniqueness and
464+
* the state machine still surface only on the real write. Closing those means
465+
* validating through the engine itself rather than growing this copy.
466+
*/
467+
export function firstConstraintViolation(
468+
data: Record<string, unknown>,
469+
metaMap: Map<string, ExportFieldMeta>,
470+
): FieldCoerceError | null {
471+
for (const meta of metaMap.values()) {
472+
if (meta.system || meta.readonly) continue;
473+
if (REQUIRED_CHECK_SKIP.has(meta.name)) continue;
474+
const value = data[meta.name];
475+
if (isBlankValue(value)) continue; // absent → nothing to bound-check
476+
const t = meta.type ?? '';
477+
478+
if (BOUNDED_NUMBER_TYPES.has(t)) {
479+
const n = typeof value === 'number' ? value : Number(value);
480+
if (!Number.isFinite(n)) continue; // a non-number is coerceRow's verdict, not ours
481+
if (meta.min !== undefined && n < meta.min) {
482+
return { field: meta.name, code: 'min_value', message: `${meta.name} must be ≥ ${meta.min}` };
483+
}
484+
if (meta.max !== undefined && n > meta.max) {
485+
return { field: meta.name, code: 'max_value', message: `${meta.name} must be ≤ ${meta.max}` };
486+
}
487+
continue;
488+
}
489+
490+
if (BOUNDED_STRING_TYPES.has(t)) {
491+
// `String(value)` matches the engine, which stringifies a non-string
492+
// (e.g. a `multiple` text cell joined by the export separator) before
493+
// measuring it.
494+
const s = typeof value === 'string' ? value : String(value);
495+
if (meta.maxLength !== undefined && s.length > meta.maxLength) {
496+
return { field: meta.name, code: 'max_length', message: `${meta.name} must be ≤ ${meta.maxLength} characters (got ${s.length})` };
497+
}
498+
if (meta.minLength !== undefined && s.length < meta.minLength) {
499+
return { field: meta.name, code: 'min_length', message: `${meta.name} must be ≥ ${meta.minLength} characters (got ${s.length})` };
500+
}
501+
}
502+
}
503+
return null;
504+
}
505+
428506
/**
429507
* Coerce a whole raw row into a storage-ready record. Unknown columns (no
430508
* matching field metadata) pass through untouched so ad-hoc / schemaless

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

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,9 @@ const MEMBER = {
125125
name: 'tier', type: 'select' as const, label: 'Tier', required: true, defaultValue: 'standard',
126126
options: [{ label: 'Standard', value: 'standard' }, { label: 'Gold', value: 'gold' }],
127127
},
128+
// framework#3956 — bounded fields. The dry run used to ignore both.
129+
penalty_amount: { name: 'penalty_amount', type: 'number' as const, label: '处罚金额', min: 0, max: 9999999.99 },
130+
nickname: { name: 'nickname', type: 'text' as const, label: 'Nickname', maxLength: 5 },
128131
},
129132
};
130133

@@ -435,6 +438,52 @@ describe('import route — required-field dry-run fidelity', () => {
435438
for (const r of res._json.results) expect(r).toMatchObject({ field: 'member_name', code: 'required' });
436439
});
437440

441+
// framework#3956 — the dry run reported `ok:1, created:1` for a row the very
442+
// same endpoint then rejected with `penalty_amount must be ≥ 0`, because the
443+
// dry-run branch returned before any field-constraint check ran. Unlike the
444+
// required pre-check above, this one is NOT gated on `runAutomations`: it runs
445+
// on the dry run only, where the engine's own validation is never reached.
446+
it('dry run fails a row that violates min/max — same verdict, same message as the real write', async () => {
447+
const rows = [{ id: 'p1', member_name: 'Eve', status: 'active', penalty_amount: -500 }];
448+
449+
const dry = await imp({ format: 'json', dryRun: true, rows });
450+
expect(dry._json).toMatchObject({ dryRun: true, total: 1, ok: 0, errors: 1, created: 0 });
451+
expect(dry._json.results[0]).toMatchObject({
452+
row: 1, ok: false, action: 'failed', field: 'penalty_amount',
453+
code: 'min_value', error: 'penalty_amount must be ≥ 0',
454+
});
455+
456+
// The real write reaches the engine's validateRecord and says the same.
457+
const real = await imp({ format: 'json', rows });
458+
expect(real._json).toMatchObject({ total: 1, ok: 0, errors: 1, created: 0 });
459+
expect(real._json.results[0].error).toContain('penalty_amount must be ≥ 0');
460+
expect(await engine.findOne('member', { where: { id: 'p1' } })).toBeNull();
461+
});
462+
463+
it('dry run fails an over-long string too (maxLength), and passes in-range rows', async () => {
464+
const over = await imp({ format: 'json', dryRun: true, rows: [
465+
{ id: 'p2', member_name: 'Fay', status: 'active', nickname: 'toolongname' },
466+
] });
467+
expect(over._json).toMatchObject({ ok: 0, errors: 1 });
468+
expect(over._json.results[0]).toMatchObject({
469+
field: 'nickname', code: 'max_length', error: 'nickname must be ≤ 5 characters (got 11)',
470+
});
471+
472+
// Boundary values are legal — the pre-check must not over-reject.
473+
const ok = await imp({ format: 'json', dryRun: true, rows: [
474+
{ id: 'p3', member_name: 'Gus', status: 'active', penalty_amount: 0, nickname: 'exact' },
475+
] });
476+
expect(ok._json).toMatchObject({ ok: 1, errors: 0, created: 1 });
477+
});
478+
479+
it('bound-checks update rows as well as creates', async () => {
480+
await engine.insert('member', { id: 'p4', member_name: 'Hana', status: 'active', penalty_amount: 10 });
481+
const res = await imp({ format: 'json', dryRun: true, writeMode: 'update', matchFields: ['id'],
482+
rows: [{ id: 'p4', penalty_amount: -1 }] });
483+
expect(res._json).toMatchObject({ ok: 0, errors: 1, updated: 0 });
484+
expect(res._json.results[0]).toMatchObject({ field: 'penalty_amount', code: 'min_value' });
485+
});
486+
438487
it('required check does not apply to update-mode rows (only the touched fields matter)', async () => {
439488
await engine.insert('member', { id: 'm6', member_name: 'Dan', status: 'active', tier: 'gold' });
440489
// writeMode:update on an existing match, touching only member_name — status

packages/rest/src/import-runner.ts

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import { randomUUID } from 'node:crypto';
4-
import { coerceRow, firstMissingRequiredField, type RefResolver, type RefMatch } from './import-coerce.js';
4+
import { coerceRow, firstMissingRequiredField, firstConstraintViolation, type RefResolver, type RefMatch } from './import-coerce.js';
55
import type { ExportFieldMeta } from './export-format.js';
66
import { bulkWrite, withTransientRetry, defaultIsTransientError, type BulkWriteRowResult } from '@objectstack/core';
77

@@ -601,9 +601,23 @@ export function runImport(opts: RunImportOptions): Promise<ImportRunSummary> {
601601
skipped++;
602602
results[i] = { row: rowNo, ok: true, action: 'skipped', code: 'NO_MATCH' };
603603
} else if (dryRun) {
604-
okCount++;
605-
if (willUpdate) { updated++; results[i] = { row: rowNo, ok: true, action: 'updated', id: String((existing as any).id ?? '') || undefined }; }
606-
else { created++; results[i] = { row: rowNo, ok: true, action: 'created' }; }
604+
// Field-constraint pre-check — DRY RUN ONLY (framework#3956).
605+
// The write path is already covered: the engine's own
606+
// `validateRecord` runs there (after beforeInsert hooks) and
607+
// produces this exact message, so re-checking here would only add
608+
// a pre-hook copy that could reject a row a hook would have made
609+
// legal. The dry run has no such backstop — without this it
610+
// reported `ok: true` for a row the very same endpoint then
611+
// failed with `VALIDATION_FAILED`.
612+
const violation = firstConstraintViolation(data, metaMap);
613+
if (violation) {
614+
errCount++;
615+
results[i] = { row: rowNo, ok: false, action: 'failed', field: violation.field, code: violation.code, error: violation.message };
616+
} else {
617+
okCount++;
618+
if (willUpdate) { updated++; results[i] = { row: rowNo, ok: true, action: 'updated', id: String((existing as any).id ?? '') || undefined }; }
619+
else { created++; results[i] = { row: rowNo, ok: true, action: 'created' }; }
620+
}
607621
} else if (willUpdate) {
608622
const target = existing as Record<string, any>;
609623
let res2: unknown;

0 commit comments

Comments
 (0)