Skip to content

Commit 53ef057

Browse files
fix(rest,objectql): the import dry run asks the engine for its verdict instead of predicting it (#4633) (#6532)
* fix(rest): import dry run asks the engine for its verdict instead of predicting it (#4633) * fix(rest,objectql): import 干跑改为向引擎索取判决,而非自行预测 (#4633) 导入的干跑(dry run)承诺"预测真实写入的判决",却用一份手抄的引擎规则副本 (import-coerce.ts 的 firstMissingRequiredField / firstConstraintViolation)去 兑现这个承诺。副本在结构上追不上它所镜像的族群:ADR-0104 值形状(address / location / 引用 / 媒体)、format 校验、对象级 validations、状态机都没有对应 物;而 coerceFieldValue 把结构化形状交给兜底分支直接透传,于是干跑根本没有形成 任何判决。issue 测得的正是这一点:一个投向 Field.address 的 CSV 字符串在干跑里 报 created: 1,真实写入报 VALIDATION_FAILED。 裁决 D 撤掉这份镜像:干跑改为调用 DataProtocol.validateData(#6037),由引擎跑 insert() 所跑的同一套 validateRecord / evaluateValidationRules,并遵循本部署自 己的 ADR-0104 姿态 —— 严格部署上是 error,warn-first 部署上是被接纳的 warning, 与真实写入逐字一致。一致性由构造保证,而不是靠手工同步的副本。 同时: - engine.validate() 在 insert 模式下先解析 defaultValue 并播种自有汇总字段, 因为 insert() 就是这么做的。缺了这一步,一个"必填但有默认值"的列在未被映射时 会被预览成 failed 而被写入成 created —— 恰恰是本裁决要消灭的假警报。 update 模式仍然不套默认值(#2706)。 - 被校验拒绝的行报告现在点名出错的列:引擎的 ValidationError 带 fields[],所以 行的 field 被填上,code 用字段级编码(required / min_value / max_length / invalid_type …)而非外层的 VALIDATION_FAILED。这与干跑和逐格强转失败本就使用 的词汇一致 —— 此前一个 min: 0 违规在干跑里是 min_value、在写入里是 VALIDATION_FAILED。 - 干跑行可携带 warnings[]:本部署接纳而非拒绝的发现(ADR-0104 warn-first)。 没有实现 validateData 的协议(plugin-auth 的身份导入,其写入走 better-auth 而非 引擎)不会拿到替代品:它的干跑只报告强转与 create/update/skip 判定。用引擎推导 一个非引擎写入的预览,只会报出那条写入永远不会产生的发现。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017uFVNMmTxLpmfQYiuKM1Yx --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 88f9d94 commit 53ef057

8 files changed

Lines changed: 623 additions & 277 deletions

File tree

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
---
2+
"@objectstack/rest": patch
3+
"@objectstack/objectql": patch
4+
---
5+
6+
fix(rest,objectql): the import dry run asks the engine for its verdict instead of predicting it (#4633 ruling D)
7+
8+
`POST /api/v1/data/:object/import?dryRun=true` green-lit rows the very same
9+
endpoint then rejected. Measured on 17.0.0-rc.1: a CSV cell aimed at a
10+
structured `address` field reported `{ ok: 1, created: 1 }` on the dry run and
11+
`{ errors: 1, code: 'VALIDATION_FAILED' }` on the real write.
12+
13+
The dry run predicted the write's verdict with a hand-copied mirror of a slice
14+
of the engine's rules (`import-coerce.ts`'s `firstMissingRequiredField` and
15+
`firstConstraintViolation`). A copy cannot structurally keep up with the family
16+
it mirrors: ADR-0104 value shapes (`address` / `location` / references / media),
17+
`format` checks, object-level `validations` and the state machine had no
18+
counterpart, and `coerceFieldValue` routes structured shapes through its
19+
pass-through catch-all, so no verdict was formed at all.
20+
21+
**The mirror is retired.** The dry run now calls `DataProtocol.validateData`
22+
(#6037), which runs the same `validateRecord` / `evaluateValidationRules` that
23+
`insert()` runs, under the deployment's own ADR-0104 posture — so a bad value
24+
shape is an error on a self-certified deployment and an admitted warning on a
25+
warn-first one, exactly as on the write. Agreement is by construction, not by a
26+
copy kept in step by hand.
27+
28+
Also in this change:
29+
30+
- **`engine.validate()` now resolves `defaultValue`s and seeds owned roll-up
31+
`summary` fields before validating, on `insert` mode**, because `insert()`
32+
does. Without it a required-but-defaulted column left unmapped was previewed
33+
`failed` and written `created` — a false alarm on the row a preview is meant
34+
to reassure you about. `update` mode still does not default (#2706).
35+
- **A row report failed by validation now names the offending column.** The
36+
engine's `ValidationError` carries `fields[]`, so the row's `field` is set and
37+
its `code` is the field-level code (`required`, `min_value`, `max_length`,
38+
`invalid_type`, …) rather than the wrapper's `VALIDATION_FAILED`. This is the
39+
same vocabulary the dry run and the per-cell coercion failures already spoke;
40+
before, a `min: 0` violation was `min_value` on the dry run and
41+
`VALIDATION_FAILED` on the write.
42+
- **Dry-run rows may carry `warnings[]`** — findings this deployment admits
43+
rather than rejects (ADR-0104 warn-first). The row is `ok`, and the complaint
44+
is visible instead of living only in a server log line.
45+
46+
A protocol that does not implement `validateData` (plugin-auth's identity
47+
import, whose write is better-auth rather than the engine) is not handed a
48+
substitute: its dry run reports coercion and create/update/skip resolution only.
49+
An engine-derived preview of a non-engine write would report findings that write
50+
never produces.

packages/objectql/src/engine.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5427,9 +5427,31 @@ export class ObjectQL implements IObjectQLEngine {
54275427
): Promise<ValidateDataResponse> {
54285428
object = this.resolveObjectName(object);
54295429
const mode = options?.mode ?? 'insert';
5430-
const rows = Array.isArray(data) ? data : [data];
54315430
const schemaForValidation = this._registry.getObject(object);
54325431

5432+
// [#4633] `insert()` resolves `defaultValue`s and seeds owned roll-up
5433+
// `summary` fields BEFORE it validates, so a required field carrying a
5434+
// default is never missing by the time `validateRecord` runs. The preview
5435+
// has to walk the same two steps or it reports `required` on a row the
5436+
// write happily creates — a FALSE ALARM, and the one failure mode the
5437+
// ruling that created this operation set out to prevent. (Measured on the
5438+
// import dry run: `tier: { required: true, defaultValue: 'standard' }`
5439+
// unmapped ⇒ preview `failed`, write `created`.)
5440+
//
5441+
// Both helpers are pure and synchronous: they read the registry, copy the
5442+
// row, and touch neither driver nor hook — so running them here keeps the
5443+
// "nothing is written, nothing is executed" contract intact. `update()`
5444+
// deliberately does not default (#2706: a PATCH's explicit `null` means
5445+
// "clear it"), so neither does an `update`-mode preview.
5446+
const rawRows = Array.isArray(data) ? data : [data];
5447+
const nowSnapshot = new Date();
5448+
const rows: Record<string, unknown>[] = mode === 'insert'
5449+
? rawRows.map((row) => this.initializeSummaryFields(
5450+
object,
5451+
this.applyFieldDefaults(object, row, options?.context, nowSnapshot),
5452+
) as Record<string, unknown>)
5453+
: rawRows;
5454+
54335455
// Resolved once for the whole set, exactly as the write path resolves them
54345456
// once per batch — this is the "same posture as the real write" guarantee.
54355457
const mediaValueShapeStrict = await this.mediaValueShapeStrictFor(schemaForValidation);

packages/objectql/src/validate-only.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,51 @@ describe('engine.validate() — validate-only (#6037)', () => {
106106
expect(out.results![1].errors[0].field).toBe('email');
107107
});
108108

109+
// [#4633] The preview must walk the pre-validation steps `insert()` walks,
110+
// or it reports findings on rows the write creates. Discovered by the import
111+
// dry run this operation exists to serve: a required-but-defaulted column
112+
// left unmapped was previewed `failed` and written `created`.
113+
describe('insert-time preparation the write does before it validates', () => {
114+
const DEFAULTED = {
115+
...LEAD,
116+
fields: {
117+
...LEAD.fields,
118+
tier: { type: 'text', required: true, defaultValue: 'standard' },
119+
},
120+
};
121+
122+
it('a required field with a `defaultValue` is NOT reported missing — the write defaults it first', async () => {
123+
const { engine } = makeEngine([DEFAULTED]);
124+
const out = await engine.validate('lead', { company: 'Acme' });
125+
expect(out.results![0].errors).toEqual([]);
126+
expect(out.valid).toBe(true);
127+
});
128+
129+
it('agrees with the write on that row — preview and insert, one engine', async () => {
130+
const { engine } = makeEngine([DEFAULTED]);
131+
const previewValid = (await engine.validate('lead', { company: 'Acme' })).valid;
132+
let writeSucceeded = true;
133+
try { await engine.insert('lead', { company: 'Acme' }); } catch { writeSucceeded = false; }
134+
expect(previewValid).toBe(writeSucceeded);
135+
expect(writeSucceeded).toBe(true);
136+
});
137+
138+
it('does not default in `update` mode — a PATCH never re-applies defaults (#2706)', async () => {
139+
const { engine } = makeEngine([DEFAULTED]);
140+
// Supplying an explicit null on update means "clear it", so the preview
141+
// must judge the null the caller sent, not a default it never gets.
142+
const out = await engine.validate('lead', { tier: null }, { mode: 'update' });
143+
expect(out.results![0].errors.some((e) => e.field === 'tier')).toBe(true);
144+
});
145+
146+
it('never mutates the caller’s row objects', async () => {
147+
const { engine } = makeEngine([DEFAULTED]);
148+
const row: Record<string, unknown> = { company: 'Acme' };
149+
await engine.validate('lead', row);
150+
expect(row).toEqual({ company: 'Acme' });
151+
});
152+
});
153+
109154
it('judges only supplied keys in `update` mode, matching a PATCH', async () => {
110155
const { engine } = makeEngine();
111156
// `company` is required but absent. An insert rejects that; a PATCH that

packages/rest/src/export-format.ts

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,16 @@ export interface ExportFieldMeta {
2424
displayField?: string;
2525
/** Field holds multiple values (an array), e.g. a `multiple: true` lookup. */
2626
multiple?: boolean;
27-
// The following four drive the import path's required-field pre-check
28-
// (import-runner.ts). They mirror the engine's insert-time validation
29-
// (objectql record-validator.ts) so a dry run can predict a NOT NULL /
30-
// required failure instead of green-lighting a row the real insert rejects.
31-
// Unused by the export path (formatting only reads type/options/reference).
27+
// ── constraint metadata, no longer read by the import path ──────────
28+
//
29+
// The eight keys below were added for the import dry run's hand-copied
30+
// pre-check mirror (`firstMissingRequiredField` / `firstConstraintViolation`,
31+
// framework#3956). That mirror is retired: the dry run now asks the engine
32+
// for its verdict through `DataProtocol.validateData` (#4633 ruling D), which
33+
// reads the object's own schema — so nothing in this repo consults these any
34+
// more. Kept for now rather than removed in the same PR: `ExportFieldMeta` is
35+
// exported from `@objectstack/rest`, and their retirement is a separable
36+
// change with its own sweep.
3237
/** Field is required — a value (or default) must exist on insert. */
3338
required?: boolean;
3439
/** Engine-owned column the client never supplies (never required of import). */
@@ -37,10 +42,6 @@ export interface ExportFieldMeta {
3742
readonly?: boolean;
3843
/** Field declares a `defaultValue` the engine applies on insert (satisfies required). */
3944
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).
4445
/** Lower bound for numeric fields. */
4546
min?: number;
4647
/** Upper bound for numeric fields. */

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

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

@@ -274,76 +273,23 @@ describe('coerceRow', () => {
274273
});
275274
});
276275

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-
});
276+
// ── the retired constraint mirror (framework#3956) ────────────────────
277+
//
278+
// `firstConstraintViolation` used to be pinned here with eight unit cases. It
279+
// is gone: the import dry run asks the engine for its verdict through
280+
// `DataProtocol.validateData` instead of re-deriving one (#4633 ruling D), so
281+
// there is no longer a copy of the engine's numeric-range / string-length
282+
// rules in this file to keep in step.
283+
//
284+
// Its VERDICTS did not retire with it — `import-dryrun-parity.test.ts` asserts
285+
// every one of them (min_value, max_value, min_length, max_length, an omitted
286+
// bounded field, boundary values) against a live engine, and asserts the dry
287+
// run and the real write agree on each. Two of the eight cases have no
288+
// successor by design: "skips system / readonly columns" and "bound-checks
289+
// only the types the engine bound-checks" existed because a hand-maintained
290+
// copy could disagree with the engine about WHICH fields and types are in
291+
// scope. With no copy, there is no second opinion to police — that question is
292+
// `record-validator.ts`'s alone, and pinned in objectql's own tests.
347293

348294
/**
349295
* #3957 — the importer's row report is where a user meets these messages, and

0 commit comments

Comments
 (0)